Update story summary recall and prompt

This commit is contained in:
2026-02-05 00:22:02 +08:00
parent 12db08abe0
commit 8137e206f9
18 changed files with 708 additions and 406 deletions

View File

@@ -32,7 +32,6 @@ function sanitizeFacts(parsed) {
if (!s || !pRaw) continue;
// 删除操作
if (item.retracted === true) {
ok.push({ s, p: pRaw, retracted: true });
continue;
@@ -43,11 +42,15 @@ function sanitizeFacts(parsed) {
const relP = normalizeRelationPredicate(pRaw);
const isRel = !!relP;
const fact = { s, p: isRel ? relP : pRaw, o };
const fact = {
s,
p: isRel ? relP : pRaw,
o,
isState: !!item.isState,
};
// 关系类保留 trend
if (isRel && item.trend) {
const validTrends = ['破裂', '厌恶', '反感', '陌生', '投缘', '亲密', '交融'];
const validTrends = ['??', '??', '??', '??', '??', '??', '??'];
if (validTrends.includes(item.trend)) {
fact.trend = item.trend;
}
@@ -59,6 +62,7 @@ function sanitizeFacts(parsed) {
parsed.factUpdates = ok;
}
// ═══════════════════════════════════════════════════════════════════════════
// causedBy 清洗(事件因果边)
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -100,14 +100,19 @@ Acknowledged. Now reviewing the incremental summarization specifications:
├─ progress: 0.0 to 1.0
└─ newMoment: 仅记录本次新增的关键时刻
[Fact Tracking - SPO Triples]
├─ s: 主体(角色名/物品名)
├─ p: 谓词(属性名)
│ - 关系类只允许对X的看法 / 与X的关系
├─ o: 值(当前状态)
├─ trend: 仅关系类填写
├─ retracted: 删除标记
└─ s+p 为键,相同键会覆盖旧值
[Fact Tracking - SPO ???]
?? ??: ?? & ???????
?? ??: ??????????????????
?? SPO ??:
? s: ??????/????
? p: ??????????????
? o: ???
?? KV ??: s+p ??????????
?? isState ????????:
? true = ????????????/??/??/???
? false = ??????????????
?? trend: ?????????/??/??/??/??/??/???
?? retracted: true ???????
Ready to process incremental summary requests with strict deduplication.`,
@@ -177,26 +182,28 @@ Before generating, observe the USER and analyze carefully:
"arcUpdates": [
{"name": "角色名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
],
"factUpdates": [
"factUpdates": [
{
"s": "主体(角色名/物品名)",
"p": "谓词(属性名/对X的看法",
"s": "主体",
"p": "谓词(复用已有谓词,避免同义词",
"o": "当前值",
"trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融",
"retracted": false
"isState": true/false,
"trend": "仅关系类:破裂|厌恶|反感|陌生|投缘|亲密|交融"
}
]
}
\`\`\`
## factUpdates 规则
- 目的: 纠错 & 世界一致性约束,只记录硬性事实
- s+p 为键,相同键会覆盖旧值
- 状态类s=角色名, p=属性(生死/位置/状态等), o=值
- 关系类s=角色A, p="对B的看法" 或 p="与B的关系"trend 仅限关系类
- 删除设置 retracted: true(不需要填 o
- 只输出有变化的条目
- 硬约束才记录,避免叙事化,确保少、硬、稳定
- isState: true=核心约束(位置/身份/生死/关系)false=有容量上限会被清理
- 关系类: p="对X的看法"trend 必填
- 删除: 设置 retracted: true
- 谓词规范化: 复用已有谓词,不要发明同义词
- 只输出有变化的条目,确保少、硬、稳定
## CRITICAL NOTES
- events.id 从 evt-{nextEventId} 开始编号
- 仅输出【增量】内容,已有事件绝不重复
@@ -267,9 +274,11 @@ function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
function formatFactsForLLM(facts) {
if (!facts?.length) {
return '(空白,尚无事实记录)';
return { text: '(空白,尚无事实记录)', predicates: [] };
}
const predicates = [...new Set(facts.map(f => f.p).filter(Boolean))];
const lines = facts.map(f => {
if (f.trend) {
return `- ${f.s} | ${f.p} | ${f.o} [${f.trend}]`;
@@ -277,11 +286,18 @@ function formatFactsForLLM(facts) {
return `- ${f.s} | ${f.p} | ${f.o}`;
});
return lines.join('\n') || '(空白,尚无事实记录)';
return {
text: lines.join('\n') || '(空白,尚无事实记录)',
predicates,
};
}
function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, historyRange, nextEventId, existingEventCount) {
const factsText = formatFactsForLLM(existingFacts);
const { text: factsText, predicates } = formatFactsForLLM(existingFacts);
const predicatesHint = predicates.length > 0
? `\n\n<\u5df2\u6709\u8c13\u8bcd\uff0c\u8bf7\u590d\u7528>\n${predicates.join('\u3001')}\n</\u5df2\u6709\u8c13\u8bcd\uff0c\u8bf7\u590d\u7528>`
: '';
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
.replace(/\{nextEventId\}/g, String(nextEventId));
@@ -293,9 +309,9 @@ function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, hi
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前事实图谱>\n${factsText}\n</当前事实图谱>` },
{ role: 'user', content: `<\u5df2\u6709\u603b\u7ed3\u72b6\u6001>\n${existingSummary}\n</\u5df2\u6709\u603b\u7ed3\u72b6\u6001>\n\n<\u5f53\u524d\u4e8b\u5b9e\u56fe\u8c31>\n${factsText}\n</\u5f53\u524d\u4e8b\u5b9e\u56fe\u8c31>${predicatesHint}` },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
{ role: 'user', content: `<新对话内容>${historyRange}\n${newHistoryText}\n</新对话内容>` }
{ role: 'user', content: `<\u65b0\u5bf9\u8bdd\u5185\u5bb9>\uff08${historyRange}\uff09\n${newHistoryText}\n</\u65b0\u5bf9\u8bdd\u5185\u5bb9>` }
];
const bottomMessages = [
@@ -311,6 +327,7 @@ function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, hi
};
}
// ═══════════════════════════════════════════════════════════════════════════
// JSON 解析
// ═══════════════════════════════════════════════════════════════════════════
@@ -415,4 +432,4 @@ export async function generateSummary(options) {
console.groupEnd();
return rawOutput;
}
}

View File

@@ -1,6 +1,6 @@
// ═══════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════════
// Story Summary - Prompt Injection (Final Clean Version)
// - 仅负责构建注入文本,不负责写入 extension_prompts
// - 仅负责"构建注入文本",不负责写入 extension_prompts
// - 注入发生在 story-summary.jsGENERATION_STARTED 时写入 extension_promptsIN_CHAT + depth
// ═══════════════════════════════════════════════════════════════════════════
@@ -8,8 +8,8 @@ import { getContext } from "../../../../../../extensions.js";
import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory, buildQueryText } from "../vector/recall.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/chunk-store.js";
import { recallMemory, buildQueryText } from "../vector/retrieval/recall.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/storage/chunk-store.js";
const MODULE_ID = "summaryPrompt";
@@ -85,6 +85,49 @@ function cleanSummary(summary) {
.trim();
}
// ─────────────────────────────────────────────────────────────────────────────
// 上下文配对工具函数
// ─────────────────────────────────────────────────────────────────────────────
/**
* 获取chunk的配对楼层
* USER楼层 → 下一楼(AI回复)
* AI楼层 → 上一楼(USER发言)
*/
function getContextFloor(chunk) {
if (chunk.isL0) return -1; // L0虚拟chunk不需要配对
return chunk.isUser ? chunk.floor + 1 : chunk.floor - 1;
}
/**
* 从候选chunks中选择最佳配对
* 策略优先选择相反角色的第一个chunk
*/
function pickContextChunk(candidates, mainChunk) {
if (!candidates?.length) return null;
const targetIsUser = !mainChunk.isUser;
// 优先相反角色
const opposite = candidates.find(c => c.isUser === targetIsUser);
if (opposite) return opposite;
// 否则选第一个
return candidates[0];
}
/**
* 格式化配对chunk完整显示带缩进和方向符号
*/
function formatContextChunkLine(chunk, isAbove) {
const { name1, name2 } = getContext();
const speaker = chunk.isUser ? (name1 || "用户") : (chunk.speaker || name2 || "角色");
const text = String(chunk.text || "").trim();
const symbol = isAbove ? "┌" : "└";
return ` ${symbol} #${chunk.floor + 1} [${speaker}] ${text}`;
}
/**
* 格式化配对chunk缩进简短摘要
*/
// ─────────────────────────────────────────────────────────────────────────────
// 系统前导与后缀
// ─────────────────────────────────────────────────────────────────────────────
@@ -150,7 +193,31 @@ function formatChunkFullLine(c) {
return ` #${c.floor + 1} [${speaker}] ${String(c.text || "").trim()}`;
}
// 因果事件格式(仅作为“前因线索”展示,仍保留楼层提示)
/**
* 格式化chunk及其配对上下文
* 返回数组:[配对行(如果在前), 主chunk行, 配对行(如果在后)]
*/
function formatChunkWithContext(mainChunk, contextChunk) {
const lines = [];
const mainLine = formatChunkFullLine(mainChunk);
if (!contextChunk) {
lines.push(mainLine);
return lines;
}
if (contextChunk.floor < mainChunk.floor) {
lines.push(formatContextChunkLine(contextChunk, true));
lines.push(mainLine);
} else {
lines.push(mainLine);
lines.push(formatContextChunkLine(contextChunk, false));
}
return lines;
}
// 因果事件格式(仅作为"前因线索"展示,仍保留楼层提示)
function formatCausalEventLine(causalItem, causalById) {
const ev = causalItem?.event || {};
const depth = Math.max(1, Math.min(9, causalItem?._causalDepth || 1));
@@ -172,9 +239,8 @@ function formatCausalEventLine(causalItem, causalById) {
const evidence = causalItem._evidenceChunk;
if (evidence) {
const speaker = evidence.speaker || "角色";
const preview = String(evidence.text || "");
const clip = preview.length > 60 ? preview.slice(0, 60) + "..." : preview;
lines.push(`${indent} #${evidence.floor + 1} [${speaker}] ${clip}`);
const text = String(evidence.text || "").trim();
lines.push(`${indent} #${evidence.floor + 1} [${speaker}] ${text}`);
}
return lines.join("\n");
@@ -216,11 +282,13 @@ function formatInjectionLog(stats, details, recentOrphanStats = null) {
const l1OrphanCount = (stats.orphans.injected || 0) - l0OrphanCount;
lines.push(` [3] 远期片段 (已总结范围)`);
lines.push(` 选入: ${stats.orphans.injected} 条 (L0: ${l0OrphanCount}, L1: ${l1OrphanCount}) | 消耗: ${stats.orphans.tokens} tokens`);
lines.push(` 配对: ${stats.orphans.contextPairs || 0}`);
lines.push('');
// [4] 待整理
lines.push(` [4] 待整理 (独立预算 5000)`);
lines.push(` 选入: ${recentOrphanStats?.injected || 0} 条 | 消耗: ${recentOrphanStats?.tokens || 0} tokens`);
lines.push(` 配对: ${recentOrphanStats?.contextPairs || 0}`);
lines.push(` 楼层: ${recentOrphanStats?.floorRange || 'N/A'}`);
lines.push('');
@@ -248,7 +316,7 @@ function formatInjectionLog(stats, details, recentOrphanStats = null) {
return lines.join('\n');
}
// 重写事件文本里的序号前缀:把 {idx}. {idx}.【...】 的 idx 替换
// 重写事件文本里的序号前缀:把 "{idx}. ""{idx}.【...】" 的 idx 替换
function renumberEventText(text, newIndex) {
const s = String(text || "");
// 匹配行首: "12." 或 "12.【"
@@ -325,11 +393,12 @@ export function buildNonVectorPromptText() {
return text;
}
// ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// 向量模式:预算装配(世界 → 事件(带证据) → 碎片 → 弧光)
// ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
async function buildVectorPrompt(store, recallResult, causalById, queryEntities = [], meta = null) {
const { chatId } = getContext();
const data = store.json || {};
const total = { used: 0, max: MAIN_BUDGET_MAX };
@@ -351,13 +420,14 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
arcs: { count: 0, tokens: 0 },
events: { selected: 0, tokens: 0 },
evidence: { attached: 0, tokens: 0 },
orphans: { injected: 0, tokens: 0 },
orphans: { injected: 0, tokens: 0, l0Count: 0, contextPairs: 0 },
};
const recentOrphanStats = {
injected: 0,
tokens: 0,
floorRange: "N/A",
contextPairs: 0,
};
const details = {
eventList: [],
@@ -473,14 +543,14 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
const bestChunk = pickBestChunkForEvent(e.event);
// 先尝试带证据
// 先尝试"带证据"
// idx 先占位写 0后面统一按时间线重排后再改号
let text = formatEventWithEvidence(e, 0, bestChunk);
let cost = estimateTokens(text);
let hasEvidence = !!bestChunk;
let chosenChunk = bestChunk || null;
// 塞不下就退化成不带证据
// 塞不下就退化成"不带证据"
if (total.used + cost > total.max) {
text = formatEventWithEvidence(e, 0, null);
cost = estimateTokens(text);
@@ -549,33 +619,90 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
assembled.events.similar = selectedSimilarTexts;
// ═══════════════════════════════════════════════════════════════════
// [优先级 4] 远期片段(已总结范围的 orphan chunks
// [优先级 4] 远期片段(已总结范围的 orphan chunks- 带上下文配对
// ═══════════════════════════════════════════════════════════════════
const lastSummarized = store.lastSummarizedMesId ?? -1;
const lastChunkFloor = meta?.lastChunkFloor ?? -1;
const keepVisible = store.keepVisibleCount ?? 3;
if (chunks.length && total.used < total.max) {
const orphans = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.filter(c => c.floor <= lastSummarized)
// 收集需要配对的楼层
const orphanContextFloors = new Set();
const orphanCandidates = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.filter(c => c.floor <= lastSummarized);
for (const c of orphanCandidates) {
if (c.isL0) continue;
const pairFloor = getContextFloor(c);
if (pairFloor >= 0) orphanContextFloors.add(pairFloor);
}
// 批量获取配对楼层的chunks
let contextChunksByFloor = new Map();
if (chatId && orphanContextFloors.size > 0) {
try {
const contextChunks = await getChunksByFloors(chatId, Array.from(orphanContextFloors));
for (const pc of contextChunks) {
if (!contextChunksByFloor.has(pc.floor)) {
contextChunksByFloor.set(pc.floor, []);
}
contextChunksByFloor.get(pc.floor).push(pc);
}
} catch (e) {
xbLog.warn(MODULE_ID, "获取配对chunks失败", e);
}
}
if (orphanCandidates.length && total.used < total.max) {
const orphans = orphanCandidates
.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0)));
const l1Budget = { used: 0, max: total.max - total.used };
let l0Count = 0;
let contextPairsCount = 0;
for (const c of orphans) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(assembled.orphans.lines, line, l1Budget)) break;
// L0 不需要配对
if (c.isL0) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(assembled.orphans.lines, line, l1Budget)) break;
injectionStats.orphans.injected++;
l0Count++;
continue;
}
// 获取配对chunk
const pairFloor = getContextFloor(c);
const candidates = contextChunksByFloor.get(pairFloor) || [];
const contextChunk = pickContextChunk(candidates, c);
// 格式化(带配对)
const formattedLines = formatChunkWithContext(c, contextChunk);
// 尝试添加所有行
let allAdded = true;
for (const line of formattedLines) {
if (!pushWithBudget(assembled.orphans.lines, line, l1Budget)) {
allAdded = false;
break;
}
}
if (!allAdded) break;
injectionStats.orphans.injected++;
if (contextChunk) contextPairsCount++;
}
assembled.orphans.tokens = l1Budget.used;
total.used += l1Budget.used;
injectionStats.orphans.tokens = l1Budget.used;
injectionStats.orphans.l0Count = l0Count;
injectionStats.orphans.contextPairs = contextPairsCount;
}
// ═══════════════════════════════════════════════════════════════════
// [独立预算] 待整理(未总结范围,独立 5000
// [独立预算] 待整理(未总结范围,独立 5000- 带上下文配对
// ═══════════════════════════════════════════════════════════════════
// 近期范围:(lastSummarized, lastChunkFloor - keepVisible]
@@ -583,55 +710,113 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
const recentEnd = lastChunkFloor - keepVisible;
if (chunks.length && recentEnd >= recentStart) {
const recentOrphans = chunks
const recentOrphanCandidates = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.filter(c => c.floor >= recentStart && c.floor <= recentEnd)
.filter(c => c.floor >= recentStart && c.floor <= recentEnd);
// 收集近期范围需要配对的楼层
const recentContextFloors = new Set();
for (const c of recentOrphanCandidates) {
if (c.isL0) continue;
const pairFloor = getContextFloor(c);
if (pairFloor >= 0) recentContextFloors.add(pairFloor);
}
// 批量获取(复用已有的 or 新获取)
let recentContextChunksByFloor = new Map();
if (chatId && recentContextFloors.size > 0) {
// 过滤掉已经获取过的
const newFloors = Array.from(recentContextFloors).filter(f => !contextChunksByFloor.has(f));
if (newFloors.length > 0) {
try {
const newContextChunks = await getChunksByFloors(chatId, newFloors);
for (const pc of newContextChunks) {
if (!contextChunksByFloor.has(pc.floor)) {
contextChunksByFloor.set(pc.floor, []);
}
contextChunksByFloor.get(pc.floor).push(pc);
}
} catch (e) {
xbLog.warn(MODULE_ID, "获取近期配对chunks失败", e);
}
}
recentContextChunksByFloor = contextChunksByFloor;
}
const recentOrphans = recentOrphanCandidates
.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0)));
const recentBudget = { used: 0, max: RECENT_ORPHAN_MAX };
let recentContextPairsCount = 0;
for (const c of recentOrphans) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(assembled.recentOrphans.lines, line, recentBudget)) break;
// L0 不需要配对
if (c.isL0) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(assembled.recentOrphans.lines, line, recentBudget)) break;
recentOrphanStats.injected++;
continue;
}
// 获取配对chunk
const pairFloor = getContextFloor(c);
const candidates = recentContextChunksByFloor.get(pairFloor) || [];
const contextChunk = pickContextChunk(candidates, c);
// 格式化(带配对)
const formattedLines = formatChunkWithContext(c, contextChunk);
// 尝试添加所有行
let allAdded = true;
for (const line of formattedLines) {
if (!pushWithBudget(assembled.recentOrphans.lines, line, recentBudget)) {
allAdded = false;
break;
}
}
if (!allAdded) break;
recentOrphanStats.injected++;
if (contextChunk) recentContextPairsCount++;
}
assembled.recentOrphans.tokens = recentBudget.used;
recentOrphanStats.tokens = recentBudget.used;
recentOrphanStats.floorRange = `${recentStart + 1}~${recentEnd + 1}`;
recentOrphanStats.contextPairs = recentContextPairsCount;
}
// ═══════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════
// 按注入顺序拼接 sections
// ═══════════════════════════════════════════════════════════════════════
const sections = [];
// 1. 世界约束 → 定了的事
if (assembled.facts.lines.length) {
sections.push(`[定了的事] 已确立的事实\n${assembled.facts.lines.join("\n")}`);
}
// 2. 核心经历 → 印象深的事
if (assembled.events.direct.length) {
sections.push(`[印象深的事] 记得很清楚\n\n${assembled.events.direct.join("\n\n")}`);
}
// 3. 过往背景 → 好像有关的事
if (assembled.events.similar.length) {
sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.events.similar.join("\n\n")}`);
}
// 4. 远期片段 → 更早以前
if (assembled.orphans.lines.length) {
sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.orphans.lines.join("\n")}`);
}
// 5. 待整理 → 刚发生的
if (assembled.recentOrphans.lines.length) {
sections.push(`[刚发生的] 还没来得及想明白\n${assembled.recentOrphans.lines.join("\n")}`);
}
// 6. 人物弧光 → 这些人
if (assembled.arcs.lines.length) {
sections.push(`[这些人] 他们现在怎样了\n${assembled.arcs.lines.join("\n")}`);
}
// 按注入顺序拼接 sections
// ═══════════════════════════════════════════════════════════════════
const sections = [];
// 1. 世界约束 → 定了的事
if (assembled.facts.lines.length) {
sections.push(`[定了的事] 已确立的事实\n${assembled.facts.lines.join("\n")}`);
}
// 2. 核心经历 → 印象深的事
if (assembled.events.direct.length) {
sections.push(`[印象深的事] 记得很清楚\n\n${assembled.events.direct.join("\n\n")}`);
}
// 3. 过往背景 → 好像有关的事
if (assembled.events.similar.length) {
sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.events.similar.join("\n\n")}`);
}
// 4. 远期片段 → 更早以前
if (assembled.orphans.lines.length) {
sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.orphans.lines.join("\n")}`);
}
// 5. 待整理 → 刚发生的
if (assembled.recentOrphans.lines.length) {
sections.push(`[刚发生的] 还没来得及想明白\n${assembled.recentOrphans.lines.join("\n")}`);
}
// 6. 人物弧光 → 这些人
if (assembled.arcs.lines.length) {
sections.push(`[这些人] 他们现在怎样了\n${assembled.arcs.lines.join("\n")}`);
}
if (!sections.length) {
if (!sections.length) {
return { promptText: "", injectionLogText: "", injectionStats };
}
@@ -846,3 +1031,4 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
return { text: finalText, logText: (recallResult.logText || "") + (injectionLogText || "") };
}