feat: dual LLM mode (default via .env, custom per-request) for AI analysis
This commit is contained in:
+7
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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 模式)'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ DATABASES = {
|
||||
"NAME": ":memory:",
|
||||
}
|
||||
}
|
||||
LLM_MODE = "default"
|
||||
LLM_BASE_URL = ""
|
||||
LLM_API_KEY = "sk-test"
|
||||
LLM_MODEL = "test-model"
|
||||
|
||||
Reference in New Issue
Block a user