Просмотр исходного кода

merge: integrate reply quality gate hardening from origin/master

ETO-kai 1 месяц назад
Родитель
Сommit
2f6b855a12

+ 5 - 1
docs/research/qiwei-reply-quality-evolva.md

@@ -35,7 +35,7 @@ Planner 根据 acknowledgement、direct question、first consultation、continua
 
 ## 4. 确定性验证与恢复
 
-Verifier 输出 10 项可解释检查:
+Verifier 输出 12 项可解释检查:
 
 | 检查 | 说明 |
 | --- | --- |
@@ -47,6 +47,8 @@ Verifier 输出 10 项可解释检查:
 | `repeated_greeting` | 多轮会话不重复问候 |
 | `repeated_question` | 拦截字面重复及预算、时间、人数等已回答字段的同义追问 |
 | `stage_next_step` | 末句包含当前阶段可执行动作 |
+| `risk_handoff` | 风险或高意向场景包含已执行或立即执行的人工接管动作 |
+| `human_boundary` | 风险或高意向场景同步设置 `requiresHuman=true` |
 | `sensitive_commitment` | 按分句阻断保证、绝对、最低价等未经证实承诺 |
 | `internal_leakage` | 不外发评分、检查项和内部运行字段 |
 
@@ -79,6 +81,8 @@ Claude 业务提示按请求构建并分别限额:
 | 合格样本误杀 | 未设门禁 | 0 |
 | 合格样本误杀率 | 未设门禁 | 0% |
 
+A4 后续确定性退化修复另见 [A4 回复质量退化修复与契约冻结](./qiwei-response-quality-a4-regression.md)。当前通用仓还固化了 10 条 A4 去标识化冻结坏回复,指定 hard check 覆盖 10/10;该结果不等同于 48 条真实模型盲测结论。
+
 专项还覆盖:无关 citation + 虚构价格、无关 tool result + 库存已确认、分句追加“其他待核验”、真实同值引用、空泛直接回答、一个问号包含三个字段、已回答预算的同义重复追问、一次修订成功/失败、请求绑定预算报告,以及 review/auto/autopilot/人工/纯致谢模式矩阵。
 
 现有 `agent:smoke` 同步更新并通过 48 项。上述 11+11 是确定性门禁回归,不等同于真实客服自然度盲测。

+ 103 - 0
docs/research/qiwei-response-quality-a4-regression.md

@@ -0,0 +1,103 @@
+# A4 回复质量退化修复与契约冻结
+
+## 1. 结论与边界
+
+本轮针对 A4 分层盲测暴露的事实编造、同义重复追问和风险场景未人工接管进行确定性修复。A4 原始评测只有 20/48 对,修复后没有再次调用付费模型,因此以下结果只表示规则门禁和冻结坏回复回归通过,不代表 48 条总体回复准确率已经提升。
+
+唯一真源是 `QIWEI-skill`。房产业务目标、房源工具和生产数据没有进入通用运行时;业务目标继续通过 `businessGoal`、`stagePlaybook` 及业务知识覆盖层配置。
+
+## 2. A4 退化与修复
+
+| 退化 | 修复 |
+| --- | --- |
+| 无关 citation/tool 为价格、库存、媒体或房况断言背书 | 事实按分句验证;外部事实只接受能定位到具体内容的 citation/tool,数字逐值匹配 |
+| “其他待核验”保护前面的确定性断言 | 不确定性只保护同一分句中的断言 |
+| 客户已给预算、目标学校、区域或房源,Agent 换说法再问 | 使用会话、画像字段和 alias 判断已答字段;识别无问号的“告诉我心理预算”等采集句 |
+| 学区、价格、房况、产权、融资、投诉、看房等风险场景继续自动推进 | Planner 标记风险类型;正文必须含已执行/立即执行的人工交接,JSON 同时要求 `requiresHuman=true` |
+| 首轮失败后修订仍失败但继续外发 | 最多修订一次;失败后 `review/auto/autopilot` 均保留人工草稿,发送方法入口再次 fail-closed |
+
+人工 `manualSend` 和人工编辑后 `approveDraft` 不经过 Agent 质量门,仍遵守既有白名单、私聊和长度边界。
+
+## 3. 冻结质量契约
+
+### 3.1 字段
+
+| 字段 | 类型 | 语义 |
+| --- | --- | --- |
+| `qualityScore` | `integer 0..100 \| null` | 确定性检查加权分;不可用或旧数据缺失为 `null`,不能把缺失解释为 `0` |
+| `qualityChecks` | `QualityCheck[] \| null` | 本次检查明细;旧数据缺失为 `null`,不是空结果 |
+| `qualityPassed` | `boolean \| null` | 仅表示 Agent 质量门结果;旧数据缺失为 `null` |
+| `rewriteCount` | `0 \| 1 \| null` | Agent 质量修订次数;旧数据缺失为 `null` |
+| `fallbackReason` | `string \| null` | 稳定流程 code;旧数据缺失为 `null` |
+
+`QualityCheck` 的核心 shape 固定为:
+
+```json
+{
+  "id": "fact_evidence",
+  "passed": false,
+  "weight": 14,
+  "hard": true,
+  "detail": "事实有可定位依据或在同一断言内明确待确认"
+}
+```
+
+前端可通过 UI adapter 本地化 `id/detail`,不改变核心字段,也不能用总分抵消 `hard=true` 的失败项。
+
+### 3.2 fallbackReason
+
+| code | 含义 |
+| --- | --- |
+| `quality_gate_failed` | 首次确定性质量门未通过,尚未完成修订 |
+| `rewrite_limit_reached` | 已使用一次修订机会,仍未通过或修订执行失败 |
+| `requires_human` | 文本通过质量门,但风险/业务边界要求人工接管 |
+| `quality_unavailable` | 本次没有可用质量评估 |
+| `""` | 质量通过且无需人工 |
+| `null` | 旧数据没有该字段,不推断结果 |
+
+失败的具体原因从 `qualityChecks`、`failedCheckIds` 或 `hardFailureIds` 读取,不再把检查 ID 拼到 `fallbackReason`。
+
+### 3.3 Delivery
+
+Delivery 只认真实 outbound 消息。草稿、质量通过、计划发送、人工接管和工具执行均不构成 Delivery;只有企微发送链实际创建 outbound 消息后才显示已发送。风险回复即使 `qualityPassed=true`,只要 `requiresHuman=true` 就必须停留在人工草稿。
+
+## 4. 确定性验收
+
+命令:
+
+```powershell
+npm run agent:quality-smoke
+node E:\企微技能包测试\testing\reply-quality\regression-review\a4-regression-gate.mjs --qiwei-root E:\workspace\QIWEI-skill
+```
+
+| 指标 | 结果 |
+| --- | ---: |
+| 固定低质量样本拦截 | 11/11 |
+| 固定合格样本误杀 | 0/11(0%) |
+| A4 去标识化冻结坏回复精确覆盖 | 10/10 |
+| 冻结坏回复直接放行 | 0 |
+| 风险模式 | `review/auto/autopilot` 均保留人工草稿,outbound=0 |
+| 合格普通回复 | `auto/autopilot` 可外发 |
+| 人工发送 | 不受 Agent 质量分阻断 |
+| 纯确认/致谢 | `no_reply_needed` |
+
+这些是确定性门禁夹具,不是模型盲测胜率或线上客服准确率。
+
+## 5. 同步边界
+
+通用能力同步到旧 VOC 时需要字段级同步:
+
+- `mcp/src/core/response-quality-contract.js`
+- `mcp/src/core/response-quality-planner.js`
+- `mcp/src/core/response-quality-verifier.js`
+- `mcp/src/core/agent-runtime.js` 的质量评估、一次修订和结构化返回区域
+- `mcp/src/core/agent-workbench-service.js` 的 Agent 内容二次门禁及 autopilot 入口校验
+- `scripts/response-quality-regression-fixtures.js`
+- `scripts/agent-response-quality-smoke-test.js`
+- `package.json` 的质量专项与语法检查入口
+
+业务项目后续手工 merge 时保留自己的房源 Provider、业务知识、Session 预创建、消息归档和 autopilot 配置,只接入公共质量字段与发送前闸门。`ClaudeCodeSessionStore`、`sessionKey`、`rotate/resume/reset` 均不在本轮变更范围。
+
+## 6. 发布状态
+
+本轮没有提交、推送、发布 npm,也没有访问或写入业务远端数据。完成旧 VOC 字段级同步和两仓完整 `release:check` 前,不进入业务副本同步。

+ 31 - 6
mcp/src/core/agent-runtime.js

@@ -5,8 +5,9 @@ const { spawn } = require('child_process');
 const { AgentContextBuilder, selectAuthoritativeHistory } = require('./agent-context-builder');
 const { planResponse } = require('./response-quality-planner');
 const { buildRewriteInstruction, verifyResponseQuality } = require('./response-quality-verifier');
+const { normalizeQualityChecks, normalizeQualityScore, qualityFallbackReason } = require('./response-quality-contract');
 
-const RISK_PATTERN = /合同|签约|资质|合规|保证|承诺|最低价|贷款|利率|投诉|退款|发票|身份证|银行卡|法律|违约/;
+const RISK_PATTERN = /合同|签约|产权|权属|资质|资格|政策|学区|入学|学位|合规|保证|承诺|最低价|成交价|报价|优惠|贷款|利率|税费|投诉|退款|赔偿|看房|预约|出价|面谈|付款|下单|发票|身份证|银行卡|法律|违约/;
 const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
 
 function isNoReplyNeededMessage(input = '') {
@@ -912,6 +913,7 @@ class QiweiAgentRuntime {
           citations,
           toolTrace,
           plan: responsePlan,
+          requiresHuman: Boolean(grounded.requiresHuman),
           passScore: this.config.qualityPassScore,
         });
         return { final: grounded, quality };
@@ -926,6 +928,7 @@ class QiweiAgentRuntime {
           passed: candidate.quality.passed,
           score: candidate.quality.score,
           failedCheckIds: candidate.quality.failedCheckIds,
+          hardFailureIds: candidate.quality.hardFailureIds,
         },
       });
 
@@ -964,12 +967,27 @@ class QiweiAgentRuntime {
             });
           }
           candidate = assess(revised.content);
+          if (!candidate.quality.passed) {
+            candidate.quality.fallbackReason = qualityFallbackReason({
+              qualityPassed: false,
+              rewriteCount,
+            });
+          }
         } catch (error) {
+          const rewriteCheck = {
+            id: 'rewrite_failed',
+            passed: false,
+            weight: 0,
+            hard: true,
+            detail: '质量重写执行失败',
+          };
           candidate.quality = {
             ...candidate.quality,
             passed: false,
             failedCheckIds: [...new Set([...candidate.quality.failedCheckIds, 'rewrite_failed'])],
-            fallbackReason: [...new Set([...candidate.quality.failedCheckIds, 'rewrite_failed'])].join(','),
+            hardFailureIds: [...new Set([...(candidate.quality.hardFailureIds || []), 'rewrite_failed'])],
+            checks: [...(candidate.quality.checks || []), rewriteCheck],
+            fallbackReason: qualityFallbackReason({ qualityPassed: false, rewriteCount }),
           };
           toolTrace.push({
             tool: 'response_quality_rewrite',
@@ -984,6 +1002,7 @@ class QiweiAgentRuntime {
             passed: candidate.quality.passed,
             score: candidate.quality.score,
             failedCheckIds: candidate.quality.failedCheckIds,
+            hardFailureIds: candidate.quality.hardFailureIds,
           },
         });
       }
@@ -992,23 +1011,29 @@ class QiweiAgentRuntime {
       const intelligence = extractExplicitCustomerIntelligence(inboundContent, currentProfile, final);
       const risky = RISK_PATTERN.test(inboundContent) || RISK_PATTERN.test(final.reply || '');
       const confidence = risky ? Math.min(clamp(final.confidence), 0.75) : clamp(final.confidence);
+      const requiresHuman = Boolean(final.requiresHuman || responsePlan.requireHumanHandoff || risky || confidence < 0.7 || !candidate.quality.passed);
       return {
         content: String(final.reply || '').trim(),
         confidence,
         intent: String(final.intent || 'unknown'),
         reason: String(final.reason || 'Agent 未提供说明'),
-        requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7 || !candidate.quality.passed),
+        requiresHuman,
         profileUpdates: intelligence.profileUpdates,
         tasks: intelligence.tasks,
         alerts: intelligence.alerts,
         citations: [...new Map(citations.map(item => [item.id, item])).values()],
         toolTrace,
-        qualityScore: candidate.quality.score,
-        qualityChecks: candidate.quality.checks,
+        qualityScore: normalizeQualityScore(candidate.quality.score),
+        qualityChecks: normalizeQualityChecks(candidate.quality.checks),
         qualityPassed: candidate.quality.passed,
         qualityPlan: candidate.quality.plan,
         rewriteCount,
-        fallbackReason: candidate.quality.fallbackReason,
+        fallbackReason: qualityFallbackReason({
+          qualityAvailable: candidate.quality?.score !== null && candidate.quality?.score !== undefined,
+          qualityPassed: candidate.quality.passed,
+          rewriteCount,
+          requiresHuman,
+        }),
       };
     }
     throw new Error('Agent 工具调用轮次超过上限,已转人工处理');

+ 21 - 12
mcp/src/core/agent-workbench-service.js

@@ -7,6 +7,7 @@ const { AgentMemoryExtractionWorker } = require('./agent-memory-worker');
 const { appendChatRecord, ensureMemory } = require('./message-archive');
 const { planResponse } = require('./response-quality-planner');
 const { verifyResponseQuality } = require('./response-quality-verifier');
+const { normalizeQualityChecks, normalizeQualityScore, qualityFallbackReason } = require('./response-quality-contract');
 
 function looksLikeGroupId(value) {
   return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
@@ -398,6 +399,7 @@ class AgentWorkbenchService extends EventEmitter {
         customerIntelligence: { tasks: currentTasks, alerts: currentAlerts },
         citations: output.citations || [],
         toolTrace: output.toolTrace || [],
+        requiresHuman: Boolean(output.requiresHuman),
         plan: output.qualityPlan || planResponse({
           inboundContent: inboundMessage.content,
           messages: conversationMessages,
@@ -407,23 +409,30 @@ class AgentWorkbenchService extends EventEmitter {
         passScore: this.config.agent?.qualityPassScore,
       });
       const qualityPassed = serviceQuality.passed && output.qualityPassed !== false;
+      const riskRequiresHuman = Boolean(serviceQuality.plan?.requireHumanHandoff);
+      const requiresHuman = Boolean(output.requiresHuman || riskRequiresHuman || !qualityPassed);
       const failureIds = [...new Set([
-        ...(output.fallbackReason ? String(output.fallbackReason).split(',') : []),
+        ...(Array.isArray(output.qualityChecks) ? output.qualityChecks.filter(check => !check?.passed).map(check => check.id) : []),
         ...serviceQuality.failedCheckIds,
       ].filter(Boolean))];
       output = {
         ...output,
-        requiresHuman: Boolean(output.requiresHuman || !qualityPassed),
-        qualityScore: serviceQuality.score,
-        qualityChecks: serviceQuality.checks,
+        requiresHuman,
+        qualityScore: normalizeQualityScore(serviceQuality.score),
+        qualityChecks: normalizeQualityChecks(serviceQuality.checks),
         qualityPassed,
         qualityPlan: serviceQuality.plan,
         rewriteCount: Math.min(1, Math.max(0, Number(output.rewriteCount || 0))),
-        fallbackReason: qualityPassed ? '' : failureIds.join(','),
+        fallbackReason: qualityFallbackReason({
+          qualityAvailable: serviceQuality?.score !== null && serviceQuality?.score !== undefined,
+          qualityPassed,
+          rewriteCount: output.rewriteCount,
+          requiresHuman,
+        }),
         toolTrace: [...(output.toolTrace || []), {
           tool: 'response_quality_service_gate',
           args: { version: serviceQuality.version },
-          result: { passed: qualityPassed, score: serviceQuality.score, failedCheckIds: failureIds },
+          result: { passed: qualityPassed, score: serviceQuality.score, failedCheckIds: failureIds, hardFailureIds: serviceQuality.hardFailureIds },
         }],
       };
       this.db.audit({
@@ -559,7 +568,7 @@ class AgentWorkbenchService extends EventEmitter {
   async sendAutopilotReply(conversation, inboundMessage, output, intelligence = {}) {
     this.requireAllowed(conversation.contact_id);
     this.requirePrivateConversation(conversation.id);
-    if (output.qualityPassed !== true || output.requiresHuman === true) {
+    if (output.qualityPassed !== true || output.requiresHuman === true || output.qualityPlan?.requireHumanHandoff === true) {
       throw new Error('Agent 回复未通过发送前质量校验,已保留为人工审核');
     }
     const content = String(output.content || '').trim();
@@ -580,11 +589,11 @@ class AgentWorkbenchService extends EventEmitter {
           requiresHuman: Boolean(output.requiresHuman),
           citations: output.citations || [],
           toolTrace: output.toolTrace || [],
-          qualityScore: Number(output.qualityScore) || 0,
-          qualityChecks: output.qualityChecks || [],
+          qualityScore: normalizeQualityScore(output.qualityScore),
+          qualityChecks: normalizeQualityChecks(output.qualityChecks),
           qualityPassed: Boolean(output.qualityPassed),
           rewriteCount: Number(output.rewriteCount) || 0,
-          fallbackReason: String(output.fallbackReason || ''),
+          fallbackReason: output.fallbackReason == null ? null : String(output.fallbackReason),
         },
       }).message;
       appendChatRecord({
@@ -607,10 +616,10 @@ class AgentWorkbenchService extends EventEmitter {
           outboundMessageId: outbound.id,
           confidence: Number(output.confidence) || 0,
           requiresHuman: Boolean(output.requiresHuman),
-          qualityScore: Number(output.qualityScore) || 0,
+          qualityScore: normalizeQualityScore(output.qualityScore),
           qualityPassed: Boolean(output.qualityPassed),
           rewriteCount: Number(output.rewriteCount) || 0,
-          fallbackReason: String(output.fallbackReason || ''),
+          fallbackReason: output.fallbackReason == null ? null : String(output.fallbackReason),
           draftCreated: false,
         },
       });

+ 44 - 0
mcp/src/core/response-quality-contract.js

@@ -0,0 +1,44 @@
+'use strict';
+
+const QUALITY_FALLBACK_REASONS = Object.freeze({
+  QUALITY_GATE_FAILED: 'quality_gate_failed',
+  REWRITE_LIMIT_REACHED: 'rewrite_limit_reached',
+  REQUIRES_HUMAN: 'requires_human',
+  QUALITY_UNAVAILABLE: 'quality_unavailable',
+});
+
+function normalizeQualityScore(value) {
+  if (value === null || value === undefined || value === '') return null;
+  const parsed = Number(value);
+  if (!Number.isFinite(parsed)) return null;
+  return Math.max(0, Math.min(100, Math.round(parsed)));
+}
+
+function normalizeQualityChecks(value) {
+  if (value === null || value === undefined) return null;
+  if (!Array.isArray(value)) return null;
+  return value.map(check => ({
+    id: String(check?.id || ''),
+    passed: Boolean(check?.passed),
+    weight: Number.isFinite(Number(check?.weight)) ? Number(check.weight) : 0,
+    hard: Boolean(check?.hard),
+    detail: String(check?.detail || ''),
+  }));
+}
+
+function qualityFallbackReason({ qualityAvailable = true, qualityPassed, rewriteCount = 0, requiresHuman = false } = {}) {
+  if (!qualityAvailable) return QUALITY_FALLBACK_REASONS.QUALITY_UNAVAILABLE;
+  if (qualityPassed !== true) {
+    return Number(rewriteCount || 0) > 0
+      ? QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED
+      : QUALITY_FALLBACK_REASONS.QUALITY_GATE_FAILED;
+  }
+  return requiresHuman ? QUALITY_FALLBACK_REASONS.REQUIRES_HUMAN : '';
+}
+
+module.exports = {
+  QUALITY_FALLBACK_REASONS,
+  normalizeQualityChecks,
+  normalizeQualityScore,
+  qualityFallbackReason,
+};

+ 34 - 4
mcp/src/core/response-quality-planner.js

@@ -2,6 +2,14 @@
 
 const ACK_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
 const QUESTION_PATTERN = /[??]|(?:吗|么|呢|怎么|如何|多少|能否|可以|有没有|是否|为什么|哪(?:个|些|里)?|几时|什么时候)/;
+const RISK_RULES = [
+  ['complaint', /投诉|举报|骗人|不处理|太差|退款|赔偿|生气|不满意/],
+  ['pricing', /最低|成交价|挂牌价|底价|报价|优惠|议价|多少钱|什么价|全成本|价格.{0,8}(?:确认|多少|能否)|费用.{0,8}(?:确认|多少|怎么收)/],
+  ['eligibility', /学区|上(?:小学|初中|高中)|入学|学位|划片|报名|户籍|资格|政策|资质|许可|名额/],
+  ['legal_or_finance', /产权|权属|合同|签约|税费|贷款|利率|违约|历史使用|法律/],
+  ['property_condition', /(?:房子|房源|这套|该套).{0,10}(?:问题|瑕疵|漏水|房况|物业)|(?:问题|瑕疵|漏水|房况|物业).{0,10}(?:房子|房源|这套|该套)/],
+  ['high_intent', /看房|预约|出价|面谈|签约|付款|下单|到店|现场/],
+];
 
 function normalizeText(value = '') {
   return String(value || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim();
@@ -18,15 +26,28 @@ function inferScenario(inboundContent = '', context = {}) {
   if (/投诉|举报|骗人|不处理|太差|退款|生气|不满意/.test(text)) return 'complaint';
   if (/太贵|不值|没用|不合适|不考虑|算了|担心|顾虑|但是|可是/.test(text)) return 'objection';
   if (/急|马上|尽快|今天|明天|现在|催|还没|什么时候能/.test(text)) return 'urgent_follow_up';
-  if (context.toolNoResult || /没有结果|未找到|无匹配/.test(String(context.toolFacts || ''))) return 'tool_no_result';
+  const toolNoResult = context.toolNoResult || (context.toolTrace || []).some(item => {
+    const result = item?.result;
+    if (Array.isArray(result)) return result.length === 0;
+    if (!result || typeof result !== 'object') return false;
+    const rows = result.items || result.results || result.data || result.rows;
+    return Array.isArray(rows) && rows.length === 0;
+  });
+  if (toolNoResult || /没有结果|未找到|无匹配/.test(String(context.toolFacts || ''))) return 'tool_no_result';
   if (QUESTION_PATTERN.test(text)) return 'direct_question';
   const hasHistory = (context.messages || []).some(message => message.role === 'assistant' || message.direction === 'outbound');
   return hasHistory ? 'continuation' : 'first_consultation';
 }
 
+function inferRiskTypes(inboundContent = '', context = {}) {
+  const text = [inboundContent, context.riskContext, context.stagePlaybook].filter(Boolean).join('\n');
+  return RISK_RULES.filter(([, pattern]) => pattern.test(text)).map(([type]) => type);
+}
+
 function planResponse(input = {}) {
   const inboundContent = String(input.inboundContent || '');
   const scenario = inferScenario(inboundContent, input);
+  const riskTypes = inferRiskTypes(inboundContent, input);
   const shouldReply = scenario !== 'acknowledgement';
   const requireDirectAnswer = shouldReply;
   const requireNextStep = shouldReply;
@@ -35,15 +56,17 @@ function planResponse(input = {}) {
     || /价格|费用|优惠|库存|名额|政策|资格|合同|结果|案例|数据|多少|最低|最高|保证/.test(inboundContent)
   );
   const enabledSections = scenario === 'complaint'
-    ? ['knownFacts', 'pendingRisks', 'nextStep']
+    ? ['knownFacts', 'pendingRisks', 'humanHandoff', 'nextStep']
     : scenario === 'objection'
       ? ['concern', 'knownFacts', 'pendingRisks', 'nextStep']
       : scenario === 'tool_no_result'
         ? ['knownFacts', 'pendingRisks', 'nextStep']
-        : ['directAnswer', 'knownFacts', 'nextStep'];
+        : ['directAnswer', 'knownFacts', ...(riskTypes.length ? ['humanHandoff'] : []), 'nextStep'];
+  const requireHumanHandoff = shouldReply && riskTypes.length > 0;
+  const requiredHumanRole = riskTypes.includes('complaint') ? 'supervisor' : requireHumanHandoff ? 'specialist' : '';
 
   return {
-    version: '1.0',
+    version: '1.1',
     scenario,
     shouldReply,
     requireDirectAnswer,
@@ -52,6 +75,9 @@ function planResponse(input = {}) {
     maxQuestions: shouldReply ? 1 : 0,
     maxChars: Number(input.maxChars || 320),
     enabledSections,
+    riskTypes,
+    requireHumanHandoff,
+    requiredHumanRole,
     businessGoal: String(input.businessGoal || '').trim().slice(0, 240),
     stagePlaybook: String(input.stagePlaybook || '').trim().slice(0, 600),
   };
@@ -65,6 +91,9 @@ function plannerInstruction(plan = {}) {
     plan.requireDirectAnswer ? '首句直接回答客户当前问题,禁止先写问候、感谢或处理过程。' : '先回应客户当前关注点。',
     '只补充能由本轮会话、画像、规则或工具结果支持的必要依据;未知事实明确待确认。',
     `最多追问 ${plan.maxQuestions} 个信息增益最高的问题,不重复已经问过或已经回答的信息。`,
+    plan.requireHumanHandoff
+      ? `这是风险或高意向场景:正文必须给出已执行或立即执行的人工接管动作,JSON requiresHuman 必须为 true;${plan.requiredHumanRole === 'supervisor' ? '投诉必须明确交主管、负责人或投诉专员。' : '明确交人工专员、顾问或负责人核验。'}`
+      : '',
     plan.requireNextStep ? '末句给出与当前阶段相符、可执行且不过度承诺的下一步。' : '',
     plan.businessGoal ? `可配置业务目标:${plan.businessGoal}` : '',
     plan.stagePlaybook ? `可配置阶段策略:${plan.stagePlaybook}` : '',
@@ -73,6 +102,7 @@ function plannerInstruction(plan = {}) {
 
 module.exports = {
   inferScenario,
+  inferRiskTypes,
   isAcknowledgement,
   normalizeText,
   planResponse,

+ 143 - 47
mcp/src/core/response-quality-verifier.js

@@ -1,6 +1,7 @@
 'use strict';
 
 const { planResponse, plannerInstruction } = require('./response-quality-planner');
+const { qualityFallbackReason } = require('./response-quality-contract');
 
 const GREETING_PATTERN = /^(?:您好|你好|哈喽|嗨|尊敬的|感谢您的?(?:咨询|关注|理解|反馈)?)[,,。!!\s]*/;
 const FILLER_OPENING_PATTERN = /^(?:关于这个问题|针对您的问题|我来为您|我先帮您|我这边先|请您耐心等待|感谢您的耐心)/;
@@ -8,8 +9,49 @@ const EMPTY_HANDOFF_PATTERN = /^(?:好的|收到|知道了|了解了|我(?:先)?
 const SENSITIVE_PROMISE_PATTERN = /(?:保证|承诺|绝对|百分之百|100%|一定(?:可以|能|会|不会)|全网最低|最低价|包过|稳赚|马上全部解决)/i;
 const UNCERTAINTY_PATTERN = /(?:待确认|需要确认|需要核验|以.+为准|暂未找到|没有返回|未返回|当前数据(?:源|中)?.*没有|尚无|不确定|人工确认|核实后)/;
 const FACT_CLAIM_PATTERN = /\d|(?:价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|当前有|已经找到|已确认|支持|最低|最高)/;
-const NEXT_STEP_PATTERN = /(?:下一步|接下来|我(?:先|会|可以)|我们(?:先|会|可以)|建议|可以先|请您|麻烦|方便|确认后|核对后|安排|发给您|同步给您|转人工|人工(?:跟进|处理|确认))/;
+const SENSITIVE_FACT_PATTERN = /(?:价格|费用|优惠|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|成交|最低|最高|保证|确定有|已经找到|已确认)/;
+const NEXT_STEP_PATTERN = /(?:下一步|接下来|我(?:先|会|可以)|我们(?:先|会|可以)|建议|可以先|请您|麻烦|方便|确认后|核对后|安排|发给您|同步给您|转人工|转交|转给|主管|负责人|人工(?:跟进|处理|确认))/;
 const INTERNAL_LEAK_PATTERN = /(?:qualityScore|qualityChecks|rewriteCount|fallbackReason|内部评分|系统提示|JSON Schema|requiresHuman)/i;
+const HUMAN_HANDOFF_ACTION_PATTERN = /(?:已|现在|立即|马上|会|将|确认后|核对后).{0,6}(?:转交|转给|转至|转|安排|请|由).{0,10}(?:人工|专员|顾问|主管|负责人)/;
+const SUPERVISOR_HANDOFF_PATTERN = /(?:主管|负责人|投诉专员).{0,12}(?:跟进|处理|核验|联系|接管)|(?:转交|转给|安排).{0,8}(?:主管|负责人|投诉专员)/;
+
+const FIELD_RULES = {
+  budget: {
+    question: /预算|价格上限|费用上限|投入上限|总投入|总价|金额|首付|月供|付款能力/,
+    answer: /(?:预算|价格|费用|投入|总价|金额|首付|月供).{0,14}\d|\d+(?:\.\d+)?\s*(?:万|元)/,
+    profileKeys: /budget|price|maxamount|investment|funding|downpayment|monthlypayment|首付|月供|预算|金额/,
+  },
+  time: {
+    question: /时间|什么时候|何时|哪天|几号|多久|开始|截止|入住|入学|上线|交付|购置/,
+    answer: /(?:时间|开始|截止|计划|入住|入学|上线|交付|购置).{0,14}(?:今天|明天|本周|下周|月|年|季度|\d)/,
+    profileKeys: /timeline|startat|endat|deadline|purchase.*at|readyat|时间|日期|周期/,
+  },
+  people: {
+    question: /多少人|几人|人数|规模|参与人|团队人数/,
+    answer: /(?:人数|规模|参与|团队).{0,8}\d|\d.{0,4}人/,
+    profileKeys: /people|participant|headcount|人数|规模/,
+  },
+  goal: {
+    question: /目标|用途|目的|效果|解决什么|核心需求|主要诉求/,
+    answer: /(?:目标|用途|目的|需求|诉求)(?:是|为|:|:)|我(?:想|要|希望|主要)/,
+    profileKeys: /goal|purpose|intent|need|objective|目标|用途|需求|诉求/,
+  },
+  location: {
+    question: /地区|区域|地点|地址|哪里|哪儿|片区|城市|小区/,
+    answer: /(?:地区|区域|地点|地址|片区|城市)(?:是|在|选|为|:|:)|我(?:想|要|考虑).{0,8}(?:在|选)/,
+    profileKeys: /location|region|area|city|address|district|区域|地区|地址|城市/,
+  },
+  target: {
+    question: /目标(?:学校|初中|小学|机构|产品)|哪(?:所|个)(?:学校|初中|小学|产品|方案)|哪套|房源|候选|项目编号|方案编号/,
+    answer: /(?:目标学校|目标机构|目标产品|房源|候选|项目|方案)(?:是|为|选|编号|:|:)|(?:看中|选中|确定).{0,8}(?:这套|该|方案|产品|项目)/,
+    profileKeys: /target|school|property|listing|candidate|selected|projectid|productid|目标|学校|房源|候选|方案/,
+  },
+  feature: {
+    question: /户型|面积|楼层|电梯|规格|版本|功能偏好|配置偏好/,
+    answer: /(?:户型|面积|楼层|电梯|规格|版本|功能|配置)(?:是|要|不要|偏好|选择|:|:)|\d+\s*(?:平|㎡|层)/,
+    profileKeys: /layout|area|floor|elevator|feature|version|configuration|户型|面积|楼层|电梯|配置/,
+  },
+};
 
 function normalized(value = '') {
   return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, '');
@@ -30,14 +72,21 @@ function questionSegments(text = '') {
 
 function questionFields(text = '') {
   const value = String(text || '');
-  const fields = [];
-  if (/预算|价格|费用|投入|总价|上限|下限|金额/.test(value)) fields.push('budget');
-  if (/时间|什么时候|何时|哪天|几号|多久|开始|截止/.test(value)) fields.push('time');
-  if (/多少人|几人|人数|规模|参与人/.test(value)) fields.push('people');
-  if (/目标|用途|目的|效果|解决什么|需求/.test(value)) fields.push('goal');
-  if (/地区|区域|地点|地址|哪里|哪儿/.test(value)) fields.push('location');
-  if (/数量|多少个|几个|套数/.test(value)) fields.push('quantity');
-  return [...new Set(fields)];
+  return Object.entries(FIELD_RULES).filter(([, rule]) => rule.question.test(value)).map(([field]) => field);
+}
+
+function questionFieldText(text = '') {
+  const cue = /(?:请问|麻烦|方便|能否|是否|可否|怎么|如何|为什么|多少|什么时候|何时|哪|几|要不要|需不需要|更看重|更在意|偏好|倾向|吗$|么$)/;
+  return String(text || '')
+    .split(/[。!!\n]/)
+    .filter(sentence => /[??]/.test(sentence) || countQuestions(sentence) > 0)
+    .flatMap(sentence => {
+      const explicit = sentence.split(/[??]/).slice(0, -1);
+      return explicit.length ? explicit : [sentence];
+    })
+    .flatMap(segment => segment.split(/[,,;;]/).map(item => item.trim()).filter(Boolean))
+    .filter((clause, index, clauses) => cue.test(clause) || index === clauses.length - 1)
+    .join('\n');
 }
 
 function countQuestions(text = '') {
@@ -45,11 +94,13 @@ function countQuestions(text = '') {
   const clauses = value.split(/[;;。!!\n]/).map(item => item.trim()).filter(Boolean);
   return clauses.reduce((total, clause) => {
     const punctuationCount = (clause.match(/[??]/g) || []).length;
-    const cueCount = (clause.match(/多少|什么时候|何时|哪(?:个|些|里|儿|天)|几(?:点|号|人|个)?|是否|能否|可否|怎么|如何|为什么/g) || []).length;
-    const implicitQuestion = !punctuationCount && cueCount > 0 && (
+    const cueCount = (clause.match(/多少|什么时候|何时|哪(?:个|些|里|儿|天|一?所)|几(?:点|号|人|个)?|是否|能否|可否|怎么|如何|为什么/g) || []).length;
+    const collectionIntent = /(?:告诉|告知|提供|补充|发(?:一下)?|说(?:一下|下)?|说明).{0,20}(?:预算|价格上限|费用上限|投入上限|总投入|心理预算|时间|日期|人数|规模|地区|区域|地点|地址|目标|学校|初中|产品|方案|房源|小区|户型|面积|楼层|规格|版本)/.test(clause)
+      || /(?:您|客户).{0,8}(?:确认|选择).{0,20}(?:哪(?:一?所|个|套)|多少|什么|何时|什么时候)/.test(clause);
+    const implicitQuestion = !punctuationCount && ((cueCount > 0 && (
       /^(?:请问|麻烦|方便|能否|是否|怎么|如何|为什么|多少|什么时候|何时|哪|几|预算|时间|人数|规模|地区|区域)/.test(clause)
       || /(?:预算|时间|人数|规模|地区|区域).{0,10}(?:多少|什么时候|何时|哪|几|吗|么)$/.test(clause)
-    );
+    )) || collectionIntent);
     if (!punctuationCount && !implicitQuestion) return total;
     const fields = questionFields(clause);
     const multiFieldCount = fields.length > 1 && !/还是/.test(clause) ? fields.length : 0;
@@ -58,17 +109,23 @@ function countQuestions(text = '') {
 }
 
 function hasAnsweredFieldQuestion(reply, input = {}) {
-  const asked = questionFields(reply);
+  const asked = questionFields(questionFieldText(reply));
   if (!asked.length || countQuestions(reply) === 0) return false;
   const source = [input.inboundContent, ...(input.messages || []).filter(message => toRole(message) === 'user').map(messageContent)].join('\n');
-  const profile = input.profile || {};
-  const answered = new Set();
-  if (/(?:预算|价格|费用|投入|总价|上限|金额).{0,12}\d|\d.{0,6}(?:万|元)/.test(source) || profile.budget || profile.budgetWan || profile.budgetMax) answered.add('budget');
-  if (/(?:时间|开始|截止|计划).{0,12}(?:今天|明天|本周|下周|月|年|\d)/.test(source) || profile.timeline || profile.startAt || profile.deadline) answered.add('time');
-  if (/(?:人数|规模|参与).{0,8}\d|\d.{0,4}人/.test(source) || profile.peopleCount || profile.participantCount) answered.add('people');
-  if (/(?:目标|用途|目的|需求)(?:是|为|:|:)/.test(source) || profile.goal || profile.purpose || profile.intent || profile.need) answered.add('goal');
-  if (/(?:地区|区域|地点|地址)(?:是|在|为|:|:)/.test(source) || profile.location || profile.region) answered.add('location');
-  return asked.some(field => answered.has(field));
+  const profileEntries = [];
+  const visit = (value, prefix = '') => {
+    if (!value || typeof value !== 'object') return;
+    for (const [key, child] of Object.entries(value)) {
+      const path = `${prefix}.${key}`.toLowerCase();
+      if (child !== undefined && child !== null && child !== '' && (typeof child !== 'object' || Array.isArray(child))) profileEntries.push(path);
+      if (child && typeof child === 'object') visit(child, path);
+    }
+  };
+  visit(input.profile || {});
+  return asked.some(field => {
+    const rule = FIELD_RULES[field];
+    return rule.answer.test(source) || profileEntries.some(key => rule.profileKeys.test(key));
+  });
 }
 
 function hasRepeatedQuestion(reply, messages = [], input = {}) {
@@ -81,9 +138,8 @@ function hasRepeatedQuestion(reply, messages = [], input = {}) {
   return current.some(question => previous.some(old => question === old || (question.length >= 8 && (question.includes(old) || old.includes(question)))));
 }
 
-function evidencePayload(input = {}) {
+function externalEvidencePayload(input = {}) {
   return [
-    JSON.stringify(input.profile || {}),
     ...(input.citations || []).map(item => JSON.stringify({
       content: item?.content,
       excerpt: item?.excerpt,
@@ -96,6 +152,23 @@ function evidencePayload(input = {}) {
   ].join('\n');
 }
 
+function customerEvidencePayload(input = {}) {
+  return [
+    JSON.stringify(input.profile || {}),
+    input.inboundContent,
+    ...(input.messages || []).filter(message => toRole(message) === 'user').map(messageContent),
+  ].join('\n');
+}
+
+function claimHasScopedUncertainty(claim = '') {
+  const clause = String(claim || '').trim();
+  if (/^(?:当前|目前|暂时|现阶段)?(?:尚未|还没|没有|未|暂无|暂未|不确定|待|需要|需先|要先)/.test(clause)) return true;
+  if (/^(?:当前|目前|暂时|现阶段).{0,12}(?:没有返回|未返回|未找到|暂无|尚无)/.test(clause)) return true;
+  if (/(?:档期|安排).{0,10}(?:需要看|需看|需要确认|待确认|需要核对|需核对)/.test(clause)) return true;
+  if (/(?:价格|费用|报价|优惠|最低|最高|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|成交).{0,10}(?:待确认|待核验|需要确认|需要.{0,6}核验|需先核验|以.+为准|不确定)/.test(clause)) return true;
+  return /(?:取决于|需要根据|需按|要按).{0,18}(?:确认|核验|评估|测算)/.test(clause);
+}
+
 function claimNgrams(claim = '') {
   const value = normalized(claim)
     .replace(/(?:当前|已经|我们|这边|可以|给您|核验|确认|结果|数据)/g, '');
@@ -109,28 +182,34 @@ function claimNgrams(claim = '') {
 
 function claimIsSupported(claim, input = {}, plan = {}) {
   const clause = String(claim || '').trim();
-  if (!FACT_CLAIM_PATTERN.test(clause)) return true;
-  if (UNCERTAINTY_PATTERN.test(clause)) return true;
+  if (HUMAN_HANDOFF_ACTION_PATTERN.test(clause)) return true;
+  const sensitiveFact = SENSITIVE_FACT_PATTERN.test(clause);
+  const assertiveFact = /(?:已确认|已经确认|已有|已经找到|当前有|确定有|完全匹配|可以直接|不会影响|一定存在|明确支持)/.test(clause);
+  if (!FACT_CLAIM_PATTERN.test(clause) && !sensitiveFact && !assertiveFact) return true;
+  if (claimHasScopedUncertainty(clause)) return true;
 
-  const evidence = normalized(evidencePayload(input));
-  const conversation = normalized([
-    input.inboundContent,
-    ...(input.messages || []).map(messageContent),
-  ].join('\n'));
+  const externalEvidence = normalized(externalEvidencePayload(input));
+  const customerEvidence = normalized(customerEvidencePayload(input));
   const numbers = clause.match(/\d+(?:\.\d+)?/g) || [];
-  const terms = clause.match(/价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|最低|最高|现货|匹配|预算/g) || [];
-  const evidenceHasNumbers = numbers.every(value => evidence.includes(normalized(value)));
-  const conversationHasNumbers = numbers.length > 0 && numbers.every(value => conversation.includes(normalized(value)));
-  const evidenceHasTerms = terms.length > 0 && terms.every(term => evidence.includes(normalized(term)));
-  const conversationHasTerms = terms.length > 0 && terms.every(term => conversation.includes(normalized(term)));
-  const exactEvidence = claimNgrams(clause).some(gram => evidence.includes(gram));
-  const explicitRestatement = /(?:您|客户)(?:的|提到|说|确认)|已记录/.test(clause)
-    && conversationHasNumbers
-    && conversationHasTerms;
+  const terms = clause.match(/价格|费用|投入|总价|金额|首付|月供|优惠|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|数据|结果|最低|最高|现货|匹配|预算/g) || [];
+  const externalHasNumbers = numbers.every(value => externalEvidence.includes(normalized(value)));
+  const customerHasNumbers = numbers.every(value => customerEvidence.includes(normalized(value)));
+  const externalHasTerms = terms.length > 0 && terms.every(term => externalEvidence.includes(normalized(term)));
+  const customerHasTerms = terms.length > 0 && terms.every(term => customerEvidence.includes(normalized(term)));
+  const grams = claimNgrams(clause);
+  const exactExternal = grams.some(gram => externalEvidence.includes(gram));
+  const exactCustomer = grams.some(gram => customerEvidence.includes(gram));
+  const explicitCustomerAttribution = /(?:您|客户)(?:的|提到|说|确认|希望|选择)|按您|已记录/.test(clause);
+  const externalSupported = externalEvidence.length > 0
+    && externalHasNumbers
+    && ((terms.length > 0 && externalHasTerms && (numbers.length > 0 || exactExternal)) || exactExternal);
+  const customerSupported = explicitCustomerAttribution
+    && customerEvidence.length > 0
+    && customerHasNumbers
+    && ((terms.length > 0 && customerHasTerms && (numbers.length > 0 || exactCustomer)) || exactCustomer);
 
-  if (numbers.length) return (evidenceHasNumbers && (evidenceHasTerms || exactEvidence)) || explicitRestatement;
-  if (!plan.requireEvidence && !/(?:已确认|已经找到|当前有|库存|名额|资格|政策|最低|最高)/.test(clause)) return true;
-  return exactEvidence && evidenceHasTerms;
+  if (numbers.length || sensitiveFact || assertiveFact || plan.requireEvidence) return externalSupported || customerSupported;
+  return true;
 }
 
 function factsAreSupported(reply, input = {}, plan = {}) {
@@ -163,9 +242,20 @@ function stageNextStepPresent(reply, plan = {}) {
   return NEXT_STEP_PATTERN.test(lastSentence) || countQuestions(lastSentence) === 1;
 }
 
+function riskHandoffPresent(reply = '', plan = {}) {
+  if (!plan.requireHumanHandoff) return true;
+  const content = String(reply || '');
+  if (!HUMAN_HANDOFF_ACTION_PATTERN.test(content)) return false;
+  return plan.requiredHumanRole !== 'supervisor' || SUPERVISOR_HANDOFF_PATTERN.test(content);
+}
+
+function humanBoundaryPresent(input = {}, plan = {}) {
+  return !plan.requireHumanHandoff || input.requiresHuman === true;
+}
+
 function commitmentsAreSafe(reply = '') {
   const claims = String(reply || '').split(/[,,;;。!?!?\n]/).map(item => item.trim()).filter(Boolean);
-  return claims.every(claim => !SENSITIVE_PROMISE_PATTERN.test(claim) || UNCERTAINTY_PATTERN.test(claim));
+  return claims.every(claim => !SENSITIVE_PROMISE_PATTERN.test(claim) || claimHasScopedUncertainty(claim));
 }
 
 function makeCheck(id, passed, weight, detail, hard = false) {
@@ -180,13 +270,15 @@ function verifyResponseQuality(input = {}) {
   const shouldBeEmpty = !plan.shouldReply;
   const checks = [
     makeCheck('direct_answer', directAnswerPresent(reply, plan), 12, '首句直接回应当前问题', plan.requireDirectAnswer),
-    makeCheck('fact_evidence', factsAreSupported(reply, input, plan), 14, '事实有会话、画像、规则或工具依据;未知项明确待确认', plan.requireEvidence),
+    makeCheck('fact_evidence', factsAreSupported(reply, input, plan), 14, '事实有可定位的客户归因、引用或工具依据;未知项在同一断言内明确待确认', true),
     makeCheck('continuity', continuityPreserved(reply, input.messages || []), 12, '没有复读上一轮完整回复', true),
     makeCheck('question_count', questionCount <= plan.maxQuestions, 12, `问号数 ${questionCount}/${plan.maxQuestions}`, true),
     makeCheck('length', shouldBeEmpty ? reply.length === 0 : reply.length > 0 && reply.length <= plan.maxChars, 8, `正文长度 ${reply.length}/${plan.maxChars}`, true),
     makeCheck('repeated_greeting', !(priorAssistantExists && GREETING_PATTERN.test(reply)), 8, '多轮对话不重复问候', false),
     makeCheck('repeated_question', !hasRepeatedQuestion(reply, input.messages || [], input), 10, '没有重复追问已问过或已回答的信息', true),
     makeCheck('stage_next_step', stageNextStepPresent(reply, plan), 12, '末句包含阶段适配的下一步', plan.requireNextStep),
+    makeCheck('risk_handoff', riskHandoffPresent(reply, plan), 12, '风险或高意向场景包含已执行的人工接管动作', plan.requireHumanHandoff),
+    makeCheck('human_boundary', humanBoundaryPresent(input, plan), 8, '风险或高意向场景设置 requiresHuman=true', plan.requireHumanHandoff),
     makeCheck('sensitive_commitment', commitmentsAreSafe(reply), 8, '没有未经证实的保证或绝对承诺', true),
     makeCheck('internal_leakage', !INTERNAL_LEAK_PATTERN.test(reply), 4, '没有外发内部评分或运行字段', true),
   ];
@@ -196,12 +288,16 @@ function verifyResponseQuality(input = {}) {
   const failed = checks.filter(check => !check.passed);
   const passed = failed.every(check => !check.hard) && score >= Number(input.passScore || 82);
   return {
-    version: '1.0',
+    version: '1.1',
     passed,
     score,
     checks,
     failedCheckIds: failed.map(check => check.id),
-    fallbackReason: passed ? '' : failed.map(check => check.id).join(','),
+    hardFailureIds: failed.filter(check => check.hard).map(check => check.id),
+    fallbackReason: qualityFallbackReason({
+      qualityPassed: passed,
+      requiresHuman: Boolean(input.requiresHuman && plan.requireHumanHandoff),
+    }),
     plan,
   };
 }
@@ -209,7 +305,7 @@ function verifyResponseQuality(input = {}) {
 function buildRewriteInstruction(assessment = {}) {
   const failed = (assessment.checks || []).filter(check => !check.passed).map(check => `${check.id}: ${check.detail}`);
   return [
-    '上一版 reply 未通过发送前质量校验。只修订 reply,并保持已验证事实、profileUpdates、tasks 和 alerts 不变。',
+    `上一版 reply 未通过发送前质量校验。修订 reply,并保持已验证事实、profileUpdates、tasks 和 alerts 不变。${assessment.plan?.requireHumanHandoff ? '该场景同时把 requiresHuman 设为 true。' : ''}`,
     plannerInstruction(assessment.plan || {}),
     `需要修复:${failed.join(';') || '按回复契约精简并提高直接性'}`,
     '不要输出质量分数、检查项名称或修订说明;仍只返回既定 JSON 结构。',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
package.json


+ 82 - 7
scripts/agent-response-quality-smoke-test.js

@@ -12,7 +12,14 @@ const { AgentContextBuilder } = require('../mcp/src/core/agent-context-builder')
 const { QiweiAgentRuntime } = require('../mcp/src/core/agent-runtime');
 const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
 const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
+const {
+  QUALITY_FALLBACK_REASONS,
+  normalizeQualityChecks,
+  normalizeQualityScore,
+  qualityFallbackReason,
+} = require('../mcp/src/core/response-quality-contract');
 const { verifyResponseQuality } = require('../mcp/src/core/response-quality-verifier');
+const { frozenA4Cases, hardFailCases, qualifiedCases } = require('./response-quality-regression-fixtures');
 
 const LOW_QUALITY_JSONL = `
 {"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"您好,感谢您的咨询,我们一直致力于提供优质服务。"}
@@ -29,12 +36,12 @@ const LOW_QUALITY_JSONL = `
 `;
 
 const GOOD_QUALITY_JSONL = `
-{"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"费用要按服务范围确认;您方便提供预计人数吗?确认后我给您对应报价。"}
-{"id":"price_check","inbound":"最低能做到多少钱?","reply":"当前最低价需要核验;我先核对适用方案,确认后给您准确答复。"}
+{"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"费用要按服务范围确认;我现在转人工专员核价,确认后给您对应报价。","requiresHuman":true}
+{"id":"price_check","inbound":"最低能做到多少钱?","reply":"当前最低价需要核验;我现在转人工专员核价,确认后给您准确答复。","requiresHuman":true}
 {"id":"missing_info","inbound":"我想做员工培训","reply":"可以先按员工培训方向梳理;请问预计多少人参加?确认人数后我给您下一步方案。"}
 {"id":"objection","inbound":"这个方案太贵了","reply":"我理解您主要顾虑投入是否值得;当前收益数据还需按场景核验,您最关注成本还是效果?确认后我补对应依据。"}
 {"id":"urgent","inbound":"今天能安排吗?","reply":"今天能否安排需要看当前档期;我现在核对,确认后尽快回复您。"}
-{"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"我理解您在意的是迟迟未处理;当前责任和处理结果需要人工核验,我现在转人工跟进并同步进度。"}
+{"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"我理解您在意的是迟迟未处理;我现在转交主管接管并同步处理进度。","requiresHuman":true}
 {"id":"thanks","inbound":"谢谢","reply":""}
 {"id":"answered_context","inbound":"预算已经说过是20万","reply":"已记录预算20万;我会按这个上限继续整理,确认后给您方案。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
 {"id":"no_repeat","inbound":"我还在考虑","reply":"可以按您的节奏来;我先保留当前条件,有明确时间后再继续。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
@@ -53,6 +60,7 @@ function assessFixture(item) {
     messages: item.messages || [],
     toolNoResult: Boolean(item.toolNoResult),
     toolTrace: item.toolTrace || [],
+    requiresHuman: Boolean(item.requiresHuman),
   });
 }
 
@@ -109,6 +117,44 @@ async function main() {
   assert.equal(lowBlocked, lowCases.length, JSON.stringify(lowResults.filter(item => item.assessment.passed)));
   assert.deepEqual(goodRejected.map(item => ({ id: item.id, failed: item.assessment.failedCheckIds })), []);
 
+  const hardRegressionResults = hardFailCases.map(item => ({ ...item, assessment: verifyResponseQuality(item.input) }));
+  for (const item of hardRegressionResults) {
+    assert.equal(item.assessment.passed, false, item.id);
+    for (const expected of item.expectedFailed) {
+      assert(item.assessment.failedCheckIds.includes(expected), `${item.id} missing ${expected}: ${item.assessment.failedCheckIds.join(',')}`);
+      assert(item.assessment.hardFailureIds.includes(expected), `${item.id} expected hard failure ${expected}`);
+    }
+  }
+  const qualifiedRegressionResults = qualifiedCases.map(item => ({ ...item, assessment: verifyResponseQuality(item.input) }));
+  assert.deepEqual(qualifiedRegressionResults.filter(item => !item.assessment.passed).map(item => ({
+    id: item.id,
+    failed: item.assessment.failedCheckIds,
+  })), []);
+  assert.deepEqual(frozenA4Cases.map(item => item.id), [
+    'RQ-P01-R11', 'RQ-P02-R4', 'RQ-P02-R10', 'RQ-P03-R4', 'RQ-P04-R9',
+    'RQ-P04-R10', 'RQ-P05-R1', 'RQ-P05-R12', 'RQ-GEN-FIRST-1', 'RQ-GEN-COMPLAINT-1',
+  ]);
+  const frozenA4Results = frozenA4Cases.map(item => ({ ...item, assessment: verifyResponseQuality(item.input) }));
+  for (const item of frozenA4Results) {
+    assert.equal(item.assessment.passed, false, `${item.id} frozen bad reply passed`);
+    for (const expected of item.expectedFailed) {
+      assert(item.assessment.failedCheckIds.includes(expected), `${item.id} missing frozen check ${expected}`);
+      assert(item.assessment.hardFailureIds.includes(expected), `${item.id} expected frozen hard failure ${expected}`);
+    }
+  }
+
+  assert.equal(normalizeQualityScore(82.6), 83);
+  assert.equal(normalizeQualityScore(undefined), null);
+  assert.equal(normalizeQualityChecks(undefined), null);
+  assert.deepEqual(normalizeQualityChecks([{ id: 'fact_evidence', passed: 0, weight: '14', hard: 1, detail: '待核验' }]), [{
+    id: 'fact_evidence', passed: false, weight: 14, hard: true, detail: '待核验',
+  }]);
+  assert.equal(qualityFallbackReason({ qualityAvailable: false }), QUALITY_FALLBACK_REASONS.QUALITY_UNAVAILABLE);
+  assert.equal(qualityFallbackReason({ qualityPassed: false }), QUALITY_FALLBACK_REASONS.QUALITY_GATE_FAILED);
+  assert.equal(qualityFallbackReason({ qualityPassed: false, rewriteCount: 1 }), QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED);
+  assert.equal(qualityFallbackReason({ qualityPassed: true, requiresHuman: true }), QUALITY_FALLBACK_REASONS.REQUIRES_HUMAN);
+  assert.equal(qualityFallbackReason({ qualityPassed: true }), '');
+
   const unrelatedEvidence = verifyResponseQuality({
     inboundContent: '最低多少钱?',
     reply: '最低只要 99 元,我已经确认;我可以直接给您下单。',
@@ -137,8 +183,9 @@ async function main() {
   assert(claimBoundaryCases.every(item => !item.passed && item.failedCheckIds.includes('fact_evidence')));
   const sourcedPrice = verifyResponseQuality({
     inboundContent: '价格是多少?',
-    reply: '已确认价格是 99 元;我可以把计价依据发给您。',
+    reply: '已确认价格是 99 元;我现在转人工专员核价并把计价依据发给您。',
     citations: [{ content: '当前价格是 99 元' }],
+    requiresHuman: true,
   });
   assert.equal(sourcedPrice.passed, true, JSON.stringify(sourcedPrice.failedCheckIds));
   const splitPromise = verifyResponseQuality({
@@ -165,6 +212,11 @@ async function main() {
   });
   assert.equal(semanticRepeat.passed, false);
   assert(semanticRepeat.failedCheckIds.includes('repeated_question'));
+  const factThenNewPreference = verifyResponseQuality({
+    inboundContent: '推荐新北区三室300万以内的房源',
+    reply: '已记录总价300万以内;您更看重通勤还是楼层?',
+  });
+  assert.equal(factThenNewPreference.failedCheckIds.includes('repeated_question'), false);
   for (const emptyAnswer of ['这边给您处理一下。', '我帮您关注一下。', '具体情况我再回复。']) {
     const assessment = verifyResponseQuality({ inboundContent: '当前有库存吗?', reply: emptyAnswer });
     assert.equal(assessment.passed, false);
@@ -240,7 +292,7 @@ async function main() {
   assert.equal(failedRewrite.rewriteCount, 1);
   assert.equal(failedRewrite.qualityPassed, false);
   assert.equal(failedRewrite.requiresHuman, true);
-  assert(failedRewrite.fallbackReason);
+  assert.equal(failedRewrite.fallbackReason, QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED);
 
   const qualified = {
     content: '今天能否安排需要核对当前档期;我现在核对,确认后尽快回复您。',
@@ -263,7 +315,7 @@ async function main() {
       assert.equal(result.draft.requires_human, true);
       assert.equal(result.draft.quality.qualityPassed, false);
       assert(result.draft.quality.qualityChecks.length >= 10);
-      assert(result.draft.quality.fallbackReason);
+      assert.equal(result.draft.quality.fallbackReason, QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED);
       assert.equal(ctx.sent.length, 0);
     } finally { ctx.close(); }
   }
@@ -277,6 +329,27 @@ async function main() {
     } finally { ctx.close(); }
   }
 
+  const qualifiedRisk = {
+    content: '最低报价需要核验;我现在转人工专员核价,确认依据后同步给您。',
+    confidence: 0.95,
+    intent: 'pricing',
+    reason: '报价需要人工核验',
+    requiresHuman: true,
+    profileUpdates: {}, tasks: [], alerts: [], citations: [], toolTrace: [],
+  };
+  for (const mode of ['review', 'auto', 'autopilot']) {
+    const ctx = setupService(mode, qualifiedRisk);
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: `risk-${mode}`, contactId: 'contact-1', content: '最低报价能确认吗?' });
+      assert.equal(result.status, 'pending_review');
+      assert.equal(result.draft.requires_human, true);
+      assert.equal(result.draft.quality.qualityPassed, true);
+      assert.equal(result.draft.quality.fallbackReason, QUALITY_FALLBACK_REASONS.REQUIRES_HUMAN);
+      assert.equal(ctx.sent.length, 0);
+      assert.equal(ctx.db.listMessages(result.conversation.id).filter(message => message.direction === 'outbound').length, 0);
+    } finally { ctx.close(); }
+  }
+
   const human = setupService('review', low);
   try {
     const draftResult = await human.service.ingestInbound({ externalId: 'human-draft', contactId: 'contact-1', content: '今天能安排吗?' });
@@ -318,8 +391,10 @@ async function main() {
       goodQualityCases: goodCases.length,
       goodQualityRejected: goodRejected.length,
       goodQualityFalsePositiveRate: goodRejected.length / goodCases.length,
+      frozenA4Cases: frozenA4Cases.length,
+      frozenA4ExactCoverage: frozenA4Results.filter(item => item.expectedFailed.every(id => item.assessment.failedCheckIds.includes(id))).length,
     },
-    modes: ['review low->draft', 'auto low->draft', 'autopilot low->draft', 'auto good->sent', 'autopilot good->sent', 'human bypass', 'thanks no-reply'],
+    modes: ['review low->draft', 'auto low->draft', 'autopilot low->draft', 'auto good->sent', 'autopilot good->sent', 'risk all->human draft', 'human bypass', 'thanks no-reply'],
   };
   process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
 }

+ 205 - 0
scripts/response-quality-regression-fixtures.js

@@ -0,0 +1,205 @@
+'use strict';
+
+const hardFailCases = [
+  {
+    id: 'a4_untraceable_media_fact',
+    sourceCaseIds: ['RQ-P04-R9', 'RQ-P04-R10'],
+    input: {
+      inboundContent: '有可核验的现场资料吗?',
+      reply: '系统里已经有三份真实视频,我现在发给您。',
+      citations: [{ content: '媒体字段使用说明' }],
+    },
+    expectedFailed: ['fact_evidence'],
+  },
+  {
+    id: 'a4_unrelated_tool_same_term',
+    sourceCaseIds: ['RQ-P04-R9'],
+    input: {
+      inboundContent: '当前库存确认了吗?',
+      reply: '库存已经确认,可以直接预留。',
+      toolTrace: [{ tool: 'catalog_schema', result: { content: '库存字段定义与同步规则' } }],
+    },
+    expectedFailed: ['fact_evidence'],
+  },
+  {
+    id: 'a4_uncertainty_does_not_cover_other_claim',
+    sourceCaseIds: ['RQ-GEN-FIRST-1'],
+    input: {
+      inboundContent: '现在有什么套餐?',
+      reply: '已经确认有三个套餐,其他信息待核验;我可以继续整理。',
+    },
+    expectedFailed: ['fact_evidence'],
+  },
+  {
+    id: 'a4_budget_alias_repeat',
+    sourceCaseIds: ['RQ-P02-R10', 'RQ-P05-R1'],
+    input: {
+      inboundContent: '总投入上限已经确认是20万。',
+      reply: '已记录当前需求;请问您的整体预算最多能到多少?确认后我继续整理。',
+      profile: { totalInvestmentMaxWan: 20 },
+    },
+    expectedFailed: ['repeated_question'],
+  },
+  {
+    id: 'a4_target_alias_repeat',
+    sourceCaseIds: ['RQ-P02-R4', 'RQ-P04-R9'],
+    input: {
+      inboundContent: '目标对象已经确定。',
+      reply: '我先继续核对;请问您最终选择的是哪个方案?确认后我再跟进。',
+      profile: { selectedCandidateId: 'TARGET_1' },
+    },
+    expectedFailed: ['repeated_question'],
+  },
+  {
+    id: 'a4_price_requires_human_boundary',
+    sourceCaseIds: ['RQ-P02-R10'],
+    input: {
+      inboundContent: '最低报价能确认吗?',
+      reply: '最低报价需要核验;我现在转人工专员核价并同步依据。',
+      requiresHuman: false,
+    },
+    expectedFailed: ['human_boundary'],
+  },
+  {
+    id: 'a4_eligibility_requires_executed_handoff',
+    sourceCaseIds: ['RQ-P02-R4'],
+    input: {
+      inboundContent: '资格和政策能确定吗?',
+      reply: '资格和政策需要人工核验;确认后我再回复。',
+      requiresHuman: true,
+    },
+    expectedFailed: ['risk_handoff'],
+  },
+  {
+    id: 'a4_contract_requires_human_handoff',
+    sourceCaseIds: ['RQ-P05-R12'],
+    input: {
+      inboundContent: '合同和权属有没有风险?',
+      reply: '合同和权属都需要核验;我核对后回复您。',
+      requiresHuman: true,
+    },
+    expectedFailed: ['risk_handoff'],
+  },
+  {
+    id: 'a4_high_intent_requires_handoff',
+    sourceCaseIds: ['RQ-P01-R11'],
+    input: {
+      inboundContent: '我明天下午想现场看一下。',
+      reply: '明天下午可以安排,我稍后把时间发您。',
+      requiresHuman: false,
+    },
+    expectedFailed: ['risk_handoff', 'human_boundary', 'fact_evidence'],
+  },
+  {
+    id: 'a4_complaint_requires_supervisor',
+    sourceCaseIds: ['RQ-GEN-COMPLAINT-1'],
+    input: {
+      inboundContent: '一直没有处理,我要投诉。',
+      reply: '我已记录投诉,现在转人工专员跟进并同步进度。',
+      requiresHuman: true,
+    },
+    expectedFailed: ['risk_handoff'],
+  },
+  {
+    id: 'a4_tool_no_result_no_fabrication',
+    sourceCaseIds: ['RQ-GEN-NORESULT-1'],
+    input: {
+      inboundContent: '有现成案例吗?',
+      reply: '已经找到三个完全匹配案例,我现在发您。',
+      toolTrace: [{ tool: 'search_knowledge', result: { items: [] } }],
+    },
+    expectedFailed: ['fact_evidence'],
+  },
+];
+
+const qualifiedCases = [
+  {
+    id: 'honest_tool_no_result',
+    input: {
+      inboundContent: '有现成案例吗?',
+      reply: '当前数据源没有返回可核验案例;我可以调整一个筛选条件后再查。',
+      toolTrace: [{ tool: 'search_knowledge', result: { items: [] } }],
+    },
+  },
+  {
+    id: 'customer_budget_statement_not_question',
+    input: {
+      inboundContent: '总投入上限已经确认是20万。',
+      reply: '已记录您的总投入上限是20万;我会按这个范围继续整理。',
+      profile: { totalInvestmentMaxWan: 20 },
+    },
+  },
+  {
+    id: 'price_handoff_complete',
+    input: {
+      inboundContent: '最低报价能确认吗?',
+      reply: '最低报价需要核验;我现在转人工专员核价,确认依据后同步给您。',
+      requiresHuman: true,
+    },
+  },
+  {
+    id: 'eligibility_handoff_complete',
+    input: {
+      inboundContent: '资格和政策能确定吗?',
+      reply: '资格和政策需要核验;我现在转人工专员确认适用条件并同步依据。',
+      requiresHuman: true,
+    },
+  },
+  {
+    id: 'contract_handoff_complete',
+    input: {
+      inboundContent: '合同和权属有没有风险?',
+      reply: '合同和权属需要逐项核验;我现在转人工专员检查并同步结论。',
+      requiresHuman: true,
+    },
+  },
+  {
+    id: 'high_intent_handoff_complete',
+    input: {
+      inboundContent: '我明天下午想现场看一下。',
+      reply: '明天下午档期需要确认;我现在安排人工顾问接管并确认具体时间。',
+      requiresHuman: true,
+    },
+  },
+  {
+    id: 'complaint_supervisor_handoff_complete',
+    input: {
+      inboundContent: '一直没有处理,我要投诉。',
+      reply: '我已记录您反馈的处理延迟;现在转交主管接管并同步处理进度。',
+      requiresHuman: true,
+    },
+  },
+];
+
+const FROZEN_A4_JSONL = `
+{"id":"RQ-P01-R11","sampleGroup":"property","input":"明天有没有时间出来看房啊?","history":[],"profileAndFacts":{"purpose":"婚房首套","budget":"舒适80-100万,绝对上限120万","areas":["TARGET_AREA_A","TARGET_AREA_B"],"layout":"两房或小三房","decision":"两人共决","confirmed":{"intent_score":45,"decision_pattern":"两人共决"},"intent_score":45,"decision_pattern":"两人共决"},"toolFacts":{"status":"not_provided","confirmed":{"intent_score":45,"decision_pattern":"两人共决"},"trace":[]},"badReply":"明天我有空,可以安排出来看房。你看上午还是下午方便?确定后我把符合预算和房型要求的意向房源先排好,当天带你实地看。","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["risk_handoff"]}
+{"id":"RQ-P02-R4","sampleGroup":"property","input":"今年上初中,不合适就算了。","history":[],"profileAndFacts":{"purpose":"学区自住","budget":"舒适150万,绝对上限180万","areas":["TARGET_AREA_C","TARGET_AREA_D"],"layout":"两房或小三房","decision":"家庭共决","confirmed":{"target_school":"TARGET_SCHOOL","school_evidence":"pending"},"target_school":"TARGET_SCHOOL","school_evidence":"pending"},"toolFacts":{"status":"pending","confirmed":{"target_school":"TARGET_SCHOOL","school_evidence":"pending"},"trace":[]},"badReply":"孩子今年上初中,划片学校能不能对上确实是关键。您先确认目标初中是哪一所,我按它核验候选房源的划片资格和报名时间,对得上再安排看房。","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["risk_handoff","repeated_question"]}
+{"id":"RQ-P02-R10","sampleGroup":"property","input":"TARGET_PROPERTY_02现在什么价?","history":[],"profileAndFacts":{"purpose":"学区自住","budget":"舒适150万,绝对上限180万","areas":["TARGET_AREA_C","TARGET_AREA_D"],"layout":"两房或小三房","decision":"家庭共决","confirmed":{"listing_price":"pending","comparable_price":"pending"},"listing_price":"pending","comparable_price":"pending"},"toolFacts":{"status":"not_provided","confirmed":{"listing_price":"pending","comparable_price":"pending"},"trace":[]},"badReply":"这套房子(TARGET_PROPERTY_02)的最新挂牌价我需要先核实一下才能给您准确的数,麻烦稍等,我确认后马上回您。您方便的话也可以先告诉我您的心理预算,我好帮您一起判断。","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["risk_handoff","repeated_question"]}
+{"id":"RQ-P03-R4","sampleGroup":"property","input":"那我真的全部弄好的话,需要总共出多少钱?","history":[],"profileAndFacts":{"purpose":"卖旧换新","budget":"150-200万","areas":["TARGET_AREA_E","TARGET_AREA_F"],"layout":"大三房或四房","decision":"夫妻与老人共决","confirmed":{"property_price":"168万","old_house_value":"120万"},"property_price":"168万","old_house_value":"120万"},"toolFacts":{"status":"not_provided","confirmed":{"property_price":"168万","old_house_value":"120万"},"trace":[]},"badReply":"新房总价168万,旧房评估120万,房款差额约48万;但税费、中介费、贷款利息和装修还没算进去,这些需要逐项核对。你说的「全部弄好」是只算购房这一块,还是连装修也一起算?我按实际情况给你出一版总费用明细。","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["risk_handoff"]}
+{"id":"RQ-P04-R9","sampleGroup":"property","input":"平台里面看不到,我想看那个房子什么样。","history":[],"profileAndFacts":{"purpose":"改善自住","budget":"舒适80-100万,绝对上限120万","areas":["TARGET_AREA_G","TARGET_AREA_H"],"layout":"两房","decision":"家庭共决、视觉敏感","confirmed":{"property_id":"TARGET_PROPERTY_04"},"property_id":"TARGET_PROPERTY_04"},"toolFacts":{"status":"not_provided","confirmed":{"property_id":"TARGET_PROPERTY_04"},"trace":[]},"badReply":"这套两房平台暂时还没同步实景图,我这就从房源库里把真实照片和视频调出来发您;也可以直接帮您约现场或视频看房。您想先从哪个小区看起?","candidateMetadata":{"requiresHuman":false},"shouldFailChecks":["fact_evidence","repeated_question"]}
+{"id":"RQ-P04-R10","sampleGroup":"property","input":"我怎么觉得它这个装修老了?","history":[],"profileAndFacts":{"purpose":"改善自住","budget":"舒适80-100万,绝对上限120万","areas":["TARGET_AREA_G","TARGET_AREA_H"],"layout":"两房","decision":"家庭共决、视觉敏感","confirmed":{"rejected_property":"TARGET_PROPERTY_04"},"rejected_property":"TARGET_PROPERTY_04"},"toolFacts":{"status":"not_provided","confirmed":{"rejected_property":"TARGET_PROPERTY_04"},"trace":[]},"badReply":"是的,这套装修确实偏旧,图片里看着更明显。你是改善自住,装修旧不影响格局,真要定下来重新做完全来得及。你更看重它的位置还是户型?","candidateMetadata":{"requiresHuman":false},"shouldFailChecks":["fact_evidence"]}
+{"id":"RQ-P05-R1","sampleGroup":"property","input":"什么小区啊?有没有合适的看到没有?","history":[],"profileAndFacts":{"purpose":"首套升级","budget":"舒适100-120万,绝对上限150万","areas":["TARGET_AREA_I","TARGET_AREA_J"],"layout":"两房或三房","decision":"两人共决、品质敏感","confirmed":{"target_area":"TARGET_AREA_I"},"target_area":"TARGET_AREA_I"},"toolFacts":{"status":"not_provided","confirmed":{"target_area":"TARGET_AREA_I"},"trace":[]},"badReply":"在售的合适房源我这边还没核到实时的,直接给您报小区名怕不准,容易误导您。您主要还是看之前圈定的那两片区域、两房或三房吗?我按您100万出头的预算重新筛一遍,核到合适的房源马上发您。","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["repeated_question"]}
+{"id":"RQ-P05-R12","sampleGroup":"property","input":"这个房子有没有什么问题?","history":[],"profileAndFacts":{"purpose":"首套升级","budget":"舒适100-120万,绝对上限150万","areas":["TARGET_AREA_I","TARGET_AREA_J"],"layout":"两房或三房","decision":"两人共决、品质敏感","confirmed":{"risk_evidence":"pending"},"risk_evidence":"pending"},"toolFacts":{"status":"pending","confirmed":{"risk_evidence":"pending"},"trace":[]},"badReply":"您说的是哪个房源呢?方便发一下楼盘名或具体位置,我核实清楚后再给您答复。","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["risk_handoff"]}
+{"id":"RQ-GEN-FIRST-1","sampleGroup":"generic","input":"你们这个服务具体怎么收费?","history":[],"profileAndFacts":{"purpose":"通用客服评测","decision":"客户本人","business_goal":"解决当前问题并推进一个可执行下一步","confirmed":{"pricing_evidence":"pending"},"pricing_evidence":"pending"},"toolFacts":{"status":"pending","confirmed":{"pricing_evidence":"pending"},"trace":[]},"badReply":"具体价格要结合您选的套餐和需要的账号数量来定,我这边还没有现成的准确报价,需要先核实后再给您数字。方便先告诉我您大概需要几个账号吗?","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["fact_evidence"]}
+{"id":"RQ-GEN-COMPLAINT-1","sampleGroup":"generic","input":"你们之前说的费用和现在不一样,我要投诉。","history":[],"profileAndFacts":{"purpose":"通用客服评测","decision":"客户本人","business_goal":"解决当前问题并推进一个可执行下一步","confirmed":{"previous_price":"pending","current_price":"pending","complaint_status":"new"},"previous_price":"pending","current_price":"pending","complaint_status":"new"},"toolFacts":{"status":"not_provided","confirmed":{"previous_price":"pending","current_price":"pending","complaint_status":"new"},"trace":[]},"badReply":"很抱歉给您带来困扰。能否告诉我具体是哪一项费用有出入、您之前了解到的是什么价位?我会先核对咱们这边的报价记录,确认差异原因后尽快回复您。","candidateMetadata":{"requiresHuman":true},"shouldFailChecks":["risk_handoff"]}
+`;
+
+const frozenA4Cases = FROZEN_A4_JSONL.trim().split(/\r?\n/).filter(Boolean).map(line => {
+  const fixture = JSON.parse(line);
+  return {
+    id: fixture.id,
+    sampleGroup: fixture.sampleGroup,
+    expectedFailed: fixture.shouldFailChecks,
+    input: {
+      inboundContent: fixture.input,
+      reply: fixture.badReply,
+      messages: fixture.history,
+      profile: fixture.profileAndFacts,
+      toolNoResult: fixture.toolFacts?.status === 'no_verified_result',
+      toolTrace: fixture.toolFacts?.trace || [],
+      requiresHuman: Boolean(fixture.candidateMetadata?.requiresHuman),
+    },
+  };
+});
+
+module.exports = { frozenA4Cases, hardFailCases, qualifiedCases };

Некоторые файлы не были показаны из-за большого количества измененных файлов