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
+98 -5
View File
@@ -1,7 +1,7 @@
import type { Chapter, NarrationNode, SceneVariant, StoryNode } from './types';
import type { Chapter, IdentityProfile, NarrationNode, SceneVariant, StoryNode } from './types';
import type { EventStore, StoryEvent } from './eventStore';
import { makeEvent } from './eventStore';
import type { JevClient } from '../services/jev';
import type { JevClient, JevQuestion } from '../services/jev';
export interface EngineState {
sessionId: string;
@@ -151,19 +151,112 @@ export class StoryEngine {
{
emotion_signature: {
type: 'choice',
description: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
options: node.variants.map((v) => v.signature),
instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])),
},
},
);
const picked = answer.emotion_signature;
if (picked?.type === 'choice') {
const match = node.variants.find((v) => v.signature === picked.selected);
const match = node.variants.find((v) => v.signature === picked.choice);
if (match) return match;
}
return node.variants[0];
}
/**
* C1-01 身份登记:从自由介绍文本中用 Jev 抽取结构化字段(匹配硬过滤字段)。
* 抽取不出来的字段留空,由用户在确认页补填。
*/
async extractIdentity(transcript: string): Promise<IdentityProfile> {
const node = this.currentNode();
if (node.type !== 'identity') throw new Error('当前节点不是身份登记点');
const ageBands = ['18-22', '23-27', '28-32', '33-40', '41及以上'];
const questions: Record<string, JevQuestion> = {
gender: {
type: 'choice',
instructions: '从这段自我介绍中判断说话人的性别',
criteria: { 男: '明确为男性', 女: '明确为女性', 其他: '非二元性别', 未提及: '文本中没有可判断的信息' },
},
age: {
type: 'score',
instructions: '根据自我介绍判断说话人所属的年龄段',
criteria: ageBands,
},
occupation: {
type: 'choice',
instructions: '从这段自我介绍中判断说话人的职业类别',
criteria: {
互联网: '互联网/软件/产品经理等',
学术科研: '高校、科研院所、教师、研究员',
金融: '银行、证券、投资等',
体制内: '公务员、事业单位、国企',
自由职业: '自由职业、个体、创作者',
学生: '在校学生',
其他: '以上都不是',
未提及: '文本中没有可判断的信息',
},
},
education: {
type: 'choice',
instructions: '从这段自我介绍中判断说话人的最高学历',
criteria: {
博士: '博士',
硕士: '硕士(含在读)',
本科: '本科',
大专及以下: '大专或以下',
未提及: '文本中没有可判断的信息',
},
},
city_mentioned: {
type: 'noul',
instructions: '自我介绍中是否提及了说话人所在的城市',
},
city: {
type: 'choice',
instructions: '自我介绍中提到的城市是哪一个(首发城市杭州,其余为常见城市兜底)',
criteria: {
杭州: '杭州', 北京: '北京', 上海: '上海', 深圳: '深圳', 广州: '广州',
南京: '南京', 成都: '成都', 西安: '西安', 武汉: '武汉',
未提及: '没有提及,或不在以上城市中',
},
},
};
const answers = await this.jev.ask({ transcript }, questions);
const choiceVal = (id: string): string => {
const a = answers[id];
return a?.type === 'choice' && a.choice !== '未提及' ? a.choice : '';
};
let age = '';
const ageAnswer = answers.age;
// 年龄段是 score 分档:置信度太低(视为未提及)则留空给用户补填
if (ageAnswer?.type === 'score' && ageAnswer.confidence >= 0.4) {
const idx = Math.max(0, Math.min(ageBands.length - 1, Math.round(ageAnswer.score)));
age = ageBands[idx];
}
let city = choiceVal('city');
const mentioned = answers.city_mentioned;
if (mentioned?.type === 'noul' && mentioned.probability < 0.5) city = '';
return { city, age, gender: choiceVal('gender'), occupation: choiceVal('occupation'), education: choiceVal('education') };
}
/** C1-01 确认页通过后调用:append profile 事件并推进 */
async confirmIdentity(profile: IdentityProfile): Promise<void> {
const node = this.currentNode();
if (node.type !== 'identity') throw new Error('当前节点不是身份登记点');
await this.store.append(
makeEvent({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
kind: 'profile',
pointId: node.pointId,
dimensions: node.dimensions,
profile,
}),
);
this.state.nodeId = node.next;
}
/**
* 重玩(ADR-0004):产生新轨迹(新 sessionId),不修改旧事件。
* 默认重玩当前章,也可指定章节。