Files
gaokao-volunteer-system/data/llm/tests/test_llm.py
Hermes Agent 43c1dbdf32
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(llm): 内置 LLM 供应商配置与审核主链接入骨架
配置层:
- Settings 新增 llm_provider/api_key/base_url/model/timeout/max_tokens
- 生产 fail-closed: GAOKAO_LLM_PROVIDER=none 禁止, provider!=none 且 API key 为空禁止
- .env.docker.example / .env.payment.example 补 LLM 变量
- payment_readiness_doctor 将 LLM 配置纳入 readiness 检查

基础设施:
- 新增 data/llm/client.py: OpenAI-compatible LLMClient
- 新增 data/llm/prompts.py: audit/cwb/full_plan prompt 模板
- 新增 data/llm/tests/test_llm.py: 12 个单元测试

主链接入:
- ReviewResultContract 新增 llm_generated / llm_summary / llm_cwb_suggestions
- _start_review_result 优先尝试 LLM 生成审核结果, 失败时回退到原规则默认逻辑
- cwb 页面优先展示 LLM 生成的三档建议

测试适配:
- conftest / health / app / p2_4 tests 注入默认 LLM 测试配置,避免被新 fail-closed 提前拦截

验证:
- data/llm/tests 12 passed
- 核心 prod settings tests 62 passed
2026-06-28 12:11:43 +08:00

163 lines
5.6 KiB
Python

"""LLM 模块测试。"""
from __future__ import annotations
import pytest
from unittest.mock import patch, MagicMock
from dataclasses import dataclass
from data.llm.client import LLMClient, LLMResponse, LLMError
from data.llm.prompts import (
build_audit_prompt,
build_cwb_prompt,
build_full_plan_prompt,
)
@dataclass
class MockSettings:
llm_provider: str = "none"
llm_api_key: str = ""
llm_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
llm_model: str = "qwen-plus"
llm_timeout_seconds: int = 60
llm_max_tokens: int = 4096
class TestLLMClient:
def test_not_configured_when_provider_none(self):
client = LLMClient(MockSettings(llm_provider="none"))
assert not client.is_configured
def test_not_configured_when_no_api_key(self):
client = LLMClient(MockSettings(llm_provider="openai", llm_api_key=""))
assert not client.is_configured
def test_configured_when_provider_and_key_set(self):
client = LLMClient(MockSettings(llm_provider="openai", llm_api_key="sk-test"))
assert client.is_configured
def test_chat_raises_when_not_configured(self):
client = LLMClient(MockSettings(llm_provider="none"))
with pytest.raises(LLMError, match="LLM 未配置"):
client.chat([{"role": "user", "content": "test"}])
@patch("data.llm.client.urllib.request.urlopen")
def test_chat_success(self, mock_urlopen):
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"choices":[{"message":{"content":"test response"}}],"usage":{"total_tokens":10},"model":"qwen-plus"}'
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
client = LLMClient(
MockSettings(llm_provider="dashscope", llm_api_key="sk-test")
)
result = client.chat([{"role": "user", "content": "hello"}])
assert isinstance(result, LLMResponse)
assert result.content == "test response"
assert result.model == "qwen-plus"
assert result.usage["total_tokens"] == 10
@patch("data.llm.client.urllib.request.urlopen")
def test_chat_http_error(self, mock_urlopen):
import urllib.error
import io
error_fp = io.BytesIO(b'{"error":"bad key"}')
mock_urlopen.side_effect = urllib.error.HTTPError(
"http://test", 401, "Unauthorized", {}, error_fp
)
client = LLMClient(MockSettings(llm_provider="openai", llm_api_key="bad"))
with pytest.raises(LLMError, match="HTTP 401"):
client.chat([{"role": "user", "content": "test"}])
def test_chat_with_system(self):
"""chat_with_system 构建正确的消息结构。"""
with patch.object(LLMClient, "chat") as mock_chat:
mock_chat.return_value = LLMResponse(content="ok")
client = LLMClient(
MockSettings(llm_provider="openai", llm_api_key="sk-test")
)
client.chat_with_system("you are helpful", "hello")
call_args = mock_chat.call_args
messages = call_args[0][0]
assert len(messages) == 2
assert messages[0]["role"] == "system"
assert messages[0]["content"] == "you are helpful"
assert messages[1]["role"] == "user"
assert messages[1]["content"] == "hello"
class TestPrompts:
def test_audit_prompt_structure(self):
system, user = build_audit_prompt(
province="湖南",
score=578,
rank=12000,
subjects=["物理", "化学", "生物"],
existing_plan="已有一版方案",
)
assert "志愿填报顾问" in system
assert "湖南" in user
assert "578" in user
assert "物理" in user
assert "JSON" in user
assert "risk_level" in user
def test_audit_prompt_with_crowd_db(self):
recs = [
{"name": "湖南大学", "major": "计算机"},
{"name": "中南大学", "major": "软件工程"},
]
system, user = build_audit_prompt(
province="湖南",
score=578,
rank=12000,
subjects=["物理"],
existing_plan="test",
crowd_db_recs=recs,
)
assert "湖南大学" in user
assert "中南大学" in user
def test_audit_prompt_minimal(self):
system, user = build_audit_prompt(
province="广东",
score=None,
rank=None,
subjects=[],
existing_plan="",
)
assert "未提供" in user
def test_cwb_prompt_structure(self):
system, user = build_cwb_prompt(
province="湖南",
score=578,
rank=12000,
subjects=["物理", "化学", "生物"],
target_cities=["长沙", "深圳"],
)
assert "冲稳保" in system
assert "长沙" in user
assert "598" in user # score + 20
assert "558" in user # score - 20
def test_full_plan_prompt_structure(self):
system, user = build_full_plan_prompt(
province="湖南",
score=578,
rank=12000,
subjects=["物理", "化学", "生物"],
target_majors=["计算机科学", "人工智能"],
family_background="家长希望省内优先",
)
assert "完整" in system
assert "计算机科学" in user
assert "家长希望省内优先" in user
assert "volunteers" in user
assert "至少 8 条" in user