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

1094 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 (v5 - Two-Stage: L0 Locate → L1 Evidence)
//
// 命名规范:
// - 存储层用 L0/L1/L2/L3StateAtom/Chunk/Event/Fact
// - 装配层用语义名称constraint/event/evidence/arc
//
// 架构变更v4 → v5
// - L0 和 L1 不再在同一个池子竞争
// - recall.js 返回 {l0Selected[], l1ByFloor: Map} 而非 evidenceChunks[]
// - 装配层按 L2→L0→L1 层级组织
// - 预算以"L0 + USER top-1 + AI top-1"为原子单元
// - 孤立 L1无对应 L0丢弃
// - 孤立 L0无对应 L1保留
//
// 职责:
// - 仅负责"构建注入文本",不负责写入 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 } from "../vector/retrieval/recall.js";
import { 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 {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();
}
/**
* 获取事件排序键
* @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;
}
/**
* 重新编号事件文本
* @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`);
}
// ─────────────────────────────────────────────────────────────────────────────
// 系统前导与后缀
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建系统前导文本
* @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 => {
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;
});
}
/**
* 格式化 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}`;
}
/**
* 格式化 L0 锚点行
* @param {object} l0 - L0 对象
* @returns {string} 格式化后的行
*/
function formatL0Line(l0) {
return ` #${l0.floor + 1} [📌] ${String(l0.text || l0.atom?.semantic || "").trim()}`;
}
/**
* 格式化 L1 chunk 行(挂在 L0 下方)
* @param {object} chunk - L1 chunk 对象
* @param {boolean} isContext - 是否为上下文USER 侧)
* @returns {string} 格式化后的行
*/
function formatL1Line(chunk, isContext) {
const { name1, name2 } = getContext();
const speaker = chunk.isUser ? (name1 || "用户") : (chunk.speaker || name2 || "角色");
const text = String(chunk.text || "").trim();
const symbol = isContext ? "┌" : "";
return ` ${symbol} #${chunk.floor + 1} [${speaker}] ${text}`;
}
/**
* 格式化因果事件行
* @param {object} causalItem - 因果事件项
* @returns {string} 格式化后的行
*/
function formatCausalEventLine(causalItem) {
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}`);
return lines.join("\n");
}
// ─────────────────────────────────────────────────────────────────────────────
// L0→L1 证据单元构建
// ─────────────────────────────────────────────────────────────────────────────
/**
* @typedef {object} EvidenceUnit
* @property {object} l0 - L0 锚点对象
* @property {object|null} userL1 - USER 侧 top-1 L1 chunk
* @property {object|null} aiL1 - AI 侧 top-1 L1 chunk
* @property {number} totalTokens - 整个单元的 token 估算
*/
/**
* 为一个 L0 构建证据单元
* @param {object} l0 - L0 对象
* @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射
* @returns {EvidenceUnit}
*/
function buildEvidenceUnit(l0, l1ByFloor) {
const pair = l1ByFloor.get(l0.floor);
const userL1 = pair?.userTop1 || null;
const aiL1 = pair?.aiTop1 || null;
// 预计算整个单元的 token 开销
let totalTokens = estimateTokens(formatL0Line(l0));
if (userL1) totalTokens += estimateTokens(formatL1Line(userL1, true));
if (aiL1) totalTokens += estimateTokens(formatL1Line(aiL1, false));
return { l0, userL1, aiL1, totalTokens };
}
/**
* 格式化一个证据单元为文本行
* @param {EvidenceUnit} unit - 证据单元
* @returns {string[]} 文本行数组
*/
function formatEvidenceUnit(unit) {
const lines = [];
lines.push(formatL0Line(unit.l0));
if (unit.userL1) {
lines.push(formatL1Line(unit.userL1, true));
}
if (unit.aiL1) {
lines.push(formatL1Line(unit.aiL1, false));
}
return lines;
}
// ─────────────────────────────────────────────────────────────────────────────
// 事件证据收集
// ─────────────────────────────────────────────────────────────────────────────
/**
* 为事件收集范围内的 L0 证据单元
* @param {object} eventObj - 事件对象
* @param {object[]} l0Selected - 所有选中的 L0
* @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射
* @param {Set<string>} usedL0Ids - 已消费的 L0 ID 集合(会被修改)
* @returns {EvidenceUnit[]} 该事件的证据单元列表
*/
function collectEvidenceForEvent(eventObj, l0Selected, l1ByFloor, usedL0Ids) {
const range = parseFloorRange(eventObj?.summary);
if (!range) return [];
const units = [];
for (const l0 of l0Selected) {
if (usedL0Ids.has(l0.id)) continue;
if (l0.floor < range.start || l0.floor > range.end) continue;
const unit = buildEvidenceUnit(l0, l1ByFloor);
units.push(unit);
usedL0Ids.add(l0.id);
}
// 按楼层排序
units.sort((a, b) => a.l0.floor - b.l0.floor);
return units;
}
// ─────────────────────────────────────────────────────────────────────────────
// 事件格式化L2→L0→L1 层级)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 格式化事件(含 L0→L1 证据)
* @param {object} eventItem - 事件召回项
* @param {number} idx - 编号
* @param {EvidenceUnit[]} evidenceUnits - 该事件的证据单元
* @param {Map<string, object>} causalById - 因果事件索引
* @returns {string} 格式化后的文本
*/
function formatEventWithEvidence(eventItem, idx, evidenceUnits, causalById) {
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));
}
// L0→L1 证据单元
for (const unit of evidenceUnits) {
lines.push(...formatEvidenceUnit(unit));
}
return lines.join("\n");
}
// ─────────────────────────────────────────────────────────────────────────────
// 非向量模式
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建非向量模式注入文本
* @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<string, object>} causalById - 因果事件索引
* @param {string[]} focusEntities - 焦点实体
* @param {object} meta - 元数据
* @param {object} metrics - 指标对象
* @returns {Promise<{promptText: string, injectionStats: object, metrics: object}>}
*/
async function buildVectorPrompt(store, recallResult, causalById, focusEntities, meta, metrics) {
const T_Start = performance.now();
const data = store.json || {};
const total = { used: 0, max: MAIN_BUDGET_MAX };
// 从 recallResult 解构
const l0Selected = recallResult?.l0Selected || [];
const l1ByFloor = recallResult?.l1ByFloor || new Map();
// 装配结果
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: { l0InEvents: 0, l1InEvents: 0, tokens: 0 },
distantEvidence: { units: 0, tokens: 0 },
recentEvidence: { units: 0, tokens: 0 },
};
const eventDetails = {
list: [],
directCount: 0,
relatedCount: 0,
};
// 已消费的 L0 ID 集合事件区域消费后evidence 区域不再重复)
const usedL0Ids = new Set();
// ═══════════════════════════════════════════════════════════════════════
// [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 → 直接命中 + 相似命中 + 因果链 + L0→L1 证据
// ═══════════════════════════════════════════════════════════════════════
const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary);
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";
// 收集该事件范围内的 L0→L1 证据单元
const evidenceUnits = collectEvidenceForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids);
// 格式化事件(含证据)
const text = formatEventWithEvidence(e, 0, evidenceUnits, causalById);
const cost = estimateTokens(text);
// 预算检查:整个事件(含证据)作为原子单元
if (total.used + cost > total.max) {
// 尝试不带证据的版本
const textNoEvidence = formatEventWithEvidence(e, 0, [], causalById);
const costNoEvidence = estimateTokens(textNoEvidence);
if (total.used + costNoEvidence > total.max) {
continue;
}
// 放入不带证据的版本,归还已消费的 L0 ID
for (const unit of evidenceUnits) {
usedL0Ids.delete(unit.l0.id);
}
if (isDirect) {
selectedDirect.push({
event: e.event, text: textNoEvidence, tokens: costNoEvidence,
evidenceUnits: [], candidateRank,
});
} else {
selectedRelated.push({
event: e.event, text: textNoEvidence, tokens: costNoEvidence,
evidenceUnits: [], candidateRank,
});
}
injectionStats.event.selected++;
injectionStats.event.tokens += costNoEvidence;
total.used += costNoEvidence;
eventDetails.list.push({
title: e.event?.title || e.event?.id,
isDirect,
hasEvidence: false,
tokens: costNoEvidence,
similarity: e.similarity || 0,
l0Count: 0,
l1Count: 0,
});
continue;
}
// 预算充足,放入完整版本
const l0Count = evidenceUnits.length;
let l1Count = 0;
for (const unit of evidenceUnits) {
if (unit.userL1) l1Count++;
if (unit.aiL1) l1Count++;
}
if (isDirect) {
selectedDirect.push({
event: e.event, text, tokens: cost,
evidenceUnits, candidateRank,
});
} else {
selectedRelated.push({
event: e.event, text, tokens: cost,
evidenceUnits, candidateRank,
});
}
injectionStats.event.selected++;
injectionStats.event.tokens += cost;
injectionStats.evidence.l0InEvents += l0Count;
injectionStats.evidence.l1InEvents += l1Count;
total.used += cost;
eventDetails.list.push({
title: e.event?.title || e.event?.id,
isDirect,
hasEvidence: l0Count > 0,
tokens: cost,
similarity: e.similarity || 0,
l0Count,
l1Count,
});
}
// 排序
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] 远期证据(已总结范围,未被事件消费的 L0→L1
// ═══════════════════════════════════════════════════════════════════════
const lastSummarized = store.lastSummarizedMesId ?? -1;
const lastChunkFloor = meta?.lastChunkFloor ?? -1;
const keepVisible = store.keepVisibleCount ?? 3;
// 收集未被事件消费的 L0按 rerankScore 降序
const remainingL0 = l0Selected
.filter(l0 => !usedL0Ids.has(l0.id))
.sort((a, b) => (b.rerankScore || 0) - (a.rerankScore || 0));
// 远期floor <= lastSummarized
const distantL0 = remainingL0.filter(l0 => l0.floor <= lastSummarized);
if (distantL0.length && total.used < total.max) {
const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) };
// 按楼层排序(时间顺序)
distantL0.sort((a, b) => a.floor - b.floor);
for (const l0 of distantL0) {
const unit = buildEvidenceUnit(l0, l1ByFloor);
// 原子单元预算检查
if (distantBudget.used + unit.totalTokens > distantBudget.max) continue;
const unitLines = formatEvidenceUnit(unit);
for (const line of unitLines) {
assembled.distantEvidence.lines.push(line);
}
distantBudget.used += unit.totalTokens;
usedL0Ids.add(l0.id);
injectionStats.distantEvidence.units++;
}
assembled.distantEvidence.tokens = distantBudget.used;
total.used += distantBudget.used;
injectionStats.distantEvidence.tokens = distantBudget.used;
}
// ═══════════════════════════════════════════════════════════════════════
// [Evidence - Recent] 近期证据(未总结范围,独立预算)
// ═══════════════════════════════════════════════════════════════════════
const recentStart = lastSummarized + 1;
const recentEnd = lastChunkFloor - keepVisible;
if (recentEnd >= recentStart) {
const recentL0 = remainingL0
.filter(l0 => !usedL0Ids.has(l0.id))
.filter(l0 => l0.floor >= recentStart && l0.floor <= recentEnd);
if (recentL0.length) {
const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX };
// 按楼层排序(时间顺序)
recentL0.sort((a, b) => a.floor - b.floor);
for (const l0 of recentL0) {
const unit = buildEvidenceUnit(l0, l1ByFloor);
if (recentBudget.used + unit.totalTokens > recentBudget.max) continue;
const unitLines = formatEvidenceUnit(unit);
for (const line of unitLines) {
assembled.recentEvidence.lines.push(line);
}
recentBudget.used += unit.totalTokens;
usedL0Ids.add(l0.id);
injectionStats.recentEvidence.units++;
}
assembled.recentEvidence.tokens = recentBudget.used;
injectionStats.recentEvidence.tokens = recentBudget.used;
}
}
// ═══════════════════════════════════════════════════════════════════════
// 按注入顺序拼接 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: "", 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,
distantEvidence: injectionStats.distantEvidence.tokens,
recentEvidence: injectionStats.recentEvidence.tokens,
arcs: assembled.arcs.tokens,
};
metrics.evidence.tokens = injectionStats.distantEvidence.tokens + injectionStats.recentEvidence.tokens;
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 totalL0Selected = l0Selected.length;
const l0WithL1 = l0Selected.filter(l0 => {
const pair = l1ByFloor.get(l0.floor);
return pair?.aiTop1 || pair?.userTop1;
}).length;
metrics.quality.l1AttachRate = totalL0Selected > 0
? Math.round(l0WithL1 / totalL0Selected * 100)
: 0;
metrics.quality.potentialIssues = detectIssues(metrics);
}
return { promptText, injectionStats, metrics };
}
// ─────────────────────────────────────────────────────────────────────────────
// 向量模式:召回 + 注入
// ─────────────────────────────────────────────────────────────────────────────
/**
* 构建向量模式注入文本(公开接口)
* @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 {
recallResult = await recallMemory(allEvents, vectorCfg, {
excludeLastAi,
pendingUserMessage,
});
recallResult = {
...recallResult,
events: recallResult?.events || [],
l0Selected: recallResult?.l0Selected || [],
l1ByFloor: recallResult?.l1ByFloor || new Map(),
causalChain: recallResult?.causalChain || [],
focusEntities: recallResult?.focusEntities || [],
metrics: recallResult?.metrics || null,
};
// 构建因果事件索引
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?.l0Selected?.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 };
}