Files
digital-me-history/digital-me-backend/test/zhuanLLM.test.js
T
admin 0593ff7e62
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
数字我 Digital Me:全量入库(大文件走 Git LFS)
2026-10-10 16:24:43 +08:00

303 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 「传·LLM 润色」的结构护栏测试 —— ★★ 命门:证明【LLM 造的引文进不了最终传】。
//
// ⚠ LLM 是运行时(Qwen),agent/CI 调不了它 —— 故本组【只测结构】:装配 / 护栏 / 降级。
// 护栏必须是【结构保证】(就算 LLM 乱写也挡得住),不是「相信 LLM 会听话」。
// ⇒ 用【注入的假 _polish 函数】喂「LLM 输出」,把 renderZhuan 当纯函数测,不碰网络、不碰 DB。
//
// 这一组钉的四件事(对应任务的四条):
// 1. 装配:料齐 → 出传;没写完 → null(书还没写到那儿)。buildPolishMessages 摊得对。
// 2. ★★★ 护栏(命门):构造含【伪造引文】的假 LLM 输出,喂进拼回/后校验,
// 断言【伪造引文进不了最终传】。两道墙:guardSentence(单句)+ assertNoForgedCites(整篇)。
// 3. 降级:无 key ⇒ 逐字等于 buildZhuan 的规则传;LLM 抛/空 ⇒ 回落规则传。
// 4. 无时代错置/失败态:游戏词/失败态/注释标记漏不进最终传。
process.env.NODE_ENV = 'test';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openLineage, appendDecision } from '../src/decisions.js';
import { computeRank, profileFromSubs } from '../src/dynasties.js';
import { buildZhuan, assertZhuan } from '../src/zhuan.js';
import { config } from '../src/config.js';
import {
renderZhuan,
isPolishable,
guardSentence,
bracketQuotes,
assembleZhuanWithPolish,
assertNoForgedCites,
buildPolishMessages,
} from '../src/services/zhuanLLM.js';
// ── 样板人:与 decisions.test.js / lineage.test.js 的 CLERK 同一人(南齐中书通事舍人 tier4)──
const CLERK = ['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'];
const PERSON = { name: '某' };
// 一部【走得完】的南齐传(纯引擎搭,不吃 DB、不吃时间戳):
// 永明那一趟江(不肯往)→ 建武的求名(出为大司农,关口二不开 → 链走完)。
function closedLineage() {
const rank = computeRank(profileFromSubs(CLERK, { wang: 0 }), 'nanchao');
let L = openLineage({ subjectId: 'u', rank });
L = appendDecision(L, 'nanqi-xuanzhi', 'bu-ken-wang');
L = appendDecision(L, 'nanqi-sheren-simen', 'qiu-ming');
return L;
}
function openLineageDoc() {
const rank = computeRank(profileFromSubs(CLERK, { wang: 0 }), 'nanchao');
return appendDecision(openLineage({ subjectId: 'u', rank }), 'nanqi-xuanzhi', 'bu-ken-wang'); // 未 closed
}
// 无 key 环境下把 renderZhuan 当纯函数跑(显式喂 _polish 绕开无-key 早返回)。
const L = closedLineage();
const RULE = buildZhuan(L, { person: PERSON });
// ══════════════════════════════════════════════════════════════════════════
// 1. 装配
// ══════════════════════════════════════════════════════════════════════════
test('装配:料齐 → 出传;未写完 → null(传末的话得等传末)', async () => {
assert.ok(RULE && Array.isArray(RULE.zhengwen) && RULE.zhengwen.length > 1, '闭合链应当出传');
assert.equal(RULE.zhengwen.length, RULE.zhu.length, '每句正文都必须有一条注');
// 未 closed ⇒ renderZhuan 与 buildZhuan 同口径:返回 null。
const openDoc = openLineageDoc();
assert.equal(buildZhuan(openDoc, { person: PERSON }), null, 'buildZhuan:没写完返回 null');
assert.equal(await renderZhuan(openDoc, { person: PERSON, _polish: () => ({}) }), null,
'renderZhuan:没写完同样返回 null,不硬拼');
});
test('装配:buildPolishMessages 把全篇摊成〔n〕〈锁/润〉行,且铁律齐备', () => {
const [sys, usr] = buildPolishMessages(RULE);
assert.equal(sys.role, 'system');
assert.equal(usr.role, 'user');
// system 里那几条【不可破】的话必须在(结构提示,不是靠它,但缺了是回归)。
for (const must of ['绝不新增', '「」', '《》', '不写', '收尾断语', 'JSON']) {
assert.ok(sys.content.includes(must), `system 铁律缺:${must}`);
}
// 每一句都摊成一行,且【可润/锁定】的标记与 isPolishable 完全一致。
const lines = usr.content.split('\n').filter((x) => /^〔\d+〕/.test(x));
assert.equal(lines.length, RULE.zhengwen.length, '每句正文都要摊成一行');
RULE.zhengwen.forEach((s, i) => {
const mark = isPolishable(s, RULE.zhu[i]) ? '〈润〉' : '〈锁〉';
assert.ok(usr.content.includes(`〔${s.n}〕${mark}`), `〔${s.n}〕的锁/润标记应当是 ${mark}`);
});
// 传主句(n=1,纯推演无引文)必是〈润〉;带引文的句(含「」)必是〈锁〉。
assert.ok(isPolishable(RULE.zhengwen[0], RULE.zhu[0]), '传主句可润');
const quoted = RULE.zhengwen.find((s) => bracketQuotes(s.text).length);
assert.ok(quoted, '这部传里应当有带「」引文的锁定句(关口一王俭那句)');
const qi = RULE.zhengwen.indexOf(quoted);
assert.equal(isPolishable(quoted, RULE.zhu[qi]), false, '带「」引文的句必须锁定');
});
// ══════════════════════════════════════════════════════════════════════════
// 2. ★★★ 护栏(命门)—— 伪造引文进不了最终传
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 命门·墙一(guardSentence):LLM 往推演句里塞【伪造籍贯+伪造「」引文】,进不了传', async () => {
// 假 LLM:把传主句改成「某,吳興烏程人也,嘗曰:「臣本布衣」。」——伪造籍贯 + 伪造原文引文。
const forgedPolish = () => ({ 1: '某,吳興烏程人也,嘗曰:「臣本布衣」。' });
const out = await renderZhuan(L, { person: PERSON, _polish: forgedPolish });
assert.ok(!out.zhengwenText.includes('臣本布衣'), '伪造的「臣本布衣」绝不许出现在最终传里');
assert.ok(!out.zhengwenText.includes('吳興烏程'), '伪造的籍贯「吳興烏程」绝不许出现在最终传里');
assert.ok(!out.zhengwenText.includes('吴兴乌程'), '简体伪造籍贯同样不许出现');
// guard 把这一句挡回原文 ⇒ 传主句 == 规则原文;且既然没有真正改动,诚实起见不谎称已润。
assert.equal(out.zhengwen[0].text, RULE.zhengwen[0].text, '传主句被单句回落成规则原文');
assert.notEqual(out.rendered, 'llm-polished', '一句都没真改动 ⇒ 不许挂「已润色」的诚实字段');
});
test('★★★ 命门·墙二(assertNoForgedCites):整篇后校验独立成立 —— 就算一句伪造引文漏进来也拦下', () => {
// 允许集来自【规则正文】的「」多重集;最终正文里任何一段不在允许集中 = 伪造。
const forgedFinal = [{ n: 1, text: '某嘗曰:「臣本布衣」。' }];
const ruleZ = [{ n: 1, text: '某,不知何許人也。' }];
assert.deepEqual(assertNoForgedCites(forgedFinal, ruleZ), ['「臣本布衣」'],
'不在允许集里的「」被判定为伪造');
// 真实的允许引文原样传下去 ⇒ 干净(不误伤锁定句里的真引文)。
const genuine = RULE.zhengwen.find((s) => bracketQuotes(s.text).length);
assert.deepEqual(assertNoForgedCites([genuine], RULE.zhengwen), [],
'规则正文里本就有的真引文,不该被判伪造');
// 多重集:允许集里出现两次的引文,最终出现三次 → 多出来的那一次算伪造。
const twice = [{ n: 1, text: '甲曰:「同一句」。' }, { n: 2, text: '乙亦曰:「同一句」。' }];
const thrice = [
{ n: 1, text: '甲曰:「同一句」。' }, { n: 2, text: '乙亦曰:「同一句」。' },
{ n: 3, text: '丙又曰:「同一句」。' },
];
assert.deepEqual(assertNoForgedCites(thrice, twice), ['「同一句」'], '超出允许次数的那一段算伪造');
});
test('★★ airtight:LLM 改写【锁定句】一律被忽略 —— 引文段永不进 LLM 的输出保留区', async () => {
// 找一条带「」引文的锁定句,喂一个把它整段替换掉的假改写。
const idx = RULE.zhengwen.findIndex((s) => bracketQuotes(s.text).length);
const n = RULE.zhengwen[idx].n;
const tamper = () => ({ [n]: `某曰:「伪造的另一句${n}」。`, 1: '某者,莫知其何許人也。' });
const out = await renderZhuan(L, { person: PERSON, _polish: tamper });
assert.equal(out.zhengwen[idx].text, RULE.zhengwen[idx].text, '锁定句原样保留,LLM 碰不到它');
assert.ok(!out.zhengwenText.includes('伪造的另一句'), '对锁定句的改写不进最终传');
// 而同一次里,那句合法的推演改写(传主句,无引文)应当被采纳 ⇒ 确实走了润色。
assert.equal(out.zhengwen[0].text, '某者,莫知其何許人也。', '合法的推演改写被采纳');
assert.equal(out.rendered, 'llm-polished', '有真正改动 ⇒ 诚实标记已润');
});
test('★ assembleZhuanWithPolish 是确定性纯函数:锁定原样、可润择优、单句回落', () => {
const rewrites = {};
// 给每一句都塞一个改写;锁定句应被忽略,含「」/marks 的可润改写应被单句回落。
RULE.zhengwen.forEach((s) => { rewrites[s.n] = `改写-${s.n}`; });
// 再故意给传主句一个含伪造引文的改写。
rewrites[1] = '某曰:「伪造」。';
const assembled = assembleZhuanWithPolish(RULE, rewrites);
assert.equal(assembled.length, RULE.zhengwen.length, '句数不变');
RULE.zhengwen.forEach((s, i) => {
assert.equal(assembled[i].n, s.n, '编号不变(注靠编号挂)');
if (!isPolishable(s, RULE.zhu[i])) {
assert.equal(assembled[i].text, s.text, '锁定句:原样');
} else if (/[「」《》]/.test(rewrites[s.n])) {
assert.equal(assembled[i].text, s.text, '含引文的可润改写:单句回落原文');
} else {
assert.equal(assembled[i].text, rewrites[s.n], '干净的可润改写:采纳');
}
});
// 拼回后整篇不含任何伪造引文。
assert.deepEqual(assertNoForgedCites(assembled, RULE.zhengwen), [], '拼回后无伪造引文');
});
test('★ guardSentence 单句护栏:伪造引文/书名号/注释标记/游戏词/超长/空 一律拒', () => {
const orig = '某,不知何许人也。';
assert.equal(guardSentence('某者,莫知其何许人也。', orig), true, '干净的润色应当收');
assert.equal(guardSentence('某曰:「臣本布衣」。', orig), false, '带「」引文:拒');
assert.equal(guardSentence('见《南齐书》云云。', orig), false, '带《》书名号:拒');
assert.equal(guardSentence('某★推演也。', orig), false, '带注释标记★:拒');
assert.equal(guardSentence('某解锁了下一关。', orig), false, '游戏词解锁:拒');
assert.equal(guardSentence('通关之后某失败了。', orig), false, '失败态/通关:拒');
assert.equal(guardSentence('', orig), false, '空:拒');
assert.equal(guardSentence('某' + '甲'.repeat(500), orig), false, '跑飞加料超长:拒');
});
// ★★ 命门相邻的坑:润色输出是【文言】,料源全是繁体(happy-path 都用繁体),LLM 极可能输出繁体。
// 若禁词只写简体,繁体游戏/失败态词整句躲过护栏进传。这一条钉死【简繁都拒】。
test('★★ guardSentence 繁体游戏词/失败态一样拒(禁词必须简繁双写)', () => {
const orig = '某,不知何许人也。';
for (const s of [
'某通關之後失敗了。', '某解鎖了下一關。', '某闖關而退。', '某讀檔重來。',
'某升級得了積分。', '某開了遊戲的副本。', '某按面板上的按鈕存檔。',
]) {
assert.equal(guardSentence(s, orig), false, `繁体游戏/失败态词必须拒:${s}`);
}
// 简体分支【不许被误伤】:原来能拒的还得拒。
for (const s of ['某通关之后失败了。', '某解锁了下一关。', '某读档重来。']) {
assert.equal(guardSentence(s, orig), false, `简体仍拒:${s}`);
}
});
test('★★ renderZhuan:繁体失败态/游戏词塞进推演句,一律进不了最终传(简繁双拒的端到端证明)', async () => {
const badPolish = () => ({
1: '某開局選了這職業,闖到第三關失敗,只好讀檔重來。', // 繁体游戏/失败态
5: '某按面板上的按鈕,升級解鎖了副本。', // 繁体游戏机制
});
const out = await renderZhuan(L, { person: PERSON, _polish: badPolish });
for (const w of ['開局', '第三關', '失敗', '讀檔', '重來', '面板', '按鈕', '升級', '解鎖', '副本', '遊戲', '關卡']) {
assert.ok(!out.zhengwenText.includes(w), `最终传里不许出现繁体游戏/失败态词:${w}`);
}
assert.deepEqual(out, RULE, '两句都被 guard 挡回 ⇒ 回落规则传(不谎称已润)');
});
// ★★ 补 (a)(b) 两墙拦不住的一处洞:引文(「」)防伪造 airtight,但一句【裸推演散句】里
// 编造的事实(把「某,不知何许人也」坐实成「某,吳興烏程人也」——无「」无《》无禁词)原本靠
// 提示词(相信 LLM)。这个产品地基是「查不实宁可不写」,故加一条【可结构判定】的窄规则:
// 原句含「未知其所出」类否定,润色版必须仍含否定,否则单句回落。
test('★★ guardSentence:原句「未知其所出」,润色不许坐实成具体籍贯(堵裸推演散句里的编造事实)', () => {
const orig = '某,不知何许人也。';
assert.equal(guardSentence('某,吳興烏程人也。', orig), false, '把「不知何许」坐实成籍贯:拒(无「」,旧护栏会放行)');
assert.equal(guardSentence('某,沛國譙人也,世為冠族。', orig), false, '坐实成另一籍贯+门第:拒');
assert.equal(guardSentence('某者,莫知其何許人也。', orig), true, '保留「莫知」的润色:收');
assert.equal(guardSentence('某者,其所出不詳。', orig), true, '保留「不詳」的润色:收');
// 原句本无「未知」标记 ⇒ 本条不介入(不误伤正常推演句)。
const orig2 = '某遭时之变,介乎其间。';
assert.equal(guardSentence('某遭逢時變,介乎其間而已。', orig2), true, '原句无未知标记:本条不管,正常润色照收');
});
test('★★ renderZhuan:把「不知何许人也」坐实成具体籍贯(无引文),进不了最终传', async () => {
// 前提:传主起句确实是「未知其所出」体(本产品无真实籍贯数据,出身恒为不知)。
assert.match(RULE.zhengwen[0].text, /不知|莫知|不详|未详|無考|无考/, '传主起句应是「未知其所出」体');
const forge = () => ({ 1: '某,吳興烏程人也,蓋江東名族。' }); // 无「」无《》无禁词 ⇒ 旧护栏会放行
const out = await renderZhuan(L, { person: PERSON, _polish: forge });
assert.ok(!out.zhengwenText.includes('吳興烏程'), '坐实的籍贯进不了最终传');
assert.ok(!out.zhengwenText.includes('名族'), '编造的门第进不了最终传');
assert.equal(out.zhengwen[0].text, RULE.zhengwen[0].text, '传主句单句回落成规则原文');
});
// ══════════════════════════════════════════════════════════════════════════
// 3. 降级
// ══════════════════════════════════════════════════════════════════════════
test('★★ 降级:无 config.llm.dashscopeKey ⇒ renderZhuan 逐字等于 buildZhuan 的规则传', async () => {
const saved = config.llm.dashscopeKey;
config.llm.dashscopeKey = ''; // mock 无 key(显式判 key 的那条早返回)
try {
const out = await renderZhuan(L, { person: PERSON }); // 不喂 _polish ⇒ 走无-key 早返回
assert.deepEqual(out, RULE, '无 key ⇒ 原样返回规则传,一个字不变');
assert.equal(out.rendered, undefined, '无 key ⇒ 不置 rendered,tili 仍是「不是 LLM 生成」');
} finally {
config.llm.dashscopeKey = saved;
}
});
test('降级:LLM 抛 / 返回空 / 返回非对象 ⇒ 回落规则传', async () => {
const threw = await renderZhuan(L, { person: PERSON, _polish: () => { throw new Error('qwen down'); } });
assert.deepEqual(threw, RULE, 'LLM 抛 ⇒ 回落规则传');
const empty = await renderZhuan(L, { person: PERSON, _polish: () => ({}) });
assert.deepEqual(empty, RULE, 'LLM 返回空改写 ⇒ 一句没改 ⇒ 回落规则传(不谎称已润)');
const junk = await renderZhuan(L, { person: PERSON, _polish: () => null });
assert.deepEqual(junk, RULE, 'LLM 返回非对象 ⇒ 回落规则传');
});
test('happy path:合法润色 ⇒ 推演段改文字、引文/注/断语原样、体例自检仍过、诚实字段置位', async () => {
const legit = () => ({
1: '某者,莫知其何許人也。', // 传主句:换个说法,不动引文
5: '子響所殺者,僚佐數人,某手無寸兵,所挾唯一旨耳。', // 推演句:更顺,仍无引文
});
const out = await renderZhuan(L, { person: PERSON, _polish: legit });
assert.equal(out.rendered, 'llm-polished', '走了润色 ⇒ 诚实标记');
assert.match(out.tili, /引文、注、.*断语.*未经 LLM|皆规则原文/, 'tili 必须说清引文/注/断语未经 LLM');
assert.equal(out.zhengwen[0].text, '某者,莫知其何許人也。', '推演句被润');
// 注、断语、shizan 原样。
assert.deepEqual(out.zhu, RULE.zhu, '注一个字都不许经 LLM');
assert.deepEqual(out.shizan, RULE.shizan, '史臣曰/赞曰断语一个字都不许经 LLM');
// 锁定引文句全部原样。
RULE.zhengwen.forEach((s, i) => {
if (!isPolishable(s, RULE.zhu[i])) assert.equal(out.zhengwen[i].text, s.text, `锁定句〔${s.n}〕原样`);
});
// 体例自检 + 无伪造引文。
assert.deepEqual(assertZhuan(out.zhengwen, out.zhu), [], '润色后仍过体例自检');
assert.deepEqual(assertNoForgedCites(out.zhengwen, RULE.zhengwen), [], '润色后无伪造引文');
assert.equal(out.zhengwenText, out.zhengwen.map((x) => x.text).join(''), 'zhengwenText 与 zhengwen 一致');
});
// ══════════════════════════════════════════════════════════════════════════
// 4. 无时代错置 / 失败态
// ══════════════════════════════════════════════════════════════════════════
test('★ 失败态/游戏词/时代错置:LLM 把它们塞进推演句,一律进不了最终传', async () => {
const badPolish = () => ({
1: '某解锁了下一关,通关之后失败了。', // 游戏/失败态词
5: '某按下面板上的按钮,读档重来。', // 游戏机制词
});
const out = await renderZhuan(L, { person: PERSON, _polish: badPolish });
for (const w of ['解锁', '通关', '失败', '下一关', '面板', '读档', '重来', '按钮', '游戏']) {
assert.ok(!out.zhengwenText.includes(w), `最终传里不许出现游戏/失败态词:${w}`);
}
// 两句都被 guard 挡回原文 ⇒ 与规则传一致。
assert.deepEqual(out, RULE, '全被挡回 ⇒ 回落规则传');
});
test('★ 注释标记(★⚠✗✓⇒)漏不进最终正文', async () => {
const marky = () => ({ 1: '某★,不知何许人也⚠。' });
const out = await renderZhuan(L, { person: PERSON, _polish: marky });
for (const m of ['★', '⚠', '✗', '✓', '⇒']) {
assert.ok(!out.zhengwenText.includes(m), `最终正文里不许有注释标记:${m}`);
}
});