verify-company-classify-e2e.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /**
  2. * **端到端**验证:真实 company_info → 真实分类接口 → 真实 DMS 两栏目。
  3. *
  4. * 与 `verify-company-classify-dms.mjs` 的分工:
  5. * - 那个脚本**注入假分类响应**,验证「拿到结果之后」的落库链路(不需要后端配合,可反复跑)
  6. * - 本脚本用**真实载荷**打真实分类接口,验证最后一公里(会真的调用 6 次 LLM、会真的写 DMS)
  7. *
  8. * 为什么载荷要从文件读、不内置:
  9. * 分类接口对**合成/精简的请求体一律返回 422**(2026-09-18 实测:文档里的示例片段、
  10. * 各种键名、`{"company":{}}` 全被拒;换成真实抓下来的 `company_info` 才 200)。
  11. * 所以这里不硬编码夹具,由调用方给一份**真实形态**的 company_info JSON。
  12. *
  13. * 怎么跑:
  14. * 1) npm run dev
  15. * 2) 把 /api/chat 的 result 事件里 data.company_info 对象存成 JSON 文件(整个对象,
  16. * 不含外层 company_info 键),例如 /tmp/company-info.json
  17. * 3) node harness/tools/verify-company-classify-e2e.mjs <company-info.json> [--cleanup]
  18. *
  19. * ⚠️ **默认不清理**:这份是真实企业数据,落库就是功能的正常行为(幂等,重复跑只更新)。
  20. * 加 `--cleanup` 会在验证后把该企业在本轮写下的行删掉(调试用)。
  21. */
  22. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  23. import { readFileSync } from "node:fs";
  24. const chatBase = process.env.CHAT_BASE || "https://localhost:8083/chat-api";
  25. const dmsBase = process.env.DMS_BASE || "https://localhost:8083/dms-api";
  26. globalThis.VITE_CHAT_API = chatBase;
  27. globalThis.VITE_DMS_API = dmsBase;
  28. const payloadPath = process.argv[2];
  29. const cleanup = process.argv.includes("--cleanup");
  30. if (!payloadPath) {
  31. console.error("用法:node verify-company-classify-e2e.mjs <company-info.json> [--cleanup]");
  32. process.exit(2);
  33. }
  34. const {
  35. classifyCompany,
  36. applyCompanyClassification,
  37. applyCompanyHonors,
  38. applyCompanyProfile,
  39. extractIdentityFromCompanyInfo,
  40. parseHonorRecords,
  41. readHonorsFromCompanyInfo,
  42. readProfileDataFromCompanyInfo,
  43. buildProfileFields,
  44. stableJson,
  45. HONOR_NAME_FIELD,
  46. ANGLE_TO_FIELD,
  47. DMS_COLUMN_ENTERPRISE,
  48. DMS_COLUMN_HONOR,
  49. deleteDmsContent,
  50. searchDmsContents,
  51. } = await import("./_company-classify.mjs");
  52. let pass = 0;
  53. let fail = 0;
  54. const check = (name, ok, extra = "") => {
  55. if (ok) {
  56. pass++;
  57. console.log(` ok ${name}`);
  58. } else {
  59. fail++;
  60. console.log(` FAIL ${name}${extra === "" ? "" : ` → ${JSON.stringify(extra)}`}`);
  61. }
  62. };
  63. const companyInfo = JSON.parse(readFileSync(payloadPath, "utf8"));
  64. const identity = extractIdentityFromCompanyInfo(companyInfo);
  65. const creditCode = identity?.creditCode;
  66. console.log(`载荷:${payloadPath}`);
  67. console.log(`企业:${identity?.name || "?"} / ${creditCode || "?"}\n`);
  68. if (!creditCode) {
  69. console.error("载荷里没有统一社会信用代码,无法继续");
  70. process.exit(2);
  71. }
  72. const findEnterprise = () =>
  73. searchDmsContents(DMS_COLUMN_ENTERPRISE, {
  74. search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
  75. page: 0,
  76. pageSize: 10,
  77. });
  78. const findHonors = () =>
  79. searchDmsContents(DMS_COLUMN_HONOR, {
  80. search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
  81. page: 0,
  82. pageSize: 200,
  83. });
  84. /* ---------------------------------------------------------------- *
  85. * ① 真实分类接口
  86. * ---------------------------------------------------------------- */
  87. console.log("【1】真实分类接口(会调用 6 次 LLM,稍慢)");
  88. const before = Date.now();
  89. const classification = await classifyCompany(companyInfo);
  90. console.log(` (耗时 ${((Date.now() - before) / 1000).toFixed(1)}s)`);
  91. check("接口返回了结果(非 null)", !!classification);
  92. check(
  93. "返回的是 completed/partial(不是 failed)",
  94. classification?.status === "completed" || classification?.status === "partial",
  95. classification?.status
  96. );
  97. check("credit_code 与载荷一致", classification?.credit_code === creditCode, classification?.credit_code);
  98. const cats = classification?.categories || {};
  99. const angles = Object.keys(cats);
  100. check("六个角度键齐全", angles.length === 6, angles);
  101. check(
  102. "每个角度都是数组",
  103. angles.every((k) => Array.isArray(cats[k])),
  104. angles.map((k) => `${k}:${Array.isArray(cats[k])}`)
  105. );
  106. console.log(` 分类结果:${JSON.stringify(cats)}`);
  107. /* ---------------------------------------------------------------- *
  108. * ② 落库(真实 DMS)
  109. * ---------------------------------------------------------------- */
  110. console.log("\n【2】落库到 DMS 1888 / 1886");
  111. await applyCompanyClassification(classification, identity);
  112. const rows = await findEnterprise();
  113. check("1888 有且只有一行", rows.length === 1, rows.length);
  114. const row = rows[0] || {};
  115. check("企业名/信用代码/法人已写入", row.c_name === identity.name && row.c_credit_code === creditCode && row.c_oper_name === identity.legalRep, {
  116. c_name: row.c_name,
  117. c_credit_code: row.c_credit_code,
  118. c_oper_name: row.c_oper_name,
  119. });
  120. /**
  121. * 库里的标签字段有两种合法形态:
  122. * - 有标签 → JSON 数组字符串(如 `["第三产业","软件信息"]`)
  123. * - 无标签 → **字段不存在**(DMS 不落空值;`[]` 也不会被写下去)
  124. * 所以断言要按「解码后的集合」比,不能按字符串比。
  125. */
  126. const decodeTags = (value) => {
  127. if (typeof value !== "string" || !value.trim()) return [];
  128. try {
  129. const parsed = JSON.parse(value);
  130. return Array.isArray(parsed) ? parsed : [];
  131. } catch {
  132. return [];
  133. }
  134. };
  135. const sameSet = (a, b) => a.length === b.length && b.every((x) => a.includes(x));
  136. for (const angle of angles) {
  137. const field = ANGLE_TO_FIELD[angle];
  138. const expectedTags = cats[angle] || [];
  139. const storedTags = decodeTags(row[field]);
  140. check(
  141. `1888 ${angle} → ${field}${expectedTags.length === 0 ? "(空集:字段不落库,语义等同)" : ""}`,
  142. sameSet(storedTags, expectedTags),
  143. { stored: row[field], storedTags, expectedTags }
  144. );
  145. }
  146. /* ---------------------------------------------------------------- *
  147. * ②b 荣誉(数据源 = company_info.honors.records)
  148. * ---------------------------------------------------------------- */
  149. console.log("\n【2b】荣誉对齐到 1886(数据源 = company_info.honors.records)");
  150. const honorParse = parseHonorRecords(readHonorsFromCompanyInfo(companyInfo));
  151. console.log(` 载荷荣誉记录 ${honorParse.records.length} 条(complete=${honorParse.complete},丢弃 ${honorParse.malformed} 条)`);
  152. await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
  153. const honors = await findHonors();
  154. check("1886 行数 = 载荷荣誉条数", honors.length === honorParse.records.length, {
  155. rows: honors.length,
  156. records: honorParse.records.length,
  157. });
  158. check(
  159. "每行都有荣誉名(title 与 c_honor)",
  160. honors.every((r) => typeof r[HONOR_NAME_FIELD] === "string" && r[HONOR_NAME_FIELD] && r.title === r[HONOR_NAME_FIELD]),
  161. honors.slice(0, 3).map((r) => ({ title: r.title, tag: r[HONOR_NAME_FIELD] }))
  162. );
  163. check(
  164. "级别/来源已写入",
  165. honors.every((r) => typeof r.c_level === "string" && typeof r.c_source === "string"),
  166. honors.slice(0, 3).map((r) => ({ level: r.c_level, source: r.c_source }))
  167. );
  168. const sampleRecord = honorParse.records[0];
  169. check(
  170. "抽样比对:载荷第一条能在库里按「荣誉名+级别+来源」找到",
  171. !sampleRecord ||
  172. honors.some((r) => r[HONOR_NAME_FIELD] === sampleRecord.name && r.c_level === sampleRecord.level && r.c_source === sampleRecord.source),
  173. sampleRecord
  174. );
  175. /* ---------------------------------------------------------------- *
  176. * ②c 工商信息(profile.data → 1888,不依赖分类接口)
  177. * ---------------------------------------------------------------- */
  178. console.log("\n【2c】工商信息同步(数据源 = company_info.profile.data)");
  179. const profileData = readProfileDataFromCompanyInfo(companyInfo);
  180. const desiredProfile = buildProfileFields(profileData);
  181. // 先记下同步前那几列 JSON 的**原始字符串**:语义相同就不该被重写
  182. const JSON_COLS = ["c_area", "c_original_name", "c_designated_representative_list", "c_revoke_info"];
  183. const beforeRow = (await findEnterprise())[0] || {};
  184. const beforeJson = Object.fromEntries(JSON_COLS.map((c) => [c, beforeRow[c]]));
  185. await applyCompanyProfile(profileData, identity);
  186. const afterRow = (await findEnterprise())[0] || {};
  187. /**
  188. * 逐列比对时**不能按字符串比 JSON 列**:库里那几列是 DMS 侧灌进来的(Python/jsonb 序列化,
  189. * 带空格、键按字母序),我们按语义比较、不重写,所以字节不同但值相同 —— 这正是期望行为。
  190. */
  191. const sameColumnValue = (col, stored, desired) => {
  192. if (!JSON_COLS.includes(col)) return stored === desired;
  193. const parse = (v) => {
  194. if (typeof v !== "string" || !v.trim()) return null;
  195. try {
  196. return JSON.parse(v);
  197. } catch {
  198. return v;
  199. }
  200. };
  201. return stableJson(parse(stored)) === stableJson(parse(desired));
  202. };
  203. check(
  204. "映射出的字段都进了库(JSON 列按语义比)",
  205. Object.entries(desiredProfile).every(([col, value]) => sameColumnValue(col, afterRow[col], value)),
  206. Object.entries(desiredProfile)
  207. .filter(([col, value]) => !sameColumnValue(col, afterRow[col], value))
  208. .map(([col, value]) => ({ col, desired: String(value).slice(0, 40), stored: String(afterRow[col]).slice(0, 40) }))
  209. );
  210. check(
  211. "抽样:经营状态 / 注册地址 / 注册资本 / 经营范围 已写入",
  212. afterRow.c_status === profileData.Status &&
  213. afterRow.c_address === profileData.Address &&
  214. afterRow.c_regist_capi === profileData.RegistCapi &&
  215. String(afterRow.c_scope || "").startsWith(String(profileData.Scope || "").slice(0, 20)),
  216. { c_status: afterRow.c_status, c_address: afterRow.c_address, c_regist_capi: afterRow.c_regist_capi }
  217. );
  218. check(
  219. "JSON 列语义正确(解析后按键排序比较,与载荷一致)",
  220. (() => {
  221. try {
  222. return stableJson(JSON.parse(afterRow.c_area)) === stableJson(profileData.Area);
  223. } catch {
  224. return false;
  225. }
  226. })(),
  227. afterRow.c_area
  228. );
  229. check(
  230. "系统字段 title = 企业名称(DMS 列表里显示的那一列)",
  231. afterRow.title === (profileData.Name || identity.name),
  232. afterRow.title
  233. );
  234. check(
  235. "**语义相同的 JSON 列被原样保留**(没有被我们重写成另一种格式)",
  236. JSON_COLS.every((c) => beforeJson[c] === undefined || afterRow[c] === beforeJson[c]),
  237. JSON_COLS.filter((c) => beforeJson[c] !== undefined && afterRow[c] !== beforeJson[c]).map((c) => ({ col: c, before: String(beforeJson[c]).slice(0, 60), after: String(afterRow[c]).slice(0, 60) }))
  238. );
  239. console.log(` 映射表覆盖 ${Object.keys(desiredProfile).length} 列(载荷 profile.data 共 ${Object.keys(profileData || {}).length} 个字段)`);
  240. /* ---------------------------------------------------------------- *
  241. * ③ 幂等:再落一次
  242. * ---------------------------------------------------------------- */
  243. console.log("\n【3】同样结果再落一次 → 零新增");
  244. await applyCompanyClassification(classification, identity);
  245. await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
  246. await applyCompanyProfile(profileData, identity);
  247. const rows2 = await findEnterprise();
  248. check("1888 仍是一行、uuid 未变", rows2.length === 1 && rows2[0]?.id === row.id, rows2.length);
  249. const honors2 = await findHonors();
  250. check("1886 行数未变", honors2.length === honors.length, { before: honors.length, after: honors2.length });
  251. check(
  252. "1886 行 uuid 集合未变(没有删了又加)",
  253. JSON.stringify(honors2.map((r) => r.id).sort()) === JSON.stringify(honors.map((r) => r.id).sort())
  254. );
  255. /* ---------------------------------------------------------------- *
  256. * ④ 可选清理
  257. * ---------------------------------------------------------------- */
  258. if (cleanup) {
  259. console.log("\n【4】--cleanup:删除本轮写入的行");
  260. for (const r of await findEnterprise()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_ENTERPRISE, r.id);
  261. for (const r of await findHonors()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_HONOR, r.id);
  262. check("1888 残留 0 行", (await findEnterprise()).length === 0);
  263. check("1886 残留 0 行", (await findHonors()).length === 0);
  264. } else {
  265. console.log("\n【4】未加 --cleanup:数据保留(真实企业数据,功能正常行为)");
  266. }
  267. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  268. process.exit(fail ? 1 : 0);