数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
// 「传·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}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user