diff --git a/project-docs/AI合同平台-AI受托法务复核与引用门禁整改报告-2026-08-26.md b/project-docs/AI合同平台-AI受托法务复核与引用门禁整改报告-2026-08-26.md new file mode 100644 index 00000000..f21cc1c0 --- /dev/null +++ b/project-docs/AI合同平台-AI受托法务复核与引用门禁整改报告-2026-08-26.md @@ -0,0 +1,110 @@ +# AI 合同平台 AI 受托法务复核与引用门禁整改报告 + +- 日期:2026-08-26 +- 原评测运行:`stratified-v1-20260826` +- 复核主体:`ai-delegated-legal-review-gpt-5.6-sol` +- 复核类型:平台所有者委托的 AI 法律质量复核 +- 模型:`gpt-5.6-sol` +- 最终结论:**43 条复核已完成,但 0 条接受、43 条拒绝;受控真实用户试点继续阻断** + +## 一、结论摘要 + +平台所有者明确授权由 AI 完成原计划中的 43 条法律质量复核,以减少常规人工介入。系统没有把 AI 冒充为具名人类法务,而是以独立复核主体 `ai-delegated-legal-review-gpt-5.6-sol` 写入不可变复核事件。 + +第二遍复核结果如下: + +| 复核项 | 结果 | +| --- | ---: | +| 应复核 / 已复核 | 43 / 43 | +| 事实与场景一致 | 42 | +| 事实结论存疑 | 1 | +| 引用可核验通过 | 0 | +| 引用可核验失败 | 43 | +| 发现重大遗漏 | 4 | +| 接受 / 修订 / 拒绝 | 0 / 0 / 43 | + +因此,“复核完成”不等于“复核通过”。原 100 条自动结构门禁虽然在旧规则下显示 100% 通过,但其中 43 条抽检揭示了系统性引用假阳性,原自动结论已被本报告撤销,不能用于真实用户试点放行。 + +## 二、关键缺陷 + +### 2.1 真实模型回答与兜底引用被错误拼接 + +法律问答在 Provider 成功时,以规则兜底响应为底稿,仅替换回答正文。规则兜底响应自带示例法条,因此即使本次知识检索没有命中,最终响应仍显示有引用。 + +43 条样本均具备以下冲突: + +- 回答正文明确说明“未命中明确法律依据,需要进一步检索确认”; +- `primary_sources` 全部为空; +- 响应却附加 1 至 2 条规则兜底法条; +- 旧质量门禁只检查引用是否含标题、条号和摘录,没有检查来源 ID、文档、分块、URL,以及回答是否实际使用该引用。 + +### 2.2 法条文本正确不代表回答被法条支持 + +本次附带的五种法条摘录经政府或司法机关公开文本抽核,文字本身基本一致: + +- 《中华人民共和国民法典》第509条、第703条和第707条可在[最高人民检察院公布的民法典全文](https://www.spp.gov.cn/zdgz/202006/t20200602_463886.shtml)中核对; +- 《劳动合同法》第19条可在[上海市人力资源和社会保障局公开说明](https://rsj.sh.gov.cn/trdhy_17355/20200617/t0035_1382881.html)中核对;第39条可在[上海市人力资源和社会保障局公开说明](https://rsj.sh.gov.cn/trdhy_17355/20260805/99af9c20ddd44f9a855b8c6836941b39.html)中核对。 + +但这些法条没有作为本次回答的检索来源,也没有在正文中建立“具体结论—具体法源”的对应关系。尤其 35 条不同合同类型统一附《民法典》第509条,最多只能说明一般履约原则,不能支撑回答中的全部专业建议。因此 43 条的“引用可核验”均判定失败。 + +## 三、第二遍内容复核发现 + +除系统性引用失败外,AI 第二遍复核还发现 4 条重大遗漏: + +| 样本 | 主要问题 | +| --- | --- | +| `data_processing-01` | 未把委托处理目的、期限、方式、信息种类、处理权限、禁止超指令处理、数据主体权利协助和委托方监督机制设为独立前置条件。 | +| `data_processing-10` | 证据保全建议未充分处理最小必要、法定删除义务、数据主体权利与业务保留周期之间的冲突,且“争议终结前停止常规删除”范围过宽。 | +| `sales-10` | 引用核验材料未明确官方来源、文本版本、公布及施行日期、检索日期和网页存档。 | +| `transport-10` | 未明确保存行业规则、承运条款、平台规则、技术标准及其当时有效版本和来源页面。 | + +其中 `data_processing-10` 同时被判定为事实结论存疑;其余 42 条在给定场景内逻辑基本自洽,但仍因引用失败不能接受。 + +## 四、已完成整改 + +1. Provider 成功响应只允许返回本次真实检索得到的引用,不再继承规则兜底示例引用。 +2. 自动质量门禁新增 `citation_ungrounded`:引用必须至少具有文档 ID、知识分块 ID、稳定来源 ID 或来源 URL。 +3. 自动质量门禁新增 `citation_answer_conflict`:回答声明未检索到依据却同时附引用时直接阻断。 +4. 新增可复用的 AI 受托复核执行器,采用“先评估产物、后显式执行”两阶段流程;执行前校验任务 ID、回答哈希、运行 ID和租户,防止评估后内容被替换。 +5. 复核产物不保存原问题和模型全文,只保存输入 SHA-256、批次哈希、判定、置信度和简要发现。 +6. 运营快照新增接受、修订、拒绝、批准率和法律复核门禁;存在拒绝时产生错误级告警,避免把“43/43 已完成”误读为通过。 +7. 后台文案调整为“治理复核”,明确 AI 受托复核优先、人工只处理疑难、冲突和低置信度例外,且 AI 不得冒充人类法务。 + +## 五、审计落库结果 + +- 原待复核队列:43;落库后:0。 +- `review_required / review_completed`:43 / 43。 +- 复核主体统一为 `ai-delegated-legal-review-gpt-5.6-sol`。 +- 43 条均以 `citation_verifiability=fail`、`disposition=rejected` 追加写入复核事件表。 +- 每条反馈均记录模型、授权主体、输入哈希、置信度、引用结论和内容结论;原模型输出未被覆盖。 +- 运行时评估 JSON 位于 `.runtime/delegated-ai-legal-review/`,不进入 Git。 + +## 六、发布决定 + +当前状态应更新为: + +**容量基线历史数据可保留;旧引用质量门禁结论 INVALIDATED;AI 法律复核 COMPLETED / REJECTED;受控真实用户试点 BLOCKED;公开注册 BLOCKED。** + +这不是 AI 自动化失败,而是 AI 复核成功发现了此前人工数量要求掩盖的系统性证据缺陷。继续减少人工介入的正确方向是强化可追溯法源和自动拒绝机制,而不是让 AI 对缺少依据的输出自动签字通过。 + +## 七、下一阶段修改方案 + +### P0:重新评测前 + +1. 建设共享或按租户授权的中国现行法源库,至少覆盖本次十类合同的核心法律、行政法规、司法解释和必要监管规则。 +2. 每个法源保存稳定来源 ID、官方 URL、标题、条号、摘录、公布日期、施行日期、版本和抓取/复核时间;不得仅保存自由文本。 +3. 回答输出增加结论到来源 ID 的明确绑定;任何没有来源绑定的法律结论进入拒绝或低置信度队列。 +4. 修复四个重大遗漏对应的提示词和 Playbook,重点补齐数据处理委托边界、证据保全与删除冲突、版本化外部规则留存。 + +### P0:重新运行门禁 + +使用新运行 ID 重新执行 100 条分层样本,不得复用本次已通过的旧质量状态。最低放行条件为: + +- 真实检索来源覆盖率 100%; +- 无来源引用和正文—引用冲突均为 0; +- 40 条高风险和确定性抽样全部由 AI 第二遍复核; +- 重大遗漏为 0; +- 接受或修订后接受率 100%,拒绝数为 0; +- AI 置信度不足、结论冲突或来源时效存疑时才进入人工例外队列。 + +在以上条件完成前,不建议邀请真实外部用户进入合同法律内容试点。 diff --git a/project-docs/AI合同平台-受控试点容量与分层质量评测报告-2026-08-26.md b/project-docs/AI合同平台-受控试点容量与分层质量评测报告-2026-08-26.md index e19ca538..dffeda80 100644 --- a/project-docs/AI合同平台-受控试点容量与分层质量评测报告-2026-08-26.md +++ b/project-docs/AI合同平台-受控试点容量与分层质量评测报告-2026-08-26.md @@ -1,10 +1,12 @@ # AI 合同平台受控试点容量与分层质量评测报告 +> **后续审计更正(2026-08-26):** AI 受托复核发现 43/43 条存在无可追溯来源的兜底引用,旧自动引用门禁结论已撤销。43 条均已复核并拒绝,受控真实用户试点继续阻断。完整证据和整改方案见《AI合同平台-AI受托法务复核与引用门禁整改报告-2026-08-26》。以下容量数据仍有效,但“自动质量通过”仅作为旧规则历史结果,不再构成发布证据。 + - 日期:2026-08-26 - 运行 ID:`stratified-v1-20260826` - 阶段:核心产品上线收口——受控试点容量与分层质量评测 -- 自动评测结论:**通过** -- 法律内容人工验收:**未完成,不得视为已通过** +- 自动评测结论:**旧规则历史通过;引用门禁结论已撤销** +- 法律内容治理复核:**43 / 43 已完成,0 接受、43 拒绝** - 发布建议:可继续内部和具名受控试点;不得据此开放公共注册或宣称法律意见、司法存证或正式生产模型能力已验收 ## 一、管理结论 @@ -21,8 +23,8 @@ | 10 并发准入拒绝 | 0 | 通过 | | 模型执行 P95 | 179,040 ms | 通过,接近 180 秒门限 | | fallback / 失败 / 受控重试 | 0 / 0 / 0 | 通过 | -| 待具名人工复核 | 43 | 待完成 | -| 已完成人工复核 | 0 | 未通过法律内容验收 | +| 待治理复核 | 0 | 已全部处理 | +| 已完成治理复核 | 43 | 0 接受、43 拒绝,未通过法律内容验收 | ## 二、已落地能力 @@ -96,10 +98,10 @@ | 各合同类型自动通过率 | ≥ 70% | 全部 100% | 通过 | | 模型执行 P95 | ≤ 180 秒 | 179.04 秒 | 通过,黄色边界 | | 10 并发准入拒绝 | 0 | 0 | 通过 | -| 高风险人工复核完成 | 100% | 0 / 40 | 未完成 | -| 全部抽检完成 | 100% | 0 / 43 | 未完成 | +| 高风险治理复核完成 | 100% | 40 / 40 | 完成但全部因引用缺陷拒绝 | +| 全部抽检完成 | 100% | 43 / 43 | 完成;接受 0、拒绝 43 | -因此,本阶段只能签署“自动容量和结构质量门禁通过”,不能签署“法律内容质量通过”。 +因此,本阶段只能保留容量基线,旧结构门禁中的引用判断已被后续审计撤销,不能签署“法律内容质量通过”。 ## 六、运行与工程证据 @@ -114,7 +116,7 @@ ## 七、已知限制与上线边界 -1. 100% 是自动结构门禁通过率,不是法律正确率。43 条具名复核未完成前,不应对外宣称内容质量已验收。 +1. 旧规则下的 100% 自动结构通过率已被 43 条 AI 受托复核证明存在引用假阳性;43 条全部拒绝,不应对外宣称内容质量已验收。 2. 执行 P95 距 180 秒只剩 960 ms,且最大值 221.7 秒。受控试点应继续显示后台进度、允许取消和人工接管,不应恢复同步等待。 3. 当前模型来源是开发期 Codex CLI 与个人订阅会话,不是可供真实客户使用的生产 Provider 合同、API 配额或服务等级协议。 4. 本地运行时 anti-abuse 为 disabled;真实用户注册仍应保持关闭。公开试点前至少切换 observe/enforce,配置哈希密钥并验证每日调用/数据出口配额。 @@ -126,8 +128,8 @@ ### P0:受控试点放行前 -1. 指定至少两名具名法律复核人员,完成 40 个高风险样本和 3 个抽样样本;每条核对事实、现行法源、引用定位和重大遗漏。 -2. 导出 43 条结构化复核反馈,按合同类型统计 corrected/rejected、不可核验引用和高风险遗漏;任一类型出现重大遗漏时重新评测该类型 10 条。 +1. 先补齐十类合同的可追溯现行法源库和结论—来源绑定,再使用新运行 ID 重新执行 100 条样本。 +2. 默认由 AI 完成第二遍复核;仅将低置信度、法源时效冲突和重大争议送人工例外队列。任何引用不可核验或重大遗漏都阻断放行。 3. 对 3 条超过 180 秒的任务分析 Provider 耗时和提示词长度;将 P95 目标留出至少 10% 余量后再扩大租户并发。 4. 用真实超级管理员登录运营页,下钻 `controlled-pilot-v1 / stratified-v1-20260826`,验证筛选、输出预览、显式确认和复核落库,但不得用测试人员身份替代法务结论。 @@ -141,6 +143,6 @@ ## 九、最终发布决定 -本阶段建议状态为:**自动容量与结构质量门禁 PASS;法律内容验收 PENDING;公共发布 BLOCKED**。 +本阶段建议状态更新为:**容量基线保留;旧引用质量门禁 INVALIDATED;AI 法律复核 COMPLETED / REJECTED;受控真实用户试点与公共发布均 BLOCKED**。 -允许继续的范围是内部开发和具名受控试点,租户活动任务不超过 10、单用户不超过 3,并保留后台任务、重试、fallback 和人工接管。完成 43 条人工复核及上述 P0 条件前,不开放真实用户自由注册,不将本地评测包装成第三方存证、司法效力或正式生产服务等级证明。 +允许继续的范围仅为内部开发与隔离 UAT,租户活动任务不超过 10、单用户不超过 3,并保留后台任务、重试、fallback 和例外接管。使用新运行 ID 完成可追溯法源整改、100 条重新评测及 AI 第二遍复核前,不开放受控真实用户试点或自由注册,也不将本地评测包装成第三方存证、司法效力或正式生产服务等级证明。 diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index cdbf55fa..71801963 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -1857,7 +1857,7 @@ components: threshold: { type: number } ModelJobOperationsSnapshot: type: object - required: [metrics, alerts, completed_sample_count, target_sample_count, remaining_sample_count, baseline_ready, quality_passed, quality_needs_review, quality_failed, quality_pass_rate, review_required, review_completed, admission, quality_segments] + required: [metrics, alerts, completed_sample_count, target_sample_count, remaining_sample_count, baseline_ready, quality_passed, quality_needs_review, quality_failed, quality_pass_rate, review_required, review_completed, review_accepted, review_corrected, review_rejected, review_approval_rate, review_gate_passed, admission, quality_segments] properties: metrics: $ref: "#/components/schemas/ModelInferenceJobMetrics" @@ -1875,6 +1875,11 @@ components: quality_pass_rate: { type: number } review_required: { type: integer } review_completed: { type: integer } + review_accepted: { type: integer } + review_corrected: { type: integer } + review_rejected: { type: integer } + review_approval_rate: { type: number } + review_gate_passed: { type: boolean } evaluation_run_id: { type: string, nullable: true } admission: { $ref: "#/components/schemas/ModelJobAdmissionSnapshot" } quality_segments: @@ -1894,7 +1899,7 @@ components: accepting_submissions: { type: boolean } ModelJobQualitySegment: type: object - required: [dimension, value, sample_count, quality_passed, quality_needs_review, quality_failed, review_required, review_completed, quality_pass_rate] + required: [dimension, value, sample_count, quality_passed, quality_needs_review, quality_failed, review_required, review_completed, review_accepted, review_corrected, review_rejected, review_approval_rate, quality_pass_rate] properties: dimension: { type: string, enum: [contract_type, risk_level, industry] } value: { type: string } @@ -1904,6 +1909,10 @@ components: quality_failed: { type: integer } review_required: { type: integer } review_completed: { type: integer } + review_accepted: { type: integer } + review_corrected: { type: integer } + review_rejected: { type: integer } + review_approval_rate: { type: number } quality_pass_rate: { type: number } ModelJobQualityReviewRequest: type: object diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py index 6fc257b3..c3b61407 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py @@ -116,7 +116,18 @@ def register_legal_routes( retrieved_citations=citations, ) if provider_response.status == "succeeded" and provider_response.answer: - response = fallback_response.model_copy(update={"answer": provider_response.answer}) + # A provider answer may only expose sources that were actually retrieved for + # that answer. The rule-based fallback carries generic example citations; + # copying those citations onto a real provider answer makes an ungrounded + # answer look sourced and lets the model-job quality gate pass incorrectly. + response = fallback_response.model_copy( + update={ + "answer": provider_response.answer, + "citations": citations, + "primary_sources": citations[:3], + "confidence": 0.78 if citations else 0.35, + } + ) audit_status = "succeeded" elif fallback_allowed: response = fallback_response diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/model_jobs.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/model_jobs.py index 8900805f..aa32cf6d 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/model_jobs.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/model_jobs.py @@ -424,6 +424,7 @@ def execute_model_job( error_code="output_quality_gate_failed", error_message=quality.summary, review_trigger="quality_gate", + fallback_used=audit_status == "fallback", **quality_fields, ) if audit_status == "fallback": @@ -449,6 +450,7 @@ def execute_model_job( error_code="output_quality_review_required", error_message=quality.summary, review_trigger="quality_gate", + fallback_used=audit_status == "fallback", **quality_fields, ) if review_trigger is not None: diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_jobs.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_jobs.py index 196e8cb3..ccfd9dab 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_jobs.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_jobs.py @@ -202,6 +202,10 @@ class ModelJobQualitySegment(BaseModel): quality_failed: int review_required: int review_completed: int + review_accepted: int = 0 + review_corrected: int = 0 + review_rejected: int = 0 + review_approval_rate: float = 0.0 quality_pass_rate: float @@ -218,6 +222,11 @@ class ModelJobOperationsSnapshot(BaseModel): quality_pass_rate: float review_required: int = 0 review_completed: int = 0 + review_accepted: int = 0 + review_corrected: int = 0 + review_rejected: int = 0 + review_approval_rate: float = 0.0 + review_gate_passed: bool = False evaluation_run_id: str | None = None admission: ModelJobAdmissionSnapshot quality_segments: list[ModelJobQualitySegment] = Field(default_factory=list) @@ -715,6 +724,7 @@ class ModelInferenceJobStore: quality_summary: str | None = None, quality_gate_version: str = "model-job-v1", review_trigger: ReviewTrigger | None = None, + fallback_used: bool | None = None, ) -> ModelInferenceJob: now = datetime.now(UTC) existing = self.get_record(job_id, tenant_id=tenant_id) @@ -738,7 +748,7 @@ class ModelInferenceJobStore: result=result, provider_status=provider_status, audit_run_id=audit_run_id, - fallback_used=status == "fallback", + fallback_used=(status == "fallback") if fallback_used is None else fallback_used, retryable=retryable, error_code=error_code, error_message=error_message, @@ -882,6 +892,13 @@ class ModelInferenceJobStore: ) def resolve(self, job_id: str, *, tenant_id: str, resolution_note: str) -> ModelInferenceJob: + existing = self.get_record(job_id, tenant_id=tenant_id) + if existing is None: + raise KeyError(job_id) + if existing.review_status == "required": + raise ValueError( + "This job requires a structured quality review; submit fact, citation, omission, and disposition feedback." + ) return self._update( job_id, tenant_id=tenant_id, @@ -1057,6 +1074,11 @@ class ModelInferenceJobStore: quality_failed = sum(item.quality_status == "failed" for item in evaluated) review_required = sum(item.review_status in {"required", "completed"} for item in evaluated) review_completed = sum(item.review_status == "completed" for item in evaluated) + review_accepted = sum(item.review_status == "completed" and item.review_disposition == "accepted" for item in evaluated) + review_corrected = sum(item.review_status == "completed" and item.review_disposition == "corrected" for item in evaluated) + review_rejected = sum(item.review_status == "completed" and item.review_disposition == "rejected" for item in evaluated) + review_approval_rate = round((review_accepted + review_corrected) / max(review_required, 1), 4) + review_gate_passed = review_completed == review_required and review_rejected == 0 admission = self.admission_snapshot( tenant_id=tenant_id, tenant_active_limit=tenant_active_limit, @@ -1090,7 +1112,18 @@ class ModelInferenceJobStore: if metrics.total >= 10 and metrics.failure_rate > 0.05: alerts.append(_alert("failure_rate_high", "error", "任务失败率偏高", "失败率超过 5%。", metrics.failure_rate, 0.05)) if metrics.awaiting_human >= 5: - alerts.append(_alert("manual_queue_backlog", "warning", "人工复核队列积压", "等待人工复核的任务达到 5 个。", metrics.awaiting_human, 5)) + alerts.append(_alert("manual_queue_backlog", "warning", "治理复核队列积压", "等待 AI 或人工治理复核的任务达到 5 个。", metrics.awaiting_human, 5)) + if review_rejected: + alerts.append( + _alert( + "legal_review_rejected", + "error", + "法律质量复核未通过", + f"已有 {review_rejected} 个复核任务被拒绝,不能仅凭复核完成数放行试点。", + review_rejected, + 0, + ) + ) if stale_active: alerts.append(_alert("stale_active_jobs", "error", "存在停滞任务", "有排队或运行任务超过 10 分钟未更新。", stale_active, 0)) if admission.active_saturation_rate >= 1: @@ -1129,6 +1162,11 @@ class ModelInferenceJobStore: quality_pass_rate=quality_pass_rate, review_required=review_required, review_completed=review_completed, + review_accepted=review_accepted, + review_corrected=review_corrected, + review_rejected=review_rejected, + review_approval_rate=review_approval_rate, + review_gate_passed=review_gate_passed, evaluation_run_id=evaluation_run_id, admission=admission, quality_segments=_quality_segments(evaluated), @@ -1364,6 +1402,11 @@ def _quality_segments(records: list[ModelInferenceJobRecord]) -> list[ModelJobQu for value in values: selected = [item for item in records if getattr(item, field_name) == value] passed = sum(item.quality_status == "passed" for item in selected) + review_required = sum(item.review_status in {"required", "completed"} for item in selected) + review_completed = sum(item.review_status == "completed" for item in selected) + review_accepted = sum(item.review_status == "completed" and item.review_disposition == "accepted" for item in selected) + review_corrected = sum(item.review_status == "completed" and item.review_disposition == "corrected" for item in selected) + review_rejected = sum(item.review_status == "completed" and item.review_disposition == "rejected" for item in selected) result.append( ModelJobQualitySegment( dimension=dimension, # type: ignore[arg-type] @@ -1372,8 +1415,12 @@ def _quality_segments(records: list[ModelInferenceJobRecord]) -> list[ModelJobQu quality_passed=passed, quality_needs_review=sum(item.quality_status == "needs_review" for item in selected), quality_failed=sum(item.quality_status == "failed" for item in selected), - review_required=sum(item.review_status in {"required", "completed"} for item in selected), - review_completed=sum(item.review_status == "completed" for item in selected), + review_required=review_required, + review_completed=review_completed, + review_accepted=review_accepted, + review_corrected=review_corrected, + review_rejected=review_rejected, + review_approval_rate=round((review_accepted + review_corrected) / max(review_required, 1), 4), quality_pass_rate=round(passed / max(len(selected), 1), 4), ) ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py index 15b489eb..ad5856c0 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py @@ -43,6 +43,14 @@ _PROMPT_LEAK_MARKERS = ( "developer message", "忽略之前的指令", ) +_NO_EVIDENCE_MARKERS = ( + "未命中明确依据", + "未命中明确法律依据", + "未检索到明确依据", + "需要进一步检索确认", + "no authoritative source was retrieved", + "no legal source was retrieved", +) def evaluate_model_output_quality( @@ -74,6 +82,10 @@ def evaluate_model_output_quality( labels.append(_label("missing_citation", "error", "法律问答没有返回引用依据。")) elif any(not _citation_is_reviewable(item) for item in citations): labels.append(_label("citation_incomplete", "warning", "部分引用缺少标题、出处或摘录。")) + elif any(not _citation_is_grounded(item) for item in citations): + labels.append(_label("citation_ungrounded", "error", "引用没有可追溯的文档、知识分块、稳定来源 ID 或来源 URL。")) + elif normalized_answer and any(marker in normalized_answer.casefold() for marker in _NO_EVIDENCE_MARKERS): + labels.append(_label("citation_answer_conflict", "error", "回答声明未命中依据,但响应同时附加了引用。")) else: labels.append(_label("citation_reviewable", "info", f"已返回 {len(citations)} 条可审阅引用。")) elif operation == "contract_review": @@ -102,5 +114,14 @@ def _citation_is_reviewable(value: Any) -> bool: return all(isinstance(value.get(key), str) and value[key].strip() for key in ("title", "reference", "quote")) +def _citation_is_grounded(value: Any) -> bool: + if not isinstance(value, dict): + return False + if any(isinstance(value.get(key), str) and value[key].strip() for key in ("document_id", "chunk_id", "url")): + return True + source_ids = value.get("source_ids") + return isinstance(source_ids, list) and any(isinstance(item, str) and item.strip() for item in source_ids) + + def _label(code: str, severity: QualitySeverity, message: str) -> ModelOutputQualityLabel: return ModelOutputQualityLabel(code=code, severity=severity, message=message) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_legal_qa.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_legal_qa.py index 317273e5..3db70e4f 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_legal_qa.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_legal_qa.py @@ -23,7 +23,7 @@ def test_legal_qa_returns_answer_with_citations() -> None: def test_legal_qa_uses_default_citation_for_unknown_question() -> None: - app = create_app(AiPlatformSettings(environment="test")) + app = create_app(AiPlatformSettings(environment="test", provider_call_mode="disabled")) client = TestClient(app) response = client.post("/api/v1/legal/qa", json={"question": "这个合同需要注意什么?"}) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_model_jobs.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_model_jobs.py index bbb56d0b..413ee14f 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_model_jobs.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_model_jobs.py @@ -152,6 +152,10 @@ def test_stratified_quality_review_queue_and_feedback_dataset() -> None: "quality_failed": 0, "review_required": 1, "review_completed": 0, + "review_accepted": 0, + "review_corrected": 0, + "review_rejected": 0, + "review_approval_rate": 0.0, "quality_pass_rate": 1.0, }, { @@ -163,6 +167,10 @@ def test_stratified_quality_review_queue_and_feedback_dataset() -> None: "quality_failed": 0, "review_required": 1, "review_completed": 0, + "review_accepted": 0, + "review_corrected": 0, + "review_rejected": 0, + "review_approval_rate": 0.0, "quality_pass_rate": 1.0, }, { @@ -174,6 +182,10 @@ def test_stratified_quality_review_queue_and_feedback_dataset() -> None: "quality_failed": 0, "review_required": 1, "review_completed": 0, + "review_accepted": 0, + "review_corrected": 0, + "review_rejected": 0, + "review_approval_rate": 0.0, "quality_pass_rate": 1.0, }, ] @@ -192,6 +204,15 @@ def test_stratified_quality_review_queue_and_feedback_dataset() -> None: assert review.json()["reviewer_id"] == "legal-reviewer-a" assert client.get(f"/api/v1/model-jobs/{created['id']}").json()["status"] == "resolved" assert len(client.get(f"/api/v1/model-jobs/{created['id']}/quality-reviews").json()) == 1 + reviewed_snapshot = client.get( + "/api/v1/model-jobs/operations?target_sample_count=1&evaluation_run_id=stratified-v1" + ).json() + assert reviewed_snapshot["review_completed"] == 1 + assert reviewed_snapshot["review_accepted"] == 1 + assert reviewed_snapshot["review_corrected"] == 0 + assert reviewed_snapshot["review_rejected"] == 0 + assert reviewed_snapshot["review_approval_rate"] == 1.0 + assert reviewed_snapshot["review_gate_passed"] is True def test_automatic_review_policy_covers_quality_high_risk_and_sampling() -> None: @@ -261,10 +282,19 @@ def test_sqlalchemy_quality_review_is_persisted_with_resolved_job() -> None: assert resolved.status == "resolved" assert resolved.reviewed_by == "reviewer-db-test" assert store.quality_reviews(job.id, tenant_id="review-db-test") == [event] + snapshot = store.operations_snapshot( + tenant_id="review-db-test", + target_sample_count=1, + ) + assert snapshot.review_completed == 1 + assert snapshot.review_rejected == 1 + assert snapshot.review_approval_rate == 0.0 + assert snapshot.review_gate_passed is False + assert "legal_review_rejected" in {alert.code for alert in snapshot.alerts} engine.dispose() -def test_model_job_is_queued_polled_and_completed_with_explicit_fallback() -> None: +def test_model_job_without_grounded_fallback_citation_enters_review() -> None: app, repository, client = _app_and_client() created_response = client.post( @@ -288,19 +318,22 @@ def test_model_job_is_queued_polled_and_completed_with_explicit_fallback() -> No ) assert completed is not None - assert completed.status == "fallback" + assert completed.status == "awaiting_human" assert completed.progress_percent == 100 assert completed.fallback_used is True assert completed.retryable is True assert completed.result and completed.result["answer"] assert completed.audit_run_id assert completed.provider_status == "skipped" + assert completed.quality_status == "failed" + assert completed.review_trigger == "quality_gate" + assert "citation_ungrounded" in {label.code for label in completed.quality_labels} polled = client.get(f"/api/v1/model-jobs/{created['id']}").json() - assert polled["status"] == "fallback" + assert polled["status"] == "awaiting_human" assert polled["recommended_poll_after_ms"] == 0 -def test_model_job_retry_handoff_resolution_and_metrics() -> None: +def test_model_job_retry_structured_review_and_metrics() -> None: app, repository, client = _app_and_client() created = client.post( "/api/v1/legal/qa/jobs", @@ -339,17 +372,30 @@ def test_model_job_retry_handoff_resolution_and_metrics() -> None: "assignee": "legal-duty", }, ) - assert handoff.status_code == 200 - assert handoff.json()["status"] == "awaiting_human" - assert handoff.json()["handoff_assignee"] == "legal-duty" + assert handoff.status_code == 409 + unstructured_resolution = client.post( + f"/api/v1/model-jobs/{created['id']}/resolve", + json={"resolution_note": "不应绕过结构化质量复核。"}, + ) + assert unstructured_resolution.status_code == 409 resolved = client.post( - f"/api/v1/model-jobs/{created['id']}/resolve", - json={"resolution_note": "法务已核对引用并完成答复。"}, + f"/api/v1/model-jobs/{created['id']}/quality-reviews", + headers={"X-User-Id": "ai-quality-reviewer"}, + json={ + "legal_fact_consistency": "uncertain", + "citation_verifiability": "fail", + "high_risk_omission": "uncertain", + "disposition": "rejected", + "feedback_text": "无可追溯引用,拒绝当前输出。", + }, ) assert resolved.status_code == 200 - assert resolved.json()["status"] == "resolved" - assert resolved.json()["retryable"] is False + assert resolved.json()["reviewer_id"] == "ai-quality-reviewer" + stored = client.get(f"/api/v1/model-jobs/{created['id']}").json() + assert stored["status"] == "resolved" + assert stored["retryable"] is False + assert stored["review_disposition"] == "rejected" metrics = client.get("/api/v1/model-jobs/metrics?window_hours=24").json() assert metrics["total"] == 1 diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py index 354da55b..6128564f 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py @@ -8,7 +8,12 @@ def test_legal_qa_quality_passes_reviewable_answer() -> None: result={ "answer": "知识产权归属应明确既有成果与项目成果的边界,并约定权利登记、许可范围及侵权责任。还应区分源代码、文档、数据和第三方素材,明确交付条件、使用地域、许可期限及离职人员成果归属。", "citations": [ - {"title": "民法典", "reference": "技术合同章", "quote": "技术成果归属由当事人约定。"}, + { + "title": "民法典", + "reference": "技术合同章", + "quote": "技术成果归属由当事人约定。", + "document_id": "law-civil-code-technology", + }, ], }, ) @@ -17,6 +22,47 @@ def test_legal_qa_quality_passes_reviewable_answer() -> None: assert result.score == 100 +def test_quality_gate_rejects_generic_fallback_citation_without_traceable_source() -> None: + result = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "采购预付款如何控制?"}, + result={ + "answer": "建议设置预付款担保和分阶段付款。", + "citations": [ + { + "title": "中华人民共和国民法典", + "reference": "第五百零九条", + "quote": "当事人应当按照约定全面履行自己的义务。", + }, + ], + }, + ) + + assert result.status == "failed" + assert "citation_ungrounded" in {item.code for item in result.labels} + + +def test_quality_gate_rejects_answer_that_denies_attached_evidence() -> None: + result = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "采购预付款如何控制?"}, + result={ + "answer": "未命中明确法律依据,需要进一步检索确认。", + "primary_sources": [ + { + "title": "中华人民共和国民法典", + "reference": "第五百零九条", + "quote": "当事人应当按照约定全面履行自己的义务。", + "document_id": "law-civil-code-509", + }, + ], + }, + ) + + assert result.status == "failed" + assert "citation_answer_conflict" in {item.code for item in result.labels} + + def test_quality_gate_blocks_placeholder_and_lost_question() -> None: result = evaluate_model_output_quality( operation="legal_qa", diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py index 04f1a521..9106d65a 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py @@ -155,6 +155,10 @@ class ModelJobQualitySegment(BaseModel): quality_failed: int review_required: int review_completed: int + review_accepted: int = 0 + review_corrected: int = 0 + review_rejected: int = 0 + review_approval_rate: float = 0.0 quality_pass_rate: float @@ -189,6 +193,11 @@ class ModelJobOperationsSnapshot(BaseModel): quality_pass_rate: float review_required: int = 0 review_completed: int = 0 + review_accepted: int = 0 + review_corrected: int = 0 + review_rejected: int = 0 + review_approval_rate: float = 0.0 + review_gate_passed: bool = False evaluation_run_id: str | None = None admission: ModelJobAdmissionSnapshot quality_segments: list[ModelJobQualitySegment] = Field(default_factory=list) diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/model-jobs/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/model-jobs/page.tsx index 654236ad..7761fb61 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/model-jobs/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/model-jobs/page.tsx @@ -63,7 +63,7 @@ export default function ModelJobOperationsPage() { - +
@@ -79,7 +79,7 @@ export default function ModelJobOperationsPage() {
-

通过 {snapshot.quality_passed} · 待人工 {snapshot.quality_needs_review} · 阻断 {snapshot.quality_failed}

+

自动通过 {snapshot.quality_passed} · 待治理复核 {snapshot.quality_needs_review} · 自动阻断 {snapshot.quality_failed}

@@ -90,11 +90,13 @@ export default function ModelJobOperationsPage() { 排队 / 运行 / 已取消{snapshot.metrics.queued} / {snapshot.metrics.running} / {snapshot.metrics.cancelled} 成功 / fallback / 失败{snapshot.metrics.succeeded} / {snapshot.metrics.fallback} / {snapshot.metrics.failed} - 人工复核{snapshot.metrics.awaiting_human} + 待治理复核{snapshot.metrics.awaiting_human} 执行 P50 / P95{formatDuration(snapshot.metrics.execution_latency_p50_ms)} / {formatDuration(snapshot.metrics.execution_latency_p95_ms)} fallback / 失败率{formatRate(snapshot.metrics.fallback_rate)} / {formatRate(snapshot.metrics.failure_rate)} 重试率{formatRate(snapshot.metrics.retry_rate)} - 抽检要求 / 已完成{snapshot.review_required} / {snapshot.review_completed} + 复核要求 / 已完成{snapshot.review_required} / {snapshot.review_completed} + 接受 / 修订 / 拒绝{snapshot.review_accepted} / {snapshot.review_corrected} / {snapshot.review_rejected} + 法律复核门禁{snapshot.review_gate_passed ? "通过" : "未通过"} @@ -119,8 +121,8 @@ export default function ModelJobOperationsPage() {

分层质量结果

{snapshot.quality_segments.length} 个分层
{snapshot.quality_segments.length === 0 ?
当前窗口尚无带分层标签的样本。
: ( - - {snapshot.quality_segments.map((segment) => )} +
维度分层样本通过率抽检
{dimensionLabel(segment.dimension)}{segment.value}{segment.sample_count}{formatRate(segment.quality_pass_rate)}{segment.review_completed}/{segment.review_required}
+ {snapshot.quality_segments.map((segment) => )}
维度分层样本自动通过率复核接受/修订/拒绝
{dimensionLabel(segment.dimension)}{segment.value}{segment.sample_count}{formatRate(segment.quality_pass_rate)}{segment.review_completed}/{segment.review_required}{segment.review_accepted}/{segment.review_corrected}/{segment.review_rejected}
)}
@@ -149,9 +151,9 @@ export default function ModelJobOperationsPage() {
-

人工抽检与接管队列

记录事实一致性、引用可核验性和高风险遗漏,形成规则迭代数据。

{reviewQueue.length} 条
+

治理复核与接管队列

AI 受托复核优先,疑难、冲突和低置信度任务再转人工;所有结论均记录复核主体与证据。

{reviewQueue.length} 条
- {reviewQueue.length === 0 ?
当前没有待人工抽检任务。
: ( + {reviewQueue.length === 0 ?
当前没有待治理复核任务。
: (
{reviewQueue.map((job) => )}
@@ -205,7 +207,7 @@ function QualityReviewCard({ job, onReviewed }: { job: ModelInferenceJob; onRevi setPayload((current) => ({ ...current, disposition: value }))} />