classify.test.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 拆解 / 打标 / 合规 / 入库契约 测试:`node --test`
  14. */
  15. import test from 'node:test';
  16. import assert from 'node:assert/strict';
  17. import path from 'node:path';
  18. import fs from 'node:fs';
  19. import os from 'node:os';
  20. import { loadDictionary, buildIdempotencyKey, normalizeMaterialAsset, imageUrlsFrom, sortAssets, uniq } from '../lib.mjs';
  21. import {
  22. assessCompliance, classifyCase, deriveCaseFields, extractOutcome, findPrivacy,
  23. inferFitStatus, normalizeSchool, splitCaseAndMaterials,
  24. } from '../classify.mjs';
  25. import { runIngest, makeTinyPng } from '../case-intake.mjs';
  26. import { makeFixtures } from './fixtures.mjs';
  27. const dict = loadDictionary();
  28. // ---------------------------------------------------------------------------
  29. // 1. 输入识别 / 拆解
  30. // ---------------------------------------------------------------------------
  31. test('splitCaseAndMaterials:案例说明 与 素材 严格分开', () => {
  32. const split = splitCaseAndMaterials({
  33. textBundle: { paragraphs: ['案例说明段落'], notes: ['备注'], titles: [], transcript: '视频转写内容' },
  34. visionResults: [
  35. { role: 'material', ocrText: '素材图里的字', description: '成绩单截图' },
  36. { role: 'description', ocrText: '说明配图的字', description: '流程图' },
  37. ],
  38. });
  39. assert.ok(split.descriptionCorpus.includes('案例说明段落'));
  40. assert.ok(split.descriptionCorpus.includes('视频转写内容'));
  41. assert.ok(split.descriptionCorpus.includes('流程图'), 'description 角色归案例说明');
  42. assert.ok(split.materialCorpus.includes('成绩单截图'), 'material 角色归素材');
  43. assert.ok(!split.materialCorpus.includes('案例说明段落'));
  44. });
  45. // ---------------------------------------------------------------------------
  46. // 2. 打标
  47. // ---------------------------------------------------------------------------
  48. test('学校别名归一:IC → 帝国理工,UCL → 伦敦大学学院;不误伤 MAGIC', () => {
  49. const a = normalizeSchool('IC 的计算机课,考前冲刺', dict);
  50. assert.equal(a.schoolCanonical, '帝国理工');
  51. assert.deepEqual(a.schoolAliases, ['IC']);
  52. assert.equal(a.country, '英国');
  53. const b = normalizeSchool('UCL 的学生找我辅导', dict);
  54. assert.equal(b.schoolCanonical, '伦敦大学学院');
  55. assert.equal(normalizeSchool('MAGIC 系统很好用', dict).schoolCanonical, '');
  56. });
  57. test('打标:亮点 / 场景 / 异议 / 静态属性 按字典与策略命中', () => {
  58. const { tags } = classifyCase({
  59. dict,
  60. groups: [],
  61. visionResults: [],
  62. textBundle: {
  63. paragraphs: ['帝国理工 计算机科学 本科二年级,期末考前冲刺,提分 18 分,家长很满意', '客户说太贵了,不放心老师'],
  64. notes: [], titles: [], transcript: '',
  65. },
  66. authorizationStatus: 'authorized',
  67. });
  68. assert.deepEqual(tags.schoolCanonical, ['帝国理工']);
  69. assert.deepEqual(tags.country, ['英国']);
  70. assert.ok(tags.major.includes('计算机科学'));
  71. assert.ok(tags.highlightTypes.includes('提分'));
  72. assert.ok(tags.highlightTypes.includes('家长认可'));
  73. assert.ok(tags.scenarioTags.includes('考前冲刺'));
  74. assert.ok(tags.scenarioTags.includes('期末考试'));
  75. assert.ok(tags.objectionTags.includes('贵'));
  76. assert.ok(tags.objectionTags.includes('不放心老师'));
  77. });
  78. test('适用跟进状态:按 docs/prd/dashboard/10 策略命中(异议处理中)', () => {
  79. const fit = inferFitStatus({
  80. highlightTypes: ['提分', '家长认可'],
  81. scenarioTags: ['考前冲刺'],
  82. objectionTags: ['贵'],
  83. subject: [],
  84. });
  85. assert.ok(fit.fitStatus.includes('异议处理中'));
  86. assert.match(fit.reason, /跟进状态/);
  87. });
  88. test('适用跟进状态:命中七个枚举之一(全部在契约枚举内)', () => {
  89. const allowed = new Set(['新进线', '挖需中', '方案推荐中', '异议处理中', '待决策', '沉默待跟进', '已成交']);
  90. for (const scenario of [
  91. { highlightTypes: ['补考通过'], scenarioTags: [], objectionTags: [], subject: [] },
  92. { highlightTypes: ['导师匹配'], scenarioTags: [], objectionTags: [], subject: [] },
  93. { highlightTypes: [], scenarioTags: ['考前冲刺'], objectionTags: [], subject: [] },
  94. { highlightTypes: ['首课体验'], scenarioTags: [], objectionTags: [], subject: ['高等数学'] },
  95. ]) {
  96. for (const status of inferFitStatus(scenario).fitStatus) {
  97. assert.ok(allowed.has(status), `非法跟进状态:${status}`);
  98. }
  99. }
  100. });
  101. // ---------------------------------------------------------------------------
  102. // 3. 隐私 / 合规
  103. // ---------------------------------------------------------------------------
  104. test('findPrivacy:片段一律掩码,绝不返回完整 PII', () => {
  105. const findings = findPrivacy('联系 13812345678 邮箱 a.b@example.com 微信:student_abc');
  106. const fields = findings.map((f) => f.field);
  107. assert.ok(fields.includes('手机号'));
  108. assert.ok(fields.includes('邮箱'));
  109. assert.ok(fields.includes('微信号'));
  110. const phone = findings.find((f) => f.field === '手机号');
  111. assert.ok(!phone.snippet.includes('13812345678'), '手机号必须已掩码');
  112. assert.match(phone.snippet, /^138\*{4}5678$/);
  113. });
  114. test('合规:客诉 / 负面事件 → riskFlags,且禁止入库', () => {
  115. const result = assessCompliance({
  116. corpus: '客户投诉退费纠纷,要求赔偿,还发了黑猫',
  117. authorizationStatus: 'authorized',
  118. hasMaterials: true,
  119. });
  120. assert.ok(result.riskFlags.includes('COMPLAINT'));
  121. assert.equal(result.authorizationOk, true);
  122. assert.ok(result.complianceBlockers.some((b) => b.includes('客诉')));
  123. assert.match(result.reviewHint, /驳回|复盘/);
  124. });
  125. test('合规:无授权一律不入库(硬规则)', () => {
  126. for (const status of ['pending', 'denied', '']) {
  127. const result = assessCompliance({
  128. corpus: '帝国理工 提分案例,很好',
  129. authorizationStatus: status,
  130. hasMaterials: true,
  131. });
  132. assert.equal(result.authorizationOk, false, `status=${status} 必须判为无授权`);
  133. assert.ok(result.riskFlags.includes('UNAUTHORIZED'));
  134. assert.ok(result.complianceBlockers.some((b) => b.includes('无授权')));
  135. }
  136. });
  137. test('合规:无素材 / 跑题 也拦下来', () => {
  138. assert.ok(assessCompliance({ corpus: '帝国理工 提分', authorizationStatus: 'authorized', hasMaterials: false })
  139. .complianceBlockers.some((b) => b.includes('可用素材')));
  140. assert.ok(assessCompliance({ corpus: '今天天气不错,出门散步', authorizationStatus: 'authorized', hasMaterials: false })
  141. .riskFlags.includes('OFF_TOPIC'));
  142. });
  143. test('合规:正常好案例 → 可入库待审,无 blocker', () => {
  144. const result = assessCompliance({
  145. corpus: '帝国理工 计算机 考前冲刺,期末提分 18 分,家长很满意',
  146. authorizationStatus: 'authorized',
  147. hasMaterials: true,
  148. });
  149. assert.equal(result.authorizationOk, true);
  150. assert.equal(result.complianceBlockers.length, 0);
  151. assert.match(result.reviewHint, /待审/);
  152. });
  153. // ---------------------------------------------------------------------------
  154. // 4. 案例字段抽取
  155. // ---------------------------------------------------------------------------
  156. test('extractOutcome:提分幅度 / 周期 / 结果 / 录取', () => {
  157. assert.equal(extractOutcome('期末提分 18 分').scoreGain, '18分');
  158. assert.equal(extractOutcome('从 52 分到 78 分').scoreRange, '52 → 78');
  159. assert.equal(extractOutcome('辅导 6 周').period, '6 周');
  160. assert.equal(extractOutcome('拿到了帝国理工的录取').admittedTo, '帝国理工');
  161. assert.equal(extractOutcome('最终 pass 了').result, 'pass');
  162. });
  163. test('deriveCaseFields:标题 / 摘要 / 使用建议 / 结果证据 都产出', () => {
  164. const fields = deriveCaseFields(
  165. 'IC 计算机科学考前冲刺案例\n客户在读本科二年级,期末前两周找到我们,担心挂科。\n结果:期末提分 18 分,家长很满意。',
  166. [{ count: 3, preview: '' }],
  167. { productLine: ['大学课程'], schoolCanonical: ['帝国理工'], subject: ['高等数学'], stage: ['本科二年级'], scenarioTags: ['考前冲刺'], objectionTags: [] },
  168. { fitStatus: ['挖需中'] },
  169. );
  170. assert.ok(fields.title.length > 0);
  171. assert.ok(fields.summary.length > 0);
  172. assert.match(fields.usageSuggestion, /痛点|挂|时间/);
  173. assert.match(fields.targetCustomer, /帝国理工/);
  174. assert.match(fields.resultEvidence, /提分/);
  175. });
  176. // ---------------------------------------------------------------------------
  177. // 5. materialAssets 契约(逐字对齐 SHARED-CONTRACT.md)
  178. // ---------------------------------------------------------------------------
  179. test('normalizeMaterialAsset:字段齐全、order 1 起、非法值报问题', () => {
  180. const { asset, problems } = normalizeMaterialAsset({ kind: 'image', url: 'https://x/a.jpg', order: 3 }, 0);
  181. assert.deepEqual(Object.keys(asset).sort(), [
  182. 'description', 'groupId', 'kind', 'label', 'localPath', 'ocrText', 'order', 'role', 'url', 'usageSuggestion',
  183. ]);
  184. assert.equal(asset.order, 3);
  185. assert.equal(asset.role, 'material');
  186. assert.equal(problems.length, 0);
  187. const bad = normalizeMaterialAsset({ kind: 'pdf' }, 0);
  188. assert.ok(bad.problems.some((p) => p.includes('kind')));
  189. assert.ok(bad.problems.some((p) => p.includes('url')));
  190. });
  191. test('imageUrlsFrom:= kind=image 的 url,按 order 排序(兼容字段)', () => {
  192. const urls = imageUrlsFrom([
  193. { order: 2, kind: 'image', url: 'https://x/b.jpg' },
  194. { order: 1, kind: 'image', url: 'https://x/a.jpg' },
  195. { order: 3, kind: 'video', url: 'https://x/c.mp4' },
  196. ]);
  197. assert.deepEqual(urls, ['https://x/a.jpg', 'https://x/b.jpg']);
  198. });
  199. test('buildIdempotencyKey:同内容同键,内容变则变(tenantId + idempotencyKey 幂等)', () => {
  200. const base = { tenantId: 'lumi-demo', sourceType: 'image_group', sourceRef: 'wx-2026-10', title: 'IC 提分', materialAssets: [{ order: 1, kind: 'image', url: 'u1' }] };
  201. const k1 = buildIdempotencyKey(base);
  202. const k2 = buildIdempotencyKey({ ...base });
  203. const k3 = buildIdempotencyKey({ ...base, title: '别的标题' });
  204. assert.equal(k1, k2);
  205. assert.notEqual(k1, k3);
  206. assert.match(k1, /^caseget-[0-9a-f]{16}$/);
  207. assert.equal(buildIdempotencyKey({ ...base, explicit: 'manual-key' }), 'manual-key');
  208. });
  209. test('uniq:多参数/嵌套都支持,并丢空值', () => {
  210. assert.deepEqual(uniq(['a'], ['b', 'a'], [], ['c']), ['a', 'b', 'c']);
  211. assert.deepEqual(uniq([[1, 2], [2, 3]]), [1, 2, 3]);
  212. assert.deepEqual(uniq([null, undefined, '', 'x']), ['x']);
  213. });
  214. test('sortAssets:按 order 升序且稳定', () => {
  215. const sorted = sortAssets([{ order: 2, label: 'b' }, { order: 1, label: 'a' }, { order: 2, label: 'c' }]);
  216. assert.deepEqual(sorted.map((a) => a.label), ['a', 'b', 'c']);
  217. });
  218. // ---------------------------------------------------------------------------
  219. // 6. 端到端:夹具 → ingest → 案例包
  220. // ---------------------------------------------------------------------------
  221. test('端到端 ingest:多图(9)+docx+pptx → 案例包,字段对齐共享契约,不自动提交', async (t) => {
  222. const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'caseget-e2e-'));
  223. t.after(() => fs.rmSync(workRoot, { recursive: true, force: true }));
  224. const fixtures = makeFixtures(path.join(workRoot, 'fixtures'));
  225. const outDir = path.join(workRoot, 'out');
  226. const report = await runIngest({
  227. inputs: [...fixtures.images, fixtures.docx, fixtures.pptx],
  228. outDir,
  229. sourceRef: 'e2e-fixture',
  230. authorizationStatus: 'authorized',
  231. assetBaseUrl: 'https://cdn.example.invalid/cases/e2e',
  232. skipVision: true, // 不调模型:OCR 留空,其余链路全跑
  233. noLearn: true,
  234. });
  235. const pkg = report.casePackage;
  236. // 输入识别:多图 + 文档 + PPT → mixed
  237. assert.equal(pkg.sourceType, 'mixed');
  238. // 素材:9 张九宫格 + 1 张异批 + docx 内嵌 1 + pptx 内嵌 1
  239. assert.equal(pkg.materialAssets.length, 12);
  240. assert.deepEqual(pkg.materialAssets.map((a) => a.order), Array.from({ length: 12 }, (_, i) => i + 1));
  241. // 四个分组:九宫格 / 异批单图 / docx 内嵌图 / pptx 内嵌图
  242. // (两份文档的内嵌图不会互相并组——sourceKey 不同)
  243. assert.equal(pkg.groupInfoList.length, 4);
  244. assert.equal(pkg.groupInfoList.filter((g) => g.count === 1).length, 3);
  245. assert.ok(pkg.materialAssets.some((a) => a.description.includes('文档') || a.label.includes('内嵌')), '应包含文档内嵌图素材');
  246. // 九宫格分组
  247. const gridGroup = pkg.groupInfoList.find((g) => g.count === 9);
  248. assert.ok(gridGroup, '应识别出 9 张的九宫格组');
  249. assert.equal(gridGroup.layout, '3x3');
  250. assert.equal(gridGroup.orderingRule, 'left-to-right,top-to-bottom');
  251. assert.equal(pkg.groupInfo.layout, '3x3');
  252. // 契约必填字段
  253. for (const key of ['sourceType', 'idempotencyKey', 'title', 'summary', 'materialAssets', 'groupInfo', 'tags', 'highlightTypes', 'scenarioTags', 'objectionTags', 'riskFlags', 'authorizationStatus']) {
  254. assert.ok(key in pkg, `案例包缺少契约字段 ${key}`);
  255. }
  256. // 落库预期:进待审清单,不进公共素材库
  257. assert.deepEqual(pkg.expected, { reviewStatus: 'pending', readyForUse: false });
  258. // 文档抽取的文本进入打标语料
  259. assert.ok(pkg.schoolCanonical === '帝国理工', `学校归一失败:${pkg.schoolCanonical}`);
  260. assert.ok(pkg.highlightTypes.includes('提分'));
  261. assert.ok(pkg.imageUrls.length >= 10);
  262. assert.ok(pkg.imageUrls.every((u) => u.startsWith('https://cdn.example.invalid/cases/e2e/')), 'url 应按 --asset-base-url 派生');
  263. assert.equal(pkg.hasImage, true);
  264. assert.equal(pkg.materialAssets.filter((a) => !a.url).length, 0, '提供了 base url 后不应再有缺 url 的素材');
  265. // 产物落盘
  266. for (const file of Object.values(report.artifacts)) {
  267. assert.ok(fs.existsSync(file), `产物缺失:${file}`);
  268. }
  269. const pkgOnDisk = JSON.parse(fs.readFileSync(report.artifacts.casePackage, 'utf8'));
  270. assert.equal(pkgOnDisk.idempotencyKey, pkg.idempotencyKey);
  271. // 报告提到九宫格顺序与待审
  272. const md = fs.readFileSync(report.artifacts.report, 'utf8');
  273. assert.match(md, /materialAssets/);
  274. assert.match(md, /3x3/);
  275. });
  276. test('端到端 ingest:未授权时不产出可提交案例包(canSubmit=false)', async (t) => {
  277. const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'caseget-unauth-'));
  278. t.after(() => fs.rmSync(workRoot, { recursive: true, force: true }));
  279. const fixtures = makeFixtures(path.join(workRoot, 'fixtures'));
  280. const report = await runIngest({
  281. inputs: [fixtures.images[0]],
  282. outDir: path.join(workRoot, 'out'),
  283. authorizationStatus: 'pending',
  284. skipVision: true,
  285. noLearn: true,
  286. });
  287. assert.equal(report.canSubmit, false);
  288. assert.ok(report.casePackage.riskFlags.includes('UNAUTHORIZED'));
  289. assert.ok(report.casePackage.complianceBlockers.length > 0);
  290. });