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
// - 装配层用语义名称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 共享一对 L1EvidenceGroup 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;
}
// ─────────────────────────────────────────────────────────────────────────────
// 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配对映射
* @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);
// 收集该事件范围内的 EvidenceGroupper-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",