| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- // 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);
- });
|