Przeglądaj źródła

feat: sync generic qiwei skill source

gangvy 1 miesiąc temu
rodzic
commit
43e05eba12
58 zmienionych plików z 2101 dodań i 2302 usunięć
  1. 12 3
      claude-code/claude-code-qiwe-assistant/.env.example
  2. 6 0
      claude-code/claude-code-qiwe-assistant/.gitignore
  3. 6 5
      claude-code/claude-code-qiwe-assistant/README.md
  4. 1 1
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-agent-memory-context.md
  5. 2 2
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-implementation-log.md
  6. 3 3
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-product-development-spec.md
  7. 6 6
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-add-tenant-register-api-plan.md
  8. 4 7
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-mode-guide.md
  9. 0 1
      claude-code/claude-code-qiwe-assistant/install.js
  10. 0 1
      claude-code/claude-code-qiwe-assistant/knowledge-base/README.md
  11. 0 7
      claude-code/claude-code-qiwe-assistant/knowledge-base/catalog.json
  12. 0 136
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyer-tags.json
  13. 0 133
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyers.json
  14. 0 302
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine-pg.js
  15. 0 496
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine.js
  16. 0 35
      claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/properties.json
  17. 2 2
      claude-code/claude-code-qiwe-assistant/knowledge/faq.md
  18. 3 3
      claude-code/claude-code-qiwe-assistant/knowledge/playbooks.md
  19. 6 6
      claude-code/claude-code-qiwe-assistant/knowledge/rules.md
  20. 7 13
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-context-builder.js
  21. 1 67
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-knowledge.js
  22. 7 11
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-memory.js
  23. 20 3
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js
  24. 24 75
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js
  25. 202 10
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-db.js
  26. 166 51
      claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js
  27. 52 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/conversation-order.js
  28. 55 4
      claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js
  29. 11 11
      claude-code/claude-code-qiwe-assistant/mcp/src/core/llm-client.js
  30. 7 6
      claude-code/claude-code-qiwe-assistant/mcp/src/core/voice-clone-service.js
  31. 243 172
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  32. 328 169
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  33. 18 51
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js
  34. 20 20
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  35. 233 110
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  36. 2 134
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js
  37. 5 2
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  38. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-control-run.js
  39. 2 2
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js
  40. 5 5
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js
  41. 5 1
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-voice-run.js
  42. 3 0
      claude-code/claude-code-qiwe-assistant/package.json
  43. 163 83
      claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js
  44. 70 0
      claude-code/claude-code-qiwe-assistant/scripts/agent-conversation-order-smoke-test.js
  45. 160 0
      claude-code/claude-code-qiwe-assistant/scripts/agent-intake-policy-smoke-test.js
  46. 111 0
      claude-code/claude-code-qiwe-assistant/scripts/agent-workspace-ui-smoke-test.js
  47. 58 50
      claude-code/claude-code-qiwe-assistant/scripts/customer-journey-smoke-test.js
  48. 7 7
      claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js
  49. 8 8
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  50. 28 2
      claude-code/claude-code-qiwe-assistant/scripts/voice-clone-smoke-test.js
  51. 8 11
      claude-code/claude-code-qiwe-assistant/scripts/workspace-library-smoke-test.js
  52. 0 1
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  53. 1 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/SKILL.md
  54. 4 6
      claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md
  55. 12 5
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  56. 0 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/output-contracts.md
  57. 3 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md
  58. 0 57
      claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md

+ 12 - 3
claude-code/claude-code-qiwe-assistant/.env.example

@@ -8,6 +8,9 @@ QIWEI_UID=
 # 也可以显式配置平台 sessionToken 或 Fmode API token。
 QIWEI_AUTH_TOKEN=
 
+# 语音合成只接受 Fmode API 控制台 /keys/ 创建的 sk- Token,不复用平台 sessionToken。
+QIWEI_VOICE_AUTH_TOKEN=
+
 # 企业微信接口访问凭据和设备上下文仅由 Fmode 网关管理,客户端无需配置。
 
 # 产品运行模式:personal(个人版,本地主动监听)或 enterprise(企业版,服务端统一回调)。
@@ -42,19 +45,25 @@ QIWEI_AUTO_REPLY_SELF_USER_ID=
 QIWEI_AUTO_REPLY_INTERVAL_MS=10000
 
 # ---- 4320 智能会话 Agent(真实消息 + 人工监管) ----
-# 默认全局暂停;监听可接收白名单消息,但暂停时不会运行或发送 Agent 回复
-QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED=true
+# 默认启动待审核监听;人工关闭后会持久保持关闭,不会被后台自动拉起
+QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED=false
 QIWEI_AGENT_DEFAULT_MODE=review
 QIWEI_AGENT_AUTO_SEND_CONFIDENCE=0.88
 QIWEI_AGENT_DB_PATH=
 QIWEI_AGENT_KNOWLEDGE_DIR=./knowledge
 QIWEI_AGENT_CONTEXT_FILES=personality.md,context.md,rules.md
 QIWEI_AGENT_CONTEXT_CHAR_LIMIT=6000
-QIWEI_AGENT_PROPERTY_DATA_FILE=
 QIWEI_AGENT_INITIAL_SYNC_LIMIT=5000
 QIWEI_AGENT_INITIAL_SYNC_MAX_PAGES=200
 QIWEI_AGENT_STARTUP_GRACE_SECONDS=10
 
+# 新私聊接入默认仅限白名单。可选:allowlist_only / auto_enroll_review / auto_enroll_autopilot。
+# 全自动接入仍必须在 Dashboard 中由管理员二次确认;环境变量不能绕过确认。
+QIWEI_PERSONAL_INTAKE_MODE=allowlist_only
+QIWEI_WELCOME_ENABLED=false
+QIWEI_WELCOME_TEXT=您好,已经收到您的消息,我会尽快为您处理。
+QIWEI_WELCOME_SEND_MODE=draft
+
 # 可选:额外生成便于检索的 JSONL 消息流水。默认关闭;Workbench 数据库归档不受影响。
 QIWEI_MESSAGE_ARCHIVE_ENABLED=0
 

+ 6 - 0
claude-code/claude-code-qiwe-assistant/.gitignore

@@ -1,5 +1,6 @@
 node_modules/
 outputs/
+output/
 .playwright-cli/
 .env
 .env.local
@@ -9,6 +10,11 @@ coverage/
 dist/
 *.tgz
 *.zip
+*.db
+*.db-shm
+*.db-wal
+*.sqlite
+*.sqlite3
 
 # 本地运行日志与企微测试数据
 *.log

+ 6 - 5
claude-code/claude-code-qiwe-assistant/README.md

@@ -1,5 +1,7 @@
 # fmode-qiwei 企业微信助手技能包
 
+本仓库 `master` 是通用版 `fmode-qiwei` 的唯一开发真源。本仓库只维护可复用的企业微信技能、MCP、工作台、监听、安装与发布能力;客户业务定制、真实数据、凭据、运行数据库、会话、日志和房源业务不进入本仓库。
+
 技能包支持两种产品运行模式:
 
 1. **个人版**:ESM Runtime 在客户主机主动轮询,单企微设备和本地数据;
@@ -439,7 +441,7 @@ CLAUDE_CODE_SESSION_MAX_AGE_MS=86400000
 
 Claude Code 返回的结构化结果会经过统一归一化:支持纯 JSON、Markdown JSON 代码块、带说明文字的 JSON,以及 `reply` 内再次嵌套 JSON。最终客户回复出口只接受自然语言;无法安全解析的结构化文本会留空并转人工审核,不会把 JSON 原文发送给客户。
 
-`claude_code_session` 的 `toolTrace` 会记录 `durationMs`、`resumed`、`sessionResetReason`、`systemChars`、`businessPromptChars` 和 `promptChars`。正常恢复 Session 通常快于 Epoch 轮换或失效后的冷启动;排查速度时应先按 `resumed` 分组比较,避免把一次性的冷启动与日常处理耗时混在一起。房源推荐状态只在业务 prompt 注入一次,以减少重复上下文。
+`claude_code_session` 的 `toolTrace` 会记录 `durationMs`、`resumed`、`sessionResetReason`、`systemChars`、`businessPromptChars` 和 `promptChars`。正常恢复 Session 通常快于 Epoch 轮换或失效后的冷启动;排查速度时应先按 `resumed` 分组比较,避免把一次性的冷启动与日常处理耗时混在一起。结构化业务状态只在业务 prompt 注入一次,以减少重复上下文。
 
 长期记忆提取不再阻塞回复草稿落库。主链只写入 `memory_extraction_jobs`,后台 worker 完成规则提取、证据关联和快照刷新;处理中断的任务会在下次打开数据库时恢复为 `pending`,失败任务按指数退避重试,达到 `QIWEI_AGENT_MEMORY_EXTRACTION_MAX_ATTEMPTS` 后才标记为 `failed`。
 
@@ -509,7 +511,6 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 | qiwei-official-schedule | skills/qiwei-official-schedule/SKILL.md | 官方日程与多人空闲时间协调 | qiwei_official_help、qiwei_official_call |
 | qiwei-official-todo | skills/qiwei-official-todo/SKILL.md | 官方待办创建、分配与推进 | qiwei_official_help、qiwei_official_call |
 | qiwei-goal-management | skills/qiwei-goal-management/SKILL.md | 大目标拆解、会议行动项和进度台账 | qiwei_goal_create_plan、qiwei_goal_import_meeting_actions、qiwei_goal_update_task、qiwei_goal_get |
-| qiwei-real-estate-auto-reply | skills/qiwei-real-estate-auto-reply/SKILL.md | 客户独立 Claude Code Session、待审核草稿和人工接管 | qiwei_agent_*、npm run agent:session |
 
 ## 限制
 
@@ -519,11 +520,11 @@ Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部
 - 官方 CLI 首次下载需要 npm 网络访问,首次业务调用前需要独立完成企业微信机器人扫码授权。
 - 官方 CLI 通道失败不会替代或改变原有 Fmode 网关接口通道。
 
-## 房产 AI 智能会话
+## AI 智能会话
 
-技能包内置 30 套脱敏房源以及客户、标签和匹配规则,可直接用于演示;正式客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖
+启动 Dashboard 后,在「智能会话」页点击「管理白名单」,直接从企微联系人中搜索并勾选客户。保存后立即写入当前企微账号的独立 Workbench DB 并热加载,无需手工查找联系人 ID 或重启技能包;扫码切换账号不会复用或覆盖另一账号的白名单、自动纳入名单和监听开关
 
-启动 Dashboard 后,在「智能会话」页点击「管理白名单」,直接从企微联系人中搜索并勾选客户。保存后立即生效并写入客户项目 `.env.local`,无需手工查找联系人 ID 或重启技能包
+个人消息接入默认使用 `allowlist_only`。可在 Agent 设置中显式选择 `auto_enroll_review`,让未知私聊自动加入当前账号白名单但只生成待审核草稿;未知群聊不会自动纳入。`auto_enroll_autopilot` 需要管理员二次确认,通用版会映射到仍受置信度和风险门槛约束的 `auto` 模式。欢迎语默认是幂等草稿,显式选择发送后才真实外发;失败可重试,无法确认发送结果时先人工核对
 
 只有在页面暂时无法读取联系人时,才需要使用下面的手工配置作为兜底;同时推荐复用 Fmode Studio 当前项目的 Claude Code 模型能力:
 

+ 1 - 1
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-agent-memory-context.md

@@ -68,7 +68,7 @@
 1. 企业客服身份、规则和安全边界;
 2. 当前客户核心记忆快照;
 3. 与最新消息相关的较早历史片段;
-4. 当前画像、未完成待办、预警和房源反馈;
+4. 当前画像、未完成待办、预警和业务反馈;
 5. 近期有效会话和最新客户消息;
 6. 结构化输出约束。
 

+ 2 - 2
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-implementation-log.md

@@ -1,7 +1,7 @@
 # 企微社群运营实施记录
 
-> 对应方案:`qiwei-group-operations-product-development-spec.md`  
-> 开始日期:2026-07-18  
+> 对应方案:`qiwei-group-operations-product-development-spec.md`
+> 开始日期:2026-07-18
 > 当前阶段:Phase 3 受控自动化底座已完成,真实自动发送保持关闭
 
 ## 状态说明

+ 3 - 3
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-group-operations-product-development-spec.md

@@ -1,8 +1,8 @@
 # 企微客户群标准化运营与督导——产品开发文档
 
-> 文档状态:方案初稿  
-> 版本:v0.1  
-> 日期:2026-07-18  
+> 文档状态:方案初稿
+> 版本:v0.1
+> 日期:2026-07-18
 > 适用项目:`claude-code/claude-code-qiwe-assistant`
 
 ## 1. 背景与问题

+ 6 - 6
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-add-tenant-register-api-plan.md

@@ -22,9 +22,9 @@ status: implemented
 
 ```text
 RELAY_BASE_URL=http://8.138.37.248:4000
-TENANT_API_KEY=qk_xxx
-TENANT_API_SECRET=xxx
-RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...
+TENANT_API_KEY=<tenant-api-key>
+TENANT_API_SECRET=<tenant-api-secret>
+RELAY_PRIVATE_KEY=<pem-private-key-with-escaped-newlines>
 ```
 
 ## 二、前提条件
@@ -84,9 +84,9 @@ Authorization: Bearer <Fmode token>
   "success": true,
   "relayBaseUrl": "http://8.138.37.248:4000",
   "tenantId": "tenant_550e8400e29b41d4a716446655440000",
-  "apiKey": "qk_d91470c99a3075afd9b582453ee2590d",
-  "apiSecret": "afea80574a9359ed2499967d11f3c7c2c8906b3d239e2d02e6e9a24b2478d178",
-  "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----",
+  "apiKey": "<generated-tenant-api-key>",
+  "apiSecret": "<generated-tenant-api-secret>",
+  "privateKey": "<generated-pem-private-key>",
   "createdAt": "2026-07-16T08:00:00.000Z"
 }
 ```

+ 4 - 7
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-mode-guide.md

@@ -72,12 +72,9 @@ Relay 凭证需要向 Fmode 提供方或你的服务管理员申请。申请时
 
 ```text
 RELAY_BASE_URL=http://8.138.37.248:4000
-TENANT_API_KEY=qk_xxxxxxxxxxxxxxxxxxxxxxxx
-TENANT_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
-RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----
-MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
-...
------END PRIVATE KEY-----
+TENANT_API_KEY=<tenant-api-key>
+TENANT_API_SECRET=<tenant-api-secret>
+RELAY_PRIVATE_KEY=<pem-private-key>
 ```
 
 > **安全提醒**:`TENANT_API_SECRET` 和 `RELAY_PRIVATE_KEY` 是敏感信息,不要截图传播、不要提交到 Git。
@@ -127,7 +124,7 @@ TENANT_API_KEY=qk_xxxxxxxx
 TENANT_API_SECRET=xxxxxxxx
 
 # RSA 私钥,必须写成单行,用 \n 替换真实换行符
-RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----
+RELAY_PRIVATE_KEY=<pem-private-key-with-escaped-newlines>
 ```
 
 > **私钥格式说明**:`.env` 文件中不能包含真实换行符,必须将 PEM 私钥中的每一行换行替换为 `\n` 字面量。程序读取时会自动还原为真实换行符(与源 Qiwei 项目 `lib/relay-config.ts` 的 `getRelayPrivateKey()` 逻辑一致)。

+ 0 - 1
claude-code/claude-code-qiwe-assistant/install.js

@@ -48,7 +48,6 @@ function assertRequiredFiles() {
     'templates/workspace.gitignore',
     'skills/qiwei-dashboard/SKILL.md',
     'skills/qiwei-goal-management/SKILL.md',
-    'skills/qiwei-real-estate-auto-reply/SKILL.md',
   ];
   for (const relative of required) {
     if (!fs.existsSync(path.join(ROOT, relative))) throw new Error(`缺少安装文件:${relative}`);

+ 0 - 1
claude-code/claude-code-qiwe-assistant/knowledge-base/README.md

@@ -5,7 +5,6 @@
 当前目录映射:
 
 - `Agent 规则与知识`:`../knowledge/`
-- `房源与匹配数据`:`../../../huaxiangpipei/src/assets/data/`
 - `知识库运行输出`:`../outputs/knowledge/`
 - `技能包说明`:由 Dashboard 自动扫描源码版、OpenClaw 版和 Agent Workbench 的 `skills/` 目录
 

+ 0 - 7
claude-code/claude-code-qiwe-assistant/knowledge-base/catalog.json

@@ -8,13 +8,6 @@
       "path": "../knowledge",
       "extensions": [".md", ".json", ".csv", ".txt"]
     },
-    {
-      "id": "property-data",
-      "label": "房源与匹配数据",
-      "description": "花巷匹配项目中的房源、客户、标签和匹配引擎",
-      "path": "./property-data",
-      "extensions": [".json", ".js", ".md", ".csv", ".txt"]
-    },
     {
       "id": "meeting-knowledge",
       "label": "企微会议知识沉淀",

+ 0 - 136
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyer-tags.json

@@ -1,136 +0,0 @@
-{
-  "tagTree": {
-    "categories": {
-      "A_基础信息": {
-        "label": "基础信息",
-        "color": "blue",
-        "subcategories": {
-          "A1_年龄": ["A1_25以下","A1_25-30","A1_30-35","A1_35-40","A1_40-45","A1_45-50","A1_50以上"],
-          "A2_家庭结构": ["A2_单身","A2_夫妻无孩","A2_有孩0-3岁","A2_有孩3-6岁","A2_有孩6-12岁","A2_有孩12岁以上","A2_三代同堂","A2_空巢老人"],
-          "A3_职业": ["A3_公务员体制内","A3_企业白领","A3_企业管理层","A3_个体经营","A3_自由职业","A3_退休"],
-          "A4_户籍": ["A4_常州本地","A4_省内迁入","A4_省外迁入"],
-          "A5_当前居住": ["A5_自有住房","A5_租房","A5_与父母同住"],
-          "A6_车辆": ["A6_有车","A6_无车"],
-          "A7_健康": ["A7_无特殊需求","A7_需电梯","A7_需无障碍"]
-        }
-      },
-      "B_购房需求": {
-        "label": "购房需求",
-        "color": "green",
-        "subcategories": {
-          "B1_购房动机": ["B1_首套刚需","B1_婚房","B1_改善置换","B1_学区驱动","B1_投资","B1_养老","B1_分巢"],
-          "B2_预算区间": ["B2_80万以下","B2_80-120万","B2_120-150万","B2_150-200万","B2_200-300万","B2_300万以上"],
-          "B3_预算弹性": ["B3_固定无弹性","B3_可上浮10%","B3_可上浮20%","B3_可上浮30%以上"],
-          "B4_期望户型": ["B4_2室1厅","B4_2室2厅","B4_3室1厅","B4_3室2厅","B4_4室以上","B4_不限"],
-          "B5_面积偏好": ["B5_60㎡以下","B5_60-90㎡","B5_90-110㎡","B5_110-130㎡","B5_130-150㎡","B5_150㎡以上"],
-          "B6_装修要求": ["B6_毛坯可接受","B6_简装可接受","B6_必须精装","B6_必须豪装","B6_不限"],
-          "B7_楼层偏好": ["B7_必须低层","B7_偏好中层","B7_偏好高层","B7_不限","B7_必须电梯"],
-          "B8_朝向要求": ["B8_必须南北通透","B8_南向可接受","B8_不限"]
-        }
-      },
-      "C_区域配套": {
-        "label": "区域与配套",
-        "color": "yellow",
-        "subcategories": {
-          "C1_目标区域": ["C1_新北区","C1_天宁区","C1_钟楼区","C1_武进区","C1_金坛区","C1_溧阳市"],
-          "C2_区域弹性": ["C2_锁定单一小区","C2_锁定单一板块","C2_2-3个板块","C2_全城不限"],
-          "C3_通勤要求": ["C3_必须近地铁","C3_通勤30分钟内","C3_通勤1小时内","C3_不限"],
-          "C4_学区要求": ["C4_必须顶级学区","C4_有学区即可","C4_不需要学区","C4_学区是加分项"],
-          "C5_配套要求": ["C5_必须近商超","C5_必须近医院","C5_必须近公园","C5_配套齐全优先","C5_不限"]
-        }
-      },
-      "D_决策特征": {
-        "label": "决策特征",
-        "color": "purple",
-        "subcategories": {
-          "D1_决策风格": ["D1_冲动型","D1_对比型","D1_谨慎型","D1_感性型","D1_理性数据型"],
-          "D2_决策主体": ["D2_男方主导","D2_女方主导","D2_共同决策","D2_父母主导","D2_子女主导"],
-          "D3_决策周期": ["D3_1个月内","D3_1-3个月","D3_3-6个月","D3_半年以上"],
-          "D4_看房频率": ["D4_每周都看","D4_每月1-2次","D4_偶尔看看","D4_首次看房"],
-          "D5_信息渠道": ["D5_APP贝壳链家","D5_朋友推荐","D5_门店进店","D5_网络端口","D5_老客户转介绍"]
-        }
-      },
-      "E_心理价值观": {
-        "label": "心理与价值观",
-        "color": "pink",
-        "subcategories": {
-          "E1_核心驱动力": ["E1_价格优先","E1_品质优先","E1_学区至上","E1_性价比至上","E1_面子身份认同"],
-          "E2_信任建立": ["E2_需要数据说话","E2_需要情感共鸣","E2_需要权威背书","E2_需要朋友推荐"],
-          "E3_抗性触发": ["E3_怕买贵","E3_怕买错","E3_怕被骗","E3_怕麻烦","E3_怕后悔"],
-          "E4_沟通偏好": ["E4_文字为主","E4_电话为主","E4_面谈为主","E4_被动等待"],
-          "E5_生活方式": ["E5_居家型","E5_社交型","E5_运动型","E5_教育型"]
-        }
-      },
-      "F_特殊标签": {
-        "label": "特殊标签",
-        "color": "red",
-        "subcategories": {
-          "F1_紧急度": ["F1_非常紧急1月内","F1_较紧急3月内","F1_正常","F1_观望中"],
-          "F2_客户等级": ["F2_S级决策人在钱到位","F2_A级需求明确","F2_B级有意向","F2_C级随便看看"],
-          "F3_付款方式": ["F3_全款","F3_商贷","F3_公积金","F3_组合贷"],
-          "F4_特殊需求": ["F4_必须有车位","F4_必须人车分流","F4_不接受开放式厨房","F4_需无障碍设施","F4_养宠物","F4_必须三房","F4_必须明厨明卫"],
-          "F5_明确排斥": ["F5_排斥底层","F5_排斥顶楼","F5_排斥临街","F5_排斥朝北","F5_排斥无电梯","F5_排斥老小区","F5_排斥开放式厨房"]
-        }
-      }
-    }
-  },
-
-  "buyerTags": {
-    "buyer_001": {
-      "type": "婚房刚需型",
-      "tags": [
-        "A1_25-30", "A2_夫妻无孩", "A3_企业白领", "A4_常州本地", "A5_租房", "A6_有车",
-        "B1_婚房", "B2_80-120万", "B3_可上浮30%以上", "B4_3室1厅", "B5_90-110㎡", "B6_必须精装", "B7_偏好中层", "B8_南向可接受",
-        "C1_新北区", "C2_2-3个板块", "C3_通勤30分钟内", "C4_不需要学区", "C5_配套齐全优先",
-        "D1_谨慎型", "D2_共同决策", "D3_1-3个月", "D4_首次看房", "D5_朋友推荐",
-        "E1_性价比至上", "E2_需要数据说话", "E3_怕买贵", "E4_文字为主", "E5_居家型",
-        "F1_较紧急3月内", "F2_A级需求明确", "F3_商贷", "F4_必须有车位", "F5_排斥底层", "F5_排斥顶楼", "F5_排斥临街"
-      ]
-    },
-    "buyer_002": {
-      "type": "学区焦虑型",
-      "tags": [
-        "A1_30-35", "A2_有孩3-6岁", "A3_公务员体制内", "A4_常州本地", "A5_自有住房", "A6_有车",
-        "B1_学区驱动", "B2_120-150万", "B3_可上浮10%", "B4_2室2厅", "B5_60-90㎡", "B6_不限", "B7_不限", "B8_不限",
-        "C1_天宁区", "C2_锁定单一板块", "C3_不限", "C4_必须顶级学区", "C5_不限",
-        "D1_对比型", "D2_共同决策", "D3_1个月内", "D4_每周都看", "D5_APP贝壳链家",
-        "E1_学区至上", "E2_需要数据说话", "E3_怕买错", "E4_电话为主", "E5_教育型",
-        "F1_非常紧急1月内", "F2_S级决策人在钱到位", "F3_公积金", "F5_排斥无电梯"
-      ]
-    },
-    "buyer_003": {
-      "type": "置换改善型",
-      "tags": [
-        "A1_35-40", "A2_三代同堂", "A3_企业管理层", "A4_常州本地", "A5_自有住房", "A6_有车",
-        "B1_改善置换", "B2_150-200万", "B3_可上浮10%", "B4_3室2厅", "B5_110-130㎡", "B6_必须精装", "B7_必须电梯", "B8_必须南北通透",
-        "C1_钟楼区", "C2_2-3个板块", "C3_通勤1小时内", "C4_不需要学区", "C5_必须近公园",
-        "D1_谨慎型", "D2_共同决策", "D3_3-6个月", "D4_每月1-2次", "D5_门店进店",
-        "E1_品质优先", "E2_需要情感共鸣", "E3_怕买贵", "E4_面谈为主", "E5_居家型",
-        "F1_正常", "F2_A级需求明确", "F3_组合贷", "F4_必须人车分流", "F4_必须三房", "F5_排斥底层", "F5_排斥无电梯"
-      ]
-    },
-    "buyer_004": {
-      "type": "隐性需求型",
-      "tags": [
-        "A1_30-35", "A2_有孩0-3岁", "A3_自由职业", "A4_常州本地", "A5_自有住房", "A6_有车",
-        "B1_改善置换", "B2_80-120万", "B3_可上浮20%", "B4_3室2厅", "B5_90-110㎡", "B6_必须精装", "B7_偏好中层", "B8_南向可接受",
-        "C1_金坛区", "C2_锁定单一板块", "C3_不限", "C4_不需要学区", "C5_不限",
-        "D1_冲动型", "D2_女方主导", "D3_3-6个月", "D4_偶尔看看", "D5_朋友推荐",
-        "E1_品质优先", "E2_需要情感共鸣", "E3_怕买错", "E4_面谈为主", "E5_居家型",
-        "F1_正常", "F2_B级有意向", "F3_组合贷", "F5_排斥底层"
-      ]
-    },
-    "buyer_005": {
-      "type": "刚需升级型",
-      "tags": [
-        "A1_25-30", "A2_夫妻无孩", "A3_企业白领", "A4_常州本地", "A5_租房", "A6_有车",
-        "B1_首套刚需", "B2_120-150万", "B3_可上浮30%以上", "B4_3室2厅", "B5_90-110㎡", "B6_必须精装", "B7_偏好高层", "B8_必须南北通透",
-        "C1_武进区", "C2_2-3个板块", "C3_不限", "C4_不需要学区", "C5_配套齐全优先",
-        "D1_冲动型", "D2_男方主导", "D3_1-3个月", "D4_每周都看", "D5_APP贝壳链家",
-        "E1_性价比至上", "E2_需要数据说话", "E3_怕买贵", "E4_文字为主", "E5_社交型",
-        "F1_较紧急3月内", "F2_A级需求明确", "F3_商贷", "F5_排斥底层", "F5_排斥顶楼"
-      ]
-    }
-  }
-}
-

+ 0 - 133
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/buyers.json

@@ -1,133 +0,0 @@
-{
-  "clients": [
-    {
-      "id": "buyer_001",
-      "type": "婚房刚需型",
-      "name": "张先生夫妇",
-      "targetDistricts": ["新北区-薛家", "新北区-三井"],
-      "budgetMin": 80,
-      "budgetMax": 120,
-      "preferredLayouts": ["2室2厅1卫", "3室1厅1卫"],
-      "areaMin": 80,
-      "areaMax": 100,
-      "floorPreference": "中",
-      "preferredOrientations": ["南向", "南北通透"],
-      "purpose": "婚房刚需",
-      "decorationRequirement": "精装",
-      "buildingAgeMax": 15,
-      "schoolDistrictRequired": false,
-      "decisionStyle": "谨慎型",
-      "familyStructure": { "members": 2, "hasElderly": false, "hasKids": false, "planKids": true },
-      "coreConcerns": ["性价比", "未来可改造空间", "通勤便利"],
-      "resistFactors": ["底层", "顶楼", "临街"],
-      "specialRequirements": ["车位或可租车位"],
-      "surfaceBudget": 80,
-      "downPayment": 40,
-      "monthlyPaymentCapacity": 2500
-    },
-    {
-      "id": "buyer_002",
-      "type": "学区焦虑型",
-      "name": "王女士家庭",
-      "targetDistricts": ["天宁区-文化宫", "天宁区-红梅", "天宁区-兰陵"],
-      "budgetMin": 120,
-      "budgetMax": 180,
-      "preferredLayouts": ["2室1厅1卫", "2室2厅1卫", "3室1厅1卫"],
-      "areaMin": 60,
-      "areaMax": 90,
-      "floorPreference": "不限",
-      "preferredOrientations": ["不限"],
-      "purpose": "学区",
-      "decorationRequirement": "不限",
-      "buildingAgeMax": 30,
-      "schoolDistrictRequired": true,
-      "targetSchools": ["局前街小学", "博爱路小学", "解放路小学"],
-      "decisionStyle": "对比型",
-      "familyStructure": { "members": 3, "hasElderly": false, "hasKids": true, "kidAge": 5 },
-      "coreConcerns": ["学区确定性", "学位占用情况", "划片稳定性"],
-      "resistFactors": [],
-      "specialRequirements": [],
-      "surfaceBudget": 150,
-      "downPayment": 60,
-      "monthlyPaymentCapacity": 5000
-    },
-    {
-      "id": "buyer_003",
-      "type": "置换改善型",
-      "name": "李先生家庭",
-      "targetDistricts": ["钟楼区-青枫公园", "武进区-湖塘"],
-      "budgetMin": 150,
-      "budgetMax": 200,
-      "preferredLayouts": ["3室2厅2卫", "4室2厅2卫"],
-      "areaMin": 110,
-      "areaMax": 130,
-      "floorPreference": "中",
-      "preferredOrientations": ["南北通透", "南向"],
-      "purpose": "改善居住",
-      "decorationRequirement": "精装",
-      "buildingAgeMax": 15,
-      "schoolDistrictRequired": false,
-      "decisionStyle": "谨慎型",
-      "familyStructure": { "members": 5, "hasElderly": true, "hasKids": true, "kidAge": 10 },
-      "coreConcerns": ["空间够用", "电梯", "小区品质", "人车分流"],
-      "resistFactors": ["底层", "无电梯"],
-      "specialRequirements": ["电梯", "人车分流", "小区绿化好"],
-      "surfaceBudget": 180,
-      "downPayment": 100,
-      "monthlyPaymentCapacity": 6000,
-      "needSellFirst": true,
-      "oldHouseEstimatedValue": 120
-    },
-    {
-      "id": "buyer_004",
-      "type": "隐性需求型",
-      "name": "赵女士",
-      "targetDistricts": ["金坛区-金坛新城", "溧阳市-燕山新区"],
-      "budgetMin": 80,
-      "budgetMax": 120,
-      "preferredLayouts": ["2室2厅1卫"],
-      "areaMin": 80,
-      "areaMax": 110,
-      "floorPreference": "中",
-      "preferredOrientations": ["南向"],
-      "purpose": "改善居住",
-      "decorationRequirement": "精装",
-      "buildingAgeMax": 15,
-      "schoolDistrictRequired": false,
-      "decisionStyle": "冲动型",
-      "familyStructure": { "members": 3, "hasElderly": false, "hasKids": true, "kidAge": 3 },
-      "coreConcerns": ["装修好看", "小区环境", "离孩子学校近"],
-      "resistFactors": ["底层"],
-      "specialRequirements": [],
-      "surfaceBudget": 80,
-      "downPayment": 30,
-      "monthlyPaymentCapacity": 3000
-    },
-    {
-      "id": "buyer_005",
-      "type": "刚需升级型",
-      "name": "陈先生",
-      "targetDistricts": ["武进区-湖塘", "武进区-大学城"],
-      "budgetMin": 80,
-      "budgetMax": 150,
-      "preferredLayouts": ["2室2厅1卫", "3室2厅2卫"],
-      "areaMin": 85,
-      "areaMax": 120,
-      "floorPreference": "中高",
-      "preferredOrientations": ["南北通透", "南向"],
-      "purpose": "首套刚需",
-      "decorationRequirement": "精装",
-      "buildingAgeMax": 12,
-      "schoolDistrictRequired": false,
-      "decisionStyle": "冲动型",
-      "familyStructure": { "members": 2, "hasElderly": false, "hasKids": false, "planKids": true },
-      "coreConcerns": ["品质感", "户型好", "性价比"],
-      "resistFactors": ["底层", "顶楼"],
-      "specialRequirements": [],
-      "surfaceBudget": 80,
-      "downPayment": 40,
-      "monthlyPaymentCapacity": 4000
-    }
-  ]
-}
-

+ 0 - 302
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine-pg.js

@@ -1,302 +0,0 @@
-#!/usr/bin/env node
-/**
- * 小牛看房 — 房源智能匹配引擎 v2 (PostgreSQL)
- * 用法: node src/assets/data/match-engine-pg.js [--buyer buyer_001] [--top 5]
- */
-
-const { Client } = require('pg');
-
-const PG_CONFIG = {
-  host: 'localhost', port: 5432,
-  user: 'postgres', password: '20061003',
-  database: 'huaxiangpipei'
-};
-
-// ============================================================
-// 数据加载(从 PostgreSQL)
-// ============================================================
-async function loadBuyers(pg) {
-  const res = await pg.query('SELECT * FROM buyers ORDER BY id');
-  if (res.rows.length === 0) {
-    // 没有buyers数据,从JSON加载默认5个客户
-    const json = require('./buyers.json');
-    return json.clients;
-  }
-  return res.rows.map(r => ({
-    id: r.buyer_code || ('buyer_' + r.id),
-    type: r.buyer_type,
-    name: r.name,
-    targetDistricts: r.target_districts || [],
-    budgetMin: parseFloat(r.budget_min) || 0,
-    budgetMax: parseFloat(r.budget_max) || 0,
-    preferredLayouts: r.preferred_layouts || [],
-    areaMin: parseFloat(r.area_min) || 0,
-    areaMax: parseFloat(r.area_max) || 0,
-    floorPreference: r.floor_preference || '中',
-    preferredOrientations: r.preferred_orientations || [],
-    purpose: r.purpose || '',
-    decorationRequirement: r.decoration_requirement || '不限',
-    buildingAgeMax: r.building_age_max || 30,
-    schoolDistrictRequired: r.school_district_required || false,
-    targetSchools: r.target_schools || [],
-    decisionStyle: r.decision_style || '对比型',
-    familyStructure: { members: r.family_members || 2, hasElderly: r.has_elderly || false, hasKids: r.has_kids || false },
-    coreConcerns: r.core_concerns || [],
-    resistFactors: r.resist_factors || [],
-    specialRequirements: r.special_requirements || [],
-    surfaceBudget: parseFloat(r.surface_budget) || 0,
-    downPayment: parseFloat(r.down_payment) || 0,
-    monthlyPaymentCapacity: parseFloat(r.monthly_payment_capacity) || 0,
-    oldHouseEstimatedValue: 0,
-    tags: r.tags || []
-  }));
-}
-
-async function loadProperties(pg) {
-  const res = await pg.query('SELECT * FROM properties ORDER BY total_price');
-  return res.rows;
-}
-
-// ============================================================
-// 权重模板(不变)
-// ============================================================
-// POI别名→名称映射
-const POI_ALIASES = {
-  '三中': '常州市第三中学',
-  '常州三中': '常州市第三中学',
-  '第三中学': '常州市第三中学',
-  '局小': '局前街小学',
-  '局前街': '局前街小学',
-  '博小': '博爱路小学',
-  '博爱路': '博爱路小学',
-  '解小': '解放路小学',
-  '文化宫': '文化宫商圈',
-  '万达': '万达商圈(新北)',
-  '吾悦': '吾悦广场(武进)',
-  '常州站': '地铁1号线常州火车站',
-  '火车站': '地铁1号线常州火车站',
-  '红梅公园': '红梅公园',
-};
-
-const WEIGHT_TEMPLATES = {
-  '婚房刚需型': { priceAdvantage:0.30, layoutMatch:0.15, areaMatch:0.10, decoration:0.10, schoolMatch:0.05, transport:0.10, community:0.05, floorMatch:0.05, orientation:0.05, surrounding:0.05, poiProximity:0.00 },
-  '学区焦虑型': { priceAdvantage:0.15, layoutMatch:0.10, areaMatch:0.05, decoration:0.05, schoolMatch:0.45, transport:0.10, community:0.05, floorMatch:0.02, orientation:0.02, surrounding:0.01, poiProximity:0.00 },
-  '置换改善型': { priceAdvantage:0.20, layoutMatch:0.20, areaMatch:0.20, decoration:0.15, schoolMatch:0.05, transport:0.05, community:0.10, floorMatch:0.03, orientation:0.02, surrounding:0.00, poiProximity:0.00 },
-  '隐性需求型': { priceAdvantage:0.20, layoutMatch:0.10, areaMatch:0.10, decoration:0.25, schoolMatch:0.05, transport:0.10, community:0.10, floorMatch:0.05, orientation:0.03, surrounding:0.02, poiProximity:0.00 },
-  '刚需升级型': { priceAdvantage:0.25, layoutMatch:0.15, areaMatch:0.15, decoration:0.20, schoolMatch:0.05, transport:0.05, community:0.05, floorMatch:0.05, orientation:0.03, surrounding:0.02, poiProximity:0.00 },
-};
-
-const HIGHLIGHT_BONUS = {
-  '豪装': { '隐性需求型':3, '刚需升级型':3 },
-  '精装': { '隐性需求型':2, '刚需升级型':2, '置换改善型':1 },
-  '满五唯一': { '婚房刚需型':2 },
-  '急售': { '婚房刚需型':2, '置换改善型':-1 },
-  '南北通透': { '婚房刚需型':1, '置换改善型':1, '刚需升级型':1, '隐性需求型':1, '学区焦虑型':1 },
-  '近地铁': { '婚房刚需型':2 },
-  '地铁': { '婚房刚需型':2 },
-  '人车分流': { '置换改善型':2 },
-  '总价低': { '婚房刚需型':2, '刚需升级型':1 },
-  '品牌开发商': { '置换改善型':1, '刚需升级型':1 },
-  '次新房': { '婚房刚需型':1, '置换改善型':1, '刚需升级型':1, '隐性需求型':1 },
-};
-
-// ============================================================
-// 匹配核心(与原版完全一致)
-// ============================================================
-function hardFilter(buyer, properties) {
-  const loanCoefficient = 180;
-  const maxLoan = buyer.monthlyPaymentCapacity * loanCoefficient / 10000;
-  const realBudgetMax = Math.max(buyer.budgetMax, (buyer.downPayment + maxLoan) * 0.9);
-
-  return properties.filter(p => {
-    const totalPrice = parseFloat(p.total_price);
-    if (totalPrice > realBudgetMax * 1.1) return false;
-    const districtMatch = buyer.targetDistricts.length === 0 || buyer.targetDistricts.some(d => {
-      const short = d.split('-')[0]; // "新北区-薛家" → "新北区"
-      return (p.district || '').includes(short) || short.includes(p.district || '');
-    });
-    if (!districtMatch) return false;
-    if (p.building_age && p.building_age > buyer.buildingAgeMax) return false;
-    // 学区:同区有学区房则严格过滤,无则放行(真实房源学区数据不完整)
-    if (buyer.schoolDistrictRequired && !p.is_school_district) {
-      const hasSchoolInDistrict = properties.some(x => x.is_school_district && x.district === p.district);
-      if (hasSchoolInDistrict) return false;
-    }
-    if (buyer.resistFactors.includes('底层') && p.is_ground_floor) return false;
-    if (buyer.resistFactors.includes('顶楼') && p.is_top_floor) return false;
-    // 人车分流降为软偏好(贝壳数据无此标注)
-    if (buyer.areaMin > 0 && parseFloat(p.area) < buyer.areaMin * 0.85) return false;
-    // POI距离硬过滤:客户说"三中附近1公里内"
-    if (buyer.nearbyPoi && buyer.nearbyPoi.name) {
-      const poiName = POI_ALIASES[buyer.nearbyPoi.name] || buyer.nearbyPoi.name;
-      const dists = p.poi_distances ? (typeof p.poi_distances === 'string' ? JSON.parse(p.poi_distances) : p.poi_distances) : {};
-      const dist = parseFloat(dists[poiName]);
-      if (dists[poiName] !== undefined && buyer.nearbyPoi.maxDistance && dist > buyer.nearbyPoi.maxDistance) return false;
-    }
-    return true;
-  });
-}
-
-function scoreProperty(buyer, p, weights) {
-  const s = {};
-  const totalPrice = parseFloat(p.total_price);
-  const area = parseFloat(p.area);
-  const tags = p.highlight_tags || [];
-
-  s.priceAdvantage = (p.price_advantage || 5) / 10;
-  s.layoutMatch = buyer.preferredLayouts.includes(p.layout) ? 1.0 :
-    buyer.preferredLayouts.some(pl => pl.split('室')[0] === (p.layout||'').split('室')[0]) ? 0.6 : 0.3;
-  s.areaMatch = area >= buyer.areaMin && area <= buyer.areaMax ? 1.0 :
-    area >= buyer.areaMin * 0.85 && area <= buyer.areaMax * 1.15 ? 0.6 : 0.2;
-  const decoMap = { '豪装':1.0, '精装':0.75, '简装':0.4, '毛坯':0.2 };
-  const reqLevel = buyer.decorationRequirement === '不限' ? 0 : (decoMap[buyer.decorationRequirement] || 0.5);
-  const actLevel = decoMap[p.decoration] || 0.5;
-  s.decoration = actLevel >= reqLevel ? 1.0 : actLevel / Math.max(reqLevel, 0.1);
-  s.schoolMatch = !buyer.schoolDistrictRequired ? 1.0 : (p.is_school_district ? 1.0 : 0.0);
-  s.transport = (p.transport_score || 5) / 10;
-  s.community = (p.community_quality || 5) / 10;
-  const floorMap = { '低':0, '中':1, '高':2 };
-  const bf = floorMap[buyer.floorPreference] ?? 1;
-  const pf = floorMap[p.floor_level] ?? 1;
-  s.floorMatch = Math.abs(bf - pf) === 0 ? 1.0 : Math.abs(bf - pf) === 1 ? 0.6 : 0.3;
-  if (buyer.preferredOrientations.includes('不限')) s.orientation = 0.8;
-  else s.orientation = buyer.preferredOrientations.some(o => (p.orientation||'').includes(o)) ? 1.0 : 0.3;
-  s.surrounding = (p.surrounding_score || 5) / 10;
-
-  // POI距离评分:客户说"三中附近"→ 越近分越高
-  if (buyer.nearbyPoi && buyer.nearbyPoi.name && buyer.nearbyPoi.maxDistance) {
-    const poiName = POI_ALIASES[buyer.nearbyPoi.name] || buyer.nearbyPoi.name;
-    const dists = p.poi_distances ? (typeof p.poi_distances === 'string' ? JSON.parse(p.poi_distances) : p.poi_distances) : {};
-    const dist = parseFloat(dists[poiName]);
-    if (!isNaN(dist) && buyer.nearbyPoi.maxDistance > 0) {
-      s.poiProximity = Math.max(0, 1 - dist / buyer.nearbyPoi.maxDistance);
-    } else {
-      s.poiProximity = 0.5;
-    }
-  } else {
-    s.poiProximity = 0.5;
-  }
-
-  let total = 0, totalW = 0;
-  for (const [k, w] of Object.entries(weights)) {
-    if (s[k] !== undefined) { total += s[k] * w; totalW += w; }
-  }
-  return totalW > 0 ? (total / totalW) * 100 : 0;
-}
-
-function calcHighlightBonus(buyerType, tags) {
-  let bonus = 0;
-  for (const tag of (tags || [])) {
-    const b = HIGHLIGHT_BONUS[tag];
-    if (b && b[buyerType]) bonus += b[buyerType];
-  }
-  return Math.min(bonus, 5);
-}
-
-function calcPsychBonus(buyer, p) {
-  let bonus = 0;
-  const tags = p.highlight_tags || [];
-  if (buyer.decisionStyle === '对比型' && (p.price_advantage || 5) >= 7) bonus += 2;
-  if (buyer.decisionStyle === '谨慎型' && !p.is_ground_floor && !p.is_top_floor) bonus += 2;
-  if ((buyer.decisionStyle === '冲动型' || buyer.type === '隐性需求型') && tags.some(t => t.includes('豪装'))) bonus += 3;
-  if (buyer.type === '置换改善型' && tags.includes('人车分流') && (p.community_quality || 5) >= 8) bonus += 1;
-  return Math.min(bonus, 3);
-}
-
-function generateCons(p) {
-  const cons = [];
-  if (p.building_age >= 20) cons.push('房龄较老,需关注管道老化和渗水情况');
-  if (p.building_age >= 25) cons.push('房龄超过25年,贷款年限可能受限');
-  if (p.decoration === '简装') cons.push('装修简单,入住前可能需翻新');
-  if (p.decoration === '毛坯') cons.push('毛坯房,需额外准备装修预算约10-15万');
-  if (p.is_ground_floor) cons.push('底层,需关注防潮和隐私问题');
-  if (p.is_top_floor) cons.push('顶楼,夏季较热,需关注防水');
-  if ((p.parking || '无') === '无') cons.push('无车位,周边停车可能不便');
-  if ((p.community_quality || 5) <= 4) cons.push('小区品质一般');
-  if ((p.transport_score || 5) <= 5) cons.push('交通便利度一般');
-  if ((p.floor_level === '低') && !p.is_ground_floor) cons.push('低楼层,采光可能受遮挡影响');
-  return cons.length > 0 ? cons : ['无明显硬伤,整体较为均衡'];
-}
-
-function match(buyer, properties, topN = 5) {
-  const weights = WEIGHT_TEMPLATES[buyer.type] || WEIGHT_TEMPLATES['婚房刚需型'];
-  const candidates = hardFilter(buyer, properties);
-
-  const scored = candidates.map(p => {
-    const s = scoreProperty(buyer, p, weights);
-    const hb = calcHighlightBonus(buyer.type, p.highlight_tags || []);
-    const pb = calcPsychBonus(buyer, p);
-    const final = s + hb + pb;
-    return { property: p, finalScore: final, stage2: s, highlightBonus: hb, psychBonus: pb,
-      level: final >= 82 ? '强烈推荐' : final >= 72 ? '推荐' : final >= 62 ? '备选' : '不推荐' };
-  });
-
-  scored.sort((a, b) => b.finalScore - a.finalScore);
-  return scored.slice(0, topN);
-}
-
-// ============================================================
-// 输出
-// ============================================================
-function printResults(buyer, results) {
-  console.log(`\n${'='.repeat(70)}`);
-  console.log(`🎯 ${buyer.name}(${buyer.type})| 预算${buyer.surfaceBudget}万 | 首付${buyer.downPayment}万`);
-  console.log(`   区域: ${buyer.targetDistricts.join('、')} | 关注: ${buyer.coreConcerns.join(' > ')}`);
-  console.log(`${'='.repeat(70)}`);
-
-  if (results.length === 0) {
-    console.log('\n⚠️ 无匹配房源');
-    return;
-  }
-
-  results.forEach((r, i) => {
-    const p = r.property;
-    const cons = generateCons(p);
-    console.log(`\n${'─'.repeat(70)}`);
-    console.log(`🏠 #${i+1} [${r.level}] ${p.community} · ${p.layout} · ${p.total_price}万`);
-    console.log(`   综合 ${r.finalScore.toFixed(1)} (基础${r.stage2.toFixed(1)}+卖点${r.highlightBonus}+心理${r.psychBonus})`);
-    console.log(`   ${p.district} | ${p.area}㎡ | ${p.floor_info||p.floor_level} | ${p.orientation} | ${p.decoration} | ${p.building_age}年`);
-    console.log(`   亮点: ${(p.highlight_tags||[]).join(' · ')}`);
-    console.log(`   ⚠️ ${cons.join(';')}`);
-    console.log(`   来源: ${p.source}`);
-  });
-
-  console.log(`\n${'─'.repeat(70)}`);
-  console.log(`📊 ${results.length}条结果\n`);
-}
-
-// ============================================================
-// 入口
-// ============================================================
-async function main() {
-  const args = process.argv.slice(2);
-  const buyerId = args.includes('--buyer') ? args[args.indexOf('--buyer')+1] : null;
-  const topN = args.includes('--top') ? parseInt(args[args.indexOf('--top')+1]) || 5 : 5;
-
-  const pg = new Client(PG_CONFIG);
-  await pg.connect();
-
-  const buyers = await loadBuyers(pg);
-  const properties = await loadProperties(pg);
-
-  console.log(`📦 数据库: ${buyers.length} 客户, ${properties.length} 房源 (${properties.filter(p=>p.source==='scraped').length}真实 + ${properties.filter(p=>p.source==='mock').length}模拟)\n`);
-
-  const targets = buyerId ? buyers.filter(b => b.id === buyerId || b.name?.includes(buyerId)) : buyers;
-  if (targets.length === 0) {
-    console.error(`❌ 未找到: ${buyerId}`);
-    console.error(`   可用: ${buyers.map(b => b.id + ':' + b.name).join(', ')}`);
-    process.exit(1);
-  }
-
-  for (const buyer of targets) {
-    const results = match(buyer, properties, topN);
-    printResults(buyer, results);
-  }
-
-  await pg.end();
-}
-
-if (require.main === module) {
-  main().catch(e => { console.error(e.message); process.exit(1); });
-}
-

+ 0 - 496
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/match-engine.js

@@ -1,496 +0,0 @@
-#!/usr/bin/env node
-/**
- * 小牛看房 — 房源智能匹配引擎 v1.0
- *
- * 用法:
- *   node match-engine.js                          # 匹配全部客户
- *   node match-engine.js --buyer buyer_001        # 匹配单个客户
- *   node match-engine.js --buyer buyer_001 --top 3 # 输出 Top-3
- */
-
-const fs = require('fs');
-const path = require('path');
-
-// ============================================================
-// 一、数据加载
-// ============================================================
-
-function loadData() {
-  const buyers = JSON.parse(
-    fs.readFileSync(path.join(__dirname, 'buyers.json'), 'utf8')
-  ).clients;
-  const properties = JSON.parse(
-    fs.readFileSync(path.join(__dirname, 'properties.json'), 'utf8')
-  ).properties;
-  return { buyers, properties };
-}
-
-// ============================================================
-// 二、五类客户权重模板(来源:经纪人访谈 + 客户画像分析)
-// ============================================================
-
-const WEIGHT_TEMPLATES = {
-  '婚房刚需型': {
-    priceAdvantage: 0.30,
-    layoutMatch: 0.15,
-    areaMatch: 0.10,
-    decoration: 0.10,
-    schoolMatch: 0.05,
-    transport: 0.10,
-    community: 0.05,
-    floorMatch: 0.05,
-    orientation: 0.05,
-    surrounding: 0.05,
-  },
-  '学区焦虑型': {
-    priceAdvantage: 0.15,
-    layoutMatch: 0.10,
-    areaMatch: 0.05,
-    decoration: 0.05,
-    schoolMatch: 0.45,
-    transport: 0.10,
-    community: 0.05,
-    floorMatch: 0.02,
-    orientation: 0.02,
-    surrounding: 0.01,
-  },
-  '置换改善型': {
-    priceAdvantage: 0.20,
-    layoutMatch: 0.20,
-    areaMatch: 0.20,
-    decoration: 0.15,
-    schoolMatch: 0.05,
-    transport: 0.05,
-    community: 0.10,
-    floorMatch: 0.03,
-    orientation: 0.02,
-    surrounding: 0.00,
-  },
-  '隐性需求型': {
-    priceAdvantage: 0.20,
-    layoutMatch: 0.10,
-    areaMatch: 0.10,
-    decoration: 0.25,
-    schoolMatch: 0.05,
-    transport: 0.10,
-    community: 0.10,
-    floorMatch: 0.05,
-    orientation: 0.03,
-    surrounding: 0.02,
-  },
-  '刚需升级型': {
-    priceAdvantage: 0.25,
-    layoutMatch: 0.15,
-    areaMatch: 0.15,
-    decoration: 0.20,
-    schoolMatch: 0.05,
-    transport: 0.05,
-    community: 0.05,
-    floorMatch: 0.05,
-    orientation: 0.03,
-    surrounding: 0.02,
-  },
-};
-
-// ============================================================
-// 三、卖点标签 → 客户类型加分映射(来源:经纪人访谈提炼)
-// ============================================================
-
-const HIGHLIGHT_BONUS = {
-  '豪装': { '隐性需求型': 3, '刚需升级型': 3 },
-  '精装': { '隐性需求型': 2, '刚需升级型': 2, '置换改善型': 1 },
-  '满五唯一': { '婚房刚需型': 2 },
-  '业主急售': { '婚房刚需型': 2, '置换改善型': -1 },
-  '急售': { '婚房刚需型': 2, '置换改善型': -1 },
-  '南北通透': { '婚房刚需型': 1, '置换改善型': 1, '刚需升级型': 1, '隐性需求型': 1, '学区焦虑型': 1 },
-  '明厨明卫': { '置换改善型': 2 },
-  '近地铁': { '婚房刚需型': 2 },
-  '地铁房': { '婚房刚需型': 2 },
-  '人车分流': { '置换改善型': 2 },
-  '近公园': { '置换改善型': 1, '隐性需求型': 1 },
-  '次新房': { '婚房刚需型': 1, '置换改善型': 1, '刚需升级型': 1 },
-  '有车位': { '置换改善型': 1 },
-  '总价低': { '婚房刚需型': 2, '刚需升级型': 1 },
-  '品牌开发商': { '置换改善型': 1, '刚需升级型': 1 },
-  '婚装': { '隐性需求型': 2 },
-};
-
-// ============================================================
-// 四、客户心理 → 房源加分(来源:七步法 + 访谈)
-// ============================================================
-
-function calcPsychologyBonus(buyer, property) {
-  let bonus = 0;
-  const style = buyer.decisionStyle;
-  const type = buyer.type;
-
-  // 对比型 — 性价比突出
-  if (style === '对比型' && property.priceAdvantage >= 7) bonus += 2;
-
-  // 谨慎型 — 无硬伤、产权清晰
-  if (style === '谨慎型' && property.isFiveYearOnly && !property.isGroundFloor && !property.isTopFloor) {
-    bonus += 2;
-  }
-
-  // 感性/冲动型(隐性需求、刚需升级)— 装修亮眼
-  if ((style === '冲动型' || type === '隐性需求型') && ['豪装'].some(t => property.highlightTags.includes(t))) {
-    bonus += 3;
-  }
-  if ((style === '冲动型' || type === '刚需升级型') && ['豪装', '婚装'].some(t => property.highlightTags.includes(t))) {
-    bonus += 2;
-  }
-
-  // 置换改善 — 人车分流+绿化好
-  if (type === '置换改善型' && property.highlightTags.includes('人车分流') && property.communityQuality >= 8) {
-    bonus += 1;
-  }
-
-  return Math.min(bonus, 3);
-}
-
-// ============================================================
-// 五、阶段 1:硬约束过滤
-// ============================================================
-
-function hardFilter(buyer, properties) {
-  // 反推真实预算上限
-  const loanCoefficient = 180; // 简化:30年贷款系数
-  const maxLoan = buyer.monthlyPaymentCapacity * loanCoefficient / 10000;
-  const realBudgetMax = Math.max(
-    buyer.budgetMax,
-    (buyer.downPayment + maxLoan) * 0.9
-  );
-
-  return properties.filter(p => {
-    // F1: 预算上限(真实上限×1.1)
-    if (p.totalPrice > realBudgetMax * 1.1) return false;
-
-    // F2: 区域不匹配
-    const districtMatch = buyer.targetDistricts.some(d => p.district.startsWith(d));
-    if (!districtMatch) return false;
-
-    // F3: 房龄超标
-    if (p.buildingAge > buyer.buildingAgeMax) return false;
-
-    // F4: 必须学区
-    if (buyer.schoolDistrictRequired && !p.isSchoolDistrict) return false;
-    if (buyer.schoolDistrictRequired && buyer.targetSchools && !buyer.targetSchools.includes(p.schoolName)) return false;
-
-    // F5: 排斥项
-    if (buyer.resistFactors.includes('底层') && p.isGroundFloor) return false;
-    if (buyer.resistFactors.includes('顶楼') && p.isTopFloor) return false;
-    if (buyer.resistFactors.includes('临街') && (p.highlightTags.includes('临街') || p.communityQuality <= 3)) return false;
-
-    // F6: 特殊需求
-    if (buyer.specialRequirements.includes('车位或可租车位') && p.parking === '无') return false;
-    if (buyer.specialRequirements.includes('电梯') && !p.highlightTags.includes('电梯') && p.floorLevel !== '低') {
-      // 简单判断:如果是低楼层且没有电梯标签,可能无电梯
-    }
-    if (buyer.specialRequirements.includes('人车分流') && !p.highlightTags.includes('人车分流')) return false;
-
-    // F7: 面积硬下限
-    if (p.area < buyer.areaMin * 0.85) return false;
-
-    return true;
-  });
-}
-
-// ============================================================
-// 六、阶段 2:加权评分
-// ============================================================
-
-function scoreDimension(buyer, property, weights) {
-  const scores = {};
-
-  // D1: 价格优势
-  scores.priceAdvantage = property.priceAdvantage / 10;
-
-  // D2: 户型匹配
-  const layoutExact = buyer.preferredLayouts.includes(property.layout);
-  const layoutPartial = buyer.preferredLayouts.some(pl => {
-    const pMatch = pl.match(/(\d+)室(\d+)厅/);
-    const propMatch = property.layout.match(/(\d+)室(\d+)厅/);
-    if (pMatch && propMatch) {
-      const pRooms = parseInt(pMatch[1]);
-      const propRooms = parseInt(propMatch[1]);
-      return pRooms === propRooms; // 室数相同算部分匹配
-    }
-    return false;
-  });
-  scores.layoutMatch = layoutExact ? 1.0 : layoutPartial ? 0.6 : 0.3;
-
-  // D3: 面积匹配
-  if (property.area >= buyer.areaMin && property.area <= buyer.areaMax) {
-    scores.areaMatch = 1.0;
-  } else if (property.area >= buyer.areaMin * 0.85 && property.area <= buyer.areaMax * 1.15) {
-    const center = (buyer.areaMin + buyer.areaMax) / 2;
-    const deviation = Math.abs(property.area - center) / center;
-    scores.areaMatch = Math.max(0.3, 1 - deviation * 2);
-  } else {
-    scores.areaMatch = 0.2;
-  }
-
-  // D4: 装修品质
-  const decorationMap = { '豪装': 1.0, '精装': 0.75, '简装': 0.4, '毛坯': 0.2 };
-  const requiredDecoMap = { '豪装': 1.0, '精装': 0.75, '简装': 0.4, '不限': 0.0 };
-  const requiredLevel = requiredDecoMap[buyer.decorationRequirement] || 0.5;
-  const actualLevel = decorationMap[property.decoration] || 0.5;
-  scores.decoration = actualLevel >= requiredLevel ? 1.0 : actualLevel / Math.max(requiredLevel, 0.1);
-
-  // D5: 学区匹配
-  if (!buyer.schoolDistrictRequired) {
-    scores.schoolMatch = 1.0; // 不在乎学区,不影响评分
-  } else {
-    if (buyer.targetSchools && buyer.targetSchools.includes(property.schoolName)) {
-      scores.schoolMatch = 1.0;
-    } else if (property.isSchoolDistrict) {
-      scores.schoolMatch = 0.5;
-    } else {
-      scores.schoolMatch = 0.0;
-    }
-  }
-
-  // D6: 交通便利
-  scores.transport = property.transportScore / 10;
-
-  // D7: 小区品质
-  scores.community = property.communityQuality / 10;
-
-  // D8: 楼层匹配
-  const floorMap = { '低': 0, '中': 1, '高': 2 };
-  const buyerFloor = floorMap[buyer.floorPreference] !== undefined ? floorMap[buyer.floorPreference] : 1;
-  const propFloor = floorMap[property.floorLevel] !== undefined ? floorMap[property.floorLevel] : 1;
-  const floorDiff = Math.abs(buyerFloor - propFloor);
-  scores.floorMatch = floorDiff === 0 ? 1.0 : floorDiff === 1 ? 0.6 : 0.3;
-
-  // D9: 朝向匹配
-  if (buyer.preferredOrientations.includes('不限')) {
-    scores.orientation = 0.8;
-  } else {
-    const orientExact = buyer.preferredOrientations.some(o => property.orientation.includes(o));
-    const orientPartial = buyer.preferredOrientations.some(o => property.orientation.includes(o.replace('南北通透', '南').replace('南向', '南')));
-    scores.orientation = orientExact ? 1.0 : orientPartial ? 0.5 : 0.2;
-  }
-
-  // D10: 周边配套
-  scores.surrounding = property.surroundingScore / 10;
-
-  // 加权汇总
-  let totalScore = 0;
-  let totalWeight = 0;
-  for (const [key, weight] of Object.entries(weights)) {
-    if (scores[key] !== undefined) {
-      totalScore += scores[key] * weight;
-      totalWeight += weight;
-    }
-  }
-
-  return {
-    totalScore: totalWeight > 0 ? (totalScore / totalWeight) * 100 : 0,
-    breakdown: scores,
-  };
-}
-
-// ============================================================
-// 七、阶段 3:智能择优(卖点 + 心理加分)
-// ============================================================
-
-function calcHighlightBonus(buyerType, property) {
-  let bonus = 0;
-  for (const tag of property.highlightTags) {
-    const tagBonus = HIGHLIGHT_BONUS[tag];
-    if (tagBonus && tagBonus[buyerType]) {
-      bonus += tagBonus[buyerType];
-    }
-  }
-  return Math.min(bonus, 5);
-}
-
-// ============================================================
-// 八、主匹配函数
-// ============================================================
-
-function matchBuyer(buyer, properties, topN = 5) {
-  const weights = WEIGHT_TEMPLATES[buyer.type] || WEIGHT_TEMPLATES['婚房刚需型'];
-
-  // 阶段 1: 硬约束过滤
-  const candidates = hardFilter(buyer, properties);
-
-  // 阶段 2: 加权评分
-  const scored = candidates.map(p => {
-    const { totalScore, breakdown } = scoreDimension(buyer, p, weights);
-    return { property: p, stage2Score: totalScore, breakdown };
-  });
-
-  // 阶段 3: 智能择优
-  const final = scored.map(s => {
-    const highlightBonus = calcHighlightBonus(buyer.type, s.property);
-    const psychBonus = calcPsychologyBonus(buyer, s.property);
-    const finalScore = s.stage2Score + highlightBonus + psychBonus;
-    return {
-      ...s,
-      highlightBonus,
-      psychBonus,
-      finalScore,
-      level: finalScore >= 90 ? '强烈推荐' : finalScore >= 80 ? '推荐' : finalScore >= 70 ? '备选' : '不推荐',
-    };
-  });
-
-  // 排序
-  final.sort((a, b) => b.finalScore - a.finalScore);
-  return final.slice(0, topN);
-}
-
-// ============================================================
-// 九、沟通策略生成
-// ============================================================
-
-const STRATEGY_TEMPLATES = {
-  '婚房刚需型': {
-    style: '财务顾问+生活规划师',
-    keyScript: (p, buyer) => `这套${p.layout}总价${p.totalPrice}万,首付约${Math.round(p.totalPrice*0.2)}万,月供大概${Math.round(p.totalPrice*0.8*0.0045*10000)}元。两居的客厅够大,以后改成三居也完全没问题。`,
-    precautions: ['帮客户算清"多花10万首付=未来5年不换房"这笔账', '关注女方感受,感性决策占比大'],
-    followUp: '首次推荐后2天发对比分析,1周内约带看',
-  },
-  '学区焦虑型': {
-    style: '政策专家+数据提供者',
-    keyScript: (p, buyer) => `这套对口${p.schoolName},近3年划片都没有变动,入学年限满足要求,学位也未被占用。这是去年该小区划片文件和升学率数据。`,
-    precautions: ['必须准备书面证据:划片文件、学位占用情况', '客户严谨较真,不要口头承诺'],
-    followUp: '首次推荐后立即发学区资料,3天内约实地看房',
-  },
-  '置换改善型': {
-    style: '全流程管家',
-    keyScript: (p, buyer) => `这套${p.layout}目前在售。您的老房子预计能卖${buyer.oldHouseEstimatedValue || 120}万,这套${p.totalPrice}万,贷款${p.totalPrice - (buyer.oldHouseEstimatedValue || 120)}万左右。如果两边节奏配合好,可以实现无缝衔接。`,
-    precautions: ['提供"卖+买"一体化时间线方案', '决策链长,尽量约全家人一起看'],
-    followUp: '了解老房子挂牌进展,同步推送新上房源',
-  },
-  '隐性需求型': {
-    style: '需求翻译官+引导者',
-    keyScript: (p, buyer) => `不用急着定,我们先多看几套对比一下。这套装修是亮点,您看这个阳台,以后周末在这里喝喝茶,感觉很舒服的。`,
-    precautions: ['同一天带看2套差异大的房源做对比', '看房后引导客户说出喜欢/不喜欢哪里'],
-    followUp: '每次带看后现场复盘感受,逐步收敛需求',
-  },
-  '刚需升级型': {
-    style: '品质推手+性价比计算器',
-    keyScript: (p, buyer) => `这套虽然比您预算多了点,但多一个独立书房和主卧套间。多花10万首付,月供只多500块,未来5年不用换房。`,
-    precautions: ['先推达标房源再推品质房源,形成对比', '让客户看到"价值差"而不是"价格差"'],
-    followUp: '先推一套80万,再推120万,让客户自己感受差异',
-  },
-};
-
-function generateStrategy(buyer, property) {
-  const template = STRATEGY_TEMPLATES[buyer.type] || STRATEGY_TEMPLATES['婚房刚需型'];
-  return {
-    style: template.style,
-    openingScript: template.keyScript(property, buyer),
-    precautions: template.precautions,
-    followUp: template.followUp,
-  };
-}
-
-// ============================================================
-// 十、格式化输出
-// ============================================================
-
-// 生成坦诚缺点(来自蓝领岗位匹配的启发:坦诚 > 隐瞒)
-function generateHonestCons(property) {
-  const cons = [];
-  if (property.buildingAge >= 20) cons.push('房龄较老,需关注管道老化和渗水情况');
-  if (property.buildingAge >= 25) cons.push('房龄超过25年,贷款年限可能受限');
-  if (property.decoration === '简装') cons.push('装修简单,入住前可能需翻新');
-  if (property.decoration === '毛坯') cons.push('毛坯房,需额外准备装修预算约10-15万');
-  if (property.isGroundFloor) cons.push('底层,需关注防潮和隐私问题');
-  if (property.isTopFloor) cons.push('顶楼,夏季较热,需关注防水');
-  if (property.parking === '无') cons.push('无车位,周边停车可能不便');
-  if (property.communityQuality <= 4) cons.push('小区品质一般,绿化物业配套有限');
-  if (property.transportScore <= 5) cons.push('交通便利度一般,公交/地铁覆盖较少');
-  if (property.surroundingScore <= 5) cons.push('周边商业配套有限,生活便利度较低');
-  if (!property.isFiveYearOnly && property.buildingAge < 5) cons.push('不满五,交易税费较高');
-  if (property.floorLevel === '低' && !property.isGroundFloor) cons.push('低楼层,采光可能受遮挡影响');
-  if (property.floorLevel === '高' && !property.isTopFloor && property.communityQuality <= 5) cons.push('高楼层,需确认电梯运行状况');
-  if (!property.isSchoolDistrict && property.district.includes('天宁区-文化宫')) cons.push('非学区房,天宁区核心学区客户需注意');
-  if (property.priceDropSpace <= 5) cons.push('业主议价空间较小,谈价弹性有限');
-  return cons.length > 0 ? cons : ['无明显硬伤,整体较为均衡'];
-}
-
-function printResults(buyer, results) {
-  console.log(`\n${'='.repeat(70)}`);
-  console.log(`🎯 客户:${buyer.name}(${buyer.type})`);
-  console.log(`   口述预算:${buyer.surfaceBudget}万 | 首付:${buyer.downPayment}万 | 月供承受:${buyer.monthlyPaymentCapacity}元`);
-  console.log(`   目标区域:${buyer.targetDistricts.join('、')}`);
-  console.log(`   核心关注:${buyer.coreConcerns.join(' > ')}`);
-  console.log(`${'='.repeat(70)}`);
-
-  if (results.length === 0) {
-    console.log(`\n⚠️  无匹配房源。建议:`);
-    if (buyer.budgetMax < 100) console.log(`   → 预算偏低,考虑提高预算或扩大区域`);
-    if (buyer.schoolDistrictRequired) console.log(`   → 学区要求严格,建议扩大目标学校范围`);
-    return;
-  }
-
-  results.forEach((r, i) => {
-    const p = r.property;
-    const s = generateStrategy(buyer, p);
-    console.log(`\n${'─'.repeat(70)}`);
-    console.log(`🏠 #${i + 1}  [${r.level}]  ${p.community} · ${p.layout} · ${p.totalPrice}万`);
-    console.log(`   综合分:${r.finalScore.toFixed(1)}  (基础${r.stage2Score.toFixed(1)} + 卖点${r.highlightBonus} + 心理${r.psychBonus})`);
-    console.log(`   区域:${p.district} | 面积:${p.area}㎡ | 楼层:${p.floor} | 朝向:${p.orientation}`);
-    console.log(`   装修:${p.decoration} | 房龄:${p.buildingAge}年 | 学区:${p.isSchoolDistrict ? p.schoolName : '无'}`);
-    console.log(`   亮点:${p.highlightTags.join(' · ')}`);
-    console.log(`   业主:${p.ownerSituation} | 议价空间:${p.priceDropSpace}万`);
-
-    // 坦诚缺点(来自蓝领匹配启发)
-    const cons = generateHonestCons(p);
-    console.log(`   ⚠️  坦诚提醒:${cons.join(';')}`);
-
-    // 评分分解
-    const bd = r.breakdown;
-    console.log(`   📊 维度分解:价格${(bd.priceAdvantage*100).toFixed(0)} | 户型${(bd.layoutMatch*100).toFixed(0)} | 面积${(bd.areaMatch*100).toFixed(0)} | 装修${(bd.decoration*100).toFixed(0)} | 交通${(bd.transport*100).toFixed(0)} | 品质${(bd.community*100).toFixed(0)} | 楼层${(bd.floorMatch*100).toFixed(0)} | 朝向${(bd.orientation*100).toFixed(0)}`);
-
-    console.log(`\n   💬 沟通策略(${s.style}):`);
-    console.log(`   "${s.openingScript}"`);
-    console.log(`   ⚠️  注意事项:${s.precautions.join(';')}`);
-    console.log(`   📅 跟进节奏:${s.followUp}`);
-  });
-
-  console.log(`\n${'─'.repeat(70)}`);
-  console.log(`📊 筛选统计:全量${30}套 → 硬约束过滤后${results.length > 0 ? '匹配' : '0'}套 → Top-${results.length}`);
-  console.log(`${'='.repeat(70)}\n`);
-}
-
-// ============================================================
-// 十一、入口
-// ============================================================
-
-function main() {
-  const { buyers, properties } = loadData();
-
-  // 命令行参数
-  const args = process.argv.slice(2);
-  const buyerId = args.includes('--buyer') ? args[args.indexOf('--buyer') + 1] : null;
-  const topIdx = args.includes('--top') ? args.indexOf('--top') : -1;
-  const topN = topIdx >= 0 ? parseInt(args[topIdx + 1]) || 5 : 5;
-
-  const targets = buyerId
-    ? buyers.filter(b => b.id === buyerId)
-    : buyers;
-
-  if (targets.length === 0) {
-    console.error(`❌ 未找到客户 ${buyerId}`);
-    console.error(`   可用客户ID:${buyers.map(b => b.id).join(', ')}`);
-    process.exit(1);
-  }
-
-  targets.forEach(buyer => {
-    const results = matchBuyer(buyer, properties, topN);
-    printResults(buyer, results);
-  });
-}
-
-if (require.main === module) {
-  main();
-}
-
-// 导出供外部调用
-module.exports = { matchBuyer, generateStrategy, hardFilter, WEIGHT_TEMPLATES, STRATEGY_TEMPLATES };
-

+ 0 - 35
claude-code/claude-code-qiwe-assistant/knowledge-base/property-data/properties.json

@@ -1,35 +0,0 @@
-{
-  "properties": [
-    {"id":"P001","community":"凤凰湖花园","district":"新北区-薛家","totalPrice":95,"unitPrice":10500,"layout":"2室2厅1卫","area":90,"floor":"8/18","orientation":"南向","decoration":"精装","buildingAge":8,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近地铁1号线薛家站","ownerSituation":"自住,想换大户型","priceDropSpace":8,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","精装","近地铁"],"communityQuality":7,"transportScore":8,"surroundingScore":6,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P002","community":"顺园新村","district":"新北区-薛家","totalPrice":85,"unitPrice":9800,"layout":"2室1厅1卫","area":87,"floor":"5/6","orientation":"南向","decoration":"简装","buildingAge":12,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"近薛家中心小学","ownerSituation":"已搬走,空置中","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","总价低"],"communityQuality":5,"transportScore":6,"surroundingScore":6,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P003","community":"中央花园","district":"新北区-三井","totalPrice":115,"unitPrice":12500,"layout":"3室2厅1卫","area":92,"floor":"12/25","orientation":"南北通透","decoration":"精装","buildingAge":6,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"万达商圈,配套成熟","ownerSituation":"置换已签新房合同,急售","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","急售","近商圈","精装"],"communityQuality":8,"transportScore":9,"surroundingScore":9,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P004","community":"凤凰湖花园","district":"新北区-薛家","totalPrice":108,"unitPrice":11200,"layout":"3室2厅1卫","area":96,"floor":"15/18","orientation":"南向","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近地铁,通勤方便","ownerSituation":"自住,诚意出售","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","近地铁","次新房"],"communityQuality":7,"transportScore":8,"surroundingScore":7,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P005","community":"西狮子巷","district":"天宁区-文化宫","totalPrice":155,"unitPrice":23000,"layout":"2室1厅1卫","area":67,"floor":"3/6","orientation":"南向","decoration":"简装","buildingAge":25,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"文化宫核心区,生活便利","ownerSituation":"出租中,投资套现","priceDropSpace":15,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["局小本部学区","满五唯一","总价学区入门"],"communityQuality":4,"transportScore":8,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P006","community":"桃园新村","district":"天宁区-文化宫","totalPrice":140,"unitPrice":21000,"layout":"2室1厅1卫","area":66,"floor":"2/5","orientation":"南向","decoration":"简装","buildingAge":28,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"近红梅公园","ownerSituation":"老人自住,换电梯房","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["局小本部学区","满五唯一","近公园"],"communityQuality":3,"transportScore":7,"surroundingScore":8,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P007","community":"博爱路小区","district":"天宁区-红梅","totalPrice":138,"unitPrice":18000,"layout":"2室2厅1卫","area":77,"floor":"4/7","orientation":"南北通透","decoration":"精装","buildingAge":22,"isSchoolDistrict":true,"schoolName":"博爱路小学","parking":"地面停车","surrounding":"近博爱路,生活配套成熟","ownerSituation":"自住,孩子已毕业","priceDropSpace":12,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["博小学区","南北通透","精装"],"communityQuality":5,"transportScore":7,"surroundingScore":7,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P008","community":"西狮子巷","district":"天宁区-文化宫","totalPrice":170,"unitPrice":24000,"layout":"2室2厅1卫","area":71,"floor":"4/6","orientation":"南向","decoration":"精装","buildingAge":20,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"文化宫核心","ownerSituation":"自住,孩子已入学","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["局小本部学区","精装","学位未占用"],"communityQuality":4,"transportScore":8,"surroundingScore":9,"priceAdvantage":4,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P009","community":"兰陵家园","district":"天宁区-兰陵","totalPrice":128,"unitPrice":15500,"layout":"2室2厅1卫","area":82,"floor":"8/18","orientation":"南向","decoration":"精装","buildingAge":10,"isSchoolDistrict":true,"schoolName":"解放路小学","parking":"有地下车位","surrounding":"近兰陵商圈","ownerSituation":"自住,换房改善","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["解小学区","电梯房","有车位"],"communityQuality":6,"transportScore":7,"surroundingScore":7,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P010","community":"青枫公馆","district":"钟楼区-青枫公园","totalPrice":168,"unitPrice":13500,"layout":"3室2厅2卫","area":124,"floor":"10/20","orientation":"南北通透","decoration":"精装","buildingAge":7,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近青枫公园,环境好","ownerSituation":"自住,卖一买一","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","近公园","人车分流","电梯"],"communityQuality":9,"transportScore":7,"surroundingScore":8,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P011","community":"景瑞曦城","district":"钟楼区-青枫公园","totalPrice":180,"unitPrice":14200,"layout":"3室2厅2卫","area":127,"floor":"15/22","orientation":"南北通透","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"紧邻青枫公园","ownerSituation":"自住,诚心出售","priceDropSpace":12,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","次新房","近公园"],"communityQuality":8,"transportScore":7,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P012","community":"青枫公馆","district":"钟楼区-青枫公园","totalPrice":155,"unitPrice":12800,"layout":"3室2厅1卫","area":121,"floor":"6/20","orientation":"南向","decoration":"精装","buildingAge":8,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"青枫公园板块,配套成熟","ownerSituation":"已置换,急售","priceDropSpace":20,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["急售","满五唯一","三居性价比"],"communityQuality":9,"transportScore":7,"surroundingScore":8,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P013","community":"吾悦广场公寓","district":"武进区-湖塘","totalPrice":145,"unitPrice":13000,"layout":"3室2厅2卫","area":112,"floor":"9/30","orientation":"南北通透","decoration":"精装","buildingAge":3,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"吾悦广场旁,商圈核心","ownerSituation":"投资客,套现","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["次新房","近商圈","南北通透"],"communityQuality":8,"transportScore":8,"surroundingScore":9,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P014","community":"星河国际","district":"武进区-湖塘","totalPrice":130,"unitPrice":12000,"layout":"3室2厅2卫","area":108,"floor":"11/28","orientation":"南向","decoration":"精装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"湖塘核心区","ownerSituation":"自住,换大户型","priceDropSpace":12,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","精装","品牌开发商"],"communityQuality":9,"transportScore":8,"surroundingScore":8,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P015","community":"新城域","district":"武进区-大学城","totalPrice":95,"unitPrice":10000,"layout":"2室2厅1卫","area":95,"floor":"7/18","orientation":"南向","decoration":"精装","buildingAge":8,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"大学城板块","ownerSituation":"自住,首次出售","priceDropSpace":8,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","总价低","近大学城"],"communityQuality":6,"transportScore":7,"surroundingScore":7,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P016","community":"绿地香颂","district":"武进区-湖塘","totalPrice":155,"unitPrice":12800,"layout":"3室2厅2卫","area":121,"floor":"13/25","orientation":"南北通透","decoration":"豪装","buildingAge":4,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"湖塘核心,近万达","ownerSituation":"自住,诚心出售","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["豪装","南北通透","次新房"],"communityQuality":8,"transportScore":8,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P017","community":"星河国际","district":"武进区-大学城","totalPrice":140,"unitPrice":12200,"layout":"3室2厅2卫","area":115,"floor":"16/28","orientation":"南向","decoration":"精装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"大学城+星河商圈","ownerSituation":"自住,换城市","priceDropSpace":12,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["精装","品牌开发商","近商圈"],"communityQuality":8,"transportScore":7,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P018","community":"金谷华城","district":"金坛区-金坛新城","totalPrice":75,"unitPrice":9000,"layout":"2室2厅1卫","area":83,"floor":"5/11","orientation":"南向","decoration":"精装","buildingAge":10,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"金坛新城核心","ownerSituation":"自住,换房","priceDropSpace":8,"isFiveYearOnly":true,"floorLevel":"中","highlightTags":["满五唯一","总价低","配套成熟"],"communityQuality":6,"transportScore":6,"surroundingScore":7,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P019","community":"中景华庭","district":"金坛区-金坛新城","totalPrice":105,"unitPrice":10500,"layout":"3室2厅2卫","area":100,"floor":"8/18","orientation":"南北通透","decoration":"精装","buildingAge":6,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"金坛新城","ownerSituation":"自住,诚意出售","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","精装","次新房"],"communityQuality":7,"transportScore":6,"surroundingScore":6,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P020","community":"金谷华城","district":"金坛区-金坛新城","totalPrice":65,"unitPrice":8500,"layout":"2室1厅1卫","area":76,"floor":"3/6","orientation":"南向","decoration":"简装","buildingAge":14,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"老城区,配套成��","ownerSituation":"空置,急售","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["急售","满五唯一","单价低"],"communityQuality":4,"transportScore":5,"surroundingScore":6,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P021","community":"燕山公馆","district":"溧阳市-燕山新区","totalPrice":110,"unitPrice":10500,"layout":"3室2厅1卫","area":105,"floor":"9/18","orientation":"南向","decoration":"精装","buildingAge":4,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"燕山新区,新城区","ownerSituation":"自住,换别墅","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["精装","次新房","新城区"],"communityQuality":8,"transportScore":6,"surroundingScore":7,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P022","community":"燕山一号","district":"溧阳市-燕山新区","totalPrice":95,"unitPrice":9800,"layout":"2室2厅1卫","area":97,"floor":"12/20","orientation":"南北通透","decoration":"精装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"燕山新区核心","ownerSituation":"自住,诚售","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["南北通透","精装","近商圈"],"communityQuality":7,"transportScore":6,"surroundingScore":7,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P023","community":"中央花园","district":"新北区-三井","totalPrice":130,"unitPrice":13000,"layout":"3室2厅2卫","area":100,"floor":"20/25","orientation":"南北通透","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"万达商圈","ownerSituation":"自住,换大房","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","近商圈"],"communityQuality":8,"transportScore":9,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P024","community":"桃园新村","district":"天宁区-文化宫","totalPrice":165,"unitPrice":22000,"layout":"3室1厅1卫","area":75,"floor":"3/7","orientation":"南向","decoration":"精装","buildingAge":20,"isSchoolDistrict":true,"schoolName":"局前街小学","parking":"无","surrounding":"文化宫核心","ownerSituation":"自住,诚意出售","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"低","highlightTags":["局小本部学区","精装","三居学区"],"communityQuality":4,"transportScore":8,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P025","community":"青枫公馆","district":"钟楼区-青枫公园","totalPrice":195,"unitPrice":14500,"layout":"4室2厅2卫","area":134,"floor":"18/20","orientation":"南北通透","decoration":"豪装","buildingAge":6,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近公园","ownerSituation":"高端自住,诚意出售","priceDropSpace":15,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","四居","人车分流"],"communityQuality":9,"transportScore":7,"surroundingScore":8,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P026","community":"新城域","district":"武进区-大学城","totalPrice":88,"unitPrice":9500,"layout":"2室2厅1卫","area":92,"floor":"4/18","orientation":"南向","decoration":"精装","buildingAge":9,"isSchoolDistrict":false,"schoolName":"","parking":"地面停车","surrounding":"大学城","ownerSituation":"投资客,套现","priceDropSpace":10,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["满五唯一","总价低"],"communityQuality":5,"transportScore":7,"surroundingScore":7,"priceAdvantage":9,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P027","community":"绿地香颂","district":"武进区-湖塘","totalPrice":165,"unitPrice":13200,"layout":"3室2厅2卫","area":125,"floor":"11/25","orientation":"南北通透","decoration":"豪装","buildingAge":4,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"万达商圈","ownerSituation":"自住,精心装修","priceDropSpace":8,"isFiveYearOnly":false,"floorLevel":"中","highlightTags":["豪装","南北通透","次新房","品牌开发商"],"communityQuality":8,"transportScore":8,"surroundingScore":9,"priceAdvantage":5,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P028","community":"中景华庭","district":"金坛区-金坛新城","totalPrice":98,"unitPrice":10000,"layout":"2室2厅1卫","area":98,"floor":"14/18","orientation":"南向","decoration":"豪装","buildingAge":5,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"金坛新城核心","ownerSituation":"新婚装修,换城市","priceDropSpace":10,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","次新房","婚装"],"communityQuality":7,"transportScore":6,"surroundingScore":6,"priceAdvantage":7,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P029","community":"燕山公馆","district":"溧阳市-燕山新区","totalPrice":125,"unitPrice":11000,"layout":"3室2厅2卫","area":114,"floor":"16/20","orientation":"南北通透","decoration":"豪装","buildingAge":3,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"新区核心","ownerSituation":"投资客,套现","priceDropSpace":12,"isFiveYearOnly":false,"floorLevel":"高","highlightTags":["豪装","南北通透","次新房","大三居"],"communityQuality":8,"transportScore":6,"surroundingScore":7,"priceAdvantage":6,"isGroundFloor":false,"isTopFloor":false},
-    {"id":"P030","community":"凤凰湖花园","district":"新北区-薛家","totalPrice":100,"unitPrice":10800,"layout":"3室2厅1卫","area":92,"floor":"3/18","orientation":"南向","decoration":"精装","buildingAge":7,"isSchoolDistrict":false,"schoolName":"","parking":"有地下车位","surrounding":"近地铁","ownerSituation":"自住,已定新房","priceDropSpace":12,"isFiveYearOnly":true,"floorLevel":"低","highlightTags":["满五唯一","精装","近地铁","业主急售"],"communityQuality":7,"transportScore":8,"surroundingScore":7,"priceAdvantage":8,"isGroundFloor":false,"isTopFloor":false}
-  ]
-}
-

+ 2 - 2
claude-code/claude-code-qiwe-assistant/knowledge/faq.md

@@ -2,11 +2,11 @@
 
 ## Agent 能做什么
 
-Agent 可以理解客户消息、维护多轮会话、检索客户画像和业务知识、调用房源搜索工具、生成回复草稿,并将低置信或敏感内容提交人工审核。
+Agent 可以理解客户消息、维护多轮会话、检索客户画像和业务知识、调用已安装的通用工具、生成回复草稿,并将低置信或敏感内容提交人工审核。
 
 ## 为什么有些回复需要人工确认
 
-价格承诺、合同条款、学区资格、产权和投诉等内容风险较高。系统保留 Agent 草稿和依据,由人工编辑或批准后发送。
+价格承诺、合同条款、退款、隐私和投诉等内容风险较高。系统保留 Agent 草稿和依据,由人工编辑或批准后发送。
 
 ## 人工接管
 

+ 3 - 3
claude-code/claude-code-qiwe-assistant/knowledge/playbooks.md

@@ -2,11 +2,11 @@
 
 ## 首次咨询
 
-确认客户当前问题;识别区域、预算、户型三项基础需求;一次只追问一个最影响匹配的缺失条件。
+确认客户当前问题;识别目标、预算、计划时间三项基础需求;一次只追问一个最影响方案的缺失条件。
 
-## 房源推荐
+## 方案建议
 
-按“为什么匹配、关键事实、待确认风险、建议下一步”的顺序表达。不要只报分数,也不要把评分当成客观事实。
+按“为什么适合、关键事实、待确认风险、建议下一步”的顺序表达。不要只报分数,也不要把评分当成客观事实。
 
 ## 异议处理
 

+ 6 - 6
claude-code/claude-code-qiwe-assistant/knowledge/rules.md

@@ -2,7 +2,7 @@
 
 ## 真实性
 
-- 不编造房源、价格、优惠、学区、交通、产权和业主意愿
+- 不编造价格、优惠、资格、合同条款、库存或服务承诺
 - 业务工具没有返回的数据必须明确说“需要进一步确认”。
 - 推荐结论必须能追溯到客户明确表达或知识库证据。
 
@@ -14,12 +14,12 @@
 
 ## 人工审核
 
-- 价格承诺、合同、产权、学区资格、金融方案、投诉、退款和敏感个人信息必须人工审核。
+- 价格承诺、合同、资质合规、金融方案、投诉、退款和敏感个人信息必须人工审核。
 - Agent 置信度低于阈值或知识证据不足时必须人工审核。
 - 人工接管、会话暂停、全局暂停时禁止自动发送。
 
-## 房产咨询
+## 客户咨询
 
-- 需求识别优先关注区域、总价预算、户型、面积、用途、时间计划和排斥项。
-- 第一次推荐属于初步判断,需要通过追问和带看反馈继续校准。
-- 推荐房源时同时展示匹配优势和待确认风险。
+- 需求识别优先关注客户目标、预算、时间计划、决策人和排斥项。
+- 第一次建议属于初步判断,需要通过追问和真实反馈继续校准。
+- 提供方案时同时展示适配优势和待确认风险。

+ 7 - 13
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-context-builder.js

@@ -35,6 +35,7 @@ class AgentContextBuilder {
 
   buildClaudePrompt(messages, context = {}) {
     if (context.directPrompt) return clip(String(context.directPrompt), this.promptCharLimit);
+    const autopilot = context.conversation?.mode === 'autopilot';
     const history = selectAuthoritativeHistory(messages, 10)
       .map(message => `${message.role === 'assistant' ? '客服' : message.role === 'tool' ? '工具' : '客户'}:${String(message.content || '').slice(0, 1200)}`)
       .join('\n\n');
@@ -42,22 +43,17 @@ class AgentContextBuilder {
     const intelligence = context.customerIntelligence || {};
     const activeTasks = (intelligence.tasks || []).filter(item => ['open', 'in_progress'].includes(item.status));
     const activeAlerts = (intelligence.alerts || []).filter(item => ['open', 'acknowledged'].includes(item.status));
-    const recommendations = (intelligence.recommendations || []).map(item => ({
-      propertyId: item.property_id || item.propertyId,
-      community: item.property_snapshot?.community || item.property?.community,
-      totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice,
-      status: item.status,
-      feedbackReason: item.feedback_reason || item.feedbackReason || '',
-    }));
     const instructionBudget = Math.floor(this.promptCharLimit * 0.43);
     const stateBudget = Math.floor(this.promptCharLimit * 0.27);
     const historyBudget = this.promptCharLimit - instructionBudget - stateBudget - 32;
     const instructions = clip([
-      '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库、规则和房源数据。',
-      '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
+      '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库和规则。',
+      autopilot
+        ? '不要修改文件或自行调用发送工具,不要编造业务事实。只返回结构化 JSON,系统会通过全自动接管链路直接发送 reply。'
+        : '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
       '严格按照 JSON Schema 输出;reply 面向客户,reason 仅供内部审核。',
       '【上下文边界】下面的“本轮有效会话”是本轮唯一可信的客户对话。即使当前 Claude Code Session 曾经出现过其他客户原话,也不得引用未在本轮有效会话中重复出现的内容。',
-      '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断购买数量、预算用途、投资意图等高影响需求,必须先向客户确认。',
+      '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断数量、预算用途或决策意图等高影响需求,必须先向客户确认。',
       'reply 使用适合企微的简洁纯文本,不输出 Markdown 星号、标题语法或内部推理。客户给出明确事实时,在 profileUpdates 中同步记录;不明确的字段标记待确认。',
       '如果最新消息只是“收到、好的、明白了、谢谢”等纯确认或致谢,且没有新的问题或待确认动作,reply 可以返回空字符串。',
       '同时输出内部 tasks 和 alerts:tasks 只记录明确可执行的跟进动作,alerts 只记录有证据的高意向、时效、矛盾、投诉或人工接管风险;每项 evidence 必须来自本轮有效会话。没有就输出空数组。',
@@ -66,8 +62,6 @@ class AgentContextBuilder {
       `当前客户画像:${safeJson(profile)}`,
       `当前未完成待办:${safeJson(activeTasks.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, reason: item.reason })))}`,
       `当前未解决预警:${safeJson(activeAlerts.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, detail: item.detail })))}`,
-      `房源推荐与反馈历史:${safeJson(recommendations)}`,
-      '不要重复推荐已明确标记 rejected 的房源;已推荐但未反馈的房源应优先询问感受,不能把“已推荐”说成“客户感兴趣”。',
       '不要重复创建语义相同的待办或预警;如需引用已有项,沿用其 key。画像事实变化时,优先更新已有业务项。',
     ].join('\n'), stateBudget);
     const authoritative = clipTail(history || `客户:${String(context.inboundContent || '')}`, historyBudget);
@@ -79,7 +73,7 @@ class AgentContextBuilder {
     return clip([
       channelType === 'group'
         ? '你是企业微信群聊客服 Agent。你需要理解不同群成员的发言,再生成一条供人工审核的群内回复草稿。'
-        : '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。',
+        : '你是企业微信客户服务 Agent。你需要自主判断是否检索知识或客户画像,再生成回复草稿。',
       '严禁编造业务事实。工具没有证据时明确说需要确认。敏感或低置信内容标记 requiresHuman=true。',
       '最终必须只输出 JSON,包含 reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks 和 alerts。',
       'reason 用于内部监管台,简要说明依据和不确定性,不要发送给客户。',

+ 1 - 67
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-knowledge.js

@@ -54,42 +54,9 @@ function contextReferences(content) {
   }));
 }
 
-function normalizeProperty(raw) {
-  return {
-    id: raw.id || raw.objectId || raw.property_id || '',
-    community: raw.community || raw.community_name || '',
-    district: raw.district || raw.area_name || '',
-    totalPrice: Number(raw.totalPrice ?? raw.total_price ?? raw.price_total ?? 0),
-    layout: raw.layout || raw.house_type || '',
-    area: Number(raw.area || 0),
-    decoration: raw.decoration || '',
-    orientation: raw.orientation || '',
-    floor: raw.floor || raw.floor_info || raw.floorLevel || raw.floor_level || '',
-    isSchoolDistrict: Boolean(raw.isSchoolDistrict ?? raw.is_school_district),
-    schoolName: raw.schoolName || raw.school_name || '',
-    highlights: raw.highlightTags || raw.highlight_tags || raw.tags || [],
-    ownerSituation: raw.ownerSituation || raw.owner_situation || '',
-    priceDropSpace: Number(raw.priceDropSpace ?? raw.price_drop_space ?? 0),
-  };
-}
-
-function parseDemand(input = {}) {
-  const query = String(input.query || '');
-  const district = input.district || (query.match(/(新北区|武进区|天宁区|钟楼区)/)?.[1] || '');
-  const budgetMax = Number(input.budgetMax || query.match(/(\d{2,4})\s*万/)?.[1] || 0);
-  const roomToken = input.rooms || query.match(/([1-5一二三四五])\s*(?:室|房|居)/)?.[1] || '';
-  const map = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5 };
-  const rooms = Number(roomToken) || map[roomToken] || 0;
-  const decoration = input.decoration || query.match(/(豪装|精装|简装|毛坯)/)?.[1] || '';
-  const orientation = input.orientation || query.match(/(南北通透|朝南|朝北|南向|北向|东向|西向)/)?.[1] || '';
-  const schoolRequired = Boolean(input.schoolRequired || /学区|上学|学校/.test(query));
-  return { district, budgetMax, rooms, decoration, orientation, schoolRequired };
-}
-
 class AgentKnowledgeStore {
-  constructor({ knowledgeDir, propertyDataFile = '', contextFiles = [], contextCharLimit = 6000 }) {
+  constructor({ knowledgeDir, contextFiles = [], contextCharLimit = 6000 }) {
     this.knowledgeDir = knowledgeDir;
-    this.propertyDataFile = propertyDataFile;
     this.contextFiles = (contextFiles.length ? contextFiles : ['personality.md', 'context.md', 'rules.md'])
       .map(normalizeContextFile)
       .filter(Boolean);
@@ -99,12 +66,6 @@ class AgentKnowledgeStore {
 
   reload() {
     this.chunks = walkMarkdown(this.knowledgeDir).flatMap(file => splitMarkdown(file, this.knowledgeDir));
-    this.properties = [];
-    if (this.propertyDataFile && fs.existsSync(this.propertyDataFile)) {
-      const parsed = JSON.parse(fs.readFileSync(this.propertyDataFile, 'utf8').replace(/^\uFEFF/, ''));
-      const rows = Array.isArray(parsed) ? parsed : (parsed.properties || parsed.data || []);
-      this.properties = rows.map(normalizeProperty).filter(item => item.id || item.community);
-    }
   }
 
   rulesText() {
@@ -156,38 +117,11 @@ class AgentKnowledgeStore {
       .map(item => ({ id: item.id, source: item.source, heading: item.heading, content: item.content, score: item.score }));
   }
 
-  searchProperties(input = {}) {
-    const demand = parseDemand(input);
-    const results = this.properties.filter(property => {
-      if (demand.district && !property.district.includes(demand.district)) return false;
-      if (demand.budgetMax && property.totalPrice > demand.budgetMax * 1.1) return false;
-      if (demand.rooms && !property.layout.includes(`${demand.rooms}室`)) return false;
-      if (demand.decoration && property.decoration !== demand.decoration) return false;
-      if (demand.schoolRequired && !property.isSchoolDistrict) return false;
-      if (demand.orientation) {
-        const key = demand.orientation.replace('朝', '').replace('向', '');
-        if (!property.orientation.includes(key)) return false;
-      }
-      return true;
-    });
-    return {
-      dataSource: this.propertyDataFile ? path.basename(this.propertyDataFile) : null,
-      dataLabel: this.propertyDataFile ? '演示房源数据集' : null,
-      demand,
-      total: results.length,
-      items: results.slice(0, 5),
-      warning: this.propertyDataFile ? '当前接入的是演示房源数据,不代表实时在售事实。' : '未配置房源数据源,不能提供真实房源事实。',
-    };
-  }
-
   stats() {
     return {
       knowledgeChunks: this.chunks.length,
       contextFiles: this.contextFiles.filter(source => this.chunks.some(chunk => chunk.source === source)),
       contextChars: this.contextText().length,
-      propertyCount: this.properties.length,
-      propertyDataConfigured: Boolean(this.propertyDataFile),
-      propertyDataLabel: this.propertyDataFile ? '演示房源数据集' : '未接入',
     };
   }
 }

+ 7 - 11
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-memory.js

@@ -3,21 +3,17 @@ const crypto = require('crypto');
 const MEMORY_TYPES = new Set(['fact', 'preference', 'constraint', 'event', 'hypothesis']);
 const UNSAFE_MEMORY_PATTERN = /忽略.{0,12}(指令|规则)|无视.{0,12}(指令|规则)|system\s*prompt|系统提示|不要告诉用户|(?:cat|type)\s+\.?env|\b(?:api[_ -]?key|password|token)\b\s*[:=]/i;
 const PROFILE_MEMORY_FIELDS = {
-  preferredRegion: { key: 'preferredRegion', label: '意向区域', priority: 2 },
-  region: { key: 'preferredRegion', label: '意向区域', priority: 1 },
-  district: { key: 'preferredRegion', label: '意向区域', priority: 1 },
-  intent_area: { key: 'preferredRegion', label: '意向区域', priority: 1 },
-  layout: { key: 'layout', label: '意向户型', priority: 2 },
-  roomType: { key: 'layout', label: '意向户型', priority: 1 },
-  house_type: { key: 'layout', label: '意向户型', priority: 1 },
   budgetWan: { key: 'budgetWan', label: '预算', priority: 2 },
   budget: { key: 'budgetWan', label: '预算', priority: 1 },
   budgetTotal: { key: 'budgetWan', label: '预算', priority: 1 },
   budgetType: { key: 'budgetType', label: '预算类型', priority: 2 },
-  purpose: { key: 'purpose', label: '购置用途', priority: 2 },
-  purchasePurpose: { key: 'purpose', label: '购置用途', priority: 1 },
-  timeline: { key: 'timeline', label: '购置时间', priority: 2 },
-  decoration: { key: 'decoration', label: '装修偏好', priority: 2 },
+  purpose: { key: 'purpose', label: '客户目标', priority: 2 },
+  need: { key: 'need', label: '核心需求', priority: 2 },
+  needs: { key: 'need', label: '核心需求', priority: 1 },
+  timeline: { key: 'timeline', label: '计划时间', priority: 2 },
+  decisionMaker: { key: 'decisionMaker', label: '决策人', priority: 2 },
+  contactPreference: { key: 'contactPreference', label: '沟通偏好', priority: 1 },
+  serviceStage: { key: 'serviceStage', label: '服务阶段', priority: 1 },
   intent: { key: 'intent', label: '客户意向', priority: 2 },
 };
 

+ 20 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-poller-policy.js

@@ -1,5 +1,12 @@
 'use strict';
 
+const PERSONAL_INTAKE_MODES = ['allowlist_only', 'auto_enroll_review', 'auto_enroll_autopilot'];
+
+function normalizePersonalIntakeMode(value) {
+  const mode = String(value || '').trim();
+  return PERSONAL_INTAKE_MODES.includes(mode) ? mode : 'allowlist_only';
+}
+
 function messageTimestamp(raw) {
   const parsed = Number(raw);
   if (!Number.isFinite(parsed)) return 0;
@@ -44,11 +51,21 @@ function evaluatePolledMessage(message = {}, config = {}) {
   const senderId = String(message.senderId || '');
   if (!content || !senderId) return { eligible: false, reason: 'empty_message' };
   if (senderId === String(config.selfUserId || '')) return { eligible: false, reason: 'self_message' };
-  if (!(config.allowedSenders || []).map(String).includes(senderId)) return { eligible: false, reason: 'not_allowlisted' };
+  const allowlisted = (config.allowedSenders || []).map(String).includes(senderId);
+  const intakeMode = normalizePersonalIntakeMode(config.intakeMode);
+  if (!allowlisted && intakeMode === 'allowlist_only') return { eligible: false, reason: 'not_allowlisted' };
   const timestamp = messageTimestamp(message.timestamp);
   if (!timestamp) return { eligible: false, reason: 'invalid_timestamp' };
   // 使用持久化游标恢复停机期间的积压消息;不要再按本次进程启动时间丢弃。
-  return { eligible: true, content, senderId, timestamp };
+  return { eligible: true, content, senderId, timestamp, allowlisted, unknownContact: !allowlisted, intakeMode };
 }
 
-module.exports = { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage };
+module.exports = {
+  PERSONAL_INTAKE_MODES,
+  normalizePersonalIntakeMode,
+  messageTimestamp,
+  roomIdOf,
+  isGroupMessage,
+  messageContent,
+  evaluatePolledMessage,
+};

+ 24 - 75
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-runtime.js

@@ -4,7 +4,7 @@ const path = require('path');
 const { spawn } = require('child_process');
 const { AgentContextBuilder, selectAuthoritativeHistory } = require('./agent-context-builder');
 
-const RISK_PATTERN = /合同|签约|产权|学区资格|保证|承诺|最低价|贷款|利率|首付|投诉|退款|发票|身份证|银行卡|法律|违约/;
+const RISK_PATTERN = /合同|签约|资质|合规|保证|承诺|最低价|贷款|利率|投诉|退款|发票|身份证|银行卡|法律|违约/;
 const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
 
 function isNoReplyNeededMessage(input = '') {
@@ -251,15 +251,14 @@ function unsupportedAttributedClaims(final = {}, authoritativeHistory = []) {
 }
 
 function groundedConfirmationReply(profile = {}, inboundContent = '') {
-  const region = profile.preferredRegion || profile.region || profile.district || '';
-  const layout = profile.layout || profile.roomType || '';
   const budget = Number(profile.budgetWan || profile.budget || 0);
-  const confirmed = [region, layout, budget ? `${budget}万预算` : ''].filter(Boolean).join('、');
+  const intent = profile.intent || profile.purpose || profile.need || '';
+  const confirmed = [intent, budget ? `预算 ${budget}` : '', profile.timeline || ''].filter(Boolean).join('、');
   const opening = confirmed ? `收到,我先按${confirmed}继续整理。` : '收到,您刚才的信息我已经记录。';
   const questions = [];
-  if (budget && (!profile.budgetType || profile.budgetType === '待确认')) questions.push(`这${budget}万是总价预算还是首付预算`);
-  if (!profile.purpose) questions.push('主要用于自住还是投资');
-  if (!profile.timeline) questions.push('希望什么时候购置');
+  if (!intent) questions.push('这次最希望解决的核心问题是什么');
+  if (budget && (!profile.budgetType || profile.budgetType === '待确认')) questions.push(`预算 ${budget} 是目标值还是上限`);
+  if (!profile.timeline) questions.push('希望什么时候推进');
   if (!questions.length) return `${opening}我会先核对可用方案,再给您准确回复。`;
   return `${opening}为了避免理解偏差,想再确认一下:${questions.join(';')}?`;
 }
@@ -280,30 +279,20 @@ function enforceAuthoritativeGrounding(final = {}, authoritativeHistory = [], pr
 function extractExplicitCustomerIntelligence(content, currentProfile = {}, modelOutput = {}) {
   const text = String(content || '').trim();
   const profileUpdates = supportedModelProfileUpdates(modelOutput.profileUpdates, text);
-  const amount = text.match(/(\d+(?:\.\d+)?)\s*万/);
-  if (amount && (/预算|总价|首付/.test(text) || /^\s*\d+(?:\.\d+)?\s*万(?:吧|左右|上下|以内|起)?\s*[。!!??]*$/.test(text))) {
+  const amount = text.match(/(\d+(?:\.\d+)?)\s*(?:|元)?/);
+  if (amount && (/预算|费用|报价|价格/.test(text))) {
     profileUpdates.budgetWan = Number(amount[1]);
-    profileUpdates.budgetType = /首付/.test(text) ? '首付' : /总价/.test(text) ? '总价' : (currentProfile.budgetType || '待确认');
-  }
-  const region = text.match(/([\p{Script=Han}]{2,8}(?:区|市|镇|板块))/u);
-  if (region) {
-    let regionValue = region[1].replace(/^.*(?:想咨询一下|咨询一下|了解一下|考虑在|想在|咨询|了解|看看|一下)/, '');
-    if (regionValue.length > 5 && !regionValue.endsWith('板块')) regionValue = regionValue.slice(-4);
-    profileUpdates.preferredRegion = regionValue;
-  }
-  const layout = text.match(/([一二三四五六七八九十两\d]+)\s*室/);
-  if (layout) profileUpdates.layout = `${layout[1]}室`;
-  if (/自己住|自住|婚房|改善/.test(text)) profileUpdates.purpose = /自己住|自住/.test(text) ? '自住' : text.match(/婚房|改善/)?.[0];
-  if (/投资|出租|收租/.test(text)) profileUpdates.purpose = '投资';
+    profileUpdates.budgetType = /上限|最多/.test(text) ? '上限' : /目标|大概/.test(text) ? '目标' : (currentProfile.budgetType || '待确认');
+  }
   if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) profileUpdates.urgency = '高';
 
   const mergedProfile = { ...currentProfile, ...profileUpdates };
   const factEvidence = [
-    mergedProfile.budgetWan || mergedProfile.budget ? `预算${mergedProfile.budgetWan || mergedProfile.budget}万` : '',
-    mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area ? `区域${mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area}` : '',
-    mergedProfile.layout || mergedProfile.roomType ? `户型${mergedProfile.layout || mergedProfile.roomType}` : '',
-    mergedProfile.purpose ? `用途${mergedProfile.purpose}` : '',
-    mergedProfile.timeline ? `时间${mergedProfile.timeline}` : '',
+    mergedProfile.intent || mergedProfile.purpose ? `意图${mergedProfile.intent || mergedProfile.purpose}` : '',
+    mergedProfile.need || mergedProfile.needs ? `需求${mergedProfile.need || mergedProfile.needs}` : '',
+    mergedProfile.budgetWan || mergedProfile.budget ? `预算${mergedProfile.budgetWan || mergedProfile.budget}` : '',
+    mergedProfile.timeline ? `计划时间${mergedProfile.timeline}` : '',
+    mergedProfile.decisionMaker ? `决策人${mergedProfile.decisionMaker}` : '',
   ].filter(Boolean).join(',').slice(0, 240);
   const evidence = text.slice(0, 240);
   const tasks = Array.isArray(modelOutput.tasks)
@@ -321,45 +310,32 @@ function extractExplicitCustomerIntelligence(content, currentProfile = {}, model
       }))
     : [];
   const hasBudget = Number(mergedProfile.budgetWan || mergedProfile.budget || 0) > 0;
-  const hasRegion = Boolean(mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area);
-  const hasLayout = Boolean(mergedProfile.layout || mergedProfile.roomType);
-  const hasPurpose = Boolean(mergedProfile.purpose);
-  const hasTimeline = Boolean(mergedProfile.timeline || mergedProfile.purchaseTime || mergedProfile.purchase_time);
+  const hasPurpose = Boolean(mergedProfile.intent || mergedProfile.purpose || mergedProfile.need || mergedProfile.needs);
+  const hasTimeline = Boolean(mergedProfile.timeline);
 
-  if (hasBudget && (!hasPurpose || !hasTimeline)) {
+  if ((hasBudget || hasPurpose) && (!hasPurpose || !hasTimeline)) {
     tasks.push({
       businessKey: 'qualification:purpose_and_timeline',
       managedBy: 'rule',
       type: 'qualification',
-      title: '确认客户用途与购置时间',
-      owner: '待分配',
-      dueAt: '',
-      priority: hasRegion || hasLayout ? 'high' : 'medium',
-      reason: `客户已经给出预算,但${[!hasPurpose ? '用途' : '', !hasTimeline ? '购置时间' : ''].filter(Boolean).join('和')}仍不明确。`,
-      evidence: factEvidence || evidence,
-    });
-  }
-  if (hasBudget && hasRegion && hasLayout) {
-    tasks.push({
-      businessKey: 'recommendation:shortlist',
-      managedBy: 'rule',
-      type: 'recommendation',
-      title: '按已确认条件筛选并发送重点方案',
+      title: '确认客户目标与计划时间',
       owner: '待分配',
       dueAt: '',
       priority: 'high',
-      reason: '预算、区域和户型三项核心条件已经明确。',
+      reason: `${[!hasPurpose ? '客户目标' : '', !hasTimeline ? '计划时间' : ''].filter(Boolean).join('和')}仍不明确。`,
       evidence: factEvidence || evidence,
     });
+  }
+  if (hasPurpose && hasTimeline) {
     alerts.push({
       businessKey: 'high_intent:core_demand_ready',
       managedBy: 'rule',
       type: 'high_intent',
       severity: 'high',
       title: '客户核心需求已基本成形',
-      detail: '预算、区域和户型信息已具备,可以从泛咨询进入重点方案或下一步转化。',
+      detail: '客户目标和计划时间已具备,可以从泛咨询进入下一步服务安排。',
       evidence: factEvidence || evidence,
-      recommendedAction: '优先核对用途和时间计划,再给出少量重点方案。',
+      recommendedAction: '核对关键约束并确认下一步动作。',
     });
   }
   if (/投诉|不满意|骗人|退款|举报|再也不|太差|生气/.test(text)) {
@@ -840,25 +816,6 @@ class QiweiAgentRuntime {
           },
         },
       },
-      {
-        type: 'function',
-        function: {
-          name: 'search_properties',
-          description: '按客户明确需求查询已接入的房源数据。数据源未配置或仅为演示数据时会明确返回警告。',
-          parameters: {
-            type: 'object',
-            properties: {
-              query: { type: 'string' },
-              district: { type: 'string' },
-              budgetMax: { type: 'number' },
-              rooms: { type: 'integer' },
-              decoration: { type: 'string' },
-              orientation: { type: 'string' },
-              schoolRequired: { type: 'boolean' },
-            },
-          },
-        },
-      },
       {
         type: 'function',
         function: {
@@ -872,7 +829,6 @@ class QiweiAgentRuntime {
 
   async executeTool(name, args, context) {
     if (name === 'search_knowledge') return this.knowledge.search(args.query, args.limit || 5);
-    if (name === 'search_properties') return this.knowledge.searchProperties(args);
     if (name === 'get_customer_profile') return context.profile || { profile: {}, tags: [] };
     return { error: `未知工具 ${name}` };
   }
@@ -925,13 +881,6 @@ class QiweiAgentRuntime {
           if (call.function.name === 'search_knowledge') {
             for (const item of result) citations.push({ id: item.id, source: item.source, heading: item.heading });
           }
-          if (call.function.name === 'search_properties' && result.dataSource) {
-            citations.push({
-              id: `properties:${result.dataSource}`,
-              source: result.dataSource,
-              heading: `${result.dataLabel || '房源数据'}查询 ${result.total} 条`,
-            });
-          }
           llmMessages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
         }
         continue;

+ 202 - 10
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-db.js

@@ -9,6 +9,10 @@ const json = value => JSON.stringify(value ?? null);
 const parse = (value, fallback) => {
   try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
 };
+const CONVERSATION_MODES = ['review', 'auto', 'autopilot', 'human', 'paused'];
+const PERSONAL_INTAKE_MODES = ['allowlist_only', 'auto_enroll_review', 'auto_enroll_autopilot'];
+const WELCOME_SEND_MODES = ['draft', 'send'];
+const WELCOME_STATES = ['skipped', 'draft_pending', 'send_pending', 'sending', 'sent', 'failed', 'delivery_unknown'];
 
 const CONTACT_NAME_PLACEHOLDERS = new Set([
   '企微客户',
@@ -73,7 +77,7 @@ class AgentWorkbenchDb {
         id TEXT PRIMARY KEY,
         contact_id TEXT NOT NULL UNIQUE,
         contact_name TEXT NOT NULL DEFAULT '',
-        mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human','paused')),
+        mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','autopilot','human','paused')),
         last_message_at TEXT,
         created_at TEXT NOT NULL,
         updated_at TEXT NOT NULL
@@ -226,6 +230,27 @@ class AgentWorkbenchDb {
         value TEXT NOT NULL,
         updated_at TEXT NOT NULL
       );
+      CREATE TABLE IF NOT EXISTS contact_onboarding (
+        account_key TEXT NOT NULL,
+        contact_id TEXT NOT NULL,
+        conversation_id TEXT,
+        first_message_id TEXT,
+        contact_name TEXT NOT NULL DEFAULT '',
+        source TEXT NOT NULL DEFAULT 'polling',
+        policy TEXT NOT NULL,
+        welcome_state TEXT NOT NULL DEFAULT 'skipped' CHECK(welcome_state IN ('skipped','draft_pending','send_pending','sending','sent','failed','delivery_unknown')),
+        welcome_text TEXT NOT NULL DEFAULT '',
+        welcome_draft_id TEXT,
+        welcome_message_id TEXT,
+        attempt_count INTEGER NOT NULL DEFAULT 0,
+        last_error TEXT NOT NULL DEFAULT '',
+        last_attempt_at TEXT,
+        discovered_at TEXT NOT NULL,
+        enrolled_at TEXT NOT NULL,
+        sent_at TEXT,
+        updated_at TEXT NOT NULL,
+        PRIMARY KEY(account_key, contact_id)
+      );
       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);
@@ -237,7 +262,9 @@ class AgentWorkbenchDb {
       CREATE INDEX IF NOT EXISTS idx_memory_extraction_jobs_pending ON memory_extraction_jobs(status, next_attempt_at, created_at);
       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);
     `);
+    this.migrateConversationModes();
     this.db.prepare(`UPDATE memory_extraction_jobs SET status='pending',next_attempt_at=?,updated_at=? WHERE status='processing'`)
       .run(now(), now());
     this.ensureColumn('customer_tasks', 'business_key', "business_key TEXT NOT NULL DEFAULT ''");
@@ -257,9 +284,17 @@ class AgentWorkbenchDb {
       CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_alerts_business_key ON customer_alerts(conversation_id, business_key);
     `);
     this.setDefault('global_paused', defaults.globalPaused ? 'true' : 'false');
-    this.setDefault('default_mode', defaults.defaultMode || 'review');
+    this.setDefault('default_mode', CONVERSATION_MODES.includes(defaults.defaultMode) ? defaults.defaultMode : 'review');
     this.setDefault('auto_send_confidence', String(defaults.autoSendConfidence ?? 0.88));
     this.setDefault('agent_cutover_at', defaults.cutoverAt || now());
+    this.setDefault('personal_intake_mode', PERSONAL_INTAKE_MODES.includes(defaults.personalIntakeMode) ? defaults.personalIntakeMode : 'allowlist_only');
+    this.setDefault('welcome_enabled', defaults.welcomeEnabled ? 'true' : 'false');
+    this.setDefault('welcome_text', String(defaults.welcomeText || '您好,已经收到您的消息,我会尽快为您处理。'));
+    this.setDefault('welcome_send_mode', WELCOME_SEND_MODES.includes(defaults.welcomeSendMode) ? defaults.welcomeSendMode : 'draft');
+    this.setDefault('personal_intake_autopilot_confirmation', '');
+    this.setDefault('personal_intake_autopilot_confirmed_at', '');
+    this.setDefault('personal_intake_autopilot_confirmed_by', '');
+    this.setDefault('auto_enrolled_contact_ids', '[]');
   }
 
   close() { this.db.close(); }
@@ -272,7 +307,7 @@ class AgentWorkbenchDb {
   customerTaskBusinessKey(item = {}) {
     const title = String(item.title || '').trim();
     const type = normalizeKeyPart(item.type || 'follow_up');
-    if (type === 'qualification' && /用途.*(购置)?时间/.test(title)) return 'qualification:purpose_and_timeline';
+    if (type === 'qualification' && /用途.*时间/.test(title)) return 'qualification:purpose_and_timeline';
     if (type === 'recommendation' && /(筛选|发送).*(重点|方案)/.test(title)) return 'recommendation:shortlist';
     const explicit = item.businessKey || item.business_key || item.taskKey || item.key;
     if (explicit) return String(explicit).split(':').map(normalizeKeyPart).filter(Boolean).join(':');
@@ -347,6 +382,156 @@ class AgentWorkbenchDb {
       ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`).run(key, String(value), now());
   }
 
+  migrateConversationModes() {
+    const schema = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='conversations'").get()?.sql || '';
+    if (schema.includes("'autopilot'")) return;
+    this.db.exec('PRAGMA foreign_keys=OFF;');
+    try {
+      this.db.exec(`
+        BEGIN IMMEDIATE;
+        CREATE TABLE conversations_mode_migration (
+          id TEXT PRIMARY KEY,
+          contact_id TEXT NOT NULL UNIQUE,
+          contact_name TEXT NOT NULL DEFAULT '',
+          mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','autopilot','human','paused')),
+          last_message_at TEXT,
+          created_at TEXT NOT NULL,
+          updated_at TEXT NOT NULL
+        );
+        INSERT INTO conversations_mode_migration(id,contact_id,contact_name,mode,last_message_at,created_at,updated_at)
+          SELECT id,contact_id,contact_name,mode,last_message_at,created_at,updated_at FROM conversations;
+        DROP TABLE conversations;
+        ALTER TABLE conversations_mode_migration RENAME TO conversations;
+        COMMIT;
+      `);
+    } catch (error) {
+      try { this.db.exec('ROLLBACK;'); } catch {}
+      throw error;
+    } finally {
+      this.db.exec('PRAGMA foreign_keys=ON;');
+    }
+  }
+
+  intakePolicy() {
+    const mode = this.getSetting('personal_intake_mode', 'allowlist_only');
+    const welcomeSendMode = this.getSetting('welcome_send_mode', 'draft');
+    return {
+      mode: PERSONAL_INTAKE_MODES.includes(mode) ? mode : 'allowlist_only',
+      welcomeEnabled: this.getSetting('welcome_enabled', 'false') === 'true',
+      welcomeText: this.getSetting('welcome_text', ''),
+      welcomeSendMode: WELCOME_SEND_MODES.includes(welcomeSendMode) ? welcomeSendMode : 'draft',
+      autopilotConfirmation: this.getSetting('personal_intake_autopilot_confirmation', ''),
+      autopilotConfirmedAt: this.getSetting('personal_intake_autopilot_confirmed_at', ''),
+      autopilotConfirmedBy: this.getSetting('personal_intake_autopilot_confirmed_by', ''),
+    };
+  }
+
+  setIntakePolicy(input = {}) {
+    if (input.mode !== undefined) {
+      if (!PERSONAL_INTAKE_MODES.includes(input.mode)) throw new Error('不支持的个人消息接入模式');
+      this.setSetting('personal_intake_mode', input.mode);
+    }
+    if (input.welcomeEnabled !== undefined) this.setSetting('welcome_enabled', input.welcomeEnabled ? 'true' : 'false');
+    if (input.welcomeText !== undefined) this.setSetting('welcome_text', String(input.welcomeText || '').trim());
+    if (input.welcomeSendMode !== undefined) {
+      if (!WELCOME_SEND_MODES.includes(input.welcomeSendMode)) throw new Error('不支持的欢迎语发送模式');
+      this.setSetting('welcome_send_mode', input.welcomeSendMode);
+    }
+    if (input.autopilotConfirmation !== undefined) this.setSetting('personal_intake_autopilot_confirmation', input.autopilotConfirmation);
+    if (input.autopilotConfirmedAt !== undefined) this.setSetting('personal_intake_autopilot_confirmed_at', input.autopilotConfirmedAt);
+    if (input.autopilotConfirmedBy !== undefined) this.setSetting('personal_intake_autopilot_confirmed_by', input.autopilotConfirmedBy);
+    return this.intakePolicy();
+  }
+
+  getAutoEnrolledContactIds() {
+    const ids = parse(this.getSetting('auto_enrolled_contact_ids', '[]'), []);
+    return [...new Set((Array.isArray(ids) ? ids : []).map(String).map(item => item.trim()).filter(Boolean))];
+  }
+
+  addAutoEnrolledContactId(contactId) {
+    const ids = [...new Set([...this.getAutoEnrolledContactIds(), String(contactId || '').trim()].filter(Boolean))];
+    this.setSetting('auto_enrolled_contact_ids', json(ids));
+    return ids;
+  }
+
+  getOnboarding(accountKey, contactId) {
+    return this.db.prepare('SELECT * FROM contact_onboarding WHERE account_key=? AND contact_id=?')
+      .get(String(accountKey), String(contactId)) || null;
+  }
+
+  listOnboardings(accountKey, { states = [], limit = 100 } = {}) {
+    const selectedStates = (Array.isArray(states) ? states : []).filter(state => WELCOME_STATES.includes(state));
+    let sql = 'SELECT * FROM contact_onboarding WHERE account_key=?';
+    const params = [String(accountKey)];
+    if (selectedStates.length) {
+      sql += ` AND welcome_state IN (${selectedStates.map(() => '?').join(',')})`;
+      params.push(...selectedStates);
+    }
+    sql += ' ORDER BY updated_at DESC, contact_id ASC LIMIT ?';
+    params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
+    return this.db.prepare(sql).all(...params);
+  }
+
+  upsertOnboarding(input = {}) {
+    const accountKey = String(input.accountKey || '').trim();
+    const contactId = String(input.contactId || '').trim();
+    if (!accountKey || !contactId) throw new Error('欢迎语幂等键缺少账号或联系人');
+    const timestamp = now();
+    const state = WELCOME_STATES.includes(input.welcomeState) ? input.welcomeState : 'skipped';
+    this.db.prepare(`INSERT INTO contact_onboarding(
+      account_key,contact_id,conversation_id,first_message_id,contact_name,source,policy,welcome_state,welcome_text,
+      welcome_draft_id,welcome_message_id,attempt_count,last_error,last_attempt_at,discovered_at,enrolled_at,sent_at,updated_at
+    ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+    ON CONFLICT(account_key,contact_id) DO UPDATE SET
+      conversation_id=COALESCE(excluded.conversation_id,contact_onboarding.conversation_id),
+      first_message_id=COALESCE(contact_onboarding.first_message_id,excluded.first_message_id),
+      contact_name=CASE WHEN excluded.contact_name<>'' THEN excluded.contact_name ELSE contact_onboarding.contact_name END,
+      source=excluded.source,policy=excluded.policy,updated_at=excluded.updated_at`)
+      .run(accountKey, contactId, input.conversationId || null, input.firstMessageId || null, String(input.contactName || ''), String(input.source || 'polling'), String(input.policy || 'allowlist_only'), state, String(input.welcomeText || ''), input.welcomeDraftId || null, input.welcomeMessageId || null, Number(input.attemptCount) || 0, String(input.lastError || ''), input.lastAttemptAt || null, input.discoveredAt || timestamp, input.enrolledAt || timestamp, input.sentAt || null, timestamp);
+    return this.getOnboarding(accountKey, contactId);
+  }
+
+  updateOnboarding(accountKey, contactId, fields = {}) {
+    const allowed = ['conversation_id', 'first_message_id', 'policy', 'welcome_state', 'welcome_text', 'welcome_draft_id', 'welcome_message_id', 'attempt_count', 'last_error', 'last_attempt_at', 'sent_at'];
+    const entries = Object.entries(fields).filter(([key, value]) => allowed.includes(key) && value !== undefined);
+    if (!entries.length) return this.getOnboarding(accountKey, contactId);
+    if (fields.welcome_state !== undefined && !WELCOME_STATES.includes(fields.welcome_state)) throw new Error('不支持的欢迎语状态');
+    const assignments = [...entries.map(([key]) => `${key}=?`), 'updated_at=?'].join(',');
+    this.db.prepare(`UPDATE contact_onboarding SET ${assignments} WHERE account_key=? AND contact_id=?`)
+      .run(...entries.map(([, value]) => value), now(), String(accountKey), String(contactId));
+    return this.getOnboarding(accountKey, contactId);
+  }
+
+  claimWelcomeSend(accountKey, contactId) {
+    const timestamp = now();
+    const result = this.db.prepare(`UPDATE contact_onboarding SET welcome_state='sending',attempt_count=attempt_count+1,
+      last_error='',last_attempt_at=?,updated_at=? WHERE account_key=? AND contact_id=? AND welcome_state IN ('send_pending','failed')`)
+      .run(timestamp, timestamp, String(accountKey), String(contactId));
+    return Number(result.changes || 0) === 1 ? this.getOnboarding(accountKey, contactId) : null;
+  }
+
+  markWelcomeSent(accountKey, contactId, messageId) {
+    const timestamp = now();
+    this.db.prepare(`UPDATE contact_onboarding SET welcome_state='sent',welcome_message_id=?,last_error='',sent_at=?,updated_at=?
+      WHERE account_key=? AND contact_id=? AND welcome_state='sending'`)
+      .run(messageId || null, timestamp, timestamp, String(accountKey), String(contactId));
+    return this.getOnboarding(accountKey, contactId);
+  }
+
+  markWelcomeFailed(accountKey, contactId, error) {
+    this.db.prepare(`UPDATE contact_onboarding SET welcome_state='failed',last_error=?,updated_at=?
+      WHERE account_key=? AND contact_id=? AND welcome_state='sending'`)
+      .run(String(error || '欢迎语发送失败').slice(0, 500), now(), String(accountKey), String(contactId));
+    return this.getOnboarding(accountKey, contactId);
+  }
+
+  markStaleWelcomeSending(accountKey, cutoff) {
+    return Number(this.db.prepare(`UPDATE contact_onboarding SET welcome_state='delivery_unknown',
+      last_error='发送结果待人工确认',updated_at=? WHERE account_key=? AND welcome_state='sending'
+      AND COALESCE(last_attempt_at,'')<>'' AND last_attempt_at<?`)
+      .run(now(), String(accountKey), String(cutoff)).changes || 0);
+  }
+
   globalState() {
     return {
       paused: this.getSetting('global_paused', 'true') === 'true',
@@ -367,8 +552,11 @@ class AgentWorkbenchDb {
     }
     const conversationId = makeId('conv');
     const timestamp = now();
+    const defaultMode = CONVERSATION_MODES.includes(this.getSetting('default_mode', 'review'))
+      ? this.getSetting('default_mode', 'review')
+      : 'review';
     this.db.prepare(`INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at)
-      VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), safeName, this.getSetting('default_mode', 'review'), timestamp, timestamp);
+      VALUES(?,?,?,?,?,?)`).run(conversationId, String(contactId), safeName, defaultMode, timestamp, timestamp);
     this.db.prepare('INSERT INTO customer_profiles(conversation_id,updated_at) VALUES(?,?)').run(conversationId, timestamp);
     return this.getConversation(conversationId);
   }
@@ -385,11 +573,13 @@ class AgentWorkbenchDb {
     return this.db.prepare(`SELECT c.*,
       (SELECT content FROM messages m WHERE m.conversation_id=c.id ORDER BY m.created_at DESC LIMIT 1) AS last_content,
       (SELECT COUNT(*) FROM drafts d WHERE d.conversation_id=c.id AND d.status='pending') AS pending_count
-      FROM conversations c ORDER BY COALESCE(c.last_message_at,c.created_at) DESC`).all();
+      FROM conversations c
+      ORDER BY CASE WHEN NULLIF(TRIM(c.last_message_at),'') IS NULL THEN 1 ELSE 0 END,
+        c.last_message_at DESC,c.created_at DESC,c.id ASC`).all();
   }
 
   setConversationMode(conversationId, mode) {
-    if (!['review', 'auto', 'human', 'paused'].includes(mode)) throw new Error('不支持的会话模式');
+    if (!CONVERSATION_MODES.includes(mode)) throw new Error('不支持的会话模式');
     const result = this.db.prepare('UPDATE conversations SET mode=?,updated_at=? WHERE id=?').run(mode, now(), conversationId);
     if (!result.changes) throw new Error('会话不存在');
     return this.getConversation(conversationId);
@@ -946,13 +1136,15 @@ class AgentWorkbenchDb {
 
   latestAgentOutcome(conversationId) {
     const row = this.db.prepare(`SELECT * FROM audit_logs
-      WHERE conversation_id=? AND action IN ('draft_created','agent_failed','agent_not_configured','agent_no_reply_needed')
+      WHERE conversation_id=? AND action IN ('draft_created','autopilot_message_sent','autopilot_send_failed','agent_failed','agent_not_configured','agent_no_reply_needed')
       ORDER BY created_at DESC LIMIT 1`).get(conversationId);
     if (!row) return null;
     const detail = parse(row.detail_json, {});
     return {
       action: row.action,
-      message: detail.message || (row.action === 'agent_no_reply_needed' ? '客户消息无需回复' : 'Agent 上游不可用'),
+      message: detail.message || (row.action === 'agent_no_reply_needed'
+        ? '客户消息无需回复'
+        : row.action === 'autopilot_message_sent' ? '全自动接管已直接发送 Agent 回复' : 'Agent 上游不可用'),
       entityId: row.entity_id,
       createdAt: row.created_at,
     };
@@ -960,7 +1152,7 @@ class AgentWorkbenchDb {
 
   latestAgentState(conversationId) {
     const outcome = this.latestAgentOutcome(conversationId);
-    if (!outcome || ['draft_created', 'agent_no_reply_needed'].includes(outcome.action)) return null;
+    if (!outcome || ['draft_created', 'autopilot_message_sent', 'agent_no_reply_needed'].includes(outcome.action)) return null;
     return outcome;
   }
 
@@ -977,7 +1169,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'];
+    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'];
     let rowsImported = 0;
     this.db.exec(`
       DROP INDEX IF EXISTS idx_customer_tasks_business_key;

+ 166 - 51
claude-code/claude-code-qiwe-assistant/mcp/src/core/agent-workbench-service.js

@@ -10,17 +10,6 @@ function looksLikeGroupId(value) {
   return /(?:@chatroom$|^(?:room|group|chatroom|r[-_:]))/i.test(String(value || '').trim());
 }
 
-function propertyRecommendationsFromTrace(toolTrace = []) {
-  const items = [];
-  for (const call of toolTrace || []) {
-    if (call?.tool !== 'search_properties') continue;
-    for (const property of call.result?.items || []) {
-      if (property?.id) items.push(property);
-    }
-  }
-  return [...new Map(items.map(item => [String(item.id), item])).values()];
-}
-
 class AgentWorkbenchService extends EventEmitter {
   constructor({ db, agent, qiwei, config, memory = null, memoryWorker = null }) {
     super();
@@ -76,6 +65,89 @@ class AgentWorkbenchService extends EventEmitter {
     if (groupMessage) throw new Error('群聊仅用于监控与人工处理,不能进入客户私聊 Agent 发送链路');
   }
 
+  intakeAccountKey() {
+    return String(this.config.accountKey || this.config.qiwei?.accountKey || 'default');
+  }
+
+  async sendOnboardingWelcome(contactId, actor = 'agent:intake') {
+    const accountKey = this.intakeAccountKey();
+    const claimed = this.db.claimWelcomeSend(accountKey, contactId);
+    if (!claimed) return { status: 'welcome_not_sendable', onboarding: this.db.getOnboarding(accountKey, contactId) };
+    const conversation = this.db.getConversation(claimed.conversation_id);
+    if (!conversation) {
+      const onboarding = this.db.markWelcomeFailed(accountKey, contactId, '欢迎语对应会话不存在');
+      return { status: 'welcome_send_failed', onboarding };
+    }
+    try {
+      this.requireAllowed(contactId);
+      this.requirePrivateConversation(conversation.id);
+      const result = await this.qiwei.sendText(contactId, claimed.welcome_text);
+      if (result?.isSendSuccess === false || Number(result?.isSendSuccess) === 0) throw new Error('企微未确认欢迎语发送成功');
+      const outbound = this.db.insertMessage({
+        conversationId: conversation.id,
+        direction: 'outbound',
+        senderType: 'agent',
+        content: claimed.welcome_text,
+        status: 'sent',
+        raw: { source: 'new_contact_welcome', intakePolicy: claimed.policy },
+      }).message;
+      appendChatRecord({
+        wxid: contactId,
+        messageId: outbound.id,
+        externalId: null,
+        dir: 'out',
+        senderType: 'agent',
+        content: claimed.welcome_text,
+        createdAt: outbound.created_at,
+        source: 'new_contact_welcome',
+      });
+      const onboarding = this.db.markWelcomeSent(accountKey, contactId, outbound.id);
+      this.db.audit({ actor, action: 'onboarding_welcome_sent', conversationId: conversation.id, entityId: outbound.id, detail: { contactId, attemptCount: onboarding.attempt_count } });
+      this.emit('change', { type: 'message', conversationId: conversation.id });
+      return { status: 'welcome_sent', conversation, message: outbound, onboarding };
+    } catch (error) {
+      const onboarding = this.db.markWelcomeFailed(accountKey, contactId, error.message);
+      this.db.audit({ actor, action: 'onboarding_welcome_failed', conversationId: conversation.id, detail: { contactId, message: error.message, attemptCount: onboarding.attempt_count } });
+      this.emit('change', { type: 'send_error', conversationId: conversation.id });
+      return { status: 'welcome_send_failed', conversation, onboarding, error: error.message };
+    }
+  }
+
+  async handleOnboardingWelcome(conversation, inboundMessage) {
+    const accountKey = this.intakeAccountKey();
+    const onboarding = this.db.getOnboarding(accountKey, conversation.contact_id);
+    if (!onboarding || onboarding.welcome_state === 'skipped') return null;
+    if (onboarding.welcome_state === 'send_pending') return this.sendOnboardingWelcome(conversation.contact_id);
+    if (onboarding.welcome_state === 'draft_pending') {
+      if (onboarding.welcome_draft_id) {
+        return { status: 'pending_review', conversation, message: inboundMessage, draft: this.db.getDraft(onboarding.welcome_draft_id), onboarding };
+      }
+      const draft = this.db.createDraft({
+        conversationId: conversation.id,
+        inboundMessageId: inboundMessage.id,
+        content: onboarding.welcome_text,
+        confidence: 1,
+        intent: 'new_contact_welcome',
+        reason: '新联系人欢迎语按接入策略进入人工审核',
+        requiresHuman: true,
+        citations: [],
+        toolTrace: [],
+      });
+      const updated = this.db.updateOnboarding(accountKey, conversation.contact_id, { welcome_draft_id: draft.id });
+      this.db.audit({ actor: 'agent:intake', action: 'onboarding_welcome_draft_created', conversationId: conversation.id, entityId: draft.id, detail: { contactId: conversation.contact_id } });
+      this.emit('change', { type: 'draft', conversationId: conversation.id });
+      return { status: 'pending_review', conversation, message: inboundMessage, draft, onboarding: updated };
+    }
+    return { status: `welcome_${onboarding.welcome_state}`, conversation, message: inboundMessage, onboarding };
+  }
+
+  async retryOnboardingWelcome(contactId, actor = 'human') {
+    const onboarding = this.db.getOnboarding(this.intakeAccountKey(), contactId);
+    if (!onboarding) throw new Error('新联系人欢迎记录不存在');
+    if (onboarding.welcome_state !== 'failed') throw new Error('只有明确发送失败的欢迎语可以重试');
+    return this.sendOnboardingWelcome(contactId, actor);
+  }
+
   conversationDetail(conversationId) {
     const conversation = this.db.getConversation(conversationId);
     if (!conversation) return null;
@@ -86,20 +158,20 @@ class AgentWorkbenchService extends EventEmitter {
       profile: this.db.getProfile(conversationId),
       tasks: this.db.listCustomerTasks(conversationId),
       alerts: this.db.listCustomerAlerts(conversationId),
-      recommendations: this.db.listCustomerRecommendations(conversationId),
       drafts: this.db.listDrafts({ conversationId }),
       memories: this.db.listCustomerMemories(conversationId, { status: '', limit: 100 }),
       memorySnapshot: this.db.latestMemorySnapshot(conversationId),
       audit: this.db.listAudit(200, conversationId),
+      onboarding: this.db.getOnboarding(this.intakeAccountKey(), conversation.contact_id),
       agentOutcome,
-      agentError: ['agent_failed', 'agent_not_configured'].includes(agentOutcome?.action) ? agentOutcome : null,
+      agentError: ['agent_failed', 'agent_not_configured', 'autopilot_send_failed'].includes(agentOutcome?.action) ? agentOutcome : null,
     };
   }
 
   setGlobal({ paused, defaultMode, autoSendConfidence }, actor = 'human') {
     if (paused !== undefined) this.db.setSetting('global_paused', paused ? 'true' : 'false');
     if (defaultMode !== undefined) {
-      if (!['review', 'auto', 'human', 'paused'].includes(defaultMode)) throw new Error('不支持的默认模式');
+      if (!['review', 'auto', 'autopilot', 'human', 'paused'].includes(defaultMode)) throw new Error('不支持的默认模式');
       this.db.setSetting('default_mode', defaultMode);
     }
     if (autoSendConfidence !== undefined) {
@@ -196,7 +268,7 @@ class AgentWorkbenchService extends EventEmitter {
     return result;
   }
 
-  async ingestInbound({ externalId, contactId, contactName = '', content, timestamp = new Date().toISOString(), raw = null }) {
+  async ingestInbound({ externalId, contactId, contactName = '', content, timestamp = new Date().toISOString(), raw = null }, options = {}) {
     if (isGroupMessage(raw || {}) || looksLikeGroupId(contactId)) return { status: 'ignored_group_message' };
     if (!this.isAllowed(contactId)) return { status: 'ignored_not_allowlisted' };
     const conversation = this.db.ensureConversation(contactId, contactName);
@@ -250,10 +322,16 @@ class AgentWorkbenchService extends EventEmitter {
       return { status: current.mode, conversation: current, message: inserted.message };
     }
 
-    return this.generateDraftForMessage(current, inserted.message);
+    if (options.onboarding === true) {
+      this.db.updateOnboarding(this.intakeAccountKey(), current.contact_id, { first_message_id: inserted.message.id });
+      const welcome = await this.handleOnboardingWelcome(current, inserted.message);
+      if (welcome) return welcome;
+    }
+
+    return this.generateDraftForMessage(current, inserted.message, { allowAutoSend: options.allowAutoSend });
   }
 
-  async generateDraftForMessage(conversation, inboundMessage) {
+  async generateDraftForMessage(conversation, inboundMessage, options = {}) {
     try {
       if (isNoReplyNeededMessage(inboundMessage.content)) {
         this.queueMemoryExtraction(conversation, inboundMessage);
@@ -276,7 +354,6 @@ class AgentWorkbenchService extends EventEmitter {
       const profile = this.db.getProfile(conversation.id);
       const currentTasks = this.db.listCustomerTasks(conversation.id);
       const currentAlerts = this.db.listCustomerAlerts(conversation.id);
-      const currentRecommendations = this.db.listCustomerRecommendations(conversation.id);
       let memoryContext = { enabled: false, snapshot: { version: 0 }, recalled: [], promptText: '' };
       try {
         memoryContext = this.memory.prepare({ conversation, inboundContent: inboundMessage.content });
@@ -293,7 +370,7 @@ class AgentWorkbenchService extends EventEmitter {
         conversation,
         messages: this.db.listMessages(conversation.id),
         profile,
-        customerIntelligence: { tasks: currentTasks, alerts: currentAlerts, recommendations: currentRecommendations },
+        customerIntelligence: { tasks: currentTasks, alerts: currentAlerts },
         inboundContent: inboundMessage.content,
         memoryContext,
       });
@@ -339,6 +416,13 @@ class AgentWorkbenchService extends EventEmitter {
           },
         });
       }
+      if (options.allowAutoSend !== false && conversation.mode === 'autopilot') {
+        return await this.sendAutopilotReply(conversation, inboundMessage, output, {
+          profile: updatedProfile,
+          tasks: customerTasks,
+          alerts: customerAlerts,
+        });
+      }
       const draft = this.db.createDraft({
         conversationId: conversation.id,
         inboundMessageId: inboundMessage.id,
@@ -350,16 +434,6 @@ class AgentWorkbenchService extends EventEmitter {
         citations: output.citations,
         toolTrace: output.toolTrace,
       });
-      const draftRecommendations = propertyRecommendationsFromTrace(output.toolTrace);
-      if (draftRecommendations.length) {
-        this.db.upsertCustomerRecommendations(conversation.id, draftRecommendations, {
-          type: 'agent-tool',
-          entityId: draft.id,
-          status: 'candidate',
-          evidence: 'Agent 房源工具查询结果,尚未确认已发送给客户',
-          createdAt: draft.created_at,
-        });
-      }
       this.db.audit({
         actor: 'agent',
         action: 'draft_created',
@@ -369,7 +443,7 @@ class AgentWorkbenchService extends EventEmitter {
       });
       this.emit('change', { type: 'draft', conversationId: conversation.id });
       const global = this.db.globalState();
-      if (conversation.mode === 'auto' && !draft.requires_human && draft.confidence >= global.autoSendConfidence) {
+      if (options.allowAutoSend !== false && conversation.mode === 'auto' && !draft.requires_human && draft.confidence >= global.autoSendConfidence) {
         return this.approveDraft(draft.id, { actor: 'agent:auto' });
       }
       return {
@@ -388,7 +462,7 @@ class AgentWorkbenchService extends EventEmitter {
       };
     } catch (error) {
       this.queueMemoryExtraction(conversation, inboundMessage);
-      const action = error instanceof AgentNotConfiguredError ? 'agent_not_configured' : 'agent_failed';
+      const action = error.agentAction || (error instanceof AgentNotConfiguredError ? 'agent_not_configured' : 'agent_failed');
       const friendly = friendlyAgentError(error);
       this.db.audit({
         actor: 'agent',
@@ -397,11 +471,71 @@ class AgentWorkbenchService extends EventEmitter {
         entityId: inboundMessage.id,
         detail: { message: friendly.message, errorCode: friendly.code, rawMessage: error.message },
       });
-      this.emit('change', { type: 'agent_error', conversationId: conversation.id });
+      this.emit('change', { type: action === 'autopilot_send_failed' ? 'send_error' : 'agent_error', conversationId: conversation.id });
       return { status: action, error: friendly.message, errorCode: friendly.code, conversation, message: inboundMessage };
     }
   }
 
+  async sendAutopilotReply(conversation, inboundMessage, output, intelligence = {}) {
+    this.requireAllowed(conversation.contact_id);
+    this.requirePrivateConversation(conversation.id);
+    const content = String(output.content || '').trim();
+    if (!content) throw new Error('Agent 没有生成可发送的回复内容');
+    if (content.length > 2000) throw new Error('回复内容过长');
+    try {
+      const result = await this.qiwei.sendText(conversation.contact_id, content);
+      const outbound = this.db.insertMessage({
+        conversationId: conversation.id,
+        direction: 'outbound',
+        senderType: 'agent',
+        content,
+        status: result.isSendSuccess === false ? 'submitted' : 'sent',
+        raw: {
+          automationMode: 'autopilot',
+          inboundMessageId: inboundMessage.id,
+          confidence: Number(output.confidence) || 0,
+          requiresHuman: Boolean(output.requiresHuman),
+          citations: output.citations || [],
+          toolTrace: output.toolTrace || [],
+        },
+      }).message;
+      appendChatRecord({
+        wxid: conversation.contact_id,
+        messageId: outbound.id,
+        externalId: null,
+        dir: 'out',
+        senderType: 'agent',
+        content,
+        createdAt: outbound.created_at,
+        source: 'autopilot',
+      });
+      this.db.audit({
+        actor: 'agent:autopilot',
+        action: 'autopilot_message_sent',
+        conversationId: conversation.id,
+        entityId: inboundMessage.id,
+        detail: {
+          inboundMessageId: inboundMessage.id,
+          outboundMessageId: outbound.id,
+          confidence: Number(output.confidence) || 0,
+          requiresHuman: Boolean(output.requiresHuman),
+          draftCreated: false,
+        },
+      });
+      this.emit('change', { type: 'message', conversationId: conversation.id });
+      return {
+        status: 'autopilot_sent',
+        conversation,
+        message: inboundMessage,
+        reply: outbound,
+        intelligence,
+      };
+    } catch (error) {
+      error.agentAction = 'autopilot_send_failed';
+      throw error;
+    }
+  }
+
   async approveDraft(draftId, { content = '', actor = 'human' } = {}) {
     const draft = this.db.getDraft(draftId);
     if (!draft) throw new Error('回复草稿不存在');
@@ -446,22 +580,6 @@ class AgentWorkbenchService extends EventEmitter {
         createdAt: outbound.created_at,
         source: 'draft_approved',
       });
-      const sentRecommendations = propertyRecommendationsFromTrace(draft.tool_trace);
-      if (sentRecommendations.length) {
-        this.db.upsertCustomerRecommendations(conversation.id, sentRecommendations, {
-          type: 'outbound-message',
-          entityId: outbound.id,
-          status: 'recommended',
-          evidence: finalContent.slice(0, 500),
-          createdAt: outbound.created_at,
-        });
-      }
-      if (
-        (draft.tool_trace || []).some(item => item.tool === 'search_properties') ||
-        /(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(finalContent)
-      ) {
-        this.db.completeCustomerTaskByBusinessKey(conversation.id, 'recommendation:shortlist', 'outbound_recommendation_sent');
-      }
       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 });
@@ -545,9 +663,6 @@ class AgentWorkbenchService extends EventEmitter {
       createdAt: message.created_at,
       source: 'manual',
     });
-    if (/(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(text)) {
-      this.db.completeCustomerTaskByBusinessKey(conversationId, 'recommendation:shortlist', 'manual_recommendation_sent');
-    }
     this.db.audit({ actor, action: 'manual_message_sent', conversationId, entityId: message.id });
     this.emit('change', { type: 'message', conversationId });
     return message;

+ 52 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/conversation-order.js

@@ -0,0 +1,52 @@
+'use strict';
+
+const MISSING_TIMESTAMP = Number.NEGATIVE_INFINITY;
+
+function conversationTimestamp(value) {
+  if (value === null || value === undefined) return MISSING_TIMESTAMP;
+  if (value instanceof Date) {
+    const timestamp = value.getTime();
+    return Number.isFinite(timestamp) ? timestamp : MISSING_TIMESTAMP;
+  }
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? value : MISSING_TIMESTAMP;
+  }
+  const text = String(value).trim();
+  if (!text) return MISSING_TIMESTAMP;
+  const timestamp = Date.parse(text);
+  return Number.isFinite(timestamp) ? timestamp : MISSING_TIMESTAMP;
+}
+
+function compareConversationRecency(left, right) {
+  const leftTimestamp = conversationTimestamp(left?.lastMessageAt);
+  const rightTimestamp = conversationTimestamp(right?.lastMessageAt);
+  return leftTimestamp === rightTimestamp ? 0 : rightTimestamp - leftTimestamp;
+}
+
+function sortConversationsByRecency(items = []) {
+  return [...items]
+    .map((item, index) => ({ item, index }))
+    .sort((left, right) => compareConversationRecency(left.item, right.item) || left.index - right.index)
+    .map(entry => entry.item);
+}
+
+function maxConversationTimestamp(...values) {
+  let selected = null;
+  let selectedTimestamp = MISSING_TIMESTAMP;
+  for (const value of values.flat()) {
+    const timestamp = conversationTimestamp(value);
+    if (timestamp > selectedTimestamp) {
+      selected = value;
+      selectedTimestamp = timestamp;
+    }
+  }
+  return selected;
+}
+
+module.exports = {
+  MISSING_TIMESTAMP,
+  conversationTimestamp,
+  compareConversationRecency,
+  sortConversationsByRecency,
+  maxConversationTimestamp,
+};

+ 55 - 4
claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js

@@ -116,16 +116,65 @@ function readFmodeConfig() {
 
 function pickFmodeAnthropicToken(env) {
   const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
-  if (!token || !/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
   const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
-  if (base && !base.includes('fmode')) return '';
-  return token;
+  return pickFmodeApiToken(token, base);
 }
 
 function normalizeToken(value) {
   return String(value || '').trim().replace(/^Bearer\s+/i, '');
 }
 
+function pickFmodeApiToken(value, apiBase = '') {
+  const token = normalizeToken(value);
+  if (!/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
+  const base = String(apiBase || '').trim().toLowerCase();
+  if (base && !base.includes('fmode')) return '';
+  return token;
+}
+
+function readFmodeVoiceToken(input = {}, sourceOverrides = {}) {
+  const processEnv = sourceOverrides.processEnv || process.env;
+  const fileEnv = sourceOverrides.fileEnv || readEnvFiles();
+  const claudeEnv = sourceOverrides.claudeEnv || readClaudeSettingsEnv();
+  const fmodeConfig = sourceOverrides.fmodeConfig || readFmodeConfig();
+  const voiceBase = firstNonEmpty([
+    input.voiceApiBase,
+    input.endpoint,
+    processEnv.QIWEI_VOICE_ENDPOINT,
+    fileEnv.QIWEI_VOICE_ENDPOINT,
+    'https://server.fmode.cn/api/voice/indextts2'
+  ]);
+  const fmodeBase = firstNonEmpty([
+    input.fmodeApiBase,
+    processEnv.FMODE_API_BASE,
+    fileEnv.FMODE_API_BASE,
+    fmodeConfig.apiBase,
+    fmodeConfig.llmBaseUrl,
+    'https://api.fmode.cn'
+  ]);
+  return firstNonEmpty([
+    pickFmodeApiToken(input.voiceAuthToken || input.authToken, voiceBase),
+    pickFmodeApiToken(processEnv.QIWEI_VOICE_AUTH_TOKEN, voiceBase),
+    pickFmodeApiToken(fileEnv.QIWEI_VOICE_AUTH_TOKEN, voiceBase),
+    pickFmodeApiToken(input.fmodeApiToken || input.fmodeApiKey || input.newapiToken, fmodeBase),
+    pickFmodeApiToken(processEnv.FMODE_API_TOKEN, fmodeBase),
+    pickFmodeApiToken(processEnv.FMODE_API_KEY, fmodeBase),
+    pickFmodeApiToken(processEnv.NEWAPI_TOKEN, fmodeBase),
+    pickFmodeApiToken(fileEnv.FMODE_API_TOKEN, fmodeBase),
+    pickFmodeApiToken(fileEnv.FMODE_API_KEY, fmodeBase),
+    pickFmodeApiToken(fileEnv.NEWAPI_TOKEN, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.fmodeApiToken, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.fmodeApiKey, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.newapiToken, fmodeBase),
+    pickFmodeApiToken(fmodeConfig.newApiToken, fmodeBase),
+    pickFmodeApiToken(claudeEnv.FMODE_API_TOKEN, fmodeBase),
+    pickFmodeApiToken(claudeEnv.FMODE_API_KEY, fmodeBase),
+    pickFmodeApiToken(claudeEnv.NEWAPI_TOKEN, fmodeBase),
+    pickFmodeAnthropicToken(processEnv),
+    pickFmodeAnthropicToken(claudeEnv)
+  ]);
+}
+
 function readQiweiAuthToken(input = {}) {
   const fileEnv = readEnvFiles();
   const claudeEnv = readClaudeSettingsEnv();
@@ -332,6 +381,7 @@ module.exports = {
   DEFAULT_API_BASE,
   CREDENTIALS_FILE,
   readQiweiAuthToken,
+  readFmodeVoiceToken,
   readQiweiUid,
   readQiweiGuid,
   readFmodeApiKey,
@@ -346,5 +396,6 @@ module.exports = {
   readEnvFiles,
   readClaudeSettingsEnv,
   readFmodeConfig,
-  pickFmodeAnthropicToken
+  pickFmodeAnthropicToken,
+  pickFmodeApiToken
 };

+ 11 - 11
claude-code/claude-code-qiwe-assistant/mcp/src/core/llm-client.js

@@ -22,7 +22,7 @@ function buildPortraitAnalysisPrompt(context) {
     return `${idx + 1}. [${time}] ${sender}: ${text}`;
   }).join('\n');
 
-  return `你是房产经纪行业的客户画像分析专家。请根据下面这位客户在企微群聊中的${incremental ? '新增消息' : '全部消息'},生成一份结构化客户画像 JSON。
+  return `你是企业客户运营场景的画像分析专家。请根据下面这位客户在企微群聊中的${incremental ? '新增消息' : '全部消息'},生成一份结构化客户画像 JSON。
 
 客户 externalUserId:${context.externalUserId}
 消息数量:${context.messageCount || messages.length}
@@ -33,26 +33,26 @@ ${incremental ? '新增消息' : '全部消息'}:
 ${messageLines || '(无文本消息)'}
 
 分析维度(JSON 字段):
-- intent: 购房/租房/出售/置换/投资等意图;无信号填 null
-- budgetRange: 预算范围,例如 "200-300万"、"首付100万";无信号填 null
-- preferredAreas: 意向区域数组;无信号填 null
-- houseType: 房型偏好;无信号填 null
-- timeline: 时间线;无信号填 null
-- keyConcerns: 关键关注点数组(学区、地铁、医院、商圈、装修、物业、停车、电梯、采光等);无信号填 null
+- intent: 咨询/采购/试用/续费/售后/合作/投诉等意图;无信号填 null
+- purpose: 客户希望达成的业务目标;无信号填 null
+- needs: 已明确的核心需求数组;无信号填 null
+- budgetRange: 预算或费用范围,例如 "10-20万";无信号填 null
+- timeline: 计划推进时间;无信号填 null
+- keyConcerns: 关键关注点数组(功能、价格、交付、服务、质量、效率、集成、售后、合规等);无信号填 null
 - urgency: 急迫程度(高/中/低/null)
 - aiSummary: 用 1-3 句话总结客户状态
-- personaType: 客户 persona 类型,例如 "刚需首套"、"改善置换"、"投资客"、"潜在客户(待激活)" 等
+- personaType: 客户 persona 类型,例如 "首次咨询"、"重点跟进"、"续费客户"、"潜在客户(待激活)" 等
 - fiveW2H: 对象,包含 Who/What/When/Where/Why/How/HowMuch
 - priorityMatrix: 对象,包含 needsClarity/engagement/actionPriority
 - confidence: 对画像整体置信度(high/medium/low)
 - coreAnxiety: 核心顾虑;无信号填 null
 - decisionMaker: 决策人情况;无信号填 null
-- loanCapacity: 贷款能力;无信号填 null
-- negotiationStage: 当前谈判/跟进阶段
+- contactPreference: 沟通渠道、频率或时间偏好;无信号填 null
+- serviceStage: 当前咨询、评估、方案、谈判、交付或售后阶段
 
 要求:
 1. 只输出合法 JSON,不要 markdown 代码块,不要额外解释。
-2. 没有依据的字段必须填 null 或 "暂无法推断",禁止编造预算、区域、房型
+2. 没有依据的字段必须填 null 或 "暂无法推断",禁止编造预算、目标或需求
 3. 如果消息极少或没有有效需求信息,confidence 必须为 low,aiSummary 要如实说明。
 4. 字段名必须严格使用上述英文,值可用中文。
 5. ${incremental ? '必须在现有画像基础上合并新增证据;新增消息未涉及的旧字段应保留,只有新证据明确冲突时才更新。输出完整合并后的画像。' : '应完全依据本次提供的全部消息重新计算,不沿用旧结论。'}`;

+ 7 - 6
claude-code/claude-code-qiwe-assistant/mcp/src/core/voice-clone-service.js

@@ -10,6 +10,7 @@ const Ffprobe = require('@ffprobe-installer/ffprobe');
 const WxVoiceModule = require('@binsee/wx-voice');
 const WxVoice = WxVoiceModule.WxVoice || WxVoiceModule.default || WxVoiceModule;
 const { categoryDir, createRunDir, writeRunManifest } = require('./output-paths');
+const { pickFmodeApiToken } = require('./credentials');
 
 const TONE_PRESETS = Object.freeze({
   natural: Object.freeze({ id: 'natural', label: '自然', method: 0, alpha: null, text: '' }),
@@ -155,9 +156,10 @@ function extensionFor(name, mime = '') {
 
 class VoiceCloneService {
   constructor({ config = {}, qiwei }) {
+    const endpoint = String(config.endpoint || 'https://server.fmode.cn/api/voice/indextts2').trim();
     this.config = {
-      endpoint: String(config.endpoint || 'https://server.fmode.cn/api/voice/indextts2').trim(),
-      authToken: String(config.authToken || '').trim().replace(/^Bearer\s+/i, ''),
+      endpoint,
+      authToken: pickFmodeApiToken(config.authToken, endpoint),
       model: String(config.model || 'fmode-voice').trim(),
       requestTimeoutMs: Math.max(15000, Math.min(300000, Number(config.requestTimeoutMs) || 180000)),
     };
@@ -190,9 +192,8 @@ class VoiceCloneService {
 
   status() {
     const metadata = this.readMetadata();
-    const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
     return {
-      configured: Boolean(authToken && this.config.endpoint),
+      configured: Boolean(this.config.authToken && this.config.endpoint),
       provider: 'fmode-voice',
       model: this.config.model,
       enrolled: Boolean(metadata && fs.existsSync(this.profilePath())),
@@ -260,8 +261,8 @@ class VoiceCloneService {
   }
 
   requireReady() {
-    const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
-    if (!authToken) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录');
+    const authToken = this.config.authToken;
+    if (!authToken) throw new Error('缺少 Fmode API Token,请前往 https://api.fmode.cn/keys/ 创建并配置 QIWEI_VOICE_AUTH_TOKEN');
     if (!fs.existsSync(this.profilePath())) throw new Error('尚未初始化本人声音,请先录制或上传参考音频');
     return authToken;
   }

+ 243 - 172
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -15,16 +15,25 @@ const {
   completeTodoKnowledge,
 } = require('./official-office-knowledge-service');
 const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
-const { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage } = require('../core/agent-poller-policy');
-const { saveQiweiClientConfig, setActiveQiweiContext } = require('../core/credentials');
+const { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage, normalizePersonalIntakeMode } = require('../core/agent-poller-policy');
+const { sortConversationsByRecency, maxConversationTimestamp } = require('../core/conversation-order');
+const { saveQiweiClientConfig, setActiveQiweiContext, readFmodeVoiceToken } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
-const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
+const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../core/allowlist-config');
 const { getProductMode } = require('../core/product-mode');
 const { getRelayDaemonStatus } = require('../core/relay-daemon');
 
 const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
+const INTAKE_AUTOPILOT_CONFIRMATION = 'ENABLE_AUTO_ENROLL_AUTOPILOT';
+const INTAKE_AUTOPILOT_CONFIRMATION_VERSION = 'v1';
+const AUTOPILOT_CONFIRMATION = 'ENABLE_AUTOPILOT';
+
+function requireAutopilotConfirmation(mode, confirmation, scope = 'global') {
+  if (mode !== 'autopilot' || confirmation === AUTOPILOT_CONFIRMATION) return;
+  throw new Error(scope === 'conversation' ? '开启会话全自动接管需要二次确认' : '开启全自动接管需要二次确认');
+}
 
 function readEnvFile(filePath) {
   try {
@@ -45,15 +54,44 @@ function readEnvFile(filePath) {
 }
 
 function refreshAllowedSendersFromEnv(config = {}, envFile = ENV_FILE) {
+  if (config.usePersistedAllowlist === true) {
+    const combined = normalizeAllowlistIds([...(config.manualAllowedSenders || []), ...(config.autoEnrolledSenders || [])]);
+    const current = Array.isArray(config.allowedSenders) ? config.allowedSenders.map(String) : [];
+    const changed = combined.length !== current.length || combined.some((id, index) => id !== current[index]);
+    if (changed) config.allowedSenders = combined;
+    return { changed, count: combined.length };
+  }
   const env = readEnvFile(envFile);
   if (!Object.prototype.hasOwnProperty.call(env, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS')) {
     return { changed: false, count: Array.isArray(config.allowedSenders) ? config.allowedSenders.length : 0 };
   }
   const next = normalizeAllowlistIds(env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS);
+  const combined = normalizeAllowlistIds([...next, ...(config.autoEnrolledSenders || [])]);
   const current = Array.isArray(config.allowedSenders) ? config.allowedSenders.map(String) : [];
-  const changed = next.length !== current.length || next.some((id, index) => id !== current[index]);
-  if (changed) config.allowedSenders = next;
-  return { changed, count: next.length };
+  const changed = combined.length !== current.length || combined.some((id, index) => id !== current[index]);
+  config.manualAllowedSenders = next;
+  if (changed) config.allowedSenders = combined;
+  return { changed, count: combined.length };
+}
+
+function storedAllowlistIds(db, key, fallback = []) {
+  const raw = db.getSetting(key, '');
+  if (!raw) {
+    const ids = normalizeAllowlistIds(fallback);
+    db.setSetting(key, JSON.stringify(ids));
+    return ids;
+  }
+  try { return normalizeAllowlistIds(JSON.parse(raw)); } catch { return []; }
+}
+
+function hydrateAccountAllowlist(config, db) {
+  const manual = storedAllowlistIds(db, 'manual_allowed_sender_ids', config.qiwei.manualAllowedSenders || config.qiwei.allowedSenders || []);
+  const automatic = db.getAutoEnrolledContactIds();
+  config.qiwei.manualAllowedSenders = manual;
+  config.qiwei.autoEnrolledSenders = automatic;
+  config.qiwei.allowedSenders = normalizeAllowlistIds([...manual, ...automatic]);
+  config.qiwei.usePersistedAllowlist = true;
+  return config.qiwei.allowedSenders;
 }
 
 function readRuntimeState() {
@@ -133,28 +171,27 @@ function loadAgentConfig(overrides = {}) {
   const provider = value('AGENT_PROVIDER', 'claude-code');
   const anthropic = provider === 'anthropic';
   const claudeCode = provider === 'claude-code';
-  const projectPropertyFile = path.join(PROJECT_ROOT, 'knowledge-base', 'property-data', 'properties.json');
-  const bundledPropertyFile = path.join(PACKAGE_ROOT, 'knowledge-base', 'property-data', 'properties.json');
-  const workspacePropertyFile = path.resolve(PACKAGE_ROOT, '..', '..', 'huaxiangpipei', 'src', 'assets', 'data', 'properties.json');
-  const configuredPropertyFile = value('QIWEI_AGENT_PROPERTY_DATA_FILE');
-  const propertyDataFile = configuredPropertyFile
-    ? resolvePath(configuredPropertyFile)
-    : (fs.existsSync(projectPropertyFile)
-      ? projectPropertyFile
-      : (fs.existsSync(bundledPropertyFile) ? bundledPropertyFile : (fs.existsSync(workspacePropertyFile) ? workspacePropertyFile : '')));
+  const manualAllowedSenders = normalizeAllowlistIds(value('QIWEI_AUTO_REPLY_ALLOWED_SENDERS'));
   const baseConfig = {
+    accountKey: accountRuntimeKey({ uid: value('QIWEI_UID') || value('QIWE_UID'), guid: value('QIWEI_GUID') || value('QIWE_GUID') }),
     dbPath: resolvePath(value('QIWEI_AGENT_DB_PATH'), latestPath('messages', 'agent-workbench.db')),
     legacyDbPath: path.resolve(PACKAGE_ROOT, '..', '..', 'qiwei-agent-workbench', 'data', 'workbench.db'),
     globalDefaultPaused: bool('QIWEI_AGENT_GLOBAL_DEFAULT_PAUSED', false),
     conversationDefaultMode: value('QIWEI_AGENT_DEFAULT_MODE', 'review'),
     autoSendConfidence: number('QIWEI_AGENT_AUTO_SEND_CONFIDENCE', 0.88, 0, 1),
+    intake: {
+      mode: normalizePersonalIntakeMode(value('QIWEI_PERSONAL_INTAKE_MODE', 'allowlist_only')),
+      welcomeEnabled: bool('QIWEI_WELCOME_ENABLED', false),
+      welcomeText: value('QIWEI_WELCOME_TEXT', '您好,已经收到您的消息,我会尽快为您处理。'),
+      welcomeSendMode: value('QIWEI_WELCOME_SEND_MODE', 'draft') === 'send' ? 'send' : 'draft',
+      effectiveAutopilotMode: 'autopilot',
+    },
     knowledgeDir: resolvePath(
       value('QIWEI_AGENT_KNOWLEDGE_DIR'),
       fs.existsSync(path.join(PROJECT_ROOT, 'knowledge')) ? path.join(PROJECT_ROOT, 'knowledge') : path.join(PACKAGE_ROOT, 'knowledge')
     ),
     contextFiles: value('QIWEI_AGENT_CONTEXT_FILES', 'personality.md,context.md,rules.md').split(',').map(item => item.trim()).filter(Boolean),
     contextCharLimit: number('QIWEI_AGENT_CONTEXT_CHAR_LIMIT', 6000, 1000, 20000),
-    propertyDataFile,
     memory: {
       enabled: bool('QIWEI_AGENT_MEMORY_ENABLED', true),
       coreCharLimit: number('QIWEI_AGENT_MEMORY_CORE_CHAR_LIMIT', 4000, 500, 8000),
@@ -196,7 +233,11 @@ function loadAgentConfig(overrides = {}) {
       userId: '',
       nickname: '',
       corpName: '',
-      allowedSenders: value('QIWEI_AUTO_REPLY_ALLOWED_SENDERS').split(',').map(item => item.trim()).filter(Boolean),
+      allowedSenders: [...manualAllowedSenders],
+      manualAllowedSenders: [...manualAllowedSenders],
+      autoEnrolledSenders: [],
+      accountKey: '',
+      intakeAutopilotConversationMode: 'autopilot',
       selfUserId: value('QIWEI_AUTO_REPLY_SELF_USER_ID'),
       intervalMs: number('QIWEI_AUTO_REPLY_INTERVAL_MS', 10000, 3000, 60000),
       initialSyncLimit: number('QIWEI_AGENT_INITIAL_SYNC_LIMIT', 5000, 100, 5000),
@@ -208,7 +249,10 @@ function loadAgentConfig(overrides = {}) {
     },
     voice: {
       endpoint: value('QIWEI_VOICE_ENDPOINT', 'https://server.fmode.cn/api/voice/indextts2'),
-      authToken: value('QIWEI_VOICE_AUTH_TOKEN'),
+      authToken: readFmodeVoiceToken({
+        voiceAuthToken: value('QIWEI_VOICE_AUTH_TOKEN'),
+        endpoint: value('QIWEI_VOICE_ENDPOINT', 'https://server.fmode.cn/api/voice/indextts2'),
+      }),
       model: 'fmode-voice',
       requestTimeoutMs: number('QIWEI_TTS_TIMEOUT_MS', 180000, 15000, 300000),
     },
@@ -217,11 +261,14 @@ function loadAgentConfig(overrides = {}) {
     ...baseConfig,
     ...overrides,
     memory: { ...baseConfig.memory, ...(overrides.memory || {}) },
+    intake: { ...baseConfig.intake, ...(overrides.intake || {}) },
     agent: { ...baseConfig.agent, ...(overrides.agent || {}) },
     qiwei: { ...baseConfig.qiwei, ...(overrides.qiwei || {}) },
     voice: { ...baseConfig.voice, ...(overrides.voice || {}) },
   };
-  config.agent.claudeAddDirs = [config.knowledgeDir, config.propertyDataFile ? path.dirname(config.propertyDataFile) : ''].filter(Boolean);
+  config.qiwei.accountKey = config.accountKey;
+  config.qiwei.intakeAutopilotConversationMode = config.intake.effectiveAutopilotMode;
+  config.agent.claudeAddDirs = [config.knowledgeDir].filter(Boolean);
   return config;
 }
 
@@ -233,6 +280,7 @@ function accountRuntimeKey(input = {}) {
 function accountWorkbenchOverrides(input = {}) {
   const storageKey = accountRuntimeKey(input);
   return {
+    accountKey: storageKey,
     dbPath: latestPath('messages', `agent-workbench-${storageKey}.db`),
     agent: {
       claudeSessionFile: latestPath('messages', `claude-code-sessions-${storageKey}.json`),
@@ -245,6 +293,7 @@ function accountWorkbenchOverrides(input = {}) {
       nickname: String(input.nickname || '').trim(),
       corpName: String(input.corpName || '').trim(),
       apiBase: String(input.apiBase || '').trim(),
+      ...(input.useConfiguredAllowlist === true ? {} : { allowedSenders: [], manualAllowedSenders: [] }),
     },
   };
 }
@@ -273,10 +322,6 @@ function backfillCustomerIntelligence(db) {
       alertCount += db.reconcileCustomerAlerts(conversation.id, alerts, message.id).alerts.length;
     }
     if (Object.keys(current.profile).length) db.updateProfile(conversation.id, { ...existing.profile, ...current.profile }, existing.tags);
-    const recommendationSent = db.listMessages(conversation.id, 200).some(message =>
-      message.direction === 'outbound' && /(房源|方案|重点|推荐).{0,20}(套|房)|(套|房).{0,20}(房源|方案|推荐)/.test(String(message.content || ''))
-    );
-    if (recommendationSent) db.completeCustomerTaskByBusinessKey(conversation.id, 'recommendation:shortlist', 'historical_recommendation_sent');
   }
   db.setSetting('customer_intelligence_backfill_version', version);
   if (profileFields || taskCount || alertCount) {
@@ -330,7 +375,7 @@ class QiweiAgentPoller {
   async start() {
     if (this.running) return this.status();
     if (!this.qiwei.isConfigured()) throw new Error('Fmode 企微网关尚未配置,请先完成 Fmode 鉴权和企微扫码登录');
-    if (this.config.allowedSenders.length === 0) throw new Error('企微联系人白名单为空,拒绝启动');
+    if (this.config.allowedSenders.length === 0 && this.db.intakePolicy().mode === 'allowlist_only') throw new Error('企微联系人白名单为空,拒绝启动');
     await this.qiwei.checkLogin();
     this.running = true;
     this.startedAt = Math.floor(Date.now() / 1000);
@@ -491,9 +536,45 @@ async function ingestMessageForWorkbench(target, message = {}, source = 'callbac
   if (contactId && OUTBOUND_CONTENT_MSG_TYPES.has(Number(message.msgType)) && messageContent(message)) {
     return recordOutboundMessage(target, message, source, contactId);
   }
-  const candidate = evaluatePolledMessage(message, target.config);
+  const intakePolicy = target.db.intakePolicy();
+  const candidate = evaluatePolledMessage(message, { ...target.config, intakeMode: intakePolicy.mode });
   if (!candidate.eligible) return { status: `ignored_${candidate.reason || 'ineligible'}` };
   const { content, senderId, timestamp } = candidate;
+  let onboarding = false;
+  let allowAutoSend;
+  if (candidate.unknownContact) {
+    if (intakePolicy.mode === 'allowlist_only') return { status: 'ignored_not_allowlisted' };
+    if (intakePolicy.mode === 'auto_enroll_autopilot' && intakePolicy.autopilotConfirmation !== INTAKE_AUTOPILOT_CONFIRMATION_VERSION) {
+      target.db.audit({ actor: 'policy', action: 'auto_enroll_blocked_unconfirmed', detail: { contactId: senderId, source } });
+      return { status: 'ignored_auto_enroll_unconfirmed' };
+    }
+    const autoIds = target.db.addAutoEnrolledContactId(senderId);
+    target.config.autoEnrolledSenders = autoIds;
+    target.config.allowedSenders = normalizeAllowlistIds([...(target.config.manualAllowedSenders || []), ...autoIds]);
+    const conversation = target.db.ensureConversation(senderId, message.senderName || '企微客户');
+    const effectiveMode = intakePolicy.mode === 'auto_enroll_review' ? 'review' : 'autopilot';
+    target.service.setConversationMode(conversation.id, effectiveMode, 'policy:auto-enroll');
+    const welcomeState = !intakePolicy.welcomeEnabled
+      ? 'skipped'
+      : (intakePolicy.mode === 'auto_enroll_autopilot' && intakePolicy.welcomeSendMode === 'send' ? 'send_pending' : 'draft_pending');
+    const accountKey = String(target.config.accountKey || 'default');
+    const existing = target.db.getOnboarding(accountKey, senderId);
+    target.db.upsertOnboarding({
+      accountKey,
+      contactId: senderId,
+      conversationId: conversation.id,
+      contactName: message.senderName || '企微客户',
+      source,
+      policy: intakePolicy.mode,
+      welcomeState,
+      welcomeText: intakePolicy.welcomeText,
+    });
+    if (!existing) {
+      target.db.audit({ actor: 'policy', action: 'contact_auto_enrolled', conversationId: conversation.id, detail: { contactId: senderId, source, policy: intakePolicy.mode, effectiveConversationMode: effectiveMode } });
+    }
+    onboarding = true;
+    allowAutoSend = intakePolicy.mode !== 'auto_enroll_review';
+  }
   return target.service.ingestInbound({
     externalId: String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`),
     contactId: senderId,
@@ -505,7 +586,7 @@ async function ingestMessageForWorkbench(target, message = {}, source = 'callbac
       source,
       fromRoomId: message.fromRoomId || null,
     },
-  });
+  }, { onboarding, allowAutoSend });
 }
 
 function createWorkbench(overrides = {}) {
@@ -514,7 +595,19 @@ function createWorkbench(overrides = {}) {
     globalPaused: config.globalDefaultPaused,
     defaultMode: config.conversationDefaultMode,
     autoSendConfidence: config.autoSendConfidence,
+    personalIntakeMode: config.intake.mode,
+    welcomeEnabled: config.intake.welcomeEnabled,
+    welcomeText: config.intake.welcomeText,
+    welcomeSendMode: config.intake.welcomeSendMode,
   });
+  hydrateAccountAllowlist(config, db);
+  const staleWelcomeCount = db.markStaleWelcomeSending(
+    config.accountKey,
+    new Date(Date.now() - 5 * 60 * 1000).toISOString(),
+  );
+  if (staleWelcomeCount) {
+    db.audit({ actor: 'runtime', action: 'onboarding_welcome_delivery_unknown', detail: { count: staleWelcomeCount } });
+  }
   if (!overrides.db && !overrides.skipLegacyImport) {
     try { db.importCompatibleDatabase(config.legacyDbPath); } catch (error) {
       db.audit({ actor: 'migration', action: 'legacy_workbench_import_failed', detail: { message: error.message } });
@@ -525,11 +618,9 @@ function createWorkbench(overrides = {}) {
   }
   const knowledge = overrides.knowledge || new AgentKnowledgeStore({
     knowledgeDir: config.knowledgeDir,
-    propertyDataFile: config.propertyDataFile,
     contextFiles: config.contextFiles,
     contextCharLimit: config.contextCharLimit,
   });
-  backfillPropertyRecommendations(db, knowledge);
   backfillSentVoiceAudioPaths(db);
   const qiwei = overrides.qiwei || new FmodeQiweiClient(config.qiwei);
   const voice = overrides.voice || new VoiceCloneService({ config: config.voice, qiwei });
@@ -548,6 +639,7 @@ function configuredStartupAccount() {
     userId: '',
     nickname: '',
     corpName: '',
+    useConfiguredAllowlist: true,
   };
 }
 
@@ -566,7 +658,6 @@ function migrateStartupWorkbench(target) {
       if (!result.imported) continue;
       const removed = target.db.cleanupInboundContentDuplicates(60);
       backfillCustomerIntelligence(target.db);
-      backfillPropertyRecommendations(target.db, target.knowledge);
       target.db.audit({
         actor: 'migration',
         action: 'account_workbench_migration_completed',
@@ -787,71 +878,6 @@ function parseJson(value, fallback) {
   catch { return fallback; }
 }
 
-function propertyMatches(toolTrace = []) {
-  const call = [...toolTrace].reverse().find(item => item.tool === 'search_properties');
-  return (call?.result?.items || []).map(item => ({
-    community: item.community,
-    title: item.layout,
-    price: item.totalPrice,
-    layout: item.layout,
-    area: item.area,
-    score: null,
-    level: call.result.warning || '',
-    highlights: item.highlights || [],
-  }));
-}
-
-function propertyRecommendationsFromTrace(toolTrace = []) {
-  return [...new Map((toolTrace || []).filter(item => item?.tool === 'search_properties').flatMap(item => item.result?.items || []).filter(item => item?.id).map(item => [String(item.id), item])).values()];
-}
-
-function detectedPropertiesInMessage(content, properties = []) {
-  const text = String(content || '');
-  if (!text) return [];
-  return properties.filter(property => {
-    const id = String(property.id || '').trim();
-    if (id && text.includes(id)) return true;
-    const community = String(property.community || '').trim();
-    const price = Number(property.totalPrice || 0);
-    if (!community || !price || !text.includes(community)) return false;
-    return new RegExp(`${price}(?:\\.0+)?\\s*万`).test(text);
-  });
-}
-
-function backfillPropertyRecommendations(db, knowledge) {
-  if (!db || typeof db.upsertCustomerRecommendations !== 'function') return 0;
-  const properties = Array.isArray(knowledge?.properties) ? knowledge.properties : [];
-  let count = 0;
-  for (const conversation of db.listConversations()) {
-    for (const draft of db.listDrafts({ conversationId: conversation.id, limit: 500 })) {
-      const items = propertyRecommendationsFromTrace(draft.tool_trace);
-      if (!items.length || draft.status === 'rejected') continue;
-      db.upsertCustomerRecommendations(conversation.id, items, {
-        type: 'agent-tool',
-        entityId: draft.id,
-        status: ['sent', 'approved'].includes(draft.status) ? 'recommended' : 'candidate',
-        evidence: draft.status === 'sent' ? String(draft.content || '').slice(0, 500) : 'Agent 房源工具查询结果,尚未确认已发送给客户',
-        createdAt: draft.created_at,
-      });
-      count += items.length;
-    }
-    if (!properties.length) continue;
-    for (const message of db.listMessages(conversation.id, 500).filter(item => item.direction === 'outbound')) {
-      const items = detectedPropertiesInMessage(message.content, properties);
-      if (!items.length) continue;
-      db.upsertCustomerRecommendations(conversation.id, items, {
-        type: 'outbound-message-detected',
-        entityId: message.id,
-        status: 'recommended',
-        evidence: String(message.content || '').slice(0, 500),
-        createdAt: message.created_at,
-      });
-      count += items.length;
-    }
-  }
-  return count;
-}
-
 function conversationChannelInfo(row, messages = []) {
   const contactId = String(row?.contact_id || '').trim();
   let roomId = '';
@@ -883,7 +909,7 @@ function publicConversation(row) {
   const pending = currentDrafts.find(item => item.status === 'pending') || null;
   const latestDraft = pending || currentDrafts.find(item => ['sent', 'approved'].includes(item.status)) || null;
   const currentAgentOutcome = latestInbound && detail.agentOutcome?.entityId === latestInbound.id ? detail.agentOutcome : null;
-  const agentError = ['agent_failed', 'agent_not_configured'].includes(currentAgentOutcome?.action)
+  const agentError = ['agent_failed', 'agent_not_configured', 'autopilot_send_failed'].includes(currentAgentOutcome?.action)
     ? { ...currentAgentOutcome, ...friendlyAgentError(currentAgentOutcome.message) }
     : null;
   const agentNotice = currentAgentOutcome?.action === 'agent_no_reply_needed' ? currentAgentOutcome : null;
@@ -891,7 +917,6 @@ function publicConversation(row) {
   const { __evidence: profileEvidence = {}, ...profile } = rawProfile;
   const customerTasks = detail.tasks || [];
   const customerAlerts = detail.alerts || [];
-  const customerRecommendations = detail.recommendations || [];
   const citations = latestDraft?.citations || [];
   const cutoverAt = Date.parse(workbench.db.getSetting('agent_cutover_at', '')) || Date.now();
   const displayMessages = [...new Map((detail.messages || []).map(message => [message.id, message])).values()]
@@ -935,7 +960,6 @@ function publicConversation(row) {
       intentLabel: latestDraft?.intent || (agentError ? 'Agent 上游不可用' : agentNotice ? '无需回复' : '待 Agent 处理'),
       demand: profile,
       completenessScore: completeness(profile),
-      matches: propertyMatches(latestDraft?.tool_trace || []),
       knowledgeSources: citations.length
         ? citations.map(item => `${item.heading || item.source}${item.source ? ` · ${item.source}` : ''}`)
         : ['真实企微消息', '客户画像', '企业规则库与知识库'],
@@ -978,18 +1002,6 @@ function publicConversation(row) {
         status: item.status,
         updatedAt: item.updated_at,
       })),
-      recommendations: customerRecommendations.map(item => ({
-        id: item.id,
-        propertyId: item.property_id,
-        property: item.property_snapshot,
-        status: item.status,
-        feedbackReason: item.feedback_reason,
-        recommendCount: item.recommend_count,
-        sources: item.sources,
-        firstRecommendedAt: item.first_recommended_at,
-        lastRecommendedAt: item.last_recommended_at,
-        updatedAt: item.updated_at,
-      })),
       memories: (detail.memories || []).map(item => ({
         id: item.id,
         type: item.type,
@@ -1012,8 +1024,6 @@ function publicConversation(row) {
         openTasks: customerTasks.filter(item => ['open', 'in_progress'].includes(item.status)).length,
         openAlerts: customerAlerts.filter(item => item.status === 'open').length,
         highAlerts: customerAlerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).length,
-        recommendationCount: customerRecommendations.length,
-        pendingFeedbackCount: customerRecommendations.filter(item => ['candidate', 'recommended'].includes(item.status)).length,
         activeMemories: (detail.memories || []).filter(item => item.status === 'active').length,
       },
     },
@@ -1032,7 +1042,14 @@ function publicConversation(row) {
     audit: visibleAudit,
     agentError,
     agentNotice,
-    lastMessageAt: displayMessages.at(-1)?.created_at || row.last_message_at,
+    onboarding: detail.onboarding ? {
+      state: detail.onboarding.welcome_state,
+      attemptCount: detail.onboarding.attempt_count,
+      lastError: detail.onboarding.last_error || '',
+      lastAttemptAt: detail.onboarding.last_attempt_at || null,
+      sentAt: detail.onboarding.sent_at || null,
+    } : null,
+    lastMessageAt: maxConversationTimestamp(displayMessages.at(-1)?.created_at, row.last_message_at),
     updatedAt: row.updated_at,
   };
 }
@@ -1077,6 +1094,7 @@ async function getAgentStatus() {
         demoMode: false,
         transport: state.qiwei.transport,
         pollIntervalMs: workbench.config.qiwei.intervalMs,
+        personalIngress: intakePolicyPayload(workbench),
       },
       safety: {
         whitelistEnabled: state.qiwei.allowlistCount > 0,
@@ -1141,35 +1159,34 @@ async function getAllowlistCandidates() {
   };
 }
 
-function updateAllowlist(input = {}) {
-  refreshAllowedSendersFromEnv(workbench.config.qiwei);
-  const previousIds = new Set(workbench.config.qiwei.allowedSenders.map(String));
+function updateAllowlistForWorkbench(target, input = {}) {
+  refreshAllowedSendersFromEnv(target.config.qiwei);
+  const previousIds = new Set(target.config.qiwei.allowedSenders.map(String));
   const ids = normalizeAllowlistIds(input.contactIds || input.allowedSenders || []);
-  const serialized = ids.join(',');
-  writeEnvValue(ENV_FILE, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS', serialized);
-  fileEnv.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
-  process.env.QIWEI_AUTO_REPLY_ALLOWED_SENDERS = serialized;
+  const retainedAutomatic = target.db.getAutoEnrolledContactIds().filter(id => ids.includes(id));
+  target.db.setSetting('manual_allowed_sender_ids', JSON.stringify(ids));
+  target.db.setSetting('auto_enrolled_contact_ids', JSON.stringify(retainedAutomatic));
+  target.config.qiwei.manualAllowedSenders = [...ids];
+  target.config.qiwei.autoEnrolledSenders = retainedAutomatic;
+  target.config.qiwei.allowedSenders = normalizeAllowlistIds([...ids, ...retainedAutomatic]);
 
   let listenerStopped = false;
   const contactNames = input.contactNames || {};
   const addedIds = ids.filter(id => !previousIds.has(id));
-  for (const target of workbenches.values()) {
-    target.config.qiwei.allowedSenders = [...ids];
-    for (const contactId of addedIds) {
-      target.db.ensureConversation(contactId, String(contactNames[contactId] || ''));
-    }
-    if (ids.length && input.autoStart !== false) {
-      target.db.setSetting('listener_enabled', 'true');
-      target.service.setGlobal({ paused: false }, input.actor || 'allowlist');
-    } else if (!ids.length) {
-      target.db.setSetting('listener_enabled', 'false');
-    }
-    if (!ids.length && target.poller.status().running) {
-      target.poller.stop();
-      listenerStopped = true;
-    }
+  for (const contactId of addedIds) {
+    target.db.ensureConversation(contactId, String(contactNames[contactId] || ''));
   }
-  workbench.db.audit({
+  if (ids.length && input.autoStart !== false) {
+    target.db.setSetting('listener_enabled', 'true');
+    target.service.setGlobal({ paused: false }, input.actor || 'allowlist');
+  } else if (!ids.length) {
+    target.db.setSetting('listener_enabled', 'false');
+  }
+  if (!ids.length && target.poller.status().running) {
+    target.poller.stop();
+    listenerStopped = true;
+  }
+  target.db.audit({
     actor: input.actor || 'human',
     action: 'allowlist_updated',
     detail: { count: ids.length, addedCount: addedIds.length, listenerStopped, autoStart: ids.length > 0 && input.autoStart !== false },
@@ -1197,12 +1214,91 @@ function addAllowlistContacts(input = {}) {
   });
 }
 
+function intakePolicyPayload(target = workbench) {
+  const policy = target.db.intakePolicy();
+  const onboardings = target.db.listOnboardings(target.config.accountKey, { limit: 500 });
+  return {
+    mode: policy.mode,
+    welcomeEnabled: policy.welcomeEnabled,
+    welcomeText: policy.welcomeText,
+    welcomeSendMode: policy.welcomeSendMode,
+    effectiveConversationMode: policy.mode === 'auto_enroll_autopilot' ? 'autopilot' : 'review',
+    autopilotConfirmed: policy.autopilotConfirmation === INTAKE_AUTOPILOT_CONFIRMATION_VERSION,
+    onboarding: {
+      total: onboardings.length,
+      failed: onboardings.filter(item => item.welcome_state === 'failed').length,
+      deliveryUnknown: onboardings.filter(item => item.welcome_state === 'delivery_unknown').length,
+    },
+  };
+}
+
+function updateAllowlist(input = {}) {
+  return updateAllowlistForWorkbench(workbench, input);
+}
+
+function getIntakePolicy() {
+  return { status: 'ok', data: intakePolicyPayload(workbench), warnings: [], errors: [] };
+}
+
+function updateIntakePolicyForWorkbench(target, input = {}, actor = 'human') {
+  const current = target.db.intakePolicy();
+  const mode = input.mode === undefined ? current.mode : String(input.mode);
+  const welcomeSendMode = input.welcomeSendMode === undefined ? current.welcomeSendMode : String(input.welcomeSendMode);
+  const welcomeEnabled = input.welcomeEnabled === undefined ? current.welcomeEnabled : input.welcomeEnabled === true;
+  const welcomeText = input.welcomeText === undefined ? current.welcomeText : String(input.welcomeText || '').trim();
+  if (!['allowlist_only', 'auto_enroll_review', 'auto_enroll_autopilot'].includes(mode)) throw new Error('不支持的个人消息接入模式');
+  if (!['draft', 'send'].includes(welcomeSendMode)) throw new Error('不支持的欢迎语发送模式');
+  if (welcomeEnabled && !welcomeText) throw new Error('启用欢迎语时必须填写欢迎内容');
+  if (welcomeSendMode === 'send' && mode !== 'auto_enroll_autopilot') throw new Error('欢迎语直接发送仅可用于全自动新联系人接入');
+  if (mode === 'auto_enroll_autopilot' && input.confirmation !== INTAKE_AUTOPILOT_CONFIRMATION) {
+    throw new Error('开启全自动新联系人接入需要二次确认');
+  }
+  const confirmed = mode === 'auto_enroll_autopilot';
+  target.db.setIntakePolicy({
+    mode,
+    welcomeEnabled,
+    welcomeText,
+    welcomeSendMode,
+    autopilotConfirmation: confirmed ? INTAKE_AUTOPILOT_CONFIRMATION_VERSION : '',
+    autopilotConfirmedAt: confirmed ? new Date().toISOString() : '',
+    autopilotConfirmedBy: confirmed ? String(actor || 'human') : '',
+  });
+  target.db.audit({
+    actor,
+    action: 'personal_intake_policy_updated',
+    detail: { mode, welcomeEnabled, welcomeSendMode, effectiveConversationMode: mode === 'auto_enroll_autopilot' ? 'autopilot' : 'review' },
+  });
+  return intakePolicyPayload(target);
+}
+
+function updateIntakePolicy(input = {}) {
+  return {
+    status: 'ok',
+    assistantMessage: '个人消息接入策略已保存',
+    data: updateIntakePolicyForWorkbench(workbench, input, 'human'),
+    warnings: [],
+    errors: [],
+  };
+}
+
+async function retryOnboardingWelcome(identifier) {
+  const value = String(identifier || '');
+  const conversation = workbench.db.getConversation(value);
+  const contactId = conversation?.contact_id || value;
+  const result = await workbench.service.retryOnboardingWelcome(contactId, 'human');
+  return {
+    status: 'ok',
+    assistantMessage: result.status === 'welcome_sent' ? '欢迎语重试已发送' : '欢迎语重试已处理',
+    data: result,
+  };
+}
+
 function displayableConversations() {
   return workbench.db.listConversations();
 }
 
 function getConversations() {
-  const conversations = displayableConversations().map(publicConversation);
+  const conversations = sortConversationsByRecency(displayableConversations().map(publicConversation));
   return {
     status: 'ok',
     data: {
@@ -1296,36 +1392,6 @@ function updateCustomerProfile(conversationId, input = {}) {
   };
 }
 
-function updateCustomerRecommendation(conversationId, recommendationId, input = {}) {
-  const recommendation = workbench.db.updateCustomerRecommendation(conversationId, recommendationId, {
-    status: input.status,
-    feedbackReason: input.feedbackReason,
-  });
-  if (!recommendation) throw new Error('房源推荐记录不存在');
-  const current = workbench.db.getProfile(conversationId);
-  const existingFeedback = Array.isArray(current.profile?.propertyFeedback) ? current.profile.propertyFeedback : [];
-  const feedback = {
-    propertyId: recommendation.property_id,
-    status: recommendation.status,
-    reason: recommendation.feedback_reason,
-    recordedAt: new Date().toISOString(),
-  };
-  const byProperty = new Map(existingFeedback.map(item => [String(item.propertyId || ''), item]));
-  byProperty.set(String(feedback.propertyId), feedback);
-  const evidence = { ...(current.profile?.__evidence || {}) };
-  evidence.propertyFeedback = { text: recommendation.feedback_reason || `人工标记为${recommendation.status}`, sourceMessageId: null, source: 'human', updatedAt: feedback.recordedAt };
-  workbench.db.updateProfile(conversationId, { ...current.profile, propertyFeedback: [...byProperty.values()].slice(-50), __evidence: evidence }, current.tags);
-  workbench.db.audit({ actor: 'human', action: 'property_recommendation_feedback', conversationId, entityId: recommendation.id, detail: { propertyId: recommendation.property_id, status: recommendation.status, reason: recommendation.feedback_reason } });
-  return {
-    status: 'ok',
-    assistantMessage: `房源“${recommendation.property_snapshot?.community || recommendation.property_id}”反馈已记录为 ${recommendation.status}。`,
-    summary: { recommendationId: recommendation.id, status: recommendation.status },
-    data: { recommendation },
-    warnings: [],
-    errors: [],
-  };
-}
-
 function resolveConversationSyncScope(input = {}, allowlist = new Set(), db = workbench.db) {
   const conversationId = String(input.conversationId || '').trim();
   let contactId = String(input.contactId || '').trim();
@@ -1448,17 +1514,19 @@ async function syncConversations(input = {}) {
       syncedConversationCount: grouped.size,
       syncedMessageCount: syncedMessages,
       scannedMessageCount: scannedMessages,
-      conversations: displayableConversations().map(publicConversation),
+      conversations: sortConversationsByRecency(displayableConversations().map(publicConversation)),
     },
   };
 }
 
-function changeGlobalMode(mode) {
+function changeGlobalMode(mode, confirmation = '') {
   const selected = String(mode || '');
+  requireAutopilotConfirmation(selected, confirmation);
   let update;
   if (['paused', 'monitor'].includes(selected)) update = { paused: true };
   else if (['review', 'suggest'].includes(selected)) update = { paused: false, defaultMode: 'review' };
   else if (selected === 'auto') update = { paused: false, defaultMode: 'auto' };
+  else if (selected === 'autopilot') update = { paused: false, defaultMode: 'autopilot' };
   else if (selected === 'human') update = { paused: false, defaultMode: 'human' };
   else throw new Error('不支持的 Agent 模式');
   workbench.db.setSetting('listener_enabled', selected === 'paused' ? 'false' : 'true');
@@ -1470,10 +1538,11 @@ function changeGlobalMode(mode) {
   };
 }
 
-function changeConversationMode(id, mode) {
+function changeConversationMode(id, mode, confirmation = '') {
   const mapped = mode === 'agent' ? 'review' : mode === 'manual' ? 'human' : mode;
+  requireAutopilotConfirmation(mapped, confirmation, 'conversation');
   const conversation = workbench.service.setConversationMode(id, mapped);
-  const labels = { review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' };
+  const labels = { review: '待审核', auto: '高置信自动', autopilot: '全自动接管', human: '人工接管', paused: '会话暂停' };
   return { status: 'ok', assistantMessage: `会话已切换为${labels[mapped]}`, data: { conversation } };
 }
 
@@ -1864,10 +1933,12 @@ module.exports = {
   getAllowlistCandidates,
   updateAllowlist,
   addAllowlistContacts,
+  getIntakePolicy,
+  updateIntakePolicy,
+  retryOnboardingWelcome,
   getConversations,
   getResponseMonitor,
   updateCustomerProfile,
-  updateCustomerRecommendation,
   syncConversations,
   changeGlobalMode,
   changeConversationMode,
@@ -1894,5 +1965,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, resolveConversationSyncScope, applyManualTakeover, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, startListenerForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
 };

+ 328 - 169
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -84,10 +84,16 @@
       voiceTone: 'auto',
       renderPending: false,
       insightInteractionUntil: 0,
+      openDrawer: null,
+      activityOpen: false,
+      intakePolicy: { mode: 'allowlist_only', welcomeEnabled: false, welcomeText: '', welcomeSendMode: 'draft' },
+      intakePolicyLoaded: false,
+      intakePolicyLoading: false,
+      intakePolicyError: '',
       cachedAccountKey: initialAgentSnapshot.accountKey || null
     },
     skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
-    knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
+    knowledge: { tree: null, selectedNodeId: null, file: null, expandedFolderIds: new Set(), foldersInitialized: false, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
     transfers: { lastTransferPreview: null, candidates: [], groups: [], dashboardSnapshot: null, loadingPromise: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
   };
 
@@ -260,6 +266,11 @@
         state.agent.voiceTone = 'auto';
         state.agent.renderPending = false;
         state.agent.insightInteractionUntil = 0;
+        state.agent.openDrawer = null;
+        state.agent.activityOpen = false;
+        state.agent.intakePolicy = { mode: 'allowlist_only', welcomeEnabled: false, welcomeText: '', welcomeSendMode: 'draft' };
+        state.agent.intakePolicyLoaded = false;
+        state.agent.intakePolicyError = '';
         state.agent.cachedAccountKey = null;
         try { sessionStorage.removeItem(AGENT_SNAPSHOT_KEY); } catch {}
       }
@@ -1690,7 +1701,7 @@
 
     page.innerHTML = `
       <section class="workspace-hero skills-hero">
-        <div><span>UNIFIED QIWEI CAPABILITY HUB</span><h2>技能中心</h2><p>把源码版、OpenClaw、Agent Workbench 和花巷房源匹配统一到一个 4320 工作台。</p></div>
+        <div><span>UNIFIED QIWEI CAPABILITY HUB</span><h2>技能中心</h2><p>把通用企微 Skills、Agent Workbench 和运行工具统一到一个 4320 工作台。</p></div>
         <div class="workspace-stat-row">
           <div><strong>${registry.summary.packageCount || 0}</strong><span>能力来源</span></div>
           <div><strong>${registry.summary.skillCount || 0}</strong><span>技能实例</span></div>
@@ -1766,7 +1777,7 @@
 
   function knowledgeIcon(node) {
     if (node.type === 'folder') return '▸';
-    return ({ 'property-dataset': '⌂', 'meeting-dashboard': '会', 'doc-dashboard': '文', 'todo-dashboard': '办', 'task-dashboard': '任', markdown: 'M', 'skill-document': 'S', json: '{ }', code: '&lt;/&gt;', csv: '▦' })[node.kind] || '•';
+    return ({ 'meeting-dashboard': '会', 'doc-dashboard': '文', 'todo-dashboard': '办', 'task-dashboard': '任', markdown: 'M', 'skill-document': 'S', json: '{ }', code: '&lt;/&gt;', csv: '▦' })[node.kind] || '•';
   }
 
   function renderKnowledgeTreeNode(node, depth = 0) {
@@ -1784,34 +1795,6 @@
     return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
   }
 
-  function propertyMeta(property) {
-    return [property.district, property.layout, property.area ? `${property.area}㎡` : '', property.decoration].filter(Boolean).join(' · ');
-  }
-
-  function renderPropertyDetail(property) {
-    if (!property) return '<div class="property-detail-empty"><strong>选择一套房源</strong><span>点击左侧房源卡片查看全部字段与数据来源。</span></div>';
-    const rows = [
-      ['区域', property.district], ['户型', property.layout], ['面积', property.area ? `${property.area}㎡` : ''],
-      ['楼层', property.floor], ['朝向', property.orientation], ['装修', property.decoration],
-      ['单价', property.unitPrice ? `${property.unitPrice} 元/㎡` : ''], ['学区', property.isSchoolDistrict ? property.schoolName || '是' : '否'],
-      ['停车', property.parking], ['房龄', property.buildingAge ? `${property.buildingAge} 年` : ''],
-      ['满五唯一', property.isFiveYearOnly ? '是' : '否'], ['议价空间', property.priceDropSpace ? `${property.priceDropSpace} 万` : ''],
-    ].filter(([, value]) => value !== '' && value !== null && value !== undefined);
-    return `<div class="property-detail"><header><span>${escapeHtml(property.id)}</span><h3>${escapeHtml(property.community)}</h3><strong>${property.totalPrice || '-'}<small>万</small></strong><p>${escapeHtml(propertyMeta(property))}</p></header><div class="property-detail-grid">${rows.map(([label, value]) => `<div><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('')}</div>${property.highlights?.length ? `<section><h4>房源亮点</h4><div class="property-tags">${property.highlights.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}</div></section>` : ''}${property.surrounding ? `<section><h4>周边描述</h4><p>${escapeHtml(property.surrounding)}</p></section>` : ''}${property.ownerSituation ? `<section><h4>业主情况</h4><p>${escapeHtml(property.ownerSituation)}</p></section>` : ''}<section><h4>匹配维度</h4><div class="property-score-list">${Object.entries(property.scores || {}).map(([key, value]) => `<div><span>${escapeHtml(({ community: '小区品质', transport: '交通', surrounding: '配套', priceAdvantage: '价格优势' })[key] || key)}</span><i><b style="width:${Math.min(100, Number(value || 0) * 10)}%"></b></i><em>${value || 0}</em></div>`).join('')}</div></section></div>`;
-  }
-
-  function renderPropertiesView() {
-    const data = state.knowledge.properties || { items: [], stats: {}, total: 0, page: 1, pageCount: 1 };
-    const filters = state.knowledge.filters;
-    const selected = state.knowledge.selectedProperty;
-    return `<div class="property-library-view">
-      <header class="knowledge-view-header"><div><span>PROPERTY DATASET</span><h3>房源数据列表</h3><p>${escapeHtml(data.sourceLabel || '房源数据')} · ${data.total || 0} 条当前结果</p></div><code>${escapeHtml(data.source || '')}</code></header>
-      <div class="property-stats"><div><strong>${data.stats.total || 0}</strong><span>总房源</span></div><div><strong>${data.stats.averagePrice || 0}<small>万</small></strong><span>平均总价</span></div><div><strong>${data.stats.districts?.length || 0}</strong><span>区域</span></div><div><strong>${data.stats.schoolDistrictCount || 0}</strong><span>学区房</span></div></div>
-      <form class="property-filterbar" id="property-filter-form"><input name="q" value="${escapeHtml(filters.q)}" placeholder="搜索小区、编号、标签" /><select name="district"><option value="">全部区域</option>${(data.stats.districts || []).map(item => `<option ${filters.district === item ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}</select><select name="layout"><option value="">全部户型</option>${(data.stats.layouts || []).map(item => `<option ${filters.layout === item ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}</select><select name="decoration"><option value="">全部装修</option>${(data.stats.decorations || []).map(item => `<option ${filters.decoration === item ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}</select><input name="maxPrice" type="number" value="${escapeHtml(filters.maxPrice)}" placeholder="最高总价/万" /><button class="btn" type="submit">筛选</button></form>
-      <div class="property-browser-layout"><div class="property-card-grid">${(data.items || []).map(property => `<button class="property-card ${selected?.id === property.id ? 'active' : ''}" data-property-id="${escapeHtml(property.id)}"><div><span>${escapeHtml(property.id)}</span><em>${property.isSchoolDistrict ? '学区' : property.decoration || '房源'}</em></div><h4>${escapeHtml(property.community)}</h4><p>${escapeHtml(propertyMeta(property))}</p><strong>${property.totalPrice || '-'}<small>万</small></strong><footer>${(property.highlights || []).slice(0, 3).map(tag => `<i>${escapeHtml(tag)}</i>`).join('')}</footer></button>`).join('') || '<div class="library-empty">没有符合条件的房源</div>'}</div><aside class="property-detail-panel">${renderPropertyDetail(selected)}</aside></div>
-    </div>`;
-  }
-
   function meetingInputValue(value) {
     return String(value || '').replace(' ', 'T').slice(0, 16);
   }
@@ -2029,9 +2012,7 @@
   function renderKnowledgeWorkspace(page) {
     const tree = state.knowledge.tree || { roots: [], summary: {} };
     const selectedNode = flattenKnowledgeNodes(tree.roots).find(item => item.id === state.knowledge.selectedNodeId);
-    const viewer = selectedNode?.kind === 'property-dataset'
-      ? renderPropertiesView()
-      : selectedNode?.kind === 'meeting-dashboard'
+    const viewer = selectedNode?.kind === 'meeting-dashboard'
         ? renderMeetingKnowledgeView()
         : selectedNode?.kind === 'doc-dashboard'
           ? renderDocumentKnowledgeView()
@@ -2041,22 +2022,11 @@
             ? renderUnifiedTaskCenter()
         : renderKnowledgeFileView(state.knowledge.file);
     page.innerHTML = `
-      <section class="workspace-hero knowledge-hero"><div><span>LOCAL KNOWLEDGE & FILE LIBRARY</span><h2>知识库</h2><p>用文件夹方式统一管理规则、技能说明、房源数据和运行输出,点击文件即可可视化查看。</p></div><div class="workspace-stat-row"><div><strong>${tree.summary.libraryCount || 0}</strong><span>目录来源</span></div><div><strong>${tree.summary.fileCount || 0}</strong><span>可预览文件</span></div><div><strong>${tree.summary.propertyCount || 0}</strong><span>房源记录</span></div></div></section>
+      <section class="workspace-hero knowledge-hero"><div><span>LOCAL KNOWLEDGE & FILE LIBRARY</span><h2>知识库</h2><p>用文件夹方式统一管理规则、技能说明和运行输出,点击文件即可可视化查看。</p></div><div class="workspace-stat-row"><div><strong>${tree.summary.libraryCount || 0}</strong><span>目录来源</span></div><div><strong>${tree.summary.fileCount || 0}</strong><span>可预览文件</span></div><div><strong>${tree.summary.skillCount || 0}</strong><span>通用 Skills</span></div></div></section>
       <section class="knowledge-layout"><aside class="knowledge-tree-panel"><header><strong>知识库目录</strong><span>本地文件 · 只读预览</span></header><div class="knowledge-tree">${(tree.roots || []).map(root => renderKnowledgeTreeNode(root)).join('')}</div></aside><main class="knowledge-viewer">${viewer}</main></section>
     `;
   }
 
-  async function loadKnowledgeProperties(page) {
-    const params = new URLSearchParams({ pageSize: '30' });
-    for (const [key, value] of Object.entries(state.knowledge.filters)) if (String(value || '').trim()) params.set(key, value);
-    const result = await api('GET', `/api/knowledge/properties?${params}`);
-    state.knowledge.properties = result.data;
-    if (state.knowledge.selectedProperty) {
-      state.knowledge.selectedProperty = result.data.items.find(item => item.id === state.knowledge.selectedProperty.id) || null;
-    }
-    renderKnowledgeWorkspace(page);
-  }
-
   async function loadMeetingKnowledgeHub(page) {
     const result = await api('GET', '/api/knowledge/meetings');
     state.knowledge.meetings.hub = result;
@@ -2094,12 +2064,10 @@
     if (!node || node.type !== 'file') return;
     state.knowledge.selectedNodeId = nodeId;
     state.knowledge.file = null;
-    state.knowledge.selectedProperty = null;
     if (node.kind === 'meeting-dashboard') await loadMeetingKnowledgeHub(page);
     else if (node.kind === 'doc-dashboard') await loadDocumentKnowledgeHub(page);
     else if (node.kind === 'todo-dashboard') await loadTodoKnowledgeHub(page);
     else if (node.kind === 'task-dashboard') await loadUnifiedTaskHub(page);
-    else if (node.kind === 'property-dataset') await loadKnowledgeProperties(page);
     else {
       const result = await api('GET', `/api/knowledge/file?id=${encodeURIComponent(nodeId)}`);
       state.knowledge.file = result.data;
@@ -2245,14 +2213,6 @@
         finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
         return;
       }
-      const propertyButton = event.target.closest('[data-property-id]');
-      if (propertyButton) {
-        try {
-          const result = await api('GET', `/api/knowledge/properties/${encodeURIComponent(propertyButton.dataset.propertyId)}`);
-          state.knowledge.selectedProperty = result.data.property;
-          renderKnowledgeWorkspace(page);
-        } catch (error) { toast(error.message || '房源读取失败', 'error'); }
-      }
     });
     page.addEventListener('submit', async event => {
       const diagnosisFeedbackForm = event.target.closest('[data-diagnosis-feedback-form]');
@@ -2380,13 +2340,6 @@
         }
         return;
       }
-      if (event.target.id === 'property-filter-form') {
-        event.preventDefault();
-        const form = new FormData(event.target);
-        for (const key of Object.keys(state.knowledge.filters)) state.knowledge.filters[key] = String(form.get(key) || '').trim();
-        try { await loadKnowledgeProperties(page); }
-        catch (error) { toast(error.message || '房源筛选失败', 'error'); }
-      }
     });
     page.addEventListener('toggle', event => {
       const folder = event.target.closest?.('[data-knowledge-folder]');
@@ -2399,14 +2352,14 @@
       state.knowledge.tree = result.data;
       if (!state.knowledge.foldersInitialized) {
         for (const root of result.data.roots) {
-          if (['agent-knowledge', 'property-data', 'meeting-knowledge', 'doc-knowledge', 'todo-knowledge', 'task-knowledge'].includes(root.libraryId)) {
+          if (['agent-knowledge', 'meeting-knowledge', 'doc-knowledge', 'todo-knowledge', 'task-knowledge'].includes(root.libraryId)) {
             state.knowledge.expandedFolderIds.add(root.id);
           }
         }
         state.knowledge.foldersInitialized = true;
       }
       const nodes = flattenKnowledgeNodes(result.data.roots);
-      const defaultNode = nodes.find(item => item.kind === 'task-dashboard') || nodes.find(item => item.kind === 'doc-dashboard') || nodes.find(item => item.kind === 'todo-dashboard') || nodes.find(item => item.kind === 'meeting-dashboard') || nodes.find(item => item.kind === 'property-dataset') || nodes.find(item => item.type === 'file');
+      const defaultNode = nodes.find(item => item.kind === 'task-dashboard') || nodes.find(item => item.kind === 'doc-dashboard') || nodes.find(item => item.kind === 'todo-dashboard') || nodes.find(item => item.kind === 'meeting-dashboard') || nodes.find(item => item.type === 'file');
       if (defaultNode) await openKnowledgeNode(page, state.knowledge.selectedNodeId || defaultNode.id);
       else renderKnowledgeWorkspace(page);
     } catch (error) {
@@ -2421,6 +2374,26 @@
     return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
   }
 
+  function agentTimestamp(value) {
+    const timestamp = Date.parse(value || '');
+    return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
+  }
+
+  function sortAgentItemsByLatest(items = [], value = item => item?.lastMessageAt) {
+    return items
+      .map((item, index) => ({ item, index, timestamp: agentTimestamp(value(item)) }))
+      .sort((a, b) => b.timestamp - a.timestamp || a.index - b.index)
+      .map(entry => entry.item);
+  }
+
+  function latestAgentMessageSource(sources = []) {
+    return sources.reduce((latest, source, index) => {
+      const timestamp = agentTimestamp(source?.time);
+      if (!latest || timestamp > latest.timestamp) return { ...source, timestamp, index };
+      return latest;
+    }, null);
+  }
+
   function agentTaskStatusLabel(status) {
     return ({ open: '待处理', in_progress: '处理中', done: '已完成', dismissed: '已忽略' })[status] || status || '待处理';
   }
@@ -2451,20 +2424,16 @@
   }
 
   function demandItems(demand = {}) {
-    const districts = Array.isArray(demand.districts) ? demand.districts.join('/') : (demand.preferredRegion || demand.region || demand.intent_area || '');
     const min = demand.budgetMin ?? demand.budget_min;
     const max = demand.budgetMax ?? demand.budget_max;
-    const rooms = demand.rooms || (demand.house_type ? String(demand.house_type).replace(/[^0-9]/g, '') : '');
     const values = [
-      ['区域', districts],
-      ['预算', demand.budgetWan ? `${demand.budgetWan}万${demand.budgetType && demand.budgetType !== '待确认' ? `(${demand.budgetType})` : ''}` : (min && max ? `${min}-${max}万` : (max ? `${max}万内` : ''))],
-      ['户型', demand.layout || (rooms ? `${rooms}室` : (demand.house_type || ''))],
-      ['面积', demand.areaMin && demand.areaMax ? `${demand.areaMin}-${demand.areaMax}㎡` : ''],
-      ['装修', demand.decoration],
-      ['用途', demand.purpose || demand.buyerType || demand.purchase_purpose],
-      ['偏好', [demand.floor, demand.orientation].filter(Boolean).join(' / ')],
-      ['学区', demand.schoolRequired ? '需要' : ''],
-      ['时间', demand.timeline],
+      ['客户意图', demand.intent || demand.purpose],
+      ['核心需求', demand.need || demand.needs || demand.keyConcerns],
+      ['预算', demand.budgetWan ? `${demand.budgetWan}${demand.budgetType && demand.budgetType !== '待确认' ? `(${demand.budgetType})` : ''}` : (min && max ? `${min}-${max}` : (max ? `${max}内` : ''))],
+      ['计划时间', demand.timeline],
+      ['决策人', demand.decisionMaker],
+      ['沟通偏好', demand.contactPreference],
+      ['服务阶段', demand.serviceStage || demand.negotiationStage],
       ['紧迫度', demand.urgency],
     ];
     return values.filter(([, value]) => value !== undefined && value !== null && String(value).trim());
@@ -2494,7 +2463,7 @@
   }
 
   function agentModeLabel(mode) {
-    return ({ review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' })[mode] || mode || '待审核';
+    return ({ review: '待审核', auto: '高置信自动', autopilot: '全自动接管', human: '人工接管', paused: '会话暂停' })[mode] || mode || '待审核';
   }
 
   function formatReplyWaiting(minutes) {
@@ -2553,14 +2522,19 @@
       const id = String(conversation.channelId || conversation.id);
       const latest = conversation.messages?.[conversation.messages.length - 1] || null;
       const current = groups.get(id) || {};
+      const latestSource = latestAgentMessageSource([
+        { time: current.lastMessageAt, preview: current.lastMessagePreview, senderName: current.lastSenderName },
+        { time: conversation.lastMessageAt || latest?.timestamp, preview: latest?.content, senderName: latest?.senderName },
+      ]);
       groups.set(id, {
         ...current,
         id,
         name: current.name || conversation.displayName,
         conversationId: conversation.id,
         messages: conversation.messages || [],
-        lastMessageAt: current.lastMessageAt || conversation.lastMessageAt,
-        lastMessagePreview: current.lastMessagePreview || latest?.content || '',
+        lastMessageAt: latestSource?.timestamp > Number.NEGATIVE_INFINITY ? latestSource.time : null,
+        lastMessagePreview: latestSource?.preview || '',
+        lastSenderName: latestSource?.senderName || '',
         status: current.status || 'monitoring',
         channelType: 'group',
         channelLabel: '客户群聊',
@@ -2571,14 +2545,18 @@
       if (!id) continue;
       const latest = groupAgent.messages?.[groupAgent.messages.length - 1] || null;
       const current = groups.get(id) || {};
+      const latestSource = latestAgentMessageSource([
+        { time: current.lastMessageAt, preview: current.lastMessagePreview, senderName: current.lastSenderName },
+        { time: latest?.timestamp, preview: latest?.content, senderName: latest?.senderName },
+      ]);
       groups.set(id, {
         ...current,
         id,
         name: current.name || groupAgent.roomName || id,
         messages: groupAgent.messages || [],
-        lastMessageAt: current.lastMessageAt || latest?.timestamp || null,
-        lastMessagePreview: current.lastMessagePreview || latest?.content || '',
-        lastSenderName: current.lastSenderName || latest?.senderName || '',
+        lastMessageAt: latestSource?.timestamp > Number.NEGATIVE_INFINITY ? latestSource.time : null,
+        lastMessagePreview: latestSource?.preview || '',
+        lastSenderName: latestSource?.senderName || '',
         pendingReply: groupAgent.pendingReply || null,
         mode: groupAgent.mode === 'auto' ? 'auto' : 'review',
         agentError: groupAgent.agentError || null,
@@ -2590,7 +2568,7 @@
         channelLabel: '客户群聊',
       });
     }
-    return [...groups.values()].sort((a, b) => Date.parse(b.lastMessageAt || 0) - Date.parse(a.lastMessageAt || 0));
+    return sortAgentItemsByLatest([...groups.values()]);
   }
 
   function agentMonitorStatusLabel(status) {
@@ -2681,6 +2659,72 @@
     })[action] || action;
   }
 
+  function agentTraceText(value, maxLength = 96) {
+    const text = String(value ?? '')
+      .replace(/\b1\d{10}\b/g, match => `${match.slice(0, 3)}****${match.slice(-4)}`)
+      .replace(/([?&](?:token|key|secret|password)=)[^&\s]+/gi, '$1[已脱敏]');
+    return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
+  }
+
+  function agentTraceSummary(value, key = '', depth = 0) {
+    if (/token|secret|password|authorization|cookie|masterkey|session/i.test(key)) return '[已脱敏]';
+    if (value === null || value === undefined || value === '') return '';
+    if (Array.isArray(value)) return `${value.length} 项`;
+    if (typeof value === 'object') {
+      if (depth >= 1) return `${Object.keys(value).length} 个字段`;
+      return Object.fromEntries(Object.entries(value).slice(0, 6).map(([childKey, childValue]) => [
+        childKey,
+        agentTraceSummary(childValue, childKey, depth + 1),
+      ]));
+    }
+    return agentTraceText(value);
+  }
+
+  function agentToolStatus(call = {}) {
+    const result = call.result || {};
+    if (call.error || result.error || result.ok === false || result.success === false) return 'failed';
+    if (call.status === 'running') return 'running';
+    return 'completed';
+  }
+
+  function renderAgentActivityTimeline(viewModel = {}) {
+    const traces = Array.isArray(viewModel.toolTrace) ? viewModel.toolTrace : [];
+    const audit = Array.isArray(viewModel.audit) ? viewModel.audit : [];
+    const planAudit = audit.filter(item => ['message_received', 'manual_agent_run_requested', 'conversation_history_synced'].includes(item.action)).slice(-2);
+    const evaluateAudit = audit.filter(item => ['draft_created', 'agent_failed', 'agent_no_reply_needed', 'message_send_failed', 'draft_approved', 'message_sent'].includes(item.action)).slice(-3);
+    const phase = (code, label, items, empty) => `
+      <section class="agent-activity-phase ${code}">
+        <header><i>${code.slice(0, 1).toUpperCase()}</i><div><strong>${label}</strong><small>${items.length} 条证据</small></div></header>
+        <div class="agent-activity-items">${items.length ? items.join('') : `<span class="agent-activity-empty">${empty}</span>`}</div>
+      </section>`;
+    const auditCard = item => `<article class="agent-activity-item"><div><strong>${escapeHtml(agentAuditLabel(item.action))}</strong><em>${escapeHtml(formatAgentTime(item.created_at || item.createdAt || item.at || item.timestamp))}</em></div><small>${escapeHtml(agentTraceText(item.detail || item.message || item.reason || '已记录到会话审计'))}</small></article>`;
+    const actItems = traces.map(call => {
+      const status = agentToolStatus(call);
+      const args = agentTraceSummary(call.args || call.params || {});
+      const argsText = typeof args === 'object'
+        ? Object.entries(args).map(([key, value]) => `${key}: ${typeof value === 'object' ? '结构化参数' : value}`).join(' · ')
+        : args;
+      return `<article class="agent-activity-item tool ${status}"><div><strong>${escapeHtml(call.tool || call.name || 'Agent 工具')}</strong><em>${status === 'failed' ? '失败' : status === 'running' ? '执行中' : '已执行'}</em></div><small>${escapeHtml(argsText || '无公开参数')}</small></article>`;
+    });
+    const observeItems = traces.map(call => {
+      const result = call.result || {};
+      const status = agentToolStatus(call);
+      const duration = Number(call.durationMs ?? result.durationMs ?? call.elapsedMs ?? result.elapsedMs);
+      const retries = Number(call.retryCount ?? result.retryCount ?? call.retries ?? result.retries);
+      const resultSummary = agentTraceSummary(call.error || result.error || result.message || result.summary || result);
+      const summaryText = typeof resultSummary === 'object'
+        ? Object.entries(resultSummary).map(([key, value]) => `${key}: ${typeof value === 'object' ? '结构化结果' : value}`).join(' · ')
+        : resultSummary;
+      return `<article class="agent-activity-item observe ${status}"><div><strong>${escapeHtml(call.tool || call.name || 'Agent 工具')} 结果</strong><em>${Number.isFinite(duration) ? `${Math.max(0, Math.round(duration))} ms` : status === 'failed' ? '失败' : '完成'}${Number.isFinite(retries) && retries > 0 ? ` · 重试 ${retries}` : ''}</em></div><small>${escapeHtml(summaryText || (status === 'failed' ? '执行失败,详情已进入审计' : '工具返回成功'))}</small></article>`;
+    });
+    return `<div class="agent-activity-timeline">
+      ${phase('plan', 'Plan · 任务计划', planAudit.map(auditCard), '等待真实消息或人工运行请求')}
+      ${phase('act', 'Act · 工具执行', actItems, '本轮没有工具调用')}
+      ${phase('observe', 'Observe · 结果观察', observeItems, '暂无工具结果')}
+      ${phase('evaluate', 'Evaluate · 评估决策', evaluateAudit.map(auditCard), '等待 Agent 形成处理结论')}
+    </div>`;
+  }
+
   function scrollAgentMessagesToLatest(page) {
     requestAnimationFrame(() => {
       const stream = page.querySelector('.agent-message-stream');
@@ -2954,7 +2998,6 @@
     const customerAlerts = customerIntelligence.alerts || [];
     const intelligenceSummary = customerIntelligence.summary || {};
     const demands = demandItems(analysis.demand || {});
-    const matches = analysis.matches || [];
     const pendingText = selected?.pendingReply?.content || '';
     const isHistorical = selected?.source === 'verified-history';
     const manualActive = Boolean(!aiActive || selected?.mode === 'manual');
@@ -3014,7 +3057,7 @@
               <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : 'AI 自动回复运行中…'}">${escapeHtml(pendingText)}</textarea>
               <div class="agent-composer-actions"><span>${manualActive ? '也可以直接在企业微信中人工回复' : '新消息将由 AI 自动回复'}</span><button class="btn" data-agent-action="approve" data-id="${selected.id}" ${pendingText || manualActive ? '' : 'disabled'}>${manualActive ? '人工发送' : '发送建议'}</button></div>
             </div>
-          ` : `<div class="agent-empty large"><strong>智能会话准备就绪</strong><span>启动监听,然后从白名单企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”</span></div>`}
+          ` : `<div class="agent-empty large"><strong>智能会话准备就绪</strong><span>启动监听,然后从白名单企微发送一条真实业务咨询。</span></div>`}
         </main>
 
         <aside class="agent-insight-panel">
@@ -3028,12 +3071,8 @@
               <div class="agent-section-title"><span>02</span><div><strong>需求画像</strong><small>从多轮会话持续归集</small></div></div>
               <div class="agent-demand-grid">${demands.length ? demands.map(([label, value]) => `<div><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('') : '<p class="agent-muted">暂无明确需求</p>'}</div>
             </div>
-            <div class="agent-insight-section">
-              <div class="agent-section-title"><span>03</span><div><strong>匹配建议</strong><small>本地房源数据与评分模型</small></div></div>
-              <div class="agent-match-list">${matches.length ? matches.map((match, index) => `<div class="agent-match-card"><span>${index + 1}</span><div><strong>${escapeHtml(match.community || match.title || '匹配结果')}</strong><small>${escapeHtml([match.price ? `${match.price}万` : '', match.layout, match.area ? `${match.area}㎡` : ''].filter(Boolean).join(' · '))}</small><em>${escapeHtml((match.highlights || []).join(' / ') || match.level || '')}</em></div>${match.score !== null && match.score !== undefined ? `<b>${match.score}</b>` : ''}</div>`).join('') : '<p class="agent-muted">当前消息未触发房源匹配</p>'}</div>
-            </div>
             <div class="agent-insight-section knowledge">
-              <div class="agent-section-title"><span>04</span><div><strong>依据与边界</strong><small>让建议可解释、可追溯</small></div></div>
+              <div class="agent-section-title"><span>03</span><div><strong>依据与边界</strong><small>让建议可解释、可追溯</small></div></div>
               <ul>${(analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
             </div>
           ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
@@ -3219,6 +3258,35 @@
     })[error.code] || { title: 'Claude Code 暂时不可用', next: '请稍后重试;详细原因已记录在审计日志中。' };
   }
 
+  function renderAgentIntakeSettings() {
+    const policy = state.agent.intakePolicy || {};
+    const mode = policy.mode || 'allowlist_only';
+    const sendMode = policy.welcomeSendMode || 'draft';
+    return `
+      <form class="agent-intake-form" data-agent-intake-form>
+        <div class="agent-drawer-section-heading"><strong>个人消息接入</strong><span>未知群聊始终不会自动纳入</span></div>
+        ${state.agent.intakePolicyError ? `<div class="agent-drawer-error">${escapeHtml(state.agent.intakePolicyError)}</div>` : ''}
+        <label class="agent-field"><span>接入策略</span><select name="mode">
+          <option value="allowlist_only" ${mode === 'allowlist_only' ? 'selected' : ''}>仅白名单(默认)</option>
+          <option value="auto_enroll_review" ${mode === 'auto_enroll_review' ? 'selected' : ''}>新私聊自动纳入 · 强制待审核</option>
+          <option value="auto_enroll_autopilot" ${mode === 'auto_enroll_autopilot' ? 'selected' : ''}>新私聊自动纳入 · 全自动接管</option>
+        </select></label>
+        <label class="agent-toggle-field"><input type="checkbox" name="welcomeEnabled" ${policy.welcomeEnabled ? 'checked' : ''}><span><strong>启用欢迎语</strong><small>默认只生成草稿,避免未经审核外发</small></span></label>
+        <label class="agent-field"><span>欢迎语</span><textarea name="welcomeText" rows="4" placeholder="输入首次联系时的欢迎内容">${escapeHtml(policy.welcomeText || '')}</textarea></label>
+        <label class="agent-field"><span>欢迎语处理</span><select name="welcomeSendMode">
+          <option value="draft" ${sendMode === 'draft' ? 'selected' : ''}>生成待审草稿</option>
+          <option value="send" ${sendMode === 'send' ? 'selected' : ''}>直接发送(仅全自动接管策略)</option>
+        </select></label>
+        <div class="agent-policy-warning ${mode === 'auto_enroll_autopilot' ? 'danger' : ''}">${mode === 'auto_enroll_autopilot' ? '全自动接入会把新私聊加入当前账号白名单,欢迎语和后续非空 Agent 回复都可能直接发送。保存时必须再次确认。' : '通用默认仅处理人工选择的白名单联系人;待审核模式不会自动外发。'}</div>
+        <button class="btn" type="submit" ${state.agent.intakePolicyLoading ? 'disabled' : ''}>${state.agent.intakePolicyLoading ? '正在保存…' : '保存接入设置'}</button>
+      </form>`;
+  }
+
+  function agentReminderForConversation(conversationId, monitor = {}) {
+    const id = String(conversationId || '');
+    return (monitor.reminders || []).find(item => String(item.conversationId || item.id || '') === id) || null;
+  }
+
   function renderAgentWorkspaceV2(page) {
     const insightScroll = captureAgentInsightScroll(page);
     const workspaceScroll = captureAgentWorkspaceScroll(page);
@@ -3240,9 +3308,14 @@
     const agent = status.agent || {};
     const knowledge = status.knowledge || {};
     const conversations = state.agent.conversations || [];
-    const privateConversations = conversations.filter(item => item.channelType !== 'group');
+    const monitor = state.agent.responseMonitor || {};
+    const monitorSummary = monitor.summary || {};
+    const privateConversations = sortAgentItemsByLatest(conversations.filter(item => item.channelType !== 'group'));
     const groupAgents = [];
     const groupConversations = [];
+    const pendingReplyCount = privateConversations.filter(item => item.pendingReply).length;
+    const reminderCount = (monitor.reminders || []).length;
+    const overdueCount = Number(monitorSummary.overdue) || (monitor.reminders || []).filter(item => item.severity === 'urgent').length;
     const listMode = 'private';
     const selected = privateConversations.find(item => item.id === state.agent.selectedId) || privateConversations[0] || null;
     const selectedGroup = null;
@@ -3260,11 +3333,11 @@
     const customerAlerts = customerIntelligence.alerts || [];
     const intelligenceSummary = customerIntelligence.summary || {};
     const demands = demandItems(analysis.demand || {});
-    const matches = analysis.matches || [];
     const pending = selected?.pendingReply || null;
     const noReplyNotice = selected?.agentNotice || null;
     const activeDraft = pending;
     const manualActive = selected?.mode === 'human';
+    const autopilotActive = selected?.mode === 'autopilot';
     const conversationPaused = selected?.mode === 'paused';
     const replyEditKey = selected ? `${selected.id}:${pending?.id || (manualActive ? 'manual' : 'composer')}` : '';
     const hasReplyEdit = Boolean(replyEditKey && Object.prototype.hasOwnProperty.call(state.agent.replyEdits, replyEditKey));
@@ -3277,44 +3350,37 @@
 
     els.accountName.textContent = accountName;
     els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
+    page.dataset.theme = String(config.theme || status.tenant?.theme || 'fmode').toLowerCase() === 'xiaoniu' ? 'xiaoniu' : 'fmode';
 
     const nextHtml = `
-      <section class="agent-hero">
-        <div>
-          <div class="agent-eyebrow">WECOM · REAL AGENT · HUMAN IN THE LOOP</div>
-          <h2>真实企微消息,由 Agent 思考,人工可随时监管</h2>
-          <p>消息进入后,Agent 自主检索规则库、知识库、画像和房源工具;草稿、依据、置信度和工具轨迹全部留痕。</p>
-        </div>
-        <div class="agent-safety-card">
-          <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
-          <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${messageIngressRunning ? '回调接收中' : '回调待恢复'}</small></div>
-          <div class="agent-safety-actions">
-            <button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
-            <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采全部白名单</button>
-            ${product.mode === 'personal'
-              ? `<button class="btn ${aiListenerActive ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (aiListenerActive ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (aiListenerActive ? '关闭 AI 监听(转人工)' : '启动 AI 监听') : '先选择白名单'}</button>`
-              : ''}
-          </div>
-        </div>
-      </section>
-
-      <section class="agent-modebar">
-        <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>${escapeHtml(product.label)} · ${product.mode === 'enterprise' ? (messageIngressRunning ? '公网回调常驻' : '公网回调正在恢复') : '数据保存在当前项目'} · 暂停时仍接收并保存消息</span></div>
+      <header class="agent-commandbar">
+        <div class="agent-command-account"><span class="agent-live-dot ${account.online ? 'online' : ''}"></span><div><strong>${escapeHtml(accountName)}</strong><small>${account.online ? '账号在线' : '账号离线'} · ${messageIngressRunning ? '监听运行中' : '监听待恢复'}</small></div></div>
         <div class="agent-mode-switch">
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
+          <button class="agent-mode-btn ${!globalPaused && globalMode === 'autopilot' ? 'active danger' : ''}" data-agent-mode="autopilot">全自动接管</button>
           <button class="agent-mode-btn ${globalPaused ? 'active danger' : ''}" data-agent-mode="paused">全局暂停</button>
         </div>
-        <div class="agent-guard"><span>发送边界</span>客服工作台仅处理 ${allowlistCount} 位白名单客户私聊 · 群聊只采集消息并沉淀画像</div>
-      </section>
-
-      ${renderResponseMonitor(state.agent.responseMonitor || {})}
+        <div class="agent-command-kpis" aria-label="工作台摘要"><span><b>${allowlistCount}</b> 白名单</span><span class="${pendingReplyCount ? 'attention' : ''}"><b>${pendingReplyCount}</b> 待审核</span><span class="${overdueCount ? 'danger' : ''}"><b>${reminderCount}</b> 待回复</span></div>
+        <div class="agent-command-actions">
+          <button class="btn btn-secondary" data-agent-action="manage-allowlist">白名单</button>
+          <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采全部</button>
+          ${product.mode === 'personal' ? `<button class="btn ${aiListenerActive ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (aiListenerActive ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (aiListenerActive ? '关闭监听' : '启动监听') : '选择白名单'}</button>` : ''}
+          <button class="btn btn-secondary" data-agent-drawer="settings" aria-expanded="${state.agent.openDrawer === 'settings'}">设置</button>
+          <button class="btn btn-secondary ${reminderCount ? 'has-alert' : ''}" data-agent-drawer="reminders" aria-expanded="${state.agent.openDrawer === 'reminders'}">提醒${reminderCount ? ` ${reminderCount}` : ''}</button>
+        </div>
+      </header>
 
       ${selected?.agentError
         ? (() => { const errorUi = agentErrorPresentation(selected.agentError); return `<div class="agent-runtime-alert"><strong>${escapeHtml(errorUi.title)}</strong><span>${escapeHtml(selected.agentError.message)} ${escapeHtml(errorUi.next)} 真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`; })()
         : noReplyNotice
           ? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
           : ''}
+      ${selected?.onboarding?.state === 'failed'
+        ? `<div class="agent-runtime-alert"><strong>欢迎语发送失败</strong><span>${escapeHtml(selected.onboarding.lastError || '可在确认企微连接后重试。')}</span><button class="btn btn-sm" data-agent-action="retry-welcome" data-id="${escapeHtml(selected.id)}">重试欢迎语</button></div>`
+        : selected?.onboarding?.state === 'delivery_unknown'
+          ? '<div class="agent-runtime-alert notice"><strong>欢迎语发送结果待确认</strong><span>为避免重复打扰,系统不会自动重发,请先在企微聊天中核对。</span></div>'
+          : ''}
 
       <section class="agent-workspace">
         <aside class="agent-conversation-list">
@@ -3325,10 +3391,13 @@
           <div class="agent-list-scroll">
             ${listMode === 'private' && privateConversations.length ? privateConversations.map(item => {
               const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
+              const reminder = agentReminderForConversation(item.id, monitor);
+              const reminderStatus = reminder?.severity === 'urgent' || reminder?.status === 'overdue' ? 'overdue' : reminder ? 'warning' : item.pendingReply ? 'pending' : '';
+              const reminderLabel = reminderStatus === 'overdue' ? '已超时' : reminderStatus === 'warning' ? '待回复' : reminderStatus === 'pending' ? '待审核' : '';
               return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
                 <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
                 <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.displayName)}</strong><i class="agent-channel-badge private">私聊</i></span><small>${escapeHtml(last?.content || '等待新消息')}</small><em>${agentModeLabel(item.mode)}${item.pendingReply ? ' · 有待审草稿' : ''}${item.customerIntelligence?.summary?.openTasks ? ` · ${item.customerIntelligence.summary.openTasks}待办` : ''}${item.customerIntelligence?.summary?.highAlerts ? ` · ${item.customerIntelligence.summary.highAlerts}预警` : ''}</em></span>
-                <time>${formatAgentTime(item.lastMessageAt)}</time>
+                <span class="agent-conversation-meta"><time>${formatAgentTime(item.lastMessageAt)}</time>${reminderLabel ? `<i class="${reminderStatus}">${reminderLabel}</i>` : ''}</span>
               </button>`;
             }).join('') : listMode === 'group' && groupConversations.length ? groupConversations.map(item => `<button class="agent-conversation-item group ${item.id === selectedGroup?.id ? 'active' : ''}" data-group-monitor-id="${escapeHtml(item.id)}">
                 <span class="agent-avatar group">群</span>
@@ -3338,7 +3407,7 @@
           </div>
         </aside>
 
-        <main class="agent-chat-panel ${selectedGroup ? 'group-mode' : ''}">
+        <main class="agent-chat-panel ${selectedGroup ? 'group-mode' : ''}" tabindex="-1">
           ${selectedGroup ? `
             <header class="agent-chat-header group">
               <div><strong>${escapeHtml(selectedGroup.name || '客户群')}</strong><span>客户群聊 · ${selectedGroup.mode === 'auto' ? '全自动(忽略风险)' : 'Agent 草稿 · 人工审核发送'}</span></div>
@@ -3365,7 +3434,8 @@
               <div class="agent-conversation-modes">
                 <button class="btn btn-sm btn-secondary" data-agent-action="sync-current-conversation" data-id="${selected.id}" ${account.online ? '' : 'disabled'}>采集当前会话</button>
                 <button class="btn btn-sm ${selected.mode === 'review' ? '' : 'btn-secondary'}" data-agent-conversation-mode="review" data-id="${selected.id}">待审核</button>
-                <button class="btn btn-sm ${selected.mode === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">自动</button>
+                <button class="btn btn-sm ${selected.mode === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">高置信自动</button>
+                <button class="btn btn-sm ${selected.mode === 'autopilot' ? 'agent-mode-danger active' : 'btn-secondary'}" data-agent-conversation-mode="autopilot" data-id="${selected.id}">全自动接管</button>
                 <button class="btn btn-sm ${selected.mode === 'human' ? '' : 'btn-secondary'}" data-agent-conversation-mode="human" data-id="${selected.id}">人工接管</button>
                 <button class="btn btn-sm ${selected.mode === 'paused' ? '' : 'btn-secondary'}" data-agent-conversation-mode="paused" data-id="${selected.id}">暂停</button>
               </div>
@@ -3377,14 +3447,14 @@
               <button class="btn btn-sm btn-secondary agent-session-action" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '初始化 Session'}</button>
             </div>
             <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
-            <div class="agent-composer ${manualActive ? 'manual' : ''}">
-              <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
-              <textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
-              ${voiceComposerToolbar({ selected, content: pendingText, draftId: pending?.id, disabled: conversationPaused })}
+            <div class="agent-composer ${manualActive ? 'manual' : autopilotActive ? 'autopilot' : ''}">
+              <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : autopilotActive ? '全自动接管运行中' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : autopilotActive ? '新消息的非空 Agent 回复将直接发送,不创建待审核草稿' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
+              <textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : autopilotActive ? '全自动接管运行中…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused || (autopilotActive && !pending) ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
+              ${voiceComposerToolbar({ selected, content: pendingText, draftId: pending?.id, disabled: conversationPaused || (autopilotActive && !pending) })}
               <div class="agent-composer-actions">
                 <span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
                 <div class="agent-review-actions">
-                  ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">${noReplyNotice ? '重新处理' : '让 Agent 处理'}</button>`}
+                  ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused || autopilotActive ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">${noReplyNotice ? '重新处理' : '让 Agent 处理'}</button>`}
                 </div>
               </div>
             </div>
@@ -3423,25 +3493,38 @@
               <div class="agent-section-title"><span>04</span><div><strong>客户预警</strong><small>${intelligenceSummary.openAlerts || 0} 项未处理 · ${intelligenceSummary.highAlerts || 0} 项高优先级</small></div></div>
               <div class="agent-customer-alert-list">${customerAlerts.length ? customerAlerts.slice(0, 6).map(alert => `<div class="agent-customer-alert ${escapeHtml(alert.severity)} ${escapeHtml(alert.status)}"><div class="agent-alert-heading"><strong>${escapeHtml(alert.title)}</strong><em>${escapeHtml(alert.severity)}</em></div><p>${escapeHtml(alert.detail || '')}</p><small>依据:${escapeHtml(alert.evidence || '待补充')}</small><div class="agent-intelligence-actions">${alert.status === 'open' ? `<button class="btn btn-sm btn-secondary" data-agent-action="customer-alert-dismiss" data-id="${alert.id}">忽略</button><button class="btn btn-sm" data-agent-action="customer-alert-ack" data-id="${alert.id}">已知悉</button>` : `<span>${alert.status === 'acknowledged' ? '已知悉' : alert.status === 'resolved' ? '已解决' : '已忽略'}</span>`}</div></div>`).join('') : '<p class="agent-muted">当前没有高意向、投诉、时效或矛盾预警</p>'}</div>
             </div>
-            <div class="agent-insight-section">
-              <div class="agent-section-title"><span>05</span><div><strong>匹配建议</strong><small>已接入房源工具的返回结果</small></div></div>
-              <div class="agent-match-list">${matches.length ? matches.map((match, index) => `<div class="agent-match-card"><span>${index + 1}</span><div><strong>${escapeHtml(match.community || match.title || '匹配结果')}</strong><small>${escapeHtml([match.price ? `${match.price}万` : '', match.layout, match.area ? `${match.area}㎡` : ''].filter(Boolean).join(' · '))}</small><em>${escapeHtml((match.highlights || []).join(' / ') || match.level || '')}</em></div></div>`).join('') : '<p class="agent-muted">当前消息未触发房源工具</p>'}</div>
-            </div>
             <div class="agent-insight-section knowledge">
-              <div class="agent-section-title"><span>06</span><div><strong>依据与引用</strong><small>${knowledge.knowledgeChunks || 0} 个知识块 · ${knowledge.propertyDataLabel || '房源未接入'}</small></div></div>
+              <div class="agent-section-title"><span>05</span><div><strong>依据与引用</strong><small>${knowledge.knowledgeChunks || 0} 个知识块 · 通用知识库</small></div></div>
               <ul>${(citations.length ? citations.map(item => `${item.heading || item.source} · ${item.source || ''}`) : analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
             </div>
             <div class="agent-insight-section">
-              <div class="agent-section-title"><span>07</span><div><strong>工具调用轨迹</strong><small>Agent 自主检索过程</small></div></div>
-              <div class="agent-tool-trace">${toolTrace.length ? toolTrace.map((item, index) => `<details ${index === 0 ? 'open' : ''}><summary>${escapeHtml(item.tool)}</summary><pre>${escapeHtml(JSON.stringify({ input: item.args || {}, result: item.result || {} }, null, 2))}</pre></details>`).join('') : '<p class="agent-muted">当前没有工具调用记录</p>'}</div>
+              <div class="agent-section-title"><span>06</span><div><strong>活动与工具轨迹</strong><small>Plan / Act / Observe / Evaluate</small></div></div>
+              <details class="agent-activity-disclosure" data-agent-activity ${state.agent.activityOpen ? 'open' : ''}><summary>查看执行证据与脱敏参数</summary>${renderAgentActivityTimeline({ toolTrace, audit })}</details>
             </div>
             <div class="agent-insight-section">
-              <div class="agent-section-title"><span>08</span><div><strong>审计日志</strong><small>接收、决策、审核和发送全留痕</small></div></div>
+              <div class="agent-section-title"><span>07</span><div><strong>审计日志</strong><small>接收、决策、审核和发送全留痕</small></div></div>
               <div class="agent-audit-list">${audit.length ? audit.slice(0, 10).map(item => `<div><span>${escapeHtml(agentAuditLabel(item.action))}</span><time>${formatAgentTime(item.created_at)}</time></div>`).join('') : '<p class="agent-muted">暂无审计记录</p>'}</div>
             </div>
           ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
         </aside>
       </section>
+
+      <div class="agent-drawer-backdrop ${state.agent.openDrawer ? 'open' : ''}" data-agent-drawer-close></div>
+      <aside class="agent-drawer settings ${state.agent.openDrawer === 'settings' ? 'open' : ''}" aria-hidden="${state.agent.openDrawer !== 'settings'}" aria-label="Agent 设置">
+        <header><div><strong>Agent 设置</strong><span>接入、欢迎语与发送边界</span></div><button type="button" data-agent-drawer-close>关闭</button></header>
+        <div class="agent-drawer-body">
+          <section class="agent-connection-summary">
+            <div><span class="agent-live-dot ${account.online ? 'online' : ''}"></span><strong>${escapeHtml(accountName)}</strong></div>
+            <dl><div><dt>产品模式</dt><dd>${escapeHtml(product.label)}</dd></div><div><dt>消息接收</dt><dd>${messageIngressRunning ? '运行中' : '待恢复'}</dd></div><div><dt>默认模式</dt><dd>${escapeHtml(agentModeLabel(globalPaused ? 'paused' : globalMode))}</dd></div><div><dt>白名单</dt><dd>${allowlistCount} 人</dd></div></dl>
+          </section>
+          ${renderAgentIntakeSettings()}
+          <section class="agent-drawer-boundary"><strong>当前发送边界</strong><p>${globalMode === 'autopilot' ? '全自动接管会直接发送白名单私聊中的非空 Agent 回复,不创建待审核草稿。' : '未知群聊始终拒绝;自动纳入联系人按当前账号隔离;高置信自动仍受置信度与人工风险门禁。'}</p></section>
+        </div>
+      </aside>
+      <aside class="agent-drawer reminders ${state.agent.openDrawer === 'reminders' ? 'open' : ''}" aria-hidden="${state.agent.openDrawer !== 'reminders'}" aria-label="回复提醒">
+        <header><div><strong>回复提醒</strong><span>${reminderCount ? `${reminderCount} 个会话需要关注` : '当前没有待处理提醒'}</span></div><button type="button" data-agent-drawer-close>关闭</button></header>
+        <div class="agent-drawer-body">${renderResponseMonitor(monitor)}</div>
+      </aside>
     `;
     const viewKey = `${listMode}:${selected?.id || selectedGroup?.id || 'empty'}`;
     const incremental = page.dataset.agentViewKey === viewKey && Boolean(page.querySelector('.agent-workspace'));
@@ -3470,6 +3553,35 @@
     };
   }
 
+  function normalizeAgentIntakePolicy(payload = {}) {
+    const policy = payload.policy || payload;
+    return {
+      mode: ['allowlist_only', 'auto_enroll_review', 'auto_enroll_autopilot'].includes(policy.mode) ? policy.mode : 'allowlist_only',
+      welcomeEnabled: Boolean(policy.welcomeEnabled),
+      welcomeText: String(policy.welcomeText || ''),
+      welcomeSendMode: policy.welcomeSendMode === 'send' ? 'send' : 'draft',
+      effectiveConversationMode: policy.effectiveConversationMode || 'review',
+      onboarding: policy.onboarding || null,
+    };
+  }
+
+  async function loadAgentIntakePolicy(page, silent = false) {
+    if (state.agent.intakePolicyLoading) return;
+    state.agent.intakePolicyLoading = true;
+    try {
+      const result = await api('GET', '/api/agent/intake-policy');
+      state.agent.intakePolicy = normalizeAgentIntakePolicy(result.data || result);
+      state.agent.intakePolicyLoaded = true;
+      state.agent.intakePolicyError = '';
+    } catch (error) {
+      state.agent.intakePolicyError = error.message || '接入策略读取失败,当前展示安全默认值';
+      if (!silent) toast(state.agent.intakePolicyError, 'error');
+    } finally {
+      state.agent.intakePolicyLoading = false;
+      if (page?.isConnected && state.page === 'agent') renderAgentWorkspaceV2(page);
+    }
+  }
+
   async function loadAgentData(page, silent = false) {
     if (state.agent.loading) return;
     state.agent.loading = true;
@@ -3597,6 +3709,49 @@
     };
     page.addEventListener('pointerdown', markInsightInteraction, true);
     page.addEventListener('scroll', markInsightInteraction, true);
+    page.addEventListener('toggle', event => {
+      if (event.target?.matches?.('[data-agent-activity]')) state.agent.activityOpen = event.target.open;
+    }, true);
+
+    page.addEventListener('submit', async event => {
+      const form = event.target.closest('[data-agent-intake-form]');
+      if (!form) return;
+      event.preventDefault();
+      const formData = new FormData(form);
+      const policy = {
+        mode: String(formData.get('mode') || 'allowlist_only'),
+        welcomeEnabled: formData.get('welcomeEnabled') === 'on',
+        welcomeText: String(formData.get('welcomeText') || '').trim(),
+        welcomeSendMode: String(formData.get('welcomeSendMode') || 'draft') === 'send' ? 'send' : 'draft',
+      };
+      if (policy.welcomeSendMode === 'send' && policy.mode !== 'auto_enroll_autopilot') {
+        toast('欢迎语直接发送仅可用于已明确确认的全自动接管策略', 'error');
+        return;
+      }
+      if (policy.mode === 'auto_enroll_autopilot' && !await confirmModal('新私聊会自动加入当前账号白名单,欢迎语和后续非空 Agent 回复可能直接真实发送。', {
+        title: '确认开启全自动新联系人接入',
+        confirmText: '确认全自动接入',
+        warning: '所有非空 Agent 回复都会直接发送,请确认当前账号的白名单范围。',
+        danger: true,
+      })) return;
+      state.agent.intakePolicy = policy;
+      state.agent.intakePolicyLoading = true;
+      renderAgentWorkspaceV2(page);
+      try {
+        const body = { ...policy, ...(policy.mode === 'auto_enroll_autopilot' ? { confirmation: 'ENABLE_AUTO_ENROLL_AUTOPILOT' } : {}) };
+        const result = await api('POST', '/api/agent/intake-policy', body);
+        state.agent.intakePolicy = normalizeAgentIntakePolicy(result.data || result);
+        state.agent.intakePolicyLoaded = true;
+        state.agent.intakePolicyError = '';
+        toast(result.assistantMessage || '接入策略已保存');
+      } catch (error) {
+        state.agent.intakePolicyError = error.message || '接入策略保存失败';
+        toast(state.agent.intakePolicyError, 'error');
+      } finally {
+        state.agent.intakePolicyLoading = false;
+        if (page.isConnected) renderAgentWorkspaceV2(page);
+      }
+    });
 
     page.addEventListener('input', event => {
       const editor = event.target.closest('#agent-reply-editor');
@@ -3644,6 +3799,20 @@
     }, true);
 
     page.addEventListener('click', async event => {
+      const drawerClose = event.target.closest('[data-agent-drawer-close]');
+      if (drawerClose) {
+        state.agent.openDrawer = null;
+        renderAgentWorkspaceV2(page);
+        return;
+      }
+      const drawerButton = event.target.closest('[data-agent-drawer]');
+      if (drawerButton) {
+        const drawer = drawerButton.dataset.agentDrawer;
+        state.agent.openDrawer = state.agent.openDrawer === drawer ? null : drawer;
+        renderAgentWorkspaceV2(page);
+        if (state.agent.openDrawer === 'settings' && !state.agent.intakePolicyLoaded) await loadAgentIntakePolicy(page, true);
+        return;
+      }
       const transcriptButton = event.target.closest('[data-voice-transcript-toggle]');
       if (transcriptButton) {
         const transcript = document.getElementById(transcriptButton.getAttribute('aria-controls'));
@@ -3678,8 +3847,9 @@
           state.agent.listMode = 'group';
           state.agent.selectedGroupId = responseMonitorButton.dataset.id;
         }
+        state.agent.openDrawer = null;
         renderAgentWorkspaceV2(page);
-        page.querySelector('.agent-workspace')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+        page.querySelector('.agent-chat-panel')?.focus({ preventScroll: true });
         return;
       }
       const listModeButton = event.target.closest('[data-agent-list-mode]');
@@ -3713,8 +3883,14 @@
           warning: '符合条件的回复将不经人工审核直接发送。',
           danger: true,
         })) return;
+        if (mode === 'autopilot' && !await confirmModal(`全自动接管会向白名单中的 ${allowlistCount} 位联系人直接发送 Agent 回复,且不创建待审核草稿。`, {
+          title: '开启全自动接管',
+          confirmText: '确认开启全自动',
+          warning: '所有非空 Agent 回复都会直接发送,请确认当前白名单范围。',
+          danger: true,
+        })) return;
         try {
-          const result = await api('POST', '/api/agent/mode', { mode });
+          const result = await api('POST', '/api/agent/mode', { mode, ...(mode === 'autopilot' ? { confirmation: 'ENABLE_AUTOPILOT' } : {}) });
           toast(result.assistantMessage || '模式已更新');
           await loadAgentData(page);
         } catch (error) {
@@ -3733,10 +3909,16 @@
           warning: '符合条件的回复将不经人工审核直接发送给当前联系人。',
           danger: true,
         })) return;
-        const action = ({ review: 'resume', auto: 'auto', human: 'takeover', paused: 'pause' })[mode];
+        if (mode === 'autopilot' && !await confirmModal('该会话将进入全自动接管,Agent 回复会直接发送且不创建待审核草稿。', {
+          title: '开启会话全自动接管',
+          confirmText: '确认开启全自动',
+          warning: '所有非空 Agent 回复都会直接发送给当前联系人。',
+          danger: true,
+        })) return;
+        const action = ({ review: 'resume', auto: 'auto', autopilot: 'autopilot', human: 'takeover', paused: 'pause' })[mode];
         conversationModeButton.disabled = true;
         try {
-          const result = await api('POST', `/api/agent/conversations/${id}/${action}`, {});
+          const result = await api('POST', `/api/agent/conversations/${id}/${action}`, mode === 'autopilot' ? { confirmation: 'ENABLE_AUTOPILOT' } : {});
           toast(result.assistantMessage || '会话模式已更新');
           await loadAgentData(page);
         } catch (error) {
@@ -3892,6 +4074,9 @@
           result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/reject`, { reason });
         } else if (action === 'regenerate') {
           result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/regenerate`, {});
+        } else if (action === 'retry-welcome') {
+          if (!await confirmModal('确认重试这位新联系人的欢迎语?', { title: '重试欢迎语', confirmText: '确认重试', warning: '该操作会立即发送真实私聊消息。', danger: true })) return;
+          result = await api('POST', `/api/agent/onboarding/${encodeURIComponent(id)}/retry-welcome`, {});
         } else if (action === 'generate') {
           result = await api('POST', `/api/agent/conversations/${id}/generate`, {});
         } else if (action === 'manual-send') {
@@ -5326,7 +5511,6 @@
     const openTasks = (selected?.tasks || []).filter(task => ['open', 'in_progress'].includes(task.status));
     const openAlerts = (selected?.alerts || []).filter(alert => alert.status === 'open');
     const nextActions = selected?.nextActions || [];
-    const recommendations = selected?.recommendations || [];
     const timeline = selected?.timeline || [];
     const intelligenceItems = `${openTasks.map(task => `<div class="customer-master-action-item"><strong>${escapeHtml(task.title)}</strong><p>${escapeHtml(task.evidence || task.reason || '')}</p><small>内部任务 · ${escapeHtml(agentTaskStatusLabel(task.status))}</small></div>`).join('')}${openAlerts.map(alert => `<div class="customer-master-action-item alert"><strong>${escapeHtml(alert.title)}</strong><p>${escapeHtml(alert.evidence || alert.detail || '')}</p><small>客户预警 · ${escapeHtml(alert.severity)}</small></div>`).join('')}`;
     const isFallback = Boolean(summary.fallback);
@@ -5336,14 +5520,13 @@
       <div class="customer-master-score-row"><div><strong>${selected.completenessScore}%</strong><span>画像完整度</span></div><div><strong>${selected.evidenceCoverage}%</strong><span>字段证据覆盖</span></div><div><strong>${selected.messageCount}</strong><span>已纳入消息</span></div><div><strong>${selected.openTaskCount}</strong><span>待跟进任务</span></div></div>
       <section class="customer-profile-fields"><header><strong>自动沉淀画像</strong><span>${escapeHtml(formatAgentTime(selected.profileUpdatedAt))} 更新</span></header><div>${(selected.fields || []).map(field => `<div><small>${escapeHtml(field.label)}</small><strong>${escapeHtml(field.displayValue || '-')}</strong><em>${field.evidence ? field.evidence.source === 'human' ? '人工核对' : '有原话依据' : '历史字段 · 待补依据'}</em></div>`).join('') || '<p class="agent-muted">还没有形成结构化画像,后续真实对话会持续补齐。</p>'}</div>${selected.tags?.length ? `<footer>${selected.tags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}</footer>` : ''}</section>
       <section class="customer-next-action-section"><header><div><strong>下一步行动工作台</strong><span>规则建议 · 需人工判断 · 不自动外发</span></div><em>${nextActions.length} 项建议</em></header><div>${nextActions.map(action => `<article class="customer-next-action-card ${escapeHtml(action.priority)}"><div><span>${action.confidence === 'rule' ? '规则建议' : 'AI 建议'}</span><strong>${escapeHtml(action.title)}</strong><p>${escapeHtml(action.description)}</p></div><ul>${(action.evidence || []).slice(0, 3).map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>当前依据待补充</li>'}</ul>${selected.fallback ? '' : `<button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">去智能会话处理</button>`}</article>`).join('') || '<p class="agent-muted">当前没有新的行动建议。</p>'}</div></section>
-      <section class="customer-recommendation-section"><header><div><strong>房源推荐历史</strong><span>保存已推荐内容和客户反馈,避免重复推荐</span></div><em>${recommendations.length} 套 · ${selected.pendingFeedbackCount || 0} 套待反馈</em></header><div>${recommendations.map(item => `<article class="customer-recommendation-card"><header><div><span>${escapeHtml(item.propertyId)}</span><strong>${escapeHtml(item.property?.community || '未命名房源')}</strong></div><em class="${escapeHtml(item.status)}">${escapeHtml(item.statusLabel)}</em></header><p>${[item.property?.district, item.property?.layout, item.property?.area ? `${item.property.area}㎡` : '', item.property?.decoration].filter(Boolean).map(escapeHtml).join(' · ') || '房源快照字段待补充'}</p><div><strong>${item.property?.totalPrice || '-'}<small>万</small></strong><span>累计推荐 ${item.recommendCount || 1} 次</span><span>最近 ${escapeHtml(formatAgentTime(item.lastRecommendedAt))}</span></div>${item.feedbackReason ? `<blockquote>${escapeHtml(item.feedbackReason)}</blockquote>` : ''}<footer>${selected.fallback ? '' : `<button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="interested">感兴趣</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="rejected">不合适</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewing">已约带看</button><button class="btn btn-sm" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewed">已带看</button>`}</footer></article>`).join('') || '<p class="agent-muted">还没有可验证的房源推荐记录。后续房源工具调用或明确发送的房源方案会自动进入这里。</p>'}</div></section>
       <div class="customer-master-intelligence-grid">
         <section><header><strong>字段依据</strong><span>${evidenceItems.length} 条</span></header>${evidenceItems.map(field => `<div class="customer-evidence-item"><strong>${escapeHtml(field.label)}</strong><p>${escapeHtml(field.evidence.text || '人工核对')}</p><small>${field.evidence.source === 'human' ? '人工修正记录' : '真实企微消息'} · ${escapeHtml(formatAgentTime(field.evidence.updatedAt))}</small></div>`).join('') || '<p class="agent-muted">现有历史画像没有逐字段原话定位;新消息产生的字段会自动记录证据。</p>'}</section>
         <section><header><strong>跟进任务与预警</strong><span>${openTasks.length + openAlerts.length} 项</span></header>${intelligenceItems || '<p class="agent-muted">当前没有待处理任务或预警。</p>'}</section>
       </div>
       ${selected.latestMessage ? `<section class="customer-latest-message"><strong>最近纳入主档的消息</strong><p>${escapeHtml(selected.latestMessage.content)}</p><small>${selected.latestMessage.role === 'customer' ? '客户' : '内部回复'} · ${escapeHtml(formatAgentTime(selected.latestMessage.timestamp))}</small></section>` : ''}
-      <section class="customer-timeline-section"><header><div><strong>客户时间线</strong><span>消息、画像、任务、预警和房源反馈统一留痕</span></div><em>${timeline.length} 个事件</em></header><div>${timeline.slice(0, 20).map(event => `<article class="customer-timeline-item ${escapeHtml(event.type)}"><i></i><time>${escapeHtml(formatAgentTime(event.occurredAt))}</time><div><strong>${escapeHtml(event.title)}</strong><p>${escapeHtml(event.description || '')}</p><small>${escapeHtml(event.source || '内部主账')}${event.status ? ` · ${escapeHtml(event.status)}` : ''}</small></div></article>`).join('') || '<p class="agent-muted">暂无客户事件。</p>'}</div></section>
-      ${selected.fallback ? `<div class="customer-master-fallback-note"><strong>参考数据</strong><span>该客户来自客户运营导入或群成员发现,尚未建立真实企微会话。同步会话后,画像、任务和证据会自动沉淀到这里。</span></div>` : `<form class="customer-master-edit-form" id="customer-master-profile-form" data-customer-id="${escapeHtml(selected.id)}"><header><div><strong>人工核对画像</strong><span>只修正主档,不会向客户发送消息</span></div><button class="btn btn-sm" type="submit" ${state.customerOps.master.loading ? 'disabled' : ''}>保存主档</button></header><div><label><span>意向区域</span><input name="preferredRegion" value="${escapeHtml(customerProfileInput(profile, 'preferredRegion', 'region', 'district'))}" /></label><label><span>预算/万</span><input name="budgetWan" type="number" value="${escapeHtml(customerProfileInput(profile, 'budgetWan'))}" /></label><label><span>户型</span><input name="layout" value="${escapeHtml(customerProfileInput(profile, 'layout', 'houseType'))}" /></label><label><span>购置用途</span><input name="intent" value="${escapeHtml(customerProfileInput(profile, 'intent', 'purpose'))}" placeholder="自住 / 投资 / 待确认" /></label><label><span>购置时间</span><input name="timeline" value="${escapeHtml(customerProfileInput(profile, 'timeline'))}" /></label><label><span>标签</span><input name="tags" value="${escapeHtml((selected.tags || []).join(','))}" /></label></div><label class="customer-master-reason"><span>修正依据(可选)</span><input name="reason" placeholder="例如:已电话确认;仅用于审计留痕" /></label></form>`}
+      <section class="customer-timeline-section"><header><div><strong>客户时间线</strong><span>消息、画像、任务和预警统一留痕</span></div><em>${timeline.length} 个事件</em></header><div>${timeline.slice(0, 20).map(event => `<article class="customer-timeline-item ${escapeHtml(event.type)}"><i></i><time>${escapeHtml(formatAgentTime(event.occurredAt))}</time><div><strong>${escapeHtml(event.title)}</strong><p>${escapeHtml(event.description || '')}</p><small>${escapeHtml(event.source || '内部主账')}${event.status ? ` · ${escapeHtml(event.status)}` : ''}</small></div></article>`).join('') || '<p class="agent-muted">暂无客户事件。</p>'}</div></section>
+      ${selected.fallback ? `<div class="customer-master-fallback-note"><strong>参考数据</strong><span>该客户来自客户运营导入或群成员发现,尚未建立真实企微会话。同步会话后,画像、任务和证据会自动沉淀到这里。</span></div>` : `<form class="customer-master-edit-form" id="customer-master-profile-form" data-customer-id="${escapeHtml(selected.id)}"><header><div><strong>人工核对画像</strong><span>只修正主档,不会向客户发送消息</span></div><button class="btn btn-sm" type="submit" ${state.customerOps.master.loading ? 'disabled' : ''}>保存主档</button></header><div><label><span>客户意图</span><input name="intent" value="${escapeHtml(customerProfileInput(profile, 'intent', 'purpose'))}" /></label><label><span>核心需求</span><input name="need" value="${escapeHtml(customerProfileInput(profile, 'need', 'needs', 'keyConcerns'))}" /></label><label><span>预算</span><input name="budgetWan" type="number" value="${escapeHtml(customerProfileInput(profile, 'budgetWan', 'budget'))}" /></label><label><span>计划时间</span><input name="timeline" value="${escapeHtml(customerProfileInput(profile, 'timeline'))}" /></label><label><span>决策人</span><input name="decisionMaker" value="${escapeHtml(customerProfileInput(profile, 'decisionMaker'))}" /></label><label><span>标签</span><input name="tags" value="${escapeHtml((selected.tags || []).join(','))}" /></label></div><label class="customer-master-reason"><span>修正依据(可选)</span><input name="reason" placeholder="例如:已电话确认;仅用于审计留痕" /></label></form>`}
     </article>` : '<div class="library-empty large"><strong>暂无已同步客户</strong><span>从真实企微会话同步消息后,画像会自动沉淀到这里。</span></div>';
     return `<section class="customer-master-shell">
       <header class="customer-master-hero"><div><span>CUSTOMER 360 · CANONICAL PROFILE</span><h2>客户管理</h2><p>${isFallback ? '当前展示客户运营导入/群成员发现数据作为参考;同步真实企微会话后会自动替换为画像主档。' : '真实企微消息持续生成客户主档,画像、标签、任务、预警和证据统一归档。'}</p></div><button class="btn secondary" id="btn-customer-master-refresh" ${state.customerOps.master.loading ? 'disabled' : ''}>刷新主档</button></header>
@@ -5474,30 +5657,6 @@
         return;
       }
       if (event.target.closest('#btn-customer-master-refresh')) void reloadCustomerMaster('客户主档已刷新');
-      const feedbackButton = event.target.closest('[data-recommendation-feedback]');
-      if (feedbackButton) {
-        const status = feedbackButton.dataset.feedbackStatus;
-        let feedbackReason = '';
-        if (status === 'rejected') {
-          const answer = await promptModal('请记录该房源不合适的原因,便于后续优化推荐。', '', { title: '记录不感兴趣原因', label: '原因', textarea: true, required: false, confirmText: '保存反馈' });
-          if (answer === null) return;
-          feedbackReason = answer.trim();
-        } else if (status === 'interested') {
-          const answer = await promptModal('可记录客户感兴趣的具体原因,便于后续跟进。', '', { title: '记录感兴趣原因', label: '原因', textarea: true, required: false, confirmText: '保存反馈' });
-          if (answer === null) return;
-          feedbackReason = answer.trim();
-        }
-        state.customerOps.master.loading = true;
-        rerenderCustomerMaster();
-        api('POST', `/api/customers/${encodeURIComponent(state.customerOps.master.selectedId)}/recommendations/${encodeURIComponent(feedbackButton.dataset.recommendationFeedback)}`, { status, feedbackReason })
-          .then(async result => {
-            if (result.status !== 'ok') throw result;
-            state.customerOps.master.hub = await api('GET', '/api/customers');
-            toast(result.assistantMessage || '房源反馈已记录');
-          })
-          .catch(error => toast(error.assistantMessage || error.message || '房源反馈记录失败', 'error'))
-          .finally(() => { state.customerOps.master.loading = false; rerenderCustomerMaster(); });
-      }
     });
     masterRoot?.addEventListener('change', event => {
       if (event.target.id !== 'customer-master-search') return;
@@ -5510,11 +5669,11 @@
       const form = new FormData(event.target);
       const budgetText = String(form.get('budgetWan') || '').trim();
       const profile = {
-        preferredRegion: String(form.get('preferredRegion') || '').trim(),
-        budgetWan: budgetText ? Number(budgetText) : '',
-        layout: String(form.get('layout') || '').trim(),
         intent: String(form.get('intent') || '').trim(),
+        need: String(form.get('need') || '').trim(),
+        budgetWan: budgetText ? Number(budgetText) : '',
         timeline: String(form.get('timeline') || '').trim(),
+        decisionMaker: String(form.get('decisionMaker') || '').trim(),
       };
       state.customerOps.master.loading = true;
       rerenderCustomerMaster();
@@ -6640,7 +6799,7 @@
       </div>
       <div class="form-group" style="margin-bottom:16px">
         <label>标签(逗号分隔)</label>
-        <input id="tag-input" type="text" placeholder="高意向,学区房" />
+          <input id="tag-input" type="text" placeholder="高意向,重点客户" />
       </div>
       <div class="form-group">
         <label>操作</label>

+ 18 - 51
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/customer-master-service.js

@@ -2,30 +2,28 @@ const fs = require('fs');
 const path = require('path');
 const { outputsRoot } = require('../core/output-paths');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { getConversations, updateCustomerProfile, updateCustomerRecommendation } = require('./agent-service');
+const { getConversations, updateCustomerProfile } = require('./agent-service');
 const { readState } = require('../core/dashboard-state');
 
 const FIELD_LABELS = {
   intent: '客户意图',
-  purpose: '购置用途',
-  preferredRegion: '意向区域',
-  region: '意向区域',
-  district: '意向区域',
+  purpose: '客户目标',
+  need: '核心需求',
+  needs: '核心需求',
+  preferredRegion: '服务区域',
+  region: '服务区域',
+  district: '服务区域',
   budgetWan: '预算',
   budgetRange: '预算范围',
   budgetType: '预算口径',
-  layout: '意向户型',
-  houseType: '意向户型',
-  area: '面积偏好',
-  decoration: '装修偏好',
-  floorPreference: '楼层偏好',
-  timeline: '购置时间',
+  layout: '方案偏好',
+  houseType: '方案偏好',
+  timeline: '计划时间',
   urgency: '紧迫度',
   keyConcerns: '核心关注',
-  schoolRequirement: '学区需求',
   decisionMaker: '决策人',
-  loanCapacity: '贷款能力',
-  negotiationStage: '谈判阶段',
+  contactPreference: '沟通偏好',
+  serviceStage: '服务阶段',
   aiSummary: '画像摘要',
 };
 
@@ -60,10 +58,6 @@ function hasProfileValue(profile, ...keys) {
   });
 }
 
-function recommendationStatusLabel(status) {
-  return ({ candidate: 'Agent 候选', recommended: '已推荐·待反馈', interested: '客户感兴趣', rejected: '客户不合适', viewing: '已约带看', viewed: '已带看', closed: '已关闭' })[status] || status || '待确认';
-}
-
 function maskPhone(phone) {
   const text = String(phone || '').trim();
   if (!text) return '';
@@ -88,7 +82,6 @@ function fallbackConversation(customerOps) {
       tags: Array.isArray(customerOps.tags) ? customerOps.tags : [],
       tasks: [],
       alerts: [],
-      recommendations: [],
       summary: {},
     },
     analysis: { completenessScore: 0 },
@@ -127,13 +120,10 @@ function buildNextActions(customer) {
   const add = (id, title, description, evidence, priority = 'medium', confidence = 'rule') => actions.push({ id, title, description, evidence: (evidence || []).filter(Boolean), priority, confidence, status: 'suggested', action: 'open-conversation' });
   const profile = customer.profile || {};
   const openTaskEvidence = customer.tasks.find(task => ['open', 'in_progress'].includes(task.status))?.evidence;
-  if (!hasProfileValue(profile, 'intent', 'purpose')) add('confirm-purpose', '确认购置用途', '询问客户是自住、投资还是为家人购置,再调整房源权重和沟通重点。', [openTaskEvidence, '客户主档中购置用途尚未确认'], 'high');
-  if (!hasProfileValue(profile, 'timeline')) add('confirm-timeline', '确认购置时间', '确认客户计划何时购置,以判断跟进频率和是否需要安排带看。', [openTaskEvidence, '客户主档中购置时间尚未确认'], 'high');
-  if (!hasProfileValue(profile, 'floorPreference')) add('confirm-floor', '补充楼层偏好', '在继续扩大推荐前确认楼层、电梯和采光偏好。', ['现有画像已有区域、预算和户型,但没有楼层偏好'], 'medium');
-  if (String(profile.budgetType || '') === '待确认') add('confirm-budget-type', '确认预算口径', '确认 200 万是总价上限、理想预算还是包含税费装修。', [`预算已记录为 ${profile.budgetWan || '-'} 万,但预算口径为待确认`], 'medium');
-  const awaitingFeedback = customer.recommendations.filter(item => item.status === 'recommended');
-  if (awaitingFeedback.length) add('collect-property-feedback', '收集已推荐房源反馈', `已有 ${awaitingFeedback.length} 套房源处于“已推荐·待反馈”,先确认喜欢和排斥点再继续匹配。`, awaitingFeedback.slice(0, 3).map(item => `${item.property.community || item.propertyId} · ${item.property.totalPrice || '-'} 万`), 'high');
-  if (!customer.recommendations.length && customer.completenessScore >= 50) add('prepare-first-shortlist', '生成首轮重点方案', '核心需求已基本成形,可从房源库筛选 2—3 套差异化方案供客户比较。', customer.fields.slice(0, 4).map(field => `${field.label}:${field.displayValue}`), 'medium');
+  if (!hasProfileValue(profile, 'intent', 'purpose', 'need', 'needs')) add('confirm-purpose', '确认客户目标', '询问客户本次咨询希望解决的核心问题,再确定后续服务重点。', [openTaskEvidence, '客户主档中的目标尚未确认'], 'high');
+  if (!hasProfileValue(profile, 'timeline')) add('confirm-timeline', '确认计划时间', '确认客户希望何时推进,以判断跟进频率和下一步安排。', [openTaskEvidence, '客户主档中的计划时间尚未确认'], 'high');
+  if (!hasProfileValue(profile, 'decisionMaker')) add('confirm-decision-maker', '确认决策人', '确认还有哪些人参与决策,避免遗漏关键沟通对象。', ['当前主档没有明确决策人'], 'medium');
+  if (String(profile.budgetType || '') === '待确认') add('confirm-budget-type', '确认预算口径', '确认当前预算是上限、目标值还是仍可调整。', [`预算已记录为 ${profile.budgetWan || '-'},但预算口径为待确认`], 'medium');
   for (const alert of customer.alerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).slice(0, 2)) add(`alert:${alert.id}`, alert.recommendedAction || alert.title, alert.detail || '优先处理高等级客户预警。', [alert.evidence], 'high');
   return actions.slice(0, 6);
 }
@@ -146,7 +136,6 @@ function buildTimeline(customer) {
   if (customer.profileUpdatedAt && customer.fields.length) events.push({ id: `profile:${customer.profileUpdatedAt}`, type: 'profile', title: '客户画像已更新', description: customer.fields.slice(0, 5).map(field => `${field.label}:${field.displayValue}`).join(' · '), occurredAt: customer.profileUpdatedAt, source: 'customer-intelligence-db', fact: true });
   for (const task of customer.tasks || []) events.push({ id: `task:${task.id}`, type: 'task', title: `内部任务 · ${task.title}`, description: task.evidence || task.reason || '', occurredAt: task.updatedAt, source: 'customer-task', status: task.status, fact: true });
   for (const alert of customer.alerts || []) events.push({ id: `alert:${alert.id}`, type: 'alert', title: `客户预警 · ${alert.title}`, description: alert.evidence || alert.detail || '', occurredAt: alert.updatedAt, source: 'customer-alert', status: alert.status, fact: true });
-  for (const recommendation of customer.recommendations || []) events.push({ id: `recommendation:${recommendation.id}`, type: 'property', title: `房源${recommendationStatusLabel(recommendation.status)}`, description: `${recommendation.property.community || recommendation.propertyId} · ${recommendation.property.totalPrice || '-'} 万${recommendation.feedbackReason ? ` · ${recommendation.feedbackReason}` : ''}`, occurredAt: recommendation.lastRecommendedAt, source: 'property-recommendation', status: recommendation.status, fact: true });
   return events.filter(item => item.occurredAt).sort((a, b) => String(b.occurredAt).localeCompare(String(a.occurredAt))).slice(0, 80);
 }
 
@@ -171,7 +160,6 @@ function normalizeCustomer(conversation) {
   const latestMessage = messages.at(-1) || null;
   const tasks = intelligence.tasks || [];
   const alerts = intelligence.alerts || [];
-  const recommendations = (intelligence.recommendations || []).map(item => ({ ...item, statusLabel: recommendationStatusLabel(item.status) }));
   const evidenced = fields.filter(item => item.evidence?.text).length;
   const customer = {
     id: conversation.id,
@@ -193,12 +181,9 @@ function normalizeCustomer(conversation) {
     messageCount: messages.length,
     tasks,
     alerts,
-    recommendations,
     openTaskCount: tasks.filter(item => ['open', 'in_progress'].includes(item.status)).length,
     openAlertCount: alerts.filter(item => item.status === 'open').length,
     highAlertCount: alerts.filter(item => item.status === 'open' && ['high', 'critical'].includes(item.severity)).length,
-    recommendationCount: recommendations.length,
-    pendingFeedbackCount: recommendations.filter(item => item.status === 'recommended').length,
     actions: { openConversation: true, editProfile: true },
   };
   customer.nextActions = buildNextActions(customer);
@@ -209,7 +194,6 @@ function normalizeCustomer(conversation) {
 function createCustomerMasterService(dependencies = {}) {
   const conversationReader = dependencies.getConversations || getConversations;
   const profileUpdater = dependencies.updateCustomerProfile || updateCustomerProfile;
-  const recommendationFeedbackUpdater = dependencies.updateCustomerRecommendation || updateCustomerRecommendation;
   const projectionPath = path.resolve(dependencies.projectionPath || path.join(outputsRoot(), 'customers', 'index.json'));
 
   function persistProjection(customers) {
@@ -230,8 +214,6 @@ function createCustomerMasterService(dependencies = {}) {
         lastMessageAt: customer.lastMessageAt,
         openTaskCount: customer.openTaskCount,
         openAlertCount: customer.openAlertCount,
-        recommendationCount: customer.recommendationCount,
-        pendingFeedbackCount: customer.pendingFeedbackCount,
       })),
     };
     writeJsonAtomic(projectionPath, snapshot);
@@ -259,8 +241,6 @@ function createCustomerMasterService(dependencies = {}) {
         priorityCount: customers.filter(item => item.stage.key === 'priority').length,
         openTaskCount: customers.reduce((sum, item) => sum + item.openTaskCount, 0),
         openAlertCount: customers.reduce((sum, item) => sum + item.openAlertCount, 0),
-        recommendationCount: customers.reduce((sum, item) => sum + item.recommendationCount, 0),
-        pendingFeedbackCount: customers.reduce((sum, item) => sum + item.pendingFeedbackCount, 0),
         projectedAt,
         fallback,
       },
@@ -293,19 +273,7 @@ function createCustomerMasterService(dependencies = {}) {
     });
   }
 
-  function updateRecommendation(conversationId, recommendationId, input = {}) {
-    const result = recommendationFeedbackUpdater(String(conversationId || ''), String(recommendationId || ''), input);
-    if (result?.status !== 'ok') return result;
-    const refreshed = hub();
-    return okResult({
-      assistantMessage: result.assistantMessage || '房源反馈已记录。',
-      summary: result.summary,
-      data: { customer: refreshed.data.customers.find(item => item.id === String(conversationId)) || null },
-      files: refreshed.files,
-    });
-  }
-
-  return { hub, update, updateRecommendation };
+  return { hub, update };
 }
 
 const service = createCustomerMasterService();
@@ -314,6 +282,5 @@ module.exports = {
   createCustomerMasterService,
   getCustomerMasterHub: service.hub,
   updateCustomerMaster: service.update,
-  updateCustomerRecommendationFeedback: service.updateRecommendation,
-  __testing: { normalizeCustomer, customerStage, profileValue, buildNextActions, buildTimeline, recommendationStatusLabel },
+  __testing: { normalizeCustomer, customerStage, profileValue, buildNextActions, buildTimeline },
 };

+ 20 - 20
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -69,6 +69,9 @@ const {
   getAgentStatus,
   getAllowlistCandidates,
   updateAllowlist,
+  getIntakePolicy,
+  updateIntakePolicy,
+  retryOnboardingWelcome,
   getConversations,
   getResponseMonitor,
   syncConversations,
@@ -97,8 +100,6 @@ const {
 const {
   listKnowledgeTree,
   readKnowledgeFile,
-  listProperties,
-  getProperty,
   listSkillRegistry,
   getSkillDetail
 } = require('./workspace-library-service');
@@ -131,8 +132,7 @@ const {
 } = require('./unified-task-service');
 const {
   getCustomerMasterHub,
-  updateCustomerMaster,
-  updateCustomerRecommendationFeedback
+  updateCustomerMaster
 } = require('./customer-master-service');
 const { qiweiBusinessDiagnosis } = require('../tools/qiwei-business-diagnosis-run');
 
@@ -596,15 +596,6 @@ async function handleRequest(req, res) {
       json(res, 200, await completeTodoKnowledge(decodeURIComponent(todoCompleteRoute[1])));
       return;
     }
-    if (pathname === '/api/knowledge/properties' && req.method === 'GET') {
-      json(res, 200, listProperties(Object.fromEntries(url.searchParams.entries())));
-      return;
-    }
-    const propertyRoute = pathname.match(/^\/api\/knowledge\/properties\/([^/]+)$/);
-    if (propertyRoute && req.method === 'GET') {
-      json(res, 200, getProperty(decodeURIComponent(propertyRoute[1])));
-      return;
-    }
     const skillRoute = pathname.match(/^\/api\/skills\/(.+)$/);
     if (skillRoute && req.method === 'GET') {
       json(res, 200, getSkillDetail(decodeURIComponent(skillRoute[1])));
@@ -644,6 +635,19 @@ async function handleRequest(req, res) {
       json(res, 200, updateAllowlist(await readBody(req)));
       return;
     }
+    if (pathname === '/api/agent/intake-policy' && req.method === 'GET') {
+      json(res, 200, getIntakePolicy());
+      return;
+    }
+    if (pathname === '/api/agent/intake-policy' && req.method === 'POST') {
+      json(res, 200, updateIntakePolicy(await readBody(req)));
+      return;
+    }
+    const onboardingRetryRoute = pathname.match(/^\/api\/agent\/onboarding\/([^/]+)\/retry-welcome$/);
+    if (onboardingRetryRoute && req.method === 'POST') {
+      json(res, 200, await retryOnboardingWelcome(decodeURIComponent(onboardingRetryRoute[1])));
+      return;
+    }
     if (pathname === '/api/accounts/switch' && req.method === 'POST') {
       json(res, 200, await switchActiveAccount(await readBody(req)));
       return;
@@ -666,7 +670,7 @@ async function handleRequest(req, res) {
     }
     if (pathname === '/api/agent/mode' && req.method === 'POST') {
       const body = await readBody(req);
-      json(res, 200, changeGlobalMode(body.mode));
+      json(res, 200, changeGlobalMode(body.mode, body.confirmation));
       return;
     }
     if (pathname === '/api/agent/listener/start' && req.method === 'POST') {
@@ -705,7 +709,7 @@ async function handleRequest(req, res) {
       json(res, 200, updateCustomerMemory(decodeURIComponent(customerMemoryRoute[1]), await readBody(req)));
       return;
     }
-    const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|approve-reply|generate|manual-send)$/);
+    const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|autopilot|approve-reply|generate|manual-send)$/);
     if (agentConversationRoute && req.method === 'POST') {
       const [, conversationId, action] = agentConversationRoute;
       const body = await readBody(req);
@@ -713,6 +717,7 @@ async function handleRequest(req, res) {
       else if (action === 'resume') json(res, 200, changeConversationMode(conversationId, 'review'));
       else if (action === 'pause') json(res, 200, changeConversationMode(conversationId, 'paused'));
       else if (action === 'auto') json(res, 200, changeConversationMode(conversationId, 'auto'));
+      else if (action === 'autopilot') json(res, 200, changeConversationMode(conversationId, 'autopilot', body.confirmation));
       else if (action === 'generate') json(res, 200, await generateLatestDraft(conversationId));
       else if (action === 'manual-send') json(res, 200, await manualSend(conversationId, body.content));
       else json(res, 200, await approveReply(conversationId, body.content));
@@ -965,11 +970,6 @@ async function handleRequest(req, res) {
       json(res, 200, updateCustomerMaster(decodeURIComponent(customerMasterRoute[1]), await readBody(req)));
       return;
     }
-    const customerRecommendationRoute = pathname.match(/^\/api\/customers\/([^/]+)\/recommendations\/([^/]+)$/);
-    if (customerRecommendationRoute && req.method === 'POST') {
-      json(res, 200, updateCustomerRecommendationFeedback(decodeURIComponent(customerRecommendationRoute[1]), decodeURIComponent(customerRecommendationRoute[2]), await readBody(req)));
-      return;
-    }
     if (pathname === '/api/customer-ops/batch-add-friends' && req.method === 'POST') {
       const body = await readBody(req);
       const id = createJob(() => qiweiBatchAddFriends(body));

+ 233 - 110
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -68,12 +68,12 @@
 .customer-stage.profiling { color: #486c91; background: #edf5fc; }
 .customer-master-layout > main { min-width: 0; }
 .customer-master-detail { display: grid; gap: 16px; padding: 22px; }
-.customer-next-action-section, .customer-recommendation-section, .customer-timeline-section { display: grid; gap: 11px; padding: 15px; border: 1px solid #e0e7e3; border-radius: 13px; }
-.customer-next-action-section > header, .customer-recommendation-section > header, .customer-timeline-section > header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
-.customer-next-action-section > header > div, .customer-recommendation-section > header > div, .customer-timeline-section > header > div { display: grid; gap: 2px; }
-.customer-next-action-section > header strong, .customer-recommendation-section > header strong, .customer-timeline-section > header strong { color: #2b3c34; font-size: 13px; }
-.customer-next-action-section > header span, .customer-recommendation-section > header span, .customer-timeline-section > header span { color: #87938d; font-size: 10px; }
-.customer-next-action-section > header em, .customer-recommendation-section > header em, .customer-timeline-section > header em { color: #52705f; font-size: 10px; font-style: normal; }
+.customer-next-action-section, .customer-timeline-section { display: grid; gap: 11px; padding: 15px; border: 1px solid #e0e7e3; border-radius: 13px; }
+.customer-next-action-section > header, .customer-timeline-section > header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
+.customer-next-action-section > header > div, .customer-timeline-section > header > div { display: grid; gap: 2px; }
+.customer-next-action-section > header strong, .customer-timeline-section > header strong { color: #2b3c34; font-size: 13px; }
+.customer-next-action-section > header span, .customer-timeline-section > header span { color: #87938d; font-size: 10px; }
+.customer-next-action-section > header em, .customer-timeline-section > header em { color: #52705f; font-size: 10px; font-style: normal; }
 .customer-next-action-section > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
 .customer-next-action-card { display: grid; grid-template-columns: minmax(0, 1fr) minmax(160px, .75fr); gap: 9px 13px; align-items: start; padding: 12px; border-radius: 10px; background: #f7f9f8; border-left: 3px solid #8eaa9b; }
 .customer-next-action-card.high { border-left-color: #e89545; background: #fffbf5; }
@@ -83,22 +83,6 @@
 .customer-next-action-card p { margin: 0; color: #647169; font-size: 10px; line-height: 1.5; }
 .customer-next-action-card ul { margin: 0; padding-left: 16px; color: #77827c; font-size: 9px; line-height: 1.55; }
 .customer-next-action-card .btn { grid-column: 1 / -1; justify-self: start; }
-.customer-recommendation-section > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
-.customer-recommendation-card { display: grid; gap: 8px; padding: 12px; border: 1px solid #e2e7e4; border-radius: 10px; background: #fcfdfd; }
-.customer-recommendation-card > header, .customer-recommendation-card > div, .customer-recommendation-card > footer { display: flex; justify-content: space-between; flex-wrap: wrap; align-items: center; gap: 7px; }
-.customer-recommendation-card > header > div { display: grid; gap: 2px; }
-.customer-recommendation-card > header span { color: #8a958f; font-size: 8px; }
-.customer-recommendation-card > header strong { color: #283b32; font-size: 12px; }
-.customer-recommendation-card > header em { border-radius: 999px; padding: 4px 8px; color: #5d6d65; background: #eef2f0; font-size: 9px; font-style: normal; }
-.customer-recommendation-card > header em.interested { color: #217147; background: #e7f7ed; }
-.customer-recommendation-card > header em.rejected { color: #a15246; background: #fff0ed; }
-.customer-recommendation-card > header em.viewing, .customer-recommendation-card > header em.viewed { color: #3a6594; background: #edf5ff; }
-.customer-recommendation-card > p { margin: 0; color: #727e78; font-size: 10px; }
-.customer-recommendation-card > div strong { color: #e77e22; font-size: 18px; }
-.customer-recommendation-card > div strong small { font-size: 9px; }
-.customer-recommendation-card > div span { color: #8a948f; font-size: 9px; }
-.customer-recommendation-card blockquote { margin: 0; padding: 7px 9px; color: #6a756f; background: #f5f7f6; border-left: 2px solid #98b0a3; font-size: 9px; }
-.customer-recommendation-card > footer { justify-content: flex-start; }
 .customer-timeline-section > div { position: relative; display: grid; gap: 0; }
 .customer-timeline-item { display: grid; grid-template-columns: 10px 78px minmax(0, 1fr); gap: 9px; align-items: start; min-height: 60px; padding: 5px 0 12px; }
 .customer-timeline-item > i { position: relative; width: 9px; height: 9px; margin-top: 4px; border-radius: 50%; background: #77a28c; box-shadow: 0 0 0 3px #edf5f1; }
@@ -106,7 +90,6 @@
 .customer-timeline-item.inbound-message > i { background: #4e8ee1; }
 .customer-timeline-item.outbound-message > i { background: #65a67e; }
 .customer-timeline-item.alert > i { background: #e47a61; }
-.customer-timeline-item.property > i { background: #e89a45; }
 .customer-timeline-item > time { color: #8b958f; font-size: 9px; line-height: 1.6; }
 .customer-timeline-item > div { display: grid; gap: 3px; }
 .customer-timeline-item strong { color: #33433b; font-size: 11px; }
@@ -156,7 +139,7 @@
 @media (max-width: 1100px) {
   .customer-master-layout { grid-template-columns: 240px minmax(0, 1fr); }
   .customer-profile-fields > div, .customer-master-edit-form > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-  .customer-next-action-section > div, .customer-recommendation-section > div { grid-template-columns: 1fr; }
+  .customer-next-action-section > div { grid-template-columns: 1fr; }
 }
 
 @media (max-width: 760px) {
@@ -164,7 +147,7 @@
   .customer-master-layout { grid-template-columns: 1fr; }
   .customer-master-layout > aside { border-right: 0; border-bottom: 1px solid #e6ebe8; }
   .customer-master-intelligence-grid, .customer-profile-fields > div, .customer-master-edit-form > div { grid-template-columns: 1fr; }
-  .customer-next-action-section > div, .customer-recommendation-section > div { grid-template-columns: 1fr; }
+  .customer-next-action-section > div { grid-template-columns: 1fr; }
   .customer-master-policy { grid-template-columns: 1fr; }
   .customer-master-policy small { grid-column: 1; }
   .customer-master-detail-head, .customer-master-hero { align-items: flex-start; flex-direction: column; }
@@ -548,14 +531,129 @@ html, body {
 
 /* ==================== Intelligent Conversations ==================== */
 .agent-page {
-  --agent-ink: #182126;
-  --agent-soft: #f5f7f6;
-  --agent-line: #e5e9e7;
-  --agent-green: #137a5b;
-  --agent-green-soft: #eaf6f1;
-  --agent-orange: #fa8c16;
+  --agent-brand: #5b63d3;
+  --agent-brand-strong: #424aa8;
+  --agent-brand-soft: #eef0ff;
+  --agent-page-bg: #f4f6f8;
+  --agent-panel: #ffffff;
+  --agent-panel-muted: #f8f9fb;
+  --agent-ink: #202632;
+  --agent-text-secondary: #5d6677;
+  --agent-text-muted: #8991a0;
+  --agent-line: #dde2ea;
+  --agent-focus: #6b73e6;
+  --agent-success: #258066;
+  --agent-success-soft: #eaf7f2;
+  --agent-warning: #b7792b;
+  --agent-warning-soft: #fff7e8;
+  --agent-error: #bf4c45;
+  --agent-error-soft: #fff2f1;
+  --agent-soft: var(--agent-panel-muted);
+  --agent-green: var(--agent-brand);
+  --agent-green-soft: var(--agent-brand-soft);
+  --agent-orange: var(--agent-warning);
+  height: calc(100dvh - 144px);
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  overflow: hidden;
 }
 
+.agent-page[data-theme="xiaoniu"] {
+  --agent-brand: #2369c8;
+  --agent-brand-strong: #174f9b;
+  --agent-brand-soft: #eaf2fc;
+  --agent-focus: #367ed9;
+}
+
+.agent-commandbar {
+  position: sticky;
+  top: 0;
+  z-index: 20;
+  flex: 0 0 auto;
+  min-height: 64px;
+  display: grid;
+  grid-template-columns: minmax(170px, 1fr) auto auto auto;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 12px;
+  border: 1px solid var(--agent-line);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--agent-panel) 96%, transparent);
+  box-shadow: 0 6px 18px rgba(31, 38, 51, .07);
+  backdrop-filter: blur(12px);
+}
+.agent-command-account { min-width: 0; display: flex; align-items: center; gap: 10px; }
+.agent-command-account > div { min-width: 0; }
+.agent-command-account strong,
+.agent-command-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-command-account strong { color: var(--agent-ink); font-size: 12px; }
+.agent-command-account small { margin-top: 3px; color: var(--agent-text-muted); font-size: 9px; }
+.agent-command-kpis { display: flex; align-items: stretch; gap: 5px; }
+.agent-command-kpis span { min-width: 55px; padding: 5px 7px; border-radius: 6px; color: var(--agent-text-secondary); background: var(--agent-panel-muted); font-size: 8px; text-align: center; white-space: nowrap; }
+.agent-command-kpis b { display: block; color: var(--agent-ink); font-size: 13px; }
+.agent-command-kpis span.attention { color: var(--agent-warning); background: var(--agent-warning-soft); }
+.agent-command-kpis span.danger { color: var(--agent-error); background: var(--agent-error-soft); }
+.agent-command-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px; }
+.agent-command-actions .btn { min-height: 32px; padding: 6px 9px; border-radius: 6px; font-size: 9px; white-space: nowrap; }
+.agent-command-actions .btn:not(.btn-secondary) { border-color: var(--agent-brand); background: var(--agent-brand); }
+.agent-command-actions .has-alert { color: var(--agent-error); border-color: color-mix(in srgb, var(--agent-error) 34%, var(--agent-line)); background: var(--agent-error-soft); }
+.agent-commandbar .agent-mode-switch { padding: 3px; border-radius: 7px; background: var(--agent-panel-muted); }
+.agent-commandbar .agent-mode-btn { min-height: 30px; padding: 5px 8px; border-radius: 5px; font-size: 9px; white-space: nowrap; }
+.agent-commandbar .agent-mode-btn.active { color: var(--agent-brand-strong); background: var(--agent-panel); box-shadow: 0 1px 5px rgba(31, 38, 51, .09); }
+.agent-commandbar .agent-mode-btn.danger.active { color: var(--agent-error); background: var(--agent-error-soft); }
+
+.agent-drawer-backdrop { position: fixed; inset: 68px 0 0 220px; z-index: 70; border: 0; background: rgba(24, 29, 41, .26); opacity: 0; pointer-events: none; transition: none; }
+.agent-drawer-backdrop.open { opacity: 1; pointer-events: auto; transition: none; }
+.agent-drawer { position: fixed; top: 68px; right: 0; bottom: 0; z-index: 80; width: min(440px, calc(100vw - 220px)); display: grid; grid-template-rows: auto minmax(0, 1fr); border-left: 1px solid var(--agent-line); background: var(--agent-panel); box-shadow: -18px 0 44px rgba(24, 30, 42, .16); transform: translateX(102%); visibility: hidden; transition: none; }
+.agent-drawer.open { transform: translateX(0); visibility: visible; transition: none; }
+.agent-drawer > header { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--agent-line); }
+.agent-drawer > header strong,
+.agent-drawer > header span { display: block; }
+.agent-drawer > header strong { color: var(--agent-ink); font-size: 15px; }
+.agent-drawer > header span { margin-top: 3px; color: var(--agent-text-muted); font-size: 9px; }
+.agent-drawer > header button { min-height: 32px; padding: 0 10px; border: 1px solid var(--agent-line); border-radius: 6px; color: var(--agent-text-secondary); background: var(--agent-panel); cursor: pointer; }
+.agent-drawer-body { min-height: 0; padding: 16px; overflow-y: auto; background: var(--agent-page-bg); }
+.agent-connection-summary { margin-bottom: 12px; padding: 14px; border: 1px solid var(--agent-line); border-radius: 8px; background: var(--agent-panel); }
+.agent-connection-summary > div { display: flex; align-items: center; gap: 9px; }
+.agent-connection-summary > div strong { color: var(--agent-ink); font-size: 12px; }
+.agent-connection-summary dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 12px 0 0; }
+.agent-connection-summary dl div { min-width: 0; padding: 8px; border-radius: 6px; background: var(--agent-panel-muted); }
+.agent-connection-summary dt { color: var(--agent-text-muted); font-size: 8px; }
+.agent-connection-summary dd { margin: 3px 0 0; overflow: hidden; color: var(--agent-text-secondary); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.agent-intake-form { display: grid; gap: 11px; padding: 14px; border: 1px solid var(--agent-line); border-radius: 8px; background: var(--agent-panel); }
+.agent-drawer-section-heading strong,
+.agent-drawer-section-heading span { display: block; }
+.agent-drawer-section-heading strong { color: var(--agent-ink); font-size: 12px; }
+.agent-drawer-section-heading span { margin-top: 3px; color: var(--agent-text-muted); font-size: 9px; }
+.agent-field { display: grid; gap: 5px; color: var(--agent-text-secondary); font-size: 9px; }
+.agent-field select,
+.agent-field textarea { width: 100%; border: 1px solid var(--agent-line); border-radius: 6px; outline: 0; padding: 8px 9px; color: var(--agent-ink); background: var(--agent-panel); font: inherit; font-size: 10px; line-height: 1.45; }
+.agent-field textarea { resize: vertical; min-height: 76px; }
+.agent-field select:focus,
+.agent-field textarea:focus { border-color: var(--agent-focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--agent-focus) 16%, transparent); }
+.agent-toggle-field { display: flex; align-items: flex-start; gap: 8px; color: var(--agent-text-secondary); }
+.agent-toggle-field input { margin-top: 2px; accent-color: var(--agent-brand); }
+.agent-toggle-field strong,
+.agent-toggle-field small { display: block; }
+.agent-toggle-field strong { font-size: 10px; }
+.agent-toggle-field small { margin-top: 3px; color: var(--agent-text-muted); font-size: 8px; }
+.agent-policy-warning,
+.agent-drawer-error { padding: 9px 10px; border-radius: 6px; color: var(--agent-warning); background: var(--agent-warning-soft); font-size: 8px; line-height: 1.5; }
+.agent-policy-warning.danger,
+.agent-drawer-error { color: var(--agent-error); background: var(--agent-error-soft); }
+.agent-intake-form > .btn { min-height: 36px; border-color: var(--agent-brand); background: var(--agent-brand); }
+.agent-drawer-boundary { margin-top: 12px; padding: 13px; border: 1px solid var(--agent-line); border-radius: 8px; background: var(--agent-panel); }
+.agent-drawer-boundary strong { color: var(--agent-ink); font-size: 10px; }
+.agent-drawer-boundary p { margin: 5px 0 0; color: var(--agent-text-secondary); font-size: 9px; line-height: 1.55; }
+.agent-drawer .response-monitor { margin: 0; border-radius: 8px; box-shadow: none; }
+.agent-drawer .response-monitor-body { grid-template-columns: 1fr; padding: 12px; }
+.agent-drawer .response-monitor-header { align-items: stretch; flex-direction: column; padding: 12px; }
+.agent-drawer .response-monitor-kpis { width: 100%; }
+.agent-drawer .response-monitor-kpis span { flex: 1; }
+.agent-drawer .response-reminder { grid-template-columns: 1fr; }
+
 .agent-loading {
   min-height: 420px;
   display: grid;
@@ -775,7 +873,8 @@ html, body {
   display: flex;
   align-items: center;
   gap: 12px;
-  margin: 0 0 16px;
+  flex: 0 0 auto;
+  margin: 0;
   padding: 12px 16px;
   border: 1px solid #f2c7b4;
   border-radius: 12px;
@@ -789,13 +888,14 @@ html, body {
 .agent-runtime-alert.notice { border-color: #b9dfcf; color: #256b54; background: #eff9f4; }
 
 .agent-workspace {
-  min-height: 650px;
+  flex: 1 1 auto;
+  min-height: 0;
   display: grid;
   grid-template-columns: 245px minmax(420px, 1fr) 330px;
   border: 1px solid var(--agent-line);
-  border-radius: 18px;
+  border-radius: 8px;
   overflow: hidden;
-  background: #fff;
+  background: var(--agent-panel);
   box-shadow: 0 12px 38px rgba(24,54,45,.08);
 }
 
@@ -805,8 +905,8 @@ html, body {
   background: #fbfcfb;
 }
 
-.agent-conversation-list { border-right: 1px solid var(--agent-line); }
-.agent-insight-panel { border-left: 1px solid var(--agent-line); overflow-y: auto; max-height: 760px; }
+.agent-conversation-list { min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; border-right: 1px solid var(--agent-line); }
+.agent-insight-panel { min-height: 0; overflow-y: auto; border-left: 1px solid var(--agent-line); overscroll-behavior: contain; }
 
 .agent-panel-heading {
   min-height: 72px;
@@ -853,7 +953,7 @@ html, body {
 .agent-list-tabs b { min-width: 18px; padding: 2px 4px; border-radius: 8px; color: #68746f; background: #e5ebe8; font-size: 8px; }
 .agent-list-tabs button.active b { color: #fff; background: var(--agent-green); }
 
-.agent-list-scroll { padding: 8px; }
+.agent-list-scroll { min-height: 0; padding: 8px; overflow-y: auto; overscroll-behavior: contain; }
 
 .agent-conversation-item {
   width: 100%;
@@ -901,14 +1001,21 @@ html, body {
 .agent-conversation-copy small { margin-top: 4px; color: #76817d; font-size: 10px; }
 .agent-conversation-copy em { width: max-content; max-width: 100%; margin-top: 6px; padding: 2px 6px; border-radius: 4px; color: var(--agent-green); background: rgba(19,122,91,.08); font-size: 9px; font-style: normal; }
 .agent-conversation-item time { color: #9aa29f; font-size: 9px; white-space: nowrap; }
+.agent-conversation-meta { display: grid; justify-items: end; gap: 5px; }
+.agent-conversation-meta i { padding: 2px 5px; border-radius: 4px; color: var(--agent-warning); background: var(--agent-warning-soft); font-size: 7px; font-style: normal; white-space: nowrap; }
+.agent-conversation-meta i.overdue { color: var(--agent-error); background: var(--agent-error-soft); }
+.agent-conversation-meta i.pending { color: var(--agent-brand-strong); background: var(--agent-brand-soft); }
 
 .agent-chat-panel {
   min-width: 0;
+  min-height: 0;
   display: grid;
-  grid-template-rows: auto auto auto minmax(320px, 1fr) auto;
-  background: #fff;
+  grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+  overflow: hidden;
+  background: var(--agent-panel);
 }
-.agent-chat-panel.group-mode { grid-template-rows: auto auto minmax(320px, 1fr) auto; }
+.agent-chat-panel > * { min-width: 0; }
+.agent-chat-panel.group-mode { grid-template-rows: auto auto minmax(0, 1fr) auto; }
 
 .agent-chat-header {
   min-height: 72px;
@@ -1017,7 +1124,8 @@ html, body {
 .agent-session-spinner { width: 12px; height: 12px; flex: 0 0 auto; border: 2px solid #c9d8d1; border-top-color: var(--agent-green); border-radius: 50%; animation: spin .75s linear infinite; }
 
 .agent-message-stream {
-  max-height: 440px;
+  min-height: 0;
+  max-height: none;
   overflow-y: auto;
   padding: 20px;
   background:
@@ -1078,11 +1186,14 @@ html, body {
 }
 
 .agent-composer.manual { background: #fffaf4; }
+.agent-composer.autopilot { border-color: color-mix(in srgb, var(--agent-error) 30%, var(--agent-line)); background: var(--agent-error-soft); }
+.agent-composer.autopilot .agent-composer-label strong { color: var(--agent-error); }
 .agent-composer.group { background: #fffaf4; }
 .agent-composer.group .agent-composer-label strong { color: #9a571f; }
 .agent-composer-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
 .agent-composer-label strong { color: var(--agent-green); font-size: 11px; }
 .agent-composer.manual .agent-composer-label strong { color: #b06c2a; }
+.agent-conversation-modes .agent-mode-danger.active { color: var(--agent-error); border-color: color-mix(in srgb, var(--agent-error) 35%, var(--agent-line)); background: var(--agent-error-soft); }
 .agent-composer-label span { color: var(--text-muted); font-size: 9px; }
 .agent-composer textarea { width: 100%; min-height: 76px; resize: vertical; padding: 10px 12px; border: 1px solid #dce5e1; border-radius: 10px; outline: 0; color: #34413c; background: #fbfdfc; font: inherit; font-size: 11px; line-height: 1.55; }
 .agent-composer textarea:focus { border-color: var(--agent-green); box-shadow: 0 0 0 3px rgba(19,122,91,.08); }
@@ -1154,6 +1265,33 @@ html, body {
 .agent-audit-list > div { display: flex; justify-content: space-between; gap: 8px; padding-bottom: 5px; border-bottom: 1px dashed #e6ebe9; color: #66736e; font-size: 8px; }
 .agent-audit-list time { flex: 0 0 auto; color: #9aa39f; }
 
+.agent-activity-disclosure { border: 1px solid var(--agent-line); border-radius: 7px; background: var(--agent-panel); }
+.agent-activity-disclosure > summary { padding: 9px 10px; color: var(--agent-brand-strong); cursor: pointer; font-size: 9px; font-weight: 700; }
+.agent-activity-timeline { display: grid; gap: 8px; padding: 0 10px 10px; }
+.agent-activity-phase { min-width: 0; display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; }
+.agent-activity-phase > header { display: flex; align-items: flex-start; gap: 6px; padding-top: 3px; }
+.agent-activity-phase > header i { width: 18px; height: 18px; display: grid; flex: 0 0 auto; place-items: center; border-radius: 5px; color: var(--agent-brand-strong); background: var(--agent-brand-soft); font-size: 8px; font-style: normal; font-weight: 800; }
+.agent-activity-phase > header strong,
+.agent-activity-phase > header small { display: block; }
+.agent-activity-phase > header strong { color: var(--agent-text-secondary); font-size: 8px; line-height: 1.35; }
+.agent-activity-phase > header small { margin-top: 2px; color: var(--agent-text-muted); font-size: 7px; }
+.agent-activity-items { min-width: 0; display: grid; gap: 5px; }
+.agent-activity-item { min-width: 0; padding: 7px 8px; border: 1px solid var(--agent-line); border-radius: 6px; background: var(--agent-panel-muted); }
+.agent-activity-item > div { display: flex; align-items: center; justify-content: space-between; gap: 7px; }
+.agent-activity-item strong { overflow: hidden; color: var(--agent-ink); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
+.agent-activity-item em { flex: 0 0 auto; color: var(--agent-success); font-size: 7px; font-style: normal; }
+.agent-activity-item small { display: block; margin-top: 4px; overflow: hidden; color: var(--agent-text-muted); font-size: 7px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
+.agent-activity-item.failed { border-color: color-mix(in srgb, var(--agent-error) 28%, var(--agent-line)); background: var(--agent-error-soft); }
+.agent-activity-item.failed em { color: var(--agent-error); }
+.agent-activity-item.running em { color: var(--agent-warning); }
+.agent-activity-empty { padding: 7px 8px; border: 1px dashed var(--agent-line); border-radius: 6px; color: var(--agent-text-muted); font-size: 7px; line-height: 1.4; }
+
+.agent-page .agent-count,
+.agent-page .agent-channel-badge.private { color: var(--agent-brand-strong); background: var(--agent-brand-soft); }
+.agent-page .agent-conversation-item.active { background: var(--agent-brand-soft); box-shadow: inset 3px 0 0 var(--agent-brand); }
+.agent-page .agent-conversation-copy em { color: var(--agent-brand-strong); background: var(--agent-brand-soft); }
+.agent-page .agent-composer textarea:focus { border-color: var(--agent-focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--agent-focus) 14%, transparent); }
+
 .agent-insight-section { padding: 17px 18px; border-bottom: 1px solid var(--agent-line); }
 .agent-section-title { display: flex; align-items: center; gap: 10px; margin-bottom: 13px; }
 .agent-section-title > span { color: #9bb7ae; font: 700 9px/1 monospace; }
@@ -1231,6 +1369,7 @@ html, body {
 .content > .page > *:nth-child(4) { animation-delay: 105ms; }
 .content > .page > *:nth-child(5) { animation-delay: 140ms; }
 .content > .page > *:nth-child(n+6) { animation-delay: 165ms; }
+.content > .agent-page > * { animation: none; }
 
 @keyframes dashboardPageIn {
   from { opacity: 0; transform: translate3d(8px, 0, 0); }
@@ -1299,7 +1438,6 @@ html, body {
 
 /* 常用列表与操作卡仅在悬停时轻移,避免数据密集页面持续运动。 */
 .skill-capability-card,
-.property-card,
 .customer-master-list-item,
 .meeting-list-item,
 .office-list-item,
@@ -1310,7 +1448,6 @@ html, body {
 }
 
 .skill-capability-card:hover,
-.property-card:hover,
 .customer-master-list-item:hover,
 .meeting-list-item:hover,
 .office-list-item:hover,
@@ -1357,41 +1494,81 @@ html, body {
 
 @media (max-width: 1320px) {
   .agent-workspace { grid-template-columns: 220px minmax(390px, 1fr) 300px; }
+  .agent-commandbar { grid-template-columns: minmax(150px, 1fr) auto auto; }
+  .agent-command-actions { grid-column: 1 / -1; justify-content: flex-start; }
+}
+
+@media (min-width: 1051px) and (max-width: 1320px) {
+  .agent-commandbar { grid-template-columns: minmax(150px, 1fr) auto auto auto; }
+  .agent-command-actions { grid-column: auto; justify-content: flex-end; }
 }
 
 @media (max-width: 1050px) {
-  .agent-modebar { grid-template-columns: 1fr auto; }
+  .agent-commandbar { grid-template-columns: minmax(0, 1fr) auto; }
+  .agent-commandbar .agent-mode-switch,
+  .agent-command-actions { grid-column: 1 / -1; }
+  .agent-command-actions { justify-content: flex-start; }
   .agent-guard { display: none; }
   .response-monitor-body { grid-template-columns: 1fr; }
-  .agent-workspace { grid-template-columns: 210px minmax(0, 1fr); }
-  .agent-insight-panel { grid-column: 1 / -1; max-height: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--agent-line); border-left: 0; }
+  .agent-workspace { grid-template-columns: 210px minmax(0, 1fr); overflow-y: auto; }
+  .agent-insight-panel { grid-column: 1 / -1; max-height: 440px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--agent-line); border-left: 0; }
+}
+
+@media (min-width: 761px) and (max-height: 760px) {
+  .agent-chat-header { min-height: 60px; padding: 8px 14px; }
+  .agent-conversation-modes { flex-wrap: nowrap; overflow-x: auto; }
+  .agent-conversation-modes .btn { flex: 0 0 auto; }
+  .agent-flow { padding: 7px 14px 8px; }
+  .agent-flow-heading { margin-bottom: 4px; }
+  .agent-flow-heading span { display: none; }
+  .agent-session-strip { padding: 6px 14px; }
+  .agent-session-strip small { display: none; }
+  .agent-message-stream { padding: 14px; }
+  .agent-composer { padding: 10px 14px 11px; }
+  .agent-composer textarea { min-height: 54px; }
+  .agent-composer-actions { align-items: stretch; flex-direction: column; gap: 6px; }
+  .agent-composer-actions > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .agent-review-actions { flex-wrap: nowrap; }
+  .agent-review-actions .btn { flex: 0 0 auto; }
+  .voice-composer-toolbar { min-height: 34px; margin-top: 4px; padding-top: 4px; }
 }
 
 @media (max-width: 760px) {
-  .agent-hero { align-items: stretch; flex-direction: column; padding: 24px; }
-  .agent-safety-card { min-width: 0; }
-  .agent-modebar { grid-template-columns: 1fr; }
-  .agent-mode-switch { justify-self: stretch; }
-  .agent-mode-btn { flex: 1; }
+  .agent-page { height: calc(100dvh - 128px); gap: 8px; }
+  .agent-commandbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 8px; }
+  .agent-commandbar .agent-mode-switch { width: 100%; overflow-x: auto; }
+  .agent-commandbar .agent-mode-btn { flex: 1 0 auto; min-width: 62px; }
+  .agent-command-kpis span { min-width: 48px; }
+  .agent-command-actions { width: 100%; overflow-x: auto; flex-wrap: nowrap; }
+  .agent-command-actions .btn { flex: 0 0 auto; min-height: 36px; }
+  .agent-drawer-backdrop { inset: 68px 0 0; }
+  .agent-drawer { width: 100vw; }
+  .agent-drawer > header { min-height: 60px; padding: 12px 14px; }
+  .agent-drawer-body { padding: 12px; }
   .response-monitor-header { align-items: stretch; flex-direction: column; }
   .response-monitor-kpis { width: 100%; }
   .response-monitor-kpis span { flex: 1; }
   .response-monitor-body { padding: 12px; }
   .response-reminder { grid-template-columns: 1fr; }
   .response-reminder footer { justify-content: space-between; }
-  .agent-workspace { display: block; }
+  .agent-workspace { display: grid; grid-template-columns: 1fr; grid-template-rows: minmax(170px, 28dvh) minmax(520px, auto) auto; overflow-y: auto; }
   .agent-conversation-list { border-right: 0; border-bottom: 1px solid var(--agent-line); }
   .agent-flow { padding: 12px; overflow-x: auto; }
   .agent-flow-heading { align-items: flex-start; flex-direction: column; gap: 3px; }
   .agent-flow ol,
   .agent-flow.group ol { min-width: 560px; }
-  .agent-chat-panel { min-height: 620px; }
-  .agent-insight-panel { display: block; }
+  .agent-chat-panel { min-height: 720px; grid-template-rows: auto auto auto minmax(160px, 1fr) auto; }
+  .agent-chat-header { align-items: flex-start; flex-direction: column; }
+  .agent-conversation-modes { width: 100%; justify-content: flex-start; overflow-x: auto; flex-wrap: nowrap; }
+  .agent-conversation-modes .btn { flex: 0 0 auto; min-height: 36px; }
+  .agent-insight-panel { display: block; max-height: none; overflow: visible; }
+  .agent-activity-phase { grid-template-columns: 64px minmax(0, 1fr); }
 }
 
 .page {
   animation: fadeInUp 0.4s ease both;
 }
+.page.agent-page { animation: none; transform: none; }
 
 @keyframes fadeInUp {
   from { opacity: 0; transform: translateY(16px); }
@@ -2786,54 +2963,6 @@ body.business-modal-open { overflow: hidden; }
 .todo-detail dd { margin: 0; color: #506159; }
 .todo-detail > p { margin-top: 14px; color: #96a29d; font-size: 8px; }
 
-.property-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 14px 18px 0; }
-.property-stats > div { padding: 12px 13px; border: 1px solid #e1eae6; border-radius: 10px; background: #f8fbf9; }
-.property-stats strong, .property-stats span { display: block; }
-.property-stats strong { color: #17674f; font-size: 18px; }
-.property-stats strong small { margin-left: 2px; font-size: 9px; }
-.property-stats span { margin-top: 3px; color: #87958f; font-size: 8px; }
-.property-filterbar { display: grid; grid-template-columns: minmax(150px, 1.4fr) repeat(3, minmax(100px, .8fr)) minmax(100px, .7fr) auto; gap: 7px; padding: 12px 18px; }
-.property-filterbar input, .property-filterbar select { min-width: 0; padding: 9px 10px; border: 1px solid #dce6e1; border-radius: 8px; outline: 0; color: #43514c; background: #fff; font: inherit; font-size: 9px; }
-.property-filterbar input:focus, .property-filterbar select:focus { border-color: #399071; box-shadow: 0 0 0 3px rgba(57,144,113,.08); }
-.property-browser-layout { display: grid; grid-template-columns: minmax(0, 1fr) 310px; min-height: 520px; border-top: 1px solid var(--border); }
-.property-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-content: start; gap: 9px; padding: 15px; }
-.property-card { min-height: 155px; padding: 13px; border: 1px solid #e0e8e4; border-radius: 12px; text-align: left; color: #34443e; background: #fff; cursor: pointer; transition: .16s ease; }
-.property-card:hover { transform: translateY(-2px); border-color: #76ae98; box-shadow: 0 9px 18px rgba(42,91,72,.09); }
-.property-card.active { border-color: #238463; background: #f0faf6; box-shadow: inset 0 0 0 1px #238463; }
-.property-card > div { display: flex; justify-content: space-between; gap: 8px; }
-.property-card > div span { color: #8a9892; font: 700 8px/1 Consolas, monospace; }
-.property-card > div em { padding: 3px 6px; border-radius: 5px; color: #277257; background: #e6f4ee; font-size: 7px; font-style: normal; }
-.property-card h4 { margin: 10px 0 4px; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
-.property-card p { margin: 0; overflow: hidden; color: #7e8c86; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
-.property-card > strong { display: block; margin: 12px 0 9px; color: #e47822; font-size: 18px; }
-.property-card > strong small { margin-left: 2px; font-size: 8px; }
-.property-card footer { display: flex; flex-wrap: wrap; gap: 3px; }
-.property-card footer i { padding: 3px 5px; border-radius: 4px; color: #718079; background: #f0f3f2; font-size: 7px; font-style: normal; }
-.property-detail-panel { border-left: 1px solid var(--border); background: #fbfdfc; }
-.property-detail-empty { min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 30px; text-align: center; color: var(--text-muted); }
-.property-detail-empty strong { color: #55665f; font-size: 13px; }
-.property-detail-empty span { font-size: 9px; line-height: 1.55; }
-.property-detail > header { padding: 20px; color: #fff; background: linear-gradient(145deg, #17674f, #2a8d6c); }
-.property-detail > header > span { color: rgba(255,255,255,.62); font: 700 9px/1 Consolas, monospace; }
-.property-detail > header h3 { margin: 7px 0 3px; font-size: 18px; }
-.property-detail > header > strong { display: block; font-size: 27px; }
-.property-detail > header > strong small { margin-left: 3px; font-size: 10px; }
-.property-detail > header p { margin: 5px 0 0; color: rgba(255,255,255,.72); font-size: 9px; }
-.property-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: #e8eeeb; }
-.property-detail-grid > div { padding: 10px 12px; background: #fff; }
-.property-detail-grid small, .property-detail-grid strong { display: block; }
-.property-detail-grid small { color: #95a19c; font-size: 7px; }
-.property-detail-grid strong { margin-top: 3px; overflow: hidden; color: #485750; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
-.property-detail section { padding: 13px 16px; border-top: 1px solid #e7ecea; }
-.property-detail section h4 { margin: 0 0 8px; color: #33463f; font-size: 10px; }
-.property-detail section p { margin: 0; color: #74817c; font-size: 9px; line-height: 1.6; }
-.property-tags { display: flex; flex-wrap: wrap; gap: 5px; }
-.property-tags span { padding: 4px 7px; border-radius: 6px; color: #28765a; background: #e6f4ee; font-size: 8px; }
-.property-score-list { display: grid; gap: 7px; }
-.property-score-list > div { display: grid; grid-template-columns: 52px 1fr 18px; align-items: center; gap: 6px; font-size: 8px; }
-.property-score-list i { height: 4px; overflow: hidden; border-radius: 4px; background: #e4ebe8; }
-.property-score-list b { display: block; height: 100%; border-radius: inherit; background: #4baa86; }
-.property-score-list em { color: #7d8b85; font-style: normal; }
 .library-empty, .library-error { padding: 35px; text-align: center; color: var(--text-muted); font-size: 11px; }
 .library-empty.large { min-height: 520px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; }
 .library-empty strong, .library-empty span { display: block; }
@@ -2842,8 +2971,6 @@ body.business-modal-open { overflow: hidden; }
 
 @media (max-width: 1280px) {
   .skill-hub-layout { grid-template-columns: 190px minmax(360px, 1fr) 290px; }
-  .property-card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-  .property-browser-layout { grid-template-columns: minmax(0, 1fr) 285px; }
 }
 
 @media (max-width: 1050px) {
@@ -2857,8 +2984,6 @@ body.business-modal-open { overflow: hidden; }
   .meeting-meta-grid { grid-template-columns: repeat(2, 1fr); }
   .office-browser { grid-template-columns: 205px minmax(0, 1fr); }
   .todo-create-form { grid-template-columns: 1fr 1fr; }
-  .property-browser-layout { display: block; }
-  .property-detail-panel { border-top: 1px solid var(--border); border-left: 0; }
 }
 
 @media (max-width: 768px) {
@@ -2877,7 +3002,7 @@ body.business-modal-open { overflow: hidden; }
   .skill-toolbar input { width: 100%; }
   .skill-hub-layout, .knowledge-layout { display: block; }
   .skill-package-list { border-right: 0; border-bottom: 1px solid var(--border); }
-  .skill-card-grid, .property-card-grid { grid-template-columns: 1fr; }
+  .skill-card-grid { grid-template-columns: 1fr; }
   .knowledge-tree-panel { border-right: 0; border-bottom: 1px solid var(--border); }
   .knowledge-tree { max-height: 330px; }
   .meeting-stat-row { grid-template-columns: repeat(2, 1fr); }
@@ -2891,8 +3016,6 @@ body.business-modal-open { overflow: hidden; }
   .office-action-grid, .office-browser, .todo-toolbar, .todo-create-form { display: block; }
   .office-action-grid form, .todo-toolbar form, .todo-create-form input, .todo-create-form select, .todo-create-form .btn { margin-top: 8px; }
   .office-browser > aside { border-right: 0; border-bottom: 1px solid #e2ebe7; }
-  .property-stats { grid-template-columns: repeat(2, 1fr); }
-  .property-filterbar { grid-template-columns: 1fr; }
 }
 
 /* 社群运营 */

+ 2 - 134
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js

@@ -14,7 +14,6 @@ const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.csv', '.txt', '.js']);
 const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
 
 const PAGE_BY_SKILL = {
-  'qiwei-real-estate-auto-reply': 'agent',
   'qiwei-agent-supervisor': 'agent',
   'qiwei-group-management': 'groups',
   'qiwei-customer-ops': 'customer-ops',
@@ -40,7 +39,6 @@ const CAPABILITY_TAGS = {
   'qiwei-official-meeting': ['会议同步', 'AI 知识沉淀', '官方 CLI'],
   'qiwei-official-todo': ['待办同步', '任务推进', '官方 CLI'],
   'qiwei-portrait-tags': ['客户画像', '标签', '批量导出'],
-  'qiwei-real-estate-auto-reply': ['房产 Agent', '需求识别', '房源工具'],
   'qiwei-agent-supervisor': ['人工监管', '审核', '审计'],
   'qiwei-voice': ['语音下载', '解码', '转写'],
   'qiwei-webhook-relay': ['Webhook', 'Relay', '长轮询'],
@@ -99,9 +97,7 @@ function loadCatalog() {
     ...item,
     root: path.resolve(KNOWLEDGE_BASE_DIR, item.path),
     extensions: new Set((item.extensions || []).map(value => String(value).toLowerCase())),
-    kind: item.id === 'property-data'
-      ? 'property-library'
-      : item.id === 'meeting-knowledge'
+    kind: item.id === 'meeting-knowledge'
         ? 'meeting-library'
         : item.id === 'doc-knowledge'
           ? 'doc-library'
@@ -110,7 +106,7 @@ function loadCatalog() {
             : item.id === 'task-knowledge'
               ? 'task-library'
         : 'document-library',
-  })).filter(item => item.id !== 'property-data' || fs.existsSync(item.root));
+  }));
   for (const pkg of packageDefinitions()) {
     const skillsRoot = pkg.skillsRoot || path.join(pkg.root, 'skills');
     if (!fs.existsSync(skillsRoot)) continue;
@@ -153,7 +149,6 @@ function safeNodePath(nodeId) {
 function fileKind(filePath) {
   const base = path.basename(filePath).toLowerCase();
   const extension = path.extname(filePath).toLowerCase();
-  if (base === 'properties.json') return 'property-dataset';
   if (base === 'skill.md') return 'skill-document';
   if (extension === '.md') return 'markdown';
   if (extension === '.json') return 'json';
@@ -200,65 +195,6 @@ function buildDirectoryChildren(library, directory, relativeDir = '', depth = 0)
   });
 }
 
-function propertySourcePath() {
-  const library = loadCatalog().libraries.find(item => item.id === 'property-data');
-  if (!library) throw new Error('房源数据目录未配置');
-  const filePath = path.join(library.root, 'properties.json');
-  if (!fs.existsSync(filePath)) throw new Error('properties.json 不存在');
-  return filePath;
-}
-
-function normalizeProperty(raw) {
-  return {
-    id: String(raw.id || raw.objectId || raw.property_id || ''),
-    community: raw.community || raw.community_name || '',
-    district: raw.district || raw.area_name || '',
-    totalPrice: Number(raw.totalPrice ?? raw.total_price ?? raw.price_total ?? 0),
-    unitPrice: Number(raw.unitPrice ?? raw.unit_price ?? raw.price_unit ?? 0),
-    layout: raw.layout || raw.house_type || '',
-    area: Number(raw.area || 0),
-    floor: raw.floor || raw.floor_info || '',
-    floorLevel: raw.floorLevel || raw.floor_level || '',
-    orientation: raw.orientation || '',
-    decoration: raw.decoration || '',
-    buildingAge: Number(raw.buildingAge ?? raw.building_age ?? 0),
-    isSchoolDistrict: Boolean(raw.isSchoolDistrict ?? raw.is_school_district),
-    schoolName: raw.schoolName || raw.school_name || '',
-    parking: raw.parking || '',
-    surrounding: raw.surrounding || '',
-    ownerSituation: raw.ownerSituation || raw.owner_situation || '',
-    priceDropSpace: Number(raw.priceDropSpace ?? raw.price_drop_space ?? 0),
-    isFiveYearOnly: Boolean(raw.isFiveYearOnly ?? raw.is_five_year_only),
-    highlights: raw.highlightTags || raw.highlight_tags || raw.tags || [],
-    scores: {
-      community: Number(raw.communityQuality || 0),
-      transport: Number(raw.transportScore || 0),
-      surrounding: Number(raw.surroundingScore || 0),
-      priceAdvantage: Number(raw.priceAdvantage || 0),
-    },
-  };
-}
-
-function allProperties() {
-  const parsed = readJson(propertySourcePath(), {});
-  const rows = Array.isArray(parsed) ? parsed : (parsed.properties || parsed.data || []);
-  return rows.map(normalizeProperty).filter(item => item.id || item.community);
-}
-
-function propertyStats(properties) {
-  const prices = properties.map(item => item.totalPrice).filter(value => value > 0);
-  return {
-    total: properties.length,
-    districts: [...new Set(properties.map(item => item.district.split('-')[0]).filter(Boolean))].sort(),
-    layouts: [...new Set(properties.map(item => item.layout).filter(Boolean))].sort(),
-    decorations: [...new Set(properties.map(item => item.decoration).filter(Boolean))].sort(),
-    minPrice: prices.length ? Math.min(...prices) : 0,
-    maxPrice: prices.length ? Math.max(...prices) : 0,
-    averagePrice: prices.length ? Math.round(prices.reduce((sum, value) => sum + value, 0) / prices.length) : 0,
-    schoolDistrictCount: properties.filter(item => item.isSchoolDistrict).length,
-  };
-}
-
 function listKnowledgeTree() {
   const catalog = loadCatalog();
   const roots = catalog.libraries.map(library => {
@@ -287,7 +223,6 @@ function listKnowledgeTree() {
       fileCount: fileChildren.reduce((sum, item) => sum + (item.type === 'file' ? 1 : item.fileCount || 0), 0),
     };
   });
-  const properties = allProperties();
   return {
     status: 'ok',
     data: {
@@ -296,7 +231,6 @@ function listKnowledgeTree() {
       summary: {
         libraryCount: roots.length,
         fileCount: roots.reduce((sum, item) => sum + item.fileCount, 0),
-        propertyCount: properties.length,
         skillCount: listSkillRegistry().data.summary.skillCount,
       },
     },
@@ -328,51 +262,10 @@ function readKnowledgeFile(nodeId) {
       modifiedAt: stats.mtime.toISOString(),
       content,
       structured,
-      propertyStats: kind === 'property-dataset' ? propertyStats(allProperties()) : null,
     },
   };
 }
 
-function listProperties(query = {}) {
-  const q = String(query.q || '').trim().toLowerCase();
-  const district = String(query.district || '').trim();
-  const layout = String(query.layout || '').trim();
-  const decoration = String(query.decoration || '').trim();
-  const maxPrice = Number(query.maxPrice || 0);
-  const page = Math.max(1, Number(query.page || 1) || 1);
-  const pageSize = Math.max(6, Math.min(60, Number(query.pageSize || 18) || 18));
-  const all = allProperties();
-  const filtered = all.filter(item => {
-    const haystack = `${item.id} ${item.community} ${item.district} ${item.layout} ${(item.highlights || []).join(' ')}`.toLowerCase();
-    if (q && !haystack.includes(q)) return false;
-    if (district && !item.district.includes(district)) return false;
-    if (layout && item.layout !== layout) return false;
-    if (decoration && item.decoration !== decoration) return false;
-    if (maxPrice && item.totalPrice > maxPrice) return false;
-    return true;
-  });
-  const offset = (page - 1) * pageSize;
-  return {
-    status: 'ok',
-    data: {
-      items: filtered.slice(offset, offset + pageSize),
-      total: filtered.length,
-      page,
-      pageSize,
-      pageCount: Math.max(1, Math.ceil(filtered.length / pageSize)),
-      stats: propertyStats(all),
-      source: propertySourcePath(),
-      sourceLabel: '演示房源数据集',
-    },
-  };
-}
-
-function getProperty(propertyId) {
-  const property = allProperties().find(item => item.id === String(propertyId));
-  if (!property) throw new Error('房源不存在');
-  return { status: 'ok', data: { property, source: propertySourcePath(), sourceLabel: '演示房源数据集' } };
-}
-
 function parseSkillFrontmatter(content, folderName) {
   const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
   const frontmatter = block?.[1] || '';
@@ -417,29 +310,6 @@ function listSkillRegistry() {
     };
   });
 
-  const properties = allProperties();
-  packages.push({
-    id: 'huaxiang-property-matching',
-    label: '花巷房源匹配项目',
-    description: '房源数据、客户样本、标签体系与多维匹配引擎。',
-    status: 'data-connected',
-    root: path.dirname(propertySourcePath()),
-    skillCount: 1,
-    skills: [{
-      id: 'huaxiang-property-matching:property-matching',
-      packageId: 'huaxiang-property-matching',
-      folder: 'property-matching',
-      name: 'property-matching',
-      title: '房源智能匹配',
-      description: `已接入 ${properties.length} 套演示房源、客户样本、标签和匹配引擎,可作为 Agent 的业务工具与知识数据源。`,
-      tags: ['房源列表', '客户画像', '多维评分'],
-      page: 'knowledge',
-      status: 'integrated',
-      content: '# 房源智能匹配\n\n房源数据与匹配引擎已经接入 4320 的知识库页面。\n\n- 数据源:`properties.json`\n- 客户样本:`buyers.json`\n- 标签体系:`buyer-tags.json`\n- 匹配引擎:`match-engine.js`\n',
-      filePath: propertySourcePath(),
-    }],
-  });
-
   const allSkills = packages.flatMap(item => item.skills);
   const uniqueNames = new Set(allSkills.map(item => item.name));
   return {
@@ -470,8 +340,6 @@ module.exports = {
   KNOWLEDGE_BASE_DIR,
   listKnowledgeTree,
   readKnowledgeFile,
-  listProperties,
-  getProperty,
   listSkillRegistry,
   getSkillDetail,
 };

+ 5 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -1442,8 +1442,11 @@ function createServer() {
     'qiwei_agent_set_global',
     {
       title: '设置企微 Agent 全局策略',
-      description: '设置 paused(全局暂停)、review(生成待审核草稿)、auto(仅高置信且无需人工时自动发送)或 human(人工模式)。',
-      inputSchema: { mode: z.enum(['paused', 'review', 'auto', 'human']) }
+      description: '设置 paused(全局暂停)、review(生成待审核草稿)、auto(仅高置信且无需人工时自动发送)、autopilot(全自动接管,非空回复直接发送)或 human(人工模式)。autopilot 必须同时传 confirmation=ENABLE_AUTOPILOT。',
+      inputSchema: {
+        mode: z.enum(['paused', 'review', 'auto', 'autopilot', 'human']),
+        confirmation: z.string().optional(),
+      }
     },
     wrap(qiweiAgentSetGlobal)
   );

+ 1 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-control-run.js

@@ -261,7 +261,7 @@ async function qiweiAgentListener(input = {}) {
 }
 
 async function qiweiAgentSetGlobal(input = {}) {
-  const result = agentService().changeGlobalMode(input.mode);
+  const result = agentService().changeGlobalMode(input.mode, input.confirmation);
   return okResult({ assistantMessage: result.assistantMessage, data: result.data });
 }
 

+ 2 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js

@@ -12,7 +12,7 @@ const PLAYBOOK_DIMENSIONS = [
 ];
 
 const PLAYBOOK_KEYWORDS = {
-  '客户分层': ['预算', '首付', '刚需', '改善', '投资'],
+  '客户分层': ['预算', '规模', '行业', '需求', '意向'],
   '开场白': ['你好', '您好', '感谢', '介绍'],
   '需求挖掘': ['考虑', '想要', '需要', '关注', '看重'],
   '异议处理': ['但是', '不过', '担心', '顾虑', '再考虑'],
@@ -20,7 +20,7 @@ const PLAYBOOK_KEYWORDS = {
   '成交信号': ['定', '签', '可以', '没问题', '确定'],
   '风险提示': ['注意', '提醒', '谨慎', '风险'],
   '话术风格': ['专业', '耐心', '直接', '细致'],
-  '产品匹配': ['户型', '面积', '楼层', '朝向', '配套'],
+  '产品匹配': ['功能', '版本', '服务', '交付', '集成'],
   '转介绍': ['推荐', '介绍', '朋友', '亲戚'],
   '复购维护': ['后续', '服务', '维护', '长期'],
   '竞争应对': ['对比', '别家', '其他', '优势'],

+ 5 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js

@@ -20,16 +20,16 @@ const REQUIRED_METHODS = {
 };
 
 const PORTRAIT_FIELDS = [
-  'intent', 'budgetRange', 'preferredAreas', 'houseType', 'timeline', 'keyConcerns',
+  'intent', 'purpose', 'needs', 'budgetRange', 'timeline', 'keyConcerns',
   'urgency', 'aiSummary', 'personaType', 'fiveW2H', 'priorityMatrix', 'confidence',
-  'coreAnxiety', 'decisionMaker', 'loanCapacity', 'negotiationStage'
+  'coreAnxiety', 'decisionMaker', 'contactPreference', 'serviceStage'
 ];
 
 const PORTRAIT_KEYWORDS = {
-  intent: ['买房', '购房', '租房', '出租', '出售', '置换', '投资'],
-  budgetRange: ['预算', '万', '总价', '首付', '月供', '贷款'],
+  intent: ['咨询', '采购', '试用', '续费', '售后', '合作', '投诉'],
+  budgetRange: ['预算', '费用', '报价', '价格', '成本', '万'],
   timeline: ['尽快', '着急', '最近', '这个月', '下个月', '半年', '今年', '年底', '明年初'],
-  keyConcerns: ['学区', '学校', '地铁', '医院', '商圈', '装修', '物业', '停车', '电梯', '采光'],
+  keyConcerns: ['功能', '价格', '交付', '服务', '质量', '效率', '集成', '售后', '合规'],
   urgency: ['着急', '马上', '尽快', '急']
 };
 

+ 5 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-voice-run.js

@@ -6,6 +6,7 @@ const net = require('net');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, outputsRoot, PACKAGE_ROOT } = require('../core/output-paths');
 const { VoiceCloneService } = require('../core/voice-clone-service');
+const { readFmodeVoiceToken } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const {
   buildContext,
@@ -258,7 +259,10 @@ function voiceRuntime(input = {}) {
     qiwei,
     config: {
       endpoint: process.env.QIWEI_VOICE_ENDPOINT || env.QIWEI_VOICE_ENDPOINT,
-      authToken: input.authToken,
+      authToken: readFmodeVoiceToken({
+        voiceAuthToken: input.voiceAuthToken || input.authToken,
+        endpoint: process.env.QIWEI_VOICE_ENDPOINT || env.QIWEI_VOICE_ENDPOINT,
+      }),
       model: 'fmode-voice',
       requestTimeoutMs: process.env.QIWEI_TTS_TIMEOUT_MS || env.QIWEI_TTS_TIMEOUT_MS,
     },

Plik diff jest za duży
+ 3 - 0
claude-code/claude-code-qiwe-assistant/package.json


+ 163 - 83
claude-code/claude-code-qiwe-assistant/scripts/agent-console-smoke-test.js

@@ -32,7 +32,7 @@ const { AgentKnowledgeStore } = require('../mcp/src/core/agent-knowledge');
 const results = [];
 const PACKAGE_ROOT = path.resolve(__dirname, '..');
 
-function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
+function setup({ paused = false, defaultMode = 'review', agentRun, qiweiSend } = {}) {
   const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
   const db = new AgentWorkbenchDb(path.join(dir, 'test.db'), {
     globalPaused: paused,
@@ -43,6 +43,7 @@ function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
   const qiwei = {
     isConfigured: () => true,
     async sendText(toId, content) {
+      if (qiweiSend) return qiweiSend(toId, content);
       sent.push({ toId, content });
       return { isSendSuccess: true };
     },
@@ -475,8 +476,8 @@ async function main() {
   await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
     const ctx = setup();
     try {
-      const first = await ctx.service.ingestInbound({ externalId: 'm1', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
-      const duplicate = await ctx.service.ingestInbound({ externalId: 'm1-copy', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
+      const first = await ctx.service.ingestInbound({ externalId: 'm1', contactId: 'contact-1', contactName: '王刚', content: '我想咨询服务方案' });
+      const duplicate = await ctx.service.ingestInbound({ externalId: 'm1-copy', contactId: 'contact-1', contactName: '王刚', content: '我想咨询服务方案' });
       assert.equal(first.status, 'pending_review');
       assert.equal(duplicate.status, 'duplicate_content');
       assert.equal(ctx.db.listMessages(first.conversation.id).length, 1);
@@ -486,7 +487,7 @@ async function main() {
   await check('审核模式生成草稿但不自动外发', async () => {
     const ctx = setup();
     try {
-      const result = await ctx.service.ingestInbound({ externalId: 'm2', contactId: 'contact-1', contactName: '王刚', content: '预算 150 万,想买三室' });
+      const result = await ctx.service.ingestInbound({ externalId: 'm2', contactId: 'contact-1', contactName: '王刚', content: '预算 15 万,想了解企业服务方案' });
       assert.equal(result.status, 'pending_review');
       assert.equal(ctx.sent.length, 0);
       assert.equal(ctx.db.getDraft(result.draft.id).status, 'pending');
@@ -506,6 +507,82 @@ async function main() {
     } finally { ctx.close(); }
   });
 
+  await check('全自动接管直接发送回复且不创建待审核草稿', async () => {
+    const ctx = setup({
+      defaultMode: 'autopilot',
+      agentRun: async () => ({
+        content: '这条低置信回复也由全自动接管直接发送',
+        confidence: 0.12,
+        intent: 'autopilot_test',
+        reason: '全自动接管不经过草稿审核',
+        requiresHuman: true,
+        profileUpdates: {},
+        citations: [],
+        toolTrace: [],
+      }),
+    });
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm-autopilot', contactId: 'contact-1', contactName: '王刚', content: '全自动接管测试' });
+      assert.equal(result.status, 'autopilot_sent');
+      assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '这条低置信回复也由全自动接管直接发送' }]);
+      assert.equal(ctx.db.listDrafts().length, 0);
+      assert.equal(ctx.db.listMessages(result.conversation.id).filter(item => item.direction === 'outbound').length, 1);
+      const outcome = ctx.db.latestAgentOutcome(result.conversation.id);
+      assert.equal(outcome.action, 'autopilot_message_sent');
+      assert.equal(outcome.entityId, result.message.id);
+    } finally { ctx.close(); }
+  });
+
+  await check('全自动接管发送失败保留失败审计且不创建草稿', async () => {
+    const ctx = setup({
+      defaultMode: 'autopilot',
+      qiweiSend: async () => { throw new Error('send failed'); },
+    });
+    try {
+      const result = await ctx.service.ingestInbound({ externalId: 'm-autopilot-failed', contactId: 'contact-1', contactName: '王刚', content: '失败审计测试' });
+      assert.equal(result.status, 'autopilot_send_failed');
+      assert.equal(ctx.db.listDrafts().length, 0);
+      assert.equal(ctx.db.latestAgentOutcome(result.conversation.id).action, 'autopilot_send_failed');
+    } finally { ctx.close(); }
+  });
+
+  await check('全局和单会话全自动接管都要求固定二次确认', async () => {
+    const { __testing } = require('../mcp/src/dashboard/agent-service');
+    assert.throws(() => __testing.requireAutopilotConfirmation('autopilot', ''), /二次确认/);
+    assert.throws(() => __testing.requireAutopilotConfirmation('autopilot', 'WRONG', 'conversation'), /会话全自动接管/);
+    assert.doesNotThrow(() => __testing.requireAutopilotConfirmation('autopilot', 'ENABLE_AUTOPILOT'));
+    assert.doesNotThrow(() => __testing.requireAutopilotConfirmation('auto', ''));
+  });
+
+  await check('旧会话数据库可幂等迁移到全自动接管模式', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-mode-migration-'));
+    const dbPath = path.join(dir, 'legacy.db');
+    try {
+      const raw = new DatabaseSync(dbPath);
+      raw.exec(`CREATE TABLE conversations (
+        id TEXT PRIMARY KEY,
+        contact_id TEXT NOT NULL UNIQUE,
+        contact_name TEXT NOT NULL DEFAULT '',
+        mode TEXT NOT NULL DEFAULT 'review' CHECK(mode IN ('review','auto','human','paused')),
+        last_message_at TEXT,
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL
+      );`);
+      const timestamp = new Date().toISOString();
+      raw.prepare('INSERT INTO conversations(id,contact_id,contact_name,mode,created_at,updated_at) VALUES(?,?,?,?,?,?)')
+        .run('legacy-conversation', 'legacy-contact', '历史客户', 'review', timestamp, timestamp);
+      raw.close();
+
+      const migrated = new AgentWorkbenchDb(dbPath, { defaultMode: 'review' });
+      try {
+        assert.equal(migrated.setConversationMode('legacy-conversation', 'autopilot').mode, 'autopilot');
+        const inserted = migrated.insertMessage({ conversationId: 'legacy-conversation', direction: 'inbound', senderType: 'customer', content: '迁移后消息' });
+        assert.equal(inserted.created, true);
+        assert.match(migrated.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='conversations'").get().sql, /autopilot/);
+      } finally { migrated.close(); }
+    } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+  });
+
   await check('全局暂停与人工接管都抑制 Agent', async () => {
     const ctx = setup({ paused: true });
     try {
@@ -523,7 +600,7 @@ async function main() {
   await check('Agent 上游失败只留审计,不生成伪回复、不外发', async () => {
     const ctx = setup({ agentRun: async () => { throw new Error('Agent 上游暂时不可用(HTTP 522)'); } });
     try {
-      const result = await ctx.service.ingestInbound({ externalId: 'm6', contactId: 'contact-1', contactName: '王刚', content: '请推荐房源' });
+      const result = await ctx.service.ingestInbound({ externalId: 'm6', contactId: 'contact-1', contactName: '王刚', content: '请推荐合适的服务方案' });
       assert.equal(result.status, 'agent_failed');
       assert.equal(ctx.db.listDrafts().length, 0);
       assert.equal(ctx.sent.length, 0);
@@ -570,7 +647,7 @@ async function main() {
         if (sessionIds.length === 1) throw new Error('Claude Code 调用失败(退出码 1):error_max_budget_usd');
         return { content: '{}', claudeCode: { resumed: false } };
       };
-      const result = await client.complete([{ role: 'user', content: '请推荐房源' }], [], { conversation: { id: 'conversation-budget', contact_name: '王刚' } });
+      const result = await client.complete([{ role: 'user', content: '请推荐合适的服务方案' }], [], { conversation: { id: 'conversation-budget', contact_name: '王刚' } });
       assert.equal(sessionIds.length, 2);
       assert.notEqual(sessionIds[0], sessionIds[1]);
       assert.equal(invokeOptions[1].maxBudgetUsd, 1);
@@ -595,7 +672,7 @@ async function main() {
         capturedArgs = args;
         return { structured_output: { reply: '测试草稿' }, duration_ms: 1, total_cost_usd: 0.01 };
       };
-      await client.invoke([{ role: 'system', content: '测试' }, { role: 'user', content: '推荐房源' }], {}, { id: '33333333-3333-4333-8333-333333333333', initialized: false });
+      await client.invoke([{ role: 'system', content: '测试' }, { role: 'user', content: '推荐服务方案' }], {}, { id: '33333333-3333-4333-8333-333333333333', initialized: false });
       assert(capturedArgs.includes('--bare'));
       assert.equal(capturedArgs[capturedArgs.indexOf('--effort') + 1], 'low');
       assert.equal(capturedArgs[capturedArgs.indexOf('--tools') + 1], 'Read,Glob,Grep');
@@ -657,17 +734,17 @@ async function main() {
   });
 
   await check('Claude Code 带说明文字或嵌套 JSON 时只提取自然语言回复', async () => {
-    const prefixed = parseFinal('根据上下文分析,草稿如下:\n```json\n{"reply":"您好,我先帮您筛选合适的房源。","confidence":0.9,"intent":"property_search","reason":"需求明确","requiresHuman":false}\n```');
-    assert.equal(prefixed.reply, '您好,我先帮您筛选合适的房源。');
-    assert.equal(prefixed.intent, 'property_search');
+    const prefixed = parseFinal('根据上下文分析,草稿如下:\n```json\n{"reply":"您好,我先帮您筛选合适的服务方案。","confidence":0.9,"intent":"solution_search","reason":"需求明确","requiresHuman":false}\n```');
+    assert.equal(prefixed.reply, '您好,我先帮您筛选合适的服务方案。');
+    assert.equal(prefixed.intent, 'solution_search');
 
     const nested = parseFinal(JSON.stringify({
-      reply: JSON.stringify({ reply: '这周可以安排看房,您周几方便?', confidence: 0.88, intent: 'schedule_viewing' }),
+      reply: JSON.stringify({ reply: '这周可以安排演示,您周几方便?', confidence: 0.88, intent: 'schedule_demo' }),
       confidence: 0.5,
       intent: 'unknown',
     }));
-    assert.equal(nested.reply, '这周可以安排看房,您周几方便?');
-    assert.equal(nested.intent, 'schedule_viewing');
+    assert.equal(nested.reply, '这周可以安排演示,您周几方便?');
+    assert.equal(nested.intent, 'schedule_demo');
 
     const unsafe = parseFinal('```json\n{"reply": invalid}\n```');
     assert.equal(unsafe.reply, '');
@@ -683,7 +760,7 @@ async function main() {
         externalId: 'memory-old-1',
         direction: 'inbound',
         senderType: 'customer',
-        content: '我之前说过不考虑学区,通勤方便更重要',
+        content: '我之前说过不考虑现场部署,远程交付更重要',
         createdAt: '2026-01-01T00:00:00.000Z',
       }).message;
       for (let index = 0; index < 6; index += 1) {
@@ -701,16 +778,16 @@ async function main() {
         externalId: 'memory-current',
         direction: 'inbound',
         senderType: 'customer',
-        content: '我更喜欢地铁附近,不考虑顶楼,预算200万',
+        content: '我更喜欢标准化交付,不考虑定制开发,预算20万',
       }).message;
       const memory = new AgentMemoryManager({ db: ctx.db, config: { recentMessageLimit: 4, recallLimit: 4 } });
       assert.equal(memory.config.coreCharLimit, 4000);
-      const captured = memory.capture({ conversationId: conversation.id, inboundMessage: inbound, profileUpdates: { budgetWan: 200 } });
+      const captured = memory.capture({ conversationId: conversation.id, inboundMessage: inbound, profileUpdates: { budgetWan: 20 } });
       assert(captured.captured >= 3);
-      assert.match(captured.snapshot.compact_text, /地铁附近/);
-      assert.match(captured.snapshot.compact_text, /顶楼/);
-      assert.match(captured.snapshot.compact_text, /200/);
-      const prepared = memory.prepare({ conversation, inboundContent: '学区和通勤按之前说的来' });
+      assert.match(captured.snapshot.compact_text, /标准化交付/);
+      assert.match(captured.snapshot.compact_text, /定制开发/);
+      assert.match(captured.snapshot.compact_text, /20/);
+      const prepared = memory.prepare({ conversation, inboundContent: '现场部署和远程交付按之前说的来' });
       assert(prepared.recalled.some(item => item.id === old.id));
       assert.match(prepared.promptText, /历史片段/);
       assert(prepared.stats.coreChars <= 4000);
@@ -749,10 +826,10 @@ async function main() {
 
       const expiring = ctx.service.addCustomerMemory(conversation.id, {
         type: 'event',
-        content: '本周临时出差,暂缓看房',
+        content: '本周临时出差,暂缓沟通',
         expiresAt: '2020-01-01T00:00:00.000Z',
       }).memory;
-      ctx.service.memory.prepare({ conversation, inboundContent: '继续聊看房' });
+      ctx.service.memory.prepare({ conversation, inboundContent: '继续聊服务方案' });
       assert.equal(ctx.db.getCustomerMemory(expiring.id).status, 'superseded');
       assert.equal(ctx.db.listCustomerMemoryRevisions(expiring.id)[0].reason, 'expired');
 
@@ -774,25 +851,25 @@ async function main() {
         externalId: 'memory-backfill-message',
         direction: 'inbound',
         senderType: 'customer',
-        content: '我更喜欢地铁附近,不考虑顶楼',
+        content: '我更关注实施周期,不需要现场部署',
       }).message;
       ctx.db.updateProfile(conversation.id, {
         budgetWan: 180,
-        preferredRegion: '新北区',
-        intent_area: '旧字段新北区',
+        need: '企业服务方案',
+        intent_area: '旧字段服务区域',
         __evidence: {
           budgetWan: { sourceMessageId: inbound.id, text: inbound.content },
-          preferredRegion: { sourceMessageId: inbound.id, text: inbound.content },
+          need: { sourceMessageId: inbound.id, text: inbound.content },
         },
       }, []);
       const first = ctx.service.memory.backfillConversation(conversation);
       const count = ctx.db.listCustomerMemories(conversation.id).length;
       const second = ctx.service.memory.backfillConversation(conversation);
-      assert(first.captured >= 3);
+      assert(first.captured >= 2);
       assert.equal(ctx.db.listCustomerMemories(conversation.id).length, count);
       assert.equal(second.snapshot.content_hash, first.snapshot.content_hash);
       assert(ctx.db.getCustomerMemoryByKey(conversation.id, 'profile:budgetWan').source_message_ids.includes(inbound.id));
-      assert.equal(ctx.db.getCustomerMemoryByKey(conversation.id, 'profile:preferredRegion').content, '意向区域:新北区');
+      assert.equal(ctx.db.getCustomerMemoryByKey(conversation.id, 'profile:need').content, '核心需求:企业服务方案');
       assert.equal(ctx.db.getCustomerMemoryByKey(conversation.id, 'profile:intent_area'), null);
     } finally { ctx.close(); }
   });
@@ -819,15 +896,15 @@ async function main() {
     const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-prompt-boundary-'));
     try {
       const messages = [
-        { role: 'user', content: '我是两个两个买' },
+        { role: 'user', content: '这是旧项目数据,不要沿用' },
         { role: 'user', content: '加进去这个api服务就不用管了' },
-        { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
-        { role: 'user', content: '200万吧' },
+        { role: 'assistant', content: '企业培训服务可以按需求配置,请问预算是多少?' },
+        { role: 'user', content: '预算20万吧' },
       ];
       const authoritative = selectAuthoritativeHistory(messages);
       assert.deepEqual(authoritative.map(item => item.content), [
-        '新北区有4套三室房,请问预算是多少?',
-        '200万吧',
+        '企业培训服务可以按需求配置,请问预算是多少?',
+        '预算20万吧',
       ]);
       const client = new ClaudeCodeClient({
         claudeSessionFile: path.join(dir, 'sessions.json'),
@@ -835,9 +912,12 @@ async function main() {
       });
       const prompt = client.buildPrompt(messages, { profile: { profile: {} } });
       assert.match(prompt, /本轮有效会话/);
-      assert.match(prompt, /200万吧/);
-      assert.doesNotMatch(prompt, /两个两个买/);
+      assert.match(prompt, /预算20万吧/);
+      assert.doesNotMatch(prompt, /旧项目数据/);
       assert.doesNotMatch(prompt, /api服务/);
+      const autopilotPrompt = client.buildPrompt(messages, { conversation: { mode: 'autopilot' }, profile: { profile: {} } });
+      assert.match(autopilotPrompt, /全自动接管链路直接发送 reply/);
+      assert.doesNotMatch(autopilotPrompt, /只生成供 Dashboard 审核/);
       const budgetedClient = new ClaudeCodeClient({
         claudeSessionFile: path.join(dir, 'budgeted-sessions.json'),
         claudeWorkdir: dir,
@@ -857,29 +937,29 @@ async function main() {
 
   await check('Session 残留原话被证据闸门拦截并降级为人工确认', async () => {
     const history = [
-      { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
-      { role: 'user', content: '200万吧' },
+      { role: 'assistant', content: '企业服务方案可以按需求配置,请问预算是多少?' },
+      { role: 'user', content: '预算20万吧' },
     ];
     const guarded = enforceAuthoritativeGrounding({
-      reply: '您之前提到“两个两个买”,是想一次买两套吗?',
+      reply: '您之前提到“需要三个现场部署点”,需要同时推进吗?',
       confidence: 0.9,
       intent: '预算确认',
-      reason: '客户之前说“两个两个买”。',
+      reason: '客户之前说需要三个现场部署点。',
       requiresHuman: false,
-    }, history, { preferredRegion: '新北区', layout: '三室', budgetWan: 200, budgetType: '待确认' }, '200万吧');
+    }, history, { need: '企业服务方案', budgetWan: 20, budgetType: '待确认' }, '预算20万吧');
     assert.equal(guarded.requiresHuman, true);
     assert(guarded.confidence <= 0.68);
-    assert.doesNotMatch(guarded.reply, /两个两个买|两套/);
-    assert.match(guarded.reply, /新北区/);
+    assert.doesNotMatch(guarded.reply, /三个现场部署点|同时推进/);
+    assert.match(guarded.reply, /预算 20/);
 
-    const intelligence = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {
-      profileUpdates: { purchaseQuantity: 2, budgetWan: 200 },
-      tasks: [{ type: 'purchase', title: '准备两套方案', evidence: '两个两个买' }],
-      alerts: [{ type: 'high_intent', severity: 'high', title: '两套购买', evidence: '两个两个买' }],
+    const intelligence = extractExplicitCustomerIntelligence('预算20万吧', { need: '企业服务方案' }, {
+      profileUpdates: { deploymentCount: 3, budgetWan: 20 },
+      tasks: [{ type: 'follow_up', title: '准备三个现场部署方案', evidence: '三个现场部署点' }],
+      alerts: [{ type: 'high_intent', severity: 'high', title: '多点部署', evidence: '三个现场部署点' }],
     });
-    assert.equal(intelligence.profileUpdates.purchaseQuantity, undefined);
-    assert.equal(intelligence.profileUpdates.budgetWan, 200);
-    assert.doesNotMatch(JSON.stringify(intelligence), /两个两个买|两套购买|准备两套方案/);
+    assert.equal(intelligence.profileUpdates.deploymentCount, undefined);
+    assert.equal(intelligence.profileUpdates.budgetWan, 20);
+    assert.doesNotMatch(JSON.stringify(intelligence), /三个现场部署点|多点部署|准备三个现场部署方案/);
   });
 
   await check('客户 Session 指引主动返回可识别名称和安全打开命令', async () => {
@@ -909,15 +989,15 @@ async function main() {
   });
 
   await check('监听消息持续沉淀客户画像、内部待办和预警', async () => {
-    const housing = extractExplicitCustomerIntelligence('我想咨询一下新北区的三室房', {}, {});
-    assert.equal(housing.profileUpdates.preferredRegion, '新北区');
-    assert.equal(housing.profileUpdates.layout, '三室');
-    const explicit = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {});
-    assert.equal(explicit.profileUpdates.budgetWan, 200);
-    assert(explicit.tasks.some(item => item.type === 'recommendation'));
-    assert(explicit.alerts.some(item => item.type === 'high_intent'));
-    const purpose = extractExplicitCustomerIntelligence('自己住吧', { budgetWan: 200 }, {});
-    assert.equal(purpose.profileUpdates.purpose, '自住');
+    const need = extractExplicitCustomerIntelligence('我想咨询企业培训服务', {}, { profileUpdates: { need: '企业培训服务' } });
+    assert.equal(need.profileUpdates.need, '企业培训服务');
+    const explicit = extractExplicitCustomerIntelligence('预算20万吧', { need: '企业培训服务' }, {});
+    assert.equal(explicit.profileUpdates.budgetWan, 20);
+    assert(explicit.tasks.some(item => item.type === 'qualification'));
+    assert.equal(explicit.alerts.some(item => item.type === 'high_intent'), false);
+    const timeline = extractExplicitCustomerIntelligence('计划三个月内推进', { need: '企业培训服务', budgetWan: 20 }, { profileUpdates: { timeline: '三个月内' } });
+    assert.equal(timeline.profileUpdates.timeline, '三个月内');
+    assert(timeline.alerts.some(item => item.type === 'high_intent'));
 
     const ctx = setup({ agentRun: async () => ({
       content: '好的,我再确认一下您的用途和时间计划。',
@@ -1013,7 +1093,7 @@ async function main() {
       senderId: 'customer-1',
       senderName: '张三',
       fromRoomId: 'r-1',
-      content: '明天下午可以看房吗',
+      content: '明天下午可以安排演示吗',
       timestamp: '2026-07-23T08:00:00.000Z',
     }];
     const sent = [];
@@ -1023,8 +1103,8 @@ async function main() {
     let agentResponse = {
       content: '可以的,请问您明天下午几点方便?',
       confidence: 0.88,
-      intent: '预约看房',
-      reason: '客户明确询问看房时间',
+      intent: '预约演示',
+      reason: '客户明确询问演示时间',
       requiresHuman: false,
       citations: [],
       toolTrace: [],
@@ -1051,7 +1131,7 @@ async function main() {
       statePath: path.join(dir, 'group-agent.json'),
       runtime,
       getAccount: () => ({ uid: 'account-1', userId: 'self-1', nickname: '王顾问' }),
-      loadGroups: () => ({ 'r-1': { roomName: '张三买房服务群', customerName: '张三' } }),
+      loadGroups: () => ({ 'r-1': { roomName: '张三客户服务群', customerName: '张三' } }),
       loadMessages: () => messages,
       appendMessage: (roomId, message) => { messages.push({ ...message, fromRoomId: roomId }); return 'memory'; },
     });
@@ -1077,7 +1157,7 @@ async function main() {
       agentResponse = {
         content: '三点可以,我先为您登记。',
         confidence: 0.1,
-        intent: '预约看房',
+        intent: '预约演示',
         reason: '低置信回复仍由全自动模式放行',
         requiresHuman: true,
         citations: [],
@@ -1144,44 +1224,44 @@ async function main() {
     const ctx = setup();
     try {
       const conversation = ctx.db.ensureConversation('contact-1', '王刚');
-      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }], 'message-a');
-      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '区域新北区' }], 'message-b');
+      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与时间', evidence: '预算20万' }], 'message-a');
+      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与时间', evidence: '需求企业培训' }], 'message-b');
       const tasks = ctx.db.listCustomerTasks(conversation.id);
       assert.equal(tasks.length, 1);
-      assert.deepEqual(JSON.parse(tasks[0].evidence_json).map(item => item.text), ['预算200万', '区域新北区']);
+      assert.deepEqual(JSON.parse(tasks[0].evidence_json).map(item => item.text), ['预算20万', '需求企业培训']);
     } finally { ctx.close(); }
   });
 
-  await check('用途和购置时间补齐后资格确认待办自动完成', async () => {
+  await check('客户目标和计划时间补齐后资格确认待办自动完成', async () => {
     let turn = 0;
     const ctx = setup({ agentRun: async () => {
       turn += 1;
       return {
         content: '信息已记录。', confidence: 0.8, intent: '需求确认', reason: '测试', requiresHuman: false,
-        profileUpdates: turn === 1 ? { budgetWan: 200 } : { purpose: '自住', timeline: '三个月内' },
-        tasks: turn === 1 ? [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }] : [],
+        profileUpdates: turn === 1 ? { budgetWan: 20 } : { purpose: '企业培训', timeline: '三个月内' },
+        tasks: turn === 1 ? [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与时间', evidence: '预算20万' }] : [],
         alerts: [], citations: [], toolTrace: [],
       };
     } });
     try {
-      const first = await ctx.service.ingestInbound({ externalId: 'profile-a', contactId: 'contact-1', contactName: '王刚', content: '预算200万' });
+      const first = await ctx.service.ingestInbound({ externalId: 'profile-a', contactId: 'contact-1', contactName: '王刚', content: '预算20万' });
       assert.equal(ctx.db.listCustomerTasks(first.conversation.id)[0].status, 'open');
-      await ctx.service.ingestInbound({ externalId: 'profile-b', contactId: 'contact-1', contactName: '王刚', content: '自住,计划三个月内购买' });
+      await ctx.service.ingestInbound({ externalId: 'profile-b', contactId: 'contact-1', contactName: '王刚', content: '用于企业培训,计划三个月内推进' });
       const qualification = ctx.db.listCustomerTasks(first.conversation.id).find(item => item.business_key === 'qualification:purpose_and_timeline');
       assert.equal(qualification.status, 'done');
       assert.equal(qualification.resolution_reason, 'profile_condition_resolved');
     } finally { ctx.close(); }
   });
 
-  await check('发送房源方案后重点方案待办自动完成', async () => {
+  await check('人工发送仅留审计且不自动改写内部待办', async () => {
     const ctx = setup();
     try {
       const conversation = ctx.db.ensureConversation('contact-1', '王刚');
-      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', managedBy: 'rule', type: 'recommendation', title: '按已确认条件筛选并发送重点方案', evidence: '预算、区域、户型已明确' }], 'message-c');
-      await ctx.service.manualSend(conversation.id, '已经为您筛选了三套重点房源方案,请查收。');
-      const task = ctx.db.listCustomerTasks(conversation.id).find(item => item.business_key === 'recommendation:shortlist');
-      assert.equal(task.status, 'done');
-      assert.equal(task.resolution_reason, 'manual_recommendation_sent');
+      ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'follow_up:send_solution', managedBy: 'rule', type: 'follow_up', title: '发送服务方案', evidence: '需求与预算已明确' }], 'message-c');
+      await ctx.service.manualSend(conversation.id, '已经为您整理好服务方案,请查收。');
+      const task = ctx.db.listCustomerTasks(conversation.id).find(item => item.business_key === 'follow_up:send_solution');
+      assert.equal(task.status, 'open');
+      assert(ctx.db.listAudit(20, conversation.id).some(item => item.action === 'manual_message_sent'));
     } finally { ctx.close(); }
   });
 
@@ -1197,8 +1277,8 @@ async function main() {
     const timestamp = new Date().toISOString();
     const insert = raw.prepare(`INSERT INTO customer_tasks(id,conversation_id,fingerprint,business_key,managed_by,type,title,status,evidence,evidence_json,created_at,updated_at)
       VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
-    insert.run('legacy-task-a', conversation.id, 'legacy-fp-a', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '预算200万', '[]', timestamp, timestamp);
-    insert.run('legacy-task-b', conversation.id, 'legacy-fp-b', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '区域新北区', '[]', timestamp, timestamp);
+    insert.run('legacy-task-a', conversation.id, 'legacy-fp-a', '', 'agent', 'qualification', '确认客户用途与时间', 'open', '预算20万', '[]', timestamp, timestamp);
+    insert.run('legacy-task-b', conversation.id, 'legacy-fp-b', '', 'agent', 'qualification', '确认客户用途与时间', 'open', '需求企业培训', '[]', timestamp, timestamp);
     raw.close();
     const target = new AgentWorkbenchDb(targetPath, { defaultMode: 'review' });
     try {
@@ -1218,12 +1298,12 @@ async function main() {
     const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-prompt-'));
     try {
       const client = new ClaudeCodeClient({ claudeWorkdir: dir, claudeSessionFile: path.join(dir, 'sessions.json') });
-      const prompt = client.buildPrompt([{ role: 'user', content: '继续推荐' }], { customerIntelligence: {
-        tasks: [{ businessKey: 'recommendation:shortlist', title: '发送重点方案', status: 'open' }],
+      const prompt = client.buildPrompt([{ role: 'user', content: '继续沟通' }], { customerIntelligence: {
+        tasks: [{ businessKey: 'follow_up:send_solution', title: '发送服务方案', status: 'open' }],
         alerts: [{ businessKey: 'high_intent:core_demand_ready', title: '高意向', status: 'open' }],
       } });
       assert.match(prompt, /当前未完成待办/);
-      assert.match(prompt, /recommendation:shortlist/);
+      assert.match(prompt, /follow_up:send_solution/);
       assert.match(prompt, /当前未解决预警/);
       assert.match(prompt, /high_intent:core_demand_ready/);
     } finally { fs.rmSync(dir, { recursive: true, force: true }); }
@@ -1233,7 +1313,7 @@ async function main() {
     const ctx = setup();
     try {
       const conversation = ctx.db.ensureConversation('contact-1', '王刚');
-      const [task] = ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', type: 'recommendation', title: '发送重点方案' }]);
+      const [task] = ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'follow_up:send_solution', type: 'follow_up', title: '发送服务方案' }]);
       let createCalls = 0;
       const sync = createCustomerTaskOfficialSync({
         db: ctx.db,

+ 70 - 0
claude-code/claude-code-qiwe-assistant/scripts/agent-conversation-order-smoke-test.js

@@ -0,0 +1,70 @@
+'use strict';
+
+const assert = require('assert/strict');
+const {
+  MISSING_TIMESTAMP,
+  conversationTimestamp,
+  compareConversationRecency,
+  sortConversationsByRecency,
+  maxConversationTimestamp,
+} = require('../mcp/src/core/conversation-order');
+
+function main() {
+  assert.equal(conversationTimestamp(null), MISSING_TIMESTAMP);
+  assert.equal(conversationTimestamp(''), MISSING_TIMESTAMP);
+  assert.equal(conversationTimestamp('not-a-time'), MISSING_TIMESTAMP);
+
+  const mixed = [
+    { id: 'private-old', channelType: 'private', lastMessageAt: '2026-08-04T08:00:00.000Z' },
+    { id: 'group-new', channelType: 'group', lastMessageAt: '2026-08-04T10:00:00.000Z' },
+    { id: 'private-invalid', channelType: 'private', lastMessageAt: 'not-a-time' },
+    { id: 'group-empty', channelType: 'group', lastMessageAt: '' },
+    { id: 'private-null', channelType: 'private', lastMessageAt: null },
+  ];
+  const sorted = sortConversationsByRecency(mixed);
+  assert.deepEqual(sorted.map(item => item.id), [
+    'group-new',
+    'private-old',
+    'private-invalid',
+    'group-empty',
+    'private-null',
+  ]);
+  assert.deepEqual(mixed.map(item => item.id), [
+    'private-old',
+    'group-new',
+    'private-invalid',
+    'group-empty',
+    'private-null',
+  ]);
+
+  const sameTime = [
+    { id: 'private-first', channelType: 'private', lastMessageAt: '2026-08-04T09:00:00.000Z' },
+    { id: 'group-second', channelType: 'group', lastMessageAt: '2026-08-04T09:00:00.000Z' },
+    { id: 'private-third', channelType: 'private', lastMessageAt: '2026-08-04T09:00:00.000Z' },
+  ];
+  assert.deepEqual(sortConversationsByRecency(sameTime).map(item => item.id), [
+    'private-first',
+    'group-second',
+    'private-third',
+  ]);
+
+  assert(compareConversationRecency(mixed[1], mixed[0]) < 0);
+  assert(compareConversationRecency(mixed[2], mixed[3]) === 0);
+
+  assert.equal(maxConversationTimestamp(
+    null,
+    'not-a-time',
+    '2026-08-04T08:00:00.000Z',
+    '2026-08-04T10:00:00.000Z',
+    '2026-08-04T09:00:00.000Z',
+  ), '2026-08-04T10:00:00.000Z');
+  assert.equal(maxConversationTimestamp(['', null, 'not-a-time']), null);
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 6 }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+}

+ 160 - 0
claude-code/claude-code-qiwe-assistant/scripts/agent-intake-policy-smoke-test.js

@@ -0,0 +1,160 @@
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const messagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-generic-intake-messages-'));
+process.env.QIWEI_MESSAGES_DIR = messagesDir;
+
+const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
+const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
+const { evaluatePolledMessage } = require('../mcp/src/core/agent-poller-policy');
+const { __testing } = require('../mcp/src/dashboard/agent-service');
+
+const results = [];
+async function check(name, fn) { await fn(); results.push({ name, status: 'passed' }); }
+
+function createContext({ accountKey = 'account-a', mode = 'allowlist_only', welcomeEnabled = false, welcomeSendMode = 'draft', sendSuccess = true } = {}) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-generic-intake-'));
+  const db = new AgentWorkbenchDb(path.join(dir, 'workbench.db'), {
+    globalPaused: false,
+    defaultMode: 'review',
+    autoSendConfidence: 0.88,
+    personalIntakeMode: mode,
+    welcomeEnabled,
+    welcomeText: '您好,已经收到您的消息。',
+    welcomeSendMode,
+  });
+  if (mode === 'auto_enroll_autopilot') {
+    db.setIntakePolicy({ mode, welcomeEnabled, welcomeText: '您好,已经收到您的消息。', welcomeSendMode, autopilotConfirmation: 'v1' });
+  }
+  let currentSendSuccess = sendSuccess;
+  const sent = [];
+  const config = {
+    accountKey,
+    intake: { effectiveAutopilotMode: 'autopilot' },
+    memory: { enabled: false },
+    agent: { apiKey: 'smoke', model: 'stub', provider: 'stub' },
+    qiwei: { accountKey, allowedSenders: [], manualAllowedSenders: [], autoEnrolledSenders: [], intakeAutopilotConversationMode: 'autopilot' },
+  };
+  const qiwei = {
+    isConfigured: () => true,
+    async sendText(contactId, content) {
+      sent.push({ contactId, content });
+      return { isSendSuccess: currentSendSuccess };
+    },
+  };
+  const agent = {
+    modelClient: { isConfigured: () => true },
+    async run() {
+      return { content: '普通 Agent 回复草稿', confidence: 0.5, intent: 'general', reason: 'smoke', requiresHuman: true, profileUpdates: {}, citations: [], toolTrace: [] };
+    },
+  };
+  const service = new AgentWorkbenchService({ db, agent, qiwei, config });
+  const poller = { status: () => ({ running: false }), stop() {} };
+  return {
+    dir, db, config, service, sent,
+    target: { config: config.qiwei, db, service, qiwei },
+    workbenchTarget: { config, db, service, poller },
+    setSendSuccess(value) { currentSendSuccess = value; },
+    close() {
+      service.close?.();
+      db.close();
+      fs.rmSync(dir, { recursive: true, force: true });
+    },
+  };
+}
+
+function inbound(contactId, overrides = {}) {
+  return {
+    msgType: 0,
+    senderId: contactId,
+    senderName: `联系人-${contactId}`,
+    content: '你好,我想咨询一下',
+    timestamp: Math.floor(Date.now() / 1000),
+    seq: Math.floor(Math.random() * 1000000),
+    msgServerId: `message-${contactId}-${Date.now()}-${Math.random()}`,
+    ...overrides,
+  };
+}
+
+async function main() {
+  await check('strict mode and group policy create no onboarding records', async () => {
+    const ctx = createContext();
+    try {
+      const strict = await __testing.ingestMessageForWorkbench(ctx.target, inbound('strict-contact'), 'smoke');
+      assert.equal(strict.status, 'ignored_not_allowlisted');
+      assert.equal(ctx.db.listConversations().length, 0);
+      assert.equal(ctx.db.listOnboardings('account-a').length, 0);
+      const group = evaluatePolledMessage(inbound('group-member', { fromRoomId: 'room-1', chatType: 'group' }), { intakeMode: 'auto_enroll_review', allowedSenders: [] });
+      assert.equal(group.reason, 'group_message');
+    } finally { ctx.close(); }
+  });
+
+  await check('review auto enrollment creates one welcome draft with zero sends', async () => {
+    const ctx = createContext({ mode: 'auto_enroll_review', welcomeEnabled: true });
+    try {
+      const first = await __testing.ingestMessageForWorkbench(ctx.target, inbound('review-contact'), 'smoke');
+      assert.equal(first.status, 'pending_review');
+      assert.equal(ctx.db.getConversationByContactId('review-contact').mode, 'review');
+      assert.equal(ctx.sent.length, 0);
+      const onboarding = ctx.db.getOnboarding('account-a', 'review-contact');
+      assert.ok(onboarding.first_message_id);
+      assert.ok(onboarding.welcome_draft_id);
+      const restarted = { ...ctx.config.qiwei, allowedSenders: ['review-contact'], autoEnrolledSenders: ['review-contact'] };
+      await __testing.ingestMessageForWorkbench({ ...ctx.target, config: restarted }, inbound('review-contact', { content: '继续了解办理流程' }), 'restart');
+      assert.equal(ctx.db.listDrafts({ conversationId: onboarding.conversation_id }).filter(item => item.intent === 'new_contact_welcome').length, 1);
+      assert.equal(ctx.sent.length, 0);
+    } finally { ctx.close(); }
+  });
+
+  await check('generic automatic intake requires confirmation and maps to full autopilot mode', async () => {
+    const ctx = createContext();
+    try {
+      assert.throws(() => __testing.updateIntakePolicyForWorkbench(ctx, { mode: 'auto_enroll_autopilot', welcomeEnabled: true, welcomeText: '欢迎', welcomeSendMode: 'send' }), /二次确认/);
+      const policy = __testing.updateIntakePolicyForWorkbench(ctx, { mode: 'auto_enroll_autopilot', welcomeEnabled: true, welcomeText: '欢迎', welcomeSendMode: 'send', confirmation: 'ENABLE_AUTO_ENROLL_AUTOPILOT' }, 'smoke-admin');
+      assert.equal(policy.autopilotConfirmed, true);
+      assert.equal(policy.effectiveConversationMode, 'autopilot');
+    } finally { ctx.close(); }
+  });
+
+  await check('failed welcome can be retried once and then stays sent', async () => {
+    const ctx = createContext({ mode: 'auto_enroll_autopilot', welcomeEnabled: true, welcomeSendMode: 'send', sendSuccess: false });
+    try {
+      const first = await __testing.ingestMessageForWorkbench(ctx.target, inbound('send-contact'), 'smoke');
+      assert.equal(first.status, 'welcome_send_failed');
+      assert.equal(ctx.db.getConversationByContactId('send-contact').mode, 'autopilot');
+      assert.equal(ctx.db.getOnboarding('account-a', 'send-contact').attempt_count, 1);
+      ctx.setSendSuccess(true);
+      assert.equal((await ctx.service.retryOnboardingWelcome('send-contact', 'smoke-admin')).status, 'welcome_sent');
+      assert.equal(ctx.db.getOnboarding('account-a', 'send-contact').attempt_count, 2);
+      await assert.rejects(() => ctx.service.retryOnboardingWelcome('send-contact', 'smoke-admin'), /明确发送失败/);
+      const next = await __testing.ingestMessageForWorkbench(ctx.target, inbound('send-contact', { content: '继续咨询' }), 'smoke-next');
+      assert.equal(next.status, 'autopilot_sent');
+      assert.equal(ctx.db.listDrafts({ conversationId: next.conversation.id }).length, 0);
+    } finally { ctx.close(); }
+  });
+
+  await check('two accounts keep manual, automatic and listener state isolated', async () => {
+    const accountA = createContext({ accountKey: 'account-a' });
+    const accountB = createContext({ accountKey: 'account-b', mode: 'auto_enroll_review' });
+    try {
+      __testing.updateAllowlistForWorkbench(accountA.workbenchTarget, { contactIds: ['contact-a'], autoStart: true });
+      assert.deepEqual(accountA.config.qiwei.allowedSenders, ['contact-a']);
+      assert.deepEqual(accountB.config.qiwei.allowedSenders, []);
+      assert.equal(accountA.db.getSetting('listener_enabled', 'false'), 'true');
+      assert.equal(accountB.db.getSetting('listener_enabled', 'false'), 'false');
+      await __testing.ingestMessageForWorkbench(accountB.target, inbound('contact-b'), 'smoke-b');
+      __testing.hydrateAccountAllowlist(accountA.config, accountA.db);
+      __testing.hydrateAccountAllowlist(accountB.config, accountB.db);
+      assert.deepEqual(accountA.config.qiwei.allowedSenders, ['contact-a']);
+      assert.deepEqual(accountB.config.qiwei.allowedSenders, ['contact-b']);
+    } finally { accountA.close(); accountB.close(); }
+  });
+
+  console.log(JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2));
+}
+
+main()
+  .finally(() => fs.rmSync(messagesDir, { recursive: true, force: true }))
+  .catch(error => { console.error(error); process.exitCode = 1; });

+ 111 - 0
claude-code/claude-code-qiwe-assistant/scripts/agent-workspace-ui-smoke-test.js

@@ -0,0 +1,111 @@
+const assert = require('assert/strict');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve(__dirname, '..');
+const app = fs.readFileSync(path.join(root, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
+const styles = fs.readFileSync(path.join(root, 'mcp', 'src', 'dashboard', 'styles.css'), 'utf8');
+const results = [];
+
+function check(name, fn) {
+  fn();
+  results.push({ name, status: 'passed' });
+}
+
+function functionSource(name, nextName) {
+  const start = app.indexOf(`function ${name}`);
+  const end = app.indexOf(`function ${nextName}`, start + 1);
+  assert.ok(start >= 0 && end > start, `missing function source: ${name}`);
+  return app.slice(start, end);
+}
+
+check('active Agent view uses a compact command bar and drawers', () => {
+  const source = functionSource('renderAgentWorkspaceV2', 'responseMonitorRenderSignature');
+  assert.match(source, /agent-commandbar/);
+  assert.match(source, /agent-drawer settings/);
+  assert.match(source, /agent-drawer reminders/);
+  assert.doesNotMatch(source, /agent-hero|agent-modebar/);
+});
+
+check('skills workspace does not receive Agent-only drawers', () => {
+  const source = functionSource('renderSkillsWorkspace', 'renderSkillsPage');
+  assert.doesNotMatch(source, /agent-drawer|renderAgentIntakeSettings|renderResponseMonitor/);
+});
+
+check('workspace reserves viewport height and internal scrolling', () => {
+  assert.match(styles, /\.agent-page\s*\{[\s\S]*?height:\s*calc\(100dvh\s*-\s*144px\)/);
+  assert.match(styles, /\.agent-workspace\s*\{[\s\S]*?min-height:\s*0;[\s\S]*?overflow:\s*hidden;/);
+  assert.match(styles, /\.agent-list-scroll\s*\{[^}]*overflow-y:\s*auto;/);
+  assert.match(styles, /\.agent-message-stream\s*\{[\s\S]*?overflow-y:\s*auto;/);
+  assert.match(styles, /\.agent-insight-panel\s*\{[\s\S]*?overflow-y:\s*auto;/);
+});
+
+check('short desktop viewports keep the chat column bounded and usable', () => {
+  assert.match(styles, /\.agent-chat-panel\s*>\s*\*\s*\{\s*min-width:\s*0;/);
+  assert.match(styles, /@media\s*\(min-width:\s*761px\)\s*and\s*\(max-height:\s*760px\)/);
+  assert.match(styles, /\.agent-composer-actions\s*\{\s*align-items:\s*stretch;\s*flex-direction:\s*column;/);
+  assert.match(styles, /\.agent-review-actions\s*\{\s*flex-wrap:\s*nowrap;/);
+});
+
+check('mobile layout is bounded and keeps controls reachable', () => {
+  const mobile = styles.slice(styles.indexOf('@media (max-width: 760px)'));
+  assert.match(mobile, /\.agent-workspace\s*\{[^}]*grid-template-columns:\s*1fr;/);
+  assert.match(mobile, /\.agent-command-actions\s*\{[^}]*overflow-x:\s*auto;/);
+  assert.match(mobile, /\.agent-drawer\s*\{[^}]*width:\s*100vw;/);
+  assert.match(mobile, /\.agent-chat-panel\s*\{[^}]*min-height:\s*720px;[^}]*minmax\(160px,\s*1fr\)/);
+  assert.match(styles, /\.page\.agent-page\s*\{\s*animation:\s*none;\s*transform:\s*none;/);
+});
+
+check('closed Agent drawers do not leave a gray page overlay', () => {
+  assert.match(styles, /\.content\s*>\s*\.agent-page\s*>\s*\*\s*\{\s*animation:\s*none;/);
+  assert.match(styles, /\.agent-drawer-backdrop\s*\{[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;[^}]*transition:\s*none;/);
+  assert.match(styles, /\.agent-drawer\s*\{[^}]*visibility:\s*hidden;[^}]*transition:\s*none;/);
+  assert.match(styles, /\.agent-drawer-backdrop\.open\s*\{[^}]*opacity:\s*1;[^}]*pointer-events:\s*auto;[^}]*transition:\s*none;/);
+  assert.match(styles, /\.agent-drawer\.open\s*\{[^}]*visibility:\s*visible;[^}]*transition:\s*none;/);
+});
+
+check('conversation ordering has explicit invalid-time and stable-tie behavior', () => {
+  assert.match(app, /Number\.NEGATIVE_INFINITY/);
+  assert.match(app, /b\.timestamp\s*-\s*a\.timestamp\s*\|\|\s*a\.index\s*-\s*b\.index/);
+  assert.match(app, /sortAgentItemsByLatest\(conversations\.filter/);
+});
+
+check('activity timeline uses existing trace and audit without raw JSON rendering', () => {
+  const source = functionSource('renderAgentActivityTimeline', 'scrollAgentMessagesToLatest');
+  assert.match(source, /Plan/);
+  assert.match(source, /Act/);
+  assert.match(source, /Observe/);
+  assert.match(source, /Evaluate/);
+  assert.doesNotMatch(source, /JSON\.stringify/);
+  assert.doesNotMatch(app, /JSON\.stringify\(viewModel\.toolTrace\[0\]/);
+});
+
+check('Agent theme is semantic and tenant-overridable', () => {
+  for (const token of ['brand', 'page-bg', 'panel', 'ink', 'line', 'focus', 'success', 'warning', 'error']) {
+    assert.match(styles, new RegExp(`--agent-${token}:`));
+  }
+  assert.match(styles, /\.agent-page\[data-theme="xiaoniu"\]/);
+  assert.match(app, /page\.dataset\.theme/);
+});
+
+check('intake settings use the fixed API contract and explicit confirmation', () => {
+  assert.match(app, /\/api\/agent\/intake-policy/);
+  assert.match(app, /welcomeEnabled/);
+  assert.match(app, /welcomeText/);
+  assert.match(app, /welcomeSendMode/);
+  assert.match(app, /ENABLE_AUTO_ENROLL_AUTOPILOT/);
+});
+
+check('private chat exposes confirmed full autopilot separately from high-confidence auto', () => {
+  const server = fs.readFileSync(path.join(root, 'mcp', 'src', 'dashboard', 'server.js'), 'utf8');
+  assert.match(app, /data-agent-mode="autopilot">全自动接管/);
+  assert.match(app, /data-agent-conversation-mode="autopilot"/);
+  assert.match(app, /全自动接管会向白名单中的/);
+  assert.match(app, /该会话将进入全自动接管/);
+  assert.match(app, /confirmation: 'ENABLE_AUTOPILOT'/);
+  assert.match(app, /新消息的非空 Agent 回复将直接发送,不创建待审核草稿/);
+  assert.match(styles, /\.agent-composer\.autopilot/);
+  assert.match(server, /takeover\|resume\|pause\|auto\|autopilot/);
+});
+
+console.log(JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2));

+ 58 - 50
claude-code/claude-code-qiwe-assistant/scripts/customer-journey-smoke-test.js

@@ -5,67 +5,75 @@ const path = require('path');
 
 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-customer-journey-'));
 process.env.QIWEI_OUTPUTS_DIR = tempDir;
-process.env.QIWEI_AGENT_DB_PATH = path.join(tempDir, 'messages', 'agent-workbench.db');
 
 async function main() {
-  const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
-  const db = new AgentWorkbenchDb(process.env.QIWEI_AGENT_DB_PATH, { globalPaused: true, defaultMode: 'review' });
-  const conversation = db.ensureConversation('external-test-1', '测试客户');
-  const property = { id: 'P-101', community: '测试花园', district: '新北区', totalPrice: 188, layout: '三室', area: 96, decoration: '精装' };
-  db.upsertCustomerRecommendations(conversation.id, [property], { type: 'outbound-message', entityId: 'message-1', status: 'recommended', evidence: '已向客户推荐测试花园 188 万', createdAt: '2026-07-17T01:00:00Z' });
-  db.upsertCustomerRecommendations(conversation.id, [property], { type: 'outbound-message', entityId: 'message-1', status: 'recommended', evidence: '重复同步', createdAt: '2026-07-17T01:00:00Z' });
-  assert.equal(db.listCustomerRecommendations(conversation.id)[0].recommend_count, 1);
-  db.upsertCustomerRecommendations(conversation.id, [property], { type: 'outbound-message', entityId: 'message-2', status: 'recommended', evidence: '第二次推荐', createdAt: '2026-07-18T01:00:00Z' });
-  assert.equal(db.listCustomerRecommendations(conversation.id)[0].recommend_count, 2);
-  const recommendationId = db.listCustomerRecommendations(conversation.id)[0].id;
-  const feedback = db.updateCustomerRecommendation(conversation.id, recommendationId, { status: 'rejected', feedbackReason: '楼层不合适' });
-  assert.equal(feedback.status, 'rejected');
-  assert.equal(feedback.feedback_reason, '楼层不合适');
-  db.close();
-
-  const { __testing: agentTesting } = require('../mcp/src/dashboard/agent-service');
-  assert.equal(agentTesting.detectedPropertiesInMessage('推荐测试花园188万三室', [property]).length, 1);
-  assert.equal(agentTesting.detectedPropertiesInMessage('推荐测试花园,但价格待确认', [property]).length, 0);
+  let updateCall = null;
+  const conversation = {
+    id: 'conversation-1',
+    channelId: 'external-test-1',
+    displayName: '测试客户',
+    maskedId: 'ex***-1',
+    source: 'live',
+    mode: 'review',
+    analysis: { completenessScore: 50 },
+    messages: [
+      { id: 'm1', role: 'customer', source: 'live', content: '预算 15 万,想了解企业服务方案', timestamp: '2026-07-17T00:00:00Z' },
+      { id: 'm2', role: 'human', source: 'live', content: '收到,我先确认您的计划时间。', timestamp: '2026-07-17T00:05:00Z' },
+    ],
+    customerIntelligence: {
+      profile: { need: '企业服务方案', budgetWan: 15, timeline: '待确认' },
+      profileEvidence: {
+        need: { text: '想了解企业服务方案', sourceMessageId: 'm1', source: 'live-message', updatedAt: '2026-07-17T00:00:00Z' },
+      },
+      tags: ['企业客户'],
+      tasks: [{ id: 't1', title: '确认计划时间', status: 'open', evidence: '计划时间未确认', updatedAt: '2026-07-17T00:10:00Z' }],
+      alerts: [{ id: 'a1', title: '重点跟进', severity: 'high', status: 'open', evidence: '客户已明确需求和预算', updatedAt: '2026-07-17T00:11:00Z' }],
+      summary: { openTasks: 1, openAlerts: 1, highAlerts: 1 },
+    },
+    lastMessageAt: '2026-07-17T00:05:00Z',
+    updatedAt: '2026-07-17T00:11:00Z',
+  };
 
-  let feedbackCall = null;
   const { createCustomerMasterService } = require('../mcp/src/dashboard/customer-master-service');
   const service = createCustomerMasterService({
     projectionPath: path.join(tempDir, 'customers', 'index.json'),
-    getConversations: () => ({ data: { conversations: [{
-      id: conversation.id,
-      displayName: '测试客户',
-      maskedId: 'ex***-1',
-      source: 'live',
-      mode: 'review',
-      analysis: { completenessScore: 50 },
-      messages: [{ id: 'm1', role: 'customer', source: 'live', content: '预算 200 万,想看三室', timestamp: '2026-07-17T00:00:00Z' }],
-      customerIntelligence: {
-        profile: { preferredRegion: '新北区', budgetWan: 200, layout: '三室', budgetType: '待确认' },
-        profileEvidence: {},
-        tags: [],
-        tasks: [{ id: 't1', title: '确认用途与时间', status: 'open', evidence: '用途和时间未确认', updatedAt: '2026-07-17T00:10:00Z' }],
-        alerts: [],
-        recommendations: [{ id: recommendationId, propertyId: property.id, property, status: 'recommended', feedbackReason: '', recommendCount: 2, sources: [], firstRecommendedAt: '2026-07-17T01:00:00Z', lastRecommendedAt: '2026-07-18T01:00:00Z' }],
-        summary: { openTasks: 1, openAlerts: 0, highAlerts: 0, recommendationCount: 1, pendingFeedbackCount: 1 },
-      },
-      lastMessageAt: '2026-07-17T00:00:00Z',
-      updatedAt: '2026-07-17T00:00:00Z',
-    }] } }),
-    updateCustomerProfile: () => ({ status: 'ok' }),
-    updateCustomerRecommendation: (customerId, id, input) => { feedbackCall = { customerId, id, input }; return { status: 'ok', assistantMessage: '反馈已记录', summary: { status: input.status } }; },
+    getConversations: () => ({ data: { conversations: [conversation] } }),
+    updateCustomerProfile: (customerId, input) => {
+      updateCall = { customerId, input };
+      conversation.customerIntelligence.profile = { ...conversation.customerIntelligence.profile, ...(input.profile || {}) };
+      return { status: 'ok', assistantMessage: '客户主档已更新。', summary: { updated: true } };
+    },
   });
+
   const hub = service.hub();
   const customer = hub.data.customers[0];
-  assert(customer.nextActions.some(item => item.id === 'confirm-purpose'));
-  assert(customer.nextActions.some(item => item.id === 'collect-property-feedback'));
-  assert(customer.timeline.some(item => item.type === 'property'));
+  assert.equal(hub.status, 'ok');
+  assert.equal(hub.summary.customerCount, 1);
+  assert.equal(hub.summary.priorityCount, 1);
+  assert.equal(customer.stage.key, 'priority');
+  assert.equal(customer.evidenceCoverage, 33);
+  assert(customer.nextActions.some(item => item.id === 'confirm-purpose') === false);
+  assert(customer.nextActions.some(item => item.id === 'confirm-timeline'));
   assert(customer.timeline.some(item => item.type === 'inbound-message'));
-  assert.equal(customer.pendingFeedbackCount, 1);
-  const feedbackResult = service.updateRecommendation(conversation.id, recommendationId, { status: 'interested', feedbackReason: '小区合适' });
-  assert.equal(feedbackResult.status, 'ok');
-  assert.equal(feedbackCall.input.status, 'interested');
+  assert(customer.timeline.some(item => item.type === 'outbound-message'));
+  assert(customer.timeline.some(item => item.type === 'task'));
+  assert(customer.timeline.some(item => item.type === 'alert'));
+  assert.equal(customer.openTaskCount, 1);
+  assert.equal(customer.highAlertCount, 1);
+  assert.equal(fs.existsSync(path.join(tempDir, 'customers', 'index.json')), true);
+
+  const updated = service.update(conversation.id, { profile: { timeline: '本月内' }, reason: '客户确认' });
+  assert.equal(updated.status, 'ok');
+  assert.equal(updateCall.customerId, conversation.id);
+  assert.equal(updateCall.input.profile.timeline, '本月内');
 
-  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, recommendationCount: customer.recommendationCount, nextActions: customer.nextActions.length, timelineEvents: customer.timeline.length }, null, 2)}\n`);
+  process.stdout.write(`${JSON.stringify({
+    status: 'ok',
+    checks: 16,
+    customerCount: hub.summary.customerCount,
+    nextActions: customer.nextActions.length,
+    timelineEvents: customer.timeline.length,
+  }, null, 2)}\n`);
 }
 
 main().catch(error => {

+ 7 - 7
claude-code/claude-code-qiwe-assistant/scripts/customer-master-smoke-test.js

@@ -17,14 +17,14 @@ async function main() {
     source: 'live',
     mode: 'review',
     analysis: { completenessScore: 50 },
-    messages: [{ id: 'm1', role: 'customer', content: '预算 200 万,想看三室', timestamp: '2026-07-17T01:00:00Z' }],
+    messages: [{ id: 'm1', role: 'customer', content: '预算 20 万,想了解企业培训服务', timestamp: '2026-07-17T01:00:00Z' }],
     customerIntelligence: {
-      profile: { budgetWan: 200, layout: '三室', preferredRegion: '新北区' },
-      profileEvidence: { budgetWan: { text: '预算 200 万', sourceMessageId: 'm1', updatedAt: '2026-07-17T01:00:00Z' } },
+      profile: { budgetWan: 20, need: '企业培训服务', timeline: '本月内' },
+      profileEvidence: { budgetWan: { text: '预算 20 万', sourceMessageId: 'm1', updatedAt: '2026-07-17T01:00:00Z' } },
       profileUpdatedAt: '2026-07-17T01:00:00Z',
       tags: ['高预算'],
       tasks: [{ id: 't1', title: '确认用途', status: 'open', evidence: '用途尚未明确' }],
-      alerts: [{ id: 'a1', title: '需求已成形', status: 'open', severity: 'high', evidence: '预算和户型明确' }],
+      alerts: [{ id: 'a1', title: '需求已成形', status: 'open', severity: 'high', evidence: '预算和服务需求明确' }],
       summary: { openTasks: 1, openAlerts: 1, highAlerts: 1 },
     },
     lastMessageAt: '2026-07-17T01:00:00Z',
@@ -52,13 +52,13 @@ async function main() {
   assert.equal(hub.data.customers[0].openTaskCount, 1);
   assert(fs.existsSync(projectionPath));
   const projection = fs.readFileSync(projectionPath, 'utf8');
-  assert.doesNotMatch(projection, /预算 200 万,想看三室|预算 200 万/);
+  assert.doesNotMatch(projection, /预算 20 万,想了解企业培训服务|预算 20 万/);
   assert.doesNotMatch(projection, /externalUserId|contact_id/);
 
-  const updated = service.update('conversation-live-1', { profile: { intent: '自住' }, reason: '人工确认' });
+  const updated = service.update('conversation-live-1', { profile: { intent: '采购服务' }, reason: '人工确认' });
   assert.equal(updated.status, 'ok');
   assert.equal(updateCall.id, 'conversation-live-1');
-  assert.equal(updateCall.input.profile.intent, '自住');
+  assert.equal(updateCall.input.profile.intent, '采购服务');
   assert.equal(updateCall.input.reason, '人工确认');
 
   process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 14, customers: hub.summary.customerCount, evidenceProjectionSafe: true }, null, 2)}\n`);

+ 8 - 8
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -120,7 +120,7 @@ async function startMockGateway({ subscribed = true } = {}) {
           nextStartIndex: 0,
           roomCount: 2,
           roomList: [
-            { roomId: 'r-1', roomName: '张三买房服务群', roomMemberCount: 3 },
+            { roomId: 'r-1', roomName: '张三客户服务群', roomMemberCount: 3 },
             { roomId: 'r-2', roomName: 'random-group', roomMemberCount: 2 }
           ]
         };
@@ -137,7 +137,7 @@ async function startMockGateway({ subscribed = true } = {}) {
         data = {
           roomList: (body.params.roomIdList || []).map(roomId => ({
             roomId,
-            roomName: roomId === 'r-1' ? '张三买房服务群' : '其他群',
+            roomName: roomId === 'r-1' ? '张三客户服务群' : '其他群',
             roomMemberCount: roomId === 'r-1' ? 3 : 2
           }))
         };
@@ -562,7 +562,7 @@ async function main() {
     const messageFile = path.join(outputsRoot(), 'messages', 'room-r1-test-portrait.json');
     fs.mkdirSync(path.dirname(messageFile), { recursive: true });
     fs.writeFileSync(messageFile, JSON.stringify([
-      { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想买房,预算200万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) }
+      { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想采购企业培训服务,预算20万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) }
     ]));
 
     const prepared = await qiweiPrepareCustomerPortrait({ externalUserId: 'wx-u-1', roomIds: ['r1'] });
@@ -575,8 +575,8 @@ async function main() {
     assert.strictEqual(updated.summary.updateMode, 'full');
 
     fs.writeFileSync(messageFile, JSON.stringify([
-      { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想买房,预算200万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) },
-      { fromRoomId: 'r1', seq: 3, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '还想看看学区房', msgUniqueIdentifier: 'm-3', timestamp: Math.floor(Date.now() / 1000) + 1 }
+      { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想采购企业培训服务,预算20万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) },
+      { fromRoomId: 'r1', seq: 3, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '还想了解交付和集成方式', msgUniqueIdentifier: 'm-3', timestamp: Math.floor(Date.now() / 1000) + 1 }
     ]));
     const incremental = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', updateMode: 'incremental', roomIds: ['r1'] });
     assert.strictEqual(incremental.status, 'ok');
@@ -587,10 +587,10 @@ async function main() {
     assert.strictEqual(noChanges.status, 'ok');
     assert.strictEqual(noChanges.summary.skipped, true);
 
-    const saved = await qiweiSaveCustomerPortrait({ externalUserId: 'wx-u-1', portrait: { intent: '购房', budgetRange: '200万' } });
+    const saved = await qiweiSaveCustomerPortrait({ externalUserId: 'wx-u-1', portrait: { intent: '采购企业培训服务', budgetRange: '20万' } });
     assert.strictEqual(saved.status, 'ok');
 
-    const tagsAdded = await qiweiAddCustomerTags({ externalUserId: 'wx-u-1', tags: ['高意向', '购房'] });
+    const tagsAdded = await qiweiAddCustomerTags({ externalUserId: 'wx-u-1', tags: ['高意向', '企业客户'] });
     assert.strictEqual(tagsAdded.status, 'ok');
     assert.strictEqual(tagsAdded.summary.total, 2);
 
@@ -611,7 +611,7 @@ async function main() {
 
     const brokerMessageFile = path.join(outputsRoot(), 'messages', 'room-r2-test-playbook.json');
     fs.writeFileSync(brokerMessageFile, JSON.stringify([
-      { fromRoomId: 'r2', seq: 1, senderId: 'broker-1', senderName: '顾问', msgType: 1, content: '您好,这个户型很适合您,预算也匹配', msgUniqueIdentifier: 'bm-1', timestamp: Math.floor(Date.now() / 1000) }
+      { fromRoomId: 'r2', seq: 1, senderId: 'broker-1', senderName: '顾问', msgType: 1, content: '您好,这个服务版本很适合您,预算也匹配', msgUniqueIdentifier: 'bm-1', timestamp: Math.floor(Date.now() / 1000) }
     ]));
 
     const brokerPrepared = await qiweiPrepareBrokerPlaybook({ brokerUserId: 'broker-1', roomIds: ['r2'] });

+ 28 - 2
claude-code/claude-code-qiwe-assistant/scripts/voice-clone-smoke-test.js

@@ -16,6 +16,7 @@ const {
 } = require('../mcp/src/core/voice-clone-service');
 const { __testing: voiceToolTesting } = require('../mcp/src/tools/qiwei-voice-run');
 const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
+const { readFmodeVoiceToken, readQiweiAuthToken } = require('../mcp/src/core/credentials');
 
 function wavBuffer({ duration = 1, sampleRate = 16000, frequency = 220 } = {}) {
   const samples = Math.floor(duration * sampleRate);
@@ -41,6 +42,29 @@ function wavBuffer({ duration = 1, sampleRate = 16000, frequency = 220 } = {}) {
 }
 
 async function main() {
+  const emptySources = { processEnv: {}, fileEnv: {}, claudeEnv: {}, fmodeConfig: {} };
+  assert.equal(readFmodeVoiceToken(
+    { authToken: 'parse-session-token' },
+    { ...emptySources, processEnv: { FMODE_API_TOKEN: 'sk-fmode-voice' } },
+  ), 'sk-fmode-voice');
+  assert.equal(readFmodeVoiceToken({ authToken: 'parse-session-token' }, emptySources), '');
+  assert.equal(readFmodeVoiceToken(
+    {},
+    { ...emptySources, processEnv: { ANTHROPIC_AUTH_TOKEN: 'sk-ant-upstream', ANTHROPIC_BASE_URL: 'https://api.anthropic.com' } },
+  ), '');
+  assert.equal(readFmodeVoiceToken(
+    {},
+    { ...emptySources, processEnv: { FMODE_API_TOKEN: 'sk-other-provider', FMODE_API_BASE: 'https://api.openai.com' } },
+  ), '');
+  assert.equal(readQiweiAuthToken({ authToken: 'parse-session-token' }), 'parse-session-token');
+
+  const sessionOnlyVoice = new VoiceCloneService({
+    config: { authToken: 'parse-session-token' },
+    qiwei: { context: () => ({ token: 'parse-session-token' }) },
+  });
+  assert.equal(sessionOnlyVoice.status().configured, false);
+  assert.throws(() => sessionOnlyVoice.requireReady(), /https:\/\/api\.fmode\.cn\/keys\//);
+
   assert.equal(inferVoiceTone('非常抱歉给您带来了不好的体验').id, 'apology');
   assert.equal(inferVoiceTone('恭喜,您的申请已经审核通过').id, 'friendly');
   assert.equal(inferVoiceTone('提醒您,请于明天下午前提交资料').id, 'reminder');
@@ -62,7 +86,7 @@ async function main() {
   const calls = [];
   let sendSuccess = true;
   const qiwei = {
-    context: () => ({ token: 'fmode-smoke-token', uid: 'account-smoke', guid: 'guid-smoke' }),
+    context: () => ({ token: 'parse-session-token', uid: 'account-smoke', guid: 'guid-smoke' }),
     isConfigured: () => true,
     async uploadVoiceFile(filePath) {
       calls.push({ type: 'upload', filePath });
@@ -78,6 +102,7 @@ async function main() {
     qiwei,
     config: {
       endpoint: 'https://server.fmode.cn/api/voice/indextts2',
+      authToken: 'sk-fmode-smoke-token',
       model: 'fmode-voice',
     },
   });
@@ -95,7 +120,7 @@ async function main() {
   const generated = wavBuffer({ duration: 1.2, sampleRate: 22050, frequency: 330 });
   global.fetch = async (url, options = {}) => {
     assert.equal(url, 'https://server.fmode.cn/api/voice/indextts2');
-    assert.equal(options.headers.Authorization, 'Bearer fmode-smoke-token');
+    assert.equal(options.headers.Authorization, 'Bearer sk-fmode-smoke-token');
     const payload = JSON.parse(options.body.get('payload'));
     assert.equal(payload.use_random, false);
     assert.equal([0, 3].includes(payload.emo_control_method), true);
@@ -149,6 +174,7 @@ async function main() {
     guid: 'upload-guid',
     apiBase: 'https://server.fmode.cn/api/qiwei',
   });
+  assert.equal(uploadClient.context().token, 'fmode-upload-token');
   const silkPath = path.join(root, 'upload.silk');
   fs.writeFileSync(silkPath, Buffer.from('silk-smoke'));
   global.fetch = async (url, options = {}) => {

+ 8 - 11
claude-code/claude-code-qiwe-assistant/scripts/workspace-library-smoke-test.js

@@ -8,28 +8,25 @@ function flatten(nodes = []) {
 function main() {
   const registry = service.listSkillRegistry();
   assert.equal(registry.status, 'ok');
-  assert.ok(registry.data.summary.packageCount >= 2);
+  assert.ok(registry.data.summary.packageCount >= 1);
   assert.ok(registry.data.summary.skillCount >= 18);
   assert.ok(registry.data.summary.uniqueCapabilityCount >= 16);
 
   const tree = service.listKnowledgeTree();
   assert.equal(tree.status, 'ok');
-  assert.ok(tree.data.summary.libraryCount >= 6);
+  assert.ok(tree.data.summary.libraryCount >= 5);
   assert.ok(tree.data.summary.fileCount >= 20);
-  assert.equal(tree.data.summary.propertyCount, 30);
+  assert.equal(Object.hasOwn(tree.data.summary, 'propertyCount'), false);
 
   const nodes = flatten(tree.data.roots);
   const rules = nodes.find(node => node.name === 'rules.md');
   assert.ok(rules, 'rules.md should exist');
   const rulesFile = service.readKnowledgeFile(rules.id);
   assert.equal(rulesFile.data.kind, 'markdown');
-  assert.match(rulesFile.data.content, /不编造房源/);
-
-  const properties = service.listProperties({ district: '新北区', maxPrice: 150, pageSize: 60 });
-  assert.ok(properties.data.items.length > 0);
-  assert.ok(properties.data.items.every(item => item.district.includes('新北区') && item.totalPrice <= 150));
-  const detail = service.getProperty(properties.data.items[0].id);
-  assert.ok(detail.data.property.community);
+  assert.match(rulesFile.data.content, /不编造价格/);
+  assert.equal(typeof service.listProperties, 'undefined');
+  assert.equal(typeof service.getProperty, 'undefined');
+  assert.equal(registry.data.packages.flatMap(item => item.skills).some(item => item.name === 'qiwei-real-estate-auto-reply'), false);
 
   const firstSkill = registry.data.packages[0].skills[0];
   const skill = service.getSkillDetail(firstSkill.id);
@@ -47,7 +44,7 @@ function main() {
     skillCount: registry.data.summary.skillCount,
     libraryCount: tree.data.summary.libraryCount,
     fileCount: tree.data.summary.fileCount,
-    propertyCount: tree.data.summary.propertyCount,
+    propertySurfaceRemoved: true,
     pathTraversalBlocked: true,
   }, null, 2)}\n`);
 }

+ 0 - 1
claude-code/claude-code-qiwe-assistant/skill-package-manifest.json

@@ -75,7 +75,6 @@
     "qiwei-official-schedule",
     "qiwei-official-todo",
     "qiwei-portrait-tags",
-    "qiwei-real-estate-auto-reply",
     "qiwei-voice",
     "qiwei-webhook-relay"
   ],

+ 1 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-business-diagnosis/SKILL.md

@@ -34,7 +34,7 @@ description: 基于真实企业微信客户主档、会话、画像、任务和
 ## 路由
 
 - 客户主档、重点跟进、画像缺口:`qiwei-customer-ops` 或 `qiwei-portrait-tags`。
-- 客服草稿、人工接管、真实会话:`qiwei-real-estate-auto-reply`。
+- 客服草稿、人工接管、真实会话:`qiwei-dashboard`。
 - 社群计划、风险、SOP 和质检:`qiwei-group-operations`。
 - 客户群未发现或消息未同步:`qiwei-group-management`。
 - 订单或经营文件接入:先放入知识库,再重新诊断。

+ 4 - 6
claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md

@@ -12,19 +12,17 @@ description: 迁移自 Qiwei 项目的客户群运营能力:批量搜索并添
 - 权威画像、标签、任务和预警保存在 `outputs/messages/agent-workbench.db`;
 - Agent 从真实客户消息提取画像字段时,自动记录字段来源消息和更新时间;
 - `qiwei-portrait-tags` 保存的画像与标签也回写同一主账,不另建冲突档案;
-- 客户管理页可人工核对区域、预算、户型、用途、购置时间和标签,修改只更新内部主档,不向客户发送消息;
+- 客户管理页可人工核对目标、需求、预算、计划时间、决策人和标签,修改只更新内部主档,不向客户发送消息;
 - `outputs/customers/index.json` 是脱敏查询投影,不保存联系人原始 ID、机器人凭据或客户原话。
 
 客户主档同时提供三类经营能力:
 
-1. **客户时间线**:按时间统一展示真实消息、画像更新、内部任务、预警、房源推荐和反馈
-2. **下一步行动**:依据画像缺口、待办、预警和推荐反馈生成内部建议,必须标明依据,不自动外发;
-3. **房源推荐历史**:记录房源快照、推荐次数和人工确认的感兴趣/不合适/带看反馈。历史消息回填只在“小区名 + 明确价格”同时匹配时成立,不能猜客户态度
+1. **客户时间线**:按时间统一展示真实消息、画像更新、内部任务和预警
+2. **下一步行动**:依据画像缺口、待办和预警生成内部建议,必须标明依据,不自动外发;
+3. **证据追溯**:画像、任务和预警保留来源消息与更新时间,不能把模型推断写成已确认事实
 
 客户会话产生的内部任务可以从客户主档跳转到智能会话继续处理,但不能把内部任务或画像字段原样发送给客户。
 
-房源反馈由工作人员点击确认后写入主账和画像反馈记录;“Agent 候选”“已推荐”“客户感兴趣”是不同状态,不能混用。
-
 ## 使用边界
 
 本 skill 只负责客户运营业务动作:

+ 12 - 5
claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md

@@ -12,8 +12,8 @@ description: 启动并预览 Qiwei Dashboard(本地 Web 操作界面),包
 - 启动本地 Dashboard Web 服务;
 - 确认服务健康、登录状态和订阅状态;
 - 在登录失效时引导用户恢复登录;
-- 浏览已接入的源码版、OpenClaw、Agent Workbench 和房源匹配能力;
-- 以只读方式预览 Agent 规则、技能说明、房源数据和运行输出;
+- 浏览已接入的源码版、OpenClaw、Agent Workbench 和通用企微能力;
+- 以只读方式预览 Agent 规则、技能说明、知识文件和运行输出;
 - 展示经营总览、诊断证据、数据缺口、今日行动和数字员工状态;
 - 在统一任务中心聚合客户跟进、目标、官方待办及文档/会议/诊断候选;
 - 对已完成的诊断行动记录仅供内部使用的效果反馈;
@@ -54,7 +54,7 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 1. `qiwei_agent_bind_controller`:自动检测失败时显式绑定项目主控 Session;
 2. `qiwei_agent_status`:读取账号、监听、Agent 和会话状态;
 3. `qiwei_agent_listener`:个人版控制本地监听;企业版回调自动常驻,该工具只返回回调状态;
-4. `qiwei_agent_set_global`:设置 paused、review、auto 或 human;
+4. `qiwei_agent_set_global`:设置 paused、review、auto、autopilot 或 human;其中 `auto` 仅发送高置信且无需人工的回复,`autopilot` 为经二次确认后直接发送非空 Agent 回复的全自动接管,调用时需同时传 `confirmation=ENABLE_AUTOPILOT`;
 5. `qiwei_agent_list_conversations`:读取客户会话和待审核草稿;
 6. `qiwei_agent_inbox`:读取前端、监听器、Agent 与人工操作事件;
 7. `qiwei_agent_generate_draft`:让客户专属 Claude Code Session 生成待审核草稿,不发送;
@@ -72,6 +72,15 @@ node .claude/plugins/qiwei-assistant/install.js preview .
 
 只提示第一项需要处理的动作,完成后重新检查。Dashboard 只是管理界面;企业版回调和 Skill 会话读取均不依赖页面是否打开。
 
+## 个人消息接入策略
+
+- 手动白名单、自动纳入名单和监听开关都按当前企微账号保存在独立 Workbench DB;扫码切换账号时不得广播到其他已打开账号。当前账号运行时白名单等于该账号的手动名单与自动纳入名单并集。
+- `allowlist_only` 是默认策略。未知私聊不创建会话、不写客户消息;未知群聊始终不自动纳入。
+- `auto_enroll_review` 只对新私聊生效,自动写入当前账号白名单并强制会话为 `review`,不继承全局自动模式。
+- `auto_enroll_autopilot` 仅在管理员输入固定确认词 `ENABLE_AUTO_ENROLL_AUTOPILOT` 后启用;新会话映射为 `autopilot`,非空 Agent 回复直接发送且不创建待审核草稿。
+- `auto` 与 `autopilot` 是两个独立模式:前者保留置信度与人工风险门槛,后者只允许当前账号白名单私聊,并要求全局或单会话显式危险确认。
+- 欢迎语设置字段固定为 `welcomeEnabled`、`welcomeText`、`welcomeSendMode=draft|send`,默认生成草稿。欢迎状态按账号和联系人幂等;发送失败可人工重试,进程中断留下的 `delivery_unknown` 先人工核对,不自动重复发送。
+
 ## 项目主控与客户会话
 
 - 把客户初始化并安装技能包的文件夹视为一个独立项目边界。
@@ -120,8 +129,6 @@ Qiwei Dashboard 已启动:http://127.0.0.1:4320/
 GET /api/skills
 GET /api/knowledge/tree
 GET /api/knowledge/file?id=<node-id>
-GET /api/knowledge/properties
-GET /api/knowledge/properties/<property-id>
 GET /api/knowledge/tasks
 POST /api/business-diagnosis
 ```

+ 0 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/output-contracts.md

@@ -21,4 +21,3 @@
 - 风险:高风险与未闭环总数
 - 群范围与数据日期
 - 下一步动作,不输出不可解释的员工排名
-

+ 3 - 3
claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md

@@ -81,8 +81,8 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 {
   "externalUserId": "wx-u-1",
   "portrait": {
-    "intent": "购房",
-    "budgetRange": "200-300万",
+    "intent": "采购企业服务",
+    "budgetRange": "10-20万",
     "aiSummary": "..."
   }
 }
@@ -105,7 +105,7 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 ### 本地标签
 
-- `qiwei_add_customer_tags`: `{ "externalUserId": "wx-u-1", "tags": ["高意向", "学区"] }`
+- `qiwei_add_customer_tags`: `{ "externalUserId": "wx-u-1", "tags": ["高意向", "重点客户"] }`
 - `qiwei_remove_customer_tags`: 同上
 - `qiwei_list_customer_tags`: `{ "externalUserId": "wx-u-1" }`
 - `qiwei_list_all_tags`: 无入参

+ 0 - 57
claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md

@@ -1,57 +0,0 @@
----
-name: qiwei-real-estate-auto-reply
-description: 管理企业微信房产 Agent、客户私聊、客户群消息采集、需求画像、房源匹配、回复草稿和人工接管。客户群只采集新消息并沉淀画像,不进入客服回复链路;企微收发统一通过 Fmode 网关。
----
-
-# 房产 AI 智能会话
-
-## 前置检查
-
-1. 调用 `qiwei_subscription_status` 和 `qiwei_login_status`,确认订阅有效且设备在线。
-2. 优先在 Dashboard「管理白名单」中搜索并勾选测试联系人;页面不可用时才手工配置 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS`。白名单为空时必须拒绝监听。
-3. 默认使用技能包内置的脱敏房源案例;客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖。
-4. 推荐设置 `AGENT_PROVIDER=claude-code`,复用客户项目中的 Claude Code/Fmode 模型配置。
-5. 默认使用 `review`:生成待审核草稿,不自动发送。
-6. 已确认客户群的新消息只落盘并触发画像增量更新,不生成草稿、不自动或人工发送群回复。
-
-## 启动与接管
-
-在技能包根目录启动工作台:
-
-```bash
-npm run dashboard
-```
-
-企业版无需打开 Dashboard 或启动 AI 监听。MCP/Skill 启动后会自动连接公网 Relay,回调消息进入共享 Workbench;全局暂停或切换“人工接管”后 Agent 不再处理,但消息仍持续接收和保存。个人版仍可用 `qiwei_agent_listener` 控制主动监听。
-
-客户新消息通过公网 Relay 回调接收,消息发送通过 Fmode 网关执行 `/msg/sendText`;登录状态使用 Fmode 专用 `/login/status`,不读取、不保存底层企微 Token,也不访问本地直连接口。
-
-## 客户 Session
-
-每个白名单客户绑定独立 Claude Code Session,后续通过 resume 延续上下文,不复用操作者的日常会话。
-
-排查客户判断时先运行:
-
-```bash
-npm run agent:session:list
-npm run agent:session -- --customer <客户名称>
-```
-
-打开的是 fork 后的审阅副本,可查看完整提示、模型思考和工具轨迹,不会改写生产客户 Session。不要复制、展示或手工拼接原始 Session ID。
-
-## 画像、待办和推荐联动
-
-- 只把客户本轮真实原话视为权威证据;语音转写残缺、无关测试消息不得直接写入画像。
-- 更新区域、预算、户型、用途或购置时间时,同时写入统一客户主账和字段证据。
-- 同一业务待办使用稳定业务键聚合多条依据,避免重复卡片。
-- `search_properties` 结果先记录为“Agent 候选”;方案实际发送后才标记“已推荐”。
-- 兴趣、拒绝原因和带看状态必须来自真实回复或人工确认,下一轮匹配应避开已明确不合适的房源。
-
-## 安全约束
-
-- 不处理或发送给非白名单联系人。
-- 不为未确认的群生成或发送回复;已确认群也必须人工批准草稿后才能真实群发。
-- 全局暂停、人工模式或审核未通过时不得自动外发。
-- 不在代码、日志、回复或 Skill 文件中输出 Fmode token、AI 密钥、设备标识或 Relay 私钥。
-- 先用测试企微与脱敏案例验收,再扩大联系人范围。
-- 同一账号只运行一个消息消费者,避免轮询与 Relay 重复处理。

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