Files
LittleWhiteBox/modules/story-summary/generate/prompt.js

1257 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════════════════════
// Story Summary - Prompt Injection (v2 - DSL 版)
// - 仅负责"构建注入文本",不负责写入 extension_prompts
// - 注入发生在 story-summary.jsGENERATION_STARTED 时写入 extension_prompts
// ═══════════════════════════════════════════════════════════════════════════
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/retrieval/recall.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/storage/chunk-store.js";
// METRICS
import { formatMetricsLog, detectIssues } from "../vector/retrieval/metrics.js";
const MODULE_ID = "summaryPrompt";
// ─────────────────────────────────────────────────────────────────────────────
// 召回失败提示节流
// ─────────────────────────────────────────────────────────────────────────────
let lastRecallFailAt = 0;
const RECALL_FAIL_COOLDOWN_MS = 10_000;
/**
* 检查是否可以通知召回失败
* @returns {boolean}
*/
function canNotifyRecallFail() {
const now = Date.now();
if (now - lastRecallFailAt < RECALL_FAIL_COOLDOWN_MS) return false;
lastRecallFailAt = now;
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// 预算常量
// ─────────────────────────────────────────────────────────────────────────────
const MAIN_BUDGET_MAX = 10000;
const ORPHAN_MAX = 2500;
const RECENT_ORPHAN_MAX = 5000;
const TOTAL_BUDGET_MAX = 15000;
const L1_MAX = 2000;
const ARCS_MAX = 1500;
const TOP_N_STAR = 5;
// ─────────────────────────────────────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────────────────────────────────────
/**
* 估算 token 数量
* @param {string} text - 文本
* @returns {number} token 数
*/
function estimateTokens(text) {
if (!text) return 0;
const s = String(text);
const zh = (s.match(/[\u4e00-\u9fff]/g) || []).length;
return Math.ceil(zh + (s.length - zh) / 4);
}
/**
* 带预算控制的行推入
* @param {Array} lines - 行数组
* @param {string} text - 文本
* @param {object} state - 预算状态 {used, max}
* @returns {boolean} 是否成功
*/
function pushWithBudget(lines, text, state) {
const t = estimateTokens(text);
if (state.used + t > state.max) return false;
lines.push(text);
state.used += t;
return true;
}
/**
* 计算余弦相似度
* @param {Array} a - 向量 a
* @param {Array} b - 向量 b
* @returns {number} 相似度
*/
function cosineSimilarity(a, b) {
if (!a?.length || !b?.length || a.length !== b.length) return 0;
let dot = 0, nA = 0, nB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
nA += a[i] * a[i];
nB += b[i] * b[i];
}
return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
}
/**
* 解析楼层范围
* @param {string} summary - 摘要文本
* @returns {object|null} {start, end}
*/
function parseFloorRange(summary) {
if (!summary) return null;
const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/);
if (!match) return null;
const start = Math.max(0, parseInt(match[1], 10) - 1);
const end = Math.max(0, (match[2] ? parseInt(match[2], 10) : parseInt(match[1], 10)) - 1);
return { start, end };
}
/**
* 清理摘要中的楼层标记
* @param {string} summary - 摘要文本
* @returns {string} 清理后的文本
*/
function cleanSummary(summary) {
return String(summary || "")
.replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "")
.trim();
}
/**
* 规范化字符串(用于比较)
* @param {string} s - 字符串
* @returns {string} 规范化后的字符串
*/
function normalize(s) {
return String(s || '')
.normalize('NFKC')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim()
.toLowerCase();
}
// ─────────────────────────────────────────────────────────────────────────────
// 上下文配对工具函数
// ─────────────────────────────────────────────────────────────────────────────
/**
* 获取上下文楼层
* @param {object} chunk - chunk 对象
* @returns {number} 配对楼层,-1 表示无效
*/
function getContextFloor(chunk) {
if (chunk.isL0) return -1;
return chunk.isUser ? chunk.floor + 1 : chunk.floor - 1;
}
/**
* 选择配对 chunk
* @param {Array} candidates - 候选 chunks
* @param {object} mainChunk - 主 chunk
* @returns {object|null} 配对 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 行
* @param {object} chunk - chunk 对象
* @param {boolean} isAbove - 是否在主 chunk 上方
* @returns {string} 格式化的行
*/
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}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// 系统前导与后缀
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建系统前导
* @returns {string}
*/
function buildSystemPreamble() {
return [
"以上是还留在眼前的对话",
"以下是脑海里的记忆:",
"• [定了的事] 这些是不会变的",
"• 其余部分是过往经历的回忆碎片",
"",
"请内化这些记忆:",
].join("\n");
}
/**
* 构建后缀
* @returns {string}
*/
function buildPostscript() {
return [
"",
"这些记忆是真实的,请自然地记住它们。",
].join("\n");
}
// ─────────────────────────────────────────────────────────────────────────────
// L1 Facts 分层过滤
// ─────────────────────────────────────────────────────────────────────────────
/**
* 从 store 获取所有已知角色名
* @param {object} store - summary store
* @returns {Set<string>} 角色名集合(规范化后)
*/
function getKnownCharacters(store) {
const names = new Set();
// 从 arcs 获取
const arcs = store?.json?.arcs || [];
for (const a of arcs) {
if (a.name) names.add(normalize(a.name));
}
// 从 characters.main 获取
const main = store?.json?.characters?.main || [];
for (const m of main) {
const name = typeof m === 'string' ? m : m.name;
if (name) names.add(normalize(name));
}
// 从当前角色获取
const { name1, name2 } = getContext();
if (name1) names.add(normalize(name1));
if (name2) names.add(normalize(name2));
return names;
}
/**
* 解析关系类 fact 的目标人物
* @param {string} predicate - 谓词,如 "对蓝袖的看法"
* @returns {string|null} 目标人物名
*/
function parseRelationTarget(predicate) {
const match = String(predicate || '').match(/^对(.+)的/);
return match ? match[1] : null;
}
/**
* 过滤 facts分层策略
*
* 规则:
* - isState=true全量保留
* - 关系类(谓词匹配 /^对.+的/from 或 to 在 focus 中
* - 人物状态类(主体是已知角色名):主体在 focus 中
* - 其他(物品/地点/规则):全量保留
*
* @param {Array} facts - 所有 facts
* @param {Array} focusEntities - 焦点实体
* @param {Set} knownCharacters - 已知角色名集合
* @returns {Array} 过滤后的 facts
*/
function filterFactsByRelevance(facts, focusEntities, knownCharacters) {
if (!facts?.length) return [];
const focusSet = new Set((focusEntities || []).map(normalize));
return facts.filter(f => {
// 1. isState=true全量保留
if (f._isState === true) return true;
// 2. 关系类from 或 to 在 focus 中
if (isRelationFact(f)) {
const from = normalize(f.s);
const target = parseRelationTarget(f.p);
const to = target ? normalize(target) : '';
// 任一方在 focus 中即保留
if (focusSet.has(from) || focusSet.has(to)) return true;
// 都不在 focus 中则过滤
return false;
}
// 3. 主体是已知角色名:检查是否在 focus 中
const subjectNorm = normalize(f.s);
if (knownCharacters.has(subjectNorm)) {
return focusSet.has(subjectNorm);
}
// 4. 主体不是人名(物品/地点/规则等):保留
return true;
});
}
/**
* 格式化 facts 用于注入
* @param {Array} facts - facts 数组
* @param {Array} focusEntities - 焦点实体
* @param {Set} knownCharacters - 已知角色名集合
* @returns {Array} 格式化后的行
*/
function formatFactsForInjection(facts, focusEntities, knownCharacters) {
// 先过滤
const filtered = filterFactsByRelevance(facts, focusEntities, knownCharacters);
if (!filtered.length) return [];
// 按 since 降序排序(最新的优先)
return filtered
.sort((a, b) => (b.since || 0) - (a.since || 0))
.map(f => {
const since = f.since ? ` (#${f.since + 1})` : '';
if (isRelationFact(f) && f.trend) {
return `- ${f.s} ${f.p}: ${f.o} [${f.trend}]${since}`;
}
return `- ${f.s}${f.p}: ${f.o}${since}`;
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 格式化函数
// ─────────────────────────────────────────────────────────────────────────────
/**
* 格式化角色弧光行
* @param {object} a - 弧光对象
* @returns {string}
*/
function formatArcLine(a) {
const moments = (a.moments || [])
.map(m => (typeof m === "string" ? m : m.text))
.filter(Boolean);
if (moments.length) {
return `- ${a.name}${moments.join(" → ")}`;
}
return `- ${a.name}${a.trajectory}`;
}
/**
* 格式化 chunk 完整行
* @param {object} c - chunk 对象
* @returns {string}
*/
function formatChunkFullLine(c) {
const { name1, name2 } = getContext();
if (c.isL0) {
return ` #${c.floor + 1} [📌] ${String(c.text || "").trim()}`;
}
const speaker = c.isUser ? (name1 || "用户") : (c.speaker || name2 || "角色");
return ` #${c.floor + 1} [${speaker}] ${String(c.text || "").trim()}`;
}
/**
* 格式化带上下文的 chunk
* @param {object} mainChunk - 主 chunk
* @param {object|null} contextChunk - 上下文 chunk
* @returns {Array} 格式化的行数组
*/
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;
}
/**
* 格式化因果事件行
* @param {object} causalItem - 因果项
* @param {Map} causalById - 因果映射
* @returns {string}
*/
function formatCausalEventLine(causalItem, causalById) {
const ev = causalItem?.event || {};
const depth = Math.max(1, Math.min(9, causalItem?._causalDepth || 1));
const indent = " │" + " ".repeat(depth - 1);
const prefix = `${indent}├─ 前因`;
const time = ev.timeLabel ? `${ev.timeLabel}` : "";
const people = (ev.participants || []).join(" / ");
const summary = cleanSummary(ev.summary);
const r = parseFloorRange(ev.summary);
const floorHint = r ? `(#${r.start + 1}${r.end !== r.start ? `-${r.end + 1}` : ""})` : "";
const lines = [];
lines.push(`${prefix}${time}${people ? ` ${people}` : ""}`);
const body = `${summary}${floorHint ? ` ${floorHint}` : ""}`.trim();
lines.push(`${indent} ${body}`);
const evidence = causalItem._evidenceChunk;
if (evidence) {
const speaker = evidence.speaker || "角色";
const text = String(evidence.text || "").trim();
lines.push(`${indent} #${evidence.floor + 1} [${speaker}] ${text}`);
}
return lines.join("\n");
}
/**
* 重新编号事件文本
* @param {string} text - 事件文本
* @param {number} newIndex - 新编号
* @returns {string}
*/
function renumberEventText(text, newIndex) {
const s = String(text || "");
return s.replace(/^(\s*)\d+(\.\s*(?:【)?)/, `$1${newIndex}$2`);
}
/**
* 获取事件排序键
* @param {object} ev - 事件对象
* @returns {number}
*/
function getEventSortKey(ev) {
const r = parseFloorRange(ev?.summary);
if (r) return r.start;
const m = String(ev?.id || "").match(/evt-(\d+)/);
return m ? parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER;
}
// ─────────────────────────────────────────────────────────────────────────────
// 非向量模式
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建非向量模式的 prompt
* @param {object} store - summary store
* @returns {string}
*/
function buildNonVectorPrompt(store) {
const data = store.json || {};
const sections = [];
// L1 facts非向量模式不做分层过滤全量注入
const allFacts = getFacts();
const factLines = allFacts
.filter(f => !f.retracted)
.sort((a, b) => (b.since || 0) - (a.since || 0))
.map(f => {
const since = f.since ? ` (#${f.since + 1})` : '';
if (isRelationFact(f) && f.trend) {
return `- ${f.s} ${f.p}: ${f.o} [${f.trend}]${since}`;
}
return `- ${f.s}${f.p}: ${f.o}${since}`;
});
if (factLines.length) {
sections.push(`[定了的事] 已确立的事实\n${factLines.join("\n")}`);
}
if (data.events?.length) {
const lines = data.events.map((ev, i) => {
const time = ev.timeLabel || "";
const title = ev.title || "";
const people = (ev.participants || []).join(" / ");
const summary = cleanSummary(ev.summary);
const header = time ? `${i + 1}.【${time}${title || people}` : `${i + 1}. ${title || people}`;
return `${header}\n ${summary}`;
});
sections.push(`[剧情记忆]\n\n${lines.join("\n\n")}`);
}
if (data.arcs?.length) {
const lines = data.arcs.map(formatArcLine);
sections.push(`[人物弧光]\n${lines.join("\n")}`);
}
if (!sections.length) return "";
return (
`${buildSystemPreamble()}\n` +
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
`${buildPostscript()}`
);
}
/**
* 构建非向量模式的注入文本
* @returns {string}
*/
export function buildNonVectorPromptText() {
if (!getSettings().storySummary?.enabled) {
return "";
}
const store = getSummaryStore();
if (!store?.json) {
return "";
}
let text = buildNonVectorPrompt(store);
if (!text.trim()) {
return "";
}
const cfg = getSummaryPanelConfig();
if (cfg.trigger?.wrapperHead) text = cfg.trigger.wrapperHead + "\n" + text;
if (cfg.trigger?.wrapperTail) text = text + "\n" + cfg.trigger.wrapperTail;
return text;
}
// ─────────────────────────────────────────────────────────────────────────────
// 向量模式:预算装配
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建向量模式的 prompt
* @param {object} store - summary store
* @param {object} recallResult - 召回结果
* @param {Map} causalById - 因果映射
* @param {Array} focusEntities - 焦点实体
* @param {object} meta - 元数据
* @param {object} metrics - 指标对象
* @returns {Promise<object>} {promptText, injectionLogText, injectionStats, metrics}
*/
async function buildVectorPrompt(store, recallResult, causalById, focusEntities = [], meta = null, metrics = null) {
const T_Start = performance.now();
const { chatId } = getContext();
const data = store.json || {};
const total = { used: 0, max: MAIN_BUDGET_MAX };
// 预装配容器
const assembled = {
facts: { lines: [], tokens: 0 },
arcs: { lines: [], tokens: 0 },
events: { direct: [], similar: [] },
orphans: { lines: [], tokens: 0 },
recentOrphans: { lines: [], tokens: 0 },
};
const injectionStats = {
budget: { max: TOTAL_BUDGET_MAX, used: 0 },
facts: { count: 0, tokens: 0, filtered: 0 },
arcs: { count: 0, tokens: 0 },
events: { selected: 0, tokens: 0 },
evidence: { attached: 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: [],
directCount: 0,
similarCount: 0,
};
// ═══════════════════════════════════════════════════════════════════════
// [优先级 1] 世界约束 - 最高优先级(带分层过滤)
// ═══════════════════════════════════════════════════════════════════════
const T_L1_Start = performance.now();
const allFacts = getFacts();
const knownCharacters = getKnownCharacters(store);
const factLines = formatFactsForInjection(allFacts, focusEntities, knownCharacters);
// METRICS: L1 指标
if (metrics) {
metrics.l1.factsTotal = allFacts.length;
metrics.l1.factsFiltered = allFacts.length - factLines.length;
}
if (factLines.length) {
const l1Budget = { used: 0, max: Math.min(L1_MAX, total.max - total.used) };
for (const line of factLines) {
if (!pushWithBudget(assembled.facts.lines, line, l1Budget)) break;
}
assembled.facts.tokens = l1Budget.used;
total.used += l1Budget.used;
injectionStats.facts.count = assembled.facts.lines.length;
injectionStats.facts.tokens = l1Budget.used;
injectionStats.facts.filtered = allFacts.length - factLines.length;
// METRICS
if (metrics) {
metrics.l1.factsInjected = assembled.facts.lines.length;
metrics.l1.tokens = l1Budget.used;
metrics.l1.samples = assembled.facts.lines.slice(0, 3).map(line =>
line.length > 60 ? line.slice(0, 60) + '...' : line
);
metrics.timing.l1Constraints = Math.round(performance.now() - T_L1_Start);
}
} else if (metrics) {
metrics.timing.l1Constraints = Math.round(performance.now() - T_L1_Start);
}
// ═══════════════════════════════════════════════════════════════════════
// [优先级 2] 人物弧光 - 预留预算
// ═══════════════════════════════════════════════════════════════════════
if (data.arcs?.length && total.used < total.max) {
const { name1 } = getContext();
const userName = String(name1 || "").trim();
const relevant = new Set(
[userName, ...(focusEntities || [])]
.map(s => String(s || "").trim())
.filter(Boolean)
);
const filtered = (data.arcs || []).filter(a => {
const n = String(a?.name || "").trim();
return n && relevant.has(n);
});
if (filtered.length) {
const arcBudget = { used: 0, max: Math.min(ARCS_MAX, total.max - total.used) };
for (const a of filtered) {
const line = formatArcLine(a);
if (!pushWithBudget(assembled.arcs.lines, line, arcBudget)) break;
}
assembled.arcs.tokens = arcBudget.used;
total.used += arcBudget.used;
injectionStats.arcs.count = assembled.arcs.lines.length;
injectionStats.arcs.tokens = arcBudget.used;
}
}
// ═══════════════════════════════════════════════════════════════════════
// [优先级 3] 事件 + 证据
// ═══════════════════════════════════════════════════════════════════════
const recalledEvents = (recallResult?.events || []).filter(e => e?.event?.summary);
const chunks = recallResult?.chunks || [];
const usedChunkIds = new Set();
/**
* 为事件选择最佳证据 chunk
* @param {object} eventObj - 事件对象
* @returns {object|null} 最佳 chunk
*/
// 优先 L0 虚拟 chunk否则按 chunkIdx 选第一个
function pickBestChunkForEvent(eventObj) {
const range = parseFloorRange(eventObj?.summary);
if (!range) return null;
let best = null;
for (const c of chunks) {
if (usedChunkIds.has(c.chunkId)) continue;
if (c.floor < range.start || c.floor > range.end) continue;
if (!best) {
best = c;
} else if (c.isL0 && !best.isL0) {
// L0 优先
best = c;
} else if (c.isL0 === best.isL0 && (c.chunkIdx ?? 0) < (best.chunkIdx ?? 0)) {
// 同类型按 chunkIdx 选靠前的
best = c;
}
}
return best;
}
/**
* 格式化带证据的事件
* @param {object} e - 事件召回项
* @param {number} idx - 索引
* @param {object|null} chunk - 证据 chunk
* @returns {string}
*/
function formatEventWithEvidence(e, idx, chunk) {
const ev = e.event || {};
const time = ev.timeLabel || "";
const title = String(ev.title || "").trim();
const people = (ev.participants || []).join(" / ").trim();
const summary = cleanSummary(ev.summary);
const displayTitle = title || people || ev.id || "事件";
const header = time ? `${idx}.【${time}${displayTitle}` : `${idx}. ${displayTitle}`;
const lines = [header];
if (people && displayTitle !== people) lines.push(` ${people}`);
lines.push(` ${summary}`);
for (const cid of ev.causedBy || []) {
const c = causalById?.get(cid);
if (c) lines.push(formatCausalEventLine(c, causalById));
}
if (chunk) {
lines.push(` ${formatChunkFullLine(chunk)}`);
}
return lines.join("\n");
}
const candidates = [...recalledEvents].sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
const selectedDirect = [];
const selectedSimilar = [];
for (let candidateRank = 0; candidateRank < candidates.length; candidateRank++) {
const e = candidates[candidateRank];
if (total.used >= total.max) break;
const isDirect = e._recallType === "DIRECT";
const bestChunk = pickBestChunkForEvent(e.event);
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);
hasEvidence = false;
chosenChunk = null;
if (total.used + cost > total.max) {
continue;
}
}
if (isDirect) {
selectedDirect.push({ event: e.event, text, tokens: cost, chunk: chosenChunk, hasEvidence, candidateRank });
} else {
selectedSimilar.push({ event: e.event, text, tokens: cost, chunk: chosenChunk, hasEvidence, candidateRank });
}
injectionStats.events.selected++;
total.used += cost;
if (hasEvidence && bestChunk) {
const chunkLine = formatChunkFullLine(bestChunk);
const ct = estimateTokens(chunkLine);
injectionStats.evidence.attached++;
injectionStats.evidence.tokens += ct;
usedChunkIds.add(bestChunk.chunkId);
injectionStats.events.tokens += Math.max(0, cost - ct);
} else {
injectionStats.events.tokens += cost;
}
details.eventList.push({
title: e.event?.title || e.event?.id,
isDirect,
hasEvidence,
tokens: cost,
similarity: e.similarity || 0,
hasL0Evidence: bestChunk?.isL0 || false,
});
}
// 重排
selectedDirect.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
selectedSimilar.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
const selectedDirectTexts = selectedDirect.map((it, i) => {
const numbered = renumberEventText(it.text, i + 1);
return it.candidateRank < TOP_N_STAR ? `${numbered}` : numbered;
});
const selectedSimilarTexts = selectedSimilar.map((it, i) => {
const numbered = renumberEventText(it.text, i + 1);
return it.candidateRank < TOP_N_STAR ? `${numbered}` : numbered;
});
details.directCount = selectedDirect.length;
details.similarCount = selectedSimilar.length;
assembled.events.direct = selectedDirectTexts;
assembled.events.similar = selectedSimilarTexts;
// ═══════════════════════════════════════════════════════════════════════
// [优先级 4] 远期片段(已总结范围的 orphan chunks
// ═══════════════════════════════════════════════════════════════════════
const lastSummarized = store.lastSummarizedMesId ?? -1;
const lastChunkFloor = meta?.lastChunkFloor ?? -1;
const keepVisible = store.keepVisibleCount ?? 3;
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);
}
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: Math.min(ORPHAN_MAX, total.max - total.used) };
let l0Count = 0;
let contextPairsCount = 0;
for (const c of orphans) {
if (c.isL0) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(assembled.orphans.lines, line, l1Budget)) break;
injectionStats.orphans.injected++;
l0Count++;
continue;
}
const pairFloor = getContextFloor(c);
const pairCandidates = contextChunksByFloor.get(pairFloor) || [];
const contextChunk = pickContextChunk(pairCandidates, 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;
}
// ═══════════════════════════════════════════════════════════════════════
// [独立预算] 待整理(未总结范围)
// ═══════════════════════════════════════════════════════════════════════
const recentStart = lastSummarized + 1;
const recentEnd = lastChunkFloor - keepVisible;
if (chunks.length && recentEnd >= recentStart) {
const recentOrphanCandidates = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.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);
}
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) {
if (c.isL0) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(assembled.recentOrphans.lines, line, recentBudget)) break;
recentOrphanStats.injected++;
continue;
}
const pairFloor = getContextFloor(c);
const pairCandidates = recentContextChunksByFloor.get(pairFloor) || [];
const contextChunk = pickContextChunk(pairCandidates, 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 T_L4_Start = performance.now();
const sections = [];
if (assembled.facts.lines.length) {
sections.push(`[定了的事] 已确立的事实\n${assembled.facts.lines.join("\n")}`);
}
if (assembled.events.direct.length) {
sections.push(`[印象深的事] 记得很清楚\n\n${assembled.events.direct.join("\n\n")}`);
}
if (assembled.events.similar.length) {
sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.events.similar.join("\n\n")}`);
}
if (assembled.orphans.lines.length) {
sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.orphans.lines.join("\n")}`);
}
if (assembled.recentOrphans.lines.length) {
sections.push(`[近期] 清晰但还没整理\n${assembled.recentOrphans.lines.join("\n")}`);
}
if (assembled.arcs.lines.length) {
sections.push(`[这些人] 他们的弧光\n${assembled.arcs.lines.join("\n")}`);
}
if (!sections.length) {
if (metrics) {
metrics.timing.l3Assembly = Math.round(performance.now() - T_Start - (metrics.timing.l1Constraints || 0));
metrics.timing.l4Formatting = 0;
}
return { promptText: "", injectionLogText: "", injectionStats, metrics };
}
const promptText =
`${buildSystemPreamble()}\n` +
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
`${buildPostscript()}`;
// METRICS: 更新 L4 和 Budget 指标
if (metrics) {
// L4 指标
metrics.l4.sectionsIncluded = [];
if (assembled.facts.lines.length) metrics.l4.sectionsIncluded.push('constraints');
if (assembled.events.direct.length) metrics.l4.sectionsIncluded.push('direct_events');
if (assembled.events.similar.length) metrics.l4.sectionsIncluded.push('similar_events');
if (assembled.orphans.lines.length) metrics.l4.sectionsIncluded.push('orphans');
if (assembled.recentOrphans.lines.length) metrics.l4.sectionsIncluded.push('recent_orphans');
if (assembled.arcs.lines.length) metrics.l4.sectionsIncluded.push('arcs');
metrics.l4.formattingTime = Math.round(performance.now() - T_L4_Start);
metrics.timing.l4Formatting = metrics.l4.formattingTime;
// Budget 指标
metrics.budget.total = total.used + (assembled.recentOrphans.tokens || 0);
metrics.budget.limit = TOTAL_BUDGET_MAX;
metrics.budget.utilization = Math.round(metrics.budget.total / TOTAL_BUDGET_MAX * 100);
metrics.budget.breakdown = {
constraints: assembled.facts.tokens,
events: injectionStats.events.tokens + injectionStats.evidence.tokens,
chunks: injectionStats.orphans.tokens,
recentOrphans: recentOrphanStats.tokens || 0,
arcs: assembled.arcs.tokens,
};
// L3 额外指标
metrics.l3.tokens = injectionStats.orphans.tokens + (recentOrphanStats.tokens || 0);
metrics.l3.contextPairsAdded = injectionStats.orphans.contextPairs + recentOrphanStats.contextPairs;
metrics.l3.assemblyTime = Math.round(performance.now() - T_Start - (metrics.timing.l1Constraints || 0) - metrics.l4.formattingTime);
metrics.timing.l3Assembly = metrics.l3.assemblyTime;
// 质量指标
const totalFacts = allFacts.length;
metrics.quality.constraintCoverage = totalFacts > 0
? Math.round(assembled.facts.lines.length / totalFacts * 100)
: 100;
metrics.quality.eventPrecisionProxy = metrics.l2?.similarityDistribution?.mean || 0;
const totalChunks = metrics.l3.chunksSelected || 0;
const chunksWithEvents = injectionStats.evidence.attached;
metrics.quality.evidenceDensity = totalChunks > 0
? Math.round(chunksWithEvents / totalChunks * 100)
: 0;
// 检测问题
metrics.quality.potentialIssues = detectIssues(metrics);
}
return { promptText, injectionLogText: "", injectionStats, metrics };
}
// ─────────────────────────────────────────────────────────────────────────────
// 因果证据补充
// ─────────────────────────────────────────────────────────────────────────────
/**
* 为因果事件附加证据 chunk
* @param {Array} causalEvents - 因果事件列表
* @param {Map} eventVectorMap - 事件向量映射
* @param {Map} chunkVectorMap - chunk 向量映射
* @param {Map} chunksMap - chunk 映射
*/
async function attachEvidenceToCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap) {
for (const c of causalEvents) {
c._evidenceChunk = null;
const ev = c.event;
if (!ev?.id) continue;
const evVec = eventVectorMap.get(ev.id);
if (!evVec?.length) continue;
const range = parseFloorRange(ev.summary);
if (!range) continue;
const candidateChunks = [];
for (const [chunkId, chunk] of chunksMap) {
if (chunk.floor >= range.start && chunk.floor <= range.end) {
const vec = chunkVectorMap.get(chunkId);
if (vec?.length) candidateChunks.push({ chunk, vec });
}
}
if (!candidateChunks.length) continue;
let best = null;
let bestSim = -1;
for (const { chunk, vec } of candidateChunks) {
const sim = cosineSimilarity(evVec, vec);
if (sim > bestSim) {
bestSim = sim;
best = chunk;
}
}
if (best && bestSim > 0.3) {
c._evidenceChunk = {
floor: best.floor,
speaker: best.speaker,
text: best.text,
similarity: bestSim,
};
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 向量模式:召回 + 注入
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建向量模式的注入文本
* @param {boolean} excludeLastAi - 是否排除最后一条 AI 消息
* @param {object} hooks - 钩子 {postToFrame, echo, pendingUserMessage}
* @returns {Promise<object>} {text, logText}
*/
export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
const { postToFrame = null, echo = null, pendingUserMessage = null } = hooks;
if (!getSettings().storySummary?.enabled) {
return { text: "", logText: "" };
}
const { chat } = getContext();
const store = getSummaryStore();
if (!store?.json) {
return { text: "", logText: "" };
}
const allEvents = store.json.events || [];
const lastIdx = store.lastSummarizedMesId ?? 0;
const length = chat?.length || 0;
if (lastIdx >= length) {
return { text: "", logText: "" };
}
const vectorCfg = getVectorConfig();
if (!vectorCfg?.enabled) {
return { text: "", logText: "" };
}
const { chatId } = getContext();
const meta = chatId ? await getMeta(chatId) : null;
let recallResult = null;
let causalById = new Map();
try {
const queryText = buildQueryText(chat, 2, excludeLastAi);
recallResult = await recallMemory(queryText, allEvents, vectorCfg, {
excludeLastAi,
pendingUserMessage,
});
recallResult = {
...recallResult,
events: recallResult?.events || [],
chunks: recallResult?.chunks || [],
causalEvents: recallResult?.causalEvents || [],
focusEntities: recallResult?.focusEntities || [],
logText: recallResult?.logText || "",
metrics: recallResult?.metrics || null,
};
// 给因果事件挂证据
const causalEvents = recallResult.causalEvents || [];
if (causalEvents.length > 0) {
if (chatId) {
try {
const floors = new Set();
for (const c of causalEvents) {
const r = parseFloorRange(c.event?.summary);
if (!r) continue;
for (let f = r.start; f <= r.end; f++) floors.add(f);
}
const [chunksList, chunkVecs, eventVecs] = await Promise.all([
getChunksByFloors(chatId, Array.from(floors)),
getAllChunkVectors(chatId),
getAllEventVectors(chatId),
]);
const chunksMap = new Map(chunksList.map(c => [c.chunkId, c]));
const chunkVectorMap = new Map(chunkVecs.map(v => [v.chunkId, v.vector]));
const eventVectorMap = new Map(eventVecs.map(v => [v.eventId, v.vector]));
await attachEvidenceToCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap);
} catch (e) {
xbLog.warn(MODULE_ID, "Causal evidence attachment failed", e);
}
}
}
causalById = new Map(
recallResult.causalEvents
.map(c => [c?.event?.id, c])
.filter(x => x[0])
);
} catch (e) {
xbLog.error(MODULE_ID, "向量召回失败", e);
if (echo && canNotifyRecallFail()) {
const msg = String(e?.message || "未知错误").replace(/\s+/g, " ").slice(0, 200);
await echo(`/echo severity=warning 向量召回失败:${msg}`);
}
if (postToFrame) {
postToFrame({
type: "RECALL_LOG",
text: `\n[Vector Recall Failed]\n${String(e?.stack || e?.message || e)}\n`,
});
}
return { text: "", logText: `\n[Vector Recall Failed]\n${String(e?.stack || e?.message || e)}\n` };
}
const hasUseful =
(recallResult?.events?.length || 0) > 0 ||
(recallResult?.chunks?.length || 0) > 0 ||
(recallResult?.causalEvents?.length || 0) > 0;
if (!hasUseful) {
if (echo && canNotifyRecallFail()) {
await echo(
"/echo severity=warning 向量召回失败:没有可用召回结果(请先在面板中生成向量,或检查指纹不匹配)"
);
}
if (postToFrame) {
postToFrame({
type: "RECALL_LOG",
text: "\n[Vector Recall Empty]\nNo recall candidates / vectors not ready.\n",
});
}
return { text: "", logText: "\n[Vector Recall Empty]\nNo recall candidates / vectors not ready.\n" };
}
// 拼装向量 prompt传入 focusEntities 和 metrics
const { promptText, metrics: promptMetrics } = await buildVectorPrompt(
store,
recallResult,
causalById,
recallResult?.focusEntities || [],
meta,
recallResult?.metrics || null
);
// wrapper
const cfg = getSummaryPanelConfig();
let finalText = String(promptText || "");
if (cfg.trigger?.wrapperHead) finalText = cfg.trigger.wrapperHead + "\n" + finalText;
if (cfg.trigger?.wrapperTail) finalText = finalText + "\n" + cfg.trigger.wrapperTail;
// METRICS: 生成完整的指标日志
const metricsLogText = promptMetrics ? formatMetricsLog(promptMetrics) : '';
// 发给 iframe
if (postToFrame) {
postToFrame({ type: "RECALL_LOG", text: metricsLogText });
}
return { text: finalText, logText: metricsLogText };
}