Improve L0 extraction flow and recall robustness

This commit is contained in:
2026-02-10 12:43:43 +08:00
parent 3af76a9651
commit 1fe0647462
5 changed files with 370 additions and 120 deletions

View File

@@ -1,17 +1,15 @@
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Story Summary - Prompt Injection (v5 - Two-Stage: L0 Locate → L1 Evidence) // Story Summary - Prompt Injection (v6 - EvidenceGroup: per-floor L0 + shared L1)
// //
// 命名规范: // 命名规范:
// - 存储层用 L0/L1/L2/L3StateAtom/Chunk/Event/Fact // - 存储层用 L0/L1/L2/L3StateAtom/Chunk/Event/Fact
// - 装配层用语义名称constraint/event/evidence/arc // - 装配层用语义名称constraint/event/evidence/arc
// //
// 架构变更v4 → v5 // 架构变更v5 → v6
// - L0 和 L1 不再在同一个池子竞争 // - 同楼层多个 L0 共享一对 L1EvidenceGroup per-floor
// - recall.js 返回 {l0Selected[], l1ByFloor: Map} 而非 evidenceChunks[] // - L0 展示文本从 semantic 字段改为从结构字段type/subject/object/value/location拼接
// - 装配层按 L2→L0→L1 层级组织 // - 移除 <type> 标签和 [tags] theme 标签,输出自然语言短句
// - 预算以"L0 + USER top-1 + AI top-1"为原子单元 // - 短行分号拼接长行换行120字阈值
// - 孤立 L1无对应 L0丢弃
// - 孤立 L0无对应 L1保留
// //
// 职责: // 职责:
// - 仅负责"构建注入文本",不负责写入 extension_prompts // - 仅负责"构建注入文本",不负责写入 extension_prompts
@@ -24,6 +22,7 @@ import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js"; import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory } from "../vector/retrieval/recall.js"; import { recallMemory } from "../vector/retrieval/recall.js";
import { getMeta } from "../vector/storage/chunk-store.js"; import { getMeta } from "../vector/storage/chunk-store.js";
import { getEngineFingerprint } from "../vector/utils/embedder.js";
// Metrics // Metrics
import { formatMetricsLog, detectIssues } from "../vector/retrieval/metrics.js"; import { formatMetricsLog, detectIssues } from "../vector/retrieval/metrics.js";
@@ -56,6 +55,9 @@ const CONSTRAINT_MAX = 2000;
const ARCS_MAX = 1500; const ARCS_MAX = 1500;
const TOP_N_STAR = 5; const TOP_N_STAR = 5;
// L0 显示文本:分号拼接 vs 多行模式的阈值
const L0_JOINED_MAX_LENGTH = 120;
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 工具函数 // 工具函数
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -295,16 +297,88 @@ function formatArcLine(arc) {
} }
/** /**
* 格式化 L0 锚点行 * 从 atom 结构字段生成可读短句(不依赖 semantic 字段)
* @param {object} l0 - L0 对象 *
* @returns {string} 格式化后的行 * 规则:
* - act: 主体+谓词+客体
* - emo: 主体+谓词+(对客体)
* - rev: 揭示:谓词+(关于客体)
* - dec: 主体+谓词+(对客体)
* - ten: 主体与客体之间:谓词
* - loc: 场景:地点或谓词
* - 地点非空且非 loc 类型时后缀 "在{location}"
*
* @param {object} l0 - L0 对象(含 l0.atom
* @returns {string} 可读短句
*/ */
function formatL0Line(l0) { function buildL0DisplayText(l0) {
return ` #${l0.floor + 1} [📌] ${String(l0.text || l0.atom?.semantic || "").trim()}`; const atom = l0.atom || l0._atom || {};
const type = atom.type || 'act';
const subject = String(atom.subject || '').trim();
const object = String(atom.object || '').trim();
const value = String(atom.value || '').trim();
const location = String(atom.location || '').trim();
if (!value && !subject) {
// 兜底:如果结构字段缺失,回退到 semantic 并剥离标签
const semantic = String(atom.semantic || l0.text || '').trim();
return semantic
.replace(/^<\w+>\s*/, '')
.replace(/\s*\[[\w/]+\]\s*$/, '')
.trim() || '(未知锚点)';
}
let result = '';
switch (type) {
case 'emo':
result = `${subject}${value}`;
if (object) result += `(对${object})`;
break;
case 'act':
result = `${subject}${value}`;
if (object) result += `${object}`;
break;
case 'rev':
result = `揭示:${value}`;
if (object) result += `(关于${object})`;
break;
case 'dec':
result = `${subject}${value}`;
if (object) result += `(对${object})`;
break;
case 'ten':
if (object) {
result = `${subject}${object}之间:${value}`;
} else {
result = `${subject}${value}`;
}
break;
case 'loc':
result = `场景:${location || value}`;
break;
default:
result = `${subject}${value}`;
if (object) result += `${object}`;
break;
}
// 地点后缀loc 类型已包含地点,不重复)
if (location && type !== 'loc') {
result += `${location}`;
}
return result.trim();
} }
/** /**
* 格式化 L1 chunk 行(挂在 L0 下方) * 格式化 L1 chunk 行
* @param {object} chunk - L1 chunk 对象 * @param {object} chunk - L1 chunk 对象
* @param {boolean} isContext - 是否为上下文USER 侧) * @param {boolean} isContext - 是否为上下文USER 侧)
* @returns {string} 格式化后的行 * @returns {string} 格式化后的行
@@ -344,99 +418,177 @@ function formatCausalEventLine(causalItem) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// L0→L1 证据单元构建 // L0 按楼层分组
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** /**
* @typedef {object} EvidenceUnit * 将 L0 列表按楼层分组
* @property {object} l0 - L0 锚点对象 * @param {object[]} l0List - L0 对象列表
* @property {object|null} userL1 - USER 侧 top-1 L1 chunk * @returns {Map<number, object[]>} floor → L0 数组
* @property {object|null} aiL1 - AI 侧 top-1 L1 chunk */
* @property {number} totalTokens - 整个单元的 token 估算 function groupL0ByFloor(l0List) {
const map = new Map();
for (const l0 of l0List) {
const floor = l0.floor;
if (!map.has(floor)) {
map.set(floor, []);
}
map.get(floor).push(l0);
}
return map;
}
// ─────────────────────────────────────────────────────────────────────────────
// EvidenceGroupper-floorN个L0 + 共享一对L1
// ─────────────────────────────────────────────────────────────────────────────
/**
* @typedef {object} EvidenceGroup
* @property {number} floor - 楼层号
* @property {object[]} l0Atoms - 该楼层所有被选中的 L0
* @property {object|null} userL1 - USER 侧 top-1 L1 chunk仅一份
* @property {object|null} aiL1 - AI 侧 top-1 L1 chunk仅一份
* @property {number} totalTokens - 整组 token 估算
*/ */
/** /**
* 为一个 L0 构建证据单元 * 为一个楼层构建证据
* @param {object} l0 - L0 对象 *
* 同楼层多个 L0 共享一对 L1避免 L1 重复输出。
*
* @param {number} floor - 楼层号
* @param {object[]} l0AtomsForFloor - 该楼层所有被选中的 L0
* @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射 * @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射
* @returns {EvidenceUnit} * @returns {EvidenceGroup}
*/ */
function buildEvidenceUnit(l0, l1ByFloor) { function buildEvidenceGroup(floor, l0AtomsForFloor, l1ByFloor) {
const pair = l1ByFloor.get(l0.floor); const pair = l1ByFloor.get(floor);
const userL1 = pair?.userTop1 || null; const userL1 = pair?.userTop1 || null;
const aiL1 = pair?.aiTop1 || null; const aiL1 = pair?.aiTop1 || null;
// 计算整个单元的 token 开销 // 计算整 token 开销
let totalTokens = estimateTokens(formatL0Line(l0)); let totalTokens = 0;
// 所有 L0 的显示文本
for (const l0 of l0AtomsForFloor) {
totalTokens += estimateTokens(buildL0DisplayText(l0));
}
// 固定开销:楼层前缀、📌 标记、分号等
totalTokens += 10;
// L1 仅算一次
if (userL1) totalTokens += estimateTokens(formatL1Line(userL1, true)); if (userL1) totalTokens += estimateTokens(formatL1Line(userL1, true));
if (aiL1) totalTokens += estimateTokens(formatL1Line(aiL1, false)); if (aiL1) totalTokens += estimateTokens(formatL1Line(aiL1, false));
return { l0, userL1, aiL1, totalTokens }; return { floor, l0Atoms: l0AtomsForFloor, userL1, aiL1, totalTokens };
} }
/** /**
* 格式化一个证据单元为文本行 * 格式化一个证据为文本行数组
* @param {EvidenceUnit} unit - 证据单元 *
* 短行模式(拼接后 ≤ 120 字):
* #500 [📌] 薇薇保持跪趴姿势;薇薇展示细节;薇薇与蓝袖之间:被审视
* ┌ #499 [蓝袖] ...
* #500 [角色] ...
*
* 长行模式(拼接后 > 120 字):
* #500 [📌] 薇薇保持跪趴姿势 在书房
* │ 薇薇展示肛周细节 在书房
* │ 薇薇与蓝袖之间:身体被审视 在书房
* ┌ #499 [蓝袖] ...
* #500 [角色] ...
*
* @param {EvidenceGroup} group - 证据组
* @returns {string[]} 文本行数组 * @returns {string[]} 文本行数组
*/ */
function formatEvidenceUnit(unit) { function formatEvidenceGroup(group) {
const displayTexts = group.l0Atoms.map(l0 => buildL0DisplayText(l0));
const lines = []; const lines = [];
lines.push(formatL0Line(unit.l0));
if (unit.userL1) { // L0 部分
lines.push(formatL1Line(unit.userL1, true)); const joined = displayTexts.join('');
if (joined.length <= L0_JOINED_MAX_LENGTH) {
// 短行:分号拼接为一行
lines.push(` #${group.floor + 1} [📌] ${joined}`);
} else {
// 长行:每个 L0 独占一行,首行带楼层号
lines.push(` #${group.floor + 1} [📌] ${displayTexts[0]}`);
for (let i = 1; i < displayTexts.length; i++) {
lines.push(`${displayTexts[i]}`);
} }
if (unit.aiL1) {
lines.push(formatL1Line(unit.aiL1, false));
} }
// L1 证据(仅一次)
if (group.userL1) {
lines.push(formatL1Line(group.userL1, true));
}
if (group.aiL1) {
lines.push(formatL1Line(group.aiL1, false));
}
return lines; return lines;
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 事件证据收集 // 事件证据收集per-floor 分组)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** /**
* 为事件收集范围内的 L0 证据单元 * 为事件收集范围内的 EvidenceGroup
*
* 同楼层多个 L0 归入同一组,共享一对 L1。
*
* @param {object} eventObj - 事件对象 * @param {object} eventObj - 事件对象
* @param {object[]} l0Selected - 所有选中的 L0 * @param {object[]} l0Selected - 所有选中的 L0
* @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射 * @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射
* @param {Set<string>} usedL0Ids - 已消费的 L0 ID 集合(会被修改) * @param {Set<string>} usedL0Ids - 已消费的 L0 ID 集合(会被修改)
* @returns {EvidenceUnit[]} 该事件的证据单元列表 * @returns {EvidenceGroup[]} 该事件的证据组列表(按楼层排序)
*/ */
function collectEvidenceForEvent(eventObj, l0Selected, l1ByFloor, usedL0Ids) { function collectEvidenceGroupsForEvent(eventObj, l0Selected, l1ByFloor, usedL0Ids) {
const range = parseFloorRange(eventObj?.summary); const range = parseFloorRange(eventObj?.summary);
if (!range) return []; if (!range) return [];
const units = []; // 收集范围内未消费的 L0按楼层分组
const floorMap = new Map();
for (const l0 of l0Selected) { for (const l0 of l0Selected) {
if (usedL0Ids.has(l0.id)) continue; if (usedL0Ids.has(l0.id)) continue;
if (l0.floor < range.start || l0.floor > range.end) continue; if (l0.floor < range.start || l0.floor > range.end) continue;
const unit = buildEvidenceUnit(l0, l1ByFloor); if (!floorMap.has(l0.floor)) {
units.push(unit); floorMap.set(l0.floor, []);
}
floorMap.get(l0.floor).push(l0);
usedL0Ids.add(l0.id); usedL0Ids.add(l0.id);
} }
// 按楼层排序 // 构建 groups
units.sort((a, b) => a.l0.floor - b.l0.floor); const groups = [];
for (const [floor, l0s] of floorMap) {
groups.push(buildEvidenceGroup(floor, l0s, l1ByFloor));
}
return units; // 按楼层排序
groups.sort((a, b) => a.floor - b.floor);
return groups;
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 事件格式化L2→L0→L1 层级) // 事件格式化L2 → EvidenceGroup 层级)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** /**
* 格式化事件(含 L0→L1 证据) * 格式化事件(含 EvidenceGroup 证据)
* @param {object} eventItem - 事件召回项 * @param {object} eventItem - 事件召回项
* @param {number} idx - 编号 * @param {number} idx - 编号
* @param {EvidenceUnit[]} evidenceUnits - 该事件的证据单元 * @param {EvidenceGroup[]} evidenceGroups - 该事件的证据
* @param {Map<string, object>} causalById - 因果事件索引 * @param {Map<string, object>} causalById - 因果事件索引
* @returns {string} 格式化后的文本 * @returns {string} 格式化后的文本
*/ */
function formatEventWithEvidence(eventItem, idx, evidenceUnits, causalById) { function formatEventWithEvidence(eventItem, idx, evidenceGroups, causalById) {
const ev = eventItem.event || {}; const ev = eventItem.event || {};
const time = ev.timeLabel || ""; const time = ev.timeLabel || "";
const title = String(ev.title || "").trim(); const title = String(ev.title || "").trim();
@@ -456,9 +608,9 @@ function formatEventWithEvidence(eventItem, idx, evidenceUnits, causalById) {
if (c) lines.push(formatCausalEventLine(c)); if (c) lines.push(formatCausalEventLine(c));
} }
// L0→L1 证据单元 // EvidenceGroup 证据
for (const unit of evidenceUnits) { for (const group of evidenceGroups) {
lines.push(...formatEvidenceUnit(unit)); lines.push(...formatEvidenceGroup(group));
} }
return lines.join("\n"); return lines.join("\n");
@@ -673,7 +825,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// [Events] L2 Events → 直接命中 + 相似命中 + 因果链 + L0→L1 证据 // [Events] L2 Events → 直接命中 + 相似命中 + 因果链 + EvidenceGroup
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary); const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary);
@@ -689,11 +841,11 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const isDirect = e._recallType === "DIRECT"; const isDirect = e._recallType === "DIRECT";
// 收集该事件范围内的 L0→L1 证据单元 // 收集该事件范围内的 EvidenceGroupper-floor
const evidenceUnits = collectEvidenceForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids); const evidenceGroups = collectEvidenceGroupsForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids);
// 格式化事件(含证据) // 格式化事件(含证据)
const text = formatEventWithEvidence(e, 0, evidenceUnits, causalById); const text = formatEventWithEvidence(e, 0, evidenceGroups, causalById);
const cost = estimateTokens(text); const cost = estimateTokens(text);
// 预算检查:整个事件(含证据)作为原子单元 // 预算检查:整个事件(含证据)作为原子单元
@@ -703,23 +855,31 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const costNoEvidence = estimateTokens(textNoEvidence); const costNoEvidence = estimateTokens(textNoEvidence);
if (total.used + costNoEvidence > total.max) { if (total.used + costNoEvidence > total.max) {
// 归还 usedL0Ids
for (const group of evidenceGroups) {
for (const l0 of group.l0Atoms) {
usedL0Ids.delete(l0.id);
}
}
continue; continue;
} }
// 放入不带证据的版本,归还已消费的 L0 ID // 放入不带证据的版本,归还已消费的 L0 ID
for (const unit of evidenceUnits) { for (const group of evidenceGroups) {
usedL0Ids.delete(unit.l0.id); for (const l0 of group.l0Atoms) {
usedL0Ids.delete(l0.id);
}
} }
if (isDirect) { if (isDirect) {
selectedDirect.push({ selectedDirect.push({
event: e.event, text: textNoEvidence, tokens: costNoEvidence, event: e.event, text: textNoEvidence, tokens: costNoEvidence,
evidenceUnits: [], candidateRank, evidenceGroups: [], candidateRank,
}); });
} else { } else {
selectedRelated.push({ selectedRelated.push({
event: e.event, text: textNoEvidence, tokens: costNoEvidence, event: e.event, text: textNoEvidence, tokens: costNoEvidence,
evidenceUnits: [], candidateRank, evidenceGroups: [], candidateRank,
}); });
} }
@@ -734,36 +894,36 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
tokens: costNoEvidence, tokens: costNoEvidence,
similarity: e.similarity || 0, similarity: e.similarity || 0,
l0Count: 0, l0Count: 0,
l1Count: 0, l1FloorCount: 0,
}); });
continue; continue;
} }
// 预算充足,放入完整版本 // 预算充足,放入完整版本
const l0Count = evidenceUnits.length; let l0Count = 0;
let l1Count = 0; let l1FloorCount = 0;
for (const unit of evidenceUnits) { for (const group of evidenceGroups) {
if (unit.userL1) l1Count++; l0Count += group.l0Atoms.length;
if (unit.aiL1) l1Count++; if (group.userL1 || group.aiL1) l1FloorCount++;
} }
if (isDirect) { if (isDirect) {
selectedDirect.push({ selectedDirect.push({
event: e.event, text, tokens: cost, event: e.event, text, tokens: cost,
evidenceUnits, candidateRank, evidenceGroups, candidateRank,
}); });
} else { } else {
selectedRelated.push({ selectedRelated.push({
event: e.event, text, tokens: cost, event: e.event, text, tokens: cost,
evidenceUnits, candidateRank, evidenceGroups, candidateRank,
}); });
} }
injectionStats.event.selected++; injectionStats.event.selected++;
injectionStats.event.tokens += cost; injectionStats.event.tokens += cost;
injectionStats.evidence.l0InEvents += l0Count; injectionStats.evidence.l0InEvents += l0Count;
injectionStats.evidence.l1InEvents += l1Count; injectionStats.evidence.l1InEvents += l1FloorCount;
total.used += cost; total.used += cost;
eventDetails.list.push({ eventDetails.list.push({
@@ -773,7 +933,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
tokens: cost, tokens: cost,
similarity: e.similarity || 0, similarity: e.similarity || 0,
l0Count, l0Count,
l1Count, l1FloorCount,
}); });
} }
@@ -798,7 +958,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
assembled.relatedEvents.lines = relatedEventTexts; assembled.relatedEvents.lines = relatedEventTexts;
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// [Evidence - Distant] 远期证据(已总结范围,未被事件消费的 L0→L1 // [Evidence - Distant] 远期证据(已总结范围,未被事件消费的 L0
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const lastSummarized = store.lastSummarizedMesId ?? -1; const lastSummarized = store.lastSummarizedMesId ?? -1;
@@ -816,21 +976,25 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
if (distantL0.length && total.used < total.max) { if (distantL0.length && total.used < total.max) {
const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) }; const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) };
// 按楼层排序(时间顺序) // 按楼层排序(时间顺序)后分组
distantL0.sort((a, b) => a.floor - b.floor); distantL0.sort((a, b) => a.floor - b.floor);
const distantFloorMap = groupL0ByFloor(distantL0);
for (const l0 of distantL0) { // 按楼层顺序遍历Map 保持插入顺序distantL0 已按 floor 排序)
const unit = buildEvidenceUnit(l0, l1ByFloor); for (const [floor, l0s] of distantFloorMap) {
const group = buildEvidenceGroup(floor, l0s, l1ByFloor);
// 原子单元预算检查 // 原子预算检查
if (distantBudget.used + unit.totalTokens > distantBudget.max) continue; if (distantBudget.used + group.totalTokens > distantBudget.max) continue;
const unitLines = formatEvidenceUnit(unit); const groupLines = formatEvidenceGroup(group);
for (const line of unitLines) { for (const line of groupLines) {
assembled.distantEvidence.lines.push(line); assembled.distantEvidence.lines.push(line);
} }
distantBudget.used += unit.totalTokens; distantBudget.used += group.totalTokens;
for (const l0 of l0s) {
usedL0Ids.add(l0.id); usedL0Ids.add(l0.id);
}
injectionStats.distantEvidence.units++; injectionStats.distantEvidence.units++;
} }
@@ -854,20 +1018,23 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
if (recentL0.length) { if (recentL0.length) {
const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX }; const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX };
// 按楼层排序(时间顺序) // 按楼层排序后分组
recentL0.sort((a, b) => a.floor - b.floor); recentL0.sort((a, b) => a.floor - b.floor);
const recentFloorMap = groupL0ByFloor(recentL0);
for (const l0 of recentL0) { for (const [floor, l0s] of recentFloorMap) {
const unit = buildEvidenceUnit(l0, l1ByFloor); const group = buildEvidenceGroup(floor, l0s, l1ByFloor);
if (recentBudget.used + unit.totalTokens > recentBudget.max) continue; if (recentBudget.used + group.totalTokens > recentBudget.max) continue;
const unitLines = formatEvidenceUnit(unit); const groupLines = formatEvidenceGroup(group);
for (const line of unitLines) { for (const line of groupLines) {
assembled.recentEvidence.lines.push(line); assembled.recentEvidence.lines.push(line);
} }
recentBudget.used += unit.totalTokens; recentBudget.used += group.totalTokens;
for (const l0 of l0s) {
usedL0Ids.add(l0.id); usedL0Ids.add(l0.id);
}
injectionStats.recentEvidence.units++; injectionStats.recentEvidence.units++;
} }
@@ -951,13 +1118,17 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
: 100; : 100;
metrics.quality.eventPrecisionProxy = metrics.event?.similarityDistribution?.mean || 0; metrics.quality.eventPrecisionProxy = metrics.event?.similarityDistribution?.mean || 0;
const totalL0Selected = l0Selected.length; // l1AttachRate有 L1 挂载的唯一楼层占所有 L0 覆盖楼层的比例
const l0WithL1 = l0Selected.filter(l0 => { const l0Floors = new Set(l0Selected.map(l0 => l0.floor));
const pair = l1ByFloor.get(l0.floor); const l0FloorsWithL1 = new Set();
return pair?.aiTop1 || pair?.userTop1; for (const floor of l0Floors) {
}).length; const pair = l1ByFloor.get(floor);
metrics.quality.l1AttachRate = totalL0Selected > 0 if (pair?.aiTop1 || pair?.userTop1) {
? Math.round(l0WithL1 / totalL0Selected * 100) l0FloorsWithL1.add(floor);
}
}
metrics.quality.l1AttachRate = l0Floors.size > 0
? Math.round(l0FloorsWithL1.size / l0Floors.size * 100)
: 0; : 0;
metrics.quality.potentialIssues = detectIssues(metrics); metrics.quality.potentialIssues = detectIssues(metrics);
@@ -1036,7 +1207,7 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
if (echo && canNotifyRecallFail()) { if (echo && canNotifyRecallFail()) {
const msg = String(e?.message || "未知错误").replace(/\s+/g, " ").slice(0, 200); const msg = String(e?.message || "未知错误").replace(/\s+/g, " ").slice(0, 200);
await echo(`/echo severity=warning 向量召回失败:${msg}`); await echo(`/echo severity=warning 嵌入 API 请求失败:${msg}(本次跳过记忆召回)`);
} }
if (postToFrame) { if (postToFrame) {
@@ -1055,12 +1226,21 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
(recallResult?.causalChain?.length || 0) > 0; (recallResult?.causalChain?.length || 0) > 0;
if (!hasUseful) { if (!hasUseful) {
const noVectorsGenerated = !meta?.fingerprint || (meta?.lastChunkFloor ?? -1) < 0;
const fpMismatch = meta?.fingerprint && meta.fingerprint !== getEngineFingerprint(vectorCfg);
if (fpMismatch) {
if (echo && canNotifyRecallFail()) { if (echo && canNotifyRecallFail()) {
await echo( await echo("/echo severity=warning 向量引擎已变更,请重新生成向量");
"/echo severity=warning 向量召回失败:没有可用召回结果(请先在面板中生成向量,或检查指纹不匹配)"
);
} }
if (postToFrame) { } else if (noVectorsGenerated) {
if (echo && canNotifyRecallFail()) {
await echo("/echo severity=warning 没有可用向量,请在剧情总结面板中生成向量");
}
}
// 向量存在但本次未命中 → 静默跳过,不打扰用户
if (postToFrame && (noVectorsGenerated || fpMismatch)) {
postToFrame({ postToFrame({
type: "RECALL_LOG", type: "RECALL_LOG",
text: "\n[Vector Recall Empty]\nNo recall candidates / vectors not ready.\n", text: "\n[Vector Recall Empty]\nNo recall candidates / vectors not ready.\n",

View File

@@ -526,6 +526,52 @@ async function handleClearVectors() {
xbLog.info(MODULE_ID, "向量数据已清除"); xbLog.info(MODULE_ID, "向量数据已清除");
} }
// ═══════════════════════════════════════════════════════════════════════════
// L0 自动补提取(每收到新消息后检查并补提取缺失楼层)
// ═══════════════════════════════════════════════════════════════════════════
async function maybeAutoExtractL0() {
const vectorCfg = getVectorConfig();
if (!vectorCfg?.enabled) return;
if (anchorGenerating || vectorGenerating) return;
const { chatId, chat } = getContext();
if (!chatId || !chat?.length) return;
const stats = await getAnchorStats();
if (stats.pending <= 0) return;
anchorGenerating = true;
try {
await incrementalExtractAtoms(chatId, chat, null, { maxFloors: 20 });
// 为新提取的 L0 楼层构建 L1 chunks
await buildIncrementalChunks({ vectorConfig: vectorCfg });
invalidateLexicalIndex();
await sendAnchorStatsToFrame();
await sendVectorStatsToFrame();
xbLog.info(MODULE_ID, "自动 L0 补提取完成");
} catch (e) {
xbLog.error(MODULE_ID, "自动 L0 补提取失败", e);
} finally {
anchorGenerating = false;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Embedding 连接预热
// ═══════════════════════════════════════════════════════════════════════════
function warmupEmbeddingConnection() {
const vectorCfg = getVectorConfig();
if (!vectorCfg?.enabled) return;
embed(['.'], vectorCfg, { timeout: 5000 }).catch(() => {});
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// 实体词典注入 + 索引预热 // 实体词典注入 + 索引预热
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -1284,6 +1330,9 @@ async function handleChatChanged() {
// 实体词典注入 + 索引预热 // 实体词典注入 + 索引预热
refreshEntityLexiconAndWarmup(); refreshEntityLexiconAndWarmup();
// Embedding 连接预热(保持 TCP keep-alive减少首次召回超时
warmupEmbeddingConnection();
setTimeout(() => checkVectorIntegrityAndWarn(), 2000); setTimeout(() => checkVectorIntegrityAndWarn(), 2000);
} }
@@ -1316,7 +1365,10 @@ async function handleMessageReceived() {
// 向量全量生成中时跳过 L1 sync避免竞争写入 // 向量全量生成中时跳过 L1 sync避免竞争写入
if (vectorGenerating) return; if (vectorGenerating) return;
await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig); await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig, () => {
sendAnchorStatsToFrame();
sendVectorStatsToFrame();
});
await maybeAutoBuildChunks(); await maybeAutoBuildChunks();
applyHideStateDebounced(); applyHideStateDebounced();
@@ -1324,6 +1376,9 @@ async function handleMessageReceived() {
// 新消息后刷新实体词典(可能有新角色) // 新消息后刷新实体词典(可能有新角色)
refreshEntityLexiconAndWarmup(); refreshEntityLexiconAndWarmup();
// 自动补提取缺失的 L0延迟执行避免与当前楼提取竞争
setTimeout(() => maybeAutoExtractL0(), 2000);
} }
function handleMessageSent() { function handleMessageSent() {

View File

@@ -334,7 +334,7 @@ export async function syncOnMessageSwiped(chatId, lastFloor) {
/** /**
* 新消息后同步:删除 + 重建最后楼层 * 新消息后同步:删除 + 重建最后楼层
*/ */
export async function syncOnMessageReceived(chatId, lastFloor, message, vectorConfig) { export async function syncOnMessageReceived(chatId, lastFloor, message, vectorConfig, onL0Complete) {
if (!chatId || lastFloor < 0 || !message) return; if (!chatId || lastFloor < 0 || !message) return;
if (!vectorConfig?.enabled) return; if (!vectorConfig?.enabled) return;
@@ -368,11 +368,10 @@ export async function syncOnMessageReceived(chatId, lastFloor, message, vectorCo
const userMessage = (userFloor >= 0 && chat[userFloor]?.is_user) ? chat[userFloor] : null; const userMessage = (userFloor >= 0 && chat[userFloor]?.is_user) ? chat[userFloor] : null;
try { try {
await extractAndStoreAtomsForRound(lastFloor, message, userMessage); await extractAndStoreAtomsForRound(lastFloor, message, userMessage, onL0Complete);
} catch (e) { } catch (e) {
xbLog.warn(MODULE_ID, `Atom 提取失败: floor ${lastFloor}`, e); xbLog.warn(MODULE_ID, `Atom 提取失败: floor ${lastFloor}`, e);
} }
} }
} }

View File

@@ -112,7 +112,8 @@ function buildL0InputText(userMessage, aiMessage) {
return parts.join('\n\n---\n\n').trim(); return parts.join('\n\n---\n\n').trim();
} }
export async function incrementalExtractAtoms(chatId, chat, onProgress) { export async function incrementalExtractAtoms(chatId, chat, onProgress, options = {}) {
const { maxFloors = Infinity } = options;
if (!chatId || !chat?.length) return { built: 0 }; if (!chatId || !chat?.length) return { built: 0 };
const vectorCfg = getVectorConfig(); const vectorCfg = getVectorConfig();
@@ -144,6 +145,11 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress) {
pendingPairs.push({ userMsg, aiMsg: msg, aiFloor: i }); pendingPairs.push({ userMsg, aiMsg: msg, aiFloor: i });
} }
// 限制单次提取楼层数(自动触发时使用)
if (pendingPairs.length > maxFloors) {
pendingPairs.length = maxFloors;
}
if (!pendingPairs.length) { if (!pendingPairs.length) {
onProgress?.('已全部提取', 0, 0); onProgress?.('已全部提取', 0, 0);
return { built: 0 }; return { built: 0 };
@@ -323,14 +329,14 @@ export async function clearAllAtomsAndVectors(chatId) {
let extractionQueue = []; let extractionQueue = [];
let isProcessing = false; let isProcessing = false;
export async function extractAndStoreAtomsForRound(aiFloor, aiMessage, userMessage) { export async function extractAndStoreAtomsForRound(aiFloor, aiMessage, userMessage, onComplete) {
const { chatId } = getContext(); const { chatId } = getContext();
if (!chatId) return; if (!chatId) return;
const vectorCfg = getVectorConfig(); const vectorCfg = getVectorConfig();
if (!vectorCfg?.enabled) return; if (!vectorCfg?.enabled) return;
extractionQueue.push({ aiFloor, aiMessage, userMessage, chatId }); extractionQueue.push({ aiFloor, aiMessage, userMessage, chatId, onComplete });
processQueue(); processQueue();
} }
@@ -339,13 +345,14 @@ async function processQueue() {
isProcessing = true; isProcessing = true;
while (extractionQueue.length > 0) { while (extractionQueue.length > 0) {
const { aiFloor, aiMessage, userMessage, chatId } = extractionQueue.shift(); const { aiFloor, aiMessage, userMessage, chatId, onComplete } = extractionQueue.shift();
try { try {
const atoms = await extractAtomsForRound(userMessage, aiMessage, aiFloor, { timeout: 12000 }); const atoms = await extractAtomsForRound(userMessage, aiMessage, aiFloor, { timeout: 12000 });
if (!atoms?.length) { if (!atoms?.length) {
xbLog.info(MODULE_ID, `floor ${aiFloor}: 无有效 atoms`); xbLog.info(MODULE_ID, `floor ${aiFloor}: 无有效 atoms`);
onComplete?.({ floor: aiFloor, atomCount: 0 });
continue; continue;
} }
@@ -356,8 +363,10 @@ async function processQueue() {
await vectorizeAtomsSimple(chatId, atoms); await vectorizeAtomsSimple(chatId, atoms);
xbLog.info(MODULE_ID, `floor ${aiFloor}: ${atoms.length} atoms 已存储`); xbLog.info(MODULE_ID, `floor ${aiFloor}: ${atoms.length} atoms 已存储`);
onComplete?.({ floor: aiFloor, atomCount: atoms.length });
} catch (e) { } catch (e) {
xbLog.error(MODULE_ID, `floor ${aiFloor} 处理失败`, e); xbLog.error(MODULE_ID, `floor ${aiFloor} 处理失败`, e);
onComplete?.({ floor: aiFloor, atomCount: 0, error: e });
} }
} }

View File

@@ -878,17 +878,24 @@ export async function recallMemory(allEvents, vectorConfig, options = {}) {
try { try {
const [vec] = await embed([bundle.queryText_v0], vectorConfig, { timeout: 10000 }); const [vec] = await embed([bundle.queryText_v0], vectorConfig, { timeout: 10000 });
queryVector_v0 = vec; queryVector_v0 = vec;
} catch (e) { } catch (e1) {
xbLog.error(MODULE_ID, 'Round 1 向量化失败', e); xbLog.warn(MODULE_ID, 'Round 1 向量化失败500ms 后重试', e1);
await new Promise(r => setTimeout(r, 500));
try {
const [vec] = await embed([bundle.queryText_v0], vectorConfig, { timeout: 15000 });
queryVector_v0 = vec;
} catch (e2) {
xbLog.error(MODULE_ID, 'Round 1 向量化重试仍失败', e2);
metrics.timing.total = Math.round(performance.now() - T0); metrics.timing.total = Math.round(performance.now() - T0);
return { return {
events: [], l0Selected: [], l1ByFloor: new Map(), causalChain: [], events: [], l0Selected: [], l1ByFloor: new Map(), causalChain: [],
focusEntities: bundle.focusEntities, focusEntities: bundle.focusEntities,
elapsed: metrics.timing.total, elapsed: metrics.timing.total,
logText: 'Embedding failed (round 1).', logText: 'Embedding failed (round 1, after retry).',
metrics, metrics,
}; };
} }
}
if (!queryVector_v0?.length) { if (!queryVector_v0?.length) {
metrics.timing.total = Math.round(performance.now() - T0); metrics.timing.total = Math.round(performance.now() - T0);