verify-company-classify-e2e.mjs 7.4 KB

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