feat: 第一季九章完整剧本与引擎(chapters 4-9、相处线索报告替代七维评分、采样覆盖驱动支线插入、consent v2、报告页隐私区块完善)+ 跨 agent 断点修复

This commit is contained in:
Jayway Liu
2026-10-04 13:26:08 +08:00
parent 9ac1162f00
commit 9c48b2a39f
31 changed files with 3836 additions and 185 deletions
+19 -17
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { ACT1 as chapters } from '../src/engine/chapters';
import { BRANCHES } from '../src/engine/branches';
import { decideInsertion } from '../src/engine/branching';
import { StoryEngine } from '../src/engine/engine';
@@ -7,13 +7,12 @@ import { makeEvent } from '../src/engine/eventStore';
import type { StoryEvent, EventStore } from '../src/engine/eventStore';
import { choiceFlags, deriveFlags, resolveConditionalText } from '../src/engine/flags';
import { MemoryProgressStore } from '../src/engine/progress';
import { buildState, scoreProfile, PROFILE_DIMENSIONS } from '../src/engine/scoring';
import { buildState, assessRelationships, PROFILE_DIMENSIONS } from '../src/engine/scoring';
import type { Confidences, ProfileDimension } from '../src/engine/scoring';
import type { Chapter, StoryNode, ChoiceBehavior } from '../src/engine/types';
import { MockJevClient } from '../src/services/jev';
import type { JevQuestion } from '../src/services/jev';
const chapters: Chapter[] = [1, 2, 3].map((i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')));
const confidences = (low: Confidences = {}): Confidences => Object.assign(Object.fromEntries(PROFILE_DIMENSIONS.map((d) => [d.id, 0.9])), low);
const inserted = (id: string) => {
const b = BRANCHES.find((b) => b.id === id)!;
@@ -203,9 +202,9 @@ for (const dimension of ['attachment', 'values', 'conflict_style', 'emotional_st
const plans = store.events.filter((e) => e.kind === 'branch_inserted');
assert.equal(plans.length, 2);
assert(plans[0].branchId?.endsWith('-a') && plans[1].branchId?.endsWith('-b'));
assert.equal(jev.calls.length, 3);
assert(store.events.some((e) => e.kind === 'evidence_insufficient' && e.dimensions.includes(dimension)));
assert(String(jev.calls[2]).includes('CB-'));
// 章末插入置信度 = samplingCoverage(事件采样统计),不再调 Jev——锁新语义
assert.equal(jev.calls.length, 0);
// 支线本身贡献采样:走完 A→B 后该维度覆盖达标,不再标记证据不足(原断言基于旧的 Jev 置信度语义)
const count = store.events.length;
e.replay('chapter1');
assert.equal(e.insertions.length, 0);
@@ -214,7 +213,6 @@ for (const dimension of ['attachment', 'values', 'conflict_style', 'emotional_st
await walkTo(e, 'c1-end');
await e.advance();
assert.equal(e.insertions[0].id, plans[0].branchId);
assert(!String(jev.calls.at(-1)).includes(plans[0].sessionId));
}
console.log('✓ 五维各跑通三章、A→B换皮、末章证据不足、本局隔离、replay 清计划');
@@ -226,28 +224,30 @@ console.log('✓ 五维各跑通三章、A→B换皮、末章证据不足、本
await walkTo(e, 'c1-end');
const oldSnapshot = progress.load()!;
await Promise.all([e.advance(), e.advance(), e.advance()]);
assert.equal(jev.calls.length, 1);
assert.equal(jev.calls.length, 0); // 新语义:章末决策用采样统计,无 Jev 调用;幂等性看 branch_inserted 事件数
assert.equal(e.state.chapterId, 'chapter2');
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, 1);
const restored = new StoryEngine(chapters, store, jev, undefined, progress, BRANCHES);
await restored.restore(oldSnapshot);
await restored.advance();
assert.equal(jev.calls.length, 1);
assert.equal(jev.calls.length, 0);
assert.equal(restored.insertions.length, 1);
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, 1);
}
for (const jev of [
{ ask: async () => { throw new Error('test offline'); } },
{ ask: async () => ({}) },
{ adaptiveScoring: false, ask: async () => { assert.fail('缺 key 不应打分'); } },
]) {
// 新语义:章末插入决策用 samplingCoverage(不调 Jev),Jev 抛错/空响应不影响插入;
// 唯一关闭路径是 adaptiveScoring === false(无 key 声明不打分)
for (const [jev, expectInserted] of [
[{ ask: async () => { throw new Error('test offline'); } }, true],
[{ ask: async () => ({}) }, true],
[{ adaptiveScoring: false, ask: async () => { assert.fail('缺 key 不应打分'); } }, false],
] as const) {
const store = new MemoryStore();
const e = new StoryEngine(chapters, store, jev, undefined, undefined, BRANCHES);
await e.restore({ sessionId: 'test', chapterId: 'chapter1', nodeId: 'c1-end', flags: {} });
await e.advance();
assert.equal(e.state.chapterId, 'chapter2');
assert.equal(e.insertions.length, 0);
assert.equal(store.events.length, 0);
assert.equal(e.insertions.length, expectInserted ? 1 : 0);
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, expectInserted ? 1 : 0);
}
console.log('✓ 重复点击与刷新幂等、Jev故障/无效响应/无key降级');
@@ -275,6 +275,8 @@ for (const branchId of ['b-cul-a', 'b-cul-b']) {
console.log('✓ 文化双问题分题记录 partial,主动离场独立于提示用尽/答错');
assert(!buildState([makeEvent({ sessionId: 'private', chapterId: 'chapter1', kind: 'profile', pointId: 'C1-01', dimensions: [], profile: { name: 'SECRET-NAME', city: '', age: '', gender: '', occupation: '', education: '' } })]).includes('SECRET-NAME'));
await assert.rejects(() => scoreProfile([], { ask: async () => ({}) }));
const threeAttachmentScenes = ['C2-01', 'C2-03', 'C2-06'].map((p) =>
makeEvent({ sessionId: 'test', chapterId: 'chapter2', pointId: p, kind: 'choice', dimensions: ['attachment'], optionId: 'a', optionLabel: 'a' }));
await assert.rejects(() => assessRelationships(threeAttachmentScenes, { ask: async () => ({}) }));
assert.equal(resolveConditionalText('【若 cb_val_a_water = d_redirect:粥棚】', { cb_val_a_water: 'a_give' }), '');
console.log('✓ 共享评分排除身份数据、无效响应不冒充低置信、条件文案不串线');