feat: Tauri SQLite 事件持久化 + 讲述点追问补全(缺失检测→模板追问→沉默即数据)
This commit is contained in:
+125
-7
@@ -2,14 +2,21 @@
|
||||
* 内容完整性与引擎流程验证(不走浏览器):
|
||||
* 1. 章节 JSON 的选择点 ID、维度标签与 docs/story-framework.md 第 4 节一一对应;
|
||||
* 2. 节点图完整(startNodeId 存在、所有 next 跳转有效、全部节点可达);
|
||||
* 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分。
|
||||
* 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分;
|
||||
* 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本;
|
||||
* 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete;
|
||||
* SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。
|
||||
* 运行:pnpm verify
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { StoryEngine } from '../src/engine/engine';
|
||||
import { LocalStorageEventStore } from '../src/engine/eventStore';
|
||||
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
|
||||
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
|
||||
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
|
||||
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
|
||||
import { MockJevClient } from '../src/services/jev';
|
||||
import type { JevAnswer, JevQuestion } from '../src/services/jev';
|
||||
|
||||
// story-framework.md 第 4 节的权威选择点地图(ID → 期望维度标签)
|
||||
const EXPECTED: Record<string, string[]> = {
|
||||
@@ -113,6 +120,12 @@ for (const n of narrationNodes) {
|
||||
);
|
||||
}
|
||||
check(narrationNodes.find((n) => n.pointId === 'C3-02')?.variantFrom === 'C1-02', 'C3-02 变体由 C1-02 驱动(variantFrom)');
|
||||
for (const n of narrationNodes) {
|
||||
check(
|
||||
FOLLOW_UP_DIMENSIONS.every((d) => typeof n.followUps?.[d] === 'string' && n.followUps[d]!.length > 0),
|
||||
`${n.pointId}: 六维 followUps 追问模板齐全`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- 4. Mock 模式端到端跑通三章 ---
|
||||
const store = new MemoryStore();
|
||||
@@ -127,21 +140,27 @@ while (!engine.state.finished && guard++ < 200) {
|
||||
const profile = await engine.extractIdentity('我叫阿黎,29岁,在杭州做互联网产品经理,硕士学历');
|
||||
await engine.confirmIdentity(profile);
|
||||
} else if (node.type === 'narration') {
|
||||
// C2-05 走"沉默"路径验证合法选项,其余讲述
|
||||
// C2-05 走"沉默"路径验证合法选项,其余讲述;Mock 检测到缺失维度则回答追问
|
||||
if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration();
|
||||
else await engine.submitNarration('(验证用占位讲述)');
|
||||
else {
|
||||
await engine.submitNarration('(验证用占位讲述)');
|
||||
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
|
||||
}
|
||||
} else {
|
||||
engine.advance();
|
||||
}
|
||||
}
|
||||
check(engine.state.finished, '三章流程跑通(Mock 模式)');
|
||||
const eventPoints = store.events.map((e) => e.pointId).sort();
|
||||
const eventPoints = store.events
|
||||
.filter((e) => e.kind === 'choice' || e.kind === 'narration' || e.kind === 'profile')
|
||||
.map((e) => e.pointId)
|
||||
.sort();
|
||||
check(
|
||||
JSON.stringify(eventPoints) === JSON.stringify(expectedIds),
|
||||
`事件轨迹覆盖全部 ${expectedIds.length} 个选择点`,
|
||||
);
|
||||
const c305 = store.events.find((e) => e.pointId === 'C3-02');
|
||||
const c102 = store.events.find((e) => e.pointId === 'C1-02');
|
||||
const c305 = store.events.find((e) => e.pointId === 'C3-02' && e.kind === 'narration');
|
||||
const c102 = store.events.find((e) => e.pointId === 'C1-02' && e.kind === 'narration');
|
||||
check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果');
|
||||
|
||||
// --- 5. 画像报告:7 维 score 问题 ---
|
||||
@@ -167,15 +186,114 @@ while (engine.currentNode().type !== 'choice') {
|
||||
await engine.confirmIdentity(await engine.extractIdentity('占位自我介绍'));
|
||||
} else if (n.type === 'narration') {
|
||||
await engine.submitNarration('(验证用占位讲述)');
|
||||
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
|
||||
} else {
|
||||
engine.advance();
|
||||
}
|
||||
}
|
||||
const firstChoice = engine.currentNode() as ChoiceNode;
|
||||
await engine.choose(firstChoice.options[0].id);
|
||||
check(store.events.length === before + 3, '重玩追加新事件(profile + narration + choice),旧事件不变');
|
||||
const added = store.events.slice(before);
|
||||
check(
|
||||
added.filter((e) => e.kind === 'profile').length === 1 &&
|
||||
added.filter((e) => e.kind === 'narration').length === 1 &&
|
||||
added.filter((e) => e.kind === 'choice').length === 1,
|
||||
`重玩追加新事件(profile + narration + choice,含追问事件共 ${added.length} 条),旧事件不变`,
|
||||
);
|
||||
check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留');
|
||||
|
||||
// --- 7. 追问补全流程(可控 noul 概率的 stub 驱动三条路径) ---
|
||||
/** noul 概率可控的 Jev stub:missing 里的维度返回 0.1(缺失),其余 0.9;非 noul 题走 Mock */
|
||||
class FixedNoulJev extends MockJevClient {
|
||||
constructor(private missing: string[]) {
|
||||
super();
|
||||
}
|
||||
override async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
|
||||
const out = await super.ask(state, questions);
|
||||
for (const [id, q] of Object.entries(questions)) {
|
||||
if (q.type === 'noul') {
|
||||
out[id] = { type: 'noul', probability: this.missing.includes(id) ? 0.1 : 0.9, confidence: 0.9 };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** 推进到指定节点(途中选择点取第一项、身份点直接确认、讲述点沉默略过) */
|
||||
async function walkTo(eng: StoryEngine, nodeId: string) {
|
||||
let g = 0;
|
||||
while (eng.currentNode().id !== nodeId && g++ < 60) {
|
||||
const n = eng.currentNode();
|
||||
if (n.type === 'choice') await eng.choose((n as ChoiceNode).options[0].id);
|
||||
else if (n.type === 'identity') await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
|
||||
else if (n.type === 'narration') await eng.skipNarration();
|
||||
else eng.advance();
|
||||
}
|
||||
}
|
||||
|
||||
// 7a. 缺失检测 → 追问挂起 → 回答合并
|
||||
{
|
||||
const s = new MemoryStore();
|
||||
const eng = new StoryEngine(chapters, s, new FixedNoulJev(['visual']));
|
||||
await walkTo(eng, 'c1-02');
|
||||
const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode;
|
||||
const r = await eng.submitNarration('去年夏天我离开了家乡');
|
||||
const pending = eng.state.pendingFollowUp;
|
||||
check(r === null && pending?.dimension === 'visual', '缺失检测:视觉缺失(最高优先级)→ 追问挂起,不立即落 narration 事件');
|
||||
check(pending?.prompt === c102.followUps.visual, '追问话术取自节点 JSON 的 followUps 模板');
|
||||
check(eng.state.nodeId === 'c1-02', '追问挂起期间节点不推进');
|
||||
let threw = false;
|
||||
try {
|
||||
await eng.submitNarration('重复提交');
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, '追问挂起期间重复提交被拒(预算纪律:一个讲述点一次追问)');
|
||||
const res = await eng.answerFollowUp('站台是灰蓝色的');
|
||||
const narrationEv = s.events.find((e) => e.kind === 'narration');
|
||||
const followupEv = s.events.find((e) => e.kind === 'followup');
|
||||
check(!!followupEv && followupEv.followUpDimension === 'visual', '追问回答落 followup 事件(含缺失维度)');
|
||||
check(
|
||||
!!narrationEv?.transcript?.includes('去年夏天我离开了家乡') && narrationEv.transcript.includes('站台是灰蓝色的'),
|
||||
'追问回答合并进讲述文本(state 合并后进情绪签名选择)',
|
||||
);
|
||||
check(!!res.variant && eng.state.pendingFollowUp === null && eng.state.nodeId === 'c1-d4', '追问收尾:变体解析完成并推进,无第二次追问');
|
||||
}
|
||||
|
||||
// 7b. "记不清了"跳过 → followup_skipped 事件
|
||||
{
|
||||
const s = new MemoryStore();
|
||||
const eng = new StoryEngine(chapters, s, new FixedNoulJev(['person']));
|
||||
await walkTo(eng, 'c2-02');
|
||||
await eng.submitNarration('有件事我一直后悔');
|
||||
check(eng.state.pendingFollowUp?.dimension === 'person', '缺失检测:人物缺失 → 追问挂起');
|
||||
await eng.skipFollowUp();
|
||||
const skipped = s.events.find((e) => e.kind === 'followup_skipped');
|
||||
check(!!skipped && skipped.followUpDimension === 'person' && skipped.transcript === null, '跳过落 followup_skipped 事件(对什么沉默也是数据)');
|
||||
const narrationEv = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C2-02');
|
||||
check(narrationEv?.transcript === '有件事我一直后悔', '跳过后讲述原文不变,流程继续');
|
||||
}
|
||||
|
||||
// 7c. 六维俱全 → 不追问,直接走完
|
||||
{
|
||||
const s = new MemoryStore();
|
||||
const eng = new StoryEngine(chapters, s, new FixedNoulJev([]));
|
||||
await walkTo(eng, 'c2-02');
|
||||
const r = await eng.submitNarration('一段细节齐全的讲述');
|
||||
check(r !== null && eng.state.pendingFollowUp === null, '无缺失维度时不触发追问');
|
||||
check(!s.events.some((e) => e.kind === 'followup' || e.kind === 'followup_skipped'), '无追问时不产生追问事件');
|
||||
}
|
||||
|
||||
// --- 8. append-only:EventStore 实现不存在 update/delete 路径 ---
|
||||
// (SQLite 实连只能在 Tauri 运行时内进行,这里只做接口层面断言)
|
||||
for (const cls of [LocalStorageEventStore, SqliteEventStore, MemoryStore]) {
|
||||
const props = Object.getOwnPropertyNames(cls.prototype).filter((p) => p !== 'constructor');
|
||||
check(
|
||||
!props.some((p) => ['update', 'delete', 'remove', 'set'].includes(p)),
|
||||
`${cls.name}: 只有 ${props.sort().join('/')}(append-only,无 update/delete)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} 项校验失败`);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user