diff --git a/modules/story-summary/generate/prompt.js b/modules/story-summary/generate/prompt.js index f12cade..756459e 100644 --- a/modules/story-summary/generate/prompt.js +++ b/modules/story-summary/generate/prompt.js @@ -1,5 +1,11 @@ // ═══════════════════════════════════════════════════════════════════════════ -// Story Summary - Prompt Injection (v3 - DSL 版 + Orphan 分组修复) +// Story Summary - Prompt Injection (v4 - 统一命名) +// +// 命名规范: +// - 存储层用 L0/L1/L2/L3(StateAtom/Chunk/Event/Fact) +// - 装配层用语义名称:constraint/event/evidence/arc +// +// 职责: // - 仅负责"构建注入文本",不负责写入 extension_prompts // - 注入发生在 story-summary.js:GENERATION_STARTED 时写入 extension_prompts // ═══════════════════════════════════════════════════════════════════════════ @@ -11,7 +17,7 @@ import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/con import { recallMemory, buildQueryText } from "../vector/retrieval/recall.js"; import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/storage/chunk-store.js"; -// METRICS +// Metrics import { formatMetricsLog, detectIssues } from "../vector/retrieval/metrics.js"; const MODULE_ID = "summaryPrompt"; @@ -35,10 +41,10 @@ function canNotifyRecallFail() { // ───────────────────────────────────────────────────────────────────────────── const MAIN_BUDGET_MAX = 10000; -const ORPHAN_MAX = 2500; -const RECENT_ORPHAN_MAX = 5000; +const DISTANT_EVIDENCE_MAX = 2500; +const RECENT_EVIDENCE_MAX = 5000; const TOTAL_BUDGET_MAX = 15000; -const L1_MAX = 2000; +const CONSTRAINT_MAX = 2000; const ARCS_MAX = 1500; const TOP_N_STAR = 5; @@ -46,6 +52,11 @@ const TOP_N_STAR = 5; // 工具函数 // ───────────────────────────────────────────────────────────────────────────── +/** + * 估算文本 token 数量 + * @param {string} text - 输入文本 + * @returns {number} token 估算值 + */ function estimateTokens(text) { if (!text) return 0; const s = String(text); @@ -53,6 +64,13 @@ function estimateTokens(text) { 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; @@ -61,6 +79,12 @@ function pushWithBudget(lines, text, state) { return true; } +/** + * 计算余弦相似度 + * @param {number[]} a - 向量A + * @param {number[]} b - 向量B + * @returns {number} 相似度 + */ function cosineSimilarity(a, b) { if (!a?.length || !b?.length || a.length !== b.length) return 0; let dot = 0, nA = 0, nB = 0; @@ -72,6 +96,11 @@ function cosineSimilarity(a, b) { return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0; } +/** + * 解析事件摘要中的楼层范围 + * @param {string} summary - 事件摘要 + * @returns {{start: number, end: number}|null} 楼层范围 + */ function parseFloorRange(summary) { if (!summary) return null; const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/); @@ -81,12 +110,22 @@ function parseFloorRange(summary) { 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') @@ -99,11 +138,22 @@ function normalize(s) { // 上下文配对工具函数 // ───────────────────────────────────────────────────────────────────────────── +/** + * 获取 chunk 的上下文楼层 + * @param {object} chunk - chunk 对象 + * @returns {number} 上下文楼层(-1 表示无) + */ function getContextFloor(chunk) { - if (chunk.isL0) return -1; + if (chunk.isAnchorVirtual) return -1; return chunk.isUser ? chunk.floor + 1 : chunk.floor - 1; } +/** + * 选择上下文 chunk + * @param {object[]} candidates - 候选 chunks + * @param {object} mainChunk - 主 chunk + * @returns {object|null} 选中的上下文 chunk + */ function pickContextChunk(candidates, mainChunk) { if (!candidates?.length) return null; const targetIsUser = !mainChunk.isUser; @@ -112,6 +162,12 @@ function pickContextChunk(candidates, mainChunk) { return candidates[0]; } +/** + * 格式化上下文 chunk 行 + * @param {object} chunk - chunk 对象 + * @param {boolean} isAbove - 是否在上方 + * @returns {string} 格式化后的行 + */ function formatContextChunkLine(chunk, isAbove) { const { name1, name2 } = getContext(); const speaker = chunk.isUser ? (name1 || "用户") : (chunk.speaker || name2 || "角色"); @@ -124,6 +180,10 @@ function formatContextChunkLine(chunk, isAbove) { // 系统前导与后缀 // ───────────────────────────────────────────────────────────────────────────── +/** + * 构建系统前导文本 + * @returns {string} 前导文本 + */ function buildSystemPreamble() { return [ "以上是还留在眼前的对话", @@ -135,6 +195,10 @@ function buildSystemPreamble() { ].join("\n"); } +/** + * 构建后缀文本 + * @returns {string} 后缀文本 + */ function buildPostscript() { return [ "", @@ -143,9 +207,14 @@ function buildPostscript() { } // ───────────────────────────────────────────────────────────────────────────── -// L1 Facts 分层过滤 +// [Constraints] L3 Facts 过滤与格式化 // ───────────────────────────────────────────────────────────────────────────── +/** + * 获取已知角色集合 + * @param {object} store - 存储对象 + * @returns {Set} 角色名称集合(标准化后) + */ function getKnownCharacters(store) { const names = new Set(); @@ -167,19 +236,33 @@ function getKnownCharacters(store) { return names; } +/** + * 解析关系谓词中的目标 + * @param {string} predicate - 谓词 + * @returns {string|null} 目标名称 + */ function parseRelationTarget(predicate) { const match = String(predicate || '').match(/^对(.+)的/); return match ? match[1] : null; } -function filterFactsByRelevance(facts, focusEntities, knownCharacters) { +/** + * 按相关性过滤 facts + * @param {object[]} facts - 所有 facts + * @param {string[]} focusEntities - 焦点实体 + * @param {Set} knownCharacters - 已知角色 + * @returns {object[]} 过滤后的 facts + */ +function filterConstraintsByRelevance(facts, focusEntities, knownCharacters) { if (!facts?.length) return []; const focusSet = new Set((focusEntities || []).map(normalize)); return facts.filter(f => { + // isState 的 facts 始终保留 if (f._isState === true) return true; + // 关系类 facts:检查 from/to 是否在焦点中 if (isRelationFact(f)) { const from = normalize(f.s); const target = parseRelationTarget(f.p); @@ -189,6 +272,7 @@ function filterFactsByRelevance(facts, focusEntities, knownCharacters) { return false; } + // 其他 facts:检查主体是否在焦点中 const subjectNorm = normalize(f.s); if (knownCharacters.has(subjectNorm)) { return focusSet.has(subjectNorm); @@ -198,8 +282,15 @@ function filterFactsByRelevance(facts, focusEntities, knownCharacters) { }); } -function formatFactsForInjection(facts, focusEntities, knownCharacters) { - const filtered = filterFactsByRelevance(facts, focusEntities, knownCharacters); +/** + * 格式化 constraints 用于注入 + * @param {object[]} facts - 所有 facts + * @param {string[]} focusEntities - 焦点实体 + * @param {Set} knownCharacters - 已知角色 + * @returns {string[]} 格式化后的行 + */ +function formatConstraintsForInjection(facts, focusEntities, knownCharacters) { + const filtered = filterConstraintsByRelevance(facts, focusEntities, knownCharacters); if (!filtered.length) return []; @@ -218,28 +309,44 @@ function formatFactsForInjection(facts, focusEntities, knownCharacters) { // 格式化函数 // ───────────────────────────────────────────────────────────────────────────── -function formatArcLine(a) { - const moments = (a.moments || []) +/** + * 格式化弧光行 + * @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 `- ${a.name}:${moments.join(" → ")}`; + return `- ${arc.name}:${moments.join(" → ")}`; } - return `- ${a.name}:${a.trajectory}`; + return `- ${arc.name}:${arc.trajectory}`; } -function formatChunkFullLine(c) { +/** + * 格式化 evidence chunk 完整行 + * @param {object} chunk - chunk 对象 + * @returns {string} 格式化后的行 + */ +function formatEvidenceFullLine(chunk) { const { name1, name2 } = getContext(); - if (c.isL0) { - return `› #${c.floor + 1} [📌] ${String(c.text || "").trim()}`; + if (chunk.isAnchorVirtual) { + return `› #${chunk.floor + 1} [📌] ${String(chunk.text || "").trim()}`; } - const speaker = c.isUser ? (name1 || "用户") : (c.speaker || name2 || "角色"); - return `› #${c.floor + 1} [${speaker}] ${String(c.text || "").trim()}`; + const speaker = chunk.isUser ? (name1 || "用户") : (chunk.speaker || name2 || "角色"); + return `› #${chunk.floor + 1} [${speaker}] ${String(chunk.text || "").trim()}`; } +/** + * 格式化因果事件行 + * @param {object} causalItem - 因果事件项 + * @param {Map} causalById - 因果事件索引 + * @returns {string} 格式化后的行 + */ function formatCausalEventLine(causalItem, causalById) { const ev = causalItem?.event || {}; const depth = Math.max(1, Math.min(9, causalItem?._causalDepth || 1)); @@ -268,30 +375,48 @@ function formatCausalEventLine(causalItem, causalById) { return lines.join("\n"); } +/** + * 重新编号事件文本 + * @param {string} text - 原始文本 + * @param {number} newIndex - 新编号 + * @returns {string} 重新编号后的文本 + */ function renumberEventText(text, newIndex) { const s = String(text || ""); return s.replace(/^(\s*)\d+(\.\s*(?:【)?)/, `$1${newIndex}$2`); } -function getEventSortKey(ev) { - const r = parseFloorRange(ev?.summary); +/** + * 获取事件排序键 + * @param {object} event - 事件对象 + * @returns {number} 排序键 + */ +function getEventSortKey(event) { + const r = parseFloorRange(event?.summary); if (r) return r.start; - const m = String(ev?.id || "").match(/evt-(\d+)/); + const m = String(event?.id || "").match(/evt-(\d+)/); return m ? parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER; } // ───────────────────────────────────────────────────────────────────────────── -// 按楼层分组装配 orphan chunks(修复上下文重复) +// 按楼层分组装配 evidence(修复上下文重复) // ───────────────────────────────────────────────────────────────────────────── -function assembleOrphansByFloor(orphanCandidates, contextChunksByFloor, budget) { - if (!orphanCandidates?.length) { - return { lines: [], l0Count: 0, contextPairsCount: 0 }; +/** + * 按楼层装配 evidence + * @param {object[]} evidenceCandidates - 候选 evidence + * @param {Map} contextChunksByFloor - 上下文 chunks 索引 + * @param {object} budget - 预算状态 + * @returns {{lines: string[], anchorCount: number, contextPairsCount: number}} + */ +function assembleEvidenceByFloor(evidenceCandidates, contextChunksByFloor, budget) { + if (!evidenceCandidates?.length) { + return { lines: [], anchorCount: 0, contextPairsCount: 0 }; } // 1. 按楼层分组 const byFloor = new Map(); - for (const c of orphanCandidates) { + for (const c of evidenceCandidates) { const arr = byFloor.get(c.floor) || []; arr.push(c); byFloor.set(c.floor, arr); @@ -306,29 +431,29 @@ function assembleOrphansByFloor(orphanCandidates, contextChunksByFloor, budget) const floorsSorted = Array.from(byFloor.keys()).sort((a, b) => a - b); const lines = []; - let l0Count = 0; + let anchorCount = 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); + // 分离锚点虚拟 chunks 和真实 chunks + const anchorChunks = chunks.filter(c => c.isAnchorVirtual); + const realChunks = chunks.filter(c => !c.isAnchorVirtual); - // L0 直接输出(不需要上下文) - for (const c of l0Chunks) { - const line = formatChunkFullLine(c); + // 锚点直接输出(不需要上下文) + for (const c of anchorChunks) { + const line = formatEvidenceFullLine(c); if (!pushWithBudget(lines, line, budget)) { - return { lines, l0Count, contextPairsCount }; + return { lines, anchorCount, contextPairsCount }; } - l0Count++; + anchorCount++; } - // L1 按楼层统一处理 - if (l1Chunks.length > 0) { - const firstChunk = l1Chunks[0]; + // 真实 chunks 按楼层统一处理 + if (realChunks.length > 0) { + const firstChunk = realChunks[0]; const pairFloor = getContextFloor(firstChunk); const pairCandidates = contextChunksByFloor.get(pairFloor) || []; const contextChunk = pickContextChunk(pairCandidates, firstChunk); @@ -337,16 +462,16 @@ function assembleOrphansByFloor(orphanCandidates, contextChunksByFloor, budget) if (contextChunk && contextChunk.floor < floor) { const contextLine = formatContextChunkLine(contextChunk, true); if (!pushWithBudget(lines, contextLine, budget)) { - return { lines, l0Count, contextPairsCount }; + return { lines, anchorCount, contextPairsCount }; } contextPairsCount++; } - // 输出该楼层所有 L1 chunks - for (const c of l1Chunks) { - const line = formatChunkFullLine(c); + // 输出该楼层所有真实 chunks + for (const c of realChunks) { + const line = formatEvidenceFullLine(c); if (!pushWithBudget(lines, line, budget)) { - return { lines, l0Count, contextPairsCount }; + return { lines, anchorCount, contextPairsCount }; } } @@ -354,26 +479,32 @@ function assembleOrphansByFloor(orphanCandidates, contextChunksByFloor, budget) if (contextChunk && contextChunk.floor > floor) { const contextLine = formatContextChunkLine(contextChunk, false); if (!pushWithBudget(lines, contextLine, budget)) { - return { lines, l0Count, contextPairsCount }; + return { lines, anchorCount, contextPairsCount }; } contextPairsCount++; } } } - return { lines, l0Count, contextPairsCount }; + return { lines, anchorCount, contextPairsCount }; } // ───────────────────────────────────────────────────────────────────────────── // 非向量模式 // ───────────────────────────────────────────────────────────────────────────── +/** + * 构建非向量模式注入文本 + * @param {object} store - 存储对象 + * @returns {string} 注入文本 + */ function buildNonVectorPrompt(store) { const data = store.json || {}; const sections = []; + // [Constraints] L3 Facts const allFacts = getFacts(); - const factLines = allFacts + const constraintLines = allFacts .filter(f => !f.retracted) .sort((a, b) => (b.since || 0) - (a.since || 0)) .map(f => { @@ -384,10 +515,11 @@ function buildNonVectorPrompt(store) { return `- ${f.s}的${f.p}: ${f.o}${since}`; }); - if (factLines.length) { - sections.push(`[定了的事] 已确立的事实\n${factLines.join("\n")}`); + 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 || ""; @@ -400,6 +532,7 @@ function buildNonVectorPrompt(store) { 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")}`); @@ -414,6 +547,10 @@ function buildNonVectorPrompt(store) { ); } +/** + * 构建非向量模式注入文本(公开接口) + * @returns {string} 注入文本 + */ export function buildNonVectorPromptText() { if (!getSettings().storySummary?.enabled) { return ""; @@ -440,6 +577,16 @@ export function buildNonVectorPromptText() { // 向量模式:预算装配 // ───────────────────────────────────────────────────────────────────────────── +/** + * 构建向量模式注入文本 + * @param {object} store - 存储对象 + * @param {object} recallResult - 召回结果 + * @param {Map} causalById - 因果事件索引 + * @param {string[]} focusEntities - 焦点实体 + * @param {object} meta - 元数据 + * @param {object} metrics - 指标对象 + * @returns {Promise<{promptText: string, injectionLogText: string, injectionStats: object, metrics: object}>} + */ async function buildVectorPrompt(store, recallResult, causalById, focusEntities = [], meta = null, metrics = null) { const T_Start = performance.now(); @@ -447,76 +594,79 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities const data = store.json || {}; const total = { used: 0, max: MAIN_BUDGET_MAX }; + // 装配结果 const assembled = { - facts: { lines: [], tokens: 0 }, + constraints: { lines: [], tokens: 0 }, + directEvents: { lines: [], tokens: 0 }, + relatedEvents: { lines: [], tokens: 0 }, + distantEvidence: { lines: [], tokens: 0 }, + recentEvidence: { 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 }, + constraint: { count: 0, tokens: 0, filtered: 0 }, + arc: { count: 0, tokens: 0 }, + event: { selected: 0, tokens: 0 }, evidence: { attached: 0, tokens: 0 }, - orphans: { injected: 0, tokens: 0, l0Count: 0, contextPairs: 0 }, + distantEvidence: { injected: 0, tokens: 0, anchorCount: 0, contextPairs: 0 }, }; - const recentOrphanStats = { + const recentEvidenceStats = { injected: 0, tokens: 0, floorRange: "N/A", contextPairs: 0, }; - const details = { - eventList: [], + const eventDetails = { + list: [], directCount: 0, - similarCount: 0, + relatedCount: 0, }; // ═══════════════════════════════════════════════════════════════════════ - // [优先级 1] 世界约束 + // [Constraints] L3 Facts → 世界约束 // ═══════════════════════════════════════════════════════════════════════ - const T_L1_Start = performance.now(); + const T_Constraint_Start = performance.now(); const allFacts = getFacts(); const knownCharacters = getKnownCharacters(store); - const factLines = formatFactsForInjection(allFacts, focusEntities, knownCharacters); + const constraintLines = formatConstraintsForInjection(allFacts, focusEntities, knownCharacters); if (metrics) { - metrics.l1.factsTotal = allFacts.length; - metrics.l1.factsFiltered = allFacts.length - factLines.length; + metrics.constraint.total = allFacts.length; + metrics.constraint.filtered = allFacts.length - constraintLines.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; + 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.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; + 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.l1.factsInjected = assembled.facts.lines.length; - metrics.l1.tokens = l1Budget.used; - metrics.l1.samples = assembled.facts.lines.slice(0, 3).map(line => + 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.l1Constraints = Math.round(performance.now() - T_L1_Start); + metrics.timing.constraintFilter = Math.round(performance.now() - T_Constraint_Start); } } else if (metrics) { - metrics.timing.l1Constraints = Math.round(performance.now() - T_L1_Start); + metrics.timing.constraintFilter = Math.round(performance.now() - T_Constraint_Start); } // ═══════════════════════════════════════════════════════════════════════ - // [优先级 2] 人物弧光 + // [Arcs] 人物弧光 // ═══════════════════════════════════════════════════════════════════════ if (data.arcs?.length && total.used < total.max) { @@ -529,54 +679,66 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities .filter(Boolean) ); - const filtered = (data.arcs || []).filter(a => { + const filteredArcs = (data.arcs || []).filter(a => { const n = String(a?.name || "").trim(); return n && relevant.has(n); }); - if (filtered.length) { + if (filteredArcs.length) { const arcBudget = { used: 0, max: Math.min(ARCS_MAX, total.max - total.used) }; - for (const a of filtered) { + 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.arcs.count = assembled.arcs.lines.length; - injectionStats.arcs.tokens = arcBudget.used; + injectionStats.arc.count = assembled.arcs.lines.length; + injectionStats.arc.tokens = arcBudget.used; } } // ═══════════════════════════════════════════════════════════════════════ - // [优先级 3] 事件 + 证据 + // [Events] L2 Events → 直接命中 + 相似命中 + 因果链 // ═══════════════════════════════════════════════════════════════════════ - const recalledEvents = (recallResult?.events || []).filter(e => e?.event?.summary); - const chunks = recallResult?.chunks || []; + const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary); + const evidenceChunks = recallResult?.evidenceChunks || []; const usedChunkIds = new Set(); - function pickBestChunkForEvent(eventObj) { + /** + * 为事件选择最佳证据 chunk + * @param {object} eventObj - 事件对象 + * @returns {object|null} 最佳 chunk + */ + function pickBestEvidenceForEvent(eventObj) { const range = parseFloorRange(eventObj?.summary); if (!range) return null; let best = null; - for (const c of chunks) { + for (const c of evidenceChunks) { if (usedChunkIds.has(c.chunkId)) continue; if (c.floor < range.start || c.floor > range.end) continue; if (!best) { best = c; - } else if (c.isL0 && !best.isL0) { + } else if (c.isAnchorVirtual && !best.isAnchorVirtual) { best = c; - } else if (c.isL0 === best.isL0 && (c.chunkIdx ?? 0) < (best.chunkIdx ?? 0)) { + } else if (c.isAnchorVirtual === best.isAnchorVirtual && (c.chunkIdx ?? 0) < (best.chunkIdx ?? 0)) { best = c; } } return best; } - function formatEventWithEvidence(e, idx, chunk) { - const ev = e.event || {}; + /** + * 格式化事件带证据 + * @param {object} eventItem - 事件项 + * @param {number} idx - 编号 + * @param {object} chunk - 证据 chunk + * @returns {string} 格式化后的文本 + */ + function formatEventWithEvidence(eventItem, idx, chunk) { + const ev = eventItem.event || {}; const time = ev.timeLabel || ""; const title = String(ev.title || "").trim(); const people = (ev.participants || []).join(" / ").trim(); @@ -595,16 +757,16 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities } if (chunk) { - lines.push(` ${formatChunkFullLine(chunk)}`); + lines.push(` ${formatEvidenceFullLine(chunk)}`); } return lines.join("\n"); } - const candidates = [...recalledEvents].sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); + const candidates = [...eventHits].sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); const selectedDirect = []; - const selectedSimilar = []; + const selectedRelated = []; for (let candidateRank = 0; candidateRank < candidates.length; candidateRank++) { const e = candidates[candidateRank]; @@ -613,7 +775,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities const isDirect = e._recallType === "DIRECT"; - const bestChunk = pickBestChunkForEvent(e.event); + const bestChunk = pickBestEvidenceForEvent(e.event); let text = formatEventWithEvidence(e, 0, bestChunk); let cost = estimateTokens(text); @@ -634,75 +796,77 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities 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 }); + selectedRelated.push({ event: e.event, text, tokens: cost, chunk: chosenChunk, hasEvidence, candidateRank }); } - injectionStats.events.selected++; + injectionStats.event.selected++; total.used += cost; if (hasEvidence && bestChunk) { - const chunkLine = formatChunkFullLine(bestChunk); + const chunkLine = formatEvidenceFullLine(bestChunk); const ct = estimateTokens(chunkLine); injectionStats.evidence.attached++; injectionStats.evidence.tokens += ct; usedChunkIds.add(bestChunk.chunkId); - injectionStats.events.tokens += Math.max(0, cost - ct); + injectionStats.event.tokens += Math.max(0, cost - ct); } else { - injectionStats.events.tokens += cost; + injectionStats.event.tokens += cost; } - details.eventList.push({ + eventDetails.list.push({ title: e.event?.title || e.event?.id, isDirect, hasEvidence, tokens: cost, similarity: e.similarity || 0, - hasL0Evidence: bestChunk?.isL0 || false, + hasAnchorEvidence: bestChunk?.isAnchorVirtual || false, }); } + // 排序 selectedDirect.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event)); - selectedSimilar.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event)); + selectedRelated.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event)); - const selectedDirectTexts = selectedDirect.map((it, i) => { + // 重新编号 + 星标 + const directEventTexts = 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 relatedEventTexts = selectedRelated.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; + eventDetails.directCount = selectedDirect.length; + eventDetails.relatedCount = selectedRelated.length; + assembled.directEvents.lines = directEventTexts; + assembled.relatedEvents.lines = relatedEventTexts; // ═══════════════════════════════════════════════════════════════════════ - // [优先级 4] 远期片段(已总结范围的 orphan chunks) + // [Evidence - Distant] L1 Chunks → 远期证据(已总结范围) // ═══════════════════════════════════════════════════════════════════════ const lastSummarized = store.lastSummarizedMesId ?? -1; const lastChunkFloor = meta?.lastChunkFloor ?? -1; const keepVisible = store.keepVisibleCount ?? 3; - const orphanContextFloors = new Set(); - const orphanCandidates = chunks + const distantContextFloors = new Set(); + const distantCandidates = evidenceChunks .filter(c => !usedChunkIds.has(c.chunkId)) .filter(c => c.floor <= lastSummarized); - for (const c of orphanCandidates) { - if (c.isL0) continue; + for (const c of distantCandidates) { + if (c.isAnchorVirtual) continue; const pairFloor = getContextFloor(c); - if (pairFloor >= 0) orphanContextFloors.add(pairFloor); + if (pairFloor >= 0) distantContextFloors.add(pairFloor); } let contextChunksByFloor = new Map(); - if (chatId && orphanContextFloors.size > 0) { + if (chatId && distantContextFloors.size > 0) { try { - const contextChunks = await getChunksByFloors(chatId, Array.from(orphanContextFloors)); + const contextChunks = await getChunksByFloors(chatId, Array.from(distantContextFloors)); for (const pc of contextChunks) { if (!contextChunksByFloor.has(pc.floor)) { contextChunksByFloor.set(pc.floor, []); @@ -714,40 +878,40 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities } } - if (orphanCandidates.length && total.used < total.max) { - const l1Budget = { used: 0, max: Math.min(ORPHAN_MAX, total.max - total.used) }; + if (distantCandidates.length && total.used < total.max) { + const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) }; - const result = assembleOrphansByFloor( - orphanCandidates.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))), + const result = assembleEvidenceByFloor( + distantCandidates.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))), contextChunksByFloor, - l1Budget + distantBudget ); - assembled.orphans.lines = result.lines; - assembled.orphans.tokens = l1Budget.used; - total.used += l1Budget.used; + assembled.distantEvidence.lines = result.lines; + assembled.distantEvidence.tokens = distantBudget.used; + total.used += distantBudget.used; - injectionStats.orphans.injected = result.lines.length; - injectionStats.orphans.tokens = l1Budget.used; - injectionStats.orphans.l0Count = result.l0Count; - injectionStats.orphans.contextPairs = result.contextPairsCount; + injectionStats.distantEvidence.injected = result.lines.length; + injectionStats.distantEvidence.tokens = distantBudget.used; + injectionStats.distantEvidence.anchorCount = result.anchorCount; + injectionStats.distantEvidence.contextPairs = result.contextPairsCount; } // ═══════════════════════════════════════════════════════════════════════ - // [独立预算] 待整理(未总结范围) + // [Evidence - Recent] L1 Chunks → 近期证据(未总结范围,独立预算) // ═══════════════════════════════════════════════════════════════════════ const recentStart = lastSummarized + 1; const recentEnd = lastChunkFloor - keepVisible; - if (chunks.length && recentEnd >= recentStart) { - const recentOrphanCandidates = chunks + if (evidenceChunks.length && recentEnd >= recentStart) { + const recentCandidates = evidenceChunks .filter(c => !usedChunkIds.has(c.chunkId)) .filter(c => c.floor >= recentStart && c.floor <= recentEnd); const recentContextFloors = new Set(); - for (const c of recentOrphanCandidates) { - if (c.isL0) continue; + for (const c of recentCandidates) { + if (c.isAnchorVirtual) continue; const pairFloor = getContextFloor(c); if (pairFloor >= 0) recentContextFloors.add(pairFloor); } @@ -769,22 +933,22 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities } } - if (recentOrphanCandidates.length) { - const recentBudget = { used: 0, max: RECENT_ORPHAN_MAX }; + if (recentCandidates.length) { + const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX }; - const result = assembleOrphansByFloor( - recentOrphanCandidates.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))), + const result = assembleEvidenceByFloor( + recentCandidates.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; + assembled.recentEvidence.lines = result.lines; + assembled.recentEvidence.tokens = recentBudget.used; - recentOrphanStats.injected = result.lines.length; - recentOrphanStats.tokens = recentBudget.used; - recentOrphanStats.floorRange = `${recentStart + 1}~${recentEnd + 1}楼`; - recentOrphanStats.contextPairs = result.contextPairsCount; + recentEvidenceStats.injected = result.lines.length; + recentEvidenceStats.tokens = recentBudget.used; + recentEvidenceStats.floorRange = `${recentStart + 1}~${recentEnd + 1}楼`; + recentEvidenceStats.contextPairs = result.contextPairsCount; } } @@ -792,24 +956,24 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities // 按注入顺序拼接 sections // ═══════════════════════════════════════════════════════════════════════ - const T_L4_Start = performance.now(); + const T_Format_Start = performance.now(); const sections = []; - if (assembled.facts.lines.length) { - sections.push(`[定了的事] 已确立的事实\n${assembled.facts.lines.join("\n")}`); + if (assembled.constraints.lines.length) { + sections.push(`[定了的事] 已确立的事实\n${assembled.constraints.lines.join("\n")}`); } - if (assembled.events.direct.length) { - sections.push(`[印象深的事] 记得很清楚\n\n${assembled.events.direct.join("\n\n")}`); + if (assembled.directEvents.lines.length) { + sections.push(`[印象深的事] 记得很清楚\n\n${assembled.directEvents.lines.join("\n\n")}`); } - if (assembled.events.similar.length) { - sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.events.similar.join("\n\n")}`); + if (assembled.relatedEvents.lines.length) { + sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.relatedEvents.lines.join("\n\n")}`); } - if (assembled.orphans.lines.length) { - sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.orphans.lines.join("\n")}`); + if (assembled.distantEvidence.lines.length) { + sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.distantEvidence.lines.join("\n")}`); } - if (assembled.recentOrphans.lines.length) { - sections.push(`[近期] 清晰但还没整理\n${assembled.recentOrphans.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")}`); @@ -817,8 +981,8 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities if (!sections.length) { if (metrics) { - metrics.timing.l3Assembly = Math.round(performance.now() - T_Start - (metrics.timing.l1Constraints || 0)); - metrics.timing.l4Formatting = 0; + metrics.timing.evidenceAssembly = Math.round(performance.now() - T_Start - (metrics.timing.constraintFilter || 0)); + metrics.timing.formatting = 0; } return { promptText: "", injectionLogText: "", injectionStats, metrics }; } @@ -829,43 +993,43 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities `${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.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.l4.formattingTime = Math.round(performance.now() - T_L4_Start); - metrics.timing.l4Formatting = metrics.l4.formattingTime; + metrics.formatting.time = Math.round(performance.now() - T_Format_Start); + metrics.timing.formatting = metrics.formatting.time; - metrics.budget.total = total.used + (assembled.recentOrphans.tokens || 0); + 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.facts.tokens, - events: injectionStats.events.tokens + injectionStats.evidence.tokens, - chunks: injectionStats.orphans.tokens, - recentOrphans: recentOrphanStats.tokens || 0, + constraints: assembled.constraints.tokens, + events: injectionStats.event.tokens + injectionStats.evidence.tokens, + distantEvidence: injectionStats.distantEvidence.tokens, + recentEvidence: recentEvidenceStats.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; + metrics.evidence.tokens = injectionStats.distantEvidence.tokens + (recentEvidenceStats.tokens || 0); + metrics.evidence.contextPairsAdded = injectionStats.distantEvidence.contextPairs + recentEvidenceStats.contextPairs; + metrics.evidence.assemblyTime = Math.round(performance.now() - T_Start - (metrics.timing.constraintFilter || 0) - metrics.formatting.time); + metrics.timing.evidenceAssembly = metrics.evidence.assemblyTime; const totalFacts = allFacts.length; metrics.quality.constraintCoverage = totalFacts > 0 - ? Math.round(assembled.facts.lines.length / totalFacts * 100) + ? Math.round(assembled.constraints.lines.length / totalFacts * 100) : 100; - metrics.quality.eventPrecisionProxy = metrics.l2?.similarityDistribution?.mean || 0; + metrics.quality.eventPrecisionProxy = metrics.event?.similarityDistribution?.mean || 0; - const totalChunks = metrics.l3.chunksSelected || 0; - const chunksWithEvents = injectionStats.evidence.attached; - metrics.quality.evidenceDensity = totalChunks > 0 - ? Math.round(chunksWithEvents / totalChunks * 100) + const totalSelected = metrics.evidence.selected || 0; + const attached = injectionStats.evidence.attached; + metrics.quality.evidenceDensity = totalSelected > 0 + ? Math.round(attached / totalSelected * 100) : 0; metrics.quality.potentialIssues = detectIssues(metrics); @@ -878,8 +1042,15 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities // 因果证据补充 // ───────────────────────────────────────────────────────────────────────────── -async function attachEvidenceToCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap) { - for (const c of causalEvents) { +/** + * 为因果事件附加证据 + * @param {object[]} causalChain - 因果链 + * @param {Map} eventVectorMap - 事件向量索引 + * @param {Map} chunkVectorMap - chunk 向量索引 + * @param {Map} chunksMap - chunks 索引 + */ +async function attachEvidenceToCausalEvents(causalChain, eventVectorMap, chunkVectorMap, chunksMap) { + for (const c of causalChain) { c._evidenceChunk = null; const ev = c.event; @@ -925,6 +1096,12 @@ async function attachEvidenceToCausalEvents(causalEvents, eventVectorMap, chunkV // 向量模式:召回 + 注入 // ───────────────────────────────────────────────────────────────────────────── +/** + * 构建向量模式注入文本(公开接口) + * @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; @@ -968,19 +1145,19 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) { recallResult = { ...recallResult, events: recallResult?.events || [], - chunks: recallResult?.chunks || [], - causalEvents: recallResult?.causalEvents || [], + evidenceChunks: recallResult?.evidenceChunks || [], + causalChain: recallResult?.causalChain || [], focusEntities: recallResult?.focusEntities || [], logText: recallResult?.logText || "", metrics: recallResult?.metrics || null, }; - const causalEvents = recallResult.causalEvents || []; - if (causalEvents.length > 0) { + const causalChain = recallResult.causalChain || []; + if (causalChain.length > 0) { if (chatId) { try { const floors = new Set(); - for (const c of causalEvents) { + for (const c of causalChain) { const r = parseFloorRange(c.event?.summary); if (!r) continue; for (let f = r.start; f <= r.end; f++) floors.add(f); @@ -996,7 +1173,7 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) { 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); + await attachEvidenceToCausalEvents(causalChain, eventVectorMap, chunkVectorMap, chunksMap); } catch (e) { xbLog.warn(MODULE_ID, "Causal evidence attachment failed", e); } @@ -1004,7 +1181,7 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) { } causalById = new Map( - recallResult.causalEvents + recallResult.causalChain .map(c => [c?.event?.id, c]) .filter(x => x[0]) ); @@ -1028,8 +1205,8 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) { const hasUseful = (recallResult?.events?.length || 0) > 0 || - (recallResult?.chunks?.length || 0) > 0 || - (recallResult?.causalEvents?.length || 0) > 0; + (recallResult?.evidenceChunks?.length || 0) > 0 || + (recallResult?.causalChain?.length || 0) > 0; if (!hasUseful) { if (echo && canNotifyRecallFail()) { diff --git a/modules/story-summary/story-summary.js b/modules/story-summary/story-summary.js index 4e7fd9e..dd16d18 100644 --- a/modules/story-summary/story-summary.js +++ b/modules/story-summary/story-summary.js @@ -1,12 +1,11 @@ -// ═══════════════════════════════════════════════════════════════════════════ -// Story Summary - 主入口(最终版) +// ═══════════════════════════════════════════════════════════════════════════ +// Story Summary - 主入口 // // 稳定目标: // 1) "聊天时隐藏已总结" 永远只隐藏"已总结"部分,绝不影响未总结部分 // 2) 关闭隐藏 = 暴力全量 unhide,确保立刻恢复 // 3) 开启隐藏 / 改Y / 切Chat / 收新消息:先全量 unhide,再按边界重新 hide -// 4) Prompt 注入位置稳定:永远插在"最后一条 user 消息"之前 -// 5) 注入回归 extension_prompts + IN_CHAT + depth(动态计算) +// 4) Prompt 注入:extension_prompts + IN_CHAT + depth(动态计算,最小为2) // ═══════════════════════════════════════════════════════════════════════════ import { getContext } from "../../../../../extensions.js"; @@ -108,7 +107,7 @@ let vectorCancelled = false; let vectorAbortController = null; let anchorGenerating = false; -// ★ 用户消息缓存(解决 GENERATION_STARTED 时 chat 尚未包含用户消息的问题) +// 用户消息缓存(解决 GENERATION_STARTED 时 chat 尚未包含用户消息的问题) let lastSentUserMessage = null; let lastSentTimestamp = 0; @@ -142,6 +141,7 @@ let lastVectorWarningAt = 0; const VECTOR_WARNING_COOLDOWN_MS = 120000; // 2分钟内不重复提醒 const EXT_PROMPT_KEY = "LittleWhiteBox_StorySummary"; +const MIN_INJECTION_DEPTH = 2; // role 映射 const ROLE_MAP = { @@ -1208,7 +1208,7 @@ async function handleChatChanged() { initButtonsForAll(); const store = getSummaryStore(); - + if (store?.hideSummarizedHistory) { await applyHideState(); } @@ -1216,7 +1216,7 @@ async function handleChatChanged() { if (frameReady) { await sendFrameBaseData(store, newLength); sendFrameFullData(store, newLength); - + sendAnchorStatsToFrame(); sendVectorStatsToFrame(); } @@ -1307,7 +1307,7 @@ async function handleGenerationStarted(type, _params, isDryRun) { clearExtensionPrompt(); - // ★ 保留:判断是否使用缓存的用户消息(30秒内有效) + // 判断是否使用缓存的用户消息(30秒内有效) let pendingUserMessage = null; if (type === "normal" && lastSentUserMessage && (Date.now() - lastSentTimestamp < 30000)) { pendingUserMessage = lastSentUserMessage; @@ -1322,7 +1322,7 @@ async function handleGenerationStarted(type, _params, isDryRun) { const store = getSummaryStore(); - // 1) boundary: + // 确定注入边界 // - 向量开:meta.lastChunkFloor(若无则回退 lastSummarizedMesId) // - 向量关:lastSummarizedMesId let boundary = -1; @@ -1335,12 +1335,12 @@ async function handleGenerationStarted(type, _params, isDryRun) { } if (boundary < 0) return; - // 2) depth:倒序插入,从末尾往前数 - // 最小为 1,避免插入到最底部导致 AI 看到的最后是总结 - const depth = Math.max(2, chatLen - boundary - 1); + // 计算深度:倒序插入,从末尾往前数 + // 最小为 MIN_INJECTION_DEPTH,避免插入太靠近底部 + const depth = Math.max(MIN_INJECTION_DEPTH, chatLen - boundary - 1); if (depth < 0) return; - // 3) 构建注入文本(保持原逻辑) + // 构建注入文本 let text = ""; if (vectorCfg?.enabled) { const r = await buildVectorPromptText(excludeLastAi, { @@ -1354,12 +1354,12 @@ async function handleGenerationStarted(type, _params, isDryRun) { } if (!text.trim()) return; - // 4) 写入 extension_prompts // 获取用户配置的 role const cfg = getSummaryPanelConfig(); const roleKey = cfg.trigger?.role || 'system'; const role = ROLE_MAP[roleKey] || extension_prompt_roles.SYSTEM; + // 写入 extension_prompts extension_prompts[EXT_PROMPT_KEY] = { value: text, position: extension_prompt_types.IN_CHAT, diff --git a/modules/story-summary/vector/pipeline/state-recall.js b/modules/story-summary/vector/pipeline/state-recall.js deleted file mode 100644 index eb67183..0000000 --- a/modules/story-summary/vector/pipeline/state-recall.js +++ /dev/null @@ -1,189 +0,0 @@ -// ═══════════════════════════════════════════════════════════════════════════ -// Story Summary - State Recall (L0) -// L0 语义锚点召回 + floor bonus + 虚拟 chunk 转换 -// ═══════════════════════════════════════════════════════════════════════════ - -import { getContext } from '../../../../../../../extensions.js'; -import { getAllStateVectors, getStateAtoms } from '../storage/state-store.js'; -import { getMeta } from '../storage/chunk-store.js'; -import { getEngineFingerprint } from '../utils/embedder.js'; -import { xbLog } from '../../../../core/debug-core.js'; - -const MODULE_ID = 'state-recall'; - -const CONFIG = { - MAX_RESULTS: 20, - MIN_SIMILARITY: 0.55, -}; - -// ═══════════════════════════════════════════════════════════════════════════ -// 工具函数 -// ═══════════════════════════════════════════════════════════════════════════ - -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; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// L0 向量检索 -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * 检索与 query 相似的 StateAtoms - * @returns {Array<{atom, similarity}>} - */ -export async function searchStateAtoms(queryVector, vectorConfig) { - const { chatId } = getContext(); - if (!chatId || !queryVector?.length) return []; - - // 检查 fingerprint - const meta = await getMeta(chatId); - const fp = getEngineFingerprint(vectorConfig); - if (meta.fingerprint && meta.fingerprint !== fp) { - xbLog.warn(MODULE_ID, 'fingerprint 不匹配,跳过 L0 召回'); - return []; - } - - // 获取向量 - const stateVectors = await getAllStateVectors(chatId); - if (!stateVectors.length) return []; - - // 获取 atoms(用于关联 semantic 等字段) - const atoms = getStateAtoms(); - const atomMap = new Map(atoms.map(a => [a.atomId, a])); - - // 计算相似度 - const scored = stateVectors - .map(sv => { - const atom = atomMap.get(sv.atomId); - if (!atom) return null; - - return { - atomId: sv.atomId, - floor: sv.floor, - similarity: cosineSimilarity(queryVector, sv.vector), - atom, - }; - }) - .filter(Boolean) - .filter(s => s.similarity >= CONFIG.MIN_SIMILARITY) - .sort((a, b) => b.similarity - a.similarity) - .slice(0, CONFIG.MAX_RESULTS); - - return scored; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Floor Bonus 构建 -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * 构建 L0 相关楼层的加权映射 - * @returns {Map} - */ -export function buildL0FloorBonus(l0Results, bonusFactor = 0.10) { - const floorBonus = new Map(); - - for (const r of l0Results || []) { - // 每个楼层只加一次,取最高相似度对应的 bonus - // 简化处理:统一加 bonusFactor,不区分相似度高低 - if (!floorBonus.has(r.floor)) { - floorBonus.set(r.floor, bonusFactor); - } - } - - return floorBonus; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 虚拟 Chunk 转换 -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * 将 L0 结果转换为虚拟 chunk 格式 - * 用于和 L1 chunks 统一处理 - */ -export function stateToVirtualChunks(l0Results) { - return (l0Results || []).map(r => ({ - chunkId: `state-${r.atomId}`, - floor: r.floor, - chunkIdx: -1, // 负值,排序时排在 L1 前面 - speaker: '📌', // 固定标记 - isUser: false, - text: r.atom.semantic, - textHash: null, - similarity: r.similarity, - isL0: true, // 标记字段 - // 保留原始 atom 信息 - _atom: r.atom, - })); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 每楼层稀疏去重 -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * 合并 L0 和 L1 chunks - * @param {Array} l0Chunks - L0 虚拟 chunks(带 similarity) - * @param {Array} l1Chunks - L1 真实 chunks(无 similarity) - * @param {number} limit - 每楼层上限 - * @returns {Array} 合并后的 chunks - */ -export function mergeAndSparsify(l0Chunks, l1Chunks, limit = 2) { - // 构建 L0 楼层 → 最高 similarity 映射 - const floorSimilarity = new Map(); - for (const c of (l0Chunks || [])) { - const existing = floorSimilarity.get(c.floor) || 0; - if ((c.similarity || 0) > existing) { - floorSimilarity.set(c.floor, c.similarity || 0); - } - } - - // L1 继承所属楼层的 L0 similarity - const l1WithScore = (l1Chunks || []).map(c => ({ - ...c, - similarity: floorSimilarity.get(c.floor) || 0.5, - })); - - // 合并并按相似度排序 - const all = [...(l0Chunks || []), ...l1WithScore] - .sort((a, b) => { - // 相似度优先 - const simDiff = (b.similarity || 0) - (a.similarity || 0); - if (Math.abs(simDiff) > 0.01) return simDiff; - - // 同楼层:L0 优先于 L1 - if (a.floor === b.floor) { - if (a.isL0 && !b.isL0) return -1; - if (!a.isL0 && b.isL0) return 1; - } - - // 按楼层升序 - return a.floor - b.floor; - }); - - // 每楼层稀疏去重 - const byFloor = new Map(); - - for (const c of all) { - const arr = byFloor.get(c.floor) || []; - if (arr.length < limit) { - arr.push(c); - byFloor.set(c.floor, arr); - } - } - - // 扁平化并保持排序 - return Array.from(byFloor.values()) - .flat() - .sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); -} - diff --git a/modules/story-summary/vector/retrieval/metrics.js b/modules/story-summary/vector/retrieval/metrics.js index 51162fb..5701f14 100644 --- a/modules/story-summary/vector/retrieval/metrics.js +++ b/modules/story-summary/vector/retrieval/metrics.js @@ -1,59 +1,60 @@ // ═══════════════════════════════════════════════════════════════════════════ -// Story Summary - Metrics Collector -// 召回质量指标收集与格式化 +// Story Summary - Metrics Collector (v2 - 统一命名) +// +// 命名规范: +// - 存储层用 L0/L1/L2/L3(StateAtom/Chunk/Event/Fact) +// - 指标层用语义名称:anchor/evidence/event/constraint/arc // ═══════════════════════════════════════════════════════════════════════════ /** * 创建空的指标对象 + * @returns {object} 指标对象 */ export function createMetrics() { return { - // L0 Query Understanding - l0: { + // Anchor (L0 StateAtoms) - 语义锚点 + anchor: { needRecall: false, - intent: '', focusEntities: [], queries: [], - implicitTopics: [], queryExpansionTime: 0, - atomsMatched: 0, + matched: 0, floorsHit: 0, - topAtoms: [], + topHits: [], }, - // L1 Constraints (Facts) - l1: { - factsTotal: 0, - factsInjected: 0, - factsFiltered: 0, + // Constraint (L3 Facts) - 世界约束 + constraint: { + total: 0, + filtered: 0, + injected: 0, tokens: 0, samples: [], }, - // L2 Narrative Retrieval - l2: { - eventsInStore: 0, - eventsConsidered: 0, - eventsSelected: 0, - byRecallType: { direct: 0, causal: 0, context: 0 }, + // Event (L2 Events) - 事件摘要 + event: { + inStore: 0, + considered: 0, + selected: 0, + byRecallType: { direct: 0, related: 0, causal: 0 }, similarityDistribution: { min: 0, max: 0, mean: 0, median: 0 }, - entityFilterStats: null, + entityFilter: null, causalChainDepth: 0, - causalEventsCount: 0, - entitiesLoaded: 0, + causalCount: 0, + entitiesUsed: 0, entityNames: [], - retrievalTime: 0, }, - // L3 Evidence Assembly - l3: { - floorsFromL0: 0, - l1Total: 0, - l1AfterCoarse: 0, - chunksInRange: 0, - chunksInRangeByType: { l0Virtual: 0, l1Real: 0 }, - chunksSelected: 0, - chunksSelectedByType: { l0Virtual: 0, l1Real: 0 }, + // Evidence (L1 Chunks) - 原文证据 + evidence: { + floorsFromAnchors: 0, + chunkTotal: 0, + chunkAfterCoarse: 0, + merged: 0, + mergedByType: { anchorVirtual: 0, chunkReal: 0 }, + selected: 0, + selectedByType: { anchorVirtual: 0, chunkReal: 0 }, contextPairsAdded: 0, tokens: 0, assemblyTime: 0, @@ -61,16 +62,22 @@ export function createMetrics() { beforeRerank: 0, afterRerank: 0, rerankTime: 0, - rerankScoreDistribution: null, + rerankScores: null, }, - // L4 Formatting - l4: { + // Arc - 人物弧光 + arc: { + injected: 0, + tokens: 0, + }, + + // Formatting - 格式化 + formatting: { sectionsIncluded: [], - formattingTime: 0, + time: 0, }, - // Budget Summary + // Budget Summary - 预算 budget: { total: 0, limit: 0, @@ -78,26 +85,26 @@ export function createMetrics() { breakdown: { constraints: 0, events: 0, - chunks: 0, - recentOrphans: 0, + distantEvidence: 0, + recentEvidence: 0, arcs: 0, }, }, - // Total Timing + // Timing - 计时 timing: { queryExpansion: 0, - l0Search: 0, - l1Constraints: 0, - l2Retrieval: 0, - l3Retrieval: 0, - l3Rerank: 0, - l3Assembly: 0, - l4Formatting: 0, + anchorSearch: 0, + constraintFilter: 0, + eventRetrieval: 0, + evidenceRetrieval: 0, + evidenceRerank: 0, + evidenceAssembly: 0, + formatting: 0, total: 0, }, - // Quality Indicators + // Quality Indicators - 质量指标 quality: { constraintCoverage: 100, eventPrecisionProxy: 0, @@ -109,6 +116,8 @@ export function createMetrics() { /** * 计算相似度分布统计 + * @param {number[]} similarities - 相似度数组 + * @returns {{min: number, max: number, mean: number, median: number}} */ export function calcSimilarityStats(similarities) { if (!similarities?.length) { @@ -128,6 +137,8 @@ export function calcSimilarityStats(similarities) { /** * 格式化指标为可读日志 + * @param {object} metrics - 指标对象 + * @returns {string} 格式化后的日志 */ export function formatMetricsLog(metrics) { const m = metrics; @@ -139,51 +150,50 @@ export function formatMetricsLog(metrics) { lines.push('════════════════════════════════════════'); lines.push(''); - // L0 Query Understanding - lines.push('[L0] Query Understanding'); - lines.push(`├─ need_recall: ${m.l0.needRecall}`); - if (m.l0.needRecall) { - lines.push(`├─ intent: ${m.l0.intent || 'mixed'}`); - lines.push(`├─ focus_entities: [${(m.l0.focusEntities || []).join(', ')}]`); - lines.push(`├─ queries: [${(m.l0.queries || []).slice(0, 3).join(', ')}]`); - lines.push(`├─ query_expansion_time: ${m.l0.queryExpansionTime}ms`); - lines.push(`├─ atoms_matched: ${m.l0.atomsMatched || 0}`); - lines.push(`└─ floors_hit: ${m.l0.floorsHit || 0}`); + // Anchor (L0 StateAtoms) + lines.push('[Anchor] L0 StateAtoms - 语义锚点'); + lines.push(`├─ need_recall: ${m.anchor.needRecall}`); + if (m.anchor.needRecall) { + lines.push(`├─ focus_entities: [${(m.anchor.focusEntities || []).join(', ')}]`); + lines.push(`├─ queries: [${(m.anchor.queries || []).slice(0, 3).join(', ')}]`); + lines.push(`├─ query_expansion_time: ${m.anchor.queryExpansionTime}ms`); + lines.push(`├─ matched: ${m.anchor.matched || 0}`); + lines.push(`└─ floors_hit: ${m.anchor.floorsHit || 0}`); } lines.push(''); - // L1 Constraints - lines.push('[L1] Constraints (Facts)'); - lines.push(`├─ facts_total: ${m.l1.factsTotal}`); - lines.push(`├─ facts_filtered: ${m.l1.factsFiltered || 0}`); - lines.push(`├─ facts_injected: ${m.l1.factsInjected}`); - lines.push(`├─ tokens: ${m.l1.tokens}`); - if (m.l1.samples && m.l1.samples.length > 0) { - lines.push(`└─ samples: "${m.l1.samples.slice(0, 2).join('", "')}"`); + // Constraint (L3 Facts) + lines.push('[Constraint] L3 Facts - 世界约束'); + lines.push(`├─ total: ${m.constraint.total}`); + lines.push(`├─ filtered: ${m.constraint.filtered || 0}`); + lines.push(`├─ injected: ${m.constraint.injected}`); + lines.push(`├─ tokens: ${m.constraint.tokens}`); + if (m.constraint.samples && m.constraint.samples.length > 0) { + lines.push(`└─ samples: "${m.constraint.samples.slice(0, 2).join('", "')}"`); } lines.push(''); - // L2 Narrative Retrieval - lines.push('[L2] Narrative Retrieval'); - lines.push(`├─ events_in_store: ${m.l2.eventsInStore}`); - lines.push(`├─ events_considered: ${m.l2.eventsConsidered}`); + // Event (L2 Events) + lines.push('[Event] L2 Events - 事件摘要'); + lines.push(`├─ in_store: ${m.event.inStore}`); + lines.push(`├─ considered: ${m.event.considered}`); - if (m.l2.entityFilterStats) { - const ef = m.l2.entityFilterStats; + if (m.event.entityFilter) { + const ef = m.event.entityFilter; lines.push(`├─ entity_filter:`); lines.push(`│ ├─ focus_entities: [${(ef.focusEntities || []).join(', ')}]`); - lines.push(`│ ├─ before_filter: ${ef.before}`); - lines.push(`│ ├─ after_filter: ${ef.after}`); - lines.push(`│ └─ filtered_out: ${ef.filtered}`); + lines.push(`│ ├─ before: ${ef.before}`); + lines.push(`│ ├─ after: ${ef.after}`); + lines.push(`│ └─ filtered: ${ef.filtered}`); } - lines.push(`├─ events_selected: ${m.l2.eventsSelected}`); + lines.push(`├─ selected: ${m.event.selected}`); lines.push(`├─ by_recall_type:`); - lines.push(`│ ├─ direct: ${m.l2.byRecallType.direct}`); - lines.push(`│ ├─ causal: ${m.l2.byRecallType.causal}`); - lines.push(`│ └─ context: ${m.l2.byRecallType.context}`); + lines.push(`│ ├─ direct: ${m.event.byRecallType.direct}`); + lines.push(`│ ├─ related: ${m.event.byRecallType.related}`); + lines.push(`│ └─ causal: ${m.event.byRecallType.causal}`); - const sim = m.l2.similarityDistribution; + const sim = m.event.similarityDistribution; if (sim && sim.max > 0) { lines.push(`├─ similarity_distribution:`); lines.push(`│ ├─ min: ${sim.min}`); @@ -192,93 +202,100 @@ export function formatMetricsLog(metrics) { lines.push(`│ └─ median: ${sim.median}`); } - lines.push(`├─ causal_chain: depth=${m.l2.causalChainDepth}, events=${m.l2.causalEventsCount}`); - lines.push(`├─ entities_loaded: ${m.l2.entitiesLoaded} [${(m.l2.entityNames || []).join(', ')}]`); - lines.push(`└─ retrieval_time: ${m.l2.retrievalTime}ms`); + lines.push(`├─ causal_chain: depth=${m.event.causalChainDepth}, count=${m.event.causalCount}`); + lines.push(`└─ entities_used: ${m.event.entitiesUsed} [${(m.event.entityNames || []).join(', ')}]`); lines.push(''); - // L3 Evidence Assembly - lines.push('[L3] Evidence Assembly'); - lines.push(`├─ floors_from_l0: ${m.l3.floorsFromL0}`); + // Evidence (L1 Chunks) + lines.push('[Evidence] L1 Chunks - 原文证据'); + lines.push(`├─ floors_from_anchors: ${m.evidence.floorsFromAnchors}`); - // L1 粗筛信息 - if (m.l3.l1Total > 0) { - lines.push(`├─ l1_coarse_filter:`); - lines.push(`│ ├─ total: ${m.l3.l1Total}`); - lines.push(`│ ├─ after: ${m.l3.l1AfterCoarse}`); - lines.push(`│ └─ filtered: ${m.l3.l1Total - m.l3.l1AfterCoarse}`); + // 粗筛信息 + if (m.evidence.chunkTotal > 0) { + lines.push(`├─ coarse_filter:`); + lines.push(`│ ├─ total: ${m.evidence.chunkTotal}`); + lines.push(`│ ├─ after: ${m.evidence.chunkAfterCoarse}`); + lines.push(`│ └─ filtered: ${m.evidence.chunkTotal - m.evidence.chunkAfterCoarse}`); } - lines.push(`├─ chunks_merged: ${m.l3.chunksInRange}`); - if (m.l3.chunksInRangeByType) { - const cir = m.l3.chunksInRangeByType; - lines.push(`│ ├─ l0_virtual: ${cir.l0Virtual || 0}`); - lines.push(`│ └─ l1_real: ${cir.l1Real || 0}`); + lines.push(`├─ merged: ${m.evidence.merged}`); + if (m.evidence.mergedByType) { + const mt = m.evidence.mergedByType; + lines.push(`│ ├─ anchor_virtual: ${mt.anchorVirtual || 0}`); + lines.push(`│ └─ chunk_real: ${mt.chunkReal || 0}`); } // Rerank 信息 - if (m.l3.rerankApplied) { + if (m.evidence.rerankApplied) { lines.push(`├─ rerank_applied: true`); - lines.push(`│ ├─ before: ${m.l3.beforeRerank}`); - lines.push(`│ ├─ after: ${m.l3.afterRerank}`); - lines.push(`│ └─ time: ${m.l3.rerankTime}ms`); - if (m.l3.rerankScoreDistribution) { - const rd = m.l3.rerankScoreDistribution; - lines.push(`├─ rerank_scores: min=${rd.min}, max=${rd.max}, mean=${rd.mean}`); + lines.push(`│ ├─ before: ${m.evidence.beforeRerank}`); + lines.push(`│ ├─ after: ${m.evidence.afterRerank}`); + lines.push(`│ └─ time: ${m.evidence.rerankTime}ms`); + if (m.evidence.rerankScores) { + const rs = m.evidence.rerankScores; + lines.push(`├─ rerank_scores: min=${rs.min}, max=${rs.max}, mean=${rs.mean}`); } } else { lines.push(`├─ rerank_applied: false`); } - lines.push(`├─ chunks_selected: ${m.l3.chunksSelected}`); - if (m.l3.chunksSelectedByType) { - const cs = m.l3.chunksSelectedByType; - lines.push(`│ ├─ l0_virtual: ${cs.l0Virtual || 0}`); - lines.push(`│ └─ l1_real: ${cs.l1Real || 0}`); + lines.push(`├─ selected: ${m.evidence.selected}`); + if (m.evidence.selectedByType) { + const st = m.evidence.selectedByType; + lines.push(`│ ├─ anchor_virtual: ${st.anchorVirtual || 0}`); + lines.push(`│ └─ chunk_real: ${st.chunkReal || 0}`); } - lines.push(`├─ context_pairs_added: ${m.l3.contextPairsAdded}`); - lines.push(`├─ tokens: ${m.l3.tokens}`); - lines.push(`└─ assembly_time: ${m.l3.assemblyTime}ms`); + lines.push(`├─ context_pairs_added: ${m.evidence.contextPairsAdded}`); + lines.push(`├─ tokens: ${m.evidence.tokens}`); + lines.push(`└─ assembly_time: ${m.evidence.assemblyTime}ms`); lines.push(''); - // L4 Formatting - lines.push('[L4] Prompt Formatting'); - lines.push(`├─ sections: [${(m.l4.sectionsIncluded || []).join(', ')}]`); - lines.push(`└─ formatting_time: ${m.l4.formattingTime}ms`); + // Arc + if (m.arc.injected > 0) { + lines.push('[Arc] 人物弧光'); + lines.push(`├─ injected: ${m.arc.injected}`); + lines.push(`└─ tokens: ${m.arc.tokens}`); + lines.push(''); + } + + // Formatting + lines.push('[Formatting] 格式化'); + lines.push(`├─ sections: [${(m.formatting.sectionsIncluded || []).join(', ')}]`); + lines.push(`└─ time: ${m.formatting.time}ms`); lines.push(''); // Budget Summary - lines.push('[Budget Summary]'); + lines.push('[Budget] 预算'); lines.push(`├─ total_tokens: ${m.budget.total}`); - lines.push(`├─ budget_limit: ${m.budget.limit}`); + lines.push(`├─ limit: ${m.budget.limit}`); lines.push(`├─ utilization: ${m.budget.utilization}%`); lines.push(`└─ breakdown:`); const bd = m.budget.breakdown || {}; - lines.push(` ├─ constraints (L1): ${bd.constraints || 0}`); - lines.push(` ├─ events (L2): ${bd.events || 0}`); - lines.push(` ├─ chunks (L3): ${bd.chunks || 0}`); - lines.push(` ├─ recent_orphans: ${bd.recentOrphans || 0}`); + lines.push(` ├─ constraints: ${bd.constraints || 0}`); + lines.push(` ├─ events: ${bd.events || 0}`); + lines.push(` ├─ distant_evidence: ${bd.distantEvidence || 0}`); + lines.push(` ├─ recent_evidence: ${bd.recentEvidence || 0}`); lines.push(` └─ arcs: ${bd.arcs || 0}`); lines.push(''); // Timing - lines.push('[Timing]'); + lines.push('[Timing] 计时'); lines.push(`├─ query_expansion: ${m.timing.queryExpansion}ms`); - lines.push(`├─ l0_search: ${m.timing.l0Search}ms`); - lines.push(`├─ l1_constraints: ${m.timing.l1Constraints}ms`); - lines.push(`├─ l2_retrieval: ${m.timing.l2Retrieval}ms`); - lines.push(`├─ l3_retrieval: ${m.timing.l3Retrieval}ms`); - if (m.timing.l3Rerank > 0) { - lines.push(`├─ l3_rerank: ${m.timing.l3Rerank}ms`); + lines.push(`├─ anchor_search: ${m.timing.anchorSearch}ms`); + lines.push(`├─ constraint_filter: ${m.timing.constraintFilter}ms`); + lines.push(`├─ event_retrieval: ${m.timing.eventRetrieval}ms`); + lines.push(`├─ evidence_retrieval: ${m.timing.evidenceRetrieval}ms`); + if (m.timing.evidenceRerank > 0) { + lines.push(`├─ evidence_rerank: ${m.timing.evidenceRerank}ms`); } - lines.push(`├─ l3_assembly: ${m.timing.l3Assembly}ms`); - lines.push(`├─ l4_formatting: ${m.timing.l4Formatting}ms`); + lines.push(`├─ evidence_assembly: ${m.timing.evidenceAssembly}ms`); + lines.push(`├─ formatting: ${m.timing.formatting}ms`); lines.push(`└─ total: ${m.timing.total}ms`); lines.push(''); // Quality Indicators - lines.push('[Quality Indicators]'); + lines.push('[Quality] 质量指标'); lines.push(`├─ constraint_coverage: ${m.quality.constraintCoverage}%`); lines.push(`├─ event_precision_proxy: ${m.quality.eventPrecisionProxy}`); lines.push(`├─ evidence_density: ${m.quality.evidenceDensity}%`); @@ -302,25 +319,27 @@ export function formatMetricsLog(metrics) { /** * 检测潜在问题 + * @param {object} metrics - 指标对象 + * @returns {string[]} 问题列表 */ export function detectIssues(metrics) { const issues = []; const m = metrics; - // 召回比例问题 - if (m.l2.eventsConsidered > 0) { - const selectRatio = m.l2.eventsSelected / m.l2.eventsConsidered; + // 事件召回比例问题 + if (m.event.considered > 0) { + const selectRatio = m.event.selected / m.event.considered; if (selectRatio < 0.1) { issues.push(`Event selection ratio too low (${(selectRatio * 100).toFixed(1)}%) - threshold may be too high`); } - if (selectRatio > 0.6 && m.l2.eventsConsidered > 10) { + if (selectRatio > 0.6 && m.event.considered > 10) { issues.push(`Event selection ratio high (${(selectRatio * 100).toFixed(1)}%) - may include noise`); } } // 实体过滤问题 - if (m.l2.entityFilterStats) { - const ef = m.l2.entityFilterStats; + if (m.event.entityFilter) { + const ef = m.event.entityFilter; if (ef.filtered === 0 && ef.before > 10) { issues.push(`No events filtered by entity - focus entities may be too broad or missing`); } @@ -330,58 +349,58 @@ export function detectIssues(metrics) { } // 相似度问题 - if (m.l2.similarityDistribution && m.l2.similarityDistribution.min > 0 && m.l2.similarityDistribution.min < 0.5) { - issues.push(`Low similarity events included (min=${m.l2.similarityDistribution.min})`); + if (m.event.similarityDistribution && m.event.similarityDistribution.min > 0 && m.event.similarityDistribution.min < 0.5) { + issues.push(`Low similarity events included (min=${m.event.similarityDistribution.min})`); } // 因果链问题 - if (m.l2.eventsSelected > 0 && m.l2.causalEventsCount === 0 && m.l2.byRecallType.direct === 0) { + if (m.event.selected > 0 && m.event.causalCount === 0 && m.event.byRecallType.direct === 0) { issues.push('No direct or causal events - query expansion may be inaccurate'); } - // L0 atoms 问题 - if ((m.l0.atomsMatched || 0) === 0) { - issues.push('L0 atoms not matched - may need to generate anchors'); + // 锚点匹配问题 + if ((m.anchor.matched || 0) === 0) { + issues.push('No anchors matched - may need to generate anchors'); } - // L1 粗筛问题 - if (m.l3.l1Total > 0 && m.l3.l1AfterCoarse > 0) { - const coarseFilterRatio = 1 - (m.l3.l1AfterCoarse / m.l3.l1Total); + // 证据粗筛问题 + if (m.evidence.chunkTotal > 0 && m.evidence.chunkAfterCoarse > 0) { + const coarseFilterRatio = 1 - (m.evidence.chunkAfterCoarse / m.evidence.chunkTotal); if (coarseFilterRatio > 0.9) { - issues.push(`Very high L1 coarse filter ratio (${(coarseFilterRatio * 100).toFixed(0)}%) - query may be too specific`); + issues.push(`Very high evidence coarse filter ratio (${(coarseFilterRatio * 100).toFixed(0)}%) - query may be too specific`); } } // Rerank 相关问题 - if (m.l3.rerankApplied) { - if (m.l3.beforeRerank > 0 && m.l3.afterRerank > 0) { - const filterRatio = 1 - (m.l3.afterRerank / m.l3.beforeRerank); + if (m.evidence.rerankApplied) { + if (m.evidence.beforeRerank > 0 && m.evidence.afterRerank > 0) { + const filterRatio = 1 - (m.evidence.afterRerank / m.evidence.beforeRerank); if (filterRatio > 0.7) { issues.push(`High rerank filter ratio (${(filterRatio * 100).toFixed(0)}%) - many irrelevant chunks removed`); } } - if (m.l3.rerankScoreDistribution) { - const rd = m.l3.rerankScoreDistribution; - if (rd.max < 0.5) { - issues.push(`Low rerank scores (max=${rd.max}) - query may be poorly matched`); + if (m.evidence.rerankScores) { + const rs = m.evidence.rerankScores; + if (rs.max < 0.5) { + issues.push(`Low rerank scores (max=${rs.max}) - query may be poorly matched`); } - if (rd.mean < 0.3) { - issues.push(`Very low average rerank score (mean=${rd.mean}) - context may be weak`); + if (rs.mean < 0.3) { + issues.push(`Very low average rerank score (mean=${rs.mean}) - context may be weak`); } } - if (m.l3.rerankTime > 2000) { - issues.push(`Slow rerank (${m.l3.rerankTime}ms) - may affect response time`); + if (m.evidence.rerankTime > 2000) { + issues.push(`Slow rerank (${m.evidence.rerankTime}ms) - may affect response time`); } } // 证据密度问题 - if (m.l3.chunksSelected > 0 && m.l3.chunksSelectedByType) { - const l1Real = m.l3.chunksSelectedByType.l1Real || 0; - const density = l1Real / m.l3.chunksSelected; - if (density < 0.3 && m.l3.chunksSelected > 10) { - issues.push(`Low L1 chunk ratio in selected (${(density * 100).toFixed(0)}%) - may lack concrete evidence`); + if (m.evidence.selected > 0 && m.evidence.selectedByType) { + const chunkReal = m.evidence.selectedByType.chunkReal || 0; + const density = chunkReal / m.evidence.selected; + if (density < 0.3 && m.evidence.selected > 10) { + issues.push(`Low real chunk ratio in selected (${(density * 100).toFixed(0)}%) - may lack concrete evidence`); } } diff --git a/modules/story-summary/vector/retrieval/recall.js b/modules/story-summary/vector/retrieval/recall.js index 9da6ea5..3e1b341 100644 --- a/modules/story-summary/vector/retrieval/recall.js +++ b/modules/story-summary/vector/retrieval/recall.js @@ -1,5 +1,9 @@ // ═══════════════════════════════════════════════════════════════════════════ -// Story Summary - Recall Engine (v4 - L0 无上限 + L1 粗筛) +// Story Summary - Recall Engine (v5 - 统一命名) +// +// 命名规范: +// - 存储层用 L0/L1/L2/L3(StateAtom/Chunk/Event/Fact) +// - 召回层用语义名称:anchor/evidence/event/constraint // ═══════════════════════════════════════════════════════════════════════════ import { getAllEventVectors, getChunksByFloors, getMeta, getChunkVectorsByIds } from '../storage/chunk-store.js'; @@ -22,17 +26,17 @@ const CONFIG = { // Query Expansion QUERY_EXPANSION_TIMEOUT: 6000, - // L0 配置 - 去掉硬上限,提高阈值 - L0_MIN_SIMILARITY: 0.58, + // Anchor (L0 StateAtoms) 配置 + ANCHOR_MIN_SIMILARITY: 0.58, - // L1 粗筛配置 - L1_MAX_CANDIDATES: 100, + // Evidence (L1 Chunks) 粗筛配置 + EVIDENCE_COARSE_MAX: 100, - // L2 配置 - L2_CANDIDATE_MAX: 100, - L2_SELECT_MAX: 50, - L2_MIN_SIMILARITY: 0.55, - L2_MMR_LAMBDA: 0.72, + // Event (L2 Events) 配置 + EVENT_CANDIDATE_MAX: 100, + EVENT_SELECT_MAX: 50, + EVENT_MIN_SIMILARITY: 0.55, + EVENT_MMR_LAMBDA: 0.72, // Rerank 配置 RERANK_THRESHOLD: 80, @@ -48,6 +52,12 @@ const CONFIG = { // 工具函数 // ═══════════════════════════════════════════════════════════════════════════ +/** + * 计算余弦相似度 + * @param {number[]} a - 向量A + * @param {number[]} b - 向量B + * @returns {number} 相似度 [0, 1] + */ function cosineSimilarity(a, b) { if (!a?.length || !b?.length || a.length !== b.length) return 0; let dot = 0, nA = 0, nB = 0; @@ -59,6 +69,11 @@ function cosineSimilarity(a, b) { return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0; } +/** + * 标准化字符串(用于实体匹配) + * @param {string} s - 输入字符串 + * @returns {string} 标准化后的字符串 + */ function normalize(s) { return String(s || '') .normalize('NFKC') @@ -67,10 +82,21 @@ function normalize(s) { .toLowerCase(); } +/** + * 清理文本用于召回 + * @param {string} text - 原始文本 + * @returns {string} 清理后的文本 + */ function cleanForRecall(text) { return filterText(text).replace(/\[tts:[^\]]*\]/gi, '').trim(); } +/** + * 从 focus entities 中移除用户名 + * @param {string[]} focusEntities - 焦点实体列表 + * @param {string} userName - 用户名 + * @returns {string[]} 过滤后的实体列表 + */ function removeUserNameFromFocus(focusEntities, userName) { const u = normalize(userName); if (!u) return Array.isArray(focusEntities) ? focusEntities : []; @@ -81,6 +107,13 @@ function removeUserNameFromFocus(focusEntities, userName) { .filter(e => normalize(e) !== u); } +/** + * 构建 rerank 查询文本 + * @param {object} expansion - query expansion 结果 + * @param {object[]} lastMessages - 最近消息 + * @param {string} pendingUserMessage - 待发送的用户消息 + * @returns {string} 查询文本 + */ function buildRerankQuery(expansion, lastMessages, pendingUserMessage) { const parts = []; @@ -109,9 +142,18 @@ function buildRerankQuery(expansion, lastMessages, pendingUserMessage) { } // ═══════════════════════════════════════════════════════════════════════════ -// MMR 选择 +// MMR 选择算法 // ═══════════════════════════════════════════════════════════════════════════ +/** + * Maximal Marginal Relevance 选择 + * @param {object[]} candidates - 候选项 + * @param {number} k - 选择数量 + * @param {number} lambda - 相关性/多样性权衡参数 + * @param {Function} getVector - 获取向量的函数 + * @param {Function} getScore - 获取分数的函数 + * @returns {object[]} 选中的候选项 + */ function mmrSelect(candidates, k, lambda, getVector, getScore) { const selected = []; const ids = new Set(); @@ -152,31 +194,38 @@ function mmrSelect(candidates, k, lambda, getVector, getScore) { } // ═══════════════════════════════════════════════════════════════════════════ -// L0 检索:无上限,阈值过滤 +// [Anchors] L0 StateAtoms 检索 // ═══════════════════════════════════════════════════════════════════════════ -async function searchL0(queryVector, vectorConfig, metrics) { +/** + * 检索语义锚点(L0 StateAtoms) + * @param {number[]} queryVector - 查询向量 + * @param {object} vectorConfig - 向量配置 + * @param {object} metrics - 指标对象 + * @returns {Promise<{hits: object[], floors: Set}>} + */ +async function recallAnchors(queryVector, vectorConfig, metrics) { const { chatId } = getContext(); if (!chatId || !queryVector?.length) { - return { atoms: [], floors: new Set() }; + return { hits: [], floors: new Set() }; } const meta = await getMeta(chatId); const fp = getEngineFingerprint(vectorConfig); if (meta.fingerprint && meta.fingerprint !== fp) { - xbLog.warn(MODULE_ID, 'L0 fingerprint 不匹配'); - return { atoms: [], floors: new Set() }; + xbLog.warn(MODULE_ID, 'Anchor fingerprint 不匹配'); + return { hits: [], floors: new Set() }; } const stateVectors = await getAllStateVectors(chatId); if (!stateVectors.length) { - return { atoms: [], floors: new Set() }; + return { hits: [], floors: new Set() }; } const atomsList = getStateAtoms(); const atomMap = new Map(atomsList.map(a => [a.atomId, a])); - // ★ 只按阈值过滤,不设硬上限 + // 按阈值过滤,不设硬上限 const scored = stateVectors .map(sv => { const atom = atomMap.get(sv.atomId); @@ -190,69 +239,79 @@ async function searchL0(queryVector, vectorConfig, metrics) { }; }) .filter(Boolean) - .filter(s => s.similarity >= CONFIG.L0_MIN_SIMILARITY) + .filter(s => s.similarity >= CONFIG.ANCHOR_MIN_SIMILARITY) .sort((a, b) => b.similarity - a.similarity); const floors = new Set(scored.map(s => s.floor)); if (metrics) { - metrics.l0.atomsMatched = scored.length; - metrics.l0.floorsHit = floors.size; - metrics.l0.topAtoms = scored.slice(0, 5).map(s => ({ + metrics.anchor.matched = scored.length; + metrics.anchor.floorsHit = floors.size; + metrics.anchor.topHits = scored.slice(0, 5).map(s => ({ floor: s.floor, semantic: s.atom?.semantic?.slice(0, 50), similarity: Math.round(s.similarity * 1000) / 1000, })); } - return { atoms: scored, floors }; + return { hits: scored, floors }; } // ═══════════════════════════════════════════════════════════════════════════ -// 统计 chunks 类型构成 +// [Evidence] L1 Chunks 拉取 + 粗筛 + Rerank // ═══════════════════════════════════════════════════════════════════════════ -function countChunksByType(chunks) { - let l0Virtual = 0; - let l1Real = 0; +/** + * 统计 evidence 类型构成 + * @param {object[]} chunks - chunk 列表 + * @returns {{anchorVirtual: number, chunkReal: number}} + */ +function countEvidenceByType(chunks) { + let anchorVirtual = 0; + let chunkReal = 0; for (const c of chunks || []) { - if (c.isL0) { - l0Virtual++; + if (c.isAnchorVirtual) { + anchorVirtual++; } else { - l1Real++; + chunkReal++; } } - return { l0Virtual, l1Real }; + return { anchorVirtual, chunkReal }; } -// ═══════════════════════════════════════════════════════════════════════════ -// L3 拉取 + L1 粗筛 + Rerank -// ═══════════════════════════════════════════════════════════════════════════ - -async function getChunksFromL0Floors(l0Floors, l0Atoms, queryVector, queryText, metrics) { +/** + * 根据锚点命中楼层拉取证据(L1 Chunks) + * @param {Set} anchorFloors - 锚点命中的楼层 + * @param {object[]} anchorHits - 锚点命中结果 + * @param {number[]} queryVector - 查询向量 + * @param {string} queryText - rerank 查询文本 + * @param {object} metrics - 指标对象 + * @returns {Promise} 证据 chunks + */ +async function pullEvidenceByFloors(anchorFloors, anchorHits, queryVector, queryText, metrics) { const { chatId } = getContext(); - if (!chatId || !l0Floors.size) { + if (!chatId || !anchorFloors.size) { return []; } - const floorArray = Array.from(l0Floors); + const floorArray = Array.from(anchorFloors); - // 1. 构建 L0 虚拟 chunks - const l0VirtualChunks = (l0Atoms || []).map(a => ({ - chunkId: `state-${a.atomId}`, + // 1. 构建锚点虚拟 chunks(来自 L0 StateAtoms) + const anchorVirtualChunks = (anchorHits || []).map(a => ({ + chunkId: `anchor-${a.atomId}`, floor: a.floor, chunkIdx: -1, speaker: '📌', isUser: false, text: a.atom?.semantic || '', similarity: a.similarity, - isL0: true, + isAnchorVirtual: true, _atom: a.atom, })); - // 2. 拉取 L1 chunks + // 2. 拉取真实 chunks(来自 L1) let dbChunks = []; try { dbChunks = await getChunksByFloors(chatId, floorArray); @@ -260,8 +319,8 @@ async function getChunksFromL0Floors(l0Floors, l0Atoms, queryVector, queryText, xbLog.warn(MODULE_ID, '从 DB 拉取 chunks 失败', e); } - // 3. ★ L1 向量粗筛 - let l1Filtered = []; + // 3. L1 向量粗筛 + let coarseFiltered = []; if (dbChunks.length > 0 && queryVector?.length) { const chunkIds = dbChunks.map(c => c.chunkId); let chunkVectors = []; @@ -270,54 +329,51 @@ async function getChunksFromL0Floors(l0Floors, l0Atoms, queryVector, queryText, } catch (e) { xbLog.warn(MODULE_ID, 'L1 向量获取失败', e); } - + const vectorMap = new Map(chunkVectors.map(v => [v.chunkId, v.vector])); - l1Filtered = dbChunks + coarseFiltered = dbChunks .map(c => { const vec = vectorMap.get(c.chunkId); if (!vec?.length) return null; return { ...c, - isL0: false, + isAnchorVirtual: false, similarity: cosineSimilarity(queryVector, vec), }; }) .filter(Boolean) .sort((a, b) => b.similarity - a.similarity) - .slice(0, CONFIG.L1_MAX_CANDIDATES); + .slice(0, CONFIG.EVIDENCE_COARSE_MAX); } // 4. 合并 - const allChunks = [...l0VirtualChunks, ...l1Filtered]; + const allEvidence = [...anchorVirtualChunks, ...coarseFiltered]; - // ★ 更新 metrics + // 更新 metrics if (metrics) { - metrics.l3.floorsFromL0 = floorArray.length; - metrics.l3.l1Total = dbChunks.length; - metrics.l3.l1AfterCoarse = l1Filtered.length; - metrics.l3.chunksInRange = l0VirtualChunks.length + l1Filtered.length; - metrics.l3.chunksInRangeByType = { - l0Virtual: l0VirtualChunks.length, - l1Real: l1Filtered.length, - }; + metrics.evidence.floorsFromAnchors = floorArray.length; + metrics.evidence.chunkTotal = dbChunks.length; + metrics.evidence.chunkAfterCoarse = coarseFiltered.length; + metrics.evidence.merged = allEvidence.length; + metrics.evidence.mergedByType = countEvidenceByType(allEvidence); } // 5. 是否需要 Rerank - if (allChunks.length <= CONFIG.RERANK_THRESHOLD) { + if (allEvidence.length <= CONFIG.RERANK_THRESHOLD) { if (metrics) { - metrics.l3.rerankApplied = false; - metrics.l3.chunksSelected = allChunks.length; - metrics.l3.chunksSelectedByType = countChunksByType(allChunks); + metrics.evidence.rerankApplied = false; + metrics.evidence.selected = allEvidence.length; + metrics.evidence.selectedByType = countEvidenceByType(allEvidence); } - return allChunks; + return allEvidence; } // 6. Rerank 精排 const T_Rerank_Start = performance.now(); - const reranked = await rerankChunks(queryText, allChunks, { + const reranked = await rerankChunks(queryText, allEvidence, { topN: CONFIG.RERANK_TOP_N, minScore: CONFIG.RERANK_MIN_SCORE, }); @@ -325,18 +381,18 @@ async function getChunksFromL0Floors(l0Floors, l0Atoms, queryVector, queryText, const rerankTime = Math.round(performance.now() - T_Rerank_Start); if (metrics) { - metrics.l3.rerankApplied = true; - metrics.l3.beforeRerank = allChunks.length; - metrics.l3.afterRerank = reranked.length; - metrics.l3.chunksSelected = reranked.length; - metrics.l3.chunksSelectedByType = countChunksByType(reranked); - metrics.l3.rerankTime = rerankTime; - metrics.timing.l3Rerank = rerankTime; + metrics.evidence.rerankApplied = true; + metrics.evidence.beforeRerank = allEvidence.length; + metrics.evidence.afterRerank = reranked.length; + metrics.evidence.selected = reranked.length; + metrics.evidence.selectedByType = countEvidenceByType(reranked); + metrics.evidence.rerankTime = rerankTime; + metrics.timing.evidenceRerank = rerankTime; const scores = reranked.map(c => c._rerankScore || 0).filter(s => s > 0); if (scores.length > 0) { scores.sort((a, b) => a - b); - metrics.l3.rerankScoreDistribution = { + metrics.evidence.rerankScores = { min: Number(scores[0].toFixed(3)), max: Number(scores[scores.length - 1].toFixed(3)), mean: Number((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(3)), @@ -344,16 +400,25 @@ async function getChunksFromL0Floors(l0Floors, l0Atoms, queryVector, queryText, } } - xbLog.info(MODULE_ID, `L3: ${dbChunks.length} L1 → ${l1Filtered.length} 粗筛 → ${reranked.length} Rerank (${rerankTime}ms)`); + xbLog.info(MODULE_ID, `Evidence: ${dbChunks.length} L1 → ${coarseFiltered.length} coarse → ${reranked.length} rerank (${rerankTime}ms)`); return reranked; } // ═══════════════════════════════════════════════════════════════════════════ -// L2 检索(保持不变) +// [Events] L2 Events 检索 // ═══════════════════════════════════════════════════════════════════════════ -async function searchL2Events(queryVector, allEvents, vectorConfig, focusEntities, metrics) { +/** + * 检索事件(L2 Events) + * @param {number[]} queryVector - 查询向量 + * @param {object[]} allEvents - 所有事件 + * @param {object} vectorConfig - 向量配置 + * @param {string[]} focusEntities - 焦点实体 + * @param {object} metrics - 指标对象 + * @returns {Promise} 事件命中结果 + */ +async function recallEvents(queryVector, allEvents, vectorConfig, focusEntities, metrics) { const { chatId } = getContext(); if (!chatId || !queryVector?.length || !allEvents?.length) { return []; @@ -362,7 +427,7 @@ async function searchL2Events(queryVector, allEvents, vectorConfig, focusEntitie const meta = await getMeta(chatId); const fp = getEngineFingerprint(vectorConfig); if (meta.fingerprint && meta.fingerprint !== fp) { - xbLog.warn(MODULE_ID, 'L2 fingerprint 不匹配'); + xbLog.warn(MODULE_ID, 'Event fingerprint 不匹配'); return []; } @@ -395,18 +460,19 @@ async function searchL2Events(queryVector, allEvents, vectorConfig, focusEntitie }); if (metrics) { - metrics.l2.eventsInStore = allEvents.length; + metrics.event.inStore = allEvents.length; } let candidates = scored - .filter(s => s.similarity >= CONFIG.L2_MIN_SIMILARITY) + .filter(s => s.similarity >= CONFIG.EVENT_MIN_SIMILARITY) .sort((a, b) => b.similarity - a.similarity) - .slice(0, CONFIG.L2_CANDIDATE_MAX); + .slice(0, CONFIG.EVENT_CANDIDATE_MAX); if (metrics) { - metrics.l2.eventsConsidered = candidates.length; + metrics.event.considered = candidates.length; } + // 实体过滤 if (focusSet.size > 0) { const beforeFilter = candidates.length; @@ -416,7 +482,7 @@ async function searchL2Events(queryVector, allEvents, vectorConfig, focusEntitie }); if (metrics) { - metrics.l2.entityFilterStats = { + metrics.event.entityFilter = { focusEntities: focusEntities || [], before: beforeFilter, after: candidates.length, @@ -425,21 +491,22 @@ async function searchL2Events(queryVector, allEvents, vectorConfig, focusEntitie } } + // MMR 选择 const selected = mmrSelect( candidates, - CONFIG.L2_SELECT_MAX, - CONFIG.L2_MMR_LAMBDA, + CONFIG.EVENT_SELECT_MAX, + CONFIG.EVENT_MMR_LAMBDA, c => c.vector, c => c.similarity ); let directCount = 0; - let contextCount = 0; + let relatedCount = 0; const results = selected.map(s => { - const recallType = s._hasEntityMatch ? 'DIRECT' : 'SIMILAR'; + const recallType = s._hasEntityMatch ? 'DIRECT' : 'RELATED'; if (recallType === 'DIRECT') directCount++; - else contextCount++; + else relatedCount++; return { event: s.event, @@ -450,18 +517,23 @@ async function searchL2Events(queryVector, allEvents, vectorConfig, focusEntitie }); if (metrics) { - metrics.l2.eventsSelected = results.length; - metrics.l2.byRecallType = { direct: directCount, context: contextCount, causal: 0 }; - metrics.l2.similarityDistribution = calcSimilarityStats(results.map(r => r.similarity)); + metrics.event.selected = results.length; + metrics.event.byRecallType = { direct: directCount, related: relatedCount, causal: 0 }; + metrics.event.similarityDistribution = calcSimilarityStats(results.map(r => r.similarity)); } return results; } // ═══════════════════════════════════════════════════════════════════════════ -// 因果链追溯(保持不变) +// [Causation] 因果链追溯 // ═══════════════════════════════════════════════════════════════════════════ +/** + * 构建事件索引 + * @param {object[]} allEvents - 所有事件 + * @returns {Map} 事件索引 + */ function buildEventIndex(allEvents) { const map = new Map(); for (const e of allEvents || []) { @@ -470,7 +542,14 @@ function buildEventIndex(allEvents) { return map; } -function traceCausalAncestors(recalledEvents, eventIndex, maxDepth = CONFIG.CAUSAL_CHAIN_MAX_DEPTH) { +/** + * 追溯因果链 + * @param {object[]} eventHits - 事件命中结果 + * @param {Map} eventIndex - 事件索引 + * @param {number} maxDepth - 最大深度 + * @returns {{results: object[], maxDepth: number}} + */ +function traceCausation(eventHits, eventIndex, maxDepth = CONFIG.CAUSAL_CHAIN_MAX_DEPTH) { const out = new Map(); const idRe = /^evt-\d+$/; let maxActualDepth = 0; @@ -497,7 +576,7 @@ function traceCausalAncestors(recalledEvents, eventIndex, maxDepth = CONFIG.CAUS } } - for (const r of recalledEvents || []) { + for (const r of eventHits || []) { const rid = r?.event?.id; if (!rid) continue; for (const cid of (r.event?.causedBy || [])) { @@ -520,6 +599,13 @@ function traceCausalAncestors(recalledEvents, eventIndex, maxDepth = CONFIG.CAUS // 辅助函数 // ═══════════════════════════════════════════════════════════════════════════ +/** + * 获取最近消息 + * @param {object[]} chat - 聊天记录 + * @param {number} count - 消息数量 + * @param {boolean} excludeLastAi - 是否排除最后的 AI 消息 + * @returns {object[]} 最近消息 + */ function getLastMessages(chat, count = 4, excludeLastAi = false) { if (!chat?.length) return []; @@ -532,6 +618,13 @@ function getLastMessages(chat, count = 4, excludeLastAi = false) { return messages.slice(-count); } +/** + * 构建查询文本 + * @param {object[]} chat - 聊天记录 + * @param {number} count - 消息数量 + * @param {boolean} excludeLastAi - 是否排除最后的 AI 消息 + * @returns {string} 查询文本 + */ export function buildQueryText(chat, count = 2, excludeLastAi = false) { if (!chat?.length) return ''; @@ -551,6 +644,14 @@ export function buildQueryText(chat, count = 2, excludeLastAi = false) { // 主函数 // ═══════════════════════════════════════════════════════════════════════════ +/** + * 执行记忆召回 + * @param {string} queryText - 查询文本 + * @param {object[]} allEvents - 所有事件(L2) + * @param {object} vectorConfig - 向量配置 + * @param {object} options - 选项 + * @returns {Promise} 召回结果 + */ export async function recallMemory(queryText, allEvents, vectorConfig, options = {}) { const T0 = performance.now(); const { chat, name1 } = getContext(); @@ -559,8 +660,16 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options = const metrics = createMetrics(); if (!allEvents?.length) { - metrics.l0.needRecall = false; - return { events: [], chunks: [], causalEvents: [], focusEntities: [], elapsed: 0, logText: 'No events.', metrics }; + metrics.anchor.needRecall = false; + return { + events: [], + evidenceChunks: [], + causalChain: [], + focusEntities: [], + elapsed: 0, + logText: 'No events.', + metrics, + }; } // ═══════════════════════════════════════════════════════════════════════ @@ -587,11 +696,11 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options = const focusEntities = removeUserNameFromFocus(expansion.focus, name1); - metrics.l0.needRecall = true; - metrics.l0.focusEntities = focusEntities; - metrics.l0.queries = expansion.queries || []; - metrics.l0.queryExpansionTime = Math.round(performance.now() - T_QE_Start); - metrics.timing.queryExpansion = metrics.l0.queryExpansionTime; + metrics.anchor.needRecall = true; + metrics.anchor.focusEntities = focusEntities; + metrics.anchor.queries = expansion.queries || []; + metrics.anchor.queryExpansionTime = Math.round(performance.now() - T_QE_Start); + metrics.timing.queryExpansion = metrics.anchor.queryExpansionTime; // ═══════════════════════════════════════════════════════════════════════ // Step 2: 向量化查询 @@ -604,54 +713,70 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options = } catch (e) { xbLog.error(MODULE_ID, '向量化失败', e); metrics.timing.total = Math.round(performance.now() - T0); - return { events: [], chunks: [], causalEvents: [], focusEntities, elapsed: metrics.timing.total, logText: 'Embedding failed.', metrics }; + return { + events: [], + evidenceChunks: [], + causalChain: [], + focusEntities, + elapsed: metrics.timing.total, + logText: 'Embedding failed.', + metrics, + }; } if (!queryVector?.length) { metrics.timing.total = Math.round(performance.now() - T0); - return { events: [], chunks: [], causalEvents: [], focusEntities, elapsed: metrics.timing.total, logText: 'Empty query vector.', metrics }; + return { + events: [], + evidenceChunks: [], + causalChain: [], + focusEntities, + elapsed: metrics.timing.total, + logText: 'Empty query vector.', + metrics, + }; } // ═══════════════════════════════════════════════════════════════════════ - // Step 3: L0 检索 + // Step 3: Anchor (L0) 检索 // ═══════════════════════════════════════════════════════════════════════ - const T_L0_Start = performance.now(); + const T_Anchor_Start = performance.now(); - const { atoms: l0Atoms, floors: l0Floors } = await searchL0(queryVector, vectorConfig, metrics); + const { hits: anchorHits, floors: anchorFloors } = await recallAnchors(queryVector, vectorConfig, metrics); - metrics.timing.l0Search = Math.round(performance.now() - T_L0_Start); + metrics.timing.anchorSearch = Math.round(performance.now() - T_Anchor_Start); // ═══════════════════════════════════════════════════════════════════════ - // Step 4: L3 拉取 + L1 粗筛 + Rerank + // Step 4: Evidence (L1) 拉取 + 粗筛 + Rerank // ═══════════════════════════════════════════════════════════════════════ - const T_L3_Start = performance.now(); + const T_Evidence_Start = performance.now(); const rerankQuery = buildRerankQuery(expansion, lastMessages, pendingUserMessage); - const chunks = await getChunksFromL0Floors(l0Floors, l0Atoms, queryVector, rerankQuery, metrics); + const evidenceChunks = await pullEvidenceByFloors(anchorFloors, anchorHits, queryVector, rerankQuery, metrics); - metrics.timing.l3Retrieval = Math.round(performance.now() - T_L3_Start); + metrics.timing.evidenceRetrieval = Math.round(performance.now() - T_Evidence_Start); // ═══════════════════════════════════════════════════════════════════════ - // Step 5: L2 独立检索 + // Step 5: Event (L2) 独立检索 // ═══════════════════════════════════════════════════════════════════════ - const T_L2_Start = performance.now(); + const T_Event_Start = performance.now(); - const eventResults = await searchL2Events(queryVector, allEvents, vectorConfig, focusEntities, metrics); + const eventHits = await recallEvents(queryVector, allEvents, vectorConfig, focusEntities, metrics); - metrics.timing.l2Retrieval = Math.round(performance.now() - T_L2_Start); + metrics.timing.eventRetrieval = Math.round(performance.now() - T_Event_Start); // ═══════════════════════════════════════════════════════════════════════ // Step 6: 因果链追溯 // ═══════════════════════════════════════════════════════════════════════ const eventIndex = buildEventIndex(allEvents); - const { results: causalMap, maxDepth: causalMaxDepth } = traceCausalAncestors(eventResults, eventIndex); + const { results: causalMap, maxDepth: causalMaxDepth } = traceCausation(eventHits, eventIndex); - const recalledIdSet = new Set(eventResults.map(x => x?.event?.id).filter(Boolean)); - const causalEvents = causalMap + const recalledIdSet = new Set(eventHits.map(x => x?.event?.id).filter(Boolean)); + const causalChain = causalMap .filter(x => x?.event?.id && !recalledIdSet.has(x.event.id)) .map(x => ({ event: x.event, @@ -661,11 +786,11 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options = chainFrom: x.chainFrom, })); - if (metrics.l2.byRecallType) { - metrics.l2.byRecallType.causal = causalEvents.length; + if (metrics.event.byRecallType) { + metrics.event.byRecallType.causal = causalChain.length; } - metrics.l2.causalChainDepth = causalMaxDepth; - metrics.l2.causalEventsCount = causalEvents.length; + metrics.event.causalChainDepth = causalMaxDepth; + metrics.event.causalCount = causalChain.length; // ═══════════════════════════════════════════════════════════════════════ // 完成 @@ -673,24 +798,24 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options = metrics.timing.total = Math.round(performance.now() - T0); - metrics.l2.entityNames = focusEntities; - metrics.l2.entitiesLoaded = focusEntities.length; + metrics.event.entityNames = focusEntities; + metrics.event.entitiesUsed = focusEntities.length; - console.group('%c[Recall v4]', 'color: #7c3aed; font-weight: bold'); + console.group('%c[Recall v5]', 'color: #7c3aed; font-weight: bold'); console.log(`Elapsed: ${metrics.timing.total}ms`); console.log(`Query Expansion: focus=[${expansion.focus.join(', ')}]`); - console.log(`L0: ${l0Atoms.length} atoms → ${l0Floors.size} floors`); - console.log(`L3: ${metrics.l3.l1Total || 0} L1 → ${metrics.l3.l1AfterCoarse || 0} 粗筛 → ${chunks.length} final`); - if (metrics.l3.rerankApplied) { - console.log(`L3 Rerank: ${metrics.l3.beforeRerank} → ${metrics.l3.afterRerank} (${metrics.l3.rerankTime}ms)`); + console.log(`Anchors: ${anchorHits.length} hits → ${anchorFloors.size} floors`); + console.log(`Evidence: ${metrics.evidence.chunkTotal || 0} L1 → ${metrics.evidence.chunkAfterCoarse || 0} coarse → ${evidenceChunks.length} final`); + if (metrics.evidence.rerankApplied) { + console.log(`Evidence Rerank: ${metrics.evidence.beforeRerank} → ${metrics.evidence.afterRerank} (${metrics.evidence.rerankTime}ms)`); } - console.log(`L2: ${eventResults.length} events, ${causalEvents.length} causal`); + console.log(`Events: ${eventHits.length} hits, ${causalChain.length} causal`); console.groupEnd(); return { - events: eventResults, - causalEvents, - chunks, + events: eventHits, + causalChain, + evidenceChunks, expansion, focusEntities, elapsed: metrics.timing.total,