Преглед изворни кода

feat(company): 用真实载荷打通端到端,更正两条结论并补契约文档

用户给了真实的 company_info(上海元以数智科技有限公司),据此重测,两个结论翻了:

1. classify 不是「后端坏了」,而是对**载荷完整性**的校验:
   - 精简/合成请求体(连文档自己的示例片段)→ 422
   - 真实完整 {company, profile, honors} → **200**,7 秒返回六角度真实分类结果
   上一轮把它记成「任何带内容的请求体都 422、需后端排查」是不准确的,已更正,
   并把 company_info 的契约章节补进 api-chat.md(含实测结构、取值顺序、注意点)

2. 1888 / 1886 **都不是空的**:2026-09-17 18:12 由 DMS 侧灌过数据——1888 是完整工商信息行,
   1886 是真实荣誉行(c_source = 来源通知名)。所以同步多数时候走「更新已存在的行」,
   只动身份三字段 + c_tag_*,其余工商字段不碰;1886 的所有权标记使其绝不误删灌入的荣誉行。
   reference/README.md 里我当轮刚写的「F4 未开始、这两栏目只有前端写的行」已改回事实。

新增两个验证脚本:
- verify-company-info-passthrough.mjs(10 项):假 SSE 流喂真实协调器,断言 result 里的
  company_info 原样带出到 totalResponse、不混进渲染正文、跨轮不沿用(resetTurnContent 生效)
- verify-company-classify-e2e.mjs(16 项):真实载荷 → 真实分类接口 → 真实 DMS 落库;
  断言六角度正确落位、1886 行数=标签数、再落一次零新增

过程中修正三条自己的错:①e2e 首跑 3 项 FAIL 是断言写错(空标签不落库,读回是字段不存在,
语义等同,应按解码后的集合比)②shell 里 curl -d 传中文会被 Git Bash 弄成 422,要用
node 写文件 + --data-binary ③上面那条 classify 结论。

验证:纯逻辑 53/53、适配层直通 10/10、真实 DMS 23/23、端到端 16/16;build 与 validate-harness 通过。
⚠️ 浏览器端到端仍未做(自动化证据齐备,只差真人点一下)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 2 дана
родитељ
комит
487ba8c806

+ 9 - 5
README.md

@@ -248,11 +248,15 @@
   - 全程 fire-and-forget:任何失败只 warn,不影响聊天;同企业并发走串行链
   - 适配层新增捕获 `company_info`(原先 `result` 分支是空的、数据被丢弃)
   - 回归脚本:`verify-company-classify-sync.mjs`(53 项纯逻辑)、
-    `verify-company-classify-dms.mjs`(23 项打真实 DMS)
-  - ⚠️ **分类接口目前对任何带内容的请求体返回 422**(连文档自己的示例也被拒,只有空对象
-    200)——属**后端问题**,已反馈;因此真实分类链路尚未端到端验证,
-    DMS 侧用注入假响应验证通过
-  - DMS 侧字段已就绪(1888 扩到 47 字段、1886 扩到 12 字段,含 `c_tag_*` 分类标签字段)
+    `verify-company-info-passthrough.mjs`(10 项,假 SSE 流验证适配层把 company_info 原样带出)、
+    `verify-company-classify-dms.mjs`(23 项打真实 DMS)、
+    `verify-company-classify-e2e.mjs`(16 项端到端:真实分类接口 + 真实 DMS)
+  - ⚠️ **分类接口对「精简/合成」的请求体一律 422**(连接口文档里自己的示例片段也被拒),
+    换成**真实完整的 company_info** 就返回 200 —— 是对载荷完整性的校验,不是接口坏了;
+    已在 `api-chat.md` 补上 company_info 的契约章节
+  - DMS 侧字段已就绪(1888 扩到 47 字段、1886 扩到 12 字段,含 `c_tag_*` 分类标签字段);
+    **两栏目都已有数据**(DMS 侧 2026-09-17 灌入),所以同步多为「更新已存在的行」,
+    只动身份三字段与标签字段;1886 靠 `c_source` 标记所有权,绝不碰灌入的荣誉行
 - 移除移动端 `@media (max-width: 360px)` 里那条 `.heading { font-size: 28px }`:
   它最初与基础值同值(空操作),09-18 基础值改小后**变成窄屏把标题放大回 28px** 的覆盖,
   而标题是 `white-space: nowrap` —— 360px 屏下标题宽 356px、可用仅 272px,**会溢出屏幕 84px**;

+ 9 - 7
harness/docs/exec-plans/active/company-classify-dms-sync.md

@@ -35,13 +35,15 @@ DMS 的企业基础信息(1888)与企业荣誉信息(1886)两个栏目
 | 探测 | 结果 |
 |---|---|
 | classify 空体 `{}` | **200**,返回六角度全空 + `input_warnings:["company_info_empty"]`,不调用 LLM |
-| classify 任何带键的体(文档示例 / ASCII / 中文 / `company:{}` / `profile:{}` / `honors:null` / `company_info:{}` / `Company:{}`) | **全部 422 `invalid_request`**(响应头有 `x-request-id`,可据此找后端日志) |
-| `POST /dms/model/getModelById`(form `modelId=2036`/`2032`) | **200**,拿到完整 fieldList:1888 47 字段 / 1886 12 字段,`c_tag_*` 已存在 |
-| 1888 must | 仅 `c_credit_code` |
-| 1886 must | `c_id` + `c_credit_code` |
-
-> ⚠️ classify 的 422 是**后端问题**(前端已排除编码/引号/键名因素),需后端排查;
-> 在它修好之前,「真实分类响应」这条链路只能靠**注入假响应**验证。
+| classify **精简/合成**载荷(文档里的示例片段 / 纯 ASCII / `company:{}` / `profile:{}` / `honors:null` / `company_info:{}`) | **全部 422 `invalid_request`** |
+| classify **真实完整**载荷(用户给的真实 company_info) | ✅ **200**,7 秒返回六角度真实分类结果 |
+| `POST /dms/model/getModelById`(form `modelId=2036`/`2032`) | **200**:1888 47 字段 / 1886 12 字段,`c_tag_*` 已存在 |
+| 1888 must / 1886 must | 仅 `c_credit_code` / `c_id` + `c_credit_code` |
+| **1888 / 1886 的实际数据** | **两栏目都已有数据**(2026-09-17 18:12 由 DMS 侧灌入):1888 是完整工商信息行,1886 是真实荣誉行(`c_source` = 来源通知名)。**原文档「仍是空的」已过期** |
+| 空标签的存储 | 写 `[]` 读回来是**字段不存在**(语义等同);`c_created_at` 在 1888 里写了读不回 |
+
+> 📌 **更正**:本轮最初把 classify 的 422 记成「后端坏了」,**不准确**——它是对**载荷完整性**的
+> 校验:精简/合成载荷被拒,真实完整载荷正常。已在 `api-chat.md` 的 company_info 章节写明。
 
 ## 步骤
 

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

@@ -122,6 +122,9 @@
 > ⚠️ **行业信息用 `c_tag_industry`**,别和原有的 `c_industry`(源库行业分类 JSON)混了。
 > ⚠️ `c_tag_honor` 的控件类型是 `content`(富文本),其余是 text——2026-09-18 实测
 > JSON 字符串**逐字往返正常**(见 `verify-company-classify-dms.mjs` 的专项断言)。
+> ⚠️ **空数组不落库**:写 `[]`(空标签)时读回来是**字段不存在**——两者语义相同,
+> 同步逻辑按集合比较,不会因此反复写(见 `diffEnterpriseRow`)。
+> ⚠️ **`c_created_at` 在这个栏目里不生效**:写了也读不回来(实测;1887/1889 是正常的)。
 
 **主体字段**
 

+ 11 - 6
harness/docs/reference/README.md

@@ -26,12 +26,17 @@
 > - **1887 助手会话 / 1889 助手问答记录**:前端已接入(会话、整段对话、反馈都写这里),
 >   属于**在用的在线数据源**。见 [`../exec-plans/active/dms-chat-storage.md`](../exec-plans/active/dms-chat-storage.md)
 >   (注:1887 的写入已于 2026-09-18 按用户指示**停用**,只保留读取与删除清理)
-> - **1886 企业荣誉信息 / 1888 企业基础信息**:**前端已开始写入**(企业信息分类同步,
->   见 [`company-classification.md`](company-classification.md) 与
->   [`../exec-plans/active/company-classify-dms-sync.md`](../exec-plans/active/company-classify-dms-sync.md))——
->   两栏目的模型已分别扩到 12 / 47 个字段(新增的 `c_tag_*` 就是分类标签)。
->   **F4 数据迁移仍未开始**,所以这两栏目里目前只有前端同步写进去的行,
->   源库(`qcc_enterprise` 8.1 万行 / `qcc_honor` 7 千行)的行还没迁过来
+> - **1886 企业荣誉信息 / 1888 企业基础信息**:**两栏目都已有数据**(2026-09-17 18:12 由
+>   DMS 侧 `ams_user` 灌入,不再是空的——此前文档里「仍是空的」已过期)。
+>   1888 的行是完整工商信息(企业名/状态/地址/经营范围等),**没有** `c_tag_*`;
+>   1886 的行是真实荣誉记录(`c_level` 国家级/区级、系统 `title` 存荣誉名、
+>   `c_source` 存来源通知名),**没有** `c_tag_name`。
+> - **前端的企业信息分类同步**会写这两栏目(见
+>   [`company-classification.md`](company-classification.md) 与
+>   [`../exec-plans/active/company-classify-dms-sync.md`](../exec-plans/active/company-classify-dms-sync.md)):
+>   1888 按 `c_credit_code` **更新已存在的行**(只动身份三字段 + 6 个 `c_tag_*`,其余工商字段不碰),
+>   库里没有的企业才新增;1886 只增删带 `c_source=company_info_sync` 标记的行,
+>   **灌入的荣誉行一律不碰**
 > - **1885 页面浏览**:仍是空的(统计埋点已于 2026-09-18 删除,前端不再写)
 
 ## 现行契约:`POST /api/chat`

+ 35 - 1
harness/docs/reference/api-chat.md

@@ -77,7 +77,7 @@ data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f
 | item | source_id、card、answer、conditions等 | 渲染单张政策/服务推荐卡片 |
 | summary | text、source_ids、status、notices | 显示末尾综合说明 |
 | interrupt | kind、question、input_help等 | 提示用户补充公司信息或确认候选 |
-| result | response、recommendation、errors | 当前轮次的完整最终快照 |
+| result | response、recommendation、errors、company_info | 当前轮次的完整最终快照 |
 | done | status | 本次流正常结束或等待输入 |
 | error | code,可选message | 本次流异常结束 |
 
@@ -163,6 +163,40 @@ data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f
 }
 ```
 
+### company_info(`result.data.company_info`)
+
+公司类问题会额外带回企业资料,**结构是 `{company, profile, honors}` 三段**(2026-09-18 实测,
+取自一次真实请求;只列关键字段,实际字段更多):
+
+```json
+{
+  "company": {
+    "KeyNo": "aaj9myb…", "Name": "上海某某科技有限公司", "CreditCode": "91310118MA1JLRL16G",
+    "StartDate": "2017-03-16", "OperName": "黄朝梁", "Status": "存续", "No": "310118003427665",
+    "Address": "上海市青浦区…"
+  },
+  "profile": {
+    "data": { "KeyNo": "…", "Name": "…", "CreditCode": "…", "OperName": "…", "Status": "存续(在营、开业、在册)",
+              "RegistCapi": "1000万元", "Address": "…", "Scope": "一般项目:…", "EconKind": "…",
+              "StartDate": "2017-03-16 00:00:00", "Area": {"Province":"上海市","City":"上海市","County":"青浦区"},
+              "OriginalName": [{"Name":"曾用名","ChangeDate":"2024-07-22"}], "…": "还有 30 余个工商字段" },
+    "source": { "provider": "企查查", "api_code": "410", "fetched_at": "2026-09-18T06:30:32+00:00" }
+  },
+  "honors": { "records": [], "sources": [{"provider":"企查查","api_code":"1001"}], "complete": true, "error": null }
+}
+```
+
+用法与注意事项:
+
+- **取值顺序**:企业名/信用代码/法人取 `profile.data.Name/CreditCode/OperName`,
+  缺失才回退 `company` 同名字段;都可能为 `null`(无企业资料时)
+- 该字段**不是每轮都有**:只有识别到公司的对话才带;`result` 缺失该键就是没有
+- 前端**不解析、不渲染**它,只把它原样交给企业信息同步(见
+  [`company-classification.md`](company-classification.md))——
+  分类接口的请求体就是它,**不要包外层、不要塞 thread_id**
+- ⚠️ 实测:分类接口对**精简/合成的请求体一律 422**,必须给完整的 `{company, profile, honors}`
+  结构(2026-09-18:文档里那个合成片段也被拒,换成真实抓下来的整份才 200)
+
 `done.data`:
 
 ```json

Разлика између датотеке није приказан због своје велике величине
+ 4 - 2
harness/feature_list.json


+ 43 - 0
harness/progress.md

@@ -2513,3 +2513,46 @@
 - **下一步最佳动作**:把 422 反馈给后端;修好后跑一次真实分类 + 浏览器端到端,
   通过后本条转 `passing`
 
+### 续:用户给了真实载荷 → 端到端打通,并更正上一段的两个结论
+
+用户贴了一份真实的 `company_info`(上海元以数智科技有限公司)。用它一跑,**结论翻了**:
+
+1. 📌 **classify 不是「后端坏了」** —— 它是对**载荷完整性**的校验:
+   **精简/合成的请求体一律 422**(连接口文档里自己的示例片段都被拒),
+   **真实完整的 `{company, profile, honors}` 返回 200**(7 秒,六角度真实结果:
+   基本信息=成立年限5-10年/是否内资公司、产业信息=第三产业/软件信息、经营活动=是否在青浦区实际经营)。
+   → 上一段把它记成「任何带内容的请求体都 422 / 需后端排查」**是不准确的**,特此更正;
+   已写进 `api-chat.md` 的 company_info 章节
+2. 📌 **1888 / 1886 都不是空的** —— 2026-09-17 18:12 由 DMS 侧以 `ams_user` 灌过数据:
+   - 1888 的行是**完整工商信息**(53 个键:企业名/状态/地址/经营范围/注册资本…)
+   - 1886 的行是**真实荣誉记录**(`c_level` 国家级/区级、系统 `title` 存荣誉名、
+     `c_source` 存来源通知名,如「关于公布第三批国家邮政局技术研发中心认定结果的通知」)
+   → 所以同步**多数时候走的是「更新已存在的行」**:只动身份三字段 + 6 个 `c_tag_*`,
+     其余工商字段不碰;1886 的所有权标记让它绝不会误删灌入的荣誉行(这条已实测)
+   → `reference/README.md` 里我当轮刚写的「F4 未开始、这两栏目只有前端写的行」**已改回事实**
+
+**本轮补充的验证(两个新脚本 + 一次真实探测)**:
+
+- 🆕 `verify-company-info-passthrough.mjs` **10/10**:用**假 SSE 流喂真实协调器**,
+  断言 `result` 里的 company_info 原样带出到 `totalResponse`、**不混进渲染正文**、
+  无该字段时不带键、null 不传、**跨轮不沿用上一轮**(resetTurnContent 生效)
+- 🆕 `verify-company-classify-e2e.mjs` **16/16**:真实 company_info → 真实分类接口 →
+  真实 DMS 落库;断言六角度正确落位、1886 行数=标签数、再落一次零新增
+- **真实探测 `/api/chat`**:公司类提问的 SSE 里,`result.data` 的键为
+  `response / company_info / recommendation / errors` —— **`company_info` 确实存在且拼写正确**
+  (用户贴的 "compang_info" 是笔误),结构与代码假设逐字段一致
+
+**过程中修正了三条我自己的错**:
+1. e2e 首跑 3 项 FAIL,是**断言写错**:空角度的期望值是 `[]`,而库里读回来是「字段不存在」
+   —— DMS **不落空值**;已改成按解码后的集合比较(代码本身一直是对的,diff 判定「无变化零写入」)
+2. 中文请求体用 shell 里的 `curl -d` 会被 Git Bash 弄成 422(`/api/chat` 也一样),
+   **要用 node 写成文件 + `--data-binary @file`** —— 这条踩了两次,值得记住
+3. 上一段那条「classify 全量 422」的结论(见上)
+
+**数据现状**:真实企业「上海元以数智科技有限公司」(91310118MA1JLRL16G)的 1888 行
+已被同步更新(新增 `c_tag_basic` / `c_tag_sector` / `c_tag_operation`),这是功能的正常行为、
+且幂等;需要删可用 e2e 脚本的 `--cleanup`。
+
+**仍未做**:**浏览器端到端**(自动化证据已齐,只差真人在页面上问一个公司类问题)。
+验证脚本清单已更新进 `harness/tools/README.md`。
+

+ 3 - 1
harness/tools/README.md

@@ -61,7 +61,9 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 | `_dms.mjs` | `verify-dms-chat-storage.mjs` | ✅ 真实 DMS(需 dev server 起着) |
 | `_dms.mjs` | `verify-dms-payload-fields.mjs` | ❌ 打桩 fetch 抓请求体 |
 | `_company-classify.mjs` | `verify-company-classify-sync.mjs` | ❌ 纯逻辑 |
-| `_company-classify.mjs` | `verify-company-classify-dms.mjs` | ✅ 真实 DMS(**注入假分类响应**,原因见该脚本头部注释) |
+| `_company-classify.mjs` | `verify-company-classify-dms.mjs` | ✅ 真实 DMS(**注入假分类响应**,可反复跑) |
+| `_company-classify.mjs` | `verify-company-classify-e2e.mjs` | ✅ 真实分类接口 + 真实 DMS(要一份**真实形态**的 company_info JSON,见脚本头注释) |
+| `_coordinator.mjs` | `verify-company-info-passthrough.mjs` | ❌ 打桩 fetch 喂假 SSE 流 |
 
 > `--define:import.meta.env='{}'`:验证脚本跑在 node 里没有 `import.meta.env`,
 > 需要喂一个空对象,否则读取环境变量的模块会直接抛错。

+ 2 - 0
harness/tools/_entry-company-classify.ts

@@ -32,6 +32,8 @@ export { applyCompanyClassification } from '../../src/network/api/dms/company-in
 
 /** 纯函数:base URL → 分类接口地址(两种 VITE_CHAT_API 写法都要对) */
 export { resolveClassifyEndpoint } from '../../src/network/api/company-classification';
+/** 端到端脚本用:走前端自己的网络层真的打一次分类接口 */
+export { classifyCompany } from '../../src/network/api/company-classification';
 
 /** 打真实 DMS 的验证脚本要用的客户端函数与栏目常量 */
 export {

+ 186 - 0
harness/tools/verify-company-classify-e2e.mjs

@@ -0,0 +1,186 @@
+/**
+ * **端到端**验证:真实 company_info → 真实分类接口 → 真实 DMS 两栏目。
+ *
+ * 与 `verify-company-classify-dms.mjs` 的分工:
+ *   - 那个脚本**注入假分类响应**,验证「拿到结果之后」的落库链路(不需要后端配合,可反复跑)
+ *   - 本脚本用**真实载荷**打真实分类接口,验证最后一公里(会真的调用 6 次 LLM、会真的写 DMS)
+ *
+ * 为什么载荷要从文件读、不内置:
+ *   分类接口对**合成/精简的请求体一律返回 422**(2026-09-18 实测:文档里的示例片段、
+ *   各种键名、`{"company":{}}` 全被拒;换成真实抓下来的 `company_info` 才 200)。
+ *   所以这里不硬编码夹具,由调用方给一份**真实形态**的 company_info JSON。
+ *
+ * 怎么跑:
+ *   1) npm run dev
+ *   2) 把 /api/chat 的 result 事件里 data.company_info 对象存成 JSON 文件(整个对象,
+ *      不含外层 company_info 键),例如 /tmp/company-info.json
+ *   3) node harness/tools/verify-company-classify-e2e.mjs <company-info.json> [--cleanup]
+ *
+ * ⚠️ **默认不清理**:这份是真实企业数据,落库就是功能的正常行为(幂等,重复跑只更新)。
+ *    加 `--cleanup` 会在验证后把该企业在本轮写下的行删掉(调试用)。
+ */
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+
+import { readFileSync } from "node:fs";
+
+const chatBase = process.env.CHAT_BASE || "https://localhost:8083/chat-api";
+const dmsBase = process.env.DMS_BASE || "https://localhost:8083/dms-api";
+globalThis.VITE_CHAT_API = chatBase;
+globalThis.VITE_DMS_API = dmsBase;
+
+const payloadPath = process.argv[2];
+const cleanup = process.argv.includes("--cleanup");
+if (!payloadPath) {
+  console.error("用法:node verify-company-classify-e2e.mjs <company-info.json> [--cleanup]");
+  process.exit(2);
+}
+
+const {
+  classifyCompany,
+  applyCompanyClassification,
+  extractIdentityFromCompanyInfo,
+  HONOR_SYNC_SOURCE,
+  HONOR_TAG_FIELD,
+  ANGLE_TO_FIELD,
+  DMS_COLUMN_ENTERPRISE,
+  DMS_COLUMN_HONOR,
+  deleteDmsContent,
+  searchDmsContents,
+} = await import("./_company-classify.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 === "" ? "" : ` → ${JSON.stringify(extra)}`}`);
+  }
+};
+
+const companyInfo = JSON.parse(readFileSync(payloadPath, "utf8"));
+const identity = extractIdentityFromCompanyInfo(companyInfo);
+const creditCode = identity?.creditCode;
+console.log(`载荷:${payloadPath}`);
+console.log(`企业:${identity?.name || "?"} / ${creditCode || "?"}\n`);
+if (!creditCode) {
+  console.error("载荷里没有统一社会信用代码,无法继续");
+  process.exit(2);
+}
+
+const findEnterprise = () =>
+  searchDmsContents(DMS_COLUMN_ENTERPRISE, {
+    search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
+    page: 0,
+    pageSize: 10,
+  });
+const findHonors = () =>
+  searchDmsContents(DMS_COLUMN_HONOR, {
+    search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
+    page: 0,
+    pageSize: 200,
+  });
+
+/* ---------------------------------------------------------------- *
+ * ① 真实分类接口
+ * ---------------------------------------------------------------- */
+console.log("【1】真实分类接口(会调用 6 次 LLM,稍慢)");
+const before = Date.now();
+const classification = await classifyCompany(companyInfo);
+console.log(`  (耗时 ${((Date.now() - before) / 1000).toFixed(1)}s)`);
+check("接口返回了结果(非 null)", !!classification);
+check(
+  "返回的是 completed/partial(不是 failed)",
+  classification?.status === "completed" || classification?.status === "partial",
+  classification?.status
+);
+check("credit_code 与载荷一致", classification?.credit_code === creditCode, classification?.credit_code);
+const cats = classification?.categories || {};
+const angles = Object.keys(cats);
+check("六个角度键齐全", angles.length === 6, angles);
+check(
+  "每个角度都是数组",
+  angles.every((k) => Array.isArray(cats[k])),
+  angles.map((k) => `${k}:${Array.isArray(cats[k])}`)
+);
+console.log(`  分类结果:${JSON.stringify(cats)}`);
+
+/* ---------------------------------------------------------------- *
+ * ② 落库(真实 DMS)
+ * ---------------------------------------------------------------- */
+console.log("\n【2】落库到 DMS 1888 / 1886");
+await applyCompanyClassification(classification, identity);
+
+const rows = await findEnterprise();
+check("1888 有且只有一行", rows.length === 1, rows.length);
+const row = rows[0] || {};
+check("企业名/信用代码/法人已写入", row.c_name === identity.name && row.c_credit_code === creditCode && row.c_oper_name === identity.legalRep, {
+  c_name: row.c_name,
+  c_credit_code: row.c_credit_code,
+  c_oper_name: row.c_oper_name,
+});
+/**
+ * 库里的标签字段有两种合法形态:
+ *   - 有标签 → JSON 数组字符串(如 `["第三产业","软件信息"]`)
+ *   - 无标签 → **字段不存在**(DMS 不落空值;`[]` 也不会被写下去)
+ * 所以断言要按「解码后的集合」比,不能按字符串比。
+ */
+const decodeTags = (value) => {
+  if (typeof value !== "string" || !value.trim()) return [];
+  try {
+    const parsed = JSON.parse(value);
+    return Array.isArray(parsed) ? parsed : [];
+  } catch {
+    return [];
+  }
+};
+const sameSet = (a, b) => a.length === b.length && b.every((x) => a.includes(x));
+
+for (const angle of angles) {
+  const field = ANGLE_TO_FIELD[angle];
+  const expectedTags = cats[angle] || [];
+  const storedTags = decodeTags(row[field]);
+  check(
+    `1888 ${angle} → ${field}${expectedTags.length === 0 ? "(空集:字段不落库,语义等同)" : ""}`,
+    sameSet(storedTags, expectedTags),
+    { stored: row[field], storedTags, expectedTags }
+  );
+}
+
+const honors = await findHonors();
+const owned = honors.filter((r) => r.c_source === HONOR_SYNC_SOURCE);
+check("1886 我方行数 = 资质荣誉标签数", owned.length === (cats["资质荣誉"] || []).length, {
+  owned: owned.length,
+  tags: cats["资质荣誉"],
+});
+
+/* ---------------------------------------------------------------- *
+ * ③ 幂等:再落一次
+ * ---------------------------------------------------------------- */
+console.log("\n【3】同样结果再落一次 → 零新增");
+await applyCompanyClassification(classification, identity);
+const rows2 = await findEnterprise();
+check("1888 仍是一行、uuid 未变", rows2.length === 1 && rows2[0]?.id === row.id, rows2.length);
+check(
+  "1886 行数未变",
+  (await findHonors()).filter((r) => r.c_source === HONOR_SYNC_SOURCE).length === owned.length
+);
+
+/* ---------------------------------------------------------------- *
+ * ④ 可选清理
+ * ---------------------------------------------------------------- */
+if (cleanup) {
+  console.log("\n【4】--cleanup:删除本轮写入的行");
+  for (const r of await findEnterprise()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_ENTERPRISE, r.id);
+  for (const r of await findHonors()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_HONOR, r.id);
+  check("1888 残留 0 行", (await findEnterprise()).length === 0);
+  check("1886 残留 0 行", (await findHonors()).length === 0);
+} else {
+  console.log("\n【4】未加 --cleanup:数据保留(真实企业数据,功能正常行为)");
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 153 - 0
harness/tools/verify-company-info-passthrough.mjs

@@ -0,0 +1,153 @@
+/**
+ * 验证适配层把 `result` 事件里的 **company_info 原样带出**(不解析、不渲染进正文)。
+ *
+ * 为什么必须测:企业信息同步的第一步就是「拿到 company_info」——
+ * 原先 `case 'result'` 是个**空分支**,整个 data 被丢弃;本次改成暂存并在 done 时
+ * 随 `totalResponse` 交给应用侧。这条通路没有别的地方能覆盖(DMS 侧验证是从
+ * 「已经拿到分类结果」开始的),所以用假 SSE 流喂真实协调器来钉住。
+ *
+ * 不联网:把 `globalThis.fetch` 打桩成返回一段构造好的 SSE 流。
+ *
+ * 怎么跑(先按 _entry-coordinator.ts 的说明重新打包):
+ *   npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_coordinator.mjs --alias:@=./src --define:import.meta.env='{}'
+ *   node harness/tools/verify-company-info-passthrough.mjs
+ */
+
+import { ApiChatCoordinator } from './_coordinator.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 === '' ? '' : ` → ${JSON.stringify(extra)}`}`);
+  }
+};
+
+const COMPANY_INFO = {
+  company: { KeyNo: 'k1', Name: '上海测试科技有限公司', CreditCode: 'TESTCODE1', OperName: '张三' },
+  profile: { data: { Name: '上海测试科技有限公司', CreditCode: 'TESTCODE1', OperName: '张三' }, source: { provider: '企查查' } },
+  honors: { records: [], sources: [], complete: true, error: null },
+};
+
+const sse = (events) =>
+  events
+    .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
+    .join('');
+
+/** 把一段 SSE 文本打桩成 fetch 的响应 */
+const stubFetchWith = (sseText) => {
+  globalThis.fetch = async () => {
+    const stream = new ReadableStream({
+      start(controller) {
+        controller.enqueue(new TextEncoder().encode(sseText));
+        controller.close();
+      },
+    });
+    return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
+  };
+};
+
+const runTurn = async (events) => {
+  stubFetchWith(sse(events));
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  const seen = { totalResponse: null, messages: [] };
+  coordinator.addEventListener('totalResponse', (payload) => (seen.totalResponse = payload));
+  coordinator.addEventListener('message', (msg) => seen.messages.push(msg));
+  await coordinator.generateAnswer('测试问题');
+  // flushContent 会等两帧再 emit totalResponse,给它一点时间
+  await new Promise((resolve) => setTimeout(resolve, 60));
+  return seen;
+};
+
+const BASE_EVENTS = [
+  ['accepted', { message: '收到' }],
+  ['answer', { text: '这是回答正文。' }],
+];
+
+console.log('【1】result 带 company_info → totalResponse 原样带出');
+{
+  const seen = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: '这是回答正文。', company_info: COMPANY_INFO, recommendation: null, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+
+  check('收到了 totalResponse', !!seen.totalResponse, seen.totalResponse);
+  check('payload 里有 company_info', !!seen.totalResponse?.company_info);
+  check(
+    '内容与后端下发**逐字段一致**(含嵌套的 company/profile/honors)',
+    JSON.stringify(seen.totalResponse?.company_info) === JSON.stringify(COMPANY_INFO),
+    seen.totalResponse?.company_info
+  );
+  check(
+    '**没有混进渲染内容**(消息里不应出现 credit code)',
+    !seen.messages.join('').includes('TESTCODE1'),
+    seen.messages.map((m) => m.slice(0, 60))
+  );
+}
+
+console.log('\n【2】result 不带 company_info → payload 里也不该有这个键');
+{
+  const seen = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: '这是回答正文。', recommendation: null, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('没有 company_info 键(而不是 null)', seen.totalResponse && !('company_info' in seen.totalResponse), seen.totalResponse);
+}
+
+console.log('\n【3】company_info 为 null / 空对象 → 不往下传(省得白跑一次分类)');
+{
+  const seenNull = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: 'x', company_info: null }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('company_info=null → 不传', seenNull.totalResponse && !('company_info' in seenNull.totalResponse));
+
+  const seenEmpty = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: 'x', company_info: {} }],
+    ['done', { status: 'completed' }],
+  ]);
+  // 空对象会带出去(适配层不做业务判断),但下游的 isCompanyInfoUsable 会静默跳过
+  check('company_info={} → 带出去交给下游判断', !!seenEmpty.totalResponse?.company_info, seenEmpty.totalResponse?.company_info);
+}
+
+console.log('\n【4】跨轮不串:上一轮有 company_info,下一轮没有 → 不该沿用');
+{
+  stubFetchWith(
+    sse([
+      ...BASE_EVENTS,
+      ['result', { response: 'x', company_info: COMPANY_INFO }],
+      ['done', { status: 'completed' }],
+    ])
+  );
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  const payloads = [];
+  coordinator.addEventListener('totalResponse', (payload) => payloads.push(payload));
+  await coordinator.generateAnswer('第一轮');
+  await new Promise((resolve) => setTimeout(resolve, 60));
+
+  stubFetchWith(
+    sse([
+      ...BASE_EVENTS,
+      ['result', { response: 'x' }], // 这一轮没有 company_info
+      ['done', { status: 'completed' }],
+    ])
+  );
+  await coordinator.generateAnswer('第二轮');
+  await new Promise((resolve) => setTimeout(resolve, 60));
+
+  check('两轮各收到一次 totalResponse', payloads.length === 2, payloads.length);
+  check('第一轮带 company_info', !!payloads[0]?.company_info);
+  check('第二轮**没有**沿用上一轮的 company_info(resetTurnContent 生效)', !('company_info' in (payloads[1] || {})), payloads[1]);
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

Неке датотеке нису приказане због велике количине промена