verify-company-classify-e2e.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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. extractIdentityFromCompanyInfo,
  39. parseHonorRecords,
  40. readHonorsFromCompanyInfo,
  41. HONOR_NAME_FIELD,
  42. ANGLE_TO_FIELD,
  43. DMS_COLUMN_ENTERPRISE,
  44. DMS_COLUMN_HONOR,
  45. deleteDmsContent,
  46. searchDmsContents,
  47. } = await import("./_company-classify.mjs");
  48. let pass = 0;
  49. let fail = 0;
  50. const check = (name, ok, extra = "") => {
  51. if (ok) {
  52. pass++;
  53. console.log(` ok ${name}`);
  54. } else {
  55. fail++;
  56. console.log(` FAIL ${name}${extra === "" ? "" : ` → ${JSON.stringify(extra)}`}`);
  57. }
  58. };
  59. const companyInfo = JSON.parse(readFileSync(payloadPath, "utf8"));
  60. const identity = extractIdentityFromCompanyInfo(companyInfo);
  61. const creditCode = identity?.creditCode;
  62. console.log(`载荷:${payloadPath}`);
  63. console.log(`企业:${identity?.name || "?"} / ${creditCode || "?"}\n`);
  64. if (!creditCode) {
  65. console.error("载荷里没有统一社会信用代码,无法继续");
  66. process.exit(2);
  67. }
  68. const findEnterprise = () =>
  69. searchDmsContents(DMS_COLUMN_ENTERPRISE, {
  70. search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
  71. page: 0,
  72. pageSize: 10,
  73. });
  74. const findHonors = () =>
  75. searchDmsContents(DMS_COLUMN_HONOR, {
  76. search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
  77. page: 0,
  78. pageSize: 200,
  79. });
  80. /* ---------------------------------------------------------------- *
  81. * ① 真实分类接口
  82. * ---------------------------------------------------------------- */
  83. console.log("【1】真实分类接口(会调用 6 次 LLM,稍慢)");
  84. const before = Date.now();
  85. const classification = await classifyCompany(companyInfo);
  86. console.log(` (耗时 ${((Date.now() - before) / 1000).toFixed(1)}s)`);
  87. check("接口返回了结果(非 null)", !!classification);
  88. check(
  89. "返回的是 completed/partial(不是 failed)",
  90. classification?.status === "completed" || classification?.status === "partial",
  91. classification?.status
  92. );
  93. check("credit_code 与载荷一致", classification?.credit_code === creditCode, classification?.credit_code);
  94. const cats = classification?.categories || {};
  95. const angles = Object.keys(cats);
  96. check("六个角度键齐全", angles.length === 6, angles);
  97. check(
  98. "每个角度都是数组",
  99. angles.every((k) => Array.isArray(cats[k])),
  100. angles.map((k) => `${k}:${Array.isArray(cats[k])}`)
  101. );
  102. console.log(` 分类结果:${JSON.stringify(cats)}`);
  103. /* ---------------------------------------------------------------- *
  104. * ② 落库(真实 DMS)
  105. * ---------------------------------------------------------------- */
  106. console.log("\n【2】落库到 DMS 1888 / 1886");
  107. await applyCompanyClassification(classification, identity);
  108. const rows = await findEnterprise();
  109. check("1888 有且只有一行", rows.length === 1, rows.length);
  110. const row = rows[0] || {};
  111. check("企业名/信用代码/法人已写入", row.c_name === identity.name && row.c_credit_code === creditCode && row.c_oper_name === identity.legalRep, {
  112. c_name: row.c_name,
  113. c_credit_code: row.c_credit_code,
  114. c_oper_name: row.c_oper_name,
  115. });
  116. /**
  117. * 库里的标签字段有两种合法形态:
  118. * - 有标签 → JSON 数组字符串(如 `["第三产业","软件信息"]`)
  119. * - 无标签 → **字段不存在**(DMS 不落空值;`[]` 也不会被写下去)
  120. * 所以断言要按「解码后的集合」比,不能按字符串比。
  121. */
  122. const decodeTags = (value) => {
  123. if (typeof value !== "string" || !value.trim()) return [];
  124. try {
  125. const parsed = JSON.parse(value);
  126. return Array.isArray(parsed) ? parsed : [];
  127. } catch {
  128. return [];
  129. }
  130. };
  131. const sameSet = (a, b) => a.length === b.length && b.every((x) => a.includes(x));
  132. for (const angle of angles) {
  133. const field = ANGLE_TO_FIELD[angle];
  134. const expectedTags = cats[angle] || [];
  135. const storedTags = decodeTags(row[field]);
  136. check(
  137. `1888 ${angle} → ${field}${expectedTags.length === 0 ? "(空集:字段不落库,语义等同)" : ""}`,
  138. sameSet(storedTags, expectedTags),
  139. { stored: row[field], storedTags, expectedTags }
  140. );
  141. }
  142. /* ---------------------------------------------------------------- *
  143. * ②b 荣誉(数据源 = company_info.honors.records)
  144. * ---------------------------------------------------------------- */
  145. console.log("\n【2b】荣誉对齐到 1886(数据源 = company_info.honors.records)");
  146. const honorParse = parseHonorRecords(readHonorsFromCompanyInfo(companyInfo));
  147. console.log(` 载荷荣誉记录 ${honorParse.records.length} 条(complete=${honorParse.complete},丢弃 ${honorParse.malformed} 条)`);
  148. await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
  149. const honors = await findHonors();
  150. check("1886 行数 = 载荷荣誉条数", honors.length === honorParse.records.length, {
  151. rows: honors.length,
  152. records: honorParse.records.length,
  153. });
  154. check(
  155. "每行都有荣誉名(title 与 c_honor)",
  156. honors.every((r) => typeof r[HONOR_NAME_FIELD] === "string" && r[HONOR_NAME_FIELD] && r.title === r[HONOR_NAME_FIELD]),
  157. honors.slice(0, 3).map((r) => ({ title: r.title, tag: r[HONOR_NAME_FIELD] }))
  158. );
  159. check(
  160. "级别/来源已写入",
  161. honors.every((r) => typeof r.c_level === "string" && typeof r.c_source === "string"),
  162. honors.slice(0, 3).map((r) => ({ level: r.c_level, source: r.c_source }))
  163. );
  164. const sampleRecord = honorParse.records[0];
  165. check(
  166. "抽样比对:载荷第一条能在库里按「荣誉名+级别+来源」找到",
  167. !sampleRecord ||
  168. honors.some((r) => r[HONOR_NAME_FIELD] === sampleRecord.name && r.c_level === sampleRecord.level && r.c_source === sampleRecord.source),
  169. sampleRecord
  170. );
  171. /* ---------------------------------------------------------------- *
  172. * ③ 幂等:再落一次
  173. * ---------------------------------------------------------------- */
  174. console.log("\n【3】同样结果再落一次 → 零新增");
  175. await applyCompanyClassification(classification, identity);
  176. await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
  177. const rows2 = await findEnterprise();
  178. check("1888 仍是一行、uuid 未变", rows2.length === 1 && rows2[0]?.id === row.id, rows2.length);
  179. const honors2 = await findHonors();
  180. check("1886 行数未变", honors2.length === honors.length, { before: honors.length, after: honors2.length });
  181. check(
  182. "1886 行 uuid 集合未变(没有删了又加)",
  183. JSON.stringify(honors2.map((r) => r.id).sort()) === JSON.stringify(honors.map((r) => r.id).sort())
  184. );
  185. /* ---------------------------------------------------------------- *
  186. * ④ 可选清理
  187. * ---------------------------------------------------------------- */
  188. if (cleanup) {
  189. console.log("\n【4】--cleanup:删除本轮写入的行");
  190. for (const r of await findEnterprise()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_ENTERPRISE, r.id);
  191. for (const r of await findHonors()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_HONOR, r.id);
  192. check("1888 残留 0 行", (await findEnterprise()).length === 0);
  193. check("1886 残留 0 行", (await findHonors()).length === 0);
  194. } else {
  195. console.log("\n【4】未加 --cleanup:数据保留(真实企业数据,功能正常行为)");
  196. }
  197. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  198. process.exit(fail ? 1 : 0);