Przeglądaj źródła

sync: 同步回复质量模块与账号隔离/登录能力

从业务仓库提取通用回复规则与账号隔离/登录能力,补齐通用版。含回复质量通用模块与配套 smoke-test、账号隔离、登录 seat flow、工作台会话预创建及研究/规格文档。

Co-Authored-By: Claude <noreply@anthropic.com>
gangvy 1 miesiąc temu
rodzic
commit
2421e159c8
37 zmienionych plików z 4782 dodań i 81 usunięć
  1. 4 0
      .env.example
  2. 4 0
      .gitignore
  3. 207 0
      docs/research/qiwei-humanlike-sales-reply-github-study.md
  4. 5 3
      docs/research/qiwei-reply-quality-evolva.md
  5. 73 0
      docs/specs/qiwei-account-login-seat-flow.md
  6. 1 0
      mcp/src/core/agent-context-builder.js
  7. 404 0
      mcp/src/core/agent-conversation-journey.js
  8. 138 18
      mcp/src/core/agent-runtime.js
  9. 411 0
      mcp/src/core/agent-sales-move-planner.js
  10. 135 2
      mcp/src/core/agent-workbench-db.js
  11. 261 11
      mcp/src/core/agent-workbench-service.js
  12. 35 0
      mcp/src/core/credentials.js
  13. 54 0
      mcp/src/core/json-io.js
  14. 241 18
      mcp/src/core/login-flow-server.js
  15. 337 0
      mcp/src/core/response-action-semantics.js
  16. 287 0
      mcp/src/core/response-future-promise-verifier.js
  17. 105 0
      mcp/src/core/response-generation-strategy.js
  18. 131 0
      mcp/src/core/response-professional-score.js
  19. 548 0
      mcp/src/core/response-provenance-ledger.js
  20. 4 2
      mcp/src/core/response-quality-planner.js
  21. 23 4
      mcp/src/core/response-quality-verifier.js
  22. 70 1
      mcp/src/providers/fmode-agent-transport.js
  23. 33 3
      mcp/src/tools/qiwei-login-run.js
  24. 4 0
      package.json
  25. 7 3
      runtime/callback-service/src/personal-polling.mjs
  26. 108 1
      scripts/agent-console-smoke-test.js
  27. 157 0
      scripts/agent-conversation-journey-smoke-test.js
  28. 268 0
      scripts/agent-generation-concurrency-smoke-test.js
  29. 55 9
      scripts/agent-response-quality-smoke-test.js
  30. 146 0
      scripts/agent-sales-move-planner-smoke-test.js
  31. 9 0
      scripts/agent-workspace-ui-smoke-test.js
  32. 31 1
      scripts/callback-runtime-smoke-test.mjs
  33. 148 0
      scripts/login-seat-flow-smoke-test.js
  34. 4 0
      scripts/release-check.js
  35. 174 0
      scripts/response-future-promise-smoke-test.js
  36. 5 5
      scripts/response-quality-regression-fixtures.js
  37. 155 0
      scripts/sales-champion-generation-plan-smoke-test.js

+ 4 - 0
.env.example

@@ -93,6 +93,10 @@ QIWEI_AGENT_QUALITY_PASS_SCORE=82
 # 可选:按项目覆盖通用客服目标和阶段策略;不要在通用包中硬编码行业目标。
 QIWEI_AGENT_BUSINESS_GOAL=
 QIWEI_AGENT_STAGE_PLAYBOOK=
+# 可选:通用阶段引擎与条件-动作规则。JSON 中不得包含业务凭据;行业项目通过覆盖层配置。
+QIWEI_AGENT_REQUIRED_PROFILE_FIELDS=
+QIWEI_AGENT_SALES_GUIDELINES_JSON=
+QIWEI_AGENT_JOURNEY_JSON=
 
 # 推荐:直接使用客户项目中的 Claude Code + Fmode 配置生成草稿。
 CLAUDE_CODE_EXECUTABLE=

+ 4 - 0
.gitignore

@@ -23,3 +23,7 @@ qwmsgs500.json
 qwrooms-once.json
 qwsessions.json
 poll_state.json
+
+# 个人材料与营销落地页(不进代码仓库)
+软著申报/
+workshops/

+ 207 - 0
docs/research/qiwei-humanlike-sales-reply-github-study.md

@@ -0,0 +1,207 @@
+# 企微拟人化销售回复:GitHub 开源项目源码研究
+
+## 1. 研究结论
+
+调研日期:2026-08-06。
+
+实现状态:本轮已在通用包独立实现 P0 的生成前销售动作规划、可配置 Journey/动作账本、分句级未来承诺校验、同会话生成串行和 stale reply 丢弃。实现没有复制第三方受限源码,也没有引入房产业务阶段。
+
+没有发现一个开源项目已经提供经过独立验证、可直接复制的“房产经纪人销冠话术”。企微项目主要解决消息接入、幂等、会话隔离和渠道限制;回复自然度和销售推进能力主要来自成熟客服框架的条件化规则、对话阶段、动作状态、事实约束、人工接管和多轮评测。
+
+本项目应保留现有确定性 `qualityScore/qualityChecks` 作为最终硬门,在生成前新增“本轮唯一销售动作”规划,在发送前新增“真实动作证据、旧回复过期和虚假未来承诺”检查。通用包只实现可配置引擎;房产阶段、带看目标、学区和产权规则继续留在业务 overlay。
+
+## 2. 重点项目
+
+### 2.1 Parlant:条件化行为与可跳转 Journey
+
+- 仓库:[emcie-co/parlant](https://github.com/emcie-co/parlant)
+- 研究时约 18.2k stars,`develop` 最近提交日期为 2026-07-10,Apache-2.0。
+- 核心证据:[Guidelines](https://github.com/emcie-co/parlant/blob/develop/docs/concepts/customization/guidelines.md)、[Journeys](https://github.com/emcie-co/parlant/blob/develop/docs/concepts/customization/journeys.md)、[message_generator.py](https://github.com/emcie-co/parlant/blob/develop/src/parlant/core/engines/alpha/message_generator.py)、[response_analysis_batch.py](https://github.com/emcie-co/parlant/blob/develop/src/parlant/core/engines/alpha/guideline_matching/generic/response_analysis_batch.py)。
+
+值得学习:
+
+- 把业务规则表示成 `condition -> action`,每轮只注入当前相关规则,避免所有话术长期堆在 system prompt。
+- Guideline 动作完成后进入已执行状态;上下文变化时才重新激活,适合解决同义重复追问。
+- Journey 是可跳过、回退、重入的状态图,不要求客户机械按问卷顺序回答。
+- 生成前提取少量 actionable insights;生成结果逐项标记事实来源、遵循/违反的指令、是否重复、是否需要继续修订。
+- 自然表达强调短、暖但不客套、主动推进、避免重复、只说提示中有来源的事实。
+
+不直接照搬完整 Python 运行时或多轮 LLM guideline matcher。当前 Node 架构只需独立实现规则匹配、优先级、Journey 状态和动作账本;现有事实硬门继续保留。
+
+### 2.2 Chatwoot Captain:生产客服质量与真实接管
+
+- 仓库:[chatwoot/chatwoot](https://github.com/chatwoot/chatwoot)
+- 研究时约 35.5k stars,`develop` 最近提交日期为 2026-08-05。
+- 核心证据:[core_rules.liquid](https://github.com/chatwoot/chatwoot/blob/develop/enterprise/lib/captain/prompts/snippets/core_rules.liquid)、[assistant.liquid](https://github.com/chatwoot/chatwoot/blob/develop/enterprise/lib/captain/prompts/assistant.liquid)、[response_rewriter.rb](https://github.com/chatwoot/chatwoot/blob/develop/enterprise/app/services/captain/assistant/response_rewriter.rb)、[false promise handler](https://github.com/chatwoot/chatwoot/blob/develop/enterprise/app/jobs/captain/conversation/v1_false_promise_handler.rb)、[handoff_tool.rb](https://github.com/chatwoot/chatwoot/blob/develop/enterprise/lib/captain/tools/handoff_tool.rb)、[ResponseBuilderJob](https://github.com/chatwoot/chatwoot/blob/develop/enterprise/app/jobs/captain/conversation/response_builder_job.rb)。
+
+值得学习:
+
+- 回复保持一到两句、短句和简单词;多步骤一次只给一步;不使用机械列表;不以“还有什么可以帮您”结束。
+- 知识不足时最多问一个真正影响回答或路由的澄清问题;目标已明确时,不用追问掩盖能力不足。
+- 单独检测“我会稍后核实、回电、通知、转交”等未来工作承诺。只有工具本轮执行成功,才允许声明动作已完成。
+- 超长回复单独做 `temperature=0` 重写,强制保留姓名、数字、日期、链接、警告、已完成动作和 citation 顺序。
+- 人工接管在锁内确认会话仍由机器人负责,并检查模型运行期间是否来了更新客户消息;旧回复或旧接管动作直接丢弃。
+- resolved marker 分隔不同咨询 episode,避免旧问题覆盖本轮意图。
+
+许可边界:Captain 核心位于 `enterprise/`,受 Chatwoot Enterprise License 限制。只可独立实现同类机制,不复制其生产代码或模板。
+
+### 2.3 OpenAI CS Agents Demo:结构化状态与少问多做
+
+- 仓库:[openai/openai-cs-agents-demo](https://github.com/openai/openai-cs-agents-demo)
+- 核心证据:[airline/agents.py](https://github.com/openai/openai-cs-agents-demo/blob/main/python-backend/airline/agents.py)、[airline/context.py](https://github.com/openai/openai-cs-agents-demo/blob/main/python-backend/airline/context.py)、[airline/guardrails.py](https://github.com/openai/openai-cs-agents-demo/blob/main/python-backend/airline/guardrails.py)、[server.py](https://github.com/openai/openai-cs-agents-demo/blob/main/python-backend/server.py)。
+
+值得学习:
+
+- 已存在的客户字段不再询问;资料足够时同轮执行必要工具并返回结果。
+- 每轮只允许一个主要 handoff,完成后总结实际变化、确认信息和下一步。
+- 公开客户字段和内部状态分离;tool、handoff 和 guardrail 事件结构化审计。
+- relevance/guardrail 重点审最新用户消息,减少旧历史误判。
+
+不照搬航空业务和演示中的随机业务编号;其输出仍需经过本项目现有质量门。
+
+### 2.4 Pipecat Flows:阶段工具与暖转人工
+
+- 仓库:[pipecat-ai/pipecat-flows](https://github.com/pipecat-ai/pipecat-flows)
+- 核心证据:[FlowManager](https://github.com/pipecat-ai/pipecat-flows/blob/main/src/pipecat_flows/manager.py)、[warm_transfer.py](https://github.com/pipecat-ai/pipecat-flows/blob/main/examples/warm_transfer.py)、[patient_intake.py](https://github.com/pipecat-ai/pipecat-flows/blob/main/examples/patient_intake.py)。
+
+值得学习:每个阶段拥有自己的目标、工具和转移条件;工具成功后才迁移阶段;人工接管前生成客户诉求、已知事实和失败原因摘要。只迁移状态思想,不引入语音传输栈,也不做线性问卷。
+
+### 2.5 Scenario:多轮客户模拟与逐项裁判
+
+- 仓库:[langwatch/scenario](https://github.com/langwatch/scenario)
+- 核心证据:[scenario_executor.py](https://github.com/langwatch/scenario/blob/main/python/scenario/scenario_executor.py)、[user_simulator_agent.py](https://github.com/langwatch/scenario/blob/main/python/scenario/user_simulator_agent.py)、[judge_agent.py](https://github.com/langwatch/scenario/blob/main/python/scenario/judge_agent.py)、[scenario_state.py](https://github.com/langwatch/scenario/blob/main/python/scenario/scenario_state.py)。
+
+值得学习:用不同客户画像连续运行多轮场景;每个场景有明确成功、失败条件和最大轮次;逐项检查重复提问、工具调用和下一步推进。LLM 裁判只用于离线评测,不接入实时发送链。
+
+### 2.6 企微通道项目
+
+| 项目 | 主要价值 | 采用边界 |
+| --- | --- | --- |
+| [CowAgent](https://github.com/zhayujie/CowAgent) | 微信客服回调先 ACK、按 `open_kfid` 串行、cursor 原子持久化、冷启动不重放历史、会话并发为 1 | 通道可靠性强;没有可验证的销冠话术或完整人工接管 |
+| [LangBot](https://github.com/langbot-app/LangBot) | WeCom/微信客服适配、唯一 msgid、个人目标限制、pipeline/session 隔离、限流和内容过滤测试 | 可参考协议与测试;回复策略仍依赖上层 Agent |
+| [openclaw-china](https://github.com/BytePioneer-AI/openclaw-china) | cursor + msgid 双重幂等、冷启动 prime、统一 envelope、空 payload 不发送 | 仓库未发现许可证;默认开放策略不适合本项目白名单默认值 |
+| [WxJava](https://github.com/binarywang/WxJava) / [go-wecom](https://github.com/wenerme/go-wecom) | 成熟企业微信 API、客服 bot/人工状态、消息去重和统计模型 | 适合作为协议参考,不提供回复质量逻辑 |
+
+## 3. 现有能力与缺口
+
+| 能力 | 当前状态 | 结论 |
+| --- | --- | --- |
+| 首句直答、最多一个问题、阶段下一步 | 已实现 | 保留 |
+| 事实证据、敏感承诺、风险人工审核 | 已实现确定性门 | 保留并继续作为 hard gate |
+| 一次质量重写、失败降 review | 已实现 | 保留 |
+| 纯确认/致谢静默 | 已实现 | 优于 Parlant 的持续回复默认,不改 |
+| 动态 `condition -> action` guideline | 已实现 | 每轮稳定选择一个 `primaryMove`,跟踪规则只在未确认时命中 |
+| 可跳转 Journey 和持久阶段 | 已实现可配置引擎 | 支持跳过、回退、重入;行业阶段由项目覆盖层提供 |
+| 已执行销售动作账本 | 已实现 | `planned/executed/confirmed` 显式持久化,已确认 guideline 不重复执行 |
+| 虚假未来工作承诺 | 已实现 | `future_promise_evidence` 与 `performed_action_evidence` 为 hard check |
+| 模型运行期间新消息到达 | 已实现 | 入站先入库、同会话串行;生成后和自动发送前校验最新 inbound |
+| 会话 episode 边界 | 缺失 | 人工结束、长间隔或业务完成后建立边界 |
+| 风格适配和短语重复 | 只拦完整回复重复 | 新增语气画像与短语/动作重复检查 |
+| 多轮模拟评测 | 现有 48 条以单轮/固定上下文为主 | 增加多轮客户模拟,不替代人工盲评 |
+
+## 4. “销冠感”的可执行定义
+
+“销冠感”不等于提高 temperature、增加热情词或随机套话,应由以下可验证行为形成:
+
+1. 记住客户说过的一个具体点,并在本轮自然引用,而不是重复完整画像。
+2. 先回答当前问题或接住顾虑,再决定本轮是追问还是行动建议;两者不机械同时出现。
+3. 每轮只推进一个主要目标,问题最多一个。
+4. 问题优先级按“对匹配/决策的影响 × 当前未知程度 × 当前阶段相关度 ÷ 回答成本”选择。
+5. 推荐时说明一个客户侧适配原因、一个可核验事实和一个待确认风险,不只报结果或分数。
+6. 异议按“确认顾虑 -> 已知证据或明确未知 -> 低成本下一步”处理,不立刻反驳或重新盘问。
+7. 下一步强度跟随意向:低意向给选择权,中意向给可执行小步骤,高意向才进入真实人工接管或邀约。
+8. 只声明本轮真正执行成功的动作;计划、草稿和工具意图都不能说成“已经核实/已经转交”。
+9. 语言跟随客户的长短、正式度和称呼,但不模仿过度口语、情绪或错别字。
+
+## 5. 建议目标链路
+
+```text
+标准化企微入站
+-> 每会话串行 + stale-message guard
+-> episode / Journey 状态恢复
+-> 画像事实与 applied-action ledger 更新
+-> 匹配少量 active guidelines
+-> 选择本轮唯一 sales move
+-> 按需执行工具
+-> 生成自然 response acts
+-> 事实 / 重复 / future-promise / 风险质量门
+-> review 或真实发送
+-> 仅在工具/发送/人工接管成功后提交阶段和动作状态
+```
+
+建议内部结构:
+
+- `Guideline`: `condition`, `action`, `stage`, `priority`, `criticality`, `track`。
+- `JourneyState`: `stage`, `completedActions`, `blockedBy`, `evidenceRefs`, `candidateNextActions`。
+- `ResponsePlan`: `primaryMove`, `knownFactRefs`, `askField`, `cta`, `humanAction`, `styleProfile`。
+- `AppliedAction`: `actionKey`, `sourceMessageId`, `status=planned|executed|confirmed`, `evidenceRefs`。
+- `StyleProfile`: `sentenceTarget`, `formality`, `addressing`, `emojiPolicy`, `verbosity`。
+
+这些字段是内部状态,不外发固定标题。最终客户回复仍是自然的 1-3 个短句。
+
+## 6. 实施优先级
+
+### 已完成的 P0 实现
+
+- `agent-sales-move-planner.js`:结构化 Guideline、六类通用动作、唯一 `primaryMove` 和规则命中审计。
+- `agent-conversation-journey.js`:可跳转 Journey 与 `planned/executed/confirmed` 状态机。
+- `conversation_journeys` / `conversation_applied_actions`:当前阶段和动作账本的显式 SQLite 持久化。
+- `response-future-promise-verifier.js`:分句识别核实、通知、回电、交接、处理等声明;证据必须同动作、成功且作用域匹配。
+- `AgentWorkbenchService`:每会话 Promise 队列、`agent_response_stale_discarded` 审计、auto/autopilot 发送前 stale guard。
+- `QiweiAgentRuntime`:生成前注入本轮唯一销售动作,工具轮次后重新规划;现有确定性质量门仍为最终发送门。
+
+可选配置为 `QIWEI_AGENT_REQUIRED_PROFILE_FIELDS`、`QIWEI_AGENT_SALES_GUIDELINES_JSON` 和 `QIWEI_AGENT_JOURNEY_JSON`。未配置行业 Journey 时使用单一通用 active 阶段,不硬编码房产流程。
+
+### P0:可信与不乱序
+
+- 每会话生成串行化,发送和建草稿前确认 inbound 仍是最新消息。(已完成)
+- 新增虚假未来动作检查;tool/handoff 未成功时禁止“我正在查、稍后回、已经转交”。(已完成)
+- 把“要求人工”与“人工已经接管”分成两个状态,Delivery 仍只认真实 outbound。
+- 建立 episode boundary,防旧问题和旧 Session 原话污染本轮。
+
+### P0:销售动作规划
+
+- 新增动态 guideline matcher、动作优先级和 applied-action ledger。(已完成)
+- `planner` 每轮只输出一个 `primaryMove`:回答、顾虑承接、补一个关键字段、给方案、处理异议、推进下一步或人工接管。
+- 画像已有字段直接 hydrate,禁止换说法重问;信息足够时执行工具并回答。
+
+### P1:拟人表达
+
+- 将回复拆为内部 response acts,再自然渲染,不输出机械四段式。
+- 增加短语级重复、模板开头、被动结尾和过度推销检测。
+- 提供可配置 `responseGuidelines/guardrails/specialistPlaybook`,通用包不硬编码房产阶段。
+- 渠道限长重写锁定数字、日期、专名、风险和 citation 顺序。
+
+### P2:多轮评测
+
+- 将现有 48 条冻结集扩展为多轮场景,覆盖跳阶段、一次提供多个字段、反悔、沉默后回流、连续异议和人工接管。
+- 同时报告 hard fail、重复字段、虚假承诺、stale reply、销售动作完成率、短语重复率和人工盲评自然度。
+- 只有旧版/新版同输入真实回复盲评通过后,才宣称拟人化或销售推进能力提升。
+
+## 7. 不采用的路线
+
+- 不整体迁入第三方 Python/Ruby Agent 运行时。
+- 不复制 Chatwoot `enterprise/` 代码或提示模板。
+- 不用提高 temperature 制造随机口语。
+- 不用固定八阶段电话销售脚本强迫微信客户按顺序回答。
+- 不把 LLM 裁判放进实时发送链。
+- 不用随机话术库绕过短语重复,而是基于当前事实、阶段和唯一动作生成。
+
+## 8. 下一阶段验收
+
+1. 同一会话并发两条入站,旧回复和旧接管均不发送、不建当前草稿。
+2. 没有成功工具/接管事件时,所有未来工作承诺均被拦截。
+3. 已确认字段的同义追问为 0;Journey 可跳过已完成阶段。
+4. 普通合格回复误杀率继续不高于当前门槛,人工路径不受 Agent 门阻断。
+5. 多轮盲评分别报告房产业务和通用客服,不用单轮确定性夹具替代真实自然度结论。
+
+## 9. 本轮确定性验收
+
+- 销售动作规划:11 项检查、14 组计划,所有计划恰好一个主动作。
+- Journey 状态机:25 项检查;草稿不推进,成功 send/tool/handoff 证据才可确认动作。
+- 未来承诺:21 个场景、52 条断言;无关 citation/工具结果、失败工具和跨分句“待核验”均不能旁路。
+- 并发链路:6 项检查;review、auto、autopilot 的旧生成结果都被丢弃,人工明确审批保持可用。
+- 既有质量门:低质量拦截 11/11,合格样本误杀 0/11,A4 冻结坏回复覆盖 10/10。
+- Agent/Runtime:`agent:smoke` 48/48、`runtime:smoke` 25/25。
+
+这些是确定性工程回归,不代表真实客户自然度或销售转化率。拟人表达的真实增益仍需多轮同输入模型候选和独立盲评确认。

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

@@ -35,7 +35,7 @@ Planner 根据 acknowledgement、direct question、first consultation、continua
 
 ## 4. 确定性验证与恢复
 
-Verifier 输出 12 项可解释检查
+Verifier 当前输出 14 项发送门检查,并附带一项非阻断的条件提议说明
 
 | 检查 | 说明 |
 | --- | --- |
@@ -47,9 +47,11 @@ Verifier 输出 12 项可解释检查:
 | `repeated_greeting` | 多轮会话不重复问候 |
 | `repeated_question` | 拦截字面重复及预算、时间、人数等已回答字段的同义追问 |
 | `stage_next_step` | 末句包含当前阶段可执行动作 |
-| `risk_handoff` | 风险或高意向场景包含已执行或立即执行的人工接管动作 |
+| `risk_handoff` | 风险或高意向场景明确人工角色和可执行核验边界;声称已交接时必须有真实证据 |
 | `human_boundary` | 风险或高意向场景同步设置 `requiresHuman=true` |
 | `sensitive_commitment` | 按分句阻断保证、绝对、最低价等未经证实承诺 |
+| `future_promise_evidence` | “稍后核实/再通知/会回电”等未来动作有同类型成功证据 |
+| `performed_action_evidence` | “已经转交/正在处理”等已执行声明有同类型成功证据 |
 | `internal_leakage` | 不外发评分、检查项和内部运行字段 |
 
 首次生成不通过时,运行时根据失败检查最多修订一次。再次不通过或修订调用失败时设置 `requiresHuman=true`,并返回 `qualityScore`、`qualityChecks`、`rewriteCount`、`fallbackReason` 供内部审计。服务层再次验证 Agent 内容,`auto` 与 `autopilot` 均不能绕过;人工 `manualSend` 和人工编辑后 `approveDraft` 仍只执行原有白名单、私聊和长度边界。
@@ -102,7 +104,7 @@ A4 当前只完成发送门禁层配对:旧版低质量拦截 0/11,新版 11
 - `qualityScore`: 0-100 的内部确定性检查得分。
 - `qualityChecks`: 检查 ID、是否通过、权重、是否硬失败和简要说明。
 - `rewriteCount`: 0 或 1。
-- `fallbackReason`: 最终失败检查 ID 列表;修订调用失败追加 `rewrite_failed`
+- `fallbackReason`: 稳定流程 code:`quality_gate_failed`、`rewrite_limit_reached`、`requires_human` 或 `quality_unavailable`;具体失败项从 `qualityChecks`/`hardFailureIds` 读取
 
 这些字段写入内部返回、工具轨迹和审计详情,不进入客户回复。
 

+ 73 - 0
docs/specs/qiwei-account-login-seat-flow.md

@@ -0,0 +1,73 @@
+# 企微账号登录与席位决策流程
+
+## 计数模型
+
+- 客户订阅席位按唯一企微账号 `corpId + userId` 计数。
+- 多台电脑使用同一 Fmode 凭据连接同一企微账号时,共享服务端账号会话,不重复占客户席位。
+- 上游登录实例容量与客户订阅席位分开管理。
+- 临时二维码、待验证码和待席位决策的登录尝试不计客户席位,并设置过期清理时间。
+
+## 完整流程
+
+```mermaid
+flowchart TD
+  A["启动客户端或打开登录页"] --> B{"Fmode Token 有效"}
+  B -- "否" --> B1["配置或刷新 Token"] --> B
+  B -- "是" --> C["查询订阅状态"]
+  C --> D{"订阅有效"}
+  D -- "否" --> D1["开通或续费"] --> C
+  D -- "是" --> E["查询已绑定账号"]
+  E --> F{"选择登录方式"}
+  F -- "已有账号在线" --> S["绑定当前电脑并共享会话"]
+  F -- "已有账号离线" --> G["复用账号 guid 生成二维码"]
+  F -- "登录其他账号" --> H["创建临时 uid 和上游实例"]
+  G --> Q["扫码与验证码状态轮询"]
+  H --> Q
+  Q --> I{"登录成功并获得 corpId + userId"}
+  I -- "身份已绑定" --> R["复用席位并更新 canonical guid"] --> S
+  I -- "新身份" --> J{"客户席位有空余"}
+  J -- "是" --> K["正式绑定新账号"] --> S
+  J -- "否" --> L{"购买、替换或取消"}
+  L -- "新增购买席位" --> M["实时询价并幂等扣费"]
+  M --> N["重新查询席位并提交登录"] --> K
+  L -- "替换已有账号" --> O["确认目标账号并停止旧 guid"] --> K
+  L -- "取消" --> P["停止临时实例并清理"]
+  S --> T["登录完成并启动监听或 Relay"]
+```
+
+## 接口约定
+
+| 接口 | 用途 |
+| --- | --- |
+| `GET /login/accounts` | 返回当前租户已绑定账号、在线状态和席位摘要 |
+| `POST /login/select` | 将当前电脑 uid 绑定到已有账号 |
+| `POST /login/start` | 创建或复用上游登录会话;`newAccount=true` 时创建临时 uid |
+| `POST /login/check` | 轮询二维码;成功后按账号身份去重并返回席位决策 |
+| `POST /login/resolve` | 执行购买后提交、替换或取消 |
+| `GET /subscribe/status?seats=N&months=1` | 获取增购实时补差报价 |
+| `POST /subscribe` | 使用报价金额和幂等键完成增购 |
+
+## 席位已满决策
+
+扫码身份为新账号且席位已满时,`/login/check` 返回:
+
+```json
+{
+  "status": 2,
+  "decisionRequired": true,
+  "loginState": "decision_required",
+  "actions": ["purchase", "replace", "cancel"]
+}
+```
+
+- `purchase`:按当前席位数加一询价并购买;购买成功后再次加锁校验,随后自动绑定当前扫码账号。
+- `replace`:客户选择一个已绑定账号,二次确认后停止旧会话并绑定新账号。
+- `cancel`:停止临时会话并清理登录尝试。
+
+支付和账号绑定使用相同租户锁串行化。支付成功但绑定请求中断时,客户端复用原幂等订单并再次执行 `purchase` 提交,避免重复扣费。
+
+## 兼容规则
+
+- 旧 `QiweiDevice` 记录存在 `guid + lastLoginAt` 时,懒迁移为一个 legacy 账号席位。
+- 只有二维码但从未成功登录的旧记录不占客户席位。
+- 业务接口继续使用 uid,服务端通过 uid 找到共享 guid,现有 MCP 工具调用保持兼容。

+ 1 - 0
mcp/src/core/agent-context-builder.js

@@ -84,6 +84,7 @@ class AgentContextBuilder {
       '如果最新消息只是“收到、好的、明白了、谢谢”等纯确认或致谢,且没有新的问题或待确认动作,reply 可以返回空字符串。',
       '回复质量契约:首句直接回答;随后只补必要依据;最多追问一个信息增益最高的问题;末句给阶段适配的下一步。为什么适合、已知事实、待确认风险、下一步只按场景启用,禁止机械四段式。',
       '同时输出内部 tasks 和 alerts:tasks 只记录明确可执行的跟进动作,alerts 只记录有证据的高意向、时效、矛盾、投诉或人工接管风险;每项 evidence 必须来自本轮有效会话。没有就输出空数组。',
+      context.salesMoveInstruction || '',
       context.qualityRepair ? `【仅本轮修订指令】${String(context.qualityRepair)}` : '',
       `可配置业务目标:${String(context.businessGoal || this.businessGoal || '未配置;按通用客服目标推进')}`,
       `可配置阶段策略:${String(context.stagePlaybook || this.stagePlaybook || '未配置;按当前问题给出最小可执行下一步')}`,

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

@@ -0,0 +1,404 @@
+'use strict';
+
+const APPLIED_ACTION_STATUSES = Object.freeze(['planned', 'executed', 'confirmed']);
+const CONFIRMING_EVIDENCE_TYPES = Object.freeze(['send', 'tool', 'handoff']);
+
+function canonicalize(value) {
+  if (Array.isArray(value)) return value.map(canonicalize);
+  if (value instanceof Date) return value.toISOString();
+  if (!value || typeof value !== 'object') return value;
+  return Object.keys(value).sort().reduce((result, key) => {
+    if (value[key] !== undefined) result[key] = canonicalize(value[key]);
+    return result;
+  }, {});
+}
+
+function clone(value) {
+  return JSON.parse(JSON.stringify(canonicalize(value)));
+}
+
+function stableStringify(value) {
+  return JSON.stringify(canonicalize(value));
+}
+
+function uniqueStrings(values = []) {
+  return [...new Set(values.map(value => String(value || '').trim()).filter(Boolean))].sort();
+}
+
+function createJourneyDefinition(input = {}) {
+  const id = String(input.id || '').trim();
+  if (!id) throw new Error('Journey id is required.');
+  if (!Array.isArray(input.stages) || input.stages.length === 0) {
+    throw new Error('Journey stages are required.');
+  }
+
+  const stageIds = input.stages.map(stage => String(stage?.id || '').trim());
+  if (stageIds.some(idValue => !idValue)) throw new Error('Every journey stage needs an id.');
+  if (new Set(stageIds).size !== stageIds.length) throw new Error('Journey stage ids must be unique.');
+
+  const stages = input.stages.map((stage, index) => ({
+    id: stageIds[index],
+    label: String(stage?.label || stageIds[index]),
+    requiredFacts: uniqueStrings(stage?.requiredFacts),
+    nextStageId: stage?.nextStageId === null
+      ? null
+      : String(stage?.nextStageId || stageIds[index + 1] || '').trim() || null,
+  }));
+  const knownIds = new Set(stageIds);
+  for (const stage of stages) {
+    if (stage.nextStageId && !knownIds.has(stage.nextStageId)) {
+      throw new Error(`Unknown next stage: ${stage.nextStageId}`);
+    }
+  }
+
+  const initialStageId = String(input.initialStageId || stageIds[0]).trim();
+  if (!knownIds.has(initialStageId)) throw new Error(`Unknown initial stage: ${initialStageId}`);
+
+  return canonicalize({
+    version: '1.0',
+    id,
+    initialStageId,
+    stages,
+  });
+}
+
+function createJourneyState(definition, input = {}) {
+  const normalizedDefinition = createJourneyDefinition(definition);
+  const initialStageId = String(input.currentStageId || normalizedDefinition.initialStageId).trim();
+  if (!normalizedDefinition.stages.some(stage => stage.id === initialStageId)) {
+    throw new Error(`Unknown current stage: ${initialStageId}`);
+  }
+
+  return canonicalize({
+    version: '1.0',
+    journeyId: normalizedDefinition.id,
+    status: 'active',
+    currentStageId: initialStageId,
+    facts: clone(input.facts || {}),
+    appliedActions: [],
+    stageHistory: [{
+      sequence: 0,
+      kind: 'enter',
+      fromStageId: null,
+      stageId: initialStageId,
+      evidenceRef: null,
+      at: input.at === undefined ? null : input.at,
+    }],
+    sequence: 0,
+    lastEventAt: input.at === undefined ? null : input.at,
+  });
+}
+
+function assertState(definition, state) {
+  const normalizedDefinition = createJourneyDefinition(definition);
+  if (!state || state.journeyId !== normalizedDefinition.id) {
+    throw new Error('Journey state does not match the definition.');
+  }
+  if (!normalizedDefinition.stages.some(stage => stage.id === state.currentStageId)) {
+    throw new Error(`Unknown current stage: ${state.currentStageId}`);
+  }
+  return normalizedDefinition;
+}
+
+function mergeFacts(current, updates) {
+  const output = clone(current || {});
+  for (const [key, value] of Object.entries(updates || {})) {
+    if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
+      output[key] = mergeFacts(output[key], value);
+    } else {
+      output[key] = canonicalize(value);
+    }
+  }
+  return output;
+}
+
+function applyJourneyFacts(definition, state, facts = {}, options = {}) {
+  assertState(definition, state);
+  const next = clone(state);
+  next.facts = mergeFacts(next.facts, facts);
+  next.sequence = Number(next.sequence || 0) + 1;
+  next.lastEventAt = options.at === undefined ? next.lastEventAt : options.at;
+  return canonicalize(next);
+}
+
+function factValue(facts, path) {
+  return String(path || '').split('.').reduce((value, key) => (
+    value && typeof value === 'object' ? value[key] : undefined
+  ), facts);
+}
+
+function hasFact(facts, path) {
+  const value = factValue(facts, path);
+  if (value === null || value === undefined) return false;
+  if (typeof value === 'string') return value.trim().length > 0;
+  if (Array.isArray(value)) return value.length > 0;
+  return true;
+}
+
+function isStageComplete(definition, state, stageId = state?.currentStageId) {
+  const normalizedDefinition = assertState(definition, state);
+  const stage = normalizedDefinition.stages.find(item => item.id === stageId);
+  if (!stage) throw new Error(`Unknown stage: ${stageId}`);
+  return stage.requiredFacts.every(path => hasFact(state.facts, path));
+}
+
+function normalizeTransition(value = {}) {
+  const mode = String(value?.mode || 'none').trim().toLowerCase();
+  if (!['none', 'auto', 'to', 'back', 'reenter'].includes(mode)) {
+    throw new Error(`Unsupported journey transition mode: ${mode}`);
+  }
+  const targetStageId = String(value?.targetStageId || '').trim() || null;
+  if (['to', 'back', 'reenter'].includes(mode) && !targetStageId) {
+    throw new Error(`${mode} transition requires targetStageId.`);
+  }
+  return { mode, targetStageId };
+}
+
+function normalizeEvidence(value, { requireSuccess = false } = {}) {
+  if (!value || typeof value !== 'object') throw new Error('Applied action evidence is required.');
+  const type = String(value.type || '').trim().toLowerCase();
+  const id = String(value.id || '').trim();
+  if (!CONFIRMING_EVIDENCE_TYPES.includes(type)) {
+    throw new Error(`Unsupported action evidence type: ${type || 'missing'}`);
+  }
+  if (!id) throw new Error('Applied action evidence id is required.');
+  if (requireSuccess && value.success !== true) {
+    throw new Error('Confirmed actions require successful evidence.');
+  }
+  return canonicalize({
+    type,
+    id,
+    success: value.success === true,
+    at: value.at === undefined ? null : value.at,
+    summary: String(value.summary || '').trim(),
+  });
+}
+
+function actionIndex(state, key) {
+  return (state.appliedActions || []).findIndex(action => action.key === key);
+}
+
+function planAppliedAction(definition, state, input = {}) {
+  const normalizedDefinition = assertState(definition, state);
+  const key = String(input.key || '').trim();
+  if (!key) throw new Error('Applied action key is required.');
+  const existingIndex = actionIndex(state, key);
+  const existing = existingIndex >= 0 ? state.appliedActions[existingIndex] : null;
+  const stageId = String(input.stageId || existing?.stageId || state.currentStageId).trim();
+  if (!normalizedDefinition.stages.some(stage => stage.id === stageId)) {
+    throw new Error(`Unknown action stage: ${stageId}`);
+  }
+
+  const proposed = canonicalize({
+    key,
+    kind: String(input.kind || 'conversation_action').trim(),
+    stageId,
+    status: 'planned',
+    transition: normalizeTransition(input.transition),
+    payload: clone(input.payload || {}),
+  });
+  if (existingIndex >= 0) {
+    const comparable = canonicalize({
+      key: existing.key,
+      kind: existing.kind,
+      stageId: existing.stageId,
+      status: 'planned',
+      transition: existing.transition,
+      payload: existing.payload,
+    });
+    if (stableStringify(comparable) !== stableStringify(proposed)) {
+      throw new Error(`Applied action key conflict: ${key}`);
+    }
+    return clone(state);
+  }
+
+  const next = clone(state);
+  next.sequence = Number(next.sequence || 0) + 1;
+  next.lastEventAt = input.at === undefined ? next.lastEventAt : input.at;
+  next.appliedActions.push(canonicalize({
+    ...proposed,
+    createdSequence: next.sequence,
+    updatedSequence: next.sequence,
+    plannedAt: input.at === undefined ? null : input.at,
+    executedAt: null,
+    confirmedAt: null,
+    executionEvidence: null,
+    confirmationEvidence: null,
+    transitionResult: null,
+  }));
+  return canonicalize(next);
+}
+
+function executeAppliedAction(definition, state, input = {}) {
+  assertState(definition, state);
+  const key = String(input.key || '').trim();
+  const index = actionIndex(state, key);
+  if (index < 0) throw new Error(`Unknown applied action: ${key}`);
+  const existing = state.appliedActions[index];
+  if (existing.status === 'confirmed') return clone(state);
+  const evidence = input.evidence ? normalizeEvidence(input.evidence) : null;
+  if (existing.status === 'executed' && stableStringify(existing.executionEvidence) === stableStringify(evidence)) {
+    return clone(state);
+  }
+
+  const next = clone(state);
+  next.sequence = Number(next.sequence || 0) + 1;
+  next.lastEventAt = input.at === undefined ? next.lastEventAt : input.at;
+  next.appliedActions[index] = canonicalize({
+    ...next.appliedActions[index],
+    status: 'executed',
+    updatedSequence: next.sequence,
+    executedAt: input.at === undefined ? null : input.at,
+    executionEvidence: evidence,
+  });
+  return canonicalize(next);
+}
+
+function appendHistory(state, entry) {
+  state.sequence = Number(state.sequence || 0) + 1;
+  state.stageHistory.push(canonicalize({ sequence: state.sequence, ...entry }));
+}
+
+function visitedStageIds(state) {
+  return new Set((state.stageHistory || []).map(entry => entry.stageId));
+}
+
+function resolveAutoPath(definition, state) {
+  const byId = new Map(definition.stages.map(stage => [stage.id, stage]));
+  const path = [state.currentStageId];
+  let stage = byId.get(state.currentStageId);
+  const seen = new Set(path);
+  while (stage && isStageComplete(definition, state, stage.id) && stage.nextStageId) {
+    if (seen.has(stage.nextStageId)) throw new Error('Journey auto transition contains a cycle.');
+    path.push(stage.nextStageId);
+    seen.add(stage.nextStageId);
+    stage = byId.get(stage.nextStageId);
+  }
+  const completed = Boolean(stage && !stage.nextStageId && isStageComplete(definition, state, stage.id));
+  return { path, targetStageId: path[path.length - 1], completed };
+}
+
+function applyConfirmedTransition(definition, state, action, evidence) {
+  const transition = normalizeTransition(action.transition);
+  if (transition.mode === 'none') {
+    return { state, result: { kind: 'none', fromStageId: state.currentStageId, toStageId: state.currentStageId, skippedStageIds: [] } };
+  }
+
+  const knownIds = new Set(definition.stages.map(stage => stage.id));
+  const fromStageId = state.currentStageId;
+  let targetStageId = transition.targetStageId;
+  let path = [fromStageId];
+  let completed = false;
+  if (transition.mode === 'auto') {
+    const resolved = resolveAutoPath(definition, state);
+    path = resolved.path;
+    targetStageId = resolved.targetStageId;
+    completed = resolved.completed;
+  } else if (!knownIds.has(targetStageId)) {
+    throw new Error(`Unknown transition target: ${targetStageId}`);
+  }
+
+  const next = clone(state);
+  const visited = visitedStageIds(next);
+  const evidenceRef = `${evidence.type}:${evidence.id}`;
+  const skippedStageIds = [];
+  let kind = transition.mode;
+
+  if (transition.mode === 'auto' || transition.mode === 'to') {
+    const targetIndex = definition.stages.findIndex(stage => stage.id === targetStageId);
+    const currentIndex = definition.stages.findIndex(stage => stage.id === fromStageId);
+    if (transition.mode === 'to' && targetIndex > currentIndex) {
+      path = definition.stages.slice(currentIndex, targetIndex + 1).map(stage => stage.id);
+    }
+    if (targetStageId !== fromStageId) {
+      for (const stageId of path.slice(1, -1)) {
+        skippedStageIds.push(stageId);
+        appendHistory(next, {
+          kind: 'skip', fromStageId, stageId, evidenceRef, at: evidence.at,
+        });
+      }
+      kind = visited.has(targetStageId) ? 'reenter' : 'enter';
+      appendHistory(next, {
+        kind, fromStageId, stageId: targetStageId, evidenceRef, at: evidence.at,
+      });
+      next.currentStageId = targetStageId;
+    } else if (completed) {
+      kind = 'complete';
+    } else {
+      kind = 'stay';
+    }
+  } else if (transition.mode === 'back') {
+    kind = 'back';
+    appendHistory(next, {
+      kind, fromStageId, stageId: targetStageId, evidenceRef, at: evidence.at,
+    });
+    next.currentStageId = targetStageId;
+    completed = false;
+  } else if (transition.mode === 'reenter') {
+    if (!visited.has(targetStageId)) throw new Error(`Cannot reenter an unvisited stage: ${targetStageId}`);
+    kind = 'reenter';
+    appendHistory(next, {
+      kind, fromStageId, stageId: targetStageId, evidenceRef, at: evidence.at,
+    });
+    next.currentStageId = targetStageId;
+    completed = false;
+  }
+
+  next.status = completed ? 'completed' : 'active';
+  return {
+    state: next,
+    result: canonicalize({ kind, fromStageId, toStageId: next.currentStageId, skippedStageIds, completed }),
+  };
+}
+
+function confirmAppliedAction(definition, state, input = {}) {
+  const normalizedDefinition = assertState(definition, state);
+  const key = String(input.key || '').trim();
+  const index = actionIndex(state, key);
+  if (index < 0) throw new Error(`Unknown applied action: ${key}`);
+  const evidence = normalizeEvidence(input.evidence, { requireSuccess: true });
+  const existing = state.appliedActions[index];
+  if (existing.status === 'confirmed') {
+    if (stableStringify(existing.confirmationEvidence) === stableStringify(evidence)) return clone(state);
+    throw new Error(`Applied action already confirmed with different evidence: ${key}`);
+  }
+
+  const next = clone(state);
+  next.sequence = Number(next.sequence || 0) + 1;
+  next.lastEventAt = evidence.at;
+  next.appliedActions[index] = canonicalize({
+    ...next.appliedActions[index],
+    status: 'confirmed',
+    updatedSequence: next.sequence,
+    executedAt: next.appliedActions[index].executedAt || evidence.at,
+    confirmedAt: evidence.at,
+    confirmationEvidence: evidence,
+  });
+
+  const transitioned = applyConfirmedTransition(
+    normalizedDefinition,
+    next,
+    next.appliedActions[index],
+    evidence,
+  );
+  const transitionedIndex = actionIndex(transitioned.state, key);
+  transitioned.state.appliedActions[transitionedIndex].transitionResult = transitioned.result;
+  return canonicalize(transitioned.state);
+}
+
+function serializeJourneyState(state) {
+  return stableStringify(state);
+}
+
+module.exports = {
+  APPLIED_ACTION_STATUSES,
+  CONFIRMING_EVIDENCE_TYPES,
+  createJourneyDefinition,
+  createJourneyState,
+  applyJourneyFacts,
+  isStageComplete,
+  planAppliedAction,
+  executeAppliedAction,
+  confirmAppliedAction,
+  serializeJourneyState,
+};

+ 138 - 18
mcp/src/core/agent-runtime.js

@@ -3,16 +3,47 @@ const fs = require('fs');
 const path = require('path');
 const { spawn } = require('child_process');
 const { AgentContextBuilder, selectAuthoritativeHistory } = require('./agent-context-builder');
-const { planResponse } = require('./response-quality-planner');
+const { planResponse, plannerInstruction } = require('./response-quality-planner');
 const { buildRewriteInstruction, verifyResponseQuality } = require('./response-quality-verifier');
 const { normalizeQualityChecks, normalizeQualityScore, qualityFallbackReason } = require('./response-quality-contract');
+const { planSalesMove } = require('./agent-sales-move-planner');
+const {
+  buildResponseGenerationStrategy,
+  responseGenerationInstruction,
+} = require('./response-generation-strategy');
 
 const RISK_PATTERN = /合同|签约|产权|权属|资质|资格|政策|学区|入学|学位|合规|保证|承诺|最低价|成交价|报价|优惠|贷款|利率|税费|投诉|退款|赔偿|看房|预约|出价|面谈|付款|下单|发票|身份证|银行卡|法律|违约/;
-const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
+const NO_REPLY_NEEDED_PATTERN = /^(?:收到|行|行的|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
+
+function knownProfileFields(profile = {}) {
+  return Object.entries(profile || {})
+    .filter(([key, value]) => key !== '__evidence' && value !== undefined && value !== null && value !== '')
+    .map(([key]) => key);
+}
+
+function salesMoveInstruction(plan = {}) {
+  const instructions = {
+    answer: '先实质回答当前问题,再补必要依据。',
+    acknowledge_objection: '先接住并回应当前顾虑,不要直接跳到追问。',
+    clarify: '只追问一个信息增益最高且尚未回答的字段。',
+    recommend: '基于已知事实给出一个可解释建议,并说明关键限制。',
+    advance: '给出一个低成本、可执行的下一步,不额外堆叠问题。',
+    handoff: '明确需要哪类人工角色核验;没有真实交接证据时,不得声称已经转交或正在处理。',
+  };
+  return [
+    `本轮唯一主要沟通动作:${String(plan.primaryMove || 'answer')}`,
+    instructions[plan.primaryMove] || instructions.answer,
+    '不要在同一回复中并列多个销售动作;已完成动作和已确认字段不得换一种说法重复执行。',
+  ].join('\n');
+}
 
 function isNoReplyNeededMessage(input = '') {
-  const normalized = String(input || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim();
-  return Boolean(normalized && NO_REPLY_NEEDED_PATTERN.test(normalized));
+  const parts = String(input || '')
+    .trim()
+    .split(/[,,。;;、!!??~~\s]+/)
+    .map(part => part.trim())
+    .filter(Boolean);
+  return Boolean(parts.length && parts.length <= 3 && parts.every(part => NO_REPLY_NEEDED_PATTERN.test(part)));
 }
 
 function claudeSessionResetReason(error) {
@@ -836,17 +867,91 @@ class QiweiAgentRuntime {
     return { error: `未知工具 ${name}` };
   }
 
-  async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent, channelType = 'private', directPrompt = '', memoryContext = {} }) {
+  async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent, channelType = 'private', directPrompt = '', memoryContext = {}, journeyState = null, completedActions = [] }) {
+    if (isNoReplyNeededMessage(inboundContent)) {
+      return {
+        status: 'no_reply_needed',
+        noReplyNeeded: true,
+        content: '',
+        confidence: 1,
+        intent: 'acknowledgement',
+        reason: '客户消息仅包含确认或致谢',
+        requiresHuman: false,
+        profileUpdates: {},
+        tasks: [],
+        alerts: [],
+        citations: [],
+        toolTrace: [],
+        qualityScore: null,
+        qualityChecks: [],
+        qualityPassed: true,
+        qualityPlan: null,
+        rewriteCount: 0,
+        fallbackReason: null,
+        salesMovePlan: null,
+        responseGenerationPlan: null,
+      };
+    }
     const history = messages.slice(-16).map(message => ({
       role: message.direction === 'inbound' ? 'user' : 'assistant',
       content: message.content,
     }));
-    const system = this.contextBuilder.buildSystemContext({ channelType, customerIntelligence, memoryContext });
-    const llmMessages = [{ role: 'system', content: system }, ...history];
+    const baseSystem = this.contextBuilder.buildSystemContext({ channelType, customerIntelligence, memoryContext });
+    const llmMessages = [{ role: 'system', content: baseSystem }, ...history];
     const toolTrace = [];
     const citations = [];
+    const currentProfile = profile?.profile || profile || {};
+    let responsePlan = null;
+    let salesMovePlan = null;
+    let responseGenerationPlan = null;
+
+    const refreshTurnPlan = () => {
+      responsePlan = planResponse({
+        inboundContent,
+        messages: history,
+        profile: currentProfile,
+        customerIntelligence,
+        toolTrace,
+        citations,
+        businessGoal: this.config.businessGoal,
+        stagePlaybook: this.config.stagePlaybook,
+      });
+      const requiredFields = Array.isArray(this.config.requiredProfileFields) ? this.config.requiredProfileFields : [];
+      const knownFields = knownProfileFields(currentProfile);
+      const known = new Set(knownFields.map(String));
+      const confirmedJourneyActions = (journeyState?.appliedActions || []).filter(action => action.status === 'confirmed');
+      salesMovePlan = planSalesMove({
+        scenario: responsePlan.scenario,
+        stage: journeyState?.currentStageId || '',
+        riskLevel: responsePlan.requireHumanHandoff ? 'high' : 'none',
+        riskTypes: responsePlan.riskTypes,
+        requiresHuman: responsePlan.requireHumanHandoff,
+        knownFields,
+        missingFields: requiredFields.filter(field => !known.has(String(field))),
+        completedActions: [
+          ...completedActions,
+          ...confirmedJourneyActions.flatMap(action => [action.key, ...(action.payload?.guidelineIds || [])]),
+        ],
+        guidelines: this.config.salesGuidelines || [],
+      });
+      responseGenerationPlan = buildResponseGenerationStrategy({
+        inboundContent,
+        responsePlan,
+        salesMovePlan,
+        profile: currentProfile,
+        citations,
+        toolTrace,
+      });
+      const moveInstruction = salesMoveInstruction(salesMovePlan);
+      llmMessages[0] = {
+        role: 'system',
+        content: `${baseSystem}\n\n【本轮回复计划】\n${plannerInstruction(responsePlan)}\n${responseGenerationInstruction(responseGenerationPlan)}\n${moveInstruction}`,
+      };
+      return moveInstruction;
+    };
 
     for (let round = 0; round < this.config.maxToolRounds; round += 1) {
+      const moveInstruction = refreshTurnPlan();
       const assistant = await this.modelClient.complete(llmMessages, this.tools(), {
         conversation,
         profile,
@@ -855,6 +960,9 @@ class QiweiAgentRuntime {
         channelType,
         directPrompt,
         memoryContext,
+        businessGoal: this.config.businessGoal,
+        stagePlaybook: this.config.stagePlaybook,
+        salesMoveInstruction: moveInstruction,
       });
       if (assistant.claudeCode) {
         toolTrace.push({
@@ -890,17 +998,6 @@ class QiweiAgentRuntime {
       }
 
       const authoritativeHistory = selectAuthoritativeHistory(history, 10);
-      const currentProfile = profile?.profile || profile || {};
-      const responsePlan = planResponse({
-        inboundContent,
-        messages: history,
-        profile: currentProfile,
-        customerIntelligence,
-        toolTrace,
-        citations,
-        businessGoal: this.config.businessGoal,
-        stagePlaybook: this.config.stagePlaybook,
-      });
       const assess = content => {
         const parsed = parseFinal(content);
         const grounded = enforceAuthoritativeGrounding(parsed, authoritativeHistory, currentProfile, inboundContent);
@@ -949,6 +1046,9 @@ class QiweiAgentRuntime {
             memoryContext,
             qualityRepair,
             rewriteAttempt: 1,
+            businessGoal: this.config.businessGoal,
+            stagePlaybook: this.config.stagePlaybook,
+            salesMoveInstruction: salesMoveInstruction(salesMovePlan),
           });
           if (revised.claudeCode) {
             toolTrace.push({
@@ -1008,6 +1108,24 @@ class QiweiAgentRuntime {
       }
 
       const final = candidate.final;
+      toolTrace.push({
+        tool: 'sales_move_planner',
+        args: { version: salesMovePlan.version, stage: journeyState?.currentStageId || null },
+        result: {
+          primaryMove: salesMovePlan.primaryMove,
+          reason: salesMovePlan.reason,
+          appliedGuidelineIds: salesMovePlan.appliedGuidelines.map(item => item.id),
+        },
+      });
+      toolTrace.push({
+        tool: 'response_generation_plan',
+        args: { version: responseGenerationPlan.version },
+        result: {
+          directAnswer: responseGenerationPlan.directAnswer,
+          judgment: responseGenerationPlan.judgment,
+          singleNextStep: responseGenerationPlan.singleNextStep,
+        },
+      });
       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);
@@ -1034,6 +1152,8 @@ class QiweiAgentRuntime {
           rewriteCount,
           requiresHuman,
         }),
+        salesMovePlan,
+        responseGenerationPlan,
       };
     }
     throw new Error('Agent 工具调用轮次超过上限,已转人工处理');

+ 411 - 0
mcp/src/core/agent-sales-move-planner.js

@@ -0,0 +1,411 @@
+'use strict';
+
+const SALES_MOVES = Object.freeze([
+  'answer',
+  'acknowledge_objection',
+  'clarify',
+  'recommend',
+  'advance',
+  'handoff',
+]);
+
+const SALES_MOVE_SET = new Set(SALES_MOVES);
+const CRITICALITY_RANK = Object.freeze({
+  low: 0,
+  normal: 1,
+  medium: 1,
+  high: 2,
+  critical: 3,
+});
+const RISK_RANK = Object.freeze({
+  none: 0,
+  low: 1,
+  medium: 2,
+  high: 3,
+  critical: 4,
+});
+
+function normalizeKey(value) {
+  return String(value ?? '').trim().toLowerCase();
+}
+
+function listOf(value) {
+  if (Array.isArray(value)) return value.map(normalizeKey).filter(Boolean);
+  if (value instanceof Set) return [...value].map(normalizeKey).filter(Boolean);
+  if (value === undefined || value === null || value === '') return [];
+  return [normalizeKey(value)].filter(Boolean);
+}
+
+function knownFieldSet(value) {
+  if (Array.isArray(value) || value instanceof Set) return new Set(listOf(value));
+  if (!value || typeof value !== 'object') return new Set();
+  return new Set(Object.entries(value)
+    .filter(([, fieldValue]) => fieldValue !== undefined && fieldValue !== null && fieldValue !== '')
+    .map(([field]) => normalizeKey(field))
+    .filter(Boolean));
+}
+
+function missingFieldSet(value) {
+  if (Array.isArray(value) || value instanceof Set) return new Set(listOf(value));
+  if (!value || typeof value !== 'object') return new Set();
+  return new Set(Object.entries(value)
+    .filter(([, state]) => state === true || state === null || ['missing', 'required'].includes(normalizeKey(state)))
+    .map(([field]) => normalizeKey(field))
+    .filter(Boolean));
+}
+
+function completedActionSet(value) {
+  if (Array.isArray(value) || value instanceof Set) return new Set(listOf(value));
+  if (!value || typeof value !== 'object') return new Set();
+  return new Set(Object.entries(value)
+    .filter(([, completed]) => Boolean(completed))
+    .map(([action]) => normalizeKey(action))
+    .filter(Boolean));
+}
+
+function normalizeContext(input = {}) {
+  const knownFields = knownFieldSet(input.knownFields);
+  const missingFields = missingFieldSet(input.missingFields);
+  for (const field of knownFields) missingFields.delete(field);
+
+  const riskLevel = normalizeKey(input.riskLevel) || (input.requiresHuman ? 'high' : 'none');
+  return {
+    scenario: normalizeKey(input.scenario) || 'unknown',
+    stage: normalizeKey(input.stage),
+    riskLevel: Object.hasOwn(RISK_RANK, riskLevel) ? riskLevel : 'none',
+    riskTypes: new Set(listOf(input.riskTypes)),
+    requiresHuman: input.requiresHuman === true,
+    knownFields,
+    missingFields,
+    completedActions: completedActionSet(input.completedActions),
+  };
+}
+
+function normalizeGuideline(guideline = {}, index = 0) {
+  const action = normalizeKey(
+    typeof guideline.action === 'object' ? guideline.action?.move : guideline.action,
+  );
+  const priorityValue = Number(guideline.priority ?? 0);
+  const criticality = normalizeKey(guideline.criticality) || 'normal';
+  return {
+    id: normalizeKey(guideline.id) || `guideline-${index + 1}`,
+    condition: guideline.condition,
+    action,
+    stage: guideline.stage,
+    priority: Number.isFinite(priorityValue) ? Math.trunc(priorityValue) : 0,
+    criticality: Object.hasOwn(CRITICALITY_RANK, criticality) ? criticality : 'normal',
+    track: guideline.track !== false,
+    index,
+  };
+}
+
+function valuesMatch(actual, expected) {
+  const choices = listOf(expected);
+  return choices.length === 0 || choices.includes('*') || choices.includes(actual);
+}
+
+function allPresent(actualSet, expected) {
+  return listOf(expected).every(value => actualSet.has(value));
+}
+
+function anyPresent(actualSet, expected) {
+  const choices = listOf(expected);
+  return choices.length === 0 || choices.some(value => actualSet.has(value));
+}
+
+function nonePresent(actualSet, expected) {
+  return listOf(expected).every(value => !actualSet.has(value));
+}
+
+function structuredConditionMatches(condition, context) {
+  const checks = [];
+  const test = (name, passed) => {
+    checks.push({ name, passed });
+    return passed;
+  };
+
+  if (condition.always === false) return { matched: false, checks: [{ name: 'always', passed: false }] };
+  if (condition.always === true) test('always', true);
+  if (condition.scenario !== undefined && !test('scenario', valuesMatch(context.scenario, condition.scenario))) {
+    return { matched: false, checks };
+  }
+  if (condition.stage !== undefined && !test('stage', valuesMatch(context.stage, condition.stage))) {
+    return { matched: false, checks };
+  }
+  if (condition.knownFields !== undefined && !test('knownFields', allPresent(context.knownFields, condition.knownFields))) {
+    return { matched: false, checks };
+  }
+  if (condition.allKnownFields !== undefined && !test('allKnownFields', allPresent(context.knownFields, condition.allKnownFields))) {
+    return { matched: false, checks };
+  }
+  if (condition.anyKnownFields !== undefined && !test('anyKnownFields', anyPresent(context.knownFields, condition.anyKnownFields))) {
+    return { matched: false, checks };
+  }
+  if (condition.missingFields !== undefined && !test('missingFields', allPresent(context.missingFields, condition.missingFields))) {
+    return { matched: false, checks };
+  }
+  if (condition.allMissingFields !== undefined && !test('allMissingFields', allPresent(context.missingFields, condition.allMissingFields))) {
+    return { matched: false, checks };
+  }
+  if (condition.anyMissingFields !== undefined && !test('anyMissingFields', anyPresent(context.missingFields, condition.anyMissingFields))) {
+    return { matched: false, checks };
+  }
+  if (condition.completedActions !== undefined && !test('completedActions', allPresent(context.completedActions, condition.completedActions))) {
+    return { matched: false, checks };
+  }
+  if (condition.notCompletedActions !== undefined && !test('notCompletedActions', nonePresent(context.completedActions, condition.notCompletedActions))) {
+    return { matched: false, checks };
+  }
+  if (condition.riskLevel !== undefined && !test('riskLevel', valuesMatch(context.riskLevel, condition.riskLevel))) {
+    return { matched: false, checks };
+  }
+  if (condition.minRiskLevel !== undefined) {
+    const minimum = RISK_RANK[normalizeKey(condition.minRiskLevel)] ?? Number.MAX_SAFE_INTEGER;
+    if (!test('minRiskLevel', RISK_RANK[context.riskLevel] >= minimum)) return { matched: false, checks };
+  }
+  if (condition.riskTypes !== undefined && !test('riskTypes', allPresent(context.riskTypes, condition.riskTypes))) {
+    return { matched: false, checks };
+  }
+  if (condition.anyRiskTypes !== undefined && !test('anyRiskTypes', anyPresent(context.riskTypes, condition.anyRiskTypes))) {
+    return { matched: false, checks };
+  }
+
+  return { matched: checks.length > 0 && checks.every(check => check.passed), checks };
+}
+
+function stringConditionMatches(condition, context) {
+  const normalized = normalizeKey(condition);
+  if (!normalized) return { matched: false, checks: [{ name: 'condition', passed: false }] };
+  if (normalized === '*' || normalized === 'always') {
+    return { matched: true, checks: [{ name: 'always', passed: true }] };
+  }
+
+  const atoms = normalized.split(/\s*&&\s*/).filter(Boolean);
+  const checks = atoms.map(atom => {
+    const separator = atom.indexOf(':');
+    const name = separator < 0 ? atom : atom.slice(0, separator);
+    const value = separator < 0 ? '' : atom.slice(separator + 1);
+    let passed = false;
+    if (name === 'scenario') passed = valuesMatch(context.scenario, value);
+    else if (name === 'stage') passed = valuesMatch(context.stage, value);
+    else if (name === 'known') passed = context.knownFields.has(value);
+    else if (name === 'missing') passed = context.missingFields.has(value);
+    else if (name === 'completed') passed = context.completedActions.has(value);
+    else if (name === 'not_completed') passed = !context.completedActions.has(value);
+    else if (name === 'risk') passed = valuesMatch(context.riskLevel, value);
+    else if (name === 'risk_type') passed = context.riskTypes.has(value);
+    return { name: atom, passed };
+  });
+  return { matched: checks.length > 0 && checks.every(check => check.passed), checks };
+}
+
+function conditionMatches(condition, context) {
+  if (condition === true) return { matched: true, checks: [{ name: 'condition', passed: true }] };
+  if (Array.isArray(condition)) {
+    const results = condition.map(item => conditionMatches(item, context));
+    return {
+      matched: results.length > 0 && results.every(result => result.matched),
+      checks: results.flatMap(result => result.checks),
+    };
+  }
+  if (condition && typeof condition === 'object') return structuredConditionMatches(condition, context);
+  return stringConditionMatches(condition, context);
+}
+
+function stageMatches(guideline, context) {
+  if (guideline.stage === undefined || guideline.stage === null || guideline.stage === '') return true;
+  return valuesMatch(context.stage, guideline.stage);
+}
+
+function guidelineApplies(guideline, context) {
+  if (!SALES_MOVE_SET.has(guideline.action)) {
+    return { matched: false, checks: [{ name: 'validAction', passed: false }] };
+  }
+  if (!stageMatches(guideline, context)) {
+    return { matched: false, checks: [{ name: 'guidelineStage', passed: false }] };
+  }
+  if (guideline.track && context.completedActions.has(guideline.id)) {
+    return { matched: false, checks: [{ name: 'notPreviouslyCompleted', passed: false }] };
+  }
+
+  const conditionResult = conditionMatches(guideline.condition, context);
+  if (!conditionResult.matched) return conditionResult;
+  if (guideline.action === 'clarify' && context.missingFields.size === 0) {
+    return { matched: false, checks: [...conditionResult.checks, { name: 'hasMissingField', passed: false }] };
+  }
+  return conditionResult;
+}
+
+function compareGuidelines(left, right) {
+  if (left.priority !== right.priority) return right.priority - left.priority;
+  const criticalityDelta = CRITICALITY_RANK[right.criticality] - CRITICALITY_RANK[left.criticality];
+  if (criticalityDelta !== 0) return criticalityDelta;
+  return left.index - right.index;
+}
+
+function contextualMove(context) {
+  if (context.scenario === 'objection') {
+    return { move: 'acknowledge_objection', code: 'scenario_objection', summary: 'The current objection must be acknowledged before advancing.' };
+  }
+  if (['question', 'direct_question', 'information_request'].includes(context.scenario)) {
+    return { move: 'answer', code: 'scenario_answer', summary: 'The current turn asks for a direct answer.' };
+  }
+  if (['recommendation', 'recommendation_ready', 'compare_options'].includes(context.scenario)) {
+    return { move: 'recommend', code: 'scenario_recommend', summary: 'The current turn is ready for a recommendation.' };
+  }
+  if (context.missingFields.size > 0) {
+    return { move: 'clarify', code: 'missing_field_clarification', summary: 'One unresolved field blocks the next useful action.' };
+  }
+  if (context.knownFields.size > 0 || context.completedActions.size > 0) {
+    return { move: 'advance', code: 'context_ready_to_advance', summary: 'Known context is sufficient to move the conversation forward.' };
+  }
+  return { move: 'answer', code: 'safe_fallback', summary: 'No explicit rule applies, so the planner falls back to a bounded answer.' };
+}
+
+function selectSingleNextStep(context, primaryMove) {
+  const targetField = context.missingFields.values().next().value || null;
+  if (primaryMove === 'handoff') {
+    return {
+      kind: 'human_boundary',
+      targetField: null,
+      optional: false,
+      evidenceRequired: true,
+      rule: 'Explain the human verification boundary; only describe a transfer as executed when matching evidence exists.',
+    };
+  }
+  if (targetField) {
+    return {
+      kind: 'request_decisive_input',
+      targetField,
+      optional: false,
+      evidenceRequired: false,
+      rule: 'Request only the single missing input with the highest configured priority; do not re-ask known fields.',
+    };
+  }
+  if (primaryMove === 'advance') {
+    return {
+      kind: 'stage_next_step',
+      targetField: null,
+      optional: false,
+      evidenceRequired: false,
+      rule: 'Advance one low-cost step that fits the current stage and does not add another question.',
+    };
+  }
+  return {
+    kind: 'close_or_confirm_tradeoff',
+    targetField: null,
+    optional: true,
+    evidenceRequired: false,
+    rule: 'After resolving the current choice, stop naturally unless one decision-changing input remains.',
+  };
+}
+
+function publicGuideline(guideline, match) {
+  return {
+    id: guideline.id,
+    condition: guideline.condition,
+    action: guideline.action,
+    stage: guideline.stage ?? null,
+    priority: guideline.priority,
+    criticality: guideline.criticality,
+    track: guideline.track,
+    matchChecks: match.checks,
+  };
+}
+
+function planSalesMove(input = {}) {
+  const context = normalizeContext(input);
+  const normalizedGuidelines = (Array.isArray(input.guidelines) ? input.guidelines : [])
+    .map((guideline, index) => normalizeGuideline(guideline, index));
+  const matches = normalizedGuidelines
+    .map(guideline => ({ guideline, match: guidelineApplies(guideline, context) }))
+    .filter(item => item.match.matched)
+    .sort((left, right) => compareGuidelines(left.guideline, right.guideline));
+
+  const highRisk = context.requiresHuman || RISK_RANK[context.riskLevel] >= RISK_RANK.high;
+  const fallback = contextualMove(context);
+  let primary;
+  if (highRisk) {
+    primary = {
+      move: 'handoff',
+      source: 'safety',
+      guidelineId: null,
+      code: 'high_risk_handoff',
+      summary: 'High-risk or explicitly human-required context takes precedence over automation.',
+    };
+  } else if (matches.length > 0) {
+    primary = {
+      move: matches[0].guideline.action,
+      source: 'guideline',
+      guidelineId: matches[0].guideline.id,
+      code: 'guideline_selected',
+      summary: `Selected the highest-ranked applicable guideline: ${matches[0].guideline.id}.`,
+    };
+  } else {
+    primary = { ...fallback, source: 'context', guidelineId: null };
+  }
+
+  const candidates = [];
+  const seen = new Set();
+  const addCandidate = candidate => {
+    const key = `${candidate.source}:${candidate.guidelineId || ''}:${candidate.move}`;
+    if (seen.has(key)) return;
+    seen.add(key);
+    candidates.push({ ...candidate, selected: false });
+  };
+  addCandidate(primary);
+  for (const item of matches) {
+    addCandidate({
+      move: item.guideline.action,
+      source: 'guideline',
+      guidelineId: item.guideline.id,
+      priority: item.guideline.priority,
+      criticality: item.guideline.criticality,
+    });
+  }
+  addCandidate({ move: fallback.move, source: 'context', guidelineId: null, code: fallback.code });
+  candidates[0].selected = true;
+
+  return {
+    version: '1.1',
+    primaryMove: primary.move,
+    singleNextStep: selectSingleNextStep(context, primary.move),
+    candidateMoves: candidates,
+    appliedGuidelines: matches.map(item => publicGuideline(item.guideline, item.match)),
+    reason: {
+      code: primary.code,
+      summary: primary.summary,
+      guidelineId: primary.guidelineId,
+      signals: {
+        scenario: context.scenario,
+        stage: context.stage || null,
+        riskLevel: context.riskLevel,
+        riskTypes: [...context.riskTypes],
+        knownFields: [...context.knownFields],
+        missingFields: [...context.missingFields],
+        completedActions: [...context.completedActions],
+      },
+    },
+  };
+}
+
+class AgentSalesMovePlanner {
+  constructor(options = {}) {
+    this.guidelines = Array.isArray(options.guidelines) ? options.guidelines : [];
+  }
+
+  plan(input = {}) {
+    return planSalesMove({ ...input, guidelines: input.guidelines ?? this.guidelines });
+  }
+}
+
+module.exports = {
+  SALES_MOVES,
+  CRITICALITY_RANK,
+  normalizeContext,
+  normalizeGuideline,
+  guidelineApplies,
+  selectSingleNextStep,
+  planSalesMove,
+  AgentSalesMovePlanner,
+};

+ 135 - 2
mcp/src/core/agent-workbench-db.js

@@ -251,6 +251,35 @@ class AgentWorkbenchDb {
         updated_at TEXT NOT NULL,
         PRIMARY KEY(account_key, contact_id)
       );
+      CREATE TABLE IF NOT EXISTS conversation_journeys (
+        conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
+        journey_id TEXT NOT NULL,
+        definition_json TEXT NOT NULL,
+        state_json TEXT NOT NULL,
+        current_stage_id TEXT NOT NULL,
+        status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','completed')),
+        sequence INTEGER NOT NULL DEFAULT 0,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL
+      );
+      CREATE TABLE IF NOT EXISTS conversation_applied_actions (
+        conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+        action_key TEXT NOT NULL,
+        journey_id TEXT NOT NULL,
+        kind TEXT NOT NULL,
+        stage_id TEXT NOT NULL,
+        status TEXT NOT NULL CHECK(status IN ('planned','executed','confirmed')),
+        transition_json TEXT NOT NULL DEFAULT '{}',
+        payload_json TEXT NOT NULL DEFAULT '{}',
+        execution_evidence_json TEXT,
+        confirmation_evidence_json TEXT,
+        transition_result_json TEXT,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL,
+        executed_at TEXT,
+        confirmed_at TEXT,
+        PRIMARY KEY(conversation_id, action_key)
+      );
       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_drafts_status ON drafts(status, created_at);
@@ -263,6 +292,8 @@ class AgentWorkbenchDb {
       CREATE INDEX IF NOT EXISTS idx_customer_memory_revisions ON customer_memory_revisions(memory_id, created_at DESC);
       CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at DESC);
       CREATE INDEX IF NOT EXISTS idx_contact_onboarding_state ON contact_onboarding(account_key, welcome_state, updated_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_conversation_journeys_stage ON conversation_journeys(journey_id, current_stage_id, updated_at DESC);
+      CREATE INDEX IF NOT EXISTS idx_applied_actions_status ON conversation_applied_actions(conversation_id, status, updated_at DESC);
     `);
     this.migrateConversationModes();
     this.db.prepare(`UPDATE memory_extraction_jobs SET status='pending',next_attempt_at=?,updated_at=? WHERE status='processing'`)
@@ -606,7 +637,7 @@ class AgentWorkbenchDb {
   }
 
   getLatestInbound(conversationId) {
-    return this.db.prepare("SELECT * FROM messages WHERE conversation_id=? AND direction='inbound' ORDER BY created_at DESC LIMIT 1").get(conversationId) || null;
+    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;
   }
 
   findRecentInboundDuplicate(conversationId, content, createdAt, windowSeconds = 60) {
@@ -1085,6 +1116,108 @@ class AgentWorkbenchDb {
     return { tasks, alerts };
   }
 
+  hydrateAppliedAction(row) {
+    if (!row) return null;
+    return {
+      key: row.action_key,
+      journeyId: row.journey_id,
+      kind: row.kind,
+      stageId: row.stage_id,
+      status: row.status,
+      transition: parse(row.transition_json, {}),
+      payload: parse(row.payload_json, {}),
+      executionEvidence: parse(row.execution_evidence_json, null),
+      confirmationEvidence: parse(row.confirmation_evidence_json, null),
+      transitionResult: parse(row.transition_result_json, null),
+      plannedAt: row.created_at,
+      executedAt: row.executed_at,
+      confirmedAt: row.confirmed_at,
+      updatedAt: row.updated_at,
+    };
+  }
+
+  listConversationAppliedActions(conversationId) {
+    return this.db.prepare(`SELECT * FROM conversation_applied_actions
+      WHERE conversation_id=? ORDER BY created_at,action_key`).all(conversationId)
+      .map(row => this.hydrateAppliedAction(row));
+  }
+
+  getConversationJourney(conversationId) {
+    const row = this.db.prepare('SELECT * FROM conversation_journeys WHERE conversation_id=?').get(conversationId);
+    if (!row) return null;
+    return {
+      conversationId: row.conversation_id,
+      journeyId: row.journey_id,
+      currentStageId: row.current_stage_id,
+      status: row.status,
+      sequence: Number(row.sequence || 0),
+      definition: parse(row.definition_json, null),
+      state: parse(row.state_json, null),
+      appliedActions: this.listConversationAppliedActions(conversationId),
+      createdAt: row.created_at,
+      updatedAt: row.updated_at,
+    };
+  }
+
+  upsertConversationJourney(conversationId, definition, state) {
+    const timestamp = now();
+    this.db.exec('BEGIN IMMEDIATE');
+    try {
+      this.db.prepare(`INSERT INTO conversation_journeys(
+        conversation_id,journey_id,definition_json,state_json,current_stage_id,status,sequence,created_at,updated_at
+      ) VALUES(?,?,?,?,?,?,?,?,?)
+      ON CONFLICT(conversation_id) DO UPDATE SET
+        journey_id=excluded.journey_id,definition_json=excluded.definition_json,state_json=excluded.state_json,
+        current_stage_id=excluded.current_stage_id,status=excluded.status,sequence=excluded.sequence,updated_at=excluded.updated_at`)
+        .run(
+          conversationId,
+          String(definition.id),
+          json(definition),
+          json(state),
+          String(state.currentStageId),
+          state.status === 'completed' ? 'completed' : 'active',
+          Number(state.sequence || 0),
+          timestamp,
+          timestamp,
+        );
+      const upsertAction = this.db.prepare(`INSERT INTO conversation_applied_actions(
+        conversation_id,action_key,journey_id,kind,stage_id,status,transition_json,payload_json,
+        execution_evidence_json,confirmation_evidence_json,transition_result_json,created_at,updated_at,executed_at,confirmed_at
+      ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+      ON CONFLICT(conversation_id,action_key) DO UPDATE SET
+        journey_id=excluded.journey_id,kind=excluded.kind,stage_id=excluded.stage_id,status=excluded.status,
+        transition_json=excluded.transition_json,payload_json=excluded.payload_json,
+        execution_evidence_json=excluded.execution_evidence_json,
+        confirmation_evidence_json=excluded.confirmation_evidence_json,
+        transition_result_json=excluded.transition_result_json,updated_at=excluded.updated_at,
+        executed_at=excluded.executed_at,confirmed_at=excluded.confirmed_at`);
+      for (const action of state.appliedActions || []) {
+        upsertAction.run(
+          conversationId,
+          String(action.key),
+          String(state.journeyId),
+          String(action.kind || 'conversation_action'),
+          String(action.stageId || state.currentStageId),
+          String(action.status || 'planned'),
+          json(action.transition || {}),
+          json(action.payload || {}),
+          action.executionEvidence ? json(action.executionEvidence) : null,
+          action.confirmationEvidence ? json(action.confirmationEvidence) : null,
+          action.transitionResult ? json(action.transitionResult) : null,
+          action.plannedAt || timestamp,
+          timestamp,
+          action.executedAt || null,
+          action.confirmedAt || null,
+        );
+      }
+      this.db.exec('COMMIT');
+    } catch (error) {
+      this.db.exec('ROLLBACK');
+      throw error;
+    }
+    return this.getConversationJourney(conversationId);
+  }
+
   createDraft({ conversationId, inboundMessageId, content, confidence, intent, reason, requiresHuman, citations, toolTrace }) {
     const draftId = makeId('draft');
     this.db.prepare(`INSERT INTO drafts(id,conversation_id,inbound_message_id,content,confidence,intent,reason,requires_human,citations_json,tool_trace_json,created_at)
@@ -1169,7 +1302,7 @@ class AgentWorkbenchDb {
     if (!sourcePath || !fs.existsSync(sourcePath) || path.resolve(sourcePath) === path.resolve(this.filePath)) return { imported: false, reason: 'source_missing' };
     if (this.listConversations().length) return { imported: false, reason: 'target_not_empty' };
     const source = new DatabaseSync(sourcePath, { readOnly: true });
-    const tableOrder = ['settings', 'conversations', 'messages', 'customer_profiles', 'customer_tasks', 'customer_alerts', 'customer_recommendations', 'customer_memory_items', 'customer_memory_snapshots', 'memory_extraction_jobs', 'customer_memory_revisions', 'drafts', 'audit_logs', 'poll_state', 'contact_onboarding'];
+    const tableOrder = ['settings', 'conversations', 'messages', 'customer_profiles', 'customer_tasks', 'customer_alerts', 'customer_recommendations', 'customer_memory_items', 'customer_memory_snapshots', 'memory_extraction_jobs', 'customer_memory_revisions', 'drafts', 'audit_logs', 'poll_state', 'contact_onboarding', 'conversation_journeys', 'conversation_applied_actions'];
     let rowsImported = 0;
     this.db.exec(`
       DROP INDEX IF EXISTS idx_customer_tasks_business_key;

+ 261 - 11
mcp/src/core/agent-workbench-service.js

@@ -8,6 +8,20 @@ const { appendChatRecord, ensureMemory } = require('./message-archive');
 const { planResponse } = require('./response-quality-planner');
 const { verifyResponseQuality } = require('./response-quality-verifier');
 const { normalizeQualityChecks, normalizeQualityScore, qualityFallbackReason } = require('./response-quality-contract');
+const { planSalesMove } = require('./agent-sales-move-planner');
+const {
+  createJourneyDefinition,
+  createJourneyState,
+  applyJourneyFacts,
+  planAppliedAction,
+  confirmAppliedAction,
+} = require('./agent-conversation-journey');
+
+const DEFAULT_JOURNEY_DEFINITION = Object.freeze({
+  id: 'generic-conversation',
+  initialStageId: 'active',
+  stages: [{ id: 'active', label: 'Active conversation', requiredFacts: [], nextStageId: null }],
+});
 
 function looksLikeGroupId(value) {
   return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
@@ -27,10 +41,14 @@ class AgentWorkbenchService extends EventEmitter {
       intervalMs: config.memory?.extractionIntervalMs,
       retryBaseMs: config.memory?.extractionRetryBaseMs,
     });
+    this.generationQueues = new Map();
     this.memoryWorker.start();
   }
 
   state(pollerStatus = {}) {
+    const resolvedTransport = typeof this.qiwei.context === 'function'
+      ? this.qiwei.context().transportMode
+      : '';
     return {
       global: this.db.globalState(),
       agent: {
@@ -42,7 +60,9 @@ class AgentWorkbenchService extends EventEmitter {
       },
       qiwei: {
         configured: this.qiwei.isConfigured(),
-        transport: this.config.qiwei.transport || 'fmode-gateway',
+        transport: resolvedTransport === 'direct_upstream'
+          ? 'direct_upstream'
+          : (this.config.qiwei.transport || 'fmode-gateway'),
         allowlistCount: this.config.qiwei.allowedSenders.length,
       },
       poller: pollerStatus,
@@ -226,6 +246,155 @@ class AgentWorkbenchService extends EventEmitter {
     this.memoryWorker?.stop();
   }
 
+  isCurrentInbound(conversationId, inboundMessageId) {
+    return this.db.getLatestInbound(conversationId)?.id === inboundMessageId;
+  }
+
+  discardStaleResponse(conversation, inboundMessage, stage = 'after_generation') {
+    const latestInbound = this.db.getLatestInbound(conversation.id);
+    this.queueMemoryExtraction(conversation, inboundMessage);
+    this.db.audit({
+      actor: 'agent:concurrency',
+      action: 'agent_response_stale_discarded',
+      conversationId: conversation.id,
+      entityId: inboundMessage.id,
+      detail: {
+        stage,
+        sourceInboundMessageId: inboundMessage.id,
+        latestInboundMessageId: latestInbound?.id || null,
+      },
+    });
+    this.emit('change', { type: 'agent_response_stale_discarded', conversationId: conversation.id });
+    return {
+      status: 'stale_discarded',
+      assistantMessage: 'Agent discarded a reply superseded by a newer customer message.',
+      conversation,
+      message: inboundMessage,
+      latestMessage: latestInbound,
+    };
+  }
+
+  enqueueGeneration(conversation, inboundMessage, options = {}) {
+    const key = String(conversation.id);
+    const previous = this.generationQueues.get(key) || Promise.resolve();
+    const task = previous
+      .catch(() => undefined)
+      .then(() => this.generateDraftForMessage(conversation, inboundMessage, options));
+    const tracked = task.finally(() => {
+      if (this.generationQueues.get(key) === tracked) this.generationQueues.delete(key);
+    });
+    this.generationQueues.set(key, tracked);
+    return tracked;
+  }
+
+  journeyDefinition() {
+    return createJourneyDefinition(this.config.agent?.journeyDefinition || DEFAULT_JOURNEY_DEFINITION);
+  }
+
+  prepareConversationJourney(conversationId, profile = {}) {
+    const definition = this.journeyDefinition();
+    const existing = this.db.getConversationJourney(conversationId);
+    let state = existing?.journeyId === definition.id && existing.state
+      ? existing.state
+      : createJourneyState(definition);
+    state = applyJourneyFacts(definition, state, profile?.profile || profile || {});
+    return { definition, state, configured: Boolean(this.config.agent?.journeyDefinition) };
+  }
+
+  planConversationAction(conversation, inboundMessage, output, profile = {}) {
+    const journey = this.prepareConversationJourney(conversation.id, profile);
+    const qualityPlan = output.qualityPlan || {};
+    const currentProfile = profile?.profile || profile || {};
+    const knownFields = Object.entries(currentProfile)
+      .filter(([key, value]) => key !== '__evidence' && value !== undefined && value !== null && value !== '')
+      .map(([key]) => key);
+    const requiredFields = Array.isArray(this.config.agent?.requiredProfileFields)
+      ? this.config.agent.requiredProfileFields
+      : [];
+    const known = new Set(knownFields.map(String));
+    const salesMovePlan = output.salesMovePlan || planSalesMove({
+      scenario: qualityPlan.scenario,
+      stage: journey.state.currentStageId,
+      riskLevel: qualityPlan.requireHumanHandoff ? 'high' : 'none',
+      riskTypes: qualityPlan.riskTypes || [],
+      requiresHuman: qualityPlan.requireHumanHandoff,
+      knownFields,
+      missingFields: requiredFields.filter(field => !known.has(String(field))),
+      completedActions: (journey.state.appliedActions || [])
+        .filter(action => action.status === 'confirmed')
+        .flatMap(action => [action.key, ...(action.payload?.guidelineIds || [])]),
+      guidelines: this.config.agent?.salesGuidelines || [],
+    });
+    const actionKey = `${journey.definition.id}:${inboundMessage.id}:${salesMovePlan.primaryMove}`;
+    let state = applyJourneyFacts(journey.definition, journey.state, output.profileUpdates || {});
+    state = planAppliedAction(journey.definition, state, {
+      key: actionKey,
+      kind: salesMovePlan.primaryMove,
+      stageId: state.currentStageId,
+      transition: { mode: journey.configured ? 'auto' : 'none' },
+      payload: {
+        inboundMessageId: inboundMessage.id,
+        primaryMove: salesMovePlan.primaryMove,
+        guidelineIds: (salesMovePlan.appliedGuidelines || []).map(item => item.id),
+      },
+      at: new Date().toISOString(),
+    });
+    const stored = this.db.upsertConversationJourney(conversation.id, journey.definition, state);
+    this.db.audit({
+      actor: 'agent:journey',
+      action: 'conversation_action_planned',
+      conversationId: conversation.id,
+      entityId: inboundMessage.id,
+      detail: { actionKey, primaryMove: salesMovePlan.primaryMove, stageId: state.currentStageId },
+    });
+    return {
+      salesMovePlan,
+      journeyActionKey: actionKey,
+      journey: stored,
+    };
+  }
+
+  confirmConversationAction(conversationId, actionKey, evidence) {
+    if (!actionKey) return null;
+    const existing = this.db.getConversationJourney(conversationId);
+    if (!existing?.definition || !existing?.state) return null;
+    const state = confirmAppliedAction(existing.definition, existing.state, { key: actionKey, evidence });
+    const stored = this.db.upsertConversationJourney(conversationId, existing.definition, state);
+    this.db.audit({
+      actor: 'agent:journey',
+      action: 'conversation_action_confirmed',
+      conversationId,
+      entityId: evidence.id,
+      detail: {
+        actionKey,
+        evidenceType: evidence.type,
+        stageId: state.currentStageId,
+        journeyStatus: state.status,
+      },
+    });
+    return stored;
+  }
+
+  tryConfirmConversationAction(conversationId, actionKey, evidence) {
+    try {
+      return this.confirmConversationAction(conversationId, actionKey, evidence);
+    } catch (error) {
+      this.db.audit({
+        actor: 'agent:journey',
+        action: 'conversation_action_confirmation_failed',
+        conversationId,
+        entityId: evidence?.id || null,
+        detail: { actionKey: actionKey || null, errorType: error?.name || 'Error' },
+      });
+      return null;
+    }
+  }
+
+  draftJourneyActionKey(draft) {
+    const trace = (draft?.tool_trace || []).find(item => item?.tool === 'conversation_journey_action');
+    return String(trace?.args?.actionKey || '');
+  }
+
   addCustomerMemory(conversationId, input = {}, actor = 'human') {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) throw new Error('会话不存在');
@@ -331,11 +500,14 @@ class AgentWorkbenchService extends EventEmitter {
       if (welcome) return welcome;
     }
 
-    return this.generateDraftForMessage(current, inserted.message, { allowAutoSend: options.allowAutoSend });
+    return this.enqueueGeneration(current, inserted.message, { allowAutoSend: options.allowAutoSend });
   }
 
   async generateDraftForMessage(conversation, inboundMessage, options = {}) {
     try {
+      if (!this.isCurrentInbound(conversation.id, inboundMessage.id)) {
+        return this.discardStaleResponse(conversation, inboundMessage, 'before_generation');
+      }
       if (isNoReplyNeededMessage(inboundMessage.content)) {
         this.queueMemoryExtraction(conversation, inboundMessage);
         const message = '客户消息属于确认或致谢,无需继续回复';
@@ -370,6 +542,7 @@ class AgentWorkbenchService extends EventEmitter {
         });
       }
       const conversationMessages = this.db.listMessages(conversation.id);
+      const preparedJourney = this.prepareConversationJourney(conversation.id, profile);
       let output = await this.agent.run({
         conversation,
         messages: conversationMessages,
@@ -377,7 +550,14 @@ class AgentWorkbenchService extends EventEmitter {
         customerIntelligence: { tasks: currentTasks, alerts: currentAlerts },
         inboundContent: inboundMessage.content,
         memoryContext,
+        journeyState: preparedJourney.state,
+        completedActions: (preparedJourney.state.appliedActions || [])
+          .filter(action => action.status === 'confirmed')
+          .map(action => action.key),
       });
+      if (!this.isCurrentInbound(conversation.id, inboundMessage.id)) {
+        return this.discardStaleResponse(conversation, inboundMessage, 'after_generation');
+      }
       if (!String(output.content || '').trim()) throw new Error('Agent 没有生成可审核的回复内容');
       const serviceQuality = verifyResponseQuality({
         reply: output.content,
@@ -426,6 +606,26 @@ class AgentWorkbenchService extends EventEmitter {
           result: { passed: qualityPassed, score: serviceQuality.score, failedCheckIds: failureIds, hardFailureIds: serviceQuality.hardFailureIds },
         }],
       };
+      const conversationAction = this.planConversationAction(conversation, inboundMessage, output, {
+        ...profile,
+        profile: { ...(profile.profile || {}), ...(output.profileUpdates || {}) },
+      });
+      output = {
+        ...output,
+        salesMovePlan: conversationAction.salesMovePlan,
+        journeyActionKey: conversationAction.journeyActionKey,
+        toolTrace: [...(output.toolTrace || []), {
+          tool: 'conversation_journey_action',
+          args: {
+            actionKey: conversationAction.journeyActionKey,
+            primaryMove: conversationAction.salesMovePlan.primaryMove,
+          },
+          result: {
+            status: 'planned',
+            stageId: conversationAction.journey.currentStageId,
+          },
+        }],
+      };
       this.db.audit({
         actor: 'agent:quality',
         action: 'agent_response_quality_evaluated',
@@ -481,6 +681,9 @@ class AgentWorkbenchService extends EventEmitter {
         });
       }
       if (options.allowAutoSend !== false && conversation.mode === 'autopilot' && output.qualityPassed && !output.requiresHuman) {
+        if (!this.isCurrentInbound(conversation.id, inboundMessage.id)) {
+          return this.discardStaleResponse(conversation, inboundMessage, 'before_autopilot_send');
+        }
         return await this.sendAutopilotReply(conversation, inboundMessage, output, {
           profile: updatedProfile,
           tasks: customerTasks,
@@ -559,6 +762,11 @@ class AgentWorkbenchService extends EventEmitter {
   async sendAutopilotReply(conversation, inboundMessage, output, intelligence = {}) {
     this.requireAllowed(conversation.contact_id);
     this.requirePrivateConversation(conversation.id);
+    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) {
       throw new Error('Agent 回复未通过发送前质量校验,已保留为人工审核');
     }
@@ -582,11 +790,13 @@ 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),
-        },
-      }).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,
+         },
+       }).message;
       appendChatRecord({
         wxid: conversation.contact_id,
         messageId: outbound.id,
@@ -595,8 +805,17 @@ 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',
+          id: outbound.id,
+          success: true,
+          at: outbound.created_at,
+          summary: 'Autopilot reply sent through the configured WeCom transport.',
+        });
+      }
       this.db.audit({
         actor: 'agent:autopilot',
         action: 'autopilot_message_sent',
@@ -636,6 +855,28 @@ class AgentWorkbenchService extends EventEmitter {
     if (!conversation) throw new Error('会话不存在');
     this.requireAllowed(conversation.contact_id);
     this.requirePrivateConversation(conversation.id);
+    if (String(actor).startsWith('agent:') && !this.isCurrentInbound(conversation.id, draft.inbound_message_id)) {
+      const latestInbound = this.db.getLatestInbound(conversation.id);
+      const updated = this.db.updateDraft(draftId, {
+        status: 'rejected',
+        reviewed_at: new Date().toISOString(),
+        reviewer: actor,
+        error: 'superseded_inbound',
+      });
+      this.db.audit({
+        actor: 'agent:concurrency',
+        action: 'agent_response_stale_discarded',
+        conversationId: conversation.id,
+        entityId: draft.inbound_message_id,
+        detail: {
+          stage: 'before_auto_approval',
+          draftId,
+          sourceInboundMessageId: draft.inbound_message_id,
+          latestInboundMessageId: latestInbound?.id || null,
+        },
+      });
+      return { status: 'stale_discarded', draft: updated, message: latestInbound };
+    }
     const finalContent = String(content || draft.content).trim();
     if (!finalContent) throw new Error('回复内容不能为空');
     if (finalContent.length > 2000) throw new Error('回复内容过长');
@@ -672,6 +913,15 @@ class AgentWorkbenchService extends EventEmitter {
         createdAt: outbound.created_at,
         source: 'draft_approved',
       });
+      if (result?.isSendSuccess !== false) {
+        this.tryConfirmConversationAction(conversation.id, this.draftJourneyActionKey(draft), {
+          type: 'send',
+          id: outbound.id,
+          success: true,
+          at: outbound.created_at,
+          summary: 'Reviewed draft sent through the configured WeCom transport.',
+        });
+      }
       const updated = this.db.updateDraft(draftId, { status: 'sent', sent_message_id: outbound.id, error: null });
       this.db.audit({ actor, action: 'message_sent', conversationId: conversation.id, entityId: outbound.id, detail: { draftId } });
       this.emit('change', { type: 'message', conversationId: conversation.id });
@@ -713,7 +963,7 @@ class AgentWorkbenchService extends EventEmitter {
     this.requirePrivateConversation(conversation.id);
     if (draft.status === 'pending') this.rejectDraft(draftId, { actor, reason: 'regenerated' });
     this.db.audit({ actor, action: 'draft_regenerate_requested', conversationId: conversation.id, entityId: draftId });
-    return this.generateDraftForMessage(conversation, inbound);
+    return this.enqueueGeneration(conversation, inbound);
   }
 
   async generateLatestDraft(conversationId, actor = 'human') {
@@ -726,7 +976,7 @@ class AgentWorkbenchService extends EventEmitter {
     const existing = this.db.listDrafts({ conversationId }).find(item => item.inbound_message_id === inbound.id && item.status === 'pending');
     if (existing) return { status: 'pending_review', conversation, message: inbound, draft: existing };
     this.db.audit({ actor, action: 'manual_agent_run_requested', conversationId, entityId: inbound.id });
-    return this.generateDraftForMessage(conversation, inbound);
+    return this.enqueueGeneration(conversation, inbound);
   }
 
   async manualSend(conversationId, content, actor = 'human') {

+ 35 - 0
mcp/src/core/credentials.js

@@ -212,6 +212,38 @@ function readQiweiAuthToken(input = {}) {
   return normalizeToken(token);
 }
 
+function readQiweiUpstreamToken(input = {}) {
+  const fileEnv = readEnvFiles();
+  return normalizeToken(firstNonEmpty([
+    input.upstreamToken,
+    input.qiweiToken,
+    process.env.QIWEI_UPSTREAM_TOKEN,
+    process.env.QIWEI_TOKEN,
+    fileEnv.QIWEI_UPSTREAM_TOKEN,
+    fileEnv.QIWEI_TOKEN
+  ]));
+}
+
+function readQiweiUpstreamApiBase(input = {}) {
+  const fileEnv = readEnvFiles();
+  return String(firstNonEmpty([
+    input.upstreamApiBase,
+    input.qiweiApiBase,
+    process.env.QIWEI_UPSTREAM_API_BASE,
+    fileEnv.QIWEI_UPSTREAM_API_BASE,
+    'https://manager.qiweapi.com/qiwe'
+  ])).replace(/\/$/, '');
+}
+
+function readQiweiTransportMode(input = {}) {
+  return String(firstNonEmpty([
+    input.transportMode,
+    process.env.QIWEI_TRANSPORT_MODE,
+    readEnvFiles().QIWEI_TRANSPORT_MODE,
+    'fmode'
+  ])).trim().toLowerCase();
+}
+
 function readCredentialsFile() {
   return readJsonMaybe(CREDENTIALS_FILE);
 }
@@ -381,6 +413,9 @@ module.exports = {
   DEFAULT_API_BASE,
   CREDENTIALS_FILE,
   readQiweiAuthToken,
+  readQiweiUpstreamToken,
+  readQiweiUpstreamApiBase,
+  readQiweiTransportMode,
   readFmodeVoiceToken,
   readQiweiUid,
   readQiweiGuid,

+ 54 - 0
mcp/src/core/json-io.js

@@ -0,0 +1,54 @@
+'use strict';
+
+/**
+ * JSON 文件读写单一实现(2026-08-11 审查 E29)
+ *
+ * 背景:项目内曾有 9+ 份各自为政的 writeJsonAtomic/atomicWriteJson 拷贝(临时文件名、
+ * 失败清理、ensureDir、尾换行行为各不相同)。本模块收敛为唯一实现:
+ *  - 原子写(tmp + rename),进程被中断不会留下半截 JSON;
+ *  - 失败时清理临时文件并抛出,调用方按需处理(不再静默吞错);
+ *  - 读时剥离 BOM、解析失败返回 fallback。
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+/** 原子写 JSON:tmp + rename;ensureDir 自动建父目录;newline 控制尾换行 */
+function writeJsonAtomic(filePath, value, { ensureDir = false, newline = false, indent = 2 } = {}) {
+  const dir = path.dirname(filePath);
+  if (ensureDir) fs.mkdirSync(dir, { recursive: true });
+  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
+  const content = `${JSON.stringify(value, null, indent)}${newline ? '\n' : ''}`;
+  try {
+    fs.writeFileSync(tmpPath, content, 'utf8');
+    fs.renameSync(tmpPath, filePath);
+  } catch (error) {
+    try { fs.unlinkSync(tmpPath); } catch { /* 临时文件清理失败不掩盖原始错误 */ }
+    throw error;
+  }
+}
+
+/** 原子写文本(非 JSON 文件,如 .env.local):tmp + rename */
+function writeTextAtomic(filePath, content) {
+  const dir = path.dirname(filePath);
+  fs.mkdirSync(dir, { recursive: true });
+  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
+  try {
+    fs.writeFileSync(tmpPath, content, 'utf8');
+    fs.renameSync(tmpPath, filePath);
+  } catch (error) {
+    try { fs.unlinkSync(tmpPath); } catch { /* 同上 */ }
+    throw error;
+  }
+}
+
+/** 读 JSON:剥离 BOM,解析失败返回 fallback(默认 null) */
+function readJson(filePath, fallback = null) {
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^/, ''));
+  } catch {
+    return fallback;
+  }
+}
+
+module.exports = { writeJsonAtomic, writeTextAtomic, readJson };

+ 241 - 18
mcp/src/core/login-flow-server.js

@@ -37,6 +37,15 @@ const FLOW_PAGE_STYLE = `
   .hint { color:#999; font-size:13px; margin-top:10px; line-height:1.8; }
   input#code { font-size:22px; letter-spacing:8px; text-align:center; width:220px; padding:8px; border:1px solid #ccc; border-radius:8px; }
   .success { font-size:18px; color:#237804; }
+  .account-list { display:grid; gap:10px; margin:14px 0; }
+  .account-row { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--border); border-radius:8px; padding:12px 14px; }
+  .account-meta { text-align:left; min-width:0; }
+  .account-meta strong,.account-meta span { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+  .account-meta span { color:#999; font-size:12px; margin-top:3px; }
+  .button-row { display:flex; flex-wrap:wrap; gap:10px; margin-top:14px; }
+  button.secondary { border:1px solid #d9d9d9; background:#fff; color:#333; font-size:14px; padding:10px 18px; border-radius:8px; cursor:pointer; }
+  button.danger { border-color:#ffccc7; color:#a8071a; }
+  select { width:100%; padding:10px; border:1px solid #d9d9d9; border-radius:8px; background:#fff; }
 `;
 
 let currentServer = null;
@@ -52,6 +61,7 @@ function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
 <head>
 <meta charset="utf-8">
 <title>企微助手 · 开通与登录</title>
+<link rel="icon" href="data:,">
 <style>${FLOW_PAGE_STYLE}</style>
 </head>
 <body>
@@ -59,8 +69,8 @@ function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
   <h1>企微助手 · 开通与登录</h1>
   <div class="steps">
     <div class="step" id="s1">1. 开通订阅</div>
-    <div class="step" id="s2">2. 扫码登录</div>
-    <div class="step" id="s3">3. 验证码确认</div>
+    <div class="step" id="s2">2. 选择账号</div>
+    <div class="step" id="s3">3. 扫码与确认</div>
     <div class="step" id="s4">4. 完成</div>
   </div>
   <div class="panel" id="panel">页面加载中…</div>
@@ -71,6 +81,7 @@ const CHECKOUT_TOKEN = ${JSON.stringify(String(checkoutToken || ''))};
 const SEATS = ${JSON.stringify(SEAT_PLANS)};
 const DURATIONS = ${JSON.stringify(DURATION_PLANS)};
 let seats = 1, months = 1, pollTimer = null, currentStep = 0, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
+let subscriptionState = { seats: 1, usedSeats: 0 }, currentDecision = null;
 
 const panel = document.getElementById('panel');
 
@@ -89,6 +100,10 @@ function showMsg(type, html) {
   el.innerHTML = html;
 }
 
+function esc(value) {
+  return String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[ch]));
+}
+
 async function api(path, opts) {
   const res = await fetch(path, opts);
   const data = await res.json().catch(() => ({}));
@@ -142,13 +157,41 @@ function renderSubscribe(detail, monthlyPrice) {
   };
 }
 
-async function startLogin() {
+function renderAccountChooser(state) {
+  const accounts = Array.isArray(state.accounts) ? state.accounts : [];
+  subscriptionState = state.subscription || subscriptionState;
+  if (!accounts.length) { startLogin(true); return; }
+  setStep(2);
+  panel.innerHTML = '<div class="section-title">选择已绑定账号,或登录其他企微账号</div>' +
+    '<div class="account-list">' + accounts.map(account =>
+      '<div class="account-row"><div class="account-meta"><strong>' + esc(account.nickname || account.userId || '已绑定账号') + '</strong>' +
+      '<span>' + esc(account.corpName || '') + (account.online ? ' · 在线' : ' · 离线') + '</span></div>' +
+      '<button class="secondary" data-account-id="' + esc(account.id) + '">' + (account.online ? '直接进入' : '重新登录') + '</button></div>'
+    ).join('') + '</div>' +
+    '<button class="main" id="login-new-account">登录其他账号</button>';
+  panel.querySelectorAll('[data-account-id]').forEach(button => button.onclick = () => selectAccount(button.dataset.accountId));
+  document.getElementById('login-new-account').onclick = () => startLogin(true);
+}
+
+async function selectAccount(accountId) {
+  panel.innerHTML = '<div class="center"><div class="section-title">正在连接已绑定账号…</div></div>';
+  const r = await api('/flow/select-account', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId }) });
+  if (!r.ok) {
+    showMsg('err', esc(r.data.message || '账号连接失败') + '<br><button class="main" id="back-accounts">返回账号列表</button>');
+    document.getElementById('back-accounts').onclick = boot;
+    return;
+  }
+  if (r.data.online) { renderSuccess(r.data.detail || r.data.account || {}); return; }
+  startLogin(false);
+}
+
+async function startLogin(newAccount = false) {
   codeSubmitted = false;
   verifyWaits = 0;
   checkErrors = 0;
   setStep(2);
   panel.innerHTML = '<div class="center"><div class="section-title">正在生成企业微信登录二维码…</div></div>';
-  const r = await api('/flow/start-login', { method: 'POST' });
+  const r = await api('/flow/start-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ newAccount }) });
   if (!r.ok) {
     panel.innerHTML = '<div class="center"></div>';
     showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '生成二维码失败') + '<br>' + (r.data.tip || '') + '<br><button class="main" onclick="startLogin()">重试</button>');
@@ -173,17 +216,22 @@ function poll() {
       if (st.data.online) { clearInterval(pollTimer); renderSuccess(st.data.detail || {}); return; }
       if (st.data.subscribed === false && !st.data.stateError) { clearInterval(pollTimer); renderSubscribe(st.data.subscribeDetail || '订阅已到期', st.data.monthlyPrice); return; }
       // 二维码过期或上游瞬时异常:未提交验证码时自动重新生成二维码
-      if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); startLogin(); return; }
+      if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); startLogin(false); return; }
       if (codeSubmitted && checkErrors >= 10) {
         clearInterval(pollTimer);
-        showMsg('err', '登录确认异常。<br><button class="main" onclick="startLogin()">重新生成二维码</button>');
+        showMsg('err', '登录确认异常。<br><button class="main" onclick="startLogin(false)">重新生成二维码</button>');
         return;
       }
       if (el) el.textContent = '状态检测异常,重试中…';
       return;
     }
     checkErrors = 0;
-    if (s === '2') { clearInterval(pollTimer); renderSuccess(r.data.detail || {}); return; }
+    if (s === '2') {
+      clearInterval(pollTimer);
+      if (r.data.decisionRequired) { renderSeatDecision(r.data); return; }
+      renderSuccess(r.data.detail || {});
+      return;
+    }
     if (s === '10') {
       if (!codeSubmitted) { clearInterval(pollTimer); renderVerify(); return; }
       // 验证码已提交:不回退到输入页,继续等待登录确认,并用设备在线状态兜底
@@ -193,15 +241,72 @@ function poll() {
       if (el) el.textContent = '验证码已提交,正在确认登录…';
       if (verifyWaits >= 10) {
         clearInterval(pollTimer);
-        showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="startLogin()">重新生成二维码</button>');
+        showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="startLogin(false)">重新生成二维码</button>');
       }
       return;
     }
-    if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="startLogin()">重新生成二维码</button>'); return; }
+    if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="startLogin(false)">重新生成二维码</button>'); return; }
     if (el) el.textContent = s === '1' ? '已扫码,请在手机上确认…' : '等待扫码…(状态 ' + s + ')';
   }, 3000);
 }
 
+function renderSeatDecision(data) {
+  currentDecision = data;
+  subscriptionState = { seats: Number(data.seats) || 1, usedSeats: Number(data.usedSeats) || 0 };
+  const account = data.account || data.detail || {};
+  const accounts = Array.isArray(data.accounts) ? data.accounts : [];
+  setStep(3);
+  panel.innerHTML = '<div class="section-title">新账号已扫码,但当前客户订阅席位已满</div>' +
+    '<div class="account-row"><div class="account-meta"><strong>' + esc(account.nickname || account.userId || '新企微账号') + '</strong><span>' + esc(account.corpName || '') + '</span></div>' +
+    '<span>' + subscriptionState.usedSeats + '/' + subscriptionState.seats + ' 席</span></div>' +
+    '<div class="button-row"><button class="main" id="buy-seat">新增购买 1 个席位</button><button class="secondary danger" id="replace-seat">替换已有账号</button><button class="secondary" id="cancel-login">取消本次登录</button></div>' +
+    '<div id="decision-detail"></div>';
+  document.getElementById('buy-seat').onclick = showUpgradeQuote;
+  document.getElementById('replace-seat').onclick = () => renderReplaceChoice(accounts);
+  document.getElementById('cancel-login').onclick = cancelPendingLogin;
+}
+
+async function showUpgradeQuote() {
+  const detail = document.getElementById('decision-detail');
+  detail.innerHTML = '<div class="hint">正在获取实时增购报价…</div>';
+  const r = await api('/flow/upgrade-quote');
+  if (!r.ok) { detail.innerHTML = '<div class="msg err">' + esc(r.data.message || '报价查询失败') + '</div>'; return; }
+  const quote = r.data.quote || {};
+  detail.innerHTML = '<div class="msg ok">新增至 ' + esc(r.data.targetSeats) + ' 个席位,本次实时金额 ¥' + esc(quote.amount) + '。支付成功后自动继续当前登录。</div>' +
+    '<button class="main" id="confirm-upgrade">确认增购并扣费</button>';
+  document.getElementById('confirm-upgrade').onclick = confirmUpgrade;
+}
+
+async function confirmUpgrade() {
+  const btn = document.getElementById('confirm-upgrade');
+  if (btn) btn.disabled = true;
+  const idempotencyKey = 'seat-upgrade-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
+  const r = await api('/flow/upgrade', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ checkoutToken: CHECKOUT_TOKEN, idempotencyKey }) });
+  if (!r.ok) { showMsg('err', esc(r.data.message || '席位增购失败') + '<br>' + esc(r.data.tip || '')); if (btn) btn.disabled = false; return; }
+  renderSuccess(r.data.account || currentDecision.account || {});
+}
+
+function renderReplaceChoice(accounts) {
+  const detail = document.getElementById('decision-detail');
+  detail.innerHTML = '<div class="section-title">选择要释放的已有账号</div><select id="replace-account">' +
+    accounts.map(account => '<option value="' + esc(account.id) + '">' + esc(account.nickname || account.userId || '已绑定账号') + (account.corpName ? ' · ' + esc(account.corpName) : '') + '</option>').join('') +
+    '</select><button class="main" id="confirm-replace">确认替换并继续</button>';
+  document.getElementById('confirm-replace').onclick = confirmReplace;
+}
+
+async function confirmReplace() {
+  const replaceAccountId = document.getElementById('replace-account').value;
+  const r = await api('/flow/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ checkoutToken: CHECKOUT_TOKEN, action: 'replace', replaceAccountId }) });
+  if (!r.ok) { showMsg('err', esc(r.data.message || '账号替换失败')); return; }
+  renderSuccess(r.data.account || currentDecision.account || {});
+}
+
+async function cancelPendingLogin() {
+  await api('/flow/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ checkoutToken: CHECKOUT_TOKEN, action: 'cancel' }) });
+  clearInterval(pollTimer);
+  boot();
+}
+
 function renderVerify() {
   setStep(3);
   panel.innerHTML = '<div class="center"><div class="section-title">已扫码确认,请输入手机企业微信上显示的 6 位验证码</div>' +
@@ -237,7 +342,7 @@ function renderSuccess(detail) {
 async function boot() {
   const r = await api('/flow/state');
   if (r.data.online) { renderSuccess(r.data.detail || {}); return; }
-  if (r.data.subscribed) { startLogin(); return; }
+  if (r.data.subscribed) { renderAccountChooser(r.data); return; }
   if (r.data.stateError) {
     panel.innerHTML = '<div class="center"></div>';
     showMsg('err', '网络异常,暂时无法获取订阅状态(不会重复扣费)。<br><button class="main" onclick="boot()">重试</button>');
@@ -257,6 +362,7 @@ function buildFallbackPageHtml({ qrcodeUrl }) {
 <head>
 <meta charset="utf-8">
 <title>企业微信扫码登录</title>
+<link rel="icon" href="data:,">
 <style>${FLOW_PAGE_STYLE}</style>
 </head>
 <body>
@@ -409,6 +515,27 @@ async function readBody(req) {
   }
 }
 
+async function loadUpgradeQuote(ctx) {
+  const current = await callFmodeWecomGateway({
+    gatewayPath: '/subscribe/status',
+    httpMethod: 'GET',
+    token: ctx.token,
+    apiBase: ctx.apiBase,
+    cacheBust: true
+  });
+  const currentSeats = Number(current.data && current.data.seats) || 1;
+  const targetSeats = currentSeats + 1;
+  const quoted = await callFmodeWecomGateway({
+    gatewayPath: '/subscribe/status',
+    httpMethod: 'GET',
+    query: { seats: targetSeats, months: 1 },
+    token: ctx.token,
+    apiBase: ctx.apiBase,
+    cacheBust: true
+  });
+  return { currentSeats, targetSeats, quote: (quoted.data && quoted.data.quote) || {} };
+}
+
 function createFlowHandler(ctx) {
   return async (req, res) => {
     const url = new URL(req.url, 'http://localhost');
@@ -426,9 +553,12 @@ function createFlowHandler(ctx) {
         let detail = null;
         let stateError = false;
         let monthlyPrice = QIWEI_MONTHLY_PRICE;
+        let subscription = { seats: 1, usedSeats: 0 };
+        let accounts = [];
         try {
           const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase, cacheBust: true });
           subscribed = Boolean(sub.data && sub.data.subscribed);
+          subscription = sub.data || subscription;
           const livePrice = Number(sub.data && sub.data.price);
           if (Number.isFinite(livePrice) && livePrice > 0) monthlyPrice = livePrice;
           if (!subscribed) subscribeDetail = '尚未开通包月订阅';
@@ -438,14 +568,20 @@ function createFlowHandler(ctx) {
         }
         if (subscribed) {
           try {
-            const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid, ...(ctx.guid ? { guid: ctx.guid } : {}) }, token: ctx.token, apiBase: ctx.apiBase });
+            const accountResult = await callFmodeWecomGateway({ gatewayPath: '/login/accounts', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase, cacheBust: true });
+            accounts = Array.isArray(accountResult.data && accountResult.data.accounts) ? accountResult.data.accounts : [];
+          } catch {
+            accounts = [];
+          }
+          try {
+            const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.activeUid || ctx.uid, ...(ctx.guid ? { guid: ctx.guid } : {}) }, token: ctx.token, apiBase: ctx.apiBase });
             online = Boolean(status.data && status.data.online);
             detail = (status.data && status.data.detail) || null;
           } catch {
             online = false;
           }
         }
-        json(res, 200, { subscribed, subscribeDetail, online, detail, stateError, monthlyPrice });
+        json(res, 200, { subscribed, subscribeDetail, online, detail, stateError, monthlyPrice, subscription, accounts });
         return;
       }
       if (url.pathname === '/flow/subscribe' && req.method === 'POST') {
@@ -488,7 +624,9 @@ function createFlowHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/start-login' && req.method === 'POST') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
+        const body = await readBody(req);
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: loginBody(ctx.uid, ctx.guid, { newAccount: body.newAccount === true }), token: ctx.token, apiBase: ctx.apiBase });
+        ctx.activeUid = String((result.data && result.data.uid) || ctx.uid);
         const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
         if (!base64) {
           json(res, 502, subscribeErrorBody(502, '网关未返回二维码'));
@@ -509,9 +647,9 @@ function createFlowHandler(ctx) {
         return;
       }
       if (url.pathname === '/flow/check') {
-        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: loginBody(ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: loginBody(ctx.activeUid || ctx.uid, ctx.guid), token: ctx.token, apiBase: ctx.apiBase });
         const data = result.data || {};
-        json(res, 200, { status: data.status, detail: data.detail || {} });
+        json(res, 200, data);
         return;
       }
       if (url.pathname === '/flow/verify' && req.method === 'POST') {
@@ -521,10 +659,95 @@ function createFlowHandler(ctx) {
           json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
           return;
         }
-        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: loginBody(ctx.uid, ctx.guid, { code }), token: ctx.token, apiBase: ctx.apiBase });
+        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: loginBody(ctx.activeUid || ctx.uid, ctx.guid, { code }), token: ctx.token, apiBase: ctx.apiBase });
         json(res, 200, { ok: true });
         return;
       }
+      if (url.pathname === '/flow/select-account' && req.method === 'POST') {
+        const body = await readBody(req);
+        const accountId = String(body.accountId || '');
+        const selected = await callFmodeWecomGateway({
+          gatewayPath: '/login/select',
+          body: { uid: ctx.uid, accountId },
+          token: ctx.token,
+          apiBase: ctx.apiBase
+        });
+        ctx.activeUid = ctx.uid;
+        const status = await callFmodeWecomGateway({
+          gatewayPath: '/login/status',
+          httpMethod: 'GET',
+          query: { uid: ctx.uid },
+          token: ctx.token,
+          apiBase: ctx.apiBase
+        });
+        json(res, 200, {
+          selected: true,
+          accountId,
+          online: Boolean(status.data && status.data.online),
+          detail: (status.data && status.data.detail) || {},
+          account: (selected.data && selected.data.account) || null
+        });
+        return;
+      }
+      if (url.pathname === '/flow/upgrade-quote' && req.method === 'GET') {
+        json(res, 200, await loadUpgradeQuote(ctx));
+        return;
+      }
+      if (url.pathname === '/flow/upgrade' && req.method === 'POST') {
+        const body = await readBody(req);
+        if (!validCheckoutRequest(req, ctx, body)) {
+          json(res, 403, subscribeErrorBody(403, '本次付款确认无效或已使用'));
+          return;
+        }
+        const idempotencyKey = String(body.idempotencyKey || '');
+        if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) {
+          json(res, 400, subscribeErrorBody(400, '订单编号格式不正确'));
+          return;
+        }
+        const upgrade = await loadUpgradeQuote(ctx);
+        await callFmodeWecomGateway({
+          gatewayPath: '/subscribe',
+          body: {
+            seats: upgrade.targetSeats,
+            months: 1,
+            expectedAmount: Number(upgrade.quote.amount),
+            idempotencyKey
+          },
+          token: ctx.token,
+          apiBase: ctx.apiBase
+        });
+        const resolved = await callFmodeWecomGateway({
+          gatewayPath: '/login/resolve',
+          body: { uid: ctx.activeUid || ctx.uid, action: 'purchase' },
+          token: ctx.token,
+          apiBase: ctx.apiBase
+        });
+        ctx.checkoutToken = createCheckoutToken();
+        json(res, 200, resolved.data || { resolved: true });
+        return;
+      }
+      if (url.pathname === '/flow/resolve' && req.method === 'POST') {
+        const body = await readBody(req);
+        if (!validCheckoutRequest(req, ctx, body)) {
+          json(res, 403, subscribeErrorBody(403, '本次操作确认无效或已使用'));
+          return;
+        }
+        const action = String(body.action || '');
+        const result = await callFmodeWecomGateway({
+          gatewayPath: '/login/resolve',
+          body: {
+            uid: ctx.activeUid || ctx.uid,
+            action,
+            ...(body.replaceAccountId ? { replaceAccountId: String(body.replaceAccountId) } : {})
+          },
+          token: ctx.token,
+          apiBase: ctx.apiBase
+        });
+        ctx.checkoutToken = createCheckoutToken();
+        if (action === 'cancel') ctx.activeUid = ctx.uid;
+        json(res, 200, result.data || { resolved: true, action });
+        return;
+      }
       json(res, 404, { message: 'not found' });
     } catch (error) {
       const httpStatus = Number((error && error.httpStatus) || 502);
@@ -536,10 +759,10 @@ function createFlowHandler(ctx) {
 function startLoginFlowServer({ token, apiBase, uid, guid, port, onQrcode } = {}) {
   const listenPort = Number(port || process.env.QIWEI_FLOW_PORT) || DEFAULT_FLOW_PORT;
   if (currentServer && currentContext) {
-    Object.assign(currentContext, { token, apiBase, uid, guid, onQrcode, checkoutToken: createCheckoutToken() });
+    Object.assign(currentContext, { token, apiBase, uid, activeUid: uid, guid, onQrcode, checkoutToken: createCheckoutToken() });
     return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
   }
-  const ctx = { token, apiBase, uid, guid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
+  const ctx = { token, apiBase, uid, activeUid: uid, guid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
   const server = http.createServer(createFlowHandler(ctx));
   return new Promise((resolve, reject) => {
     server.once('error', reject);

+ 337 - 0
mcp/src/core/response-action-semantics.js

@@ -0,0 +1,337 @@
+'use strict';
+
+const RESPONSE_ACTION_TYPES = Object.freeze({
+  NONE: 'none',
+  CONSTRAINT_RESOLUTION: 'constraint_resolution',
+  IDENTITY_RESOLUTION: 'identity_resolution',
+  SCHEDULE_INTAKE: 'schedule_intake',
+  EXTERNAL_LOOKUP: 'external_lookup',
+  EXTERNAL_VERIFICATION: 'external_verification',
+  MEDIA_DELIVERY: 'media_delivery',
+  MEDIA_CAPTURE: 'media_capture',
+  SCHEDULE_VIEWING: 'schedule_viewing',
+  CONTACT_EXTERNAL: 'contact_external',
+});
+
+const RESPONSE_SPEECH_ACTS = Object.freeze({
+  NONE: 'none',
+  CRITERIA_APPLICATION: 'criteria_application',
+  INPUT_REQUEST: 'input_request',
+  CAPABILITY_OFFER: 'capability_offer',
+  SCHEDULED: 'scheduled',
+  IN_PROGRESS: 'in_progress',
+  COMPLETED: 'completed',
+});
+
+const ACTION_EFFECTS = Object.freeze({
+  NONE: 'none',
+  LOOKUP: 'lookup',
+  DELIVERY: 'delivery',
+  CONTACT: 'contact',
+  BOOKING: 'booking',
+  NEGOTIATION: 'negotiation',
+  MONITORING: 'monitoring',
+});
+
+const EVIDENCE_PHASES = Object.freeze({
+  NONE: 'none',
+  CAPABILITY: 'capability',
+  TASK_RECEIPT: 'task_receipt',
+  EXECUTION_RECEIPT: 'execution_receipt',
+});
+
+const CLAIM_TYPES = Object.freeze({
+  CUSTOMER_ECHO: 'customer_echo',
+  METHODOLOGY: 'methodology',
+  PROPERTY_DYNAMIC: 'property_dynamic',
+  POLICY_RISK: 'policy_risk',
+});
+
+const ACTION_METADATA = Object.freeze({
+  [RESPONSE_ACTION_TYPES.NONE]: Object.freeze({ executionRequired: false, evidenceRequired: false, legacyActionIds: [] }),
+  [RESPONSE_ACTION_TYPES.CONSTRAINT_RESOLUTION]: Object.freeze({ executionRequired: false, evidenceRequired: false, legacyActionIds: [] }),
+  [RESPONSE_ACTION_TYPES.IDENTITY_RESOLUTION]: Object.freeze({ executionRequired: false, evidenceRequired: false, legacyActionIds: [] }),
+  [RESPONSE_ACTION_TYPES.SCHEDULE_INTAKE]: Object.freeze({ executionRequired: false, evidenceRequired: false, legacyActionIds: [] }),
+  [RESPONSE_ACTION_TYPES.EXTERNAL_LOOKUP]: Object.freeze({ executionRequired: true, evidenceRequired: true, legacyActionIds: ['property_detail', 'select_property'] }),
+  [RESPONSE_ACTION_TYPES.EXTERNAL_VERIFICATION]: Object.freeze({ executionRequired: true, evidenceRequired: true, legacyActionIds: ['verify', 'property_detail'] }),
+  [RESPONSE_ACTION_TYPES.MEDIA_DELIVERY]: Object.freeze({ executionRequired: true, evidenceRequired: true, legacyActionIds: ['media_prepare', 'notify'] }),
+  [RESPONSE_ACTION_TYPES.MEDIA_CAPTURE]: Object.freeze({ executionRequired: true, evidenceRequired: true, legacyActionIds: ['media_prepare'] }),
+  [RESPONSE_ACTION_TYPES.SCHEDULE_VIEWING]: Object.freeze({ executionRequired: true, evidenceRequired: true, legacyActionIds: ['book'] }),
+  [RESPONSE_ACTION_TYPES.CONTACT_EXTERNAL]: Object.freeze({ executionRequired: true, evidenceRequired: true, legacyActionIds: ['callback', 'handoff', 'verify'] }),
+});
+
+const ACTION_PRIORITY = Object.freeze([
+  RESPONSE_ACTION_TYPES.CONTACT_EXTERNAL,
+  RESPONSE_ACTION_TYPES.SCHEDULE_VIEWING,
+  RESPONSE_ACTION_TYPES.MEDIA_CAPTURE,
+  RESPONSE_ACTION_TYPES.MEDIA_DELIVERY,
+  RESPONSE_ACTION_TYPES.EXTERNAL_VERIFICATION,
+  RESPONSE_ACTION_TYPES.EXTERNAL_LOOKUP,
+  RESPONSE_ACTION_TYPES.SCHEDULE_INTAKE,
+  RESPONSE_ACTION_TYPES.CONSTRAINT_RESOLUTION,
+  RESPONSE_ACTION_TYPES.IDENTITY_RESOLUTION,
+  RESPONSE_ACTION_TYPES.NONE,
+]);
+
+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 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}(?:一起|都)(?:先)?保留/;
+const CONSTRAINT_RESOLUTION_PATTERN = /(?:先|再|需要)?(?:确认|明确|补充|说清|定下).{0,12}(?:几房|房间数|面积|平方数|区域|片区|地段|预算|总价|楼层)|(?:您|你)(?:想看|考虑|需要|偏向|更看重|先定|主要看).{0,12}(?:几房|多大面积|哪个区域|哪个片区|什么地段|多少预算|总价|什么楼层)/;
+const EXECUTING_LOOKUP_PATTERN = /(?:我|我们|这边).{0,18}(?:查|查询|找|筛|筛选|挑|选|留意|整理|理一理|圈出)|(?:再|马上|这就|接下来).{0,8}(?:查|查询|找|筛|筛选|挑|选|留意|整理)/;
+const CAPABILITY_ASSERTION_PATTERN = /(?:我|我们|我这边|这边).{0,10}(?:都能看到|能看到|看得到|能查到|查得到|能调到|调得到|能找到|找得到|可以查|可以调).{0,18}(?:房源|房子|挂牌|成交|户型图|平面图|图片|照片|视频|媒体|资料)/;
+const COST_CALCULATION_PATTERN = /(?:(?:我|我们|这边)?(?:帮(?:您|你))?).{0,8}(?:把)?(?:账|税费|费用|总支出|首付|月供).{0,8}(?:算清楚|算明白|算一下|计算|核清|对清)|(?:算清楚|算明白|算一下|计算|核清|对清).{0,8}(?:账|税费|费用|总支出|首付|月供)/;
+const NEGATED_ACTION_PATTERN = /(?:(?:不|没|没有|没法|无法|不会|不能|尚不能|还不能|不再|暂不|先不|尚未|还没|并未|无需|不用).{0,14}(?:查|核|确认|安排|预约|约|联系|转达|转交|报价|出价|谈价|议价|筛|找|留意|发送|发给|给到|拍|录|通知|同步|算清|计算|看到|拿到|调到)|(?:不能|尚不能|还不能).{0,14}(?:当|作为|算作|视为))/;
+const NO_MATCH_BOUNDARY_PATTERN = /(?:没有|暂无|无|未有|未查到|查不到|没查到|不可用|不把.{0,8}(?:说成|当作)|不再说).{0,18}(?:房源|库存|结果|查到|找到|在售|匹配|可用)/;
+const CONDITIONAL_ACTION_PATTERN = /(?:如果|若|要是|等|待|收到|拿到|有了|发来|发我|提供|告诉).{0,36}(?:后|再|就|可以|能|好).{0,18}(?:查|核|确认|比较|筛|找|留意|看|给|回复|同步)|(?:发|提供|告诉).{0,24}(?:房源号|链接|截图|小区|学校|日期|时段|信息).{0,24}(?:我|这边)?.{0,10}(?:再|好|可以|能).{0,12}(?:查|核|确认|比较|筛|找|看|给|回复|同步)/;
+const COMPLETED_ACTION_PATTERN = /(?:已|已经|刚刚|刚才).{0,12}(?:查|核实|核验|核对|确认|联系|转达|转交|报价|出价|谈价|议价|安排|预约|约好|发送|发给|给到|拍|录|通知|同步|算清|计算|筛出|找出)/;
+const IN_PROGRESS_ACTION_PATTERN = /(?:正在|正(?:在)?|已经在|已开始|这就|马上|立即).{0,12}(?:查|核实|核验|核对|确认|联系|转达|转交|报价|出价|谈价|议价|安排|预约|发送|发给|拍|录|通知|同步|计算|筛|找)/;
+const EXPLICIT_SCHEDULE_PATTERN = /(?:会|将|稍后|晚点|随后|接下来|下一步|今天|今晚|明天|到时|我去|我来|我这就).{0,18}(?:查|核实|核验|核对|确认|联系|转达|转交|报价|出价|谈价|议价|安排|预约|约|发送|发给|给到|拍|录|通知|同步|算清|计算|筛出|找出)|(?:一有|有了).{0,12}(?:结果|准话|变化).{0,8}(?:通知|同步|告诉|回复)/;
+const CRITERIA_APPLICATION_PATTERN = /(?:按|照|沿用|保留|维持|保持|继续按|仍按).{0,24}(?:条件|预算|总价|区域|片区|地段|楼层|户型|面积|方向|范围|标准).{0,16}(?:筛|找|看|留意|取舍)?|(?:后面|后续|往下|继续|接下来).{0,10}(?:按.{0,16})?(?:筛|找|看|留意)|(?:把|将).{0,16}(?:安静|通勤|车位|预算|总价|区域|楼层|户型|面积|条件).{0,12}(?:前置|列为|当作|作为|放在前面|一起保留)|(?:筛房|找房|取舍).{0,12}(?:先看|优先看|重点看)|(?:帮(?:您|你))?(?:留意|圈着看)(?:合适的|房源)?/;
+const RESULT_ACTION_PATTERN = /(?:筛出|筛(?:几套|一套|两三套|三两套)|挑出|选出|找出|找到|定位到|准备好|备好|发给|发送给|给到|约好|安排好|确认好|算清|算明白|转达给|联系到|谈到|报\s*\d|报\d).{0,18}(?:\d|套|房源|户型图|图片|照片|视频|业主|房东|时间|结果|价格|账|税费)?/;
+const NEGOTIATION_EFFECT_PATTERN = /(?:报价|报个?价|报\s*\d+(?:\.\d+)?\s*(?:万|元)?|报\d+(?:\.\d+)?\s*(?:万|元)?|出价|还价|谈价|议价|谈到|让价|探(?:价|口风|业主|一下|一嘴)|往.{0,8}方向去探|转达给业主|转达给房东|少\s*\d+(?:\.\d+)?\s*(?:万|元))/;
+const CONTACT_EFFECT_PATTERN = /(?:联系|沟通|询问|问|转达|转交|回电|致电|打电话|探).{0,12}(?:业主|房东|物业|邻居|负责人)|(?:转达给|转交给)(?:业主|房东)/;
+const INPUT_REQUEST_PATTERN = /(?:请|麻烦|您|你|把|发|告诉|提供|补充|确认).{0,18}(?:房源号|房源编号|链接|截图|小区名|学校|日期|哪天|几点|时段|上午|下午|预算|区域|户型|面积|几房)/;
+const POLICY_RISK_PATTERN = /(?:学区|入学|划片|学校|户籍|落户|税费|总支出|产权|权属|合同|抵押|查封|贷款|利率|政策)/;
+const PROPERTY_DYNAMIC_PATTERN = /(?:挂牌|成交价|在售|房源状态|库存|房况|车位|停车|楼层|总层数|户型图|图片|照片|视频|业主心态|谈价空间)/;
+const CUSTOMER_ECHO_PATTERN = /(?:按|照|根据)(?:您|你)(?:说的|的)|(?:您|你)(?:刚才|已经|已)?(?:说|提|确认)|(?:记下|记住|先按|保留)(?:您|你)?(?:的)?(?:预算|区域|户型|楼层|条件|方向)/;
+const METHODOLOGY_PATTERN = /(?:一般|通常|思路|顺序|取舍|筛选|先.{0,12}再|重点|优先|作为条件|列为条件)/;
+const FIRST_PERSON_FUTURE_ACTION_PATTERN = /(?:我|我们|我这边|这边).{0,16}(?:先|会|将|再|马上|现在|接下来|下一步|去|来|好).{0,16}(?:查|核|确认|比较|筛|找|留意|整理|联系|转达|转交|报价|出价|谈价|议价|安排|预约|约|发送|发给|给到|拍|录|通知|同步|算清|计算|探)/;
+
+const POLICY_VERIFICATION_PATTERN = /(?:\u6838\u5b9e|\u6838\u9a8c|\u6838\u5bf9|\u67e5\u6e05|\u786e\u8ba4).{0,24}(?:\u5b66\u533a|\u5165\u5b66|\u5212\u7247|\u5b66\u6821|\u843d\u6237)/u;
+const FIRST_PERSON_VERIFICATION_PATTERN = /^(?:\u6211|\u6211\u4eec|\u6211\u8fd9\u8fb9|\u8fd9\u8fb9).{0,24}(?:\u6838\u5b9e|\u6838\u9a8c|\u6838\u5bf9|\u67e5\u6e05|\u786e\u8ba4)/u;
+const VERIFIED_COMPLETION_PATTERN = /(?:\u6211)?(?:\u5df2|\u5df2\u7ecf).{0,12}(?:\u6838\u5b9e|\u6838\u9a8c|\u6838\u5bf9|\u67e5\u6e05|\u786e\u8ba4)/u;
+
+function metadataForAction(type) {
+  return ACTION_METADATA[type] || ACTION_METADATA[RESPONSE_ACTION_TYPES.NONE];
+}
+
+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 ([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;
+  return ACTION_EFFECTS.NONE;
+}
+
+function inferClaimType(text) {
+  const value = String(text || '');
+  if (POLICY_RISK_PATTERN.test(value)) return CLAIM_TYPES.POLICY_RISK;
+  if (PROPERTY_DYNAMIC_PATTERN.test(value)) return CLAIM_TYPES.PROPERTY_DYNAMIC;
+  if (CUSTOMER_ECHO_PATTERN.test(value)) return CLAIM_TYPES.CUSTOMER_ECHO;
+  if (METHODOLOGY_PATTERN.test(value)) return CLAIM_TYPES.METHODOLOGY;
+  return '';
+}
+
+function inferSpeechAct(type, text) {
+  const value = String(text || '').trim();
+  const verifiedCompletion = VERIFIED_COMPLETION_PATTERN.test(value);
+  const negated = (NEGATED_ACTION_PATTERN.test(value) || NO_MATCH_BOUNDARY_PATTERN.test(value)) && !verifiedCompletion;
+  const conditional = CONDITIONAL_ACTION_PATTERN.test(value);
+  const concreteResult = RESULT_ACTION_PATTERN.test(value);
+  const explicitCompleted = COMPLETED_ACTION_PATTERN.test(value) || verifiedCompletion;
+  const explicitProgress = IN_PROGRESS_ACTION_PATTERN.test(value);
+  const explicitSchedule = EXPLICIT_SCHEDULE_PATTERN.test(value);
+  const inputRequest = INPUT_REQUEST_PATTERN.test(value)
+    && !FIRST_PERSON_VERIFICATION_PATTERN.test(value)
+    && !concreteResult
+    && !/(?:记下|记住|保留|沿用|维持|保持|按这个|按您给的条件|先定下来|范围看)/.test(value);
+
+  let speechAct = RESPONSE_SPEECH_ACTS.NONE;
+  if (negated && (!concreteResult || NO_MATCH_BOUNDARY_PATTERN.test(value))) {
+    speechAct = inputRequest ? RESPONSE_SPEECH_ACTS.INPUT_REQUEST : RESPONSE_SPEECH_ACTS.CRITERIA_APPLICATION;
+  } else if (explicitCompleted) speechAct = RESPONSE_SPEECH_ACTS.COMPLETED;
+  else if (concreteResult && !negated && !conditional) speechAct = RESPONSE_SPEECH_ACTS.SCHEDULED;
+  else if (type === RESPONSE_ACTION_TYPES.CONTACT_EXTERNAL && !negated) speechAct = RESPONSE_SPEECH_ACTS.SCHEDULED;
+  else if (type === RESPONSE_ACTION_TYPES.SCHEDULE_VIEWING && !negated) speechAct = RESPONSE_SPEECH_ACTS.SCHEDULED;
+  else if (explicitProgress && !negated) speechAct = RESPONSE_SPEECH_ACTS.IN_PROGRESS;
+  else if ((explicitSchedule || FIRST_PERSON_VERIFICATION_PATTERN.test(value)) && !negated) {
+    speechAct = RESPONSE_SPEECH_ACTS.SCHEDULED;
+  }
+  else if (inputRequest) speechAct = RESPONSE_SPEECH_ACTS.INPUT_REQUEST;
+  else if (CAPABILITY_ASSERTION_PATTERN.test(value) || conditional) speechAct = RESPONSE_SPEECH_ACTS.CAPABILITY_OFFER;
+  else if (CRITERIA_APPLICATION_PATTERN.test(value) || (type === RESPONSE_ACTION_TYPES.EXTERNAL_LOOKUP && !concreteResult && !negated && !FIRST_PERSON_FUTURE_ACTION_PATTERN.test(value))) {
+    speechAct = RESPONSE_SPEECH_ACTS.CRITERIA_APPLICATION;
+  }
+
+  if (speechAct === RESPONSE_SPEECH_ACTS.NONE
+    && !negated
+    && !conditional
+    && FIRST_PERSON_FUTURE_ACTION_PATTERN.test(value)
+    && type !== RESPONSE_ACTION_TYPES.IDENTITY_RESOLUTION) {
+    speechAct = RESPONSE_SPEECH_ACTS.SCHEDULED;
+  }
+
+  // A negated action describes the current boundary, rather than an execution.
+  if (negated && speechAct === RESPONSE_SPEECH_ACTS.NONE) {
+    speechAct = inputRequest ? RESPONSE_SPEECH_ACTS.INPUT_REQUEST : RESPONSE_SPEECH_ACTS.CRITERIA_APPLICATION;
+  }
+
+  let evidencePhase = EVIDENCE_PHASES.NONE;
+  if (speechAct === RESPONSE_SPEECH_ACTS.CAPABILITY_OFFER) evidencePhase = EVIDENCE_PHASES.CAPABILITY;
+  else if (speechAct === RESPONSE_SPEECH_ACTS.SCHEDULED) evidencePhase = EVIDENCE_PHASES.TASK_RECEIPT;
+  else if ([RESPONSE_SPEECH_ACTS.IN_PROGRESS, RESPONSE_SPEECH_ACTS.COMPLETED].includes(speechAct)) evidencePhase = EVIDENCE_PHASES.EXECUTION_RECEIPT;
+
+  return {
+    speechAct,
+    evidencePhase,
+    effect: inferEffect(type, value),
+    claimType: inferClaimType(value),
+    supportRequirement: speechAct === RESPONSE_SPEECH_ACTS.SCHEDULED
+      ? EVIDENCE_PHASES.TASK_RECEIPT
+      : [RESPONSE_SPEECH_ACTS.IN_PROGRESS, RESPONSE_SPEECH_ACTS.COMPLETED].includes(speechAct)
+        ? EVIDENCE_PHASES.EXECUTION_RECEIPT
+        : speechAct === RESPONSE_SPEECH_ACTS.CAPABILITY_OFFER
+          ? EVIDENCE_PHASES.CAPABILITY
+          : EVIDENCE_PHASES.NONE,
+    negated,
+    conditional,
+    handled: [RESPONSE_SPEECH_ACTS.CRITERIA_APPLICATION, RESPONSE_SPEECH_ACTS.INPUT_REQUEST].includes(speechAct),
+  };
+}
+
+function actionItem(type, text, sentenceIndex = 0, options = {}) {
+  const metadata = metadataForAction(type);
+  const structured = inferSpeechAct(type, text);
+  const structuredNonExecuting = options.semanticMode === 'v24.1'
+    && (structured.handled || structured.speechAct === RESPONSE_SPEECH_ACTS.CAPABILITY_OFFER);
+  return {
+    type,
+    text: String(text || '').trim(),
+    sentenceIndex,
+    executionRequired: structuredNonExecuting ? false : metadata.executionRequired,
+    evidenceRequired: structuredNonExecuting ? false : metadata.evidenceRequired,
+    legacyActionIds: [...metadata.legacyActionIds],
+    claimMode: CAPABILITY_ASSERTION_PATTERN.test(String(text || '')) ? 'capability' : type === RESPONSE_ACTION_TYPES.NONE ? 'none' : 'action',
+    ...structured,
+  };
+}
+
+function classifySentence(text = '', options = {}) {
+  const sentence = String(text || '').trim();
+  if (!sentence) return [actionItem(RESPONSE_ACTION_TYPES.NONE, sentence, options.sentenceIndex, options)];
+  const actions = [];
+  const hasIdentity = PROPERTY_IDENTITY_PATTERN.test(sentence) && IDENTITY_RESOLUTION_PATTERN.test(sentence);
+  const hasMedia = MEDIA_PATTERN.test(sentence) || MEDIA_LOOKUP_PATTERN.test(sentence) || Boolean(options.mediaContext);
+  const safeViewingProposal = SAFE_VIEWING_PROPOSAL_PATTERN.test(sentence);
+  const hasMediaCapture = hasMedia && MEDIA_CAPTURE_PATTERN.test(sentence);
+  const hasMediaLookup = hasMedia && MEDIA_LOOKUP_PATTERN.test(sentence);
+  const hasMediaDelivery = hasMedia && (MEDIA_DELIVERY_PATTERN.test(sentence)
+    || (hasMediaLookup && /给(?:您|你)/.test(sentence)));
+  const hasConstraintResolution = CONSTRAINT_RESOLUTION_PATTERN.test(sentence);
+  const hasScheduleIntake = SCHEDULE_INTAKE_PATTERN.test(sentence);
+  const hasNegatedSchedule = /(?:不|没|没有|没法|无法|不会|不再|暂不|先不|尚未|还没|并未|无需|不用).{0,14}(?:安排|预约|约(?:看|房)?|排期|带看)/.test(sentence);
+  const customerEcho = CUSTOMER_ECHO_PATTERN.test(sentence)
+    || /(?:是|为)(?:您|你)(?:的)?(?:明确)?(?:报价|出价|预算|条件)/.test(sentence);
+  const localOrganization = /(?:整理|理清|梳理).{0,12}(?:价格|付款方式|有效时间|报价信息|条件)/.test(sentence)
+    || /(?:价格|付款方式|有效时间|报价信息|条件).{0,12}(?:整理|理清|梳理)/.test(sentence);
+
+  if ((CONTACT_EXTERNAL_PATTERN.test(sentence) || NEGOTIATION_EFFECT_PATTERN.test(sentence))
+    && (!customerEcho || /(?:转达|联系|沟通|询问|跟|向).{0,10}(?:业主|房东)/.test(sentence))) {
+    actions.push(RESPONSE_ACTION_TYPES.CONTACT_EXTERNAL);
+  }
+  if (!safeViewingProposal && !hasNegatedSchedule && SCHEDULE_VIEWING_PATTERN.test(sentence)) actions.push(RESPONSE_ACTION_TYPES.SCHEDULE_VIEWING);
+  if (hasMediaCapture) actions.push(RESPONSE_ACTION_TYPES.MEDIA_CAPTURE);
+  if (hasMediaDelivery) actions.push(RESPONSE_ACTION_TYPES.MEDIA_DELIVERY);
+  if (hasMediaLookup && !hasMediaDelivery) actions.push(RESPONSE_ACTION_TYPES.EXTERNAL_LOOKUP);
+  if (CAPABILITY_ASSERTION_PATTERN.test(sentence)) actions.push(RESPONSE_ACTION_TYPES.EXTERNAL_LOOKUP);
+  if (COST_CALCULATION_PATTERN.test(sentence)) actions.push(RESPONSE_ACTION_TYPES.EXTERNAL_VERIFICATION);
+  if (EXTERNAL_VERIFICATION_PATTERN.test(sentence) || POLICY_VERIFICATION_PATTERN.test(sentence)) {
+    actions.push(RESPONSE_ACTION_TYPES.EXTERNAL_VERIFICATION);
+  }
+  if (!hasMediaCapture && !hasMediaDelivery
+    && (!NON_EXECUTING_SELECTION_PATTERN.test(sentence) || EXECUTING_LOOKUP_PATTERN.test(sentence))
+    && EXTERNAL_LOOKUP_PATTERN.test(sentence)
+    && !localOrganization) actions.push(RESPONSE_ACTION_TYPES.EXTERNAL_LOOKUP);
+  if (hasConstraintResolution) actions.push(RESPONSE_ACTION_TYPES.CONSTRAINT_RESOLUTION);
+  if (hasIdentity) actions.push(RESPONSE_ACTION_TYPES.IDENTITY_RESOLUTION);
+  if (hasScheduleIntake) actions.push(RESPONSE_ACTION_TYPES.SCHEDULE_INTAKE);
+
+  const unique = [...new Set(actions)];
+  if (!unique.length) unique.push(RESPONSE_ACTION_TYPES.NONE);
+  return unique.map(type => actionItem(type, sentence, options.sentenceIndex, options));
+}
+
+function splitSentences(text = '') {
+  return String(text || '')
+    .split(/[。!?!?;;\n]+/)
+    .map(value => value.trim())
+    .filter(Boolean);
+}
+
+function classifyResponseActions(text = '', options = {}) {
+  const selectionHandled = NON_EXECUTING_SELECTION_PATTERN.test(String(text || ''));
+  const constraintHandled = CONSTRAINT_RESOLUTION_PATTERN.test(String(text || ''));
+  const scheduleIntakeHandled = SCHEDULE_INTAKE_PATTERN.test(String(text || ''));
+  const sentences = splitSentences(text);
+  const actions = sentences.flatMap((sentence, sentenceIndex) => classifySentence(sentence, {
+    ...options,
+    sentenceIndex,
+    mediaContext: Boolean(options.mediaContext) || MEDIA_PATTERN.test(sentence),
+  }));
+  const meaningful = actions.filter(item => item.type !== RESPONSE_ACTION_TYPES.NONE);
+  const effective = meaningful.length ? meaningful : [actionItem(RESPONSE_ACTION_TYPES.NONE, String(text || ''), 0, options)];
+  const primaryType = ACTION_PRIORITY.find(type => effective.some(item => item.type === type)) || RESPONSE_ACTION_TYPES.NONE;
+  return {
+    version: '1.3',
+    primaryType,
+    actions: effective,
+    executionRequired: effective.some(item => item.executionRequired),
+    evidenceRequired: effective.some(item => item.evidenceRequired),
+    speechActs: [...new Set(effective.map(item => item.speechAct))],
+    evidencePhases: [...new Set(effective.map(item => item.evidencePhase))],
+    effects: [...new Set(effective.map(item => item.effect))],
+    claimTypes: [...new Set(effective.map(item => item.claimType).filter(Boolean))],
+    supportRequirement: effective.some(item => item.speechAct === RESPONSE_SPEECH_ACTS.SCHEDULED)
+      ? EVIDENCE_PHASES.TASK_RECEIPT
+      : effective.some(item => item.evidencePhase === EVIDENCE_PHASES.EXECUTION_RECEIPT)
+        ? EVIDENCE_PHASES.EXECUTION_RECEIPT
+        : effective.some(item => item.evidencePhase === EVIDENCE_PHASES.CAPABILITY)
+          ? EVIDENCE_PHASES.CAPABILITY
+          : EVIDENCE_PHASES.NONE,
+    identityOnly: effective.every(item => item.type === RESPONSE_ACTION_TYPES.IDENTITY_RESOLUTION),
+    handled: selectionHandled || constraintHandled || scheduleIntakeHandled,
+    nonExecuting: (selectionHandled || constraintHandled || scheduleIntakeHandled) && !effective.some(item => item.executionRequired),
+    suppressedLegacyActions: [
+      ...(selectionHandled ? ['reserve', 'select_property'] : []),
+      ...(constraintHandled ? ['verify'] : []),
+      ...(scheduleIntakeHandled ? ['book', 'verify'] : []),
+    ],
+  };
+}
+
+function preferredLegacyActionId(action = {}) {
+  const text = String(action.text || '');
+  if (action.type === RESPONSE_ACTION_TYPES.CONTACT_EXTERNAL) {
+    if (/转人工|转交|转给|移交|升级/.test(text)) return 'handoff';
+    if (/回电|致电|电话|联系(?:您|你)/.test(text)) return 'callback';
+    return 'verify';
+  }
+  return action.legacyActionIds?.[0] || '';
+}
+
+module.exports = {
+  ACTION_EFFECTS,
+  ACTION_METADATA,
+  CLAIM_TYPES,
+  EVIDENCE_PHASES,
+  RESPONSE_SPEECH_ACTS,
+  RESPONSE_ACTION_TYPES,
+  classifyResponseActions,
+  classifySentence,
+  metadataForAction,
+  preferredLegacyActionId,
+};

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

@@ -0,0 +1,287 @@
+'use strict';
+
+const FUTURE_PROMISE_REASON_CODES = Object.freeze({
+  NO_ACTION_CLAIMS: 'no_action_claims',
+  SAFE_CONDITIONAL_OFFER: 'safe_conditional_offer',
+  SAFE_MISSING_INPUT_REQUEST: 'safe_missing_input_request',
+  FUTURE_ACTION_VERIFIED: 'future_action_verified',
+  PERFORMED_ACTION_VERIFIED: 'performed_action_verified',
+  IN_PROGRESS_ACTION_VERIFIED: 'in_progress_action_verified',
+  FUTURE_ACTION_MISSING_EVIDENCE: 'future_action_missing_evidence',
+  PERFORMED_ACTION_MISSING_EVIDENCE: 'performed_action_missing_evidence',
+  IN_PROGRESS_ACTION_MISSING_EVIDENCE: 'in_progress_action_missing_evidence',
+  ACTION_EVIDENCE_FAILED: 'action_evidence_failed',
+  ACTION_EVIDENCE_UNRELATED: 'action_evidence_unrelated',
+});
+
+const ACTION_RULES = Object.freeze([
+  { id: 'handoff', claim: /(?:转人工|转交|转给|转至|移交|升级|接管|安排.{0,6}(?:专员|顾问|主管|负责人|人工))/, evidence: /(?:handoff|transfer|escalat|assign.?human|human.?agent|转人工|转交|移交|升级|接管)/i },
+  { 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: '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 },
+  { id: 'process', claim: /(?:处理|跟进|推进|办理|关注|解决)/, evidence: /(?:process|follow.?up|workflow|handle|resolve|task|处理|跟进|推进|办理|解决)/i },
+]);
+
+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: '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 },
+  { id: 'refund', claim: /(?:退款|退费|返款)/, evidence: /(?:退款|退费|返款|refund|repay)/i },
+  { id: 'contact', claim: /(?:电话|回电|手机号|联系人)/, evidence: /(?:电话|回电|手机号|联系人|phone|call|mobile|contact)/i },
+]);
+
+const COMPLETED_CUE = /(?:已|已经|刚刚|刚才|已成功|已经成功).{0,8}(?:核实|核验|查验|查询|查看|检查|确认|通知|同步|告知|回复|答复|反馈|回信|回电|致电|电话联系|转人工|转交|转给|转至|移交|升级|接管|安排|退款|退费|返款|取消|撤销|关闭|预约|预订|预定|排期|保留|锁定|预留|提交|登记|报备|发起|创建|处理|跟进|推进|办理|解决)/;
+const IN_PROGRESS_CUE = /(?:正在|正为您|已经在|已开始|处理中|跟进中|办理中).{0,8}(?:核实|核验|查验|查询|查看|检查|确认|通知|同步|告知|回复|答复|反馈|回信|回电|致电|电话联系|转人工|转交|转给|转至|移交|升级|接管|安排|退款|退费|返款|取消|撤销|关闭|预约|预订|预定|排期|保留|锁定|预留|提交|登记|报备|发起|创建|处理|跟进|推进|办理|解决)|(?:处理|跟进|办理)中/;
+const FUTURE_CUE = /(?:^|(?:我|我们|这边|客服|专员|顾问|主管|负责人|人工|系统|平台))\s*(?:会|将|再|稍后|晚点|随后|之后|过会儿|马上|尽快|今天内|明天|我去|我们去)/;
+const ACTION_AFTER_CUE = /(?:核实|核验|查询|确认|处理|跟进|提交|转交|安排|办理)后.{0,16}(?:会|将|再|通知|同步|回复|答复|反馈|回电)/;
+const CONDITIONAL_OFFER = /(?:如果|如需|若|要是).{0,18}(?:需要|愿意|同意|方便|确认)|(?:是否|要不要|需不需要|需要我|要我|可否).{0,24}(?:核实|核验|查询|确认|通知|回电|转交|安排|处理|提交)|(?:我|我们|这边)?\s*(?:可以|可|能够|能).{0,18}(?:核实|核验|查询|确认|通知|回电|转交|安排|处理|提交).{0,18}(?:吗|呢|是否|要不要|需要)/;
+const MISSING_INPUT_REQUEST = /(?:请|麻烦|需要您|您先|等您|收到您).{0,28}(?:提供|告知|告诉|发送|发我|补充|确认|选择|回复).{0,40}(?:后|以后|之后|再)|(?:您|客户).{0,18}(?:提供|发送|发来|发我|补充|告知|确认|选择).{0,40}(?:后|以后|之后)/;
+const SAFE_NECESSITY = /(?:^|[,,])\s*(?:当前|目前|暂时|现阶段)?\s*(?:还|尚)?(?:需要|需|待|建议|最好|应当|必须).{0,8}(?:核实|核验|查询|确认|人工处理|转人工)/;
+const DECLARATIVE_VERIFICATION_NEED = /(?:需要|待)(?:由)?(?:人工|专员|顾问|主管|负责人)?(?:核验|核实|核对|确认|复核|查询|检查)/;
+const FIRST_PERSON_FUTURE_ACTION = /(?:我|我们|这边).{0,8}(?:会|将|稍后|晚点|随后|马上|尽快|现在|去)(?:.{0,12})(?:核验|核实|核对|确认|复核|查询|检查|回复|通知|转交|处理)/;
+const FIRST_PERSON_ACTIVE_ACTION = /(?:我|我们|这边).{0,8}(?:现在|正在|马上|立即|已开始).{0,12}(?:核验|核实|核对|确认|复核|查询|检查|回复|通知|回电|转交|转人工|处理|跟进|提交|退款|取消|预约|预订|保留)/;
+const DECLARATIVE_CONFIRMED_FACT = /^(?:当前)?(?:已确认|经核验|核验结果).{0,30}(?:\d|价格|费用|库存|名额|档期|日期|时间|资格|政策|产权|权属|税费|利率)/;
+const FAILURE_STATUS = /^(?:fail|failed|failure|error|errored|rejected|cancelled|canceled|timeout|timed_out)$/i;
+const SUCCESS_STATUS = /^(?:ok|success|succeeded|complete|completed|done|sent|delivered|processed|accepted|created|started|in_progress)$/i;
+
+function makeCheck(id, passed, detail, hard = true) {
+  return { id, passed: Boolean(passed), weight: 1, hard: Boolean(hard), detail: String(detail || '') };
+}
+
+function asArray(value) {
+  if (value === null || value === undefined) return [];
+  return Array.isArray(value) ? value : [value];
+}
+
+function compactText(value) {
+  if (value === null || value === undefined) return '';
+  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
+  try {
+    return JSON.stringify(value);
+  } catch {
+    return '';
+  }
+}
+
+function explicitSuccess(value) {
+  if (!value || typeof value !== 'object') return false;
+  if (value.isError === true || value.error || FAILURE_STATUS.test(String(value.status || ''))) return false;
+  if (value.success === true || value.ok === true || value.delivered === true || value.sent === true || value.isSendSuccess === true) return true;
+  if (SUCCESS_STATUS.test(String(value.status || ''))) return true;
+  if (value.result && typeof value.result === 'object') return explicitSuccess(value.result);
+  return false;
+}
+
+function actionIdsFromText(value = '') {
+  const text = String(value || '');
+  return ACTION_RULES.filter(rule => rule.evidence.test(text)).map(rule => rule.id);
+}
+
+function normalizeActionId(value = '') {
+  const normalized = String(value || '').trim().toLowerCase();
+  if (!normalized) return '';
+  if (ACTION_RULES.some(rule => rule.id === normalized)) return normalized;
+  return actionIdsFromText(normalized)[0] || '';
+}
+
+function evidenceRef(source, index, actionIds, item = {}) {
+  return {
+    source,
+    index,
+    actionTypes: [...new Set(actionIds)],
+    name: String(item.tool || item.name || item.type || item.action || source),
+  };
+}
+
+function normalizeEvidenceItem(item, source, index, forcedAction) {
+  if (!item || typeof item !== 'object') return null;
+  const name = [item.type, item.action, item.category, item.tool, item.name].filter(Boolean).join(' ');
+  const explicitAction = normalizeActionId(forcedAction || item.actionType || item.action || item.type || item.category);
+  const actionIds = [...new Set([explicitAction, ...actionIdsFromText(name)].filter(Boolean))];
+  return {
+    source,
+    success: explicitSuccess(item),
+    actionIds,
+    searchable: compactText({
+      action: item.action,
+      type: item.type,
+      tool: item.tool,
+      name: item.name,
+      input: item.input,
+      args: item.args,
+      parameters: item.parameters,
+      result: item.result,
+      subject: item.subject,
+      scope: item.scope,
+    }),
+    ref: evidenceRef(source, index, actionIds, item),
+  };
+}
+
+function normalizeActionEvidence(input = {}) {
+  const evidence = [];
+  const append = (value, source, forcedAction) => {
+    asArray(value).forEach((item, index) => {
+      const normalized = normalizeEvidenceItem(item, source, index, forcedAction);
+      if (normalized) evidence.push(normalized);
+    });
+  };
+  append(input.toolTrace, 'tool', '');
+  append(input.evidence, 'evidence', '');
+  append(input.handoffEvidence, 'handoff', 'handoff');
+  append(input.outboundEvidence, 'outbound', 'notify');
+  return evidence;
+}
+
+function splitReply(reply = '') {
+  const result = [];
+  String(reply || '')
+    .split(/[。!?!?;;\n]+/)
+    .map(sentence => sentence.trim())
+    .filter(Boolean)
+    .forEach((sentence, sentenceIndex) => {
+      sentence.split(/[,,]+/).map(clause => clause.trim()).filter(Boolean).forEach((clause, clauseIndex) => {
+        result.push({ sentence, sentenceIndex, clause, clauseIndex });
+      });
+    });
+  return result;
+}
+
+function claimKind(clause = '', sentence = '') {
+  const value = String(clause || '').trim();
+  if (FIRST_PERSON_ACTIVE_ACTION.test(value)) return 'in_progress';
+  if (IN_PROGRESS_CUE.test(value)) return 'in_progress';
+  if (COMPLETED_CUE.test(value)) return 'completed';
+  if (FUTURE_CUE.test(value) || ACTION_AFTER_CUE.test(sentence)) return 'future';
+  return '';
+}
+
+function safeReason(sentence = '', clause = '') {
+  const value = `${sentence} ${clause}`;
+  if (CONDITIONAL_OFFER.test(value)) return FUTURE_PROMISE_REASON_CODES.SAFE_CONDITIONAL_OFFER;
+  if (MISSING_INPUT_REQUEST.test(value)) return FUTURE_PROMISE_REASON_CODES.SAFE_MISSING_INPUT_REQUEST;
+  return '';
+}
+
+function claimActionIds(clause = '') {
+  return ACTION_RULES.filter(rule => rule.claim.test(clause)).map(rule => rule.id);
+}
+
+function scopeRequirements(claim = '') {
+  const numbers = String(claim || '').match(/\d+(?:\.\d+)?/g) || [];
+  const scopes = SCOPE_RULES.filter(rule => rule.claim.test(claim));
+  return { numbers: [...new Set(numbers)], scopes };
+}
+
+function evidenceCoversClaim(evidence, claim = '') {
+  const { numbers, scopes } = scopeRequirements(claim);
+  const searchable = String(evidence.searchable || '');
+  if (!numbers.every(number => searchable.includes(number))) return false;
+  return scopes.every(scope => scope.evidence.test(searchable));
+}
+
+function missingReason(kind) {
+  if (kind === 'completed') return FUTURE_PROMISE_REASON_CODES.PERFORMED_ACTION_MISSING_EVIDENCE;
+  if (kind === 'in_progress') return FUTURE_PROMISE_REASON_CODES.IN_PROGRESS_ACTION_MISSING_EVIDENCE;
+  return FUTURE_PROMISE_REASON_CODES.FUTURE_ACTION_MISSING_EVIDENCE;
+}
+
+function verifiedReason(kind) {
+  if (kind === 'completed') return FUTURE_PROMISE_REASON_CODES.PERFORMED_ACTION_VERIFIED;
+  if (kind === 'in_progress') return FUTURE_PROMISE_REASON_CODES.IN_PROGRESS_ACTION_VERIFIED;
+  return FUTURE_PROMISE_REASON_CODES.FUTURE_ACTION_VERIFIED;
+}
+
+function assessClaim(claim, evidence) {
+  if (!claim.requiresEvidence) return claim;
+  const sameAction = evidence.filter(item => item.actionIds.includes(claim.action));
+  const successful = sameAction.filter(item => item.success);
+  const matched = successful.filter(item => evidenceCoversClaim(item, claim.text));
+  if (matched.length) {
+    return {
+      ...claim,
+      evidenceMatched: true,
+      evidenceRefs: matched.map(item => item.ref),
+      reasonCode: verifiedReason(claim.kind),
+    };
+  }
+  if (sameAction.some(item => !item.success)) {
+    return { ...claim, reasonCode: FUTURE_PROMISE_REASON_CODES.ACTION_EVIDENCE_FAILED };
+  }
+  if (evidence.length > 0) {
+    return { ...claim, reasonCode: FUTURE_PROMISE_REASON_CODES.ACTION_EVIDENCE_UNRELATED };
+  }
+  return { ...claim, reasonCode: missingReason(claim.kind) };
+}
+
+function extractActionClaims(reply = '') {
+  const claims = [];
+  for (const part of splitReply(reply)) {
+    if (DECLARATIVE_CONFIRMED_FACT.test(part.clause) && !/(?:我|我们|这边)/.test(part.clause)) continue;
+    const safe = safeReason(part.sentence, part.clause);
+    const kind = claimKind(part.clause, part.sentence) || (safe ? 'future' : '');
+    const declarativeNeed = DECLARATIVE_VERIFICATION_NEED.test(part.clause)
+      && !FIRST_PERSON_FUTURE_ACTION.test(part.clause);
+    if (!kind || SAFE_NECESSITY.test(part.clause) || declarativeNeed) continue;
+    for (const action of claimActionIds(part.clause)) {
+      claims.push({
+        id: `claim_${claims.length + 1}`,
+        sentenceIndex: part.sentenceIndex,
+        clauseIndex: part.clauseIndex,
+        text: part.clause,
+        kind,
+        action,
+        conditional: Boolean(safe),
+        requiresEvidence: !safe,
+        evidenceMatched: false,
+        evidenceRefs: [],
+        reasonCode: safe || '',
+      });
+    }
+  }
+  return claims;
+}
+
+function verifyFuturePromises(input = {}) {
+  const reply = String(input.reply ?? input.content ?? '').trim();
+  const evidence = normalizeActionEvidence(input);
+  const claims = extractActionClaims(reply).map(claim => assessClaim(claim, evidence));
+  const futureClaims = claims.filter(claim => claim.kind === 'future' && claim.requiresEvidence);
+  const performedClaims = claims.filter(claim => claim.kind !== 'future' && claim.requiresEvidence);
+  const conditionalClaims = claims.filter(claim => !claim.requiresEvidence);
+  const futurePassed = futureClaims.every(claim => claim.evidenceMatched);
+  const performedPassed = performedClaims.every(claim => claim.evidenceMatched);
+  const checks = [
+    makeCheck('future_promise_evidence', futurePassed, `${futureClaims.filter(claim => claim.evidenceMatched).length}/${futureClaims.length} 个未来动作声明有同类型成功证据`),
+    makeCheck('performed_action_evidence', performedPassed, `${performedClaims.filter(claim => claim.evidenceMatched).length}/${performedClaims.length} 个已执行或处理中声明有同类型成功证据`),
+    makeCheck('conditional_proposal_safety', true, `${conditionalClaims.length} 个条件提议或缺失信息请求无需执行证据`, false),
+  ];
+  const failedClaimIds = claims.filter(claim => claim.requiresEvidence && !claim.evidenceMatched).map(claim => claim.id);
+  const reasonCodes = claims.length
+    ? [...new Set(claims.map(claim => claim.reasonCode).filter(Boolean))]
+    : [FUTURE_PROMISE_REASON_CODES.NO_ACTION_CLAIMS];
+  return {
+    version: '1.0',
+    passed: checks.every(check => check.passed || !check.hard),
+    checks,
+    claims,
+    failedClaimIds,
+    reasonCodes,
+  };
+}
+
+module.exports = {
+  FUTURE_PROMISE_REASON_CODES,
+  extractActionClaims,
+  normalizeActionEvidence,
+  verifyFuturePromises,
+};

+ 105 - 0
mcp/src/core/response-generation-strategy.js

@@ -0,0 +1,105 @@
+'use strict';
+
+const ANSWER_MODE_BY_MOVE = Object.freeze({
+  answer: 'direct_resolution',
+  acknowledge_objection: 'objection_resolution',
+  clarify: 'direction_before_clarification',
+  recommend: 'recommendation_with_tradeoff',
+  advance: 'stage_resolution',
+  handoff: 'bounded_risk_answer',
+});
+
+function clip(value, limit = 360) {
+  return String(value ?? '').trim().replace(/\s+/g, ' ').slice(0, limit);
+}
+
+function hasGroundingEvidence(input = {}) {
+  if (Array.isArray(input.citations) && input.citations.length > 0) return true;
+  if ((input.toolTrace || []).some(item => {
+    const tool = String(item?.tool || '');
+    return tool && !/^(?:response_|sales_move_planner$|claude_code_session$)/.test(tool);
+  })) return true;
+  const profile = input.profile?.profile || input.profile || {};
+  return Boolean(profile.__evidence && Object.keys(profile.__evidence).length > 0);
+}
+
+function buildResponseGenerationStrategy(input = {}) {
+  const responsePlan = input.responsePlan || {};
+  const salesMovePlan = input.salesMovePlan || {};
+  const shouldReply = responsePlan.shouldReply !== false;
+  const answerMode = ANSWER_MODE_BY_MOVE[salesMovePlan.primaryMove] || 'direct_resolution';
+  const evidenceAvailable = hasGroundingEvidence(input);
+  const evidenceLimited = Boolean(responsePlan.requireEvidence && !evidenceAvailable);
+  const plannedNextStep = salesMovePlan.singleNextStep || null;
+
+  return {
+    version: '1.0',
+    customerQuestion: {
+      source: 'current_inbound',
+      text: clip(input.inboundContent || responsePlan.customerQuestion),
+      resolutionGoal: 'resolve_the_current_decision_before_collecting_more_fields',
+    },
+    directAnswer: {
+      required: shouldReply,
+      position: 'opening',
+      mode: answerMode,
+      answerBeforeQuestion: true,
+      evidenceBoundary: evidenceLimited ? 'direction_with_explicit_unknowns' : 'supported_conclusion_or_direction',
+    },
+    judgment: {
+      required: shouldReply,
+      mode: 'bounded_tradeoff',
+      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.'
+        : 'Connect supported facts to one customer-relevant preference, tradeoff, or reason.',
+    },
+    singleNextStep: {
+      required: Boolean(shouldReply && plannedNextStep && plannedNextStep.optional !== true),
+      maxCount: shouldReply ? 1 : 0,
+      maxQuestions: shouldReply ? 1 : 0,
+      kind: plannedNextStep?.kind || 'close_when_resolved',
+      targetField: plannedNextStep?.targetField || null,
+      move: salesMovePlan.primaryMove || 'answer',
+      evidenceRequired: plannedNextStep?.evidenceRequired === true,
+      rule: plannedNextStep?.rule || 'If the current question is resolved, stop naturally instead of forcing another question.',
+    },
+    constraints: {
+      preserveFactAndActionGates: true,
+      doNotRepeatKnownFields: true,
+      doNotStackQuestions: true,
+      doNotPromiseUnverifiedActions: true,
+    },
+  };
+}
+
+function responseGenerationInstruction(strategy = {}) {
+  if (strategy.directAnswer?.required === false) {
+    return '本轮无需回复;reply 返回空字符串。';
+  }
+  const evidenceBoundary = strategy.directAnswer?.evidenceBoundary === 'direction_with_explicit_unknowns'
+    ? '证据不足也要先给当前能成立的方向判断,再用一句话说明未知边界;不得用“需要核实”代替回答。'
+    : '先给有依据的结论或倾向;不得用复述客户条件代替回答。';
+  const target = strategy.singleNextStep?.targetField
+    ? `只围绕决定性输入“${strategy.singleNextStep.targetField}”推进。`
+    : '问题已经闭环时自然收住,不强行追问。';
+  const actionBoundary = strategy.singleNextStep?.evidenceRequired
+    ? '该下一步涉及外部动作;没有匹配凭证时只能说明边界或收集输入,不得声称已安排、正在执行或将按时完成。'
+    : '下一步不得新增无依据事实或外部执行承诺。';
+
+  return [
+    `客户本轮真正要解决的问题:${strategy.customerQuestion?.text || '以最新客户消息为准'}`,
+    `1. 先答:${evidenceBoundary}`,
+    '2. 再判断:结合客户场景给一个有依据的倾向、取舍或理由;信息不足时给决策方法,不编造具体房源事实。',
+    `3. 唯一步:整条回复最多推进一件事、最多问一个问题。${target}${actionBoundary}`,
+    '强制顺序是“回答 -> 判断 -> 可选的一个下一步”;禁止先盘问字段,禁止并列多个行动,三个槽位按需要自然合成1至3句,不输出标题。',
+  ].join('\n');
+}
+
+module.exports = {
+  ANSWER_MODE_BY_MOVE,
+  buildResponseGenerationStrategy,
+  hasGroundingEvidence,
+  responseGenerationInstruction,
+};

+ 131 - 0
mcp/src/core/response-professional-score.js

@@ -0,0 +1,131 @@
+'use strict';
+
+const DIMENSION_IDS = Object.freeze([
+  'direct_answer',
+  'judgment_value',
+  'continuity',
+  'next_step',
+  'naturalness',
+  'non_repetition',
+]);
+
+const FILLER_PATTERN = /^(?:好的|收到|了解|明白|行|可以)[,,。\s]*(?:我看看|我来处理|这边处理|稍后回复|正在处理|帮您关注)?[。!!\s]*$/;
+const INTERNAL_PATTERN = /qualityScore|qualityChecks|requiresHuman|fallbackReason|系统提示|内部审核|机器评分/i;
+const JUDGMENT_PATTERN = /因为|所以|取决于|主要看|关键是|区别在|相比|更适合|优先|不建议|建议|重点|要结合|需要结合|不能只看|暂时不能|目前不确定|还不能确定|不能直接下结论/;
+const NEXT_STEP_PATTERN = /(?:您|你)(?:先)?(?:发|说|告诉|确认|选)|可以先|先看|先按|先把|接下来|下一步|再比较|再判断|再定|不用急|不着急|有需要再|方便时再|[??]/;
+const PRESSURE_PATTERN = /马上定|现在就定|赶紧|尽快交|直接签|先交定金|今天必须|错过就没/;
+const MECHANICAL_PATTERN = /(?:建议由人工(?:专员|顾问).{0,10}(?:复核|核验)){2,}|(?:需要核验|待核验).{0,12}(?:需要核验|待核验)|^(?:根据您提供的信息|感谢您的咨询)/;
+
+function normalize(value = '') {
+  return String(value || '').toLowerCase().replace(/[\s,。!?!?,;;::'"“”‘’()()【】\[\]-]+/g, '');
+}
+
+function roleOf(message = {}) {
+  return message.role || (message.direction === 'outbound' ? 'assistant' : 'user');
+}
+
+function contentOf(message = {}) {
+  return String(message.content || message.text || '').trim();
+}
+
+function questionFields(text = '') {
+  const value = String(text || '');
+  return [
+    ['budget', /预算|总价|首付|月供/],
+    ['region', /区域|片区|地段|小区|哪里|哪边/],
+    ['roomCount', /几房|几室|户型|房间数/],
+    ['area', /面积|多大|多少平/],
+    ['school', /学校|学区|入学|户籍|划片/],
+    ['schedule', /哪天|什么时候|几点|上午|下午|时段/],
+  ].filter(([, pattern]) => pattern.test(value)).map(([id]) => id);
+}
+
+function makeDimension(score, detail) {
+  return { score: Math.max(0, Math.min(2, Number(score) || 0)), detail: String(detail || '') };
+}
+
+function scoreDirectAnswer(reply = '') {
+  const content = String(reply || '').trim();
+  if (!content || FILLER_PATTERN.test(content)) return makeDimension(0, '空回复或空泛承接');
+  const first = content.replace(/^(?:好的|收到|了解|明白)[,,::\s]*/, '').split(/[。!?!?\n]/)[0];
+  if (!first || /^(?:我看看|我来处理|这边处理|稍后回复|正在处理)/.test(first)) return makeDimension(0, '首句是处理过程');
+  if (/[??]$/.test(first) && !/还不能|不确定|要看|取决于/.test(first)) return makeDimension(1, '首句直接进入澄清,但没有先给方向回答');
+  return makeDimension(2, '首句给出实质回应或诚实边界');
+}
+
+function scoreJudgmentValue(reply = '') {
+  const content = String(reply || '').trim();
+  if (!content || FILLER_PATTERN.test(content)) return makeDimension(0, '没有判断信息');
+  if (JUDGMENT_PATTERN.test(content)) return makeDimension(2, '包含判断、取舍依据或明确边界');
+  if (/^[^。!?!?]*[??]$/.test(content)) return makeDimension(0, '只有追问,没有判断');
+  return makeDimension(1, '有回应,但判断依据较弱');
+}
+
+function scoreContinuity(reply = '', messages = [], inbound = '') {
+  const userHistory = messages.filter(message => roleOf(message) === 'user').map(contentOf).filter(Boolean);
+  if (!userHistory.length) return makeDimension(2, '当前没有需要承接的历史条件');
+  const knownText = [...userHistory, inbound].join('\n');
+  const asked = questionFields(reply);
+  const alreadyKnown = questionFields(knownText);
+  if (asked.some(field => alreadyKnown.includes(field)) && /[??]|请问|告诉我|说下/.test(reply)) {
+    return makeDimension(0, '再次采集历史中已有字段');
+  }
+  const knownNumbers = knownText.match(/\d+(?:\.\d+)?/g) || [];
+  if (knownNumbers.length && knownNumbers.some(number => String(reply).includes(number))) return makeDimension(2, '复用了历史中的明确条件');
+  if (/按您|您刚|您说|之前|继续保留|先按|记下/.test(reply)) return makeDimension(2, '显式承接上一轮条件');
+  return makeDimension(1, '未冲突,但对历史条件承接不明显');
+}
+
+function scoreNextStep(reply = '', plan = {}) {
+  const content = String(reply || '').trim();
+  if (PRESSURE_PATTERN.test(content)) return makeDimension(0, '下一步对当前阶段施压过强');
+  const lowPressure = plan.generationStrategy?.signals?.lowPressure;
+  if (lowPressure && /什么时候|哪天|几点/.test(content) && !/不急|方便时|定下来再/.test(content)) {
+    return makeDimension(0, '客户低压力时仍追问推进时间');
+  }
+  if (NEXT_STEP_PATTERN.test(content)) return makeDimension(2, '下一步明确且与当前阶段兼容');
+  return makeDimension(1, '完成回答,但下一步不够清晰');
+}
+
+function scoreNaturalness(reply = '') {
+  const content = String(reply || '').trim();
+  if (!content || INTERNAL_PATTERN.test(content) || FILLER_PATTERN.test(content)) return makeDimension(0, '存在内部状态、空正文或空泛承接');
+  if (MECHANICAL_PATTERN.test(content) || content.length > 240) return makeDimension(1, '表达偏机械或过长');
+  return makeDimension(2, '表达简洁、客户可读');
+}
+
+function scoreNonRepetition(reply = '', messages = []) {
+  const current = normalize(reply);
+  const previous = messages.filter(message => roleOf(message) === 'assistant').map(message => normalize(contentOf(message))).filter(Boolean);
+  if (previous.some(item => item === current && current.length >= 6)) return makeDimension(0, '逐字复读历史回复');
+  if (previous.some(item => current.length >= 12 && item.length >= 12 && (item.includes(current) || current.includes(item)))) {
+    return makeDimension(1, '与历史回复存在明显复用');
+  }
+  return makeDimension(2, '未发现明显复读');
+}
+
+function scoreProfessionalResponse(input = {}) {
+  const reply = String(input.reply ?? input.content ?? '').trim();
+  const messages = Array.isArray(input.messages) ? input.messages : [];
+  const plan = input.plan || {};
+  const dimensions = {
+    direct_answer: scoreDirectAnswer(reply),
+    judgment_value: scoreJudgmentValue(reply),
+    continuity: scoreContinuity(reply, messages, String(input.inboundContent || input.inbound || '')),
+    next_step: scoreNextStep(reply, plan),
+    naturalness: scoreNaturalness(reply),
+    non_repetition: scoreNonRepetition(reply, messages),
+  };
+  const total = DIMENSION_IDS.reduce((sum, id) => sum + dimensions[id].score, 0);
+  return {
+    version: '1.0',
+    total,
+    max: DIMENSION_IDS.length * 2,
+    dimensions,
+  };
+}
+
+module.exports = {
+  DIMENSION_IDS,
+  scoreProfessionalResponse,
+};

+ 548 - 0
mcp/src/core/response-provenance-ledger.js

@@ -0,0 +1,548 @@
+'use strict';
+
+const LEDGER_FIELDS = Object.freeze(['roomCount', 'area', 'budget', 'region']);
+
+const FACT_CLAIM_TYPES = Object.freeze({
+  CUSTOMER_ECHO: 'customer_echo',
+  METHODOLOGY: 'methodology',
+  PROPERTY_DYNAMIC: 'property_dynamic',
+  POLICY_RISK: 'policy_risk',
+  OTHER: 'other',
+});
+
+const ROOM_VALUE = Object.freeze({
+  一: 1,
+  二: 2,
+  两: 2,
+  三: 3,
+  四: 4,
+  五: 5,
+  六: 6,
+  七: 7,
+  八: 8,
+  九: 9,
+  十: 10,
+});
+
+const FIELD_PROFILE_KEYS = Object.freeze({
+  roomCount: /(?:^|\.)(?:rooms?|roomcount|bedrooms?|layout|layouttype|roomtype|layout_type)$/i,
+  area: /(?:^|\.)(?:area|areamin|areamax|areasqm|buildingareasqm|area_range)$/i,
+  budget: /(?:^|\.)(?:budget|budgetwan|budgetmin|budgetmax|budgetrange|budget_range)$/i,
+  region: /(?:^|\.)(?:region|district|preferredregion|targetregion|target_region|board)$/i,
+});
+
+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 PERSONAL_SCHEDULE_CONFIRMATION = /(?:我|我这边|这边).{0,6}(?:倒是|其实|目前|明天|今天|后天)?\s*(?:有空|有时间|时间没问题)|(?:今天|明天|后天).{0,10}(?:我|我这边|这边).{0,6}(?:有空|有时间|时间没问题)/;
+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 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}(?:价格|费用|收费)/;
+const POLICY_RISK_TOPIC = /(?:税费|契税|个税|增值税|满五|唯一住房|学区|学位|入学|划片|户籍|招生政策|产权|权属|合同|贷款|利率|资格|报名)/;
+const POLICY_RISK_CONCLUSION = /(?:税费|契税|个税|增值税).{0,24}(?:免|不用交|不需要交|只剩|是|为|合计|一共|总共|大概|约|\d+(?:\.\d+)?\s*[%%])|(?:满五|唯一住房).{0,30}(?:个税|增值税|契税).{0,18}(?:免|不用交|不需要交|按差额)|(?:能上|可以上|可以入学|保证入学|属于.{0,8}学区|对口.{0,8}(?:小学|中学|初中|学校)|学位稳|入学没问题)|(?:产权|权属|合同|贷款|利率|资格).{0,18}(?:没问题|(?<!不)确定|就是|一定|可以签|符合|通过)/;
+const POLICY_RISK_ASSERTION = /(?:保证|肯定|一定|绝对|(?<!不)确定|明确|没问题|无风险|不存在风险|可以签|符合|通过|能上|可以上|可以入学|免征|不用交|不需要交)/;
+const POLICY_RISK_SCOPED_UNCERTAINTY = /^(?:(?:当前|目前|暂时|现阶段)?(?:尚无|暂无|没有|尚未|还未|还没有|未能|无法|不能|没法).{0,16}(?:可确认|已确认|确定|明确|核实|核验|核对|复核)?(?:的)?(?:税费|契税|个税|增值税|学区|学位|入学|划片|产权|权属|合同|贷款|利率|资格|政策).{0,10}(?:结论|结果|依据|信息|状态)?|(?:税费|契税|个税|增值税|学区|学位|入学|划片|产权|权属|合同|贷款|利率|资格|政策)(?:状态|结论|结果|依据|信息)?(?:现在|目前|当前)?(?:还|仍)?(?:不确定|待确认|待核验))$/;
+const POLICY_RISK_METHOD = /(?:要|得|需要|应当|先|最终要|具体以|取决于|核对|准备|要备|对上|结合|落到).{0,32}(?:学校|户籍|划片|当期政策|招生政策|房产证|不动产权证|户口本|房本|产权材料|总价|面积|卖方情况)|(?:学校|户籍|划片|当期政策|招生政策|房产证|不动产权证|户口本|房本|产权材料).{0,32}(?:要|得|需要|核对|准备|为准|确认|落到)/;
+const PROPERTY_DYNAMIC_CLAIM = /(?:不同|各个).{0,8}(?:片区|板块|小区).{0,18}(?:价格|总价|单价|行情).{0,12}(?:差别|差异|不一样|波动)|(?:价格|总价|单价|行情).{0,18}(?:不同|各个).{0,8}(?:片区|板块|小区).{0,12}(?:差别|差异|不一样|波动)|(?:两房|三房|户型|房源).{0,20}(?:是不是都|一般|通常|大多|普遍|多在|都要).{0,18}(?:\d|价格|总价|面积)|(?:是不是都|一般|通常|大多|普遍|多在|都要).{0,20}(?:\d|价格|总价|面积|两房|三房)|(?:选择|房源).{0,6}(?:挺多|不少|很多)|(?:有|找到|筛到).{0,8}(?:合适|匹配).{0,6}(?:房源|房子)|^(?:当前|目前|现在)?(?:还有|有)(?:合适|匹配)的(?:[,,。]|$)|(?:第[一二三123]套|这套|该套|具体房源|当前房源).{0,24}(?:在售|停售|下架|可售|价格|总价|单价|面积|户型|安静|清静|不吵|靠河|临路|楼层|匹配|合适|对得上|优先看)/;
+const PROPERTY_MARKET_OUTCOME = /(?:地铁房|靠地铁|高层|中低楼层|低楼层|楼层).{0,24}(?:投资|放租|出租|租金|升值|保值|转手|流通).{0,12}(?:稳|好|容易|慢|快|难)|(?:投资|放租|出租|租金|升值|保值|转手|流通).{0,24}(?:稳|好|容易|慢|快|难).{0,12}(?:地铁房|靠地铁|高层|中低楼层|低楼层|楼层)/;
+const PROPERTY_NEGOTIATION_CLAIM = /(?:价格|报价|价位).{0,16}(?:有得谈|有商量空间|能谈|可以谈|可谈|谈得动)|(?:有得谈|有商量空间|能谈|可以谈|可谈|谈得动).{0,16}(?:价格|报价|价位)/;
+const PROPERTY_COMPARATIVE_JUDGEMENT = /(?:楼层|高层|低楼层|中高楼层).{0,32}(?:采光|视野|上下|进出|通风|转手|出租).{0,16}(?:更好|更差|更方便|更省事|省事些|更难|更慢|更快|更稳)/;
+const FACT_METHOD_CUES = /(?:筛|筛选|排序|排在前面|优先保留|不单看|取舍|权衡|先把|先从|按顺序|理清|对齐|定方向|付款方式|付款节奏|报价思路|谈判时|按经验|别急着|不急着|先不报|不猜价格|价格留到|逐项核|逐项看|核对|准备|要备|圈着看|留意|作为参考|怎么判断|如何判断)/;
+const CUSTOMER_ATTRIBUTION_CUES = /(?:记下|记住|记录|按(?:您|你|你们)(?:说的|提的|的)?|(?:您|你|你们)(?:说的|提的|确认的|想要的|在意的|担心的)|先按|这个范围|这个方向|都记下|不急着定)/;
+const CUSTOMER_ECHO_UNSAFE_ADDITION = /(?:选择|房源).{0,6}(?:挺多|不少|很多)|(?:有|找到|筛到).{0,8}(?:合适|匹配).{0,6}(?:房源|房子)|(?:够用|够买|没问题|充足|宽裕|好找)|(?:这套|该套|具体房源).{0,18}(?:很|更|最|确实|就是).{0,8}(?:安静|清静|靠河|不临路|采光好|户型好)/;
+const CUSTOMER_DIMENSIONS = Object.freeze({
+  budget: /(?:预算|总价|价格上限|压力|首付|月供)/,
+  roomCount: /(?:两房|二房|2房|三房|3房|四房|4房|户型)/,
+  transit: /(?:通勤|公司|上班|软件园|地铁|步行|走路|分钟)/,
+  quiet: /(?:怕吵|安静|清静|噪音|噪声|不吵)/,
+  floor: /(?:楼层|中间楼层|中层|低层|高层)/,
+  river: /(?:靠河|沿河|河景|河边)/,
+  propertyType: /(?:新房|二手房)/,
+  payment: /(?:按揭|现金|付款方式|付款节奏)/,
+  school: /(?:学校|小学|初中|入学|学区|学位|片区|小区)/,
+});
+
+function normalizeText(value = '') {
+  return String(value ?? '').trim();
+}
+
+function roleOf(message = {}) {
+  return message.role || (message.direction === 'outbound' ? 'assistant' : 'user');
+}
+
+function toolSucceeded(trace = {}) {
+  const status = String(trace.status || trace.result?.status || '');
+  if (trace.isError === true || trace.error || trace.result?.ok === false || trace.result?.success === false || FAILURE_STATUS.test(status)) return false;
+  return trace.ok === true || trace.success === true || trace.result?.ok === true || trace.result?.success === true || SUCCESS_STATUS.test(status);
+}
+
+function roomNumber(value = '') {
+  const token = String(value || '').trim();
+  return Number(token) || ROOM_VALUE[token] || 0;
+}
+
+function extractRoomCount(text = '') {
+  const value = normalizeText(text);
+  const matches = [...value.matchAll(/([1-9一二三四五六七八九十两])\s*(?:室|房|居)/g)];
+  for (const match of matches.reverse()) {
+    const prefix = value.slice(Math.max(0, Number(match.index || 0) - 10), Number(match.index || 0));
+    if (/(?:不要|不考虑|不是|排除|别看|不想要)\s*$/.test(prefix)) continue;
+    const count = roomNumber(match[1]);
+    if (count) return count;
+  }
+  return 0;
+}
+
+function extractArea(text = '') {
+  const value = normalizeText(text);
+  const range = value.match(/(\d{2,4}(?:\.\d+)?)\s*(?:-|—|~|~|到|至)\s*(\d{2,4}(?:\.\d+)?)\s*(?:平|平方米|㎡)/);
+  if (range) return { min: Number(range[1]), max: Number(range[2]) };
+  const single = value.match(/(\d{2,4}(?:\.\d+)?)\s*(?:平|平方米|㎡)/);
+  return single ? { value: Number(single[1]) } : null;
+}
+
+function extractBudget(text = '') {
+  const value = normalizeText(text);
+  if (!/(?:预算|总价|房价|价位|万\s*(?:以内|以下|封顶|左右|上下|以上|起))/.test(value)) return null;
+  const range = value.match(/(\d{2,4}(?:\.\d+)?)\s*(?:-|—|~|~|到|至)\s*(\d{2,4}(?:\.\d+)?)\s*万/);
+  if (range) return { minWan: Number(range[1]), maxWan: Number(range[2]) };
+  const single = value.match(/(\d{2,4}(?:\.\d+)?)\s*万/);
+  if (!single) return null;
+  const amount = Number(single[1]);
+  if (/(?:以内|以下|封顶|上限)/.test(value)) return { maxWan: amount };
+  if (/(?:以上|起)/.test(value)) return { minWan: amount };
+  return { valueWan: amount };
+}
+
+function extractRegion(text = '') {
+  const value = normalizeText(text);
+  const fixed = [...value.matchAll(/(新北区|武进区|天宁区|钟楼区|新北|武进|天宁|钟楼)/g)].at(-1)?.[1] || '';
+  if (fixed) return fixed.endsWith('区') ? fixed : `${fixed}区`;
+  const board = [...value.matchAll(/([\p{Script=Han}]{2,8}板块)/gu)].at(-1)?.[1] || '';
+  if (board && !/(?:学区|小区)/.test(board)) return board;
+  const contextual = value.match(/(?:考虑在|想在|想买|想看|区域|位置|倾向|优先)\s*([\p{Script=Han}]{2,8}区)/u)?.[1] || '';
+  return /(?:小区|学区)/.test(contextual) ? '' : contextual;
+}
+
+function sourceRecord(field, value, sourceType, sourceRef, text = '') {
+  return {
+    field,
+    value,
+    sourceType,
+    sourceRef: String(sourceRef || ''),
+    text: normalizeText(text).slice(0, 180),
+  };
+}
+
+function addTextFields(output, text, sourceType, sourceRef) {
+  const extractors = {
+    roomCount: extractRoomCount,
+    area: extractArea,
+    budget: extractBudget,
+    region: extractRegion,
+  };
+  for (const [field, extract] of Object.entries(extractors)) {
+    const value = extract(text);
+    if (value !== null && value !== '' && value !== 0) output.push(sourceRecord(field, value, sourceType, sourceRef, text));
+  }
+}
+
+function flattenProfile(value, prefix = '', output = []) {
+  if (!value || typeof value !== 'object') return output;
+  for (const [key, child] of Object.entries(value)) {
+    if (['labels', 'caseMetadata', 'case_metadata', 'goldenCase', 'golden_case'].includes(key)) continue;
+    const fieldPath = prefix ? `${prefix}.${key}` : key;
+    if (child && typeof child === 'object' && !Array.isArray(child)) {
+      if (Object.prototype.hasOwnProperty.call(child, 'value')) output.push({ path: fieldPath, value: child.value });
+      flattenProfile(child, fieldPath, output);
+    } else if (child !== undefined && child !== null && child !== '') {
+      output.push({ path: fieldPath, value: child });
+    }
+  }
+  return output;
+}
+
+function addToolFields(output, toolTrace = []) {
+  for (const [index, trace] of successfulToolEvidence(toolTrace).entries()) {
+    for (const item of flattenProfile(trace.result || {})) {
+      for (const field of LEDGER_FIELDS) {
+        if (!FIELD_PROFILE_KEYS[field].test(item.path)) continue;
+        const text = normalizeText(item.value);
+        let value = item.value;
+        if (field === 'roomCount') value = extractRoomCount(text) || roomNumber(text);
+        if (field === 'area') value = extractArea(text) || (Number.isFinite(Number(item.value)) ? { value: Number(item.value) } : null);
+        if (field === 'budget') value = extractBudget(text) || (Number.isFinite(Number(item.value)) ? { valueWan: Number(item.value) } : null);
+        if (field === 'region') value = text;
+        if (value !== null && value !== '' && value !== 0) {
+          output.push(sourceRecord(field, value, 'tool', `${trace.tool || trace.name || 'tool'}:${index}:${item.path}`, text));
+        }
+      }
+    }
+  }
+}
+
+function addProfileFields(output, profile = {}) {
+  for (const item of flattenProfile(profile)) {
+    for (const field of LEDGER_FIELDS) {
+      if (!FIELD_PROFILE_KEYS[field].test(item.path)) continue;
+      const text = normalizeText(item.value);
+      let value = item.value;
+      if (field === 'roomCount') value = extractRoomCount(text) || roomNumber(text);
+      if (field === 'area') value = extractArea(text) || (Number.isFinite(Number(item.value)) ? { value: Number(item.value) } : null);
+      if (field === 'budget') value = extractBudget(text) || (Number.isFinite(Number(item.value)) ? { valueWan: Number(item.value) } : null);
+      if (field === 'region') value = text;
+      if (value !== null && value !== '' && value !== 0) output.push(sourceRecord(field, value, 'profile', item.path, text));
+    }
+  }
+}
+
+function successfulToolEvidence(toolTrace = []) {
+  return (toolTrace || []).filter(toolSucceeded);
+}
+
+function marketEvidencePresent(input = {}) {
+  if (input.capabilities?.marketEvidence === true) return true;
+  const citations = (input.citations || []).map(item => JSON.stringify(item || {})).join('\n');
+  if (/(?:成交|挂牌|价格|单价).{0,24}(?:面积|户型|三房|两房)|(?:分布|样本|中位数|平均)/.test(citations)) return true;
+  return successfulToolEvidence(input.toolTrace).some(trace => {
+    const name = String(trace.tool || trace.name || '');
+    const result = trace.result || {};
+    const rows = result.items || result.properties || result.matches || result.rows || result.nearItems || result.data?.items;
+    return /(?:propert(?:y|ies)|market|price|房源|市场|价格)/i.test(name)
+      && ((Array.isArray(rows) && rows.length > 1) || /(?:distribution|median|average|分布|中位数|平均)/i.test(JSON.stringify(result)));
+  });
+}
+
+function areaDistributionEvidencePresent(input = {}) {
+  if (input.capabilities?.areaDistributionEvidence === true) return true;
+  const knowledge = [input.knowledgeEvidence, input.knowledgeFacts, input.knowledgeContext]
+    .flatMap(value => Array.isArray(value) ? value : value ? [value] : [])
+    .map(value => typeof value === 'string' ? value : JSON.stringify(value || {}));
+  const citations = (input.citations || []).map(item => JSON.stringify(item || {}));
+  const toolEvidence = successfulToolEvidence(input.toolTrace).map(trace => JSON.stringify(trace.result || {}));
+  if ([...knowledge, ...citations, ...toolEvidence].some(text => (
+    EXPLICIT_AREA_RANGE.test(text) && /(?:三房|两房|[2-4]房|户型|面积|room|layout|area)/i.test(text)
+  ))) return true;
+  return successfulToolEvidence(input.toolTrace).some(trace => {
+    const result = trace.result || {};
+    const rows = result.items || result.properties || result.matches || result.rows || result.nearItems || result.data?.items;
+    if (!Array.isArray(rows) || rows.length < 2) return false;
+    const areas = rows.map(item => Number(item?.area ?? item?.areaSqm ?? item?.buildingAreaSqm)).filter(Number.isFinite);
+    return areas.length >= 2 && /(?:propert(?:y|ies)|market|房源|市场)/i.test(String(trace.tool || trace.name || ''));
+  });
+}
+
+function inventoryStatusEvidencePresent(input = {}) {
+  if (input.capabilities?.inventoryStatus === true) return true;
+  return (input.toolTrace || []).filter(trace => (
+    trace && trace.isError !== true && trace.error === undefined
+      && trace.result?.ok !== false && trace.result?.success !== false
+  )).some(trace => {
+    const result = trace.result || {};
+    const rows = result.items || result.properties || result.matches || result.rows || result.data?.items;
+    const explicitStatus = ['available', 'hasInventory', 'inventoryStatus', 'listingStatus', 'total'].some(key => (
+      Object.prototype.hasOwnProperty.call(result, key) || Object.prototype.hasOwnProperty.call(result.data || {}, key)
+    ));
+    return /(?:propert(?:y|ies)|listing|inventory|房源|在售)/i.test(String(trace.tool || trace.name || ''))
+      && (Array.isArray(rows) || explicitStatus);
+  });
+}
+
+function buildResponseProvenanceLedger(input = {}) {
+  const records = [];
+  addProfileFields(records, input.profile?.profile || input.profile || {});
+  const userMessages = (input.messages || []).filter(message => roleOf(message) === 'user');
+  userMessages.forEach((message, index) => addTextFields(records, message.content || message.text || '', 'history', `user:${index}`));
+  addTextFields(records, input.inboundContent || input.inbound || '', 'current_inbound', 'current');
+  addToolFields(records, input.toolTrace || []);
+  const priorities = { current_inbound: 4, history: 3, profile: 2, tool: 1 };
+  const fields = {};
+  for (const field of LEDGER_FIELDS) {
+    const sources = records.filter(record => record.field === field);
+    const selected = [...sources].sort((left, right) => priorities[right.sourceType] - priorities[left.sourceType]).at(-1) || null;
+    if (sources.length) {
+      const current = sources.filter(record => record.sourceType === 'current_inbound').at(-1)
+        || sources.filter(record => record.sourceType === 'history').at(-1)
+        || sources.filter(record => record.sourceType === 'profile').at(-1)
+        || selected;
+      fields[field] = { value: current.value, sources };
+    }
+  }
+  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 || ''))),
+    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),
+    inventoryStatus: inventoryStatusEvidencePresent(input),
+  };
+  return {
+    version: '1.0',
+    fields,
+    capabilities,
+    sourceCount: records.length,
+  };
+}
+
+function ledgerFieldValue(ledger = {}, field = '') {
+  return ledger.fields?.[field]?.value;
+}
+
+function fieldHasSource(ledger = {}, field = '') {
+  return Array.isArray(ledger.fields?.[field]?.sources) && ledger.fields[field].sources.length > 0;
+}
+
+function provenanceInstruction(ledger = {}) {
+  const known = LEDGER_FIELDS.filter(field => fieldHasSource(ledger, field)).map(field => {
+    const entry = ledger.fields[field];
+    const sources = [...new Set(entry.sources.map(item => item.sourceType))].join('/');
+    return `${field}=${JSON.stringify(entry.value)}(${sources})`;
+  });
+  return `字段来源账本:${known.join(';') || '暂无已确认预算、区域、房间数或面积'}。只使用账本已有字段;roomCount 来源不等于面积分布来源,没有 areaDistributionEvidence 时不得生成三房面积区间;没有市场证据时不得生成预算对应面积或户型分布。`;
+}
+
+function customerFactEchoClaim(claim = '', input = {}) {
+  const text = normalizeText(claim);
+  if (!text || CUSTOMER_ECHO_UNSAFE_ADDITION.test(text)) return false;
+  const customerText = [
+    input.inboundContent || input.inbound,
+    ...(input.messages || [])
+      .filter(message => roleOf(message) === 'user')
+      .map(message => message.content || message.text || ''),
+  ].filter(Boolean).join('\n');
+  if (!customerText || !CUSTOMER_ATTRIBUTION_CUES.test(text)) return false;
+  const dimensions = Object.values(CUSTOMER_DIMENSIONS).filter(pattern => pattern.test(text));
+  if (!dimensions.length || !dimensions.every(pattern => pattern.test(customerText))) return false;
+  const numbers = text.match(/\d+(?:\.\d+)?/g) || [];
+  return numbers.every(number => normalizeText(customerText).includes(number));
+}
+
+function methodologyClaim(claim = '') {
+  const text = normalizeText(claim);
+  if (!text || PROPERTY_DYNAMIC_CLAIM.test(text) || POLICY_RISK_CONCLUSION.test(text)) return false;
+  if (safeMethodologyClaim(text)) return true;
+  return FACT_METHOD_CUES.test(text)
+    && !/(?:已确认|确定有|当前有|完全匹配|保证|肯定|一定).{0,24}(?:房源|价格|在售|学区|税费|产权)/.test(text);
+}
+
+function classifyFactClaim(claim = '', input = {}) {
+  const text = normalizeText(claim);
+  if (!text) return { type: FACT_CLAIM_TYPES.OTHER, requiresExternalEvidence: false };
+  const inbound = normalizeText(input.inboundContent || input.inbound || '');
+  if (POLICY_RISK_TOPIC.test(text)) {
+    if (POLICY_RISK_CONCLUSION.test(text) || POLICY_RISK_ASSERTION.test(text)) {
+      return { type: FACT_CLAIM_TYPES.POLICY_RISK, requiresExternalEvidence: true };
+    }
+    if (POLICY_RISK_SCOPED_UNCERTAINTY.test(text) && !POLICY_RISK_ASSERTION.test(text)) {
+      return { type: FACT_CLAIM_TYPES.METHODOLOGY, requiresExternalEvidence: false };
+    }
+    if (POLICY_RISK_METHOD.test(text) || /(?:入学|学位|学区).{0,24}(?:学校|户籍|划片|当期政策|核对|确认|准备|落到)/.test(text)) {
+      return { type: FACT_CLAIM_TYPES.METHODOLOGY, requiresExternalEvidence: false };
+    }
+    return { type: FACT_CLAIM_TYPES.POLICY_RISK, requiresExternalEvidence: true };
+  }
+  if (UNSOURCED_BUDGET_FEASIBILITY.test(text)
+    || FIRST_PERSON_INVENTORY_STATE.test(text)
+    || PROPERTY_DYNAMIC_CLAIM.test(text)
+    || PROPERTY_MARKET_OUTCOME.test(text)
+    || PROPERTY_NEGOTIATION_CLAIM.test(text)
+    || (/(?:价格|报价|价位).{0,12}(?:能不能谈|可以谈吗|能低吗|空间)/.test(inbound) && /^(?:能谈|可以谈|有得谈)(?:[,,。]|$)/.test(text))
+    || PROPERTY_COMPARATIVE_JUDGEMENT.test(text)
+    || MARKET_DISTRIBUTION.test(text)
+    || MARKET_PRICE_AREA_OUTCOME.test(text)
+    || ROOM_AREA_DISTRIBUTION.test(text)) {
+    return { type: FACT_CLAIM_TYPES.PROPERTY_DYNAMIC, requiresExternalEvidence: true };
+  }
+  if (customerFactEchoClaim(text, input)) {
+    return { type: FACT_CLAIM_TYPES.CUSTOMER_ECHO, requiresExternalEvidence: false };
+  }
+  const customerText = [input.inboundContent || input.inbound, ...(input.messages || [])
+    .filter(message => roleOf(message) === 'user')
+    .map(message => message.content || message.text || '')].filter(Boolean).join('\n');
+  if (/(?:明白|这个范围|补个|发个|小区名|大概位置|对上是哪一套|先看片区|不急着定)/.test(text)
+    && /(?:靠河|中间楼层|房源编号|小区|片区|位置)/.test(customerText)
+    && !PROPERTY_DYNAMIC_CLAIM.test(text)) {
+    return { type: FACT_CLAIM_TYPES.CUSTOMER_ECHO, requiresExternalEvidence: false };
+  }
+  if (methodologyClaim(text)) {
+    return { type: FACT_CLAIM_TYPES.METHODOLOGY, requiresExternalEvidence: false };
+  }
+  return { type: FACT_CLAIM_TYPES.OTHER, requiresExternalEvidence: false };
+}
+
+function safeMethodologyClaim(claim = '') {
+  const text = normalizeText(claim);
+  if (!text || DEFINITE_FACT_CUES.test(text) || MARKET_DISTRIBUTION.test(text) || UNSOURCED_GENERIC_PRICING_METHOD.test(text)) return false;
+  return SAFE_METHOD_CUES.test(text)
+    || /(?:面积跨度|面积差别|面积大小).{0,24}(?:取决于|结合|要看).{0,24}(?:小区|得房率|户型|布局)/.test(text);
+}
+
+function evaluateResponseProvenance(reply = '', input = {}) {
+  const content = normalizeText(reply);
+  const inbound = normalizeText(input.inboundContent || input.inbound || '');
+  const ledger = input.constraintLedger || buildResponseProvenanceLedger(input);
+  const violations = [];
+  const personalScheduleClaim = PERSONAL_SCHEDULE_CONFIRMATION.test(content);
+  const definiteViewingClaim = DEFINITE_VIEWING_AVAILABILITY.test(content);
+  const tentativeViewing = TENTATIVE_VIEWING_PROPOSAL.test(content)
+    || (!personalScheduleClaim && SCOPED_VIEWING_BOUNDARY.test(content));
+  if (VIEWING_REQUEST.test(inbound)
+    && (personalScheduleClaim || definiteViewingClaim)
+    && !tentativeViewing
+    && ledger.capabilities.appointment !== true) {
+    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' });
+  }
+  if (FIRST_PERSON_CAPABILITY.test(content)) {
+    const media = /(?:户型图|平面图|图片|照片|视频|媒体|资料)/.test(content);
+    if ((media && !ledger.capabilities.mediaAccess) || (!media && !ledger.capabilities.propertyAccess)) {
+      violations.push({ code: 'capability_without_evidence', detail: '第一人称房源或媒体访问能力缺少 capability evidence' });
+    }
+  }
+  const roomCount = Number(ledgerFieldValue(ledger, 'roomCount') || 0);
+  if (UNSOURCED_ROOM_ASSUMPTION.test(content) && roomCount !== 3 && !ledger.capabilities.propertyAccess) {
+    violations.push({ code: 'room_count_without_source', detail: '回复使用三房或三房面积区间,但 roomCount 没有对应来源' });
+  }
+  if ((MARKET_DISTRIBUTION.test(content) || MARKET_PRICE_AREA_OUTCOME.test(content)) && !ledger.capabilities.marketEvidence) {
+    violations.push({ code: 'market_distribution_without_evidence', detail: '预算、户型或面积分布缺少市场证据' });
+  }
+  if (ROOM_AREA_DISTRIBUTION.test(content) && !ledger.capabilities.areaDistributionEvidence) {
+    violations.push({ code: 'area_distribution_without_evidence', detail: 'roomCount 来源不能替代面积区间或面积分布证据' });
+  }
+  if (UNSOURCED_BUDGET_FEASIBILITY.test(content) && !ledger.capabilities.marketEvidence) {
+    violations.push({ code: 'budget_feasibility_without_evidence', detail: '预算记录不能直接推导市场可行性' });
+  }
+  return { passed: violations.length === 0, ledger, violations };
+}
+
+function stableIndex(seed = '', count = 1) {
+  let value = 2166136261;
+  for (const char of String(seed || '')) {
+    value ^= char.charCodeAt(0);
+    value = Math.imul(value, 16777619);
+  }
+  return count > 0 ? (value >>> 0) % count : 0;
+}
+
+function safeRewriteForViolations(input = {}, violations = [], seed = '') {
+  const inbound = normalizeText(input.inboundContent || input.inbound || '');
+  const ledger = input.constraintLedger || buildResponseProvenanceLedger(input);
+  const codes = new Set(violations.map(item => item.code));
+  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)) {
+      return choose('media-capability', [
+        '先对准具体哪套,再看对应的户型或视频资料。您发链接或截图给我。',
+        '户型和视频资料要先对应到具体房源。您把房源号或截图发我一个。',
+        '先确定您指的是哪套,资料才不会对错。发个链接或截图就行。',
+      ]);
+    }
+    return choose('property-capability', [
+      '先对准您说的具体房源,再判断哪些信息能直接比较。您发小区名或房源号都行。',
+      '具体哪套还没对上,先不把房源信息说满。您给我小区名或房源号。',
+      '要先知道您指的是哪套。发个小区名、房源号或截图中的一个就行。',
+    ]);
+  }
+  if (codes.has('area_distribution_without_evidence')) {
+    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 || '您刚说的条件'},面积要结合区域和具体户型看。您优先考虑哪个区域?`,
+    ]);
+  }
+  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}万以内记。您更想先定区域还是户型?`
+      : '预算先按您刚说的范围记。您更想先定区域还是户型?';
+  }
+  if (codes.has('room_count_without_source')) {
+    if (/(?:多大面积|多少平|平方|面积)/.test(inbound)) {
+      return choose('generic-area', [
+        '面积要先看您说的是哪种户型。您目前主要考虑几房?',
+        '不同户型的面积差别很大,先确认房间数更准确。您想看几房?',
+        '现在还没有房间数,先不套面积区间。您主要考虑几房?',
+      ]);
+    }
+    return choose('good-layout', [
+      '好房型先看房间数和实际布局。您目前主要考虑几房?',
+      '房型好不好要结合您的使用需求,先从房间数开始。您想看几房?',
+      '先不默认具体户型。您更需要几房?',
+    ]);
+  }
+  return '';
+}
+
+function enforceResponseProvenance(final = {}, input = {}) {
+  const reply = normalizeText(final.reply || '');
+  if (!reply) return final;
+  const assessment = evaluateResponseProvenance(reply, input);
+  if (assessment.passed) return { ...final, constraintLedger: assessment.ledger };
+  const rewritten = safeRewriteForViolations({ ...input, constraintLedger: assessment.ledger }, assessment.violations, input.conversationKey || 'default');
+  if (!rewritten) return { ...final, constraintLedger: assessment.ledger };
+  return {
+    ...final,
+    reply: rewritten,
+    constraintLedger: assessment.ledger,
+    reason: `${normalizeText(final.reason)}${final.reason ? ';' : ''}字段来源账本已阻止无来源字段、能力或市场分布进入客户回复。`,
+  };
+}
+
+module.exports = {
+  FACT_CLAIM_TYPES,
+  LEDGER_FIELDS,
+  buildResponseProvenanceLedger,
+  classifyFactClaim,
+  customerFactEchoClaim,
+  enforceResponseProvenance,
+  evaluateResponseProvenance,
+  extractArea,
+  extractBudget,
+  extractRegion,
+  extractRoomCount,
+  fieldHasSource,
+  ledgerFieldValue,
+  areaDistributionEvidencePresent,
+  inventoryStatusEvidencePresent,
+  marketEvidencePresent,
+  methodologyClaim,
+  provenanceInstruction,
+  safeMethodologyClaim,
+  safeRewriteForViolations,
+  stableIndex,
+};

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

@@ -53,6 +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))
     || /价格|费用|优惠|库存|名额|政策|资格|合同|结果|案例|数据|多少|最低|最高|保证/.test(inboundContent)
   );
   const enabledSections = scenario === 'complaint'
@@ -66,9 +67,10 @@ function planResponse(input = {}) {
   const requiredHumanRole = riskTypes.includes('complaint') ? 'supervisor' : requireHumanHandoff ? 'specialist' : '';
 
   return {
-    version: '1.1',
+    version: '1.2',
     scenario,
     shouldReply,
+    customerQuestion: inboundContent.trim().replace(/\s+/g, ' ').slice(0, 360),
     requireDirectAnswer,
     requireEvidence,
     requireNextStep,
@@ -92,7 +94,7 @@ function plannerInstruction(plan = {}) {
     '只补充能由本轮会话、画像、规则或工具结果支持的必要依据;未知事实明确待确认。',
     `最多追问 ${plan.maxQuestions} 个信息增益最高的问题,不重复已经问过或已经回答的信息。`,
     plan.requireHumanHandoff
-      ? `这是风险或高意向场景:正文必须给出已执行或立即执行的人工接管动作,JSON requiresHuman 必须为 true;${plan.requiredHumanRole === 'supervisor' ? '投诉必须明确交主管、负责人或投诉专员。' : '明确交人工专员、顾问或负责人核验。'}`
+      ? `这是风险或高意向场景:正文说明需要哪类人工核验或接管,JSON requiresHuman 必须为 true;只有存在真实交接凭证时才能声称已转交、正在处理或会在具体时间完成。${plan.requiredHumanRole === 'supervisor' ? '投诉需明确由主管、负责人或投诉专员接管。' : '明确由人工专员、顾问或负责人核验。'}`
       : '',
     plan.requireNextStep ? '末句给出与当前阶段相符、可执行且不过度承诺的下一步。' : '',
     plan.businessGoal ? `可配置业务目标:${plan.businessGoal}` : '',

+ 23 - 4
mcp/src/core/response-quality-verifier.js

@@ -2,6 +2,7 @@
 
 const { planResponse, plannerInstruction } = require('./response-quality-planner');
 const { qualityFallbackReason } = require('./response-quality-contract');
+const { verifyFuturePromises } = require('./response-future-promise-verifier');
 
 const GREETING_PATTERN = /^(?:您好|你好|哈喽|嗨|尊敬的|感谢您的?(?:咨询|关注|理解|反馈)?)[,,。!!\s]*/;
 const FILLER_OPENING_PATTERN = /^(?:关于这个问题|针对您的问题|我来为您|我先帮您|我这边先|请您耐心等待|感谢您的耐心)/;
@@ -15,6 +16,9 @@ const INTERNAL_LEAK_PATTERN = /(?:qualityScore|qualityChecks|rewriteCount|fallba
 const HUMAN_HANDOFF_ACTION_PATTERN = /(?:已|现在|立即|马上|会|将|确认后|核对后).{0,6}(?:转交|转给|转至|转|安排|请|由).{0,10}(?:人工|专员|顾问|主管|负责人)/;
 const SUPERVISOR_HANDOFF_PATTERN = /(?:主管|负责人|投诉专员).{0,12}(?:跟进|处理|核验|联系|接管)|(?:转交|转给|安排).{0,8}(?:主管|负责人|投诉专员)/;
 
+const HUMAN_HANDOFF_REQUIRED_PATTERN = /(?:需要|应由|建议由|必须由|待)(?:人工专员|人工顾问|专员|顾问|主管|负责人|投诉专员).{0,12}(?:核验|核实|确认|接管|处理|跟进|复核)|(?:人工专员|人工顾问|专员|顾问|主管|负责人|投诉专员).{0,8}(?:需要|待)(?:核验|核实|确认|接管|处理|跟进|复核)/;
+const SUPERVISOR_HANDOFF_REQUIRED_PATTERN = /(?:需要|应由|建议由|必须由|待)(?:主管|负责人|投诉专员).{0,12}(?:核验|核实|确认|接管|处理|跟进|复核)|(?:主管|负责人|投诉专员).{0,8}(?:需要|待)(?:核验|核实|确认|接管|处理|跟进|复核)/;
+
 const FIELD_RULES = {
   budget: {
     question: /预算|价格上限|费用上限|投入上限|总投入|总价|金额|首付|月供|付款能力/,
@@ -162,6 +166,7 @@ function customerEvidencePayload(input = {}) {
 
 function claimHasScopedUncertainty(claim = '') {
   const clause = String(claim || '').trim();
+  if (/(?:尚未|还未|尚无|没有|未)(?:核实|核验|核对|确认|复核)/.test(clause)) return true;
   if (/^(?:当前|目前|暂时|现阶段)?(?:尚未|还没|没有|未|暂无|暂未|不确定|待|需要|需先|要先)/.test(clause)) return true;
   if (/^(?:当前|目前|暂时|现阶段).{0,12}(?:没有返回|未返回|未找到|暂无|尚无)/.test(clause)) return true;
   if (/(?:档期|安排).{0,10}(?:需要看|需看|需要确认|待确认|需要核对|需核对)/.test(clause)) return true;
@@ -242,11 +247,18 @@ function stageNextStepPresent(reply, plan = {}) {
   return NEXT_STEP_PATTERN.test(lastSentence) || countQuestions(lastSentence) === 1;
 }
 
-function riskHandoffPresent(reply = '', plan = {}) {
+function riskHandoffPresent(reply = '', plan = {}, futurePromise = {}) {
   if (!plan.requireHumanHandoff) return true;
   const content = String(reply || '');
-  if (!HUMAN_HANDOFF_ACTION_PATTERN.test(content)) return false;
-  return plan.requiredHumanRole !== 'supervisor' || SUPERVISOR_HANDOFF_PATTERN.test(content);
+  const unsupportedHandoffClaim = (futurePromise.claims || []).some(claim => (
+    claim.action === 'handoff' && claim.requiresEvidence && !claim.evidenceMatched
+  ));
+  const actionPresent = HUMAN_HANDOFF_ACTION_PATTERN.test(content) && !unsupportedHandoffClaim;
+  const requirementPresent = HUMAN_HANDOFF_REQUIRED_PATTERN.test(content);
+  if (!actionPresent && !requirementPresent) return false;
+  return plan.requiredHumanRole !== 'supervisor'
+    || (SUPERVISOR_HANDOFF_PATTERN.test(content) && !unsupportedHandoffClaim)
+    || SUPERVISOR_HANDOFF_REQUIRED_PATTERN.test(content);
 }
 
 function humanBoundaryPresent(input = {}, plan = {}) {
@@ -265,6 +277,7 @@ function makeCheck(id, passed, weight, detail, hard = false) {
 function verifyResponseQuality(input = {}) {
   const reply = String(input.reply ?? input.content ?? '').trim();
   const plan = input.plan || planResponse(input);
+  const futurePromise = verifyFuturePromises({ ...input, reply });
   const priorAssistantExists = (input.messages || []).some(message => toRole(message) === 'assistant');
   const questionCount = countQuestions(reply);
   const shouldBeEmpty = !plan.shouldReply;
@@ -282,13 +295,18 @@ function verifyResponseQuality(input = {}) {
     makeCheck('sensitive_commitment', commitmentsAreSafe(reply), 8, '没有未经证实的保证或绝对承诺', true),
     makeCheck('internal_leakage', !INTERNAL_LEAK_PATTERN.test(reply), 4, '没有外发内部评分或运行字段', true),
   ];
+  const riskHandoffCheck = checks.find(check => check.id === 'risk_handoff');
+  if (riskHandoffCheck) riskHandoffCheck.passed = riskHandoffPresent(reply, plan, futurePromise);
+  checks.splice(checks.length - 1, 0, ...futurePromise.checks
+    .filter(check => check.hard)
+    .map(check => makeCheck(check.id, check.passed, 10, check.detail, true)));
   const total = checks.reduce((sum, check) => sum + check.weight, 0);
   const earned = checks.reduce((sum, check) => sum + (check.passed ? check.weight : 0), 0);
   const score = Math.round((earned / total) * 100);
   const failed = checks.filter(check => !check.passed);
   const passed = failed.every(check => !check.hard) && score >= Number(input.passScore || 82);
   return {
-    version: '1.1',
+    version: '1.2',
     passed,
     score,
     checks,
@@ -299,6 +317,7 @@ function verifyResponseQuality(input = {}) {
       requiresHuman: Boolean(input.requiresHuman && plan.requireHumanHandoff),
     }),
     plan,
+    futurePromise,
   };
 }
 

+ 70 - 1
mcp/src/providers/fmode-agent-transport.js

@@ -8,9 +8,28 @@ const {
   readQiweiUid,
   readQiweiGuid,
   readQiweiApiBase,
+  readQiweiUpstreamToken,
+  readQiweiUpstreamApiBase,
+  readQiweiTransportMode,
 } = require('../core/credentials');
 const { callFmodeWecomGateway } = require('./fmode-wecom-gateway');
 
+function directUpstreamError(raw, httpStatus) {
+  const bizCode = Number(raw?.code ?? httpStatus ?? 0);
+  const error = new Error(
+    bizCode === 422100
+      ? '企微设备实例已失效,请重新扫码登录'
+      : '企微上游服务请求失败'
+  );
+  error.status = Number(httpStatus) || 0;
+  error.bizCode = bizCode;
+  if (bizCode === 422100) {
+    error.kind = 'device';
+    error.reason = 'upstream_device_missing';
+  }
+  return error;
+}
+
 class FmodeQiweiClient {
   constructor(config = {}) {
     this.config = config;
@@ -22,16 +41,27 @@ class FmodeQiweiClient {
       uid: readQiweiUid({ uid: this.config.uid }),
       guid: readQiweiGuid({ guid: this.config.guid, uid: this.config.uid }),
       apiBase: readQiweiApiBase({ apiBase: this.config.apiBase }),
+      transportMode: readQiweiTransportMode({ transportMode: this.config.transportMode }),
+      upstreamToken: readQiweiUpstreamToken({ upstreamToken: this.config.upstreamToken }),
+      upstreamApiBase: readQiweiUpstreamApiBase({ upstreamApiBase: this.config.upstreamApiBase }),
     };
   }
 
   isConfigured() {
     const ctx = this.context();
+    if (ctx.transportMode === 'direct_upstream') {
+      return Boolean(ctx.upstreamToken && ctx.guid && ctx.upstreamApiBase);
+    }
     return Boolean(ctx.token && ctx.uid && ctx.apiBase);
   }
 
   requireContext() {
     const ctx = this.context();
+    if (ctx.transportMode === 'direct_upstream') {
+      if (!ctx.upstreamToken) throw new Error('缺少企微上游服务凭据');
+      if (!ctx.guid) throw new Error('缺少企微设备 guid');
+      return ctx;
+    }
     if (!ctx.token) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录');
     if (!ctx.uid) throw new Error('缺少 Fmode 企微设备 uid,请先完成企微扫码登录');
     return ctx;
@@ -39,6 +69,32 @@ class FmodeQiweiClient {
 
   async call(method, params = {}, options = {}) {
     const ctx = this.requireContext();
+    if (ctx.transportMode === 'direct_upstream') {
+      const controller = new AbortController();
+      const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 30000);
+      try {
+        const response = await fetch(`${ctx.upstreamApiBase}/api/qw/doApi`, {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            'X-QIWEI-TOKEN': ctx.upstreamToken,
+          },
+          body: JSON.stringify({
+            tokenId: ctx.upstreamToken,
+            method,
+            params: { ...params, guid: params.guid || ctx.guid },
+          }),
+          signal: controller.signal,
+        });
+        const raw = await response.json().catch(() => ({}));
+        if (!response.ok || (raw.code !== undefined && Number(raw.code) !== 0)) {
+          throw directUpstreamError(raw, response.status);
+        }
+        return raw.data && raw.data.data !== undefined ? raw.data.data : (raw.data || {});
+      } finally {
+        clearTimeout(timeout);
+      }
+    }
     const result = await callFmodeWecomGateway({
       gatewayPath: '/doApi',
       body: {
@@ -56,6 +112,19 @@ class FmodeQiweiClient {
 
   async checkLogin() {
     const ctx = this.requireContext();
+    if (ctx.transportMode === 'direct_upstream') {
+      const detail = await this.call('/login/checkLogin', {});
+      const online = Number(detail.userOnlineStatus ?? detail.status) === 2;
+      return {
+        ...detail,
+        configured: true,
+        online,
+        userOnlineStatus: online ? 2 : (detail.userOnlineStatus ?? detail.status ?? 0),
+        errorCode: online ? 0 : (detail.errorCode ?? -1),
+        nickname: detail.nickname || detail.userName || '',
+        corpName: detail.corpName || detail.corpFullName || '',
+      };
+    }
     let result;
     try {
       result = await callFmodeWecomGateway({
@@ -286,4 +355,4 @@ class FmodeQiweiClient {
   }
 }
 
-module.exports = { FmodeQiweiClient };
+module.exports = { FmodeQiweiClient, directUpstreamError };

+ 33 - 3
mcp/src/tools/qiwei-login-run.js

@@ -228,11 +228,21 @@ function subscriptionRequiredResult({ uid, detail }) {
 async function qiweiLoginStart(input = {}) {
   const token = readQiweiAuthToken(input);
   if (!token) return authRequiredResult();
-  const uid = ensureQiweiUid(input);
+  let uid = ensureQiweiUid(input);
   const guid = readQiweiGuid(input);
   const apiBase = readQiweiApiBase(input);
+  const loginOptions = {};
+  if (input.forceSwitch === true) loginOptions.forceSwitch = true;
+  if (input.newAccount === true) loginOptions.newAccount = true;
+  if (input.areaCode !== undefined && input.areaCode !== null && input.areaCode !== '') {
+    const areaCode = Number(input.areaCode);
+    if (!Number.isInteger(areaCode) || areaCode < 100000 || areaCode > 999999) {
+      return errorResult('areaCode 必须是 6 位行政区划代码。');
+    }
+    loginOptions.areaCode = areaCode;
+  }
   if (input.persistConfig !== false) saveQiweiClientConfig({ uid, apiBase });
-  if (input.flowUi !== false) {
+  if (input.flowUi !== false && input.forceSwitch !== true) {
     try {
       const { url } = await startLoginFlowServer({
         token,
@@ -285,11 +295,12 @@ async function qiweiLoginStart(input = {}) {
   try {
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/start',
-      body: loginBody(uid, guid),
+      body: loginBody(uid, guid, loginOptions),
       token,
       apiBase
     });
     const data = result.data || {};
+    uid = String(data.uid || uid).trim();
     const qrcodeBase64 = String(data.loginQrcodeBase64Data || '').replace(/^data:image\/\w+;base64,/, '');
     const qrcodeBuffer = qrcodeBase64 ? Buffer.from(qrcodeBase64, 'base64') : null;
 
@@ -395,6 +406,25 @@ async function qiweiLoginCheck(input = {}) {
     const statusText = QR_STATUS[statusCode] || `未知状态 ${statusCode}`;
     const detail = data.detail || {};
     if (statusCode === '2') {
+      if (data.decisionRequired) {
+        return {
+          status: 'needs_seat_decision',
+          assistantMessage: '新企微账号已完成扫码,当前订阅席位已满。请在登录流程页选择新增购买席位、替换已有账号或取消本次登录。',
+          summary: {
+            loggedIn: false,
+            uid,
+            statusCode: 2,
+            decisionRequired: true,
+            seats: data.seats,
+            usedSeats: data.usedSeats
+          },
+          data: { ...data, uid, status: detail },
+          files: [],
+          nextActions: ['在登录流程页完成席位处理'],
+          warnings: [],
+          errors: []
+        };
+      }
       const guid = String(detail.guid || readQiweiGuid(input) || '').trim();
       if (guid && input.persistConfig !== false) {
         saveQiweiClientConfig({ guid, apiBase });

Plik diff jest za duży
+ 4 - 0
package.json


+ 7 - 3
runtime/callback-service/src/personal-polling.mjs

@@ -98,12 +98,14 @@ export class PersonalPollingRuntime {
     let result = await this.listenerApi.getStatus();
     let listener = result?.data?.listener || {};
     let account = result?.data?.account || {};
+    let requiresLogin = account.reason === 'upstream_device_missing';
 
-    if (account.online === false) {
+    if (account.online === false && !requiresLogin) {
       await this.recoverAndRestart(listener);
       result = await this.listenerApi.getStatus();
       listener = result?.data?.listener || {};
       account = result?.data?.account || {};
+      requiresLogin = account.reason === 'upstream_device_missing';
     }
 
     if (!listener.running && account.online !== false) {
@@ -125,10 +127,12 @@ export class PersonalPollingRuntime {
     if (listenerHealthy) this.lastRecoveryError = '';
     this.onState({
       personalPolling: {
-        status: listenerHealthy ? 'running' : 'waiting',
+        status: listenerHealthy ? 'running' : (requiresLogin ? 'needs_login' : 'waiting'),
         syncKey: Number(listener.syncKey || 0),
         startedAt: listener.startedAt || null,
-        lastError: listenerHealthy ? '' : (listener.lastError || this.lastRecoveryError),
+        lastError: listenerHealthy
+          ? ''
+          : (requiresLogin ? account.statusText : '') || listener.lastError || this.lastRecoveryError,
       },
     });
     return listener;

+ 108 - 1
scripts/agent-console-smoke-test.js

@@ -223,6 +223,7 @@ async function main() {
         uid: 'uid-smoke',
         guid: 'guid-smoke',
         apiBase: 'https://gateway.example/api/qiwei',
+        transportMode: 'fmode',
       });
       const account = await client.checkLogin();
       await client.syncMessages(8, 50);
@@ -273,6 +274,53 @@ async function main() {
     }
   });
 
+  await check('直连上游会识别失效设备且不泄露上游错误正文', async () => {
+    const originalFetch = global.fetch;
+    const previousTransportMode = process.env.QIWEI_TRANSPORT_MODE;
+    global.fetch = async () => ({
+      ok: true,
+      status: 200,
+      async json() {
+        return { code: 422100, data: { message: 'private upstream detail' } };
+      },
+    });
+    try {
+      const client = new FmodeQiweiClient({
+        guid: 'guid-stale',
+        transportMode: 'direct_upstream',
+        upstreamToken: 'test-upstream-token',
+        upstreamApiBase: 'https://upstream.example',
+      });
+      await assert.rejects(
+        () => client.checkLogin(),
+        error => error.kind === 'device'
+          && error.reason === 'upstream_device_missing'
+          && error.bizCode === 422100
+          && !error.message.includes('private upstream detail')
+      );
+      assert.equal(client.context().transportMode, 'direct_upstream');
+
+      process.env.QIWEI_TRANSPORT_MODE = 'direct_upstream';
+      const { __testing } = require('../mcp/src/dashboard/agent-service');
+      const directConfig = __testing.loadAgentConfig();
+      assert.equal(directConfig.qiwei.transportMode, 'direct_upstream');
+      assert.equal(directConfig.qiwei.transport, 'direct_upstream');
+
+      const stateContext = setup();
+      try {
+        stateContext.service.qiwei.context = () => ({ transportMode: 'direct_upstream' });
+        stateContext.service.config.qiwei.transport = 'fmode-gateway';
+        assert.equal(stateContext.service.state().qiwei.transport, 'direct_upstream');
+      } finally {
+        stateContext.close();
+      }
+    } finally {
+      global.fetch = originalFetch;
+      if (previousTransportMode === undefined) delete process.env.QIWEI_TRANSPORT_MODE;
+      else process.env.QIWEI_TRANSPORT_MODE = previousTransportMode;
+    }
+  });
+
   await check('多企微账号使用独立工作台数据库和 Claude Session', async () => {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
     const accountA = { uid: 'account-a', guid: 'guid-a', nickname: '账号 A' };
@@ -288,6 +336,65 @@ async function main() {
     assert.equal(configB.qiwei.guid, accountB.guid);
   });
 
+  await check('旧浏览器账号缓存不会覆盖同一 UID 的有效运行时设备', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const qiwei = {
+      context: () => ({ transportMode: 'direct_upstream' }),
+      async call(method, params) {
+        assert.equal(method, '/login/checkLogin');
+        if (params.guid === 'guid-stale') {
+          const error = new Error('stale device');
+          error.kind = 'device';
+          error.reason = 'upstream_device_missing';
+          throw error;
+        }
+        return { userOnlineStatus: 2 };
+      },
+    };
+    const current = { uid: 'account-a', guid: 'guid-current' };
+    assert.equal(
+      await __testing.resolveSwitchGuid(current, { uid: 'account-a', guid: 'guid-stale' }, qiwei),
+      'guid-current',
+    );
+    assert.equal(
+      await __testing.resolveSwitchGuid(current, { uid: 'account-a', guid: 'guid-fresh' }, qiwei),
+      'guid-fresh',
+    );
+    await assert.rejects(
+      () => __testing.resolveSwitchGuid(current, { uid: 'account-b', guid: 'guid-stale' }, qiwei),
+      error => error?.reason === 'upstream_device_missing',
+    );
+  });
+
+  await check('设备 GUID 变化会重建消息游标并保存新设备基线', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    const state = new Map([['sync_key', '9391276'], ['sync_scope', 'old-device-scope']]);
+    const audits = [];
+    const db = {
+      getPollState: (key, fallback = '') => state.get(key) ?? fallback,
+      setPollState: (key, value) => state.set(key, String(value)),
+      intakePolicy: () => ({ mode: 'allowlist_only' }),
+      audit: entry => audits.push(entry),
+    };
+    const qiwei = {
+      context: () => ({ transportMode: 'direct_upstream', uid: 'account-a', guid: 'guid-new' }),
+      isConfigured: () => true,
+      checkLogin: async () => ({ userOnlineStatus: 2 }),
+      syncMessages: async () => ({ syncMsgList: [], travelSyncKey: 8075265 }),
+    };
+    const poller = new __testing.QiweiAgentPoller({
+      config: { allowedSenders: ['contact-1'], initialSyncMaxPages: 10, initialSyncLimit: 100, intervalMs: 60000 },
+      db,
+      qiwei,
+      service: {},
+    });
+    await poller.start();
+    poller.stop();
+    assert.equal(state.get('sync_key'), '8075265');
+    assert.notEqual(state.get('sync_scope'), 'old-device-scope');
+    assert.equal(audits.some(item => item.action === 'poller_cursor_scope_changed'), true);
+  });
+
   await check('自动监听热加载白名单并尊重人工关闭状态', async () => {
     const { __testing } = require('../mcp/src/dashboard/agent-service');
     const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-listener-default-'));
@@ -538,7 +645,7 @@ async function main() {
     const ctx = setup({
       defaultMode: 'autopilot',
       agentRun: async () => ({
-        content: '可以处理,我现在核对执行条件,确认后立即回复您。',
+        content: '可以验证发送失败边界;建议按当前测试步骤继续。',
         confidence: 0.95,
         intent: 'autopilot_send_failure',
         reason: '合格回复用于验证发送失败边界',

+ 157 - 0
scripts/agent-conversation-journey-smoke-test.js

@@ -0,0 +1,157 @@
+'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');
+
+function confirmation(type, id, at) {
+  return { type, id, at, success: true };
+}
+
+function main() {
+  const definition = createJourneyDefinition({
+    id: 'generic-consultation',
+    initialStageId: 'understand',
+    stages: [
+      { id: 'understand', requiredFacts: ['goal'] },
+      { id: 'qualify', requiredFacts: ['constraints.budget', 'constraints.timeline'] },
+      { id: 'recommend', requiredFacts: ['selection'] },
+      { id: 'commit', requiredFacts: ['commitment'] },
+    ],
+  });
+  assert.deepEqual(APPLIED_ACTION_STATUSES, ['planned', 'executed', 'confirmed']);
+  assert.deepEqual(CONFIRMING_EVIDENCE_TYPES, ['send', 'tool', 'handoff']);
+
+  const initial = createJourneyState(definition, { at: '2026-08-06T01:00:00.000Z' });
+  const observed = applyJourneyFacts(definition, initial, {
+    constraints: { timeline: 'soon', budget: 100 },
+    goal: 'compare options',
+  }, { at: '2026-08-06T01:01:00.000Z' });
+  assert.equal(initial.facts.goal, undefined);
+  assert.equal(observed.currentStageId, 'understand');
+  assert.equal(isStageComplete(definition, observed, 'qualify'), true);
+
+  const planned = planAppliedAction(definition, observed, {
+    key: 'reply:message-1',
+    kind: 'reply',
+    transition: { mode: 'auto' },
+    payload: { draftId: 'draft-1' },
+    at: '2026-08-06T01:02:00.000Z',
+  });
+  assert.equal(planned.currentStageId, 'understand');
+  assert.equal(planned.appliedActions[0].status, 'planned');
+
+  const executed = executeAppliedAction(definition, planned, {
+    key: 'reply:message-1',
+    evidence: { type: 'send', id: 'attempt-1', success: false },
+    at: '2026-08-06T01:03:00.000Z',
+  });
+  assert.equal(executed.currentStageId, 'understand');
+  assert.equal(executed.appliedActions[0].status, 'executed');
+
+  assert.throws(() => confirmAppliedAction(definition, executed, {
+    key: 'reply:message-1',
+    evidence: { type: 'draft', id: 'draft-1', success: true },
+  }), /Unsupported action evidence type/);
+  assert.throws(() => confirmAppliedAction(definition, executed, {
+    key: 'reply:message-1',
+    evidence: { type: 'send', id: 'message-1', success: false },
+  }), /successful evidence/);
+
+  const advanced = confirmAppliedAction(definition, executed, {
+    key: 'reply:message-1',
+    evidence: confirmation('send', 'message-1', '2026-08-06T01:04:00.000Z'),
+  });
+  assert.equal(advanced.currentStageId, 'recommend');
+  assert.equal(advanced.appliedActions[0].status, 'confirmed');
+  assert.deepEqual(advanced.appliedActions[0].transitionResult.skippedStageIds, ['qualify']);
+  assert(advanced.stageHistory.some(entry => entry.kind === 'skip' && entry.stageId === 'qualify'));
+
+  const backPlanned = planAppliedAction(definition, advanced, {
+    key: 'handoff:requalify',
+    kind: 'human_handoff',
+    transition: { mode: 'back', targetStageId: 'qualify' },
+  });
+  const backed = confirmAppliedAction(definition, backPlanned, {
+    key: 'handoff:requalify',
+    evidence: confirmation('handoff', 'handoff-1', '2026-08-06T01:05:00.000Z'),
+  });
+  assert.equal(backed.currentStageId, 'qualify');
+  assert.equal(backed.stageHistory.at(-1).kind, 'back');
+
+  const reenterPlanned = planAppliedAction(definition, backed, {
+    key: 'tool:refresh-qualification',
+    kind: 'refresh',
+    transition: { mode: 'reenter', targetStageId: 'qualify' },
+  });
+  const reentered = confirmAppliedAction(definition, reenterPlanned, {
+    key: 'tool:refresh-qualification',
+    evidence: confirmation('tool', 'tool-run-1', '2026-08-06T01:06:00.000Z'),
+  });
+  assert.equal(reentered.currentStageId, 'qualify');
+  assert.equal(reentered.stageHistory.at(-1).kind, 'reenter');
+
+  const skipPlanned = planAppliedAction(definition, reentered, {
+    key: 'reply:direct-commit',
+    kind: 'reply',
+    transition: { mode: 'to', targetStageId: 'commit' },
+  });
+  const skipped = confirmAppliedAction(definition, skipPlanned, {
+    key: 'reply:direct-commit',
+    evidence: confirmation('send', 'message-2', '2026-08-06T01:07:00.000Z'),
+  });
+  assert.equal(skipped.currentStageId, 'commit');
+  assert.deepEqual(skipped.appliedActions.at(-1).transitionResult.skippedStageIds, ['recommend']);
+
+  const deduped = planAppliedAction(definition, skipped, {
+    key: 'reply:direct-commit',
+    kind: 'reply',
+    transition: { mode: 'to', targetStageId: 'commit' },
+  });
+  assert.equal(deduped.appliedActions.length, skipped.appliedActions.length);
+  assert.equal(deduped.sequence, skipped.sequence);
+  assert.throws(() => planAppliedAction(definition, skipped, {
+    key: 'reply:direct-commit',
+    kind: 'different-action',
+    transition: { mode: 'none' },
+  }), /key conflict/);
+
+  const serialized = serializeJourneyState(skipped);
+  assert.equal(serialized, serializeJourneyState(JSON.parse(serialized)));
+  const reordered = {
+    ...skipped,
+    facts: {
+      goal: skipped.facts.goal,
+      constraints: {
+        budget: skipped.facts.constraints.budget,
+        timeline: skipped.facts.constraints.timeline,
+      },
+    },
+  };
+  assert.equal(serialized, serializeJourneyState(reordered));
+  assert(skipped.appliedActions.every(action => APPLIED_ACTION_STATUSES.includes(action.status)));
+
+  process.stdout.write(`${JSON.stringify({
+    status: 'ok',
+    checks: 25,
+    finalStage: skipped.currentStageId,
+    actions: skipped.appliedActions.length,
+    historyEntries: skipped.stageHistory.length,
+  }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+}

+ 268 - 0
scripts/agent-generation-concurrency-smoke-test.js

@@ -0,0 +1,268 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
+const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
+
+const results = [];
+
+function deferred() {
+  let resolve;
+  let reject;
+  const promise = new Promise((resolvePromise, rejectPromise) => {
+    resolve = resolvePromise;
+    reject = rejectPromise;
+  });
+  return { promise, resolve, reject };
+}
+
+function qualifiedOutput(content, profileUpdates = {}) {
+  return {
+    content,
+    confidence: 0.96,
+    intent: 'next_step',
+    reason: 'Deterministic concurrency fixture.',
+    requiresHuman: false,
+    profileUpdates,
+    tasks: [],
+    alerts: [],
+    citations: [],
+    toolTrace: [],
+  };
+}
+
+function setup({ mode = 'review', agentRun, journeyDefinition = null } = {}) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-generation-concurrency-'));
+  const db = new AgentWorkbenchDb(path.join(dir, 'workbench.db'), {
+    defaultMode: mode,
+    autoSendConfidence: 0.8,
+  });
+  const sent = [];
+  const service = new AgentWorkbenchService({
+    db,
+    agent: {
+      modelClient: { isConfigured: () => true },
+      run: agentRun,
+    },
+    qiwei: {
+      isConfigured: () => true,
+      async sendText(toId, content) {
+        sent.push({ toId, content });
+        return { isSendSuccess: true };
+      },
+    },
+    config: {
+      accountKey: 'generation-concurrency-smoke',
+      agent: {
+        apiKey: 'fixture',
+        model: 'fixture',
+        provider: 'fixture',
+        qualityPassScore: 82,
+        journeyDefinition,
+      },
+      qiwei: { allowedSenders: ['contact-1', 'contact-2'] },
+      memory: { enabled: false },
+    },
+  });
+  return {
+    dir,
+    db,
+    sent,
+    service,
+    close() {
+      service.stopBackgroundWorkers();
+      db.close();
+      fs.rmSync(dir, { recursive: true, force: true });
+    },
+  };
+}
+
+async function check(name, fn) {
+  await fn();
+  results.push({ name, status: 'passed' });
+}
+
+async function runStaleMode(mode) {
+  const firstStarted = deferred();
+  const firstReply = deferred();
+  let calls = 0;
+  let active = 0;
+  let maxActive = 0;
+  const ctx = setup({
+    mode,
+    agentRun: async input => {
+      calls += 1;
+      active += 1;
+      maxActive = Math.max(maxActive, active);
+      try {
+        if (calls === 1) {
+          firstStarted.resolve();
+          await firstReply.promise;
+          return qualifiedOutput('第一条旧回复不应落库;建议忽略该结果。', { staleField: 'must-not-persist' });
+        }
+        assert.match(input.inboundContent, /第二条/);
+        return qualifiedOutput('第二条信息已作为当前依据;建议按最新内容继续推进。', { latestField: 'persisted' });
+      } finally {
+        active -= 1;
+      }
+    },
+  });
+  try {
+    const first = ctx.service.ingestInbound({
+      externalId: `${mode}-first`,
+      contactId: 'contact-1',
+      content: '第一条问题,请给建议。',
+    });
+    await firstStarted.promise;
+    const second = ctx.service.ingestInbound({
+      externalId: `${mode}-second`,
+      contactId: 'contact-1',
+      content: '第二条补充,请按最新内容处理。',
+    });
+    assert.equal(ctx.sent.length, 0);
+    firstReply.resolve();
+
+    const firstResult = await first;
+    const secondResult = await second;
+    assert.equal(firstResult.status, 'stale_discarded');
+    assert.equal(secondResult.status, mode === 'review' ? 'pending_review' : mode === 'auto' ? 'sent' : 'autopilot_sent');
+    assert.equal(calls, 2);
+    assert.equal(maxActive, 1);
+    assert.equal(ctx.sent.length, mode === 'review' ? 0 : 1);
+
+    const conversation = ctx.db.getConversationByContactId('contact-1');
+    const profile = ctx.db.getProfile(conversation.id).profile;
+    assert.equal(profile.staleField, undefined);
+    assert.equal(profile.latestField, 'persisted');
+    const drafts = ctx.db.listDrafts({ conversationId: conversation.id });
+    assert.equal(drafts.some(draft => draft.inbound_message_id === firstResult.message.id), false);
+    assert.equal(ctx.db.listConversationAppliedActions(conversation.id).some(action => (
+      action.payload?.inboundMessageId === firstResult.message.id
+    )), false);
+    assert.equal(ctx.db.listAudit(100, conversation.id).filter(item => item.action === 'agent_response_stale_discarded').length, 1);
+  } finally {
+    ctx.close();
+  }
+}
+
+async function main() {
+  for (const mode of ['review', 'auto', 'autopilot']) {
+    await check(`${mode} discards a pending stale generation and serializes the next turn`, () => runStaleMode(mode));
+  }
+
+  await check('direct autopilot and automatic approval reject superseded inbound messages', async () => {
+    const ctx = setup({ mode: 'autopilot', agentRun: async () => qualifiedOutput('当前回复可执行;建议继续推进。') });
+    try {
+      const conversation = ctx.db.ensureConversation('contact-1', 'Fixture');
+      const oldInbound = ctx.db.insertMessage({
+        conversationId: conversation.id,
+        direction: 'inbound',
+        senderType: 'customer',
+        content: '旧问题',
+      }).message;
+      const staleDraft = ctx.db.createDraft({
+        conversationId: conversation.id,
+        inboundMessageId: oldInbound.id,
+        content: '旧草稿',
+        confidence: 0.99,
+        requiresHuman: false,
+        citations: [],
+        toolTrace: [],
+      });
+      ctx.db.insertMessage({
+        conversationId: conversation.id,
+        direction: 'inbound',
+        senderType: 'customer',
+        content: '新问题',
+      });
+      await assert.rejects(() => ctx.service.sendAutopilotReply(conversation, oldInbound, {
+        content: '旧自动回复',
+        qualityPassed: true,
+        requiresHuman: false,
+      }), error => error.code === 'AGENT_RESPONSE_STALE');
+      const autoApproval = await ctx.service.approveDraft(staleDraft.id, { actor: 'agent:auto' });
+      assert.equal(autoApproval.status, 'stale_discarded');
+      assert.equal(autoApproval.draft.status, 'rejected');
+      assert.equal(ctx.sent.length, 0);
+    } finally {
+      ctx.close();
+    }
+  });
+
+  await check('human approval remains available for an intentionally reviewed older draft', async () => {
+    const ctx = setup({ mode: 'review', agentRun: async () => qualifiedOutput('当前回复可执行;建议继续推进。') });
+    try {
+      const conversation = ctx.db.ensureConversation('contact-1', 'Fixture');
+      const oldInbound = ctx.db.insertMessage({ conversationId: conversation.id, direction: 'inbound', senderType: 'customer', content: '旧问题' }).message;
+      const draft = ctx.db.createDraft({
+        conversationId: conversation.id,
+        inboundMessageId: oldInbound.id,
+        content: '人工确认后的旧问题答复',
+        confidence: 0.1,
+        requiresHuman: true,
+        citations: [],
+        toolTrace: [],
+      });
+      ctx.db.insertMessage({ conversationId: conversation.id, direction: 'inbound', senderType: 'customer', content: '新问题' });
+      const result = await ctx.service.approveDraft(draft.id, { actor: 'human', content: '人工明确选择发送的内容' });
+      assert.equal(result.status, 'sent');
+      assert.equal(ctx.sent.length, 1);
+    } finally {
+      ctx.close();
+    }
+  });
+
+  await check('journey remains planned for a draft and advances only after a real send', async () => {
+    const journeyDefinition = {
+      id: 'generic-service-flow',
+      initialStageId: 'understand',
+      stages: [
+        { id: 'understand', requiredFacts: ['goal'], nextStageId: 'qualify' },
+        { id: 'qualify', requiredFacts: ['budget'], nextStageId: 'act' },
+        { id: 'act', requiredFacts: [], nextStageId: null },
+      ],
+    };
+    const ctx = setup({
+      mode: 'review',
+      journeyDefinition,
+      agentRun: async () => qualifiedOutput(
+        '可以围绕当前目标继续推进;建议先整理一个可执行方案。',
+        { goal: 'improve service', budget: 'confirmed range' },
+      ),
+    });
+    try {
+      const generated = await ctx.service.ingestInbound({
+        externalId: 'journey-message',
+        contactId: 'contact-1',
+        content: '目标和投入范围都明确了,请给下一步建议。',
+      });
+      assert.equal(generated.status, 'pending_review');
+      let journey = ctx.db.getConversationJourney(generated.conversation.id);
+      assert.equal(journey.currentStageId, 'understand');
+      assert.equal(journey.status, 'active');
+      assert.equal(journey.appliedActions.length, 1);
+      assert.equal(journey.appliedActions[0].status, 'planned');
+
+      await ctx.service.approveDraft(generated.draft.id, { actor: 'human' });
+      journey = ctx.db.getConversationJourney(generated.conversation.id);
+      assert.equal(journey.currentStageId, 'act');
+      assert.equal(journey.status, 'completed');
+      assert.equal(journey.appliedActions[0].status, 'confirmed');
+      assert.equal(journey.appliedActions[0].confirmationEvidence.type, 'send');
+      assert.deepEqual(journey.state.stageHistory.filter(item => item.kind === 'skip').map(item => item.stageId), ['qualify']);
+    } finally {
+      ctx.close();
+    }
+  });
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: results.length, results }, null, 2)}\n`);
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+});

+ 55 - 9
scripts/agent-response-quality-smoke-test.js

@@ -9,7 +9,7 @@ const RUN_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-response-quality-'
 process.env.QIWEI_OUTPUTS_DIR = path.join(RUN_ROOT, 'outputs');
 
 const { AgentContextBuilder } = require('../mcp/src/core/agent-context-builder');
-const { QiweiAgentRuntime } = require('../mcp/src/core/agent-runtime');
+const { QiweiAgentRuntime, isNoReplyNeededMessage } = require('../mcp/src/core/agent-runtime');
 const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
 const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
 const {
@@ -36,12 +36,12 @@ const LOW_QUALITY_JSONL = `
 `;
 
 const GOOD_QUALITY_JSONL = `
-{"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"费用要按服务范围确认;我现在转人工专员核价,确认后给您对应报价。","requiresHuman":true}
-{"id":"price_check","inbound":"最低能做到多少钱?","reply":"当前最低价需要核验;我现在转人工专员核价,确认后给您准确答复。","requiresHuman":true}
+{"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"费用要按服务范围确认;建议由人工专员核价,核验前不作为最终报价依据。","requiresHuman":true}
+{"id":"price_check","inbound":"最低能做到多少钱?","reply":"当前最低价需要核验;建议由人工专员复核,核验前不作为最终报价依据。","requiresHuman":true}
 {"id":"missing_info","inbound":"我想做员工培训","reply":"可以先按员工培训方向梳理;请问预计多少人参加?确认人数后我给您下一步方案。"}
 {"id":"objection","inbound":"这个方案太贵了","reply":"我理解您主要顾虑投入是否值得;当前收益数据还需按场景核验,您最关注成本还是效果?确认后我补对应依据。"}
-{"id":"urgent","inbound":"今天能安排吗?","reply":"今天能否安排需要看当前档期;我现在核对,确认后尽快回复您。"}
-{"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"我理解您在意的是迟迟未处理;我现在转交主管接管并同步处理进度。","requiresHuman":true}
+{"id":"urgent","inbound":"今天能安排吗?","reply":"今天能否安排暂时不确定;当前档期尚未核实,建议由人工顾问确认时间。"}
+{"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"您在意的是迟迟未处理;建议由主管接管复核,结论出来前按未处理完成看待。","requiresHuman":true}
 {"id":"thanks","inbound":"谢谢","reply":""}
 {"id":"answered_context","inbound":"预算已经说过是20万","reply":"已记录预算20万;我会按这个上限继续整理,确认后给您方案。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
 {"id":"no_repeat","inbound":"我还在考虑","reply":"可以按您的节奏来;我先保留当前条件,有明确时间后再继续。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
@@ -183,7 +183,7 @@ async function main() {
   assert(claimBoundaryCases.every(item => !item.passed && item.failedCheckIds.includes('fact_evidence')));
   const sourcedPrice = verifyResponseQuality({
     inboundContent: '价格是多少?',
-    reply: '已确认价格是 99 元;我现在转人工专员核价并把计价依据发给您。',
+    reply: '已确认价格是 99 元;建议由人工专员复核计价依据。',
     citations: [{ content: '当前价格是 99 元' }],
     requiresHuman: true,
   });
@@ -257,9 +257,31 @@ async function main() {
   assert.notDeepEqual(requestA.budgetReport, {});
   assert.notStrictEqual(requestA.budgetReport, requestB.budgetReport);
 
+  let noReplyModelCalls = 0;
+  const noReplyRuntime = new QiweiAgentRuntime({
+    config: { provider: 'stub', maxToolRounds: 2, qualityPassScore: 82 },
+    knowledge: { search: () => [], contextText: () => '' },
+    modelClient: { async complete() { noReplyModelCalls += 1; throw new Error('pure acknowledgement must not reach the model'); } },
+  });
+  const noReply = await noReplyRuntime.run({
+    conversation: { id: 'runtime-no-reply', mode: 'autopilot' },
+    messages: [{ direction: 'inbound', content: '行,知道了,谢谢' }],
+    profile: { profile: {} },
+    inboundContent: '行,知道了,谢谢',
+  });
+  assert.equal(isNoReplyNeededMessage('行,知道了,谢谢'), true);
+  assert.equal(isNoReplyNeededMessage('行的、明白,谢谢'), true);
+  assert.equal(isNoReplyNeededMessage('行,我还想问价格'), false);
+  assert.equal(isNoReplyNeededMessage('好的,价格是多少?'), false);
+  assert.equal(noReply.status, 'no_reply_needed');
+  assert.equal(noReply.noReplyNeeded, true);
+  assert.equal(noReply.content, '');
+  assert.equal(noReply.qualityScore, null);
+  assert.equal(noReplyModelCalls, 0);
+
   const sequence = [
     modelPayload('您好,感谢您的咨询,请耐心等待。'),
-    modelPayload('今天能否安排需要核对当前档期;我现在核对,确认后尽快回复您。'),
+    modelPayload('今天能否安排暂时不确定;当前档期尚未核实,建议由人工顾问确认时间。'),
   ];
   const runtime = new QiweiAgentRuntime({
     config: { provider: 'stub', maxToolRounds: 2, qualityPassScore: 82 },
@@ -294,8 +316,32 @@ async function main() {
   assert.equal(failedRewrite.requiresHuman, true);
   assert.equal(failedRewrite.fallbackReason, QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED);
 
+  let plannedSystem = '';
+  let plannedContext = null;
+  const plannedRuntime = new QiweiAgentRuntime({
+    config: { provider: 'stub', maxToolRounds: 1, qualityPassScore: 82 },
+    knowledge: { search: () => [], contextText: () => '' },
+    modelClient: {
+      async complete(messages, tools, context) {
+        plannedSystem = messages[0].content;
+        plannedContext = context;
+        return { content: modelPayload('这个方案偏贵的顾虑合理;当前收益没有可核验结论,建议先比较最关键的结果。') };
+      },
+    },
+  });
+  const plannedReply = await plannedRuntime.run({
+    conversation: { id: 'runtime-sales-move', mode: 'review' },
+    messages: [{ direction: 'inbound', content: '这个方案太贵了。' }],
+    profile: { profile: {} },
+    inboundContent: '这个方案太贵了。',
+  });
+  assert.equal(plannedReply.salesMovePlan.primaryMove, 'acknowledge_objection');
+  assert.equal(plannedReply.salesMovePlan.candidateMoves.filter(item => item.selected).length, 1);
+  assert.match(plannedSystem, /本轮唯一主要沟通动作:acknowledge_objection/);
+  assert.match(plannedContext.salesMoveInstruction, /先接住并回应当前顾虑/);
+
   const qualified = {
-    content: '今天能否安排需要核对当前档期;我现在核对,确认后尽快回复您。',
+    content: '今天能否安排暂时不确定;当前档期尚未核实,建议由人工顾问确认时间。',
     confidence: 0.95,
     intent: 'schedule',
     reason: '当前档期需要核验',
@@ -330,7 +376,7 @@ async function main() {
   }
 
   const qualifiedRisk = {
-    content: '最低报价需要核验;我现在转人工专员核价,确认依据后同步给您。',
+    content: '最低报价需要核验;建议由人工专员复核计价依据,核验前不作为最终报价。',
     confidence: 0.95,
     intent: 'pricing',
     reason: '报价需要人工核验',

+ 146 - 0
scripts/agent-sales-move-planner-smoke-test.js

@@ -0,0 +1,146 @@
+'use strict';
+
+const assert = require('assert/strict');
+const {
+  SALES_MOVES,
+  AgentSalesMovePlanner,
+  planSalesMove,
+} = require('../mcp/src/core/agent-sales-move-planner');
+
+function assertSinglePrimary(plan) {
+  assert(SALES_MOVES.includes(plan.primaryMove), plan.primaryMove);
+  assert.equal(plan.candidateMoves.filter(candidate => candidate.selected).length, 1);
+  assert.equal(plan.candidateMoves.find(candidate => candidate.selected).move, plan.primaryMove);
+}
+
+function main() {
+  assert.deepEqual(SALES_MOVES, [
+    'answer', 'acknowledge_objection', 'clarify', 'recommend', 'advance', 'handoff',
+  ]);
+
+  const scenarioPlans = [
+    planSalesMove({ scenario: 'direct_question' }),
+    planSalesMove({ scenario: 'objection' }),
+    planSalesMove({ scenario: 'recommendation_ready' }),
+    planSalesMove({ scenario: 'unknown', missingFields: ['goal'] }),
+    planSalesMove({ scenario: 'continuation', knownFields: { goal: 'retention' } }),
+  ];
+  scenarioPlans.forEach(assertSinglePrimary);
+
+  const knownField = planSalesMove({
+    scenario: 'first_consultation',
+    knownFields: { budget: 20 },
+    missingFields: ['budget'],
+    guidelines: [{
+      id: 'collect-budget',
+      condition: { scenario: 'first_consultation', missingFields: ['budget'] },
+      action: 'clarify',
+      stage: '*',
+      priority: 100,
+      criticality: 'high',
+      track: true,
+    }],
+  });
+  assert.equal(knownField.primaryMove, 'advance');
+  assert.deepEqual(knownField.appliedGuidelines, []);
+
+  const objection = planSalesMove({ scenario: 'objection', knownFields: ['goal'] });
+  assert.equal(objection.primaryMove, 'acknowledge_objection');
+  assert.equal(objection.reason.code, 'scenario_objection');
+
+  const highRisk = planSalesMove({
+    scenario: 'recommendation_ready',
+    riskLevel: 'high',
+    guidelines: [{
+      id: 'recommend-now', condition: 'always', action: 'recommend', stage: '*',
+      priority: 999, criticality: 'critical', track: true,
+    }],
+  });
+  assert.equal(highRisk.primaryMove, 'handoff');
+  assert.equal(highRisk.reason.code, 'high_risk_handoff');
+  assert.equal(highRisk.appliedGuidelines[0].id, 'recommend-now');
+
+  const stableTie = planSalesMove({
+    scenario: 'continuation',
+    guidelines: [
+      { id: 'first', condition: 'always', action: 'recommend', priority: 10, criticality: 'normal', track: true },
+      { id: 'second', condition: 'always', action: 'advance', priority: 10, criticality: 'normal', track: true },
+    ],
+  });
+  assert.equal(stableTie.primaryMove, 'recommend');
+  assert.deepEqual(stableTie.appliedGuidelines.map(item => item.id), ['first', 'second']);
+
+  const completed = planSalesMove({
+    scenario: 'continuation',
+    completedActions: ['collect-goal'],
+    missingFields: ['goal'],
+    guidelines: [
+      { id: 'collect-goal', condition: 'missing:goal', action: 'clarify', priority: 20, criticality: 'high', track: true },
+      { id: 'offer-next-step', condition: 'always', action: 'advance', priority: 10, criticality: 'normal', track: true },
+    ],
+  });
+  assert.equal(completed.primaryMove, 'advance');
+  assert.deepEqual(completed.appliedGuidelines.map(item => item.id), ['offer-next-step']);
+
+  const explicit = new AgentSalesMovePlanner({
+    guidelines: [{
+      id: 'stage-follow-up',
+      condition: { scenario: 'continuation', knownFields: ['goal'], notCompletedActions: ['stage-follow-up'] },
+      action: 'advance',
+      stage: 'follow_up',
+      priority: 30,
+      criticality: 'medium',
+      track: true,
+    }],
+  }).plan({ scenario: 'continuation', stage: 'follow_up', knownFields: ['goal'] });
+  assert.equal(explicit.primaryMove, 'advance');
+  assert.equal(explicit.reason.guidelineId, 'stage-follow-up');
+  assert.deepEqual(explicit.appliedGuidelines[0].matchChecks.map(check => check.passed), [true, true, true]);
+
+  const safeFallback = planSalesMove({
+    scenario: 'unknown',
+    guidelines: [{
+      id: 'unknown-natural-language-rule', condition: 'customer seems interested', action: 'recommend',
+      priority: 100, criticality: 'high', track: true,
+    }],
+  });
+  assert.equal(safeFallback.primaryMove, 'answer');
+  assert.equal(safeFallback.reason.code, 'safe_fallback');
+  assert.deepEqual(safeFallback.appliedGuidelines, []);
+
+  const incompleteRuleFallback = planSalesMove({
+    scenario: 'unknown',
+    guidelines: [
+      { id: 'empty-object', condition: {}, action: 'recommend', priority: 100, criticality: 'high', track: true },
+      { id: 'empty-array', condition: [], action: 'advance', priority: 90, criticality: 'high', track: true },
+    ],
+  });
+  assert.equal(incompleteRuleFallback.primaryMove, 'answer');
+  assert.deepEqual(incompleteRuleFallback.appliedGuidelines, []);
+
+  const stageMismatch = planSalesMove({
+    scenario: 'continuation',
+    stage: 'stage-b',
+    knownFields: ['goal'],
+    guidelines: [{
+      id: 'stage-a-only', condition: 'always', action: 'recommend', stage: 'stage-a',
+      priority: 100, criticality: 'critical', track: true,
+    }],
+  });
+  assert.equal(stageMismatch.primaryMove, 'advance');
+  assert.deepEqual(stageMismatch.appliedGuidelines, []);
+
+  const plans = [
+    ...scenarioPlans, knownField, objection, highRisk, stableTie, completed, explicit, safeFallback,
+    incompleteRuleFallback, stageMismatch,
+  ];
+  plans.forEach(assertSinglePrimary);
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 11, plans: plans.length }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+}

+ 9 - 0
scripts/agent-workspace-ui-smoke-test.js

@@ -70,6 +70,15 @@ check('conversation ordering has explicit invalid-time and stable-tie behavior',
   assert.match(app, /sortAgentItemsByLatest\(conversations\.filter/);
 });
 
+check('runtime account hydration restores fresh-browser identity and online state', () => {
+  const source = functionSource('hydrateCurrentAccountBinding', 'init');
+  assert.match(source, /if \(!active\.uid\) return false/);
+  assert.match(source, /addAccount\(\{/);
+  assert.match(source, /online:\s*Boolean\(active\.online\)/);
+  assert.match(source, /account\.online\s*=\s*Boolean\(active\.online\)/);
+  assert.doesNotMatch(source, /if \(!account\) return false;[\s\S]*?const result = await api/);
+});
+
 check('activity timeline uses existing trace and audit without raw JSON rendering', () => {
   const source = functionSource('renderAgentActivityTimeline', 'scrollAgentMessagesToLatest');
   assert.match(source, /Plan/);

+ 31 - 1
scripts/callback-runtime-smoke-test.mjs

@@ -162,7 +162,37 @@ try {
   assert.equal(personalStates.at(-1).personalPolling.status, 'running');
   assert.equal(personalStates.at(-1).personalPolling.lastError, '');
 
-  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 25 }, null, 2)}\n`);
+  let missingDeviceRecoveryCount = 0;
+  const missingDeviceStates = [];
+  const missingDeviceRuntime = new PersonalPollingRuntime({
+    config: { retryMs: 3000, friendPollingEnabled: false },
+    workspaceRoot: tempRoot,
+    onState: statePatch => missingDeviceStates.push(statePatch),
+    listenerApi: {
+      getStatus: async () => ({
+        data: {
+          account: {
+            online: false,
+            reason: 'upstream_device_missing',
+            statusText: '设备实例已失效,请重新扫码登录',
+          },
+          listener: { running: false, syncKey: 8, lastError: '旧错误' },
+        },
+      }),
+      recoverLogin: async () => {
+        missingDeviceRecoveryCount += 1;
+        return { summary: { loggedIn: false, statusCode: -1 } };
+      },
+      start: async () => ({ data: { running: false } }),
+      stop: async () => {},
+    },
+  });
+  await missingDeviceRuntime.pollOnce();
+  assert.equal(missingDeviceRecoveryCount, 0);
+  assert.equal(missingDeviceStates.at(-1).personalPolling.status, 'needs_login');
+  assert.equal(missingDeviceStates.at(-1).personalPolling.lastError, '设备实例已失效,请重新扫码登录');
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 26 }, null, 2)}\n`);
 } finally {
   fs.rmSync(tempRoot, { recursive: true, force: true });
 }

+ 148 - 0
scripts/login-seat-flow-smoke-test.js

@@ -0,0 +1,148 @@
+const assert = require('assert');
+const http = require('http');
+const {
+  startLoginFlowServer,
+  stopLoginFlowServer
+} = require('../mcp/src/core/login-flow-server');
+
+async function body(req) {
+  const chunks = [];
+  for await (const chunk of req) chunks.push(chunk);
+  if (!chunks.length) return {};
+  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
+}
+
+async function startGateway() {
+  const requests = [];
+  const server = http.createServer(async (req, res) => {
+    const url = new URL(req.url, 'http://localhost');
+    const input = await body(req);
+    requests.push({ path: url.pathname, method: req.method, input, authorization: req.headers.authorization });
+    let data;
+    if (url.pathname === '/subscribe/status') {
+      const quotedSeats = Number(url.searchParams.get('seats'));
+      data = quotedSeats
+        ? { subscribed: true, seats: 1, usedSeats: 1, quote: { seats: quotedSeats, amount: 125 } }
+        : { subscribed: true, seats: 1, usedSeats: 1, price: 500 };
+    } else if (url.pathname === '/login/accounts') {
+      data = { seats: 1, usedSeats: 1, accounts: [{ id: `account_${'a'.repeat(40)}`, nickname: '原账号', corpName: '测试企业', online: false }] };
+    } else if (url.pathname === '/login/status') {
+      data = { configured: true, online: false, statusCode: -1 };
+    } else if (url.pathname === '/login/select') {
+      data = { selected: true, uid: input.uid, accountId: input.accountId, online: false };
+    } else if (url.pathname === '/login/start') {
+      data = { uid: input.newAccount ? 'qiwei-temporary-login' : input.uid, loginQrcodeBase64Data: Buffer.from('mock-png').toString('base64') };
+    } else if (url.pathname === '/login/check') {
+      data = {
+        uid: input.uid,
+        status: 2,
+        detail: { corpId: 'corp-new', userId: 'user-new', nickname: '新账号' },
+        decisionRequired: true,
+        seats: 1,
+        usedSeats: 1,
+        account: { id: `account_${'b'.repeat(40)}`, nickname: '新账号' },
+        accounts: [{ id: `account_${'a'.repeat(40)}`, nickname: '原账号' }],
+        actions: ['purchase', 'replace', 'cancel']
+      };
+    } else if (url.pathname === '/login/resolve') {
+      data = { resolved: true, action: input.action, cancelled: input.action === 'cancel' };
+    } else if (url.pathname === '/subscribe') {
+      data = { seats: input.seats, amount: input.expectedAmount, state: 'active' };
+    } else {
+      res.writeHead(404, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify({ code: 404, mess: 'not found' }));
+      return;
+    }
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ code: 200, data }));
+  });
+  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+  return {
+    apiBase: `http://127.0.0.1:${server.address().port}`,
+    requests,
+    close: () => new Promise(resolve => server.close(resolve))
+  };
+}
+
+async function main() {
+  const gateway = await startGateway();
+  const port = 4397;
+  try {
+    const { url } = await startLoginFlowServer({
+      token: 'sk-login-seat-smoke',
+      apiBase: gateway.apiBase,
+      uid: 'qiwei-client-one',
+      port
+    });
+    const page = await fetch(url).then(res => res.text());
+    const checkoutToken = JSON.parse(page.match(/const CHECKOUT_TOKEN = ("[^"]+")/)[1]);
+    const origin = `http://127.0.0.1:${port}`;
+
+    const state = await fetch(`${origin}/flow/state`).then(res => res.json());
+    assert.strictEqual(state.subscribed, true);
+    assert.strictEqual(state.accounts.length, 1);
+
+    const select = await fetch(`${origin}/flow/select-account`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
+      body: JSON.stringify({ accountId: state.accounts[0].id })
+    }).then(res => res.json());
+    assert.strictEqual(select.selected, true);
+
+    await fetch(`${origin}/flow/start-login`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
+      body: JSON.stringify({ newAccount: true })
+    });
+    const decision = await fetch(`${origin}/flow/check`).then(res => res.json());
+    assert.strictEqual(decision.decisionRequired, true);
+    assert.deepStrictEqual(decision.actions, ['purchase', 'replace', 'cancel']);
+
+    const quote = await fetch(`${origin}/flow/upgrade-quote`).then(res => res.json());
+    assert.strictEqual(quote.targetSeats, 2);
+    assert.strictEqual(quote.quote.amount, 125);
+
+    const cancel = await fetch(`${origin}/flow/resolve`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
+      body: JSON.stringify({ checkoutToken, action: 'cancel' })
+    }).then(res => res.json());
+    assert.strictEqual(cancel.cancelled, true);
+
+    const refreshedPage = await fetch(origin).then(res => res.text());
+    const upgradeToken = JSON.parse(refreshedPage.match(/const CHECKOUT_TOKEN = ("[^"]+")/)[1]);
+    await fetch(`${origin}/flow/start-login`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
+      body: JSON.stringify({ newAccount: true })
+    });
+    await fetch(`${origin}/flow/check`);
+    const upgraded = await fetch(`${origin}/flow/upgrade`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
+      body: JSON.stringify({ checkoutToken: upgradeToken, idempotencyKey: 'seat-upgrade-smoke-001' })
+    }).then(res => res.json());
+    assert.strictEqual(upgraded.resolved, true);
+    assert(gateway.requests.some(request => request.path === '/subscribe' && request.input.expectedAmount === 125));
+    assert(gateway.requests.some(request => request.path === '/login/resolve' && request.input.action === 'purchase'));
+
+    const replacePage = await fetch(origin).then(res => res.text());
+    const replaceToken = JSON.parse(replacePage.match(/const CHECKOUT_TOKEN = ("[^"]+")/)[1]);
+    await fetch(`${origin}/flow/start-login`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
+      body: JSON.stringify({ newAccount: true })
+    });
+    await fetch(`${origin}/flow/check`);
+    const replaced = await fetch(`${origin}/flow/resolve`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin },
+      body: JSON.stringify({ checkoutToken: replaceToken, action: 'replace', replaceAccountId: state.accounts[0].id })
+    }).then(res => res.json());
+    assert.strictEqual(replaced.resolved, true);
+    assert(gateway.requests.some(request => request.path === '/login/resolve' && request.input.action === 'replace'));
+    assert(gateway.requests.every(request => request.authorization === 'Bearer sk-login-seat-smoke'));
+    console.log('[ok] account selection and purchase, replace, cancel seat decisions');
+  } finally {
+    stopLoginFlowServer();
+    await gateway.close();
+  }
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+});

+ 4 - 0
scripts/release-check.js

@@ -22,6 +22,10 @@ const checks = [
   'diagnosis-actions:smoke',
   'smoke',
   'agent:quality-smoke',
+  'agent:sales-move-smoke',
+  'agent:conversation-journey-smoke',
+  'agent:future-promise-smoke',
+  'agent:concurrency-smoke',
   'agent:smoke',
   'library:smoke',
   'excel:smoke',

+ 174 - 0
scripts/response-future-promise-smoke-test.js

@@ -0,0 +1,174 @@
+'use strict';
+
+const assert = require('assert/strict');
+
+const {
+  FUTURE_PROMISE_REASON_CODES: REASONS,
+  extractActionClaims,
+  normalizeActionEvidence,
+  verifyFuturePromises,
+} = require('../mcp/src/core/response-future-promise-verifier');
+
+let assertions = 0;
+
+function check(condition, message) {
+  assertions += 1;
+  assert(condition, message);
+}
+
+function equal(actual, expected, message) {
+  assertions += 1;
+  assert.equal(actual, expected, message);
+}
+
+function hasReason(result, reason) {
+  check(result.reasonCodes.includes(reason), `${reason} missing: ${result.reasonCodes.join(',')}`);
+}
+
+function successfulTool(tool, result, extra = {}) {
+  return { tool, status: 'completed', result, ...extra };
+}
+
+const noEvidence = verifyFuturePromises({ reply: '我稍后核实库存,再通知您。' });
+equal(noEvidence.passed, false, 'future promises without evidence must fail');
+equal(noEvidence.failedClaimIds.length, 2, 'verify and notify should be independent claims');
+hasReason(noEvidence, REASONS.FUTURE_ACTION_MISSING_EVIDENCE);
+
+const matchingEvidence = verifyFuturePromises({
+  reply: '库存已经核实,结果已通知您。',
+  toolTrace: [successfulTool('inventory_verify', { ok: true, subject: '库存', available: true })],
+  outboundEvidence: { status: 'delivered', subject: '库存', result: { delivered: true } },
+});
+equal(matchingEvidence.passed, true, 'matching successful evidence should pass');
+equal(matchingEvidence.claims.length, 2);
+check(matchingEvidence.claims.every(claim => claim.evidenceMatched), 'all matching claims should carry evidence');
+check(matchingEvidence.claims.every(claim => claim.evidenceRefs.length === 1), 'evidence refs should be stable and compact');
+
+const unrelatedTool = verifyFuturePromises({
+  reply: '库存已经核实。',
+  toolTrace: [successfulTool('knowledge_search', { ok: true, subject: '品牌介绍' })],
+});
+equal(unrelatedTool.passed, false, 'unrelated tool result must not support stock verification');
+hasReason(unrelatedTool, REASONS.ACTION_EVIDENCE_UNRELATED);
+
+const citationBypass = verifyFuturePromises({
+  reply: '我会稍后核实价格。',
+  citations: [{ content: '价格说明' }],
+});
+equal(citationBypass.passed, false, 'citations are not execution evidence');
+hasReason(citationBypass, REASONS.FUTURE_ACTION_MISSING_EVIDENCE);
+
+const scopedUncertainty = verifyFuturePromises({ reply: '价格已经确认,其他信息待核验。' });
+equal(scopedUncertainty.passed, false, 'uncertainty in another clause must not protect a performed claim');
+hasReason(scopedUncertainty, REASONS.PERFORMED_ACTION_MISSING_EVIDENCE);
+
+const conditionalOffer = verifyFuturePromises({ reply: '可以为您核验,是否需要?' });
+equal(conditionalOffer.passed, true, 'conditional proposal is safe');
+equal(conditionalOffer.claims[0].requiresEvidence, false);
+hasReason(conditionalOffer, REASONS.SAFE_CONDITIONAL_OFFER);
+
+const missingInput = verifyFuturePromises({ reply: '请提供订单号,我再为您核实。' });
+equal(missingInput.passed, true, 'requesting missing input before continuing is safe');
+equal(missingInput.claims[0].requiresEvidence, false);
+hasReason(missingInput, REASONS.SAFE_MISSING_INPUT_REQUEST);
+
+const handoffMissing = verifyFuturePromises({ reply: '已经转交人工专员。' });
+equal(handoffMissing.passed, false, 'performed handoff requires evidence');
+equal(handoffMissing.claims[0].action, 'handoff');
+hasReason(handoffMissing, REASONS.PERFORMED_ACTION_MISSING_EVIDENCE);
+
+const handoffPassed = verifyFuturePromises({
+  reply: '已经转交人工专员。',
+  handoffEvidence: { status: 'completed', handoffId: 'handoff-test' },
+});
+equal(handoffPassed.passed, true, 'completed handoff evidence should pass');
+hasReason(handoffPassed, REASONS.PERFORMED_ACTION_VERIFIED);
+
+const outboundNotHandoff = verifyFuturePromises({
+  reply: '已经转交人工专员。',
+  outboundEvidence: { status: 'delivered', result: { delivered: true } },
+});
+equal(outboundNotHandoff.passed, false, 'outbound delivery cannot prove a handoff');
+hasReason(outboundNotHandoff, REASONS.ACTION_EVIDENCE_UNRELATED);
+
+const processingMissing = verifyFuturePromises({ reply: '正在处理您的退款。' });
+equal(processingMissing.passed, false, 'in-progress processing requires evidence');
+hasReason(processingMissing, REASONS.IN_PROGRESS_ACTION_MISSING_EVIDENCE);
+
+const activeCrossCheckMissing = verifyFuturePromises({ reply: '我现在核对执行条件,确认后回复您。' });
+equal(activeCrossCheckMissing.passed, false, 'first-person active cross-check requires evidence');
+hasReason(activeCrossCheckMissing, REASONS.IN_PROGRESS_ACTION_MISSING_EVIDENCE);
+
+const processingPassed = verifyFuturePromises({
+  reply: '正在处理您的退款。',
+  toolTrace: [successfulTool('process_refund', { ok: true, subject: '退款' })],
+});
+equal(processingPassed.passed, true, 'successful matching process evidence should pass');
+hasReason(processingPassed, REASONS.IN_PROGRESS_ACTION_VERIFIED);
+
+const failedEvidence = verifyFuturePromises({
+  reply: '价格已经核实。',
+  toolTrace: [{ tool: 'price_verify', status: 'failed', result: { ok: false, subject: '价格' } }],
+});
+equal(failedEvidence.passed, false, 'failed matching tool must not support a claim');
+hasReason(failedEvidence, REASONS.ACTION_EVIDENCE_FAILED);
+
+const callbackMissing = verifyFuturePromises({ reply: '我会尽快给您回电。' });
+equal(callbackMissing.passed, false, 'callback promise requires successful call evidence');
+equal(callbackMissing.claims[0].action, 'callback');
+
+const callbackPassed = verifyFuturePromises({
+  reply: '已经给您回电。',
+  toolTrace: [successfulTool('call_customer', { success: true, subject: '电话' })],
+});
+equal(callbackPassed.passed, true, 'call evidence should prove callback');
+
+const submitPassed = verifyFuturePromises({
+  reply: '退款申请已经提交。',
+  toolTrace: [successfulTool('submit_refund_request', { ok: true, subject: '退款申请', requestId: 'fixture' })],
+});
+equal(submitPassed.passed, true, 'matching submit evidence should prove submission');
+equal(submitPassed.claims.length, 2, 'refund and submit actions are independently evidenced by one matching tool');
+
+const cancelPassed = verifyFuturePromises({
+  reply: '订单已经取消。',
+  toolTrace: [successfulTool('cancel_order', { ok: true, subject: '订单' })],
+});
+equal(cancelPassed.passed, true, 'matching cancel evidence should pass');
+
+const safeNecessity = verifyFuturePromises({ reply: '当前价格需要核验。' });
+equal(safeNecessity.passed, true, 'honest necessity is not an execution claim');
+equal(safeNecessity.claims.length, 0);
+hasReason(safeNecessity, REASONS.NO_ACTION_CLAIMS);
+
+const noClaims = verifyFuturePromises({ reply: '当前资料不足,请提供订单号。' });
+equal(noClaims.passed, true);
+equal(noClaims.checks.length, 3);
+check(noClaims.checks.every(item => (
+  typeof item.id === 'string'
+  && typeof item.passed === 'boolean'
+  && typeof item.weight === 'number'
+  && typeof item.hard === 'boolean'
+  && typeof item.detail === 'string'
+)), 'checks must keep the stable quality-check shape');
+
+const extracted = extractActionClaims('我稍后核实价格,再通知您。');
+equal(extracted.length, 2);
+check(extracted.every((claim, index) => claim.id === `claim_${index + 1}`), 'claim ids must be deterministic');
+
+const normalized = normalizeActionEvidence({
+  handoffEvidence: { status: 'completed' },
+  outboundEvidence: { delivered: true },
+});
+equal(normalized.length, 2);
+check(normalized.every(item => item.success), 'explicit success is required and preserved');
+check(normalized.some(item => item.actionIds.includes('handoff')));
+check(normalized.some(item => item.actionIds.includes('notify')));
+
+console.log(JSON.stringify({
+  ok: true,
+  assertions,
+  cases: 21,
+  checks: ['future_promise_evidence', 'performed_action_evidence', 'conditional_proposal_safety'],
+  reasonCodes: Object.values(REASONS),
+}, null, 2));

+ 5 - 5
scripts/response-quality-regression-fixtures.js

@@ -133,7 +133,7 @@ const qualifiedCases = [
     id: 'price_handoff_complete',
     input: {
       inboundContent: '最低报价能确认吗?',
-      reply: '最低报价需要核验;我现在转人工专员核价,确认依据后同步给您。',
+      reply: '最低报价需要核验;建议由人工专员复核计价依据,核验前不作为最终报价。',
       requiresHuman: true,
     },
   },
@@ -141,7 +141,7 @@ const qualifiedCases = [
     id: 'eligibility_handoff_complete',
     input: {
       inboundContent: '资格和政策能确定吗?',
-      reply: '资格和政策需要核验;我现在转人工专员确认适用条件并同步依据。',
+      reply: '当前没有可确认的资格结论;建议由人工专员复核适用条件。',
       requiresHuman: true,
     },
   },
@@ -149,7 +149,7 @@ const qualifiedCases = [
     id: 'contract_handoff_complete',
     input: {
       inboundContent: '合同和权属有没有风险?',
-      reply: '合同和权属需要逐项核验;我现在转人工专员检查并同步结论。',
+      reply: '当前没有可确认的权属结论;建议由人工专员逐项复核材料。',
       requiresHuman: true,
     },
   },
@@ -157,7 +157,7 @@ const qualifiedCases = [
     id: 'high_intent_handoff_complete',
     input: {
       inboundContent: '我明天下午想现场看一下。',
-      reply: '明天下午档期需要确认;我现在安排人工顾问接管并确认具体时间。',
+      reply: '明天下午是否可看需要核验;建议由人工顾问确认具体时间。',
       requiresHuman: true,
     },
   },
@@ -165,7 +165,7 @@ const qualifiedCases = [
     id: 'complaint_supervisor_handoff_complete',
     input: {
       inboundContent: '一直没有处理,我要投诉。',
-      reply: '我已记录您反馈的处理延迟;现在转交主管接管并同步处理进度。',
+      reply: '您反馈的是处理延迟;建议由主管接管复核,结论出来前按未处理完成看待。',
       requiresHuman: true,
     },
   },

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

@@ -0,0 +1,155 @@
+'use strict';
+
+const assert = require('assert/strict');
+const { planResponse, plannerInstruction } = require('../mcp/src/core/response-quality-planner');
+const { planSalesMove } = require('../mcp/src/core/agent-sales-move-planner');
+const {
+  buildResponseGenerationStrategy,
+  responseGenerationInstruction,
+} = require('../mcp/src/core/response-generation-strategy');
+const { QiweiAgentRuntime } = require('../mcp/src/core/agent-runtime');
+
+function buildPlan({ inboundContent, knownFields = [], missingFields = [], profile = {}, citations = [], toolTrace = [] }) {
+  const responsePlan = planResponse({ inboundContent, profile, citations, toolTrace });
+  const salesMovePlan = planSalesMove({
+    scenario: responsePlan.scenario,
+    riskLevel: responsePlan.requireHumanHandoff ? 'high' : 'none',
+    riskTypes: responsePlan.riskTypes,
+    requiresHuman: responsePlan.requireHumanHandoff,
+    knownFields,
+    missingFields,
+  });
+  const generationPlan = buildResponseGenerationStrategy({
+    inboundContent,
+    responsePlan,
+    salesMovePlan,
+    profile,
+    citations,
+    toolTrace,
+  });
+  return {
+    responsePlan,
+    salesMovePlan,
+    generationPlan,
+    instruction: responseGenerationInstruction(generationPlan),
+  };
+}
+
+async function main() {
+  const comparison = buildPlan({
+    inboundContent: '两套差20万,我先看哪套?',
+    knownFields: ['daily_commute', 'option_a_price', 'option_b_price', 'option_tradeoffs'],
+    missingFields: ['living_room_is_hard_requirement', 'floor_preference', 'viewing_time'],
+    profile: { daily_commute: true },
+  });
+  assert.equal(comparison.generationPlan.customerQuestion.text, '两套差20万,我先看哪套?');
+  assert.equal(comparison.generationPlan.directAnswer.position, 'opening');
+  assert.equal(comparison.generationPlan.directAnswer.answerBeforeQuestion, true);
+  assert.equal(comparison.generationPlan.judgment.mode, 'bounded_tradeoff');
+  assert.equal(comparison.generationPlan.singleNextStep.maxCount, 1);
+  assert.equal(comparison.generationPlan.singleNextStep.maxQuestions, 1);
+  assert.equal(comparison.generationPlan.singleNextStep.targetField, 'living_room_is_hard_requirement');
+  assert.match(comparison.instruction, /先给有依据的结论或倾向/);
+  assert.match(comparison.instruction, /倾向、取舍或理由/);
+  assert(comparison.instruction.indexOf('1. 先答') < comparison.instruction.indexOf('2. 再判断'));
+  assert(comparison.instruction.indexOf('2. 再判断') < comparison.instruction.indexOf('3. 唯一步'));
+
+  const knownConstraints = buildPlan({
+    inboundContent: '中上楼层可以,就按前面的条件看。',
+    knownFields: ['budget', 'region', 'layout', 'floor'],
+    missingFields: ['budget', 'region', 'layout', 'commute_priority'],
+  });
+  assert.equal(knownConstraints.salesMovePlan.singleNextStep.targetField, 'commute_priority');
+  assert(!['budget', 'region', 'layout'].includes(knownConstraints.salesMovePlan.singleNextStep.targetField));
+  assert.equal(knownConstraints.generationPlan.singleNextStep.maxQuestions, 1);
+
+  const missingEvidence = buildPlan({
+    inboundContent: '这套现在挂牌价是多少,值不值?',
+    knownFields: ['purpose'],
+    missingFields: ['property_id', 'price_evidence'],
+  });
+  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, /不编造具体房源事实/);
+
+  const riskBoundary = buildPlan({
+    inboundContent: '这个房子的产权和税费能确认吗?',
+    knownFields: ['property_id'],
+    missingFields: ['ownership_evidence', 'tax_evidence'],
+  });
+  assert.equal(riskBoundary.salesMovePlan.primaryMove, 'handoff');
+  assert.equal(riskBoundary.generationPlan.singleNextStep.kind, 'human_boundary');
+  assert.equal(riskBoundary.generationPlan.singleNextStep.evidenceRequired, true);
+  assert.equal(riskBoundary.generationPlan.constraints.preserveFactAndActionGates, true);
+  assert.match(riskBoundary.instruction, /没有匹配凭证时/);
+  assert.match(plannerInstruction(riskBoundary.responsePlan), /只有存在真实交接凭证时/);
+
+  const noReply = buildPlan({ inboundContent: '好的' });
+  assert.equal(noReply.generationPlan.directAnswer.required, false);
+  assert.equal(noReply.generationPlan.singleNextStep.maxCount, 0);
+
+  let capturedSystem = '';
+  const runtime = new QiweiAgentRuntime({
+    config: {
+      provider: 'fixture',
+      model: 'fixture',
+      maxToolRounds: 1,
+      qualityPassScore: 82,
+      requiredProfileFields: ['living_room_is_hard_requirement', 'floor_preference', 'viewing_time'],
+      salesGuidelines: [],
+      businessGoal: '先解决客户当前选择,再推进一个决定性输入',
+      stagePlaybook: '比较阶段先给倾向与理由',
+    },
+    knowledge: { contextText: () => '', search: () => [] },
+    modelClient: {
+      async complete(messages) {
+        capturedSystem = String(messages.find(message => message.role === 'system')?.content || '');
+        return {
+          content: JSON.stringify({
+            reply: '按您每天通勤,先看离地铁近的那套。多出的20万如果能换来稳定通勤,优先级通常高于暂时更大的客厅;客厅空间是不是硬要求?',
+            confidence: 0.92,
+            intent: 'compare_options',
+            reason: '先给通勤取舍,再确认一个决定性条件。',
+            requiresHuman: false,
+            profileUpdates: {},
+            tasks: [],
+            alerts: [],
+          }),
+        };
+      },
+    },
+  });
+  const runtimeResult = await runtime.run({
+    conversation: { id: 'sc-gap-01-fixture', mode: 'review' },
+    messages: [
+      { direction: 'inbound', content: '我每天通勤,A离地铁近但客厅小,B离地铁远但客厅大。' },
+      { direction: 'inbound', content: '两套差20万,我先看哪套?' },
+    ],
+    profile: {
+      daily_commute: true,
+      option_a_price: 'customer_provided',
+      option_b_price: 'customer_provided',
+      option_tradeoffs: 'customer_provided',
+    },
+    inboundContent: '两套差20万,我先看哪套?',
+  });
+  assert.match(capturedSystem, /客户本轮真正要解决的问题:两套差20万,我先看哪套/);
+  assert(capturedSystem.indexOf('1. 先答') < capturedSystem.indexOf('3. 唯一步'));
+  assert.equal(runtimeResult.responseGenerationPlan.directAnswer.required, true);
+  assert.equal(runtimeResult.responseGenerationPlan.singleNextStep.targetField, 'living_room_is_hard_requirement');
+  assert.match(runtimeResult.content, /^按您每天通勤,先看/);
+
+  process.stdout.write(`${JSON.stringify({
+    status: 'ok',
+    cases: 5,
+    fixtureRuntimeIntegrations: 1,
+    assertions: 33,
+    contract: ['customerQuestion', 'directAnswer', 'judgment', 'singleNextStep'],
+  }, null, 2)}\n`);
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+});

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików