Răsfoiți Sursa

sync: 迁入通用客服平台能力并剥离看房经纪人角色

把业务仓的拟人闸门、投递幂等、入站媒体和账号隔离同步到行业通用客服版,避免把带看/房源行为带回本仓库。

Co-authored-by: Cursor <cursoragent@cursor.com>
gangvy 1 lună în urmă
părinte
comite
f2c0f794d5
47 a modificat fișierele cu 2379 adăugiri și 196 ștergeri
  1. 19 3
      docs/AGENT-HOUSE-MIGRATION.md
  2. 19 0
      knowledge/README.md
  3. 8 0
      knowledge/customer-service/chat-role.md
  4. 7 0
      knowledge/customer-service/chat-style.md
  5. 9 0
      knowledge/customer-service/communication-stages.md
  6. 11 0
      knowledge/customer-service/response-rules.md
  7. 12 0
      knowledge/faq.md
  8. 2 1
      knowledge/personality.md
  9. 46 4
      knowledge/playbooks.md
  10. 32 4
      knowledge/rules.md
  11. 25 6
      mcp/src/core/agent-context-builder.js
  12. 116 0
      mcp/src/core/agent-conversation-journey.js
  13. 75 10
      mcp/src/core/agent-knowledge.js
  14. 17 4
      mcp/src/core/agent-poller-policy.js
  15. 43 6
      mcp/src/core/agent-runtime.js
  16. 105 0
      mcp/src/core/agent-workbench-db.js
  17. 109 14
      mcp/src/core/agent-workbench-service.js
  18. 19 1
      mcp/src/core/credentials.js
  19. 219 0
      mcp/src/core/inbound-media.js
  20. 197 0
      mcp/src/core/listener-runtime-control.js
  21. 16 3
      mcp/src/core/message-archive.js
  22. 7 7
      mcp/src/core/response-action-semantics.js
  23. 5 5
      mcp/src/core/response-future-promise-verifier.js
  24. 2 2
      mcp/src/core/response-generation-strategy.js
  25. 285 0
      mcp/src/core/response-human-style.js
  26. 37 37
      mcp/src/core/response-provenance-ledger.js
  27. 74 2
      mcp/src/core/response-quality-contract.js
  28. 6 6
      mcp/src/core/response-quality-planner.js
  29. 11 11
      mcp/src/core/response-quality-verifier.js
  30. 70 45
      mcp/src/core/webhook-processor.js
  31. 9 4
      mcp/src/core/webhook-store.js
  32. 9 1
      mcp/src/core/webhook-types.js
  33. 36 2
      mcp/src/dashboard/agent-service.js
  34. 23 0
      mcp/src/dashboard/app.js
  35. 5 0
      package.json
  36. 37 0
      runtime/callback-service/src/personal-polling.mjs
  37. 26 13
      scripts/agent-conversation-journey-smoke-test.js
  38. 129 0
      scripts/agent-inbound-media-smoke-test.js
  39. 10 1
      scripts/agent-intake-policy-smoke-test.js
  40. 58 0
      scripts/agent-knowledge-smoke-test.js
  41. 50 0
      scripts/agent-response-human-style-smoke-test.js
  42. 9 1
      scripts/message-archive-smoke-test.js
  43. 67 0
      scripts/online-qiwei-smoke.mjs
  44. 1 1
      scripts/sales-champion-generation-plan-smoke-test.js
  45. 262 0
      scripts/session-reply-delivery-regression-smoke-test.js
  46. 31 0
      scripts/start-dashboard.js
  47. 14 2
      scripts/validate-reusable-boundary.js

+ 19 - 3
docs/AGENT-HOUSE-MIGRATION.md

@@ -1,19 +1,35 @@
 # agent-house 可复用能力迁移边界
 
-本仓库只承载可复用的企业微信技能、MCP 工具、工作台页面与服务、Webhook/Relay 监听运行时、安装与发布流程。
+本仓库只承载可复用的企业微信技能、MCP 工具、工作台页面与服务、Webhook/Relay 监听运行时、安装与发布流程。回复角色是**行业通用客服**,北极星是把客户当前问题闭环,而不是带看成交。
 
 ## 已迁移
 
 - 通用响应质量门契约:`mcp/src/core/response-quality-contract.js`
+- 拟人干预闸门:`mcp/src/core/response-human-style.js`
+- 会话状态快照注入:`agent-conversation-journey.js` + `agent-context-builder.js`
+- 出站投递幂等:`outbound_delivery_claims`、并发只发一次、全局暂停拦截
+- 入站媒体:`inbound-media.js`、图片/纯语音不自动回复、Dashboard 媒体重试
+- 监听掉线记录:`runtime/callback-service/src/personal-polling.mjs`
+- Webhook 按账号隔离去重:`runWithQiweiContext` + `isDuplicateEvent(eventId, account)`
+- Dashboard 同时拉起 callback runtime 与 relay daemon
+- 知识检索标题加权,并剥离「常见说法」别名
 - 工作区回归 Smoke 入口:`scripts/workspace-regression-smoke-test.js`
 - 现有 `mcp/`、`skills/`、`runtime/`、`install.js`、Dashboard 与发布检查能力
 
 ## 明确排除
 
-- 客户业务定制、房产/房源匹配、真实客户画像与业务知识
+- 看房经纪人行为:房源检索、小牛看房 / xnfang、带看北极星、22 问、宋军人设、小程序房源卡片、业主/学区话术
+- `property-search-service.js`、`qiwei-property-match`、`knowledge/real-estate/**`、黄金问题集、`enforcePropertyDelivery`
+- 客户业务定制、真实客户画像与业务知识
 - 真实凭据、`.env.local`、token、设备信息
 - 运行数据库、会话、消息、日志及任何 `outputs/` 运行产物
 
+## 通用仓必须保留
+
+- `agent-memory.js` / `agent-memory-worker.js`
+- `templates/`、`validate-reusable-boundary.js`
+- `account-switch` / `message-archive-smoke` / `outbound-callback-smoke` / `online-qiwei-smoke.mjs`
+
 ## 发布前检查
 
-运行 `npm run boundary:check`。该检查会扫描发布工作区的路径与文本,发现运行态或业务定制内容时失败。运行态目录只允许在本地生成,并由 `.gitignore` 排除。
+运行 `npm run boundary:check`。该检查会扫描发布工作区的路径与文本,发现运行态、`小牛看房` / `xnfang` / `宋军` 等内容时失败。运行态目录只允许在本地生成,并由 `.gitignore` 排除。

+ 19 - 0
knowledge/README.md

@@ -0,0 +1,19 @@
+# 知识库
+
+本仓库内置的是**行业通用客服**知识,不是某个垂直行业的经纪人剧本。客户项目若要换成自己的角色、话术和阶段,只需在项目根维护 `./knowledge/`,运行时会优先加载。
+
+```
+./knowledge/
+├── rules.md                          # 核心规则(每轮强制摘要)
+├── playbooks.md                      # 话术与流程
+├── faq.md                            # 常见问题
+├── personality.md                    # 客服人格
+├── context.md                        # 工作上下文
+└── customer-service/                 # 通用客服补充材料
+    ├── chat-role.md
+    ├── chat-style.md
+    ├── response-rules.md
+    └── communication-stages.md
+```
+
+垂直行业(例如房产看房、医美、教育)的定制知识不要提交回本仓库。

+ 8 - 0
knowledge/customer-service/chat-role.md

@@ -0,0 +1,8 @@
+# 聊天角色
+
+你是企业微信里的行业通用客服,不是看房经纪人,也不是私人朋友。
+
+- 代表企业解答产品、服务、流程和进度问题。
+- 先回答当前问题,再按阶段推进一个低压力下一步。
+- 没有工具或凭证时,不承诺已预约、已发货、已调价或已转交。
+- 合同、退款、合规、投诉标记人工审核;普通咨询由你本人承接,不把问题推给不存在的“专员”。

+ 7 - 0
knowledge/customer-service/chat-style.md

@@ -0,0 +1,7 @@
+# 聊天风格
+
+- 微信短句,口语清楚,不写公文腔。
+- 避免“以……为准”“建议由……复核”“当前缺少判断依据”这类系统腔直接发给客户。
+- 一轮最多一个问号;不要用顿号罗列三项以上索取。
+- 不复读上一轮已经说过的整句。
+- 有人味但不卖萌:可以有“我这边先按这个理解”,不要堆表情。

+ 9 - 0
knowledge/customer-service/communication-stages.md

@@ -0,0 +1,9 @@
+# 沟通阶段
+
+1. 接触识别:弄清客户在问什么。
+2. 需求澄清:收集会改变方案的关键约束。
+3. 偏好取舍:帮客户排序优先级。
+4. 方案匹配:基于知识库或已接入目录给出可解释建议。
+5. 预约或转人工:仅在有证据时确认预约;否则收集信息或升级。
+
+阶段用于选择动作,不是固定话术模板。

+ 11 - 0
knowledge/customer-service/response-rules.md

@@ -0,0 +1,11 @@
+# 统一应答结构
+
+内部可按“当前问题|已确认条件|待确认条件|关键追问|回复|下一步”分析,面向客户不得输出这些栏目。
+
+客户正文优先:
+
+1. 直接回答
+2. 一句依据或边界
+3. 至多一个下一步问题
+
+没有新信息时,不要为了“有下一步”而硬加预约或追问。

+ 12 - 0
knowledge/faq.md

@@ -4,6 +4,12 @@
 
 Agent 可以理解客户消息、维护多轮会话、检索客户画像和业务知识、调用已安装的通用工具、生成回复草稿,并将低置信或敏感内容提交人工审核。
 
+## 费用怎么确认
+
+没有核验结果时,不报具体价格或折扣。费用要按服务范围、套餐和人数确认;核价前只能说明边界,并转人工或专员核验。
+
+> 常见说法:怎么收费、多少钱、报价、最低价
+
 ## 为什么有些回复需要人工确认
 
 价格承诺、合同条款、退款、隐私和投诉等内容风险较高。系统保留 Agent 草稿和依据,由人工编辑或批准后发送。
@@ -15,3 +21,9 @@ Agent 可以理解客户消息、维护多轮会话、检索客户画像和业
 ## 数据边界
 
 客户画像和推荐结果必须来自已接入的数据源。未接入的数据不应被当成真实业务事实。
+
+## 常见说法
+
+> 常见说法:客服机器人、自动回复、AI 助手、智能客服
+
+以上别名只用于检索命中,不要对客户念出同义词清单。

+ 2 - 1
knowledge/personality.md

@@ -1,6 +1,7 @@
 # 客服人格
 
-- 表达自然、克制、可信,像熟悉业务的企业客服,不模拟私人关系。
+- 表达自然、克制、可信,像熟悉业务的企业客服,不模拟私人关系,也不扮演某个行业销售冠军
 - 先解决客户当前问题,再补充最关键的一项追问。
 - 不用夸张承诺、催促式销售话术或连续反问。
 - 不确定时明确说明需要核实,不用流畅表达掩盖证据不足。
+- 用微信短句说话:先接住对方,再给判断或边界,最后最多一个下一步。

+ 46 - 4
knowledge/playbooks.md

@@ -2,24 +2,66 @@
 
 ## 首次咨询
 
-确认客户当前问题;识别目标、预算、计划时间三项基础需求;一次只追问一个最影响方案的缺失条件
+先直接回答客户当前问题,再只追问一个最影响判断的缺失条件。首次咨询不要求一次问完所有字段,也不默认邀约
 
 ## 方案建议
 
-按“为什么适合、关键事实、待确认风险、建议下一步”的顺序表达。不要只报分数,也不要把评分当成客观事实。
+按场景选用“为什么适合、关键事实、待确认风险、建议下一步”,不机械写成固定四段。不要只报分数,也不要把评分当成客观事实;没有具体候选时不得假装已经给出方案
 
 ## 异议处理
 
-按场景启用“复述顾虑 → 已知证据/未知信息 → 一个关键问题 → 可执行下一步”。不要机械显示四段标题;未知信息交给人工或业务工具核验,不使用话术掩盖不确定性。
+先复述客户顾虑,再说明已有证据和未知信息。未知信息交给人工或业务工具核验,不使用话术掩盖不确定性。
 
 ## 催促与投诉
 
-催促先直接说明当前已知进度和缺口,再给明确的核验或回访动作。投诉先确认客户核心诉求和当前事实,涉及承诺、退款、合同或责任判断时转人工,不用绝对承诺安抚。
+催促先直接说明当前已知进度和缺口,再给明确的核验或回访动作。投诉先确认客户核心诉求和当前事实,涉及承诺、退款、合同或责任判断时转主管,不用绝对承诺安抚。
 
 ## 工具无结果
 
 明确说明当前数据源没有返回可验证结果,区分“没有结果”和“事实不存在”。最多补问一个会改变检索结果的条件,或给出人工核验的下一步。
 
+## 跨轮去重与纠错
+
+1. 首次缺证据:自然说明一次“目前拿不准”,不讲系统流程。
+2. 再次追问:识别客户本轮要的是比较、缩选、安排还是纠错,切换到对应动作,不复读“核实后告诉您”。
+3. 指出重复或矛盾:先承认“前面说绕了/说早了”,再说当前能确定的事实,不辩解。
+4. 证据增加:从“不确定”升级为“部分相对判断”或“明确结论”,旧的不确定话术立即退出。
+5. 每轮只推进一个下一步,且必须比上一轮更具体;没有新增事实时,不新增未来承诺。
+
+## S1-S5 单轮行动
+
+### S1:回答并识别一个条件
+
+- 动作:回答客户本轮问题,再补一个条件。
+- 不做:不问预约时间,不把首次咨询变成问卷。
+
+### S2:补一个真正缺口
+
+- 动作:从未确认字段里选择一个会改变方案结果的问题。
+- 去重:目标、范围、时间已有答案时,只选一个新缺口,不换说法重问。
+
+### S3:帮助客户做取舍
+
+- 动作:给方向判断、理由和代价,再让客户选优先级。
+- 判断边界:可以说同类经验和比较方法,不把经验写成已确认的价格、库存或承诺。
+
+### S4:方案或资料驱动推进
+
+- 有具体方案且客户感兴趣:说明匹配点和一个风险,再低压力给下一步。
+- 客户要资料:已有材料就先交付;缺失时说明材料状态,不承诺尚未执行的发送。
+- 没有方案、资料或兴趣信号:继续回答和缩选,不硬塞预约。
+
+### S5:锁时间与风险
+
+- 客户愿意预约后,只确认一个具体时间项;同时保留一个必须人工核对的关键风险。
+- 没有预约证据:说“咱们约周几几点”,不说“已经约好、已经安排”。
+
+### 禁止的循环模板
+
+- 禁止每轮都写:“我先帮您整理 → 您哪天方便 → 我再给您确认。”
+- 禁止每轮都写:“这项我还要核实 → 核实好告诉您。”
+- 禁止固定四段式。短确认可以只有一句,比较可以用两句,风险场景才补第三句。
+
 ## 跟进节奏
 
 客户未回复时不要连续发送。需要再次跟进时,应由人工策略或明确计划触发,而不是 Agent 自行频繁营销。

+ 32 - 4
knowledge/rules.md

@@ -1,9 +1,15 @@
 # 企业微信回复规则
 
+## 目标总纲(最高优先级)
+
+- **北极星 = 把客户当前问题闭环**:先答清本轮问题,再推进一个可执行下一步(补一项关键信息、给方案边界、预约、或转人工)。
+- **不是每一轮都要成交或邀约**。S1–S3 以解答和澄清为主;客户主动要求预约、下单或进入明确推进阶段后,才约时间或转执行。
+- **尊重节奏**:不催促、不连续追问、不在客户明确结束对话后继续营销。客户拒绝时先确认障碍,再改路径。
+
 ## 真实性
 
-- 不编造价格、优惠、资格、合同条款、库存或服务承诺。
-- 业务工具没有返回的数据必须明确说“需要进一步确认”。
+- 不编造价格、优惠、资格、合同条款、库存、档期或服务承诺。
+- 业务工具没有返回的数据必须明确说“需要进一步确认”。同一未知事实只完整说明一次,后续轮次不复读“系统还在核实”。
 - 推荐结论必须能追溯到客户明确表达或知识库证据。
 
 ## 沟通方式
@@ -12,7 +18,24 @@
 - 首句直接回答客户当前问题,不用重复问候、解释性铺垫或“我正在处理”之类空泛过程语。
 - 只补充必要依据,再追问信息增益最高的一个缺失信息;整条回复问号最多 1 个。
 - 末句给与当前阶段匹配的可执行下一步,不做未经证实的保证或绝对承诺。
-- 不连续轰炸客户,不在客户明确结束对话后继续营销。
+- 不外泄内部状态:质量分、系统、数据库、规则命中、降级原因不得发给客户。
+- 普通咨询由客服本人承接;只有合同、退款、合规、投诉等敏感事项才明确转人工或主管。
+
+### 跨轮自然对话
+
+- 一项未知事实只完整解释一次。客户继续追问时,先回应其新动作(比较、缩选、约时间、纠错或转人工),不复读处理流程。
+- 不用“正在核实、核好告诉您、没有新的结果”代替回答;这些是内部任务状态,不是客户价值。
+- 客户指出重复、绕弯或前后不一致时,先用一句话承认表达问题,再给当前能确定的判断。
+- 已有部分证据时,直接说证据支持的相对判断,并把未知边界压缩为半句。
+- 下一步必须改变会话状态;不得连续两轮重复“之后再告诉您”。
+
+### 分阶段回复节奏
+
+- **S1 首次接触**:先实质回答,只补一个最影响理解的条件;客户未主动提预约时不邀约。
+- **S2 需求澄清**:只补一个会改变方案的缺口;已确认字段禁止换说法重问。
+- **S3 偏好取舍**:给“更倾向哪边 + 理由 + 代价”,不继续机械采集。
+- **S4 方案匹配**:基于知识库或已接入数据给可解释建议;没有候选时不得假装已经筛出结果。
+- **S5 预约 / 转人工**:有证据才确认预约或交接;未完成真实预约时只说“约哪个时间”,不得说“已经约好”。
 
 ## 发送前质量校验
 
@@ -29,6 +52,11 @@
 
 ## 客户咨询
 
-- 需求识别优先关注客户目标、预算、时间计划、决策人和排斥项。
+- 需求识别优先关注客户目标、预算/范围、时间计划、决策人和排斥项。
 - 第一次建议属于初步判断,需要通过追问和真实反馈继续校准。
 - 提供方案时同时展示适配优势和待确认风险。
+
+@context customer-service/chat-role.md
+@context customer-service/chat-style.md
+@context customer-service/response-rules.md
+@context customer-service/communication-stages.md

+ 25 - 6
mcp/src/core/agent-context-builder.js

@@ -1,3 +1,5 @@
+const { buildConversationStateSnapshot } = require('./agent-conversation-journey');
+
 function selectAuthoritativeHistory(messages = [], limit = 10) {
   const history = messages.filter(message => message.role !== 'system');
   const latestInbound = history.findLastIndex(message => message.role === 'user');
@@ -60,16 +62,24 @@ class AgentContextBuilder {
     const profile = context.profile?.profile || context.profile || {};
     const profileEvidence = profile.__evidence || {};
     const intelligence = context.customerIntelligence || {};
+    const stateSnapshot = context.conversationStateSnapshot || buildConversationStateSnapshot({
+      messages,
+      inboundContent: context.inboundContent,
+      profile,
+      customerIntelligence: intelligence,
+      toolTrace: context.toolTrace,
+    });
     const activeTasks = (intelligence.tasks || []).filter(item => ['open', 'in_progress'].includes(item.status));
     const activeAlerts = (intelligence.alerts || []).filter(item => ['open', 'acknowledged'].includes(item.status));
     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),
+      instructions: Math.floor(usable * 0.28),
+      recentConversation: Math.floor(usable * 0.27),
+      stateSnapshot: Math.floor(usable * 0.15),
+      profileEvidence: Math.floor(usable * 0.12),
+      unfinishedQuestions: Math.floor(usable * 0.10),
+      toolFacts: Math.floor(usable * 0.08),
     };
     const instructions = clip([
       context.directPrompt || '',
@@ -93,6 +103,13 @@ class AgentContextBuilder {
       `当前客户画像事实:${safeJson(Object.fromEntries(Object.entries(profile).filter(([key]) => key !== '__evidence')))}`,
       `画像证据索引:${safeJson(profileEvidence)}`,
     ].join('\n'), budgets.profileEvidence);
+    const stateBlock = clip([
+      `已知客户约束快照:${safeJson(stateSnapshot.knownConstraints)}`,
+      `未决关键风险:${safeJson(stateSnapshot.unresolvedRisks)}`,
+      `持续动作状态:${safeJson(stateSnapshot.highIntent)}`,
+      `已知字段禁止重复追问:${safeJson(stateSnapshot.knownFields)}`,
+      '跨轮快照优先于被裁剪的旧原话;除非有新的确认或工具证据,不得清空未决风险、重问已知约束或重置预约/下单意图。',
+    ].join('\n'), budgets.stateSnapshot);
     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 })))}`,
@@ -103,6 +120,7 @@ class AgentContextBuilder {
     const prompt = clip([
       `【执行约束】\n${instructions}`,
       `【最近有效会话(最高优先级)】\n${authoritative}`,
+      `【跨轮状态快照】\n${stateBlock}`,
       `【客户画像证据】\n${profileBlock}`,
       `【未完成问题】\n${unfinishedBlock}`,
       `【工具事实】\n${tools}`,
@@ -113,7 +131,8 @@ class AgentContextBuilder {
         budget,
         used: key === 'instructions' ? instructions.length
           : key === 'recentConversation' ? authoritative.length
-            : key === 'profileEvidence' ? profileBlock.length
+          : key === 'profileEvidence' ? profileBlock.length
+              : key === 'stateSnapshot' ? stateBlock.length
               : key === 'unfinishedQuestions' ? unfinishedBlock.length
                 : tools.length,
       },

+ 116 - 0
mcp/src/core/agent-conversation-journey.js

@@ -25,6 +25,119 @@ function uniqueStrings(values = []) {
   return [...new Set(values.map(value => String(value || '').trim()).filter(Boolean))].sort();
 }
 
+const CONSTRAINT_RULES = Object.freeze([
+  { key: 'budget', pattern: /预算|价格上限|费用上限|投入上限|总价|金额|\d+(?:\.\d+)?\s*万/, profileKeys: /budget|price|maxamount|investment|funding|预算|总价|金额/i },
+  { key: 'scope', pattern: /范围|地区|城市|门店|区域|哪边|服务范围/, profileKeys: /region|area|city|scope|district|区域|地区|城市|范围/i },
+  { key: 'spec', pattern: /规格|型号|版本|套餐|配置|方案/, profileKeys: /spec|model|version|package|plan|规格|型号|版本|套餐|配置|方案/i },
+  { key: 'timeline', pattern: /什么时候|何时|哪天|周期|交付|上线|截止/, profileKeys: /timeline|deadline|startat|endat|时间|日期|周期/i },
+  { key: 'appointmentIntent', pattern: /预约|约时间|到店|上门|现场|面谈/, profileKeys: /appointment|schedule|visit|预约|到店|上门|面谈/i },
+  { key: 'channelPreference', pattern: /电话|微信|邮件|线下|远程/, profileKeys: /channel|phone|wechat|email|渠道/i },
+]);
+
+const RISK_RULES = Object.freeze([
+  { category: 'policy', pattern: /资质|资格|政策|许可|名额|报名/, profilePattern: /policy|eligibility|资质|资格|政策|许可|名额/i },
+  { category: 'pricing', pattern: /价格|费用|报价|优惠|最低价|折扣/, profilePattern: /price|fee|discount|价格|费用|报价|优惠/i },
+  { category: 'legal', pattern: /合同|签约|违约|法律|权属/, profilePattern: /legal|contract|合同|签约|违约|法律/i },
+  { category: 'product_condition', pattern: /质量|故障|损坏|不能用|瑕疵/, profilePattern: /condition|quality|质量|故障|损坏|瑕疵/i },
+  { category: 'complaint', pattern: /投诉|举报|不处理|退款|赔偿/, profilePattern: /complaint|refund|投诉|退款|赔偿/i },
+]);
+
+const PENDING_STATUS_PATTERN = /pending|unverified|unknown|待确认|待核验|待核实|未核验|未确认|不确定|尚未|未解决/i;
+const RESOLVED_STATUS_PATTERN = /verified|confirmed|resolved|closed|已确认|已核验|已核实|已解决|已关闭/i;
+
+function messageRole(message = {}) {
+  return message.role || (message.direction === 'inbound' ? 'user' : 'assistant');
+}
+
+function messageText(message = {}) {
+  return String(message.content || message.text || '').trim();
+}
+
+function customerTurns(input = {}) {
+  const messages = (input.messages || [])
+    .filter(message => messageRole(message) === 'user')
+    .map(messageText)
+    .filter(Boolean);
+  const current = String(input.inboundContent || input.inbound || '').trim();
+  return [...messages, ...(current && messages.at(-1) !== current ? [current] : [])];
+}
+
+function flattenProfile(value, prefix = '', output = []) {
+  if (!value || typeof value !== 'object') return output;
+  for (const [key, child] of Object.entries(value)) {
+    if (key === '__evidence') continue;
+    const path = prefix ? `${prefix}.${key}` : key;
+    if (child && typeof child === 'object' && !Array.isArray(child)) flattenProfile(child, path, output);
+    else if (child !== undefined && child !== null && String(child).trim() !== '') output.push({ path, value: String(child) });
+  }
+  return output;
+}
+
+function buildConversationStateSnapshot(input = {}) {
+  const turns = customerTurns(input);
+  const current = String(input.inboundContent || input.inbound || '').trim();
+  const profile = input.profile?.profile || input.profile || {};
+  const profileEntries = flattenProfile(profile);
+  const knownConstraints = {};
+  for (const rule of CONSTRAINT_RULES) {
+    const profileMatch = profileEntries.find(entry => rule.profileKeys.test(entry.path));
+    const messageMatch = [...turns].reverse().find(text => rule.pattern.test(text));
+    if (!profileMatch && !messageMatch) continue;
+    const source = messageMatch ? 'conversation' : 'profile';
+    knownConstraints[rule.key] = canonicalize({
+      value: messageMatch || profileMatch?.value,
+      source,
+      evidence: messageMatch || profileMatch?.path,
+      current: Boolean(messageMatch && messageMatch === current),
+    });
+  }
+
+  const activeAlerts = Array.isArray(input.customerIntelligence?.alerts)
+    ? input.customerIntelligence.alerts.filter(item => ['open', 'acknowledged'].includes(item.status))
+    : [];
+  const unresolvedRisks = [];
+  for (const rule of RISK_RULES) {
+    const matchingTurns = turns.filter(text => rule.pattern.test(text));
+    const alert = activeAlerts.find(item => rule.pattern.test(`${item.business_key || item.businessKey || ''} ${item.title || ''} ${item.detail || ''}`));
+    const categoryStatusText = profileEntries
+      .filter(entry => rule.profilePattern.test(entry.path) || rule.pattern.test(entry.path) || rule.pattern.test(entry.value))
+      .map(entry => `${entry.path}:${entry.value}`)
+      .join('\n');
+    const pending = PENDING_STATUS_PATTERN.test(categoryStatusText) || Boolean(alert);
+    const resolved = Boolean(categoryStatusText) && RESOLVED_STATUS_PATTERN.test(categoryStatusText) && !pending;
+    if ((!matchingTurns.length && !alert) || resolved) continue;
+    const latestEvidence = alert?.detail || matchingTurns.at(-1);
+    unresolvedRisks.push(canonicalize({
+      category: rule.category,
+      evidence: String(latestEvidence || '').slice(0, 180),
+      source: matchingTurns.some(text => text === current) ? 'current' : 'history',
+      carried: Boolean(matchingTurns.length && !matchingTurns.some(text => text === current)),
+      critical: true,
+      criticalHard: false,
+    }));
+  }
+
+  const dialogue = turns.join('\n');
+  const scheduleActive = /预约|约时间|到店|上门|现场|面谈/.test(dialogue);
+  const orderActive = /下单|付款|签约|购买|成交/.test(dialogue);
+  const scheduleExecution = /(?:帮我|替我|直接|马上|现在).{0,8}(?:约|预约|安排|定下)|(?:确认|锁定|敲定).{0,8}(?:时间|档期|预约)/.test(current);
+  const orderExecution = /(?:帮我|替我|直接).{0,8}(?:下单|付款|签约)/.test(current);
+  const highIntent = scheduleActive
+    ? { active: true, kind: scheduleExecution ? 'schedule_commit' : 'schedule_intake', carried: !/(?:预约|约时间|到店|上门|现场|面谈)/.test(current), executionRequired: scheduleExecution }
+    : orderActive
+      ? { active: true, kind: orderExecution ? 'order_execute' : 'order_intake', carried: !/(?:下单|付款|签约|购买)/.test(current), executionRequired: orderExecution }
+      : { active: false, kind: 'none', carried: false, executionRequired: false };
+
+  return canonicalize({
+    version: '1.0',
+    knownConstraints,
+    knownFields: Object.keys(knownConstraints).sort(),
+    unresolvedRisks,
+    highIntent,
+    sourceMessageCount: turns.length,
+  });
+}
+
 function createJourneyDefinition(input = {}) {
   const id = String(input.id || '').trim();
   if (!id) throw new Error('Journey id is required.');
@@ -393,8 +506,11 @@ function serializeJourneyState(state) {
 module.exports = {
   APPLIED_ACTION_STATUSES,
   CONFIRMING_EVIDENCE_TYPES,
+  CONSTRAINT_RULES,
+  RISK_RULES,
   createJourneyDefinition,
   createJourneyState,
+  buildConversationStateSnapshot,
   applyJourneyFacts,
   isStageComplete,
   planAppliedAction,

+ 75 - 10
mcp/src/core/agent-knowledge.js

@@ -23,6 +23,12 @@ function terms(text) {
   return [...new Set(expanded)];
 }
 
+const RESEARCH_KNOWLEDGE_SOURCE_PATTERN = /(?:^|\/)golden-questions(?:\/|$)|(?:黄金问题集|完整提示词|咨询报告|问答手册)/;
+
+function isSearchableKnowledgeSource(source = '') {
+  return !RESEARCH_KNOWLEDGE_SOURCE_PATTERN.test(String(source || '').replace(/\\/g, '/'));
+}
+
 function splitMarkdown(filePath, root) {
   const source = path.relative(root, filePath).replace(/\\/g, '/');
   const text = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
@@ -35,14 +41,39 @@ function splitMarkdown(filePath, root) {
     if (content) chunks.push({ id: `${source}#${chunks.length + 1}`, source, heading, content });
     body = [];
   };
+  const headingPattern = /(?:^|\/)(?:faq|playbooks|rules|chat-role|chat-style|response-rules|communication-stages)\.md$/.test(source)
+    ? /^#{1,4}\s+(.+)$/
+    : /^#{1,3}\s+(.+)$/;
   for (const line of lines) {
-    const match = line.match(/^#{1,3}\s+(.+)$/);
+    const match = line.match(headingPattern);
     if (match) { flush(); heading = match[1].trim(); } else body.push(line);
   }
   flush();
   return chunks;
 }
 
+function stripRetrievalAliases(text) {
+  return String(text || '')
+    .replace(/^[ \t]*>[ \t]*常见说法[::][^\n]*\n?/gm, '')
+    .replace(/[;;][ \t]*常见说法[::][^|\n]*/g, '')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+}
+
+function scoreKnowledgeChunk(chunk, queryTerms) {
+  const headingLower = String(chunk.heading || '').toLowerCase();
+  const contentLower = String(chunk.content || '').toLowerCase();
+  let rawScore = 0;
+  for (const term of queryTerms) {
+    const weight = Math.max(1, term.length);
+    if (headingLower.includes(term)) rawScore += weight * 2;
+    else if (contentLower.includes(term)) rawScore += weight;
+  }
+  if (rawScore <= 0) return 0;
+  const haystackLen = headingLower.length + contentLower.length + 1;
+  return rawScore / Math.log10(haystackLen + 10);
+}
+
 function normalizeContextFile(value) {
   return String(value || '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
 }
@@ -65,7 +96,9 @@ class AgentKnowledgeStore {
   }
 
   reload() {
-    this.chunks = walkMarkdown(this.knowledgeDir).flatMap(file => splitMarkdown(file, this.knowledgeDir));
+    this.chunks = walkMarkdown(this.knowledgeDir)
+      .flatMap(file => splitMarkdown(file, this.knowledgeDir))
+      .filter(chunk => isSearchableKnowledgeSource(chunk.source));
   }
 
   rulesText() {
@@ -94,7 +127,7 @@ class AgentKnowledgeStore {
     const sections = [];
     let used = 0;
     for (const chunk of selected) {
-      const content = chunk.content.replace(/^\s*@context\s+[^\r\n]+\s*$/gmi, '').trim();
+      const content = stripRetrievalAliases(chunk.content.replace(/^\s*@context\s+[^\r\n]+\s*$/gmi, '').trim());
       if (!content) continue;
       const heading = `## ${chunk.source} / ${chunk.heading}\n`;
       const remaining = this.contextCharLimit - used - heading.length - (sections.length ? 2 : 0);
@@ -109,12 +142,44 @@ class AgentKnowledgeStore {
 
   search(query, limit = 5) {
     const queryTerms = terms(query);
-    return this.chunks.map(chunk => {
-      const haystack = `${chunk.heading}\n${chunk.content}`.toLowerCase();
-      const score = queryTerms.reduce((sum, term) => sum + (haystack.includes(term) ? Math.max(1, term.length) : 0), 0);
-      return { ...chunk, score };
-    }).filter(item => item.score > 0).sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(10, limit)))
-      .map(item => ({ id: item.id, source: item.source, heading: item.heading, content: item.content, score: item.score }));
+    return this.chunks.map(chunk => ({ ...chunk, score: scoreKnowledgeChunk(chunk, queryTerms) }))
+      .filter(item => item.score > 0)
+      .sort((a, b) => b.score - a.score)
+      .slice(0, Math.max(1, Math.min(10, limit)))
+      .map(item => ({
+        id: item.id,
+        source: item.source,
+        heading: item.heading,
+        content: stripRetrievalAliases(item.content),
+        score: item.score,
+      }));
+  }
+
+  compactRulesText() {
+    return [
+      '【当前活动行为规则:knowledge/rules.md + knowledge/playbooks.md】',
+      '- 身份:企业微信行业通用客服,不是看房经纪人,也不是私人朋友',
+      '- 北极星:把客户当前问题闭环;先答清本轮问题,再推进一个可执行下一步',
+      '- 不是每一轮都要成交或邀约;客户主动要求预约、下单或进入明确推进阶段后,才约时间或转执行',
+      '- 提问纪律:每轮最多一个真正会改变判断的问题,不是每轮必须提问',
+      '- 未知事实:只完整说明一次自然边界;不复读核实流程和未来承诺',
+      '- 真实性:不编造价格、优惠、资格、合同条款、库存、档期或服务承诺',
+      '- 沟通:像真人微信交流,普通回复限定 1-3 句、优先 50-80 字、问号最多 1 个',
+    ].join('\n');
+  }
+
+  knowledgeIndex() {
+    const files = new Map();
+    for (const chunk of this.chunks) {
+      if (!files.has(chunk.source)) files.set(chunk.source, { source: chunk.source, headings: [] });
+      const entry = files.get(chunk.source);
+      if (!entry.headings.includes(chunk.heading)) entry.headings.push(chunk.heading);
+    }
+    const entries = [...files.values()].map(item =>
+      `  knowledge/${item.source}(${item.headings.slice(0, 5).join(' / ')}${item.headings.length > 5 ? ' …' : ''})`
+    );
+    if (!entries.length) return '';
+    return ['【可用知识库文件(用 Read 工具按需检索,避免全量读取)】', ...entries].join('\n');
   }
 
   stats() {
@@ -126,4 +191,4 @@ class AgentKnowledgeStore {
   }
 }
 
-module.exports = { AgentKnowledgeStore };
+module.exports = { AgentKnowledgeStore, stripRetrievalAliases, scoreKnowledgeChunk };

+ 17 - 4
mcp/src/core/agent-poller-policy.js

@@ -1,5 +1,7 @@
 'use strict';
 
+const { MEDIA_MESSAGE_TYPES, mediaMessageDescriptor } = require('./inbound-media');
+
 const PERSONAL_INTAKE_MODES = ['allowlist_only', 'auto_enroll_review', 'auto_enroll_autopilot'];
 
 function normalizePersonalIntakeMode(value) {
@@ -45,9 +47,11 @@ function messageContent(message = {}) {
 }
 
 function evaluatePolledMessage(message = {}, config = {}) {
-  if (![0, 1, 2].includes(Number(message.msgType))) return { eligible: false, reason: 'unsupported_type' };
+  const msgType = Number(message.msgType);
+  const media = mediaMessageDescriptor(message);
+  if (![0, 1, 2, ...MEDIA_MESSAGE_TYPES].includes(msgType)) return { eligible: false, reason: 'unsupported_type' };
   if (isGroupMessage(message)) return { eligible: false, reason: 'group_message', roomId: roomIdOf(message) };
-  const content = messageContent(message);
+  const content = media?.content || messageContent(message);
   const senderId = String(message.senderId || '');
   if (!content || !senderId) return { eligible: false, reason: 'empty_message' };
   if (senderId === String(config.selfUserId || '')) return { eligible: false, reason: 'self_message' };
@@ -56,8 +60,17 @@ function evaluatePolledMessage(message = {}, config = {}) {
   if (!allowlisted && intakeMode === 'allowlist_only') return { eligible: false, reason: 'not_allowlisted' };
   const timestamp = messageTimestamp(message.timestamp);
   if (!timestamp) return { eligible: false, reason: 'invalid_timestamp' };
-  // 使用持久化游标恢复停机期间的积压消息;不要再按本次进程启动时间丢弃。
-  return { eligible: true, content, senderId, timestamp, allowlisted, unknownContact: !allowlisted, intakeMode };
+  return {
+    eligible: true,
+    content,
+    senderId,
+    timestamp,
+    allowlisted,
+    unknownContact: !allowlisted,
+    intakeMode,
+    media,
+    generateReply: !media?.mediaOnly,
+  };
 }
 
 module.exports = {

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

@@ -5,14 +5,20 @@ const { spawn } = require('child_process');
 const { AgentContextBuilder, selectAuthoritativeHistory } = require('./agent-context-builder');
 const { planResponse, plannerInstruction } = require('./response-quality-planner');
 const { buildRewriteInstruction, verifyResponseQuality } = require('./response-quality-verifier');
-const { normalizeQualityChecks, normalizeQualityScore, qualityFallbackReason } = require('./response-quality-contract');
+const { deriveHumanReviewState, normalizeQualityChecks, normalizeQualityScore, qualityFallbackReason } = require('./response-quality-contract');
 const { planSalesMove } = require('./agent-sales-move-planner');
 const {
   buildResponseGenerationStrategy,
   responseGenerationInstruction,
 } = require('./response-generation-strategy');
+const { buildConversationStateSnapshot } = require('./agent-conversation-journey');
+const {
+  enforceHumanReplyStyle,
+  enforceSingleFinalReply,
+  enforceTabooFreeReply,
+} = require('./response-human-style');
 
-const RISK_PATTERN = /合同|签约|产权|权属|资质|资格|政策|学区|入学|学位|合规|保证|承诺|最低价|成交价|报价|优惠|贷款|利率|税费|投诉|退款|赔偿|看房|预约|出价|面谈|付款|下单|发票|身份证|银行卡|法律|违约/;
+const RISK_PATTERN = /合同|签约|权属|资质|资格|政策|合规|保证|承诺|最低价|报价|优惠|贷款|利率|税费|投诉|退款|赔偿|预约|出价|面谈|付款|下单|发票|身份证|银行卡|法律|违约/;
 const NO_REPLY_NEEDED_PATTERN = /^(?:收到|行|行的|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
 
 function knownProfileFields(profile = {}) {
@@ -885,6 +891,7 @@ class QiweiAgentRuntime {
         qualityScore: null,
         qualityChecks: [],
         qualityPassed: true,
+        humanReview: { required: false, reasonCode: '', customerBoundaryRequired: false, requiredHumanRole: '' },
         qualityPlan: null,
         rewriteCount: 0,
         fallbackReason: null,
@@ -901,6 +908,13 @@ class QiweiAgentRuntime {
     const toolTrace = [];
     const citations = [];
     const currentProfile = profile?.profile || profile || {};
+    const conversationStateSnapshot = buildConversationStateSnapshot({
+      inboundContent,
+      messages: history,
+      profile: currentProfile,
+      customerIntelligence,
+      toolTrace,
+    });
     let responsePlan = null;
     let salesMovePlan = null;
     let responseGenerationPlan = null;
@@ -915,6 +929,7 @@ class QiweiAgentRuntime {
         citations,
         businessGoal: this.config.businessGoal,
         stagePlaybook: this.config.stagePlaybook,
+        conversationStateSnapshot,
       });
       const requiredFields = Array.isArray(this.config.requiredProfileFields) ? this.config.requiredProfileFields : [];
       const knownFields = knownProfileFields(currentProfile);
@@ -1001,8 +1016,20 @@ class QiweiAgentRuntime {
       const assess = content => {
         const parsed = parseFinal(content);
         const grounded = enforceAuthoritativeGrounding(parsed, authoritativeHistory, currentProfile, inboundContent);
+        const styled = enforceSingleFinalReply(enforceTabooFreeReply(enforceHumanReplyStyle(
+          grounded,
+          inboundContent,
+          responseGenerationPlan || {},
+          {
+            messages: history,
+            profile: currentProfile,
+            toolTrace,
+            citations,
+            variantKey: conversation?.id || conversation?.contact_id || '',
+          },
+        )));
         const quality = verifyResponseQuality({
-          reply: grounded.reply,
+          reply: styled.reply,
           inboundContent,
           messages: history,
           profile: currentProfile,
@@ -1010,10 +1037,11 @@ class QiweiAgentRuntime {
           citations,
           toolTrace,
           plan: responsePlan,
-          requiresHuman: Boolean(grounded.requiresHuman),
+          requiresHuman: Boolean(styled.requiresHuman),
           passScore: this.config.qualityPassScore,
+          conversationStateSnapshot,
         });
-        return { final: grounded, quality };
+        return { final: styled, quality };
       };
 
       let candidate = assess(assistant.content);
@@ -1129,13 +1157,20 @@ 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);
+      const humanReview = deriveHumanReviewState({
+        plan: responsePlan,
+        qualityPassed: candidate.quality.passed,
+        confidence,
+        modelRequiresHuman: Boolean(final.requiresHuman),
+      });
+      const requiresHuman = Boolean(final.requiresHuman || responsePlan.requireHumanHandoff || risky || confidence < 0.7 || !candidate.quality.passed || humanReview.required);
       return {
         content: String(final.reply || '').trim(),
         confidence,
         intent: String(final.intent || 'unknown'),
         reason: String(final.reason || 'Agent 未提供说明'),
         requiresHuman,
+        humanReview,
         profileUpdates: intelligence.profileUpdates,
         tasks: intelligence.tasks,
         alerts: intelligence.alerts,
@@ -1151,9 +1186,11 @@ class QiweiAgentRuntime {
           qualityPassed: candidate.quality.passed,
           rewriteCount,
           requiresHuman,
+          humanReview,
         }),
         salesMovePlan,
         responseGenerationPlan,
+        conversationStateSnapshot,
       };
     }
     throw new Error('Agent 工具调用轮次超过上限,已转人工处理');

+ 105 - 0
mcp/src/core/agent-workbench-db.js

@@ -95,6 +95,16 @@ class AgentWorkbenchDb {
         created_at TEXT NOT NULL,
         raw_json TEXT
       );
+      CREATE TABLE IF NOT EXISTS outbound_delivery_claims (
+        conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+        inbound_message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
+        claim_token TEXT NOT NULL,
+        status TEXT NOT NULL DEFAULT 'sending' CHECK(status IN ('sending','sent','failed')),
+        outbound_message_id TEXT,
+        claimed_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL,
+        PRIMARY KEY(conversation_id, inbound_message_id)
+      );
       CREATE TABLE IF NOT EXISTS customer_profiles (
         conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
         profile_json TEXT NOT NULL DEFAULT '{}',
@@ -284,6 +294,7 @@ class AgentWorkbenchDb {
       );
       CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
       CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(conversation_id, direction, content, created_at);
+      CREATE INDEX IF NOT EXISTS idx_delivery_claims_status ON outbound_delivery_claims(status, updated_at);
       CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts(status, created_at);
       CREATE INDEX IF NOT EXISTS idx_customer_tasks_conversation ON customer_tasks(conversation_id, status, updated_at DESC);
       CREATE INDEX IF NOT EXISTS idx_customer_alerts_conversation ON customer_alerts(conversation_id, status, updated_at DESC);
@@ -651,6 +662,100 @@ class AgentWorkbenchDb {
     return this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' ORDER BY created_at DESC,rowid DESC LIMIT 1").get(conversationId) || null;
   }
 
+  findOutboundByInboundMessageId(conversationId, inboundMessageId) {
+    const target = String(inboundMessageId || '').trim();
+    if (!target) return null;
+    const rows = this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='outbound' ORDER BY created_at DESC LIMIT 50").all(conversationId);
+    return rows.find(row => {
+      const raw = parse(row.raw_json, {});
+      return String(raw.inboundMessageId || raw.inbound_message_id || '').trim() === target;
+    }) || null;
+  }
+
+  claimOutboundDelivery(conversationId, inboundMessageId, claimToken, options = {}) {
+    const conversationKey = String(conversationId || '').trim();
+    const inboundKey = String(inboundMessageId || '').trim();
+    const token = String(claimToken || '').trim();
+    if (!conversationKey || !inboundKey || !token) throw new Error('Outbound delivery claim requires conversation, inbound, and token.');
+    const leaseMs = Math.max(1000, Number(options.leaseMs || 120000));
+    const nowIso = now();
+    const cutoff = new Date(Date.now() - leaseMs).toISOString();
+    this.db.exec('BEGIN IMMEDIATE');
+    try {
+      let existing = this.db.prepare(`SELECT * FROM outbound_delivery_claims
+        WHERE conversation_id=? AND inbound_message_id=?`).get(conversationKey, inboundKey) || null;
+      if (existing?.status === 'sent') {
+        this.db.exec('COMMIT');
+        return { status: 'sent', claim: existing };
+      }
+      if (existing?.status === 'sending' && String(existing.updated_at || '') > cutoff) {
+        this.db.exec('COMMIT');
+        return { status: 'in_flight', claim: existing };
+      }
+      if (existing) {
+        this.db.prepare(`UPDATE outbound_delivery_claims
+          SET claim_token=?, status='sending', outbound_message_id=NULL, claimed_at=?, updated_at=?
+          WHERE conversation_id=? AND inbound_message_id=?`).run(
+          token, nowIso, nowIso, conversationKey, inboundKey,
+        );
+      } else {
+        this.db.prepare(`INSERT INTO outbound_delivery_claims
+          (conversation_id, inbound_message_id, claim_token, status, claimed_at, updated_at)
+          VALUES (?, ?, ?, 'sending', ?, ?)`).run(conversationKey, inboundKey, token, nowIso, nowIso);
+      }
+      existing = this.db.prepare(`SELECT * FROM outbound_delivery_claims
+        WHERE conversation_id=? AND inbound_message_id=?`).get(conversationKey, inboundKey) || null;
+      this.db.exec('COMMIT');
+      return { status: 'claimed', claim: existing };
+    } catch (error) {
+      try { this.db.exec('ROLLBACK'); } catch {}
+      throw error;
+    }
+  }
+
+  markOutboundDeliverySent(conversationId, inboundMessageId, claimToken, outboundMessageId) {
+    const result = this.db.prepare(`UPDATE outbound_delivery_claims
+      SET status='sent', outbound_message_id=?, updated_at=?
+      WHERE conversation_id=? AND inbound_message_id=? AND claim_token=?`).run(
+      String(outboundMessageId || ''), now(), String(conversationId || ''), String(inboundMessageId || ''), String(claimToken || ''),
+    );
+    return result.changes > 0;
+  }
+
+  markOutboundDeliveryFailed(conversationId, inboundMessageId, claimToken) {
+    const result = this.db.prepare(`UPDATE outbound_delivery_claims
+      SET status='failed', updated_at=?
+      WHERE conversation_id=? AND inbound_message_id=? AND claim_token=?`).run(
+      now(), String(conversationId || ''), String(inboundMessageId || ''), String(claimToken || ''),
+    );
+    return result.changes > 0;
+  }
+
+  findRecentOutboundMatch(conversationId, content, createdAt, windowSeconds = 180) {
+    const rows = this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='outbound' AND content=? ORDER BY created_at DESC LIMIT 30").all(conversationId, String(content));
+    const timestamp = Date.parse(createdAt);
+    return rows.find(row => {
+      if (row.external_id) return false;
+      const rowTimestamp = Date.parse(row.created_at);
+      return Number.isFinite(timestamp) && Number.isFinite(rowTimestamp)
+        && Math.abs(timestamp - rowTimestamp) <= windowSeconds * 1000;
+    }) || null;
+  }
+
+  attachExternalId(messageId, externalId, raw = null) {
+    const id = String(externalId || '').trim();
+    if (!id) return this.getMessage(messageId);
+    const existing = this.db.prepare('SELECT * FROM messages WHERE external_id=?').get(id);
+    if (existing && existing.id !== messageId) return existing;
+    const current = this.getMessage(messageId);
+    if (!current) return null;
+    const mergedRaw = raw && typeof raw === 'object'
+      ? { ...parse(current.raw_json, {}), ...raw }
+      : parse(current.raw_json, {});
+    this.db.prepare('UPDATE messages SET external_id=?,raw_json=? WHERE id=?').run(id, json(mergedRaw), messageId);
+    return this.getMessage(messageId);
+  }
+
   findRecentInboundDuplicate(conversationId, content, createdAt, windowSeconds = 60) {
     const rows = this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' AND content=? ORDER BY created_at DESC LIMIT 20").all(conversationId, String(content));
     const timestamp = Date.parse(createdAt);

+ 109 - 14
mcp/src/core/agent-workbench-service.js

@@ -1,4 +1,5 @@
 const { EventEmitter } = require('events');
+const crypto = require('crypto');
 const { AgentNotConfiguredError, isNoReplyNeededMessage, buildClaudeSessionName } = require('./agent-runtime');
 const { isGroupMessage } = require('./agent-poller-policy');
 const { friendlyAgentError } = require('./agent-error-message');
@@ -42,6 +43,7 @@ class AgentWorkbenchService extends EventEmitter {
       retryBaseMs: config.memory?.extractionRetryBaseMs,
     });
     this.generationQueues = new Map();
+    this.deliveryQueues = new Map();
     this.memoryWorker.start();
   }
 
@@ -449,12 +451,14 @@ class AgentWorkbenchService extends EventEmitter {
     return result;
   }
 
-  async ingestInbound({ externalId, contactId, contactName = '', content, timestamp = new Date().toISOString(), raw = null }, options = {}) {
+  async ingestInbound({ externalId, contactId, contactName = '', content, timestamp = new Date().toISOString(), raw = null, contentType = 'text', payload = null }, options = {}) {
     if (isGroupMessage(raw || {}) || looksLikeGroupId(contactId)) return { status: 'ignored_group_message' };
     if (!this.isAllowed(contactId)) return { status: 'ignored_not_allowlisted' };
     const conversation = this.db.ensureConversation(contactId, contactName);
     ensureMemory(contactId, contactName);
-    const contentDuplicate = this.db.findRecentInboundDuplicate(conversation.id, content, timestamp, 60);
+    const contentDuplicate = contentType === 'text'
+      ? this.db.findRecentInboundDuplicate(conversation.id, content, timestamp, 60)
+      : null;
     if (contentDuplicate) {
       this.db.audit({
         actor: 'policy',
@@ -471,6 +475,8 @@ class AgentWorkbenchService extends EventEmitter {
       direction: 'inbound',
       senderType: 'customer',
       content,
+      contentType,
+      payload,
       status: 'received',
       createdAt: timestamp,
       raw,
@@ -505,6 +511,21 @@ class AgentWorkbenchService extends EventEmitter {
 
     if (options.onboarding === true) {
       this.db.updateOnboarding(this.intakeAccountKey(), current.contact_id, { first_message_id: inserted.message.id });
+    }
+
+    if (options.generateReply === false) {
+      this.queueMemoryExtraction(current, inserted.message);
+      this.db.audit({
+        actor: 'policy',
+        action: 'media_received_without_auto_reply',
+        conversationId: conversation.id,
+        entityId: inserted.message.id,
+        detail: { contentType },
+      });
+      return { status: 'media_received', conversation: current, message: inserted.message };
+    }
+
+    if (options.onboarding === true) {
       const welcome = await this.handleOnboardingWelcome(current, inserted.message);
       if (welcome) return welcome;
     }
@@ -769,19 +790,86 @@ class AgentWorkbenchService extends EventEmitter {
   }
 
   async sendAutopilotReply(conversation, inboundMessage, output, intelligence = {}) {
+    const key = `${conversation.id}:${inboundMessage.id}`;
+    const previous = this.deliveryQueues.get(key) || Promise.resolve();
+    const current = previous
+      .catch(() => undefined)
+      .then(() => this._sendAutopilotReply(conversation, inboundMessage, output, intelligence));
+    this.deliveryQueues.set(key, current);
+    try { return await current; }
+    finally { if (this.deliveryQueues.get(key) === current) this.deliveryQueues.delete(key); }
+  }
+
+  async _sendAutopilotReply(conversation, inboundMessage, output, intelligence = {}) {
     this.requireAllowed(conversation.contact_id);
     this.requirePrivateConversation(conversation.id);
+    const global = this.db.globalState();
+    if (global.paused) {
+      this.db.audit({
+        actor: 'agent:concurrency',
+        action: 'autopilot_paused_delivery_suppressed',
+        conversationId: conversation.id,
+        entityId: inboundMessage.id,
+        detail: { globalPaused: true },
+      });
+      return { status: 'paused', conversation, message: inboundMessage, intelligence };
+    }
     if (!this.isCurrentInbound(conversation.id, inboundMessage.id)) {
       const error = new Error('Agent response was superseded by a newer customer message.');
       error.code = 'AGENT_RESPONSE_STALE';
       throw error;
     }
-    if (output.qualityPassed !== true || output.requiresHuman === true || output.qualityPlan?.requireHumanHandoff === true) {
+    const alreadySent = typeof this.db.findOutboundByInboundMessageId === 'function'
+      ? this.db.findOutboundByInboundMessageId(conversation.id, inboundMessage.id)
+      : null;
+    if (alreadySent) {
+      this.db.audit({
+        actor: 'agent:concurrency',
+        action: 'autopilot_duplicate_send_suppressed',
+        conversationId: conversation.id,
+        entityId: inboundMessage.id,
+        detail: { outboundMessageId: alreadySent.id },
+      });
+      return { status: 'autopilot_already_sent', conversation, message: inboundMessage, reply: alreadySent, intelligence };
+    }
+    const deliveryClaimToken = crypto.randomUUID();
+    const deliveryClaim = typeof this.db.claimOutboundDelivery === 'function'
+      ? this.db.claimOutboundDelivery(conversation.id, inboundMessage.id, deliveryClaimToken)
+      : { status: 'claimed', claim: null };
+    if (deliveryClaim.status !== 'claimed') {
+      const claimedOutbound = deliveryClaim.claim?.outbound_message_id
+        ? this.db.getMessage(deliveryClaim.claim.outbound_message_id)
+        : null;
+      this.db.audit({
+        actor: 'agent:concurrency',
+        action: deliveryClaim.status === 'in_flight'
+          ? 'autopilot_delivery_in_flight_suppressed'
+          : 'autopilot_duplicate_send_suppressed',
+        conversationId: conversation.id,
+        entityId: inboundMessage.id,
+        detail: { outboundMessageId: claimedOutbound?.id || null, claimStatus: deliveryClaim.status },
+      });
+      return { status: 'autopilot_already_sent', conversation, message: inboundMessage, reply: claimedOutbound, intelligence };
+    }
+    if (output.qualityPassed !== true || output.requiresHuman === true || output.humanReview?.required === true || output.qualityPlan?.requireHumanHandoff === true) {
+      if (typeof this.db.markOutboundDeliveryFailed === 'function') {
+        this.db.markOutboundDeliveryFailed(conversation.id, inboundMessage.id, deliveryClaimToken);
+      }
       throw new Error('Agent 回复未通过发送前质量校验,已保留为人工审核');
     }
     const content = String(output.content || '').trim();
-    if (!content) throw new Error('Agent 没有生成可发送的回复内容');
-    if (content.length > 2000) throw new Error('回复内容过长');
+    if (!content) {
+      if (typeof this.db.markOutboundDeliveryFailed === 'function') {
+        this.db.markOutboundDeliveryFailed(conversation.id, inboundMessage.id, deliveryClaimToken);
+      }
+      throw new Error('Agent 没有生成可发送的回复内容');
+    }
+    if (content.length > 2000) {
+      if (typeof this.db.markOutboundDeliveryFailed === 'function') {
+        this.db.markOutboundDeliveryFailed(conversation.id, inboundMessage.id, deliveryClaimToken);
+      }
+      throw new Error('回复内容过长');
+    }
     try {
       const result = await this.qiwei.sendText(conversation.contact_id, content);
       const outbound = this.db.insertMessage({
@@ -799,13 +887,17 @@ class AgentWorkbenchService extends EventEmitter {
           toolTrace: output.toolTrace || [],
           qualityScore: normalizeQualityScore(output.qualityScore),
           qualityChecks: normalizeQualityChecks(output.qualityChecks),
-           qualityPassed: Boolean(output.qualityPassed),
-           rewriteCount: Number(output.rewriteCount) || 0,
-           fallbackReason: output.fallbackReason == null ? null : String(output.fallbackReason),
-           journeyActionKey: output.journeyActionKey || null,
-           primaryMove: output.salesMovePlan?.primaryMove || null,
-         },
-       }).message;
+          qualityPassed: Boolean(output.qualityPassed),
+          rewriteCount: Number(output.rewriteCount) || 0,
+          fallbackReason: output.fallbackReason == null ? null : String(output.fallbackReason),
+          journeyActionKey: output.journeyActionKey || null,
+          primaryMove: output.salesMovePlan?.primaryMove || null,
+          humanReview: output.humanReview || null,
+        },
+      }).message;
+      if (typeof this.db.markOutboundDeliverySent === 'function') {
+        this.db.markOutboundDeliverySent(conversation.id, inboundMessage.id, deliveryClaimToken, outbound.id);
+      }
       appendChatRecord({
         wxid: conversation.contact_id,
         messageId: outbound.id,
@@ -814,8 +906,8 @@ class AgentWorkbenchService extends EventEmitter {
         senderType: 'agent',
         content,
         createdAt: outbound.created_at,
-       source: 'autopilot',
-     });
+        source: 'autopilot',
+      });
       if (result?.isSendSuccess !== false) {
         this.tryConfirmConversationAction(conversation.id, output.journeyActionKey, {
           type: 'send',
@@ -851,6 +943,9 @@ class AgentWorkbenchService extends EventEmitter {
         intelligence,
       };
     } catch (error) {
+      if (typeof this.db.markOutboundDeliveryFailed === 'function') {
+        this.db.markOutboundDeliveryFailed(conversation.id, inboundMessage.id, deliveryClaimToken);
+      }
       error.agentAction = 'autopilot_send_failed';
       throw error;
     }

+ 19 - 1
mcp/src/core/credentials.js

@@ -2,10 +2,12 @@ const crypto = require('crypto');
 const fs = require('fs');
 const path = require('path');
 const os = require('os');
+const { AsyncLocalStorage } = require('async_hooks');
 
 const DEFAULT_API_BASE = 'https://server.fmode.cn/api/qiwei';
 const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', 'qiwei-credentials.json');
 let activeQiweiContext = {};
+const qiweiContextStorage = new AsyncLocalStorage();
 
 function safeAccountKey(value, fallback = 'legacy-default') {
   return String(value || fallback).trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100) || fallback;
@@ -24,7 +26,22 @@ function setActiveQiweiContext(input = {}) {
 }
 
 function getActiveQiweiContext() {
-  return { ...activeQiweiContext };
+  return { ...activeQiweiContext, ...(qiweiContextStorage.getStore() || {}) };
+}
+
+function runWithQiweiContext(input = {}, callback) {
+  if (typeof callback !== 'function') throw new TypeError('callback must be a function');
+  const parent = getActiveQiweiContext();
+  const context = {
+    ...parent,
+    uid: String(input.uid || input.qiweiUid || parent.uid || '').trim(),
+    guid: String(input.guid || input.qiweiGuid || input.deviceGuid || parent.guid || '').trim(),
+    userId: String(input.userId || input.wecomUserId || parent.userId || '').trim(),
+    nickname: String(input.nickname || parent.nickname || '').trim(),
+    corpName: String(input.corpName || parent.corpName || '').trim(),
+    apiBase: String(input.apiBase || input.baseUrl || parent.apiBase || '').trim().replace(/\/$/, ''),
+  };
+  return qiweiContextStorage.run(context, callback);
 }
 
 function readJsonMaybe(filePath) {
@@ -462,6 +479,7 @@ module.exports = {
   safeAccountKey,
   setActiveQiweiContext,
   getActiveQiweiContext,
+  runWithQiweiContext,
   isConfigured,
   readEnvFiles,
   readClaudeSettingsEnv,

+ 219 - 0
mcp/src/core/inbound-media.js

@@ -0,0 +1,219 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const WxVoiceModule = require('@binsee/wx-voice');
+const { categoryDir, ensureDir } = require('./output-paths');
+
+const WxVoice = WxVoiceModule.WxVoice || WxVoiceModule.default || WxVoiceModule;
+const MEDIA_MESSAGE_TYPES = Object.freeze([14, 16, 101]);
+const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
+
+function firstText(values) {
+  for (const value of values) {
+    if (typeof value === 'string' && value.trim()) return value.trim();
+  }
+  return '';
+}
+
+function mediaKind(message = {}) {
+  const type = Number(message.msgType);
+  if (type === 14) return 'image';
+  if (type === 16) return 'voice';
+  if (type !== 101) return '';
+  const data = message.msgData || {};
+  const hint = `${data.fileType || ''} ${data.contentType || ''} ${data.mimeType || ''} ${data.fileName || ''}`.toLowerCase();
+  if (/voice|audio|silk|amr|mp3|wav/.test(hint)) return 'voice';
+  if (/image|photo|png|jpe?g|gif|webp/.test(hint)) return 'image';
+  return 'media';
+}
+
+function mediaAnalysisText(message = {}) {
+  const data = message.msgData || {};
+  return firstText([
+    data.transcript,
+    data.voiceText,
+    data.ocrText,
+    data.caption,
+    data.description,
+  ]);
+}
+
+function mediaMessageDescriptor(message = {}) {
+  const kind = mediaKind(message);
+  if (!kind) return null;
+  const analysisText = mediaAnalysisText(message);
+  const label = kind === 'image' ? '[图片]' : kind === 'voice' ? '[语音]' : '[媒体]';
+  const data = message.msgData || {};
+  return {
+    kind,
+    label,
+    content: analysisText ? `${label} ${analysisText}` : label,
+    analysisText,
+    mediaOnly: !analysisText,
+    contentType: kind,
+    payload: {
+      mediaKind: kind,
+      duration: Number(data.voiceTime || data.duration) || 0,
+      width: Number(data.width || data.imageWidth) || 0,
+      height: Number(data.height || data.imageHeight) || 0,
+      fileName: String(data.fileName || data.filename || '').slice(0, 160),
+    },
+  };
+}
+
+function safeRemoteUrl(value) {
+  const raw = String(value || '').trim();
+  if (!/^https:\/\//i.test(raw)) return '';
+  try {
+    const url = new URL(raw);
+    return url.protocol === 'https:' ? url.toString() : '';
+  } catch {
+    return '';
+  }
+}
+
+function responseUrl(value) {
+  if (!value || typeof value !== 'object') return '';
+  const candidates = [
+    value.cloudUrl,
+    value.fileUrl,
+    value.coverUrl,
+    value.url,
+    value.data?.cloudUrl,
+    value.data?.fileUrl,
+    value.data?.coverUrl,
+    value.data?.url,
+  ];
+  for (const candidate of candidates) {
+    const url = safeRemoteUrl(candidate);
+    if (url) return url;
+  }
+  return '';
+}
+
+function sniffMedia(buffer, header = '') {
+  const contentType = String(header || '').split(';')[0].trim().toLowerCase();
+  if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return { ext: '.png', contentType: 'image/png' };
+  if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return { ext: '.jpg', contentType: 'image/jpeg' };
+  if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) return { ext: '.gif', contentType: 'image/gif' };
+  if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return { ext: '.webp', contentType: 'image/webp' };
+  if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WAVE') return { ext: '.wav', contentType: 'audio/wav' };
+  if (buffer.length >= 3 && buffer.subarray(0, 3).toString('ascii') === 'ID3') return { ext: '.mp3', contentType: 'audio/mpeg' };
+  if (buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return { ext: '.mp3', contentType: 'audio/mpeg' };
+  if (buffer.subarray(0, 10).toString('ascii').includes('#!SILK_V3')) return { ext: '.silk', contentType: 'audio/silk' };
+  if (contentType.startsWith('image/')) return { ext: contentType === 'image/jpeg' ? '.jpg' : `.${contentType.slice(6)}`, contentType };
+  if (contentType.startsWith('audio/')) return { ext: contentType === 'audio/mpeg' ? '.mp3' : `.${contentType.slice(6)}`, contentType };
+  return null;
+}
+
+async function downloadUrl(url, fetchImpl = fetch) {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), 30000);
+  try {
+    const response = await fetchImpl(url, { signal: controller.signal, redirect: 'follow' });
+    if (!response.ok) throw new Error('MEDIA_UPSTREAM_HTTP');
+    const declared = Number(response.headers.get('content-length')) || 0;
+    if (declared > MAX_MEDIA_BYTES) throw new Error('MEDIA_TOO_LARGE');
+    const buffer = Buffer.from(await response.arrayBuffer());
+    if (!buffer.length || buffer.length > MAX_MEDIA_BYTES) throw new Error(buffer.length ? 'MEDIA_TOO_LARGE' : 'MEDIA_EMPTY');
+    return { buffer, contentType: response.headers.get('content-type') || '' };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+function mediaCacheRoot() {
+  return ensureDir(path.join(categoryDir('messages'), 'inbound-media'));
+}
+
+function cacheBase(messageId) {
+  const digest = crypto.createHash('sha256').update(String(messageId)).digest('hex').slice(0, 24);
+  return path.join(mediaCacheRoot(), digest);
+}
+
+async function decodeSilk(inputPath, outputPath) {
+  const voice = new WxVoice();
+  voice.on('error', () => {});
+  await voice.decode(inputPath, outputPath);
+}
+
+class InboundMediaService {
+  constructor({ qiwei, fetchImpl = fetch } = {}) {
+    this.qiwei = qiwei;
+    this.fetchImpl = fetchImpl;
+  }
+
+  async remoteUrl(raw = {}, descriptor) {
+    const data = raw.msgData || {};
+    for (const candidate of [data.fileBigHttpUrl, data.fileMiddleHttpUrl, data.fileThumbHttpUrl, data.fileUrl, data.url]) {
+      const direct = safeRemoteUrl(candidate);
+      if (direct) return direct;
+    }
+    const fileId = String(data.fileId || '').trim();
+    const fileAeskey = String(data.fileAesKey || data.fileAeskey || '').trim();
+    if (fileId && fileAeskey) {
+      const resolved = await this.qiwei.call('/cloud/wxWorkDownload', {
+        fileId,
+        fileAeskey,
+        fileSize: Number(data.fileSize) || 0,
+        fileType: descriptor.kind === 'image' ? 1 : 5,
+      }, { sanitizeResponse: false, networkAttempts: 1 });
+      const url = responseUrl(resolved);
+      if (url) return url;
+    }
+    const fileUrl = safeRemoteUrl(data.fileUrl || data.fileBigHttpUrl || data.fileMiddleHttpUrl || data.fileThumbHttpUrl);
+    const fileAuthkey = String(data.fileAuthKey || data.fileAuthkey || '').trim();
+    if (fileUrl && fileAeskey && fileAuthkey) {
+      const resolved = await this.qiwei.call('/cloud/wxDownload', {
+        fileAeskey,
+        fileAuthkey,
+        fileSize: Number(data.fileSize) || 0,
+        fileType: descriptor.kind === 'image' ? 1 : 5,
+        fileUrl,
+      }, { sanitizeResponse: false, networkAttempts: 1 });
+      const url = responseUrl(resolved);
+      if (url) return url;
+    }
+    throw new Error('MEDIA_URL_UNAVAILABLE');
+  }
+
+  async resolve(messageId, raw = {}) {
+    const descriptor = mediaMessageDescriptor(raw);
+    if (!descriptor) throw new Error('MEDIA_MESSAGE_UNSUPPORTED');
+    const existing = raw.mediaCache || {};
+    if (existing.status === 'ready' && existing.filePath && fs.existsSync(existing.filePath)) {
+      return { ...existing, kind: descriptor.kind };
+    }
+    const url = await this.remoteUrl(raw, descriptor);
+    const downloaded = await downloadUrl(url, this.fetchImpl);
+    const detected = sniffMedia(downloaded.buffer, downloaded.contentType);
+    if (!detected) throw new Error('MEDIA_FORMAT_UNSUPPORTED');
+    const base = cacheBase(messageId);
+    if (detected.ext === '.silk') {
+      const silkPath = `${base}.silk`;
+      const mp3Path = `${base}.mp3`;
+      fs.writeFileSync(silkPath, downloaded.buffer, { mode: 0o600 });
+      try {
+        await decodeSilk(silkPath, mp3Path);
+      } finally {
+        try { fs.unlinkSync(silkPath); } catch {}
+      }
+      return { status: 'ready', kind: 'voice', filePath: mp3Path, contentType: 'audio/mpeg', size: fs.statSync(mp3Path).size };
+    }
+    const filePath = `${base}${detected.ext}`;
+    fs.writeFileSync(filePath, downloaded.buffer, { mode: 0o600 });
+    return { status: 'ready', kind: descriptor.kind, filePath, contentType: detected.contentType, size: downloaded.buffer.length };
+  }
+}
+
+module.exports = {
+  MEDIA_MESSAGE_TYPES,
+  InboundMediaService,
+  mediaKind,
+  mediaAnalysisText,
+  mediaMessageDescriptor,
+  sniffMedia,
+  responseUrl,
+};

+ 197 - 0
mcp/src/core/listener-runtime-control.js

@@ -0,0 +1,197 @@
+'use strict';
+
+// 监听宿主统一控制层
+// 设计:个人版监听由「独立 runtime 进程」(scripts/start-callback-runtime.mjs)承载,
+// Dashboard 与 MCP 工具只负责:确保 runtime 进程在跑(启动)、请求 runtime 停止(关闭),
+// 不再直接操作各自进程内的 poller(此前导致启停按钮控制不到真实监听、状态不同步)。
+
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+const { outputsRoot, PACKAGE_ROOT, WORKSPACE_ROOT } = require('./output-paths');
+const { AgentWorkbenchDb } = require('./agent-workbench-db');
+const { getProductMode } = require('./product-mode');
+const { qiweiAccountKey, readQiweiAccountMetadata } = require('./credentials');
+
+const RUNTIME_SCRIPT = path.join(PACKAGE_ROOT, 'scripts', 'start-callback-runtime.mjs');
+
+function runtimeStateFile() {
+  return path.join(outputsRoot(), 'runtime', 'qiwei-runtime.json');
+}
+
+function runtimeStopFile() {
+  return runtimeStateFile().replace(/\.json$/i, '.stop.json');
+}
+
+function readRuntimeState() {
+  try {
+    return JSON.parse(fs.readFileSync(runtimeStateFile(), 'utf8').replace(/^/, ''));
+  } catch {
+    return {};
+  }
+}
+
+function isProcessAlive(pid) {
+  const numericPid = Number(pid);
+  if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
+  try {
+    process.kill(numericPid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function isRuntimeActive() {
+  const state = readRuntimeState();
+  return isProcessAlive(state.pid) && state.status === 'running';
+}
+
+// 打开当前账号的 Workbench DB(与 agent-service 相同的文件约定),用于读写 listener_enabled
+// 注意:必须按当前账号 userId 精确计算 key(qiweiAccountKey),绝不按文件修改时间回退——
+// 多账号环境下回退会写错账号的 DB(导致 runtime 读到的 listener_enabled 不一致,2026-08-11 审查 P1#14)。
+function workbenchDb() {
+  try {
+    const key = qiweiAccountKey(readQiweiAccountMetadata({}));
+    if (!key) return null;
+    // AgentWorkbenchDb 构造即建库(sqlite 自动创建文件):当前账号 DB 不存在时直接创建,不碰其他账号
+    return new AgentWorkbenchDb(path.join(outputsRoot(), 'messages', `agent-workbench-${key}.db`));
+  } catch {
+    return null;
+  }
+}
+
+function setListenerEnabled(enabled) {
+  try {
+    const db = workbenchDb();
+    if (db) db.setSetting('listener_enabled', enabled ? 'true' : 'false');
+  } catch {
+    // DB 写入失败不阻断进程控制
+  }
+}
+
+function spawnRuntime() {
+  // --no-dashboard:Dashboard 由独立进程(start-dashboard.js)承载,runtime 只跑监听,避免 4320 端口冲突
+  const child = spawn(process.execPath, [RUNTIME_SCRIPT, 'start', '--no-dashboard'], {
+    cwd: WORKSPACE_ROOT,
+    env: { ...process.env },
+    detached: true,
+    stdio: 'ignore',
+    windowsHide: true,
+  });
+  // spawn 失败(脚本缺失/execPath 异常)触发 'error' 事件:必须监听,否则 unhandled 'error' 会崩掉整个 MCP server
+  child.on('error', (error) => {
+    console.error(`[listener-runtime] 拉起 runtime 进程失败:${error.message}`);
+  });
+  child.unref();
+  return child;
+}
+
+/** 清除挂起的停止令:显式「启动」覆盖关闭后遗留的 .stop.json,防 500ms 窗口内快速重开被旧停止令杀死(2026-08-11 审查) */
+function clearPendingStopRequest() {
+  try {
+    if (fs.existsSync(runtimeStopFile())) fs.unlinkSync(runtimeStopFile());
+  } catch {
+    // 清理失败不阻断
+  }
+}
+
+/** 等待 runtime 写入状态文件且 pid/status 就绪;未就绪返回 false(调用方报 started:false,不谎报已启动) */
+function waitForRuntimeReady(pid, timeoutMs = 3000) {
+  return new Promise((resolve) => {
+    const startedAt = Date.now();
+    const check = () => {
+      const state = readRuntimeState();
+      const ready =
+        Number(state.pid) === Number(pid) &&
+        ['starting', 'running'].includes(state.status) &&
+        isProcessAlive(pid);
+      if (ready || Date.now() - startedAt >= timeoutMs) {
+        resolve(ready);
+        return;
+      }
+      setTimeout(check, 200);
+    };
+    check();
+  });
+}
+
+// 启动监听:listener_enabled=true + 确保独立 runtime 进程在跑(未跑则拉起,runtime 自动启动监听)
+async function ensureListenerRuntime() {
+  const product = getProductMode();
+  if (product.mode === 'enterprise') {
+    return {
+      running: true,
+      transport: 'server_relay',
+      note: '企业版由服务端 Relay 常驻,无需本地启动监听',
+    };
+  }
+  setListenerEnabled(true);
+  // 显式启动优先:先清掉遗留停止令,避免「停→立刻开」竞态
+  clearPendingStopRequest();
+  if (isRuntimeActive()) {
+    const state = readRuntimeState();
+    return {
+      running: true,
+      pid: Number(state.pid) || null,
+      transport: 'local_polling',
+    };
+  }
+  const child = spawnRuntime();
+  // 验证 runtime 真的起来了(状态文件就绪 + 进程存活)才报 started:true,否则如实报失败
+  const ready = await waitForRuntimeReady(child.pid, 3000);
+  if (!ready) {
+    return {
+      running: false,
+      started: false,
+      transport: 'local_polling',
+      error: 'runtime 进程未在预期时间内就绪,监听未启动(请检查 runtime 日志)',
+    };
+  }
+  return {
+    running: true,
+    started: true,
+    transport: 'local_polling',
+    note: '本地监听 runtime 已拉起,监听将自动启动',
+  };
+}
+
+// 关闭监听:listener_enabled=false + 请求 runtime 进程停止(写 .stop.json,runtime 收到后自行退出)
+function requestListenerStop() {
+  const product = getProductMode();
+  if (product.mode === 'enterprise') {
+    setListenerEnabled(false);
+    return {
+      running: false,
+      note: '企业版仅停止 AI 处理,Relay 仍持续收集消息',
+    };
+  }
+  setListenerEnabled(false);
+  const state = readRuntimeState();
+  const pid = Number(state.pid) || null;
+  try {
+    fs.mkdirSync(path.dirname(runtimeStopFile()), { recursive: true });
+    fs.writeFileSync(
+      runtimeStopFile(),
+      `${JSON.stringify({ targetPid: pid, requestedAt: new Date().toISOString() }, null, 2)}\n`,
+      'utf8',
+    );
+  } catch {
+    // 停止请求写入失败不阻断
+  }
+  return {
+    running: false,
+    pid,
+    requestedStop: true,
+    note: '已请求监听 runtime 停止',
+  };
+}
+
+module.exports = {
+  ensureListenerRuntime,
+  requestListenerStop,
+  isRuntimeActive,
+  isProcessAlive,
+  readRuntimeState,
+  runtimeStateFile,
+};

+ 16 - 3
mcp/src/core/message-archive.js

@@ -37,12 +37,25 @@ function archiveEnabled(options = {}) {
   return String(process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED || '').trim() === '1';
 }
 
-function enqueueAppend(filePath, line, options = {}) {
+function recordDedupeKey(row = {}) {
+  const timestamp = Date.parse(row.ts || row.timestamp || '');
+  const second = Number.isFinite(timestamp) ? Math.floor(timestamp / 1000) : String(row.ts || row.timestamp || '');
+  return [row.dir || '', row.sender || row.senderId || '', String(row.content || '').trim(), second].join('\u0000');
+}
+
+function enqueueAppend(filePath, line, options = {}, dedupeKey = '') {
   if (!archiveEnabled(options)) return Promise.resolve({ skipped: true });
   const previous = writeQueues.get(filePath) || Promise.resolve();
   const next = previous.then(() => {
     try {
       fs.mkdirSync(path.dirname(filePath), { recursive: true });
+      if (dedupeKey && fs.existsSync(filePath)) {
+        const exists = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).some(raw => {
+          if (!raw.trim()) return false;
+          try { return recordDedupeKey(JSON.parse(raw)) === dedupeKey; } catch { return false; }
+        });
+        if (exists) return { written: false, deduped: true, filePath };
+      }
       fs.appendFileSync(filePath, `${line}\n`, 'utf8');
       return { written: true, filePath };
     } catch (error) {
@@ -70,7 +83,7 @@ function appendChatRecord({ wxid, messageId, externalId, dir, senderType, conten
     source: source || '',
   };
   const filePath = path.join(MESSAGES_ROOT, 'chat', accountDir(), safeDirName(wxid), `${dateOf(row.ts)}.json`);
-  return enqueueAppend(filePath, JSON.stringify(row), options);
+  return enqueueAppend(filePath, JSON.stringify(row), options, recordDedupeKey(row));
 }
 
 function appendGroupRecord({ roomId, messageId, seq, msgType, senderId, senderName, self, content, timestamp }, options = {}) {
@@ -87,7 +100,7 @@ function appendGroupRecord({ roomId, messageId, seq, msgType, senderId, senderNa
     content: String(content ?? ''),
   };
   const filePath = path.join(MESSAGES_ROOT, 'group', accountDir(), safeDirName(roomId), `${dateOf(row.ts)}.json`);
-  return enqueueAppend(filePath, JSON.stringify(row), options);
+  return enqueueAppend(filePath, JSON.stringify(row), options, recordDedupeKey(row));
 }
 
 function memoryTemplate(name, contactName = '') {

+ 7 - 7
mcp/src/core/response-action-semantics.js

@@ -73,17 +73,17 @@ const ACTION_PRIORITY = Object.freeze([
   RESPONSE_ACTION_TYPES.NONE,
 ]);
 
-const PROPERTY_IDENTITY_PATTERN = /(?:房源号|房源编号|房源链接|小程序链接|链接|截图|小区名|楼盘名|具体房源|具体哪套|哪一套|哪套房|哪套|哪个小区|哪一个小区|您说的是|你说的是)/;
-const IDENTITY_RESOLUTION_PATTERN = /(?:对准|对上|定位|分清|确认|确定|知道|说清|告诉|发我|给我|是哪套|指哪套|哪一套|哪套房|哪个小区)/;
-const PROPERTY_DETAIL_PATTERN = /(?:价格|价位|报价|挂牌价|成交价|底价|在售|房源状态|车位|停车|楼型|楼层|大高层|高层|总层数|房况|产权|学区|入学|税费|贷款|利率|业主心态|谈价空间)/;
-const MEDIA_PATTERN = /(?:户型图|平面图|照片|图片|实拍|视频|媒体|实景|现场|房子什么样|房源资料)/;
+const PROPERTY_IDENTITY_PATTERN = /(?:房源号|房源编号|房源链接|小程序链接|链接|截图|小区名|楼盘名|具体房源|具体哪套|哪一套|哪套房|哪套|哪个小区|哪一个小区|方案编号|产品编号|订单号|您说的是|你说的是)/;
+const IDENTITY_RESOLUTION_PATTERN = /(?:对准|对上|定位|分清|确认|确定|知道|说清|告诉|发我|给我|是哪套|指哪套|哪一套|哪套房|哪个小区|哪一项|哪个方案)/;
+const PROPERTY_DETAIL_PATTERN = /(?:价格|价位|报价|挂牌价|成交价|底价|在售|房源状态|车位|停车|楼型|楼层|大高层|高层|总层数|房况|产权|学区|入学|税费|贷款|利率|业主心态|谈价空间|费用|库存|名额|档期|资格)/;
+const MEDIA_PATTERN = /(?:户型图|平面图|照片|图片|实拍|视频|媒体|实景|现场|房子什么样|房源资料|资料|手册)/;
 const EXTERNAL_LOOKUP_PATTERN = /(?:查|查询|查看|看看|找|调取|调出|检索|搜索|评估|分析|筛|筛选|挑|选|留意|整理|理一理|圈出).{0,24}(?:价格|价位|报价|挂牌价|成交价|底价|在售|房源状态|车位|停车|楼型|楼层|大高层|高层|总层数|房况|产权|学区|入学|税费|贷款|利率|业主心态|谈价空间|房源|候选)|(?:价格|价位|报价|挂牌价|成交价|底价|在售|房源状态|车位|停车|楼型|楼层|大高层|高层|总层数|房况|产权|学区|入学|税费|贷款|利率|业主心态|谈价空间|房源|候选).{0,24}(?:查|查询|查看|看看|找|调取|调出|检索|搜索|评估|分析|筛|筛选|挑|选|留意|整理|理一理|圈出)/;
 const EXTERNAL_VERIFICATION_PATTERN = /(?:核实|核验|核对|核价|查验|查清|确认清楚|落实|复核|确认|帮(?:您|你)?看看).{0,24}(?:价格|报价|挂牌价|成交价|底价|在售|房源状态|车位|停车|楼型|楼层|总层数|房况|产权|学区|入学|税费|贷款|利率|特殊情况|业主心态|谈价空间)|(?:价格|报价|挂牌价|成交价|底价|在售|房源状态|车位|停车|楼型|楼层|总层数|房况|产权|学区|入学|税费|贷款|利率|特殊情况|业主心态|谈价空间).{0,24}(?:核实|核验|核对|核价|查验|查清|确认清楚|落实|复核|确认)/;
 const MEDIA_DELIVERY_PATTERN = /(?:发给|发送给|给到|交付|调给|找给|传给|给(?:您|你)看|拿给).{0,18}(?:户型图|平面图|照片|图片|实拍|视频|媒体|资料)|(?:户型图|平面图|照片|图片|实拍|视频|媒体|资料).{0,18}(?:发给|发送给|给到|交付|调给|找给|传给|给(?:您|你)看|拿给)|(?:定位|对准|确认|对着).{0,12}(?:后|了|这套).{0,12}(?:给(?:您|你)看|发给(?:您|你)|给到(?:您|你))|(?:再|会|将|先|帮(?:您|你)|给(?:您|你))?.{0,6}(?:准备|备好|整理好).{0,8}(?:户型图|平面图|照片|图片|实拍|视频|媒体|资料)/;
 const MEDIA_LOOKUP_PATTERN = /(?:找|找出|找到|定位到|定位|调取|调出).{0,18}(?:户型图|户型|平面图|照片|图片|实拍|视频|媒体|资料)|(?:户型图|户型|平面图|照片|图片|实拍|视频|媒体|资料).{0,18}(?:找|找出|找到|定位到|定位|调取|调出)/;
 const MEDIA_CAPTURE_PATTERN = /(?:拍|拍摄|补拍|重拍|(?<!记)录|录制).{0,16}(?:照片|图片|实拍|视频|房子|室内|现场|实景)|(?:照片|图片|实拍|视频|现场|实景).{0,16}(?:拍|拍摄|补拍|重拍|(?<!记)录|录制)|(?:对着|按着).{0,8}(?:拍|(?<!记)录)/;
-const SCHEDULE_VIEWING_PATTERN = /(?:我|我们|这边).{0,18}(?:帮(?:您|你))?(?:约(?:看房)?时间|约好(?:看房)?时间|约业主|安排看房|安排带看|安排.{0,16}(?:看房|带看)|排期|协调(?:看房)?时间)|(?:已经|已|马上|这就|会|将).{0,16}(?:约好|约上|约看|安排看房|安排带看|安排.{0,16}(?:看房|带看)|排期)|(?:能安排|可以安排|按(?:您|你).{0,8}时间.{0,8}(?:约|安排))|(?:今天|明天|后天|周[一二三四五六日天末]|上午|下午|晚上)?.{0,8}(?:档期|看房时间|带看时间).{0,8}(?:没问题|已确认|确认了|定了|可以|有空)|(?:档期|看房时间|带看时间).{0,8}(?:已经|已)?(?:定好|确认|排好)/;
-const SCHEDULE_INTAKE_PATTERN = /(?:您|你).{0,8}(?:想看|要看|准备看).{0,8}(?:哪套|哪个房源)|(?:您|你).{0,8}(?:哪天|什么日期|几点|上午还是下午|下午还是上午|哪个时段|什么时段).{0,8}(?:方便|合适|有时间)?|(?:房源号|哪套|具体房源|房源).{0,18}(?:日期|哪天|时间|时段|上午|下午)|(?:日期|哪天|时间|时段|上午|下午).{0,18}(?:房源号|哪套|具体房源|房源)|(?:把|发|告诉).{0,10}(?:房源号|哪套|具体房源).{0,18}(?:日期|哪天|时间|时段|上午|下午)|(?:上午还是下午|下午还是上午)/;
+const SCHEDULE_VIEWING_PATTERN = /(?:我|我们|这边).{0,18}(?:帮(?:您|你))?(?:约(?:看房|到店|上门)?时间|约好(?:看房|到店)?时间|约业主|安排看房|安排带看|安排预约|安排.{0,16}(?:看房|带看|到店|上门)|排期|协调(?:看房|预约)?时间)|(?:已经|已|马上|这就|会|将).{0,16}(?:约好|约上|约看|安排看房|安排带看|安排预约|安排.{0,16}(?:看房|带看|到店|上门)|排期)|(?:能安排|可以安排|按(?:您|你).{0,8}时间.{0,8}(?:约|安排))|(?:今天|明天|后天|周[一二三四五六日天末]|上午|下午|晚上)?.{0,8}(?:档期|看房时间|带看时间|预约时间).{0,8}(?:没问题|已确认|确认了|定了|可以|有空)|(?:档期|看房时间|带看时间|预约时间).{0,8}(?:已经|已)?(?:定好|确认|排好)/;
+const SCHEDULE_INTAKE_PATTERN = /(?:您|你).{0,8}(?:想看|要看|准备看|想约|要约).{0,8}(?:哪套|哪个房源|哪项|哪个方案)|(?:您|你).{0,8}(?:哪天|什么日期|几点|上午还是下午|下午还是上午|哪个时段|什么时段).{0,8}(?:方便|合适|有时间)?|(?:房源号|哪套|具体房源|房源|方案编号|订单号).{0,18}(?:日期|哪天|时间|时段|上午|下午)|(?:日期|哪天|时间|时段|上午|下午).{0,18}(?:房源号|哪套|具体房源|房源|方案编号|订单号)|(?:把|发|告诉).{0,10}(?:房源号|哪套|具体房源|方案编号).{0,18}(?:日期|哪天|时间|时段|上午|下午)|(?:上午还是下午|下午还是上午)/;
 const CONTACT_EXTERNAL_PATTERN = /(?:我|我们|这边).{0,18}(?:联系|沟通|询问|问|回电|致电|打电话|转交|转给|转人工|探(?:谈价|口风|一下|一嘴|业主)?).{0,12}(?:业主|房东|物业|邻居|主管|负责人|专员|顾问|人工|您|你|价格|空间)|(?:联系|沟通|询问|问|探(?:谈价|口风|一下|一嘴|业主)?).{0,10}(?:业主|房东|物业|邻居|价格|空间)|(?:(?:替|代|帮)(?:您|你)?|我).{0,10}(?:向|跟)?(?:业主|房东).{0,8}(?:报价|报个?价|报\s*\d+(?:\.\d+)?\s*(?:万|元)?|报\d+(?:\.\d+)?\s*(?:万|元)?|出价|还价|谈价|议价|谈到\s*\d+(?:\.\d+)?\s*(?:万|元)?)|(?:向|跟)(?:业主|房东).{0,8}(?:报价|报个?价|报\s*\d+(?:\.\d+)?\s*(?:万|元)?|报\d+(?:\.\d+)?\s*(?:万|元)?|出价|还价|谈价|议价|谈到\s*\d+(?:\.\d+)?\s*(?:万|元)?)|(?:回电|致电|电话联系|转人工|转交|移交|升级处理)/;
 const SAFE_VIEWING_PROPOSAL_PATTERN = /(?:如果|要是|觉得合适|您愿意|你愿意).{0,20}(?:约个?时间|一起看|去看)|^(?:我们|咱们)约个?时间一起看$/;
 const NON_EXECUTING_SELECTION_PATTERN = /(?:作为|当作|先定为|列为).{0,8}(?:筛选方向|筛选条件|筛选标准|筛选边界|找房方向)|(?:筛选方向|筛选条件|筛选标准|筛选边界).{0,8}(?:可以|合适|明确)|(?:保留|沿用|维持|保持|照旧|不变|先不改|继续按|仍按).{0,20}(?:预算|总价|区域|片区|地段|楼层|户型|面积|条件|方向|范围|标准)|(?:预算|总价|区域|片区|地段|楼层|户型|面积|条件|方向|范围|标准).{0,20}(?:保留|沿用|维持|保持|照旧|不变|先不改)|(?:预算|总价|区域|片区|地段|楼层|户型|面积|条件|方向).{0,16}(?:一起|都)(?:先)?保留/;
@@ -120,7 +120,7 @@ function inferEffect(type, text) {
   const value = String(text || '');
   if (NEGOTIATION_EFFECT_PATTERN.test(value)) return ACTION_EFFECTS.NEGOTIATION;
   if (CONTACT_EFFECT_PATTERN.test(value) || type === RESPONSE_ACTION_TYPES.CONTACT_EXTERNAL) return ACTION_EFFECTS.CONTACT;
-  if (type === RESPONSE_ACTION_TYPES.SCHEDULE_VIEWING || /(?:预约|约看|看房|带看|档期)/.test(value)) return ACTION_EFFECTS.BOOKING;
+  if (type === RESPONSE_ACTION_TYPES.SCHEDULE_VIEWING || /(?:预约|约看|看房|带看|档期|到店|上门)/.test(value)) return ACTION_EFFECTS.BOOKING;
   if ([RESPONSE_ACTION_TYPES.MEDIA_CAPTURE, RESPONSE_ACTION_TYPES.MEDIA_DELIVERY].includes(type)) return ACTION_EFFECTS.DELIVERY;
   if (/(?:留意|监测|有变化|一有.*通知)/.test(value)) return ACTION_EFFECTS.MONITORING;
   if ([RESPONSE_ACTION_TYPES.EXTERNAL_LOOKUP, RESPONSE_ACTION_TYPES.EXTERNAL_VERIFICATION].includes(type)) return ACTION_EFFECTS.LOOKUP;

+ 5 - 5
mcp/src/core/response-future-promise-verifier.js

@@ -19,8 +19,8 @@ const ACTION_RULES = Object.freeze([
   { id: 'callback', claim: /(?:回电|致电|电话联系|打电话|来电联系)/, evidence: /(?:callback|phone.?call|call.?customer|dial|回电|致电|电话联系)/i },
   { id: 'refund', claim: /(?:退款|退费|返款)/, evidence: /(?:refund|repay|退款|退费|返款)/i },
   { id: 'cancel', claim: /(?:取消|撤销|关闭)(?:订单|预约|申请|服务|合同)?/, evidence: /(?:cancel|revoke|close.?order|取消|撤销)/i },
-  { id: 'book', claim: /(?:预约|预订|预定|安排看房|安排带看|排期)/, evidence: /(?:book|schedule|appointment|viewing|预约|预订|排期|带看)/i },
-  { id: 'reserve', claim: /(?:保留|锁定|预留)(?:名额|库存|房源|档期|价格)?/, evidence: /(?:reserve|hold|lock|保留|锁定|预留)/i },
+  { id: 'book', claim: /(?:预约|预订|预定|安排看房|安排带看|安排到店|排期)/, evidence: /(?:book|schedule|appointment|viewing|预约|预订|排期|带看|到店)/i },
+  { id: 'reserve', claim: /(?:保留|锁定|预留)(?:名额|库存|房源|档期|价格|方案)?/, evidence: /(?:reserve|hold|lock|保留|锁定|预留)/i },
   { id: 'submit', claim: /(?:提交|登记|报备|发起|创建)(?:申请|工单|任务|订单|记录|报备)?/, evidence: /(?:submit|register|report|create.?(?:ticket|task|order)|ticket|提交|登记|报备|工单)/i },
   { id: 'notify', claim: /(?:通知|同步|告知|回复|答复|反馈|回信|发给您|发送给您|更新进度)/, evidence: /(?:notify|message|send.?text|send.?message|reply|outbound|deliver|通知|同步|告知|回复|发送)/i },
   { id: 'verify', claim: /(?:核实|核验|核对|查验|查询|查看|检查|确认|查清|了解清楚)/, evidence: /(?:verify|check|query|lookup|search|fetch|get.?detail|inspect|核实|核验|核对|查询|检查|确认)/i },
@@ -29,9 +29,9 @@ const ACTION_RULES = Object.freeze([
 
 const SCOPE_RULES = Object.freeze([
   { id: 'price', claim: /(?:价格|报价|费用|金额|最低价|最高价|首付|月供)/, evidence: /(?:价格|报价|费用|金额|最低价|最高价|首付|月供|price|quote|cost|amount)/i },
-  { id: 'inventory', claim: /(?:库存|在售|名额|余量|房源)/, evidence: /(?:库存|在售|名额|余量|房源|inventory|stock|availability|listing|property)/i },
-  { id: 'schedule', claim: /(?:档期|时间|日期|排期|看房|带看|预约)/, evidence: /(?:档期|时间|日期|排期|看房|带看|预约|schedule|time|date|viewing|appointment)/i },
-  { id: 'school', claim: /(?:学校|学区|入学|学位)/, evidence: /(?:学校|学区|入学|学位|school|education|enrollment)/i },
+  { id: 'inventory', claim: /(?:库存|在售|名额|余量|房源|方案)/, evidence: /(?:库存|在售|名额|余量|房源|方案|inventory|stock|availability|listing|property|catalog)/i },
+  { id: 'schedule', claim: /(?:档期|时间|日期|排期|看房|带看|预约|到店)/, evidence: /(?:档期|时间|日期|排期|看房|带看|预约|到店|schedule|time|date|viewing|appointment)/i },
+  { id: 'school', claim: /(?:学校|学区|入学|学位|资质|资格)/, evidence: /(?:学校|学区|入学|学位|资质|资格|school|education|enrollment|qualification)/i },
   { id: 'rights', claim: /(?:产权|权属|合同|抵押|查封)/, evidence: /(?:产权|权属|合同|抵押|查封|title|ownership|contract|mortgage|lien)/i },
   { id: 'qualification', claim: /(?:资格|资质|政策|贷款|利率|税费)/, evidence: /(?:资格|资质|政策|贷款|利率|税费|qualification|policy|loan|rate|tax)/i },
   { id: 'order', claim: /(?:订单|申请|工单|任务|编号)/, evidence: /(?:订单|申请|工单|任务|编号|order|application|ticket|task|request.?id)/i },

+ 2 - 2
mcp/src/core/response-generation-strategy.js

@@ -52,7 +52,7 @@ function buildResponseGenerationStrategy(input = {}) {
       basis: ['current_inbound', 'authoritative_history', 'profile_evidence', 'tool_evidence'],
       evidenceLimited,
       rule: evidenceLimited
-        ? 'Give a useful decision framework or directional judgment without inventing property, price, risk, or availability facts.'
+        ? 'Give a useful decision framework or directional judgment without inventing price, inventory, risk, or availability facts.'
         : 'Connect supported facts to one customer-relevant preference, tradeoff, or reason.',
     },
     singleNextStep: {
@@ -91,7 +91,7 @@ function responseGenerationInstruction(strategy = {}) {
   return [
     `客户本轮真正要解决的问题:${strategy.customerQuestion?.text || '以最新客户消息为准'}`,
     `1. 先答:${evidenceBoundary}`,
-    '2. 再判断:结合客户场景给一个有依据的倾向、取舍或理由;信息不足时给决策方法,不编造具体房源事实。',
+    '2. 再判断:结合客户场景给一个有依据的倾向、取舍或理由;信息不足时给决策方法,不编造具体产品或库存事实。',
     `3. 唯一步:整条回复最多推进一件事、最多问一个问题。${target}${actionBoundary}`,
     '强制顺序是“回答 -> 判断 -> 可选的一个下一步”;禁止先盘问字段,禁止并列多个行动,三个槽位按需要自然合成1至3句,不输出标题。',
   ].join('\n');

+ 285 - 0
mcp/src/core/response-human-style.js

@@ -0,0 +1,285 @@
+'use strict';
+
+const VAGUE_ACKNOWLEDGEMENT_PATTERN = /^(?:好的?|行|可以|没问题|明白|收到|嗯)[,,。!!\s]*(?:[^。!?]{0,14})?[。!]?$/;
+
+const REPLY_TOPIC_MATCHERS = [
+  { topic: 'price', inbound: /什么价|多少钱|怎么收费|报价|费用|单价|总价/, reply: /价|费|万|元|预算|报价|核价/ },
+  { topic: 'appointment', inbound: /预约|约时间|哪天方便|几点|到店|上门/, reply: /约|时间|上午|下午|哪天|档期|方便/ },
+  { topic: 'availability', inbound: /还有没有|有没有名额|有货吗|档期还有/, reply: /有|没有|名额|库存|档期|名额/ },
+  { topic: 'policy', inbound: /资质|资格|政策|合同|退款|发票/, reply: /资质|资格|政策|合同|退款|发票|人工|核验/ },
+  { topic: 'complaint', inbound: /投诉|不处理|太差|骗人/, reply: /抱歉|记录|主管|处理|跟进|接管/ },
+  { topic: 'timeline', inbound: /什么时候|多久|周期|交付|上线/, reply: /时间|周期|天|周|月|交付|确认/ },
+  { topic: 'spec', inbound: /规格|型号|版本|套餐|配置/, reply: /规格|型号|版本|套餐|配置|方案/ },
+];
+
+const TABOO_PHRASES = Object.freeze([
+  '我先帮您梳理一下需求',
+  '请您耐心等待',
+  '我们一直致力于提供优质服务',
+  '为了更好地为您服务',
+  '系统提示',
+  '当前缺少判断依据',
+  '已降级为人工核验',
+]);
+
+function clauseSimilarity(a = '', b = '') {
+  const strip = value => String(value || '').replace(/[\s,。!?、;:""''()()]/g, '');
+  const x = strip(a);
+  const y = strip(b);
+  if (x.length < 6 || y.length < 6) return 0;
+  const grams = text => {
+    const map = new Map();
+    for (let index = 0; index < text.length - 1; index += 1) {
+      const gram = text.slice(index, index + 2);
+      map.set(gram, (map.get(gram) || 0) + 1);
+    }
+    return map;
+  };
+  const gx = grams(x);
+  const gy = grams(y);
+  let overlap = 0;
+  for (const [gram, count] of gx) overlap += Math.min(count, gy.get(gram) || 0);
+  const total = [...gx.values()].reduce((sum, count) => sum + count, 0)
+    + [...gy.values()].reduce((sum, count) => sum + count, 0);
+  return total ? (2 * overlap) / total : 0;
+}
+
+function deduplicateResponseClauses(reply = '') {
+  const clauses = String(reply || '').match(/[^。!?\n]+[。!?]?/g) || [];
+  const kept = [];
+  for (const clause of clauses) {
+    if (kept.some(previous => clauseSimilarity(previous, clause) >= 0.5)) continue;
+    kept.push(clause);
+  }
+  return kept.join('');
+}
+
+function normalizeWeChatReply(reply = '') {
+  const normalized = deduplicateResponseClauses(String(reply || ''))
+    .trim()
+    .replace(/,/g, ',')
+    .replace(/\?/g, '?')
+    .replace(/!/g, '!');
+  if (!normalized) return '';
+  const sentences = normalized.match(/[^。!?\n]+[。!?]?/g) || [normalized];
+  const kept = [];
+  let questions = 0;
+  for (const sentence of sentences) {
+    const question = /?/.test(sentence);
+    if (question && questions >= 1) continue;
+    if (question) questions += 1;
+    kept.push(sentence.trim());
+    if (kept.length >= 3) break;
+  }
+  return kept.join('');
+}
+
+function pickReplyVariant(seed = '', variants = []) {
+  if (!variants.length) return '';
+  let value = 2166136261;
+  for (const char of String(seed || '')) {
+    value ^= char.charCodeAt(0);
+    value = Math.imul(value, 16777619);
+  }
+  return variants[(value >>> 0) % variants.length];
+}
+
+function replyStyleViolations(reply = '', inbound = '', _generationStrategy = {}, styleContext = {}) {
+  const text = String(reply || '').trim();
+  const ask = String(inbound || '');
+  if (!text) return [];
+  const violations = [];
+
+  if (/(?:帮|替|给)(?:您|你).{0,6}(?:查|问|找|确认|核实|核对|安排|转达)|(?:确认|核实)(?:好|完|后).{0,8}(?:回|发|告诉|答复)|稍等.{0,8}(?:我|回)/.test(text)) {
+    violations.push('unverified_commitment');
+  }
+  if (/(?:肯定|保证|一定|绝对)(?:能|会|有|可以|给|没问题|不会)|包(?:您|你)|百分百|准没错/.test(text)) {
+    violations.push('absolute_guarantee');
+  }
+  if (styleContext.hasAppointmentEvidence !== true) {
+    const acceptsNamedDate = (/(?:今天|明天|后天|周[一二三四五六日天末])/.test(text)
+      && /(?:我(?:有空|可以|能)|可以安排|能安排|没问题|都行|随时)/.test(text))
+      || /(?:今天|明天|后天|周[一二三四五六日天末])(?:是|就)?(?:可以|行|没问题|方便)/.test(text);
+    const promisesArrangement = /(?:预约|到店|上门).{0,6}(?:没问题|都行|随时|好说)/.test(text)
+      || /(?:按|依|照)(?:您|你)(?:的)?(?:时间|档期|节奏)(?:来)?(?:安排|定|走|排)/.test(text)
+      || /(?:帮|替|给)(?:您|你).{0,8}(?:约|安排|排).{0,6}(?:时间|档期|上门|到店)/.test(text);
+    if (acceptsNamedDate || promisesArrangement) violations.push('schedule_confirmation');
+  }
+  if (/(?:尽快|抓紧|赶紧|趁早|要快)|(?:再不|错过).{0,10}(?:就没|可惜)/.test(text)) {
+    violations.push('customer_pressure');
+  }
+  if (/(?:资料|系统|数据库|后台|记录)(?:里|中)?.{0,8}(?:没有|查不到|看不出|未收录)|缺少.{0,12}判断依据|没有新的核验结果|当前(?:还)?缺少|需要(?:顾问|专员|同事).{0,6}确认/.test(text)) {
+    violations.push('system_speak');
+  }
+  const customerText = [ask, ...(styleContext.messages || [])
+    .filter(message => (message?.role === 'user' || message?.direction === 'inbound'))
+    .map(message => String(message?.content || ''))].join('\n');
+  const numbers = text.match(/\d+(?:\.\d+)?/g) || [];
+  const quotesUnsourcedNumber = numbers.some(number => !customerText.includes(number))
+    && /(?:%|%|万|元|年|折)/.test(text)
+    && styleContext.hasCatalogEvidence !== true;
+  if (quotesUnsourcedNumber) violations.push('unsourced_number');
+  if (/税/.test(ask) && /(?:免税|不用交税|全免)|(?:税率|税额).{0,8}\d/.test(text)) {
+    violations.push('tax_conclusion');
+  }
+  if (/投诉|退款|赔偿|合同纠纷|隐私泄露/.test(ask)
+    && !/(?:人工|主管|负责人).{0,10}(?:复核|核验|确认|接管)/.test(text)) {
+    violations.push('risk_handoff_missing');
+  }
+  if (VAGUE_ACKNOWLEDGEMENT_PATTERN.test(text)) violations.push('vague_acknowledgement');
+  for (const matcher of REPLY_TOPIC_MATCHERS) {
+    if (matcher.inbound.test(ask) && !matcher.reply.test(text)) {
+      violations.push(`off_topic:${matcher.topic}`);
+      break;
+    }
+  }
+  if (text.length <= 24
+    && !/[??]/.test(text)
+    && /(?:要看|得看|取决于|看具体|看情况)/.test(text)
+    && !/(?:一般|通常|大多|因为|比如|差别|不一样)/.test(text)) {
+    violations.push('deflection_only');
+  }
+  if ((text.match(/[??]/g) || []).length >= 3) violations.push('question_overload');
+  const recentOutbound = (styleContext.messages || [])
+    .filter(message => (message?.role === 'assistant' || message?.direction === 'outbound'))
+    .slice(-4)
+    .map(message => String(message?.content || ''))
+    .filter(Boolean);
+  if (recentOutbound.some(previous => clauseSimilarity(previous, text) >= 0.82)) {
+    violations.push('repeat_previous');
+  }
+  return violations;
+}
+
+function fallbackForViolations(violations = [], inbound = '', seed = '') {
+  const codes = new Set(violations.map(item => String(item).split(':')[0]));
+  const pick = (key, variants) => pickReplyVariant(`${seed}\n${key}`, variants);
+  if (codes.has('risk_handoff_missing')) {
+    return pick('handoff', [
+      '这件事需要主管复核,我先记下您的诉求,结论出来前先按未处理完成看待。',
+      '投诉和责任判断要转主管跟进。您最希望先解决哪一项?',
+    ]);
+  }
+  if (codes.has('schedule_confirmation')) {
+    return pick('schedule', [
+      '具体时间还要再核对档期,我先记下您方便的时段。您更想上午还是下午?',
+      '预约还没落到日历上,先不确定已经约好。您哪天更方便?',
+    ]);
+  }
+  if (codes.has('absolute_guarantee') || codes.has('unsourced_number')) {
+    return pick('boundary', [
+      '这一项我现在不能打包票,得按核验结果说。您最想先确认价格、时间还是范围?',
+      '现有信息还不够下确定结论。您把最关键的一项条件再说清楚,我按这个往下核。',
+    ]);
+  }
+  if (codes.has('unverified_commitment') || codes.has('system_speak')) {
+    return pick('no-process', [
+      '我先按现有信息回答;还没核到的部分不先说满。您最想先确认哪一项?',
+      '能确定的我直接说,拿不准的不替工具下结论。您更在意进度还是具体条件?',
+    ]);
+  }
+  if (codes.has('vague_acknowledgement') || codes.has('deflection_only') || codes.has('off_topic')) {
+    return pick('answer', [
+      '您这个问题我先直接说:当前还要结合具体条件确认。您把最关键的一项发我。',
+      '我先回应您刚问的点。把具体对象或条件给我一个,我好对上再给下一步。',
+    ]);
+  }
+  if (codes.has('repeat_previous') || codes.has('question_overload') || codes.has('customer_pressure')) {
+    return pick('pace', [
+      '刚才那句说绕了。您按自己的节奏来,有明确问题我再补一句。',
+      '不催您决定。您先看当前这条是否把问题说清,不清楚再问一个点就行。',
+    ]);
+  }
+  return pick('generic', [
+    '我先按能确定的说,拿不准的不替您承诺。您最想先确认哪一项?',
+  ]);
+}
+
+function enforceHumanReplyStyle(final = {}, inboundContent = '', generationStrategy = {}, styleContext = {}) {
+  const inbound = String(inboundContent || '');
+  const original = String(final.reply || '');
+  if (!original.trim()) return final;
+  const violations = replyStyleViolations(original, inbound, generationStrategy, styleContext);
+  if (!violations.length) {
+    const clean = normalizeWeChatReply(original.replace(/您们/g, '你们'));
+    if (!clean.trim() || clean === original) return final;
+    return {
+      ...final,
+      reply: clean,
+      reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端仅做微信节奏归一,未改写模型语气。`,
+    };
+  }
+  const seed = [
+    styleContext.variantKey,
+    generationStrategy.stage,
+    generationStrategy.scenario,
+    inbound,
+    original,
+  ].filter(Boolean).join('\n');
+  return {
+    ...final,
+    reply: fallbackForViolations(violations, inbound, seed),
+    reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端因回复越线已改写:${violations.join('、')}。`,
+    styleViolations: violations,
+  };
+}
+
+function enforceTabooFreeReply(final = {}) {
+  let reply = String(final.reply || '').trim();
+  if (!reply) return final;
+  const hits = [];
+  for (const phrase of TABOO_PHRASES) {
+    if (reply.includes(phrase)) {
+      hits.push(phrase);
+      reply = reply.split(phrase).join('');
+    }
+  }
+  if (!hits.length) return final;
+  reply = reply
+    .replace(/[。!?,、\s]+(?=[。!?,、])/g, '')
+    .replace(/^(?:[,,、。;:]+|[了得的]?[,,、。;:])/, '')
+    .replace(/\s{2,}/g, ' ')
+    .trim();
+  if (!reply || !/[^\s,。!?;:、]/u.test(reply)) return final;
+  return {
+    ...final,
+    reply,
+    reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端已删除聊天风格禁忌语:${hits.join('、')}。`,
+  };
+}
+
+function enforceSingleFinalReply(final = {}) {
+  const text = String(final.reply || '').trim();
+  if (!text) return final;
+  const markers = [
+    /(?:^|[。!?\n;]|[((【\[]\s*)(?:以下是|这是|上面是|我的最终回复是)?\s*(?:按(?:聊天|沟通)?风格处理(?:后|过)?的?(?:最终)?(?:回复|消息|版本)|检查后(?:生成的)?(?:的)?(?:最终)?(?:回复|消息)|最终(?:回复|消息|版本))[))】\]]?\s*[::]?\s*/g,
+    /(?:^|[。!?\n;])\s*[((【\[]\s*(?:按(?:聊天|沟通)?风格处理(?:后|过)?|检查后|风格化(?:处理)?)\s*[))】\]]\s*[::]?\s*/g,
+  ];
+  let cut = -1;
+  for (const rx of markers) {
+    let match;
+    while ((match = rx.exec(text))) {
+      const next = match.index + match[0].length;
+      if (text.slice(next).trim() && next > cut) cut = next;
+    }
+  }
+  if (cut < 0) return final;
+  const finalPart = text.slice(cut).trim();
+  if (!finalPart) return final;
+  return {
+    ...final,
+    reply: finalPart,
+    reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端检测到多版本回复,已按“最终版本”收敛为单条。`,
+  };
+}
+
+module.exports = {
+  TABOO_PHRASES,
+  clauseSimilarity,
+  deduplicateResponseClauses,
+  enforceHumanReplyStyle,
+  enforceSingleFinalReply,
+  enforceTabooFreeReply,
+  normalizeWeChatReply,
+  replyStyleViolations,
+};

+ 37 - 37
mcp/src/core/response-provenance-ledger.js

@@ -33,19 +33,19 @@ const FIELD_PROFILE_KEYS = Object.freeze({
 
 const SUCCESS_STATUS = /^(?:ok|success|succeeded|complete|completed|done|accepted|created|booked|scheduled|sent|delivered)$/i;
 const FAILURE_STATUS = /^(?:fail|failed|failure|error|errored|rejected|cancelled|canceled|timeout|timed_out)$/i;
-const VIEWING_REQUEST = /(?:想|要|准备|打算|约|安排|周[末六日]|今天|明天|后天)[^。!?]{0,14}(?:看房|看这套|去看|带看|还能?看|可以看)|(?:看房|带看|还能?看)[^。!?]{0,12}(?:时间|安排|方便|吗|么)|(?:什么时候|哪天|几点)[^。!?]{0,10}(?:来?看|看房)/;
-const DEFINITE_VIEWING_AVAILABILITY = /^(?:好的?[,,。!!\s]*|可以的?[,,。!!\s]*|没问题[,,。!!\s]*|能安排[,,。!!\s]*|看房没问题)|(?:今天|明天|后天|周[一二三四五六日天末]).{0,18}(?:可以的?(?:[,,。!!]|$)|能安排|可以安排|有空|有时间|时间没问题|能看|可以看)/;
-const SCOPED_VIEWING_BOUNDARY = /(?:具体|实际).{0,10}(?:时间|时段|安排).{0,16}(?:还要|需要|得).{0,12}(?:确认|对上|协调)|(?:先作为意向|先定想看的房子)|(?:业主|房源).{0,10}(?:时间|档期).{0,10}(?:确认|对上|协调)/;
-const TENTATIVE_VIEWING_PROPOSAL = /(?:今天|明天|后天|周[一二三四五六日天末])?(?:可以)?先作为(?:看房)?意向|先定(?:下)?想看的房子和(?:大概)?时段|具体(?:房源和)?(?:时间|时段|安排).{0,12}(?:还要|需要|得).{0,10}(?:确认|对上|协调)/;
+const VIEWING_REQUEST = /(?:想|要|准备|打算|约|安排|周[末六日]|今天|明天|后天)[^。!?]{0,14}(?:看房|看这套|去看|带看|预约|到店|上门|还能?看|可以看)|(?:看房|带看|预约|到店|上门)[^。!?]{0,12}(?:时间|安排|方便|吗|么)|(?:什么时候|哪天|几点)[^。!?]{0,10}(?:来?看|看房|到店|上门|过来)/;
+const DEFINITE_VIEWING_AVAILABILITY = /^(?:好的?[,,。!!\s]*|可以的?[,,。!!\s]*|没问题[,,。!!\s]*|能安排[,,。!!\s]*|看房没问题|预约没问题)|(?:今天|明天|后天|周[一二三四五六日天末]).{0,18}(?:可以的?(?:[,,。!!]|$)|能安排|可以安排|有空|有时间|时间没问题|能看|可以看)/;
+const SCOPED_VIEWING_BOUNDARY = /(?:具体|实际).{0,10}(?:时间|时段|安排).{0,16}(?:还要|需要|得).{0,12}(?:确认|对上|协调)|(?:先作为意向|先定想看的房子|先记意向)|(?:业主|房源|档期|双方).{0,10}(?:时间|档期).{0,10}(?:确认|对上|协调)/;
+const TENTATIVE_VIEWING_PROPOSAL = /(?:今天|明天|后天|周[一二三四五六日天末])?(?:可以)?先作为(?:看房|预约)?意向|先定(?:下)?想看的房子和(?:大概)?时段|具体(?:房源和)?(?:时间|时段|安排).{0,12}(?:还要|需要|得).{0,10}(?:确认|对上|协调)/;
 const PERSONAL_SCHEDULE_CONFIRMATION = /(?:我|我这边|这边).{0,6}(?:倒是|其实|目前|明天|今天|后天)?\s*(?:有空|有时间|时间没问题)|(?:今天|明天|后天).{0,10}(?:我|我这边|这边).{0,6}(?:有空|有时间|时间没问题)/;
-const FIRST_PERSON_CAPABILITY = /(?:我|我们|我这边|这边).{0,10}(?:都能看到|能看到|看得到|能查到|查得到|能调到|调得到|能找到|找得到|可以查|可以调).{0,18}(?:房源|房子|挂牌|成交|户型图|平面图|图片|照片|视频|媒体|资料)|(?:房源|房子|挂牌|成交|户型图|平面图|图片|照片|视频|媒体|资料).{0,10}(?:我|我们|我这边|这边).{0,10}(?:都能看到|能看到|看得到|能查到|查得到|能调到|调得到|能找到|找得到|可以查|可以调)/;
+const FIRST_PERSON_CAPABILITY = /(?:我|我们|我这边|这边).{0,10}(?:都能看到|能看到|看得到|能查到|查得到|能调到|调得到|能找到|找得到|可以查|可以调).{0,18}(?:房源|房子|挂牌|成交|户型图|平面图|图片|照片|视频|媒体|资料|方案|库存|名额)|(?:房源|房子|挂牌|成交|户型图|平面图|图片|照片|视频|媒体|资料|方案|库存|名额).{0,10}(?:我|我们|我这边|这边).{0,10}(?:都能看到|能看到|看得到|能查到|查得到|能调到|调得到|能找到|找得到|可以查|可以调)/;
 const MARKET_DISTRIBUTION = /(?:这个|该)?(?:总价段|预算(?:里|下|范围)).{0,24}(?:三房|两房|户型|面积).{0,24}(?:通常|一般|多在|多落在|大多|普遍|偏紧凑|偏实用)|(?:三房|两房|户型|面积).{0,24}(?:在这个|该)(?:总价段|预算(?:里|下|范围)).{0,24}(?:通常|一般|多在|多落在|大多|普遍|偏紧凑|偏实用)|(?:通常|一般|大多|普遍|多在|多落在).{0,28}(?:\d{2,4}\s*(?:平|㎡)|三房|两房).{0,18}(?:预算|总价段)/;
 const ROOM_AREA_DISTRIBUTION = /(?:三房|两房|[2-4]房).{0,24}(?:通常|一般|大多|常见|多在|先看|参考|大概).{0,24}(?:\d{2,4}\s*(?:到|至|-|~|~)\s*\d{2,4}\s*(?:平|㎡|平方米)|\d{2,4}\s*(?:多)?平)|(?:三房|两房|[2-4]房).{0,40}\d{2,4}\s*(?:多)?平.{0,30}\d{2,4}\s*(?:多)?平.{0,20}(?:紧凑|宽松|舒展|舒服)|\d{2,4}\s*(?:到|至|-|~|~)\s*\d{2,4}\s*(?:平|㎡|平方米).{0,28}(?:三房|两房|[2-4]房|紧凑|宽松)/;
 const MARKET_PRICE_AREA_OUTCOME = /(?:预算|总价|附近|区域|片区|现在|当前).{0,20}(?:[\d一二两三四五六七八九十百千万亿]+(?:\.\d+)?\s*(?:万|万元)?).{0,18}(?:只能买|能买到|可以买到|买到).{0,14}(?:[\d一二两三四五六七八九十百千万亿]+(?:\.\d+)?\s*(?:平|㎡|平方米))/;
 const EXPLICIT_AREA_RANGE = /\d{2,4}(?:\.\d+)?\s*(?:到|至|-|~|~)\s*\d{2,4}(?:\.\d+)?\s*(?:平|㎡|平方米)/;
 const UNSOURCED_ROOM_ASSUMPTION = /(?:紧凑|宽松|舒适|实用)?\s*(?:三房|三室|3室)|(?:三房|三室|3室).{0,18}(?:紧凑|宽松|舒适|实用|90\s*(?:到|至|-|—)\s*120\s*(?:平|㎡)|90多平|110平)/;
 const UNSOURCED_BUDGET_FEASIBILITY = /(?:预算|总价|\d{2,4}\s*万).{0,16}(?:没问题|肯定够|够用|可选不少|选择很多|能挑不少|可以挑不少|很充足|很宽裕)|(?:没问题|肯定够|够用|可选不少|选择很多|能挑不少|可以挑不少|很充足|很宽裕).{0,16}(?:预算|总价|\d{2,4}\s*万)/;
-const FIRST_PERSON_INVENTORY_STATE = /(?:我这边|我们|这边|手上|手头|我.{0,10}(?:这边|区域|板块)).{0,24}(?:有|已有|没有|没|还没|暂无|暂时没|没筛出|没找到|没捋出).{0,20}(?:(?:特别)?合适|匹配|在售|可看|几套|多套|少量)?(?:房源|房子|户型)|(?:我这边|我们|这边|手上|手头|我.{0,10}(?:这边|区域|板块)).{0,20}(?:房源|房子|户型).{0,12}(?:有|没有|没|还没|暂无)|(?:我这边|我们|这边|手上|手头|我.{0,10}(?:这边|区域|板块)).{0,24}(?:合适|匹配|符合|筛到|整理出).{0,16}(?:房源|房子|候选)/;
+const FIRST_PERSON_INVENTORY_STATE = /(?:我这边|我们|这边|手上|手头|我.{0,10}(?:这边|区域|板块)).{0,24}(?:有|已有|没有|没|还没|暂无|暂时没|没筛出|没找到|没捋出).{0,20}(?:(?:特别)?合适|匹配|在售|可看|几套|多套|少量)?(?:房源|房子|户型|方案|库存|名额)|(?:我这边|我们|这边|手上|手头|我.{0,10}(?:这边|区域|板块)).{0,20}(?:房源|房子|户型|方案|库存|名额).{0,12}(?:有|没有|没|还没|暂无)|(?:我这边|我们|这边|手上|手头|我.{0,10}(?:这边|区域|板块)).{0,24}(?:合适|匹配|符合|筛到|整理出).{0,16}(?:房源|房子|候选|方案)/;
 const SAFE_METHOD_CUES = /(?:要|得|需要|应该|可以|建议|先|主要)(?:把|从|看|结合|比较|确认|对准|分清)|(?:不能|不宜|不好|先不)(?:只看|直接|马上|下结论|报数|判断)|(?:关键|重点|主要).{0,12}(?:看|比较|取舍)|(?:还要|仍要|具体).{0,12}(?:看|结合|确认|对上)/;
 const DEFINITE_FACT_CUES = /(?:已确认|确定有|当前有|完全匹配|一定|保证|肯定|都能|普遍|大多|多在|多落在|通常|一般).{0,24}(?:\d|价格|房源|面积|三房|两房|在售|学区|产权|可看|能看)/;
 const UNSOURCED_GENERIC_PRICING_METHOD = /(?:价格|费用|收费).{0,20}(?:套餐|账号|席位|人数|数量).{0,20}(?:结合|取决于|要看|来定)|(?:套餐|账号|席位|人数|数量).{0,20}(?:决定|影响).{0,12}(?:价格|费用|收费)/;
@@ -281,8 +281,8 @@ function buildResponseProvenanceLedger(input = {}) {
   }
   const successful = successfulToolEvidence(input.toolTrace);
   const capabilities = {
-    appointment: input.capabilities?.appointment === true || successful.some(trace => /(?:book|schedule|appointment|viewing|带看|看房|预约|排期)/i.test(String(trace.tool || trace.name || ''))),
-    propertyAccess: input.capabilities?.propertyAccess === true || successful.some(trace => /(?:propert(?:y|ies)|listing|房源|楼盘)/i.test(String(trace.tool || trace.name || ''))),
+    appointment: input.capabilities?.appointment === true || successful.some(trace => /(?:book|schedule|appointment|viewing|带看|看房|预约|排期|到店)/i.test(String(trace.tool || trace.name || ''))),
+    propertyAccess: input.capabilities?.propertyAccess === true || successful.some(trace => /(?:propert(?:y|ies)|listing|inventory|catalog|房源|楼盘|方案|库存)/i.test(String(trace.tool || trace.name || ''))),
     mediaAccess: input.capabilities?.mediaAccess === true || successful.some(trace => /(?:media|photo|image|video|floor.?plan|户型图|图片|照片|视频|媒体)/i.test(`${trace.tool || trace.name || ''}\n${JSON.stringify(trace.result || {})}`)),
     marketEvidence: marketEvidencePresent(input),
     areaDistributionEvidence: areaDistributionEvidencePresent(input),
@@ -402,15 +402,15 @@ function evaluateResponseProvenance(reply = '', input = {}) {
     && (personalScheduleClaim || definiteViewingClaim)
     && !tentativeViewing
     && ledger.capabilities.appointment !== true) {
-    violations.push({ code: 'appointment_without_evidence', detail: '看房可用性缺少 appointment evidence' });
+    violations.push({ code: 'appointment_without_evidence', detail: '预约可用性缺少 appointment evidence' });
   }
   if (FIRST_PERSON_INVENTORY_STATE.test(content) && ledger.capabilities.inventoryStatus !== true) {
-    violations.push({ code: 'inventory_state_without_evidence', detail: '第一人称房源库存或匹配状态缺少 inventory evidence' });
+    violations.push({ code: 'inventory_state_without_evidence', detail: '第一人称库存或匹配状态缺少 inventory evidence' });
   }
   if (FIRST_PERSON_CAPABILITY.test(content)) {
-    const media = /(?:户型图|平面图|图片|照片|视频|媒体|资料)/.test(content);
+    const media = /(?:户型图|平面图|图片|照片|视频|媒体|资料|手册)/.test(content);
     if ((media && !ledger.capabilities.mediaAccess) || (!media && !ledger.capabilities.propertyAccess)) {
-      violations.push({ code: 'capability_without_evidence', detail: '第一人称房源或媒体访问能力缺少 capability evidence' });
+      violations.push({ code: 'capability_without_evidence', detail: '第一人称资料或媒体访问能力缺少 capability evidence' });
     }
   }
   const roomCount = Number(ledgerFieldValue(ledger, 'roomCount') || 0);
@@ -445,64 +445,64 @@ function safeRewriteForViolations(input = {}, violations = [], seed = '') {
   const choose = (key, values) => values[stableIndex(`${seed}\n${key}`, values.length)];
   if (codes.has('appointment_without_evidence')) {
     return choose('appointment', [
-      '看房可以先定意向,具体时间还要和房源、业主时段对上。您哪天更方便?',
-      '先把想看的房子和大概时段定下来,具体安排还要再确认。您上午还是下午方便?',
-      '看房时间要结合具体房源和双方时段。您先说哪天方便。',
+      '预约可以先记意向,具体时间还要和档期对上。您哪天更方便?',
+      '先把大概时段定下来,具体安排还要再确认。您上午还是下午方便?',
+      '预约时间要结合双方档期。您先说哪天方便。',
     ]);
   }
   if (codes.has('inventory_state_without_evidence')) {
     return choose('inventory-state', [
-      '现有信息还不足以判断哪些房源合适。您先说最想看的区域或预算。',
-      '目前只能先补齐找房条件,不能先下库存结论。您更想先定区域还是预算?',
-      '先不判断手上有没有合适房源。您把区域和预算里更重要的一项告诉我。',
+      '现有信息还不足以判断有没有合适方案。您先说最关键的一项条件。',
+      '目前只能先补齐条件,不能先下库存结论。您更想先定范围还是预算?',
+      '先不判断手上有没有合适方案。您把范围和预算里更重要的一项告诉我。',
     ]);
   }
   if (codes.has('capability_without_evidence')) {
-    if (/(?:户型图|平面图|图片|照片|视频|媒体)/.test(inbound)) {
+    if (/(?:图片|照片|视频|资料|手册)/.test(inbound)) {
       return choose('media-capability', [
-        '先对准具体哪套,再看对应的户型或视频资料。您发链接或截图给我。',
-        '户型和视频资料要先对应到具体房源。您把房源号或截图发我一个。',
-        '先确定您指的是哪,资料才不会对错。发个链接或截图就行。',
+        '先对准具体对象,再看对应资料。您发链接或截图给我。',
+        '资料要先对应到具体方案。您把编号或截图发我一个。',
+        '先确定您指的是哪一项,资料才不会对错。发个链接或截图就行。',
       ]);
     }
     return choose('property-capability', [
-      '先对准您说的具体房源,再判断哪些信息能直接比较。您发小区名或房源号都行。',
-      '具体哪套还没对上,先不把房源信息说满。您给我小区名或房源号。',
-      '要先知道您指的是哪套。发个小区名、房源号或截图中的一个就行。',
+      '先对准您说的具体对象,再判断哪些信息能直接比较。您发名称或编号都行。',
+      '具体哪一项还没对上,先不把信息说满。您给我名称或编号。',
+      '要先知道您指的是哪一项。发个名称、编号或截图中的一个就行。',
     ]);
   }
   if (codes.has('area_distribution_without_evidence')) {
-    return '三房面积跨度取决于小区、得房率和户型,现在线索还不足以报固定区间。您更看重总价还是空间?';
+    return '面积跨度取决于具体规格和方案,现在线索还不足以报固定区间。您更看重总投入还是容量?';
   }
   if (codes.has('market_distribution_without_evidence')) {
     const budget = ledgerFieldValue(ledger, 'budget');
     const roomCount = Number(ledgerFieldValue(ledger, 'roomCount') || 0);
     const known = [budget ? '预算' : '', roomCount ? `${roomCount}房` : ''].filter(Boolean).join('和');
     return choose('market-distribution', [
-      `${known || '当前条件'}先保留,区域和具体房源没定前先不估面积分布。您重点看哪个区域?`,
-      `${known || '现有方向'}先按这个理解;没有区域和市场样本,现在不先下面积结论。您想看哪个片区?`,
-      `先保留${known || '您刚说的条件'},面积要结合区域和具体户型看。您优先考虑哪个区域?`,
+      `${known || '当前条件'}先保留,范围和具体方案没定前先不估分布。您重点看哪个范围?`,
+      `${known || '现有方向'}先按这个理解;没有范围和市场样本,现在不先下分布结论。您想先定哪个范围?`,
+      `先保留${known || '您刚说的条件'},容量要结合范围和具体规格看。您优先考虑哪个范围?`,
     ]);
   }
   if (codes.has('budget_feasibility_without_evidence')) {
     const budget = ledgerFieldValue(ledger, 'budget') || {};
     const amount = Number(budget.maxWan ?? budget.valueWan ?? budget.minWan ?? 0);
     return amount
-      ? `预算先按${amount}万以内记。您更想先定区域还是户型?`
-      : '预算先按您刚说的范围记。您更想先定区域还是户型?';
+      ? `预算先按${amount}万以内记。您更想先定范围还是规格?`
+      : '预算先按您刚说的范围记。您更想先定范围还是规格?';
   }
   if (codes.has('room_count_without_source')) {
     if (/(?:多大面积|多少平|平方|面积)/.test(inbound)) {
       return choose('generic-area', [
-        '面积要先看您说的是哪种户型。您目前主要考虑几房?',
-        '不同户型的面积差别很大,先确认房间数更准确。您想看几房?',
-        '现在还没有房间数,先不套面积区间。您主要考虑几房?',
+        '面积要先看您说的是哪种规格。您目前主要考虑哪个版本?',
+        '不同规格的容量差别很大,先确认规格更准确。您目前倾向哪个版本?',
+        '现在还没有规格线索,先不套容量区间。您主要考虑哪个版本?',
       ]);
     }
     return choose('good-layout', [
-      '好房型先看房间数和实际布局。您目前主要考虑几房?',
-      '房型好不好要结合您的使用需求,先从房间数开始。您想看几房?',
-      '先不默认具体户型。您更需要几房?',
+      '规格是否合适要结合您的实际使用。您目前主要考虑哪个版本?',
+      '先按您的使用需求确认规格,再比较容量。您更需要哪一档?',
+      '先不默认具体规格。您更需要哪一档配置?',
     ]);
   }
   return '';

+ 74 - 2
mcp/src/core/response-quality-contract.js

@@ -7,6 +7,67 @@ const QUALITY_FALLBACK_REASONS = Object.freeze({
   QUALITY_UNAVAILABLE: 'quality_unavailable',
 });
 
+const HUMAN_REVIEW_REASONS = Object.freeze({
+  NONE: '',
+  RISK_HANDOFF: 'risk_handoff',
+  QUALITY_REVIEW: 'quality_review',
+  CONFIDENCE_REVIEW: 'confidence_review',
+  POLICY_REVIEW: 'policy_review',
+});
+
+const RISK_STATE_CATEGORIES = Object.freeze({
+  POLICY: 'policy',
+  PRICING: 'pricing',
+  LEGAL: 'legal',
+  PRODUCT_CONDITION: 'product_condition',
+  COMPLAINT: 'complaint',
+});
+
+const HIGH_INTENT_STATES = Object.freeze({
+  NONE: 'none',
+  SCHEDULE_INTAKE: 'schedule_intake',
+  SCHEDULE_COMMIT: 'schedule_commit',
+  ORDER_INTAKE: 'order_intake',
+  ORDER_EXECUTE: 'order_execute',
+});
+
+function normalizeHumanReviewReason(value = '') {
+  const reason = String(value || '').trim();
+  return Object.values(HUMAN_REVIEW_REASONS).includes(reason) ? reason : HUMAN_REVIEW_REASONS.NONE;
+}
+
+function deriveHumanReviewState({
+  plan = {},
+  qualityPassed,
+  confidence,
+  modelRequiresHuman = false,
+  policyReview = false,
+  reasonCode = '',
+} = {}) {
+  let reason = normalizeHumanReviewReason(reasonCode);
+  if (plan.requireHumanHandoff === true || plan.riskState?.customerBoundaryRequired === true) {
+    reason = HUMAN_REVIEW_REASONS.RISK_HANDOFF;
+  } else if (qualityPassed === false) {
+    reason = HUMAN_REVIEW_REASONS.QUALITY_REVIEW;
+  } else if (Number.isFinite(Number(confidence)) && Number(confidence) < 0.7) {
+    reason = HUMAN_REVIEW_REASONS.CONFIDENCE_REVIEW;
+  } else if (reason) {
+    reason = normalizeHumanReviewReason(reason);
+  } else if (policyReview || plan.requireReview === true || modelRequiresHuman === true) {
+    reason = HUMAN_REVIEW_REASONS.POLICY_REVIEW;
+  } else {
+    reason = HUMAN_REVIEW_REASONS.NONE;
+  }
+  return {
+    required: Boolean(reason),
+    reasonCode: reason,
+    customerBoundaryRequired: reason === HUMAN_REVIEW_REASONS.RISK_HANDOFF,
+    requiredHumanRole: reason === HUMAN_REVIEW_REASONS.RISK_HANDOFF
+      ? String(plan.requiredHumanRole || 'specialist')
+      : '',
+  };
+}
+
 function normalizeQualityScore(value) {
   if (value === null || value === undefined || value === '') return null;
   const parsed = Number(value);
@@ -26,18 +87,29 @@ function normalizeQualityChecks(value) {
   }));
 }
 
-function qualityFallbackReason({ qualityAvailable = true, qualityPassed, rewriteCount = 0, requiresHuman = false } = {}) {
+function qualityFallbackReason({
+  qualityAvailable = true,
+  qualityPassed,
+  rewriteCount = 0,
+  requiresHuman = false,
+  humanReview = null,
+} = {}) {
   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 : '';
+  return requiresHuman || humanReview?.required ? QUALITY_FALLBACK_REASONS.REQUIRES_HUMAN : '';
 }
 
 module.exports = {
+  HIGH_INTENT_STATES,
   QUALITY_FALLBACK_REASONS,
+  HUMAN_REVIEW_REASONS,
+  RISK_STATE_CATEGORIES,
+  deriveHumanReviewState,
+  normalizeHumanReviewReason,
   normalizeQualityChecks,
   normalizeQualityScore,
   qualityFallbackReason,

+ 6 - 6
mcp/src/core/response-quality-planner.js

@@ -4,11 +4,11 @@ const ACK_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了
 const QUESTION_PATTERN = /[??]|(?:吗|么|呢|怎么|如何|多少|能否|可以|有没有|是否|为什么|哪(?:个|些|里)?|几时|什么时候)/;
 const RISK_RULES = [
   ['complaint', /投诉|举报|骗人|不处理|太差|退款|赔偿|生气|不满意/],
-  ['pricing', /最低|成交价|挂牌价|底价|报价|优惠|议价|多少钱|什么价|全成本|价格.{0,8}(?:确认|多少|能否)|费用.{0,8}(?:确认|多少|怎么收)/],
-  ['eligibility', /学区|上(?:小学|初中|高中)|入学|学位|划片|报名|户籍|资格|政策|资质|许可|名额/],
-  ['legal_or_finance', /产权|权属|合同|签约|税费|贷款|利率|违约|历史使用|法律/],
-  ['property_condition', /(?:房子|房源|这套|该套).{0,10}(?:问题|瑕疵|漏水|房况|物业)|(?:问题|瑕疵|漏水|房况|物业).{0,10}(?:房子|房源|这套|该套)/],
-  ['high_intent', /看房|预约|出价|面谈|签约|付款|下单|到店|现场/],
+  ['pricing', /最低|报价|优惠|议价|多少钱|什么价|费用.{0,8}(?:确认|多少|怎么收)/],
+  ['eligibility', /资格|政策|资质|许可|名额|报名|学区|入学|学位|上(?:小学|初中|高中)|划片/],
+  ['legal_or_finance', /合同|签约|税费|贷款|利率|违约|法律|产权|权属/],
+  ['product_condition', /质量|故障|损坏|不能用|瑕疵|售后|(?:房子|房源|这套|该套|产品|方案).{0,10}(?:问题|瑕疵)|(?:问题|瑕疵).{0,10}(?:房子|房源|这套|该套|产品|方案)/],
+  ['high_intent', /预约|出价|面谈|签约|付款|下单|到店|现场看|看房|带看/],
 ];
 
 function normalizeText(value = '') {
@@ -53,7 +53,7 @@ function planResponse(input = {}) {
   const requireNextStep = shouldReply;
   const requireEvidence = shouldReply && (
     scenario === 'tool_no_result'
-    || riskTypes.some(type => ['pricing', 'eligibility', 'legal_or_finance', 'property_condition'].includes(type))
+    || riskTypes.some(type => ['pricing', 'eligibility', 'legal_or_finance', 'product_condition'].includes(type))
     || /价格|费用|优惠|库存|名额|政策|资格|合同|结果|案例|数据|多少|最低|最高|保证/.test(inboundContent)
   );
   const enabledSections = scenario === 'complaint'

+ 11 - 11
mcp/src/core/response-quality-verifier.js

@@ -9,8 +9,8 @@ const FILLER_OPENING_PATTERN = /^(?:关于这个问题|针对您的问题|我来
 const EMPTY_HANDOFF_PATTERN = /^(?:好的|收到|知道了|了解了|我(?:先)?看看|我(?:先)?查查|稍后回复|稍后答复|正在处理|处理中|这边给您处理一下|我帮您关注一下|具体情况我再回复)(?:[,,。!!\s]|$)/;
 const SENSITIVE_PROMISE_PATTERN = /(?:保证|承诺|绝对|百分之百|100%|一定(?:可以|能|会|不会)|全网最低|最低价|包过|稳赚|马上全部解决)/i;
 const UNCERTAINTY_PATTERN = /(?:待确认|需要确认|需要核验|以.+为准|暂未找到|没有返回|未返回|当前数据(?:源|中)?.*没有|尚无|不确定|人工确认|核实后)/;
-const FACT_CLAIM_PATTERN = /\d|(?:价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|当前有|已经找到|已确认|支持|最低|最高)/;
-const SENSITIVE_FACT_PATTERN = /(?:价格|费用|优惠|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|成交|最低|最高|保证|确定有|已经找到|已确认)/;
+const FACT_CLAIM_PATTERN = /\d|(?:价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|当前有|已经找到|已确认|支持|最低|最高|视频|照片|媒体)/;
+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}(?:人工|专员|顾问|主管|负责人)/;
@@ -46,14 +46,14 @@ const FIELD_RULES = {
     profileKeys: /location|region|area|city|address|district|区域|地区|地址|城市/,
   },
   target: {
-    question: /目标(?:学校|初中|小学|机构|产品)|哪(?:所|个)(?:学校|初中|小学|产品|方案)|哪套|房源|候选|项目编号|方案编号/,
-    answer: /(?:目标学校|目标机构|目标产品|房源|候选|项目|方案)(?:是|为|选|编号|:|:)|(?:看中|选中|确定).{0,8}(?:这套|该|方案|产品|项目)/,
-    profileKeys: /target|school|property|listing|candidate|selected|projectid|productid|目标|学校|房源|候选|方案/,
+    question: /目标(?:机构|产品|方案|学校)|哪(?:个|项|所)(?:产品|方案|学校)|候选|项目编号|方案编号/,
+    answer: /(?:目标机构|目标产品|候选|项目|方案)(?:是|为|选|编号|:|:)|(?:看中|选中|确定).{0,8}(?:该|方案|产品|项目)/,
+    profileKeys: /target|candidate|selected|projectid|productid|school|目标|候选|方案|产品|学校/,
   },
   feature: {
-    question: /户型|面积|楼层|电梯|规格|版本|功能偏好|配置偏好/,
-    answer: /(?:户型|面积|楼层|电梯|规格|版本|功能|配置)(?:是|要|不要|偏好|选择|:|:)|\d+\s*(?:平|㎡|层)/,
-    profileKeys: /layout|area|floor|elevator|feature|version|configuration|户型|面积|楼层|电梯|配置/,
+    question: /规格|版本|功能偏好|配置偏好|户型|面积/,
+    answer: /(?:规格|版本|功能|配置|户型|面积)(?:是|要|不要|偏好|选择|:|:)/,
+    profileKeys: /feature|version|configuration|layout|area|规格|版本|配置|户型|面积/,
   },
 };
 
@@ -99,7 +99,7 @@ function countQuestions(text = '') {
   return clauses.reduce((total, clause) => {
     const punctuationCount = (clause.match(/[??]/g) || []).length;
     const cueCount = (clause.match(/多少|什么时候|何时|哪(?:个|些|里|儿|天|一?所)|几(?:点|号|人|个)?|是否|能否|可否|怎么|如何|为什么/g) || []).length;
-    const collectionIntent = /(?:告诉|告知|提供|补充|发(?:一下)?|说(?:一下|下)?|说明).{0,20}(?:预算|价格上限|费用上限|投入上限|总投入|心理预算|时间|日期|人数|规模|地区|区域|地点|地址|目标|学校|初中|产品|方案|房源|小区|户型|面积|楼层|规格|版本)/.test(clause)
+    const collectionIntent = /(?:告诉|告知|提供|补充|发(?:一下)?|说(?:一下|下)?|说明).{0,20}(?:预算|价格上限|费用上限|投入上限|总投入|心理预算|时间|日期|人数|规模|地区|区域|地点|地址|目标|产品|方案|规格|版本)/.test(clause)
       || /(?:您|客户).{0,8}(?:确认|选择).{0,20}(?:哪(?:一?所|个|套)|多少|什么|何时|什么时候)/.test(clause);
     const implicitQuestion = !punctuationCount && ((cueCount > 0 && (
       /^(?:请问|麻烦|方便|能否|是否|怎么|如何|为什么|多少|什么时候|何时|哪|几|预算|时间|人数|规模|地区|区域)/.test(clause)
@@ -170,7 +170,7 @@ function claimHasScopedUncertainty(claim = '') {
   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;
+  if (/(?:价格|费用|报价|优惠|最低|最高|库存|名额|档期|预约|安排|政策|资格|资质|合同|贷款|利率|税费|媒体|照片|视频|案例|成交).{0,10}(?:待确认|待核验|需要确认|需要.{0,6}核验|需先核验|以.+为准|不确定)/.test(clause)) return true;
   return /(?:取决于|需要根据|需按|要按).{0,18}(?:确认|核验|评估|测算)/.test(clause);
 }
 
@@ -196,7 +196,7 @@ function claimIsSupported(claim, input = {}, plan = {}) {
   const externalEvidence = normalized(externalEvidencePayload(input));
   const customerEvidence = normalized(customerEvidencePayload(input));
   const numbers = clause.match(/\d+(?:\.\d+)?/g) || [];
-  const terms = clause.match(/价格|费用|投入|总价|金额|首付|月供|优惠|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|数据|结果|最低|最高|现货|匹配|预算/g) || [];
+  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)));

+ 70 - 45
mcp/src/core/webhook-processor.js

@@ -1,7 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 const { outputsRoot } = require('./output-paths');
-const { readQiweiGuid, qiweiAccountKey } = require('./credentials');
+const { readQiweiGuid, runWithQiweiContext } = require('./credentials');
 const {
   readCustomerByExternalUserId,
   readCustomerByPhone,
@@ -644,61 +644,86 @@ async function processWebhookEvents(envelope) {
   }
 
   const result = { processed: 0, ignored: 0, errors: 0 };
+  const accountBoundTypes = new Set([
+    ParsedWebhookEvent.NEW_MESSAGE,
+    ParsedWebhookEvent.GROUP_CREATED,
+    ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED,
+    ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED,
+  ]);
 
   for (const event of events) {
-    if (isDuplicateEvent(event.eventId)) {
+    if (accountBoundTypes.has(event.parsedType) && !event.guid) {
+      console.error(`[Webhook] 事件 ${event.eventId} 缺少 guid,拒绝写入当前账号`);
+      result.errors++;
+      continue;
+    }
+
+    const eventAccountUserId = event.guid ? resolveWecomUserIdForGuid(event.guid) : '';
+    const eventAccount = eventAccountUserId
+      ? { guid: event.guid, userId: eventAccountUserId }
+      : {};
+
+    if (isDuplicateEvent(event.eventId, eventAccount)) {
       console.log(`[Webhook] 重复事件已跳过: ${event.eventId}`);
       result.ignored++;
       continue;
     }
 
-    const eventFilePath = saveWebhookEventStructured(event, envelope.source || 'callback', envelope.__rawBody);
-    let completed = false;
+    if (event.guid && !eventAccountUserId) {
+      console.error(`[Webhook] 未登记设备 guid=${event.guid},拒绝写入当前账号`);
+      result.errors++;
+      continue;
+    }
 
-    try {
-      const config = readWebhookConfig();
-      const autoCreateGroupEnabled = config.autoCreateGroup !== false;
-
-      if (event.parsedType === ParsedWebhookEvent.NEW_MESSAGE) {
-        const msgResult = await routeMessageToAgent(event, eventFilePath);
-        if (msgResult.success) result.processed++;
-        else if (msgResult.ignored) result.ignored++;
-        else result.errors++;
-        completed = Boolean(msgResult.success || msgResult.ignored);
-        continue;
-      }
+    await runWithQiweiContext(eventAccount, async () => {
+      const eventFilePath = saveWebhookEventStructured(event, envelope.source || 'callback', envelope.__rawBody);
+      let completed = false;
+
+      try {
+        const config = readWebhookConfig();
+        const autoCreateGroupEnabled = config.autoCreateGroup !== false;
+
+        if (event.parsedType === ParsedWebhookEvent.NEW_MESSAGE) {
+          const msgResult = await routeMessageToAgent(event, eventFilePath);
+          if (msgResult.success) result.processed++;
+          else if (msgResult.ignored) result.ignored++;
+          else result.errors++;
+          completed = Boolean(msgResult.success || msgResult.ignored);
+          return;
+        }
 
-      if (event.parsedType === ParsedWebhookEvent.GROUP_CREATED) {
-        const groupResult = await handleGroupCreateWebhook(event, eventFilePath);
-        if (groupResult.success) result.processed++;
-        else if (groupResult.ignored) result.ignored++;
-        else result.errors++;
-        completed = Boolean(groupResult.success || groupResult.ignored);
-        continue;
-      }
+        if (event.parsedType === ParsedWebhookEvent.GROUP_CREATED) {
+          const groupResult = await handleGroupCreateWebhook(event, eventFilePath);
+          if (groupResult.success) result.processed++;
+          else if (groupResult.ignored) result.ignored++;
+          else result.errors++;
+          completed = Boolean(groupResult.success || groupResult.ignored);
+          return;
+        }
 
-      if (autoCreateGroupEnabled && (
-        event.parsedType === ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED ||
-        event.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED
-      )) {
-        const autoResult = await triggerAutoCreateGroup(event, eventFilePath);
-        if (autoResult.success) result.processed++;
-        else if (autoResult.ignored) result.ignored++;
-        else result.errors++;
-        completed = Boolean(autoResult.success || autoResult.ignored);
-        continue;
-      }
+        if (autoCreateGroupEnabled && (
+          event.parsedType === ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED ||
+          event.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED
+        )) {
+          const autoResult = await triggerAutoCreateGroup(event, eventFilePath);
+          if (autoResult.success) result.processed++;
+          else if (autoResult.ignored) result.ignored++;
+          else result.errors++;
+          completed = Boolean(autoResult.success || autoResult.ignored);
+          return;
+        }
 
-      updateWebhookEventStatus(eventFilePath, 'IGNORED', `事件类型 ${event.parsedType} 不需要自动处理`);
-      result.ignored++;
-      completed = true;
-    } catch (err) {
-      console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err);
-      updateWebhookEventStatus(eventFilePath, 'ERROR', String(err && err.message ? err.message : err));
-      result.errors++;
-    } finally {
-      if (completed) markEventProcessed(event.eventId);
-    }
+        updateWebhookEventStatus(eventFilePath, 'IGNORED', `事件类型 ${event.parsedType} 不需要自动处理`);
+        result.ignored++;
+        completed = true;
+      } catch (err) {
+        console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err);
+        updateWebhookEventStatus(eventFilePath, 'ERROR', String(err && err.message ? err.message : err));
+        result.errors++;
+      } finally {
+        if (completed) markEventProcessed(event.eventId, eventAccount);
+      }
+    });
   }
 
   return result;

+ 9 - 4
mcp/src/core/webhook-store.js

@@ -65,16 +65,21 @@ function writeProcessedEventIds(set) {
   return ids;
 }
 
-function markEventProcessed(eventId) {
+function eventScopeKey(eventId, account = {}) {
+  const userId = String(account.userId || '').trim();
+  return userId ? `${userId}:${eventId}` : String(eventId || '');
+}
+
+function markEventProcessed(eventId, account = {}) {
   const set = readProcessedEventIds();
-  set.add(eventId);
+  set.add(eventScopeKey(eventId, account));
   writeProcessedEventIds(set);
   return true;
 }
 
-function isDuplicateEvent(eventId) {
+function isDuplicateEvent(eventId, account = {}) {
   const set = readProcessedEventIds();
-  return set.has(eventId);
+  return set.has(eventScopeKey(eventId, account));
 }
 
 function saveWebhookEventStructured(event, source = 'callback', rawBody = null) {

+ 9 - 1
mcp/src/core/webhook-types.js

@@ -155,8 +155,16 @@ function convertV2ToV1Item(v2) {
     msgData = v2.data.content;
   }
 
+  const guid = String(
+    v2.guid
+    || (v2.meta && (v2.meta.guid || v2.meta.deviceGuid))
+    || (v2.data && (v2.data.guid || v2.data.deviceGuid))
+    || (v2.data && v2.data.device && v2.data.device.guid)
+    || ''
+  ).trim();
+
   return {
-    guid: '',
+    guid,
     cmd: cls.cmd,
     msgType,
     msgUniqueIdentifier: eventId,

+ 36 - 2
mcp/src/dashboard/agent-service.js

@@ -36,6 +36,7 @@ const { getRelayDaemonStatus } = require('../core/relay-daemon');
 const { normalizeMessage } = require('../core/message-normalizer');
 const { sanitizePayload } = require('../providers/fmode-wecom-gateway');
 const { appendChatRecord, ensureMemory } = require('../core/message-archive');
+const { mediaMessageDescriptor } = require('../core/inbound-media');
 
 const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
@@ -982,6 +983,39 @@ function conversationChannelInfo(row, messages = []) {
   };
 }
 
+function publicMessageRaw(raw = {}) {
+  if (raw.audioAvailable === true) {
+    return {
+      msgType: Number(raw.msgType),
+      audioAvailable: true,
+      duration: Number(raw.duration) || 0,
+      fileSize: Number(raw.fileSize) || 0,
+      tone: raw.tone || null,
+      profileId: raw.profileId || null,
+    };
+  }
+  const descriptor = mediaMessageDescriptor(raw);
+  if (!descriptor) {
+    return {
+      msgType: Number(raw.msgType) || 0,
+      source: String(raw.source || ''),
+      fromRoomId: raw.fromRoomId ? String(raw.fromRoomId) : null,
+    };
+  }
+  const cache = raw.mediaCache || {};
+  return {
+    msgType: Number(raw.msgType),
+    mediaKind: descriptor.kind,
+    mediaAvailable: cache.status !== 'unavailable',
+    mediaStatus: cache.status || 'pending',
+    mediaErrorCode: cache.status === 'unavailable' ? String(cache.errorCode || 'MEDIA_UNAVAILABLE') : '',
+    duration: Number(descriptor.payload?.duration) || 0,
+    width: Number(descriptor.payload?.width) || 0,
+    height: Number(descriptor.payload?.height) || 0,
+    fileName: descriptor.payload?.fileName || '',
+  };
+}
+
 function publicConversation(row) {
   const detail = workbench.service.conversationDetail(row.id);
   const claudeSession = getCustomerSessionGuide(row, {
@@ -1041,7 +1075,7 @@ function publicConversation(row) {
         contentType: message.content_type,
         contentTypeLabel: raw.normalizedContentLabel || '',
         payload: parseJson(message.payload_json, {}),
-        raw: sanitizePayload(raw),
+        raw: publicMessageRaw(raw),
       };
     }),
     analysis: {
@@ -2495,5 +2529,5 @@ module.exports = {
   getAgentRuntimeConfig,
   closeAgentWorkbenches,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, archiveInboundMessage, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillMessageNormalization, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, archiveInboundMessage, conversationChannelInfo, publicConversation, publicMessageRaw, backfillCustomerIntelligence, backfillCustomerMemory, backfillMessageNormalization, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
 };

+ 23 - 0
mcp/src/dashboard/app.js

@@ -2955,10 +2955,18 @@
     </div>`;
   }
 
+  function agentMediaUnavailableHtml(message, label) {
+    const url = agentMediaPath(message);
+    return `<button type="button" class="agent-media-retry" data-media-retry data-media-url="${escapeHtml(url)}" aria-label="重试加载${escapeHtml(label)}">媒体暂时不可用,点击重试</button>`;
+  }
+
   function agentCardMessageHtml(message) {
     const type = String(message.contentType || '').toLowerCase();
     const data = agentMessageData(message);
     if (['redpacket', 'external_redpacket', 'transfer'].includes(type)) return agentFinancialMessageHtml(message, type, data);
+    if (message.raw?.mediaStatus === 'unavailable' && ['image', 'emotion', 'video', 'file', 'voice'].includes(type)) {
+      return agentMediaUnavailableHtml(message, type === 'emotion' ? '动画表情' : type === 'video' ? '视频' : type === 'file' ? '文件' : type === 'voice' ? '语音' : '图片');
+    }
 
     if (type === 'image' || type === 'emotion') {
       const localMedia = message.role === 'customer' ? agentMediaPath(message, 'preview') : '';
@@ -5023,6 +5031,21 @@
     }, true);
 
     page.addEventListener('click', async event => {
+      const mediaRetry = event.target.closest('[data-media-retry]');
+      if (mediaRetry) {
+        mediaRetry.disabled = true;
+        try {
+          const response = await fetch(mediaRetry.dataset.mediaUrl, { cache: 'no-store' });
+          if (!response.ok) throw new Error('媒体文件暂时不可用');
+          await loadAgentData(page, true);
+          toast('媒体已恢复', 'success');
+        } catch (error) {
+          toast(error.message || '媒体文件暂时不可用', 'error');
+        } finally {
+          mediaRetry.disabled = false;
+        }
+        return;
+      }
       const drawerClose = event.target.closest('[data-agent-drawer-close]');
       if (drawerClose) {
         state.agent.openDrawer = null;

Fișier diff suprimat deoarece este prea mare
+ 5 - 0
package.json


+ 37 - 0
runtime/callback-service/src/personal-polling.mjs

@@ -1,3 +1,4 @@
+import fs from 'node:fs';
 import path from 'node:path';
 import { spawn } from 'node:child_process';
 import { PACKAGE_ROOT } from './config-loader.mjs';
@@ -34,6 +35,7 @@ export class PersonalPollingRuntime {
     this.cancelWait = null;
     this.lastRecoveryAttemptAt = 0;
     this.lastRecoveryError = '';
+    this.lastObservedOnline = null;
   }
 
   wait(ms) {
@@ -50,6 +52,16 @@ export class PersonalPollingRuntime {
     });
   }
 
+  _recordOfflineEvent(entry) {
+    try {
+      const filePath = path.join(this.workspaceRoot, 'outputs', 'runtime', 'offline-events.jsonl');
+      fs.mkdirSync(path.dirname(filePath), { recursive: true });
+      fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, 'utf8');
+    } catch (error) {
+      process.stderr.write(`[offline-monitor] 记录失败: ${error.message}\n`);
+    }
+  }
+
   startFriendWorker() {
     if (!this.config.friendPollingEnabled || this.friendWorker) return;
     const scriptPath = path.join(PACKAGE_ROOT, 'scripts', 'friend-polling-worker.js');
@@ -100,12 +112,37 @@ export class PersonalPollingRuntime {
     let account = result?.data?.account || {};
     let requiresLogin = account.reason === 'upstream_device_missing';
 
+    if (account.online === false && this.lastObservedOnline === true) {
+      this._recordOfflineEvent({
+        ts: new Date().toISOString(),
+        event: 'device_offline',
+        online: false,
+        reason: account.reason || null,
+        statusText: account.statusText || null,
+        requiresLogin,
+        syncKey: listener.syncKey || null,
+      });
+    }
+    if (account.online === true || account.online === false) {
+      this.lastObservedOnline = account.online === true;
+    }
+
     if (account.online === false && !requiresLogin) {
+      const prevRecoveryAttempt = this.lastRecoveryAttemptAt;
       await this.recoverAndRestart(listener);
       result = await this.listenerApi.getStatus();
       listener = result?.data?.listener || {};
       account = result?.data?.account || {};
       requiresLogin = account.reason === 'upstream_device_missing';
+      if (this.lastRecoveryAttemptAt > prevRecoveryAttempt) {
+        this._recordOfflineEvent({
+          ts: new Date().toISOString(),
+          event: 'device_recover_result',
+          recoveredOnline: account.online !== false,
+          reasonAfter: account.reason || null,
+          error: this.lastRecoveryError || null,
+        });
+      }
     }
 
     if (!listener.running && account.online !== false) {

+ 26 - 13
scripts/agent-conversation-journey-smoke-test.js

@@ -1,18 +1,19 @@
 'use strict';
 
 const assert = require('assert/strict');
-const {
-  APPLIED_ACTION_STATUSES,
-  CONFIRMING_EVIDENCE_TYPES,
-  createJourneyDefinition,
-  createJourneyState,
-  applyJourneyFacts,
-  isStageComplete,
-  planAppliedAction,
-  executeAppliedAction,
-  confirmAppliedAction,
-  serializeJourneyState,
-} = require('../mcp/src/core/agent-conversation-journey');
+  const {
+    APPLIED_ACTION_STATUSES,
+    CONFIRMING_EVIDENCE_TYPES,
+    createJourneyDefinition,
+    createJourneyState,
+    applyJourneyFacts,
+    isStageComplete,
+    planAppliedAction,
+    executeAppliedAction,
+    confirmAppliedAction,
+    serializeJourneyState,
+    buildConversationStateSnapshot,
+  } = require('../mcp/src/core/agent-conversation-journey');
 
 function confirmation(type, id, at) {
   return { type, id, at, success: true };
@@ -141,12 +142,24 @@ function main() {
   assert.equal(serialized, serializeJourneyState(reordered));
   assert(skipped.appliedActions.every(action => APPLIED_ACTION_STATUSES.includes(action.status)));
 
+  const snapshot = buildConversationStateSnapshot({
+    inboundContent: '预算 20 万,想预约明天下午到店',
+    messages: [{ role: 'user', content: '想了解企业服务收费' }],
+    profile: { budget: '20万' },
+  });
+  assert.equal(snapshot.knownConstraints.budget.source, 'conversation');
+  assert.equal(snapshot.highIntent.active, true);
+  assert.equal(snapshot.highIntent.kind, 'schedule_intake');
+  assert(snapshot.knownFields.includes('budget'));
+  assert(snapshot.knownFields.includes('appointmentIntent'));
+
   process.stdout.write(`${JSON.stringify({
     status: 'ok',
-    checks: 25,
+    checks: 30,
     finalStage: skipped.currentStageId,
     actions: skipped.appliedActions.length,
     historyEntries: skipped.stageHistory.length,
+    snapshotFields: snapshot.knownFields,
   }, null, 2)}\n`);
 }
 

+ 129 - 0
scripts/agent-inbound-media-smoke-test.js

@@ -0,0 +1,129 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-inbound-media-'));
+process.env.QIWEI_OUTPUTS_DIR = path.join(tempRoot, 'outputs');
+
+const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
+const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
+const { evaluatePolledMessage } = require('../mcp/src/core/agent-poller-policy');
+const { InboundMediaService, mediaMessageDescriptor, sniffMedia } = require('../mcp/src/core/inbound-media');
+
+async function main() {
+  const imageMessage = {
+    msgType: 14,
+    senderId: 'contact-1',
+    timestamp: 1785920000,
+    msgData: { fileId: 'file-1', fileAesKey: 'key-1', fileSize: 68, width: 1, height: 1 },
+  };
+  const imageCandidate = evaluatePolledMessage(imageMessage, { allowedSenders: ['contact-1'] });
+  assert.equal(imageCandidate.eligible, true);
+  assert.equal(imageCandidate.content, '[图片]');
+  assert.equal(imageCandidate.media.kind, 'image');
+  assert.equal(imageCandidate.generateReply, false);
+
+  const voiceCandidate = evaluatePolledMessage({
+    msgType: 16,
+    senderId: 'contact-1',
+    timestamp: 1785920001,
+    msgData: { fileId: 'voice-1', fileAesKey: 'key-2', voiceTime: 6 },
+  }, { allowedSenders: ['contact-1'] });
+  assert.equal(voiceCandidate.eligible, true);
+  assert.equal(voiceCandidate.content, '[语音]');
+  assert.equal(voiceCandidate.generateReply, false);
+
+  const analyzedVoice = mediaMessageDescriptor({
+    msgType: 16,
+    msgData: { transcript: '我明天下午有时间' },
+  });
+  assert.equal(analyzedVoice.mediaOnly, false);
+  assert.equal(analyzedVoice.content, '[语音] 我明天下午有时间');
+
+  const groupCandidate = evaluatePolledMessage({ ...imageMessage, fromRoomId: 'room-1' }, { allowedSenders: ['contact-1'] });
+  assert.equal(groupCandidate.eligible, false);
+  assert.equal(groupCandidate.reason, 'group_message');
+
+  const db = new AgentWorkbenchDb(path.join(tempRoot, 'workbench.db'));
+  let agentCalls = 0;
+  const service = new AgentWorkbenchService({
+    db,
+    agent: { run: async () => { agentCalls += 1; throw new Error('agent must not run'); } },
+    qiwei: {},
+    config: { qiwei: { allowedSenders: ['contact-1'] }, agent: {} },
+  });
+  const stored = await service.ingestInbound({
+    externalId: 'image-message-1',
+    contactId: 'contact-1',
+    contactName: '测试联系人',
+    content: imageCandidate.content,
+    timestamp: new Date(1785920000 * 1000).toISOString(),
+    raw: imageMessage,
+    contentType: imageCandidate.media.contentType,
+    payload: imageCandidate.media.payload,
+  }, { generateReply: false });
+  assert.equal(stored.status, 'media_received');
+  assert.equal(agentCalls, 0);
+  const second = await service.ingestInbound({
+    externalId: 'image-message-2',
+    contactId: 'contact-1',
+    contactName: '测试联系人',
+    content: imageCandidate.content,
+    timestamp: new Date((1785920000 + 1) * 1000).toISOString(),
+    raw: { ...imageMessage, msgServerId: 'image-message-2' },
+    contentType: imageCandidate.media.contentType,
+    payload: imageCandidate.media.payload,
+  }, { generateReply: false });
+  assert.equal(second.status, 'media_received');
+  const rows = db.listMessages(stored.conversation.id);
+  assert.equal(rows.length, 2);
+  assert.equal(rows[0].content_type, 'image');
+  assert.equal(JSON.parse(rows[0].raw_json).msgData.fileId, 'file-1');
+  if (typeof service.stopBackgroundWorkers === 'function') service.stopBackgroundWorkers();
+  db.close();
+
+  const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
+  assert.equal(sniffMedia(png, 'application/octet-stream').contentType, 'image/png');
+  let downloadCalls = 0;
+  const resolver = new InboundMediaService({
+    qiwei: {
+      call: async (method, params, options) => {
+        assert.equal(method, '/cloud/wxWorkDownload');
+        assert.equal(params.fileType, 1);
+        assert.equal(options.sanitizeResponse, false);
+        return { cloudUrl: 'https://media.example.test/image.png' };
+      },
+    },
+    fetchImpl: async url => {
+      downloadCalls += 1;
+      assert.equal(url, 'https://media.example.test/image.png');
+      return new Response(png, { status: 200, headers: { 'content-type': 'image/png' } });
+    },
+  });
+  const resolved = await resolver.resolve('message-1', imageMessage);
+  assert.equal(downloadCalls, 1);
+  assert.equal(resolved.contentType, 'image/png');
+  assert.equal(fs.readFileSync(resolved.filePath).equals(png), true);
+
+  const dashboardApp = fs.readFileSync(path.join(__dirname, '..', 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
+  const dashboardServer = fs.readFileSync(path.join(__dirname, '..', 'mcp', 'src', 'dashboard', 'server.js'), 'utf8');
+  const agentService = fs.readFileSync(path.join(__dirname, '..', 'mcp', 'src', 'dashboard', 'agent-service.js'), 'utf8');
+  const workbenchService = fs.readFileSync(path.join(__dirname, '..', 'mcp', 'src', 'core', 'agent-workbench-service.js'), 'utf8');
+  assert.match(dashboardApp, /data-media-retry/);
+  assert.match(dashboardApp, /\/api\/agent\/messages\/\$\{encodeURIComponent\(message\.id\)\}\/media/);
+  assert.match(dashboardServer, /\/api\\\/agent\\\/messages\\\/\(\[\^\/\]\+\)\\\/media/);
+  assert.match(agentService, /function publicMessageRaw/);
+  assert.match(workbenchService, /media_received_without_auto_reply/);
+
+  fs.rmSync(tempRoot, { recursive: true, force: true });
+  console.log('agent inbound media smoke: passed');
+}
+
+main().catch(error => {
+  try { fs.rmSync(tempRoot, { recursive: true, force: true }); } catch {}
+  console.error(error);
+  process.exit(1);
+});

+ 10 - 1
scripts/agent-intake-policy-smoke-test.js

@@ -47,7 +47,16 @@ function createContext({ accountKey = 'account-a', mode = 'allowlist_only', welc
   const agent = {
     modelClient: { isConfigured: () => true },
     async run() {
-      return { content: '普通 Agent 回复草稿', confidence: 0.5, intent: 'general', reason: 'smoke', requiresHuman: true, profileUpdates: {}, citations: [], toolTrace: [] };
+      return {
+        content: '可以按您的节奏继续;您更关心材料清单还是办理时间?',
+        confidence: 0.95,
+        intent: 'general',
+        reason: 'smoke',
+        requiresHuman: false,
+        profileUpdates: {},
+        citations: [],
+        toolTrace: [],
+      };
     },
   };
   const service = new AgentWorkbenchService({ db, agent, qiwei, config });

+ 58 - 0
scripts/agent-knowledge-smoke-test.js

@@ -0,0 +1,58 @@
+'use strict';
+
+const assert = require('assert/strict');
+const path = require('path');
+const { AgentKnowledgeStore, stripRetrievalAliases, scoreKnowledgeChunk } = require('../mcp/src/core/agent-knowledge');
+
+function main() {
+  const knowledge = new AgentKnowledgeStore({
+    knowledgeDir: path.resolve(__dirname, '..', 'knowledge'),
+  });
+
+  const rules = knowledge.compactRulesText();
+  assert.match(rules, /把客户当前问题闭环/);
+  assert.match(rules, /像真人微信交流,普通回复限定 1-3 句、优先 50-80 字、问号最多 1 个/);
+  assert.match(rules, /不是每轮必须提问/);
+  assert.doesNotMatch(rules, /接触即邀约|默认朝向邀约带看|每一轮沟通的真实目的都是把客户约出来带看/);
+  assert.doesNotMatch(rules, /专业房产经纪人|促成有效带看/);
+
+  const index = knowledge.knowledgeIndex();
+  assert.match(index, /knowledge\/customer-service\/chat-role\.md/);
+  assert.doesNotMatch(index, /knowledge\/real-estate\//);
+
+  const stats = knowledge.stats();
+  assert(stats.knowledgeChunks >= 8, `expected knowledge chunks, got ${stats.knowledgeChunks}`);
+
+  const headingHit = scoreKnowledgeChunk({
+    heading: '北极星目标',
+    content: '辅助说明',
+  }, ['北极星']);
+  const bodyHit = scoreKnowledgeChunk({
+    heading: '其他',
+    content: '这里也提到北极星作为背景',
+  }, ['北极星']);
+  assert(headingHit > bodyHit, `heading should outrank body: ${headingHit} vs ${bodyHit}`);
+
+  const billing = knowledge.search('怎么收费', 5);
+  assert(billing.length > 0);
+  assert(billing.some(item => /收费|费用|人工/.test(`${item.heading}\n${item.content}`)));
+
+  const aliasQuery = knowledge.search('客服机器人', 5);
+  assert(aliasQuery.some(item => item.source === 'faq.md'));
+  for (const item of aliasQuery) {
+    assert.doesNotMatch(item.content, /常见说法/);
+  }
+  assert.equal(stripRetrievalAliases('> 常见说法:客服机器人\n\n正文').includes('常见说法'), false);
+
+  process.stdout.write(`${JSON.stringify({
+    status: 'ok',
+    chunks: stats.knowledgeChunks,
+    billingHits: billing.length,
+  }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+}

+ 50 - 0
scripts/agent-response-human-style-smoke-test.js

@@ -0,0 +1,50 @@
+'use strict';
+
+const assert = require('assert/strict');
+const {
+  replyStyleViolations,
+  enforceHumanReplyStyle,
+  enforceTabooFreeReply,
+  enforceSingleFinalReply,
+} = require('../mcp/src/core/response-human-style');
+
+function main() {
+  const inbound = '想了解你们的企业服务怎么收费?';
+  const clean = '费用要按服务范围确认;您更关心套餐还是人数?';
+  assert.deepEqual(replyStyleViolations(clean, inbound), []);
+
+  const promised = replyStyleViolations('我先帮您核实好再回复您。', inbound);
+  assert(promised.includes('unverified_commitment'));
+
+  const guaranteed = replyStyleViolations('保证给您全网最低价,一定不会更贵。', inbound);
+  assert(guaranteed.includes('absolute_guarantee'));
+
+  const enforced = enforceHumanReplyStyle(
+    { reply: '我先帮您核实好再回复您。', reason: 'fixture' },
+    inbound,
+    {},
+    { conversationKey: 'human-style-smoke' },
+  );
+  assert.notEqual(enforced.reply, '我先帮您核实好再回复您。');
+  assert.match(enforced.reply, /确定|核|条件|确认|范围|档期/);
+  assert.doesNotMatch(enforced.reply, /房源|看房|带看|小牛/);
+
+  const taboo = enforceTabooFreeReply({
+    reply: '为了更好地为您服务,我们一直致力于提供优质服务。费用按范围确认。',
+  });
+  assert.doesNotMatch(taboo.reply, /为了更好地为您服务|我们一直致力于提供优质服务/);
+
+  const collapsed = enforceSingleFinalReply({
+    reply: '草稿版本。\n最终回复:费用要按服务范围确认,您更关心套餐还是人数?',
+  });
+  assert.match(collapsed.reply, /费用要按服务范围确认/);
+  assert.doesNotMatch(collapsed.reply, /草稿版本/);
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 6 }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+}

+ 9 - 1
scripts/message-archive-smoke-test.js

@@ -50,7 +50,15 @@ async function main() {
     assert.match(archive.memoryIndexText('contact-1'), /用户记忆索引/);
     assert.equal(archive.memoryRelBase('contact/1'), `messages/memory/${accountDir}/contact_1`);
 
-    process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, archiveDefault: 'off', memoryDefault: 'off' }, null, 2)}\n`);
+    await archive.appendChatRecord({
+      wxid: 'contact/unsafe', messageId: 'm-dup-1', externalId: 'e-dup-1', dir: 'in', senderType: 'customer',
+      content: '需要下周安排一次产品演示', createdAt: '2026-08-03T10:00:00.000Z', source: 'smoke-dup',
+    });
+    await archive.drainQueues();
+    const chatLines = fs.readFileSync(chatFile, 'utf8').split(/\r?\n/).filter(Boolean);
+    assert.equal(chatLines.length, 1);
+
+    process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 15, archiveDefault: 'off', memoryDefault: 'off' }, null, 2)}\n`);
   } finally {
     if (previousArchive === undefined) delete process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED;
     else process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = previousArchive;

+ 67 - 0
scripts/online-qiwei-smoke.mjs

@@ -0,0 +1,67 @@
+const baseUrl = String(
+  process.argv[2] || process.env.QIWEI_ONLINE_BASE || "https://server.fmode.cn",
+).replace(/\/+$/, "");
+const authToken = String(process.env.QIWEI_AUTH_TOKEN || "").trim();
+const timeoutMs = 15_000;
+
+async function request(path, options = {}) {
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), timeoutMs);
+  try {
+    const response = await fetch(`${baseUrl}${path}`, {
+      ...options,
+      headers: {
+        Accept: "application/json",
+        ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
+        ...(options.headers || {}),
+      },
+      signal: controller.signal,
+    });
+    const contentType = response.headers.get("content-type") || "";
+    const text = await response.text();
+    let body = null;
+    if (contentType.includes("json")) {
+      try {
+        body = JSON.parse(text);
+      } catch {
+        body = null;
+      }
+    }
+    return {
+      path,
+      status: response.status,
+      contentType,
+      bytes: Buffer.byteLength(text),
+      code: body && typeof body === "object" ? body.code ?? null : null,
+    };
+  } finally {
+    clearTimeout(timeout);
+  }
+}
+
+function expect(name, result, statuses) {
+  const ok = statuses.includes(result.status);
+  console.log(JSON.stringify({ name, ...result, ok }));
+  if (!ok) throw new Error(`${name} returned HTTP ${result.status}`);
+}
+
+const health = await request("/api/qiwei/health");
+expect("health", health, [200]);
+
+const test = await request("/api/qiwei/test");
+expect("test", test, [200]);
+
+const plans = await request("/api/qiwei/product/plans?seats=1&months=1");
+expect("plans", plans, [200]);
+
+if (authToken) {
+  const trial = await request("/api/qiwei/trial/status");
+  expect("trial-status", trial, [200]);
+  const subscription = await request("/api/qiwei/subscribe/status?seats=1&months=1");
+  expect("subscription-status", subscription, [200]);
+} else {
+  const unauthenticated = await request("/api/qiwei/trial/status");
+  expect("trial-status-without-auth", unauthenticated, [401, 403]);
+}
+
+console.log(JSON.stringify({ ok: true, baseUrl, authenticatedChecks: Boolean(authToken) }));

+ 1 - 1
scripts/sales-champion-generation-plan-smoke-test.js

@@ -71,7 +71,7 @@ async function main() {
   assert.equal(missingEvidence.generationPlan.directAnswer.evidenceBoundary, 'direction_with_explicit_unknowns');
   assert.equal(missingEvidence.generationPlan.judgment.evidenceLimited, true);
   assert.match(missingEvidence.instruction, /先给当前能成立的方向判断/);
-  assert.match(missingEvidence.instruction, /不编造具体房源事实/);
+  assert.match(missingEvidence.instruction, /不编造具体产品或库存事实/);
 
   const riskBoundary = buildPlan({
     inboundContent: '这个房子的产权和税费能确认吗?',

+ 262 - 0
scripts/session-reply-delivery-regression-smoke-test.js

@@ -0,0 +1,262 @@
+'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-session-delivery-'));
+process.env.QIWEI_MESSAGES_DIR = path.join(RUN_ROOT, 'messages');
+process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = '1';
+
+const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
+const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
+const { appendChatRecord, drainQueues } = require('../mcp/src/core/message-archive');
+
+const results = [];
+
+function check(name, fn) {
+  return Promise.resolve().then(fn).then(() => {
+    results.push({ name, status: 'passed' });
+  });
+}
+
+function findFiles(root) {
+  if (!fs.existsSync(root)) return [];
+  const files = [];
+  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
+    const target = path.join(root, entry.name);
+    if (entry.isDirectory()) files.push(...findFiles(target));
+    else files.push(target);
+  }
+  return files;
+}
+
+function qualifiedOutput(content) {
+  return {
+    content,
+    confidence: 0.96,
+    intent: 'session_delivery_regression',
+    reason: 'Deterministic delivery fixture.',
+    requiresHuman: false,
+    qualityPassed: true,
+    qualityScore: 96,
+    qualityChecks: [],
+    citations: [],
+    toolTrace: [],
+  };
+}
+
+async function testLocalOutboundMergesRemoteEcho() {
+  const dbPath = path.join(RUN_ROOT, 'merge', 'workbench.db');
+  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+  const db = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
+  try {
+    const conversation = db.ensureConversation('contact-merge', 'Fixture');
+    const createdAt = '2026-08-18T10:00:00.000Z';
+    const local = db.insertMessage({
+      conversationId: conversation.id,
+      direction: 'outbound',
+      senderType: 'agent',
+      content: '这条方案我先给您记下,您看周六上午方便吗?',
+      status: 'sent',
+      createdAt,
+      raw: { source: 'autopilot', inboundMessageId: 'inbound-1' },
+    }).message;
+    const match = db.findRecentOutboundMatch(conversation.id, local.content, '2026-08-18T10:00:12.000Z', 300);
+    assert.equal(match.id, local.id);
+
+    const merged = db.attachExternalId(local.id, 'remote-outbound-1', { source: 'manual_sync' });
+    assert.equal(merged.external_id, 'remote-outbound-1');
+    const replay = db.insertMessage({
+      conversationId: conversation.id,
+      externalId: 'remote-outbound-1',
+      direction: 'outbound',
+      senderType: 'human',
+      content: local.content,
+      status: 'sent',
+      createdAt: '2026-08-18T10:00:12.000Z',
+      raw: { source: 'manual_sync' },
+    });
+    assert.equal(replay.created, false);
+    assert.equal(db.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 1);
+  } finally {
+    db.close();
+  }
+}
+
+async function testConcurrentAutopilotSendsOnce() {
+  const dbPath = path.join(RUN_ROOT, 'concurrency', 'workbench.db');
+  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+  const db = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
+  const sent = [];
+  const service = new AgentWorkbenchService({
+    db,
+    agent: { modelClient: { isConfigured: () => true } },
+    qiwei: {
+      isConfigured: () => true,
+      async sendText(toId, content) {
+        sent.push({ toId, content });
+        return { isSendSuccess: true, msgServerId: 'remote-concurrent-1' };
+      },
+    },
+    config: {
+      accountKey: 'session-delivery-regression',
+      agent: { qualityPassScore: 82 },
+      qiwei: { allowedSenders: ['contact-concurrent'] },
+      memory: { enabled: false },
+    },
+  });
+  try {
+    const conversation = db.ensureConversation('contact-concurrent', 'Fixture');
+    const inbound = db.insertMessage({
+      conversationId: conversation.id,
+      externalId: 'remote-inbound-1',
+      direction: 'inbound',
+      senderType: 'customer',
+      content: '请把当前最合适的方案发我',
+    }).message;
+    const output = qualifiedOutput('按当前条件,这个方案先给您参考;您看周六上午方便吗?');
+    const [first, second] = await Promise.all([
+      service.sendAutopilotReply(conversation, inbound, output),
+      service.sendAutopilotReply(conversation, inbound, output),
+    ]);
+    assert.equal(sent.length, 1);
+    assert.equal(first.status, 'autopilot_sent');
+    assert.equal(second.status, 'autopilot_already_sent');
+    assert.equal(db.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 1);
+    assert.equal(db.listAudit(50, conversation.id).filter(item => item.action === 'autopilot_duplicate_send_suppressed').length, 1);
+  } finally {
+    if (typeof service.stopBackgroundWorkers === 'function') service.stopBackgroundWorkers();
+    db.close();
+  }
+}
+
+async function testCrossProcessDeliveryClaimSendsOnce() {
+  const dbPath = path.join(RUN_ROOT, 'cross-process', 'workbench.db');
+  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+  const dbA = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
+  const dbB = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
+  const sent = [];
+  const makeService = db => new AgentWorkbenchService({
+    db,
+    agent: { modelClient: { isConfigured: () => true } },
+    qiwei: {
+      isConfigured: () => true,
+      async sendText(toId, content) {
+        sent.push({ toId, content });
+        await new Promise(resolve => setTimeout(resolve, 30));
+        return { isSendSuccess: true, msgServerId: 'remote-cross-process-1' };
+      },
+    },
+    config: {
+      accountKey: 'session-delivery-cross-process',
+      agent: { qualityPassScore: 82 },
+      qiwei: { allowedSenders: ['contact-cross-process'] },
+      memory: { enabled: false },
+    },
+  });
+  const serviceA = makeService(dbA);
+  const serviceB = makeService(dbB);
+  try {
+    const conversation = dbA.ensureConversation('contact-cross-process', 'Fixture');
+    const inbound = dbA.insertMessage({
+      conversationId: conversation.id,
+      externalId: 'remote-inbound-cross-process-1',
+      direction: 'inbound',
+      senderType: 'customer',
+      content: '请把当前最合适的方案发我',
+    }).message;
+    const output = qualifiedOutput('按当前条件,这个方案先给您参考;您看周六上午方便吗?');
+    const [first, second] = await Promise.all([
+      serviceA.sendAutopilotReply(conversation, inbound, output),
+      serviceB.sendAutopilotReply(conversation, inbound, output),
+    ]);
+    assert.equal(sent.length, 1);
+    assert.deepEqual([first.status, second.status].sort(), ['autopilot_already_sent', 'autopilot_sent']);
+    assert.equal(dbA.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 1);
+    assert.equal(dbA.listAudit(50, conversation.id).filter(item => item.action === 'autopilot_delivery_in_flight_suppressed').length, 1);
+  } finally {
+    if (typeof serviceA.stopBackgroundWorkers === 'function') serviceA.stopBackgroundWorkers();
+    if (typeof serviceB.stopBackgroundWorkers === 'function') serviceB.stopBackgroundWorkers();
+    dbA.close();
+    dbB.close();
+  }
+}
+
+async function testGlobalPauseSuppressesQueuedAutopilotDelivery() {
+  const dbPath = path.join(RUN_ROOT, 'global-pause', 'workbench.db');
+  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+  const db = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
+  const sent = [];
+  const service = new AgentWorkbenchService({
+    db,
+    agent: { modelClient: { isConfigured: () => true } },
+    qiwei: {
+      isConfigured: () => true,
+      async sendText(toId, content) {
+        sent.push({ toId, content });
+        return { isSendSuccess: true, msgServerId: 'remote-global-pause-1' };
+      },
+    },
+    config: {
+      accountKey: 'session-delivery-global-pause',
+      agent: { qualityPassScore: 82 },
+      qiwei: { allowedSenders: ['contact-global-pause'] },
+      memory: { enabled: false },
+    },
+  });
+  try {
+    const conversation = db.ensureConversation('contact-global-pause', 'Fixture');
+    db.setConversationMode(conversation.id, 'autopilot');
+    const inbound = db.insertMessage({
+      conversationId: conversation.id,
+      externalId: 'remote-inbound-global-pause-1',
+      direction: 'inbound',
+      senderType: 'customer',
+      content: '暂停后不要自动发出',
+    }).message;
+    service.setGlobal({ paused: true }, 'regression');
+    const result = await service.sendAutopilotReply(conversation, inbound, qualifiedOutput('这条不应出站'));
+    assert.equal(result.status, 'paused');
+    assert.equal(sent.length, 0);
+    assert.equal(db.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 0);
+    assert.equal(db.listAudit(50, conversation.id).filter(item => item.action === 'autopilot_paused_delivery_suppressed').length, 1);
+  } finally {
+    if (typeof service.stopBackgroundWorkers === 'function') service.stopBackgroundWorkers();
+    db.close();
+  }
+}
+
+async function testArchiveDeduplicatesSameVisibleMessage() {
+  const createdAt = '2026-08-18T10:00:00.000Z';
+  const payload = {
+    wxid: 'contact-archive',
+    messageId: 'local-outbound-archive',
+    externalId: 'remote-outbound-archive',
+    dir: 'out',
+    senderType: 'agent',
+    content: '同一条可见消息只应归档一次。',
+    createdAt,
+  };
+  await appendChatRecord(payload);
+  await appendChatRecord({ ...payload, messageId: 'remote-outbound-archive', source: 'manual_sync' });
+  await drainQueues();
+  const files = findFiles(path.join(RUN_ROOT, 'messages'));
+  const rows = files.flatMap(file => fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)));
+  assert.equal(rows.filter(row => row.content === payload.content && row.dir === 'out').length, 1);
+}
+
+async function main() {
+  await check('local outbound is merged with the remote echo', testLocalOutboundMergesRemoteEcho);
+  await check('concurrent autopilot delivery emits one visible outbound', testConcurrentAutopilotSendsOnce);
+  await check('cross-process delivery claim emits one visible outbound', testCrossProcessDeliveryClaimSendsOnce);
+  await check('global pause suppresses queued autopilot delivery', testGlobalPauseSuppressesQueuedAutopilotDelivery);
+  await check('chat archive suppresses duplicate visible rows', testArchiveDeduplicatesSameVisibleMessage);
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: results.length, results }, null, 2)}\n`);
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+});

+ 31 - 0
scripts/start-dashboard.js

@@ -1,4 +1,6 @@
+const fs = require('fs');
 const path = require('path');
+const { spawn } = require('child_process');
 const { applyWorkspaceContext } = require('../mcp/src/core/runtime-context');
 
 const packageRoot = path.resolve(__dirname, '..');
@@ -6,6 +8,35 @@ applyWorkspaceContext({ packageRoot });
 const { ensureRelayDaemon } = require('../mcp/src/core/relay-daemon');
 const { startServer } = require('../mcp/src/dashboard/server');
 
+function ensureCallbackRuntime() {
+  const statePath = path.join(packageRoot, 'outputs', 'runtime', 'qiwei-runtime.json');
+  let state = {};
+  try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch {}
+  const pid = Number(state.pid || 0);
+  const alive = pid > 0
+    ? (() => { try { process.kill(pid, 0); return true; } catch { return false; } })()
+    : false;
+  if (alive && ['starting', 'running'].includes(state.status)) {
+    console.log(`[start-dashboard] callback runtime 已在运行 (pid=${pid})`);
+    return;
+  }
+  const runtimeScript = path.join(packageRoot, 'scripts', 'start-callback-runtime.mjs');
+  fs.mkdirSync(path.join(packageRoot, 'outputs', 'runtime'), { recursive: true });
+  const outFd = fs.openSync(path.join(packageRoot, 'outputs', 'runtime', 'runtime.stdout.log'), 'a');
+  const errFd = fs.openSync(path.join(packageRoot, 'outputs', 'runtime', 'runtime.stderr.log'), 'a');
+  const child = spawn(process.execPath, [runtimeScript, 'start', '--no-dashboard'], {
+    cwd: packageRoot,
+    detached: true,
+    stdio: ['ignore', outFd, errFd],
+    windowsHide: true,
+  });
+  fs.closeSync(outFd);
+  fs.closeSync(errFd);
+  child.unref();
+  console.log(`[start-dashboard] 已同步拉起 callback runtime (pid=${child.pid})`);
+}
+
+ensureCallbackRuntime();
 ensureRelayDaemon();
 startServer().catch(error => {
   console.error('启动 Dashboard 失败:', error);

+ 14 - 2
scripts/validate-reusable-boundary.js

@@ -6,10 +6,22 @@ const { execFileSync } = require('child_process');
 
 const root = path.resolve(__dirname, '..');
 const forbiddenPath = /(^|[\\/])(outputs?|messages|sessions?|logs?|knowledge-base[\\/]property-data)([\\/]|$)|(^|[\\/])(?:\.env\.local|.*\.db(?:-(?:shm|wal))?|.*\.log)$/i;
+const verticalLeak = /小牛看房|xnfang|宋军/;
+const sourceScan = /^(?:mcp[\\/]src[\\/]core|knowledge|scripts|runtime)[\\/]/i;
+const skipScan = /(?:^|[\\/])validate-reusable-boundary\.js$/i;
+
 const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }).split(/\r?\n/).filter(Boolean);
-const issues = tracked.filter(rel => forbiddenPath.test(rel));
+const pathIssues = tracked.filter(rel => forbiddenPath.test(rel));
+const textIssues = [];
+for (const rel of tracked) {
+  if (!sourceScan.test(rel) || skipScan.test(rel) || !/\.(?:js|mjs|md)$/i.test(rel)) continue;
+  const content = fs.readFileSync(path.join(root, rel), 'utf8');
+  if (verticalLeak.test(content)) textIssues.push(`vertical leak: ${rel}`);
+}
+
+const issues = [...pathIssues.map(rel => `forbidden tracked path: ${rel}`), ...textIssues];
 if (issues.length) {
-  console.error(issues.map(rel => `forbidden tracked path: ${rel}`).join('\n'));
+  console.error(issues.join('\n'));
   process.exit(1);
 }
 console.log(`[ok] reusable boundary is clean (${tracked.length} tracked files checked)`);

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff