feat: dual LLM mode (default via .env, custom per-request) for AI analysis

This commit is contained in:
hyh
2026-09-04 12:06:05 +08:00
parent 1a935cae6e
commit 4a4c6f2ec2
10 changed files with 154 additions and 63 deletions
+7 -4
View File
@@ -2,10 +2,13 @@ SECRET_KEY=change-me
DEBUG=true
ALLOWED_HOSTS=*
# LLM(OpenAI 兼容接口)
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-placeholder
LLM_MODEL=gpt-4o-mini
# LLM 模式: default = 用下面全局配置; custom = 每次请求在 body.llm 里自带 base_url/model/api_key
LLM_MODE=default
# default 模式的全局 LLM(OpenAI 兼容接口)
LLM_BASE_URL=http://100.69.1.2:8001/v1
LLM_API_KEY=
LLM_MODEL=qwen38
# 检测执行
MAX_UPLOAD_SIZE_MB=200
+4 -3
View File
@@ -97,10 +97,11 @@ CORS_ALLOW_ALL_ORIGINS = DEBUG
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# LLM 配置(OpenAI 兼容接口)
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "")
# LLM 配置: default 模式(全局, .env) / custom 模式(每次请求自带)
LLM_MODE = os.getenv("LLM_MODE", "default") # default | custom
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://100.69.1.2:8001/v1")
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
LLM_MODEL = os.getenv("LLM_MODEL", "")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen38")
# 检测执行配置
WORKSPACE_DIR = BASE_DIR / "workspace"
+52 -39
View File
@@ -56,36 +56,60 @@ def _extract_json(text: str) -> dict:
return json.loads(text[start:end + 1])
def analyze_requirements(content: str) -> dict:
"""调用 LLM 分析作业要求,返回检测维度计划。未配置 LLM 时返回默认计划。"""
key = settings.LLM_API_KEY
if not key or key.startswith("sk-placeholder") or not settings.LLM_MODEL:
logger.warning("LLM 未配置,使用默认检测计划")
def resolve_llm(override: dict | None = None) -> tuple[str, str, str]:
"""返回 (base_url, api_key, model)。default 模式读 settings;
custom 模式用 override 中的 base_url/api_key/model。"""
mode = (override or {}).get("mode") or settings.LLM_MODE
if mode == "custom":
base_url = (override.get("base_url") or "").strip()
api_key = (override.get("api_key") or "").strip()
model = (override.get("model") or "").strip()
if not base_url or not model:
raise ValueError("custom 模式必须提供 base_url 和 model")
return base_url.rstrip("/"), api_key, model
base_url = settings.LLM_BASE_URL.rstrip("/")
api_key = settings.LLM_API_KEY
model = settings.LLM_MODEL
if not base_url or not model:
raise ValueError("LLM 未配置: 请在 .env 设置 LLM_BASE_URL/LLM_MODEL, 或用 custom 模式")
return base_url, api_key, model
def _chat(base_url: str, api_key: str, model: str, messages: list[dict], timeout: int = 120) -> str:
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
resp = requests.post(
base_url + "/chat/completions",
headers=headers,
json={"model": model, "messages": messages, "temperature": 0.1},
timeout=timeout,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def analyze_requirements(content: str, llm_override: dict | None = None) -> dict:
"""调用 LLM 分析作业要求,返回检测维度计划。LLM 不可用时返回默认计划。"""
try:
base_url, api_key, model = resolve_llm(llm_override)
except ValueError as exc:
logger.warning("%s, 使用默认检测计划", exc)
return _default_spec()
url = settings.LLM_BASE_URL.rstrip("/") + "/chat/completions"
prompt = ANALYZE_PROMPT.format(
content=content[:8000],
stability_concurrency=settings.STABILITY_CONCURRENCY,
base_duration=settings.STABILITY_BASE_DURATION,
rampup_duration=settings.STABILITY_RAMPUP_DURATION,
)
resp = requests.post(
url,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={
"model": settings.LLM_MODEL,
"messages": [
{"role": "system", "content": "你是严格遵循 JSON 输出格式的 OJ 检测分析引擎。"},
{"role": "user", "content": prompt},
],
"temperature": 0.1,
},
timeout=120,
text = _chat(
base_url, api_key, model,
[
{"role": "system", "content": "你是严格遵循 JSON 输出格式的 OJ 检测分析引擎。"},
{"role": "user", "content": prompt},
],
)
resp.raise_for_status()
text = resp.json()["choices"][0]["message"]["content"]
spec = _extract_json(text)
return _validate_spec(spec)
return _validate_spec(_extract_json(text))
def _default_spec() -> dict:
@@ -98,12 +122,13 @@ def _default_spec() -> dict:
}
def ai_summarize(project_dir: str, spec: dict) -> dict:
def ai_summarize(project_dir: str, spec: dict, llm_override: dict | None = None) -> dict:
"""AI 检测: 阅读项目关键文件,对照检测计划输出中文报告片段。"""
key = settings.LLM_API_KEY
if not key or key.startswith("sk-placeholder") or not settings.LLM_MODEL:
return {"summary": "LLM 未配置,跳过 AI 检测。请在 .env 中配置 LLM_API_KEY。", "issues": []}
from .code_reader import read_key_files
try:
base_url, api_key, model = resolve_llm(llm_override)
except ValueError as exc:
return {"summary": f"LLM 不可用({exc}),跳过 AI 检测", "issues": []}
excerpt = read_key_files(project_dir, max_chars=12000)
prompt = (
"你是 OJ 平台的代码审查专家。下面是成员提交的项目关键代码摘录和检测计划(JSON):\n"
@@ -113,19 +138,7 @@ def ai_summarize(project_dir: str, spec: dict) -> dict:
"\"issues\": [{\"point\": \"检查点\", \"status\": \"pass|fail|warn\", \"detail\": \"中文说明\"}]}\n"
"对照检测计划逐项核查,重点关注: 功能点是否完善、稳定度是否可能达标、潜在风险。只输出 JSON。"
)
url = settings.LLM_BASE_URL.rstrip("/") + "/chat/completions"
resp = requests.post(
url,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={
"model": settings.LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.2,
},
timeout=180,
)
resp.raise_for_status()
text = resp.json()["choices"][0]["message"]["content"]
text = _chat(base_url, api_key, model, [{"role": "user", "content": prompt}], timeout=180)
data = _extract_json(text)
return {
"summary": str(data.get("summary", "")),
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-04 03:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('requirements', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='requirement',
name='llm_config',
field=models.JSONField(blank=True, default=dict, verbose_name='LLM 覆盖配置(custom 模式)'),
),
]
+1
View File
@@ -7,6 +7,7 @@ class Requirement(models.Model):
title = models.CharField("标题", max_length=200)
content = models.TextField("作业要求内容")
spec = models.JSONField("检测维度计划", default=dict, blank=True)
llm_config = models.JSONField("LLM 覆盖配置(custom 模式)", default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
+2 -2
View File
@@ -8,8 +8,8 @@ class RequirementSerializer(serializers.ModelSerializer):
class Meta:
model = Requirement
fields = ["id", "title", "content", "spec", "created_at", "submission_count"]
read_only_fields = ["id", "spec", "created_at"]
fields = ["id", "title", "content", "spec", "llm_config", "created_at", "submission_count"]
read_only_fields = ["id", "spec", "llm_config", "created_at"]
class SubmissionSerializer(serializers.ModelSerializer):
+4 -2
View File
@@ -22,10 +22,12 @@ def run_submission(submission_id: int):
archive = Path(file_path)
root = archive.parent
llm_override = submission.requirement.llm_config or {}
spec = submission.requirement.spec
try:
report, root = run_checks(archive, submission.requirement.spec)
report, root = run_checks(archive, spec)
try:
ai = ai_summarize(str(root), submission.spec)
ai = ai_summarize(str(root), spec, llm_override)
except Exception:
logger.exception("AI 检测失败")
ai = {"summary": "AI 检测执行失败", "issues": []}
+36 -11
View File
@@ -49,17 +49,15 @@ class EndToEndTests(APITestCase):
def test_create_requirement_analyzes_spec(self):
self._login()
with mock.patch("requirements.llm.requests.post") as post:
post.return_value = mock.Mock(
json=lambda: {"choices": [{"message": {"content": (
'{"ai_inspection": true, '
'"stability": {"enabled": true, "requirements": "基准并发20错误率≤1%"}, '
'"functional": {"enabled": true, "points": ["首页可访问", "返回 ok"]}, '
'"performance": {"enabled": false, "requirements": ""}, '
'"security": {"enabled": false, "requirements": ""}}'
)}}]},
raise_for_status=lambda: None,
)
llm_json = (
'{"ai_inspection": true, '
'"stability": {"enabled": true, "requirements": "基准并发20错误率≤1%"}, '
'"functional": {"enabled": true, "points": ["首页可访问", "返回 ok"]}, '
'"performance": {"enabled": false, "requirements": ""}, '
'"security": {"enabled": false, "requirements": ""}}'
)
with mock.patch("requirements.llm.resolve_llm", return_value=("http://llm.test/v1", "k", "m")), \
mock.patch("requirements.llm._chat", return_value=llm_json):
resp = self.client.post("/api/requirements/create/", {
"title": "作业1-分析",
"content": "实现一个 web 服务, 首页返回 ok, 要求稳定度达到 99%",
@@ -69,6 +67,33 @@ class EndToEndTests(APITestCase):
self.assertTrue(requirement.spec["stability"]["enabled"])
self.assertIn("首页可访问", requirement.spec["functional"]["points"])
def test_create_requirement_custom_llm(self):
self._login()
override = {"mode": "custom", "base_url": "http://custom.test/v1",
"api_key": "ck", "model": "custom-model"}
llm_json = '{"ai_inspection": true, "functional": {"enabled": true, "points": ["自定义功能点"]}}'
captured = {}
with mock.patch("requirements.llm.resolve_llm", side_effect=lambda ov: captured.update(ov) or ("http://custom.test/v1", "ck", "custom-model")), \
mock.patch("requirements.llm._chat", return_value=llm_json):
resp = self.client.post("/api/requirements/create/", {
"title": "custom-llm-作业",
"content": "任意",
"llm": override,
}, format="json")
self.assertEqual(resp.status_code, 201, resp.content)
self.assertEqual(captured["mode"], "custom")
requirement = Requirement.objects.get(title="custom-llm-作业")
self.assertEqual(requirement.llm_config["mode"], "custom")
self.assertIn("自定义功能点", requirement.spec["functional"]["points"])
def test_custom_llm_requires_fields(self):
self._login()
resp = self.client.post("/api/requirements/create/", {
"title": "缺字段", "content": "任意",
"llm": {"mode": "custom", "base_url": "", "model": ""},
}, format="json")
self.assertEqual(resp.status_code, 400)
def test_submission_full_pipeline(self):
self._login()
requirement = Requirement.objects.create(
+29 -2
View File
@@ -1,3 +1,4 @@
import json
import logging
from django.db import transaction
@@ -12,6 +13,26 @@ from .models import Requirement, Submission
logger = logging.getLogger(__name__)
def _parse_llm_override(request) -> dict:
raw = request.data.get("llm")
if not raw:
return {}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return {}
return raw if isinstance(raw, dict) else {}
def _validate_llm_override(override: dict) -> str | None:
if override.get("mode") != "custom":
return None
if not (override.get("base_url") or "").strip() or not (override.get("model") or "").strip():
return "custom 模式必须提供 llm.base_url 和 llm.model"
return None
@api_view(["POST"])
@permission_classes([IsAuthenticated])
@transaction.atomic
@@ -20,13 +41,19 @@ def create_requirement(request):
content = (request.data.get("content") or "").strip()
if not title or not content:
return Response({"detail": "title 和 content 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
override = _parse_llm_override(request)
err = _validate_llm_override(override)
if err:
return Response({"detail": err}, status=status.HTTP_400_BAD_REQUEST)
try:
spec = analyze_requirements(content)
spec = analyze_requirements(content, override)
except Exception:
logger.exception("AI 分析作业要求失败, 使用默认检测计划")
from .llm import _default_spec
spec = _default_spec()
requirement = Requirement.objects.create(title=title, content=content, spec=spec)
requirement = Requirement.objects.create(
title=title, content=content, spec=spec, llm_config=override,
)
from .serializers import RequirementSerializer
return Response(RequirementSerializer(requirement).data, status=status.HTTP_201_CREATED)
+1
View File
@@ -44,6 +44,7 @@ DATABASES = {
"NAME": ":memory:",
}
}
LLM_MODE = "default"
LLM_BASE_URL = ""
LLM_API_KEY = "sk-test"
LLM_MODEL = "test-model"