feat: 对齐 Jev 真实 API schema、Web Speech 语音输入(优雅降级)、C1-01 语音化身份登记与真 key 冒烟脚本

This commit is contained in:
Jayway Liu
2026-10-03 12:43:41 +08:00
parent bf9d7dad5b
commit bed4d734ca
14 changed files with 623 additions and 95 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* 真 key 冒烟(pnpm verify:live):直连 https://api.typesafe.ai/v1/systemone。
* 手动解析根目录 .env(不引 dotenv),任何输出都不打印 key 本身。
* 只跑三次调用控制 token 消耗:
* 1. C1-01 身份抽取问题组(gender/age/occupation/education/city)
* 2. C1-02 情绪签名 → 场景变体选择(choice)
* 3. 画像报告 7 维 score
*/
import { readFileSync } from 'node:fs';
import { StoryEngine } from '../src/engine/engine';
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
import type { Chapter } from '../src/engine/types';
import { HttpJevClient, type JevQuestion } from '../src/services/jev';
function loadApiKey(): string | null {
let raw: string;
try {
raw = readFileSync(new URL('../.env', import.meta.url), 'utf8');
} catch {
return null;
}
for (const line of raw.split('\n')) {
const m = line.match(/^\s*VITE_TYPESAFE_API_KEY\s*=\s*(.+?)\s*$/);
if (m && m[1] && !m[1].startsWith('#')) return m[1];
}
return null;
}
class MemoryStore implements EventStore {
events: StoryEvent[] = [];
async append(e: StoryEvent) {
this.events.push(e);
}
async list(sessionId?: string) {
return sessionId ? this.events.filter((e) => e.sessionId === sessionId) : [...this.events];
}
}
const key = loadApiKey();
if (!key) {
console.error('未在 .env 中找到 VITE_TYPESAFE_API_KEY,跳过真 key 冒烟');
process.exit(1);
}
console.log('已加载 API key(长度 ' + key.length + ',不打印内容)');
const jev = new HttpJevClient(key);
const chapters = [1, 2, 3].map(
(i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')) as Chapter,
);
// --- 1. C1-01 身份抽取(走引擎真实代码路径) ---
const store = new MemoryStore();
const engine = new StoryEngine(chapters, store, jev);
while (engine.currentNode().type !== 'identity') engine.advance();
const intro = '我叫林晚,今年29岁,在杭州一家互联网公司做产品经理,硕士毕业五年了。';
const profile = await engine.extractIdentity(intro);
console.log('\n[1] C1-01 身份抽取');
console.log(' 输入:', intro);
console.log(' 抽取:', JSON.stringify(profile));
// --- 2. C1-02 情绪签名 → 场景变体(choice 格式实测) ---
await engine.confirmIdentity(profile);
while (engine.currentNode().type !== 'narration') engine.advance();
const farewell = '外婆走的那天我在外地考试,没赶上最后一面。后来我每年清明都回去,院子里的桂花树还在。';
const result = await engine.submitNarration(farewell);
console.log('\n[2] C1-02 情绪签名 → 场景变体');
console.log(' 输入:', farewell);
console.log(` 签名: ${result.variant.signature} → 变体: ${result.variant.variantId}(${result.variant.label})`);
// --- 3. 画像报告 7 维 score ---
const dims: [string, string][] = [
['attachment', '依恋类型'],
['conflict_style', '冲突处理风格'],
['values', '价值观与人生优先级'],
['emotional_stability', '情绪稳定性'],
['risk', '风险偏好'],
['culture', '文化底蕴'],
['mate_preference', '择偶偏好'],
];
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
const state = `用户在互动剧情中的行为轨迹:
[C1-01] 身份登记:${JSON.stringify(profile)}
[C1-02] 离别之门讲述:${farewell}(情绪签名: ${result.variant.signature})
[C1-04] 老鬼提议带纪念品,选择「我们是去记录的,不是去拿的」当场回绝
[C1-06] 穿越落点选择「精确落点:直接进城,赌一把」`;
const questions: Record<string, JevQuestion> = Object.fromEntries(
dims.map(([id, label]) => [
id,
{ type: 'score' as const, instructions: `根据用户行为轨迹评估其「${label}」维度水平`, criteria },
]),
);
const answers = await jev.ask(state, questions);
console.log('\n[3] 画像报告 7 维 score(1-10 级,浮点为概率加权)');
for (const [id, label] of dims) {
const a = answers[id];
if (a?.type === 'score') {
console.log(` ${label}: ${(a.score + 1).toFixed(1)}/10 置信度 ${(a.confidence * 100).toFixed(0)}%`);
} else {
console.log(` ${label}: 未返回 score 答案`, JSON.stringify(a));
}
}
console.log('\n真 key 冒烟完成');
+17 -5
View File
@@ -84,7 +84,7 @@ for (const ch of chapters) {
}
check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`);
for (const n of ch.nodes) {
if (n.type === 'choice' || n.type === 'narration') {
if (n.type === 'choice' || n.type === 'narration' || n.type === 'identity') {
check(!seenPoints.has(n.pointId), `${n.pointId}: 选择点 ID 全局唯一`);
seenPoints.set(n.pointId, n.dimensions);
}
@@ -123,6 +123,9 @@ while (!engine.state.finished && guard++ < 200) {
const node = engine.currentNode();
if (node.type === 'choice') {
await engine.choose((node as ChoiceNode).options[0].id);
} else if (node.type === 'identity') {
const profile = await engine.extractIdentity('我叫阿黎,29岁,在杭州做互联网产品经理,硕士学历');
await engine.confirmIdentity(profile);
} else if (node.type === 'narration') {
// C2-05 走"沉默"路径验证合法选项,其余讲述
if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration();
@@ -142,11 +145,11 @@ const c102 = store.events.find((e) => e.pointId === 'C1-02');
check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果');
// --- 5. 画像报告:7 维 score 问题 ---
const criteria = Array.from({ length: 10 }, (_, i) => String(i + 1));
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
const dims = ['attachment', 'conflict_style', 'values', 'emotional_stability', 'risk', 'culture', 'mate_preference'];
const answers = await jev.ask(
store.events,
Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, description: d, criteria }])),
Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, instructions: d, criteria }])),
);
check(
dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1),
@@ -158,10 +161,19 @@ const before = store.events.length;
const oldSession = engine.state.sessionId;
engine.replay('chapter1');
check(engine.state.sessionId !== oldSession, '重玩产生新 sessionId');
while (engine.currentNode().type !== 'choice') engine.advance();
while (engine.currentNode().type !== 'choice') {
const n = engine.currentNode();
if (n.type === 'identity') {
await engine.confirmIdentity(await engine.extractIdentity('占位自我介绍'));
} else if (n.type === 'narration') {
await engine.submitNarration('(验证用占位讲述)');
} else {
engine.advance();
}
}
const firstChoice = engine.currentNode() as ChoiceNode;
await engine.choose(firstChoice.options[0].id);
check(store.events.length === before + 1, '重玩追加新事件,旧事件不变');
check(store.events.length === before + 3, '重玩追加新事件(profile + narration + choice),旧事件不变');
check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留');
if (failures > 0) {