feat: Tauri SQLite 事件持久化 + 讲述点追问补全(缺失检测→模板追问→沉默即数据)

This commit is contained in:
Jayway Liu
2026-10-03 12:55:00 +08:00
parent 6117ef894f
commit ba50cf3928
18 changed files with 1360 additions and 34 deletions
+122 -6
View File
@@ -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;
}
}