Improve L0 extraction flow and recall robustness
This commit is contained in:
@@ -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/L3(StateAtom/Chunk/Event/Fact)
|
||||
// - 装配层用语义名称:constraint/event/evidence/arc
|
||||
//
|
||||
// 架构变更(v4 → v5):
|
||||
// - L0 和 L1 不再在同一个池子竞争
|
||||
// - recall.js 返回 {l0Selected[], l1ByFloor: Map} 而非 evidenceChunks[]
|
||||
// - 装配层按 L2→L0→L1 层级组织
|
||||
// - 预算以"L0 + USER top-1 + AI top-1"为原子单元
|
||||
// - 孤立 L1(无对应 L0)丢弃
|
||||
// - 孤立 L0(无对应 L1)保留
|
||||
// 架构变更(v5 → v6):
|
||||
// - 同楼层多个 L0 共享一对 L1(EvidenceGroup per-floor)
|
||||
// - L0 展示文本从 semantic 字段改为从结构字段(type/subject/object/value/location)拼接
|
||||
// - 移除 <type> 标签和 [tags] theme 标签,输出自然语言短句
|
||||
// - 短行分号拼接,长行换行(120字阈值)
|
||||
//
|
||||
// 职责:
|
||||
// - 仅负责"构建注入文本",不负责写入 extension_prompts
|
||||
@@ -24,6 +22,7 @@ import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
|
||||
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
|
||||
import { recallMemory } from "../vector/retrieval/recall.js";
|
||||
import { getMeta } from "../vector/storage/chunk-store.js";
|
||||
import { getEngineFingerprint } from "../vector/utils/embedder.js";
|
||||
|
||||
// Metrics
|
||||
import { formatMetricsLog, detectIssues } from "../vector/retrieval/metrics.js";
|
||||
@@ -56,6 +55,9 @@ const CONSTRAINT_MAX = 2000;
|
||||
const ARCS_MAX = 1500;
|
||||
const TOP_N_STAR = 5;
|
||||
|
||||
// L0 显示文本:分号拼接 vs 多行模式的阈值
|
||||
const L0_JOINED_MAX_LENGTH = 120;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 工具函数
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -295,16 +297,88 @@ function formatArcLine(arc) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 L0 锚点行
|
||||
* @param {object} l0 - L0 对象
|
||||
* @returns {string} 格式化后的行
|
||||
* 从 atom 结构字段生成可读短句(不依赖 semantic 字段)
|
||||
*
|
||||
* 规则:
|
||||
* - act: 主体+谓词+客体
|
||||
* - emo: 主体+谓词+(对客体)
|
||||
* - rev: 揭示:谓词+(关于客体)
|
||||
* - dec: 主体+谓词+(对客体)
|
||||
* - ten: 主体与客体之间:谓词
|
||||
* - loc: 场景:地点或谓词
|
||||
* - 地点非空且非 loc 类型时后缀 "在{location}"
|
||||
*
|
||||
* @param {object} l0 - L0 对象(含 l0.atom)
|
||||
* @returns {string} 可读短句
|
||||
*/
|
||||
function formatL0Line(l0) {
|
||||
return ` › #${l0.floor + 1} [📌] ${String(l0.text || l0.atom?.semantic || "").trim()}`;
|
||||
function buildL0DisplayText(l0) {
|
||||
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 {boolean} isContext - 是否为上下文(USER 侧)
|
||||
* @returns {string} 格式化后的行
|
||||
@@ -344,99 +418,177 @@ function formatCausalEventLine(causalItem) {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// L0→L1 证据单元构建
|
||||
// L0 按楼层分组
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {object} EvidenceUnit
|
||||
* @property {object} l0 - L0 锚点对象
|
||||
* @property {object|null} userL1 - USER 侧 top-1 L1 chunk
|
||||
* @property {object|null} aiL1 - AI 侧 top-1 L1 chunk
|
||||
* @property {number} totalTokens - 整个单元的 token 估算
|
||||
* 将 L0 列表按楼层分组
|
||||
* @param {object[]} l0List - L0 对象列表
|
||||
* @returns {Map<number, object[]>} floor → L0 数组
|
||||
*/
|
||||
function groupL0ByFloor(l0List) {
|
||||
const map = new Map();
|
||||
for (const l0 of l0List) {
|
||||
const floor = l0.floor;
|
||||
if (!map.has(floor)) {
|
||||
map.set(floor, []);
|
||||
}
|
||||
map.get(floor).push(l0);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EvidenceGroup(per-floor:N个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配对映射
|
||||
* @returns {EvidenceUnit}
|
||||
* @returns {EvidenceGroup}
|
||||
*/
|
||||
function buildEvidenceUnit(l0, l1ByFloor) {
|
||||
const pair = l1ByFloor.get(l0.floor);
|
||||
function buildEvidenceGroup(floor, l0AtomsForFloor, l1ByFloor) {
|
||||
const pair = l1ByFloor.get(floor);
|
||||
const userL1 = pair?.userTop1 || null;
|
||||
const aiL1 = pair?.aiTop1 || null;
|
||||
|
||||
// 预计算整个单元的 token 开销
|
||||
let totalTokens = estimateTokens(formatL0Line(l0));
|
||||
// 计算整组 token 开销
|
||||
let totalTokens = 0;
|
||||
|
||||
// 所有 L0 的显示文本
|
||||
for (const l0 of l0AtomsForFloor) {
|
||||
totalTokens += estimateTokens(buildL0DisplayText(l0));
|
||||
}
|
||||
// 固定开销:楼层前缀、📌 标记、分号等
|
||||
totalTokens += 10;
|
||||
|
||||
// L1 仅算一次
|
||||
if (userL1) totalTokens += estimateTokens(formatL1Line(userL1, true));
|
||||
if (aiL1) totalTokens += estimateTokens(formatL1Line(aiL1, false));
|
||||
|
||||
return { 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[]} 文本行数组
|
||||
*/
|
||||
function formatEvidenceUnit(unit) {
|
||||
function formatEvidenceGroup(group) {
|
||||
const displayTexts = group.l0Atoms.map(l0 => buildL0DisplayText(l0));
|
||||
|
||||
const lines = [];
|
||||
lines.push(formatL0Line(unit.l0));
|
||||
if (unit.userL1) {
|
||||
lines.push(formatL1Line(unit.userL1, true));
|
||||
|
||||
// L0 部分
|
||||
const joined = displayTexts.join(';');
|
||||
|
||||
if (joined.length <= L0_JOINED_MAX_LENGTH) {
|
||||
// 短行:分号拼接为一行
|
||||
lines.push(` › #${group.floor + 1} [📌] ${joined}`);
|
||||
} else {
|
||||
// 长行:每个 L0 独占一行,首行带楼层号
|
||||
lines.push(` › #${group.floor + 1} [📌] ${displayTexts[0]}`);
|
||||
for (let i = 1; i < displayTexts.length; i++) {
|
||||
lines.push(` │ ${displayTexts[i]}`);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 事件证据收集
|
||||
// 事件证据收集(per-floor 分组)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 为事件收集范围内的 L0 证据单元
|
||||
* 为事件收集范围内的 EvidenceGroup
|
||||
*
|
||||
* 同楼层多个 L0 归入同一组,共享一对 L1。
|
||||
*
|
||||
* @param {object} eventObj - 事件对象
|
||||
* @param {object[]} l0Selected - 所有选中的 L0
|
||||
* @param {Map<number, object>} l1ByFloor - 楼层→L1配对映射
|
||||
* @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);
|
||||
if (!range) return [];
|
||||
|
||||
const units = [];
|
||||
// 收集范围内未消费的 L0,按楼层分组
|
||||
const floorMap = new Map();
|
||||
|
||||
for (const l0 of l0Selected) {
|
||||
if (usedL0Ids.has(l0.id)) continue;
|
||||
if (l0.floor < range.start || l0.floor > range.end) continue;
|
||||
|
||||
const unit = buildEvidenceUnit(l0, l1ByFloor);
|
||||
units.push(unit);
|
||||
if (!floorMap.has(l0.floor)) {
|
||||
floorMap.set(l0.floor, []);
|
||||
}
|
||||
floorMap.get(l0.floor).push(l0);
|
||||
usedL0Ids.add(l0.id);
|
||||
}
|
||||
|
||||
// 按楼层排序
|
||||
units.sort((a, b) => a.l0.floor - b.l0.floor);
|
||||
// 构建 groups
|
||||
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 {number} idx - 编号
|
||||
* @param {EvidenceUnit[]} evidenceUnits - 该事件的证据单元
|
||||
* @param {EvidenceGroup[]} evidenceGroups - 该事件的证据组
|
||||
* @param {Map<string, object>} causalById - 因果事件索引
|
||||
* @returns {string} 格式化后的文本
|
||||
*/
|
||||
function formatEventWithEvidence(eventItem, idx, evidenceUnits, causalById) {
|
||||
function formatEventWithEvidence(eventItem, idx, evidenceGroups, causalById) {
|
||||
const ev = eventItem.event || {};
|
||||
const time = ev.timeLabel || "";
|
||||
const title = String(ev.title || "").trim();
|
||||
@@ -456,9 +608,9 @@ function formatEventWithEvidence(eventItem, idx, evidenceUnits, causalById) {
|
||||
if (c) lines.push(formatCausalEventLine(c));
|
||||
}
|
||||
|
||||
// L0→L1 证据单元
|
||||
for (const unit of evidenceUnits) {
|
||||
lines.push(...formatEvidenceUnit(unit));
|
||||
// EvidenceGroup 证据
|
||||
for (const group of evidenceGroups) {
|
||||
lines.push(...formatEvidenceGroup(group));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -689,11 +841,11 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
|
||||
const isDirect = e._recallType === "DIRECT";
|
||||
|
||||
// 收集该事件范围内的 L0→L1 证据单元
|
||||
const evidenceUnits = collectEvidenceForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids);
|
||||
// 收集该事件范围内的 EvidenceGroup(per-floor)
|
||||
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);
|
||||
|
||||
// 预算检查:整个事件(含证据)作为原子单元
|
||||
@@ -703,23 +855,31 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
const costNoEvidence = estimateTokens(textNoEvidence);
|
||||
|
||||
if (total.used + costNoEvidence > total.max) {
|
||||
// 归还 usedL0Ids
|
||||
for (const group of evidenceGroups) {
|
||||
for (const l0 of group.l0Atoms) {
|
||||
usedL0Ids.delete(l0.id);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 放入不带证据的版本,归还已消费的 L0 ID
|
||||
for (const unit of evidenceUnits) {
|
||||
usedL0Ids.delete(unit.l0.id);
|
||||
for (const group of evidenceGroups) {
|
||||
for (const l0 of group.l0Atoms) {
|
||||
usedL0Ids.delete(l0.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirect) {
|
||||
selectedDirect.push({
|
||||
event: e.event, text: textNoEvidence, tokens: costNoEvidence,
|
||||
evidenceUnits: [], candidateRank,
|
||||
evidenceGroups: [], candidateRank,
|
||||
});
|
||||
} else {
|
||||
selectedRelated.push({
|
||||
event: e.event, text: textNoEvidence, tokens: costNoEvidence,
|
||||
evidenceUnits: [], candidateRank,
|
||||
evidenceGroups: [], candidateRank,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -734,36 +894,36 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
tokens: costNoEvidence,
|
||||
similarity: e.similarity || 0,
|
||||
l0Count: 0,
|
||||
l1Count: 0,
|
||||
l1FloorCount: 0,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 预算充足,放入完整版本
|
||||
const l0Count = evidenceUnits.length;
|
||||
let l1Count = 0;
|
||||
for (const unit of evidenceUnits) {
|
||||
if (unit.userL1) l1Count++;
|
||||
if (unit.aiL1) l1Count++;
|
||||
let l0Count = 0;
|
||||
let l1FloorCount = 0;
|
||||
for (const group of evidenceGroups) {
|
||||
l0Count += group.l0Atoms.length;
|
||||
if (group.userL1 || group.aiL1) l1FloorCount++;
|
||||
}
|
||||
|
||||
if (isDirect) {
|
||||
selectedDirect.push({
|
||||
event: e.event, text, tokens: cost,
|
||||
evidenceUnits, candidateRank,
|
||||
evidenceGroups, candidateRank,
|
||||
});
|
||||
} else {
|
||||
selectedRelated.push({
|
||||
event: e.event, text, tokens: cost,
|
||||
evidenceUnits, candidateRank,
|
||||
evidenceGroups, candidateRank,
|
||||
});
|
||||
}
|
||||
|
||||
injectionStats.event.selected++;
|
||||
injectionStats.event.tokens += cost;
|
||||
injectionStats.evidence.l0InEvents += l0Count;
|
||||
injectionStats.evidence.l1InEvents += l1Count;
|
||||
injectionStats.evidence.l1InEvents += l1FloorCount;
|
||||
total.used += cost;
|
||||
|
||||
eventDetails.list.push({
|
||||
@@ -773,7 +933,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
tokens: cost,
|
||||
similarity: e.similarity || 0,
|
||||
l0Count,
|
||||
l1Count,
|
||||
l1FloorCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -798,7 +958,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
assembled.relatedEvents.lines = relatedEventTexts;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// [Evidence - Distant] 远期证据(已总结范围,未被事件消费的 L0→L1)
|
||||
// [Evidence - Distant] 远期证据(已总结范围,未被事件消费的 L0)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
const lastSummarized = store.lastSummarizedMesId ?? -1;
|
||||
@@ -816,21 +976,25 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
if (distantL0.length && total.used < total.max) {
|
||||
const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) };
|
||||
|
||||
// 按楼层排序(时间顺序)
|
||||
// 按楼层排序(时间顺序)后分组
|
||||
distantL0.sort((a, b) => a.floor - b.floor);
|
||||
const distantFloorMap = groupL0ByFloor(distantL0);
|
||||
|
||||
for (const l0 of distantL0) {
|
||||
const unit = buildEvidenceUnit(l0, l1ByFloor);
|
||||
// 按楼层顺序遍历(Map 保持插入顺序,distantL0 已按 floor 排序)
|
||||
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);
|
||||
for (const line of unitLines) {
|
||||
const groupLines = formatEvidenceGroup(group);
|
||||
for (const line of groupLines) {
|
||||
assembled.distantEvidence.lines.push(line);
|
||||
}
|
||||
distantBudget.used += unit.totalTokens;
|
||||
usedL0Ids.add(l0.id);
|
||||
distantBudget.used += group.totalTokens;
|
||||
for (const l0 of l0s) {
|
||||
usedL0Ids.add(l0.id);
|
||||
}
|
||||
injectionStats.distantEvidence.units++;
|
||||
}
|
||||
|
||||
@@ -854,20 +1018,23 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
if (recentL0.length) {
|
||||
const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX };
|
||||
|
||||
// 按楼层排序(时间顺序)
|
||||
// 按楼层排序后分组
|
||||
recentL0.sort((a, b) => a.floor - b.floor);
|
||||
const recentFloorMap = groupL0ByFloor(recentL0);
|
||||
|
||||
for (const l0 of recentL0) {
|
||||
const unit = buildEvidenceUnit(l0, l1ByFloor);
|
||||
for (const [floor, l0s] of recentFloorMap) {
|
||||
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);
|
||||
for (const line of unitLines) {
|
||||
const groupLines = formatEvidenceGroup(group);
|
||||
for (const line of groupLines) {
|
||||
assembled.recentEvidence.lines.push(line);
|
||||
}
|
||||
recentBudget.used += unit.totalTokens;
|
||||
usedL0Ids.add(l0.id);
|
||||
recentBudget.used += group.totalTokens;
|
||||
for (const l0 of l0s) {
|
||||
usedL0Ids.add(l0.id);
|
||||
}
|
||||
injectionStats.recentEvidence.units++;
|
||||
}
|
||||
|
||||
@@ -951,13 +1118,17 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
|
||||
: 100;
|
||||
metrics.quality.eventPrecisionProxy = metrics.event?.similarityDistribution?.mean || 0;
|
||||
|
||||
const totalL0Selected = l0Selected.length;
|
||||
const l0WithL1 = l0Selected.filter(l0 => {
|
||||
const pair = l1ByFloor.get(l0.floor);
|
||||
return pair?.aiTop1 || pair?.userTop1;
|
||||
}).length;
|
||||
metrics.quality.l1AttachRate = totalL0Selected > 0
|
||||
? Math.round(l0WithL1 / totalL0Selected * 100)
|
||||
// l1AttachRate:有 L1 挂载的唯一楼层占所有 L0 覆盖楼层的比例
|
||||
const l0Floors = new Set(l0Selected.map(l0 => l0.floor));
|
||||
const l0FloorsWithL1 = new Set();
|
||||
for (const floor of l0Floors) {
|
||||
const pair = l1ByFloor.get(floor);
|
||||
if (pair?.aiTop1 || pair?.userTop1) {
|
||||
l0FloorsWithL1.add(floor);
|
||||
}
|
||||
}
|
||||
metrics.quality.l1AttachRate = l0Floors.size > 0
|
||||
? Math.round(l0FloorsWithL1.size / l0Floors.size * 100)
|
||||
: 0;
|
||||
|
||||
metrics.quality.potentialIssues = detectIssues(metrics);
|
||||
@@ -1036,7 +1207,7 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
|
||||
|
||||
if (echo && canNotifyRecallFail()) {
|
||||
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) {
|
||||
@@ -1055,12 +1226,21 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
|
||||
(recallResult?.causalChain?.length || 0) > 0;
|
||||
|
||||
if (!hasUseful) {
|
||||
if (echo && canNotifyRecallFail()) {
|
||||
await echo(
|
||||
"/echo severity=warning 向量召回失败:没有可用召回结果(请先在面板中生成向量,或检查指纹不匹配)"
|
||||
);
|
||||
const noVectorsGenerated = !meta?.fingerprint || (meta?.lastChunkFloor ?? -1) < 0;
|
||||
const fpMismatch = meta?.fingerprint && meta.fingerprint !== getEngineFingerprint(vectorCfg);
|
||||
|
||||
if (fpMismatch) {
|
||||
if (echo && canNotifyRecallFail()) {
|
||||
await echo("/echo severity=warning 向量引擎已变更,请重新生成向量");
|
||||
}
|
||||
} else if (noVectorsGenerated) {
|
||||
if (echo && canNotifyRecallFail()) {
|
||||
await echo("/echo severity=warning 没有可用向量,请在剧情总结面板中生成向量");
|
||||
}
|
||||
}
|
||||
if (postToFrame) {
|
||||
// 向量存在但本次未命中 → 静默跳过,不打扰用户
|
||||
|
||||
if (postToFrame && (noVectorsGenerated || fpMismatch)) {
|
||||
postToFrame({
|
||||
type: "RECALL_LOG",
|
||||
text: "\n[Vector Recall Empty]\nNo recall candidates / vectors not ready.\n",
|
||||
|
||||
@@ -526,6 +526,52 @@ async function handleClearVectors() {
|
||||
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();
|
||||
|
||||
// Embedding 连接预热(保持 TCP keep-alive,减少首次召回超时)
|
||||
warmupEmbeddingConnection();
|
||||
|
||||
setTimeout(() => checkVectorIntegrityAndWarn(), 2000);
|
||||
}
|
||||
|
||||
@@ -1316,7 +1365,10 @@ async function handleMessageReceived() {
|
||||
// 向量全量生成中时跳过 L1 sync(避免竞争写入)
|
||||
if (vectorGenerating) return;
|
||||
|
||||
await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig);
|
||||
await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig, () => {
|
||||
sendAnchorStatsToFrame();
|
||||
sendVectorStatsToFrame();
|
||||
});
|
||||
await maybeAutoBuildChunks();
|
||||
|
||||
applyHideStateDebounced();
|
||||
@@ -1324,6 +1376,9 @@ async function handleMessageReceived() {
|
||||
|
||||
// 新消息后刷新实体词典(可能有新角色)
|
||||
refreshEntityLexiconAndWarmup();
|
||||
|
||||
// 自动补提取缺失的 L0(延迟执行,避免与当前楼提取竞争)
|
||||
setTimeout(() => maybeAutoExtractL0(), 2000);
|
||||
}
|
||||
|
||||
function handleMessageSent() {
|
||||
|
||||
@@ -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 (!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;
|
||||
|
||||
try {
|
||||
await extractAndStoreAtomsForRound(lastFloor, message, userMessage);
|
||||
await extractAndStoreAtomsForRound(lastFloor, message, userMessage, onL0Complete);
|
||||
} catch (e) {
|
||||
xbLog.warn(MODULE_ID, `Atom 提取失败: floor ${lastFloor}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -112,7 +112,8 @@ function buildL0InputText(userMessage, aiMessage) {
|
||||
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 };
|
||||
|
||||
const vectorCfg = getVectorConfig();
|
||||
@@ -144,6 +145,11 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress) {
|
||||
pendingPairs.push({ userMsg, aiMsg: msg, aiFloor: i });
|
||||
}
|
||||
|
||||
// 限制单次提取楼层数(自动触发时使用)
|
||||
if (pendingPairs.length > maxFloors) {
|
||||
pendingPairs.length = maxFloors;
|
||||
}
|
||||
|
||||
if (!pendingPairs.length) {
|
||||
onProgress?.('已全部提取', 0, 0);
|
||||
return { built: 0 };
|
||||
@@ -323,14 +329,14 @@ export async function clearAllAtomsAndVectors(chatId) {
|
||||
let extractionQueue = [];
|
||||
let isProcessing = false;
|
||||
|
||||
export async function extractAndStoreAtomsForRound(aiFloor, aiMessage, userMessage) {
|
||||
export async function extractAndStoreAtomsForRound(aiFloor, aiMessage, userMessage, onComplete) {
|
||||
const { chatId } = getContext();
|
||||
if (!chatId) return;
|
||||
|
||||
const vectorCfg = getVectorConfig();
|
||||
if (!vectorCfg?.enabled) return;
|
||||
|
||||
extractionQueue.push({ aiFloor, aiMessage, userMessage, chatId });
|
||||
extractionQueue.push({ aiFloor, aiMessage, userMessage, chatId, onComplete });
|
||||
processQueue();
|
||||
}
|
||||
|
||||
@@ -339,13 +345,14 @@ async function processQueue() {
|
||||
isProcessing = true;
|
||||
|
||||
while (extractionQueue.length > 0) {
|
||||
const { aiFloor, aiMessage, userMessage, chatId } = extractionQueue.shift();
|
||||
const { aiFloor, aiMessage, userMessage, chatId, onComplete } = extractionQueue.shift();
|
||||
|
||||
try {
|
||||
const atoms = await extractAtomsForRound(userMessage, aiMessage, aiFloor, { timeout: 12000 });
|
||||
|
||||
if (!atoms?.length) {
|
||||
xbLog.info(MODULE_ID, `floor ${aiFloor}: 无有效 atoms`);
|
||||
onComplete?.({ floor: aiFloor, atomCount: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -356,8 +363,10 @@ async function processQueue() {
|
||||
await vectorizeAtomsSimple(chatId, atoms);
|
||||
|
||||
xbLog.info(MODULE_ID, `floor ${aiFloor}: ${atoms.length} atoms 已存储`);
|
||||
onComplete?.({ floor: aiFloor, atomCount: atoms.length });
|
||||
} catch (e) {
|
||||
xbLog.error(MODULE_ID, `floor ${aiFloor} 处理失败`, e);
|
||||
onComplete?.({ floor: aiFloor, atomCount: 0, error: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -878,16 +878,23 @@ export async function recallMemory(allEvents, vectorConfig, options = {}) {
|
||||
try {
|
||||
const [vec] = await embed([bundle.queryText_v0], vectorConfig, { timeout: 10000 });
|
||||
queryVector_v0 = vec;
|
||||
} catch (e) {
|
||||
xbLog.error(MODULE_ID, 'Round 1 向量化失败', e);
|
||||
metrics.timing.total = Math.round(performance.now() - T0);
|
||||
return {
|
||||
events: [], l0Selected: [], l1ByFloor: new Map(), causalChain: [],
|
||||
focusEntities: bundle.focusEntities,
|
||||
elapsed: metrics.timing.total,
|
||||
logText: 'Embedding failed (round 1).',
|
||||
metrics,
|
||||
};
|
||||
} catch (e1) {
|
||||
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);
|
||||
return {
|
||||
events: [], l0Selected: [], l1ByFloor: new Map(), causalChain: [],
|
||||
focusEntities: bundle.focusEntities,
|
||||
elapsed: metrics.timing.total,
|
||||
logText: 'Embedding failed (round 1, after retry).',
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!queryVector_v0?.length) {
|
||||
|
||||
Reference in New Issue
Block a user