feat: Tauri SQLite 事件持久化 + 讲述点追问补全(缺失检测→模板追问→沉默即数据)
This commit is contained in:
@@ -45,7 +45,15 @@
|
||||
"dimensions": ["attachment", "self_disclosure"],
|
||||
"followUp": {
|
||||
"maxQuestions": 1,
|
||||
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,馆长口吻追问一句(MVP 未接,占位)"
|
||||
"strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,馆长口吻模板追问一句;记不清了合法"
|
||||
},
|
||||
"followUps": {
|
||||
"visual": "闭上眼再想一想——那个画面里,最显眼的是什么颜色,或者什么物件?",
|
||||
"person": "这段离别里,你最想再见到的那个人,是谁?",
|
||||
"time_place": "那是哪一年的事?在什么地方?",
|
||||
"emotion": "现在回想起来,心里最先浮上来的,是什么感觉?",
|
||||
"auditory": "那段记忆里有没有声音?一句话,或是一种声响。",
|
||||
"olfactory_gustatory": "那天有没有什么气味或味道,你到现在还记得?"
|
||||
},
|
||||
"variants": [
|
||||
{ "signature": "温暖×永失", "variantId": "sealed_love_tomb", "label": "「爱被封存」墓室", "tint": "#d4a96a" },
|
||||
|
||||
@@ -50,7 +50,15 @@
|
||||
"dimensions": ["values", "emotional_stability"],
|
||||
"followUp": {
|
||||
"maxQuestions": 1,
|
||||
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,老人口吻追问一句(MVP 未接,占位)"
|
||||
"strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,老人口吻模板追问一句;记不清了合法"
|
||||
},
|
||||
"followUps": {
|
||||
"visual": "老朽眼拙,你再说说——那场景里最扎眼的是个什么物件?",
|
||||
"person": "这事儿里头,牵着的是哪个人?说个称呼也成。",
|
||||
"time_place": "是哪一年的事?在哪个地界?",
|
||||
"emotion": "讲完了,心里是个什么滋味?别怕说出来。",
|
||||
"auditory": "那会儿耳边有什么声响没有?一句话也算。",
|
||||
"olfactory_gustatory": "人呐,记性最靠的是鼻子和舌头——那阵子有什么味道?"
|
||||
},
|
||||
"variants": [
|
||||
{ "signature": "自责", "variantId": "intel_full", "label": "老人展开整张手绘城图(情报详细度·上)", "tint": "#6a8a6a" },
|
||||
@@ -116,7 +124,15 @@
|
||||
"dimensions": ["attachment", "reciprocity"],
|
||||
"followUp": {
|
||||
"maxQuestions": 1,
|
||||
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,温简口吻追问一句(MVP 未接,占位)"
|
||||
"strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,温简口吻模板追问一句;记不清了合法"
|
||||
},
|
||||
"followUps": {
|
||||
"visual": "那年的画面里,你印象最深的一幕是什么样子的?",
|
||||
"person": "那段日子里,有没有谁一直陪着你?",
|
||||
"time_place": "那是哪一年,在什么地方?",
|
||||
"emotion": "那时候最难熬的,是哪种感觉?",
|
||||
"auditory": "有没有一个声音,到现在还忘不掉?",
|
||||
"olfactory_gustatory": "那段日子有什么气味或味道,留在记忆里了吗?"
|
||||
},
|
||||
"variants": [
|
||||
{ "signature": "深暴露", "variantId": "wenjian_close_early", "label": "温简当夜就悄悄告诉你一个档案馆的秘密", "tint": "#d4844b" },
|
||||
|
||||
@@ -41,7 +41,15 @@
|
||||
"dimensions": ["emotional_stability", "attachment"],
|
||||
"followUp": {
|
||||
"maxQuestions": 1,
|
||||
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,幻境中故人口吻追问一句(MVP 未接,占位)"
|
||||
"strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,幻境中故人口吻模板追问一句;记不清了合法"
|
||||
},
|
||||
"followUps": {
|
||||
"visual": "雾里的影像还模糊——再想一想,当时眼前最清楚的是什么?",
|
||||
"person": "幻境里还站着一个人。那个人是谁?",
|
||||
"time_place": "幻境在等一个坐标:那是何时、何地?",
|
||||
"emotion": "把那场告别说完——你最后想说的那句话,带着什么心情?",
|
||||
"auditory": "幻境里太安静了。当时有什么声音吗?",
|
||||
"olfactory_gustatory": "幻境还原不了气味——除非你现在想起来。"
|
||||
},
|
||||
"variantFrom": "C1-02",
|
||||
"variants": [
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.1",
|
||||
"@tauri-apps/plugin-sql": "^2.5.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
|
||||
Generated
+10
@@ -11,6 +11,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.12.1
|
||||
version: 2.12.1
|
||||
'@tauri-apps/plugin-sql':
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
react:
|
||||
specifier: ^19.2.8
|
||||
version: 19.3.0
|
||||
@@ -507,6 +510,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-sql@2.5.0':
|
||||
resolution: {integrity: sha512-wHzfXAgMbn3G1xWQWM1LlWLJkBEfO2CkAWvu+VYigRBXBocPXZqvGEkYWSM1RrZSSc4iJYAWXkFZrgTyu8zBkw==}
|
||||
|
||||
'@types/node@24.19.1':
|
||||
resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==}
|
||||
|
||||
@@ -979,6 +985,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.12.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.12.1
|
||||
|
||||
'@tauri-apps/plugin-sql@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.12.1
|
||||
|
||||
'@types/node@24.19.1':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
@@ -62,9 +62,16 @@ console.log(' 抽取:', JSON.stringify(profile));
|
||||
await engine.confirmIdentity(profile);
|
||||
while (engine.currentNode().type !== 'narration') engine.advance();
|
||||
const farewell = '外婆走的那天我在外地考试,没赶上最后一面。后来我每年清明都回去,院子里的桂花树还在。';
|
||||
const result = await engine.submitNarration(farewell);
|
||||
let result = await engine.submitNarration(farewell);
|
||||
console.log('\n[2] C1-02 情绪签名 → 场景变体');
|
||||
console.log(' 输入:', farewell);
|
||||
if (!result && engine.state.pendingFollowUp) {
|
||||
// 追问补全(六维缺失检测):真 key 下可能挂起追问,回答一句完成流程
|
||||
const p = engine.state.pendingFollowUp;
|
||||
console.log(` 追问(${p.dimension}): ${p.prompt}`);
|
||||
result = await engine.answerFollowUp('院子里是桂花的甜香,风一吹就往屋里钻。');
|
||||
}
|
||||
if (!result) throw new Error('讲述流程未完成');
|
||||
console.log(` 签名: ${result.variant.signature} → 变体: ${result.variant.variantId}(${result.variant.label})`);
|
||||
|
||||
// --- 3. 画像报告 7 维 score ---
|
||||
|
||||
+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);
|
||||
|
||||
Generated
+916
-3
File diff suppressed because it is too large
Load Diff
@@ -23,3 +23,4 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.12.1" }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
"core:default",
|
||||
"sql:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import './App.css';
|
||||
import { StoryEngine } from './engine/engine';
|
||||
import { LocalStorageEventStore } from './engine/eventStore';
|
||||
import { SqliteEventStore, isTauriRuntime } from './engine/sqliteEventStore';
|
||||
import type { Chapter } from './engine/types';
|
||||
import { getJevClient } from './services/jev';
|
||||
import { StoryPlayer } from './ui/StoryPlayer';
|
||||
@@ -15,8 +16,9 @@ const chapters = [chapter1, chapter2, chapter3] as Chapter[];
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState<'story' | 'report'>('story');
|
||||
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
|
||||
// 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口)
|
||||
const { engine, store, jev } = useMemo(() => {
|
||||
const store = new LocalStorageEventStore();
|
||||
const store = isTauriRuntime() ? new SqliteEventStore() : new LocalStorageEventStore();
|
||||
const jev = getJevClient();
|
||||
return { engine: new StoryEngine(chapters, store, jev), store, jev };
|
||||
}, []);
|
||||
|
||||
+122
-6
@@ -1,4 +1,5 @@
|
||||
import type { Chapter, IdentityProfile, NarrationNode, SceneVariant, StoryNode } from './types';
|
||||
import type { Chapter, FollowUpDimension, IdentityProfile, NarrationNode, SceneVariant, StoryNode } from './types';
|
||||
import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } from './types';
|
||||
import type { EventStore, StoryEvent } from './eventStore';
|
||||
import { makeEvent } from './eventStore';
|
||||
import type { JevClient, JevQuestion } from '../services/jev';
|
||||
@@ -9,6 +10,8 @@ export interface EngineState {
|
||||
nodeId: string;
|
||||
/** 当前场景变体(讲述点情绪签名解析结果,驱动 UI 场景配色) */
|
||||
activeVariant: SceneVariant | null;
|
||||
/** 追问补全:讲述后检测到缺失维度时挂起,等用户回答或"记不清了" */
|
||||
pendingFollowUp: PendingFollowUp | null;
|
||||
/** 全部三章完成后为 true → 进入画像报告 */
|
||||
finished: boolean;
|
||||
}
|
||||
@@ -18,6 +21,31 @@ export interface NarrationResult {
|
||||
event: StoryEvent;
|
||||
}
|
||||
|
||||
/** 一次挂起的追问:原始讲述文本先存着,回答合并后才落 narration 事件 */
|
||||
export interface PendingFollowUp {
|
||||
pointId: string;
|
||||
dimension: FollowUpDimension;
|
||||
/** 模板化追问话术(取自节点 JSON 的 followUps) */
|
||||
prompt: string;
|
||||
/** 已问次数(本轮上限 1,字段预留第二次追问位置) */
|
||||
asked: number;
|
||||
/** 用户最初的讲述文本,追问回答将合并进来 */
|
||||
baseTranscript: string;
|
||||
}
|
||||
|
||||
/** 六维缺失检测的 noul 问题文案(一次 Jev 调用并行问完) */
|
||||
const MISSING_DETECTION_INSTRUCTIONS: Record<FollowUpDimension, string> = {
|
||||
visual: '这段讲述是否包含视觉细节(颜色、形状、光线、物件外观等)',
|
||||
auditory: '这段讲述是否包含听觉细节(声音、语气、环境音等)',
|
||||
olfactory_gustatory: '这段讲述是否包含嗅觉或味觉细节(气味、味道等)',
|
||||
person: '这段讲述是否提及具体的人物',
|
||||
time_place: '这段讲述是否提及具体的时间或地点',
|
||||
emotion: '这段讲述是否有明确的情绪表达',
|
||||
};
|
||||
|
||||
/** noul 概率低于此阈值视为该维度缺失 */
|
||||
const MISSING_THRESHOLD = 0.5;
|
||||
|
||||
function newSessionId(): string {
|
||||
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
@@ -49,6 +77,7 @@ export class StoryEngine {
|
||||
chapterId: start.id,
|
||||
nodeId: start.startNodeId,
|
||||
activeVariant: null,
|
||||
pendingFollowUp: null,
|
||||
finished: false,
|
||||
};
|
||||
}
|
||||
@@ -104,12 +133,96 @@ export class StoryEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* V 类讲述:落事件(transcript 为 ASR 占位,沉默为 null),
|
||||
* 并解析情绪签名 → 场景变体(ADR-0003 通道 1,只做选择)。
|
||||
* V 类讲述提交。追问补全流程(CONTEXT「追问补全」):
|
||||
* 文本讲述 → Jev 六维缺失检测 → 有缺口则挂起追问(返回 null,
|
||||
* state.pendingFollowUp 挂起,UI 展示追问话术)→ answerFollowUp/skipFollowUp 收尾;
|
||||
* 无缺口或沉默则直接走完:落 narration 事件 + 情绪签名→场景变体(ADR-0003 通道 1)。
|
||||
*/
|
||||
async submitNarration(transcript: string | null): Promise<NarrationResult> {
|
||||
async submitNarration(transcript: string | null): Promise<NarrationResult | null> {
|
||||
const node = this.currentNode();
|
||||
if (node.type !== 'narration') throw new Error('当前节点不是讲述点');
|
||||
if (this.state.pendingFollowUp) throw new Error('有未处理的追问,请先 answerFollowUp / skipFollowUp');
|
||||
if (transcript === null) return this.finalizeNarration(node, null);
|
||||
const missing = await this.detectMissing(node, transcript);
|
||||
if (missing) {
|
||||
this.state.pendingFollowUp = {
|
||||
pointId: node.pointId,
|
||||
dimension: missing,
|
||||
prompt: node.followUps[missing]!,
|
||||
asked: 1,
|
||||
baseTranscript: transcript,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
return this.finalizeNarration(node, transcript);
|
||||
}
|
||||
|
||||
/** 追问被回答:落 followup 事件,回答合并进讲述文本,再走情绪签名选择 */
|
||||
async answerFollowUp(answer: string): Promise<NarrationResult> {
|
||||
const { node, pending } = this.takePendingFollowUp();
|
||||
await this.store.append(
|
||||
makeEvent({
|
||||
sessionId: this.state.sessionId,
|
||||
chapterId: this.state.chapterId,
|
||||
kind: 'followup',
|
||||
pointId: node.pointId,
|
||||
dimensions: node.dimensions,
|
||||
followUpDimension: pending.dimension,
|
||||
transcript: answer,
|
||||
}),
|
||||
);
|
||||
this.state.pendingFollowUp = null;
|
||||
return this.finalizeNarration(node, `${pending.baseTranscript}\n${answer}`);
|
||||
}
|
||||
|
||||
/** "记不清了"是合法回答(ADR-0004)——跳过也落事件,对什么沉默是数据 */
|
||||
async skipFollowUp(): Promise<NarrationResult> {
|
||||
const { node, pending } = this.takePendingFollowUp();
|
||||
await this.store.append(
|
||||
makeEvent({
|
||||
sessionId: this.state.sessionId,
|
||||
chapterId: this.state.chapterId,
|
||||
kind: 'followup_skipped',
|
||||
pointId: node.pointId,
|
||||
dimensions: node.dimensions,
|
||||
followUpDimension: pending.dimension,
|
||||
transcript: null,
|
||||
}),
|
||||
);
|
||||
this.state.pendingFollowUp = null;
|
||||
return this.finalizeNarration(node, pending.baseTranscript);
|
||||
}
|
||||
|
||||
private takePendingFollowUp(): { node: NarrationNode; pending: PendingFollowUp } {
|
||||
const pending = this.state.pendingFollowUp;
|
||||
if (!pending) throw new Error('当前没有待处理的追问');
|
||||
const node = this.currentNode();
|
||||
if (node.type !== 'narration' || node.pointId !== pending.pointId) {
|
||||
throw new Error('追问挂起状态与当前节点不一致');
|
||||
}
|
||||
return { node, pending };
|
||||
}
|
||||
|
||||
/**
|
||||
* 缺失检测:一次 Jev 调用并行问 6 个 noul 问题;概率 <0.5 视为缺失;
|
||||
* 按幻境渲染价值优先级取一个(FOLLOW_UP_PRIORITY),且节点 JSON 里要有对应模板。
|
||||
* 预算纪律:每个讲述点最多追问 1 次(maxQuestions ≥2 的第二轮留给后续迭代)。
|
||||
*/
|
||||
private async detectMissing(node: NarrationNode, transcript: string): Promise<FollowUpDimension | null> {
|
||||
if (node.followUp.maxQuestions < 1) return null;
|
||||
const questions: Record<string, JevQuestion> = Object.fromEntries(
|
||||
FOLLOW_UP_DIMENSIONS.map((d) => [d, { type: 'noul', instructions: MISSING_DETECTION_INSTRUCTIONS[d] }]),
|
||||
);
|
||||
const answers = await this.jev.ask({ transcript, pointId: node.pointId }, questions);
|
||||
for (const dim of FOLLOW_UP_PRIORITY) {
|
||||
const a = answers[dim];
|
||||
if (node.followUps[dim] && a?.type === 'noul' && a.probability < MISSING_THRESHOLD) return dim;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 讲述收尾:落 narration 事件、解析情绪签名 → 场景变体、推进节点 */
|
||||
private async finalizeNarration(node: NarrationNode, transcript: string | null): Promise<NarrationResult> {
|
||||
const variant = await this.resolveVariant(node, transcript);
|
||||
const event = makeEvent({
|
||||
sessionId: this.state.sessionId,
|
||||
@@ -127,9 +240,11 @@ export class StoryEngine {
|
||||
return { variant, event };
|
||||
}
|
||||
|
||||
/** 沉默永远是合法选项(CONTEXT:在什么面前沉默本身也是数据) */
|
||||
/** 沉默永远是合法选项(CONTEXT:在什么面前沉默本身也是数据);沉默路径不触发追问 */
|
||||
async skipNarration(): Promise<NarrationResult> {
|
||||
return this.submitNarration(null);
|
||||
const r = await this.submitNarration(null);
|
||||
if (!r) throw new Error('unreachable: 沉默路径不触发追问');
|
||||
return r;
|
||||
}
|
||||
|
||||
private async resolveVariant(node: NarrationNode, transcript: string | null): Promise<SceneVariant> {
|
||||
@@ -268,6 +383,7 @@ export class StoryEngine {
|
||||
this.state.chapterId = chapter.id;
|
||||
this.state.nodeId = chapter.startNodeId;
|
||||
this.state.activeVariant = null;
|
||||
this.state.pendingFollowUp = null;
|
||||
this.state.finished = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DimensionTag, IdentityProfile } from './types';
|
||||
import type { DimensionTag, FollowUpDimension, IdentityProfile } from './types';
|
||||
|
||||
/**
|
||||
* 选择/讲述事件存储(ADR-0004:历史不可涂改)。
|
||||
@@ -17,7 +17,7 @@ export interface StoryEvent {
|
||||
sessionId: string;
|
||||
ts: number;
|
||||
chapterId: string;
|
||||
kind: 'choice' | 'narration' | 'profile';
|
||||
kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped';
|
||||
/** 选择点 ID,如 C1-03 */
|
||||
pointId: string;
|
||||
dimensions: DimensionTag[];
|
||||
@@ -27,7 +27,9 @@ export interface StoryEvent {
|
||||
/** narration 事件:选中的场景变体(情绪签名 → variantId) */
|
||||
variantId?: string;
|
||||
emotionSignature?: string;
|
||||
/** 讲述转写文本(Web Speech API 只给文本,不留音频,符合 ADR-0004 即焚原则;沉默为 null) */
|
||||
/** followup / followup_skipped 事件:追问针对的缺失维度 */
|
||||
followUpDimension?: FollowUpDimension;
|
||||
/** 讲述/追问的转写文本(Web Speech API 只给文本,不留音频,符合 ADR-0004 即焚原则;沉默为 null) */
|
||||
transcript?: string | null;
|
||||
/** profile 事件:用户确认后的身份登记字段(匹配硬过滤字段) */
|
||||
profile?: IdentityProfile;
|
||||
@@ -66,7 +68,7 @@ export class LocalStorageEventStore implements EventStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri 原生端实现预留:后续用 tauri-plugin-sql(SQLite)实现同一接口,
|
||||
* 表结构即 StoryEvent 一行一条,只写 INSERT/SELECT,不建 UPDATE/DELETE 路径。
|
||||
* 见 README「已知缺口」。
|
||||
* Tauri 原生端实现:见 ./sqliteEventStore.ts(tauri-plugin-sql + SQLite,
|
||||
* 同一 EventStore 接口,SQL 层同样只写 INSERT/SELECT)。
|
||||
* 运行时选择在 App.tsx:window.__TAURI__ 存在用 SqliteEventStore,否则 localStorage。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { EventStore, StoryEvent } from './eventStore';
|
||||
import type Database from '@tauri-apps/plugin-sql';
|
||||
|
||||
/** Tauri 原生端运行时判定(浏览器 dev 无 window.__TAURI__) */
|
||||
export function isTauriRuntime(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI__' in window;
|
||||
}
|
||||
|
||||
const DB_PATH = 'sqlite:events.db';
|
||||
|
||||
/**
|
||||
* Tauri 原生端事件存储:tauri-plugin-sql(sqlx + SQLite)。
|
||||
* 与 LocalStorageEventStore 实现同一 EventStore 接口——ADR-0004 的
|
||||
* append-only 约束在 SQL 层同样成立:本类只写 INSERT/SELECT,
|
||||
* 不存在 UPDATE/DELETE 路径。重玩 = 新 sessionId 的新行,旧行原样保留。
|
||||
* payload 为 StoryEvent 整行 JSON(事件 schema 演进不动表结构)。
|
||||
*/
|
||||
export class SqliteEventStore implements EventStore {
|
||||
private db: Promise<Database>;
|
||||
|
||||
constructor(path: string = DB_PATH) {
|
||||
// 动态 import:浏览器 dev 永不加载本类,避免把插件打进 web 路径的初始化
|
||||
this.db = (async () => {
|
||||
const { default: Database } = await import('@tauri-apps/plugin-sql');
|
||||
const db = await Database.load(path);
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sessionId TEXT NOT NULL,
|
||||
chapterId TEXT NOT NULL,
|
||||
pointId TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
return db;
|
||||
})();
|
||||
}
|
||||
|
||||
async append(event: StoryEvent): Promise<void> {
|
||||
const db = await this.db;
|
||||
await db.execute(
|
||||
'INSERT INTO events (sessionId, chapterId, pointId, payload, createdAt) VALUES ($1, $2, $3, $4, $5)',
|
||||
[event.sessionId, event.chapterId, event.pointId, JSON.stringify(event), event.ts],
|
||||
);
|
||||
}
|
||||
|
||||
async list(sessionId?: string): Promise<StoryEvent[]> {
|
||||
const db = await this.db;
|
||||
const rows = sessionId
|
||||
? await db.select<{ payload: string }[]>('SELECT payload FROM events WHERE sessionId = $1 ORDER BY id', [sessionId])
|
||||
: await db.select<{ payload: string }[]>('SELECT payload FROM events ORDER BY id');
|
||||
return rows.map((r) => JSON.parse(r.payload) as StoryEvent);
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -61,7 +61,28 @@ export interface SceneVariant {
|
||||
tint: string;
|
||||
}
|
||||
|
||||
/** 语音讲述点(V 类):提示语 + 追问策略占位 + 情绪签名→变体选择 */
|
||||
/** 追问缺失检测的六个维度(五感拆三 + 人物 + 时空 + 情绪) */
|
||||
export const FOLLOW_UP_DIMENSIONS = [
|
||||
'visual', // 视觉细节
|
||||
'auditory', // 听觉细节
|
||||
'olfactory_gustatory', // 嗅觉或味觉细节
|
||||
'person', // 具体人物
|
||||
'time_place', // 具体时间地点
|
||||
'emotion', // 明确情绪表达
|
||||
] as const;
|
||||
export type FollowUpDimension = (typeof FOLLOW_UP_DIMENSIONS)[number];
|
||||
|
||||
/** 追问选择优先级(对幻境渲染价值排序):视觉 > 人物 > 时空 > 情绪 > 听觉 > 嗅觉味觉 */
|
||||
export const FOLLOW_UP_PRIORITY: FollowUpDimension[] = [
|
||||
'visual',
|
||||
'person',
|
||||
'time_place',
|
||||
'emotion',
|
||||
'auditory',
|
||||
'olfactory_gustatory',
|
||||
];
|
||||
|
||||
/** 语音讲述点(V 类):提示语 + 追问补全 + 情绪签名→变体选择 */
|
||||
export interface NarrationNode extends BaseNode {
|
||||
type: 'narration';
|
||||
pointId: string;
|
||||
@@ -70,8 +91,10 @@ export interface NarrationNode extends BaseNode {
|
||||
/** 讲述提示语 */
|
||||
prompt: string;
|
||||
dimensions: DimensionTag[];
|
||||
/** 追问策略占位(Jev 缺失维度检测 + 馆长口吻追问,至多 1-2 句,MVP 未接) */
|
||||
/** 追问策略:Jev 六维缺失检测 + NPC 口吻模板追问,至多 1-2 句(工程实现先 1 句) */
|
||||
followUp: { maxQuestions: number; strategy: string };
|
||||
/** 模板化追问话术,按缺失维度取一条(LLM 口吻化生成后续再接) */
|
||||
followUps: Partial<Record<FollowUpDimension, string>>;
|
||||
/** 预写场景变体表 */
|
||||
variants: SceneVariant[];
|
||||
/**
|
||||
|
||||
+41
-3
@@ -1,8 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import type { StoryEngine } from '../engine/engine';
|
||||
import type { ChoiceNode, IdentityNode, IdentityProfile, NarrationNode } from '../engine/types';
|
||||
import type { StoryEngine, PendingFollowUp } from '../engine/engine';
|
||||
import type { ChoiceNode, FollowUpDimension, IdentityNode, IdentityProfile, NarrationNode } from '../engine/types';
|
||||
import { VoiceInput } from './VoiceInput';
|
||||
|
||||
/** 追问维度展示名 */
|
||||
const FOLLOW_UP_LABELS: Record<FollowUpDimension, string> = {
|
||||
visual: '画面',
|
||||
auditory: '声音',
|
||||
olfactory_gustatory: '气味与味道',
|
||||
person: '人物',
|
||||
time_place: '时间与地点',
|
||||
emotion: '情绪',
|
||||
};
|
||||
|
||||
/** speaker/sprite → 立绘占位色(无图片素材,CSS 色块) */
|
||||
const SPRITE_COLORS: Record<string, string> = {
|
||||
curator: '#6a5a8a',
|
||||
@@ -61,8 +71,11 @@ export function StoryPlayer({ engine, onFinish }: Props) {
|
||||
{node.type === 'narration' && (
|
||||
<NarrationView
|
||||
node={node}
|
||||
pendingFollowUp={engine.state.pendingFollowUp}
|
||||
onSubmit={async (text) => { await engine.submitNarration(text); rerender(); }}
|
||||
onSkip={async () => { await engine.skipNarration(); rerender(); }}
|
||||
onFollowUpAnswer={async (text) => { await engine.answerFollowUp(text); rerender(); }}
|
||||
onFollowUpSkip={async () => { await engine.skipFollowUp(); rerender(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -122,14 +135,39 @@ function ChoiceView({ node, onPick }: { node: ChoiceNode; onPick: (id: string) =
|
||||
|
||||
function NarrationView({
|
||||
node,
|
||||
pendingFollowUp,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
onFollowUpAnswer,
|
||||
onFollowUpSkip,
|
||||
}: {
|
||||
node: NarrationNode;
|
||||
pendingFollowUp: PendingFollowUp | null;
|
||||
onSubmit: (text: string) => void;
|
||||
onSkip: () => void;
|
||||
onFollowUpAnswer: (text: string) => void;
|
||||
onFollowUpSkip: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// 追问补全阶段:讲述已收到,NPC 就缺失的细节追问一句(至多 1 句;"记不清了"合法)
|
||||
if (pendingFollowUp) {
|
||||
return (
|
||||
<div className="narration-box">
|
||||
<div className="mechanism-tag">{node.mechanism} · 追问({FOLLOW_UP_LABELS[pendingFollowUp.dimension]})</div>
|
||||
<p className="narration-prompt">{pendingFollowUp.prompt}</p>
|
||||
<VoiceInput
|
||||
disabled={busy}
|
||||
onSubmit={async (t) => { setBusy(true); await onFollowUpAnswer(t); setBusy(false); }}
|
||||
/>
|
||||
<button className="secondary" disabled={busy} onClick={onFollowUpSkip}>
|
||||
记不清了(跳过追问)
|
||||
</button>
|
||||
<p className="hint">追问至多 {node.followUp.maxQuestions} 句;想不起来可以直说,也是合法回答。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="narration-box">
|
||||
<div className="mechanism-tag">{node.mechanism} · 语音讲述点</div>
|
||||
@@ -138,7 +176,7 @@ function NarrationView({
|
||||
<button className="secondary" disabled={busy} onClick={onSkip}>
|
||||
保持沉默(也是合法选项)
|
||||
</button>
|
||||
<p className="hint">讲述后档案馆将就缺失的细节追问至多 {node.followUp.maxQuestions} 句(追问未接,占位)</p>
|
||||
<p className="hint">讲述后档案馆可能就缺失的细节追问至多 {node.followUp.maxQuestions} 句。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ function buildState(events: StoryEvent[]): string {
|
||||
if (e.kind === 'choice') {
|
||||
return `[${e.pointId}] 选择「${e.optionLabel}」(维度: ${e.dimensions.join('/') || '字段采集'})`;
|
||||
}
|
||||
if (e.kind === 'followup') {
|
||||
return `[${e.pointId}] 追问(${e.followUpDimension})回答: ${e.transcript}`;
|
||||
}
|
||||
if (e.kind === 'followup_skipped') {
|
||||
return `[${e.pointId}] 追问(${e.followUpDimension})回答: 记不清了(跳过)`;
|
||||
}
|
||||
const t = e.transcript === null ? '(沉默)' : `(讲述: ${e.transcript})`;
|
||||
return `[${e.pointId}] 讲述${t} 情绪签名: ${e.emotionSignature ?? '未知'}`;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user