From 4a4c6f2ec27e8cdb6015586a53b5dc096a8ec903 Mon Sep 17 00:00:00 2001 From: hyh Date: Fri, 4 Sep 2026 12:06:05 +0800 Subject: [PATCH] feat: dual LLM mode (default via .env, custom per-request) for AI analysis --- .env.example | 11 ++- ojplatform/settings.py | 7 +- requirements/llm.py | 91 +++++++++++-------- .../migrations/0002_requirement_llm_config.py | 18 ++++ requirements/models.py | 1 + requirements/serializers.py | 4 +- requirements/tasks.py | 6 +- requirements/tests.py | 47 +++++++--- requirements/views.py | 31 ++++++- test_settings.py | 1 + 10 files changed, 154 insertions(+), 63 deletions(-) create mode 100644 requirements/migrations/0002_requirement_llm_config.py diff --git a/.env.example b/.env.example index 5cdd4bc..51fb168 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/ojplatform/settings.py b/ojplatform/settings.py index 9160216..0901453 100644 --- a/ojplatform/settings.py +++ b/ojplatform/settings.py @@ -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" diff --git a/requirements/llm.py b/requirements/llm.py index 89565c8..2b2d157 100644 --- a/requirements/llm.py +++ b/requirements/llm.py @@ -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", "")), diff --git a/requirements/migrations/0002_requirement_llm_config.py b/requirements/migrations/0002_requirement_llm_config.py new file mode 100644 index 0000000..707b879 --- /dev/null +++ b/requirements/migrations/0002_requirement_llm_config.py @@ -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 模式)'), + ), + ] diff --git a/requirements/models.py b/requirements/models.py index d8e7461..18d5422 100644 --- a/requirements/models.py +++ b/requirements/models.py @@ -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: diff --git a/requirements/serializers.py b/requirements/serializers.py index 183c0b4..22dda43 100644 --- a/requirements/serializers.py +++ b/requirements/serializers.py @@ -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): diff --git a/requirements/tasks.py b/requirements/tasks.py index 7d2a4c7..c853120 100644 --- a/requirements/tasks.py +++ b/requirements/tasks.py @@ -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": []} diff --git a/requirements/tests.py b/requirements/tests.py index 572ad83..ce35cc2 100644 --- a/requirements/tests.py +++ b/requirements/tests.py @@ -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( diff --git a/requirements/views.py b/requirements/views.py index 3a0798f..ee76943 100644 --- a/requirements/views.py +++ b/requirements/views.py @@ -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) diff --git a/test_settings.py b/test_settings.py index 6030b8c..a1187f6 100644 --- a/test_settings.py +++ b/test_settings.py @@ -44,6 +44,7 @@ DATABASES = { "NAME": ":memory:", } } +LLM_MODE = "default" LLM_BASE_URL = "" LLM_API_KEY = "sk-test" LLM_MODEL = "test-model"