From a010681ea6e8fc2a70f6383f63da14d6dfa003f1 Mon Sep 17 00:00:00 2001 From: bielie Date: Mon, 26 Jan 2026 23:50:48 +0800 Subject: [PATCH] Update story summary recall and prompt injection --- modules/story-summary/generate/generator.js | 54 ++ modules/story-summary/generate/llm.js | 11 +- modules/story-summary/generate/prompt.js | 988 ++++++++++++++++---- modules/story-summary/story-summary.css | 29 +- modules/story-summary/vector/recall.js | 284 +++++- 5 files changed, 1139 insertions(+), 227 deletions(-) diff --git a/modules/story-summary/generate/generator.js b/modules/story-summary/generate/generator.js index de22c56..ffe98c2 100644 --- a/modules/story-summary/generate/generator.js +++ b/modules/story-summary/generate/generator.js @@ -8,6 +8,7 @@ import { generateSummary, parseSummaryJson } from "./llm.js"; const MODULE_ID = 'summaryGenerator'; const SUMMARY_SESSION_ID = 'xb9'; +const MAX_CAUSED_BY = 2; // ═══════════════════════════════════════════════════════════════════════════ // worldUpdate 清洗 @@ -45,6 +46,57 @@ function sanitizeWorldUpdate(parsed) { parsed.worldUpdate = ok; } +// ═══════════════════════════════════════════════════════════════════════════ +// causedBy 清洗(事件因果边) +// - 允许引用:已存在事件 + 本次新输出事件 +// - 限制长度:0-2 +// - 去重、剔除非法ID、剔除自引用 +// ═══════════════════════════════════════════════════════════════════════════ + +function sanitizeEventsCausality(parsed, existingEventIds) { + if (!parsed) return; + + const events = Array.isArray(parsed.events) ? parsed.events : []; + if (!events.length) return; + + const idRe = /^evt-\d+$/; + + // 本次新输出事件ID集合(允许引用) + const newIds = new Set( + events + .map(e => String(e?.id || '').trim()) + .filter(id => idRe.test(id)) + ); + + const allowed = new Set([...(existingEventIds || []), ...newIds]); + + for (const e of events) { + const selfId = String(e?.id || '').trim(); + if (!idRe.test(selfId)) { + // id 不合格的话,causedBy 直接清空,避免污染 + e.causedBy = []; + continue; + } + + const raw = Array.isArray(e.causedBy) ? e.causedBy : []; + const out = []; + const seen = new Set(); + + for (const x of raw) { + const cid = String(x || '').trim(); + if (!idRe.test(cid)) continue; + if (cid === selfId) continue; + if (!allowed.has(cid)) continue; + if (seen.has(cid)) continue; + seen.add(cid); + out.push(cid); + if (out.length >= MAX_CAUSED_BY) break; + } + + e.causedBy = out; + } +} + // ═══════════════════════════════════════════════════════════════════════════ // 辅助函数 // ═══════════════════════════════════════════════════════════════════════════ @@ -180,6 +232,8 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) { } sanitizeWorldUpdate(parsed); + const existingEventIds = new Set((store?.json?.events || []).map(e => e?.id).filter(Boolean)); + sanitizeEventsCausality(parsed, existingEventIds); const merged = mergeNewData(store?.json || {}, parsed, slice.endMesId); diff --git a/modules/story-summary/generate/llm.js b/modules/story-summary/generate/llm.js index 8783b86..899b0ff 100644 --- a/modules/story-summary/generate/llm.js +++ b/modules/story-summary/generate/llm.js @@ -1,6 +1,7 @@ // LLM Service const PROVIDER_MAP = { + // ... openai: "openai", google: "gemini", gemini: "gemini", @@ -35,6 +36,7 @@ Incremental_Summary_Requirements: - 转折: 改变某条线走向 - 点睛: 有细节不影响主线 - 氛围: 纯粹氛围片段 + - Causal_Chain: 为每个新事件标注直接前因事件ID(causedBy),0-2个。只填 evt-数字 形式,必须指向“已存在事件”或“本次新输出事件”。不要写解释文字。 - Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融) - Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0) - World_State_Tracking: 维护当前世界的硬性约束。解决"什么不能违反"。采用 KV 覆盖模型,追踪生死、物品归属、秘密知情、关系状态、环境规则等不可违背的事实。(覆盖式更新) @@ -171,7 +173,8 @@ Before generating, observe the USER and analyze carefully: "summary": "1-2句话描述,涵盖丰富信息素,末尾标注楼层(#X-Y)", "participants": ["参与角色名"], "type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常", - "weight": "核心|主线|转折|点睛|氛围" + "weight": "核心|主线|转折|点睛|氛围", + "causedBy": ["evt-12", "evt-14"] } ], "newCharacters": ["仅本次首次出现的角色名"], @@ -211,6 +214,10 @@ Before generating, observe the USER and analyze carefully: - events.id 从 evt-{nextEventId} 开始编号 - 仅输出【增量】内容,已有事件绝不重复 - keywords 是全局关键词,综合已有+新增 +- causedBy 规则: + - 数组,最多2个;无前因则 [] + - 只能填 evt-数字(例如 evt-12) + - 必须引用“已存在事件”或“本次新输出事件”(允许引用本次 JSON 内较早出现的事件) - worldUpdate 可为空数组 - 合法JSON,字符串值内部避免英文双引号 - 用小说家的细腻笔触记录,带烟火气 @@ -441,4 +448,4 @@ export async function generateSummary(options) { console.groupEnd(); return rawOutput; -} \ No newline at end of file +} diff --git a/modules/story-summary/generate/prompt.js b/modules/story-summary/generate/prompt.js index 3deb3c7..0ad781d 100644 --- a/modules/story-summary/generate/prompt.js +++ b/modules/story-summary/generate/prompt.js @@ -2,22 +2,142 @@ // 注入格式:世界状态 → 亲身经历(含闪回) → 相关背景(含闪回) → 记忆碎片 → 人物弧光 import { getContext } from "../../../../../../extensions.js"; -import { extension_prompts, extension_prompt_types, extension_prompt_roles } from "../../../../../../../script.js"; +import { + extension_prompts, + extension_prompt_types, + extension_prompt_roles, +} from "../../../../../../../script.js"; import { xbLog } from "../../../core/debug-core.js"; import { getSummaryStore } from "../data/store.js"; import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js"; import { recallMemory, buildQueryText } from "../vector/recall.js"; +import { getChunksByFloors, getAllChunkVectors, getAllEventVectors } from "../vector/chunk-store.js"; const MODULE_ID = "summaryPrompt"; const SUMMARY_PROMPT_KEY = "LittleWhiteBox_StorySummary"; -const BUDGET = { total: 10000, l3Max: 2000, l2Max: 5000 }; -const MAX_CHUNKS_PER_EVENT = 2; -const MAX_ORPHAN_CHUNKS = 6; +// 预算:只保留主预算与 L3 上限,其它由装配算法决定 +const BUDGET = { total: 10000, l3Max: 2000 }; -// ═══════════════════════════════════════════════════════════════════════════ +// 你确认的参数 +const TARGET_UTILIZATION = 0.8; +const TOP_RELEVANCE_COUNT = 5; + +// ───────────────────────────────────────────────────────────────────────────── +// Injection log +// ───────────────────────────────────────────────────────────────────────────── + +function pct(n, d) { + return d > 0 ? Math.round((n / d) * 100) : 0; +} + +function formatInjectionLog(stats) { + const lines = [ + "", + "╔══════════════════════════════════════════════════════════════╗", + "║ Prompt Injection Report ║", + "╠══════════════════════════════════════════════════════════════╣", + `║ Token budget: ${stats.budget.max}`, + "╚══════════════════════════════════════════════════════════════╝", + "", + ]; + + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [Packing] Budget-aware assembly │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + if (stats.packing) { + lines.push( + ` Target utilization: ${(stats.packing.targetUtilization * 100).toFixed(0)}%` + ); + lines.push( + ` L2 budget: ${stats.packing.l2Used} / ${stats.packing.l2Max} (${pct(stats.packing.l2Used, stats.packing.l2Max)}%)` + ); + lines.push( + ` Selected events: ${stats.packing.selectedEvents} (DIRECT: ${stats.packing.selectedDirect}, SIMILAR: ${stats.packing.selectedSimilar})` + ); + lines.push( + ` Evidence levels: E3=${stats.packing.e3} | E2=${stats.packing.e2} | E1=${stats.packing.e1} | E0=${stats.packing.e0}` + ); + lines.push(` Evidence chunks (total): ${stats.packing.evidenceChunks}`); + } else { + lines.push(" (no packing stats)"); + } + lines.push(""); + + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [World] L3 │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + lines.push(` Injected: ${stats.world.count} | Tokens: ${stats.world.tokens}`); + lines.push(""); + + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [Direct] │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + lines.push( + ` Events: ${stats.direct.recalled} -> ${stats.direct.injected}${stats.direct.recalled > stats.direct.injected ? ` (budget cut ${stats.direct.recalled - stats.direct.injected})` : ""}` + ); + lines.push(` Causal: ${stats.direct.causalCount}`); + lines.push(` L1 chunks: ${stats.direct.chunksCount}`); + lines.push(` Tokens: ${stats.direct.tokens}`); + lines.push(""); + + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [Similar] │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + lines.push( + ` Events: ${stats.similar.recalled} -> ${stats.similar.injected}${stats.similar.recalled > stats.similar.injected ? ` (budget cut ${stats.similar.recalled - stats.similar.injected})` : ""}` + ); + lines.push(` Causal: ${stats.similar.causalCount}`); + lines.push(` L1 chunks: ${stats.similar.chunksCount}`); + lines.push(` Tokens: ${stats.similar.tokens}`); + lines.push(""); + + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [Orphans] │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + lines.push( + ` Chunks: ${stats.orphans.recalled} -> ${stats.orphans.injected}${stats.orphans.recalled > stats.orphans.injected ? ` (budget cut ${stats.orphans.recalled - stats.orphans.injected})` : ""}` + ); + lines.push(` Tokens: ${stats.orphans.tokens}`); + lines.push(""); + + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [Arcs] │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + lines.push(` Injected: ${stats.arcs.count} | Tokens: ${stats.arcs.tokens}`); + lines.push(""); + + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [Total] │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + lines.push( + ` Tokens: ${stats.budget.used} / ${stats.budget.max} (${Math.round((stats.budget.used / stats.budget.max) * 100)}%)` + ); + lines.push(""); + + return lines.join("\n"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 向量工具 +// ───────────────────────────────────────────────────────────────────────────── + +function cosineSimilarity(a, b) { + if (!a?.length || !b?.length || a.length !== b.length) return 0; + let dot = 0, + nA = 0, + nB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + nA += a[i] * a[i]; + nB += b[i] * b[i]; + } + return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0; +} + +// ───────────────────────────────────────────────────────────────────────────── // 工具函数 -// ═══════════════════════════════════════════════════════════════════════════ +// ───────────────────────────────────────────────────────────────────────────── function estimateTokens(text) { if (!text) return 0; @@ -26,6 +146,10 @@ function estimateTokens(text) { return Math.ceil(zh + (s.length - zh) / 4); } +function clamp(n, min, max) { + return Math.max(min, Math.min(max, n)); +} + function pushWithBudget(lines, text, state) { const t = estimateTokens(text); if (state.used + t > state.max) return false; @@ -37,61 +161,189 @@ function pushWithBudget(lines, text, state) { // 从 summary 解析楼层范围:(#321-322) 或 (#321) function parseFloorRange(summary) { if (!summary) return null; - - // 匹配 (#123-456) 或 (#123) const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/); if (!match) return null; - - const start = parseInt(match[1], 10); - const end = match[2] ? parseInt(match[2], 10) : start; - + + // summary 里写的是 #楼层(1-based),chunks 里 floor 是消息下标(0-based) + const start = Math.max(0, parseInt(match[1], 10) - 1); + const end = Math.max( + 0, + (match[2] ? parseInt(match[2], 10) : parseInt(match[1], 10)) - 1 + ); + return { start, end }; } // 去掉 summary 末尾的楼层标记 function cleanSummary(summary) { - return String(summary || '').replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '').trim(); + return String(summary || "") + .replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "") + .trim(); } -// ═══════════════════════════════════════════════════════════════════════════ -// L1 → L2 归属 -// ═══════════════════════════════════════════════════════════════════════════ +// ───────────────────────────────────────────────────────────────────────────── +// Evidence Windowing (证据窗口) +// E1: 核心1条 / E2: ±1(约3条) / E3: ±2(约5条) +// 不改chunk切分,不做重叠,只在注入时补邻域,提高语义完整度。 +// ───────────────────────────────────────────────────────────────────────────── + +const EVIDENCE_LEVEL = { + E0: 0, + E1: 1, + E2: 2, + E3: 3, +}; + +function getEvidenceWindowRadius(level) { + if (level === EVIDENCE_LEVEL.E3) return 2; + if (level === EVIDENCE_LEVEL.E2) return 1; + return 0; +} + +function buildChunksByFloorMap(chunks) { + const map = new Map(); + for (const c of chunks || []) { + const f = c.floor; + if (!map.has(f)) map.set(f, []); + map.get(f).push(c); + } + for (const arr of map.values()) { + arr.sort((a, b) => (a.chunkIdx ?? 0) - (b.chunkIdx ?? 0)); + } + return map; +} + +function pickAnchorChunkIdx(eventItem, floorChunks, recalledChunksInRange = []) { + // 优先:用本轮召回的chunks里,同楼层且相似度最高的作为anchor + let best = null; + for (const rc of recalledChunksInRange) { + if (rc.floor !== eventItem._evidenceFloor) continue; + if (!best || (rc.similarity || 0) > (best.similarity || 0)) best = rc; + } + if (best && best.chunkIdx != null) return best.chunkIdx; + + // 退化:该楼层第一个chunk + const first = floorChunks?.[0]; + return first?.chunkIdx ?? 0; +} + +function getEvidenceChunksForEvent(eventItem, chunksByFloor, recalledChunksInRange, evidenceLevel) { + if (evidenceLevel === EVIDENCE_LEVEL.E0) return []; + + const floor = eventItem._evidenceFloor; + const floorChunks = chunksByFloor.get(floor) || []; + if (!floorChunks.length) return []; + + const radius = getEvidenceWindowRadius(evidenceLevel); + const anchorIdx = pickAnchorChunkIdx(eventItem, floorChunks, recalledChunksInRange); + + // 找到anchor在floorChunks中的位置 + const pos = floorChunks.findIndex(c => (c.chunkIdx ?? 0) === anchorIdx); + const anchorPos = pos >= 0 ? pos : 0; + + const start = clamp(anchorPos - radius, 0, floorChunks.length - 1); + const end = clamp(anchorPos + radius, 0, floorChunks.length - 1); + const selected = floorChunks.slice(start, end + 1); + + // E1只取核心一条 + if (evidenceLevel === EVIDENCE_LEVEL.E1) return [floorChunks[anchorPos]]; + return selected; +} + +function downgrade(level) { + if (level <= EVIDENCE_LEVEL.E0) return EVIDENCE_LEVEL.E0; + return level - 1; +} + +function chooseInitialEvidenceLevel(e, isTop) { + if (isTop) return EVIDENCE_LEVEL.E3; + if (e._recallType === "DIRECT") return EVIDENCE_LEVEL.E2; + return EVIDENCE_LEVEL.E1; +} + +// ───────────────────────────────────────────────────────────────────────────── +// L1 → L2 归属:这里只挂“候选chunks”,最终证据窗口在装配阶段决定 +// ───────────────────────────────────────────────────────────────────────────── function attachChunksToEvents(events, chunks) { const usedChunkIds = new Set(); - - // 给每个 event 挂载 chunks + for (const e of events) { - e._chunks = []; + e._candidateChunks = []; const range = parseFloorRange(e.event?.summary); if (!range) continue; - + for (const c of chunks) { if (c.floor >= range.start && c.floor <= range.end) { if (!usedChunkIds.has(c.chunkId)) { - e._chunks.push(c); + e._candidateChunks.push(c); usedChunkIds.add(c.chunkId); } } } - - // 每个事件最多保留 N 条,按相似度排序 - e._chunks.sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); - e._chunks = e._chunks.slice(0, MAX_CHUNKS_PER_EVENT); + + e._candidateChunks.sort( + (a, b) => (a.floor - b.floor) || ((b.similarity || 0) - (a.similarity || 0)) + ); } - - // 找出无归属的 chunks(记忆碎片) + const orphans = chunks .filter(c => !usedChunkIds.has(c.chunkId)) - .sort((a, b) => (b.similarity || 0) - (a.similarity || 0)) - .slice(0, MAX_ORPHAN_CHUNKS); - + .sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); + return { events, orphans }; } -// ═══════════════════════════════════════════════════════════════════════════ +// ───────────────────────────────────────────────────────────────────────────── +// 因果事件证据补充:用 eventVector 匹配最相关的 chunk +// ───────────────────────────────────────────────────────────────────────────── + +async function attachEvidenceToaCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap) { + for (const c of causalEvents) { + c._evidenceChunk = null; + + const ev = c.event; + if (!ev?.id) continue; + + const evVec = eventVectorMap.get(ev.id); + if (!evVec?.length) continue; + + const range = parseFloorRange(ev.summary); + if (!range) continue; + + const candidateChunks = []; + for (const [chunkId, chunk] of chunksMap) { + if (chunk.floor >= range.start && chunk.floor <= range.end) { + const vec = chunkVectorMap.get(chunkId); + if (vec?.length) candidateChunks.push({ chunk, vec }); + } + } + if (!candidateChunks.length) continue; + + let best = null; + let bestSim = -1; + for (const { chunk, vec } of candidateChunks) { + const sim = cosineSimilarity(evVec, vec); + if (sim > bestSim) { + bestSim = sim; + best = chunk; + } + } + + if (best && bestSim > 0.3) { + c._evidenceChunk = { + floor: best.floor, + speaker: best.speaker, + text: best.text, + similarity: bestSim, + }; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── // 格式化函数 -// ═══════════════════════════════════════════════════════════════════════════ +// ───────────────────────────────────────────────────────────────────────────── function formatWorldLines(world) { return [...(world || [])] @@ -100,202 +352,482 @@ function formatWorldLines(world) { } function formatChunkLine(c) { - const text = String(c.text || ''); - const preview = text.length > 80 ? text.slice(0, 80) + '...' : text; - return `› #${c.floor} ${preview}`; + const text = String(c.text || ""); + const preview = text.length > 80 ? text.slice(0, 80) + "..." : text; + const speaker = c.isUser ? "{{user}}" : "{{char}}"; + return `› #${c.floor + 1} [${speaker}] ${preview}`; } -function formatEventBlock(e, idx) { +function formatEventBlock(e, idx, isHighRelevance = false, evidenceChunks = []) { const ev = e.event || {}; - const time = ev.timeLabel || ''; - const people = (ev.participants || []).join(' / '); + const time = ev.timeLabel || ""; + const title = String(ev.title || "").trim(); + const people = (ev.participants || []).join(" / ").trim(); const summary = cleanSummary(ev.summary); - + const lines = []; - - // 标题行 - const header = time ? `${idx}.【${time}】${people}` : `${idx}. ${people}`; + + const displayTitle = title || people || ev.id || "事件"; + const marker = isHighRelevance ? "★" : ""; + const header = time ? `${marker}${idx}.【${time}】${displayTitle}` : `${marker}${idx}. ${displayTitle}`; lines.push(header); - - // 摘要 + + if (people && displayTitle !== people) { + lines.push(` ${people}`); + } + lines.push(` ${summary}`); - - // 挂载的闪回 - for (const c of (e._chunks || [])) { + + for (const c of evidenceChunks || []) { lines.push(` ${formatChunkLine(c)}`); } - - return lines.join('\n'); + + return lines.join("\n"); +} + +function formatCausalEventLine(causalItem, _causalById) { + const ev = causalItem?.event || {}; + const depth = Math.max(1, Math.min(9, causalItem?._causalDepth || 1)); + const indent = " │" + " ".repeat(depth - 1); + const prefix = `${indent}├─ 前因`; + + const time = ev.timeLabel ? `【${ev.timeLabel}】` : ""; + const people = (ev.participants || []).join(" / "); + const summary = cleanSummary(ev.summary); + + const r = parseFloorRange(ev.summary); + const floorHint = r ? `(#${r.start + 1}${r.end !== r.start ? `-${r.end + 1}` : ""})` : ""; + + const lines = []; + lines.push(`${prefix}${time}${people ? ` ${people}` : ""}`); + const body = `${summary}${floorHint ? ` ${floorHint}` : ""}`.trim(); + lines.push(`${indent} ${body}`); + + const evidence = causalItem._evidenceChunk; + if (evidence) { + const speaker = evidence.speaker || "角色"; + const preview = evidence.text.length > 60 ? evidence.text.slice(0, 60) + "..." : evidence.text; + lines.push(`${indent} › #${evidence.floor + 1} [${speaker}] ${preview}`); + } + + return lines.join("\n"); } function formatArcLine(a) { const moments = (a.moments || []) - .map(m => typeof m === 'string' ? m : m.text) + .map(m => (typeof m === "string" ? m : m.text)) .filter(Boolean); - + if (moments.length) { - return `- ${a.name}:${moments.join(' → ')}(当前:${a.trajectory})`; + return `- ${a.name}:${moments.join(" → ")}(当前:${a.trajectory})`; } return `- ${a.name}:${a.trajectory}`; } -// ═══════════════════════════════════════════════════════════════════════════ -// 主构建函数 -// ═══════════════════════════════════════════════════════════════════════════ - -function buildMemoryPromptVectorEnabled(store, recallResult) { - const data = store.json || {}; - const total = { used: 0, max: BUDGET.total }; - const sections = []; - - // ───────────────────────────────────────────────────────────────────── - // [世界状态] - // ───────────────────────────────────────────────────────────────────── - const worldLines = formatWorldLines(data.world); - if (worldLines.length) { - const l3 = { used: 0, max: Math.min(BUDGET.l3Max, total.max) }; - const l3Lines = []; - - for (const line of worldLines) { - if (!pushWithBudget(l3Lines, line, l3)) break; - } - - if (l3Lines.length) { - sections.push(`[世界状态] 请严格遵守\n${l3Lines.join('\n')}`); - total.used += l3.used; - } - } - - // ───────────────────────────────────────────────────────────────────── - // L1 → L2 归属处理 - // ───────────────────────────────────────────────────────────────────── - const events = recallResult?.events || []; - const chunks = recallResult?.chunks || []; - const { events: eventsWithChunks, orphans } = attachChunksToEvents(events, chunks); - - // 分离 DIRECT 和 SIMILAR - const directEvents = eventsWithChunks.filter(e => e._recallType === 'DIRECT'); - const similarEvents = eventsWithChunks.filter(e => e._recallType !== 'DIRECT'); - - // ───────────────────────────────────────────────────────────────────── - // [亲身经历] - DIRECT - // ───────────────────────────────────────────────────────────────────── - if (directEvents.length) { - const l2 = { used: 0, max: Math.min(BUDGET.l2Max * 0.7, total.max - total.used) }; - const lines = []; - - let idx = 1; - for (const e of directEvents) { - const block = formatEventBlock(e, idx); - if (!pushWithBudget(lines, block, l2)) break; - idx++; - } - - if (lines.length) { - sections.push(`[亲身经历]\n\n${lines.join('\n\n')}`); - total.used += l2.used; - } - } - - // ───────────────────────────────────────────────────────────────────── - // [相关背景] - SIMILAR - // ───────────────────────────────────────────────────────────────────── - if (similarEvents.length) { - const l2s = { used: 0, max: Math.min(BUDGET.l2Max * 0.3, total.max - total.used) }; - const lines = []; - - let idx = directEvents.length + 1; - for (const e of similarEvents) { - const block = formatEventBlock(e, idx); - if (!pushWithBudget(lines, block, l2s)) break; - idx++; - } - - if (lines.length) { - sections.push(`[相关背景]\n\n${lines.join('\n\n')}`); - total.used += l2s.used; - } - } - - // ───────────────────────────────────────────────────────────────────── - // [记忆碎片] - 无归属的 chunks - // ───────────────────────────────────────────────────────────────────── - if (orphans.length && total.used < total.max) { - const l1 = { used: 0, max: total.max - total.used }; - const lines = []; - - for (const c of orphans) { - const line = formatChunkLine(c); - if (!pushWithBudget(lines, line, l1)) break; - } - - if (lines.length) { - sections.push(`[记忆碎片]\n${lines.join('\n')}`); - total.used += l1.used; - } - } - - // ───────────────────────────────────────────────────────────────────── - // [人物弧光] - // ───────────────────────────────────────────────────────────────────── - if (data.arcs?.length && total.used < total.max) { - const arcLines = data.arcs.map(formatArcLine); - const arcText = `[人物弧光]\n${arcLines.join('\n')}`; - - if (total.used + estimateTokens(arcText) <= total.max) { - sections.push(arcText); - } - } - - // ───────────────────────────────────────────────────────────────────── - // 组装 - // ───────────────────────────────────────────────────────────────────── - if (!sections.length) return ''; - - return `<剧情记忆>\n\n${sections.join('\n\n')}\n\n`; +function buildTopKIdSet(directEvents, similarEvents) { + return new Set( + [...directEvents, ...similarEvents] + .sort((a, b) => (b.similarity || 0) - (a.similarity || 0)) + .slice(0, TOP_RELEVANCE_COUNT) + .map(e => e.event?.id) + .filter(Boolean) + ); } +function computeEventTextCost(e, isTop, evidenceChunks = []) { + const tmp = formatEventBlock(e, 1, isTop, evidenceChunks); + return estimateTokens(tmp); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 非向量模式:沿用旧行为(简单、快) +// ───────────────────────────────────────────────────────────────────────────── + function buildMemoryPromptVectorDisabled(store) { const data = store.json || {}; const sections = []; - - // 世界状态 + if (data.world?.length) { const lines = formatWorldLines(data.world); - sections.push(`[世界状态] 请严格遵守\n${lines.join('\n')}`); + sections.push(`[世界状态] 请严格遵守\n${lines.join("\n")}`); } - - // 全部事件(无召回,按时间) + if (data.events?.length) { const lines = data.events.map((ev, i) => { - const time = ev.timeLabel || ''; - const people = (ev.participants || []).join(' / '); + const time = ev.timeLabel || ""; + const title = ev.title || ""; + const people = (ev.participants || []).join(" / "); const summary = cleanSummary(ev.summary); - const header = time ? `${i + 1}.【${time}】${people}` : `${i + 1}. ${people}`; + const header = time ? `${i + 1}.【${time}】${title || people}` : `${i + 1}. ${title || people}`; return `${header}\n ${summary}`; }); - sections.push(`[剧情记忆]\n\n${lines.join('\n\n')}`); + sections.push(`[剧情记忆]\n\n${lines.join("\n\n")}`); } - - // 弧光 + if (data.arcs?.length) { const lines = data.arcs.map(formatArcLine); - sections.push(`[人物弧光]\n${lines.join('\n')}`); + sections.push(`[人物弧光]\n${lines.join("\n")}`); } - - if (!sections.length) return ''; - - return `<剧情记忆>\n\n${sections.join('\n\n')}\n\n`; + + if (!sections.length) return { promptText: "", injectionLogText: "", injectionStats: null }; + return { + promptText: `<剧情记忆>\n\n${sections.join("\n\n")}\n\n`, + injectionLogText: "", + injectionStats: null, + }; } -// ═══════════════════════════════════════════════════════════════════════════ -// 导出 -// ═══════════════════════════════════════════════════════════════════════════ +// ───────────────────────────────────────────────────────────────────────────── +// 预算驱动装配(向量模式) +// ───────────────────────────────────────────────────────────────────────────── -export function formatPromptWithMemory(store, recallResult) { +async function buildMemoryPromptVectorEnabled(store, recallResult, causalById, queryEntities = []) { + const data = store.json || {}; + const total = { used: 0, max: BUDGET.total }; + const sections = []; + + const injectionStats = { + budget: { max: BUDGET.total, used: 0 }, + world: { count: 0, tokens: 0 }, + direct: { recalled: 0, injected: 0, causalCount: 0, chunksCount: 0, tokens: 0 }, + similar: { recalled: 0, injected: 0, causalCount: 0, chunksCount: 0, tokens: 0 }, + orphans: { recalled: 0, injected: 0, tokens: 0 }, + arcs: { count: 0, tokens: 0 }, + packing: null, + }; + + const targetUsed = Math.floor(BUDGET.total * TARGET_UTILIZATION); + + // [世界状态] + const worldLines = formatWorldLines(data.world); + if (worldLines.length) { + const l3 = { used: 0, max: Math.min(BUDGET.l3Max, total.max) }; + const l3Lines = []; + + for (const line of worldLines) { + if (!pushWithBudget(l3Lines, line, l3)) break; + } + + if (l3Lines.length) { + sections.push(`[世界状态] 请严格遵守\n${l3Lines.join("\n")}`); + total.used += l3.used; + injectionStats.world.count = l3Lines.length; + injectionStats.world.tokens = l3.used; + } + } + + // L1 → L2 归属 + const events = recallResult?.events || []; + const chunks = recallResult?.chunks || []; + const { events: eventsWithChunks, orphans } = attachChunksToEvents(events, chunks); + + const directEvents = eventsWithChunks.filter(e => e._recallType === "DIRECT"); + const similarEvents = eventsWithChunks.filter(e => e._recallType !== "DIRECT"); + + injectionStats.direct.recalled = directEvents.length; + injectionStats.similar.recalled = similarEvents.length; + + const topKIds = buildTopKIdSet(directEvents, similarEvents); + + // 证据楼层选择:用事件 range.end 作为 evidenceFloor(贴近事件结尾) + const evidenceFloors = new Set(); + for (const e of eventsWithChunks) { + const r = parseFloorRange(e.event?.summary); + if (!r) continue; + e._evidenceFloor = r.end; + evidenceFloors.add(r.end); + } + + // 批量加载这些楼层 chunks,用于证据窗口 + const { chatId } = getContext(); + let chunksByFloor = new Map(); + if (chatId && evidenceFloors.size) { + try { + const floorChunks = await getChunksByFloors(chatId, Array.from(evidenceFloors)); + chunksByFloor = buildChunksByFloorMap(floorChunks); + } catch (e) { + xbLog.warn(MODULE_ID, "Failed to load floor chunks for evidence windowing", e); + } + } + + // ───────────────────────────────────────────────────────────────────── + // 预算装配(不再固定条数) + // ───────────────────────────────────────────────────────────────────── + // L2预算:目标 65% 总预算,上限 80%(保守避免 L2 吞满全部) + const l2Target = Math.floor(BUDGET.total * 0.65); + const l2Ceil = Math.floor(BUDGET.total * 0.8); + const l2Budget = { + used: 0, + max: Math.min(l2Ceil, Math.max(0, BUDGET.total - total.used)), + }; + + const packStats = { + targetUtilization: TARGET_UTILIZATION, + l2Used: 0, + l2Max: l2Budget.max, + selectedEvents: 0, + selectedDirect: 0, + selectedSimilar: 0, + e3: 0, + e2: 0, + e1: 0, + e0: 0, + evidenceChunks: 0, + }; + + // 候选:按 similarity 降序(更贴近“本轮需要”) + const candidates = [...directEvents, ...similarEvents] + .filter(e => e?.event?.summary) + .sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); + + const selected = []; // { e, evidenceLevel, evidenceChunks, cost, isTop } + const selectedIds = new Set(); + + for (const e of candidates) { + const id = e.event?.id; + if (!id || selectedIds.has(id)) continue; + + const isTop = topKIds.has(id); + let level = chooseInitialEvidenceLevel(e, isTop); + + const recalledInRange = e._candidateChunks || []; + + // 从高到低降级,直到能塞入 L2 budget + while (true) { + const evChunks = getEvidenceChunksForEvent(e, chunksByFloor, recalledInRange, level); + const cost = computeEventTextCost(e, isTop, evChunks); + + if (l2Budget.used + cost <= l2Budget.max) { + selected.push({ e, evidenceLevel: level, evidenceChunks: evChunks, cost, isTop }); + selectedIds.add(id); + l2Budget.used += cost; + break; + } + + if (level === EVIDENCE_LEVEL.E0) break; + level = downgrade(level); + } + + // 达到 L2 目标就先停(后续仍可能做“证据升级”填预算) + if (l2Budget.used >= l2Target) break; + } + + // 若总预算仍明显不足目标利用率,做一次“证据升级”填预算(安全填充) + if (total.used + l2Budget.used < targetUsed && l2Budget.used < l2Budget.max && selected.length) { + const upgradable = [...selected].sort((a, b) => { + if (a.isTop !== b.isTop) return a.isTop ? -1 : 1; + return (b.e.similarity || 0) - (a.e.similarity || 0); + }); + + for (const item of upgradable) { + if (total.used + l2Budget.used >= targetUsed) break; + if (l2Budget.used >= l2Budget.max) break; + + const cur = item.evidenceLevel; + const next = cur >= EVIDENCE_LEVEL.E3 ? cur : cur + 1; + if (next === cur) continue; + + const recalledInRange = item.e._candidateChunks || []; + const nextChunks = getEvidenceChunksForEvent(item.e, chunksByFloor, recalledInRange, next); + const nextCost = computeEventTextCost(item.e, item.isTop, nextChunks); + const delta = nextCost - item.cost; + + if (delta <= 0) continue; + if (l2Budget.used + delta <= l2Budget.max) { + item.evidenceLevel = next; + item.evidenceChunks = nextChunks; + item.cost = nextCost; + l2Budget.used += delta; + } + } + } + + // packing stats 汇总 + packStats.l2Used = l2Budget.used; + + for (const item of selected) { + packStats.selectedEvents++; + if (item.e._recallType === "DIRECT") packStats.selectedDirect++; + else packStats.selectedSimilar++; + + if (item.evidenceLevel === EVIDENCE_LEVEL.E3) packStats.e3++; + else if (item.evidenceLevel === EVIDENCE_LEVEL.E2) packStats.e2++; + else if (item.evidenceLevel === EVIDENCE_LEVEL.E1) packStats.e1++; + else packStats.e0++; + + packStats.evidenceChunks += item.evidenceChunks?.length || 0; + } + injectionStats.packing = packStats; + + // 最终输出仍按时间线:按事件 summary 里的楼层范围 start 排序 + function getEventFloorStart(ev) { + const r = parseFloorRange(ev?.summary); + return r?.start ?? Number.POSITIVE_INFINITY; + } + + const selectedEventsOrdered = selected.sort( + (a, b) => getEventFloorStart(a.e.event) - getEventFloorStart(b.e.event) + ); + + // ───────────────────────────────────────────────────────────────────── + // [亲身经历] DIRECT + // ───────────────────────────────────────────────────────────────────── + { + const directLines = []; + let idx = 1; + let injectedCount = 0; + let causalCount = 0; + let chunksCount = 0; + + for (const item of selectedEventsOrdered) { + if (item.e._recallType !== "DIRECT") continue; + + const block = formatEventBlock(item.e, idx, item.isTop, item.evidenceChunks); + directLines.push(block); + injectedCount++; + chunksCount += item.evidenceChunks?.length || 0; + + for (const cid of item.e.event?.causedBy || []) { + const c = causalById.get(cid); + if (!c) continue; + directLines.push(formatCausalEventLine(c, causalById)); + causalCount++; + } + idx++; + } + + if (directLines.length) { + const text = `[亲身经历]\n\n${directLines.join("\n\n")}`; + const t = estimateTokens(text); + if (total.used + t <= total.max) { + sections.push(text); + total.used += t; + injectionStats.direct.injected = injectedCount; + injectionStats.direct.causalCount = causalCount; + injectionStats.direct.chunksCount = chunksCount; + injectionStats.direct.tokens = t; + } + } + } + + // ───────────────────────────────────────────────────────────────────── + // [相关背景] SIMILAR + // ───────────────────────────────────────────────────────────────────── + { + const similarLines = []; + let idx = (injectionStats.direct.injected || 0) + 1; + let injectedCount = 0; + let causalCount = 0; + let chunksCount = 0; + + for (const item of selectedEventsOrdered) { + if (item.e._recallType === "DIRECT") continue; + + const block = formatEventBlock(item.e, idx, item.isTop, item.evidenceChunks); + similarLines.push(block); + injectedCount++; + chunksCount += item.evidenceChunks?.length || 0; + + for (const cid of item.e.event?.causedBy || []) { + const c = causalById.get(cid); + if (!c) continue; + similarLines.push(formatCausalEventLine(c, causalById)); + causalCount++; + } + idx++; + } + + if (similarLines.length) { + const text = `[相关背景]\n\n${similarLines.join("\n\n")}`; + const t = estimateTokens(text); + if (total.used + t <= total.max) { + sections.push(text); + total.used += t; + injectionStats.similar.injected = injectedCount; + injectionStats.similar.causalCount = causalCount; + injectionStats.similar.chunksCount = chunksCount; + injectionStats.similar.tokens = t; + } + } + } + + // ───────────────────────────────────────────────────────────────────── + // [记忆碎片] Orphans:按剩余预算自然装入(仍受预算约束),按时间排序 + // ───────────────────────────────────────────────────────────────────── + if (orphans.length && total.used < total.max) { + const l1 = { used: 0, max: total.max - total.used }; + const lines = []; + + injectionStats.orphans.recalled = orphans.length; + + orphans.sort((a, b) => a.floor - b.floor); + + for (const c of orphans) { + const line = formatChunkLine(c); + if (!pushWithBudget(lines, line, l1)) break; + injectionStats.orphans.injected++; + } + + if (lines.length) { + sections.push(`[记忆碎片]\n${lines.join("\n")}`); + total.used += l1.used; + injectionStats.orphans.tokens = l1.used; + } + } + + // ───────────────────────────────────────────────────────────────────── + // [人物弧光]:只保留 USER + queryEntities + // ───────────────────────────────────────────────────────────────────── + if (data.arcs?.length && total.used < total.max) { + const { name1 } = getContext(); + const userName = String(name1 || "").trim(); + + const relevantEntities = new Set( + [userName, ...(queryEntities || [])] + .map(s => String(s || "").trim()) + .filter(Boolean) + ); + + const filteredArcs = (data.arcs || []).filter(a => { + const arcName = String(a?.name || "").trim(); + return arcName && relevantEntities.has(arcName); + }); + + if (filteredArcs.length) { + const arcLines = filteredArcs.map(formatArcLine); + const arcText = `[人物弧光]\n${arcLines.join("\n")}`; + const arcTokens = estimateTokens(arcText); + + if (total.used + arcTokens <= total.max) { + sections.push(arcText); + total.used += arcTokens; + injectionStats.arcs.count = filteredArcs.length; + injectionStats.arcs.tokens = arcTokens; + } + } + } + + // 组装 + if (!sections.length) { + injectionStats.budget.used = total.used; + return { promptText: "", injectionLogText: "", injectionStats }; + } + + injectionStats.budget.used = total.used; + const promptText = `<剧情记忆>\n\n${sections.join("\n\n")}\n\n`; + const injectionLogText = formatInjectionLog(injectionStats); + + return { promptText, injectionLogText, injectionStats }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Exported API +// ───────────────────────────────────────────────────────────────────────────── + +export async function formatPromptWithMemory(store, recallResult, causalById, queryEntities = []) { const vectorCfg = getVectorConfig(); return vectorCfg?.enabled - ? buildMemoryPromptVectorEnabled(store, recallResult) + ? await buildMemoryPromptVectorEnabled(store, recallResult, causalById, queryEntities) : buildMemoryPromptVectorDisabled(store); } @@ -323,19 +855,67 @@ export async function recallAndInjectPrompt(excludeLastAi = false, postToFrame = } const vectorCfg = getVectorConfig(); - let recallResult = { events: [], chunks: [] }; + let recallResult = { events: [], chunks: [], causalEvents: [], queryEntities: [] }; + let causalById = new Map(); if (vectorCfg?.enabled) { try { const queryText = buildQueryText(chat, 2, excludeLastAi); recallResult = await recallMemory(queryText, allEvents, vectorCfg, { excludeLastAi }); - postToFrame?.({ type: "RECALL_LOG", text: recallResult.logText || "" }); + + // Attach evidence chunks for causal events + const causalEvents = recallResult.causalEvents || []; + if (causalEvents.length > 0) { + const { chatId } = getContext(); + if (chatId) { + try { + const floors = new Set(); + for (const c of causalEvents) { + const r = parseFloorRange(c.event?.summary); + if (!r) continue; + for (let f = r.start; f <= r.end; f++) floors.add(f); + } + + const [chunks, chunkVecs, eventVecs] = await Promise.all([ + getChunksByFloors(chatId, Array.from(floors)), + getAllChunkVectors(chatId), + getAllEventVectors(chatId), + ]); + + const chunksMap = new Map(chunks.map(c => [c.chunkId, c])); + const chunkVectorMap = new Map(chunkVecs.map(v => [v.chunkId, v.vector])); + const eventVectorMap = new Map(eventVecs.map(v => [v.eventId, v.vector])); + + await attachEvidenceToaCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap); + } catch (e) { + xbLog.warn(MODULE_ID, "Causal evidence attachment failed", e); + } + } + } + + causalById = new Map( + (recallResult.causalEvents || []) + .map(c => [c?.event?.id, c]) + .filter(x => x[0]) + ); } catch (e) { xbLog.error(MODULE_ID, "召回失败", e); } } - injectPrompt(store, recallResult, chat); + const result = await injectPrompt( + store, + recallResult, + chat, + causalById, + recallResult?.queryEntities || [] + ); + + if (postToFrame) { + const recallLog = recallResult.logText || ""; + const injectionLog = result?.injectionLogText || ""; + postToFrame({ type: "RECALL_LOG", text: recallLog + injectionLog }); + } } export function updateSummaryExtensionPrompt() { @@ -352,41 +932,41 @@ export function updateSummaryExtensionPrompt() { return; } - injectPrompt(store, { events: [], chunks: [] }, chat); + // 注意:这里保持“快速注入”以降低频繁触发时的开销(不做预算装配/DB批量拉取) + // 真正的预算驱动装配在 recallAndInjectPrompt() 中执行。 + injectPrompt(store, { events: [], chunks: [], causalEvents: [], queryEntities: [] }, chat, new Map(), []); } -function injectPrompt(store, recallResult, chat) { +async function injectPrompt(store, recallResult, chat, causalById, queryEntities = []) { const length = chat?.length || 0; - let text = formatPromptWithMemory(store, recallResult); + const result = await formatPromptWithMemory(store, recallResult, causalById, queryEntities); + let text = result?.promptText || ""; + const injectionLogText = result?.injectionLogText || ""; const cfg = getSummaryPanelConfig(); - if (cfg.trigger?.wrapperHead) { - text = cfg.trigger.wrapperHead + "\n" + text; - } - if (cfg.trigger?.wrapperTail) { - text = text + "\n" + cfg.trigger.wrapperTail; - } + if (cfg.trigger?.wrapperHead) text = cfg.trigger.wrapperHead + "\n" + text; + if (cfg.trigger?.wrapperTail) text = text + "\n" + cfg.trigger.wrapperTail; if (!text.trim()) { delete extension_prompts[SUMMARY_PROMPT_KEY]; - return; + return { injectionLogText: "" }; } const lastIdx = store.lastSummarizedMesId ?? 0; let depth = length - lastIdx - 1; if (depth < 0) depth = 0; - if (cfg.trigger?.forceInsertAtEnd) { - depth = 10000; - } + if (cfg.trigger?.forceInsertAtEnd) depth = 10000; extension_prompts[SUMMARY_PROMPT_KEY] = { value: text, position: extension_prompt_types.IN_CHAT, depth, - role: extension_prompt_roles.ASSISTANT, + role: extension_prompt_roles.SYSTEM, }; + + return { injectionLogText }; } export function clearSummaryExtensionPrompt() { diff --git a/modules/story-summary/story-summary.css b/modules/story-summary/story-summary.css index 4bdc429..b64a035 100644 --- a/modules/story-summary/story-summary.css +++ b/modules/story-summary/story-summary.css @@ -1261,9 +1261,21 @@ h1 span { #recall-log-modal .modal-box { max-width: 900px; + display: flex; + flex-direction: column; +} + +#recall-log-modal .modal-body { + flex: 1; + min-height: 0; + padding: 0; + display: flex; + flex-direction: column; } #recall-log-content { + flex: 1; + min-height: 0; white-space: pre-wrap; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; font-size: 12px; @@ -1271,8 +1283,6 @@ h1 span { background: var(--bg3); padding: 16px; border-radius: 4px; - min-height: 200px; - max-height: 60vh; overflow-y: auto; } @@ -1283,6 +1293,21 @@ h1 span { font-style: italic; } +/* 移动端适配 */ +@media (max-width: 768px) { + #recall-log-modal .modal-box { + max-width: 100%; + max-height: 100%; + height: 100%; + border-radius: 0; + } + + #recall-log-content { + font-size: 11px; + padding: 12px; + } +} + /* ═══════════════════════════════════════════════════════════════════════════ HF Guide ═══════════════════════════════════════════════════════════════════════════ */ diff --git a/modules/story-summary/vector/recall.js b/modules/story-summary/vector/recall.js index 47bb091..f91a7a7 100644 --- a/modules/story-summary/vector/recall.js +++ b/modules/story-summary/vector/recall.js @@ -20,13 +20,18 @@ const CONFIG = { QUERY_MAX_CHARS: 600, QUERY_CONTEXT_CHARS: 240, - CANDIDATE_CHUNKS: 120, - CANDIDATE_EVENTS: 100, + // 因果链 + CAUSAL_CHAIN_MAX_DEPTH: 10, // 放宽跳数,让图自然终止 + CAUSAL_INJECT_MAX: 30, // 放宽上限,由 prompt token 预算最终控制 - TOP_K_CHUNKS: 40, - TOP_K_EVENTS: 35, + CANDIDATE_CHUNKS: 200, + CANDIDATE_EVENTS: 150, - MIN_SIMILARITY: 0.35, + MAX_CHUNKS: 40, + MAX_EVENTS: 120, + + MIN_SIMILARITY_CHUNK: 0.55, + MIN_SIMILARITY_EVENT: 0.6, MMR_LAMBDA: 0.72, BONUS_PARTICIPANT_HIT: 0.08, @@ -58,6 +63,78 @@ function normalizeVec(v) { return v.map(x => x / s); } +// ═══════════════════════════════════════════════════════════════════════════ +// 因果链追溯(Graph-augmented retrieval) +// - 从已召回事件出发,沿 causedBy 向上追溯祖先事件 +// - 记录边:chainFrom = 哪个召回事件需要它 +// - 不在这里决定“是否额外注入”,只负责遍历与结构化结果 +// ═══════════════════════════════════════════════════════════════════════════ + +function buildEventIndex(allEvents) { + const map = new Map(); + for (const e of allEvents || []) { + if (e?.id) map.set(e.id, e); + } + return map; +} + +/** + * @returns {Map} + */ +function traceCausalAncestors(recalledEvents, eventIndex, maxDepth = CONFIG.CAUSAL_CHAIN_MAX_DEPTH) { + const out = new Map(); + const idRe = /^evt-\d+$/; + + function visit(parentId, depth, chainFrom) { + if (depth > maxDepth) return; + if (!idRe.test(parentId)) return; + + const ev = eventIndex.get(parentId); + if (!ev) return; + + // 如果同一个祖先被多个召回事件引用:保留更“近”的深度或追加来源 + const existed = out.get(parentId); + if (!existed) { + out.set(parentId, { event: ev, depth, chainFrom: [chainFrom] }); + } else { + if (depth < existed.depth) existed.depth = depth; + if (!existed.chainFrom.includes(chainFrom)) existed.chainFrom.push(chainFrom); + } + + for (const next of (ev.causedBy || [])) { + visit(String(next || '').trim(), depth + 1, chainFrom); + } + } + + for (const r of recalledEvents || []) { + const rid = r?.event?.id; + if (!rid) continue; + for (const cid of (r.event?.causedBy || [])) { + visit(String(cid || '').trim(), 1, rid); + } + } + + return out; +} + +/** + * 因果事件排序:引用数 > 深度 > 编号 + */ +function sortCausalEvents(causalArray) { + return causalArray.sort((a, b) => { + // 1. 被多条召回链引用的优先 + const refDiff = b.chainFrom.length - a.chainFrom.length; + if (refDiff !== 0) return refDiff; + + // 2. 深度浅的优先 + const depthDiff = a.depth - b.depth; + if (depthDiff !== 0) return depthDiff; + + // 3. 事件编号排序 + return String(a.event.id).localeCompare(String(b.event.id)); + }); +} + function normalize(s) { return String(s || '').normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '').trim(); } @@ -243,14 +320,31 @@ async function searchChunks(queryVector, vectorConfig) { }; }); + // Pre-filter stats for logging + const preFilterStats = { + total: scored.length, + passThreshold: scored.filter(s => s.similarity >= CONFIG.MIN_SIMILARITY_CHUNK).length, + threshold: CONFIG.MIN_SIMILARITY_CHUNK, + distribution: { + '0.8+': scored.filter(s => s.similarity >= 0.8).length, + '0.7-0.8': scored.filter(s => s.similarity >= 0.7 && s.similarity < 0.8).length, + '0.6-0.7': scored.filter(s => s.similarity >= 0.6 && s.similarity < 0.7).length, + '0.55-0.6': scored.filter(s => s.similarity >= 0.55 && s.similarity < 0.6).length, + '<0.55': scored.filter(s => s.similarity < 0.55).length, + }, + }; + const candidates = scored - .filter(s => s.similarity >= CONFIG.MIN_SIMILARITY) + .filter(s => s.similarity >= CONFIG.MIN_SIMILARITY_CHUNK) .sort((a, b) => b.similarity - a.similarity) .slice(0, CONFIG.CANDIDATE_CHUNKS); + // 动态 K:质量不够就少拿 + const dynamicK = Math.min(CONFIG.MAX_CHUNKS, candidates.length); + const selected = mmrSelect( candidates, - CONFIG.TOP_K_CHUNKS, + dynamicK, CONFIG.MMR_LAMBDA, c => c.vector, c => c.similarity @@ -270,7 +364,7 @@ async function searchChunks(queryVector, vectorConfig) { const chunks = await getChunksByFloors(chatId, floors); const chunkMap = new Map(chunks.map(c => [c.chunkId, c])); - return sparse.map(item => { + const results = sparse.map(item => { const chunk = chunkMap.get(item.chunkId); if (!chunk) return null; return { @@ -283,6 +377,13 @@ async function searchChunks(queryVector, vectorConfig) { similarity: item.similarity, }; }).filter(Boolean); + + // Attach stats for logging + if (results.length > 0) { + results._preFilterStats = preFilterStats; + } + + return results; } // ═══════════════════════════════════════════════════════════════════════════ @@ -291,14 +392,27 @@ async function searchChunks(queryVector, vectorConfig) { async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities) { const { chatId, name1 } = getContext(); - if (!chatId || !queryVector?.length) return []; + if (!chatId || !queryVector?.length) { + console.warn('[searchEvents] 早期返回: chatId或queryVector为空'); + return []; + } const meta = await getMeta(chatId); const fp = getEngineFingerprint(vectorConfig); + console.log('[searchEvents] fingerprint检查:', { + metaFp: meta.fingerprint, + currentFp: fp, + match: meta.fingerprint === fp || !meta.fingerprint, + }); if (meta.fingerprint && meta.fingerprint !== fp) return []; const eventVectors = await getAllEventVectors(chatId); const vectorMap = new Map(eventVectors.map(v => [v.eventId, v.vector])); + console.log('[searchEvents] 向量数据:', { + eventVectorsCount: eventVectors.length, + vectorMapSize: vectorMap.size, + allEventsCount: allEvents?.length, + }); if (!vectorMap.size) return []; const userName = normalize(name1); @@ -350,14 +464,40 @@ async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEn }; }); + // 相似度分布日志 + const simValues = scored.map(s => s.similarity).sort((a, b) => b - a); + console.log('[searchEvents] 相似度分布(前20):', simValues.slice(0, 20)); + console.log('[searchEvents] 相似度分布(后20):', simValues.slice(-20)); + console.log('[searchEvents] 有向量的事件数:', scored.filter(s => s.similarity > 0).length); + console.log('[searchEvents] sim >= 0.6:', scored.filter(s => s.similarity >= 0.6).length); + console.log('[searchEvents] sim >= 0.5:', scored.filter(s => s.similarity >= 0.5).length); + console.log('[searchEvents] sim >= 0.3:', scored.filter(s => s.similarity >= 0.3).length); + + // ★ 记录过滤前的分布(用 finalScore,与显示一致) + const preFilterDistribution = { + total: scored.length, + '0.85+': scored.filter(s => s.finalScore >= 0.85).length, + '0.7-0.85': scored.filter(s => s.finalScore >= 0.7 && s.finalScore < 0.85).length, + '0.6-0.7': scored.filter(s => s.finalScore >= 0.6 && s.finalScore < 0.7).length, + '0.5-0.6': scored.filter(s => s.finalScore >= 0.5 && s.finalScore < 0.6).length, + '<0.5': scored.filter(s => s.finalScore < 0.5).length, + passThreshold: scored.filter(s => s.finalScore >= CONFIG.MIN_SIMILARITY_EVENT).length, + threshold: CONFIG.MIN_SIMILARITY_EVENT, + }; + + // ★ 过滤改成用 finalScore(包含 bonus) const candidates = scored - .filter(s => s.similarity >= CONFIG.MIN_SIMILARITY) + .filter(s => s.finalScore >= CONFIG.MIN_SIMILARITY_EVENT) .sort((a, b) => b.finalScore - a.finalScore) .slice(0, CONFIG.CANDIDATE_EVENTS); + console.log('[searchEvents] 过滤后candidates:', candidates.length); + + // 动态 K:质量不够就少拿 + const dynamicK = Math.min(CONFIG.MAX_EVENTS, candidates.length); const selected = mmrSelect( candidates, - CONFIG.TOP_K_EVENTS, + dynamicK, CONFIG.MMR_LAMBDA, c => c.vector, c => c.finalScore @@ -370,14 +510,59 @@ async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEn similarity: s.finalScore, _recallType: s.isDirect ? 'DIRECT' : 'SIMILAR', _recallReason: s.reasons.length ? s.reasons.join('+') : '相似', + _preFilterDistribution: preFilterDistribution, })); } // ═══════════════════════════════════════════════════════════════════════════ -// 日志 +// 日志:因果树格式化 // ═══════════════════════════════════════════════════════════════════════════ -function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResults, allEvents, queryEntities }) { +function formatCausalTree(causalEvents, recalledEvents) { + if (!causalEvents?.length) return ''; + + const lines = [ + '', + '┌─────────────────────────────────────────────────────────────┐', + '│ 【因果链追溯】 │', + '└─────────────────────────────────────────────────────────────┘', + ]; + + // 按 chainFrom 分组展示 + const bySource = new Map(); + for (const c of causalEvents) { + for (const src of c.chainFrom || []) { + if (!bySource.has(src)) bySource.set(src, []); + bySource.get(src).push(c); + } + } + + for (const [sourceId, ancestors] of bySource) { + const sourceEvent = recalledEvents.find(e => e.event?.id === sourceId); + const sourceTitle = sourceEvent?.event?.title || sourceId; + lines.push(` ${sourceId} "${sourceTitle}" 的前因链:`); + + // 按深度排序 + ancestors.sort((a, b) => a.depth - b.depth); + + for (const c of ancestors) { + const indent = ' ' + ' '.repeat(c.depth - 1); + const ev = c.event; + const title = ev.title || '(无标题)'; + const refs = c.chainFrom.length > 1 ? ` [被${c.chainFrom.length}条链引用]` : ''; + lines.push(`${indent}└─ [depth=${c.depth}] ${ev.id} "${title}"${refs}`); + } + } + + lines.push(''); + return lines.join('\n'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 日志:主报告 +// ═══════════════════════════════════════════════════════════════════════════ + +function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResults, allEvents, queryEntities, causalEvents = [], chunkPreFilterStats = null }) { const lines = [ '╔══════════════════════════════════════════════════════════════╗', '║ 记忆召回报告 ║', @@ -413,9 +598,21 @@ function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResult lines.push(''); lines.push('┌─────────────────────────────────────────────────────────────┐'); - lines.push(`│ 【L1 原文片段】召回 ${chunkResults.length} 条`); + lines.push('│ 【L1 原文片段】 │'); lines.push('└─────────────────────────────────────────────────────────────┘'); + if (chunkPreFilterStats) { + const dist = chunkPreFilterStats.distribution || {}; + lines.push(` 过滤前: ${chunkPreFilterStats.total} 条`); + lines.push(' 相似度分布:'); + lines.push(` 0.8+: ${dist['0.8+'] || 0} | 0.7-0.8: ${dist['0.7-0.8'] || 0} | 0.6-0.7: ${dist['0.6-0.7'] || 0}`); + lines.push(` 0.55-0.6: ${dist['0.55-0.6'] || 0} | <0.55: ${dist['<0.55'] || 0}`); + lines.push(` 通过阈值(>=${chunkPreFilterStats.threshold}): ${chunkPreFilterStats.passThreshold} 条`); + lines.push(` MMR+Floor去重后: ${chunkResults.length} 条`); + } else { + lines.push(` 召回: ${chunkResults.length} 条`); + } + chunkResults.slice(0, 15).forEach((c, i) => { const preview = c.text.length > 50 ? c.text.slice(0, 50) + '...' : c.text; lines.push(` ${String(i + 1).padStart(2)}. #${String(c.floor).padStart(3)} [${c.speaker}] ${preview}`); @@ -428,7 +625,7 @@ function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResult lines.push(''); lines.push('┌─────────────────────────────────────────────────────────────┐'); - lines.push(`│ 【L2 事件记忆】召回 ${eventResults.length} / ${allEvents.length} 条`); + lines.push('│ 【L2 事件记忆】 │'); lines.push('│ DIRECT=亲身经历 SIMILAR=相关背景 │'); lines.push('└─────────────────────────────────────────────────────────────┘'); @@ -442,16 +639,27 @@ function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResult // 统计 const directCount = eventResults.filter(e => e._recallType === 'DIRECT').length; const similarCount = eventResults.filter(e => e._recallType === 'SIMILAR').length; + const preFilterDist = eventResults[0]?._preFilterDistribution || {}; lines.push(''); lines.push('┌─────────────────────────────────────────────────────────────┐'); lines.push('│ 【统计】 │'); lines.push('└─────────────────────────────────────────────────────────────┘'); lines.push(` L1 片段: ${chunkResults.length} 条`); - lines.push(` L2 事件: ${eventResults.length} 条 (DIRECT: ${directCount}, SIMILAR: ${similarCount})`); + lines.push(` L2 事件: ${eventResults.length} / ${allEvents.length} 条 (DIRECT: ${directCount}, SIMILAR: ${similarCount})`); + if (preFilterDist.total) { + lines.push(` L2 过滤前分布(${preFilterDist.total} 条,含bonus):`); + lines.push(` 0.85+: ${preFilterDist['0.85+'] || 0} | 0.7-0.85: ${preFilterDist['0.7-0.85'] || 0} | 0.6-0.7: ${preFilterDist['0.6-0.7'] || 0}`); + lines.push(` 0.5-0.6: ${preFilterDist['0.5-0.6'] || 0} | <0.5: ${preFilterDist['<0.5'] || 0}`); + lines.push(` 通过阈值(>=${preFilterDist.threshold || 0.6}): ${preFilterDist.passThreshold || 0} 条`); + } lines.push(` 实体命中: ${queryEntities?.length || 0} 个`); + if (causalEvents.length) lines.push(` 因果链追溯: ${causalEvents.length} 条`); lines.push(''); + // 追加因果树详情 + lines.push(formatCausalTree(causalEvents, eventResults)); + return lines.join('\n'); } @@ -492,15 +700,53 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options = searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities), ]); + const chunkPreFilterStats = chunkResults._preFilterStats || null; + + // ───────────────────────────────────────────────────────────────────── + // 因果链追溯:从 eventResults 出发找祖先事件 + // 注意:是否“额外注入”要去重(如果祖先事件本来已召回,就不额外注入) + // ───────────────────────────────────────────────────────────────────── + const eventIndex = buildEventIndex(allEvents); + const causalMap = traceCausalAncestors(eventResults, eventIndex); + + const recalledIdSet = new Set(eventResults.map(x => x?.event?.id).filter(Boolean)); + const causalEvents = Array.from(causalMap.values()) + .filter(x => x?.event?.id && !recalledIdSet.has(x.event.id)) + .map(x => ({ + event: x.event, + similarity: 0, + _recallType: 'CAUSAL', + _recallReason: `因果链(${x.chainFrom.join(',')})`, + _causalDepth: x.depth, + _chainFrom: x.chainFrom, + chainFrom: x.chainFrom, + depth: x.depth, + })); + + // 排序:引用数 > 深度 > 编号,然后截断 + sortCausalEvents(causalEvents); + const causalEventsTruncated = causalEvents.slice(0, CONFIG.CAUSAL_INJECT_MAX); + const elapsed = Math.round(performance.now() - T0); - const logText = formatRecallLog({ elapsed, queryText, segments, weights, chunkResults, eventResults, allEvents, queryEntities }); + const logText = formatRecallLog({ + elapsed, + queryText, + segments, + weights, + chunkResults, + eventResults, + allEvents, + queryEntities, + causalEvents: causalEventsTruncated, + chunkPreFilterStats, + }); console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold'); console.log(`Elapsed: ${elapsed}ms | Entities: ${queryEntities.join(', ') || '(none)'}`); - console.log(`L1: ${chunkResults.length} | L2: ${eventResults.length}/${allEvents.length}`); + console.log(`L1: ${chunkResults.length} | L2: ${eventResults.length}/${allEvents.length} | Causal: ${causalEventsTruncated.length}`); console.groupEnd(); - return { events: eventResults, chunks: chunkResults, elapsed, logText }; + return { events: eventResults, causalEvents: causalEventsTruncated, chunks: chunkResults, elapsed, logText, queryEntities }; } export function buildQueryText(chat, count = 2, excludeLastAi = false) {