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

1071 lines
43 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 (v3 - DSL 版 + Orphan 分组修复)
// - 仅负责"构建注入文本",不负责写入 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 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;
// ─────────────────────────────────────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────────────────────────────────────
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);
}
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;
}
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;
}
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 };
}
function cleanSummary(summary) {
return String(summary || "")
.replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "")
.trim();
}
function normalize(s) {
return String(s || '')
.normalize('NFKC')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim()
.toLowerCase();
}
// ─────────────────────────────────────────────────────────────────────────────
// 上下文配对工具函数
// ─────────────────────────────────────────────────────────────────────────────
function getContextFloor(chunk) {
if (chunk.isL0) return -1;
return chunk.isUser ? chunk.floor + 1 : chunk.floor - 1;
}
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];
}
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}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// 系统前导与后缀
// ─────────────────────────────────────────────────────────────────────────────
function buildSystemPreamble() {
return [
"以上是还留在眼前的对话",
"以下是脑海里的记忆:",
"• [定了的事] 这些是不会变的",
"• 其余部分是过往经历的回忆碎片",
"",
"请内化这些记忆:",
].join("\n");
}
function buildPostscript() {
return [
"",
"这些记忆是真实的,请自然地记住它们。",
].join("\n");
}
// ─────────────────────────────────────────────────────────────────────────────
// L1 Facts 分层过滤
// ─────────────────────────────────────────────────────────────────────────────
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;
}
function parseRelationTarget(predicate) {
const match = String(predicate || '').match(/^对(.+)的/);
return match ? match[1] : null;
}
function filterFactsByRelevance(facts, focusEntities, knownCharacters) {
if (!facts?.length) return [];
const focusSet = new Set((focusEntities || []).map(normalize));
return facts.filter(f => {
if (f._isState === true) return true;
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;
}
const subjectNorm = normalize(f.s);
if (knownCharacters.has(subjectNorm)) {
return focusSet.has(subjectNorm);
}
return true;
});
}
function formatFactsForInjection(facts, focusEntities, knownCharacters) {
const filtered = filterFactsByRelevance(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}`;
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 格式化函数
// ─────────────────────────────────────────────────────────────────────────────
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}`;
}
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()}`;
}
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");
}
function renumberEventText(text, newIndex) {
const s = String(text || "");
return s.replace(/^(\s*)\d+(\.\s*(?:【)?)/, `$1${newIndex}$2`);
}
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// 按楼层分组装配 orphan chunks修复上下文重复
// ─────────────────────────────────────────────────────────────────────────────
function assembleOrphansByFloor(orphanCandidates, contextChunksByFloor, budget) {
if (!orphanCandidates?.length) {
return { lines: [], l0Count: 0, contextPairsCount: 0 };
}
// 1. 按楼层分组
const byFloor = new Map();
for (const c of orphanCandidates) {
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 l0Count = 0;
let contextPairsCount = 0;
for (const floor of floorsSorted) {
const chunks = byFloor.get(floor);
if (!chunks?.length) continue;
// 分离 L0 和 L1
const l0Chunks = chunks.filter(c => c.isL0);
const l1Chunks = chunks.filter(c => !c.isL0);
// L0 直接输出(不需要上下文)
for (const c of l0Chunks) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(lines, line, budget)) {
return { lines, l0Count, contextPairsCount };
}
l0Count++;
}
// L1 按楼层统一处理
if (l1Chunks.length > 0) {
const firstChunk = l1Chunks[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, l0Count, contextPairsCount };
}
contextPairsCount++;
}
// 输出该楼层所有 L1 chunks
for (const c of l1Chunks) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(lines, line, budget)) {
return { lines, l0Count, contextPairsCount };
}
}
// 上下文在后
if (contextChunk && contextChunk.floor > floor) {
const contextLine = formatContextChunkLine(contextChunk, false);
if (!pushWithBudget(lines, contextLine, budget)) {
return { lines, l0Count, contextPairsCount };
}
contextPairsCount++;
}
}
}
return { lines, l0Count, contextPairsCount };
}
// ─────────────────────────────────────────────────────────────────────────────
// 非向量模式
// ─────────────────────────────────────────────────────────────────────────────
function buildNonVectorPrompt(store) {
const data = store.json || {};
const sections = [];
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()}`
);
}
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// 向量模式:预算装配
// ─────────────────────────────────────────────────────────────────────────────
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);
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;
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();
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) {
best = c;
} else if (c.isL0 === best.isL0 && (c.chunkIdx ?? 0) < (best.chunkIdx ?? 0)) {
best = c;
}
}
return best;
}
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 l1Budget = { used: 0, max: Math.min(ORPHAN_MAX, total.max - total.used) };
const result = assembleOrphansByFloor(
orphanCandidates.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))),
contextChunksByFloor,
l1Budget
);
assembled.orphans.lines = result.lines;
assembled.orphans.tokens = l1Budget.used;
total.used += l1Budget.used;
injectionStats.orphans.injected = result.lines.length;
injectionStats.orphans.tokens = l1Budget.used;
injectionStats.orphans.l0Count = result.l0Count;
injectionStats.orphans.contextPairs = result.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);
}
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 (recentOrphanCandidates.length) {
const recentBudget = { used: 0, max: RECENT_ORPHAN_MAX };
const result = assembleOrphansByFloor(
recentOrphanCandidates.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))),
contextChunksByFloor,
recentBudget
);
assembled.recentOrphans.lines = result.lines;
assembled.recentOrphans.tokens = recentBudget.used;
recentOrphanStats.injected = result.lines.length;
recentOrphanStats.tokens = recentBudget.used;
recentOrphanStats.floorRange = `${recentStart + 1}~${recentEnd + 1}`;
recentOrphanStats.contextPairs = result.contextPairsCount;
}
}
// ═══════════════════════════════════════════════════════════════════════
// 按注入顺序拼接 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()}`;
if (metrics) {
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;
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,
};
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 };
}
// ─────────────────────────────────────────────────────────────────────────────
// 因果证据补充
// ─────────────────────────────────────────────────────────────────────────────
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,
};
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 向量模式:召回 + 注入
// ─────────────────────────────────────────────────────────────────────────────
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" };
}
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 };
}