chore: update story summary and lint fixes

This commit is contained in:
2026-02-08 12:22:45 +08:00
parent 56e30bfe02
commit d3d818da6a
15 changed files with 2479 additions and 852 deletions

View File

@@ -1,5 +1,5 @@
// ============================================================================
// atom-extraction.js - 30并发 + 首批错开 + 取消支持 + 进度回调
// ============================================================================
// atom-extraction.js - L0 叙事锚点提取(三层 themes 版)
// ============================================================================
import { callLLM, parseJson } from './llm-service.js';
@@ -12,7 +12,7 @@ const CONCURRENCY = 10;
const RETRY_COUNT = 2;
const RETRY_DELAY = 500;
const DEFAULT_TIMEOUT = 20000;
const STAGGER_DELAY = 80; // 首批错开延迟ms
const STAGGER_DELAY = 80;
let batchCancelled = false;
@@ -24,49 +24,150 @@ export function isBatchCancelled() {
return batchCancelled;
}
const SYSTEM_PROMPT = `你是叙事锚点提取器。从一轮对话(用户发言+角色回复中提取4-8个关键锚点。
// ============================================================================
// L0 提取 Prompt三层 themes
// ============================================================================
const SYSTEM_PROMPT = `你是叙事锚点提取器。从一轮对话中提取4-8个关键锚点用于后续语义检索。
输入格式:
<round>
<user>...</user>
<assistant>...</assistant>
<user name="用户名">...</user>
<assistant name="角色名">...</assistant>
</round>
只输出严格JSON(不要解释,不要前后多余文字)
{"atoms":[{"t":"类型","s":"主体","v":"值","f":"来源"}]}
只输出严格JSON
{"atoms":[{"t":"类型","s":"主体","o":"客体","v":"谓词","l":"地点","f":"来源","th":{"fn":[],"pt":[],"kw":[]}}]}
类型t
- emo: 情绪状态需要s主体
- loc: 地点/场景
- act: 关键动作需要s主体
- rev: 揭示/发现
- ten: 冲突/张力
- dec: 决定/承诺
## 类型t
- emo: 情绪状态变化
- act: 关键动作/行为
- rev: 揭示/发现/真相
- dec: 决定/承诺/宣言
- ten: 冲突/张力/对立
- loc: 场景/地点变化
## 字段说明
- s: 主体(必填)
- o: 客体(可空)
- v: 谓词15字内必填
- l: 地点(可空)
- f: "u"=用户 / "a"=角色(必填)
- th: 主题标签(必填,结构化对象)
## th 三层结构
fn叙事功能1-2个枚举
establish=建立设定 | escalate=升级加剧 | reveal=揭示发现 | challenge=挑战试探
commit=承诺锁定 | conflict=冲突对抗 | resolve=解决收束 | transform=转变逆转
bond=连接羁绊 | break=断裂破坏
pt互动模式1-3个枚举
power_down=上对下 | power_up=下对上 | power_equal=对等 | power_contest=争夺
asymmetric=信息不对称 | witnessed=有观众 | secluded=隔绝私密
ritual=仪式正式 | routine=日常惯例 | triangular=三方介入
kw具体关键词1-3个自由格式
## 示例输出
{"atoms":[
{"t":"act","s":"艾拉","o":"古龙","v":"用圣剑刺穿心脏","l":"火山口","f":"a",
"th":{"fn":["commit"],"pt":["power_down","ritual"],"kw":["战斗","牺牲"]}},
{"t":"emo","s":"林夏","o":"陆远","v":"意识到自己喜欢他","l":"","f":"a",
"th":{"fn":["reveal","escalate"],"pt":["asymmetric","secluded"],"kw":["心动","暗恋"]}},
{"t":"dec","s":"凯尔","o":"王国","v":"放弃王位继承权","l":"王座厅","f":"a",
"th":{"fn":["commit","break"],"pt":["ritual","witnessed"],"kw":["抉择","自由"]}},
{"t":"rev","s":"","o":"","v":"管家其实是间谍","l":"","f":"a",
"th":{"fn":["reveal"],"pt":["asymmetric"],"kw":["背叛","真相"]}},
{"t":"ten","s":"兄弟二人","o":"","v":"为遗产反目","l":"","f":"a",
"th":{"fn":["conflict","break"],"pt":["power_contest"],"kw":["冲突","亲情破裂"]}}
]}
规则:
- s: 主体(谁)
- v: 简洁值10字内
- f: "u"=用户发言中, "a"=角色回复中
- 只提取对未来检索有价值的锚点
- 无明显锚点返回空数组`;
- fn 回答"这在故事里推动了什么"
- pt 回答"这是什么结构的互动"
- kw 用于细粒度检索
- 无明显锚点时返回 {"atoms":[]}`;
const JSON_PREFILL = '{"atoms":[';
// ============================================================================
// Semantic 构建
// ============================================================================
function buildSemantic(atom, userName, aiName) {
const speaker = atom.f === 'u' ? userName : aiName;
const s = atom.s || speaker;
const type = atom.t || 'act';
const subject = atom.s || (atom.f === 'u' ? userName : aiName);
const object = atom.o || '';
const verb = atom.v || '';
const location = atom.l || '';
// 三层 themes 合并
const th = atom.th || {};
const tags = [
...(Array.isArray(th.fn) ? th.fn : []),
...(Array.isArray(th.pt) ? th.pt : []),
...(Array.isArray(th.kw) ? th.kw : []),
].filter(Boolean);
switch (atom.t) {
case 'emo': return `${s}感到${atom.v}`;
case 'loc': return `场景:${atom.v}`;
case 'act': return `${s}${atom.v}`;
case 'rev': return `揭示:${atom.v}`;
case 'ten': return `冲突:${atom.v}`;
case 'dec': return `${s}决定${atom.v}`;
default: return `${s} ${atom.v}`;
const typePart = `<${type}>`;
const themePart = tags.length > 0 ? ` [${tags.join('/')}]` : '';
const locPart = location ? `${location}` : '';
const objPart = object ? ` -> ${object}` : '';
let semantic = '';
switch (type) {
case 'emo':
semantic = object
? `${typePart} ${subject} -> ${verb} (对${object})${locPart}`
: `${typePart} ${subject} -> ${verb}${locPart}`;
break;
case 'act':
semantic = `${typePart} ${subject} -> ${verb}${objPart}${locPart}`;
break;
case 'rev':
semantic = object
? `${typePart} 揭示: ${verb} (关于${object})${locPart}`
: `${typePart} 揭示: ${verb}${locPart}`;
break;
case 'dec':
semantic = object
? `${typePart} ${subject} -> ${verb} (对${object})${locPart}`
: `${typePart} ${subject} -> ${verb}${locPart}`;
break;
case 'ten':
semantic = object
? `${typePart} ${subject} <-> ${object}: ${verb}${locPart}`
: `${typePart} ${subject}: ${verb}${locPart}`;
break;
case 'loc':
semantic = location
? `${typePart} 场景: ${location} - ${verb}`
: `${typePart} 场景: ${verb}`;
break;
default:
semantic = `${typePart} ${subject} -> ${verb}${objPart}${locPart}`;
}
return semantic + themePart;
}
// ============================================================================
// 睡眠工具
// ============================================================================
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ============================================================================
// 单轮提取(带重试)
// ============================================================================
async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, options = {}) {
const { timeout = DEFAULT_TIMEOUT } = options;
@@ -86,8 +187,6 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op
const input = `<round>\n${parts.join('\n')}\n</round>`;
xbLog.info(MODULE_ID, `floor ${aiFloor} 发送输入 len=${input.length}`);
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
if (batchCancelled) return [];
@@ -95,16 +194,15 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op
const response = await callLLM([
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: input },
{ role: 'assistant', content: '收到,开始提取并仅输出 JSON。' },
{ role: 'assistant', content: JSON_PREFILL },
], {
temperature: 0.2,
max_tokens: 500,
max_tokens: 1000,
timeout,
});
const rawText = String(response || '');
if (!rawText.trim()) {
xbLog.warn(MODULE_ID, `floor ${aiFloor} 解析失败:响应为空`);
if (attempt < RETRY_COUNT) {
await sleep(RETRY_DELAY);
continue;
@@ -112,11 +210,13 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op
return null;
}
const fullJson = JSON_PREFILL + rawText;
let parsed;
try {
parsed = parseJson(rawText);
parsed = parseJson(fullJson);
} catch (e) {
xbLog.warn(MODULE_ID, `floor ${aiFloor} 解析失败JSON 异常`);
xbLog.warn(MODULE_ID, `floor ${aiFloor} JSON解析失败`);
if (attempt < RETRY_COUNT) {
await sleep(RETRY_DELAY);
continue;
@@ -125,8 +225,6 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op
}
if (!parsed?.atoms || !Array.isArray(parsed.atoms)) {
xbLog.warn(MODULE_ID, `floor ${aiFloor} atoms 缺失raw="${rawText.slice(0, 300)}"`);
xbLog.warn(MODULE_ID, `floor ${aiFloor} 解析失败atoms 缺失`);
if (attempt < RETRY_COUNT) {
await sleep(RETRY_DELAY);
continue;
@@ -141,20 +239,20 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op
floor: aiFloor,
type: a.t,
subject: a.s || null,
value: String(a.v).slice(0, 30),
object: a.o || null,
value: String(a.v).slice(0, 50),
location: a.l || null,
source: a.f === 'u' ? 'user' : 'ai',
themes: a.th || { fn: [], pt: [], kw: [] },
semantic: buildSemantic(a, userName, aiName),
}));
if (!filtered.length) {
xbLog.warn(MODULE_ID, `floor ${aiFloor} atoms 为空raw="${rawText.slice(0, 300)}"`);
}
return filtered;
} catch (e) {
if (batchCancelled) return null;
if (attempt < RETRY_COUNT) {
xbLog.warn(MODULE_ID, `floor ${aiFloor}${attempt + 1}次失败,重试...`, e?.message);
await sleep(RETRY_DELAY * (attempt + 1));
continue;
}
@@ -166,18 +264,14 @@ async function extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, op
return null;
}
/**
* 单轮配对提取(增量时使用)
*/
export async function extractAtomsForRound(userMessage, aiMessage, aiFloor, options = {}) {
return extractAtomsForRoundWithRetry(userMessage, aiMessage, aiFloor, options);
}
/**
* 批量提取(首批 staggered 启动)
* @param {Array} chat
* @param {Function} onProgress - (current, total, failed) => void
*/
// ============================================================================
// 批量提取
// ============================================================================
export async function batchExtractAtoms(chat, onProgress) {
if (!chat?.length) return [];
@@ -198,14 +292,10 @@ export async function batchExtractAtoms(chat, onProgress) {
let failed = 0;
for (let i = 0; i < pairs.length; i += CONCURRENCY) {
if (batchCancelled) {
xbLog.info(MODULE_ID, `批量提取已取消 (${completed}/${pairs.length})`);
break;
}
if (batchCancelled) break;
const batch = pairs.slice(i, i + CONCURRENCY);
// ★ 首批 staggered 启动:错开 80ms 发送
if (i === 0) {
const promises = batch.map((pair, idx) => (async () => {
await sleep(idx * STAGGER_DELAY);
@@ -213,10 +303,15 @@ export async function batchExtractAtoms(chat, onProgress) {
if (batchCancelled) return;
try {
const atoms = await extractAtomsForRoundWithRetry(pair.userMsg, pair.aiMsg, pair.aiFloor, { timeout: DEFAULT_TIMEOUT });
const atoms = await extractAtomsForRoundWithRetry(
pair.userMsg,
pair.aiMsg,
pair.aiFloor,
{ timeout: DEFAULT_TIMEOUT }
);
if (atoms?.length) {
allAtoms.push(...atoms);
} else {
} else if (atoms === null) {
failed++;
}
} catch {
@@ -227,14 +322,18 @@ export async function batchExtractAtoms(chat, onProgress) {
})());
await Promise.all(promises);
} else {
// 后续批次正常并行
const promises = batch.map(pair =>
extractAtomsForRoundWithRetry(pair.userMsg, pair.aiMsg, pair.aiFloor, { timeout: DEFAULT_TIMEOUT })
extractAtomsForRoundWithRetry(
pair.userMsg,
pair.aiMsg,
pair.aiFloor,
{ timeout: DEFAULT_TIMEOUT }
)
.then(atoms => {
if (batchCancelled) return;
if (atoms?.length) {
allAtoms.push(...atoms);
} else {
} else if (atoms === null) {
failed++;
}
completed++;
@@ -251,14 +350,12 @@ export async function batchExtractAtoms(chat, onProgress) {
await Promise.all(promises);
}
// 批次间隔
if (i + CONCURRENCY < pairs.length && !batchCancelled) {
await sleep(30);
}
}
const status = batchCancelled ? '已取消' : '完成';
xbLog.info(MODULE_ID, `批量提取${status}: ${allAtoms.length} atoms, ${completed}/${pairs.length}, ${failed} 失败`);
xbLog.info(MODULE_ID, `批量提取完成: ${allAtoms.length} atoms, ${failed} 失败`);
return allAtoms;
}