فهرست منبع

feat: add agent response quality gate

gangvy 1 ماه پیش
والد
کامیت
f46139c73b

+ 4 - 0
.env.example

@@ -89,6 +89,10 @@ AGENT_MODEL=
 AGENT_MAX_TOOL_ROUNDS=4
 QIWEI_AGENT_PROMPT_CHAR_LIMIT=16000
 QIWEI_AGENT_SYSTEM_PROMPT_CHAR_LIMIT=12000
+QIWEI_AGENT_QUALITY_PASS_SCORE=82
+# 可选:按项目覆盖通用客服目标和阶段策略;不要在通用包中硬编码行业目标。
+QIWEI_AGENT_BUSINESS_GOAL=
+QIWEI_AGENT_STAGE_PLAYBOOK=
 
 # 推荐:直接使用客户项目中的 Claude Code + Fmode 配置生成草稿。
 CLAUDE_CODE_EXECUTABLE=

+ 1 - 0
docs/OUTPUT-STANDARD.md

@@ -100,6 +100,7 @@ docs/
 ├── OUTPUT-STANDARD.md   # 本标准
 ├── specs/               # 设计与需求规格(人工撰写,kebab-case.md)
 ├── guides/              # 使用指南、课程讲义
+├── research/            # 可复现的研究、基线与评测说明
 └── generated/           # 脚本生成的文档,文件头必须注明生成脚本与时间,不手工编辑
 ```
 

+ 111 - 0
docs/research/qiwei-reply-quality-evolva.md

@@ -0,0 +1,111 @@
+# 企微回复质量闭环:Evolva 工程机制借鉴与实现记录
+
+## 1. 范围与结论
+
+本轮借鉴的是 Evolva 的工程机制,不是客服话术。已确认的可迁移机制包括:按权重与预算注入上下文、最终输出前验证、证据不足时限次恢复、检查结果与过程留痕。Evolva 的质量基线主要验证工具路由、上下文/记忆检索、验证门、恢复和 checkpoint,不能据此宣称其拥有更自然的企微客服表达。
+
+本项目的“首句直接回答、必要依据、最多一个高信息增益问题、阶段下一步”等规则,是结合企微沟通场景和现有业务反馈定义的通用回复契约。房产带看、具体房源、学区、产权等行业目标不进入通用包;项目可通过 `QIWEI_AGENT_BUSINESS_GOAL` 和 `QIWEI_AGENT_STAGE_PLAYBOOK` 加载业务覆盖层。
+
+## 2. 改造前基线
+
+2026-08-05 使用真实 `AgentWorkbenchService`、内存发送桩和 11 类固定低质量候选执行 `autopilot` 基线,不调用真实企微、不写业务远端数据。
+
+| 指标 | 改造前 |
+| --- | ---: |
+| 低质量候选 | 11 |
+| 被直接外发 | 11 |
+| 被阻断 | 0 |
+| 低质量拦截率 | 0% |
+
+覆盖场景:首次咨询空泛开场、价格绝对承诺、多问题盘问、异议未回应、催促空话、投诉过度承诺、纯致谢续聊、已回答信息重复追问、字面重复追问、工具无结果却编造结果、仅回复“好的”。
+
+原链路已有能力只有:结构化 JSON、客户原话 grounding 降级、风险词降低置信度、`requiresHuman`、纯确认/致谢不回复。缺少 deterministic verifier、一次修订恢复、发送前统一闸门和结构化质量审计,`autopilot` 会直接发送非空回复。
+
+## 3. 通用回复契约
+
+回复内容按场景启用,不机械输出固定四段:
+
+1. 首句实质回应当前问题、顾虑或投诉,不用重复问候和处理过程占位。
+2. 随后只补充会话、画像、规则或工具事实能支持的必要依据;未知事实明确待确认。
+3. 最多追问一个信息增益最高的问题,不重复已经问过或已经回答的字段。
+4. 末句给出与当前阶段相符的可执行下一步。
+5. 纯确认或致谢且没有新问题、风险或动作时不回复。
+
+Planner 根据 acknowledgement、direct question、first consultation、continuation、objection、urgent follow-up、complaint、tool no result 等场景选择“直接回答、顾虑、已知事实、待确认风险、下一步”内容块。
+
+## 4. 确定性验证与恢复
+
+Verifier 输出 10 项可解释检查:
+
+| 检查 | 说明 |
+| --- | --- |
+| `direct_answer` | 首句是否为实质回应;异议、投诉、无结果有场景专用规则 |
+| `fact_evidence` | 按分句核验数字、价格、库存、资格等事实;证据必须与具体断言匹配 |
+| `continuity` | 不复读上一轮完整回复 |
+| `question_count` | 同时计算问号、疑问意图和独立字段,最多一个问题 |
+| `length` | Agent 正文非空且不超过通用上限;致谢场景必须为空 |
+| `repeated_greeting` | 多轮会话不重复问候 |
+| `repeated_question` | 拦截字面重复及预算、时间、人数等已回答字段的同义追问 |
+| `stage_next_step` | 末句包含当前阶段可执行动作 |
+| `sensitive_commitment` | 按分句阻断保证、绝对、最低价等未经证实承诺 |
+| `internal_leakage` | 不外发评分、检查项和内部运行字段 |
+
+首次生成不通过时,运行时根据失败检查最多修订一次。再次不通过或修订调用失败时设置 `requiresHuman=true`,并返回 `qualityScore`、`qualityChecks`、`rewriteCount`、`fallbackReason` 供内部审计。服务层再次验证 Agent 内容,`auto` 与 `autopilot` 均不能绕过;人工 `manualSend` 和人工编辑后 `approveDraft` 仍只执行原有白名单、私聊和长度边界。
+
+## 5. 上下文权重
+
+Claude 业务提示按请求构建并分别限额:
+
+| 分区 | 提示预算 |
+| --- | ---: |
+| 执行约束与回复契约 | 31% |
+| 最近有效会话 | 32% |
+| 客户画像与证据 | 15% |
+| 未完成问题/待办/风险 | 12% |
+| 工具事实 | 10% |
+
+系统上下文按“权威边界 30%、相关记忆 20%、受控业务规则 50%”限额。权威顺序固定为:最近有效会话与本轮工具事实 > 带证据画像 > 未完成问题 > 受控规则 > 相关记忆 > 旧模型 Session。预算报告可由单次 `build*Result()` 返回用于测试,不进入共享并发业务审计。
+
+## 6. A2 专项结果
+
+专项入口:`npm run agent:quality-smoke`。
+
+| 指标 | 改造前 | 当前 |
+| --- | ---: | ---: |
+| 低质量样本 | 11 | 11 |
+| 低质量拦截 | 0 | 11 |
+| 低质量拦截率 | 0% | 100% |
+| 合格样本 | 未设门禁 | 11 |
+| 合格样本误杀 | 未设门禁 | 0 |
+| 合格样本误杀率 | 未设门禁 | 0% |
+
+专项还覆盖:无关 citation + 虚构价格、无关 tool result + 库存已确认、分句追加“其他待核验”、真实同值引用、空泛直接回答、一个问号包含三个字段、已回答预算的同义重复追问、一次修订成功/失败、请求绑定预算报告,以及 review/auto/autopilot/人工/纯致谢模式矩阵。
+
+现有 `agent:smoke` 同步更新并通过 48 项。上述 11+11 是确定性门禁回归,不等同于真实客服自然度盲测。
+
+## 7. A3 独立评测资产
+
+业务评测副本已冻结 41 条匿名场景,覆盖 5 类画像各 8 条及 1 条通用投诉;每条均包含匿名输入、必要历史/画像/工具事实、场景标签、golden constraints 和 8 维期望。量表维度为直接回答、相关性、事实可追溯、自然度、上下文连续、信息增益、行动推进、风险边界,通过线为 75/100,并设置核心维度下限与硬失败。
+
+生成校验结果:41 个 ID 唯一且与 A4 contract 顺序一致,8 维字段完整,12 个文件严格 UTF-8 解码通过,手机号、wxid、token、邮箱和长数字 ID 扫描 0 命中。
+
+证据边界:4 条匿名历史样本全部来自已拒绝草稿,其中 3 条输入相同,只能观察失败类型,不能估计旧版总体通过率或准确率。41 条旧式回复均标记为 `synthetic_fixture`,只验证评分管线和 A4 接口,不计入旧版实测胜率。
+
+A4 当前只完成发送门禁层配对:旧版低质量拦截 0/11,新版 11/11;新版合格样本误杀 0/11。41 条回复文本盲测状态为 `awaiting_candidates`,需要冻结的旧版/新版同 ID 响应对后才计算自然度、信息增益等 8 维胜率。
+
+## 8. 审计字段
+
+- `qualityScore`: 0-100 的内部确定性检查得分。
+- `qualityChecks`: 检查 ID、是否通过、权重、是否硬失败和简要说明。
+- `rewriteCount`: 0 或 1。
+- `fallbackReason`: 最终失败检查 ID 列表;修订调用失败追加 `rewrite_failed`。
+
+这些字段写入内部返回、工具轨迹和审计详情,不进入客户回复。
+
+## 9. 当前边界
+
+- 未发布 npm。
+- 未调用真实企微发送。
+- 未写业务远端数据。
+- 未迁入房产 Provider、云函数或房产业务目标。
+- 尚待 41 组冻结的旧版/新版同 ID 回复完成 8 维文本盲测、退化项和典型差异。

+ 9 - 1
knowledge/playbooks.md

@@ -10,7 +10,15 @@
 
 ## 异议处理
 
-先复述客户顾虑,再说明已有证据和未知信息。未知信息交给人工或业务工具核验,不使用话术掩盖不确定性。
+按场景启用“复述顾虑 → 已知证据/未知信息 → 一个关键问题 → 可执行下一步”。不要机械显示四段标题;未知信息交给人工或业务工具核验,不使用话术掩盖不确定性。
+
+## 催促与投诉
+
+催促先直接说明当前已知进度和缺口,再给明确的核验或回访动作。投诉先确认客户核心诉求和当前事实,涉及承诺、退款、合同或责任判断时转人工,不用绝对承诺安抚。
+
+## 工具无结果
+
+明确说明当前数据源没有返回可验证结果,区分“没有结果”和“事实不存在”。最多补问一个会改变检索结果的条件,或给出人工核验的下一步。
 
 ## 跟进节奏
 

+ 11 - 2
knowledge/rules.md

@@ -8,10 +8,19 @@
 
 ## 沟通方式
 
-- 使用简洁、自然、专业的中文,每次优先控制在 2 到 5 句话。
-- 先回答客户当前问题,再追问最关键的一个缺失信息。
+- 使用简洁、自然、专业的微信口吻。普通回复优先 1 到 3 句话、50 到 80 字;复杂风险说明最多 5 句话。
+- 首句直接回答客户当前问题,不用重复问候、解释性铺垫或“我正在处理”之类空泛过程语。
+- 只补充必要依据,再追问信息增益最高的一个缺失信息;整条回复问号最多 1 个。
+- 末句给与当前阶段匹配的可执行下一步,不做未经证实的保证或绝对承诺。
 - 不连续轰炸客户,不在客户明确结束对话后继续营销。
 
+## 发送前质量校验
+
+- Agent 回复必须经过直接性、事实证据、上下文连续、问题数、长度、重复问候、重复追问、阶段下一步和敏感承诺检查。
+- 未通过时最多修订一次;再次未通过必须转人工审核,全自动接管也不能跳过。
+- `qualityScore`、检查项、修订次数和降级原因仅用于内部审计,不得发给客户。
+- 纯确认或致谢且没有新问题、风险或待办时保持不回复。
+
 ## 人工审核
 
 - 价格承诺、合同、资质合规、金融方案、投诉、退款和敏感个人信息必须人工审核。

+ 101 - 20
mcp/src/core/agent-context-builder.js

@@ -31,22 +31,48 @@ class AgentContextBuilder {
     this.knowledge = knowledge;
     this.promptCharLimit = Math.max(2000, Number(config.promptCharLimit || 16000));
     this.systemCharLimit = Math.max(2000, Number(config.systemPromptCharLimit || 12000));
+    this.businessGoal = String(config.businessGoal || '').trim();
+    this.stagePlaybook = String(config.stagePlaybook || '').trim();
+    this.lastBudgetReport = { prompt: {}, system: {} };
   }
 
   buildClaudePrompt(messages, context = {}) {
-    if (context.directPrompt) return clip(String(context.directPrompt), this.promptCharLimit);
+    return this.buildClaudePromptResult(messages, context).text;
+  }
+
+  buildClaudePromptResult(messages, context = {}) {
+    if (context.directPrompt && !context.qualityRepair) {
+      const text = clip(String(context.directPrompt), this.promptCharLimit);
+      const prompt = { directPrompt: { budget: this.promptCharLimit, used: text.length }, total: { budget: this.promptCharLimit, used: text.length } };
+      this.lastBudgetReport.prompt = prompt;
+      return { text, budgetReport: { prompt: JSON.parse(JSON.stringify(prompt)) } };
+    }
     const autopilot = context.conversation?.mode === 'autopilot';
-    const history = selectAuthoritativeHistory(messages, 10)
-      .map(message => `${message.role === 'assistant' ? '客服' : message.role === 'tool' ? '工具' : '客户'}:${String(message.content || '').slice(0, 1200)}`)
+    const authoritativeMessages = selectAuthoritativeHistory(messages, 10);
+    const history = authoritativeMessages
+      .filter(message => message.role !== 'tool')
+      .map(message => `${message.role === 'assistant' ? '客服' : '客户'}:${String(message.content || '').slice(0, 1200)}`)
       .join('\n\n');
+    const toolFacts = [
+      ...authoritativeMessages.filter(message => message.role === 'tool').map(message => String(message.content || '')),
+      ...(Array.isArray(context.toolFacts) ? context.toolFacts : context.toolFacts ? [context.toolFacts] : []),
+    ].join('\n');
     const profile = context.profile?.profile || context.profile || {};
+    const profileEvidence = profile.__evidence || {};
     const intelligence = context.customerIntelligence || {};
     const activeTasks = (intelligence.tasks || []).filter(item => ['open', 'in_progress'].includes(item.status));
     const activeAlerts = (intelligence.alerts || []).filter(item => ['open', 'acknowledged'].includes(item.status));
-    const instructionBudget = Math.floor(this.promptCharLimit * 0.43);
-    const stateBudget = Math.floor(this.promptCharLimit * 0.27);
-    const historyBudget = this.promptCharLimit - instructionBudget - stateBudget - 32;
+    const separatorReserve = 160;
+    const usable = this.promptCharLimit - separatorReserve;
+    const budgets = {
+      instructions: Math.floor(usable * 0.31),
+      recentConversation: Math.floor(usable * 0.32),
+      profileEvidence: Math.floor(usable * 0.15),
+      unfinishedQuestions: Math.floor(usable * 0.12),
+      toolFacts: Math.floor(usable * 0.10),
+    };
     const instructions = clip([
+      context.directPrompt || '',
       '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库和规则。',
       autopilot
         ? '不要修改文件或自行调用发送工具,不要编造业务事实。只返回结构化 JSON,系统会通过全自动接管链路直接发送 reply。'
@@ -56,32 +82,87 @@ class AgentContextBuilder {
       '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断数量、预算用途或决策意图等高影响需求,必须先向客户确认。',
       'reply 使用适合企微的简洁纯文本,不输出 Markdown 星号、标题语法或内部推理。客户给出明确事实时,在 profileUpdates 中同步记录;不明确的字段标记待确认。',
       '如果最新消息只是“收到、好的、明白了、谢谢”等纯确认或致谢,且没有新的问题或待确认动作,reply 可以返回空字符串。',
+      '回复质量契约:首句直接回答;随后只补必要依据;最多追问一个信息增益最高的问题;末句给阶段适配的下一步。为什么适合、已知事实、待确认风险、下一步只按场景启用,禁止机械四段式。',
       '同时输出内部 tasks 和 alerts:tasks 只记录明确可执行的跟进动作,alerts 只记录有证据的高意向、时效、矛盾、投诉或人工接管风险;每项 evidence 必须来自本轮有效会话。没有就输出空数组。',
-    ].join('\n'), instructionBudget);
-    const state = clip([
-      `当前客户画像:${safeJson(profile)}`,
-      `当前未完成待办:${safeJson(activeTasks.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, reason: item.reason })))}`,
-      `当前未解决预警:${safeJson(activeAlerts.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, detail: item.detail })))}`,
+      context.qualityRepair ? `【仅本轮修订指令】${String(context.qualityRepair)}` : '',
+      `可配置业务目标:${String(context.businessGoal || this.businessGoal || '未配置;按通用客服目标推进')}`,
+      `可配置阶段策略:${String(context.stagePlaybook || this.stagePlaybook || '未配置;按当前问题给出最小可执行下一步')}`,
+    ].filter(Boolean).join('\n'), budgets.instructions);
+    const profileBlock = clip([
+      `当前客户画像事实:${safeJson(Object.fromEntries(Object.entries(profile).filter(([key]) => key !== '__evidence')))}`,
+      `画像证据索引:${safeJson(profileEvidence)}`,
+    ].join('\n'), budgets.profileEvidence);
+    const unfinishedBlock = clip([
+      `当前未完成问题/待办:${safeJson(activeTasks.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, reason: item.reason })))}`,
+      `当前未解决风险:${safeJson(activeAlerts.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, detail: item.detail })))}`,
       '不要重复创建语义相同的待办或预警;如需引用已有项,沿用其 key。画像事实变化时,优先更新已有业务项。',
-    ].join('\n'), stateBudget);
-    const authoritative = clipTail(history || `客户:${String(context.inboundContent || '')}`, historyBudget);
-    return clip(`${instructions}\n\n${state}\n\n本轮有效会话:\n${authoritative}`, this.promptCharLimit);
+    ].join('\n'), budgets.unfinishedQuestions);
+    const authoritative = clipTail(history || `客户:${String(context.inboundContent || '')}`, budgets.recentConversation);
+    const tools = clip(toolFacts || '本轮尚无工具事实;不得据此编造外部结论。', budgets.toolFacts);
+    const prompt = clip([
+      `【执行约束】\n${instructions}`,
+      `【最近有效会话(最高优先级)】\n${authoritative}`,
+      `【客户画像证据】\n${profileBlock}`,
+      `【未完成问题】\n${unfinishedBlock}`,
+      `【工具事实】\n${tools}`,
+    ].join('\n\n'), this.promptCharLimit);
+    const promptReport = Object.fromEntries(Object.entries(budgets).map(([key, budget]) => ({
+      key,
+      value: {
+        budget,
+        used: key === 'instructions' ? instructions.length
+          : key === 'recentConversation' ? authoritative.length
+            : key === 'profileEvidence' ? profileBlock.length
+              : key === 'unfinishedQuestions' ? unfinishedBlock.length
+                : tools.length,
+      },
+    })).map(item => [item.key, item.value]));
+    promptReport.total = { budget: this.promptCharLimit, used: prompt.length };
+    this.lastBudgetReport.prompt = promptReport;
+    return { text: prompt, budgetReport: { prompt: JSON.parse(JSON.stringify(promptReport)) } };
   }
 
   buildSystemContext({ channelType = 'private', customerIntelligence = {}, memoryContext = {} } = {}) {
+    return this.buildSystemContextResult({ channelType, customerIntelligence, memoryContext }).text;
+  }
+
+  buildSystemContextResult({ channelType = 'private', customerIntelligence = {}, memoryContext = {} } = {}) {
     const projectContext = typeof this.knowledge?.contextText === 'function' ? this.knowledge.contextText() : '';
-    return clip([
+    const separatorReserve = 48;
+    const usable = this.systemCharLimit - separatorReserve;
+    const budgets = {
+      authority: Math.floor(usable * 0.30),
+      memory: Math.floor(usable * 0.20),
+      businessRules: Math.floor(usable * 0.50),
+    };
+    const authority = clip([
       channelType === 'group'
         ? '你是企业微信群聊客服 Agent。你需要理解不同群成员的发言,再生成一条供人工审核的群内回复草稿。'
         : '你是企业微信客户服务 Agent。你需要自主判断是否检索知识或客户画像,再生成回复草稿。',
       '严禁编造业务事实。工具没有证据时明确说需要确认。敏感或低置信内容标记 requiresHuman=true。',
       '最终必须只输出 JSON,包含 reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks 和 alerts。',
       'reason 用于内部监管台,简要说明依据和不确定性,不要发送给客户。',
-      memoryContext.promptText || '',
-      '',
-      '【项目级人格、上下文与规则】以下内容来自受控知识库文件,不依赖当前 Claude Session:',
-      projectContext,
-    ].join('\n'), this.systemCharLimit);
+      '权威顺序:本轮最近有效会话和本轮工具事实 > 带证据的当前画像 > 未完成问题 > 受控业务规则 > 相关记忆 > 旧模型 Session。旧 Session 原话不得覆盖本轮权威上下文。',
+    ].join('\n'), budgets.authority);
+    const memory = clip(memoryContext.promptText || '本轮没有相关持久记忆。', budgets.memory);
+    const businessRules = clip(projectContext || '未加载额外业务规则。', budgets.businessRules);
+    const system = clip([
+      authority,
+      `【相关记忆(仅作辅助)】\n${memory}`,
+      `【受控业务规则】\n${businessRules}`,
+    ].join('\n\n'), this.systemCharLimit);
+    const systemReport = {
+      authority: { budget: budgets.authority, used: authority.length },
+      memory: { budget: budgets.memory, used: memory.length },
+      businessRules: { budget: budgets.businessRules, used: businessRules.length },
+      total: { budget: this.systemCharLimit, used: system.length },
+    };
+    this.lastBudgetReport.system = systemReport;
+    return { text: system, budgetReport: { system: JSON.parse(JSON.stringify(systemReport)) } };
+  }
+
+  budgetReport() {
+    return JSON.parse(JSON.stringify(this.lastBudgetReport));
   }
 }
 

+ 108 - 3
mcp/src/core/agent-runtime.js

@@ -3,6 +3,8 @@ const fs = require('fs');
 const path = require('path');
 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 RISK_PATTERN = /合同|签约|资质|合规|保证|承诺|最低价|贷款|利率|投诉|退款|发票|身份证|银行卡|法律|违约/;
 const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
@@ -886,10 +888,107 @@ class QiweiAgentRuntime {
         continue;
       }
 
-      const parsedFinal = parseFinal(assistant.content);
       const authoritativeHistory = selectAuthoritativeHistory(history, 10);
       const currentProfile = profile?.profile || profile || {};
-      const final = enforceAuthoritativeGrounding(parsedFinal, authoritativeHistory, currentProfile, inboundContent);
+      const responsePlan = planResponse({
+        inboundContent,
+        messages: history,
+        profile: currentProfile,
+        customerIntelligence,
+        toolTrace,
+        citations,
+        businessGoal: this.config.businessGoal,
+        stagePlaybook: this.config.stagePlaybook,
+      });
+      const assess = content => {
+        const parsed = parseFinal(content);
+        const grounded = enforceAuthoritativeGrounding(parsed, authoritativeHistory, currentProfile, inboundContent);
+        const quality = verifyResponseQuality({
+          reply: grounded.reply,
+          inboundContent,
+          messages: history,
+          profile: currentProfile,
+          customerIntelligence,
+          citations,
+          toolTrace,
+          plan: responsePlan,
+          passScore: this.config.qualityPassScore,
+        });
+        return { final: grounded, quality };
+      };
+
+      let candidate = assess(assistant.content);
+      let rewriteCount = 0;
+      toolTrace.push({
+        tool: 'response_quality_gate',
+        args: { phase: 'evaluate', version: candidate.quality.version },
+        result: {
+          passed: candidate.quality.passed,
+          score: candidate.quality.score,
+          failedCheckIds: candidate.quality.failedCheckIds,
+        },
+      });
+
+      if (!candidate.quality.passed) {
+        rewriteCount = 1;
+        const qualityRepair = buildRewriteInstruction(candidate.quality);
+        const repairMessages = llmMessages.map((message, index) => index === 0 && message.role === 'system'
+          ? { ...message, content: `${message.content}\n\n【生成后质量修订】\n${qualityRepair}` }
+          : message);
+        try {
+          const revised = await this.modelClient.complete(repairMessages, [], {
+            conversation,
+            profile,
+            customerIntelligence,
+            inboundContent,
+            channelType,
+            directPrompt,
+            memoryContext,
+            qualityRepair,
+            rewriteAttempt: 1,
+          });
+          if (revised.claudeCode) {
+            toolTrace.push({
+              tool: 'claude_code_session',
+              args: { provider: 'Fmode Studio', model: revised.claudeCode.model, phase: 'quality_rewrite' },
+              result: {
+                sessionName: revised.claudeCode.sessionName,
+                durationMs: revised.claudeCode.durationMs,
+                costUsd: revised.claudeCode.costUsd,
+                budgetLimitUsd: revised.claudeCode.budgetLimitUsd,
+                resumed: revised.claudeCode.resumed,
+                systemChars: revised.claudeCode.systemChars,
+                businessPromptChars: revised.claudeCode.businessPromptChars,
+                promptChars: revised.claudeCode.promptChars,
+              },
+            });
+          }
+          candidate = assess(revised.content);
+        } catch (error) {
+          candidate.quality = {
+            ...candidate.quality,
+            passed: false,
+            failedCheckIds: [...new Set([...candidate.quality.failedCheckIds, 'rewrite_failed'])],
+            fallbackReason: [...new Set([...candidate.quality.failedCheckIds, 'rewrite_failed'])].join(','),
+          };
+          toolTrace.push({
+            tool: 'response_quality_rewrite',
+            args: { phase: 'recover' },
+            result: { status: 'failed', errorType: error?.name || 'Error' },
+          });
+        }
+        toolTrace.push({
+          tool: 'response_quality_gate',
+          args: { phase: 'rewrite_evaluate', version: candidate.quality.version },
+          result: {
+            passed: candidate.quality.passed,
+            score: candidate.quality.score,
+            failedCheckIds: candidate.quality.failedCheckIds,
+          },
+        });
+      }
+
+      const final = candidate.final;
       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);
@@ -898,12 +997,18 @@ class QiweiAgentRuntime {
         confidence,
         intent: String(final.intent || 'unknown'),
         reason: String(final.reason || 'Agent 未提供说明'),
-        requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7),
+        requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7 || !candidate.quality.passed),
         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,
+        qualityPassed: candidate.quality.passed,
+        qualityPlan: candidate.quality.plan,
+        rewriteCount,
+        fallbackReason: candidate.quality.fallbackReason,
       };
     }
     throw new Error('Agent 工具调用轮次超过上限,已转人工处理');

+ 88 - 5
mcp/src/core/agent-workbench-service.js

@@ -5,6 +5,8 @@ const { friendlyAgentError } = require('./agent-error-message');
 const { AgentMemoryManager } = require('./agent-memory');
 const { AgentMemoryExtractionWorker } = require('./agent-memory-worker');
 const { appendChatRecord, ensureMemory } = require('./message-archive');
+const { planResponse } = require('./response-quality-planner');
+const { verifyResponseQuality } = require('./response-quality-verifier');
 
 function looksLikeGroupId(value) {
   return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
@@ -366,15 +368,68 @@ class AgentWorkbenchService extends EventEmitter {
           detail: { message: error.message },
         });
       }
-      const output = await this.agent.run({
+      const conversationMessages = this.db.listMessages(conversation.id);
+      let output = await this.agent.run({
         conversation,
-        messages: this.db.listMessages(conversation.id),
+        messages: conversationMessages,
         profile,
         customerIntelligence: { tasks: currentTasks, alerts: currentAlerts },
         inboundContent: inboundMessage.content,
         memoryContext,
       });
       if (!String(output.content || '').trim()) throw new Error('Agent 没有生成可审核的回复内容');
+      const serviceQuality = verifyResponseQuality({
+        reply: output.content,
+        inboundContent: inboundMessage.content,
+        messages: conversationMessages.map(message => ({
+          role: message.direction === 'inbound' ? 'user' : 'assistant',
+          content: message.content,
+        })),
+        profile: profile.profile || profile,
+        customerIntelligence: { tasks: currentTasks, alerts: currentAlerts },
+        citations: output.citations || [],
+        toolTrace: output.toolTrace || [],
+        plan: output.qualityPlan || planResponse({
+          inboundContent: inboundMessage.content,
+          messages: conversationMessages,
+          businessGoal: this.config.agent?.businessGoal,
+          stagePlaybook: this.config.agent?.stagePlaybook,
+        }),
+        passScore: this.config.agent?.qualityPassScore,
+      });
+      const qualityPassed = serviceQuality.passed && output.qualityPassed !== false;
+      const failureIds = [...new Set([
+        ...(output.fallbackReason ? String(output.fallbackReason).split(',') : []),
+        ...serviceQuality.failedCheckIds,
+      ].filter(Boolean))];
+      output = {
+        ...output,
+        requiresHuman: Boolean(output.requiresHuman || !qualityPassed),
+        qualityScore: serviceQuality.score,
+        qualityChecks: serviceQuality.checks,
+        qualityPassed,
+        qualityPlan: serviceQuality.plan,
+        rewriteCount: Math.min(1, Math.max(0, Number(output.rewriteCount || 0))),
+        fallbackReason: qualityPassed ? '' : failureIds.join(','),
+        toolTrace: [...(output.toolTrace || []), {
+          tool: 'response_quality_service_gate',
+          args: { version: serviceQuality.version },
+          result: { passed: qualityPassed, score: serviceQuality.score, failedCheckIds: failureIds },
+        }],
+      };
+      this.db.audit({
+        actor: 'agent:quality',
+        action: 'agent_response_quality_evaluated',
+        conversationId: conversation.id,
+        entityId: inboundMessage.id,
+        detail: {
+          qualityScore: output.qualityScore,
+          qualityChecks: output.qualityChecks,
+          qualityPassed: output.qualityPassed,
+          rewriteCount: output.rewriteCount,
+          fallbackReason: output.fallbackReason,
+        },
+      });
       const changedProfileFields = Object.keys(output.profileUpdates || {});
       const profileEvidence = { ...(profile.profile?.__evidence || {}) };
       for (const field of changedProfileFields) {
@@ -416,7 +471,7 @@ class AgentWorkbenchService extends EventEmitter {
           },
         });
       }
-      if (options.allowAutoSend !== false && conversation.mode === 'autopilot') {
+      if (options.allowAutoSend !== false && conversation.mode === 'autopilot' && output.qualityPassed && !output.requiresHuman) {
         return await this.sendAutopilotReply(conversation, inboundMessage, output, {
           profile: updatedProfile,
           tasks: customerTasks,
@@ -439,7 +494,14 @@ class AgentWorkbenchService extends EventEmitter {
         action: 'draft_created',
         conversationId: conversation.id,
         entityId: draft.id,
-        detail: { confidence: draft.confidence, requiresHuman: draft.requires_human },
+        detail: {
+          confidence: draft.confidence,
+          requiresHuman: draft.requires_human,
+          qualityScore: output.qualityScore,
+          qualityPassed: output.qualityPassed,
+          rewriteCount: output.rewriteCount,
+          fallbackReason: output.fallbackReason,
+        },
       });
       this.emit('change', { type: 'draft', conversationId: conversation.id });
       const global = this.db.globalState();
@@ -450,7 +512,16 @@ class AgentWorkbenchService extends EventEmitter {
         status: 'pending_review',
         conversation,
         message: inboundMessage,
-        draft,
+        draft: {
+          ...draft,
+          quality: {
+            qualityScore: output.qualityScore,
+            qualityChecks: output.qualityChecks,
+            qualityPassed: output.qualityPassed,
+            rewriteCount: output.rewriteCount,
+            fallbackReason: output.fallbackReason,
+          },
+        },
         intelligence: { profile: updatedProfile, tasks: customerTasks, alerts: customerAlerts },
         memory: {
           recalled: memoryContext.recalled?.length || 0,
@@ -479,6 +550,9 @@ 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) {
+      throw new Error('Agent 回复未通过发送前质量校验,已保留为人工审核');
+    }
     const content = String(output.content || '').trim();
     if (!content) throw new Error('Agent 没有生成可发送的回复内容');
     if (content.length > 2000) throw new Error('回复内容过长');
@@ -497,6 +571,11 @@ class AgentWorkbenchService extends EventEmitter {
           requiresHuman: Boolean(output.requiresHuman),
           citations: output.citations || [],
           toolTrace: output.toolTrace || [],
+          qualityScore: Number(output.qualityScore) || 0,
+          qualityChecks: output.qualityChecks || [],
+          qualityPassed: Boolean(output.qualityPassed),
+          rewriteCount: Number(output.rewriteCount) || 0,
+          fallbackReason: String(output.fallbackReason || ''),
         },
       }).message;
       appendChatRecord({
@@ -519,6 +598,10 @@ class AgentWorkbenchService extends EventEmitter {
           outboundMessageId: outbound.id,
           confidence: Number(output.confidence) || 0,
           requiresHuman: Boolean(output.requiresHuman),
+          qualityScore: Number(output.qualityScore) || 0,
+          qualityPassed: Boolean(output.qualityPassed),
+          rewriteCount: Number(output.rewriteCount) || 0,
+          fallbackReason: String(output.fallbackReason || ''),
           draftCreated: false,
         },
       });

+ 80 - 0
mcp/src/core/response-quality-planner.js

@@ -0,0 +1,80 @@
+'use strict';
+
+const ACK_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
+const QUESTION_PATTERN = /[??]|(?:吗|么|呢|怎么|如何|多少|能否|可以|有没有|是否|为什么|哪(?:个|些|里)?|几时|什么时候)/;
+
+function normalizeText(value = '') {
+  return String(value || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim();
+}
+
+function isAcknowledgement(value = '') {
+  const normalized = normalizeText(value);
+  return Boolean(normalized && ACK_PATTERN.test(normalized));
+}
+
+function inferScenario(inboundContent = '', context = {}) {
+  const text = String(inboundContent || '').trim();
+  if (isAcknowledgement(text)) return 'acknowledgement';
+  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';
+  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 planResponse(input = {}) {
+  const inboundContent = String(input.inboundContent || '');
+  const scenario = inferScenario(inboundContent, input);
+  const shouldReply = scenario !== 'acknowledgement';
+  const requireDirectAnswer = shouldReply;
+  const requireNextStep = shouldReply;
+  const requireEvidence = shouldReply && (
+    scenario === 'tool_no_result'
+    || /价格|费用|优惠|库存|名额|政策|资格|合同|结果|案例|数据|多少|最低|最高|保证/.test(inboundContent)
+  );
+  const enabledSections = scenario === 'complaint'
+    ? ['knownFacts', 'pendingRisks', 'nextStep']
+    : scenario === 'objection'
+      ? ['concern', 'knownFacts', 'pendingRisks', 'nextStep']
+      : scenario === 'tool_no_result'
+        ? ['knownFacts', 'pendingRisks', 'nextStep']
+        : ['directAnswer', 'knownFacts', 'nextStep'];
+
+  return {
+    version: '1.0',
+    scenario,
+    shouldReply,
+    requireDirectAnswer,
+    requireEvidence,
+    requireNextStep,
+    maxQuestions: shouldReply ? 1 : 0,
+    maxChars: Number(input.maxChars || 320),
+    enabledSections,
+    businessGoal: String(input.businessGoal || '').trim().slice(0, 240),
+    stagePlaybook: String(input.stagePlaybook || '').trim().slice(0, 600),
+  };
+}
+
+function plannerInstruction(plan = {}) {
+  if (!plan.shouldReply) return '这是纯确认或致谢;reply 返回空字符串,不追加营销或客套回复。';
+  const sections = (plan.enabledSections || []).join('、');
+  return [
+    `当前场景:${plan.scenario}。按需使用这些内容块:${sections};不要机械输出固定标题。`,
+    plan.requireDirectAnswer ? '首句直接回答客户当前问题,禁止先写问候、感谢或处理过程。' : '先回应客户当前关注点。',
+    '只补充能由本轮会话、画像、规则或工具结果支持的必要依据;未知事实明确待确认。',
+    `最多追问 ${plan.maxQuestions} 个信息增益最高的问题,不重复已经问过或已经回答的信息。`,
+    plan.requireNextStep ? '末句给出与当前阶段相符、可执行且不过度承诺的下一步。' : '',
+    plan.businessGoal ? `可配置业务目标:${plan.businessGoal}` : '',
+    plan.stagePlaybook ? `可配置阶段策略:${plan.stagePlaybook}` : '',
+  ].filter(Boolean).join('\n');
+}
+
+module.exports = {
+  inferScenario,
+  isAcknowledgement,
+  normalizeText,
+  planResponse,
+  plannerInstruction,
+};

+ 224 - 0
mcp/src/core/response-quality-verifier.js

@@ -0,0 +1,224 @@
+'use strict';
+
+const { planResponse, plannerInstruction } = require('./response-quality-planner');
+
+const GREETING_PATTERN = /^(?:您好|你好|哈喽|嗨|尊敬的|感谢您的?(?:咨询|关注|理解|反馈)?)[,,。!!\s]*/;
+const FILLER_OPENING_PATTERN = /^(?:关于这个问题|针对您的问题|我来为您|我先帮您|我这边先|请您耐心等待|感谢您的耐心)/;
+const EMPTY_HANDOFF_PATTERN = /^(?:好的|收到|知道了|了解了|我(?:先)?看看|我(?:先)?查查|稍后回复|稍后答复|正在处理|处理中|这边给您处理一下|我帮您关注一下|具体情况我再回复)(?:[,,。!!\s]|$)/;
+const SENSITIVE_PROMISE_PATTERN = /(?:保证|承诺|绝对|百分之百|100%|一定(?:可以|能|会|不会)|全网最低|最低价|包过|稳赚|马上全部解决)/i;
+const UNCERTAINTY_PATTERN = /(?:待确认|需要确认|需要核验|以.+为准|暂未找到|没有返回|未返回|当前数据(?:源|中)?.*没有|尚无|不确定|人工确认|核实后)/;
+const FACT_CLAIM_PATTERN = /\d|(?:价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|当前有|已经找到|已确认|支持|最低|最高)/;
+const NEXT_STEP_PATTERN = /(?:下一步|接下来|我(?:先|会|可以)|我们(?:先|会|可以)|建议|可以先|请您|麻烦|方便|确认后|核对后|安排|发给您|同步给您|转人工|人工(?:跟进|处理|确认))/;
+const INTERNAL_LEAK_PATTERN = /(?:qualityScore|qualityChecks|rewriteCount|fallbackReason|内部评分|系统提示|JSON Schema|requiresHuman)/i;
+
+function normalized(value = '') {
+  return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, '');
+}
+
+function toRole(message = {}) {
+  if (message.role) return message.role;
+  return message.direction === 'outbound' ? 'assistant' : message.direction === 'inbound' ? 'user' : '';
+}
+
+function messageContent(message = {}) {
+  return String(message.content || '');
+}
+
+function questionSegments(text = '') {
+  return String(text || '').split(/[??]/).slice(0, -1).map(item => normalized(item).slice(-80)).filter(item => item.length >= 4);
+}
+
+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)];
+}
+
+function countQuestions(text = '') {
+  const value = String(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 && (
+      /^(?:请问|麻烦|方便|能否|是否|怎么|如何|为什么|多少|什么时候|何时|哪|几|预算|时间|人数|规模|地区|区域)/.test(clause)
+      || /(?:预算|时间|人数|规模|地区|区域).{0,10}(?:多少|什么时候|何时|哪|几|吗|么)$/.test(clause)
+    );
+    if (!punctuationCount && !implicitQuestion) return total;
+    const fields = questionFields(clause);
+    const multiFieldCount = fields.length > 1 && !/还是/.test(clause) ? fields.length : 0;
+    return total + Math.max(1, punctuationCount, cueCount, multiFieldCount);
+  }, 0);
+}
+
+function hasAnsweredFieldQuestion(reply, input = {}) {
+  const asked = questionFields(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));
+}
+
+function hasRepeatedQuestion(reply, messages = [], input = {}) {
+  const current = questionSegments(reply);
+  if (hasAnsweredFieldQuestion(reply, { ...input, messages })) return true;
+  if (!current.length) return false;
+  const previous = messages
+    .filter(message => toRole(message) === 'assistant')
+    .flatMap(message => questionSegments(messageContent(message)));
+  return current.some(question => previous.some(old => question === old || (question.length >= 8 && (question.includes(old) || old.includes(question)))));
+}
+
+function evidencePayload(input = {}) {
+  return [
+    JSON.stringify(input.profile || {}),
+    ...(input.citations || []).map(item => JSON.stringify({
+      content: item?.content,
+      excerpt: item?.excerpt,
+      text: item?.text,
+      quote: item?.quote,
+    })),
+    ...(input.toolTrace || [])
+      .filter(item => item?.tool && !String(item.tool).includes('quality'))
+      .map(item => JSON.stringify(item.result ?? null)),
+  ].join('\n');
+}
+
+function claimNgrams(claim = '') {
+  const value = normalized(claim)
+    .replace(/(?:当前|已经|我们|这边|可以|给您|核验|确认|结果|数据)/g, '');
+  const grams = [];
+  for (let size = Math.min(8, value.length); size >= 4; size -= 1) {
+    for (let index = 0; index + size <= value.length; index += 1) grams.push(value.slice(index, index + size));
+    if (grams.length) break;
+  }
+  return [...new Set(grams)].slice(0, 12);
+}
+
+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;
+
+  const evidence = normalized(evidencePayload(input));
+  const conversation = normalized([
+    input.inboundContent,
+    ...(input.messages || []).map(messageContent),
+  ].join('\n'));
+  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;
+
+  if (numbers.length) return (evidenceHasNumbers && (evidenceHasTerms || exactEvidence)) || explicitRestatement;
+  if (!plan.requireEvidence && !/(?:已确认|已经找到|当前有|库存|名额|资格|政策|最低|最高)/.test(clause)) return true;
+  return exactEvidence && evidenceHasTerms;
+}
+
+function factsAreSupported(reply, input = {}, plan = {}) {
+  const claims = String(reply || '').split(/[,,;;。!?!?\n]/).map(item => item.trim()).filter(Boolean);
+  return claims.every(claim => claimIsSupported(claim, input, plan));
+}
+
+function directAnswerPresent(reply, plan = {}) {
+  if (!plan.shouldReply || !plan.requireDirectAnswer) return true;
+  const compact = String(reply || '').trim();
+  if (!compact) return false;
+  if (GREETING_PATTERN.test(compact) || FILLER_OPENING_PATTERN.test(compact) || EMPTY_HANDOFF_PATTERN.test(compact)) return false;
+  const firstSentence = compact.split(/[。!?!?\n]/)[0] || compact;
+  if (plan.scenario === 'objection' && !/(?:理解|顾虑|担心|在意|确实|成本|投入|价值|贵|不合适)/.test(firstSentence)) return false;
+  if (plan.scenario === 'complaint' && !/(?:理解|抱歉|已记录|您反馈|当前|人工|处理)/.test(firstSentence)) return false;
+  if (plan.scenario === 'tool_no_result' && !/(?:没有|未找到|未返回|尚无|待核验|需要核验)/.test(firstSentence)) return false;
+  return firstSentence.length <= 90;
+}
+
+function continuityPreserved(reply, messages = []) {
+  const current = normalized(reply);
+  if (!current) return true;
+  const priorReplies = messages.filter(message => toRole(message) === 'assistant').map(message => normalized(messageContent(message))).filter(Boolean);
+  return !priorReplies.some(previous => current.length >= 8 && current === previous);
+}
+
+function stageNextStepPresent(reply, plan = {}) {
+  if (!plan.shouldReply || !plan.requireNextStep) return true;
+  const lastSentence = String(reply || '').trim().split(/(?<=[。!?!?])|\n/).filter(Boolean).at(-1) || '';
+  return NEXT_STEP_PATTERN.test(lastSentence) || countQuestions(lastSentence) === 1;
+}
+
+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));
+}
+
+function makeCheck(id, passed, weight, detail, hard = false) {
+  return { id, passed: Boolean(passed), weight, hard: Boolean(hard), detail };
+}
+
+function verifyResponseQuality(input = {}) {
+  const reply = String(input.reply ?? input.content ?? '').trim();
+  const plan = input.plan || planResponse(input);
+  const priorAssistantExists = (input.messages || []).some(message => toRole(message) === 'assistant');
+  const questionCount = countQuestions(reply);
+  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('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('sensitive_commitment', commitmentsAreSafe(reply), 8, '没有未经证实的保证或绝对承诺', true),
+    makeCheck('internal_leakage', !INTERNAL_LEAK_PATTERN.test(reply), 4, '没有外发内部评分或运行字段', true),
+  ];
+  const total = checks.reduce((sum, check) => sum + check.weight, 0);
+  const earned = checks.reduce((sum, check) => sum + (check.passed ? check.weight : 0), 0);
+  const score = Math.round((earned / total) * 100);
+  const failed = checks.filter(check => !check.passed);
+  const passed = failed.every(check => !check.hard) && score >= Number(input.passScore || 82);
+  return {
+    version: '1.0',
+    passed,
+    score,
+    checks,
+    failedCheckIds: failed.map(check => check.id),
+    fallbackReason: passed ? '' : failed.map(check => check.id).join(','),
+    plan,
+  };
+}
+
+function buildRewriteInstruction(assessment = {}) {
+  const failed = (assessment.checks || []).filter(check => !check.passed).map(check => `${check.id}: ${check.detail}`);
+  return [
+    '上一版 reply 未通过发送前质量校验。只修订 reply,并保持已验证事实、profileUpdates、tasks 和 alerts 不变。',
+    plannerInstruction(assessment.plan || {}),
+    `需要修复:${failed.join(';') || '按回复契约精简并提高直接性'}`,
+    '不要输出质量分数、检查项名称或修订说明;仍只返回既定 JSON 结构。',
+  ].join('\n');
+}
+
+module.exports = {
+  buildRewriteInstruction,
+  countQuestions,
+  hasRepeatedQuestion,
+  verifyResponseQuality,
+};

+ 3 - 0
mcp/src/dashboard/agent-service.js

@@ -210,6 +210,9 @@ function loadAgentConfig(overrides = {}) {
       maxToolRounds: number('AGENT_MAX_TOOL_ROUNDS', 4, 1, 8),
       promptCharLimit: number('QIWEI_AGENT_PROMPT_CHAR_LIMIT', 16000, 2000, 50000),
       systemPromptCharLimit: number('QIWEI_AGENT_SYSTEM_PROMPT_CHAR_LIMIT', 12000, 2000, 50000),
+      qualityPassScore: number('QIWEI_AGENT_QUALITY_PASS_SCORE', 82, 60, 100),
+      businessGoal: value('QIWEI_AGENT_BUSINESS_GOAL'),
+      stagePlaybook: value('QIWEI_AGENT_STAGE_PLAYBOOK'),
       claudeExecutable: value('CLAUDE_CODE_EXECUTABLE') || path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
       claudeWorkdir: resolvePath(value('CLAUDE_CODE_WORKDIR'), PROJECT_ROOT),
       claudeSessionFile: resolvePath(value('CLAUDE_CODE_SESSION_FILE'), latestPath('messages', 'claude-code-sessions.json')),

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
package.json


+ 24 - 11
scripts/agent-console-smoke-test.js

@@ -507,14 +507,14 @@ async function main() {
     } finally { ctx.close(); }
   });
 
-  await check('全自动接管直接发送回复且不创建待审核草稿', async () => {
+  await check('全自动接管的低质量或需人工回复会降级为待审核草稿', async () => {
     const ctx = setup({
       defaultMode: 'autopilot',
       agentRun: async () => ({
-        content: '这条低置信回复也由全自动接管直接发送',
+        content: '好的。',
         confidence: 0.12,
         intent: 'autopilot_test',
-        reason: '全自动接管不经过草稿审核',
+        reason: '低质量回复必须经过质量门',
         requiresHuman: true,
         profileUpdates: {},
         citations: [],
@@ -523,19 +523,32 @@ async function main() {
     });
     try {
       const result = await ctx.service.ingestInbound({ externalId: 'm-autopilot', contactId: 'contact-1', contactName: '王刚', content: '全自动接管测试' });
-      assert.equal(result.status, 'autopilot_sent');
-      assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '这条低置信回复也由全自动接管直接发送' }]);
-      assert.equal(ctx.db.listDrafts().length, 0);
-      assert.equal(ctx.db.listMessages(result.conversation.id).filter(item => item.direction === 'outbound').length, 1);
+      assert.equal(result.status, 'pending_review');
+      assert.deepEqual(ctx.sent, []);
+      assert.equal(ctx.db.listDrafts().length, 1);
+      assert.equal(ctx.db.listMessages(result.conversation.id).filter(item => item.direction === 'outbound').length, 0);
       const outcome = ctx.db.latestAgentOutcome(result.conversation.id);
-      assert.equal(outcome.action, 'autopilot_message_sent');
-      assert.equal(outcome.entityId, result.message.id);
+      assert.equal(outcome.action, 'draft_created');
+      assert.equal(result.draft.requires_human, true);
+      assert.equal(result.draft.quality.qualityPassed, false);
     } finally { ctx.close(); }
   });
 
   await check('全自动接管发送失败保留失败审计且不创建草稿', async () => {
     const ctx = setup({
       defaultMode: 'autopilot',
+      agentRun: async () => ({
+        content: '可以处理,我现在核对执行条件,确认后立即回复您。',
+        confidence: 0.95,
+        intent: 'autopilot_send_failure',
+        reason: '合格回复用于验证发送失败边界',
+        requiresHuman: false,
+        profileUpdates: {},
+        tasks: [],
+        alerts: [],
+        citations: [],
+        toolTrace: [],
+      }),
       qiweiSend: async () => { throw new Error('send failed'); },
     });
     try {
@@ -1302,9 +1315,9 @@ async function main() {
         tasks: [{ businessKey: 'follow_up:send_solution', title: '发送服务方案', status: 'open' }],
         alerts: [{ businessKey: 'high_intent:core_demand_ready', title: '高意向', status: 'open' }],
       } });
-      assert.match(prompt, /当前未完成待办/);
+      assert.match(prompt, /当前未完成问题\/待办/);
       assert.match(prompt, /follow_up:send_solution/);
-      assert.match(prompt, /当前未解决预警/);
+      assert.match(prompt, /当前未解决风险/);
       assert.match(prompt, /high_intent:core_demand_ready/);
     } finally { fs.rmSync(dir, { recursive: true, force: true }); }
   });

+ 332 - 0
scripts/agent-response-quality-smoke-test.js

@@ -0,0 +1,332 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const RUN_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-response-quality-'));
+process.env.QIWEI_OUTPUTS_DIR = path.join(RUN_ROOT, 'outputs');
+
+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 { verifyResponseQuality } = require('../mcp/src/core/response-quality-verifier');
+
+const LOW_QUALITY_JSONL = `
+{"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"您好,感谢您的咨询,我们一直致力于提供优质服务。"}
+{"id":"price_promise","inbound":"最低能做到多少钱?","reply":"保证给您全网最低价,一定不会更贵。"}
+{"id":"missing_info","inbound":"我想做员工培训","reply":"您的预算是多少?计划什么时候开始?预计多少人参加?"}
+{"id":"objection","inbound":"这个方案太贵了","reply":"您的预算是多少?"}
+{"id":"urgent","inbound":"今天能安排吗?","reply":"您好,感谢关注,我们会认真研究并持续努力为您提供全面周到专业细致的服务体验,请耐心等待。"}
+{"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"我们一定马上全部解决,绝对不会让您失望。"}
+{"id":"thanks","inbound":"谢谢","reply":"不客气,后续有需要随时联系。"}
+{"id":"answered_context","inbound":"预算已经说过是20万","reply":"请问您的预算是多少?","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
+{"id":"repeat_question","inbound":"我还在考虑","reply":"请问您的预算是多少?","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
+{"id":"tool_no_result","inbound":"有现成案例吗?","reply":"有的,我们已经找到 3 个完全匹配案例。","toolNoResult":true,"toolTrace":[{"tool":"search_knowledge","result":{"items":[]}}]}
+{"id":"low_quality","inbound":"能不能先给个建议?","reply":"好的。"}
+`;
+
+const GOOD_QUALITY_JSONL = `
+{"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"费用要按服务范围确认;您方便提供预计人数吗?确认后我给您对应报价。"}
+{"id":"price_check","inbound":"最低能做到多少钱?","reply":"当前最低价需要核验;我先核对适用方案,确认后给您准确答复。"}
+{"id":"missing_info","inbound":"我想做员工培训","reply":"可以先按员工培训方向梳理;请问预计多少人参加?确认人数后我给您下一步方案。"}
+{"id":"objection","inbound":"这个方案太贵了","reply":"我理解您主要顾虑投入是否值得;当前收益数据还需按场景核验,您最关注成本还是效果?确认后我补对应依据。"}
+{"id":"urgent","inbound":"今天能安排吗?","reply":"今天能否安排需要看当前档期;我现在核对,确认后尽快回复您。"}
+{"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"我理解您在意的是迟迟未处理;当前责任和处理结果需要人工核验,我现在转人工跟进并同步进度。"}
+{"id":"thanks","inbound":"谢谢","reply":""}
+{"id":"answered_context","inbound":"预算已经说过是20万","reply":"已记录预算20万;我会按这个上限继续整理,确认后给您方案。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
+{"id":"no_repeat","inbound":"我还在考虑","reply":"可以按您的节奏来;我先保留当前条件,有明确时间后再继续。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
+{"id":"tool_no_result","inbound":"有现成案例吗?","reply":"当前数据源没有返回可验证案例;我可以调整一个筛选条件后再查。","toolNoResult":true,"toolTrace":[{"tool":"search_knowledge","result":{"items":[]}}]}
+{"id":"suggestion","inbound":"能不能先给个建议?","reply":"可以,建议先明确最重要的结果目标;您最希望先解决哪个问题?确认后我给您第一版方案。"}
+`;
+
+function parseJsonl(value) {
+  return String(value || '').trim().split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
+}
+
+function assessFixture(item) {
+  return verifyResponseQuality({
+    inboundContent: item.inbound,
+    reply: item.reply,
+    messages: item.messages || [],
+    toolNoResult: Boolean(item.toolNoResult),
+    toolTrace: item.toolTrace || [],
+  });
+}
+
+function modelPayload(reply, overrides = {}) {
+  return JSON.stringify({
+    reply,
+    confidence: 0.95,
+    intent: 'quality_smoke',
+    reason: '专项测试',
+    requiresHuman: false,
+    profileUpdates: {},
+    tasks: [],
+    alerts: [],
+    ...overrides,
+  });
+}
+
+function setupService(mode, output) {
+  const dir = fs.mkdtempSync(path.join(RUN_ROOT, `${mode}-`));
+  const db = new AgentWorkbenchDb(path.join(dir, 'workbench.db'), { defaultMode: mode, autoSendConfidence: 0.88 });
+  const sent = [];
+  const service = new AgentWorkbenchService({
+    db,
+    agent: {
+      modelClient: { isConfigured: () => true },
+      async run() { return { ...output }; },
+    },
+    qiwei: {
+      isConfigured: () => true,
+      async sendText(toId, content) { sent.push({ toId, content }); return { isSendSuccess: true }; },
+    },
+    config: {
+      accountKey: 'quality-smoke',
+      agent: { apiKey: 'smoke', model: 'stub', provider: 'stub', qualityPassScore: 82 },
+      qiwei: { allowedSenders: ['contact-1'] },
+      memory: { enabled: false },
+    },
+  });
+  return {
+    db,
+    sent,
+    service,
+    close() { service.stopBackgroundWorkers(); db.close(); },
+  };
+}
+
+async function main() {
+  const lowCases = parseJsonl(LOW_QUALITY_JSONL);
+  const goodCases = parseJsonl(GOOD_QUALITY_JSONL);
+  const lowResults = lowCases.map(item => ({ id: item.id, assessment: assessFixture(item) }));
+  const goodResults = goodCases.map(item => ({ id: item.id, assessment: assessFixture(item) }));
+  const lowBlocked = lowResults.filter(item => !item.assessment.passed).length;
+  const goodRejected = goodResults.filter(item => !item.assessment.passed);
+  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 unrelatedEvidence = verifyResponseQuality({
+    inboundContent: '最低多少钱?',
+    reply: '最低只要 99 元,我已经确认;我可以直接给您下单。',
+    toolTrace: [{ tool: 'search_knowledge', result: { title: '品牌介绍', content: '成立于某年' } }],
+    citations: [{ id: 'brand.md#intro', source: 'brand.md', heading: '品牌介绍' }],
+  });
+  assert.equal(unrelatedEvidence.passed, false);
+  assert(unrelatedEvidence.failedCheckIds.includes('fact_evidence'));
+  const claimBoundaryCases = [
+    verifyResponseQuality({
+      inboundContent: '价格确认了吗?',
+      reply: '价格已确认,其他信息待核验;我可以继续帮您核对。',
+      citations: [{ content: '其他信息待核验' }],
+    }),
+    verifyResponseQuality({
+      inboundContent: '还有库存吗?',
+      reply: '库存已确认;我可以直接为您保留。',
+      toolTrace: [{ tool: 'inventory_lookup', result: { content: '库存字段说明' } }],
+    }),
+    verifyResponseQuality({
+      inboundContent: '价格是多少?',
+      reply: '已确认价格是 99 元,其他待核验;我可以把依据发给您。',
+      citations: [{ content: '当前价格是 88 元' }],
+    }),
+  ];
+  assert(claimBoundaryCases.every(item => !item.passed && item.failedCheckIds.includes('fact_evidence')));
+  const sourcedPrice = verifyResponseQuality({
+    inboundContent: '价格是多少?',
+    reply: '已确认价格是 99 元;我可以把计价依据发给您。',
+    citations: [{ content: '当前价格是 99 元' }],
+  });
+  assert.equal(sourcedPrice.passed, true, JSON.stringify(sourcedPrice.failedCheckIds));
+  const splitPromise = verifyResponseQuality({
+    inboundContent: '价格能确认吗?',
+    reply: '保证是 99 元,其他待核验;我可以继续跟进。',
+    citations: [{ content: '其他信息待核验' }],
+  });
+  assert.equal(splitPromise.passed, false);
+  assert(splitPromise.failedCheckIds.includes('sensitive_commitment'));
+  const bundledQuestions = verifyResponseQuality({
+    inboundContent: '我想咨询服务',
+    reply: '可以先了解需求;预算多少、什么时候开始、多少人参加?确认后我给您方案。',
+  });
+  assert.equal(bundledQuestions.passed, false);
+  assert(bundledQuestions.failedCheckIds.includes('question_count'));
+  const singleQuestion = verifyResponseQuality({
+    inboundContent: '我想咨询服务',
+    reply: '可以先明确投入范围;请问您的预算上限大概是多少?确认后我给您方案。',
+  });
+  assert.equal(singleQuestion.passed, true, JSON.stringify(singleQuestion.failedCheckIds));
+  const semanticRepeat = verifyResponseQuality({
+    inboundContent: '预算已经确认是20万',
+    reply: '已记录当前需求;请问您的总投入上限多少?确认后我继续整理。',
+  });
+  assert.equal(semanticRepeat.passed, false);
+  assert(semanticRepeat.failedCheckIds.includes('repeated_question'));
+  for (const emptyAnswer of ['这边给您处理一下。', '我帮您关注一下。', '具体情况我再回复。']) {
+    const assessment = verifyResponseQuality({ inboundContent: '当前有库存吗?', reply: emptyAnswer });
+    assert.equal(assessment.passed, false);
+    assert(assessment.failedCheckIds.includes('direct_answer'));
+  }
+  assert.equal(verifyResponseQuality({
+    inboundContent: '当前有库存吗?',
+    reply: '当前资料不足,需要先核验在售状态;我确认后回复您。',
+  }).passed, true);
+
+  const builder = new AgentContextBuilder({
+    config: { promptCharLimit: 2400, systemPromptCharLimit: 2200, businessGoal: '推进到清晰且可执行的下一步' },
+    knowledge: { contextText: () => '通用规则:未知事实必须核验。'.repeat(200) },
+  });
+  const system = builder.buildSystemContext({ memoryContext: { promptText: '旧 Session 说客户接受高价。'.repeat(100) } });
+  const prompt = builder.buildClaudePrompt([
+    { role: 'assistant', content: '请问预算是多少?' },
+    { role: 'user', content: '本轮我只确认预算是20万。' },
+    { role: 'tool', content: JSON.stringify({ result: '当前没有已核验报价' }) },
+  ], {
+    inboundContent: '本轮我只确认预算是20万。',
+    profile: { profile: { budgetWan: 20, __evidence: { budgetWan: { text: '预算是20万' } } } },
+    customerIntelligence: { tasks: [{ status: 'open', title: '确认时间' }], alerts: [] },
+  });
+  const budget = builder.budgetReport();
+  assert(prompt.length <= 2400 && system.length <= 2200);
+  assert.match(prompt, /本轮我只确认预算是20万/);
+  assert.match(system, /旧模型 Session/);
+  assert(Object.values(budget.prompt).every(item => item.used <= item.budget));
+  assert(Object.values(budget.system).every(item => item.used <= item.budget));
+  const [requestA, requestB] = await Promise.all([
+    Promise.resolve(builder.buildClaudePromptResult([{ role: 'user', content: '请求甲的唯一内容' }], { profile: { profile: { request: 'A' } } })),
+    Promise.resolve(builder.buildClaudePromptResult([{ role: 'user', content: '请求乙的唯一内容' }], { profile: { profile: { request: 'B' } } })),
+  ]);
+  assert.match(requestA.text, /请求甲的唯一内容/);
+  assert.doesNotMatch(requestA.text, /请求乙的唯一内容/);
+  assert.match(requestB.text, /请求乙的唯一内容/);
+  assert.notDeepEqual(requestA.budgetReport, {});
+  assert.notStrictEqual(requestA.budgetReport, requestB.budgetReport);
+
+  const sequence = [
+    modelPayload('您好,感谢您的咨询,请耐心等待。'),
+    modelPayload('今天能否安排需要核对当前档期;我现在核对,确认后尽快回复您。'),
+  ];
+  const runtime = new QiweiAgentRuntime({
+    config: { provider: 'stub', maxToolRounds: 2, qualityPassScore: 82 },
+    knowledge: { search: () => [], contextText: () => '' },
+    modelClient: { async complete() { return { content: sequence.shift() }; } },
+  });
+  const rewritten = await runtime.run({
+    conversation: { id: 'runtime-rewrite', mode: 'autopilot' },
+    messages: [{ direction: 'inbound', content: '今天能安排吗?' }],
+    profile: { profile: {} },
+    inboundContent: '今天能安排吗?',
+  });
+  assert.equal(rewritten.rewriteCount, 1);
+  assert.equal(rewritten.qualityPassed, true);
+  assert.equal(rewritten.requiresHuman, false);
+
+  let failedCalls = 0;
+  const failedRuntime = new QiweiAgentRuntime({
+    config: { provider: 'stub', maxToolRounds: 2, qualityPassScore: 82 },
+    knowledge: { search: () => [], contextText: () => '' },
+    modelClient: { async complete() { failedCalls += 1; return { content: modelPayload('好的。') }; } },
+  });
+  const failedRewrite = await failedRuntime.run({
+    conversation: { id: 'runtime-fallback', mode: 'autopilot' },
+    messages: [{ direction: 'inbound', content: '今天能安排吗?' }],
+    profile: { profile: {} },
+    inboundContent: '今天能安排吗?',
+  });
+  assert.equal(failedCalls, 2);
+  assert.equal(failedRewrite.rewriteCount, 1);
+  assert.equal(failedRewrite.qualityPassed, false);
+  assert.equal(failedRewrite.requiresHuman, true);
+  assert(failedRewrite.fallbackReason);
+
+  const qualified = {
+    content: '今天能否安排需要核对当前档期;我现在核对,确认后尽快回复您。',
+    confidence: 0.95,
+    intent: 'schedule',
+    reason: '当前档期需要核验',
+    requiresHuman: false,
+    profileUpdates: {}, tasks: [], alerts: [], citations: [], toolTrace: [],
+  };
+  const low = {
+    content: '好的。', confidence: 0.95, intent: 'schedule', reason: 'low', requiresHuman: false,
+    profileUpdates: {}, tasks: [], alerts: [], citations: [], toolTrace: [], rewriteCount: 1,
+  };
+
+  for (const mode of ['review', 'auto', 'autopilot']) {
+    const ctx = setupService(mode, low);
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: `low-${mode}`, contactId: 'contact-1', content: '今天能安排吗?' });
+      assert.equal(result.status, 'pending_review');
+      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(ctx.sent.length, 0);
+    } finally { ctx.close(); }
+  }
+
+  for (const mode of ['auto', 'autopilot']) {
+    const ctx = setupService(mode, qualified);
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: `good-${mode}`, contactId: 'contact-1', content: '今天能安排吗?' });
+      assert.equal(result.status, mode === 'auto' ? 'sent' : 'autopilot_sent');
+      assert.equal(ctx.sent.length, 1);
+    } finally { ctx.close(); }
+  }
+
+  const human = setupService('review', low);
+  try {
+    const draftResult = await human.service.ingestInbound({ externalId: 'human-draft', contactId: 'contact-1', content: '今天能安排吗?' });
+    await human.service.approveDraft(draftResult.draft.id, { content: '好的。', actor: 'human' });
+    await human.service.manualSend(draftResult.conversation.id, '收到。', 'human');
+    assert.equal(human.sent.length, 2);
+  } finally { human.close(); }
+
+  const directAutopilot = setupService('autopilot', qualified);
+  try {
+    const conversation = directAutopilot.db.ensureConversation('contact-1', '匿名客户');
+    const inbound = directAutopilot.db.insertMessage({
+      conversationId: conversation.id,
+      direction: 'inbound',
+      senderType: 'customer',
+      content: '今天能安排吗?',
+    }).message;
+    await assert.rejects(() => directAutopilot.service.sendAutopilotReply(conversation, inbound, {
+      ...low,
+      qualityPassed: false,
+      requiresHuman: true,
+    }), /质量校验/);
+    assert.equal(directAutopilot.sent.length, 0);
+  } finally { directAutopilot.close(); }
+
+  const thanks = setupService('autopilot', qualified);
+  try {
+    const result = await thanks.service.ingestInbound({ externalId: 'thanks', contactId: 'contact-1', content: '谢谢' });
+    assert.equal(result.status, 'no_reply_needed');
+    assert.equal(thanks.sent.length, 0);
+  } finally { thanks.close(); }
+
+  const summary = {
+    baseline: { lowQualityAutopilotSent: 11, lowQualityBlocked: 0 },
+    current: {
+      lowQualityCases: lowCases.length,
+      lowQualityBlocked: lowBlocked,
+      lowQualityBlockRate: lowBlocked / lowCases.length,
+      goodQualityCases: goodCases.length,
+      goodQualityRejected: goodRejected.length,
+      goodQualityFalsePositiveRate: goodRejected.length / goodCases.length,
+    },
+    modes: ['review low->draft', 'auto low->draft', 'autopilot low->draft', 'auto good->sent', 'autopilot good->sent', 'human bypass', 'thanks no-reply'],
+  };
+  process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+}).finally(() => {
+  fs.rmSync(RUN_ROOT, { recursive: true, force: true });
+});

+ 1 - 0
scripts/release-check.js

@@ -21,6 +21,7 @@ const checks = [
   'diagnosis:smoke',
   'diagnosis-actions:smoke',
   'smoke',
+  'agent:quality-smoke',
   'agent:smoke',
   'library:smoke',
   'excel:smoke',

+ 1 - 1
scripts/validate-output-standard.js

@@ -8,7 +8,7 @@ const {
   outputsRoot
 } = require('../mcp/src/core/output-paths');
 
-const DOCS_ALLOWED = new Set(['OUTPUT-STANDARD.md', 'DASHBOARD-DEV-LOG.md', 'RELEASE.md', 'specs', 'guides', 'generated']);
+const DOCS_ALLOWED = new Set(['OUTPUT-STANDARD.md', 'DASHBOARD-DEV-LOG.md', 'RELEASE.md', 'specs', 'guides', 'research', 'generated']);
 const RUN_DIR_RE = /^\d{6}-[a-z0-9\u4e00-\u9fa5-]+$/;
 const DATE_DIR_RE = /^\d{4}-\d{2}-\d{2}$/;
 

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است