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

1345 lines
53 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 (v7 - L0 scene-based display)
//
// 命名规范:
// - 存储层用 L0/L1/L2/L3StateAtom/Chunk/Event/Fact
// - 装配层用语义名称constraint/event/evidence/arc
//
// 架构变更v5 → v6
// - 同楼层多个 L0 共享一对 L1EvidenceGroup per-floor
// - L0 展示文本直接使用 semantic 字段v7: 场景摘要,纯自然语言)
// - 仅负责"构建注入文本",不负责写入 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";
import { getEngineFingerprint } from "../vector/utils/embedder.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 SHARED_POOL_MAX = 10000;
const CONSTRAINT_MAX = 2000;
const ARCS_MAX = 1500;
const EVENT_BUDGET_MAX = 5000;
const RELATED_EVENT_MAX = 1000;
const SUMMARIZED_EVIDENCE_MAX = 1500;
const UNSUMMARIZED_EVIDENCE_MAX = 5000;
const TOP_N_STAR = 5;
// 邻近补挂:未被事件消费的 L0如果距最近事件 ≤ 此值则补挂
const NEARBY_FLOOR_TOLERANCE = 2;
// L0 显示文本:分号拼接 vs 多行模式的阈值
const L0_JOINED_MAX_LENGTH = 120;
// 背景证据:无实体匹配时保留的最低相似度(与 recall.js CONFIG.EVENT_ENTITY_BYPASS_SIM 保持一致)
const EVIDENCE_ENTITY_BYPASS_SIM = 0.70;
// ─────────────────────────────────────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────────────────────────────────────
/**
* 估算文本 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();
}
/**
* 收集 L0 的实体集合(用于背景证据实体过滤)
* 支持新结构 who/edges也兼容旧结构 subject/object。
* @param {object} l0
* @returns {Set<string>}
*/
function collectL0Entities(l0) {
const atom = l0?.atom || l0?._atom || {};
const set = new Set();
const add = (v) => {
const n = normalize(v);
if (n) set.add(n);
};
for (const w of (atom.who || [])) add(w);
for (const e of (atom.edges || [])) {
add(e?.s);
add(e?.t);
}
// 兼容旧数据
add(atom.subject);
add(atom.object);
return set;
}
/**
* 背景证据是否保留(按焦点实体过滤)
* 规则:
* 1) 无焦点实体:保留
* 2) similarity >= 0.70:保留(旁通)
* 3) who/edges 命中焦点实体:保留
* 4) 兼容旧数据semantic 文本包含焦点实体:保留
* 否则过滤。
* @param {object} l0
* @param {Set<string>} focusSet
* @returns {boolean}
*/
function shouldKeepEvidenceL0(l0, focusSet) {
if (!focusSet?.size) return true;
if ((l0?.similarity || 0) >= EVIDENCE_ENTITY_BYPASS_SIM) return true;
const entities = collectL0Entities(l0);
for (const f of focusSet) {
if (entities.has(f)) return true;
}
// 旧数据兜底:从 semantic 文本里做包含匹配
const textNorm = normalize(l0?.atom?.semantic || l0?.text || '');
for (const f of focusSet) {
if (f && textNorm.includes(f)) return true;
}
return false;
}
/**
* 获取事件排序键
* @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 获取展示文本
*
* v7: L0 的 semantic 字段已是纯自然语言场景摘要60-100字直接使用。
*
* @param {object} l0 - L0 对象
* @returns {string} 场景描述文本
*/
function buildL0DisplayText(l0) {
const atom = l0.atom || {};
return String(atom.semantic || l0.text || '').trim() || '(未知锚点)';
}
/**
* 格式化 L1 chunk 行
* @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 按楼层分组
// ─────────────────────────────────────────────────────────────────────────────
/**
* 将 L0 列表按楼层分组
* @param {object[]} l0List - L0 对象列表
* @returns {Map<number, object[]>} floor → L0 数组
*/
function groupL0ByFloor(l0List) {
const map = new Map();
for (const l0 of l0List) {
const floor = l0.floor;
if (!map.has(floor)) {
map.set(floor, []);
}
map.get(floor).push(l0);
}
return map;
}
// ─────────────────────────────────────────────────────────────────────────────
// EvidenceGroupper-floorN个L0 + 共享一对L1
// ─────────────────────────────────────────────────────────────────────────────
/**
* @typedef {object} EvidenceGroup
* @property {number} floor - 楼层号
* @property {object[]} l0Atoms - 该楼层所有被选中的 L0
* @property {object|null} userL1 - USER 侧 top-1 L1 chunk仅一份
* @property {object|null} aiL1 - AI 侧 top-1 L1 chunk仅一份
* @property {number} totalTokens - 整组 token 估算
*/
/**
* 为一个楼层构建证据组
*
* 同楼层多个 L0 共享一对 L1避免 L1 重复输出。
*
* @param {number} floor - 楼层号
* @param {object[]} l0AtomsForFloor - 该楼层所有被选中的 L0
* @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射
* @returns {EvidenceGroup}
*/
function buildEvidenceGroup(floor, l0AtomsForFloor, l1ByFloor) {
const pair = l1ByFloor.get(floor);
const userL1 = pair?.userTop1 || null;
const aiL1 = pair?.aiTop1 || null;
// 计算整组 token 开销
let totalTokens = 0;
// 所有 L0 的显示文本
for (const l0 of l0AtomsForFloor) {
totalTokens += estimateTokens(buildL0DisplayText(l0));
}
// 固定开销:楼层前缀、📌 标记、分号等
totalTokens += 10;
// L1 仅算一次
if (userL1) totalTokens += estimateTokens(formatL1Line(userL1, true));
if (aiL1) totalTokens += estimateTokens(formatL1Line(aiL1, false));
return { floor, l0Atoms: l0AtomsForFloor, userL1, aiL1, totalTokens };
}
/**
* 格式化一个证据组为文本行数组
*
* 短行模式(拼接后 ≤ 120 字):
* #500 [📌] 小林整理会议记录;小周补充行动项;两人确认下周安排
* ┌ #499 [小周] ...
* #500 [角色] ...
*
* 长行模式(拼接后 > 120 字):
* #500 [📌] 小林在图书馆归档旧资料
* │ 小周核对目录并修正编号
* │ 两人讨论借阅规则并更新说明
* ┌ #499 [小周] ...
* #500 [角色] ...
*
* @param {EvidenceGroup} group - 证据组
* @returns {string[]} 文本行数组
*/
function formatEvidenceGroup(group) {
const displayTexts = group.l0Atoms.map(l0 => buildL0DisplayText(l0));
const lines = [];
// L0 部分
const joined = displayTexts.join('');
if (joined.length <= L0_JOINED_MAX_LENGTH) {
// 短行:分号拼接为一行
lines.push(` #${group.floor + 1} [📌] ${joined}`);
} else {
// 长行:每个 L0 独占一行,首行带楼层号
lines.push(` #${group.floor + 1} [📌] ${displayTexts[0]}`);
for (let i = 1; i < displayTexts.length; i++) {
lines.push(`${displayTexts[i]}`);
}
}
// L1 证据(仅一次)
if (group.userL1) {
lines.push(formatL1Line(group.userL1, true));
}
if (group.aiL1) {
lines.push(formatL1Line(group.aiL1, false));
}
return lines;
}
// ─────────────────────────────────────────────────────────────────────────────
// 事件证据收集per-floor 分组)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 为事件收集范围内的 EvidenceGroup
*
* 同楼层多个 L0 归入同一组,共享一对 L1。
*
* @param {object} eventObj - 事件对象
* @param {object[]} l0Selected - 所有选中的 L0
* @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射
* @param {Set<string>} usedL0Ids - 已消费的 L0 ID 集合(会被修改)
* @returns {EvidenceGroup[]} 该事件的证据组列表(按楼层排序)
*/
function collectEvidenceGroupsForEvent(eventObj, l0Selected, l1ByFloor, usedL0Ids) {
const range = parseFloorRange(eventObj?.summary);
if (!range) return [];
// 收集范围内未消费的 L0按楼层分组
const floorMap = new Map();
for (const l0 of l0Selected) {
if (usedL0Ids.has(l0.id)) continue;
if (l0.floor < range.start || l0.floor > range.end) continue;
if (!floorMap.has(l0.floor)) {
floorMap.set(l0.floor, []);
}
floorMap.get(l0.floor).push(l0);
usedL0Ids.add(l0.id);
}
// 构建 groups
const groups = [];
for (const [floor, l0s] of floorMap) {
groups.push(buildEvidenceGroup(floor, l0s, l1ByFloor));
}
// 按楼层排序
groups.sort((a, b) => a.floor - b.floor);
return groups;
}
// ─────────────────────────────────────────────────────────────────────────────
// 事件格式化L2 → EvidenceGroup 层级)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 格式化事件(含 EvidenceGroup 证据)
* @param {object} eventItem - 事件召回项
* @param {number} idx - 编号
* @param {EvidenceGroup[]} evidenceGroups - 该事件的证据组
* @param {Map<string, object>} causalById - 因果事件索引
* @returns {string} 格式化后的文本
*/
function formatEventWithEvidence(eventItem, idx, evidenceGroups, causalById) {
const ev = eventItem?.event || eventItem || {};
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));
}
// EvidenceGroup 证据
for (const group of evidenceGroups) {
lines.push(...formatEvidenceGroup(group));
}
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: SHARED_POOL_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: SHARED_POOL_MAX + UNSUMMARIZED_EVIDENCE_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 → 直接命中 + 相似命中 + 因果链 + EvidenceGroup
// ═══════════════════════════════════════════════════════════════════════
const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary);
const candidates = [...eventHits].sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
const eventBudget = { used: 0, max: Math.min(EVENT_BUDGET_MAX, total.max - total.used) };
const relatedBudget = { used: 0, max: RELATED_EVENT_MAX };
const selectedDirect = [];
const selectedRelated = [];
for (let candidateRank = 0; candidateRank < candidates.length; candidateRank++) {
const e = candidates[candidateRank];
if (total.used >= total.max) break;
if (eventBudget.used >= eventBudget.max) break;
const isDirect = e._recallType === "DIRECT";
if (!isDirect && relatedBudget.used >= relatedBudget.max) continue;
// 收集该事件范围内的 EvidenceGroupper-floor
const evidenceGroups = collectEvidenceGroupsForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids);
// 格式化事件(含证据)
const text = formatEventWithEvidence(e, 0, evidenceGroups, 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) {
// 归还 usedL0Ids
for (const group of evidenceGroups) {
for (const l0 of group.l0Atoms) {
usedL0Ids.delete(l0.id);
}
}
continue;
}
// 放入不带证据的版本,归还已消费的 L0 ID
for (const group of evidenceGroups) {
for (const l0 of group.l0Atoms) {
usedL0Ids.delete(l0.id);
}
}
if (isDirect) {
selectedDirect.push({
event: e.event, text: textNoEvidence, tokens: costNoEvidence,
evidenceGroups: [], candidateRank,
});
} else {
selectedRelated.push({
event: e.event, text: textNoEvidence, tokens: costNoEvidence,
evidenceGroups: [], candidateRank,
});
}
injectionStats.event.selected++;
injectionStats.event.tokens += costNoEvidence;
total.used += costNoEvidence;
eventBudget.used += costNoEvidence;
if (!isDirect) relatedBudget.used += costNoEvidence;
eventDetails.list.push({
title: e.event?.title || e.event?.id,
isDirect,
hasEvidence: false,
tokens: costNoEvidence,
similarity: e.similarity || 0,
l0Count: 0,
l1FloorCount: 0,
});
continue;
}
// 预算充足,放入完整版本
let l0Count = 0;
let l1FloorCount = 0;
for (const group of evidenceGroups) {
l0Count += group.l0Atoms.length;
if (group.userL1 || group.aiL1) l1FloorCount++;
}
if (isDirect) {
selectedDirect.push({
event: e.event, text, tokens: cost,
evidenceGroups, candidateRank,
});
} else {
selectedRelated.push({
event: e.event, text, tokens: cost,
evidenceGroups, candidateRank,
});
}
injectionStats.event.selected++;
injectionStats.event.tokens += cost;
injectionStats.evidence.l0InEvents += l0Count;
injectionStats.evidence.l1InEvents += l1FloorCount;
total.used += cost;
eventBudget.used += cost;
if (!isDirect) relatedBudget.used += cost;
eventDetails.list.push({
title: e.event?.title || e.event?.id,
isDirect,
hasEvidence: l0Count > 0,
tokens: cost,
similarity: e.similarity || 0,
l0Count,
l1FloorCount,
});
}
// 排序
selectedDirect.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
selectedRelated.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
// ═══════════════════════════════════════════════════════════════════
// 邻近补挂:未被事件消费的 L0距最近已选事件 ≤ 2 楼则补挂
// 每个 L0 只挂最近的一个事件,不扩展事件范围,不产生重叠
// ═══════════════════════════════════════════════════════════════════
const allSelectedItems = [...selectedDirect, ...selectedRelated];
const nearbyByItem = new Map();
for (const l0 of l0Selected) {
if (usedL0Ids.has(l0.id)) continue;
let bestItem = null;
let bestDistance = Infinity;
for (const item of allSelectedItems) {
const range = parseFloorRange(item.event?.summary);
if (!range) continue;
let distance;
if (l0.floor < range.start) distance = range.start - l0.floor;
else if (l0.floor > range.end) distance = l0.floor - range.end;
else continue;
if (distance <= NEARBY_FLOOR_TOLERANCE && distance <= bestDistance) {
bestDistance = distance;
bestItem = item;
}
}
if (bestItem) {
if (!nearbyByItem.has(bestItem)) nearbyByItem.set(bestItem, []);
nearbyByItem.get(bestItem).push(l0);
usedL0Ids.add(l0.id);
}
}
for (const [item, nearbyL0s] of nearbyByItem) {
const floorMap = groupL0ByFloor(nearbyL0s);
for (const [floor, l0s] of floorMap) {
const group = buildEvidenceGroup(floor, l0s, l1ByFloor);
item.evidenceGroups.push(group);
}
item.evidenceGroups.sort((a, b) => a.floor - b.floor);
const newText = formatEventWithEvidence(item, 0, item.evidenceGroups, causalById);
const newTokens = estimateTokens(newText);
const delta = newTokens - item.tokens;
if (total.used + delta > total.max) {
for (const l0 of nearbyL0s) usedL0Ids.delete(l0.id);
continue;
}
total.used += delta;
eventBudget.used += delta;
const isDirect = selectedDirect.includes(item);
if (!isDirect) relatedBudget.used += delta;
injectionStats.evidence.l0InEvents += nearbyL0s.length;
item.text = newText;
item.tokens = newTokens;
injectionStats.event.tokens += delta;
}
// 重新编号 + 星标
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
// ═══════════════════════════════════════════════════════════════════════
const lastSummarized = store.lastSummarizedMesId ?? -1;
const lastChunkFloor = meta?.lastChunkFloor ?? -1;
const keepVisible = store.keepVisibleCount ?? 3;
// 收集未被事件消费的 L0按 rerankScore 降序
const focusSetForEvidence = new Set((focusEntities || []).map(normalize).filter(Boolean));
const remainingL0 = l0Selected
.filter(l0 => !usedL0Ids.has(l0.id))
.filter(l0 => shouldKeepEvidenceL0(l0, focusSetForEvidence))
.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(SUMMARIZED_EVIDENCE_MAX, total.max - total.used) };
// 按楼层排序(时间顺序)后分组
distantL0.sort((a, b) => a.floor - b.floor);
const distantFloorMap = groupL0ByFloor(distantL0);
// 按楼层顺序遍历Map 保持插入顺序distantL0 已按 floor 排序)
for (const [floor, l0s] of distantFloorMap) {
const group = buildEvidenceGroup(floor, l0s, l1ByFloor);
// 原子组预算检查
if (distantBudget.used + group.totalTokens > distantBudget.max) continue;
const groupLines = formatEvidenceGroup(group);
for (const line of groupLines) {
assembled.distantEvidence.lines.push(line);
}
distantBudget.used += group.totalTokens;
for (const l0 of l0s) {
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: UNSUMMARIZED_EVIDENCE_MAX };
// 按楼层排序后分组
recentL0.sort((a, b) => a.floor - b.floor);
const recentFloorMap = groupL0ByFloor(recentL0);
for (const [floor, l0s] of recentFloorMap) {
const group = buildEvidenceGroup(floor, l0s, l1ByFloor);
if (recentBudget.used + group.totalTokens > recentBudget.max) continue;
const groupLines = formatEvidenceGroup(group);
for (const line of groupLines) {
assembled.recentEvidence.lines.push(line);
}
recentBudget.used += group.totalTokens;
for (const l0 of l0s) {
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;
const effectiveTotal = total.used + (assembled.recentEvidence.tokens || 0);
const effectiveLimit = SHARED_POOL_MAX + UNSUMMARIZED_EVIDENCE_MAX;
metrics.budget.total = effectiveTotal;
metrics.budget.limit = effectiveLimit;
metrics.budget.utilization = Math.round(effectiveTotal / effectiveLimit * 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;
// l1AttachRate有 L1 挂载的唯一楼层占所有 L0 覆盖楼层的比例
const l0Floors = new Set(l0Selected.map(l0 => l0.floor));
const l0FloorsWithL1 = new Set();
for (const floor of l0Floors) {
const pair = l1ByFloor.get(floor);
if (pair?.aiTop1 || pair?.userTop1) {
l0FloorsWithL1.add(floor);
}
}
metrics.quality.l1AttachRate = l0Floors.size > 0
? Math.round(l0FloorsWithL1.size / l0Floors.size * 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 嵌入 API 请求失败:${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) {
const noVectorsGenerated = !meta?.fingerprint || (meta?.lastChunkFloor ?? -1) < 0;
const fpMismatch = meta?.fingerprint && meta.fingerprint !== getEngineFingerprint(vectorCfg);
if (fpMismatch) {
if (echo && canNotifyRecallFail()) {
await echo("/echo severity=warning 向量引擎已变更,请重新生成向量");
}
} else if (noVectorsGenerated) {
if (echo && canNotifyRecallFail()) {
await echo("/echo severity=warning 没有可用向量,请在剧情总结面板中生成向量");
}
}
// 向量存在但本次未命中 → 静默跳过,不打扰用户
if (postToFrame && (noVectorsGenerated || fpMismatch)) {
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 };
}