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

1248 lines
50 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 (v4 - 统一命名)
//
// 命名规范:
// - 存储层用 L0/L1/L2/L3StateAtom/Chunk/Event/Fact
// - 装配层用语义名称constraint/event/evidence/arc
//
// 职责:
// - 仅负责"构建注入文本",不负责写入 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;
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 DISTANT_EVIDENCE_MAX = 2500;
const RECENT_EVIDENCE_MAX = 5000;
const TOTAL_BUDGET_MAX = 15000;
const CONSTRAINT_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 {string[]} 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 {number[]} a - 向量A
* @param {number[]} 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 {{start: number, end: number}|null} 楼层范围
*/
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();
}
// ─────────────────────────────────────────────────────────────────────────────
// 上下文配对工具函数
// ─────────────────────────────────────────────────────────────────────────────
/**
* 获取 chunk 的上下文楼层
* @param {object} chunk - chunk 对象
* @returns {number} 上下文楼层(-1 表示无)
*/
function getContextFloor(chunk) {
if (chunk.isAnchorVirtual) return -1;
return chunk.isUser ? chunk.floor + 1 : chunk.floor - 1;
}
/**
* 选择上下文 chunk
* @param {object[]} 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 - 是否在上方
* @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");
}
// ─────────────────────────────────────────────────────────────────────────────
// [Constraints] L3 Facts 过滤与格式化
// ─────────────────────────────────────────────────────────────────────────────
/**
* 获取已知角色集合
* @param {object} store - 存储对象
* @returns {Set<string>} 角色名称集合(标准化后)
*/
function getKnownCharacters(store) {
const names = new Set();
const arcs = store?.json?.arcs || [];
for (const a of arcs) {
if (a.name) names.add(normalize(a.name));
}
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;
}
/**
* 解析关系谓词中的目标
* @param {string} predicate - 谓词
* @returns {string|null} 目标名称
*/
function parseRelationTarget(predicate) {
const match = String(predicate || '').match(/^对(.+)的/);
return match ? match[1] : null;
}
/**
* 按相关性过滤 facts
* @param {object[]} facts - 所有 facts
* @param {string[]} focusEntities - 焦点实体
* @param {Set<string>} knownCharacters - 已知角色
* @returns {object[]} 过滤后的 facts
*/
function filterConstraintsByRelevance(facts, focusEntities, knownCharacters) {
if (!facts?.length) return [];
const focusSet = new Set((focusEntities || []).map(normalize));
return facts.filter(f => {
// isState 的 facts 始终保留
if (f._isState === true) return true;
// 关系类 facts检查 from/to 是否在焦点中
if (isRelationFact(f)) {
const from = normalize(f.s);
const target = parseRelationTarget(f.p);
const to = target ? normalize(target) : '';
if (focusSet.has(from) || focusSet.has(to)) return true;
return false;
}
// 其他 facts检查主体是否在焦点中
const subjectNorm = normalize(f.s);
if (knownCharacters.has(subjectNorm)) {
return focusSet.has(subjectNorm);
}
return true;
});
}
/**
* 格式化 constraints 用于注入
* @param {object[]} facts - 所有 facts
* @param {string[]} focusEntities - 焦点实体
* @param {Set<string>} knownCharacters - 已知角色
* @returns {string[]} 格式化后的行
*/
function formatConstraintsForInjection(facts, focusEntities, knownCharacters) {
const filtered = filterConstraintsByRelevance(facts, focusEntities, knownCharacters);
if (!filtered.length) return [];
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} arc - 弧光对象
* @returns {string} 格式化后的行
*/
function formatArcLine(arc) {
const moments = (arc.moments || [])
.map(m => (typeof m === "string" ? m : m.text))
.filter(Boolean);
if (moments.length) {
return `- ${arc.name}${moments.join(" → ")}`;
}
return `- ${arc.name}${arc.trajectory}`;
}
/**
* 格式化 evidence chunk 完整行
* @param {object} chunk - chunk 对象
* @returns {string} 格式化后的行
*/
function formatEvidenceFullLine(chunk) {
const { name1, name2 } = getContext();
if (chunk.isAnchorVirtual) {
return ` #${chunk.floor + 1} [📌] ${String(chunk.text || "").trim()}`;
}
const speaker = chunk.isUser ? (name1 || "用户") : (chunk.speaker || name2 || "角色");
return ` #${chunk.floor + 1} [${speaker}] ${String(chunk.text || "").trim()}`;
}
/**
* 格式化因果事件行
* @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} event - 事件对象
* @returns {number} 排序键
*/
function getEventSortKey(event) {
const r = parseFloorRange(event?.summary);
if (r) return r.start;
const m = String(event?.id || "").match(/evt-(\d+)/);
return m ? parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER;
}
// ─────────────────────────────────────────────────────────────────────────────
// 按楼层分组装配 evidence修复上下文重复
// ─────────────────────────────────────────────────────────────────────────────
/**
* 按楼层装配 evidence
* @param {object[]} evidenceCandidates - 候选 evidence
* @param {Map} contextChunksByFloor - 上下文 chunks 索引
* @param {object} budget - 预算状态
* @returns {{lines: string[], anchorCount: number, contextPairsCount: number}}
*/
function assembleEvidenceByFloor(evidenceCandidates, contextChunksByFloor, budget) {
if (!evidenceCandidates?.length) {
return { lines: [], anchorCount: 0, contextPairsCount: 0 };
}
// 1. 按楼层分组
const byFloor = new Map();
for (const c of evidenceCandidates) {
const arr = byFloor.get(c.floor) || [];
arr.push(c);
byFloor.set(c.floor, arr);
}
// 2. 楼层内按 chunkIdx 排序
for (const [, chunks] of byFloor) {
chunks.sort((a, b) => (a.chunkIdx ?? 0) - (b.chunkIdx ?? 0));
}
// 3. 按楼层顺序装配
const floorsSorted = Array.from(byFloor.keys()).sort((a, b) => a - b);
const lines = [];
let anchorCount = 0;
let contextPairsCount = 0;
for (const floor of floorsSorted) {
const chunks = byFloor.get(floor);
if (!chunks?.length) continue;
// 分离锚点虚拟 chunks 和真实 chunks
const anchorChunks = chunks.filter(c => c.isAnchorVirtual);
const realChunks = chunks.filter(c => !c.isAnchorVirtual);
// 锚点直接输出(不需要上下文)
for (const c of anchorChunks) {
const line = formatEvidenceFullLine(c);
if (!pushWithBudget(lines, line, budget)) {
return { lines, anchorCount, contextPairsCount };
}
anchorCount++;
}
// 真实 chunks 按楼层统一处理
if (realChunks.length > 0) {
const firstChunk = realChunks[0];
const pairFloor = getContextFloor(firstChunk);
const pairCandidates = contextChunksByFloor.get(pairFloor) || [];
const contextChunk = pickContextChunk(pairCandidates, firstChunk);
// 上下文在前
if (contextChunk && contextChunk.floor < floor) {
const contextLine = formatContextChunkLine(contextChunk, true);
if (!pushWithBudget(lines, contextLine, budget)) {
return { lines, anchorCount, contextPairsCount };
}
contextPairsCount++;
}
// 输出该楼层所有真实 chunks
for (const c of realChunks) {
const line = formatEvidenceFullLine(c);
if (!pushWithBudget(lines, line, budget)) {
return { lines, anchorCount, contextPairsCount };
}
}
// 上下文在后
if (contextChunk && contextChunk.floor > floor) {
const contextLine = formatContextChunkLine(contextChunk, false);
if (!pushWithBudget(lines, contextLine, budget)) {
return { lines, anchorCount, contextPairsCount };
}
contextPairsCount++;
}
}
}
return { lines, anchorCount, contextPairsCount };
}
// ─────────────────────────────────────────────────────────────────────────────
// 非向量模式
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建非向量模式注入文本
* @param {object} store - 存储对象
* @returns {string} 注入文本
*/
function buildNonVectorPrompt(store) {
const data = store.json || {};
const sections = [];
// [Constraints] L3 Facts
const allFacts = getFacts();
const constraintLines = 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 (constraintLines.length) {
sections.push(`[定了的事] 已确立的事实\n${constraintLines.join("\n")}`);
}
// [Events] L2 Events
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")}`);
}
// [Arcs]
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// 向量模式:预算装配
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建向量模式注入文本
* @param {object} store - 存储对象
* @param {object} recallResult - 召回结果
* @param {Map} causalById - 因果事件索引
* @param {string[]} focusEntities - 焦点实体
* @param {object} meta - 元数据
* @param {object} metrics - 指标对象
* @returns {Promise<{promptText: string, injectionLogText: string, injectionStats: object, metrics: object}>}
*/
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 = {
constraints: { lines: [], tokens: 0 },
directEvents: { lines: [], tokens: 0 },
relatedEvents: { lines: [], tokens: 0 },
distantEvidence: { lines: [], tokens: 0 },
recentEvidence: { lines: [], tokens: 0 },
arcs: { lines: [], tokens: 0 },
};
// 注入统计
const injectionStats = {
budget: { max: TOTAL_BUDGET_MAX, used: 0 },
constraint: { count: 0, tokens: 0, filtered: 0 },
arc: { count: 0, tokens: 0 },
event: { selected: 0, tokens: 0 },
evidence: { attached: 0, tokens: 0 },
distantEvidence: { injected: 0, tokens: 0, anchorCount: 0, contextPairs: 0 },
};
const recentEvidenceStats = {
injected: 0,
tokens: 0,
floorRange: "N/A",
contextPairs: 0,
};
const eventDetails = {
list: [],
directCount: 0,
relatedCount: 0,
};
// ═══════════════════════════════════════════════════════════════════════
// [Constraints] L3 Facts → 世界约束
// ═══════════════════════════════════════════════════════════════════════
const T_Constraint_Start = performance.now();
const allFacts = getFacts();
const knownCharacters = getKnownCharacters(store);
const constraintLines = formatConstraintsForInjection(allFacts, focusEntities, knownCharacters);
if (metrics) {
metrics.constraint.total = allFacts.length;
metrics.constraint.filtered = allFacts.length - constraintLines.length;
}
if (constraintLines.length) {
const constraintBudget = { used: 0, max: Math.min(CONSTRAINT_MAX, total.max - total.used) };
for (const line of constraintLines) {
if (!pushWithBudget(assembled.constraints.lines, line, constraintBudget)) break;
}
assembled.constraints.tokens = constraintBudget.used;
total.used += constraintBudget.used;
injectionStats.constraint.count = assembled.constraints.lines.length;
injectionStats.constraint.tokens = constraintBudget.used;
injectionStats.constraint.filtered = allFacts.length - constraintLines.length;
if (metrics) {
metrics.constraint.injected = assembled.constraints.lines.length;
metrics.constraint.tokens = constraintBudget.used;
metrics.constraint.samples = assembled.constraints.lines.slice(0, 3).map(line =>
line.length > 60 ? line.slice(0, 60) + '...' : line
);
metrics.timing.constraintFilter = Math.round(performance.now() - T_Constraint_Start);
}
} else if (metrics) {
metrics.timing.constraintFilter = Math.round(performance.now() - T_Constraint_Start);
}
// ═══════════════════════════════════════════════════════════════════════
// [Arcs] 人物弧光
// ═══════════════════════════════════════════════════════════════════════
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 filteredArcs = (data.arcs || []).filter(a => {
const n = String(a?.name || "").trim();
return n && relevant.has(n);
});
if (filteredArcs.length) {
const arcBudget = { used: 0, max: Math.min(ARCS_MAX, total.max - total.used) };
for (const a of filteredArcs) {
const line = formatArcLine(a);
if (!pushWithBudget(assembled.arcs.lines, line, arcBudget)) break;
}
assembled.arcs.tokens = arcBudget.used;
total.used += arcBudget.used;
injectionStats.arc.count = assembled.arcs.lines.length;
injectionStats.arc.tokens = arcBudget.used;
}
}
// ═══════════════════════════════════════════════════════════════════════
// [Events] L2 Events → 直接命中 + 相似命中 + 因果链
// ═══════════════════════════════════════════════════════════════════════
const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary);
const evidenceChunks = recallResult?.evidenceChunks || [];
const usedChunkIds = new Set();
/**
* 为事件选择最佳证据 chunk
* @param {object} eventObj - 事件对象
* @returns {object|null} 最佳 chunk
*/
function pickBestEvidenceForEvent(eventObj) {
const range = parseFloorRange(eventObj?.summary);
if (!range) return null;
let best = null;
for (const c of evidenceChunks) {
if (usedChunkIds.has(c.chunkId)) continue;
if (c.floor < range.start || c.floor > range.end) continue;
if (!best) {
best = c;
} else if (c.isAnchorVirtual && !best.isAnchorVirtual) {
best = c;
} else if (c.isAnchorVirtual === best.isAnchorVirtual && (c.chunkIdx ?? 0) < (best.chunkIdx ?? 0)) {
best = c;
}
}
return best;
}
/**
* 格式化事件带证据
* @param {object} eventItem - 事件项
* @param {number} idx - 编号
* @param {object} chunk - 证据 chunk
* @returns {string} 格式化后的文本
*/
function formatEventWithEvidence(eventItem, idx, chunk) {
const ev = eventItem.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(` ${formatEvidenceFullLine(chunk)}`);
}
return lines.join("\n");
}
const candidates = [...eventHits].sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
const selectedDirect = [];
const selectedRelated = [];
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 = pickBestEvidenceForEvent(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 {
selectedRelated.push({ event: e.event, text, tokens: cost, chunk: chosenChunk, hasEvidence, candidateRank });
}
injectionStats.event.selected++;
total.used += cost;
if (hasEvidence && bestChunk) {
const chunkLine = formatEvidenceFullLine(bestChunk);
const ct = estimateTokens(chunkLine);
injectionStats.evidence.attached++;
injectionStats.evidence.tokens += ct;
usedChunkIds.add(bestChunk.chunkId);
injectionStats.event.tokens += Math.max(0, cost - ct);
} else {
injectionStats.event.tokens += cost;
}
eventDetails.list.push({
title: e.event?.title || e.event?.id,
isDirect,
hasEvidence,
tokens: cost,
similarity: e.similarity || 0,
hasAnchorEvidence: bestChunk?.isAnchorVirtual || false,
});
}
// 排序
selectedDirect.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
selectedRelated.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
// 重新编号 + 星标
const directEventTexts = selectedDirect.map((it, i) => {
const numbered = renumberEventText(it.text, i + 1);
return it.candidateRank < TOP_N_STAR ? `${numbered}` : numbered;
});
const relatedEventTexts = selectedRelated.map((it, i) => {
const numbered = renumberEventText(it.text, i + 1);
return it.candidateRank < TOP_N_STAR ? `${numbered}` : numbered;
});
eventDetails.directCount = selectedDirect.length;
eventDetails.relatedCount = selectedRelated.length;
assembled.directEvents.lines = directEventTexts;
assembled.relatedEvents.lines = relatedEventTexts;
// ═══════════════════════════════════════════════════════════════════════
// [Evidence - Distant] L1 Chunks → 远期证据(已总结范围)
// ═══════════════════════════════════════════════════════════════════════
const lastSummarized = store.lastSummarizedMesId ?? -1;
const lastChunkFloor = meta?.lastChunkFloor ?? -1;
const keepVisible = store.keepVisibleCount ?? 3;
const distantContextFloors = new Set();
const distantCandidates = evidenceChunks
.filter(c => !usedChunkIds.has(c.chunkId))
.filter(c => c.floor <= lastSummarized);
for (const c of distantCandidates) {
if (c.isAnchorVirtual) continue;
const pairFloor = getContextFloor(c);
if (pairFloor >= 0) distantContextFloors.add(pairFloor);
}
let contextChunksByFloor = new Map();
if (chatId && distantContextFloors.size > 0) {
try {
const contextChunks = await getChunksByFloors(chatId, Array.from(distantContextFloors));
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 (distantCandidates.length && total.used < total.max) {
const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) };
const result = assembleEvidenceByFloor(
distantCandidates.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))),
contextChunksByFloor,
distantBudget
);
assembled.distantEvidence.lines = result.lines;
assembled.distantEvidence.tokens = distantBudget.used;
total.used += distantBudget.used;
injectionStats.distantEvidence.injected = result.lines.length;
injectionStats.distantEvidence.tokens = distantBudget.used;
injectionStats.distantEvidence.anchorCount = result.anchorCount;
injectionStats.distantEvidence.contextPairs = result.contextPairsCount;
}
// ═══════════════════════════════════════════════════════════════════════
// [Evidence - Recent] L1 Chunks → 近期证据(未总结范围,独立预算)
// ═══════════════════════════════════════════════════════════════════════
const recentStart = lastSummarized + 1;
const recentEnd = lastChunkFloor - keepVisible;
if (evidenceChunks.length && recentEnd >= recentStart) {
const recentCandidates = evidenceChunks
.filter(c => !usedChunkIds.has(c.chunkId))
.filter(c => c.floor >= recentStart && c.floor <= recentEnd);
const recentContextFloors = new Set();
for (const c of recentCandidates) {
if (c.isAnchorVirtual) continue;
const pairFloor = getContextFloor(c);
if (pairFloor >= 0) recentContextFloors.add(pairFloor);
}
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);
}
}
}
if (recentCandidates.length) {
const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX };
const result = assembleEvidenceByFloor(
recentCandidates.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))),
contextChunksByFloor,
recentBudget
);
assembled.recentEvidence.lines = result.lines;
assembled.recentEvidence.tokens = recentBudget.used;
recentEvidenceStats.injected = result.lines.length;
recentEvidenceStats.tokens = recentBudget.used;
recentEvidenceStats.floorRange = `${recentStart + 1}~${recentEnd + 1}`;
recentEvidenceStats.contextPairs = result.contextPairsCount;
}
}
// ═══════════════════════════════════════════════════════════════════════
// 按注入顺序拼接 sections
// ═══════════════════════════════════════════════════════════════════════
const T_Format_Start = performance.now();
const sections = [];
if (assembled.constraints.lines.length) {
sections.push(`[定了的事] 已确立的事实\n${assembled.constraints.lines.join("\n")}`);
}
if (assembled.directEvents.lines.length) {
sections.push(`[印象深的事] 记得很清楚\n\n${assembled.directEvents.lines.join("\n\n")}`);
}
if (assembled.relatedEvents.lines.length) {
sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.relatedEvents.lines.join("\n\n")}`);
}
if (assembled.distantEvidence.lines.length) {
sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.distantEvidence.lines.join("\n")}`);
}
if (assembled.recentEvidence.lines.length) {
sections.push(`[近期] 清晰但还没整理\n${assembled.recentEvidence.lines.join("\n")}`);
}
if (assembled.arcs.lines.length) {
sections.push(`[这些人] 他们的弧光\n${assembled.arcs.lines.join("\n")}`);
}
if (!sections.length) {
if (metrics) {
metrics.timing.evidenceAssembly = Math.round(performance.now() - T_Start - (metrics.timing.constraintFilter || 0));
metrics.timing.formatting = 0;
}
return { promptText: "", injectionLogText: "", injectionStats, metrics };
}
const promptText =
`${buildSystemPreamble()}\n` +
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
`${buildPostscript()}`;
if (metrics) {
metrics.formatting.sectionsIncluded = [];
if (assembled.constraints.lines.length) metrics.formatting.sectionsIncluded.push('constraints');
if (assembled.directEvents.lines.length) metrics.formatting.sectionsIncluded.push('direct_events');
if (assembled.relatedEvents.lines.length) metrics.formatting.sectionsIncluded.push('related_events');
if (assembled.distantEvidence.lines.length) metrics.formatting.sectionsIncluded.push('distant_evidence');
if (assembled.recentEvidence.lines.length) metrics.formatting.sectionsIncluded.push('recent_evidence');
if (assembled.arcs.lines.length) metrics.formatting.sectionsIncluded.push('arcs');
metrics.formatting.time = Math.round(performance.now() - T_Format_Start);
metrics.timing.formatting = metrics.formatting.time;
metrics.budget.total = total.used + (assembled.recentEvidence.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.constraints.tokens,
events: injectionStats.event.tokens + injectionStats.evidence.tokens,
distantEvidence: injectionStats.distantEvidence.tokens,
recentEvidence: recentEvidenceStats.tokens || 0,
arcs: assembled.arcs.tokens,
};
metrics.evidence.tokens = injectionStats.distantEvidence.tokens + (recentEvidenceStats.tokens || 0);
metrics.evidence.contextPairsAdded = injectionStats.distantEvidence.contextPairs + recentEvidenceStats.contextPairs;
metrics.evidence.assemblyTime = Math.round(performance.now() - T_Start - (metrics.timing.constraintFilter || 0) - metrics.formatting.time);
metrics.timing.evidenceAssembly = metrics.evidence.assemblyTime;
const totalFacts = allFacts.length;
metrics.quality.constraintCoverage = totalFacts > 0
? Math.round(assembled.constraints.lines.length / totalFacts * 100)
: 100;
metrics.quality.eventPrecisionProxy = metrics.event?.similarityDistribution?.mean || 0;
const totalSelected = metrics.evidence.selected || 0;
const attached = injectionStats.evidence.attached;
metrics.quality.evidenceDensity = totalSelected > 0
? Math.round(attached / totalSelected * 100)
: 0;
metrics.quality.potentialIssues = detectIssues(metrics);
}
return { promptText, injectionLogText: "", injectionStats, metrics };
}
// ─────────────────────────────────────────────────────────────────────────────
// 因果证据补充
// ─────────────────────────────────────────────────────────────────────────────
/**
* 为因果事件附加证据
* @param {object[]} causalChain - 因果链
* @param {Map} eventVectorMap - 事件向量索引
* @param {Map} chunkVectorMap - chunk 向量索引
* @param {Map} chunksMap - chunks 索引
*/
async function attachEvidenceToCausalEvents(causalChain, eventVectorMap, chunkVectorMap, chunksMap) {
for (const c of causalChain) {
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 - 钩子函数
* @returns {Promise<{text: string, logText: string}>}
*/
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 || [],
evidenceChunks: recallResult?.evidenceChunks || [],
causalChain: recallResult?.causalChain || [],
focusEntities: recallResult?.focusEntities || [],
logText: recallResult?.logText || "",
metrics: recallResult?.metrics || null,
};
const causalChain = recallResult.causalChain || [];
if (causalChain.length > 0) {
if (chatId) {
try {
const floors = new Set();
for (const c of causalChain) {
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(causalChain, eventVectorMap, chunkVectorMap, chunksMap);
} catch (e) {
xbLog.warn(MODULE_ID, "Causal evidence attachment failed", e);
}
}
}
causalById = new Map(
recallResult.causalChain
.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?.evidenceChunks?.length || 0) > 0 ||
(recallResult?.causalChain?.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" };
}
const { promptText, metrics: promptMetrics } = await buildVectorPrompt(
store,
recallResult,
causalById,
recallResult?.focusEntities || [],
meta,
recallResult?.metrics || null
);
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;
const metricsLogText = promptMetrics ? formatMetricsLog(promptMetrics) : '';
if (postToFrame) {
postToFrame({ type: "RECALL_LOG", text: metricsLogText });
}
return { text: finalText, logText: metricsLogText };
}