// Copyright (c) 未来飞马 // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // Trademark Notice: // The MPL-2.0 license grants copyright permissions for source code only. // It does NOT grant any rights to use trademarks including "未来飞马", // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步". // Any use of these trademarks requires separate written permission. /** * 拆解 / 打标 / 合规 / 入库契约 测试:`node --test` */ import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import { loadDictionary, buildIdempotencyKey, normalizeMaterialAsset, imageUrlsFrom, sortAssets, uniq } from '../lib.mjs'; import { assessCompliance, classifyCase, deriveCaseFields, extractOutcome, findPrivacy, inferFitStatus, normalizeSchool, splitCaseAndMaterials, } from '../classify.mjs'; import { runIngest, makeTinyPng } from '../case-intake.mjs'; import { makeFixtures } from './fixtures.mjs'; const dict = loadDictionary(); // --------------------------------------------------------------------------- // 1. 输入识别 / 拆解 // --------------------------------------------------------------------------- test('splitCaseAndMaterials:案例说明 与 素材 严格分开', () => { const split = splitCaseAndMaterials({ textBundle: { paragraphs: ['案例说明段落'], notes: ['备注'], titles: [], transcript: '视频转写内容' }, visionResults: [ { role: 'material', ocrText: '素材图里的字', description: '成绩单截图' }, { role: 'description', ocrText: '说明配图的字', description: '流程图' }, ], }); assert.ok(split.descriptionCorpus.includes('案例说明段落')); assert.ok(split.descriptionCorpus.includes('视频转写内容')); assert.ok(split.descriptionCorpus.includes('流程图'), 'description 角色归案例说明'); assert.ok(split.materialCorpus.includes('成绩单截图'), 'material 角色归素材'); assert.ok(!split.materialCorpus.includes('案例说明段落')); }); // --------------------------------------------------------------------------- // 2. 打标 // --------------------------------------------------------------------------- test('学校别名归一:IC → 帝国理工,UCL → 伦敦大学学院;不误伤 MAGIC', () => { const a = normalizeSchool('IC 的计算机课,考前冲刺', dict); assert.equal(a.schoolCanonical, '帝国理工'); assert.deepEqual(a.schoolAliases, ['IC']); assert.equal(a.country, '英国'); const b = normalizeSchool('UCL 的学生找我辅导', dict); assert.equal(b.schoolCanonical, '伦敦大学学院'); assert.equal(normalizeSchool('MAGIC 系统很好用', dict).schoolCanonical, ''); }); test('打标:亮点 / 场景 / 异议 / 静态属性 按字典与策略命中', () => { const { tags } = classifyCase({ dict, groups: [], visionResults: [], textBundle: { paragraphs: ['帝国理工 计算机科学 本科二年级,期末考前冲刺,提分 18 分,家长很满意', '客户说太贵了,不放心老师'], notes: [], titles: [], transcript: '', }, authorizationStatus: 'authorized', }); assert.deepEqual(tags.schoolCanonical, ['帝国理工']); assert.deepEqual(tags.country, ['英国']); assert.ok(tags.major.includes('计算机科学')); assert.ok(tags.highlightTypes.includes('提分')); assert.ok(tags.highlightTypes.includes('家长认可')); assert.ok(tags.scenarioTags.includes('考前冲刺')); assert.ok(tags.scenarioTags.includes('期末考试')); assert.ok(tags.objectionTags.includes('贵')); assert.ok(tags.objectionTags.includes('不放心老师')); }); test('适用跟进状态:按 docs/prd/dashboard/10 策略命中(异议处理中)', () => { const fit = inferFitStatus({ highlightTypes: ['提分', '家长认可'], scenarioTags: ['考前冲刺'], objectionTags: ['贵'], subject: [], }); assert.ok(fit.fitStatus.includes('异议处理中')); assert.match(fit.reason, /跟进状态/); }); test('适用跟进状态:命中七个枚举之一(全部在契约枚举内)', () => { const allowed = new Set(['新进线', '挖需中', '方案推荐中', '异议处理中', '待决策', '沉默待跟进', '已成交']); for (const scenario of [ { highlightTypes: ['补考通过'], scenarioTags: [], objectionTags: [], subject: [] }, { highlightTypes: ['导师匹配'], scenarioTags: [], objectionTags: [], subject: [] }, { highlightTypes: [], scenarioTags: ['考前冲刺'], objectionTags: [], subject: [] }, { highlightTypes: ['首课体验'], scenarioTags: [], objectionTags: [], subject: ['高等数学'] }, ]) { for (const status of inferFitStatus(scenario).fitStatus) { assert.ok(allowed.has(status), `非法跟进状态:${status}`); } } }); // --------------------------------------------------------------------------- // 3. 隐私 / 合规 // --------------------------------------------------------------------------- test('findPrivacy:片段一律掩码,绝不返回完整 PII', () => { const findings = findPrivacy('联系 13812345678 邮箱 a.b@example.com 微信:student_abc'); const fields = findings.map((f) => f.field); assert.ok(fields.includes('手机号')); assert.ok(fields.includes('邮箱')); assert.ok(fields.includes('微信号')); const phone = findings.find((f) => f.field === '手机号'); assert.ok(!phone.snippet.includes('13812345678'), '手机号必须已掩码'); assert.match(phone.snippet, /^138\*{4}5678$/); }); test('合规:客诉 / 负面事件 → riskFlags,且禁止入库', () => { const result = assessCompliance({ corpus: '客户投诉退费纠纷,要求赔偿,还发了黑猫', authorizationStatus: 'authorized', hasMaterials: true, }); assert.ok(result.riskFlags.includes('COMPLAINT')); assert.equal(result.authorizationOk, true); assert.ok(result.complianceBlockers.some((b) => b.includes('客诉'))); assert.match(result.reviewHint, /驳回|复盘/); }); test('合规:无授权一律不入库(硬规则)', () => { for (const status of ['pending', 'denied', '']) { const result = assessCompliance({ corpus: '帝国理工 提分案例,很好', authorizationStatus: status, hasMaterials: true, }); assert.equal(result.authorizationOk, false, `status=${status} 必须判为无授权`); assert.ok(result.riskFlags.includes('UNAUTHORIZED')); assert.ok(result.complianceBlockers.some((b) => b.includes('无授权'))); } }); test('合规:无素材 / 跑题 也拦下来', () => { assert.ok(assessCompliance({ corpus: '帝国理工 提分', authorizationStatus: 'authorized', hasMaterials: false }) .complianceBlockers.some((b) => b.includes('可用素材'))); assert.ok(assessCompliance({ corpus: '今天天气不错,出门散步', authorizationStatus: 'authorized', hasMaterials: false }) .riskFlags.includes('OFF_TOPIC')); }); test('合规:正常好案例 → 可入库待审,无 blocker', () => { const result = assessCompliance({ corpus: '帝国理工 计算机 考前冲刺,期末提分 18 分,家长很满意', authorizationStatus: 'authorized', hasMaterials: true, }); assert.equal(result.authorizationOk, true); assert.equal(result.complianceBlockers.length, 0); assert.match(result.reviewHint, /待审/); }); // --------------------------------------------------------------------------- // 4. 案例字段抽取 // --------------------------------------------------------------------------- test('extractOutcome:提分幅度 / 周期 / 结果 / 录取', () => { assert.equal(extractOutcome('期末提分 18 分').scoreGain, '18分'); assert.equal(extractOutcome('从 52 分到 78 分').scoreRange, '52 → 78'); assert.equal(extractOutcome('辅导 6 周').period, '6 周'); assert.equal(extractOutcome('拿到了帝国理工的录取').admittedTo, '帝国理工'); assert.equal(extractOutcome('最终 pass 了').result, 'pass'); }); test('deriveCaseFields:标题 / 摘要 / 使用建议 / 结果证据 都产出', () => { const fields = deriveCaseFields( 'IC 计算机科学考前冲刺案例\n客户在读本科二年级,期末前两周找到我们,担心挂科。\n结果:期末提分 18 分,家长很满意。', [{ count: 3, preview: '' }], { productLine: ['大学课程'], schoolCanonical: ['帝国理工'], subject: ['高等数学'], stage: ['本科二年级'], scenarioTags: ['考前冲刺'], objectionTags: [] }, { fitStatus: ['挖需中'] }, ); assert.ok(fields.title.length > 0); assert.ok(fields.summary.length > 0); assert.match(fields.usageSuggestion, /痛点|挂|时间/); assert.match(fields.targetCustomer, /帝国理工/); assert.match(fields.resultEvidence, /提分/); }); // --------------------------------------------------------------------------- // 5. materialAssets 契约(逐字对齐 SHARED-CONTRACT.md) // --------------------------------------------------------------------------- test('normalizeMaterialAsset:字段齐全、order 1 起、非法值报问题', () => { const { asset, problems } = normalizeMaterialAsset({ kind: 'image', url: 'https://x/a.jpg', order: 3 }, 0); assert.deepEqual(Object.keys(asset).sort(), [ 'description', 'groupId', 'kind', 'label', 'localPath', 'ocrText', 'order', 'role', 'url', 'usageSuggestion', ]); assert.equal(asset.order, 3); assert.equal(asset.role, 'material'); assert.equal(problems.length, 0); const bad = normalizeMaterialAsset({ kind: 'pdf' }, 0); assert.ok(bad.problems.some((p) => p.includes('kind'))); assert.ok(bad.problems.some((p) => p.includes('url'))); }); test('imageUrlsFrom:= kind=image 的 url,按 order 排序(兼容字段)', () => { const urls = imageUrlsFrom([ { order: 2, kind: 'image', url: 'https://x/b.jpg' }, { order: 1, kind: 'image', url: 'https://x/a.jpg' }, { order: 3, kind: 'video', url: 'https://x/c.mp4' }, ]); assert.deepEqual(urls, ['https://x/a.jpg', 'https://x/b.jpg']); }); test('buildIdempotencyKey:同内容同键,内容变则变(tenantId + idempotencyKey 幂等)', () => { const base = { tenantId: 'lumi-demo', sourceType: 'image_group', sourceRef: 'wx-2026-10', title: 'IC 提分', materialAssets: [{ order: 1, kind: 'image', url: 'u1' }] }; const k1 = buildIdempotencyKey(base); const k2 = buildIdempotencyKey({ ...base }); const k3 = buildIdempotencyKey({ ...base, title: '别的标题' }); assert.equal(k1, k2); assert.notEqual(k1, k3); assert.match(k1, /^caseget-[0-9a-f]{16}$/); assert.equal(buildIdempotencyKey({ ...base, explicit: 'manual-key' }), 'manual-key'); }); test('uniq:多参数/嵌套都支持,并丢空值', () => { assert.deepEqual(uniq(['a'], ['b', 'a'], [], ['c']), ['a', 'b', 'c']); assert.deepEqual(uniq([[1, 2], [2, 3]]), [1, 2, 3]); assert.deepEqual(uniq([null, undefined, '', 'x']), ['x']); }); test('sortAssets:按 order 升序且稳定', () => { const sorted = sortAssets([{ order: 2, label: 'b' }, { order: 1, label: 'a' }, { order: 2, label: 'c' }]); assert.deepEqual(sorted.map((a) => a.label), ['a', 'b', 'c']); }); // --------------------------------------------------------------------------- // 6. 端到端:夹具 → ingest → 案例包 // --------------------------------------------------------------------------- test('端到端 ingest:多图(9)+docx+pptx → 案例包,字段对齐共享契约,不自动提交', async (t) => { const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'caseget-e2e-')); t.after(() => fs.rmSync(workRoot, { recursive: true, force: true })); const fixtures = makeFixtures(path.join(workRoot, 'fixtures')); const outDir = path.join(workRoot, 'out'); const report = await runIngest({ inputs: [...fixtures.images, fixtures.docx, fixtures.pptx], outDir, sourceRef: 'e2e-fixture', authorizationStatus: 'authorized', assetBaseUrl: 'https://cdn.example.invalid/cases/e2e', // 单测不联网:关掉真上传,专门验证 --asset-base-url 派生的旧行为 upload: false, skipVision: true, // 不调模型:OCR 留空,其余链路全跑 noLearn: true, }); const pkg = report.casePackage; // 输入识别:多图 + 文档 + PPT → mixed assert.equal(pkg.sourceType, 'mixed'); // 素材:9 张九宫格 + 1 张异批 + docx 内嵌 1 + pptx 内嵌 1 assert.equal(pkg.materialAssets.length, 12); assert.deepEqual(pkg.materialAssets.map((a) => a.order), Array.from({ length: 12 }, (_, i) => i + 1)); // 四个分组:九宫格 / 异批单图 / docx 内嵌图 / pptx 内嵌图 // (两份文档的内嵌图不会互相并组——sourceKey 不同) assert.equal(pkg.groupInfoList.length, 4); assert.equal(pkg.groupInfoList.filter((g) => g.count === 1).length, 3); assert.ok(pkg.materialAssets.some((a) => a.description.includes('文档') || a.label.includes('内嵌')), '应包含文档内嵌图素材'); // 九宫格分组 const gridGroup = pkg.groupInfoList.find((g) => g.count === 9); assert.ok(gridGroup, '应识别出 9 张的九宫格组'); assert.equal(gridGroup.layout, '3x3'); assert.equal(gridGroup.orderingRule, 'left-to-right,top-to-bottom'); assert.equal(pkg.groupInfo.layout, '3x3'); // 契约必填字段 for (const key of ['sourceType', 'idempotencyKey', 'title', 'summary', 'materialAssets', 'groupInfo', 'tags', 'highlightTypes', 'scenarioTags', 'objectionTags', 'riskFlags', 'authorizationStatus']) { assert.ok(key in pkg, `案例包缺少契约字段 ${key}`); } // 落库预期:进待审清单,不进公共素材库 assert.deepEqual(pkg.expected, { reviewStatus: 'pending', readyForUse: false }); // 文档抽取的文本进入打标语料 assert.ok(pkg.schoolCanonical === '帝国理工', `学校归一失败:${pkg.schoolCanonical}`); assert.ok(pkg.highlightTypes.includes('提分')); assert.ok(pkg.imageUrls.length >= 10); assert.ok(pkg.imageUrls.every((u) => u.startsWith('https://cdn.example.invalid/cases/e2e/')), 'url 应按 --asset-base-url 派生'); assert.equal(pkg.hasImage, true); assert.equal(pkg.materialAssets.filter((a) => !a.url).length, 0, '提供了 base url 后不应再有缺 url 的素材'); // 产物落盘 for (const file of Object.values(report.artifacts)) { assert.ok(fs.existsSync(file), `产物缺失:${file}`); } const pkgOnDisk = JSON.parse(fs.readFileSync(report.artifacts.casePackage, 'utf8')); assert.equal(pkgOnDisk.idempotencyKey, pkg.idempotencyKey); // 报告提到九宫格顺序与待审 const md = fs.readFileSync(report.artifacts.report, 'utf8'); assert.match(md, /materialAssets/); assert.match(md, /3x3/); }); test('端到端 ingest:未授权时不产出可提交案例包(canSubmit=false)', async (t) => { const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'caseget-unauth-')); t.after(() => fs.rmSync(workRoot, { recursive: true, force: true })); const fixtures = makeFixtures(path.join(workRoot, 'fixtures')); const report = await runIngest({ inputs: [fixtures.images[0]], outDir: path.join(workRoot, 'out'), authorizationStatus: 'pending', upload: false, // 单测不联网 skipVision: true, noLearn: true, }); assert.equal(report.canSubmit, false); assert.ok(report.casePackage.riskFlags.includes('UNAUTHORIZED')); assert.ok(report.casePackage.complianceBlockers.length > 0); });