From 43c1dbdf32a6112dbf75b1537d5187e8fe5ad6bc Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 28 Jun 2026 12:11:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E5=86=85=E7=BD=AE=20LLM=20?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E4=B8=BB=E9=93=BE=E6=8E=A5=E5=85=A5=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置层: - 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 --- .env.docker.example | 7 + .env.payment.example | 13 +- admin/config.py | 54 ++++++- admin/routes/web_public.py | 181 +++++++++++++++++++-- admin/tests/conftest.py | 18 +-- admin/tests/test_app.py | 3 +- admin/tests/test_health.py | 6 + admin/tests/test_p2_4_p2_5_secrets.py | 5 + data/llm/__init__.py | 20 +++ data/llm/client.py | 144 +++++++++++++++++ data/llm/prompts.py | 223 ++++++++++++++++++++++++++ data/llm/tests/__init__.py | 8 + data/llm/tests/test_llm.py | 162 +++++++++++++++++++ scripts/payment_readiness_doctor.py | 4 + 14 files changed, 813 insertions(+), 35 deletions(-) create mode 100644 data/llm/__init__.py create mode 100644 data/llm/client.py create mode 100644 data/llm/prompts.py create mode 100644 data/llm/tests/__init__.py create mode 100644 data/llm/tests/test_llm.py diff --git a/.env.docker.example b/.env.docker.example index 1aa4d0b..b146c67 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -9,6 +9,13 @@ GAOKAO_ORDERS_FERNET_KEY=replace-with-strong-orders-fernet-secret-before-product GAOKAO_PORTAL_UPLOAD_DIR=/var/lib/gaokao/portal_uploads GAOKAO_PORTAL_UPLOAD_MAX_BYTES=5242880 GAOKAO_PORTAL_UPLOAD_MAX_FILES=5 +# LLM 自动审核 / 方案生成(生产环境必须配置) +GAOKAO_LLM_PROVIDER=dashscope +GAOKAO_LLM_API_KEY=replace-with-real-llm-api-key +GAOKAO_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +GAOKAO_LLM_MODEL=qwen-plus +GAOKAO_LLM_TIMEOUT=60 +GAOKAO_LLM_MAX_TOKENS=4096 GAOKAO_PAYMENT_PROVIDER=mock GAOKAO_PAYMENT_BASE_URL=https://example.com GAOKAO_PAYMENT_WEBHOOK_SECRET=replace-with-independent-payment-webhook-secret diff --git a/.env.payment.example b/.env.payment.example index 8725ce6..6036218 100644 --- a/.env.payment.example +++ b/.env.payment.example @@ -2,7 +2,18 @@ # 复制为 .env.payment 并填写真实值后 source 使用 # 正式上线前必须完成一次真实 acceptance -# 应用 ID(支付宝开放平台 → 应用管理) +# ===== LLM 供应商(自动生成志愿方案所必需) ===== +# 当前产品要求系统内自动调用 LLM 完成审核/冲稳保/完整规划。 +# 生产环境禁止 provider=none。 +GAOKAO_LLM_PROVIDER=none +GAOKAO_LLM_API_KEY= +# 默认 DashScope OpenAI-compatible;若用 OpenAI 则改为 https://api.openai.com/v1 +GAOKAO_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +GAOKAO_LLM_MODEL=qwen-plus +GAOKAO_LLM_TIMEOUT=60 +GAOKAO_LLM_MAX_TOKENS=4096 + +# ===== 支付/安全/运营 ===== GAOKAO_PAYMENT_APP_ID= # 商户 ID(支付宝商户平台 → 账户管理) diff --git a/admin/config.py b/admin/config.py index c83d34b..7a6afb9 100644 --- a/admin/config.py +++ b/admin/config.py @@ -73,6 +73,12 @@ class Settings: consent_version: str # 当前同意协议版本号,与 docs/PRIVACY_POLICY_DRAFT.md 版本对齐 consent_scope_portal: str # portal 资料提交默认 scope consent_scope_channel_prefix: str # 后台代录 scope 前缀 + llm_provider: str # openai|dashscope|anthropic|none + llm_api_key: str + llm_base_url: str + llm_model: str + llm_timeout_seconds: int + llm_max_tokens: int def _resolve_payment_webhook_secret(env: str) -> str: @@ -190,6 +196,31 @@ def _enforce_payment_provider_policy(settings: Settings) -> None: ) +_ALLOWED_LLM_PROVIDERS = {"openai", "dashscope", "anthropic", "none"} + + +def _enforce_llm_provider_policy(settings: Settings) -> None: + """生产环境 LLM provider 必须显式配置且不为 none,否则无法生成志愿方案。""" + provider = (settings.llm_provider or "none").strip().lower() + if provider not in _ALLOWED_LLM_PROVIDERS: + raise RuntimeError( + f"GAOKAO_LLM_PROVIDER={provider} 不在受支持列表 " + f"{sorted(_ALLOWED_LLM_PROVIDERS)}" + ) + if settings.env == "prod" and provider == "none": + raise RuntimeError( + "生产环境 GAOKAO_LLM_PROVIDER=none 被禁止:" + "产品需要 LLM 自动生成志愿方案,必须配置有效的供应商 " + "(openai/dashscope/anthropic)" + ) + if provider != "none" and not settings.llm_api_key: + if settings.env == "prod": + raise RuntimeError( + f"生产环境 GAOKAO_LLM_PROVIDER={provider} 但 GAOKAO_LLM_API_KEY 为空," + "无法调用 LLM 服务" + ) + + def load_settings() -> Settings: """从环境变量加载配置。 @@ -293,6 +324,14 @@ def load_settings() -> Settings: consent_scope_channel_prefix=os.getenv( "GAOKAO_CONSENT_SCOPE_CHANNEL_PREFIX", "channel-intake" ), + llm_provider=os.getenv("GAOKAO_LLM_PROVIDER", "none"), + llm_api_key=os.getenv("GAOKAO_LLM_API_KEY", ""), + llm_base_url=os.getenv( + "GAOKAO_LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1" + ), + llm_model=os.getenv("GAOKAO_LLM_MODEL", "qwen-plus"), + llm_timeout_seconds=int(os.getenv("GAOKAO_LLM_TIMEOUT", "60")), + llm_max_tokens=int(os.getenv("GAOKAO_LLM_MAX_TOKENS", "4096")), ) # 生产环境 post-load 校验:webhook / portal token / JWT / admin password # / payment provider 必须满足强度门槛, 任一不满足 fail-closed (P0-2/P2-4/P2-5/6/20)。 @@ -301,6 +340,7 @@ def load_settings() -> Settings: _enforce_jwt_secret_policy(settings) _enforce_default_admin_password_policy(settings) _enforce_payment_provider_policy(settings) + _enforce_llm_provider_policy(settings) return settings @@ -335,14 +375,12 @@ def is_default_admin_password_secure(settings: Settings) -> tuple[bool, str]: if settings.env == "prod" and password == _DEFAULT_ADMIN_PASSWORD: return False, "生产环境禁止使用默认管理员密码 admin123" if settings.env == "prod": - classes = sum( - ( - any(ch.islower() for ch in password), - any(ch.isupper() for ch in password), - any(ch.isdigit() for ch in password), - any(ch in string.punctuation for ch in password), - ) - ) + classes = sum(( + any(ch.islower() for ch in password), + any(ch.isupper() for ch in password), + any(ch.isdigit() for ch in password), + any(ch in string.punctuation for ch in password), + )) if classes < 3: return False, "生产环境默认管理员密码至少覆盖 3 类字符(大小写/数字/符号)" if settings.env == "dev" and password == _DEFAULT_ADMIN_PASSWORD: diff --git a/admin/routes/web_public.py b/admin/routes/web_public.py index 5f025d7..f6c5a89 100644 --- a/admin/routes/web_public.py +++ b/admin/routes/web_public.py @@ -28,6 +28,13 @@ from data.customer_portal.token import ( verify_portal_token, ) from data.crowd_db.loader import CrowdDBLoader +from data.llm import ( + LLMClient, + LLMError, + build_audit_prompt, + build_cwb_prompt, + build_full_plan_prompt, +) from data.notifications.email_service import DeliveryNotificationService from data.orders import crypto from data.orders.dao import OrderNotFound, OrdersDAO @@ -118,6 +125,9 @@ class ReviewResultContract(BaseModel): review_input_summary: str = "" review_input_attachments: list[str] = Field(default_factory=list) review_constraints: dict[str, Any] = Field(default_factory=dict) + llm_generated: bool = False + llm_summary: str = "" + llm_cwb_suggestions: dict[str, list[str]] = Field(default_factory=dict) class ReviewActionRequest(BaseModel): @@ -4006,6 +4016,108 @@ def _review_constraints_display(value: Any) -> str: return text or "待补充" +def _get_crowd_db_recs_for_review(constraints: dict[str, Any]) -> list[dict[str, Any]]: + """根据当前约束从 crowd_db 取同分段参考。""" + province = str(constraints.get("candidate_province") or "").strip() + score = constraints.get("candidate_score") + if not province or score in (None, ""): + return [] + try: + score_int = int(score) + except (TypeError, ValueError): + return [] + try: + loader = CrowdDBLoader(warn_low_confidence=False) + return loader.find_recommendations(province, score_int) + except Exception: + return [] + + +def _llm_review_contract( + *, + settings: Settings, + review_result_id: str, + source: Literal["home", "status", "report", "direct"], + resolved_summary: str, + resolved_constraints: dict[str, Any], + resolved_attachments: list[str], +) -> ReviewResultContract | None: + """尝试用 LLM 生成审核结果;未配置或失败时返回 None。""" + client = LLMClient(settings) + if not client.is_configured: + return None + + province = ( + str(resolved_constraints.get("candidate_province") or "").strip() or "湖南" + ) + score = resolved_constraints.get("candidate_score") + rank = resolved_constraints.get("candidate_rank") + subjects = list(resolved_constraints.get("candidate_subjects") or []) + crowd_recs = _get_crowd_db_recs_for_review(resolved_constraints) + system, user = build_audit_prompt( + province=province, + score=int(score) if score not in (None, "") else None, + rank=int(rank) if rank not in (None, "") else None, + subjects=[str(s) for s in subjects], + existing_plan=resolved_summary, + crowd_db_recs=crowd_recs, + ) + try: + resp = client.chat_with_system(system, user, temperature=0.3) + data = json.loads(resp.content) + except (LLMError, json.JSONDecodeError, TypeError, ValueError): + return None + + risk_level = str(data.get("risk_level") or "medium").lower() + if risk_level not in {"low", "medium", "high"}: + risk_level = "medium" + findings = [ + str(x).strip() for x in list(data.get("key_findings") or []) if str(x).strip() + ][:5] + if not findings: + findings = [str(data.get("risk_summary") or "当前方案可继续复核")] + + cwb = data.get("cwb_suggestions") or {} + cwb_suggestions = { + "rush": [ + f"{item.get('school', '?')} - {item.get('major', '?')}" + for item in list(cwb.get("rush") or []) + if isinstance(item, dict) + ], + "stable": [ + f"{item.get('school', '?')} - {item.get('major', '?')}" + for item in list(cwb.get("stable") or []) + if isinstance(item, dict) + ], + "safety": [ + f"{item.get('school', '?')} - {item.get('major', '?')}" + for item in list(cwb.get("safety") or []) + if isinstance(item, dict) + ], + } + + profile_ready = _is_profile_minimum_complete(resolved_constraints) + recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"] = ( + "go_cwb" if profile_ready else "go_step1" + ) + + return ReviewResultContract( + review_result_id=review_result_id, + risk_level=risk_level, + top_findings=findings, + recommended_action=recommended_action, + available_actions=["go_cwb", "go_step1", "go_full_plan"], + review_entry_source=source, + review_followup_action="none", + review_input_summary=resolved_summary or "未提供现有方案说明", + review_input_attachments=resolved_attachments, + review_constraints=resolved_constraints, + llm_generated=True, + llm_summary=str(data.get("risk_summary") or ""), + llm_cwb_suggestions=cwb_suggestions, + ) + + def _start_review_result( *, source: Literal["home", "status", "report", "direct"], @@ -4049,23 +4161,34 @@ def _start_review_result( recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"] = ( "go_cwb" if profile_ready else "go_step1" ) - top_finding = ( - "Step 1 已齐全,可直接进入冲稳保微调,再决定是否进入完整规划。" - if profile_ready - else "当前方案建议先补充 Step 1 后再继续判断梯度风险。" - ) - contract = ReviewResultContract( + + # 优先尝试 LLM 生成审核结果;未配置或失败时回退到规则默认逻辑 + contract = _llm_review_contract( + settings=settings, review_result_id=review_result_id, - risk_level="medium", - top_findings=[top_finding], - recommended_action=recommended_action, - available_actions=["go_cwb", "go_step1", "go_full_plan"], - review_entry_source=source, - review_followup_action="none", - review_input_summary=resolved_summary or "未提供现有方案说明", - review_input_attachments=resolved_attachments, - review_constraints=resolved_constraints, + source=source, + resolved_summary=resolved_summary, + resolved_constraints=resolved_constraints, + resolved_attachments=resolved_attachments, ) + if contract is None: + top_finding = ( + "Step 1 已齐全,可直接进入冲稳保微调,再决定是否进入完整规划。" + if profile_ready + else "当前方案建议先补充 Step 1 后再继续判断梯度风险。" + ) + contract = ReviewResultContract( + review_result_id=review_result_id, + risk_level="medium", + top_findings=[top_finding], + recommended_action=recommended_action, + available_actions=["go_cwb", "go_step1", "go_full_plan"], + review_entry_source=source, + review_followup_action="none", + review_input_summary=resolved_summary or "未提供现有方案说明", + review_input_attachments=resolved_attachments, + review_constraints=resolved_constraints, + ) if token: order = _resolve_order_from_token(token, settings) intake_store = IntakeStore.for_db(settings.orders_db_path) @@ -4104,6 +4227,11 @@ def _render_review_start_page(contract: ReviewResultContract, token: str | None) ) or "无附件" ) + llm_summary_html = ( + f'

AI 风险总结

{escape(contract.llm_summary)}

' + if contract.llm_generated and contract.llm_summary + else "" + ) constraints = contract.review_constraints or {} recommended_label = { "go_cwb": "先去看冲稳保建议", @@ -4156,6 +4284,7 @@ def _render_review_start_page(contract: ReviewResultContract, token: str | None)

+{llm_summary_html}