瀏覽代碼

chore(harness): harness、CLAUDE.md、agents.md 纳入版本控制

依据参考资料的原则「计划、质量、技术债和代码一起版本化」——团队 clone
应当能看到状态、计划与契约文档,而不是只存在于某个人的本机。

- .gitignore 去掉 /harness、/CLAUDE.md、/agents.md 三条排除
- 仍然排除带凭据的本机文件:.env.*.local、.claude/settings.local.json
- 新增排除 harness/tools/_*.mjs:esbuild 从 _entry-*.ts 生成的构建产物,
  入库会与源码漂移、让人误以为测的是新代码

顺带修掉一个由此暴露的真问题:_policy-library-utils.mjs 的入口文件从来
没被保留(当初生成产物后丢了入口),产物一旦不入库,政策库验证脚本就没
人能重跑。已补 _entry-policy-library.ts,并把三个产物可抄的重新生成命令
写进 tools/README.md(含 --define:import.meta.env='{}' 这个坑)。

入库前做了凭据扫描:harness 内只有 legacy/API.md 两处带 ... 的截断示例,
无真实 token;.env.development.local(含 DMS token)经 check-ignore 确认排除。

验证:模拟 fresh clone(删光三个产物 → 按 README 命令重新生成 → 跑验证)
四个脚本全绿 —— verify-empty-content-agreement 30/30、verify-policy-library
21/21、verify-dms-chat-storage 36/36、check-policy-sources 符合预期;
validate-harness 通过;npm run build 通过。

⚠️ 不可逆:harness 的内部工作笔记(对后端问题的记录、踩坑过程、需求取舍)
自此对团队可见。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 4 天之前
父節點
當前提交
3b8533df9e
共有 37 個文件被更改,包括 6838 次插入4 次删除
  1. 9 4
      .gitignore
  2. 86 0
      CLAUDE.md
  3. 8 0
      README.md
  4. 53 0
      agents.md
  5. 94 0
      harness/README.md
  6. 111 0
      harness/docs/architecture.md
  7. 75 0
      harness/docs/exec-plans/README.md
  8. 142 0
      harness/docs/exec-plans/active/dms-api-replacement.md
  9. 67 0
      harness/docs/exec-plans/active/dms-chat-storage.md
  10. 57 0
      harness/docs/exec-plans/tech-debt-tracker.md
  11. 90 0
      harness/docs/quality.md
  12. 479 0
      harness/docs/reference/DMS_API.md
  13. 446 0
      harness/docs/reference/DMS_COLUMNS.md
  14. 179 0
      harness/docs/reference/DMS_MAPPING.md
  15. 144 0
      harness/docs/reference/README.md
  16. 435 0
      harness/docs/reference/api-chat-fields-zh.md
  17. 552 0
      harness/docs/reference/api-chat.md
  18. 1161 0
      harness/docs/reference/legacy/API.md
  19. 43 0
      harness/feature_list.json
  20. 79 0
      harness/init.sh
  21. 1069 0
      harness/progress.md
  22. 57 0
      harness/sops/evaluator-rubric.md
  23. 46 0
      harness/sops/handoff.md
  24. 57 0
      harness/sops/session-end.md
  25. 59 0
      harness/sops/session-start.md
  26. 69 0
      harness/sops/verification.md
  27. 61 0
      harness/tools/README.md
  28. 23 0
      harness/tools/_entry-dms.ts
  29. 2 0
      harness/tools/_entry-empty-content.ts
  30. 19 0
      harness/tools/_entry-policy-library.ts
  31. 107 0
      harness/tools/check-policy-sources.mjs
  32. 149 0
      harness/tools/dms-delete-probe.mjs
  33. 225 0
      harness/tools/dms-step0-verify.mjs
  34. 150 0
      harness/tools/validate-harness.mjs
  35. 196 0
      harness/tools/verify-dms-chat-storage.mjs
  36. 110 0
      harness/tools/verify-empty-content-agreement.mjs
  37. 129 0
      harness/tools/verify-policy-library.mjs

+ 9 - 4
.gitignore

@@ -1,10 +1,14 @@
 .DS_Store
 node_modules
 /dist
-/harness
 # 注:原根目录 docs/ 已并入 harness/docs/,该规则保留以防重建
 /docs
 
+# harness 的验证脚本产物(esbuild 打包出来的 _*.mjs)
+# 由 tools/ 下的 _entry-*.ts 生成,属于构建产物:入库会与源码漂移,
+# 让人误以为测的是新代码。重新生成命令见 harness/tools/README.md
+/harness/tools/_*.mjs
+
 
 # local env files
 .env.local
@@ -38,6 +42,7 @@ components.d.ts
 # 构建产物:rollup-plugin-visualizer 生成的体积报告
 stats.html
 
-# 本机工作文件:项目指令与 agent 说明,不入库
-/CLAUDE.md
-/agents.md
+# 注:harness/、CLAUDE.md、agents.md 自 2026-09-17 起**纳入版本控制**
+# (此前排除是本机工作流的临时安排;参考资料的原则是「计划、质量、技术债
+#  和代码一起版本化」——团队 clone 应当能看到状态、计划与契约文档)
+# 仍然排除的是带凭据的本机文件:.env.*.local(见上)与 .claude/settings.local.json

+ 86 - 0
CLAUDE.md

@@ -0,0 +1,86 @@
+# CLAUDE.md
+
+本仓库用 `harness/` 管理长时开发。**这个文件只做入口,细节都在 `harness/` 里。**
+
+## 开工前先读
+
+1. `harness/progress.md` — 当前已验证状态 + 上一轮记录
+2. `harness/feature_list.json` — 功能清单与状态
+3. `harness/docs/architecture.md` — 架构与关键约束(**第一次进这个仓库必读**)
+4. `harness/sops/session-start.md` — 开工流程(含 `init.sh` 与结构校验两条命令)
+
+## 规则(必须遵守)
+
+- **同一时间只做一个功能**(`feature_list.json` 里只有一个 `in_progress`)
+- **没有可运行证据(构建/测试的实际输出)时,不要声称完成**
+- **每次改动完成后立刻更新记录,不要攒到会话结束** —— 攒到最后必然漏记、
+  且已写下的描述会过期(真实发生过)
+- **每次改动完成后立即提交并推送**(见下方"提交约定")
+- **计划类文件只能写进 `harness/docs/exec-plans/active/`** ——
+  写到仓库外(如工具自带的 `~/.claude/plans/`)或项目根目录都算没写:
+  换个会话接手的人看不到,等于计划不存在。工具生成的草稿在收尾前必须落进仓库。
+- **收尾时跑 `node harness/tools/validate-harness.mjs`**(退出码非 0 就先修)
+- 不要通过重写功能清单来隐藏未完成的工作
+- 不要为了让状态好看而删除或削弱验证步骤
+- **仓库内文件是唯一事实来源** —— 不要把结论只留在聊天里
+
+## 提交约定
+
+**每次改动完成就提交,并推送到 origin** —— 不要攒成一个大提交,也不要留给用户手动提交。
+
+提交前的最低要求:
+
+- `npm run build` 通过
+- 已按上面的规则更新 `harness/progress.md` 与 `harness/feature_list.json`
+
+提交信息写清「改了什么、为什么」,用中文;结尾带 `Co-Authored-By` 行。
+
+```bash
+git add -A && git commit -m "..." && git push origin main
+```
+
+> **`harness/`、`CLAUDE.md`、`agents.md` 是入库的**(2026-09-17 起)——
+> 参考资料的原则是「计划、质量、技术债和代码一起版本化」,团队 clone 应当能看到。
+> 所以记录类改动**也要一起提交**,别只提交 `src/`。
+>
+> 仍然不入库的只有带凭据的本机文件:`.env.*.local`、`.claude/settings.local.json`,
+> 以及 esbuild 生成的 `harness/tools/_*.mjs`(构建产物,会与源码漂移)。
+
+## 完成门槛
+
+验证成功、且结果记录进 `harness/feature_list.json` 的 `evidence` 与
+`harness/progress.md` 之后,功能状态才能切 `passing`。
+
+## 收尾前
+
+按 `harness/sops/session-end.md` 过一遍清单,当场更新:
+- `harness/progress.md`(追加 session 记录)
+- `harness/feature_list.json`(状态与证据)
+- 仍损坏 / 未验证的内容
+
+---
+
+## 项目速览
+
+青浦区营商智能助手前端(Vue 3 + TypeScript + Vite)。对话走接口 `POST /api/chat`,
+前端经**适配层**翻译成内容标记,渲染层不感知协议。
+
+**最容易踩的三条**(详见 `harness/docs/architecture.md`):
+
+1. 请求体**只接受** `{thread_id, question}` 两个字段,多一个返回 422
+2. **界面样式必须与原版一致**;既有 UI 的可选项、文案、筛选逻辑不要擅自改动
+3. 排查协议问题**必须读原始 SSE 载荷**,不要只看封装的中间事件
+
+## 详细索引
+
+| 想了解 | 看 |
+|---|---|
+| 架构、目录、核心系统、约束 | `harness/docs/architecture.md` |
+| 接口契约与字段释义 | `harness/docs/reference/`(先读该目录 README) |
+| 已知技术债与未决问题 | `harness/docs/exec-plans/tech-debt-tracker.md` |
+| 质量现状与缺口 | `harness/docs/quality.md` |
+| 验证流程 | `harness/sops/verification.md` |
+| 临时脚本放哪 | `harness/tools/` |
+
+> `harness/` 与 `harness/docs/` 按 `.gitignore` 约定**不入库**,缺失属正常。
+> 入库的改动记录见 `README.md`。

+ 8 - 0
README.md

@@ -116,3 +116,11 @@
   `docs/plans/` → `docs/exec-plans/`(`completed/` + `tech-debt-tracker.md`),
   并新增结构校验脚本 `harness/tools/validate-harness.mjs`(机械约束优先于口头约定:
   可检出假 passing、多个 in_progress、游离的计划文件等)
+- **`harness/`、`CLAUDE.md`、`agents.md` 纳入版本控制**(此前排除)
+  - 依据:参考资料的原则是「计划、质量、技术债**和代码一起版本化**」——
+    团队 clone 应当能看到状态、计划与契约文档,而不是只在本机
+  - 仍然排除的是带凭据的本机文件(`.env.*.local`、`.claude/settings.local.json`),
+    以及 esbuild 生成的 `harness/tools/_*.mjs`(构建产物,入库会与源码漂移);
+    产物重新生成的命令写在 `harness/tools/README.md`
+  - 入库前做了凭据扫描(无真实 token),并**模拟 fresh clone**(删光产物 →
+    按文档重新生成 → 四个验证脚本全绿)确认别人拿到仓库能跑通

+ 53 - 0
agents.md

@@ -0,0 +1,53 @@
+# Agents.md
+
+## 业务自滚动吸顶原则
+
+- 自动滚动的吸顶判断,应以“最后一条 AI 消息是否仍处于打字机动画中”为核心依据。
+- 不要仅依赖 `isGenerating` 之类的请求状态来决定是否吸顶;请求结束不等于内容展示结束。
+- 当自动滚动推进到最后一条 AI 消息顶部阈值时,应进入吸顶控制状态,避免打字机动画继续把内容顶出视口。
+- 吸顶状态应是阶段性的、可退出的;同一轮滚动中避免反复触发或抖动。
+- 用户发生手动滚动、触摸、鼠标滚轮或其他明确交互后,应重置吸顶状态,恢复默认滚动行为。
+- PC 和 Mobile 的滚动策略应保持一致,差异只应来自平台滚动容器本身,而不是业务规则分叉。
+- 调试阶段可以保留必要日志,用于判断吸顶是否触发、是否已停止自动滚动、当前滚动位置以及最后一条 AI 消息的打字状态。
+
+## 会话切换与消息状态原则
+
+- 切换会话后,新会话应自动滚动到消息列表底部,保证用户直接看到最新上下文。
+- 历史消息和实时生成消息应复用同一套消息状态判断,避免同一种业务状态在不同来源下表现不一致。
+- AI 消息内容为空、被中断、请求取消,或只残留未完成的思考/查询 `scope` 时,应明确展示“请求已取消”。
+- “请求已取消”的展示不应影响正常生成中的消息,也不应打断仍在流式追加的代码或文本内容。
+
+## Scope 动画与布局原则
+
+- `scopeContent` 的进入、退出、淡入、淡出应交给 Vue `Transition`/`TransitionGroup` 管理,避免在业务逻辑中手写动画状态。
+- `scopeContent` 退场只需要 fade,不需要 translate;退场过程中不应重置光点坐标,视觉位置应保持连续。
+- `scope` 达到最大宽度前,标题、正文行和行列表应优先使用 ellipsis 隐藏换行内容;只有达到最大宽度后才允许自然换行。
+- `scope-content-line` 与 `scope-line-list` 应遵循同一套宽度与换行规则,避免局部换行导致动画抖动。
+- 当 `scope` 准备淡出时,不应通过临时 `position: absolute` 改变布局位置;布局稳定性应由外层高度锁定或过渡结构承担。
+
+## 打字机与 Markdown 渲染原则
+
+- 文本打字机应由父级统一维护“当前可见行”和“当前正在打字的行”,避免多段文本同时竞争动画节奏。
+- 一条 AI 回复中,后段内容可以先进入数据队列,但 UI 上应按顺序释放可见文本,保证阅读节奏自然。
+- 请求流关闭不等于打字机结束;最后一个消息到达后,未完成的打字动画应继续执行直到文本全部展示完毕。
+- 打字机每 tick 应推进稳定数量的字符,避免速度忽快忽慢;当前约定以每 tick 2 个字作为基础节奏。
+- Markdown 内容在打字机过程中应尽量每 tick 实时 parse,使展示结构即时更新,减少纯文本与 Markdown 成品之间切换造成的抖动。
+- `markdown-content.block-markdown` 不应自行设置 `min-height`;高度稳定应由外层消息容器或业务记录容器维护。
+
+## 消息高度稳定原则
+
+- 最后一条 AI 消息处于打字机动画时,应在 `message-row.ai.is-last` 上维护最小高度,而不是让内部 Markdown 节点自行锁高。
+- 该最小高度在打字机过程中只增不减,用来抵消 Markdown 重排、scope 淡出和文本接续造成的高度回缩。
+- 当最后一条 AI 消息仍在视口内时,应保持最小高度;只有当 `business-record-body` 的底边已经接触滚动容器底部时,才允许释放高度锁。
+- 高度锁应服务于滚动与动画稳定,不应成为永久布局样式。
+
+## 流式分段与节奏原则
+
+- 流式解析应识别 `</scope>\n` 这样的自然边界,并在边界到达时立即 enqueue,避免 scope 结束后的正文被延迟到后续大段内容一起出现。
+- `scope` 内容、普通正文、Markdown 标题和 followup JSON 等不同片段应保持清晰边界,便于动画、取消态和最终态分别处理。
+- 流暂停输出 message 时,前端打字机仍应以自己的节奏继续推进;网络节奏不应直接决定文字动画是否流畅。
+
+## 调试入口原则
+
+- 用于测试输入和 `playMockStreamMessage` 的按钮可以保留在 PC 端调试入口中,并应放在 `actions-right` 内、发送按钮左侧。
+- 调试按钮不应影响 Mobile 端正式输入区,也不应改变用户发送消息的主流程。

+ 94 - 0
harness/README.md

@@ -0,0 +1,94 @@
+# harness/ — agent 长时开发的工作环境
+
+这套东西解决的问题:**多轮会话开发时,状态不要存在于聊天记录或人的记忆里,
+而是存在于仓库文件中。**
+
+参考:[Learn Harness Engineering](https://walkinglabs.github.io/learn-harness-engineering/zh/resources/)
+(核心公式 `Agent = Model + Harness`;设计原则:**短入口,深链接**)
+
+## 结构
+
+```
+harness/
+├── README.md            本文件:结构导航
+├── progress.md          ★ 进度日志:当前已验证状态 + 每轮会话记录
+├── feature_list.json    ★ 功能清单:状态 / 验证步骤 / 证据
+├── init.sh              安装依赖 + 基础验证 + 打印启动命令
+│
+├── tools/               脚本都写这里(验证脚本、调试脚本、结构校验)
+│   ├── README.md
+│   └── validate-harness.mjs  ★ 校验 harness 结构本身(见下「机械约束」)
+│
+├── docs/                项目文档
+│   ├── architecture.md    架构、目录、核心系统、关键约束
+│   ├── quality.md         质量评分:各领域现状与缺口
+│   ├── exec-plans/        ★ 执行计划与技术债
+│   │   ├── README.md          计划管理方式(含「计划只能放这里」的硬规则)
+│   │   ├── active/            进行中的计划
+│   │   ├── completed/         已完成的计划(决策追溯)
+│   │   └── tech-debt-tracker.md  技术债清单
+│   └── reference/         接口契约与参考资料
+│       ├── README.md        索引:先读哪份
+│       ├── api-chat.md            现行接口契约
+│       ├── api-chat-fields-zh.md  字段中英对照
+│       ├── DMS_*.md               DMS 契约与栏目现状(含实测结论)
+│       └── legacy/API.md          交接时的代码快照(非规格,勿照抄)
+│
+└── sops/                标准流程
+    ├── session-start.md   开工流程
+    ├── session-end.md     收尾清单
+    ├── handoff.md         会话交接
+    ├── verification.md    验证流程(含本项目踩过的坑)
+    └── evaluator-rubric.md 六维度评审表
+```
+
+★ = 每轮都会读写;其余按需查。
+
+## 机械约束(不是口头约定)
+
+文档写「计划要放 `exec-plans/`」「同一时间只能一个 `in_progress`」,
+光写着没用 —— **违反的时候得有东西报错**:
+
+```bash
+node harness/tools/validate-harness.mjs    # 退出码非 0 = 有违规
+```
+
+它会检查:必备文件是否齐、有没有游离的计划文件、`feature_list.json` 是否有
+假 passing / 重复 id / 多个 in_progress、`progress.md` 的会话编号是否重复。
+**收尾时跑一次**(见 `sops/session-end.md`)。
+
+## 怎么用
+
+**开工**:按 [`sops/session-start.md`](sops/session-start.md) 走 ——
+读 `progress.md` 与 `feature_list.json` → 看提交 → 跑 `init.sh` → 只选一个功能做。
+
+**收尾**:按 [`sops/session-end.md`](sops/session-end.md) 过一遍清单,
+**当场**更新 `progress.md` 与 `feature_list.json`,不要攒到会话结束。
+
+**验证**:按 [`sops/verification.md`](sops/verification.md) ——
+接口相关改动必须对真实后端端到端验证,脚本写到 `tools/`。
+
+## 三条规则
+
+1. **同一时间只做一个功能**(`feature_list.json` 里只有一个 `in_progress`)
+2. **没有可运行证据不得声称完成**(构建通过只是底线,不是充分条件)
+3. **每次改动完成就立刻更新记录**,不要攒 —— 攒到最后必然漏记、且已写下的描述会过期
+
+## 深链接在哪
+
+根 `CLAUDE.md` 只保留**很短的入口**(开工步骤 + 规则 + 指向本目录)。
+架构细节、接口契约、流程细节都在 `harness/` 里 —— 这是刻意的:
+入口短才有人读,细节深才不会污染入口。
+
+## 与仓库其他部分的关系
+
+| 位置 | 内容 | 入库 |
+|---|---|---|
+| `README.md` | **改动记录**(按日期),给团队看 | ✅ 入库 |
+| `CLAUDE.md` / `agents.md` | 简短入口 + 工作规则 / 开发原则 | ✅ 入库(2026-09-17 起) |
+| `harness/`(本目录) | 状态、流程、文档、脚本 | ✅ 入库(2026-09-17 起) |
+| `.env.*.local`、`.claude/settings.local.json` | 带凭据的本机配置 | ❌ 不入库 |
+| `harness/tools/_*.mjs` | esbuild 打包产物 | ❌ 不入库(会与源码漂移) |
+
+> ⚠️ **不要在三处重复记同一件事。** 变更明细记在 `README.md`;
+> `harness/` 里只记 **README 没有的东西** —— 哪些验证过、哪些还挂着、下一步做什么。

+ 111 - 0
harness/docs/architecture.md

@@ -0,0 +1,111 @@
+# 架构说明
+
+> 本文是项目的架构事实来源。根 `CLAUDE.md` 只做简短入口,深层规则都在这里。
+
+## 项目定位
+
+青浦区营商智能助手前端。Vue 3 + TypeScript + Vite,面向企业的**政策匹配与办事指引**:
+用户自然语言提问,助手检索政策知识库,以「开场概述 + 政策卡片 + 综合说明 + 参考资料」
+的形式给出有原文依据的回答;涉及具体企业时先引导用户确认公司主体,再结合工商信息作答。
+
+## 容易误判的几件事
+
+先看这几条,可以省掉不少弯路:
+
+- **对话走接口** `POST /api/chat`(SSE)。前端**不直接渲染接口协议**,而是经适配层
+  `src/components/api-chat-coordinator.ts` 翻译成既有渲染组件可识别的内容标记。
+  详见 [`reference/api-chat.md`](reference/api-chat.md)。
+- **没有 3D 虚拟人渲染**。`three` 仅用于粒子背景(`business-assistant-particle.ts`);
+  `src/three-libs/asr` 是**语音识别**;`src/three-libs/metamaker` 是未被引用的 SDK 包。
+- **没有状态管理库**(无 Vuex / Pinia)。对话状态在 `useBusinessAssistantChat` 组合式函数内,
+  会话与消息持久化到 `localStorage`。
+- **没有 `src/hooks/` 目录**;也**没有** `stream-message-coordinator-v2.ts`。
+
+## 技术栈
+
+| 项 | 说明 |
+|---|---|
+| 框架 | Vue 3(`<script setup>`)+ TypeScript + Vite 5 |
+| UI | Ant Design Vue 4(`unplugin-vue-components` 按需自动引入) |
+| 状态 | 无状态管理库;组合式函数 + `localStorage` |
+| 流式 | 原生 `fetch` + `ReadableStream`(现行);`@microsoft/fetch-event-source`(旧协议,保留未用) |
+| Markdown | `marked` |
+| 其他 | Three.js 0.143.0(粒子背景)、`alloyfinger`(语音相关) |
+
+## 主入口与页面壳
+
+- `src/main.ts` — 应用入口
+- `src/App.vue` — 根组件
+- `src/components/BusinessAssistant.vue` — **只做 PC / 移动端切换,不是主壳**
+- `src/components/BusinessAssistantPC.vue` / `BusinessAssistantMobile.vue` — 真正的页面主壳
+  (输入区、消息列表、滚动与打字机联动都在这里)
+
+## 目录结构
+
+```
+src/
+├── components/
+│   ├── Chat/                    消息渲染、政策卡片、补问问卷等
+│   ├── Common/                  通用组件(MediaViewer、ScrollList、Toast…)
+│   ├── business-assistant/      对话编排(useBusinessAssistantChat、上传、滚动等)
+│   ├── api-chat-coordinator.ts  ★ 协议适配层(现行)
+│   └── stream-message-coordinator.ts  旧协议实现(保留未用,勿改)
+├── network/api/                 接口封装(chat-sessions、enterprise、assistant-statistics、card)
+├── types/                       TypeScript 类型定义
+├── utils/                       工具(runtime-config、stream-xml-filter、scope-record-rows…)
+└── three-libs/asr/              语音识别
+```
+
+## 核心系统
+
+### 对话协议适配层(现行)
+
+`src/components/api-chat-coordinator.ts` —— 接口 `POST /api/chat` 的客户端。
+把 SSE 事件**翻译成内容标记**交给既有渲染组件:
+
+| 接口事件 | 翻译为 | 由谁渲染 |
+|---|---|---|
+| `progress` / `heartbeat` | `<scope title="阶段文字">` | ScopeContent("思考中"卡片) |
+| `item` | `<!-- POLICY_TABLE {"data":[…]} -->` | PolicyMatch(政策卡片 + 详情面板) |
+| `source` | `<ref_links>[…]</ref_links>` | 参考资料 chips + 侧边面板 |
+| `interrupt` | `<question-cards>{…}</question-cards>` | QuestionCard(补问问卷) |
+| `answer` / `summary` | 纯文本 | TextContent(markdown) |
+
+**渲染层不需要感知新协议。** 这是刻意设计:协议会变,渲染层不动。
+
+### 旧协议实现(保留未用)
+
+`src/components/stream-message-coordinator.ts`。当前未被使用,**不要改动**。
+
+## 关键约束
+
+### 接口层
+
+1. **请求体只接受 `{thread_id, question}` 两个字段,多一个返回 422。**
+   旧协议的 `transmission.files / file_pos` 发不出去(这是"文件上传"功能待定的原因)。
+2. **`answer.text` 与 `result.response` 内容相同,只能展示一次**(result 是快照)。
+3. **未收到 `done`/`error` 即断流** → 按连接中断处理,**不自动重试**。
+4. **不要用后端文案做判断。** 补问形态由 `kind` + `status` 字段决定,
+   而不是拿 `input_help` 的中文措辞做正则 —— 后端改一个字判定就会失效(踩过)。
+
+### 界面
+
+- **界面样式必须与原版一致。** 新协议能力通过适配层翻译成内容标记来复用既有渲染组件,
+  不要新建带自有样式的 UI 组件。
+- **既有 UI 的可选项、文案、筛选逻辑不要擅自改动** —— 确需变更先取得确认。
+
+### 开发环境
+
+- dev server 为 **HTTPS**(自签证书),浏览器首次访问会有证书告警,属正常现象
+- 聊天接口走**同源代理** `/chat-api` → `http://192.168.2.23:8000`(见 `vite.config.ts`)。
+  代理由服务端发起,因此局域网同事访问也不会有混合内容或跨域问题
+- 生产环境 vite 代理不生效,需由 nginx 做等价转发
+
+## 环境变量
+
+| 变量 | 用途 |
+|---|---|
+| `VITE_API` | 主业务 API 网关 |
+| `VITE_CHAT_API` | 对话接口。服务前缀(`/chat-api`、`http://host:8000`)会自动追加 `/api/chat`;已是完整地址则原样使用 |
+| `VITE_MODEL_SERVER` | 模型服务 |
+| `VITE_ASR` / `VITE_STREAM_SERVER` | 语音识别 / 流媒体 |

+ 75 - 0
harness/docs/exec-plans/README.md

@@ -0,0 +1,75 @@
+# exec-plans/ — 执行计划与技术债
+
+> 目录名与结构对齐 [learn-harness-engineering 的 OpenAI 高级包](https://walkinglabs.github.io/learn-harness-engineering/zh/resources/openai-advanced/)。
+> 核心原则:**计划与代码一起、放在仓库里** —— 不是只存在于聊天里,也不是存在于仓库外。
+
+## 🚨 计划类文件**必须**写在这个目录里
+
+**这条是硬规则,踩过。** 实际发生过:实施计划被写到了 Claude Code 自己的
+计划目录(`~/.claude/plans/xxx.md`,**在本仓库之外**),仓库里什么都没有 ——
+换个会话接手的人看不到它,等于计划不存在。
+
+| 做法 | 判定 |
+|---|---|
+| 计划写在 `harness/docs/exec-plans/active/` | ✅ 唯一正确位置 |
+| 计划只写在聊天回复里 | ❌ 聊天下一次就没了 |
+| 计划写在 `~/.claude/plans/`(工具自带的 plan 目录) | ❌ 仓库外,别人看不到。**工具生成的那份只算草稿**,必须落到本目录才算数 |
+| 计划写在项目根目录、`src/` 下 | ❌ 污染代码目录 |
+
+> 用 plan 类工具时:工具把草稿放在别处没关系,**收尾前必须把内容写进本目录**。
+
+## 结构
+
+| 位置 | 放什么 | 生命周期 |
+|---|---|---|
+| `active/` | 进行中的计划。一个计划一个文件 | 做完移入 `completed/` |
+| `completed/` | 已完成的计划,保留作决策追溯 | 长期保留 |
+| `tech-debt-tracker.md` | 技术债清单 | 持续维护 |
+
+## 一个计划文件长什么样
+
+不需要长,但要能让"换一个会话接手"的人看懂:
+
+```markdown
+# <计划名>
+
+## 目标
+一句话说清要达成什么、为什么现在做。
+
+## 现状
+动手前的相关事实:涉及哪些文件、当前是什么行为、有什么约束。
+
+## 步骤
+1. …
+
+## 验证
+怎么确认做成了(可执行的命令或可观察的现象)。
+
+## 风险 / 未决
+可能出问题的地方;需要谁拍板的决策。
+```
+
+事实与决策写**结论**,不要抄一遍代码。已经落地、且属于长期约束的内容,
+应该从计划里**提炼进 `../architecture.md`**,计划本身只留决策过程。
+
+## 什么时候写计划
+
+- 改动跨多个文件 / 需要多轮会话 → **写**
+- 有多个可选方案、需要先定方向 → **写**(把选项和取舍列出来)
+- 单文件小修 → **不写**,直接做
+
+> 反模式:把小改动硬撑成一个大计划,或把大改动不做计划直接开干。
+> 判断标准是"换个人能不能照着继续做"。
+
+## tech-debt-tracker.md
+
+技术债**和功能一样列出来**:每条写清是什么、为什么先欠着、欠久了会怎样、
+什么条件下该还。不要留在脑子里。
+
+不是所有债都要马上还 —— 写下来是为了**它是有意欠的,不是忘了**。
+
+## 机械检查
+
+`harness/tools/validate-harness.mjs` 会校验本目录的存在性与基本形态
+(见该脚本)。机械约束优先于口头约定 —— 文档写"要放这里"是不够的,
+得有个命令能让违反的时候报错。

+ 142 - 0
harness/docs/exec-plans/active/dms-api-replacement.md

@@ -0,0 +1,142 @@
+# 用 DMS 接口替换前端接口 — 方案(待拍板)
+
+> **状态**:⏳ 等用户决定 —— 见文末「需要你决定」
+> **日期**:2026-09-17
+> **依据文档**:[`DMS_API.md`](../../reference/DMS_API.md)、[`DMS_COLUMNS.md`](../../reference/DMS_COLUMNS.md)、
+> [`DMS_MAPPING.md`](../../reference/DMS_MAPPING.md)(三份都来自 `DMS_Data_Migration` 项目)
+> **现状清单**:本文第四节逐条列出前端当前调用的全部接口
+
+---
+
+## 结论先说
+
+DMS 是**数据管理服务**(建栏目、管模型、存内容),不是业务 API 网关。
+能替换的是「**按 `credit_code` / `session_id` 读业务数据**」这一类,共 **3 条只读 + 3 条写**;
+对话、政策、上传、语音、登录这些都换不了(各有原因,见第三节)。
+
+**而且现在一条都不能换**:DMS 五个栏目目前**全是空的**
+(`selectContentList` 一律返回 `code=202 数据不存在`)。数据迁移(`F4-*`)没做完之前切过去,
+前端会从「有数据」直接变成「全空」。
+
+---
+
+## 一、能替换的:3 条只读
+
+三条都是「精确查 + 分页」,语义与 DMS 的 `selectContentList` 天然吻合。
+
+### 1.1 企业信息 ← 最干净的一条
+
+| | |
+|---|---|
+| 前端现在调 | `GET {VITE_API}/fta_ent_policy/enterprise_info?credit_code=` |
+| 位置 | `src/network/api/enterprise.ts` |
+| 改走 DMS | 栏目 **1888** 企业基础信息,`search=[{"field":"c_credit_code","searchType":1,"content":{"value":"…"}}]` |
+| 调用方 | `src/components/useEnterpriseAuth.ts`(登录后拉一次) |
+
+> ✅ **前端其实只用两个字段**:`name`、`credit_code`
+> (`useEnterpriseAuth.ts:58-59`;其余 19 个字段在 `src/` 里**零引用**)。
+> DMS 的 `c_name` / `c_credit_code` 正好覆盖 —— 所以这条不需要字段映射层。
+
+### 1.2 会话列表
+
+| | |
+|---|---|
+| 前端现在调 | `GET {VITE_API}/chat/sessions?credit_code=&page_index=&page_size=` |
+| 位置 | `src/network/api/chat-sessions.ts` |
+| 改走 DMS | 栏目 **1887** 助手会话:`c_credit_code` 精确 + `orderBy=[{"field":"c_updated_at","orderByType":2}]` + 分页 |
+| 调用方 | `BusinessAssistantPC.vue:1041`、`BusinessAssistantMobile.vue:937` |
+
+字段对得上:`session_id` / `title` / `updated_at` / `credit_code` ← `c_session_id` / `c_title` / `c_updated_at` / `c_credit_code`。
+
+> ⚠️ **时间字段格式不同**:前端 `RemoteSession.updated_at` 是 **epoch 毫秒**(number),
+> DMS 里是 `timestamp`。取值后要换算,否则时间显示会错(这一条容易漏)。
+
+### 1.3 会话历史(问答记录)
+
+| | |
+|---|---|
+| 前端现在调 | `GET {VITE_API}/chat/session_record?session_id=` |
+| 位置 | `src/network/api/chat-sessions.ts` |
+| 改走 DMS | 栏目 **1889** 助手问答记录:`c_session_id` 精确 |
+| 调用方 | `useBusinessAssistantChat.ts:1513` |
+
+字段对得上:`question` / `answer` / `created_at` ← `c_question` / `c_answer` / `c_created_at`。
+
+> ⚠️ 返回是**列表**,前端现在依赖服务端给的顺序;换 DMS 必须显式 `orderBy c_created_at`,
+> 否则顺序不保证 —— 会表现为「历史消息乱序」。
+
+---
+
+## 二、能替换但要小心:3 条写操作
+
+| # | 前端现在调 | 改走 DMS | 风险 |
+|---|---|---|---|
+| 2.1 | `PUT /chat/session_info`(改标题/绑企业) | 1887 `updateContent` | ⚠️ **定位方式不同**:前端用 `session_id`,DMS 的 `content` 里要的是记录 **uuid** → 得先查一次拿 uuid |
+| 2.2 | `POST /chat/del_session_info`(删会话) | 1887 `delContentById` | ⚠️ 该端点**参数未文档化**(OpenAPI 里存在但没写参数,Apifox 也未收录)。**必须先在测试栏目验证** |
+| 2.3 | `POST /chat/feedback`(赞/踩) | 1889 的 `c_feedback_status` / `c_feedback_option` / `c_feedback_remark` / `c_feedback_at` | ⚠️ 同样需要 uuid;且要确认状态码语义与前端 `FeedbackType` 的对应 |
+
+> 写操作还有一个共同问题:**DMS 的写入会改变「行数核对」的基准**。
+> 若迁移工具按 `id` 幂等键做增量同步,前端在 DMS 里新建/修改的记录会不会被下一次同步覆盖?
+> 这条要在动手前跟迁移那边对齐。
+
+---
+
+## 三、替换不了的,以及为什么
+
+| 前端接口 | 为什么换不了 |
+|---|---|
+| `POST /api/chat`(对话 SSE) | DMS 是数据管理服务,**没有对话能力**,无对应端点 |
+| `{VITE_MODEL_SERVER}/v1/policies`(政策列表查询) | DMS **没有政策栏目**(五个栏目里没有政策);且这条已有本地 json 兜底路径 |
+| `POST /enterprise/login`(企业登录换 token) | 这是 **OAuth 登录**。DMS 反过来**依赖**它(`AuthInterceptor` 回调 OAuth 校验,`serviceId=2`)—— 被依赖方无法被依赖者替代 |
+| OSS 上传(`/common/qp_signed_url` + 直传) | 用途不同:那是**对话附件**。DMS 的 `/file/uploadFile` 是给**栏目内容**挂附件的(`columnId`+`contentId`) |
+| ASR(`/api/human_asr/v2/asr/gen_auth` + wss) | 语音识别,与 DMS 无关 |
+| `GET /dialog/file_info`(卡片媒体信息) | DMS 无对应 |
+| `POST /dialog/append_history`、`/knowledge/append_history` | 该调用已在前端**禁用**(`useBusinessAssistantChat.ts:310` 注释保留),无需处理 |
+| `POST assistant_statistics/page_view`(埋点写入) | 栏目 **1885** 只覆盖它的 4 个字段,技术上**能**写;但**不建议**:埋点是高频写入,DMS 是管理服务,不是埋点接收端。除非明确要「数据统一收口到 DMS」 |
+| `POST assistant_statistics/feedback`(统计反馈) | 五个栏目里**没有**对应的反馈栏目,无处可写 |
+
+---
+
+## 四、动手前必须解决的 5 件事
+
+1. **数据迁移要先完成**(当前 5 栏目全空,`code=202`)。这是硬前置。
+2. **鉴权与暴露面** —— 最需要你拍板的一条。
+   DMS 要 header `token: {access_token}`,权限按栏目 `tag`(`yszs_*`)注册在 OAuth 上。
+   **让浏览器直连 DMS,等于把「数据管理」的 token 发给每一个访客**——它能读的就不止这五个栏目。
+   建议**后端加一层转发**(照 `/chat-api` 的做法),前端不直连 DMS。
+3. **HTTPS 混合内容**:页面是 HTTPS,DMS 是 `http://121.43.55.7:10081` → 浏览器会拦。
+   dev 用 vite 代理、生产用 nginx,与聊天接口同样的处理。
+   (若走后端转发,这条自动解决。)
+4. **`states` 与发布态**:`selectContentList` 的 `states` 过滤会影响 `count`。
+   迁移写入的内容若是**草稿态**,前端按发布态(`3`)查会**一条都查不到**。
+   需要确认迁移后数据的发布状态。
+5. **读接口全部标着 📄「未实测」**:`selectContentList` / `selectContentById` 等
+   只在 SKILL.md / Apifox 里有,本项目**没调用过**。第一次用必须先在测试栏目验证
+   `search` / `orderBy` / `states` 的确切写法。
+
+---
+
+## 五、如果要做,建议的顺序
+
+| 步 | 内容 | 验收 |
+|---|---|---|
+| **0** | 小样本验证:拿 **1887 助手会话**(7 字段,最少)跑通 `selectContentList` | 鉴权、代理、`search`/`orderBy`/`states` 写法、时间格式全部确认 |
+| **1** | 只读替换:企业信息(1888)→ 会话(1887)→ 问答记录(1889) | 页面显示与现在**逐字段一致**;条数对得上 |
+| **2** | 写操作:会话改名/删除、反馈 | 改动后 DMS 里确实变了;不被下次同步覆盖 |
+| **3** | 埋点写入(**建议不做**) | — |
+
+**每一步都加开关、可回退** —— 照 `VITE_POLICY_LOCAL_FIRST` 的先例(新老数据源可切,
+验证通过再删旧的)。不要一次性全切。
+
+---
+
+## 六、需要你决定
+
+1. **DMS 是给前端「在线查询」用的,还是只做「数据底座」?**
+   若是底座(给后台/BI/迁移用),**以上替换一条都不该做** —— 前端继续走业务 API,
+   DMS 只服务管理与分析。这一条决定了整个方案做不做。
+2. 若要做:**前端直连 DMS,还是后端转发?**(我建议后端转发,理由见第四节第 2 条)
+3. 数据迁移(`F4-*`)什么时候完成?
+4. 反馈与埋点要不要一起收口到 DMS?
+
+> 你确认方向后,我再把它拆成 `feature_list.json` 里的条目逐项做 —— 现在只是方案,没有动代码。

+ 67 - 0
harness/docs/exec-plans/active/dms-chat-storage.md

@@ -0,0 +1,67 @@
+# 会话/问答记录/反馈落地 DMS
+
+> **状态**:🔄 进行中(存储粒度已按用户要求改为「一会话一行」;浏览器端到端待人工验证)
+> **日期**:2026-09-17
+> **前置**:[`dms-api-replacement.md`](dms-api-replacement.md)(DMS 替换总方案,用户已批准的 3 读 + 3 写)
+
+## 目标
+
+把会话、问答记录、反馈从旧接口 `{VITE_API}/chat/*` 迁到 DMS 数据管理服务
+(栏目 1887 助手会话 / 1889 助手问答记录),并且:
+
+- **存储粒度是一个会话一行**(用户明确要求):不要把每轮问答各存一行,
+  一行里要包含该会话窗口的全部消息
+- 访客(未登录)也要存
+- 反馈写进 DMS
+
+## 现状(动手前的事实)
+
+- 前端会话标识 = `session.id`(UUID,恒等于聊天协议 `thread_id`,跨刷新稳定)
+- DMS 两个栏目已建好但**全空**,F4 数据迁移未开始
+- DMS 的 content 读写接口在原文档里全是「📄 未实测」
+
+## 关键决策
+
+| 决策 | 选择 | 理由 |
+|---|---|---|
+| 会话标识 | `session.id` → `c_session_id` | 恒等于协议 thread_id,可与协议对账;跨刷新稳定 |
+| 问答存储粒度 | **一个会话一行**,整段消息 JSON 存 `c_answer` | 用户要求:一行拿到整个会话 |
+| 写入方式 | 每次以本地 messages 为准**整段重写** | 本地状态是权威;避免「读-改-写」的并发覆盖 |
+| 反馈存储 | 写进该行 JSON 里**对应那条消息**上 | 用户选择;反馈仍能精确定位到某一轮 |
+| 访客归属 | `c_credit_code = 访客_<埋点访客id>` | 复用现有访客 id,跨刷新稳定,零新增代码 |
+| token | **vite 代理注入**,前端产物不含 | 避免把数据管理权限发给每个访客 |
+
+## 步骤(已完成部分)
+
+1. ✅ **Step-0 实测 DMS**(见 `progress.md` Session 022):`addContent` 不需要 `c_id`、
+   返回记录 uuid(纯字符串)、**删除是 `POST /content/updateAudit` + `state=4`**、
+   时间戳格式与长文本往返
+2. ✅ 基建:`.env.development` 的 `VITE_DMS_API` / `VITE_DMS_TARGET`、
+   `vite.config.ts` 的 `/dms-api/` 代理(补 `/dms` 前缀 + 注入 token)、
+   `runtime-config.ts` 的 `getDmsApiBaseUrl()`、`index.html` 注入
+3. ✅ DMS 客户端 `src/network/api/dms/client.ts` + 业务层 `chat-sessions-dms.ts`
+4. ✅ `chat-sessions.ts` 换内部实现(签名不变,调用方零改动)
+5. ✅ 写入挂钩:`sendMessage` / `totalResponse` / `close` / `submitQuestionAnswers`
+6. ✅ 反馈接线 `BusinessRecord.vue`
+7. ✅ **改存储粒度为一会话一行**(本轮):`saveDmsTranscript()` 整段重写;
+   `fetchDmsSessionRecords()` 从 JSON 还原问答对;`writeDmsFeedback()` 改成
+   「读整行 → 改那条消息 → 写回」,并与对话写入共用 `session:` 顺序链
+8. ✅ 顺带修:**去掉开发环境硬编码账号的自动登录**(它导致「没登录也进登录态」)
+
+## 验证
+
+- `npm run build` 通过
+- `node harness/tools/verify-dms-chat-storage.mjs <代理地址>` —— **36/36 通过**,
+  其中关键断言:第二轮后仍是一行(不是一封问答一行)、反馈只改命中那条消息、
+  另一条消息未被误改、定位不到时不新增行
+- 代理注入 token 实证:经代理 `202`,直连不带 token `208 无token`
+
+## 风险 / 未决
+
+- ⚠️ **浏览器端到端未人工点过**:发消息 → DMS 出现一行 → 刷新 → 列表/历史 → 反馈
+- ⚠️ **DMS token 2026-09-17 22:19 过期**;过期后写入静默降级(仅控制台 warn)
+- ⚠️ **生产未接**:需 nginx 等价转发 + 长效凭据(现在给的是临时 token)
+- ⚠️ **反馈状态刷新后不恢复**:JSON 里存着,但 `BusinessRecord` 的
+  `localFeedback` 初始恒为 None,未做「从 DMS 读回反馈态」的回填。要做得另接一条线。
+- ⚠️ 跨标签页同时写同一会话可能相互覆盖(单页内已由顺序链串行化)
+- ⚠️ **旧会话不在 DMS**:切换后远端列表为空(本地历史仍在);F4 迁移未开始

+ 57 - 0
harness/docs/exec-plans/tech-debt-tracker.md

@@ -0,0 +1,57 @@
+# 技术债
+
+> 记在这里的债都是**有意欠的**,不是忘了。每条写清:是什么、为什么先欠着、
+> 什么条件下该还。
+
+## 未还
+
+### 1. 公司候选提交的是文本而非序号(协议标注的做法)
+
+- **是什么**:接口文档写的是"选择第 1 家公司提交 `question="1"`,**API 由序号映射到原候选**",
+  而当前前端提交的是**选项全部内容拼接的文本**(公司名 + 信用代码 + 状态 + 负责人)。
+- **为什么先欠着**:这是产品明确要求的形态。文本在语义上无法表达"我选这一个",
+  后端可能把它当成"换关键词"走 refine 路径重新检索。
+- **欠着会怎样**:**可能出现"选一次又弹回同一批候选"的死循环。**
+- **该还的条件**:上游公司数据服务恢复后**必须补测** —— 走一遍有候选(`status: found`)的流程,
+  选中一家,确认返回的是选定公司后的结果而不是又弹候选列表。
+  若循环,两条路:①改回提交序号(序号取自当前显示顺序);②后端扩语义识别全字段文本。
+- **当前状态**:上游持续 `provider_failure`,**`status: found` 一次都没出现过**,因此从未验证。
+
+### 2. `answer.text` 与 `summary.text` 首句重复
+
+- **是什么**:后端把同一段话既放进 `answer.text`(渲染在消息正文)又放进 `summary.text` 的开头,
+  实测两者首句**逐字相同**,用户会看到同一段话出现两次。
+- **为什么先欠着**:已确认是**后端内容重复**,不是前端渲染问题(前端对两个字段各渲染一次)。
+- **欠着会怎样**:回答开头重复一段,观感差。
+- **该还的条件**:后端修(推荐,最干净),或前端加去重(权宜:比对首句并剥离,
+  但属启发式判断,可能误删)。**需用户拍板走哪条。**
+
+### 3. 文件上传功能待定
+
+- **是什么**:旧协议的文件/图片上传(按钮、拖拽、粘贴三条入口)当前**入口已隐藏**,
+  发送逻辑被注释保留。
+- **为什么先欠着**:接口请求体只接受 `{thread_id, question}` 两个字段,多一个返回 422,
+  旧协议的 `transmission.files / file_pos` **发不出去**。
+- **该还的条件**:先定后端方案 —— ①后端扩展请求体接受 files/file_pos;
+  ②前端把 OSS 地址拼进 question 文本;③后端提供文件登记接口返回 file_id。
+- **参考**:原实现的完整参数见 `harness/docs/reference/legacy/API.md`(旧协议快照,仅供理解原实现)
+
+### 4. 部门筛选在新数据下筛不出结果
+
+- **是什么**:政策「更多」面板的部门下拉是**固定的最初 15 个部门**,
+  按字面相等匹配;而接口返回的部门是全称(如"青浦区科学技术委员会"),字面不相等。
+- **为什么先欠着**:**用户明确要求**保持最初的实现,暂时不做匹配。
+- **⚠️ 不要当 bug 修**:曾试过①按接口数据动态生成选项、②保留固定选项但加关键词归一化匹配,
+  两个方案都被否掉。
+- **该还的条件**:用户明确要求时再动。
+
+### 5. `Question.message` 字段未被渲染
+
+- **是什么**:`QuestionCard` 的 `Question` 接口里定义了 `message` 字段,
+  但模板**从未渲染它** —— 适配层写的提示文案一直不可见。
+- **为什么先欠着**:只影响观感,不影响功能;改模板会扩大改动面。
+- **该还的条件**:需要展示这类提示时。
+
+## 已还
+
+(还清后从上面移到此处,保留一行说明怎么还的,便于追溯)

+ 90 - 0
harness/docs/quality.md

@@ -0,0 +1,90 @@
+# 质量评分
+
+> **这份文档评的是"代码库本身",不是"某一次改动做得好不好"。**
+> 前者回答"项目在变强还是变弱",后者用 [`sops/evaluator-rubric.md`](../sops/evaluator-rubric.md)。
+>
+> 更新时间:**每轮重要会话之后 / 做基准对比之前 / 清理或重构之后 / 换模型或换 agent 时**
+
+## 怎么打分
+
+每个领域按四项打 **强 / 中 / 弱**:
+
+| 维度 | 问自己 |
+|---|---|
+| 验证状态 | 这块有验证手段吗?还是只能靠手点? |
+| Agent 可读性 | 一个新会话能只靠仓库内文件看懂这块吗? |
+| 测试稳定性 | 验证手段是稳定的,还是时好时坏? |
+| 关键缺口 | 有没有已知但没记下来的问题? |
+
+---
+
+## 产品领域
+
+### 对话协议适配(`api-chat-coordinator.ts`)
+
+| 维度 | 评级 | 说明 |
+|---|---|---|
+| 验证状态 | **强** | 有对真实后端端到端验证的套路;契约测试可复现 |
+| Agent 可读性 | **强** | 类型定义完整、关键决策有注释、接口契约在 `harness/docs/reference/` |
+| 测试稳定性 | 中 | 验证依赖真实后端;上游不稳定时会误判(上游失败 ≠ 前端错) |
+| 关键缺口 | ⚠️ **有** | "选中候选提交文本是否循环"从未验证(上游持续失败,`status: found` 未出现过)。见 `exec-plans/tech-debt-tracker.md` #1 |
+
+### 消息渲染(`components/Chat/`)
+
+| 维度 | 评级 | 说明 |
+|---|---|---|
+| 验证状态 | 中 | 顺序、换行、复制等有过针对性验证;视觉观感仍靠人工看 |
+| Agent 可读性 | 中 | `BusinessRecord.vue` 体量大、状态机复杂(打字机 / scope 退出 / 行合并),新人上手成本高 |
+| 测试稳定性 | 弱 | 没有自动化视觉或行为测试 |
+| 关键缺口 | ⚠️ **有** | 渲染管线有多处"历史遗留的隐式行为"(如 `processPolicyTableBlock` 会把 POLICY_TABLE 之前的内容当纯文本),靠注释提示,没有测试兜底 |
+
+### 政策卡片与详情(`PolicyMatch.vue`)
+
+| 维度 | 评级 | 说明 |
+|---|---|---|
+| 验证状态 | 中 | 字段映射有验证;筛选行为按用户要求**刻意保持**旧实现 |
+| Agent 可读性 | 中 | 单文件体量大;"哪些是刻意保持的"只在 `tech-debt.md` 里说明 |
+| 测试稳定性 | 弱 | 无自动化测试 |
+| 关键缺口 | 有 | 部门筛选在新数据下筛不出结果(刻意,见 `tech-debt.md` #4) |
+
+### 补问问卷(`QuestionCard.vue`)
+
+| 维度 | 评级 | 说明 |
+|---|---|---|
+| 验证状态 | **强** | 四种 `kind`+`status` 形态有断言;提交映射有覆盖 |
+| Agent 可读性 | 中 | 新增的 `freeformInputOnTop` 等字段有注释;但 `message` 字段定义了却没渲染 |
+| 测试稳定性 | 中 | 静态断言稳定;真实交互仍靠手点 |
+| 关键缺口 | 有 | `status: failed` 与"无 status"两种只有静态断言,未在真实后端遇到过 |
+
+### 文件上传(待定)
+
+| 维度 | 评级 | 说明 |
+|---|---|---|
+| 验证状态 | **弱** | 入口已隐藏,未开始 |
+| Agent 可读性 | 中 | 原实现参数有文档(legacy 快照) |
+| 测试稳定性 | — | 未开始 |
+| 关键缺口 | ⚠️ **有** | 后端方案未定,无法开工(见 `tech-debt.md` #3) |
+
+---
+
+## 架构层
+
+| 层 | 边界执行 | Agent 可读性 | 说明 |
+|---|---|---|---|
+| 协议适配层 | **强** | **强** | 职责单一:事件 → 内容标记。渲染层不感知协议,边界清晰 |
+| 渲染层 | 中 | 中 | 边界靠约定维持,无强制手段 |
+| 网络层(`network/api/`) | 中 | **强** | 文件少、职责清楚 |
+| 状态层 | 中 | 中 | 无状态库,状态散在组合式函数 + localStorage;跨会话恢复靠 localStorage |
+
+---
+
+## 当前总评
+
+**整体在变强**。协议适配层的引入把一个高变动风险(接口协议)隔离在了单一文件里,
+这是这段时间最有价值的改动。
+
+**最弱的一环是"验证"**:除了协议适配层,其余部分基本没有自动化验证手段,
+依赖人工查看。`tools/` 目录建起来之后,鼓励把一次性的验证脚本沉淀下来。
+
+**最需要盯的缺口**:`tech-debt.md` #1 —— 那是一个可能表现为"死循环"的风险,
+且因为上游持续失败而**从未被验证过**。上游一恢复就该补测。

+ 479 - 0
harness/docs/reference/DMS_API.md

@@ -0,0 +1,479 @@
+# DMS 接口文档
+
+**服务**:`http://121.43.55.7:10081/dms`(`DMS_HOST` 可覆盖)
+**整理时间**:2026-09-16
+
+> 📍 **本文引用的 artifacts / tools / RUNBOOK 都不在本仓库。**
+> 这些文件属于**另一个项目** `F:\yysk\AI_zhaoshang\DMS_Data_Migration\`
+> (本文就是从它的 `harness/docs/` 复制过来的)。本文里的链接已改为**绝对路径**指向那边;
+> 若那边目录挪了位置,链接会失效 —— 记得回来改。
+**权威来源**:服务端自带 OpenAPI [`dms_openapi.json`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/artifacts/dms_openapi.json)
+(`GET /dms/v3/api-docs`,实时抓取,比 Apifox 新)
+
+> **可信度标注**:本文每个接口都标了来源。
+> - ✅ **实测** —— 本项目真实调用过,参数与响应已验证
+> - 📄 **文档** —— 仅见于 SKILL.md / Apifox / OpenAPI,**本项目尚未调用过**,用前先小样本验证
+
+---
+
+## 1. 通用约定
+
+### 1.1 鉴权
+
+除登录外所有接口都要在 header 带 token:
+
+```
+token: {access_token}
+```
+
+`AuthInterceptor` 会拦截 `/content/**`、`/model/**`、`/column/**`、`/task/**`,
+内部回调 OAuth 校验:
+
+```
+POST {oauth}/api/user/getUserByToken
+  Header: token
+  Body: serviceId=2, strUrl={当前请求URI}
+```
+
+**OAuth serviceId 固定传 2**(DMS 专用)。
+
+token 通过环境变量传递,**不要写进任何文件**:
+
+```bash
+export DMS_TOKEN='...'
+```
+
+### 1.2 请求格式
+
+| 场景 | Content-Type |
+|------|--------------|
+| 多数接口 | `application/x-www-form-urlencoded` |
+| 文件上传 | `multipart/form-data` |
+
+> ⚠️ **`model` / `column` 的写接口有个陷阱**:OpenAPI 声明它们是 query 参数,
+> 但把大 payload(如 41 字段的 `fieldList`)放 query string 会触发 **HTTP 400**。
+> **一律用 form body**。详见 §5.3。
+
+### 1.3 响应格式
+
+```json
+{ "code": 200, "content": ..., "message": "成功" }
+```
+
+| code | 含义 |
+|------|------|
+| `200` | 成功 |
+| `201` | 无权限 |
+| `202` | 数据不存在(栏目为空) |
+| `205` | 无数据 / 模型不存在 |
+| `212` | 无效 token |
+| `-1` | 参数错误 |
+| `214` | 数据错误 |
+
+> `-1`/`214`/`205` 的**具体触发条件**见 §5.4 —— 这几个是实测踩出来的,文档里没有。
+
+---
+
+## 2. 接口总览
+
+`GET /dms/v3/api-docs` 共 82 个端点。按业务分组:
+
+| 分组 | 主要端点 |
+|------|----------|
+| **栏目** | `/column/getColumnList`、`/column/getColumnById`、`/column/addColumn`、`/column/update`、`/column/delColumn`、`/column/selectChild` |
+| **模型** | `/model/addModel`、`/model/getModelById`、`/model/getModelByName`、`/model/getModelListByPage`、`/model/updateFieldList`、`/model/createModelByOldModel` |
+| **内容** | `/content/addContent`、`/content/updateContent`、`/content/selectContentList`、`/content/selectContentById`、`/content/delContentById`、`/content/importJSON` |
+| **字段库** | `/param/selectAll`、`/param/addParam` |
+| **单/多选框** | `/select/addSelects`、`/select/getSelectByType` |
+| **类别** | `/category/addCategorys`、`/category/selectByType` |
+| **任务** | `/task/addDataSync`、`/task/run`、`/task/taskLog` |
+| **权限** | `/permission/getColumnByPermission` |
+| **文件** | `/file/uploadFile` |
+
+---
+
+## 3. 本项目已实测的接口 ✅
+
+这 8 个是本次建栏目真实调用并验证过的。
+
+### 3.1 `GET /dms/v3/api-docs` ✅
+
+服务端自带的 OpenAPI 规范。**排查接口问题的第一站**,比 Apifox 权威。
+
+```bash
+curl -s "http://${DMS_HOST}/dms/v3/api-docs" -H "token: ${DMS_TOKEN}" -o dms_openapi.json
+```
+
+返回 118KB JSON,含 82 个端点的参数定义。已存档
+[`dms_openapi.json`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/artifacts/dms_openapi.json)。
+
+### 3.2 `POST /column/getColumnList` ✅
+
+**获取分级栏目树**(无参数)。
+
+```bash
+curl -s -X POST "http://${DMS_HOST}/dms/column/getColumnList" -H "token: ${DMS_TOKEN}"
+```
+
+返回 `content` 为**顶级栏目数组**,每个节点:
+
+| 字段 | 说明 |
+|------|------|
+| `id` | 栏目 id |
+| `title` | 栏目名 |
+| `type` | `0` 分级栏目 / `1` 内容栏目 |
+| `tag` | 权限标签,同步 OAuth `permissionName` |
+| `parentId` | 父栏目 id |
+| `level` / `state` | 层级 / 状态 |
+| `columnList` | **子栏目数组**(递归) |
+
+> 实测返回 28 个顶级栏目。树是**嵌套**的,找子栏目要递归 `columnList`,
+> 不要只看顶层。
+
+### 3.3 `POST /model/getModelById` ✅
+
+```bash
+curl -s "http://${DMS_HOST}/dms/model/getModelById?modelId=1839" -H "token: ${DMS_TOKEN}"
+```
+
+> ⚠️ 参数走 **query string**(不是 form body),`modelId` 必须带。
+
+`content` 字段:
+
+| 字段 | 说明 |
+|------|------|
+| `modelName` | 模型名,物理表为 `column_{modelName}` |
+| `modelAlias` | 中文别名 |
+| `type` | `1` 基础模型 / `2` 栏目模型 |
+| `fieldList` | **JSON 字符串**,见 §5.1 |
+| `searchField` / `sortField` | 实测为 `"[]"` |
+| `pageSize` | 实测为 `20` |
+| `authorId` / `authorName` | 创建者 |
+
+### 3.4 `POST /model/getModelByName` ✅
+
+```bash
+curl -s "http://${DMS_HOST}/dms/model/getModelByName?modelName=yszs_page_view_model" -H "token: ${DMS_TOKEN}"
+```
+
+参数走 query string。用于**幂等检查**:建模型前先查名字是否存在。
+
+### 3.5 `POST /model/getModelListByPage` ✅
+
+```bash
+curl -s "http://${DMS_HOST}/dms/model/getModelListByPage?type=2" -H "token: ${DMS_TOKEN}"
+```
+
+**唯一参数是 `type`**。`type=1` 基础模型,`type=2` 栏目模型。
+
+> ⚠️ 不传 `type` 会返回 `code=205 无数据`(不是空列表)。
+> 同名的 `/model/getModelList` 无论怎么传都返回 205 —— 用 `getModelListByPage`。
+
+### 3.6 `POST /param/selectAll` ✅
+
+**查询所有初始字段**(无参数)。返回 28 个基础字段模板:
+
+| id | name | type | alias |
+|----|------|------|-------|
+| 1 | summary | text | 摘要 |
+| 2 | content | text | 正文内容 |
+| 3 | date_time | timestamp | 时间 |
+| 4 | text | text | 文本内容 |
+| 5 | int_num | integer | 整数类型 |
+| 6 | float_num | double | 浮点数类型 |
+| 7 | boolean | boolean | 布尔类型 |
+| 8 | check | check | 单选框 |
+| 9 | multiple_check | mcheck | 多选框 |
+| 11 | select | type | 类别 |
+| 12 | multiple_select | mtype | 多级类别 |
+| 20 | files | file | 文件 |
+| 21 | picture | varchar | 图片 |
+| … | | | |
+
+**这个表的 `name` 就是 `fieldList` 里 `frontType` 该填的值。** 见 §5.2。
+
+### 3.7 `PUT /model/addModel` ✅
+
+**创建基础模型**。
+
+参数(**form body**):
+
+| 参数 | 值 | 说明 |
+|------|-----|------|
+| `modelName` | 字符串 | 模型名,**必须唯一** |
+| `modelAlias` | 字符串 | 中文别名 |
+| `fieldList` | JSON 字符串 | 见 §5.1 |
+| `searchField` | `"[]"` | 字面量空数组,不是字段名 |
+| `sortField` | `"[]"` | 同上 |
+| `type` | `1` | 基础模型 |
+| `pageSize` | `20` | |
+| `authorId` | 整数 | **必传**,否则 `code=-1` |
+| `authorName` | 字符串 | **必传**,否则 `code=-1` |
+
+`authorId`/`authorName` 从 token 的 JWT payload 里取(`userId`/`username`)。
+
+### 3.7b `POST /model/updateProperty` ✅ — 更新模型
+
+改模型字段(含别名)用这个。参数与 `addModel` 相同,外加 `id`(模型 id):
+
+| 参数 | 说明 |
+|------|------|
+| `id` | **模型 id**(必传) |
+| `modelName` / `modelAlias` / `fieldList` / `searchField` / `sortField` / `type` / `pageSize` | 与 `addModel` 同 |
+| `authorId` / `authorName` | 必传 |
+
+⚠️ **端点选择有坑(实测)**:
+
+| 端点 | 结果 |
+|------|------|
+| `POST /model/updateProperty` | ✅ `code=200` |
+| `POST /model/updateFields` | ❌ HTTP 400 |
+| `POST /model/updateFieldList` | ❌ HTTP 500 |
+
+名字最贴切的 `updateFieldList`(摘要「修改FieldList内容字段」)反而不能用。
+工具:[`fix_field_alias.py`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/tools/fix_field_alias.py)
+
+### 3.8 `PUT /column/addColumn` ✅
+
+**创建栏目。要求 `modelId` 指向的模型已存在**,否则 `code=205 模型不存在`。
+
+参数(**form body**):
+
+| 参数 | 值 | 说明 |
+|------|-----|------|
+| `type` | `0`/`1` | `0` 分级栏目(仅目录,无内容表)/`1` 内容栏目 |
+| `parentId` | 整数 | 父栏目 id;`-1` 表示无父级 |
+| `tag` | 字符串 | **全局唯一**,同步 OAuth permissionName |
+| `title` | 字符串 | 栏目名 |
+| `content` | 字符串 | 说明 |
+| `state` | `0` | `0` 开启 / `1` 关闭 / `2` 销毁 |
+| `level` | `0` | 开放 |
+| `modelId` | 整数 | 基础模型 id |
+| `modelName` | 字符串 | 模型名 |
+| `authorId` / `authorName` | | **必传** |
+
+**副作用**:
+1. DMS 把基础模型(type=1)**克隆**为栏目模型(type=2),字段自动加 `c_` 前缀
+2. 生成物理表 `column_{栏目模型名}`
+3. 联动 OAuth 注册权限(`Column.tag` → `permissionName`)
+
+**幂等做法**:先用 `getModelByName` 查模型是否存在,存在则跳过整个流程。
+参考实现 [`create_dms_columns.py`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/tools/create_dms_columns.py)。
+
+---
+
+## 4. 迁移阶段将用到的接口 📄
+
+> ⚠️ **以下均未实测。** 参数来自 `SKILL.md` / Apifox,OpenAPI 未描述(这些接口用
+> `@RequestBody`,服务端规范抓不到)。**首次使用前请先在测试栏目验证。**
+
+### 4.1 `POST /content/selectContentList` 📄 — 列表查询(最常用)
+
+```
+columnId=123&page=0&pageSize=20&search=[...]&orderBy=[...]&states=3
+```
+
+返回 `content.data`(数组)+ `content.count`(总数)。
+
+> **`count` 是行数核对(校验标准 C1)的基准值。**
+> ⚠️ `states` 过滤会影响 count —— 核对时**不要带 `states`**,否则会误判"少了数据"。
+
+**search 格式:**
+
+```json
+[{"field":"c_title","searchType":2,"content":{"value":"政策"}}]
+```
+
+| searchType | 含义 |
+|------------|------|
+| 1 | 精确 |
+| 2 | 模糊 |
+| 3 | 区间(start/end) |
+| 4 | in 数组 |
+| 5 | between |
+
+**orderBy 格式:**
+
+```json
+[{"field":"c_created_at","orderByType":2}]
+```
+
+`orderByType`:`1` 升序,`2` 降序。
+
+**states(发布状态):**
+
+`0` 草稿 · `1` 待审 · `2` 审完 · `3` 发布 · `4` 销毁 · `5` 退回
+
+多个用逗号:`states=2,3`。指定 `3` 则为发布态。
+
+### 4.2 `POST /content/selectContentListInfo` 📄 — 综合查询(详情)
+
+同 `selectContentList` 参数,额外做**字典解析**(把 `check`/`type` 等字段的码值翻译成文本)。
+排查详情问题时用它。
+
+### 4.3 `POST /content/selectContentById` 📄 — 单条查询
+
+```
+columnId=123&contentId={uuid}
+```
+
+> `contentId` 是 **UUID 字符串**,不是数字 id。
+
+### 4.4 `POST /content/addContent` 📄 — 新增
+
+| 参数 | 说明 |
+|------|------|
+| `columnId` | 栏目 id |
+| `modelId` | 栏目模型 id(type=2 的那个) |
+| `content` | **JSON 字符串**,字段用 `c_` 前缀 |
+
+```bash
+curl -s -X POST "http://${DMS_HOST}/dms/content/addContent" \
+  -H "token: ${DMS_TOKEN}" \
+  -d 'columnId=1885' \
+  -d 'modelId=2030' \
+  -d 'content={"c_id":1,"c_business_code":"abc"}'
+```
+
+### 4.5 `POST /content/updateContent` 📄 — 修改
+
+参数同 `addContent`,`content` 里**必须带 `id`**(记录 uuid)。
+
+### 4.6 `DELETE /content/delContentById` 📄 — 删除
+
+> 服务端 OpenAPI **确认存在**(`DELETE /content/delContentById`,摘要「删除内容」),
+> 但**参数未文档化**,Apifox 也未收录。
+> **在生产栏目用之前,先在测试栏目确认它对 `columnId`/`contentId` 的确切要求。**
+
+### 4.7 `POST /content/importJSON` 📄 — JSON 批量导入
+
+> 服务端 OpenAPI **确认存在**(POST/PUT/GET/DELETE 都注册了),
+> 但参数未文档化。大批量迁移可能比逐条 `addContent` 快得多,**值得优先验证**。
+
+### 4.8 `POST /content/importBeautifiedExcel` 📄 — Excel 导入
+
+`multipart/form-data`:`file`、`columnId`、`parseArray`、`titleParam`、`contentParam`。
+
+### 4.9 `POST /file/uploadFile` 📄 — 附件上传
+
+`multipart/form-data`:`columnId`、`contentId`、`paraName`、`type`、`file`。
+`type`:`0` 图 · `1` 音 · `2` 视 · `3` 文件。
+
+---
+
+## 5. 建栏目实战(实测契约)
+
+这几条是本次**踩出来的**,SKILL.md 与 Apifox 都没有。详见
+[`DMS_MAPPING.md`](DMS_MAPPING.md) §5。
+
+### 5.1 `fieldList` 格式
+
+外层是 dict,值是**转义后的 JSON 字符串**(fastjson 紧凑输出,**无空格**):
+
+```json
+{
+  "credit_code": "{\"name\":\"credit_code\",\"alias\":\"统一社会信用代码\",\"must\":true,\"index\":0,\"sequence\":0,\"customType\":\"\",\"describe\":\"\",\"defaultValue\":\"\",\"type\":\"text\",\"frontType\":\"varchar\",\"searchType\":\"1\",\"showParam\":\"name,alias,desc,type,front_type,must,default_value\"}"
+}
+```
+
+字段属性:
+
+| 属性 | 要求 |
+|------|------|
+| `name` | **不加 `c_` 前缀** —— DMS 克隆时才加。自己加会变成 `c_c_xxx` |
+| `index` | **必须有**,从 `0` 起 |
+| `sequence` | 从 `0` 起 |
+| `type` | DMS 类型(`text`/`integer`/`double`/`boolean`/`timestamp`/`file`…) |
+| `frontType` | **不是 `"content"`** —— 取 §3.6 基础字段表的 `name` |
+| `id` | 新格式**不需要** |
+| `must` | 是否必填 |
+
+### 5.2 PG → DMS 类型映射
+
+| PG 类型 | `type` | `frontType` | 状态 |
+|---------|--------|-------------|------|
+| `character varying` / `character` | `text` | `varchar` | ✅ 实测 |
+| `text` | `text` | `text` | ✅ 实测 |
+| `jsonb` / `json` | `text` | `text` | ✅ 实测(DMS 无原生 JSON,**存 text 后不可用 search 检索内部**) |
+| `bigint` / `integer` / `smallint` | `integer` | `int_num` | ✅ 实测 |
+| `numeric` / `double precision` | `double` | `float_num` | ✅ 实测 |
+| `boolean` | `boolean` | `boolean` | ✅ 实测 |
+| (epoch 秒换算后) | `timestamp` | `date_time` | ✅ 实测 |
+| — | `check` / `mcheck` | 单/多选 | 📄 未用到,未验证 |
+| — | `type` / `mtype` | 类别 | 📄 未用到,未验证 |
+| — | `file` | `files` | 📄 未用到,未验证 |
+| — | `geometry` | 点/线/面 | 📄 未用到,未验证 |
+
+> `customType`/`defaultValue` 传空字符串 `""`。
+
+### 5.3 传输方式:必须用 form body
+
+OpenAPI 把 `addModel`/`addColumn` 的参数都声明为 `query`,但:
+
+| 方式 | 结果 |
+|------|------|
+| query string | 41 字段的 `fieldList` → **HTTP 400**(URL 过长) |
+| form body(`-d`) | ✅ 正常 |
+
+**结论:大 payload 一律用 form body,忽略 OpenAPI 的 `in: query`。**
+
+### 5.4 错误码触发条件(实测)
+
+| code | 实测触发条件 |
+|------|--------------|
+| `-1` 参数错误 | `addModel` / `addColumn` **缺 `authorId`/`authorName`** |
+| `205` 模型不存在 | `addColumn` 时模型还没建 |
+| `214` 数据错误 | ① `frontType` 填了 `"content"` 而非 param 名<br>② 缺 `index`<br>③ fieldList 的 JSON 带空格(非紧凑) |
+| HTTP 400 | `fieldList` 放进了 query string |
+
+### 5.5 建栏目顺序
+
+```
+PUT /model/addModel      建基础模型 (type=1)
+   ↓
+POST /model/getModelByName  拿 modelId(幂等检查也用它)
+   ↓
+PUT /column/addColumn    建内容栏目 (type=1),DMS 自动克隆模型 → type=2 + c_ 前缀
+```
+
+**不可颠倒** —— `addColumn` 要求模型已存在。
+
+---
+
+## 6. 命令速查
+
+```bash
+export DMS_HOST=121.43.55.7:10081
+export DMS_TOKEN='...'
+
+# 接口规范(排障第一站)
+curl -s "http://${DMS_HOST}/dms/v3/api-docs" -H "token: ${DMS_TOKEN}"
+
+# 栏目树
+curl -s -X POST "http://${DMS_HOST}/dms/column/getColumnList" -H "token: ${DMS_TOKEN}"
+
+# 模型
+curl -s "http://${DMS_HOST}/dms/model/getModelById?modelId=2030" -H "token: ${DMS_TOKEN}"
+curl -s "http://${DMS_HOST}/dms/model/getModelListByPage?type=2" -H "token: ${DMS_TOKEN}"
+
+# 基础字段库(frontType 的取值来源)
+curl -s -X POST "http://${DMS_HOST}/dms/param/selectAll" -H "token: ${DMS_TOKEN}"
+
+# 核对行数(C1 校验)
+curl -s -X POST "http://${DMS_HOST}/dms/content/selectContentList" \
+  -H "token: ${DMS_TOKEN}" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -d "columnId=1885&page=0&pageSize=1"
+```
+
+---
+
+## 7. 相关文档
+
+| 文档 | 内容 |
+|------|------|
+| [`dms_openapi.json`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/artifacts/dms_openapi.json) | 服务端 OpenAPI 全量(82 端点),**最权威** |
+| [`SKILL.md`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/2.%20DMS-Manage/cursor-skill-dms/SKILL.md) | DMS 领域模型与常用流程 |
+| [`apifox-dms.md`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/2.%20DMS-Manage/cursor-skill-dms/apifox-dms.md) | Apifox 干净版 |
+| [`DMS_MAPPING.md`](DMS_MAPPING.md) | 本项目表→栏目映射 + 踩坑记录 |
+| [`RUNBOOK.md`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/docs/RUNBOOK.md) | 操作手册 |

+ 446 - 0
harness/docs/reference/DMS_COLUMNS.md

@@ -0,0 +1,446 @@
+# 营商助手 · 栏目使用手册
+
+**面向日常使用** —— 查数据、写数据、做集成时看这份。
+
+> 与 [`DMS_MAPPING.md`](DMS_MAPPING.md) 的分工:
+> 那份是**迁移视角**(源表→栏目怎么映射、建栏目踩了什么坑);
+> 这份是**使用视角**(这五个栏目怎么用,字段叫什么)。
+> 接口本身的通用约定见 [`DMS_API.md`](DMS_API.md)。
+
+**服务**:`http://121.43.55.7:10081/dms`
+**父栏目**:营商助手 `columnId=1883`(分级栏目,`tag=yszs`)
+**最后核对**:2026-09-16
+
+> 📍 **本文引用的 artifacts / RUNBOOK 不在本仓库**,属于另一个项目
+> `F:\yysk\AI_zhaoshang\DMS_Data_Migration\`(本文从它的 `harness/docs/` 复制而来)。
+> 链接已改为**绝对路径**指向那边。
+
+---
+
+## ⚠️ 先读这段:栏目建好了,但**数据还没迁**
+
+| 栏目 | columnId | 当前内容数 |
+|------|----------|-----------|
+| 助手页面浏览 | 1885 | **0** |
+| 企业荣誉信息 | 1886 | **0** |
+| 助手会话 | 1887 | **0** |
+| 企业基础信息 | 1888 | **0** |
+| 助手问答记录 | 1889 | **0** |
+
+实测 `selectContentList` 五个栏目全部返回 `code=202 数据不存在`。
+
+**五张表的结构(栏目 + 字段)已就绪,但业务数据尚未写入。**
+数据迁移(`F4-*`)还没开始。所以现在查任何栏目都只会得到 202。
+
+---
+
+## 1. 速查表
+
+| 栏目 | columnId | tag(权限名) | 栏目模型 modelId | 字段数 | 源表 | 源行数 |
+|------|----------|---------------|------------------|--------|------|--------|
+| 助手页面浏览 | **1885** | `yszs_page_view` | **2030** | 4 | `assistant_page_view` | 39,723 |
+| 企业荣誉信息 | **1886** | `yszs_qcc_honor` | **2032** | 11 | `qcc_honor` | 7,118 |
+| 助手会话 | **1887** | `yszs_chat_session` | **2034** | 7 | `chat_session` | 3,133 |
+| 企业基础信息 | **1888** | `yszs_qcc_enterprise` | **2036** | 41 | `qcc_enterprise` | 81,980 |
+| 助手问答记录 | **1889** | `yszs_chat_record` | **2038** | 13 | `chat_record` | 15,542 |
+
+**调 content 接口时用 `columnId`。** 权限用 `tag`(已同步 OAuth `permissionName`)。
+
+**业务主线**:`c_credit_code`(统一社会信用代码)串起 `企业基础信息` ↔ `企业荣誉信息` ↔ `助手会话` ↔ `助手问答记录`。
+
+---
+
+## 2. 字段全表
+
+> 字段名以 `c_` 开头。`type` 是 DMS 存储类型,`前端控件` 是 DMS 界面渲染用的控件名。
+> 类型为 `text` 的字段**用模糊查询**(`searchType=2`),其余用精确(`searchType=1`)。
+
+### 2.1 助手页面浏览 `columnId=1885`
+
+埋点数据。**无企业关联**(没有 `credit_code`),只有访客维度。
+
+| 字段 | 别名 | 类型 | 前端控件 | 必填 |
+|------|------|------|----------|------|
+| `c_id` | ID | integer | int_num | ✓ |
+| `c_business_code` | 业务编码 | text | varchar | |
+| `c_visitor_id` | 访客ID | text | varchar | |
+| `c_created_at` | 创建时间 | **timestamp** | date_time | |
+
+### 2.2 企业荣誉信息 `columnId=1886`
+
+| 字段 | 别名 | 类型 | 前端控件 | 必填 |
+|------|------|------|----------|------|
+| `c_id` | ID | integer | int_num | ✓ |
+| `c_credit_code` | 统一社会信用代码 | text | varchar | ✓ |
+| `c_name` | 企业名称 | text | varchar | |
+| `c_level` | 级别 | text | varchar | |
+| `c_source` | 来源 | text | varchar | |
+| `c_publish_office` | 发布机构 | text | varchar | |
+| `c_publish_date` | 发布日期 | **text** | varchar | |
+| `c_beging_date` | 起始日期 | **text** | varchar | |
+| `c_dead_line` | 截止日期 | **text** | varchar | |
+| `c_certificate_code` | 证书编号 | text | varchar | |
+| `c_created_at` | 创建时间 | **timestamp** | date_time | |
+
+### 2.3 助手会话 `columnId=1887`
+
+| 字段 | 别名 | 类型 | 前端控件 | 必填 |
+|------|------|------|----------|------|
+| `c_id` | ID | integer | int_num | ✓ |
+| `c_credit_code` | 统一社会信用代码 | text | varchar | ✓ |
+| `c_session_id` | 会话ID | text | varchar | |
+| `c_title` | 标题 | text | varchar | |
+| `c_source` | 来源 | text | varchar | |
+| `c_created_at` | 创建时间 | **timestamp** | date_time | |
+| `c_updated_at` | 更新时间 | **timestamp** | date_time | |
+
+### 2.4 企业基础信息 `columnId=1888`
+
+41 字段。**主键 `c_credit_code`**(注意:没有 `c_id`)。
+
+**主体字段**
+
+| 字段 | 别名 | 类型 |
+|------|------|------|
+| `c_credit_code` | 统一社会信用代码 | text |
+| `c_name` | 企业名称 | text |
+| `c_belong_org` | 所属机构 | text |
+| `c_oper_id` | 经营者ID | text |
+| `c_oper_name` | 经营者名称 | text |
+| `c_ent_type` | 企业类型 | text |
+| `c_econ_kind` | 经济类型 | text |
+| `c_status` | 经营状态 | text |
+| `c_province` | 省份 | text |
+| `c_area_code` | 行政区划代码 | text |
+| `c_address` | 注册地址 | text |
+| `c_phone_number` | 联系电话 | text |
+| `c_email` | 邮箱 | text |
+
+**经营期限与资本**
+
+| 字段 | 别名 | 类型 |
+|------|------|------|
+| `c_start_date` | 成立日期 | text |
+| `c_end_date` | 结束日期 | text |
+| `c_term_start` | 营业期限起 | text |
+| `c_term_end` | 营业期限止 | text |
+| `c_check_date` | 核准日期 | text |
+| `c_updated_date` | 更新日期 | text |
+| `c_regist_capi` | 注册资本 | text |
+| `c_registered_capital` | 注册资本数值 | text |
+| `c_registered_capital_unit` | 注册资本单位 | text |
+| `c_registered_capital_ccy` | 注册资本币种 | text |
+| `c_rec_cap` | 实收资本 | text |
+| `c_paid_up_capital` | 实缴资本 | text |
+| `c_paid_up_capital_unit` | 实缴资本单位 | text |
+| `c_paid_up_capital_ccy` | 实缴资本币种 | text |
+
+**上市与编码**
+
+| 字段 | 别名 | 类型 |
+|------|------|------|
+| `c_is_on_stock` | 是否上市 | text |
+| `c_stock_number` | 股票代码 | text |
+| `c_stock_type` | 股票类型 | text |
+| `c_org_no` | 组织机构代码 | text |
+| `c_no` | 编号 | text |
+| `c_image_url` | 图片地址 | text |
+
+**长文本**
+
+| 字段 | 别名 | 类型 |
+|------|------|------|
+| `c_scope` | 经营范围 | **text** |
+| `c_scope_brief` | 经营范围简述 | text |
+
+**JSON 存成 text 的字段**(⚠️ 内容不可被 DMS search 检索,见 §5.3)
+
+| 字段 | 别名 | 源 jsonb 内容 |
+|------|------|---------------|
+| `c_designated_representative_list` | 法定代表人列表 | 法定代表人数组 |
+| `c_original_name` | 曾用名 | 曾用名数组 |
+| `c_revoke_info` | 撤销信息 | 撤销记录 |
+| `c_area` | 行政区划 | 行政区划对象 |
+| `c_industry` | 行业 | 行业分类对象 |
+
+**时间**
+
+| 字段 | 别名 | 类型 |
+|------|------|------|
+| `c_created_at` | 创建时间 | **timestamp** |
+
+### 2.5 助手问答记录 `columnId=1889`
+
+| 字段 | 别名 | 类型 | 前端控件 | 必填 |
+|------|------|------|----------|------|
+| `c_id` | ID | integer | int_num | ✓ |
+| `c_credit_code` | 统一社会信用代码 | text | varchar | ✓ |
+| `c_session_id` | 会话ID | text | varchar | |
+| `c_record_id` | 记录ID | text | varchar | |
+| `c_question` | 问题 | **text** | text | |
+| `c_answer` | 回答 | **text** | text | |
+| `c_reason` | 原因 | **text** | text | |
+| `c_source` | 来源 | text | varchar | |
+| `c_feedback_status` | 反馈状态 | integer | int_num | |
+| `c_feedback_option` | 反馈选项 | text | varchar | |
+| `c_feedback_remark` | 反馈备注 | **text** | text | |
+| `c_created_at` | 创建时间 | **timestamp** | date_time | |
+| `c_feedback_at` | 反馈时间 | **timestamp** | date_time | |
+
+---
+
+## 3. 查询示例
+
+所有查询共用:
+
+```bash
+export DMS_HOST=121.43.55.7:10081
+export DMS_TOKEN='...'
+```
+
+### 3.1 通用模板
+
+```bash
+curl -s -X POST "http://${DMS_HOST}/dms/content/selectContentList" \
+  -H "token: ${DMS_TOKEN}" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -d "columnId=1888" \
+  -d "page=0" \
+  -d "pageSize=20" \
+  -d "search=[{\"field\":\"c_name\",\"searchType\":2,\"content\":{\"value\":\"科技\"}}]" \
+  -d "orderBy=[{\"field\":\"c_created_at\",\"orderByType\":2}]"
+```
+
+返回 `content.data`(数组)+ `content.count`(总数)。
+
+`page` **从 0 开始**。
+
+### 3.2 按企业名模糊查(企业基础信息 1888)
+
+```bash
+-d 'search=[{"field":"c_name","searchType":2,"content":{"value":"上海某某"}}]'
+```
+
+### 3.3 按信用代码精确查(跨栏目都能用)
+
+```bash
+-d 'search=[{"field":"c_credit_code","searchType":1,"content":{"value":"91310118MA1JLXXXXX"}}]'
+```
+
+### 3.4 查某企业的荣誉(企业荣誉信息 1886)
+
+```bash
+curl -s -X POST "http://${DMS_HOST}/dms/content/selectContentList" \
+  -H "token: ${DMS_TOKEN}" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -d "columnId=1886" \
+  -d 'search=[{"field":"c_credit_code","searchType":1,"content":{"value":"91310118MA1JLXXXXX"}}]' \
+  -d "page=0&pageSize=50"
+```
+
+### 3.5 查某企业的问答记录(助手问答记录 1889)
+
+```bash
+-d "columnId=1889"
+-d 'search=[{"field":"c_credit_code","searchType":1,"content":{"value":"91310118MA1JLXXXXX"}}]'
+```
+
+### 3.6 时间区间查(`searchType=3`)
+
+```bash
+-d 'search=[{"field":"c_created_at","searchType":3,"content":{"start":"2026-01-01 00:00:00","end":"2026-12-31 23:59:59"}}]'
+```
+
+### 3.7 多条件(数组里放多个对象 = AND)
+
+```bash
+-d 'search=[
+  {"field":"c_credit_code","searchType":1,"content":{"value":"91310118MA1JLXXXXX"}},
+  {"field":"c_level","searchType":2,"content":{"value":"市级"}}
+]'
+```
+
+### 3.8 searchType 速查
+
+| 值 | 含义 | content 写法 |
+|----|------|--------------|
+| `1` | 精确 | `{"value":"..."}` |
+| `2` | 模糊 | `{"value":"..."}` |
+| `3` | 区间 | `{"start":"...","end":"..."}` |
+| `4` | in 数组 | `{"value":["a","b"]}` |
+| `5` | between | `{"start":..,"end":..}` |
+
+### 3.9 取单条详情
+
+```bash
+# columnId + contentId(contentId 是 UUID 字符串)
+curl -s -X POST "http://${DMS_HOST}/dms/content/selectContentById" \
+  -H "token: ${DMS_TOKEN}" \
+  -d "columnId=1888&contentId={uuid}"
+```
+
+**需要字典解析**(把码值翻成文本)时用 `selectContentListInfo`,参数相同。
+
+### 3.10 用 Python 查(推荐,避免 shell 转义地狱)
+
+```python
+import sys; sys.path.insert(0, 'harness/tools')
+from dms import Dms
+import json
+
+d = Dms()
+r = d.call("POST", "/content/selectContentList", {
+    "columnId": 1888,
+    "page": 0,
+    "pageSize": 20,
+    "search": json.dumps([{"field": "c_name", "searchType": 2,
+                           "content": {"value": "科技"}}], ensure_ascii=False),
+    "orderBy": json.dumps([{"field": "c_created_at", "orderByType": 2}]),
+}, expect=None)
+print(r["content"]["count"], "条")
+for row in r["content"]["data"]:
+    print(row.get("c_credit_code"), row.get("c_name"))
+```
+
+---
+
+## 4. 写入示例
+
+**写入前先读 §5.1(时间换算)和 §5.2(幂等)** —— 这两条不注意会产生脏数据。
+
+### 4.1 新增
+
+```bash
+curl -s -X POST "http://${DMS_HOST}/dms/content/addContent" \
+  -H "token: ${DMS_TOKEN}" \
+  -d "columnId=1886" \
+  -d "modelId=2032" \
+  -d 'content={"c_id":1,"c_credit_code":"91310118MA1JLXXXXX","c_name":"某某公司","c_level":"市级"}'
+```
+
+`content` 是 **JSON 字符串**,字段名带 `c_` 前缀。
+`modelId` 用**栏目模型 id**(速查表里的那个)。
+
+### 4.2 修改
+
+同 `addContent`,但 `content` 里**必须带 `id`**(记录 UUID,不是业务 id):
+
+```bash
+-d 'content={"id":"{记录uuid}","c_level":"国家级"}'
+```
+
+> ⚠️ 注意区分两个「id」:`c_id` 是**业务 id**(来自源库),`id` 是 **DMS 记录 UUID**。
+> 修改时要用后者。
+
+### 4.3 批量
+
+`POST /content/importJSON` —— 服务端确认存在,但**参数未文档化、本项目未实测**。
+大批量写入前先在测试栏目验证(见 [`DMS_API.md`](DMS_API.md) §4.7)。
+
+---
+
+## 5. 使用注意
+
+### 5.1 ⏱ 时间是 **epoch 秒**,查询和写入都要注意
+
+源库的 `created_at` / `updated_at` / `feedback_at` 是 **epoch 秒**(不是毫秒),
+栏目里存为 `timestamp`。
+
+**查询时**用正常的时间字符串(DMS 已转好):
+
+```bash
+-d 'search=[{"field":"c_created_at","searchType":3,"content":{"start":"2026-09-01 00:00:00","end":"2026-09-30 23:59:59"}}]'
+```
+
+**写入/换算时**:`datetime.fromtimestamp(v)`,**不要除以 1000**。
+
+```python
+from datetime import datetime
+datetime.fromtimestamp(1789450583)   # 2026-09-15 13:36 ✅
+datetime.fromtimestamp(1789450583/1000)  # 1970-01-22 ❌
+```
+
+### 5.2 🔁 幂等键(重跑不产生重复)
+
+| 栏目 | 幂等键 | 说明 |
+|------|--------|------|
+| 企业基础信息 1888 | `c_credit_code` | 源表主键,天然唯一 |
+| 企业荣誉信息 1886 | `c_id` | 源表主键 |
+| 助手会话 1887 | `c_id` | 源表主键 |
+| 助手问答记录 1889 | `c_id` | 源表主键 |
+| 助手页面浏览 1885 | `c_id` | 源表主键 |
+
+**写入前先按幂等键查一次**:不存在则 `addContent`,已存在则 `updateContent`。
+
+### 5.3 🔍 jsonb 字段存成 text,**搜不了内部**
+
+企业基础信息里这 5 个字段是 JSON 文本,**DMS 的 search 无法检索其内部值**:
+
+`c_designated_representative_list`(法定代表人)、`c_industry`(行业)、
+`c_area`(行政区划)、`c_original_name`(曾用名)、`c_revoke_info`(撤销信息)
+
+> 例如「查法定代表人是张三的企业」**做不到** —— 除非把 JSON 反序列化后另建字段/栏目。
+> 如果这类查询有需求,需要单独提出来做。
+
+### 5.4 🕳 空值
+
+- `c_credit_code` 在企业基础信息里是主键;但在荣誉/会话/问答里**可空**
+- 关联查询时注意 `c_credit_code` 为空的行会被漏掉
+
+### 5.5 🔐 权限
+
+栏目 `tag` 已同步 OAuth `permissionName`:
+
+| 栏目 | tag |
+|------|-----|
+| 助手页面浏览 | `yszs_page_view` |
+| 企业荣誉信息 | `yszs_qcc_honor` |
+| 助手会话 | `yszs_chat_session` |
+| 企业基础信息 | `yszs_qcc_enterprise` |
+| 助手问答记录 | `yszs_chat_record` |
+
+无权限访问返回 `code=201`,token 失效返回 `212`。
+
+> ⚠️ **权限边界尚未验证**(未用测试账号确认「有权限能读 / 没权限返回 201」)。
+
+---
+
+## 6. 已知问题
+
+| 问题 | 影响 | 处置 |
+|------|------|------|
+| ~~`c_updated_at`(1887)显示名为 `updated_at`~~ | — | ✅ **已修复**(2026-09-16),现显示「更新时间」。根因是建栏目脚本别名表漏配 `updated_at`,已同步修正 |
+| 数据尚未迁移 | 5 个栏目全部 `code=202 数据不存在` | 等 `F4-*` |
+| 权限边界未验证 | 不知道无权限用户能否读到 | 等 `F6-01` |
+
+**改字段别名的方法**(实测可用):
+
+```bash
+python harness/tools/fix_field_alias.py --model 2034 --field c_updated_at --alias 更新时间 --apply
+```
+
+> ⚠️ 更新模型要用 `POST /model/updateProperty`。
+> `updateFields` 返回 HTTP 400、`updateFieldList` 返回 HTTP 500 —— 名字最像的恰好不能用。
+
+---
+
+## 7. 错误码
+
+| code | 含义 | 排查 |
+|------|------|------|
+| `200` | 成功 | |
+| `201` | 无权限 | 检查栏目 `tag` 与用户 OAuth 权限 |
+| `202` | **数据不存在** | 栏目是空的 —— 当前 5 个栏目都会返回这个 |
+| `205` | 无数据 / 模型不存在 | 查询条件无匹配;或 `getModelList` 没传 `type` |
+| `212` | token 无效/过期 | 重新获取 |
+
+---
+
+## 8. 相关文档
+
+| 文档 | 用途 |
+|------|------|
+| [`DMS_API.md`](DMS_API.md) | DMS 接口通用约定与全部端点 |
+| [`DMS_MAPPING.md`](DMS_MAPPING.md) | 源表→栏目映射、建栏目实测契约 |
+| [`RUNBOOK.md`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/docs/RUNBOOK.md) | 操作手册 |
+| [`dms_column_fields.json`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/artifacts/dms_column_fields.json) | 本文档字段表的机器可读版(从 DMS 实时拉取) |

+ 179 - 0
harness/docs/reference/DMS_MAPPING.md

@@ -0,0 +1,179 @@
+# 表 → 栏目 / 字段映射
+
+**DMS 环境**:`http://121.43.55.7:10081/dms`(`DMS_HOST` 可覆盖)
+**建栏时间**:2026-09-16(session 004)
+
+> 📍 **本文引用的 tools / artifacts 不在本仓库**,属于另一个项目
+> `F:\yysk\AI_zhaoshang\DMS_Data_Migration\`(本文从它的 `harness/docs/` 复制而来)。
+> 链接已改为**绝对路径**指向那边。
+**执行工具**:[`create_dms_columns.py`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/tools/create_dms_columns.py)
+**原始记录**:[`created_columns.json`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/artifacts/created_columns.json)
+
+> 这份文件是**机器可读契约**,不是散文。迁移脚本会直接消费它。
+> 改动这里 = 改动迁移行为,请同步更新对应 feature 的证据。
+
+---
+
+## 1. 目标栏目结构(已建成)
+
+父栏目 **营商助手**:`columnId=1883` · `type=0`(分级)· `tag=yszs`
+
+```
+营商助手 (1883, 分级, yszs)
+├── 助手页面浏览      (1885)  yszs_page_view         ← assistant_page_view   39,723 行
+├── 企业荣誉信息      (1886)  yszs_qcc_honor         ← qcc_honor             7,118 行
+├── 助手会话          (1887)  yszs_chat_session      ← chat_session          3,133 行
+├── 企业基础信息      (1888)  yszs_qcc_enterprise    ← qcc_enterprise       81,980 行
+└── 助手问答记录      (1889)  yszs_chat_record       ← chat_record          15,542 行
+```
+
+合计 **147,496 行**待迁移。
+
+---
+
+## 2. 完整映射表
+
+| 源表 | 行数 | 栏目名 | columnId | tag | 基础模型 id | 栏目模型 id | 栏目模型名 | 字段数 |
+|------|------|--------|----------|-----|------------|------------|-----------|--------|
+| `assistant_page_view` | 39,723 | 助手页面浏览 | **1885** | `yszs_page_view` | 2029 | 2030 | `yszs_page_view_yszs_page_view_model` | 4 |
+| `qcc_honor` | 7,118 | 企业荣誉信息 | **1886** | `yszs_qcc_honor` | 2031 | 2032 | `yszs_qcc_honor_yszs_qcc_honor_model` | 11 |
+| `chat_session` | 3,133 | 助手会话 | **1887** | `yszs_chat_session` | 2033 | 2034 | `yszs_chat_session_yszs_chat_session_model` | 7 |
+| `qcc_enterprise` | 81,980 | 企业基础信息 | **1888** | `yszs_qcc_enterprise` | 2035 | 2036 | `yszs_qcc_enterprise_yszs_qcc_enterprise_model` | 41 |
+| `chat_record` | 15,542 | 助手问答记录 | **1889** | `yszs_chat_record` | 2037 | 2038 | `yszs_chat_record_yszs_chat_record_model` | 13 |
+
+**写入时用 `columnId` + `modelId`(栏目模型 id,即上表「栏目模型 id」列)。**
+
+> DMS 在 `addColumn` 时把基础模型(type=1)克隆为栏目模型(type=2),
+> 克隆时给每个字段加 `c_` 前缀。物理表名是 `column_{栏目模型名}`。
+
+---
+
+## 3. 字段级映射
+
+字段名规则:`源字段` → `c_{源字段}`(前缀由 DMS 克隆时自动加)。
+中文别名与类型映射在 [`create_dms_columns.py`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/tools/create_dms_columns.py) 里定义。
+
+### 3.1 `assistant_page_view` → 助手页面浏览 (1885)
+
+| 源字段 | PG 类型 | 目标字段 | DMS type | frontType | 别名 |
+|--------|---------|----------|----------|-----------|------|
+| `id` | bigint | `c_id` | integer | int_num | ID |
+| `business_code` | varchar(64) | `c_business_code` | text | varchar | 业务编码 |
+| `visitor_id` | varchar(64) | `c_visitor_id` | text | varchar | 访客ID |
+| `created_at` | bigint | `c_created_at` | timestamp | date_time | 创建时间 |
+
+### 3.2 `qcc_honor` → 企业荣誉信息 (1886)
+
+| 源字段 | 目标字段 | DMS type | 别名 |
+|--------|----------|----------|------|
+| `id` | `c_id` | integer | ID |
+| `credit_code` | `c_credit_code` | text | 统一社会信用代码 |
+| `name` | `c_name` | text | 企业名称 |
+| `level` | `c_level` | text | 级别 |
+| `source` | `c_source` | text | 来源 |
+| `publish_office` | `c_publish_office` | text | 发布机构 |
+| `publish_date` | `c_publish_date` | text | 发布日期 |
+| `beging_date` | `c_beging_date` | text | 起始日期 |
+| `dead_line` | `c_dead_line` | text | 截止日期 |
+| `certificate_code` | `c_certificate_code` | text | 证书编号 |
+| `created_at` | `c_created_at` | timestamp | 创建时间 |
+
+### 3.3 `chat_session` → 助手会话 (1887)
+
+| 源字段 | 目标字段 | DMS type | 别名 |
+|--------|----------|----------|------|
+| `id` | `c_id` | integer | ID |
+| `credit_code` | `c_credit_code` | text | 统一社会信用代码 |
+| `session_id` | `c_session_id` | text | 会话ID |
+| `title` | `c_title` | text | 标题 |
+| `created_at` | `c_created_at` | timestamp | 创建时间 |
+| `updated_at` | `c_updated_at` | timestamp | 更新时间 |
+| `source` | `c_source` | text | 来源 |
+
+### 3.4 `qcc_enterprise` → 企业基础信息 (1888)
+
+41 字段。**主键 `credit_code`**(无 `id` 列)。完整字段表由
+[`create_dms_columns.py`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/tools/create_dms_columns.py) 的 `ALIAS` 字典定义,摘要:
+
+- 主体:`credit_code` → `c_credit_code`、`name` → `c_name`
+- jsonb 列(`designated_representative_list`/`original_name`/`revoke_info`/`area`/`industry`)
+  → `c_*`,**类型在模型里定义为 `text`**
+- `created_at` → `c_created_at`(timestamp)
+
+### 3.5 `chat_record` → 助手问答记录 (1889)
+
+13 字段。完整定义同上工具文件。
+
+---
+
+## 4. ⚠️ 迁移时必须注意
+
+### 4.1 `created_at` 是 **epoch 秒**,不是毫秒
+
+实测(session 003/004):
+
+```
+chat_record.created_at = 1789450583  → 2026-09-15 13:36   ✅ 秒
+                       当作毫秒 → 1970-01-22              ❌
+```
+
+模型里定义为 `timestamp`,**写入前需 `datetime.fromtimestamp(v)` 换算**。
+涉及字段:`created_at`、`updated_at`、`feedback_at`(按表不同)。
+
+### 4.2 `credit_code` 是主键但不是 NOT NULL
+
+`qcc_enterprise.credit_code` 是主键;`qcc_honor.credit_code`、`chat_*` 的
+`credit_code` 可空。做幂等键时要处理空值情况。
+
+### 4.3 幂等键
+
+| 表 | 幂等键 | 说明 |
+|----|--------|------|
+| `qcc_enterprise` | `credit_code` | 主键,天然唯一 |
+| `qcc_honor` | `id` | 主键 |
+| `chat_session` | `id` | 主键 |
+| `chat_record` | `id` | 主键 |
+| `assistant_page_view` | `id` | 主键 |
+
+> 写入前按幂等键查 DMS 是否已存在:不存在 `addContent`,已存在 `updateContent`。
+> 详见 feature `F4-02`。
+
+### 4.4 jsonb 字段存成 text 后不可检索
+
+`qcc_enterprise` 的 5 个 jsonb 列在 DMS 里是 `text`,**DMS 的 search 无法检索其内部字段**。
+若将来需要按法定代表人/行业检索,需另建展开栏目(见 `F3-02` 后续)。
+
+---
+
+## 5. 建栏目时踩过的坑(DMS API 真实契约)
+
+这些是**实测得出**的,文档(`SKILL.md`/Apifox)里没有。写在这里避免重复踩。
+
+| # | 坑 | 正确做法 |
+|---|-----|----------|
+| 1 | `addModel`/`addColumn` 必须带 `authorId` + `authorName` | 否则 `code=-1 参数错误`。可从 JWT payload 解出(见 `dms.py` 的 `identity()`) |
+| 2 | `fieldList` 里字段的 `frontType` 不是 `"content"` | 必须是基础字段库(`/param/selectAll`)里的 **param 名**:`varchar`/`text`/`int_num`/`float_num`/`date_time`/`boolean`/`files`。用错得到 `code=214 数据错误` |
+| 3 | 字段要有 `index`,`sequence` 从 **0** 起 | 缺 `index` 或 1 起会 `code=214` |
+| 4 | 基础模型字段名**不加** `c_` 前缀 | 前缀由 `addColumn` 克隆时自动加。自己加会导致 `c_c_xxx` |
+| 5 | `searchField`/`sortField` 是空数组字面量 `"[]"` | 填字段名会 `code=214` |
+| 6 | 参数放 **query string** 会有长度限制 | 41 字段的 fieldList 触发 **HTTP 400**。用 **form body**(PUT + `application/x-www-form-urlencoded`) |
+| 7 | `addColumn` 要求模型**已存在** | 否则 `code=205 模型不存在`。必须先 `addModel` 再 `addColumn` |
+| 8 | 服务端有 OpenAPI 规范 | `GET /dms/v3/api-docs`(只读,118KB)。比 Apifox 新,**优先查它** |
+| 9 | 更新模型**不能用** `updateFields` / `updateFieldList` | 用 **`POST /model/updateProperty`**。实测:`updateProperty`→200,`updateFields`→HTTP 400,`updateFieldList`→HTTP 500。**名字最贴切的反而不能用** |
+| 10 | 字段别名漏配不会报错,只会静默显示英文名 | 建栏目后应逐字段核对 alias。检查法:alias 是否等于字段名去掉 `c_` 前缀 |
+
+> 完整 OpenAPI 已存档:[`dms_openapi.json`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/artifacts/dms_openapi.json)
+
+---
+
+## 6. 重跑
+
+建栏目是**幂等**的(模型已存在则跳过):
+
+```bash
+export DMS_TOKEN='...'
+python harness/tools/create_dms_columns.py           # dry-run 看计划
+python harness/tools/create_dms_columns.py --apply   # 执行
+```
+
+---

+ 144 - 0
harness/docs/reference/README.md

@@ -0,0 +1,144 @@
+# 接口契约与参考资料
+
+这个目录放**外部接口的契约文档**,供人和 agent 共同查阅。
+原则:参考材料显式收进仓库,避免依赖聊天上下文或人的记忆。
+
+## 先看哪份
+
+| 文档 | 状态 | 什么时候读 |
+|---|---|---|
+| `api-chat.md` | ✅ **现行契约** | 改任何聊天相关代码之前 |
+| `api-chat-fields-zh.md` | ✅ **现行契约** | 需要字段中英对照、状态取值释义时 |
+| `legacy/API.md` | 📦 **交接快照**(非规格、不更新) | 只在需要翻"当初调了什么接口"时读 |
+| `DMS_API.md` | 📄 **外部服务契约**(含实测结论) | 要调 DMS(数据管理服务)时 |
+| `DMS_COLUMNS.md` | 📄 **栏目现状** | 查 DMS 五个栏目的 columnId / 字段时 |
+| `DMS_MAPPING.md` | 📄 **表→栏目映射** | 做数据迁移、核对字段映射时 |
+
+> **DMS 三份文档的来路**:它们出自**另一个项目**
+> `F:\yysk\AI_zhaoshang\DMS_Data_Migration\`(那边有 `harness/artifacts/dms_openapi.json`、
+> `harness/tools/create_dms_columns.py`、`harness/docs/RUNBOOK.md`)。
+> 复制进来时没带上这些同伴,**本文档里指向它们的链接已改为绝对路径**。
+> 那边的项目才是 DMS 工作的主场;这里保留它们,是为了让本仓库的问题排查能查到 DMS 契约。
+>
+> **栏目现状**(2026-09-17 更新,原「五个栏目全空」的说法已过期):
+> - **1887 助手会话 / 1889 助手问答记录**:前端已接入(本站的会话、整段对话、反馈都写这里),
+>   属于**在用的在线数据源**。见 [`../exec-plans/active/dms-chat-storage.md`](../exec-plans/active/dms-chat-storage.md)
+> - **1885 页面浏览 / 1886 企业荣誉 / 1888 企业基础信息**:仍是空的,
+>   F4 数据迁移未开始,查它们只会得到 `code=202 数据不存在`
+
+## 现行契约:`POST /api/chat`
+
+```bash
+curl --request POST \
+  --url http://192.168.2.23:8000/api/chat \
+  --header 'Content-Type: application/json' \
+  --data '{
+  "thread_id": "1111",
+  "question": "高新技术企业的奖励是多少钱"
+}'
+```
+
+**最容易踩的坑:请求体只接受 `thread_id` 与 `question` 两个字段,多一个就返回 422。**
+旧的 `transmission.files` / `file_pos` 等字段在新接口上发不出去(这正是"文件上传"功能阻塞的原因)。
+
+- 返回的是 SSE 命名事件流:`accepted` / `progress` / `heartbeat` / `answer` / `source` /
+  `item` / `summary` / `interrupt` / `result` / `done` / `error`
+- 每个事件的 `data` 统一封装为 `{thread_id, request_id, data}`
+- `answer.text` 与 `result.response` 相同,**只能展示一次**(result 是快照,不要重复追加)
+- 未收到 `done`/`error` 就断开 → 按连接中断处理,**不自动重试**
+
+前端侧的适配层在 `src/components/api-chat-coordinator.ts`:它把新协议翻译成既有渲染组件
+能识别的内容标记(`<scope>` / `<!-- POLICY_TABLE -->` / `<ref_links>` / `<question-cards>`),
+因此渲染层(BusinessRecord / PolicyMatch / QuestionCard / ScopeContent)不需要感知新协议。
+
+接口地址由 `VITE_CHAT_API` 配置,两种写法都支持(服务前缀会自动追加 `/api/chat`,
+已是完整地址则原样使用),见 `src/utils/runtime-config.ts` 的 `getChatApiBaseUrl()`。
+开发环境走同源代理 `/chat-api` → `http://192.168.2.23:8000`(见 `vite.config.ts`)。
+
+### ⚠️ 已知的有意偏离:公司候选提交的是公司名,不是序号
+
+`api-chat.md` 的 interrupt 一节写的是:
+
+> | 用户操作 | 下一次 question |
+> | --- | --- |
+> | 选择显示的第1家公司 | `"1"`,从1开始;不要重排候选后仍按原序号提交 |
+> | 换关键词 | 新名称或代码 |
+>
+> 不能直接把 actions 或 KeyNo 对象放入 question;当前 question 必须是字符串,
+> **API 由序号映射到原候选**。
+
+**当前前端不这么做。** 公司候选提交的是**选项全部内容拼接成的文本**
+(公司名 + 信用代码 + 状态 + 负责人,见 `src/components/api-chat-coordinator.ts`
+的 `toChatQuestionAnswer`)。这是产品明确要求。
+
+> 🚨 **已实际发生的事故:死循环。** 提交公司名后再次调用接口,落进了协议里
+> "换关键词"那条路径 → 后端重新检索 → 返回同一批候选 → 用户再选 → 再循环。
+> 这与上表的语义冲突是**预期内**的:协议把"文本"这种输入形式保留给了 refine,
+> 文本在语义上无法表达"我选这一个"。
+
+**若死循环再次出现**,只有两条路:
+
+1. **改回提交序号**(协议的做法)——序号取自**当前显示顺序**(1 起),
+   不要重排候选后仍按原序号提交
+2. **后端扩语义**——让后端能识别这种"全字段文本"等价于选中该候选
+
+**验证方式**:在真实后端走一遍公司候选流程,选中一家后确认返回的是选定公司后的
+结果,而不是再次弹出候选列表。
+
+> 📌 **判据以跑着的后端为准。** 本文档(含 `api-chat.md`)描述的是**某一时刻**的约定,
+> 接口会持续演进。文档与后端行为不一致时,以**后端实际行为**为准,
+> 并回头更新本文档——不要拿文档去否定实际观测到的行为。
+
+固定动作选项不受影响,仍映射为协议控制串:查看更多 → `/next`、重新查询 → `/retry`、
+取消公司查询 → `/cancel`、不需要公司信息 → `不需要`。
+自由输入(用户手打名称/代码)原样发送,那本就是协议的"换关键词"路径。
+
+### interrupt 的全部状态(后端确认)
+
+补问用 `kind` + `status` 两个字段描述,**四种情况都可能出现**:
+
+| kind | status | 含义 | 前端形态 |
+|---|---|---|---|
+| `company_selection` | `found` | 有候选待选择 | 候选列表 + 查看更多 / 取消公司查询 |
+| `company_need` | `not_found` | 未找到候选,需要补充信息 | 上方常驻输入框 + 跳过公司查询 |
+| `company_need` | `failed` | **查询服务失败** | 上方常驻输入框 + 跳过公司查询(文案须说明是失败) |
+| `company_need` | 无 `status` | **初次询问是否需要公司信息** | 上方常驻输入框 + 不需要公司信息 |
+
+两条容易踩的规则:
+
+1. **`failed` 不能说成"未查询到"**。接口文档明确要求"空候选与查询失败需分别展示"、
+   "有此错误时,不把空候选说成查无公司"。失败时要给出原因(`result.error` 经
+   `describeCompanyError` 翻译),或至少说"公司查询服务暂时失败"。
+2. **不要用 `input_help` 的文案做判断**。曾经用 `/跳过|skip/` 正则去猜按钮文案,
+   后端改一个字就会失效 —— `status` 才是为这件事准备的字段。
+
+> 实现见 `src/components/api-chat-coordinator.ts` 的 `buildQuestionCardsContent`。
+> `status` 缺失时退回"有没有候选"判断,以兼容旧版本。
+
+## `legacy/API.md` 是什么
+
+**它是交接时根据项目源码生成的快照,不是规格。**
+
+- **来源**:接手这个项目时,由当时的项目源码整理生成,涵盖那一刻前端调用的全部后端接口
+- **版本可追溯性**:**没有**。接口和功能后续一直在改,这份文档不会跟着更新,
+  从那之后新增/修改的接口它一律不知道
+- **性质**:它是"当时的代码长这样"的记录,**不是"接口应该长这样"的约定**
+
+保留它的理由只有一条:需要翻"某个前端模块(上传、会话管理、反馈等)**当初**调了什么接口、
+传了什么参数"时,它是个方便的索引。
+
+> ⚠️ **两件事都不要做:**
+> 1. **不要照着它实现新功能。** 新增或修改接口调用一律以 `api-chat.md` 为准。
+> 2. **不要拿它当"原来行为是什么"的判据。** 它是二手整理,可能遗漏或失真;
+>    要精确判断原行为,直接 diff 备份目录里的源码(见下)。
+
+## 相关位置
+
+- [`../architecture.md`](../architecture.md) —— 架构与关键约束
+- [`../exec-plans/tech-debt-tracker.md`](../exec-plans/tech-debt-tracker.md) —— 已知技术债(含接口相关的未决项)
+- [`../../README.md`](../../README.md) —— `harness/` 的结构导航
+- `F:\yysk\AI_zhaoshang\备份\zhaoshang-llm` —— **改动之前的原始代码备份(仓库外)**。
+  判断"原来怎么写的/怎么做的"以**源码**为准,直接 diff 那里,不要凭记忆、也不要只信
+  `legacy/API.md`(它是生成物,会失真)。
+- `F:\yysk\AI_zhaoshang\接口文档\` —— 上述文档在仓库外的原件,未随仓库维护。
+  注意其中 `API.md` 同样是交接时的快照,不是持续维护的规格。

+ 435 - 0
harness/docs/reference/api-chat-fields-zh.md

@@ -0,0 +1,435 @@
+# /api/chat 字段与取值中文对照
+
+适用日期:2026-09-14。配合 [接口文档与完整样例](api-chat.md) 阅读。本页按实际返回层级解释英文key和固定value,不改变接口协议。
+
+**key是字段名,value是字段值。** 例如 `"eligibility":"not_evaluated"` 表示“资格评估:未评估”;`"priority":{"rank":1}` 表示“展示优先级:第1位”,不是通过率。
+
+阅读约定:`[]`表示数组中的每一项;下面的路径是定位字段用的写法,不是额外的接口参数。`true`表示是,`false`表示否,`null`表示未提供/未知,不能都当成“否”;`[]`表示空列表,`{}`表示空对象。ID、原文路径、哈希值和错误码用于程序关联,不适合作为页面正文。
+
+例如收到progress事件时,里面的JSON可以这样读。以下是带中文注释的阅读示例(JSONC),实际接口JSON不含注释:
+
+```jsonc
+{
+  "thread_id": "window-001", // 这个结果属于哪个会话窗口
+  "request_id": "a78b17a3b38241fa83c8ee91f40ec608", // 当前这一轮请求的标识
+  "data": { // 当前事件的具体内容
+    "stage": "retrieve", // 处理阶段:政策与服务检索
+    "status": "running", // 阶段状态:正在处理
+    "message": "正在检索相关政策和配套服务……" // 前端直接展示这段中文
+  }
+}
+```
+
+## 1. 最常用字段:页面上显示什么
+
+| 英文key / 路径 | 中文名称 | 前端展示建议 |
+| --- | --- | --- |
+| progress.data.message | 当前处理提示 | 显示“正在检索相关政策……” |
+| heartbeat.data.elapsed_seconds | 已等待秒数 | 显示“已等待20秒” |
+| answer.data.text | 回答文本 | 普通聊天的完整回复;推荐场景的开场概述 |
+| item.data.card.name.text | 政策/产品/服务完整名称 | 卡片标题 |
+| item.data.card.department.text | 所属部门或提供机构 | 卡片“所属部门” |
+| item.data.card.support_objects.text | 支持对象 | 卡片“支持对象” |
+| item.data.card.support_methods_standards.text | 支持方式和标准 | 卡片“支持内容” |
+| item.data.card.application_conditions.text | 申报条件 | 卡片“申报条件” |
+| item.data.match_reason | 与当前问题的匹配原因 | 卡片“匹配原因” |
+| summary.data.text | 综合说明 | 所有卡片后的结论与下一步 |
+| interrupt.data.question | 需要用户回答的问题 | 公司补问/候选确认提示 |
+| result.data.errors | 本轮图流程错误列表 | 非空时提示本轮存在异常,不把错误码直接当回复 |
+
+左侧的progress/item等是SSE事件名,**不在JSON对象中**。事件data内的JSON统一还有thread_id、request_id、data三项。卡片字段可能为null,先判断对象存在再取.text;可显示“原文未明确”或隐藏该栏。
+
+## 2. 请求、事件与统一封装
+
+| key | 中文名称 | value含义 |
+| --- | --- | --- |
+| thread_id | 会话窗口ID | 例如window-001,同一窗口复用;不是用户ID或权限凭证 |
+| question | 用户本轮输入 | 普通问题,或针对补问的回复字符串 |
+| request_id | 本次请求ID | 每次POST生成一个新ID,用于区分同一窗口的不同轮次 |
+| data | 本事件的数据内容 | 具体结构取决于event;不是所有data都具有相同字段 |
+| event(SSE行) | 事件类型 | 下表说明;不是JSON里的key |
+
+| event固定value | 中文含义 | 是否是最终内容 |
+| --- | --- | --- |
+| accepted | 已接收问题 | 否,只表示接纳 |
+| progress | 处理阶段更新 | 否,过程提示 |
+| heartbeat | 等待中的存活通知 | 否,显示仍在处理 |
+| answer | 回答文本 | 是,正文或概述 |
+| source | 一条参考来源 | 是,证据详情 |
+| item | 一项推荐 | 是,一张卡片及其依据 |
+| summary | 综合说明 | 是,末尾结论 |
+| interrupt | 等待用户补充/确认 | 是交互问题,尚需继续 |
+| result | 本轮完整结果快照 | 与前面answer/item等有重复,不重复追加展示 |
+| done | 本次响应流正常结束 | 停止本轮加载动画 |
+| error | 本次响应流异常结束 | 显示错误并停止加载 |
+
+## 3. 进度与完成状态
+
+| key | 中文名称 | 说明 |
+| --- | --- | --- |
+| stage | 当前阶段标识 | 固定英文值见下表 |
+| status | 当前状态 | 同名字段在不同位置含义不同,必须结合路径判断 |
+| message | 给用户看的说明 | 直接展示已返回的中文 |
+| elapsed_seconds | 本次请求累计耗时 | 单位秒,不是剩余时间或节点单独耗时 |
+
+| stage的value | 中文含义 |
+| --- | --- |
+| session | 读取会话信息 |
+| prepare | 整理用户问题 |
+| analyze | 识别需求;普通对话的LLM回复也在此生成 |
+| direct | 输出普通对话回复 |
+| clarify | 准备补问信息 |
+| search | 查找公司候选(此处不是政策检索) |
+| confirm | 整理公司候选供用户确认 |
+| basic | 查询工商信息 |
+| honors | 查询企业荣誉/资质 |
+| handoff | 汇总需求及已有信息 |
+| plan | 拆解需求、规划检索 |
+| retrieve | 检索政策及配套服务 |
+| recommend | 分析原文、核对条件并整理回答 |
+
+| status所在位置 | 固定value | 中文含义 |
+| --- | --- | --- |
+| progress.data.status | running | 正在处理这一阶段 |
+| progress.data.status | completed | 这一阶段执行结束,不保证所有业务结果成功 |
+| progress.data.status | waiting_input | 等待用户补充或确认 |
+| progress.data.status | failed | 阶段执行抛错 |
+| heartbeat.data.status | processing | 本次请求仍在处理 |
+| done.data.status | completed | 本轮流程结束,不等于审批通过 |
+| done.data.status | needs_input | 本轮流已结束,等待用户下一次POST继续 |
+
+## 4. 最终result与recommendation
+
+以下相对 `result.data` 解释;普通聊天的recommendation为 `{}`,没有下面的推荐子字段。
+
+| key | 中文名称 | 说明 |
+| --- | --- | --- |
+| response | 本轮回复文本 | 与answer.data.text一致 |
+| recommendation | 政策/服务推荐结果 | 聊天为空对象;政策或具体需求为结构化对象 |
+| errors | 会话及图流程错误 | 字符串列表;不覆盖所有候选分析或检索错误 |
+
+以下相对 `result.data.recommendation` 解释:
+
+| key | 中文名称 | value / 用途 |
+| --- | --- | --- |
+| overview | 开场概述 | 与本轮response一致 |
+| conversation_kind | 会话目的 | policy_overview=了解政策;specific_need=解决具体需求 |
+| audience | 本轮处理对象 | general=一般政策介绍;individual=个人/无需公司路径;enterprise=需要企业信息的路径,不代表公司已核实 |
+| items | 推荐条目列表 | 每项与一个item事件的数据一致 |
+| sources | 来源列表 | 每项与一个source事件的数据一致;可能多于推荐条目 |
+| follow_up_questions | 待补充的资格条件问题 | 字符串列表,不等于公司interrupt中断 |
+| summary | 综合结论 | 内容与summary事件一致 |
+| excluded_items | 未入选展示的条目 | 包含source_id和reason |
+| presentation | 推荐展示方式 | 顺序提示,见下表 |
+| retrieval_analysis_complete | 本轮检索和分析是否完整 | true只代表本轮有限候选处理完整,不代表没有遗漏政策 |
+| complete | 本轮结果完整性标记 | 还考虑必要企业信息和规划等;不是获批结果,也不覆盖summary降级 |
+| company_context | 公司信息获取情况 | 不是完整工商画像 |
+| analysis_errors | 候选逐项分析错误 | 每项record_id=知识记录ID、code=错误码 |
+| retrieval | 检索过程信息 | 查询及失败范围等,可用于详情或诊断 |
+| search_plan | 查询拆解计划 | 查询方向是假设,不代表政策存在 |
+
+| 嵌套key | 中文名称 | 固定value中文解释 |
+| --- | --- | --- |
+| presentation.order | 建议展示顺序 | ["items","summary"]=先推荐卡片,再综合说明 |
+| presentation.sorted_by | 排序依据标识 | subject_policy_evidence=主体与政策证据;纯政策介绍实际仍按原检索顺序,以items/priority为准 |
+| company_context.profile_verified | 已取得身份核对后的公司工商资料 | true=已取得;不代表政策资格已通过 |
+| company_context.honors_complete | 荣誉获取是否完整 | true=完整,false=未完整,null=无该状态/未查询 |
+| company_context.errors | 公司流程错误列表 | 如工商查询失败等 |
+
+普通对话在图内部还有service_intro=身份/能力咨询、greeting=问候/致谢、smalltalk=闲聊/轻量创作、general_info=一般信息咨询、needs_clarification=需求待说明、out_of_scope=无法处理的范围外任务、analysis_failed=识别失败。**这些内部分类目前不单独返回在普通聊天result中**,不要要求前端必须收到它们。
+
+## 5. item与卡片字段
+
+以下相对 `item.data` 或 `recommendation.items[]` 解释:
+
+| key | 中文名称 | 说明 |
+| --- | --- | --- |
+| source_id | 对应来源ID | 与sources[].id关联 |
+| title | 来源标题 | 页面正式名称优先显示card.name.text |
+| card | 卡片信息 | 各字段见下表 |
+| answer | 这一条的解释正文 | 与外层answer事件不同,是单条政策/服务说明 |
+| match_reason | 匹配原因 | 此处是字符串 |
+| match_basis | 主体与政策的比较依据 | 数组,逐项记录用户/公司事实与政策条款 |
+| citations | 引用列表 | 每条包含path、quote |
+| conditions | 条件核验列表 | 不是原文中所有申报条件的简单拷贝 |
+| eligibility | 资格评估状态 | not_evaluated=未评估;当前具体需求入选条目通常为“待确认” |
+| recommendation_status | 推荐结论说明 | 已是中文,如“相关政策与服务介绍”“相关线索,资格待核验” |
+| priority | 展示优先级 | 不表示概率 |
+| priority.rank | 展示序号 | 1=第1项,2=第2项,数值越小越靠前 |
+| priority.basis | 排序理由 | 已是中文,如“原检索顺序” |
+
+| card内key | 中文名称 | value结构/含义 |
+| --- | --- | --- |
+| kind | 卡片类型 | policy=政策;financial_product=金融产品;service=服务事项 |
+| name | 正式完整名称 | {text,citations},应保留完整名称 |
+| department | 所属部门/提供机构 | {text,citations}或null |
+| support_objects | 支持对象 | {text,citations}或null |
+| support_methods_standards | 支持方式和标准 | {text,citations}或null |
+| application_conditions | 申报条件 | {text,citations}或null |
+| match_reason | 匹配原因信息 | 对象,含text、citations、subject_policy_comparisons;不是字符串 |
+| match_reason.subject_policy_comparisons | 主体与政策逐项比较 | 与item.match_basis对应的比较数组 |
+| text | 可展示文本 | 实际中文内容,不是英文枚举 |
+| citations[].path | 引用在原始记录中的字段路径 | 例如/title、/raw_record/content;不是网址 |
+| citations[].quote | 原文引句 | 逐字引用的文本 |
+
+同名key要看层级:`item.match_reason`是文字,`item.card.match_reason`是对象;`item.answer`是单条说明,SSE `answer.data.text`是整轮正文或概述。
+
+## 6. conditions:资格条件核验
+
+| key | 中文名称 | value含义 |
+| --- | --- | --- |
+| dimension | 核验维度 | 地区/主体/行业/时间/其他条件,已是中文 |
+| status | 条件核验状态 | 满足/不满足/待确认,已是中文;当前模型输出“满足”会被保守转为“待确认” |
+| requirement | 条件要求 | 原文规定或尚需核验的要求 |
+| quote | 条件依据的原文引句 | 可能为空,表示没有该引用 |
+| user_evidence | 已知用户信息依据 | 用户自述或当前已有事实,可能为空 |
+| question | 需要补问的内容 | 可能为空;不代表一定触发interrupt |
+| evidence_path | 证据所在字段路径 | 可选字段,例如明确有效期已过时附带 |
+
+“待确认”不是“不符合”;“有相关依据”不是“已具备资格”。页面宜显示核验状态和具体缺口,不转换为通过率。
+
+## 7. match_basis:主体事实与政策比较
+
+| key | 中文名称 | value含义 |
+| --- | --- | --- |
+| dimension | 比较维度 | 需求/地区/行业/资质/主体/时间/其他;与conditions维度不完全相同 |
+| status | 相关性比较结论 | supports=支持相关性;conflicts=存在冲突;unknown=无法判断 |
+| fact_path | 主体事实路径 | 例如/question=本轮原问题,/company/...=工商字段,/honors/...=荣誉字段 |
+| fact_quote | 主体事实引文 | 用户或公司资料中的原始内容 |
+| policy_path | 政策证据路径 | 政策原文记录内的位置 |
+| policy_quote | 政策原文引句 | 用来比较的政策条款 |
+| explanation | 比较结论解释 | 中文说明 |
+| fact_origin | 主体事实来源类别 | user=用户自述;company=工商资料;honor=荣誉资质资料 |
+| provenance | 来源追溯信息 | 根据来源类型附带不同字段 |
+| provenance.type | 来源类型说明 | user_statement=用户自述 |
+| provenance.api_code | 企业信息接口编号 | 886=公司候选查询;410=工商详情;1001=荣誉资质(主体比较通常用后两项) |
+| provenance.fetched_at | 信息获取时间 | 时间字符串,不是政策发布日期 |
+| provenance.page_index | 原始查询页码 | 荣誉分页等使用 |
+
+`supports`只能译为“有支持相关性的依据”,不能译成“已满足条件”。
+
+## 8. summary与排除原因
+
+| key | 中文名称 | value含义 |
+| --- | --- | --- |
+| summary.text | 综合说明正文 | 中文结论及下一步 |
+| summary.source_ids | 综合说明依据的来源ID列表 | 对应sources[].id |
+| summary.status | 说明生成状态 | generated=模型已生成;fallback=生成失败后使用备用说明;no_recommendations=没有入选推荐 |
+| summary.notices | 补充提示列表 | 例如资料未获取完整、同名条目处理等 |
+| summary.error | 综合说明错误码 | 可选,summary_generation_failed=综合说明生成失败 |
+| excluded_items[].source_id | 未入选条目的来源ID | 可关联sources查看原文 |
+| excluded_items[].reason | 未入选原因 | 下表释义 |
+
+| reason固定value | 中文含义 |
+| --- | --- |
+| expired | 原文明示的执行/有效期限已过 |
+| ineligible | 存在明确不满足的资格条件 |
+| reference_only | 仅供参考,未形成可展示的具体卡片 |
+| subject_conflict | 主体事实与政策条款有冲突 |
+| insufficient_personal_match | 缺少针对当前主体的需求/行业/资质匹配依据 |
+| duplicate_name | 与已展示条目名称重复,未合并不同版本 |
+
+## 9. source:引用来源与原文
+
+| key(相对source.data或sources[]) | 中文名称 | 说明 |
+| --- | --- | --- |
+| id | 来源记录ID | 与item.source_id关联 |
+| title | 原始记录标题 | 可能与卡片正式名称不同 |
+| type | 检索知识类型 | 下表释义 |
+| source | 原始资料来源信息 | 来源对象或null,不是原文正文 |
+| full_record | 完整知识记录 | 保留入库字段及原始内容,下面只解释统一模板字段 |
+| snapshot | 知识快照标识 | 用来定位这批入库数据,不能当作有效期或日期 |
+| analysis_complete | 这条资料是否分析完整 | true/false;不表示资格核验通过 |
+| detail_anchor | 页面详情锚点建议值 | 例如source-k_...;不是后端详情URL |
+
+| source.type / retrieval.searched_types值 | 中文含义 |
+| --- | --- |
+| policy | 政策知识 |
+| finance | 金融产品知识 |
+| qa | 问答知识 |
+| article | 文章类知识 |
+| service_guide | 办事指南 |
+| service_catalog | 服务目录 |
+
+注意:金融知识类型是finance,金融卡片类型是financial_product;不是拼写错误。原始full_record.kind还可能是general=通用知识;catalog=索引目录是入库分类,当前常规搜索不查询它。
+
+### 原始source对象中的统一字段
+
+| key | 中文名称 |
+| --- | --- |
+| file | 来源文件相对路径 |
+| locator | 文件内定位信息(结构依资料类型) |
+| sha256 | 来源文件内容校验值 |
+| url | 原始网页地址;为空时不应自行拼接 |
+| publisher | 发布机构 |
+| published_text | 原文发布日期文字 |
+| updated_text | 原文更新时间文字 |
+| acquired_at | 资料获取时间 |
+
+### full_record统一模板字段
+
+| key | 中文名称 / 说明 |
+| --- | --- |
+| schema_version | 数据结构版本 |
+| id、kind、title | 记录ID、原始知识类别、标题 |
+| collection | 所属资料集合 |
+| classification_path | 分类层级路径 |
+| applicability | 适用范围对象 |
+| applicability.region | 适用地区 |
+| applicability.target | 适用对象 |
+| source | 原始来源信息,见上表 |
+| validity | 有效性信息 |
+| validity.status | 有效性状态;unverified=未核实,不是“已失效” |
+| validity.effective_from / effective_to | 生效/失效日期,可能未知 |
+| data | 按资料类型整理的业务字段,不同于SSE外层data |
+| sections | 分段内容列表 |
+| raw_record | 原始资料记录,字段来自原数据,不能强制套用卡片字段含义 |
+| content_sha256 | 内容校验值,不用于正文展示 |
+| quality_flags | 资料整理质量提示列表,不是推荐评分 |
+| duplicate_of | 重复记录对应的ID;null=没有该关联 |
+
+以上仅解释统一模板,实际full_record还可能包含摘要增强等字段;外部raw_record中的任意字段不能在没有来源定义时猜译。前端日常展示优先用card,原始数据可放入展开的“完整来源”。模板见 [政策](../knowledge/templates/policy.template.json)、[金融](../knowledge/templates/finance.template.json)、[问答](../knowledge/templates/qa.template.json)、[通用知识](../knowledge/templates/general.template.json)。
+
+## 10. 检索与需求拆解字段
+
+| key(相对recommendation) | 中文名称 | 说明 |
+| --- | --- | --- |
+| search_plan.needs | 拆解出的独立需求 | 对象数组 |
+| search_plan.needs[].need | 需求描述 | 用户想解决什么 |
+| search_plan.needs[].evidence | 用户原话依据 | 必须来自本轮问题 |
+| search_plan.needs[].query | 为此需求生成的检索表达 | 搜索方向,不是政策名称或结论 |
+| search_plan.queries | 扩展检索表达列表 | 原问题仍会单独进入检索 |
+| search_plan.error | 拆解失败码 | null=此字段无错误;query_planning_failed=需求拆解失败 |
+| retrieval.queries | 实际用于检索的查询列表 | 包括原问题及有效扩展 |
+| retrieval.failed_databases | 检索失败的知识库类型列表 | 英文类型见第9节;retrieval_service=检索服务整体失败占位标识 |
+| retrieval.failed_queries | 失败的查询与知识类型组合 | 对象数组 |
+| retrieval.failed_queries[].query_index | 查询序号 | 从0开始,对应retrieval.queries;不同于从1开始的卡片rank |
+| retrieval.failed_queries[].type | 失败的知识类型 | policy/finance/qa等 |
+| retrieval.content_failures | 原文读取或校验失败的记录ID列表 | 不是未命中列表 |
+| retrieval.complete | 检索阶段是否完整 | true不代表每个政策都找到了 |
+| retrieval.snapshot | 检索所用知识快照 | 数据批次标识 |
+| retrieval.searched_types | 搜索过的知识类型 | 如policy、finance等 |
+| retrieval.candidate_limit | 候选预算上限 | 当前默认12,不是已推荐条数 |
+
+整体服务降级时retrieval的部分字段可能缺失,不能把缺失强制转换成正常空列表。
+
+## 11. interrupt与公司候选
+
+| key | 中文名称 | value含义 |
+| --- | --- | --- |
+| kind | 补问类型 | company_need=补充是否需要公司信息;company_selection=确认公司候选 |
+| question | 给用户的问题 | 直接展示 |
+| input_help | 输入方式提示 | 直接展示 |
+| schema | 内部补问参数结构说明 | 不要当成HTTP额外请求参数 |
+| schema.needs_company | 是否需要公司信息 | 文档提示boolean=布尔值;API仍通过question文字提交 |
+| schema.company_hint | 公司线索 | 文档提示string=字符串 |
+| result | 本次公司候选查询结果 | 不等于SSE result事件的最终答复 |
+| result.candidates | 公司候选列表 | 保持后端顺序,以显示序号选择 |
+| result.may_have_more | 是否可能有下一页 | true显示下一页操作 |
+| result.error | 候选查询失败码 | 有此错误时,不把空候选说成“查无公司” |
+| result.source | 公司查询来源信息 | 与知识source对象结构不同,见下表 |
+| page | 当前候选页码 | 从1开始 |
+| actions | 内部支持操作说明 | select=选择,refine=换关键词,next=下一页,retry=重试,cancel=取消公司查询 |
+| actions.select.key_no | 所选公司的内部标识 | 当前API由数字序号映射,不直接提交该对象 |
+| actions.refine.keyword | 新查询关键词 | API用question字符串提交 |
+
+| candidates[]中的key | 中文含义 |
+| --- | --- |
+| KeyNo | 公司候选唯一标识 |
+| Name | 公司名称 |
+| CreditCode | 统一社会信用代码 |
+| StartDate | 成立日期 |
+| OperName | 法定代表人/负责人姓名(依主体类型) |
+| Status | 企业登记状态,例如存续、注销;不是请求处理状态 |
+| No | 企业注册号 |
+| Address | 企业注册地址 |
+
+| 公司查询result.source中的key | 中文含义 |
+| --- | --- |
+| provider | 数据提供方,当前为企查查 |
+| api_code | 查询接口编号,886/410/1001释义见第7节 |
+| document_url | 数据接口文档地址,不是公司主页 |
+| fetched_at | 查询获取时间 |
+| page_index | 本次查询页码,未分页时可能为null |
+| total_records | 服务提供方报告的总条数,未知时为null |
+
+用户选择第1家公司时提交 `question="1"`;换关键词直接提交新名称;/next=下一页,/retry=重试,/cancel=取消公司查询。不需要公司信息可输入“不需要”“无需公司信息”或/skip。不要把schema或actions里的对象原样塞入question。
+
+## 12. 错误码中文说明
+
+错误对象中 `error`或`code`是程序识别的错误码,`message`是可选用户提示;不是每个错误都有message。多个层级的错误来源不同,页面应结合当前操作描述。
+
+### HTTP层和SSE终止错误
+
+| 固定value | 中文含义 |
+| --- | --- |
+| application_json_required | 请求必须使用application/json |
+| invalid_request | 请求JSON或参数不符合要求 |
+| request_too_large | 请求内容超过64KiB |
+| request_timeout | 接收请求体超时 |
+| thread_busy | 该会话已有问题正在处理 |
+| server_busy | 服务处理容量已满或正在关闭 |
+| service_unavailable | 暂时无法提交后台处理任务 |
+| processing_failed | 本轮处理异常失败 |
+| stream_timeout | 流持续时间超过上限 |
+| unfinished_turn | 上轮还有未完成阶段,需要/retry恢复或换新会话 |
+
+### 图流程与推荐中的常见错误
+
+| 固定value | 中文含义 |
+| --- | --- |
+| intent_analysis_failed | 用户意图识别/普通对话模型调用失败 |
+| direct_response_unavailable | 普通回复缺失或格式不合法,已使用兜底 |
+| search_limit | 达到本轮公司候选查询次数上限 |
+| query_planning_failed | 需求拆解失败,本轮按原问题检索 |
+| summary_generation_failed | 综合说明生成失败,使用备用说明 |
+| analysis_budget_limit | 候选原文超过单条分析预算 |
+| analysis_context_limit | 模型输入超过上下文预算 |
+| incomplete_generation | 模型输出未完整结束 |
+| model_service_failure | 模型服务调用失败或输出无法解析 |
+| analysis_failed | 候选分析异常失败 |
+| invented_citation | 引文无法在对应原文中找到,已拒绝使用 |
+| invented_condition_quote | 条件引用无法在原文中找到 |
+| invented_card_citation | 卡片字段引用无法在原文中找到 |
+| invented_match_evidence | 主体或政策比较证据无法核对 |
+| missing_citations / missing_card_citation | 缺少正文/卡片引用 |
+| missing_card | 缺少规定的卡片字段 |
+| rewritten_policy_name | 卡片名称与原文完整名称不一致 |
+
+分析错误还可能出现invalid_*、missing_*等结构校验码;这些表示模型输出未通过相应字段校验,不是用户资格“不满足”。未知码可显示“部分内容未能完成分析”,保留原码供排查,不猜测为“没有政策”。
+
+### 公司查询错误(可能带search:、basic:、honors:前缀)
+
+| 值或前缀 | 中文含义 |
+| --- | --- |
+| search: | 公司候选查询阶段 |
+| basic: | 工商资料查询阶段 |
+| honors: | 荣誉资质查询阶段 |
+| network_failure | 网络连接失败或超时 |
+| http_数字 | 上游HTTP错误,例如http_503=上游暂不可用 |
+| provider_failure | 企查查业务返回失败 |
+| response_too_large | 上游响应内容过大 |
+| invalid_json | 上游不是合法JSON |
+| invalid_result / invalid_search_result | 上游数据结构不符合要求 |
+| invalid_candidate / duplicate_candidate | 候选身份缺失或重复 |
+| invalid_paging | 分页信息不合法 |
+| company_identity_mismatch | 工商详情与用户确认的公司身份不一致 |
+| invalid_honor_result / invalid_honor_data | 荣誉返回结构不合法 |
+| inconsistent_honor_result / inconsistent_honor_paging | 荣誉记录或分页数据不一致 |
+| page_limit | 已到荣誉查询页数上限,资料可能未取全 |
+
+例如 `basic:network_failure` 应理解为“查询工商信息时网络失败”,不能只显示“basic”。Redis等未捕获异常在API层通常被统一为processing_failed,不应期待接口透出所有内部错误。
+
+## 13. 不同“完成”标记分别判断什么
+
+| 字段 | 可以理解为 | 不能理解为 |
+| --- | --- | --- |
+| progress.status=completed | 当前阶段结束 | 依赖一定成功、政策一定符合 |
+| done.status=completed | 本次响应流正常结束 | 模型一定没有降级、用户一定符合资格 |
+| retrieval.complete=true | 本轮检索范围内处理完整 | 所有政策都找到了 |
+| recommendation.complete=true | 本轮规定信息完整性条件通过 | 综合说明一定是模型生成、政策一定获批 |
+| summary.status=generated | 综合说明成功生成 | 内容已获人工审核或资格审批 |
+| conditions.status=待确认 | 资格条件还需补充核验 | 不符合条件 |
+| match_basis.status=supports | 当前事实支持相关性判断 | 全部申报条件已满足 |
+
+页面上优先展示后端给出的中文message/text和具体事实。英文状态按所在字段翻译成标签即可,原始ID、路径、校验值、错误码放在引用详情或诊断信息中。

+ 552 - 0
harness/docs/reference/api-chat.md

@@ -0,0 +1,552 @@
+# POST /api/chat 接口文档
+
+**看不懂英文key或value时,先查 [字段与取值中文对照表](api-chat-fields-zh.md)**:逐层说明字段中文名称、状态/类型/原因的含义,以及前端该展示哪些内容。
+
+适用版本:2026-09-14,当前F026–F028实现。服务名称:青浦区营商助手。本文供前端与接口调用方对接,重点说明实际返回格式;启动与部署配置见 [第四步服务说明](step4-api.md)。
+
+**一次请求返回一串SSE事件,不是一个普通JSON响应。** 所有事件使用相同外层结构;普通聊天返回文字,政策与具体需求还会返回来源、卡片、综合说明。本文政策和企业名称均为合成示例,不代表存在对应政策或用户具备资格。标注“片段”的示例仅展示部分字段。
+
+## 1. 接口与请求
+
+| 项目 | 说明 |
+| --- | --- |
+| 方法 / 路径 | `POST /api/chat` |
+| 地址 | `http://<API_HOST>:<API_PORT>/api/chat`;客户端使用实际服务IP/域名,不使用监听地址0.0.0.0 |
+| 请求类型 | `Content-Type: application/json` |
+| 建议响应类型 | `Accept: text/event-stream`(当前不强制校验) |
+| 成功响应 | HTTP 200,`Content-Type: text/event-stream; charset=utf-8` |
+| 默认配置 | 本机127.0.0.1:8000,监听地址和端口由环境配置控制 |
+
+```json
+{
+  "thread_id": "window-20260914-001",
+  "question": "我想开一家小店,但启动资金不够,有什么办法?"
+}
+```
+
+| 参数 | 类型 | 必填 | 约束与含义 |
+| --- | --- | --- | --- |
+| thread_id | string | 是 | 窗口标识,1–128字符,仅ASCII字母、数字、`_`、`-` |
+| question | string | 是 | 本轮输入,不能为空或全空白,最多8000字符 |
+
+只接受这两个字段,多余字段也返回422。请求体最多65536字节,读取请求体超时15秒。新窗口可生成 `crypto.randomUUID()`;同一窗口复用ID。未存在或Redis已过期的ID开始新会话。当前ID不是用户鉴权凭证。
+
+## 2. 返回的两层结构
+
+网络上的一条事件如下,**最后一个空行是事件结束标记**:
+
+```text
+event: progress
+data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f40ec608","data":{"stage":"retrieve","status":"running","message":"正在检索相关政策和配套服务……"}}
+
+```
+
+`event:` 指定事件名称;`data:` 后面才是可用 `JSON.parse()` 解析的JSON:
+
+```json
+{
+  "thread_id": "window-20260914-001",
+  "request_id": "a78b17a3b38241fa83c8ee91f40ec608",
+  "data": {
+    "stage": "retrieve",
+    "status": "running",
+    "message": "正在检索相关政策和配套服务……"
+  }
+}
+```
+
+| 外层字段 | 类型 | 说明 |
+| --- | --- | --- |
+| thread_id | string | 本轮所属窗口,等于请求值 |
+| request_id | string | 服务端为本次HTTP请求生成的32位十六进制ID;同一流内保持一致,下一次请求会改变 |
+| data | object | 事件业务内容,由event名称决定 |
+
+事件名称不在JSON对象中,没有 `type`、`event` 或 `code=200` 这类统一业务字段。除完整网络示例外,下文各事件的JSON示例都只展示这个内层 **data对象**。
+
+一个网络数据块可能包含半条事件或多条事件,中文UTF-8字符也可能跨块;不可对每次 `reader.read()` 的结果直接执行 `JSON.parse()`。
+
+## 3. 事件总览与顺序
+
+| event | 主要data字段 | 前端用途 |
+| --- | --- | --- |
+| accepted | message | 立即显示“已收到问题” |
+| progress | stage、status、message | 更新当前处理阶段 |
+| heartbeat | status、message、elapsed_seconds | 等待期间更新已耗时 |
+| answer | text | 普通对话正文,或推荐的开场概述 |
+| source | id、title、full_record等 | 保存完整来源,供引用详情查看 |
+| item | source_id、card、answer、conditions等 | 渲染单张政策/服务推荐卡片 |
+| summary | text、source_ids、status、notices | 显示末尾综合说明 |
+| interrupt | kind、question、input_help等 | 提示用户补充公司信息或确认候选 |
+| result | response、recommendation、errors | 当前轮次的完整最终快照 |
+| done | status | 本次流正常结束或等待输入 |
+| error | code,可选message | 本次流异常结束 |
+
+正常顺序(`…`仅表示零个或多个事件,不是协议字符):
+
+```text
+普通对话:accepted → progress… → answer → result → done
+政策/需求:accepted → progress… → answer → source… → item… → summary → result → done
+公司补问:accepted → progress… → interrupt → result → done(needs_input)
+流中失败:accepted → 已产生的事件… → error
+```
+
+耗时期间可穿插heartbeat,失败可能发生在任一阶段。不保证有source/item;不保证每次请求都经过所有progress阶段。`error`之后不保证有result或done。
+
+这是**真实阶段与已生成结果的事件流**,不是模型逐token输出。普通回复在LLM分类与生成完成后发送;推荐须整体生成并校验后,再依次发送source/item/summary。此时不要把逐条item到达理解为后台刚分析完对应条目。
+
+## 4. 处理中间状态
+
+### accepted
+
+```json
+{"message":"已收到您的问题,正在处理……"}
+```
+
+表示已接纳请求,不表示模型调用或业务处理成功。
+
+### progress
+
+```json
+{"stage":"retrieve","status":"running","message":"正在检索相关政策和配套服务……"}
+```
+
+| status | 含义 |
+| --- | --- |
+| running | 阶段开始,先通知再执行耗时节点 |
+| completed | 该阶段执行结束;不等于政策符合或所有依赖成功 |
+| waiting_input | 需要用户输入,随后有interrupt |
+| failed | 节点抛出异常,随后终止错误;已被业务捕获的失败仍可能表现为completed并在结果中说明 |
+
+| stage | 阶段 |
+| --- | --- |
+| session | 读取会话 |
+| prepare | 整理问题 |
+| analyze | LLM需求识别;普通对话回复也在这次调用生成 |
+| direct | 输出普通对话答复 |
+| clarify | 准备公司信息补问 |
+| search | 查询公司候选 |
+| confirm | 整理公司候选供用户确认 |
+| basic | 获取已确认公司的工商信息 |
+| honors | 查询荣誉与资质 |
+| handoff | 整理需求与已有信息 |
+| plan | 规划政策检索方向 |
+| retrieve | 检索相关政策与服务 |
+| recommend | 原文分析、条件比较和回答整理 |
+
+前端优先直接展示message。相同阶段可能因恢复或重新查询再次出现,不能将stage当作全会话唯一事件ID。不提供百分比或预计剩余时间。
+
+### heartbeat
+
+```json
+{"status":"processing","message":"问题仍在处理中,请稍候……","elapsed_seconds":20}
+```
+
+无可发送结果且任务仍执行时按配置间隔发送,默认约10秒;不是严格定时器。elapsed_seconds是本次请求累计耗时的整数秒,不是当前节点耗时,也不是剩余时间。收到done/error后停止等待动画。
+
+## 5. 普通对话:文字结果
+
+身份和能力咨询、普通信息、问候、闲聊等由LLM按原话生成。示例问题:“今天工作好累”。
+
+`answer.data`:
+
+```json
+{"text":"辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。"}
+```
+
+`result.data`:
+
+```json
+{
+  "response": "辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。",
+  "recommendation": {},
+  "errors": []
+}
+```
+
+`done.data`:
+
+```json
+{"status":"completed"}
+```
+
+answer.text和result.response是同一回复,后者不要再追加一次。没有item/source/summary。普通对话的内部分类目前不单独暴露在result中,前端无需区分service_intro、smalltalk或general_info才能展示。
+
+模型调用或生成格式失败时,可能仍收到固定兜底文字、result及done=completed。应检查result.errors,例如 `intent_analysis_failed` 或 `direct_response_unavailable`,不能只凭done判断内容生成成功。
+
+## 6. 政策与具体需求:结构化结果
+
+两类共用推荐数据结构,区别如下:
+
+| 内容 | 了解政策 | 具体需求 |
+| --- | --- | --- |
+| 示例 | “有哪些创业扶持政策?” | “我想开店但资金不足” |
+| recommendation.conversation_kind | policy_overview | specific_need |
+| audience | general | individual或enterprise |
+| 卡片顺序 | 保留原检索顺序 | 按需求与主体依据优先级排序 |
+| eligibility | not_evaluated | 当前入选条目通常为待确认 |
+| conditions | 通常为空,不做用户资格判断 | 地区、主体、行业、时间等条件核验 |
+| follow_up_questions | 空数组 | 需要确认的关键条件,最多5条 |
+| 公司确认 | 不需要 | 仅在当前需求需要企业事实时触发 |
+
+**answer.text只是概述,卡片正文在item.answer及item.card,末尾结论在summary.text。** 只显示answer会丢失主要业务内容。
+
+### 6.1 result.data完整外形
+
+以下为“成功检索但没有相关依据”的合成示例;有推荐时items/sources用后续小节的对象填充:
+
+```json
+{
+  "response": "当前知识库未找到足够相关的原文依据,不能据此认定不存在相关政策。请补充具体需求。",
+  "recommendation": {
+    "overview": "当前知识库未找到足够相关的原文依据,不能据此认定不存在相关政策。请补充具体需求。",
+    "conversation_kind": "policy_overview",
+    "audience": "general",
+    "items": [],
+    "sources": [],
+    "follow_up_questions": [],
+    "summary": {
+      "text": "本轮没有筛选出可展示的具体推荐。可补充希望解决的事项,例如融资、设备更新或人才招聘,以便进一步查找。",
+      "source_ids": [],
+      "status": "no_recommendations",
+      "notices": []
+    },
+    "excluded_items": [],
+    "presentation": {"order":["items","summary"],"sorted_by":"subject_policy_evidence"},
+    "retrieval_analysis_complete": true,
+    "complete": true,
+    "company_context": {"profile_verified":false,"honors_complete":null,"errors":[]},
+    "analysis_errors": [],
+    "retrieval": {
+      "queries": ["合成主题查询"],
+      "failed_databases": [],
+      "failed_queries": [],
+      "content_failures": [],
+      "complete": true,
+      "snapshot": "0000000000000000000000000000000000000000000000000000000000000000",
+      "searched_types": ["policy","finance","qa","article","service_guide","service_catalog"],
+      "candidate_limit": 12
+    },
+    "search_plan": {"needs":[],"queries":[],"error":null}
+  },
+  "errors": []
+}
+```
+
+这里的snapshot为占位值;searched_types实际为当前检索类型列表,不应据本例写死。retrieval为后端诊断数据,降级时部分键可能不存在。
+
+| 字段(相对recommendation) | 类型 | 说明 |
+| --- | --- | --- |
+| overview | string | 与result.response、answer.text一致的概述 |
+| items | array | 已筛选并排序的展示条目,不额外限制6条;当前检索仍有候选预算 |
+| sources | array | 完整来源,不一定与items一一对应;可能含仅供参考或分析未完成的来源 |
+| summary | object | 综合说明;独立summary事件与这里相同 |
+| follow_up_questions | string[] | 资格待确认问题列表,不是LangGraph中断 |
+| excluded_items | array | 未入选条目的source_id与reason,适合详情或诊断 |
+| presentation | object | 建议先显示items再summary;实际顺序以items数组/priority.rank为准 |
+| retrieval_analysis_complete | boolean | 本轮有限候选检索和逐项分析是否完整;不表示总体召回充分 |
+| complete | boolean | 在上项基础上考虑必要企业信息完整性等;不表示获批,且不包含所有降级状态 |
+| company_context | object | profile_verified、honors_complete和errors;不是完整企业画像 |
+| analysis_errors | array | 各候选分析错误,通常为record_id/code |
+| retrieval | object | 查询、失败库/查询/内容、快照等诊断;无候选全文数组 |
+| search_plan | object | needs、queries、error;needs每项有need/evidence/query,均为检索假设 |
+
+result.errors只包含会话/图层错误,不能替代recommendation.analysis_errors、retrieval、summary.status/error等字段。
+
+### 6.2 item:一张卡片及其证据
+
+下面是政策介绍模式的合成item.data,展示完整顶层字段:
+
+```json
+{
+  "source_id": "k_000000000000000000000001",
+  "title": "合成创业服务示例",
+  "card": {
+    "kind": "service",
+    "name": {"text":"合成创业服务示例","citations":[{"path":"/title","quote":"合成创业服务示例"}]},
+    "department": null,
+    "support_objects": {"text":"拟创业人员","citations":[{"path":"/raw_record/content","quote":"面向拟创业人员"}]},
+    "support_methods_standards": {"text":"提供创业咨询","citations":[{"path":"/raw_record/content","quote":"提供创业咨询"}]},
+    "application_conditions": null,
+    "match_reason": {
+      "text": "服务内容与咨询的创业主题相关。",
+      "citations": [{"path":"/raw_record/content","quote":"提供创业咨询"}],
+      "subject_policy_comparisons": []
+    }
+  },
+  "match_basis": [],
+  "match_reason": "服务内容与咨询的创业主题相关。",
+  "answer": "这项服务面向拟创业人员,提供创业咨询。",
+  "citations": [{"path":"/raw_record/content","quote":"面向拟创业人员,提供创业咨询。"}],
+  "conditions": [],
+  "eligibility": "not_evaluated",
+  "recommendation_status": "相关政策与服务介绍",
+  "priority": {"rank":1,"basis":"原检索顺序"}
+}
+```
+
+| card字段 | 展示内容 |
+| --- | --- |
+| kind | policy / financial_product / service |
+| name | 完整政策、产品或服务名称,读取name.text |
+| department | 主管/实施部门或金融提供机构 |
+| support_objects | 支持对象 |
+| support_methods_standards | 支持方式及原文明示的标准 |
+| application_conditions | 原文申报条件,不等于用户符合条件 |
+| match_reason | 相关/匹配理由及主体—政策比较 |
+
+name及四个业务字段采用 `{text, citations}` 对象,不是字符串;后四项可能为null,可显示“原文未明确”或隐藏。当前入选items要求有有效card,前端仍应容错缺失字段。item.title是来源标题,展示正式名称优先使用card.name.text。
+
+item.match_reason是字符串;card.match_reason是对象,含text、citations、subject_policy_comparisons。item.citations与card各字段citations中的path/quote指向该item.source_id对应的完整来源。path是记录内字段定位,不是HTTP地址。
+
+priority.rank从1开始且数值越小越靠前;它不是分数或获批概率。不满足、明确过期、主体冲突和不适合推荐的条目会被筛出items,可查看excluded_items和sources。reason当前包括expired、ineligible、reference_only、subject_conflict、insufficient_personal_match、duplicate_name。
+
+### 6.3 具体需求的条件与匹配依据
+
+以下为具体需求item的字段片段(并非完整item),展示数组元素结构:
+
+```json
+{
+  "eligibility": "待确认",
+  "recommendation_status": "相关线索,资格待核验",
+  "conditions": [
+    {
+      "dimension": "主体",
+      "status": "待确认",
+      "requirement": "面向拟创业人员",
+      "quote": "面向拟创业人员",
+      "user_evidence": "我想开一家小店",
+      "question": "请确认目前是否处于筹备创业阶段。"
+    }
+  ],
+  "match_basis": [
+    {
+      "dimension": "需求",
+      "status": "supports",
+      "fact_path": "/question",
+      "fact_quote": "我想开一家小店",
+      "policy_path": "/raw_record/content",
+      "policy_quote": "面向拟创业人员,提供创业咨询。",
+      "explanation": "创业咨询与用户开店准备需求相关。",
+      "fact_origin": "user",
+      "provenance": {"type":"user_statement"}
+    }
+  ]
+}
+```
+
+conditions维度为地区/主体/行业/时间/其他条件,状态契约为满足/不满足/待确认;当前实现对模型“满足”会保守改为“待确认”,不自动批准资格。quote、user_evidence、question可能为空字符串。
+
+match_basis维度为需求/地区/行业/资质/主体/时间/其他,状态为supports/conflicts/unknown;fact_origin为user/company/honor,provenance按来源可能有api_code、fetched_at、page_index。supports仅表示有关联依据,不表示全部条件满足;不要与conditions的中文状态混用。
+
+**follow_up_questions不等于interrupt。** 前者是回答内容,通常仍done=completed;目前再输入答案会开始一个新问题,并未实现基于旧卡片的增量资格重算。需要补充时前端可让用户同时带上原问题和新条件。
+
+### 6.4 source:完整来源
+
+source事件及recommendation.sources中的同一对象结构如下,full_record在此只展示部分字段:
+
+```json
+{
+  "id": "k_000000000000000000000001",
+  "title": "合成创业服务示例",
+  "type": "policy",
+  "source": null,
+  "full_record": {
+    "id": "k_000000000000000000000001",
+    "title": "合成创业服务示例",
+    "raw_record": {"content":"面向拟创业人员,提供创业咨询。"}
+  },
+  "snapshot": "0000000000000000000000000000000000000000000000000000000000000000",
+  "analysis_complete": true,
+  "detail_anchor": "source-k_000000000000000000000001"
+}
+```
+
+实际full_record保留入库的完整原始字段,各知识类型结构不同,不能写死只有raw_record.content。source是原始来源对象或null;实际URL、文件等定位信息应按该对象展示,无URL时不要自行拼接地址。
+
+source.id与item.source_id关联。detail_anchor是建议使用的页面锚点,不是后端详情接口,`/api/chat`也不会生成CLI的本地HTML报告。前端可以把full_record放入可展开来源详情,并按path/quote显示引文。snapshot为知识快照标识,不代表政策生效日期。
+
+### 6.5 summary:综合说明
+
+```json
+{
+  "text": "本轮服务可供筹备创业时进一步了解,具体办理条件可查看原文。",
+  "source_ids": ["k_000000000000000000000001"],
+  "status": "generated",
+  "notices": []
+}
+```
+
+status为generated(模型生成)、fallback(生成失败使用备用说明)或no_recommendations(没有入选推荐)。fallback时可能有 `error: "summary_generation_failed"`;notices包含本轮不完整或同名来源处理等提示。summary.source_ids可用于跳转来源。**summary降级并不一定使recommendation.complete=false,需独立检查。**
+
+## 7. 公司补问与继续请求
+
+### company_need
+
+interrupt.data示例:
+
+```json
+{
+  "kind": "company_need",
+  "question": "本次是否需要结合具体公司情况?如需要,请补充公司名称或信用代码。",
+  "schema": {"needs_company":"boolean","company_hint":"string"},
+  "input_help": "输入公司名称或信用代码;不需要则输入“不需要”。"
+}
+```
+
+随后仍有result和 `done.data={"status":"needs_input"}`,result.response可能为空。前端应展示interrupt.question,不能把空response当作失败。
+
+下一次POST仍只传两个字段:
+
+```json
+{"thread_id":"window-20260914-001","question":"合成示例有限公司"}
+```
+
+无需公司信息则question填“不需要”“无需公司信息”或 `/skip`。**schema是内部补问说明,不要将needs_company/company_hint作为额外HTTP参数提交。**
+
+### company_selection
+
+以下interrupt.data为部分字段示例:
+
+```json
+{
+  "kind": "company_selection",
+  "question": "请选择您所指的公司;也可以补充名称重新检索或取消。",
+  "result": {
+    "candidates": [
+      {"KeyNo":"synthetic-company-key","Name":"合成示例有限公司","CreditCode":null,"Address":null,"Status":null}
+    ],
+    "may_have_more": false
+  },
+  "page": 1,
+  "input_help": "输入候选序号确认,或输入新关键词;支持/next、/retry、/cancel。"
+}
+```
+
+实际还包含actions等信息;候选可能有StartDate、OperName、No,缺失值为null。result.source可携带来源与获取时间,查询失败时result.error为错误码且candidates可能为空。空候选与查询失败需分别展示。
+
+| 用户操作 | 下一次question |
+| --- | --- |
+| 选择显示的第1家公司 | `"1"`,从1开始;不要重排候选后仍按原序号提交 |
+| 换关键词 | 新名称或代码 |
+| 下一页 | `/next`,仅may_have_more=true有效 |
+| 查询失败重试 | `/retry` |
+| 取消公司查询 | `/cancel`,继续以现有信息给出一般线索,不是取消HTTP |
+
+不能直接把actions或KeyNo对象放入question;当前question必须是字符串,API由序号映射到原候选。存在补问时下一次输入用于回答补问,不能同时作为新咨询;要另问可换thread_id。
+
+## 8. 三种“失败/没有结果”不要混淆
+
+### 8.1 流开始前:HTTP错误
+
+```json
+{"error":"thread_busy"}
+```
+
+这不是SSE,也不带统一thread_id/request_id封装。
+
+| HTTP状态 | error | 说明 |
+| --- | --- | --- |
+| 415 | application_json_required | Content-Type不正确 |
+| 422 | invalid_request | JSON或两个字段不符合约束 |
+| 413 | request_too_large | 超过64KiB |
+| 408 | request_timeout | 请求体读取超时 |
+| 409 | thread_busy | 同窗口的上一请求还在执行 |
+| 429 | server_busy | 问答并发容量已满或正在关闭 |
+| 503 | service_unavailable | 无法提交后台任务 |
+
+应用的409/429/503分支附 `Retry-After: 2`,只是建议重试间隔,不保证2秒后完成。Uvicorn、代理或网关也可能返回503/其它错误及纯文本,前端不能假设每个非200响应都有JSON。
+
+### 8.2 流开始后:error事件
+
+```text
+event: error
+data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f40ec608","data":{"code":"stream_timeout"}}
+
+```
+
+| code | 说明 |
+| --- | --- |
+| processing_failed | 本轮处理抛错,有通用message;不能据此认定没有相关政策 |
+| stream_timeout | 超过流持续时长上限,可能只有code,没有message |
+| unfinished_turn | 上轮有未完成的非补问节点;同ID输入/retry恢复,或换ID提出新问题 |
+
+此时HTTP可能仍为200。error是终止事件,之后不保证有done。未收到done/error就断开,前端显示连接中断,不能假装回答已完成;不支持Last-Event-ID重放或按事件断点续传。
+
+### 8.3 正常结束,但业务降级或没有推荐
+
+- `items=[]`、`complete=true`:可能没有对应依据,也可能全部条目被筛除;结合overview、sources和excluded_items解释,不说“政策不存在”。
+- `complete=false`或analysis_errors非空:存在检索/分析/必要企业信息等缺口,不是正常无结果。
+- 普通对话result.errors非空:可能是LLM失败后的兜底答复。
+- summary.status=fallback:综合说明降级,卡片仍可使用已返回的证据。
+
+`done.status=completed`只表示本轮流程结束,不能代替这些业务判断。满足条款、推荐排序、完整检索均不等于获批。
+
+## 9. 前端渲染与解析示例
+
+可将progress/heartbeat放在独立状态栏;answer为正文或开场,item为卡片,summary为结尾。将source按id缓存用于引用详情。result是最终快照:可以用它替换之前拼装的数据,**不要把它再次追加成第二份回答/卡片**。
+
+正文可能包含换行或Markdown,但没有单独format字段;纯文本安全展示即可,使用Markdown渲染时关闭或清理原始HTML。字段null不应显示成“null”,空数组不表示错误。不要将thread_id当作所有请求的唯一ID,回调时用request_id区分旧流与新流。
+
+原生EventSource不能发送此JSON POST,可使用以下fetch解析器。它处理网络分块、UTF-8跨块、LF/CRLF分隔及非JSON HTTP错误;回调接收事件名和完整外层对象:
+
+```javascript
+async function chat(threadId, question, onEvent, signal) {
+  const response = await fetch('/api/chat', {
+    method: 'POST',
+    headers: {'Content-Type': 'application/json', 'Accept': 'text/event-stream'},
+    body: JSON.stringify({thread_id: threadId, question}),
+    signal
+  });
+  if (!response.ok) {
+    let code = `http_${response.status}`;
+    try { code = (await response.json()).error || code; } catch {}
+    throw new Error(code);
+  }
+  if (!response.headers.get('content-type')?.includes('text/event-stream')) {
+    throw new Error('unexpected_content_type');
+  }
+  const reader = response.body.getReader();
+  const decoder = new TextDecoder();
+  let buffer = '', terminal = false;
+  try {
+    while (true) {
+      const {value, done} = await reader.read();
+      buffer += done ? decoder.decode() : decoder.decode(value, {stream: true});
+      let boundary;
+      while ((boundary = /\r?\n\r?\n/.exec(buffer)) !== null) {
+        const block = buffer.slice(0, boundary.index);
+        buffer = buffer.slice(boundary.index + boundary[0].length);
+        const lines = block.split(/\r?\n/);
+        const event = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
+        const data = lines.filter(line => line.startsWith('data:'))
+          .map(line => line.slice(5).replace(/^ /, '')).join('\n');
+        if (!event || !data) continue;
+        const payload = JSON.parse(data);
+        onEvent(event, payload);
+        if (event === 'done' || event === 'error') terminal = true;
+      }
+      if (done) break;
+    }
+    if (!terminal) throw new Error('stream_disconnected');
+  } finally {
+    try { await reader.cancel(); } finally { reader.releaseLock(); }
+  }
+}
+```
+
+onEvent须处理error事件;函数正常解析到error不自动抛出服务错误。AbortController可以中止前端连接,但后台已发出的同步调用不能强制取消,会话锁保留到执行线程真正结束,立即重发可能409。新问题不要无条件自动重试,以免重复生成或外部调用。
+
+## 10. 当前接入限制
+
+当前单进程运行,同会话排他;默认同时16个问答,超额拒绝,不保证真实业务QPS。默认流超时600秒。会话保存在Redis,默认写入后15天过期;新问题重置本轮主体,未实现普通聊天历史理解。ID不存在/过期不会返回404,而是新建状态。
+
+尚未实现跨进程会话锁、用户登录/归属校验、跨域CORS、历史列表或删除接口;前端通常通过同源代理接入。反向代理需关闭响应缓冲并配置合理超时。本接口没有新增天气、实时新闻或办理进度查询能力。
+
+## 11. 实现对照
+
+- [HTTP校验、SSE封装与容量控制](../src/step4_api/app.py)
+- [事件顺序、最终结果与会话恢复](../src/step4_api/runtime.py)
+- [阶段提示](../src/step4_api/progress.py)
+- [推荐与来源字段](../src/step3_question_answer/recommendation.py)
+- [卡片及综合说明](../src/step3_question_answer/cards.py)
+- [主体与政策比较](../src/step3_question_answer/matching.py)

+ 1161 - 0
harness/docs/reference/legacy/API.md

@@ -0,0 +1,1161 @@
+# 招商助手前端 API 接口文档
+
+> ## ⚠️ 这是交接时的代码快照,不是接口规格
+>
+> - **来源**:接手项目时根据当时的源码整理生成
+> - **没有版本可追溯性**:接口和功能后续一直在改,本文档不会跟着更新;
+>   从生成那一刻之后新增或修改的接口,它一律不知道
+> - **用途**:只能用来翻"某个前端模块**当初**调了什么接口、传了什么参数"
+>
+> **不要**照着它实现新功能(现行契约见 `../api-chat.md`);
+> **也不要**拿它当"原来行为是什么"的判据——它是二手整理,可能遗漏或失真,
+> 精确判断请直接 diff 改动前的源码备份。
+
+> 基于 `zhaoshang-llm` 项目源码整理,涵盖全部前端调用的后端接口。
+>
+> 环境变量说明:
+> - `VITE_API` — 主业务 API 网关基础地址(如 `//qingpu-data-api.metamaker.cn`)
+> - `VITE_MODEL_SERVER` — LLM 模型服务地址(如 `https://qingpu-data-api.metamaker.cn/llms/qingpu`)
+> - `VITE_ASR` — 语音识别服务地址
+> - `VITE_ASSISTANT_STATISTICS_BASE_URL` — 统计服务基础地址(回退到 `VITE_API`)
+> - `VITE_STREAM_SERVER` — 流媒体/数字人视频流服务地址
+
+---
+
+## 目录
+
+1. [对话服务](#1-对话服务)
+   - 1.1 [AI 对话生成(SSE 流式)](#11-ai-对话生成sse-流式)
+   - 1.2 [表单对话生成(SSE 流式)](#12-表单对话生成sse-流式)
+   - 1.3 [表单提交对话(SSE 流式)](#13-表单提交对话sse-流式)
+2. [企业认证服务](#2-企业认证服务)
+   - 2.1 [企业登录](#21-企业登录)
+   - 2.2 [获取企业信息](#22-获取企业信息)
+3. [会话管理服务](#3-会话管理服务)
+   - 3.1 [获取远程会话列表](#31-获取远程会话列表)
+   - 3.2 [获取会话聊天记录](#32-获取会话聊天记录)
+   - 3.3 [更新会话信息(重命名)](#33-更新会话信息重命名)
+   - 3.4 [删除会话](#34-删除会话)
+4. [反馈服务](#4-反馈服务)
+   - 4.1 [对话反馈(点赞/点踩)](#41-对话反馈点赞点踩)
+   - 4.2 [统计反馈上报](#42-统计反馈上报)
+5. [统计服务](#5-统计服务)
+   - 5.1 [页面访问统计](#51-页面访问统计)
+6. [文件上传服务](#6-文件上传服务)
+   - 6.1 [获取 OSS 上传签名凭证](#61-获取-oss-上传签名凭证)
+   - 6.2 [上传文件到 OSS](#62-上传文件到-oss)
+7. [媒体资源服务](#7-媒体资源服务)
+   - 7.1 [获取媒体文件信息](#71-获取媒体文件信息)
+8. [政策服务](#8-政策服务)
+   - 8.1 [政策列表搜索](#81-政策列表搜索)
+   - 8.2 [政策列表更新数据获取](#82-政策列表更新数据获取)
+9. [历史记录服务(已停用)](#9-历史记录服务已停用)
+   - 9.1 [追加历史记录](#91-追加历史记录)
+10. [TTS 语音合成服务(已停用)](#10-tts-语音合成服务已停用)
+    - 10.1 [文本转语音](#101-文本转语音)
+11. [ASR 语音识别服务](#11-asr-语音识别服务)
+    - 11.1 [ASR WebSocket 语音识别](#111-asr-websocket-语音识别)
+12. [环境配置与代理映射](#12-环境配置与代理映射)
+13. [后端服务架构总览](#13-后端服务架构总览)
+
+---
+
+## 1. 对话服务
+
+### 1.1 AI 对话生成(SSE 流式)
+
+**用户向 AI 发送问题,服务端以 Server-Sent Events 流式返回回答内容。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/knowledge/chat` 或 `{VITE_API}/dialog/chat` |
+| **选择逻辑** | 由 `isKnowledgeApi()` 返回 `true` 时用 `/knowledge/chat`,否则用 `/dialog/chat` |
+| **请求方法** | `POST` |
+| **通讯协议** | SSE(Server-Sent Events),使用 `@microsoft/fetch-event-source` 库 |
+| **源码位置** | `src/components/stream-message-coordinator.ts` L270-551 |
+| **调用入口** | `useBusinessAssistantChat.ts` → `sendMessage()` → `smc.generateAnswer(text)` |
+
+#### 请求 Headers
+
+| Header | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `Content-Type` | `string` | ✅ | 固定为 `application/json` |
+| `Authorization` | `string` | ⚠️ 可选 | 用户认证 token(`globalThis.authToken` 或 `globalThis.token`) |
+| `Uber-Trace-Id` | `string` | ⚠️ 可选 | 阿里云 ARMS 链路追踪 ID(当 `window.__bl` 存在时自动注入) |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "card_id": 0,
+  "department_id": "",
+  "text": "用户输入的问题文本",
+  "session_id": "uuid-session-id",
+  "format": "object",
+  "model": "当前 LLM 模型名(globalThis.llmModel)",
+  "transmission": {
+    "session_id": "uuid-session-id",
+    "model": "当前 LLM 模型名",
+    "credit_code": "企业统一社会信用代码",
+    "files": ["https://oss-url/uploaded-file1.pdf"],
+    "file_pos": [0],
+    "enable_thinking": true
+  },
+  "source": "zhaoshang",
+  "credit_code": "企业统一社会信用代码",
+  "knowledge_ids": ["知识库 ID 列表"],
+  "enable_history": true
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `card_id` | `number` | ✅ | 卡片 ID,固定传入 |
+| `department_id` | `string` | ⚠️ | 部门 ID |
+| `text` | `string` | ✅ | 用户问题文本 |
+| `session_id` | `string` | ✅ | 当前会话 UUID |
+| `format` | `string` | ✅ | 固定值 `"object"` |
+| `model` | `string` | ✅ | LLM 模型标识,如 `"gpt-4"` |
+| `transmission` | `object` | ✅ | 传输参数,包含会话、模型、企业、文件等上下文 |
+| `transmission.credit_code` | `string` | ⚠️ | 企业信用代码(登录用户才有) |
+| `transmission.files` | `string[]` | ⚠️ | 用户上传的文件 URL 列表 |
+| `transmission.file_pos` | `number[]` | ⚠️ | 文件位置标记(与 files 长度一致) |
+| `transmission.enable_thinking` | `boolean` | ⚠️ | 是否开启深度思考模式 |
+| `source` | `string` | ⚠️ | 来源标识,如 `"zhaoshang"` |
+| `credit_code` | `string` | ⚠️ | 企业信用代码 |
+| `knowledge_ids` | `string[]` | ⚠️ | 知识库 ID 列表(从上一轮返回中获取) |
+| `enable_history` | `boolean` | ✅ | 固定值 `true`,启用历史上下文 |
+| `task_id` | `string` | ⚠️ | 仅在 `formChatting=true` 时传入 |
+
+#### SSE 事件处理
+
+**`onopen` 回调**:
+
+连接建立后触发,可能返回服务端预处理信息(如限流提示),通过 `handleServerPreprocessInfo(response)` 解析。
+
+**`onmessage` 回调**:
+
+每条 SSE 消息的 `data` 为 JSON 字符串:
+
+```json
+{
+  "message": "流式输出的文本片段",
+  "record_id": "对话记录 ID(首条消息携带)",
+  "status": "done | interrupt",
+  "reasoning_content": "思考过程文本(深度思考模式)",
+  "knowledge_ids": ["知识库 ID"],
+  "disease_id": "诊断 ID(医疗场景)",
+  "from": "风险等级标识"
+}
+```
+
+| 事件/字段 | 说明 |
+|---|---|
+| `msg.event === "fail"` | 服务端返回失败,`data.message` 为错误信息 |
+| `data.record_id` | 首次出现时,触发 `recordId` 事件供反馈使用 |
+| `data.reasoning_content` | 深度思考内容,触发 `reasoning_content` 事件 |
+| `data.message` 包含 `<think>` / `</think>` | 切换思考模式标志 |
+| `data.message` 包含媒体资源 | 解析后触发 `relate` / `reference` 事件 |
+| `data.message` 包含 `<!-- POLICY_TABLE` | 政策表格标记,整块 enqueue |
+| `data.message` 包含 `<scope>` | 进入范围标签缓冲区 |
+| `data.message` 包含 `<followup>` | 由 `StreamXMLFilter` 解析并触发 `followupSuggestions` 事件 |
+| `data.message` 包含 `<policy-list-updated>` | 触发政策列表更新、刷新 `globalThis.policiesPublic` |
+| `data.status === "done"` 或 `"interrupt"` | 对话结束或被中断 |
+
+**`onerror` 回调**:触发 `error` 事件并自动 `stopGenerate()`。
+
+**`onclose` 回调**:流式连接关闭,`generating = false`,触发 `close` 事件,存储完整回复。
+
+---
+
+### 1.2 表单对话生成(SSE 流式)
+
+**与 1.1 相同连接,当 `formChatting = true` 时自动切换到表单对话 URL。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/knowledge/form_chat` |
+| **请求方法** | `POST` |
+| **通讯协议** | SSE |
+| **源码位置** | `src/components/stream-message-coordinator.ts` L343 |
+| **触发条件** | `smc.formChatting === true` 且调用 `generateAnswer()` |
+
+请求参数与 1.1 一致,额外携带 `task_id` 字段。
+
+---
+
+### 1.3 表单提交对话(SSE 流式)
+
+**独立的 LLM 模型服务接口,用于表单提交结果的对话生成。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_MODEL_SERVER}/v1/chat/completions` |
+| **请求方法** | `POST` |
+| **通讯协议** | SSE |
+| **源码位置** | `src/components/stream-message-coordinator.ts` L553-770 |
+| **调用入口** | `smc.generateFormSubmit(text)` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "card_id": 0,
+  "department_id": "",
+  "text": "用户输入",
+  "session_id": "uuid-session-id",
+  "format": "object",
+  "model": "form_submit",
+  "transmission": {
+    "session_id": "uuid-session-id",
+    "model": "form_submit"
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `model` | `string` | 固定为 `"form_submit"` |
+
+SSE 响应处理与 1.1 完全一致。
+
+---
+
+## 2. 企业认证服务
+
+### 2.1 企业登录
+
+**通过政务系统的 `access_token` 换取企业信用代码和内部认证 token。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/enterprise/login` |
+| **请求方法** | `POST` |
+| **源码位置** | `src/network/api/enterprise.ts` L49-79 |
+| **调用入口** | `useEnterpriseAuth.ts` → `initAuth()` → `fetchEnterpriseLogin(accessToken)` |
+| **触发时机** | URL 中携带 `access_token` 参数时,应用初始化阶段自动调用 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+
+#### 请求参数
+
+**Query Parameters:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `access_token` | `string` | ✅ | 政务系统签发的访问令牌(URL 编码) |
+
+**Request Body (JSON):**
+
+```json
+{
+  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+```
+
+#### 响应
+
+**成功 (200):**
+
+```json
+{
+  "err_code": 0,
+  "err_msg": "",
+  "ret": {
+    "credit_code": "91310118MA1JLRL16G",
+    "access_token": "内部签发的 JWT token"
+  }
+}
+```
+
+**失败:**
+
+```json
+{
+  "err_code": 1001,
+  "err_msg": "token 无效或已过期"
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---|---|---|
+| `err_code` | `number` | 0 = 成功,非 0 = 失败 |
+| `err_msg` | `string` | 错误信息 |
+| `ret.credit_code` | `string` | 企业统一社会信用代码 |
+| `ret.access_token` | `string` | 后续请求使用的内部 auth token |
+
+#### 前端处理
+
+- 成功后将 `authToken` 存入 `globalThis.authToken`、`globalThis.token` 和 `localStorage(ba_auth_token)`。
+- 随后自动调用 `fetchEnterpriseInfo` 获取企业详细信息。
+
+---
+
+### 2.2 获取企业信息
+
+**根据企业信用代码查询企业详细注册信息。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/fta_ent_policy/enterprise_info` |
+| **请求方法** | `GET` |
+| **源码位置** | `src/network/api/enterprise.ts` L81-109 |
+| **调用入口** | `useEnterpriseAuth.ts` → `initAuthWithCreditCode()` → `fetchEnterpriseInfo(creditCode)` |
+| **触发时机** | 企业登录成功后 或 URL 直接携带 `credit_code` 参数时 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{authToken}` |
+
+#### 请求参数 (Query)
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `credit_code` | `string` | ✅ | 企业统一社会信用代码 |
+| `source` | `string` | ⚠️ | 来源标识,如 `"zhaoshang"` |
+
+#### 响应
+
+**成功 (200):**
+
+```json
+{
+  "err_code": 0,
+  "err_msg": "",
+  "ret": {
+    "credit_code": "91310118MA1JLRL16G",
+    "name": "上海某某科技有限公司",
+    "regno": "310118003XXXXXX",
+    "peid": "企业 PE ID",
+    "op_from": "2015-01-01",
+    "op_to": "2045-01-01",
+    "es_date": "2015-01-01",
+    "appr_date": "2015-01-01",
+    "reg_cap": 1000000,
+    "op_scope": "计算机软件开发、信息技术咨询...",
+    "dom": "上海市青浦区XX路XX号",
+    "ent_type_name": "有限责任公司",
+    "industry_phy_name": "软件和信息技术服务业",
+    "industry_co_name": "软件开发",
+    "reg_org_name": "上海市青浦区市场监督管理局",
+    "reg_org_province": "上海",
+    "reg_org_city": "上海市",
+    "reg_org_county": "青浦区",
+    "reg_cap_cur_name": "人民币",
+    "ent_status_name": "在营",
+    "fr_name": "法人姓名"
+  }
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---|---|---|
+| `ret.credit_code` | `string` | 统一社会信用代码 |
+| `ret.name` | `string` | 企业名称 |
+| `ret.reg_cap` | `number` | 注册资本(单位:元) |
+| `ret.op_scope` | `string` | 经营范围 |
+| `ret.dom` | `string` | 注册地址 |
+| `ret.ent_type_name` | `string` | 企业类型 |
+| `ret.industry_phy_name` | `string` | 所属行业大类 |
+| `ret.industry_co_name` | `string` | 所属行业小类 |
+| `ret.reg_org_name` | `string` | 登记机关 |
+| `ret.ent_status_name` | `string` | 企业状态 |
+| `ret.fr_name` | `string` | 法定代表人 |
+| `ret.op_from` / `ret.op_to` | `string` | 营业期限起止 |
+| `ret.es_date` / `ret.appr_date` | `string` | 成立日期 / 核准日期 |
+
+#### 前端处理
+
+- 信息存入 `enterpriseInfo` 响应式变量和 `localStorage(ba_enterprise_info)`。
+- `credit_code` 写入 `globalThis.credit_code` 和 `globalThis.transmission.credit_code` 供后续对话接口使用。
+
+---
+
+## 3. 会话管理服务
+
+### 3.1 获取远程会话列表
+
+**查询已登录用户的所有远程会话列表。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/chat/sessions` |
+| **请求方法** | `GET` |
+| **源码位置** | `src/network/api/chat-sessions.ts` L47-86 |
+| **调用入口** | `BusinessAssistantPC.vue` / `BusinessAssistantMobile.vue` → `onMounted` → `fetchAndMergeRemoteSessions()` → `fetchRemoteSessions(creditCode)` |
+| **触发时机** | 组件挂载且企业已登录时 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{authToken}` |
+
+#### 请求参数 (Query)
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|---|---|---|---|---|
+| `credit_code` | `string` | ✅ | — | 企业统一社会信用代码 |
+| `page_index` | `number` | ✅ | `1` | 页码 |
+| `page_size` | `number` | ✅ | `100` | 每页数量 |
+| `source` | `string` | ⚠️ | `"zhaoshang"` | 来源标识 |
+
+#### 响应
+
+```json
+{
+  "err_code": 0,
+  "err_msg": "",
+  "ret": {
+    "data": [
+      {
+        "session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+        "title": "关于高新技术企业优惠政策",
+        "updated_at": 1700000000,
+        "credit_code": "91310118MA1JLRL16G"
+      }
+    ],
+    "total": 10,
+    "page_index": 1,
+    "page_size": 100
+  }
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---|---|---|
+| `ret.data` | `RemoteSession[]` | 会话数组 |
+| `ret.data[].session_id` | `string` | 会话 UUID |
+| `ret.data[].title` | `string` | 会话标题 |
+| `ret.data[].updated_at` | `number` | 最后更新时间(Unix 时间戳,秒) |
+| `ret.total` | `number` | 总会话数 |
+
+---
+
+### 3.2 获取会话聊天记录
+
+**获取指定会话的完整消息历史。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/chat/session_record` |
+| **请求方法** | `GET` |
+| **源码位置** | `src/network/api/chat-sessions.ts` L88-117 |
+| **调用入口** | `useBusinessAssistantChat.ts` → `loadRemoteSessionRecords(sessionId)` → `fetchRemoteSessionRecords(sessionId)` |
+| **触发时机** | 切换到已有会话,且本地无缓存消息时 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{authToken}` |
+
+#### 请求参数 (Query)
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `session_id` | `string` | ✅ | 会话 UUID |
+| `source` | `string` | ⚠️ | 来源标识 |
+
+#### 响应
+
+```json
+{
+  "err_code": 0,
+  "ret": {
+    "total": 5,
+    "page_size": 100,
+    "page_index": 1,
+    "data": [
+      {
+        "session_id": "a1b2c3d4-...",
+        "question": "高新技术企业有哪些优惠政策?",
+        "answer": "根据青浦区政策...",
+        "created_at": 1700000000
+      }
+    ]
+  }
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---|---|---|
+| `ret.data` | `RemoteSessionRecord[]` | 消息记录数组 |
+| `ret.data[].question` | `string` | 用户提问内容 |
+| `ret.data[].answer` | `string` | AI 回答内容(Markdown 格式) |
+| `ret.data[].created_at` | `number` | 创建时间(Unix 时间戳) |
+
+---
+
+### 3.3 更新会话信息(重命名)
+
+**更新会话标题。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/chat/session_info` |
+| **请求方法** | `PUT` |
+| **源码位置** | `src/network/api/chat-sessions.ts` L119-150 |
+| **调用入口** | `useBusinessAssistantChat.ts` → `syncSessionTitleToServer(sessionId, title)` → `updateSessionInfo(sessionId, creditCode, title)` |
+| **触发时机** | 1) 新会话首次发送消息后自动生成标题同步;2) 用户手动重命名会话 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{authToken}` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+  "credit_code": "91310118MA1JLRL16G",
+  "title": "新的会话标题",
+  "source": "zhaoshang"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `session_id` | `string` | ✅ | 会话 UUID |
+| `credit_code` | `string` | ✅ | 企业信用代码 |
+| `title` | `string` | ✅ | 新标题文本 |
+| `source` | `string` | ✅ | 来源标识 |
+
+#### 响应
+
+```json
+{
+  "err_code": 0,
+  "err_msg": ""
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `err_code` | 0 = 成功 |
+
+---
+
+### 3.4 删除会话
+
+**删除指定会话及其聊天记录。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/chat/del_session_info` |
+| **请求方法** | `POST` |
+| **源码位置** | `src/network/api/chat-sessions.ts` L152-181 |
+| **调用入口** | `useBusinessAssistantChat.ts` → `syncSessionDeleteToServer(sessionId)` → `deleteSessionInfo(sessionId, creditCode)` |
+| **触发时机** | 用户在侧边栏删除会话时 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{authToken}` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+  "credit_code": "91310118MA1JLRL16G",
+  "source": "zhaoshang"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `session_id` | `string` | ✅ | 会话 UUID |
+| `credit_code` | `string` | ✅ | 企业信用代码 |
+| `source` | `string` | ✅ | 来源标识 |
+
+#### 响应
+
+```json
+{
+  "err_code": 0,
+  "err_msg": ""
+}
+```
+
+---
+
+## 4. 反馈服务
+
+### 4.1 对话反馈(点赞/点踩)
+
+**用户对 AI 回复进行满意度评价。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/chat/feedback` |
+| **请求方法** | `POST` |
+| **源码位置** | `src/network/api/card/index.js` L22-43 |
+| **调用入口** | `BusinessRecord.vue` → `FeedbackWidget.vue` / `DislikeDialog.vue` → `cardAPI.giveFeedback(payload)` |
+| **触发时机** | 用户点击消息下方的 👍 / 👎 按钮 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{token}` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "id": "record_id_from_sse",
+  "status": 1,
+  "option": "回答不准确",
+  "remark": "实际政策已经更新了"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `id` | `string` | ✅ | 对话记录 ID(从 SSE 流中的 `record_id` 获取) |
+| `status` | `number` | ✅ | 反馈状态。`1` = 点赞,`2` = 点踩 |
+| `option` | `string` | ⚠️ | 不满意的原因分类(点踩时可选) |
+| `remark` | `string` | ⚠️ | 补充说明文本(点踩时可选) |
+
+#### 响应
+
+```json
+{
+  "err_code": 0,
+  "err_msg": ""
+}
+```
+
+---
+
+### 4.2 统计反馈上报
+
+**将用户反馈同步到独立的统计系统。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_ASSISTANT_STATISTICS_BASE_URL}/assistant_statistics/feedback` |
+| **请求方法** | `POST` |
+| **源码位置** | `src/network/api/assistant-statistics.ts` L122-164 |
+| **调用入口** | `reportAssistantFeedback(payload, options)` |
+| **触发时机** | 与 4.1 同步触发 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{token}` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "record_id": "对话记录 ID",
+  "status": 1,
+  "visitor_id": "uuid-visitor-id",
+  "business_code": "business_assistant",
+  "env": "release"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `record_id` | `string` | ✅ | 对话记录 ID |
+| `status` | `number` | ✅ | 反馈状态 |
+| `visitor_id` | `string` | ✅ | 访客 UUID(`localStorage` 持久化,自动生成) |
+| `business_code` | `string` | ✅ | 业务编码,固定 `"business_assistant"` |
+| `env` | `string` | ✅ | 环境标识:`"release"` (qingpu) / `"test"` (production) |
+
+#### 响应
+
+HTTP 状态码 200 即为成功,无特定响应体结构要求。
+
+---
+
+## 5. 统计服务
+
+### 5.1 页面访问统计
+
+**记录用户访问招商助手页面的行为。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_ASSISTANT_STATISTICS_BASE_URL}/assistant_statistics/page_view` |
+| **请求方法** | `POST` |
+| **源码位置** | `src/network/api/assistant-statistics.ts` L76-115 |
+| **调用入口** | `BusinessAssistant.vue` → `onMounted` → `recordCurrentAssistantPageView({ apiBaseUrl })` |
+| **触发时机** | 页面首次加载完成时 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{token}` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "visitor_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+  "business_code": "business_assistant",
+  "env": "release",
+  "source": "zhaoshang"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `visitor_id` | `string` | ✅ | 访客 UUID,存储于 `localStorage(assistant_statistics_visitor_id)`,首次访问自动生成 |
+| `business_code` | `string` | ✅ | 固定 `"business_assistant"`(由 `policyMode` → `resolveAssistantBusinessCode()` 映射) |
+| `env` | `string` | ✅ | `"release"` (qingpu 部署) / `"test"` (production 部署) |
+| `source` | `string` | ⚠️ | 来源标识 |
+
+#### 响应
+
+HTTP 状态码 200 即为成功。
+
+---
+
+## 6. 文件上传服务
+
+### 6.1 获取 OSS 上传签名凭证
+
+**在上传文件前,先获取阿里云 OSS 的临时签名凭证。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `//qingpu-data-api.metamaker.cn/common/qp_signed_url` |
+| **请求方法** | `POST` |
+| **源码位置** | `src/components/business-assistant/useBusinessAssistantUpload.ts` L115-141 |
+| **调用入口** | `uploadFile()` → `requestOssSignature(extension, signal)` |
+| **触发时机** | 用户选择图片/附件后自动触发 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "ext": "png"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `ext` | `string` | ✅ | 文件扩展名,如 `"png"`, `"pdf"`, `"docx"` |
+
+#### 响应
+
+```json
+{
+  "ret": {
+    "file_url": "https://oss-bucket.oss-cn-shanghai.aliyuncs.com/path/to/file.png",
+    "host": "https://oss-bucket.oss-cn-shanghai.aliyuncs.com",
+    "key": "uploads/2024/01/01/uuid.png",
+    "policy": "eyJleHBpcmF0aW9uIjoiMjAyNC0...",
+    "x-oss-signature": "签名字符串",
+    "x-oss-signature-version": "OSS4-HMAC-SHA256",
+    "x-oss-credential": "凭证字符串",
+    "x-oss-date": "20240101T000000Z",
+    "security_token": "STS临时安全令牌"
+  }
+}
+```
+
+| 响应字段 | 类型 | 说明 |
+|---|---|---|
+| `ret.file_url` | `string` | ✅ 上传成功后的可访问 URL |
+| `ret.host` | `string` | ✅ OSS 上传目标地址 |
+| `ret.key` | `string` | ✅ 对象存储 key |
+| `ret.policy` | `string` | ✅ OSS 签名策略 |
+| `ret.x-oss-signature` | `string` | ✅ OSS 签名(也可能返回 `x_oss_signature` 格式) |
+| `ret.x-oss-signature-version` | `string` | 签名版本 |
+| `ret.x-oss-credential` | `string` | 凭证 |
+| `ret.x-oss-date` | `string` | 签名日期 |
+| `ret.security_token` | `string` | ⚠️ STS 临时令牌(使用临时凭证时) |
+
+> ⚠️ 字段命名兼容两种格式:`x-oss-*`(kebab-case)和 `x_oss_*`(snake_case),前端通过 `getOssField()` 工具函数统一处理。
+
+---
+
+### 6.2 上传文件到 OSS
+
+**将实际文件以 FormData 方式直接上传到阿里云 OSS。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{signature.host}`(动态,来自 6.1 响应的 `host` 字段) |
+| **请求方法** | `POST` |
+| **Content-Type** | `multipart/form-data`(浏览器自动设置) |
+| **源码位置** | `src/components/business-assistant/useBusinessAssistantUpload.ts` L144-170 |
+| **调用入口** | `uploadFile()` → `uploadToOss(file, signature, signal)` |
+
+#### FormData 字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `success_action_status` | `string` | ✅ | 固定值 `"200"` |
+| `policy` | `string` | ✅ | 来自签名凭证 |
+| `x-oss-signature` | `string` | ✅ | 来自签名凭证 |
+| `x-oss-signature-version` | `string` | ✅ | 默认 `"OSS4-HMAC-SHA256"` |
+| `x-oss-credential` | `string` | ✅ | 来自签名凭证 |
+| `x-oss-date` | `string` | ✅ | 来自签名凭证 |
+| `key` | `string` | ✅ | 来自签名凭证 |
+| `x-oss-security-token` | `string` | ⚠️ | STS 临时令牌(存在时) |
+| `file` | `File` | ✅ | 实际文件数据 |
+
+#### 文件限制
+
+| 限制项 | 图片 | 附件 |
+|---|---|---|
+| 最大大小 | 20MB (`MAX_IMAGE_SIZE`) | 50MB (`MAX_FILE_SIZE`) |
+| 支持格式 | `SUPPORTED_IMAGE_EXTENSIONS` | `SUPPORTED_ATTACHMENT_EXTENSIONS` |
+
+#### 响应
+
+HTTP 200 表示上传成功,无需额外解析响应体。上传成功后使用签名凭证中的 `file_url` 作为文件的最终访问地址。
+
+#### 上传状态流
+
+```
+选择文件 → 创建上传项(status='uploading')
+         → 请求 OSS 签名(6.1)
+         → 上传到 OSS(6.2)
+         → 成功: status='success', fileUrl=signature.file_url
+         → 失败: status='error', errorMsg=错误信息
+         → 取消: AbortController.abort()
+```
+
+---
+
+## 7. 媒体资源服务
+
+### 7.1 获取媒体文件信息
+
+**根据文件 ID 列表批量获取媒体文件的元信息(URL、尺寸等)。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/dialog/file_info` |
+| **请求方法** | `GET` |
+| **源码位置** | `src/network/api/card/index.js` L7-19 |
+| **调用入口** | `cardAPI.getMediaList(ids)` |
+| **触发时机** | AI 回复中包含媒体引用时 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/x-www-form-urlencoded` |
+| `Authorization` | `{token}` |
+
+#### 请求参数 (Query)
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `ids` | `string` | ✅ | 逗号分隔的文件 ID 列表,如 `"id1,id2,id3"` |
+
+#### 响应
+
+返回媒体文件的详细信息数组(包含 URL、类型、尺寸等)。
+
+---
+
+## 8. 政策服务
+
+### 8.1 政策列表搜索
+
+**在政策面板中按关键词、部门、分类搜索和分页浏览政策列表。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_MODEL_SERVER}/v1/policies` |
+| **请求方法** | `GET` |
+| **源码位置** | `src/components/Chat/PolicyMatch.vue` L791-840 + `src/components/Chat/policy-match-utils.ts` L24-62 |
+| **调用入口** | `PolicyMatch.vue` → `fetchPolicyList()` |
+| **触发时机** | 用户在政策面板中搜索、翻页、切换筛选条件时 |
+
+#### 请求参数 (Query)
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `page` | `number` | ✅ | 页码,从 1 开始 |
+| `page_size` | `number` | ✅ | 每页数量 |
+| `keyword` | `string` | ⚠️ | 搜索关键词(空则不传) |
+| `department` | `string` | ⚠️ | 部门筛选(`"全部部门"` 时不传) |
+| `auto_granted` | `string` | ⚠️ | 免申即享筛选:`"true"` / `"false"`(`"所有分类"` 时不传) |
+| `session_id` | `string` | ⚠️ | 当前会话 ID |
+
+#### 响应
+
+```json
+{
+  "data": [
+    {
+      "title": "政策名称",
+      "department": "发布部门",
+      "desc": "政策描述",
+      "declaration_item": "申报事项",
+      "auto_granted": true
+    }
+  ],
+  "total": 100
+}
+```
+
+> 响应结构由 `extractPolicyListResponse()` 自适应解析,支持多种嵌套格式(`data.items`, `data.list`, `data.records`, `data.results` 等)。
+
+---
+
+### 8.2 政策列表更新数据获取
+
+**当 AI 回复中包含 `<policy-list-updated>` 标签时,从指定 URL 拉取最新政策数据。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | 动态 URL(由流式消息中 `<policy-list-updated>` 的 `link` 字段指定) |
+| **请求方法** | `GET` |
+| **源码位置** | `src/components/Chat/PolicyListUpdatedMatch.vue` L55-89 + `src/components/stream-message-coordinator.ts` L176-210 |
+| **触发位置** | 1) `StreamMessageCoordinator` 的 `followupFilter` 处理器内(L176);2) `PolicyListUpdatedMatch.vue` 组件内(L73) |
+| **触发时机** | AI 在流式回复中返回政策更新标签 |
+
+#### URL 构建
+
+```
+原始 link(从 JSON 中提取)+ payload 中的其他参数作为 query string
+```
+
+#### 响应
+
+```json
+{
+  "data": [
+    {
+      "title": "政策名称",
+      "department": "部门"
+    }
+  ]
+}
+```
+
+#### 前端处理
+
+- `data.data` 数组存入 `globalThis.policiesPublic`,供本地政策列表搜索和展示使用。
+
+---
+
+## 9. 历史记录服务(已停用)
+
+### 9.1 追加历史记录
+
+> ⛔ **状态:已停用**。代码保留但调用处已注释,改由后端通过 `enable_history=true` 自动管理。
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `{VITE_API}/knowledge/append_history` 或 `{VITE_API}/dialog/append_history` |
+| **请求方法** | `POST` |
+| **源码位置** | `src/network/api/card/index.js` L45-75 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `{token}` |
+
+#### 请求体 (JSON)
+
+```json
+{
+  "card_id": 0,
+  "id": "record_id"
+}
+```
+
+---
+
+## 10. TTS 语音合成服务(已停用)
+
+### 10.1 文本转语音
+
+> ⚠️ **状态:代码保留,调用链已注释**。`checkAvailableTexts()` 中的 TTS 调用被注释 (L1110)。
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `//llm-api.test.metamaker.cn/knowledge/tts`(默认值,可通过 `args.TTSURL` 覆盖) |
+| **请求方法** | `POST` |
+| **Content-Type** | `application/x-www-form-urlencoded` |
+| **源码位置** | `src/components/stream-message-coordinator.ts` L990-1026 |
+
+#### 请求 Headers
+
+| Header | 值 |
+|---|---|
+| `Content-Type` | `application/x-www-form-urlencoded` |
+| `Authorization` | `{token}` |
+| `X-Heijing-Verf` | 签名值(`this.sign()` 存在时) |
+
+#### 请求体 (x-www-form-urlencoded)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `text` | `string` | 待合成文本(已去除 Markdown 标签) |
+| `tts_args` | `string` | JSON 序列化的 TTS 参数,含 `anim_agent`, `silence_type` 等 |
+| `audio_type` | `string` | 固定 `"wav"` |
+| `storage_type` | `string` | 固定 `"cloud"` |
+| `card_id` | `number` | 卡片 ID |
+| `is_strict` | `boolean` | 是否严格模式 |
+| `disable_censor` | `boolean` | 是否禁用敏感词过滤 |
+
+#### 响应
+
+```json
+{
+  "err_code": 0,
+  "ret": {
+    "audio": "https://oss-url/audio.wav",
+    "expression_anim": "https://oss-url/expression.json",
+    "teeth_anim": "https://oss-url/teeth.json"
+  }
+}
+```
+
+---
+
+## 11. ASR 语音识别服务
+
+### 11.1 ASR WebSocket 语音识别
+
+**实时语音识别,将麦克风输入转为文本。**
+
+| 属性 | 值 |
+|---|---|
+| **接口地址** | `VITE_ASR` 配置值(如 `wss://qingpu-data-api.metamaker.cn`) |
+| **通讯协议** | WebSocket |
+| **源码位置** | `src/three-libs/asr/index.ts`(ASR SDK) |
+| **调用入口** | PC: `BusinessAssistantPC.vue` → `toggleVoiceInput()`;Mobile: `BusinessAssistantMobile.vue` → `toggleVoiceInput()` |
+| **触发时机** | 用户点击麦克风按钮 |
+
+---
+
+## 12. 环境配置与代理映射
+
+### 环境变量总览
+
+| 变量 | Development | Production | Qingpu | Test |
+|---|---|---|---|---|
+| `VITE_API` | `//qingpu-data-api.metamaker.cn` | `//qingpu-data-api.metamaker.cn` | `//qingpu-data-api.metamaker.cn` | `//human-card-mini.metamaker.cn` |
+| `VITE_MODEL_SERVER` | `https://qingpu-data-api.metamaker.cn/llms/qingpu` | `https://qingpu-data-api.metamaker.cn/llms/qingpu` | `https://qingpu-data-api.metamaker.cn/llms/qingpu` | — |
+| `VITE_ASR` | `https://qingpu-data-api.metamaker.cn` | `wss://qingpu-data-api.metamaker.cn` | `wss://qingpu-data-api.metamaker.cn` | `https://qingpu-data-api.metamaker.cn` |
+| `VITE_STREAM_SERVER` | `//flv-enc.metamaker.cn` | `//flv-enc.metamaker.cn` | `/stream` | `//flv-enc.test.metamaker.cn` |
+| `VITE_USE_KNOWLEDGE_API` | `true` | `true` | `true` | `false` |
+| `VITE_ASSISTANT_STATISTICS_BASE_URL` | — | — | `https://qingpu-data-api.metamaker.cn/` | — |
+| `VITE_DOWNLOAD_URL` | `https://qingpu-data-api.metamaker.cn/llm/download/` | `https://qingpu-data-api.metamaker.cn/llm/download/` | `/api/download/` | — |
+
+### 开发环境代理
+
+| 前端路径 | 目标服务 | 备注 |
+|---|---|---|
+| `/api/*` | `http://aixq.shqp.gov.cn` | 政务 API 网关 |
+| `/asr/*` | `https://human-screen-v3.metamaker.cn` | ASR 语音识别(WebSocket) |
+| `/stream/*` | `https://flv-enc.metamaker.cn` | 流媒体/数字人视频流(WebSocket) |
+
+---
+
+## 13. 后端服务架构总览
+
+### 按物理服务地址分类
+
+```
+┌──────────────────────────────────────────────────────────┐
+│                    前端应用 (Vue 3)                        │
+└───────┬──────────┬──────────┬──────────┬────────┬────────┘
+        │          │          │          │        │
+        ▼          ▼          ▼          ▼        ▼
+   ① 主网关      ② ASR      ③ 流媒体   ④ TTS    ⑤ OSS
+   qingpu-data   qingpu-    flv-enc.   llm-api   阿里云
+   -api.meta     data-api   metamaker  .test.    OSS
+   maker.cn      (WSS)      .cn        meta      (动态)
+                                       maker.cn
+                                       (已停用)
+```
+
+### ① 主网关内部路由模块
+
+| 路径前缀 | 逻辑模块 | 接口数量 |
+|---|---|---|
+| `/dialog/*` | 对话服务(旧版) | 2 |
+| `/knowledge/*` | 知识库对话服务(新版) | 3 |
+| `/chat/*` | 会话管理 + 反馈 | 5 |
+| `/enterprise/*` | 企业认证 | 1 |
+| `/fta_ent_policy/*` | 企业政策 | 1 |
+| `/common/*` | 公共工具 | 1 |
+| `/assistant_statistics/*` | 统计 | 2 |
+| `/llms/qingpu/v1/*` | LLM 模型服务 | 2 |
+
+### 认证机制
+
+- **全局 Token**:`globalThis.authToken` / `globalThis.token`,通过 `Authorization` Header 传递。
+- **401 拦截**:`useEnterpriseAuth.ts` 中通过 Fetch 拦截器监听,收到 401 时自动清除登录态。
+- **来源标识**:`globalThis.source`(如 `"zhaoshang"`),跟随 `policyMode` 配置。
+
+### 通用响应格式
+
+```json
+{
+  "err_code": 0,
+  "err_msg": "成功/错误描述",
+  "ret": {}
+}
+```
+
+- `err_code === 0` 表示成功
+- `err_code !== 0` 表示业务错误
+- HTTP 401 表示认证失效
+
+### 接口总览表
+
+| # | 接口路径 | 方法 | 文件 | 功能 | 状态 |
+|---|---|---|---|---|---|
+| 1 | `/dialog/chat` 或 `/knowledge/chat` | SSE POST | `stream-message-coordinator.ts` | AI 对话(主流程) | ✅ 在用 |
+| 2 | `/knowledge/form_chat` | SSE POST | `stream-message-coordinator.ts` | 表单对话 | ✅ 在用 |
+| 3 | `{MODEL_SERVER}/v1/chat/completions` | SSE POST | `stream-message-coordinator.ts` | 表单提交对话 | ✅ 在用 |
+| 4 | `/enterprise/login` | POST | `enterprise.ts` | 企业登录 | ✅ 在用 |
+| 5 | `/fta_ent_policy/enterprise_info` | GET | `enterprise.ts` | 企业信息查询 | ✅ 在用 |
+| 6 | `/chat/sessions` | GET | `chat-sessions.ts` | 远程会话列表 | ✅ 在用 |
+| 7 | `/chat/session_record` | GET | `chat-sessions.ts` | 会话消息记录 | ✅ 在用 |
+| 8 | `/chat/session_info` | PUT | `chat-sessions.ts` | 会话重命名 | ✅ 在用 |
+| 9 | `/chat/del_session_info` | POST | `chat-sessions.ts` | 删除会话 | ✅ 在用 |
+| 10 | `/chat/feedback` | POST | `card/index.js` | 对话反馈 | ✅ 在用 |
+| 11 | `/assistant_statistics/feedback` | POST | `assistant-statistics.ts` | 统计反馈 | ✅ 在用 |
+| 12 | `/assistant_statistics/page_view` | POST | `assistant-statistics.ts` | 页面访问统计 | ✅ 在用 |
+| 13 | `/common/qp_signed_url` | POST | `useBusinessAssistantUpload.ts` | OSS 签名获取 | ✅ 在用 |
+| 14 | `{signature.host}` (OSS) | POST | `useBusinessAssistantUpload.ts` | 文件上传 | ✅ 在用 |
+| 15 | `/dialog/append_history` 或 `/knowledge/append_history` | POST | `card/index.js` | 追加历史 | ⛔ 已禁用 |
+| 16 | `/dialog/file_info` | GET | `card/index.js` | 媒体信息查询 | ✅ 在用 |
+| 17 | `/knowledge/tts` | POST | `stream-message-coordinator.ts` | TTS 语音合成 | ⚠️ 代码保留,调用已注释 |
+| 18 | `{MODEL_SERVER}/v1/policies` | GET | `PolicyMatch.vue` | 政策列表搜索 | ✅ 在用 |
+| 19 | 动态 URL (policy-list-updated) | GET | `PolicyListUpdatedMatch.vue` | 政策数据更新 | ✅ 在用 |
+| 20 | ASR WebSocket | WS | `three-libs/asr/` | 语音识别 | ✅ 在用 |

File diff suppressed because it is too large
+ 43 - 0
harness/feature_list.json


+ 79 - 0
harness/init.sh

@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# 启动脚本:一条命令完成依赖安装 + 基础验证 + 打印启动命令。
+#
+# 用法:
+#   bash harness/init.sh              # 安装依赖 + 跑验证 + 打印启动命令
+#   RUN_START_COMMAND=1 bash harness/init.sh   # 验证通过后直接启动开发服务器
+#
+# 验证失败时应当**停下来先修基础状态**,不要在坏的基础上继续叠新功能。
+
+set -u
+
+# ── 按项目实际情况修改这三个变量 ────────────────────────────────────────────
+INSTALL_CMD="npm install"
+VERIFY_CMD="npm run build"
+START_CMD="npm run dev"
+# ────────────────────────────────────────────────────────────────────────────
+
+# 切到仓库根目录(脚本可能在任意位置被调用)
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+cd "$REPO_ROOT" || exit 1
+
+echo "=============================================="
+echo " 仓库根目录"
+echo "=============================================="
+pwd
+echo ""
+
+echo "=============================================="
+echo " 最近提交"
+echo "=============================================="
+if git rev-parse --git-dir >/dev/null 2>&1; then
+  if git rev-parse HEAD >/dev/null 2>&1; then
+    git log --oneline -5
+  else
+    echo "(仓库尚无任何提交——跳过。初始提交由用户自行完成。)"
+  fi
+else
+  echo "(不是 git 仓库——跳过)"
+fi
+echo ""
+
+echo "=============================================="
+echo " 安装依赖"
+echo "=============================================="
+echo "\$ ${INSTALL_CMD}"
+if ! ${INSTALL_CMD}; then
+  echo ""
+  echo "!! 依赖安装失败——先解决它,不要在坏的基础上继续。" >&2
+  exit 1
+fi
+echo ""
+
+echo "=============================================="
+echo " 基础验证"
+echo "=============================================="
+echo "\$ ${VERIFY_CMD}"
+if ! ${VERIFY_CMD}; then
+  echo ""
+  echo "!! 基础验证失败——先修好再开工,不要在上面叠新功能。" >&2
+  exit 1
+fi
+echo ""
+echo "基础验证通过。"
+echo ""
+
+echo "=============================================="
+echo " 启动命令"
+echo "=============================================="
+echo "\$ ${START_CMD}"
+echo ""
+echo "提示:开发服务器为 HTTPS(自签证书),浏览器首次访问会有证书告警,属正常现象。"
+echo "      聊天接口走同源代理 /chat-api,无需额外配置。"
+echo ""
+
+if [ "${RUN_START_COMMAND:-0}" = "1" ]; then
+  echo "RUN_START_COMMAND=1,正在启动……"
+  exec ${START_CMD}
+fi

+ 1069 - 0
harness/progress.md

@@ -0,0 +1,1069 @@
+# 进度日志
+
+> 通用的仓库内会话进度日志。文件名沿用课程历史约定,不绑定 Claude Code——
+> Codex、OpenHands 等 agent 同样可用,前提是仓库指令要求它在开工时读取、收尾时更新。
+> **agent 不会自动维护这个文件。**
+
+## 当前已验证状态
+
+- **仓库根目录**:`f:\yysk\AI_zhaoshang\zhaoshang-llm`
+- **标准启动路径**:`npm run dev` → https://localhost:8083(dev server 为 HTTPS,带自签证书告警属正常)
+- **标准验证路径**:`npm run build`(生产构建,必过);可选 `npm run test`(jest)
+- **当前最高优先级未完成功能**:`file-upload`(文件与图片上传)
+- **当前 blocker**:新接口 `/api/chat` 的请求体只接受 `{thread_id, question}`,多余字段返回 422,
+  因此旧协议的 `transmission.files / file_pos` 发不出去。上传功能如何随请求提交的后端契约未定,
+  需先确认方案(后端扩展请求体 / 把 OSS 地址拼进 question / 后端提供文件登记接口)。
+
+### 需要了解环境与约束
+
+**不在本文件重复**,见:
+
+| 想了解 | 看 |
+|---|---|
+| 架构、目录、核心系统、关键约束 | `docs/architecture.md` |
+| 接口契约与字段释义 | `docs/reference/`(先读该目录 README) |
+| 已知技术债与未决问题 | `docs/plans/tech-debt.md` |
+| 质量现状与缺口 | `docs/quality.md` |
+| 验证流程 | `sops/verification.md` |
+
+## 会话记录
+
+## Session 024
+
+- **日期**:2026-09-17
+- **本轮目标**:用户指示「按照参考资料执行」→ **把 harness 纳入版本控制**
+  (这是 Session 023 末尾我提出的那条张力:参考资料的原则是「计划、质量、技术债
+  和代码一起版本化」,而本仓库此前把 `harness/` 排除在 git 之外)
+- **已完成**:
+  - **`.gitignore` 去掉 `/harness`、`/CLAUDE.md`、`/agents.md` 三条排除**,
+    改为入库;并在原位置留了说明注释(为什么改、何时改)
+  - **仍然排除**(都是带凭据的本机文件,不能入库):
+    `.env.*.local`、`.claude/settings.local.json`
+  - **新增排除 `harness/tools/_*.mjs`**:那是 esbuild 从 `_entry-*.ts` 生成的
+    **构建产物**,入库会与源码漂移、让人误以为测的是新代码
+  - **修掉一个由此暴露的真问题**:`_policy-library-utils.mjs` 的**入口文件从来没被保留**
+    (Session 011 生成产物后丢了入口),产物一旦不入库,政策库验证脚本就没人能重跑。
+    已补 `_entry-policy-library.ts`,并**模拟 fresh clone**(删光三个产物 → 按 README
+    的命令重新生成 → 跑验证)确认全链路可用
+  - `harness/tools/README.md` 补上三个产物**逐一可抄的重新生成命令**,
+    并写明 `--define:import.meta.env='{}'` 这个坑(node 里没有 `import.meta.env`)
+  - 改掉三处已过时的「harness 不入库」说明:根 `CLAUDE.md` 的提交约定、
+    `harness/README.md` 的关系表
+- **入库前的凭据扫描**(推共享远端前必做):
+  - JWT 形态扫描:harness 内**只有 `legacy/API.md` 里两处带 `...` 的截断示例**,无真实凭据
+  - 关键词扫描(password/secret/api_key 等):零命中
+  - `git check-ignore` 逐项核对:`.env.development.local`(含 DMS token)**已排除** ✓
+  - 入库 35 个文件;内网地址(192.168.2.23、121.43.55.7 等)与仓库中已有的
+    `vite.config.ts`、`.env.*` 属同一类,不是新增暴露面
+- **运行过的验证**:
+  - **模拟 fresh clone 全链路**:删光 `harness/tools/_*.mjs` → 按 README 命令重新生成
+    三个产物 → 四个验证脚本全绿
+  - `verify-empty-content-agreement.mjs` **30/30**
+  - `verify-policy-library.mjs` **21/21**(用新入口重新生成的产物)
+  - `verify-dms-chat-storage.mjs` **36/36**(打真实 DMS)
+  - `check-policy-sources.mjs` 符合预期
+  - `node harness/tools/validate-harness.mjs` 通过
+  - `npm run build` 通过
+- **已记录证据**:本文件 Session 024;`.gitignore`;`harness/tools/README.md`;
+  根 `README.md` 的 20260917 节
+- **更新过的文件或工件**:`.gitignore`、`CLAUDE.md`、`harness/README.md`、
+  `harness/tools/README.md`、`harness/tools/_entry-policy-library.ts`(新增)、
+  根 `README.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **不可逆**:harness 的内部工作笔记(对后端问题的直白记录、踩坑过程、
+    需求取舍)从现在起对团队可见,且**已在 git 历史里**。此前若要撤回需重写历史
+  - ⚠️ 本次提交较大(35 个文件、含 900+ 行的 `progress.md`),是入库必然的
+  - ⚠️ 浏览器端到端仍未人工点过(沿用 Session 022/023 的遗留)
+- **下一步最佳动作**:浏览器实测;以及「harness 入库后」的一个新问题:
+  **`progress.md` 会持续变大**(已 1000+ 行),后续可考虑按会话分文件或定期归档,
+  但那是另一个改动,本轮不做
+
+## Session 023
+
+- **日期**:2026-09-17
+- **本轮目标**:用户提的三件事 —— ①会话消息的存储粒度 ②为什么默认登录 ③参考官方资料完善 harness
+- **已完成**:
+
+### ① 存储粒度改为「一个会话一行」(用户要求)
+
+用户原话:「每次一问一答就会在 DMS 存一条消息,我希望一条消息同时包含同一个会话窗口的所有消息」。
+**用户三个决策**:一行一个会话(1889);反馈存进该行 JSON 里对应那条消息;dev 自动登录去掉。
+
+- `chat-sessions-dms.ts`:
+  - **新增 `saveDmsTranscript()`** 取代 `upsertDmsRecord()` —— 幂等键从 `c_record_id` 改为
+    **`c_session_id`**,整段消息序列化成 `{version:1, messages:[…]}` 存进 `c_answer`,
+    `c_question` 存首问
+  - **写入方式改为「以本地 messages 为准整段重写」**,不做读-改-写 →
+    从根上消除了并发覆盖问题(本地状态就是权威)
+  - `fetchDmsSessionRecords()` 改成从该行 JSON 里**还原问答对**
+  - `writeDmsFeedback()` 改成「读整行 → 只改命中那条消息 → 写回」,与对话写入
+    **共用 `session:` 顺序链**;定位不到时**不新增行**(避免库里出现只有反馈没有内容的行)
+  - ⚠️ **不存消息级时间戳**:本地消息本就没有时间戳,整段重写时现造 `Date.now()`
+    会让所有消息的时间都变成最后一次保存的时刻 —— 假数据比没有更糟。顺序由数组顺序表达
+- `useBusinessAssistantChat.ts`:4 处调用点(sendMessage / totalResponse / close /
+  submitQuestionAnswers)统一换成 `saveSessionTranscriptToDms(session)`
+
+### ② 去掉开发环境硬编码账号的自动登录
+
+- 根因:`useEnterpriseAuth.ts` 里 `isDevLogin = MODE === 'development'` 时**无条件**用
+  硬编码的 devLogin token 登录 → 表现为「我没登录也进入登录态」
+- 已整段移除该分支;要企业态请显式带 `?access_token=` 或 `?credit_code=`。
+  开发环境默认即**访客态**,正好方便验证访客记录。
+- 影响:dev 下 `globalThis.token` 不再自动注入,依赖它的旧接口(企业信息等)
+  需要显式登录才可用 —— 本轮涉及的会话/历史/反馈已全部走 DMS(代理注入 token),不受影响
+
+### ③ harness 结构对齐官方资料
+
+参考:<https://walkinglabs.github.io/learn-harness-engineering/zh/resources/>
+(该站被网络策略挡了,改从 GitHub raw 取到正文与 OpenAI 高级包结构)
+
+- **`docs/plans/` → `docs/exec-plans/`**,`done/` → `completed/`,
+  `tech-debt.md` → `tech-debt-tracker.md`(对齐参考资料命名)
+- **新增硬规则并写进入口**:计划类文件**只能**写在 `harness/docs/exec-plans/active/`。
+  用户指出的问题属实:本轮的实施计划先前被写到了 Claude Code 自带的
+  `~/.claude/plans/`(**仓库外**),换个会话接手看不到。已把内容落进仓库
+  (`exec-plans/active/dms-chat-storage.md`),并在 `CLAUDE.md`、
+  `exec-plans/README.md`、`sops/session-end.md` 三处写明
+- **新增机械约束 `harness/tools/validate-harness.mjs`**:参考资料的「机械约束优先于
+  口头约定」。校验必备文件、**游离的计划文件**(仓库根/`src/` 下)、
+  `feature_list.json` 的假 passing / 重复 id / 多个 `in_progress`、
+  `progress.md` 会话编号重复。已实测它能报错(放一个 `plan-test.md` 后 exit=1)
+- `sops/session-start.md` 加「4b 校验结构」;`sops/session-end.md` 加两条收尾项
+- `harness/README.md` 结构图与「机械约束」一节更新
+- 更正 `reference/README.md` 里**已过期**的「DMS 五个栏目全空、别当在线数据源用」——
+  1887/1889 现在正是前端在用的在线数据源
+
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `node harness/tools/verify-dms-chat-storage.mjs <代理>` —— **36/36 通过**
+    (新增关键断言:*第二轮后仍是同一行*、*反馈只改命中那条消息*、
+    *另一条消息未被误改*、*定位不到时不新增行*)
+  - `node harness/tools/validate-harness.mjs` —— 通过;并**实测其能报错**(exit=1)
+  - DMS 残留复查:测试数据已清理(残留 0 行)
+- **已记录证据**:本文件 Session 023;`docs/exec-plans/active/dms-chat-storage.md`;
+  `harness/tools/validate-harness.mjs`;根 `README.md` 的 20260917 节
+- **更新过的文件或工件**:`src/network/api/dms/chat-sessions-dms.ts`、
+  `src/components/business-assistant/useBusinessAssistantChat.ts`、
+  `src/components/useEnterpriseAuth.ts`、`harness/docs/exec-plans/*`(重构)、
+  `harness/tools/{validate-harness.mjs,_entry-dms.ts,verify-dms-chat-storage.mjs}`、
+  `harness/README.md`、`harness/sops/{session-start,session-end}.md`、
+  `harness/docs/reference/README.md`、`harness/docs/quality.md`、`CLAUDE.md`、根 `README.md`
+- **已知风险或未解决问题**:
+  - ⚠️ **浏览器端到端仍未人工点过**(沿用 Session 022 的遗留)
+  - ⚠️ **反馈状态刷新后不恢复**:JSON 里存着,但 `BusinessRecord` 的 `localFeedback`
+    初始恒为 None,没做「从 DMS 读回反馈态」的回填。要做得另接一条线
+  - ⚠️ DMS token 今晚 22:19 过期;生产未接(nginx + 长效凭据待定)
+  - ⚠️ **harness 仍不入库**(`.gitignore` 有意排除,Session 009/013 用户确认过)。
+    参考资料的原则是「计划、质量、技术债和代码一起版本化」,本仓库与之**有张力**:
+    团队 clone 看不到 harness。若哪天想改,是一行 `.gitignore` 的事 —— **但那是用户的决定,没动**
+- **下一步最佳动作**:浏览器实测(发消息 → DMS 一行 → 刷新 → 列表/历史 → 点赞 → 改名/删除);
+  另需用户提供新 token(旧的今晚过期)
+
+## Session 022
+
+- **日期**:2026-09-17
+- **本轮目标**:会话/问答记录切换到 DMS(用户指令),反馈一并落地
+- **方案**:[`docs/plans/active/dms-api-replacement.md`](docs/plans/active/dms-api-replacement.md) 的后续落地;
+  本轮方案与用户拍板记录见本文件末尾「Step-0 结论」。
+  **用户拍板三条**:①反馈写入 DMS;②会话改名/删除一并切;③访客 id 复用埋点 visitorId
+  (`c_credit_code = 访客_<assistant_statistics_visitor_id>`)
+
+### Step-0 实测结论(DMS content 接口,此前全是「📄 未实测」)
+
+跑法:`node harness/tools/dms-step0-verify.mjs <TOKEN>`(14/17 通过)+ `dms-delete-probe.mjs`。
+**三个 FAIL 里一个是我判据写错,两个是真发现**:
+
+| # | 结论 | 影响 |
+|---|---|---|
+| 1 | **`addContent` 不需要 `c_id`**(自动填 0) | ✅ 最大阻塞风险解除(模型里 `c_id` 是 must=true,实测不强校验) |
+| 2 | **`addContent` 返回记录 uuid**,形态是**纯字符串**:`{"code":200,"content":"056dbae8-…"}` | ✅ 新增行后不必再反查一次;**不是** `content.id` 对象 |
+| 3 | **`c_created_at` 不自动填**,必须自己写 | ⚠️ 写入时要带时间戳 |
+| 4 | 时间戳格式:`"YYYY-MM-DD HH:mm:ss"` 与 ISO 串**都接受**;**读回是 epoch 毫秒(数字)** | 写入用前者;读取按毫秒处理 |
+| 5 | **删除不是 `delContentById`**(各种传法都 `code=-1`/POST 405) | ⚠️ 见 #6 |
+| 6 | **删除的正确姿势:`POST /content/updateAudit`,form `{columnId, id:<uuid>, state:4}`** | ✅ `state=4`=销毁,返回 200 且行从列表消失。**这条文档里完全没有,是本次试出来的** |
+| 7 | 长文本(1105 字含 `<scope>`/`POLICY_TABLE`/`<question-cards>` 标记)**逐字节往返一致** | ✅ 协议标记可原样存 DMS |
+| 8 | 中文+下划线的 `c_credit_code` 精确 search 命中 | ✅ 访客 `访客_<uuid>` 可查 |
+| 9 | 反馈字段(`c_feedback_status/option/remark/at`)经 `updateContent` 写入生效 | ✅ 反馈方案可行 |
+| 10 | `pageSize=200` 被接受;写后不带 `states` 也能查回 | — |
+
+> ⚠️ **测试数据已全部清理**(1887/1889 复查均 0 行)。清理过程中发现探测脚本把 1887 的
+> columnId 用到了 1889 的行上,导致一度残留 1 行,已手工补删。
+
+**记录**:本文件 Session 022;脚本 `harness/tools/dms-step0-verify.mjs`、`dms-delete-probe.mjs`
+
+### 实现(本轮改动)
+
+**新增**:
+- `src/network/api/dms/client.ts` —— DMS HTTP 层(token 由代理注入、form body、`code===200` 判定、202 当空、**任何失败都不抛异常**)
+- `src/network/api/dms/chat-sessions-dms.ts` —— 业务层:`upsertDmsSession` / `upsertDmsRecord` /
+  `fetchDmsSessions` / `fetchDmsSessionRecords` / `deleteDmsSession` / `writeDmsFeedback`,
+  含**顺序链去重**(同幂等键的写串行,避免「提交写 question」与「完成补 answer」并发各 add 一行)
+- `harness/tools/verify-dms-chat-storage.mjs` + `_entry-dms.ts`(验证脚本,26 项断言)
+
+**改**:
+- `src/network/api/chat-sessions.ts` —— 4 个导出换成 DMS 实现,**签名与返回类型不变**(调用方零改动);
+  `RemoteSessionRecord` 新增可选 `record_id`
+- `src/components/business-assistant/useBusinessAssistantChat.ts` —— 挂钩:
+  `sendMessage` 提交时 `upsertDmsSession` + `upsertDmsRecord(question)`;
+  `totalResponse` 补 `answer`;`close`(停止/断流)也补一次(否则中断的问答只有 question);
+  `submitQuestionAnswers` 补问路径同样写入(存**真实提交文本**,不存「已提交企业信息补充」占位);
+  历史回读用 `record_id` 回填 AI 消息 id → **从 DMS 读回的历史消息点赞仍命中原始行**;
+  `syncSessionTitleToServer` / `syncSessionDeleteToServer` **去掉登录 guard**(访客也写)
+- `src/components/Chat/BusinessRecord.vue` —— 反馈三函数换 `writeDmsFeedback`,移除 cardAPI 调用
+- `vite.config.ts` —— 改用 `loadEnv(mode, cwd, "")` + 新增 `/dms-api/` 代理
+  (rewrite 补回 `/dms` 前缀、**在代理侧注入 token**,浏览器产物里没有 token)
+- `src/utils/runtime-config.ts` —— 新增 `getDmsApiBaseUrl()`;`index.html` 注入 `globalThis.VITE_DMS_API`
+- `.env.development` —— `VITE_DMS_API="/dms-api"`、`VITE_DMS_TARGET`
+- `.env.development.local`(**不入库**,`.gitignore` 已覆盖 `*.local`)—— `DMS_TOKEN`
+
+### 运行过的验证
+- `npm run build` **通过**(改动后重跑)
+- **验证脚本 26/26 通过**(`harness/tools/verify-dms-chat-storage.mjs`,打的是**真实 DMS**,
+  经 vite 代理、与浏览器同一条路径):会话增/改不重复、问答 question→answer 补写不重复、
+  长文本含协议标记往返一致、反馈三种状态、删除连带清理、时间戳解析、访客归属
+- **代理链路实测**:经 `https://localhost:8084/dms-api/...` 查询 → `202`(token 已注入);
+  直连 DMS 不带 token → `208 无token` → 证明 token 确实只在代理侧
+- DMS 残留复查:1887 / 1889 均 `202 空`(测试数据已清理干净)
+
+### 🐛 验证脚本抓到的真 bug(已修)
+`findRowBy` 里写死了 `orderBy: [{field:'c_updated_at'}]`,但**栏目 1889 没有这个字段**:
+按不存在的字段排序 → DMS 报错(返回非标准响应)→ 反查恒空 →
+**每次 upsert 都判定「不存在」→ 新增**,同一问答被写成多行(实测第 4、5 步各多一行)。
+修法:`findRowBy` **不加 orderBy**(两栏目字段不同,只有 `c_created_at` 是共同的)。
+`fetchDmsSessions` 的 `c_updated_at` 排序保留 —— 那个字段在 1887 里**确实存在**。
+> 教训:跨栏目复用同一个查询函数时,**排序字段必须两个栏目都有**。
+
+### 已知风险或未解决问题
+- ⚠️ **未做浏览器实测**:写入挂钩挂在 `useBusinessAssistantChat`(PC/Mobile 共用),
+  逻辑与 DMS 契约均已用真实 DMS 验证,但「发一条消息 → 库里出现两行」这一步
+  仍需在浏览器点一遍确认。
+- ⚠️ **token 今晚 22:19 过期**:过期后写入静默降级(仅控制台 warn),
+  聊天与本地历史不受影响;需用户提供新 token(替换 `.env.development.local` 并重启 dev server)。
+- ⚠️ **生产未接**:`.env.production` 未配 `VITE_DMS_API`,需 nginx 做等价转发
+  (`location /dms-api/ { proxy_pass http://121.43.55.7:10081/dms/; proxy_set_header token "…"; }`),
+  且 DMS 的长效凭据方案待与 DMS 侧确认(现在给的是临时 token)。
+- ⚠️ **旧会话不在 DMS**:切换后远端会话列表为空(本地 localStorage 历史仍在);
+  F4 数据迁移未开始,本轮不做历史回填。
+- ⚠️ 访客**只写不读**:`fetchRemoteSessions` 对空 credit_code 直接返回 `[]`(保持原行为),
+  符合用户「访客也存」的要求;若将来要「访客也能看自己的历史」,需要另开一条读路径。
+
+### 下一步最佳动作
+浏览器实测:发一条消息 → 查 DMS 两栏目应各出现一行(`c_session_id`=localStorage
+`ba_current_session_id`);刷新后列表出现该会话;改名/删除/点赞各验一次。
+
+## Session 021
+
+- **日期**:2026-09-17
+- **本轮目标**:按用户要求,就「用 DMS 接口替换前端接口」出**方案**(用户先看方案再定是否可行)
+- **已完成**:
+  - ⚠️ **用户指出的流程问题:本轮我漏执行了 harness 的开工流程。** 具体漏项:
+    1. **没跑 `bash harness/init.sh`**(`sops/session-start.md` 第 4 步「跑基础验证」)——
+       事后补跑,**exit 0 通过**
+    2. **没读 `docs/architecture.md`** —— 根 `CLAUDE.md` 写明「第一次进这个仓库必读」,
+       而本会话是第一次进,我跳过了
+    3. **一处严重失误**:列 `harness/docs/reference/` 时,`ls` 的输出里**没有** DMS 三份文档,
+       我据此说「缺失」,随后 `find` 证明**它们存在**(mtime 9/16 17:17,不是新拷入的)。
+       原因不明(疑似输出被截断),但教训明确:**不要用单次 `ls` 的输出当「文件不存在」的证据**,
+       要用 `find` / `test -e` 复核
+  - **修好 DMS 三份文档里的断链**(用户要求:与实际不符就更新文件):
+    它们是从**另一个项目** `F:\yysk\AI_zhaoshang\DMS_Data_Migration\harness\docs\` 复制来的,
+    复制时没带同伴,导致 **7 个引用全部指向不存在的位置**。已逐一核实外部原件存在后,
+    把相对链接改为**绝对路径**,并在每份文档头部加了「引用的 artifacts / tools 不在本仓库」的说明:
+    `dms_openapi.json`(118KB)、`created_columns.json`、`dms_column_fields.json`、
+    `create_dms_columns.py`、`fix_field_alias.py`、`RUNBOOK.md`、`SKILL.md`、`apifox-dms.md` —— **8/8 均存在**
+  - **`reference/README.md` 索引表补上 DMS 三份文档**(原索引只列了 api-chat 系列,
+    而 DMS 文档在里面躺了一整天没人知道),并写明 DMS 栏目**当前全空**、别当在线数据源用
+  - **写了方案** `docs/plans/active/dms-api-replacement.md`(该目录此前一直空着):
+    - **能替换 3 条只读**:企业信息(栏目 1888)、会话列表(1887)、会话历史(1889)
+      —— 都是「精确查 + 分页」,与 `selectContentList` 语义吻合
+    - **能替换但要注意 3 条写**:会话改名、删会话、反馈(后两条 DMS 用**记录 uuid** 定位,
+      而前端用 session_id;`delContentById` 参数未文档化)
+    - **替换不了**:对话 SSE、政策列表(无栏目)、企业登录(DMS **依赖**它)、
+      OSS 上传(用途不同)、ASR、卡片媒体/历史
+    - **硬前置**:DMS 五栏目**现在全空**(`code=202`),数据迁移(`F4-*`)没做完不能切
+    - **需要用户拍板 4 条**(见方案第六节),其中最关键是:
+      **DMS 是给前端在线查询用的,还是只做数据底座?** 以及**前端直连还是后端转发**
+      (直连等于把数据管理 token 发给每个访客,我建议后端转发)
+  - **本轮为方案分析,未改任何代码**
+- **运行过的验证**:
+  - `bash harness/init.sh` 通过(**exit 0**)—— 事后补跑的开工验证
+  - 断开链接核验:`grep` 确认三份 DMS 文档已无残留的 `../artifacts` / `../tools` / `(RUNBOOK.md)` 相对引用
+  - 外部原件核验:8 个被引用文件**逐一 `test -e` 确认存在**(含带空格的 `2. DMS-Manage` 路径)
+  - 前端接口清单:逐个 grep 出调用方与字段消费面(见下)
+- **已记录证据**:本文件 Session 021;`docs/plans/active/dms-api-replacement.md`;
+  `docs/reference/README.md` 的 DMS 索引节
+- **提交记录**:**无** —— 本轮改动(`harness/` 下的文档与方案)按 `.gitignore` 约定**不入库**,
+  故无内容可提交。这是预期行为,不是漏提交
+- **更新过的文件或工件**:`docs/reference/DMS_API.md`、`docs/reference/DMS_COLUMNS.md`、
+  `docs/reference/DMS_MAPPING.md`、`docs/reference/README.md`、
+  `docs/plans/active/dms-api-replacement.md`(新增)、本文件
+- **本轮查证到的两个关键事实**(方案里用到,也值得单独记住):
+  1. **前端只用了 `EnterpriseInfo` 的 2 个字段**:`name`、`credit_code`
+     (`useEnterpriseAuth.ts:58-59`;另外 19 个字段在 `src/` 里**零引用**)
+     → 用 DMS 的企业信息替换**不需要字段映射层**,比预想的简单
+  2. **DMS 的「反馈」就在问答记录栏目里**:1889 的 13 个字段包含
+     `c_feedback_status` / `c_feedback_option` / `c_feedback_remark` / `c_feedback_at`
+     → 前端 `POST /chat/feedback` 理论上可由 1889 承担
+- **已知风险或未解决问题**:
+  - ⚠️ 方案**未获批准**,一行代码都没动;`feature_list.json` 也**未新增条目**
+    (避免在没有决定前把未定的事写成待办)
+  - ⚠️ DMS 的读接口(`selectContentList` 等)在文档里标的是 📄**未实测**,
+    本项目从未调用过 —— 方案第 0 步就是先小样本验证
+  - ⚠️ **`ls` 漏文件那件事没有查到根因**。若再遇到「列目录看不到文件」,先 `test -e` 复核再下结论
+- **下一步最佳动作**:等用户回答方案第六节的 4 个问题(最关键是「DMS 是底座还是在线数据源」);
+  确认后再拆成 `feature_list.json` 条目逐项做
+
+## Session 020
+
+- **日期**:2026-09-17
+- **本轮目标**:开发环境改用本地政策库 json(用户指示「先用本地的json」)
+- **已完成**:
+  - ✅ **顺手定位了 Session 019 遗留的问题 1(「政策名称」是纯文本)** ——
+    真因是**两个来源的字段不一致**,Session 019 记的两个猜测(陈旧 HMR 状态 / 走了兜底路径)
+    **都不对**:
+    | 来源 | 条数 | `data.市级政策id` |
+    |---|---|---|
+    | 本地 `public/merchant-agent/policies_public.v1.json` | 262 | **261/262 有** |
+    | 远端 `VITE_DOWNLOAD_URL` | 327 | **0/327 全无** |
+    - 远端那份没有 `市级政策id`;它有的 `policy_id` 是 **40 位、且与该条 `id` 相同**,
+      与 `市级政策id`(24 位,如 `676ea4ff61aee302a854ab72`)**是两套 id 体系**,拼不出一网通办的地址
+    - `index.html` 原本是**远端优先**,而远端**带 CORS 头**(实测 `Access-Control-Allow-Origin`
+      回显 `https://localhost:8083` 与 `https://aixq.shqp.gov.cn`)→ 浏览器里远端确实赢了
+      → `buildPolicyDetailUrl` 取不到 id → 返回空串 → 「政策名称」纯文本
+    - 可验证的预测:改之前浏览器里政策事项列表应是 **327 行**(不是 262)——Session 019 问过这个数
+  - **新增环境变量 `VITE_POLICY_LOCAL_FIRST`**:仅 `.env.development` 为 `true`,
+    `.env.production` / `.env.qingpu` / `.env.test` 显式写 `false`(四份都写,避免占位符不被替换)
+  - `index.html` 改为**按该开关排来源顺序**:dev 本地优先、远端兜底;生产仍远端优先、本地兜底。
+    错误日志按来源名打印(不再用 `isFallback` 布尔反推)
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 生产产物 `dist/index.html`:`var localFirst = "false" === "true"` → **生产行为未变**
+  - dev server:新起的 8084 **与用户已在跑的 8083** 都是 `"true" === "true"`
+    (Vite 检测到 `.env` 变更会自行重启,故 8083 也已生效,无需手动重启)
+  - dev server 提供的 `/merchant-agent/policies_public.v1.json` 与仓库本地文件
+    **sha256 一致**(`1a5127c0…`),即 262 条、261 条带 id
+  - `harness/tools/verify-policy-library.mjs` **21/21 通过**
+  - 新增 `harness/tools/check-policy-sources.mjs`:对比两个来源,输出
+    `本地 262 / 261 带 id`、`远端 327 / 0 带 id`,退出码 0(与预期一致)
+- **已记录证据**:本文件 Session 020;`harness/tools/check-policy-sources.mjs`;
+  `feature_list.json` 的 `policy-library-back-entry`;根 `README.md` 的 20260917 节
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`.env.development`、`.env.production`、`.env.qingpu`、`.env.test`、
+  `index.html`、`harness/tools/check-policy-sources.mjs`(新增)、根 `README.md`、
+  `harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **dev 用的这份比远端少 65 条申报事项**(262 vs 327;仅远端有的政策名 7 个、
+    仅本地有的 4 个)——这是「先用本地」的代价,生产不受影响
+  - ⚠️ **生产环境「政策名称」仍不可点**(远端那份没有 id)。这是**后端数据问题**,
+    不是前端能修的:需后端在远端补 `data.市级政策id`(或让远端也带 id)。
+    开关的**移除条件**已写进 `check-policy-sources.mjs` 的输出——
+    远端哪天有 id 了,脚本会提醒可以去掉这个开关
+  - ⚠️ 两个来源的条数/内容差异不止 id 一个字段,dev 与生产的库数据本来就不同版本,
+    排查政策库问题时**先确认当前加载的是哪一份**(控制台会打「加载完成(local/remote):N 条」)
+- **下一步最佳动作**:浏览器**硬刷新**后从政策事项列表点进详情,确认「政策名称」是可点链接;
+  列表行数应为 **262**(若仍是 327,说明没走本地那份,需看控制台那行日志)
+
+## Session 019
+
+- **日期**:2026-09-17
+- **本轮目标**:修用户报告的两个问题
+- **已完成**:
+  - ✅ **思考中卡片的阶段文案突然换行** —— 机制已定位:
+    `ScopeContent` 按**卡片宽度**切样式(`isMaxWidthReached = cardWidth >= parentWidth - ε`):
+    没撑满容器时标题 `nowrap` + 省略号,撑满时整块切成 `pre-wrap`。
+    原协议标题是固定的短句"正在思考中...",永不撑满、从不翻转;
+    新协议我把整条阶段文案放进了标题(长得多),撑满即翻 → 突然换行、卡片高度跳动。
+    改法:把原本**同时管标题与正文**的那条规则拆开 —— 正文保持 `pre-wrap`(它是为多行设计的),
+    标题恒定 `nowrap` + 省略号。用户选的就是「一律不换行」。
+  - ⚠️ **政策事项列表点进详情时「政策名称」是纯文本** —— **未定位到原因,需用户配合**。
+    已排除的:
+    - 源码 / dev server / 打包产物**三处都确认含** `buildPolicyDetailUrl`,不是没编译进去
+    - `apply_link` 的编译结果正确:`item.apply_link || item['政策原文地址'] || item.data['政策原文地址'] || buildPolicyDetailUrl(item) || ""`
+    - 模板分支正确(`v-if="selectedPolicyItem?.apply_link"` → `<a>`,否则纯文本)
+    - `.policy-link` 样式正确(紫色 + 下划线 + 手型)
+    - 元信息容器没有 `pointer-events` 阻挡
+    - `buildPolicyDetailUrl` 对真实库数据覆盖 261/262(脚本断言过)
+    **剩下的两个可能**:
+    ① 页面/内存状态陈旧 —— HMR 更新了组件代码,但已构建好的 `policyListItems`
+       (旧 `apply_link` 为空)仍在内存里;**硬刷新或关掉列表重新进**才会用新逻辑重建
+    ② 当时列表其实走的是**兜底路径**(`globalThis.policiesPublic` 未加载完 → 退回落卡片数据),
+       点的是卡片而非库条目 —— `openDetailByItem` 会匹配到它自己那张卡(`index >= 0`),
+       用的是**卡片的下发地址**;若该来源没有 url,`apply_link` 就是空 → 纯文本。
+       这与「库路径」是两套地址来源(见 Session 018 记录的风险项)
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 样式核对:`nowrap` 分支在 `:not(.is-max-width-reached)` 与 `.is-max-width-reached` 下各有一份,
+    正文的 `pre-wrap` 未被误改
+  - 新鲜度核对:dev server 与 dist 产物均含新代码
+- **已记录证据**:本文件 Session 019;根 `README.md` 的 20260917 节
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`src/components/Chat/ScopeContent.vue`、根 `README.md`、本文件
+  (`feature_list.json` 未动:本轮是样式修正,无新增能力)
+- **已知风险或未解决问题**:
+  - ⚠️ **问题 1 未解决**(详见上)。需要用户:硬刷新后再看;若仍是纯文本,
+    告知**列表有多少行**(262 行 = 库列表;几行 = 兜底走了卡片数据)
+  - ⚠️ 改的是 `ScopeContent`(共享组件)的样式。理由是用户明确要求「一律不换行」,
+    且该组件当前只有新协议在用(旧协议未使用)
+- **下一步最佳动作**:等用户反馈问题 1 的进一步信息
+
+## Session 018
+
+- **日期**:2026-09-17
+- **本轮目标**:政策事项列表里「政策名称」支持点击跳转
+- **已完成**:
+  - 用户给出地址规则:`https://zwdt.sh.gov.cn/qykj/shell_oc_policy_zq/policy/policy-detail?id=市级政策id`
+  - **数据核实**:`data.市级政策id` 有 **261 / 262** 条(40 个不同 id),
+    只有 1 条缺 —— 覆盖率远好于之前考察的两个来源:
+    - `apply_link` 字段:262 条**全为空**
+    - `资源申请备注` 里的 URL:只有 46 条有,且**25 条指向内网登录页**
+      (`http://10.235.238.34:7202/imanage/login`,用户打不开),
+      可用外网地址仅 21 条;且同名政策下的 URL 是按申报事项分别对应的,
+      **不能互相借**(借了会指向别的申报事项的页面)
+  - 新增 `buildPolicyDetailUrl()` 到 `src/components/Chat/policy-library-utils.ts`
+    (纯逻辑,可被验证脚本引用);**没有 id 时返回空串——不猜**,
+    宁可没有链接也不给打不开的地址
+  - 接入 `normalizePolicy` 的 `apply_link` 兜底链:卡片的 `apply_link` 优先,
+    库条目的 `市级政策id` 兜底
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `harness/tools/verify-policy-library.mjs` 扩到 **21 项断言,全通过**:
+    新增 7 项地址拼接用例(有 id / 无 id / data 缺失 / null / 空白 id / 去空格 / 特殊字符编码)
+    + 真实库覆盖率断言(261/262)
+- **已记录证据**:本文件 Session 018;`harness/tools/verify-policy-library.mjs`;
+  `feature_list.json` 的 `policy-library-back-entry`;根 `README.md` 的 20260917 节
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`src/components/Chat/policy-library-utils.ts`、
+  `src/components/Chat/PolicyMatch.vue`、`harness/tools/verify-policy-library.mjs`、
+  根 `README.md`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **未实测**:需浏览器确认从列表点进详情后「政策名称」确实是可点链接、且跳转地址正确
+  - ⚠️ 只有 1 条(《上海市青浦区加强知识产权保护…》下的
+    「支持知识产权托管 -获批上海市知识产权托管项目」)没有 `市级政策id`,
+    该条不显示链接
+  - ⚠️ 卡片路径的 `apply_link` 仍来自后端下发的 source url,与库路径的拼接地址
+    是两套来源 —— 若后端统一提供,可简化为一套
+- **下一步最佳动作**:浏览器实测跳转;`tech-debt.md` #1(上游恢复后补测选中流程)
+
+## Session 017
+
+- **日期**:2026-09-17
+- **本轮目标**:删除详情面板的「原文引用」板块(用户要求)
+- **已完成**:
+  - 注释掉模板中的「原文引用」板块(`PolicyMatch.vue` 第 406-418 行,用 `<!-- -->` 整块包住)
+  - 注释掉随之无用的 `selectedCitations` 计算属性
+  - **按项目约定用注释而非直接删除**(第一条指令就是「原代码注释掉,不要直接删除」),
+    并加了 `[已移除]` 标记说明原因
+  - 检查过没有孤儿 import:`resolveFieldCitations` 本来就没被 PolicyMatch 引入,
+    `selectedCitations` 也只被这个板块使用
+  - 详情面板现在是**两个**区块:条件核验、匹配依据
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 读回源码确认第 406-418 行确实被 `<!-- -->` 包住(grep 分不清注释内外,所以直接看了原文)
+- **已记录证据**:本文件 Session 017;`feature_list.json` 的 `policy-detail-blocks`(已改为两块);
+  根 `README.md` 的 20260917 节
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`src/components/Chat/PolicyMatch.vue`、根 `README.md`、
+  `harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **未实测**:需浏览器确认详情面板底部只剩两个区块
+  - ⚠️ **链路上的两个未决项仍在**(与本轮无关):
+    ①`tech-debt.md` #1 —— 公司候选提交文本是否会被当作 refine 而循环,从未验证
+    (上游持续 provider_failure,`status: found` 一次都没出现过)
+    ②政策事项列表的「政策名称」跳转 —— 库里 262 条 `apply_link` 全空,
+    仅 21 条能从 `资源申请备注` 取到可用外网地址(另有 25 条指向内网登录页);
+    已向用户说明,等其定方向
+- **下一步最佳动作**:浏览器实测本轮改动;定政策名称跳转的方案
+
+## Session 016
+
+- **日期**:2026-09-17
+- **本轮目标**:修「切会话就显示请求已取消」(用户报告的 bug)
+- **已完成**:
+  - **根因定位**(用户先要我分析、不要改代码):
+    两处「内容是否为空」的判空**口径不一致**——
+    `normalizeSessionHistory` 用 `content.trim()`(`<scope>` 进度块**算**有内容),
+    `isInterruptedEmptyScopeMessage` 用 `stripScopeBlocks()`(进度块**不算**内容)。
+    同一段内容一处认为「有」、一处认为「没有」。
+    新协议的正文要等 `done` 才写入,处理期间内容**只有进度块**,
+    把这个缝隙从偶发放大成「一切会话就出现」。
+  - **判定责任方:前端**(用户问「该前端还是后端改」)。三条理由:
+    ①两处判空都在前端;②`<scope>` 标记是适配层发明的、后端看不见;
+    ③后端能做的是改成流式(让回答更早出现),那是体验优化、是掩盖不是修复。
+  - **修法(用户选定「统一判空口径」)**:抽出**唯一**的判空函数
+    `hasVisibleMessageContent()`(`src/utils/interrupted-message.ts`),
+    归一化与渲染层都改用它(渲染层顺带把重复的两处内联 strip 也收敛了)。
+  - **新增回归脚本 `harness/tools/verify-empty-content-agreement.mjs`**:
+    这个 bug 的本质是「两处不一致」,所以断言的不变量就是
+    **两处必须一致**(9 例基准 + 18 例一致性 + 3 例回归 = 30 项)。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 验证脚本 **30/30 通过**;修复前「只有进度块」那一行会是
+    `渲染=true / 归一化=false`(即不一致),现在两处一致
+  - 覆盖用例含边界:空串、空白、1 个进度块、多个进度块、只有 silence、
+    进度块+正文、正文+进度块、只有 POLICY_TABLE 标记
+- **已记录证据**:本文件 Session 016;`feature_list.json` 的 `empty-content-agreement`;
+  `harness/tools/verify-empty-content-agreement.mjs`;根 `README.md` 的 20260917 节
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`src/utils/interrupted-message.ts`、
+  `src/components/business-assistant/shared.ts`、
+  `harness/tools/verify-empty-content-agreement.mjs`(新增,含 esbuild 入口 `_entry-empty-content.ts`)、
+  根 `README.md`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **观感变化需确认**:原本显示「请求已取消」的场景,现在可能显示
+    「会话已经取消」(CANCELLED_SESSION_TEXT)。这是代码库里「消息结束但无内容」的
+    既有措辞,且只在会话**未在生成中**时触发(switchSession 对正在生成的会话会跳过归一化)。
+    若该措辞也不合适,需另定文案或改为不落文案。
+  - ⚠️ 本轮我一度把 README 的新日期节插到了旧日期**前面**,违反用户要求的时间正序,
+    已自行发现并修正(顺序:20260914 → 20260915 → 20260916 → 20260917)。
+- **下一步最佳动作**:浏览器实测切会话是否还会出现该提示
+
+## Session 015
+
+- **日期**:2026-09-16
+- **本轮目标**:加快打字速度(用户反馈"偏慢",要求"参考市面上主流 AI 打字速度")
+- **已完成**:
+  - **先查主流实测数据再动手**,不凭感觉调:
+    Claude 3.5 约 **59.8 字符/秒**、DeepSeek-R1 约 **75.5**、GPT-5.5 约 62 token/秒;
+    主流区间 **50~120 字符/秒**(快速模型取上沿)。
+  - **原值 2 字/33ms = 61 字符/秒**——数值上正好等于 Claude 3.5,并不算慢。
+    但用户仍感觉慢,**原因已定位**:主流产品是模型边生成边流出,用户感知的等待约等于
+    生成时间;而**本项目后端一次返回整段**(适配层在 done 时一次性输出),
+    打字机是**纯额外延迟**——同样 60 字符/秒会比主流慢一整段。
+  - 因此取主流区间**上沿**:`typingCharsPerTick` 2 → **4**(≈121 字符/秒),
+    积压加速档位同步加倍(8/12/16/20/24)。
+  - ⚠️ **未按字面设成 10**:10 字/33ms ≈ 303 字符/秒,**远超主流区间**(最快约 120)。
+    已将依据写进代码注释,并告知用户"要更快说一声"。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 速度换算核对:改前 61、改后 121 字符/秒;各档位 242~727 字符/秒
+- **已记录证据**:本文件 Session 015;根 `README.md` 的 20260916 节;
+  代码注释中写明基准来源与取值理由
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`src/components/Chat/TextContent.vue`、根 `README.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **仍待浏览器实测**:121 字符/秒的观感是否合适。数值对齐了主流,但"快慢"终归是主观的,
+    用户若仍觉得慢或反过来觉得太跳,都只需改 `typingCharsPerTick` 一个数。
+  - ⚠️ 影响打字速度的其实有两处(`TextContent` 的逐字机 + `BusinessRecord` 的 `gapTime = 30`),
+    本轮只动了前者。若实测仍偏慢,下一处该看 `BusinessRecord.vue` 的 `gapTime`。
+- **下一步最佳动作**:浏览器实测打字速度观感
+
+## Session 014
+
+- **日期**:2026-09-16
+- **本轮目标**:政策事项列表进入时重置筛选(用户要求)
+- **已完成**:
+  - `openPolicyList()` 里**每次进入都重置筛选**:页码归 1、清空关键词、
+    部门回"全部部门"、分类回"所有分类"。
+  - 这是 Session 012 那个"列表只剩一条"的**根治**:此前筛选跨入口保留,
+    而库里部门是简称、卡片是全角写法的差异会让筛选几乎筛不出东西。
+    上一轮统一了数据源(两边一致),这一轮把残留也消掉了。
+  - 副作用说明:重置会连带触发 `watch([currentFilterOption, currentDeptFilter, searchKeyword])`
+    与 `watch(currentPage)`,导致列表被**重复构建**一次——两次结果相同,可接受,未做去抖。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 代码核对:`openPolicyList` 中四个重置项齐备;两个 entry(模板第 29 行的卡片「更多」、
+    第 220 行的详情「返回」)都走它
+- **已记录证据**:本文件 Session 014;`feature_list.json` 的 `policy-library-back-entry`;
+  根 `README.md` 的 20260916 节
+- **提交记录**:见下条提交(本轮改动含入库文件,已按约定提交推送)
+- **更新过的文件或工件**:`src/components/Chat/PolicyMatch.vue`、根 `README.md`、
+  `harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ 仍待浏览器实测:进入列表是否 262 条、匹配项是否置顶、筛选是否已清空
+- **下一步最佳动作**:浏览器实测;`tech-debt.md` #1(上游恢复后补测选中流程)
+
+## Session 013
+
+- **日期**:2026-09-16
+- **本轮目标**:改提交约定(用户要求"每次修改完都要提交")
+- **已完成**:
+  - **约定变更**:此前是"由用户自行提交"(见 Session 008 计划阶段用户的明确要求),
+    现改为**每次改动完成即提交并推送到 origin**。
+    - 根 `CLAUDE.md` 新增「提交约定」一节:最低要求(`npm run build` 通过 +
+      已更新进度日志与功能清单)、提交信息写法(中文,说清改了什么为什么,
+      结尾带 `Co-Authored-By`)、命令示例;并注明 `harness/`、`CLAUDE.md`、`agents.md`
+      不入库是预期行为
+    - `sops/session-end.md` 的逐项检查**首项**改为"已提交并推送"
+    - `sops/handoff.md` 的常用命令补上提交推送
+  - 按新约定提交并推送了上一轮的改动(`103e53b`)
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `git push` 成功;`git log` 确认提交已上远程
+- **已记录证据**:提交 `103e53b`;本文件 Session 013
+- **提交记录**:`103e53b` 已推送 origin/main(本条记录本身因 `harness/` 不入库,不进提交)
+- **更新过的文件或工件**:根 `CLAUDE.md`、`harness/sops/session-end.md`、
+  `harness/sops/handoff.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **提交约定与"记录不入库"存在天然缝隙**:`harness/` 与 `CLAUDE.md` 被 `.gitignore` 排除,
+    所以"每次改动都提交"实际上只覆盖入库文件;状态记录与工作规则**不会**进提交。
+    这是用户有意的安排(Session 009 确认过),不是疏漏。
+  - ⚠️ 仍待浏览器实测:政策事项列表两个入口是否都出 262 条、匹配项是否置顶(Session 012)
+- **下一步最佳动作**:浏览器实测政策事项列表;`tech-debt.md` #1(上游恢复后补测选中流程)
+
+## Session 012
+
+- **日期**:2026-09-16
+- **本轮目标**:修「政策事项列表」入口行为;按钮改名
+- **已完成**:
+  - **按钮改名**:详情面板的「返回政策列表」→「**返回政策事项列表**」
+  - **统一列表入口**(用户明确要求:"政策事项列表不论从哪进,点更多进和点返回政策事项列表进,
+    **效果是一样的**")。上一轮我做成了两套数据源(`listSource: cards | library`:
+    卡片「更多」看卡片数据、详情「返回」看惠企政策库),这是**理解错了**——
+    用户要的是一个列表、两个入口。
+    - 两个入口合并为 `openPolicyList()`,模板两处按钮都指向它
+    - 删掉 `listSource`
+    - 列表统一来自惠企政策库;**库未加载完时退回落卡片数据**,避免空白
+  - **用户反馈的现象**:"点更多进去只看到一条,且那一条正是我刚看过的那条"。
+    成因已定位:**筛选状态在两个入口间共享**(部门/分类/关键词),而两套数据源的
+    部门写法不同(库里是简称"区科委",卡片是全称"青浦区科学技术委员会"),
+    筛选在卡片数据上几乎筛不出东西。统一数据源后两边行为一致,该偏差消失。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `harness/tools/verify-policy-library.mjs` 13/13 通过(判定与置顶逻辑未受影响)
+  - 残留检查:`listSource` / `openLibraryList` / `openList` 均无残留
+  - 接线检查:模板两处按钮(第 29 行卡片「更多」、第 220 行详情「返回」)都指向 `openPolicyList`
+  - 筛选改动来源检查:`searchKeyword` / `currentDeptFilter` / `currentFilterOption`
+    只由用户点击触发,无程序写入
+- **已记录证据**:本文件 Session 012;根 `README.md` 的 20260916 节已同步更正
+- **提交记录**:无(由用户处理)
+- **更新过的文件或工件**:`src/components/Chat/PolicyMatch.vue`、根 `README.md`、
+  `harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **仍需浏览器实测**:统一后点「更多」应看到 262 条的库列表、匹配项在最前
+  - ⚠️ **筛选状态跨入口保留**(用户未要求重置):若用户曾在列表里选过部门/填过关键词,
+    之后再从卡片「更多」进来,仍会带着该筛选 —— 现在两个入口一致,但可能仍显得"条目少"。
+    若实际体验不佳,可加"打开列表时重置筛选"。
+  - ⚠️ 库未加载完时退回落卡片数据,这是**临时的兜底**,不是设计意图
+- **下一步最佳动作**:浏览器实测两个入口;`tech-debt.md` #1(上游恢复后补测选中流程)
+
+## Session 011
+
+- **日期**:2026-09-16
+- **本轮目标**:详情面板的「返回政策列表」按惠企政策区分
+- **已完成**:
+  - **明确"惠企政策"的定义**:= `policies_public.v1.json` 里的政策
+    (实测 262 条申报事项 / **38 个政策**,由 index.html 预加载到 `globalThis.policiesPublic`)。
+    **并验证了它与后端字段同源**:后端 `is_policy_library=true` 的那条标题,
+    在库中能匹配到(该政策名下有 44 条申报事项)。
+  - **判定采用"两者结合"**:优先用接口字段 `is_policy_library`(精确、无需匹配),
+    缺失时回退到"标题能在库里找到"。标题匹配做了归一化——
+    库里带《》、卡片标题不带,另有全半角括号、连接符、空白差异。
+  - **详情面板**:顶部「返回政策列表」改为 `v-if="selectedIsPolicyLibrary"`,
+    非惠企政策不显示该入口。
+  - **列表新增数据源维度** `listSource`(`cards` | `library`):
+    卡片区「更多 >>」→ `cards`(维持卡片数据,不动);
+    惠企政策详情「返回」→ `library`(惠企政策库,**本轮匹配到的惠企政策置顶**)。
+  - 库列表**复用既有列表模板**即可渲染:`normalizePolicy` 能吃
+    `name`/`department`/`declaration_item`,且 `auto_granted` 会自动出「免申即享」标签。
+    顺带说明:**分类筛选(免申即享)在这个数据源上是真正可用的**——
+    它就是为这个库存设计的字段(此前在卡片数据上必然失效)。
+  - **纯逻辑抽到 `src/components/Chat/policy-library-utils.ts`**,
+    让验证脚本引用**同一份实现**而非复制——复制会漂移,测的就不是真代码。
+  - 🐛 **修掉自己写出的一个缓存 bug(重要教训)**:一开始把库数据写成
+    `computed(() => globalThis.policiesPublic)`。但 `globalThis` 的属性**不是响应式依赖**,
+    computed 会把首次读到的值缓存住 —— 而 `policiesPublic` 是 index.html **异步 fetch**
+    加载的(且应用**不等待**它,`dataLoaded` 事件与它无关、src 里无人监听),
+    首次读到很可能是空数组,加载完成后也不会更新。
+    改为 **函数 `getLibraryPolicies()` / `getLibraryNameSet()`,每次调用现读**。
+- **运行过的验证**:
+  - `npm run build` 通过;`policy-library-utils.ts` 无新增类型错误
+  - **新建 `harness/tools/verify-policy-library.mjs`(13 项断言,全通过)**:
+    名称归一化、《》差异、判定正例与负例、命中收集、置顶排序(含"无命中时顺序不变")
+  - 真实库数据抽样:库里前 3 条政策名均能被判定为惠企政策
+- **已记录证据**:本文件 Session 011;`harness/tools/verify-policy-library.mjs`;
+  `feature_list.json` 的 `policy-library-back-entry`
+- **提交记录**:无(本轮改动均入库文件,提交由用户处理)
+- **更新过的文件或工件**:
+  `src/components/Chat/PolicyMatch.vue`、`src/components/Chat/policy-library-utils.ts`(新增)、
+  `harness/tools/verify-policy-library.mjs`(新增)、
+  `harness/tools/_policy-library-utils.mjs`(esbuild 产物,供脚本 import)、
+  `harness/feature_list.json`、根 `README.md`
+- **已知风险或未解决问题**:
+  - ⚠️ **未在浏览器实测**:判定与排序逻辑有脚本断言、构建通过,但"点开非惠企政策卡片时
+    按钮确实消失""点返回后列表确实是 262 条且匹配项在最前"仍需人工看一眼。
+  - ⚠️ **残留风险:库列表为空**。`policiesPublic` 异步加载且应用不等待它。
+    已修掉 computed 缓存问题(现读),但如果用户在下载完成前(约一秒内)就点开
+    惠企政策详情并点「返回」,列表会是空的。实测时请留意这一点;
+    若实际会发生,可加一个"库未就绪时短暂重试"的兜底。
+  - `harness/tools/_policy-library-utils.mjs` 是 esbuild 产物,**改了源文件要重新打包**
+    才能在脚本里生效(脚本顶部已注明)。
+- **下一步最佳动作**:浏览器实测上述两点;`tech-debt.md` #1(上游恢复后补测选中流程)
+
+## Session 010
+
+- **日期**:2026-09-16
+- **本轮目标**:重构 harness 结构(用户反馈"现在的 harness 不是很好",要求结构更标准清晰、
+  docs 收进 harness、新建 tools 目录,参考资源库继续完善)
+- **已完成**:
+  - **结构调整**(参考资源库的 OpenAI 高级骨架 + "短入口,深链接"原则):
+    ```
+    harness/
+    ├── README.md          结构导航(入口)
+    ├── progress.md        ★ 进度日志(原 claude-progress.md 更名)
+    ├── feature_list.json  ★ 功能清单
+    ├── init.sh
+    ├── tools/             ← 新增:临时脚本(以前写在项目根目录、用完就删)
+    ├── docs/              ← 原仓库根的 docs/ 整体迁入
+    │   ├── architecture.md   ← 新增:从 CLAUDE.md 拆出的架构深内容
+    │   ├── quality.md        ← 新增:质量评分(按领域 + 架构层)
+    │   ├── plans/            ← 新增:执行计划(active / done / tech-debt)
+    │   └── reference/        ← 接口参考(原样迁入,4 份)
+    └── sops/              ← 流程(原散落的三个文件归入并补两份)
+        ├── session-start.md   ← 新增:开工流程
+        ├── session-end.md     ← 原 clean-state-checklist.md
+        ├── handoff.md         ← 原 session-handoff.md
+        ├── verification.md    ← 新增:验证流程
+        └── evaluator-rubric.md← 原文件迁入
+    ```
+  - **根 `CLAUDE.md` 瘦身为简短入口**:只留开工步骤、规则、完成门槛、速览与索引表;
+    架构细节整体移入 `harness/docs/architecture.md`
+  - **把已知未决项沉淀为 `docs/plans/tech-debt.md`**(5 条):
+    ①公司候选提交文本 vs 序号(可能死循环,**从未验证**)②answer/summary 首句重复
+    ③文件上传待定 ④部门筛选在新数据下筛不出(**刻意保持,勿当 bug 修**)
+    ⑤`Question.message` 字段定义了却没渲染
+  - **`tools/README.md`** 写清了为什么要有这个目录:以前脚本写在项目根、用完即删,
+    导致根目录被污染、好用的脚本丢失、当时的验证方式无迹可查
+- **运行过的验证**:
+  - `npm run build` 通过(结构改动只涉及文档与目录)
+  - 路径一致性排查:更新了 4 处指向旧路径的引用
+    (tech-debt / quality / sops/verification / tools);
+    `progress.md` 里 **Session 000–009 的历史记录保留旧路径不改**——那是当时的事实
+- **已记录证据**:本文件 Session 010;`harness/README.md` 结构表
+- **提交记录**:无(`harness/` 与 `CLAUDE.md` 均不入库)
+- **更新过的文件或工件**:整个 `harness/` 目录重构;根 `CLAUDE.md` 重写;
+  原根 `docs/` 迁入 `harness/docs/`
+- **已知风险或未解决问题**:
+  - ⚠️ **上一轮的教训要盯住**:上次建了 7 个文件,其中 4 个建完就没再用过。
+    这次新增了 `quality.md`、`plans/`、`sops/session-start.md`、`sops/verification.md`,
+    **同样有过期无人用的风险**。判断它们是否有用的标准:下一轮开工时是否真的照着走了。
+    若某个文件连续几轮没被读过,就该删掉或合并——**harness 简化是常规工作**。
+  - `harness/docs/` 与 `harness/` 均不入库(用户要求),团队 clone 看不到;
+    入库的改动记录在根 `README.md`
+- **下一步最佳动作**:等上游公司数据服务恢复后,补测 `tech-debt.md` #1(有候选时的选中流程)
+
+## Session 009
+
+- **日期**:2026-09-16
+- **本轮目标**:调整入库范围与 README 形态;清除项目内的飞书内容
+- **已完成**:
+  - **发现并纠正一个静默失误**:首次提交时 `docs/` 与 `harness/` 未入库。
+    原因是项目**原有**的 `.gitignore` 第 4、5 行就是 `/docs` 与 `/harness`——
+    我创建这两个目录时被静默排除,而我在提交信息里却写了"含 docs/reference 与 harness",
+    属表述失实。经用户确认**这两处是用户有意排除的,无需上传**。
+    教训:新建目录后应 `git check-ignore` 确认是否被忽略,不要假定 `git add -A` 会带上。
+  - `CLAUDE.md` 与 `agents.md` 按用户要求改为**不入库**(加入 `.gitignore`,
+    `git rm --cached`,磁盘文件保留)
+  - **README 重写**:按用户给的参考格式,只保留「按日期的改动记录」
+    (20260914/15/16 三节)。此前的项目概述、技术栈、目录结构、接口对接说明、
+    参考规范等**全部撤下**——用户明确要求项目结束再总结
+  - **清除项目内全部飞书内容**:README 链接 + `src/styles/common/define.less` 与
+    `rule.less` 头部指向飞书设计规则的外链(保留文件用途说明,仅去外链)
+- **运行过的验证**:
+  - `grep -riE "飞书|feishu|lark|awbm"` 在工作区与 git 跟踪文件中**零命中**
+  - `npm run build` 通过(改动了 .less 文件,确认不影响构建)
+  - `git status` 干净,已推送 origin/main
+- **已记录证据**:提交 `d8178ca`(去跟踪)、`a41eec8`(README CHANGELOG)、
+  `14bab9f`(README 精简 + 去飞书)
+- **提交记录**:上述三个提交均已推送 origin/main
+- **更新过的文件或工件**:`.gitignore`、`README.md`、`src/styles/common/define.less`、
+  `src/styles/common/rule.less`、本文件
+- **已知风险或未解决问题**:
+  - 飞书链接**仍在 git 历史里**(`08524fb` 初始提交、`a41eec8`)。用户确认**留着无所谓**,
+    故不做历史重写
+  - 两个功能性问题仍挂着:①"选中候选后提交文本是否被当作 refine 而循环"从未验证
+    (上游持续 provider_failure,`status: found` 一次都没出现过);
+    ②`answer.text` 与 `summary.text` 首句重复(已确认是后端内容重复,非前端渲染)
+- **下一步最佳动作**:等上游恢复后补测有候选的选中流程;确认 summary 重复由后端修还是前端去重
+
+## Session 008
+
+- **日期**:2026-09-16
+- **本轮目标**:写 README 项目概述、首次推送远程、修正 CLAUDE.md 的失实描述
+- **已完成**:
+  - **README 重写为项目概述**:业务能力、技术栈、对话接口的两条关键约束
+    (请求体只收两个字段 / 前端经适配层不直接渲染新协议)、命令与环境变量、
+    目录结构、harness 入口,以及用户指定的**后续改动参考规范**
+    (「华新镇产权单位和企业信息采集小程序」20260909 的升级清单全文)
+  - **首次提交并推送到** `http://47.103.92.60:3003/skyversation/zhaoshang_client_ui.git`
+    (远程原为空仓库)。107 个文件、64595 行。
+    按用户确认排除 `.claude/settings.local.json`(本机权限配置)与 `stats.html`
+    (965KB 构建产物),两者已加入 `.gitignore`。
+  - **修正 CLAUDE.md 的失实描述**(用户批准后执行,逐条核实过):
+    端口 8082→8083;删除"Vuex 4"(项目无状态管理库);删除不存在的
+    `stream-message-coordinator-v2.ts` 与 `src/hooks/`;修正 `src/network/api/` 清单;
+    修正"3D avatar rendering"(three 仅用于粒子背景);修正主壳描述(BusinessAssistant.vue
+    只是 PC/移动端切换);CI/CD 段说明仓库内无 `.gitlab-ci.yml`;代理表补 `/chat-api`。
+- **运行过的验证**:
+  - `git ls-remote origin` 与本地 HEAD 一致(08524fb)
+  - 修正后 grep 复查:无 `8082` / `Vuex` / `stream-message-coordinator-v2` / `src/hooks` /
+    `gitlab-ci` 等过时表述残留(剩余匹配均为"没有…"这类修正后的否定陈述)
+- **已记录证据**:提交历史 `08524fb`(基线)、`975dee9`(文档修正)
+- **提交记录**:`08524fb`、`975dee9` 均已推送 origin/main
+- **更新过的文件或工件**:`README.md`、`CLAUDE.md`、`.gitignore`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ 两个功能性问题仍挂着(非本轮引入):
+    ①"选中候选后提交文本是否会被当作 refine 而循环"从未验证(上游持续 provider_failure,
+    `status: found` 一次都没出现过);②`answer.text` 与 `summary.text` 首句重复,
+    已确认是后端内容重复(非前端渲染),待后端修或前端去重
+  - `.env.*` 已入库,含 `VITE_APP_KEY` / `VITE_STREAM_TOKEN`。这类 `VITE_` 变量本就会被
+    编译进前端产物、对访问者可见,不属额外泄露;但若安全规范要求不入库,现在改成本最低
+    (历史仅两个提交)
+  - 提交者身份用的是本机 git 配置(gongtianxiao <1091877844@qq.com>)
+- **下一步最佳动作**:等上游恢复后补测有候选的选中流程;确认 summary 重复由后端修还是前端去重
+
+## Session 007
+
+- **日期**:2026-09-16
+- **本轮目标**:补齐 interrupt 的全部状态处理
+- **已完成**:
+  - 用户给出补问状态的**完整清单**(后端确认),共四种情况,我此前只处理了两种:
+    | kind | status | 含义 |
+    |---|---|---|
+    | company_selection | found | 有候选待选择 |
+    | company_need | not_found | 未找到候选 |
+    | company_need | **failed** | **查询服务失败** ← 未处理 |
+    | company_need | **无 status** | **初次询问是否需要公司信息** ← 未处理 |
+  - **修正的两个错误**:
+    1. `failed` 此前会落进 not_found 分支,问题文案被填成"未查询到匹配的公司"——
+       **把失败说成了查无**,违反接口文档"空候选与查询失败需分别展示""不把空候选说成查无公司"。
+       现在 failed 独立分支,文案说明失败原因(`result.error` 经 `describeCompanyError` 翻译),
+       无 error 时退回"公司查询服务暂时失败"。
+    2. 无 status(初次询问)此前会给"跳过公司查询",现改为"不需要公司信息"(→`不需要`),
+       与该变体后端 input_help 的"不需要则输入'不需要'"一致。
+  - 抽出 `fallbackQuestion()`,统一处理"已去重则保持为空"的逻辑,避免再次被 `||` 兜底复活。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 四种情况逐一断言,输出符合预期(含输入框位置、选项、问题文案)
+  - 专项核对:`failed` 文案**不含**"未查询到";`not_found` 文案为"未查询到匹配的公司"
+  - 去重回归:正文已含该段 → 问题字段仍为空
+- **已记录证据**:`docs/reference/README.md` 新增「interrupt 的全部状态」表;
+  见 `feature_list.json` 的 `company-interrupt-status-matching`(已更新)
+- **提交记录**:无(用户自行提交)
+- **更新过的文件或工件**:`src/components/api-chat-coordinator.ts`、
+  `docs/reference/README.md`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ `failed` 与"无 status 初次询问"两种情况**只有静态断言,未在真实后端遇到过**
+    (上游持续 provider_failure,实际只观测到 not_found)。真实出现时需确认文案是否合适。
+  - ⚠️ "选中候选后提交文本是否会被当作 refine 而循环"**仍未验证**
+- **下一步最佳动作**:上游恢复后补测有候选时的选中流程
+
+## Session 006
+
+- **日期**:2026-09-16
+- **本轮目标**:用 `status` 字段替掉猜测式判定
+- **已完成**:
+  - **承认问题**:用户指出 `interrupt` 有 `status` 字段(`found`/`not_found`),
+    查证后确认**我完全没用**,而是靠三样替代品在猜:
+    `kind === 'company_selection'`、`!candidates.length`、以及**最脆的**——
+    拿 `input_help` 的中文文本做正则 `/跳过|skip/i` 来决定按钮文案。
+  - 补 `ChatInterruptPayload.status` 类型(含 `found` / `not_found` 与一一对应关系的注释)。
+  - 判定改为以 `status` 为权威信号:`showCandidateList = hasCandidates && status !== 'not_found'`。
+    用户确认另外两种组合(`company_selection`+`not_found`、`company_need`+`found`)不会出现。
+  - **删掉 `input_help` 正则嗅探**,跳过选项文案改由状态决定。
+  - 保留 `result.error` 分支(查无 vs 查询失败要分开说),status 缺失时退回"有没有候选"兼容旧版本。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 六种组合断言:found+有候选 / not_found+无候选 / 无status两种 / found但0候选 / not_found但带候选
+  - 真实后端:`kind=company_need status=not_found` → 输入框在上 + [跳过公司查询] + 问题字段为空(已去重)
+- **已记录证据**:见 `feature_list.json` 的 `company-interrupt-status-matching`
+- **提交记录**:无(用户自行提交)
+- **更新过的文件或工件**:`src/components/api-chat-coordinator.ts`、
+  `harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **本轮自己引入过一次回归并已修复**:重构时把兜底写成 `question || '未查询到匹配的公司。'`,
+    而"正文已展示则清空问题"的去重逻辑把 `question` 置空后,被这个 `||` 又填回来了,等于去重失效。
+    已改为用 `questionAlreadyShown` 标志显式保留去重结果。**教训:清空哨兵值时,后续的 `||` 兜底会把它复活。**
+  - ⚠️ "选中候选后提交文本是否会被当作 refine 而循环"**仍未验证**(上游持续 provider_failure)
+- **下一步最佳动作**:上游恢复后补测有候选时的选中流程
+
+## Session 005
+
+- **日期**:2026-09-16
+- **本轮目标**:修公司补问卡片的三个问题(用户看截图指出)
+- **已完成**:
+  1. **序号错乱**:输入框常驻在最上却拿 B、选项在下面却拿 A。
+     新增 `getCardOptionLabel(card, optIdx)`:`freeformInputOnTop` 时选项序号整体后移一位,
+     输入框自己取 A。常规选项与两处下拉项共 3 处已替换。
+  2. **同一段文字出现两次**:后端把同一段话**既放进 `answer.text`(渲染在消息正文)、
+     又放进 `interrupt.question`**(实测两者字符串完全相同)。改为
+     `buildQuestionCardsContent(interrupt, shownText)`,正文已含该段时卡片里不再重复;
+     模板侧问题行加 `v-if="card.question"`,避免只剩一个"(单选)"。
+  3. **跳过公司查询发的是 `/skip`**:改为发选项文本「跳过公司查询」。
+     依据:该变体的后端 `input_help` 明确写着 `输入"跳过公司查询"或 /skip 继续`——两种都收。
+     同时移除映射表里对应的 token。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 三项修复的静态断言全部通过(含"正文不含该段时不被误删"的反向用例)
+  - 真实后端整链验证:卡片问题字段为空(已去重)、输入框在上、选项 [跳过公司查询]、
+    正文确认已含该段
+- **已记录证据**:见 `feature_list.json` 的 `company-need-input-on-top`(已更新)
+- **提交记录**:无(用户自行提交)
+- **更新过的文件或工件**:`src/components/api-chat-coordinator.ts`、
+  `src/components/Chat/QuestionCard.vue`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **保留了一处不一致,需用户确认**:`不需要公司信息` 仍然发 `"不需要"` 而不是选项文本。
+    理由是那个变体的后端 `input_help` 写的是"不需要则输入'不需要'",按它给的原字符串发最稳。
+    若统一改成发选项文本,需确认后端也认"不需要公司信息"这个说法。
+  - ⚠️ "选中候选后提交文本是否会被当作 refine 而循环"**仍未验证**(上游持续 provider_failure)
+- **下一步最佳动作**:上游恢复后补测有候选时的选中流程
+
+## Session 004
+
+- **日期**:2026-09-16
+- **本轮目标**:公司未找到时,补问卡片改为「上方输入框补充信息 + 下方跳过公司查询」
+- **已完成**:
+  - **发现后端已改**:公司未找到的场景现在走 `company_need`(不再是 `company_selection`),
+    且后端自己的文案就写着"请核对并补充工商注册全名或统一社会信用代码;也可以输入'跳过公司查询'"。
+    这与用户的诉求完全一致——**先读实际载荷再动手,比照文档猜要可靠**。
+  - `QuestionCard` 新增两个**可选**字段(不影响既有行为):
+    `freeformInputOnTop`(输入框常驻并排在最上)、`freeformPlaceholder`;
+    并同步修正 4 处自由输入判定,使常驻输入框无需点击激活即可参与"已作答/可提交/提交内容"判断。
+  - 适配层 `company_need` 分支改为:输入框在上 + 下方跳过选项。
+    跳过选项**跟随后端自己的说法**——提示里出现"跳过/skip"就用"跳过公司查询"(→`/skip`),
+    否则用"不需要公司信息"(→`不需要`),避免按钮与上方提示自相矛盾。
+  - 顺带记录:`Question.message` 字段**从未被模板渲染**,此前写的提示一直是不可见的。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 真实后端两个变体均验证通过:
+    「上海星溯算力集团有限公司,有什么优惠政策」→ 输入框在上 + [跳过公司查询]
+    「我想申请青浦区的企业扶持补贴…」→ 输入框在上 + [不需要公司信息]
+  - 回归:有候选(company_selection)时行为不变,仍为候选列表 + [查看更多][取消公司查询]
+  - 提交映射:跳过公司查询→`/skip`、不需要公司信息→`不需要`、自由输入→原文
+- **已记录证据**:见 `feature_list.json` 的 `company-need-input-on-top`
+- **提交记录**:无(用户自行提交)
+- **更新过的文件或工件**:`src/components/Chat/QuestionCard.vue`、
+  `src/components/api-chat-coordinator.ts`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ "选中候选后提交文本是否会被当作 refine 而循环"**仍未验证**:上游公司数据服务持续
+    失败(`provider_failure`),拿不到有候选的正常场景。上游恢复后必须补测。
+  - `Question.message` 未被渲染——若需要显示提示文案,需另行改模板(本轮未改,避免扩大改动面)。
+- **下一步最佳动作**:上游恢复后补测有候选时的选中流程
+
+## Session 003
+
+- **日期**:2026-09-15
+- **本轮目标**:定位并修复公司补问的死循环
+- **已完成**:
+  - ✅ **找到真正根因**(与我此前两次猜测都不同):用用户给的测试问法
+    「上海星溯算力集团有限公司,有什么优惠政策」实测,后端返回
+    `company_selection` + **`candidates: []`** + **`error: "provider_failure"`**
+    ——上游公司数据服务(企查查)**查询失败**,不是"查无公司"。
+  - 而前端卡片在这种情况下仍写「**请选择您所指的公司**」、**不显示错误**、且
+    允许自由输入并提示"输入新关键词" → 用户只能手打公司名 → 走 refine 路径 →
+    后端再查 → 上游又失败 → 又弹同一张卡 → **死循环**。
+  - 修 `buildQuestionCardsContent`,按接口文档要求区分三种情况
+    (文档原文:"空候选与查询失败需分别展示"、"有此错误时,不把空候选说成查无公司"):
+    ①查询失败 → 说明失败原因 + 重试/取消;②查无公司 → 说明没搜到 + 建议换关键词;
+    ③正常 → 列出候选。
+  - 新增 `describeCompanyError()`,把错误码翻成人话(含 search:/basic:/honors: 阶段前缀,
+    保留原错误码便于排查)。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - 三种卡片状态的静态断言(查询失败 / 查无公司 / 正常有候选)输出符合预期
+  - **真实后端端到端复现并确认修复**:同一问法现在返回
+    "公司数据服务返回失败(provider_failure),暂时无法列出候选公司。" + [重新查询][取消公司查询]
+- **已记录证据**:见 `feature_list.json` 的 `company-query-error-states`
+- **提交记录**:无(用户自行提交)
+- **更新过的文件或工件**:`src/components/api-chat-coordinator.ts`、
+  `harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **"选项文本 vs 序号"这个疑点仍未验证**:当前上游查询一直失败,拿不到有候选的
+    正常场景,因此无法验证"选中候选后提交文本"是否会被后端当作 refine 而循环。
+    **上游恢复后必须补测**:走一遍有候选的流程,选中一家,确认返回的是选定公司后的结果。
+    若那时出现循环,则只有两条路:①改回提交序号(序号取自当前显示顺序);
+    ②后端扩语义。
+  - 本轮同时证实:排查这类问题必须**读原始 SSE 载荷**,不能只看自己封装的中间事件——
+    我前两次都因为测试脚本查了不存在的事件(`interrupt`/`done` 在适配层已并入 message 通道)
+    而误判"后端不返回补问"。
+- **下一步最佳动作**:等上游恢复后补测有候选时的选中流程
+
+## Session 002
+
+- **日期**:2026-09-15
+- **本轮目标**:修公司候选提交导致的问题
+- **已完成**:
+  - 🚨 **确认事故**:上一轮改成提交公司名后,**问卷死循环**——提交后再次调用接口只传了
+    公司名,落进协议的"换关键词"路径,后端重新检索又返回同一批候选,用户再选再循环。
+    根因是我上一轮的改动方向错了:协议(`api-chat.md`)明确"**API 由序号映射到原候选**",
+    文本这种输入形式被保留给了 refine,语义上无法表达"我选这一个"。
+  - 按用户要求改为提交**选项全部内容拼接成的文本**(公司名 + 信用代码 + 状态 + 负责人)。
+    实现要点:`QuestionCard` 提交时只回传 label、不回传 description,因此协调器新增
+    `lastInterruptPayload` 留存最近一次补问,`toChatQuestionAnswer(answers, interrupt)`
+    按 label 反查候选后重建完整文本。
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `toChatQuestionAnswer` 单元断言 10/10 通过(含反查重建、固定动作、自由输入、多选多题、无补问降级)
+- **已记录证据**:见 `feature_list.json` 的 `questionnaire-submit-text`
+- **提交记录**:无(用户自行提交)
+- **更新过的文件或工件**:`src/components/api-chat-coordinator.ts`、
+  `src/components/business-assistant/useBusinessAssistantChat.ts`、
+  `docs/reference/README.md`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **死循环是否真的解除,未验证。** 排查时后端**已完全不返回补问**——
+    "上海青浦发展集团有限公司能享受哪些政策""帮我查一下我公司的补贴政策"
+    "我想申请青浦区的企业扶持补贴,帮我看看我的公司符不符合条件" 三种问法全部返回"无补问"。
+    因此本轮只完成了静态与单元验证,**端到端未验证**,必须由用户在浏览器确认。
+  - ⚠️ 若浏览器里仍循环:只有两条路——①改回提交序号(协议做法,序号取自当前显示顺序);
+    ②后端扩语义,能识别"全字段文本"等价于选中该候选。
+  - 📌 **规范修正(用户指出)**:引用接口约定时注意区分——
+    `API.md` 是**初始项目**的接口文档;当前用的是**新接口**,契约看 `api-chat.md`;
+    但**两者都会滞后,真正的事实来源是跑着的后端**。不要拿文档去否定实际观测。
+- **下一步最佳动作**:在浏览器验证公司候选提交后是否还会弹回候选列表
+
+## Session 001
+
+- **日期**:2026-09-15
+- **本轮目标**:修问卷/补问提交序号的问题;建立接口参考文档目录
+- **已完成**:
+  - `toChatQuestionAnswer` 改为提交**选项文字内容**:公司候选直接发公司名,选项不再带 `N. ` 前缀;
+    顺带修掉三个缺陷(只取第一题、多选只取第一项、数字开头选项被静默改写)
+  - 清掉 `finishTurn` 里从未使用的 `donePayload` 死参数(误导过一次排查)
+  - 新建 `docs/reference/`:新协议契约 + `legacy/API.md`(**交接时代码快照,非规格**)+ 索引 README
+  - 删除 `dos/`(内容已迁入 `docs/reference/`)
+  - **重新定性 `legacy/API.md`**:用户澄清它是"接手项目时根据源码生成的快照,后续接口会改",
+    据此在四处统一措辞(文档顶部横幅、索引表、`CLAUDE.md`、`harness/README.md`),
+    并删除原先"它是仓库内唯一依据"的错误说法——判据优先级明确为**源码备份 > 生成文档**
+- **运行过的验证**:
+  - `npm run build` 通过(本次改动后又重跑)
+  - `toChatQuestionAnswer` 单元断言 14/14 通过
+  - 措辞一致性 grep:无"已停用的旧协议""唯一依据"等旧表述残留
+- **已记录证据**:见 `feature_list.json` 的 `questionnaire-submit-text`
+- **提交记录**:无(用户自行提交)
+- **更新过的文件或工件**:`src/components/api-chat-coordinator.ts`、`docs/reference/*`、
+  `CLAUDE.md`、`harness/README.md`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **公司候选提交公司名是对协议的刻意偏离**(协议写的是提交序号 `"1"`)。若后端按名称
+    触发 refine 重新检索,会出现"选一次又弹回同一批候选"的循环。
+    **真实后端验证未完成**——排查时后端已不再返回任何补问("上海青浦发展集团有限公司能享受哪些政策"
+    与"帮我查一下我公司的补贴政策"两种之前都能触发的问法,现在都返回"无补问")。
+    需在浏览器里走一遍公司候选流程确认。
+  - ⚠️ **流程问题(自我记录)**:本轮的最后一次改动(`legacy/API.md` 重新定性)**没有在改完后
+    立即记录**,是用户追问"每次修改后有更新 harness 文档吗"才补上的,且当时记录里还留着被推翻的
+    旧措辞。这正是 harness 要防的失效模式——文件不会自动维护,**每轮收尾必须即时更新,不要攒**。
+- **下一步最佳动作**:在浏览器验证公司候选提交后的行为;若出现循环,与后端确认候选确认按什么取值
+
+## Session 000(基线)
+
+- **日期**:2026-09-15
+- **本轮目标**:建立 harness 基线,把此前的接口迁移工作固化成可交接的仓库事实
+- **已完成**:
+  - 建立 `harness/` 文件夹(本套文件)并在根 `CLAUDE.md` 加入 Harness 工作流段落
+  - 汇总先前的接口迁移成果到 `feature_list.json`(6 项 passing,1 项 blocked,1 项 not_started)
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `node -e "JSON.parse(...)"` 校验 `feature_list.json`:JSON 合法、8 个功能、字段齐全、无重复 id、状态合法、`in_progress` 数量为 0
+- **已记录证据**:`feature_list.json` 中每项 passing 功能都带 verification 与 evidence 字段
+- **提交记录**:无(仓库尚无任何提交;初始提交由用户自行完成)
+- **更新过的文件或工件**:新建 `harness/`(README、feature_list.json、claude-progress.md、init.sh、
+  session-handoff.md、clean-state-checklist.md、evaluator-rubric.md);修改根 `CLAUDE.md`
+- **已知风险或未解决问题**:
+  - `file-upload` 与 `chat-markdown-format` 两项受外部条件阻塞,见各自 notes
+  - 部门筛选按用户要求还原为最初的固定列表 + 字面匹配,新接口部门是全称,选中会筛出空列表——
+    这是**刻意保持**的状态,不要当成 bug 去修
+  - 仓库尚无 git 提交,`init.sh` 的提交历史检查会降级跳过
+- **下一步最佳动作**:确认 `file-upload` 的后端契约(三选一),然后把它切到 `in_progress` 开始实现
+
+### 还没写进 feature_list 的待办
+
+- 适配层映射了新字段 `knowledge_type` 的类型但未渲染,用户未要求展示
+- 惠企政策标识目前只在卡片右上角,用户明确要求不同步到「更多」列表与详情面板

+ 57 - 0
harness/sops/evaluator-rubric.md

@@ -0,0 +1,57 @@
+# 评审评分表
+
+> 会话结束后或到里程碑时,用它评估 agent 做的东西够不够格。
+> 每个维度 **0–2 分**,满分 12 分。
+
+## 六个维度
+
+| 维度 | 0 分 | 1 分 | 2 分 |
+|---|---|---|---|
+| **正确性** | 实现出来的行为不符合目标功能 | 主要路径对,边界情况有问题 | 行为符合目标功能,边界情况也考虑到了 |
+| **验证** | 没跑任何检查,或只有"应该没问题" | 跑了检查但没留证据 | 要求的检查都跑了,命令与结果都记录在仓库内 |
+| **范围纪律** | 顺手改了一堆无关的东西 | 基本在范围内,有个别越界 | 严格保持在选定功能范围内 |
+| **可靠性** | 重启或重跑后行为不一致 | 重跑能过,但依赖临时状态 | 结果能在重启或重跑后继续工作 |
+| **可维护性** | 代码和文档乱到下一轮接不上 | 能看懂,但需要口头补充 | 清楚到足以直接交给下一轮会话 |
+| **交接准备度** | 新会话完全无法只靠仓库内文件继续 | 能继续,但要先猜一阵 | 新会话只靠仓库内文件即可推进 |
+
+## 结论选项
+
+| 结论 | 含义 |
+|---|---|
+| **Accept** | 达标 |
+| **Revise** | 需要修补才能接受 |
+| **Block** | 有根本性问题,需要先解决 |
+
+## 校准说明(重要)
+
+> **开箱即用的 agent 做评审很弱——它会发现问题,然后把自己说服到通过。**
+
+所以 evaluator 需要校准,预计需要 **3–5 轮**:
+
+1. 用 evaluator 给一个**已完成**的 sprint 打分
+2. 把它的分数和你自己的人工判断对比
+3. 有分歧的地方,把 rubric 里的通过/失败标准**写得更具体**
+4. 对同一个输出**重新跑** evaluator,看对齐了没有
+5. 重复直到 evaluator 的判断和人工评审基本一致
+
+每轮记录改了什么、为什么改。
+
+## 本项目的评审补充
+
+除了六个通用维度,本项目的输出还要额外看:
+
+- **是否保持了界面基线** —— 有没有擅自新增/改动样式、可选项、文案、筛选逻辑
+- **是否走了适配层** —— 新协议能力有没有通过翻译成原内容标记来复用既有渲染组件,
+  而不是新写一套 UI
+- **是否用真实后端验证** —— 涉及接口的改动,是否对 `http://192.168.2.23:8000` 实测过
+- **是否诚实记录了刻意状态** —— 例如部门筛选的空列表是按要求保持的,
+  如果被当成 bug"修掉"了,属于范围违规
+
+## 和 Quality Document 的区别
+
+| 工具 | 回答的问题 |
+|---|---|
+| Evaluator rubric | 这轮 agent 做得好不好? |
+| Quality document(本项目暂未引入) | 这个项目是在变强还是变弱? |
+
+本项目规模尚小,暂未引入 quality document;等进入多模块、多阶段演化时再考虑。

+ 46 - 0
harness/sops/handoff.md

@@ -0,0 +1,46 @@
+# 会话交接摘要
+
+> 一轮会话结束时写,下一轮开始时读。让接手的人(或 agent)快速了解现状。
+> 短会话可以不写;会话长、或项目有多个并行区域时,它很关键。
+> **每次交接覆盖写这一份**(历史记录累积在 `progress.md` 里)。
+
+## 当前已验证
+
+<!-- 哪些是确认能用的,跑过什么验证。写命令和结果,不要写"应该没问题"。 -->
+
+- 生产构建:`npm run build` 通过(最近一次:Session 000)
+- 接口适配层:对新协议的端到端验证通过(普通问答 / 政策推荐 / 公司补问 / 候选确认 / 翻页 / 取消)
+
+## 本轮改动
+
+<!-- 改了什么代码或基础设施。列文件路径。 -->
+
+
+## 仍损坏或未验证
+
+<!-- 已知问题和风险区。诚实记录,不要美化。 -->
+
+- `file-upload`:未开始,阻塞于后端契约未定(见 `feature_list.json` 的 notes)
+- `chat-markdown-format`:阻塞于方案未拍板(同上)
+- 政策「更多」面板的部门筛选:按用户要求保持最初实现,新接口部门是全称,选中会筛出空列表——
+  这是刻意状态,**不要去"修"**
+- 仓库尚无 git 提交
+
+## 下一步最佳动作
+
+<!-- 下一轮该做什么,哪些东西不要动。 -->
+
+- 确认 `file-upload` 的后端契约(后端扩展请求体 / OSS 地址拼进 question / 文件登记接口),
+  确定后把该功能切到 `in_progress` 开始实现
+- **不要动**:`src/components/stream-message-coordinator.ts`(旧协议,保留未用);
+  政策「更多」面板的部门筛选逻辑;项目的样式基线
+
+## 命令
+
+```bash
+bash harness/init.sh     # 安装依赖 + 验证 + 打印启动命令
+git add -A && git commit -m "..." && git push origin main   # 每次改动完成即提交推送
+npm run dev              # 开发服务器 → https://localhost:8083
+npm run build            # 生产构建(提交前必过)
+npm run test             # jest(可选)
+```

+ 57 - 0
harness/sops/session-end.md

@@ -0,0 +1,57 @@
+# 收尾检查清单
+
+> 每次会话结束前过一遍,确保仓库处于**下一轮可以直接开工**的状态。
+> agent 的收尾流程里也应当包含这些检查。
+
+## 逐项检查
+
+- [ ] **已提交并推送** —— 每次改动完成就该提交(不攒、也不留给用户手动提交):
+      `git add -A && git commit -m "..." && git push origin main`
+- [ ] **标准启动路径还能用** —— `npm run dev` 能起在 8083,页面能打开
+- [ ] **标准验证还能跑** —— `npm run build` 通过(不是"上次通过",是这次跑过)
+- [ ] **进度日志已更新** —— `harness/progress.md` 追加了本轮 session 记录
+- [ ] **功能清单真实反映状态** —— `feature_list.json` 里
+      - `passing` 的项都有 verification + evidence,没有**假 passing**
+      - 同一时间只有一个 `in_progress`(没有的话,下一个重点应写在进度日志里)
+      - `blocked` 的项写清了阻塞原因和需要的决策
+- [ ] **没有半成品处于未记录状态** —— 做了一半的东西要么记进清单,要么回退
+- [ ] **未验证的内容已显式记录** —— 没跑过的、只在特定条件下跑过的,写清楚
+- [ ] **下一轮不需要人工修复就能继续** —— 新会话只靠仓库内文件就能接上
+- [ ] **没有把待办塞进聊天就结束** —— 仓库内文件才是唯一事实来源
+- [ ] **跑过结构校验** —— `node harness/tools/validate-harness.mjs` 退出码为 0
+- [ ] **计划类文件在仓库里** —— 本轮的方案/计划写进了 `harness/docs/exec-plans/`,
+      而不是只存在于聊天、或工具自带的仓库外计划目录(`~/.claude/plans/`)
+
+## 最常见的漏项:记录比改动旧
+
+**每次改动完成后就该更新记录,不要攒到会话结束。** 一轮会话常含多次改动,
+攒到最后必然漏记;更隐蔽的是**已写下的描述会过期**(真实发生过:一次改动没记,
+记录里还留着后来被推翻的旧结论,下一轮会话读到的是错的)。
+
+自查方法:比对文件修改时间。
+
+```bash
+# 若 harness 文件的修改时间早于其它源文件,很可能有改动没被记录
+ls -lt --time-style=+%H:%M:%S CLAUDE.md harness/progress.md harness/feature_list.json \n  src/components/api-chat-coordinator.ts | head
+```
+
+## 本项目的额外检查
+
+- [ ] **没有擅自改动界面样式** —— 既有 UI 的样式、可选项、文案、筛选逻辑都应保持原样;
+      确需新增时先取得确认
+- [ ] **协议层改动没有破坏原渲染路径** —— 新协议内容仍通过适配层翻译成
+      `<scope>` / `<!-- POLICY_TABLE -->` / `<ref_links>` / `<question-cards>`,
+      渲染组件(BusinessRecord / PolicyMatch / QuestionCard / ScopeContent)未被改写
+- [ ] **`stream-message-coordinator.ts` 仍未被改动** —— 它是保留的旧协议实现;确需改动需先说明理由
+- [ ] **验证用了真实后端** —— 涉及接口的改动,除构建通过外,应对 `http://192.168.2.23:8000` 实测;
+      后端不可达时要在记录里写明"本轮只做了静态验证"
+
+## 常见红灯
+
+| 现象 | 意味着 |
+|---|---|
+| "构建通过"但没跑过接口 | 只证明了能编译,没证明行为对 |
+| 清单里 `passing` 却没有 evidence | 假 passing,必须降级 |
+| 同一时间多个 `in_progress` | 违反了单功能纪律,收敛到一个 |
+| 靠改写清单让状态变好看 | 掩盖未完成工作,比不写更糟 |
+| 在坏的基础上继续叠新功能 | 先修基础状态 |

+ 59 - 0
harness/sops/session-start.md

@@ -0,0 +1,59 @@
+# SOP:每轮开工
+
+> 目标:**不要靠记忆**开工。上一轮的状态、还挂着什么、下一步做什么,全在仓库里。
+
+## 步骤
+
+1. **确认位置**
+
+   ```bash
+   pwd    # 应为项目根目录
+   ```
+
+2. **读状态**(按顺序)
+
+   - `harness/progress.md` — 「当前已验证状态」一节 + 最后一条 session 记录
+   - `harness/feature_list.json` — 哪些是 `passing`、哪些 `not_started`、哪些 `blocked`
+
+3. **看最近提交**
+
+   ```bash
+   git log --oneline -5
+   ```
+
+4. **跑基础验证**
+
+   ```bash
+   bash harness/init.sh
+   ```
+
+   验证失败就**先修基础状态**,不要在坏的基础上叠新功能。
+
+4b. **校验 harness 结构**(很快,别省)
+
+   ```bash
+   node harness/tools/validate-harness.mjs
+   ```
+
+   它会检查必备文件、游离的计划文件、`feature_list.json` 的假 passing /
+   多个 `in_progress`、`progress.md` 的会话编号重复。**退出码非 0 就先修它。**
+
+5. **选一件事做**
+
+   只选**一个**未完成功能,围绕它工作,直到:
+
+   - 验证通过(并把证据写进 `progress.md` 与 `feature_list.json`),或
+   - 被明确记为 `blocked`(写清阻塞原因和需要谁决定什么)
+
+## 别做的事
+
+- **不要一次开多个功能** —— 半成品是最常见的翻车形态
+- **不要跳过第 4 步** —— 构建挂着的状态下改代码,问题会叠在一起分不清
+- **不要凭记忆开工** —— 想不起来上轮做到哪,就说明该看文件了
+
+## 这个项目的已知坑(开工前扫一眼)
+
+- 界面样式必须与原版一致;既有 UI 的可选项、文案、筛选逻辑不要擅自改动
+- 新协议能力走适配层翻译成内容标记,**不要新建带自有样式的 UI 组件**
+- `src/components/stream-message-coordinator.ts` 是保留的旧协议实现,**不要动**
+- 排查协议问题**必须读原始 SSE 载荷**(见 `sops/verification.md`)

+ 69 - 0
harness/sops/verification.md

@@ -0,0 +1,69 @@
+# SOP:验证
+
+> 目标:**"我验证过了"要有可复现的依据**,而不是"我记得跑过"。
+> 这个项目吃过几次亏,下面的规则都是踩出来的。
+
+## 分层验证(按改动类型选,不必每次都全跑)
+
+| 改动类型 | 至少要做到 |
+|---|---|
+| 纯文案 / 注释 / 文档 | 构建通过 |
+| 样式、模板结构 | 构建通过 + 实际页面看一眼 |
+| 业务逻辑(不改协议) | 构建通过 + 在 `tools/` 写脚本对真实数据断言 |
+| **任何接口 / 协议相关** | 上面全部 **+ 对真实后端端到端跑一遍**(见下) |
+
+## 构建
+
+```bash
+npm run build     # 必过。这是提交前的底线,不是充分条件
+```
+
+> ⚠️ **"构建通过"只证明能编译,不证明行为对。** 接口相关的改动只跑构建等于没验证。
+
+## 接口改动:必须对真实后端验证
+
+```bash
+# 后端
+http://192.168.2.23:8000
+
+# 开发环境经代理访问(见 harness/docs/reference/api-chat.md)
+https://localhost:8083/chat-api/api/chat
+```
+
+**关键:读原始 SSE 载荷。** 项目里踩过两次同样的坑 —— 只看自己封装的中间事件,
+误判"后端不返回补问",实际是适配层已把事件并入了 message 通道、查错了事件名。
+
+正确做法是把原始 `event:` / `data:` 打出来看,而不是去监听封装后的事件。
+
+## 脚本放哪里
+
+验证脚本写到 **`harness/tools/`**,不要写进项目根目录。
+
+- 命名 `<用途>.mjs`,顶部写清"验证什么、怎么跑"
+- 有复用价值的**留着** —— 它们是"当时怎么验的"最好的证据
+
+## 记录证据
+
+验证通过后,**当场**写进两个地方(不要攒到会话结束):
+
+1. `harness/progress.md` — 本轮 session 记录里写「运行过的验证」和「已记录证据」
+2. `harness/feature_list.json` — 对应功能的 `verification` 与 `evidence`
+
+**完成门槛**:只有验证成功、且证据已记录,功能状态才能切 `passing`。
+
+## 已知的"看起来像 bug 其实不是"
+
+记录这些是为了避免后来者(包括未来的自己)去"修"不该修的东西:
+
+| 现象 | 真相 |
+|---|---|
+| 政策「更多」面板选中任何部门都筛出空列表 | 按用户要求**刻意**保持最初的固定部门列表 + 字面匹配;新接口返回的部门是全称,字面不相等。**不要当 bug 修。** |
+| `answer.text` 与 `summary.text` 首句重复 | 后端内容重复(同一段话既在 answer 又在 summary 开头),**不是前端渲染问题** |
+| 上游公司数据服务偶发失败 | 表现为 `status: failed` 或空响应;属上游问题,前端只负责正确展示 |
+
+## 反模式
+
+- ❌ "构建通过,应该没问题了"
+- ❌ 只在特定条件下跑过一次,却记成"已验证"
+- ❌ 改了验证步骤让结果变好看
+- ❌ 把没验证的说成验证过的(这一条比不写更糟)

+ 61 - 0
harness/tools/README.md

@@ -0,0 +1,61 @@
+# tools/ — 临时脚本放这里
+
+写验证脚本、调试脚本、一次性数据处理的脚本,**都放这个目录**,不要再丢到项目根目录。
+
+## 为什么
+
+以前这类脚本临时写在项目根目录(`_tmp_xxx.mjs`),用完就删。问题有三个:
+
+- 根目录被污染,`ls` 一下分不清哪些是项目文件
+- 好用的脚本删了就没了,下次还要重写(例如"验证适配层输出契约"这种脚本写过不止一次)
+- 出了问题无法复现 —— 当时是怎么验的,没留下任何东西
+
+放在这里之后,**脚本可以留着**。它们是"当时怎么验证的"最好的证据。
+
+## 约定
+
+- 命名:`<用途>.mjs`,例如 `verify-adapter.mjs`、`probe-summary-dup.mjs`
+- 顶部写一行注释说明:**这个脚本验证什么、怎么跑**
+- 只读脚本优先(探测接口、打印数据),改动数据的脚本要显式说明
+- 一次性用完确实没价值的,可以删;**有复用价值的留下**
+
+## 常用套路
+
+项目里几个反复用到的做法,可以直接抄:
+
+```bash
+# 把 TS 源码打包成能在 node 直接跑的 ESM(用于验证 src 里的真实逻辑)
+npx esbuild harness/tools/<某入口>.ts --bundle --format=esm --outfile=harness/tools/<产物>.mjs
+
+# 直接打后端 SSE 原始载荷(排查协议问题必须看原始载荷,不能只看封装的中间事件)
+# —— 见 harness/docs/reference/api-chat.md 的事件说明
+```
+
+## ⚠️ 打包产物 `_*.mjs` 不入库,改了源码要重新打
+
+`_*.mjs` 是 esbuild 从 `_entry-*.ts` 生成的**构建产物**,按 `.gitignore` 排除
+(入库会与源码漂移,让人误以为测的是新代码)。**clone 下来要跑验证脚本前,先重新生成**:
+
+```bash
+# 会话/问答记录 DMS 相关(入口 _entry-dms.ts)
+npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
+  --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}'
+
+# 内容判空口径(入口 _entry-empty-content.ts)
+npx esbuild harness/tools/_entry-empty-content.ts --bundle --format=esm \
+  --outfile=harness/tools/_empty-content.mjs
+
+# 政策库判定与置顶(入口 _entry-policy-library.ts)
+npx esbuild harness/tools/_entry-policy-library.ts --bundle --format=esm \
+  --outfile=harness/tools/_policy-library-utils.mjs
+```
+
+> `--define:import.meta.env='{}'`:验证脚本跑在 node 里没有 `import.meta.env`,
+> 需要喂一个空对象,否则读取环境变量的模块会直接抛错。
+
+> ⚠️ 排查协议类问题时,**务必读原始 SSE 载荷**。曾经因为只看自己封装的中间事件,
+> 两次误判"后端不返回补问",而实际是适配层已把事件并入 message 通道、查错了事件名。
+
+## 本目录不参与构建
+
+`tools/` 下的脚本不进入前端产物,也不参与 `npm run build`。放心写。

+ 23 - 0
harness/tools/_entry-dms.ts

@@ -0,0 +1,23 @@
+/** esbuild 入口:把 DMS 客户端与业务层打包成 node 能直接 import 的 ESM */
+export {
+  searchDmsContents,
+  addDmsContent,
+  updateDmsContent,
+  deleteDmsContent,
+  DMS_COLUMN_SESSION,
+  DMS_MODEL_SESSION,
+  DMS_COLUMN_RECORD,
+  DMS_MODEL_RECORD,
+} from '../../src/network/api/dms/client';
+
+export {
+  upsertDmsSession,
+  saveDmsTranscript,
+  fetchDmsSessions,
+  fetchDmsSessionRecords,
+  deleteDmsSession,
+  writeDmsFeedback,
+  resolveDmsCreditCode,
+  parseDmsTimestamp,
+  nowDmsTimestamp,
+} from '../../src/network/api/dms/chat-sessions-dms';

+ 2 - 0
harness/tools/_entry-empty-content.ts

@@ -0,0 +1,2 @@
+export { hasVisibleMessageContent, isInterruptedEmptyScopeMessage } from '../../src/utils/interrupted-message';
+export { normalizeSessionHistory, CANCELLED_SESSION_TEXT } from '../../src/components/business-assistant/shared';

+ 19 - 0
harness/tools/_entry-policy-library.ts

@@ -0,0 +1,19 @@
+/**
+ * esbuild 入口:把政策库的纯逻辑打包成 node 能直接 import 的 ESM。
+ *
+ * 生成命令见 tools/README.md:
+ *   npx esbuild harness/tools/_entry-policy-library.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_policy-library-utils.mjs
+ *
+ * 注:这个入口文件此前**缺失**(产物在、入口丢了),导致产物无法重新生成。
+ *     按 tools/README 的约定补回:每个 `_*.mjs` 产物都必须有对应的 `_entry-*.ts`。
+ */
+export {
+  normPolicyName,
+  buildLibraryNameSet,
+  isPolicyLibraryItem,
+  collectMatchedLibraryNames,
+  pinMatchedFirst,
+  buildPolicyDetailUrl,
+  POLICY_DETAIL_URL_BASE,
+} from '../../src/components/Chat/policy-library-utils';

+ 107 - 0
harness/tools/check-policy-sources.mjs

@@ -0,0 +1,107 @@
+/**
+ * 对比政策库的**两个来源**,确认「开发环境本地优先」这个开关还有没有必要。
+ *
+ * 背景(2026-09-17):
+ *   政策详情面板的「政策名称」跳转地址由 `data.市级政策id` 拼出
+ *   (见 src/components/Chat/policy-library-utils.ts 的 buildPolicyDetailUrl)。
+ *   但两个来源的字段并不一致:
+ *     - 本地 public/merchant-agent/policies_public.v1.json → 有 市级政策id
+ *     - 远端 VITE_DOWNLOAD_URL                          → 327 条全无该字段
+ *   而 index.html 原本是**远端优先**,于是浏览器里取不到 id,「政策名称」是纯文本。
+ *   为此加了 VITE_POLICY_LOCAL_FIRST(仅 .env.development 为 true)。
+ *
+ * 这个脚本干什么:
+ *   把两个来源的条数与 市级政策id 覆盖率打出来,并判断那个开关是否仍然需要。
+ *
+ * 怎么跑(在项目根目录):
+ *   node harness/tools/check-policy-sources.mjs
+ *
+ * 退出码:0 = 与预期一致;1 = 情况变了,需要有人看一眼(见输出末尾的提示)
+ */
+
+import { readFileSync } from 'node:fs';
+
+const LOCAL_PATH = 'public/merchant-agent/policies_public.v1.json';
+const ENV_PATH = '.env.development';
+
+/** 从 .env.development 读 VITE_DOWNLOAD_URL,避免把地址写死在脚本里 */
+const readDownloadUrl = () => {
+  const env = readFileSync(ENV_PATH, 'utf8');
+  const line = env.split(/\r?\n/).find((l) => l.trim().startsWith('VITE_DOWNLOAD_URL'));
+  const raw = line?.split('=').slice(1).join('=').trim().replace(/^["']|["']$/g, '');
+  return raw ? raw + 'merchant-agent/policies_public.v1.json' : '';
+};
+
+const toList = (json) => (Array.isArray(json) ? json : json?.data) || [];
+
+/** 统计 data.市级政策id 的覆盖率(buildPolicyDetailUrl 读的就是这个字段) */
+const countCityId = (list) => {
+  const missing = [];
+  let has = 0;
+  for (const item of list) {
+    const value = (item?.data || {})['市级政策id'];
+    if (value && String(value).trim()) has++;
+    else missing.push(item?.name || '(无名)');
+  }
+  return { has, total: list.length, missing };
+};
+
+const localList = toList(JSON.parse(readFileSync(LOCAL_PATH, 'utf8')));
+const localId = countCityId(localList);
+
+console.log('本地 ' + LOCAL_PATH);
+console.log('  条数: ' + localList.length + ' | 市级政策id: ' + localId.has + '/' + localId.total);
+
+let remoteList = [];
+let remoteId = { has: 0, total: 0, missing: [] };
+let remoteError = '';
+const url = readDownloadUrl();
+console.log('\n远端 ' + (url || '(未在 ' + ENV_PATH + ' 找到 VITE_DOWNLOAD_URL)'));
+if (url) {
+  try {
+    const res = await fetch(url);
+    if (!res.ok) throw new Error('HTTP ' + res.status);
+    remoteList = toList(await res.json());
+    remoteId = countCityId(remoteList);
+    console.log('  条数: ' + remoteList.length + ' | 市级政策id: ' + remoteId.has + '/' + remoteId.total);
+  } catch (err) {
+    remoteError = err.message;
+    console.log('  拉取失败: ' + remoteError + '(跳过远端对比)');
+  }
+}
+
+const localOk = localId.has > 0;
+const remoteHasId = remoteId.has > 0;
+console.log('\n结论:');
+console.log('  本地这份带 市级政策id:' + (localOk ? '是' : '否'));
+if (url && !remoteError) {
+  console.log('  远端这份带 市级政策id:' + (remoteHasId ? '是' : '否'));
+}
+
+let exitCode = 0;
+if (!localOk) {
+  console.log('  ⚠️ 本地这份也没有 id 了 —— 「本地优先」这个开关失去意义,需重新找 id 来源');
+  exitCode = 1;
+} else if (url && !remoteError && remoteHasId) {
+  console.log('  ⚠️ 远端现在也带 id 了 —— 「开发环境本地优先」已无必要,可以去掉 VITE_POLICY_LOCAL_FIRST');
+  exitCode = 1;
+} else {
+  console.log('  ✅ 与预期一致:远端无 id,开发环境仍需本地优先');
+}
+
+if (remoteList.length && localList.length !== remoteList.length) {
+  const localNames = new Set(localList.map((x) => x.name));
+  const remoteNames = new Set(remoteList.map((x) => x.name));
+  console.log(
+    '\n条数差异(本地少 ' +
+      (remoteList.length - localList.length) +
+      ' 条):仅远端有的政策名 ' +
+      [...remoteNames].filter((n) => !localNames.has(n)).length +
+      ' 个,仅本地有的 ' +
+      [...localNames].filter((n) => !remoteNames.has(n)).length +
+      ' 个'
+  );
+  console.log('  ⚠️ 开发环境用本地这份意味着比远端少 ' + (remoteList.length - localList.length) + ' 条申报事项');
+}
+
+process.exit(exitCode);

+ 149 - 0
harness/tools/dms-delete-probe.mjs

@@ -0,0 +1,149 @@
+/**
+ * 探测 DMS 删除内容的**正确姿势**(OpenAPI 与 Apifox 对 delContentById 的参数都未文档化)。
+ *
+ * 背景:Step-0 用 `DELETE /content/delContentById?id=<uuid>` 得 code=-1,行未删。
+ * 线索:OpenAPI 里另有 `POST /content/updateAudit`(修改内容状态),
+ *       而 DMS 的 states 语义是 0草稿 1待审 2审完 3发布 4销毁 5退回
+ *       —— “删除”很可能是把 state 改成 4,而不是物理删除。
+ *
+ * 本脚本会:造测试行 → 逐一试候选形态 → 校验行是否真的消失 → 清理残留。
+ * 用法:node harness/tools/dms-delete-probe.mjs <DMS_TOKEN>
+ */
+
+const HOST = process.env.DMS_HOST || '121.43.55.7:10081';
+const BASE = `http://${HOST}/dms`;
+const TOKEN = process.argv[2] || '';
+const COL = '1887';
+const MODEL = '2034';
+const TAG = `deltest_${Date.now().toString(36)}`;
+
+if (!TOKEN) {
+  console.error('用法:node harness/tools/dms-delete-probe.mjs <DMS_TOKEN>');
+  process.exit(2);
+}
+
+const call = async (method, path, form, jsonBody) => {
+  const init = { method, headers: { token: TOKEN } };
+  if (jsonBody !== undefined) {
+    init.headers['Content-Type'] = 'application/json';
+    init.body = JSON.stringify(jsonBody);
+  } else if (form) {
+    init.headers['Content-Type'] = 'application/x-www-form-urlencoded';
+    init.body = new URLSearchParams(form).toString();
+  }
+  try {
+    const res = await fetch(`${BASE}${path}`, init);
+    const text = await res.text();
+    let json = null;
+    try {
+      json = JSON.parse(text);
+    } catch {
+      /* 非 JSON */
+    }
+    return { status: res.status, json, text };
+  } catch (err) {
+    return { status: 0, json: null, text: String(err) };
+  }
+};
+
+/** 该 column 下用 c_credit_code 标记的所有行 */
+const rowsWithTag = async (columnId, tag) => {
+  const r = await call('POST', '/content/selectContentList', {
+    columnId: String(columnId),
+    page: '0',
+    pageSize: '100',
+    search: JSON.stringify([{ field: 'c_credit_code', searchType: 1, content: { value: tag } }]),
+  });
+  return r.json?.content?.data || [];
+};
+
+const mkRow = async () => {
+  const sid = `${TAG}_${Math.random().toString(36).slice(2, 8)}`;
+  const r = await call('POST', '/content/addContent', {
+    columnId: COL,
+    modelId: MODEL,
+    content: JSON.stringify({ c_credit_code: TAG, c_session_id: sid, c_title: 'deltest' }),
+  });
+  return typeof r.json?.content === 'string' ? { uuid: r.json.content, sid } : null;
+};
+
+const run = async () => {
+  console.log(`\nDMS 删除姿势探测  ${BASE}\n`);
+
+  const candidates = [
+    ['POST /content/delContentById form{columnId,contentId}', (u) => call('POST', '/content/delContentById', { columnId: COL, contentId: u })],
+    ['DELETE /content/delContentById json{columnId,contentId}', (u) => call('DELETE', '/content/delContentById', null, { columnId: Number(COL), contentId: u })],
+    ['DELETE /content/delContentById json{id}', (u) => call('DELETE', '/content/delContentById', null, { id: u })],
+    ['DELETE form body{columnId,contentId}', (u) => call('DELETE', '/content/delContentById', { columnId: COL, contentId: u })],
+    ['POST /content/updateAudit form{columnId,contentId,state=4}', (u) => call('POST', '/content/updateAudit', { columnId: COL, contentId: u, state: '4' })],
+    ['POST /content/updateAudit form{columnId,ids,state=4}', (u) => call('POST', '/content/updateAudit', { columnId: COL, ids: u, state: '4' })],
+    ['POST /content/updateAudit form{columnId,contentIds,state=4}', (u) => call('POST', '/content/updateAudit', { columnId: COL, contentIds: u, state: '4' })],
+    ['POST /content/updateAudit form{columnId,id,state=4}', (u) => call('POST', '/content/updateAudit', { columnId: COL, id: u, state: '4' })],
+    ['POST /content/updateAudit json{columnId,contentIds:[uuid],state:4}', (u) => call('POST', '/content/updateAudit', null, { columnId: Number(COL), contentIds: [u], state: 4 })],
+  ];
+
+  const winners = [];
+  for (const [label, fn] of candidates) {
+    const row = await mkRow();
+    if (!row) {
+      console.log(`  ??  ${label} —— 造行失败`);
+      continue;
+    }
+    let resp;
+    try {
+      resp = await fn(row.uuid);
+    } catch (err) {
+      console.log(`  xx  ${label} —— 异常 ${err.message}`);
+      continue;
+    }
+    // 判定:默认查询里还能不能看到这一行
+    const still = (await rowsWithTag(COL, TAG)).some((r) => r.id === row.uuid);
+    const code = resp.json?.code ?? resp.status;
+    const ok = !still && code === 200;
+    console.log(`  ${ok ? 'ok ' : 'xx '} ${label}\n        → code=${code}${still ? '(行仍在)' : '(行已消失)'} ${String(resp.text).slice(0, 120)}`);
+    if (ok) winners.push({ label, fn });
+  }
+
+  console.log(`\n可用姿势:${winners.length ? winners.map((w) => w.label).join(' / ') : '(都不行)'}`);
+
+  // ── 清理:用可用的姿势删掉所有 deltest_* 与 step0_* 残留 ──────────────
+  console.log('\n【清理残留】');
+  const targets = [
+    ...(await rowsWithTag(COL, TAG)),
+    ...(await rowsWithTag('1889', TAG)),
+    ...(await rowsWithTag(COL, 'step0_credit_code')),
+    ...(await rowsWithTag('1889', 'step0_credit_code')),
+    ...(await rowsWithTag(COL, 'probe_cc')),
+    ...(await rowsWithTag('1889', 'probe_cc')),
+  ];
+  console.log(`  待清理 ${targets.length} 行`);
+  if (!winners.length) {
+    console.log('  ⚠️ 没有可用姿势,残留无法自动清理(需人工在 DMS 后台处理)');
+    console.log('  残留 id:', targets.map((t) => `${t.id}(col ${t.column_id})`).join(', '));
+    return;
+  }
+  for (const row of targets) {
+    for (const w of winners) {
+      try {
+        await w.fn(row.id);
+        if (!(await rowsWithTag(String(row.column_id), row.c_credit_code)).some((r) => r.id === row.id)) break;
+      } catch {
+        /* 换下一种 */
+      }
+    }
+  }
+  const left = [
+    ...(await rowsWithTag(COL, TAG)),
+    ...(await rowsWithTag('1889', TAG)),
+    ...(await rowsWithTag(COL, 'step0_credit_code')),
+    ...(await rowsWithTag('1889', 'step0_credit_code')),
+    ...(await rowsWithTag(COL, 'probe_cc')),
+    ...(await rowsWithTag('1889', 'probe_cc')),
+  ];
+  console.log(`  清理后剩余 ${left.length} 行${left.length ? '(' + left.map((r) => r.id).join(', ') + ')' : ' ✅'}`);
+};
+
+run().catch((e) => {
+  console.error('脚本异常:', e);
+  process.exit(1);
+});

+ 225 - 0
harness/tools/dms-step0-verify.mjs

@@ -0,0 +1,225 @@
+/**
+ * Step-0:切换前端到 DMS 之前的**小样本实测**。
+ *
+ * 为什么需要它:DMS 的 content 读写接口在文档里全是「📄 未实测」
+ * (参数来自 SKILL.md/Apifox,OpenAPI 里是 @RequestBody 抓不到),
+ * 而前端的 upsert 依赖三件没被证实的事:
+ *   ① addContent 是否要求 c_id(模型里 must=true)→ 若强校验,整个方案要改
+ *   ② addContent 返回体里有没有记录 uuid(有则省掉一次反查)
+ *   ③ timestamp 写入格式、states 默认值、长文本往返
+ *
+ * 怎么跑(在项目根目录;token 不落盘,从参数读):
+ *   node harness/tools/dms-step0-verify.mjs <DMS_TOKEN>
+ *
+ * 脚本会自行清理它写入的测试数据(session_id/record_id 以 step0_ 开头)。
+ */
+
+const HOST = process.env.DMS_HOST || '121.43.55.7:10081';
+const BASE = `http://${HOST}/dms`;
+const TOKEN = process.argv[2] || process.env.DMS_TOKEN || '';
+
+const COL_SESSION = 1887;
+const MODEL_SESSION = 2034;
+const COL_RECORD = 1889;
+const MODEL_RECORD = 2038;
+
+const SUFFIX = Date.now().toString(36);
+const SESSION_ID = `step0_sess_${SUFFIX}`;
+const RECORD_ID = `step0_rec_${SUFFIX}`;
+const CREDIT = 'step0_credit_code';
+
+if (!TOKEN) {
+  console.error('缺 token。用法:node harness/tools/dms-step0-verify.mjs <DMS_TOKEN>');
+  process.exit(2);
+}
+
+const results = [];
+const note = (name, ok, detail) => {
+  results.push({ name, ok, detail });
+  console.log(`  ${ok === null ? '?  ' : ok ? 'ok  ' : 'FAIL'} ${name}${detail ? '  → ' + detail : ''}`);
+};
+
+async function call(method, path, form) {
+  const init = { method, headers: { token: TOKEN } };
+  if (form) {
+    init.headers['Content-Type'] = 'application/x-www-form-urlencoded';
+    init.body = new URLSearchParams(form).toString();
+  }
+  const res = await fetch(`${BASE}${path}`, init);
+  const text = await res.text();
+  let json = null;
+  try {
+    json = JSON.parse(text);
+  } catch {
+    /* 非 JSON 原样返回 */
+  }
+  return { status: res.status, json, text };
+}
+
+const search = (columnId, field, value, extra = {}) =>
+  call('POST', '/content/selectContentList', {
+    columnId: String(columnId),
+    page: '0',
+    pageSize: '10',
+    search: JSON.stringify([{ field, searchType: 1, content: { value } }]),
+    ...extra,
+  });
+
+const cleanup = async (columnId, field, value) => {
+  const r = await search(columnId, field, value);
+  const rows = r.json?.content?.data || [];
+  for (const row of rows) {
+    if (row?.id) await call('DELETE', `/content/delContentById?id=${encodeURIComponent(row.id)}`);
+  }
+  return rows.length;
+};
+
+const run = async () => {
+  console.log(`\nDMS Step-0 实测  ${BASE}   标记=${SUFFIX}\n`);
+
+  // ── 1. addContent:不传 c_id 会不会被拒(最高优先级)────────────────────
+  console.log('【1】addContent(1887 会话)不传 c_id');
+  const addSession = await call('POST', '/content/addContent', {
+    columnId: String(COL_SESSION),
+    modelId: String(MODEL_SESSION),
+    content: JSON.stringify({
+      c_credit_code: CREDIT,
+      c_session_id: SESSION_ID,
+      c_title: 'step0 会话标题',
+      c_source: 'zhaoshang',
+    }),
+  });
+  console.log('    原始响应:', addSession.text.slice(0, 400));
+  const addOk = addSession.json?.code === 200;
+  note('addContent 不传 c_id 被接受', addOk, addOk ? '' : `code=${addSession.json?.code}`);
+  const returnedUuid = addSession.json?.content?.id || addSession.json?.content?.uuid || null;
+  note('addContent 响应回传记录 uuid', !!returnedUuid, returnedUuid ? String(returnedUuid) : '(未回传,需反查)');
+
+  // ── 2. 反查:states 默认值 / c_id 是否自动生成 ──────────────────────────
+  console.log('\n【2】反查(不带 states)');
+  const found = await search(COL_SESSION, 'c_session_id', SESSION_ID);
+  const rows = found.json?.content?.data || [];
+  note('写后能查回(不传 states)', rows.length > 0, `命中 ${rows.length} 行, code=${found.json?.code}`);
+  const row = rows[0] || {};
+  console.log('    行内容:', JSON.stringify(row).slice(0, 500));
+  note('c_id 由 DMS 自动生成', row.c_id !== undefined && row.c_id !== null && row.c_id !== '', `c_id=${row.c_id}`);
+  note('记录 uuid 存在行里(反查可取)', !!row.id, String(row.id || ''));
+  const uuid = row.id || returnedUuid;
+
+  // ── 3. timestamp 形态 ──────────────────────────────────────────────────
+  console.log('\n【3】timestamp');
+  note('c_created_at 未传时是否自动填充', row.c_created_at !== undefined && row.c_created_at !== null, `读到 ${JSON.stringify(row.c_created_at)}`);
+  const tsFormats = ['2026-09-17 21:30:00', '2026-09-17T21:30:00+08:00'];
+  for (const ts of tsFormats) {
+    const r = await call('POST', '/content/updateContent', {
+      columnId: String(COL_SESSION),
+      modelId: String(MODEL_SESSION),
+      content: JSON.stringify({ id: uuid, c_updated_at: ts }),
+    });
+    const back = await search(COL_SESSION, 'c_session_id', SESSION_ID);
+    const got = back.json?.content?.data?.[0]?.c_updated_at;
+    note(`写入 "${ts}"`, r.json?.code === 200 && got != null, `code=${r.json?.code}, 读回=${JSON.stringify(got)}`);
+  }
+
+  // ── 4. updateContent 改标题 ───────────────────────────────────────────
+  console.log('\n【4】updateContent(带 id=uuid)');
+  const upd = await call('POST', '/content/updateContent', {
+    columnId: String(COL_SESSION),
+    modelId: String(MODEL_SESSION),
+    content: JSON.stringify({ id: uuid, c_title: 'step0 改后的标题' }),
+  });
+  const afterUpd = await search(COL_SESSION, 'c_session_id', SESSION_ID);
+  const newTitle = afterUpd.json?.content?.data?.[0]?.c_title;
+  note('改标题生效', upd.json?.code === 200 && newTitle === 'step0 改后的标题', `code=${upd.json?.code}, c_title=${JSON.stringify(newTitle)}`);
+
+  // ── 5. 长文本 + 中文 + 协议标记往返 ────────────────────────────────────
+  console.log('\n【5】1889 长文本往返(含协议标记)');
+  const longAnswer = [
+    '<!-- POLICY_TABLE {"data":[{"title":"测试政策"}]} -->',
+    '<scope title="正在思考中...">\n</scope>',
+    '第一段中文回答。'.repeat(120),
+    '<question-cards>{"kind":"company_need"}</question-cards>',
+    '结尾行',
+  ].join('\n');
+  const addRecord = await call('POST', '/content/addContent', {
+    columnId: String(COL_RECORD),
+    modelId: String(MODEL_RECORD),
+    content: JSON.stringify({
+      c_credit_code: CREDIT,
+      c_session_id: SESSION_ID,
+      c_record_id: RECORD_ID,
+      c_question: 'step0 问题:高新技术企业奖励是多少?',
+      c_answer: longAnswer,
+      c_source: 'zhaoshang',
+    }),
+  });
+  note('1889 addContent 成功', addRecord.json?.code === 200, `code=${addRecord.json?.code}`);
+  const recRows = (await search(COL_RECORD, 'c_record_id', RECORD_ID)).json?.content?.data || [];
+  const rec = recRows[0] || {};
+  note('长文本逐字节一致', rec.c_answer === longAnswer, `原长 ${longAnswer.length} / 读回 ${String(rec.c_answer || '').length}`);
+  note('中文问题往返一致', rec.c_question === 'step0 问题:高新技术企业奖励是多少?', JSON.stringify(rec.c_question));
+
+  // ── 6. 反馈字段 update ────────────────────────────────────────────────
+  console.log('\n【6】1889 反馈字段');
+  const fb = await call('POST', '/content/updateContent', {
+    columnId: String(COL_RECORD),
+    modelId: String(MODEL_RECORD),
+    content: JSON.stringify({
+      id: rec.id,
+      c_feedback_status: 2,
+      c_feedback_option: '没有回答我的问题',
+      c_feedback_remark: 'step0 备注',
+      c_feedback_at: '2026-09-17 21:31:00',
+    }),
+  });
+  const recAfter = (await search(COL_RECORD, 'c_record_id', RECORD_ID)).json?.content?.data?.[0] || {};
+  note(
+    '反馈字段写入生效',
+    fb.json?.code === 200 && Number(recAfter.c_feedback_status) === 2 && recAfter.c_feedback_option === '没有回答我的问题',
+    `code=${fb.json?.code}, status=${recAfter.c_feedback_status}, option=${JSON.stringify(recAfter.c_feedback_option)}, at=${JSON.stringify(recAfter.c_feedback_at)}`
+  );
+
+  // ── 7. 中文 search 精确匹配(访客 访客_xxx 依赖)──────────────────────
+  console.log('\n【7】中文精确 search');
+  const cn = await search(COL_SESSION, 'c_credit_code', CREDIT);
+  note('中文/下划线 credit_code 精确命中', (cn.json?.content?.data || []).length > 0, `命中 ${(cn.json?.content?.data || []).length} 行`);
+
+  // ── 8. pageSize 上限 ─────────────────────────────────────────────────
+  console.log('\n【8】pageSize');
+  const big = await call('POST', '/content/selectContentList', {
+    columnId: String(COL_RECORD),
+    page: '0',
+    pageSize: '200',
+    search: JSON.stringify([{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }]),
+  });
+  note('pageSize=200 被接受', big.json?.code === 200 || big.json?.code === 202, `code=${big.json?.code}, 返回 ${(big.json?.content?.data || []).length} 行`);
+
+  // ── 9. delContentById 参数形态 ───────────────────────────────────────
+  console.log('\n【9】delContentById 参数形态');
+  const probe = await call('DELETE', `/content/delContentById?id=${encodeURIComponent(rec.id)}`);
+  const stillThere = ((await search(COL_RECORD, 'c_record_id', RECORD_ID)).json?.content?.data || []).length;
+  note('DELETE ?id=<uuid> 生效', stillThere === 0, `code=${probe.json?.code}, 剩余 ${stillThere} 行`);
+
+  // ── 10. 清理 1887 测试行 ─────────────────────────────────────────────
+  console.log('\n【10】清理');
+  const removed = await cleanup(COL_SESSION, 'c_session_id', SESSION_ID);
+  const left = await cleanup(COL_RECORD, 'c_record_id', RECORD_ID);
+  note('测试数据已清理', true, `1887 删 ${removed} 行 / 1889 删 ${left} 行`);
+
+  // ── 汇总 ─────────────────────────────────────────────────────────────
+  const failed = results.filter((r) => r.ok === false);
+  console.log(`\n===== 汇总:${results.length - failed.length}/${results.length} 通过 =====`);
+  if (failed.length) {
+    console.log('未通过项:');
+    failed.forEach((f) => console.log('  - ' + f.name + (f.detail ? '  ' + f.detail : '')));
+  }
+  console.log('\n关键结论(回填到计划/记录里):');
+  console.log('  addContent 是否需 c_id  :', addOk ? '不需要(自动生成)' : '被拒 → 需 DMS 侧调整模型');
+  console.log('  addContent 是否回 uuid  :', returnedUuid ? '回传,可省一次反查' : '不回传,需 search 反查');
+  console.log('  时间戳                  : 见【3】读回值');
+};
+
+run().catch((err) => {
+  console.error('\n脚本异常:', err);
+  process.exit(1);
+});

+ 150 - 0
harness/tools/validate-harness.mjs

@@ -0,0 +1,150 @@
+/**
+ * 校验 harness 结构本身是否符合约定。
+ *
+ * 为什么要有它:文档里写「计划要放 exec-plans/」「同一时间只能有一个 in_progress」,
+ * 这些都是**口头约定**——违反了没人知道。这个脚本把它变成**机械约束**:
+ * 违反时退出码非 0,收尾时跑一下就能发现。
+ *
+ * 怎么跑(在项目根目录):
+ *   node harness/tools/validate-harness.mjs
+ *
+ * 退出码:0 通过;1 有违规项
+ */
+
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const HARNESS = join(HERE, '..');
+const ROOT = join(HARNESS, '..');
+
+const problems = [];
+const warnings = [];
+const bad = (msg) => problems.push(msg);
+const warn = (msg) => warnings.push(msg);
+
+// ── 1. 必备文件存在 ────────────────────────────────────────────────────
+const REQUIRED = [
+  'README.md',
+  'progress.md',
+  'feature_list.json',
+  'init.sh',
+  'docs/architecture.md',
+  'docs/exec-plans/README.md',
+  'docs/exec-plans/tech-debt-tracker.md',
+  'docs/exec-plans/active',
+  'docs/exec-plans/completed',
+  'docs/reference/README.md',
+  'sops/session-start.md',
+  'sops/session-end.md',
+  'tools/README.md',
+  'tools/validate-harness.mjs',
+];
+for (const rel of REQUIRED) {
+  if (!existsSync(join(HARNESS, rel))) bad(`缺少必备文件/目录:harness/${rel}`);
+}
+
+// ── 2. 计划必须在仓库里(踩过的坑:写到 ~/.claude/plans/ 去了)─────────
+// 能机械检查的部分:仓库根目录下不该出现游离的计划文件
+const STRAY_PATTERNS = [/^plan[-_].*\.md$/i, /^exec-plan.*\.md$/i, /计划.*\.md$/];
+for (const name of existsSync(ROOT) ? readdirSync(ROOT) : []) {
+  if (STRAY_PATTERNS.some((re) => re.test(name))) {
+    bad(`仓库根目录出现游离的计划文件:${name}(应放进 harness/docs/exec-plans/active/)`);
+  }
+}
+// src/ 下也不该有
+const srcDir = join(ROOT, 'src');
+if (existsSync(srcDir)) {
+  const walk = (dir, depth = 0) => {
+    if (depth > 4) return;
+    for (const name of readdirSync(dir)) {
+      const p = join(dir, name);
+      if (statSync(p).isDirectory()) walk(p, depth + 1);
+      else if (/计划|plan[-_]/i.test(name) && name.endsWith('.md')) {
+        bad(`src/ 下出现计划文件:${p.replace(ROOT, '')}(应放进 harness/docs/exec-plans/active/)`);
+      }
+    }
+  };
+  walk(srcDir);
+}
+
+// ── 3. feature_list.json ───────────────────────────────────────────────
+let features = [];
+try {
+  const raw = JSON.parse(readFileSync(join(HARNESS, 'feature_list.json'), 'utf8'));
+  features = raw.features || [];
+  if (!raw.meta) warn('feature_list.json 缺少 meta 段');
+
+  const ids = new Set();
+  for (const f of features) {
+    if (!f.id) bad('feature_list.json 有条目缺少 id');
+    else if (ids.has(f.id)) bad(`feature_list.json 有重复 id:${f.id}`);
+    else ids.add(f.id);
+
+    const LEGAL = ['not_started', 'in_progress', 'blocked', 'passing'];
+    if (!LEGAL.includes(f.status)) bad(`功能 ${f.id} 的 status 非法:${f.status}`);
+
+    // 假 passing:passing 必须有 verification + evidence
+    if (f.status === 'passing') {
+      if (!Array.isArray(f.verification) || f.verification.length === 0) {
+        bad(`功能 ${f.id} 是 passing 但没有 verification(假 passing)`);
+      }
+      if (!f.evidence || !String(f.evidence).trim()) {
+        bad(`功能 ${f.id} 是 passing 但没有 evidence(假 passing)`);
+      }
+    }
+    if (f.status === 'blocked' && !String(f.notes || '').trim()) {
+      bad(`功能 ${f.id} 是 blocked 但没写清阻塞原因(notes 为空)`);
+    }
+  }
+
+  const inProgress = features.filter((f) => f.status === 'in_progress');
+  if (inProgress.length > 1) {
+    bad(`同一时间只能有一个 in_progress,实际有 ${inProgress.length} 个:${inProgress.map((f) => f.id).join(', ')}`);
+  }
+} catch (err) {
+  bad(`feature_list.json 无法解析:${err.message}`);
+}
+
+// ── 4. progress.md 里的会话编号不重复 ──────────────────────────────────
+try {
+  const text = readFileSync(join(HARNESS, 'progress.md'), 'utf8');
+  const seen = new Map();
+  for (const m of text.matchAll(/^## Session (\d+)/gm)) {
+    const n = Number(m[1]);
+    seen.set(n, (seen.get(n) || 0) + 1);
+  }
+  for (const [n, count] of seen) {
+    if (count > 1) bad(`progress.md 里 Session ${String(n).padStart(3, '0')} 出现了 ${count} 次`);
+  }
+  if (!seen.size) warn('progress.md 里没有任何 Session 记录');
+} catch (err) {
+  bad(`progress.md 读取失败:${err.message}`);
+}
+
+// ── 5. 活跃计划不该长期空着(提醒,不算错)────────────────────────────
+const activeDir = join(HARNESS, 'docs/exec-plans/active');
+if (existsSync(activeDir)) {
+  const actives = readdirSync(activeDir).filter((f) => f.endsWith('.md'));
+  if (!actives.length && features.some((f) => f.status === 'in_progress')) {
+    warn('有 in_progress 的功能,但 exec-plans/active/ 里没有计划文件——大改动应当先写计划');
+  }
+}
+
+// ── 输出 ───────────────────────────────────────────────────────────────
+console.log('harness 结构校验\n');
+if (warnings.length) {
+  console.log('提醒:');
+  warnings.forEach((w) => console.log('  ⚠️  ' + w));
+  console.log('');
+}
+if (problems.length) {
+  console.log('违规:');
+  problems.forEach((p) => console.log('  ❌ ' + p));
+  console.log(`\n不通过:${problems.length} 项违规`);
+  process.exit(1);
+}
+console.log(`✅ 通过(功能 ${features.length} 项,in_progress ${
+  features.filter((f) => f.status === 'in_progress').length
+} 项)`);

+ 196 - 0
harness/tools/verify-dms-chat-storage.mjs

@@ -0,0 +1,196 @@
+/**
+ * 端到端验证「会话 / 整段对话 / 反馈」在 DMS 里的读写(栏目 1887 / 1889)。
+ *
+ * 存储粒度约定(用户 2026-09-17 定的):
+ *   - 1887 助手会话:**一个会话一行**(标题、归属、更新时间)
+ *   - 1889 助手问答记录:**也是一个会话一行**,整段对话的消息数组以 JSON 存在 `c_answer`;
+ *     反馈记在 JSON 里对应那条消息上
+ *
+ * 验证什么:前端真实调用的那些函数(引用 src 里同一份实现,不是复制逻辑)
+ *   打到真实 DMS 上能否正确 增/改/查/删,尤其是:
+ *   ① 反复保存同一会话**只有一行**(不是一轮问答一行)
+ *   ② 反馈能在 JSON 里精确定位到某条消息
+ *   ③ 历史读回能与写入的消息一一对应
+ *
+ * 怎么跑(需要 dev server 起着,代理会注入 token):
+ *   1) npm run dev
+ *   2) npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
+ *        --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}'
+ *   3) node harness/tools/verify-dms-chat-storage.mjs https://localhost:8083/dms-api
+ *
+ * 脚本**不持有 token**——和浏览器一样只打代理地址。
+ */
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+
+const base = process.argv[2] || 'https://localhost:8083/dms-api';
+globalThis.VITE_DMS_API = base;
+
+const {
+  upsertDmsSession,
+  saveDmsTranscript,
+  fetchDmsSessions,
+  fetchDmsSessionRecords,
+  deleteDmsSession,
+  writeDmsFeedback,
+  resolveDmsCreditCode,
+  parseDmsTimestamp,
+  nowDmsTimestamp,
+  searchDmsContents,
+  deleteDmsContent,
+  DMS_COLUMN_SESSION,
+  DMS_COLUMN_RECORD,
+} = await import('./_dms.mjs');
+
+let pass = 0;
+let fail = 0;
+const check = (name, ok, extra = '') => {
+  if (ok) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+const TEST_CREDIT = `verify_cc_${Date.now().toString(36)}`;
+const SESSION_ID = `verify_sess_${Date.now().toString(36)}`;
+const MSG_USER_1 = 'msg_u1_' + Date.now().toString(36);
+const MSG_AI_1 = 'msg_a1_' + Date.now().toString(36);
+const MSG_USER_2 = 'msg_u2_' + Date.now().toString(36);
+const MSG_AI_2 = 'msg_a2_' + Date.now().toString(36);
+const TITLE_A = '验证会话标题';
+const TITLE_B = '验证会话标题(改过)';
+const Q1 = '高新技术企业的奖励是多少?';
+const A1 = '<!-- POLICY_TABLE {"data":[]} -->\n<scope title="思考中">\n</scope>\n第一轮回答。';
+const Q2 = '那申报条件呢?';
+const A2 = '第二轮回答,带换行。\n第二行。';
+
+/** 模拟前端的消息列表:两轮问答 */
+const transcriptAfterRound1 = [
+  { id: MSG_USER_1, role: 'user', content: Q1 },
+  { id: MSG_AI_1, role: 'ai', content: A1 },
+];
+const transcriptAfterRound2 = [
+  ...transcriptAfterRound1,
+  { id: MSG_USER_2, role: 'user', content: Q2 },
+  { id: MSG_AI_2, role: 'ai', content: A2 },
+];
+
+console.log(`\nDMS 会话/整段对话/反馈 端到端验证\n  代理地址: ${base}\n  测试标记: ${TEST_CREDIT}\n`);
+
+const rowsForSession = (columnId) =>
+  searchDmsContents(columnId, {
+    search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
+    page: 0,
+    pageSize: 20,
+  });
+
+const cleanup = async () => {
+  await deleteDmsSession(SESSION_ID);
+  for (const col of [DMS_COLUMN_SESSION, DMS_COLUMN_RECORD]) {
+    const rows = await searchDmsContents(col, {
+      search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }],
+      page: 0,
+      pageSize: 50,
+    });
+    for (const row of rows) if (row.id) await deleteDmsContent(col, String(row.id));
+  }
+};
+
+try {
+  // ── 1. 会话行 ──────────────────────────────────────────────────────
+  console.log('【1】会话(1887)');
+  check('upsertDmsSession 新增', (await upsertDmsSession({ sessionId: SESSION_ID, title: TITLE_A, creditCode: TEST_CREDIT })) === true);
+  let sessions = await fetchDmsSessions(TEST_CREDIT);
+  check('列表能查到', sessions.some((s) => s.session_id === SESSION_ID));
+  check('标题正确', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_A);
+  check('updated_at 合理', Math.abs((sessions.find((s) => s.session_id === SESSION_ID)?.updated_at || 0) - Date.now()) < 10 * 60 * 1000);
+
+  await upsertDmsSession({ sessionId: SESSION_ID, title: TITLE_B, creditCode: TEST_CREDIT });
+  sessions = await fetchDmsSessions(TEST_CREDIT);
+  check('改名后只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1);
+  check('标题已更新', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_B);
+
+  // ── 2. 整段对话:一轮一问一答 → 一行 ───────────────────────────────
+  console.log('\n【2】整段对话(1889)—— 粒度必须是「一会话一行」');
+  await saveDmsTranscript({ sessionId: SESSION_ID, messages: transcriptAfterRound1, creditCode: TEST_CREDIT });
+  let rows = await rowsForSession(DMS_COLUMN_RECORD);
+  check('第一轮后:只有一行', rows.length === 1, `实际 ${rows.length} 行`);
+
+  // ── 3. 第二轮:仍是同一行(不能变成两行)───────────────────────────
+  await saveDmsTranscript({ sessionId: SESSION_ID, messages: transcriptAfterRound2, creditCode: TEST_CREDIT });
+  rows = await rowsForSession(DMS_COLUMN_RECORD);
+  check('第二轮后:仍是同一行(不是一封问答一行)', rows.length === 1, `实际 ${rows.length} 行`);
+
+  const stored = JSON.parse(rows[0]?.c_answer || '{}');
+  check('存的是包装对象 {version, messages}', stored.version === 1 && Array.isArray(stored.messages), Object.keys(stored).join(','));
+  check('整段 4 条消息都在', stored.messages?.length === 4, `实际 ${stored.messages?.length} 条`);
+  check('消息顺序与角色正确', stored.messages?.map((m) => m.role).join(',') === 'user,ai,user,ai', stored.messages?.map((m) => m.role).join(','));
+  check('第一轮回答完整往返(含协议标记)', stored.messages?.[1]?.content === A1, `长 ${String(stored.messages?.[1]?.content || '').length}/${A1.length}`);
+  check('第二轮回答完整往返', stored.messages?.[3]?.content === A2);
+  check('消息 id 保留(反馈定位依赖)', stored.messages?.map((m) => m.id).join(',') === [MSG_USER_1, MSG_AI_1, MSG_USER_2, MSG_AI_2].join(','));
+  check('c_question 存了首问', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question));
+
+  // ── 4. 历史读回 ───────────────────────────────────────────────────
+  console.log('\n【4】历史读回');
+  const records = await fetchDmsSessionRecords(SESSION_ID);
+  check('读出两轮问答', records.length === 2, `实际 ${records.length} 轮`);
+  check('第一轮 question/answer 正确', records[0]?.question === Q1 && records[0]?.answer === A1);
+  check('第二轮 question/answer 正确', records[1]?.question === Q2 && records[1]?.answer === A2);
+  check('record_id 回填的是 AI 消息 id(反馈闭环)', records[0]?.record_id === MSG_AI_1 && records[1]?.record_id === MSG_AI_2,
+    `${records[0]?.record_id} / ${records[1]?.record_id}`);
+
+  // ── 5. 反馈:定位到 JSON 里那一条消息 ──────────────────────────────
+  console.log('\n【5】反馈(写进该会话行 JSON 的第二条 AI 消息)');
+  await writeDmsFeedback({ recordId: MSG_AI_2, sessionId: SESSION_ID, status: 2, option: '没有回答我的问题', remark: '验证备注' });
+  rows = await rowsForSession(DMS_COLUMN_RECORD);
+  check('反馈后仍是一行', rows.length === 1, `实际 ${rows.length} 行`);
+  const after = JSON.parse(rows[0]?.c_answer || '{}');
+  const fbMsg = after.messages?.find((m) => m.id === MSG_AI_2);
+  const untouched = after.messages?.find((m) => m.id === MSG_AI_1);
+  check('命中的那条消息带上了反馈', fbMsg?.feedback === 2, String(fbMsg?.feedback));
+  check('feedbackOption 正确', fbMsg?.feedbackOption === '没有回答我的问题', JSON.stringify(fbMsg?.feedbackOption));
+  check('feedbackRemark 正确', fbMsg?.feedbackRemark === '验证备注', JSON.stringify(fbMsg?.feedbackRemark));
+  check('feedbackAt 已写入', typeof fbMsg?.feedbackAt === 'number' && fbMsg.feedbackAt > 0, String(fbMsg?.feedbackAt));
+  check('**另一条消息没被误改**', untouched && untouched.feedback === undefined, JSON.stringify(untouched?.feedback));
+  check('对话内容未被反馈覆盖', after.messages?.length === 4 && after.messages[1].content === A1);
+
+  // 取消反馈
+  await writeDmsFeedback({ recordId: MSG_AI_2, sessionId: SESSION_ID, status: 0 });
+  rows = await rowsForSession(DMS_COLUMN_RECORD);
+  const cancelled = JSON.parse(rows[0]?.c_answer || '{}').messages?.find((m) => m.id === MSG_AI_2);
+  check('取消反馈 status=0', cancelled?.feedback === 0, String(cancelled?.feedback));
+
+  // 反馈定位不到时不能造出空行
+  const beforeCount = (await rowsForSession(DMS_COLUMN_RECORD)).length;
+  await writeDmsFeedback({ recordId: 'not_exist_msg', sessionId: SESSION_ID, status: 1 });
+  await writeDmsFeedback({ recordId: MSG_AI_1, sessionId: 'not_exist_session', status: 1 });
+  check('定位不到时不新增行', (await rowsForSession(DMS_COLUMN_RECORD)).length === beforeCount);
+
+  // ── 6. 删除 ───────────────────────────────────────────────────────
+  console.log('\n【6】删除会话');
+  check('deleteDmsSession 返回 true', (await deleteDmsSession(SESSION_ID)) === true);
+  check('会话行已删', !(await fetchDmsSessions(TEST_CREDIT)).some((s) => s.session_id === SESSION_ID));
+  check('对话行已连带删除', (await rowsForSession(DMS_COLUMN_RECORD)).length === 0);
+
+  // ── 7. 工具函数 ───────────────────────────────────────────────────
+  console.log('\n【7】时间戳与访客归属');
+  check('nowDmsTimestamp 格式', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(nowDmsTimestamp()), nowDmsTimestamp());
+  check('parseDmsTimestamp 毫秒', parseDmsTimestamp(1789651800000) === 1789651800000);
+  check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000);
+  check('parseDmsTimestamp 字符串', parseDmsTimestamp('2026-09-17 21:30:00') === 1789651800000, String(parseDmsTimestamp('2026-09-17 21:30:00')));
+  check('访客归属形态 访客_<id>', /^访客_.+/.test(resolveDmsCreditCode()), resolveDmsCreditCode());
+} finally {
+  console.log('\n【清理】');
+  await cleanup();
+  const left = [
+    ...(await searchDmsContents(DMS_COLUMN_SESSION, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }], page: 0, pageSize: 50 })),
+    ...(await searchDmsContents(DMS_COLUMN_RECORD, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT } }], page: 0, pageSize: 50 })),
+  ];
+  console.log(`  测试数据已清理(残留 ${left.length} 行)`);
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 110 - 0
harness/tools/verify-empty-content-agreement.mjs

@@ -0,0 +1,110 @@
+/**
+ * 验证「内容是否为空」的判空口径在两处保持一致。
+ *
+ * 验证什么:
+ *   这个 bug 的本质是**两处判空口径不一致**,所以这里断言的不变量就是"两处必须一致":
+ *   `hasVisibleMessageContent(c)` 为 false 时,
+ *     - `isInterruptedEmptyScopeMessage`(渲染层)必须判为"空的、要显示请求已取消"
+ *     - `normalizeSessionHistory`(归一化)必须把内容替换成 CANCELLED_SESSION_TEXT
+ *   反之必须都不成立。
+ *
+ *   触发场景:新协议的正文要等到 done 才写入,处理期间内容**只有 `<scope>` 进度块**;
+ *   此时切会话,归一化曾认为"有内容"(不替换文案)却把状态翻成 Finish,
+ *   渲染层则认为"没内容"→ 误报「请求已取消」。
+ *
+ * 怎么跑(在项目根目录):
+ *   npx esbuild harness/tools/_entry-empty-content.ts --bundle --format=esm \
+ *     --alias:@=./src --outfile=harness/tools/_empty-content.mjs
+ *   node harness/tools/verify-empty-content-agreement.mjs
+ */
+
+import {
+  hasVisibleMessageContent,
+  isInterruptedEmptyScopeMessage,
+  normalizeSessionHistory,
+  CANCELLED_SESSION_TEXT,
+} from './_empty-content.mjs';
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, extra = '') => {
+  if (cond) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+const SCOPE = '<scope title="正在检索相关政策和配套服务……">\n</scope>\n';
+const SCOPE2 = '<scope title="问题仍在处理中,请稍候……">\n</scope>\n';
+
+const CASES = [
+  { name: '空字符串', content: '', empty: true },
+  { name: '只有空白', content: '   \n  ', empty: true },
+  { name: '只有 1 个进度块', content: SCOPE, empty: true },
+  { name: '只有多个进度块(= 处理中的真实状态)', content: SCOPE + SCOPE2, empty: true },
+  { name: '只有 silence 标签', content: '<silence></silence>', empty: true },
+  { name: '进度块 + 正文', content: SCOPE + '这是回答正文。', empty: false },
+  { name: '纯正文', content: '这是回答正文。', empty: false },
+  { name: '正文 + 进度块', content: '这是回答正文。\n\n' + SCOPE, empty: false },
+  { name: '只有标记(POLICY_TABLE)', content: '<!-- POLICY_TABLE {"data":[]} POLICY_TABLE -->', empty: false },
+];
+
+console.log('=== 1. hasVisibleMessageContent 的基准判定 ===');
+CASES.forEach((c) => {
+  check(`「${c.name}」判为空=${c.empty}`, hasVisibleMessageContent(c.content) === !c.empty);
+});
+
+console.log('\n=== 2. 不变量:两处判空必须一致 ===');
+CASES.forEach((c) => {
+  // 渲染层:history 消息、已结束状态
+  const renderSaysEmpty = isInterruptedEmptyScopeMessage({
+    role: 'ai',
+    state: 3,
+    history: true,
+    content: c.content,
+  });
+
+  // 归一化:pending 状态的消息,看它是否把内容替换成"会话已经取消"
+  const session = {
+    id: 's1',
+    title: 't',
+    messages: [{ id: 'm1', role: 'ai', content: c.content, state: 1, history: false }],
+  };
+  normalizeSessionHistory(session);
+  const normalizeSaysEmpty = session.messages[0].content === CANCELLED_SESSION_TEXT;
+
+  check(
+    `「${c.name}」两处一致(渲染=${renderSaysEmpty} 归一化=${normalizeSaysEmpty})`,
+    renderSaysEmpty === normalizeSaysEmpty,
+    `期望两处都为 ${c.empty}`
+  );
+  check(`「${c.name}」都判为 ${c.empty ? '空' : '非空'}`, renderSaysEmpty === c.empty);
+});
+
+console.log('\n=== 3. 回归:正文不会被误替换 ===');
+{
+  const session = {
+    id: 's1',
+    title: 't',
+    messages: [{ id: 'm1', role: 'ai', content: SCOPE + '真实回答', state: 1, history: false }],
+  };
+  normalizeSessionHistory(session);
+  check('有正文时不替换成"会话已经取消"', session.messages[0].content !== CANCELLED_SESSION_TEXT);
+  check('但状态仍归一到 Finish', session.messages[0].state === 3);
+}
+{
+  const session = {
+    id: 's1',
+    title: 't',
+    messages: [{ id: 'm1', role: 'ai', content: SCOPE, state: 1, history: false }],
+  };
+  normalizeSessionHistory(session);
+  check('只有进度块时替换成"会话已经取消"(不再让渲染层误报"请求已取消")',
+    session.messages[0].content === CANCELLED_SESSION_TEXT);
+}
+
+console.log(`\n通过 ${pass} 项,失败 ${fail} 项`);
+process.exit(fail ? 1 : 0);

+ 129 - 0
harness/tools/verify-policy-library.mjs

@@ -0,0 +1,129 @@
+/**
+ * 验证「惠企政策」判定与置顶逻辑。
+ *
+ * 验证什么:
+ *   1. 惠企政策判定——优先用接口字段 is_policy_library,缺失时回退到标题匹配,
+ *      标题匹配要能容忍《》全半角等差异
+ *   2. 置顶——本轮匹配到的惠企政策排在最前,其余保持库内原顺序
+ *
+ * 怎么跑(在项目根目录):
+ *   npm run build:tools   # 或手工:npx esbuild <入口> --bundle ...
+ *   node harness/tools/verify-policy-library.mjs
+ *
+ * 说明:本脚本直接引用 src 里的同一份实现(policy-library-utils.ts),
+ *      不是复制一份逻辑——复制会漂移,测的就不是真代码了。
+ */
+
+import { createRequire } from 'node:module';
+import {
+  normPolicyName,
+  buildLibraryNameSet,
+  isPolicyLibraryItem,
+  collectMatchedLibraryNames,
+  pinMatchedFirst,
+  buildPolicyDetailUrl,
+  POLICY_DETAIL_URL_BASE,
+} from './_policy-library-utils.mjs';
+
+const require = createRequire(import.meta.url);
+const library = require('../../public/merchant-agent/policies_public.v1.json');
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, extra = '') => {
+  if (cond) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+console.log('惠企政策库:' + library.length + ' 条');
+const nameSet = buildLibraryNameSet(library);
+console.log('去重后政策名:' + nameSet.size + ' 个\n');
+
+console.log('=== 1. 名称归一化 ===');
+check(
+  '《》差异可忽略',
+  normPolicyName('《上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施》') ===
+    normPolicyName('上海市青浦区加强知识产权保护、促进质量提升和标准体系建设的政策措施')
+);
+check('括号差异可忽略', normPolicyName('政策(试行)') === normPolicyName('政策(试行)'));
+check('空白差异可忽略', normPolicyName(' 政策 A ') === normPolicyName('政策A'));
+
+console.log('\n=== 2. 惠企政策判定 ===');
+const sampleName = String(library[0].name).replace(/[《》]/g, '');
+check(
+  '标题能在库里找到 → 是惠企政策',
+  isPolicyLibraryItem({ title: sampleName }, nameSet)
+);
+check(
+  '标题带《》也能找到 → 是惠企政策',
+  isPolicyLibraryItem({ title: '《' + sampleName + '》' }, nameSet)
+);
+check(
+  '接口字段 is_policy_library=true → 是惠企政策',
+  isPolicyLibraryItem({ title: '库里没有的名字', originalData: { is_policy_library: true } }, nameSet)
+);
+check(
+  '库中没有且无字段 → 不是惠企政策',
+  !isPolicyLibraryItem({ title: '这个名字肯定不在惠企政策库里' }, nameSet)
+);
+check('空标题 → 不是惠企政策', !isPolicyLibraryItem({ title: '' }, nameSet));
+
+console.log('\n=== 3. 收集本轮匹配到的惠企政策 ===');
+const cards = [
+  { title: sampleName }, // 命中
+  { title: '不在库里的政策' },
+  { title: '库里没有但后端标记了', originalData: { is_policy_library: true } },
+];
+const matched = collectMatchedLibraryNames(cards, nameSet);
+check('收集到 2 条命中', matched.size === 2, '实际 ' + matched.size);
+
+console.log('\n=== 4. 置顶排序 ===');
+const items = [
+  { title: 'A-未命中' },
+  { title: sampleName }, // 命中,应置顶
+  { title: 'B-未命中' },
+  { title: '库里没有但后端标记了', originalData: { is_policy_library: true } }, // 命中,应置顶
+  { title: 'C-未命中' },
+];
+const ordered = pinMatchedFirst(items, matched, (i) => i.title);
+check('命中项排在最前', ordered.slice(0, 2).every((i) => matched.has(normPolicyName(i.title))), JSON.stringify(ordered.map((i) => i.title)));
+check('未命中项保持原顺序', JSON.stringify(ordered.slice(2).map((i) => i.title)) === JSON.stringify(['A-未命中', 'B-未命中', 'C-未命中']), JSON.stringify(ordered.slice(2).map((i) => i.title)));
+check('总数不变', ordered.length === items.length);
+check('无命中时顺序不变', JSON.stringify(pinMatchedFirst(items, new Set(), (i) => i.title).map((i) => i.title)) === JSON.stringify(items.map((i) => i.title)));
+
+console.log('\n=== 5. 真实场景抽样(库里前 3 条政策名)===');
+library.slice(0, 3).forEach((p) => {
+  const t = String(p.name).replace(/[《》]/g, '');
+  console.log('  ' + (isPolicyLibraryItem({ title: t }, nameSet) ? '✓' : '✗') + ' ' + t.slice(0, 40));
+});
+
+console.log('\n=== 6. 政策详情页地址(由 市级政策id 拼)===');
+{
+  const withId = { data: { '市级政策id': '676eb30861aee302a8607104' } };
+  check(
+    '有 id → 拼出正确地址',
+    buildPolicyDetailUrl(withId) === POLICY_DETAIL_URL_BASE + '?id=676eb30861aee302a8607104',
+    buildPolicyDetailUrl(withId)
+  );
+  check('无 id → 空串(不猜)', buildPolicyDetailUrl({ data: {} }) === '');
+  check('data 缺失 → 空串', buildPolicyDetailUrl({}) === '');
+  check('null → 空串', buildPolicyDetailUrl(null) === '');
+  check('id 为空白 → 空串', buildPolicyDetailUrl({ data: { '市级政策id': '   ' } }) === '');
+  check('id 前后空格 → 去掉', buildPolicyDetailUrl({ data: { '市级政策id': ' abc ' } }) === POLICY_DETAIL_URL_BASE + '?id=abc');
+  check('特殊字符 → 编码', buildPolicyDetailUrl({ data: { '市级政策id': 'a&b=c' } }) === POLICY_DETAIL_URL_BASE + '?id=a%26b%3Dc');
+
+  // 真实库覆盖率
+  const covered = library.filter((p) => buildPolicyDetailUrl(p)).length;
+  console.log('  真实库覆盖率: ' + covered + ' / ' + library.length);
+  check('覆盖 261/262', covered === 261, '实际 ' + covered);
+  const sample = library.find((p) => buildPolicyDetailUrl(p));
+  console.log('  示例: ' + buildPolicyDetailUrl(sample));
+}
+
+console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
+process.exit(fail ? 1 : 0);

Some files were not shown because too many files changed in this diff