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",
|
||||
|
||||
Reference in New Issue
Block a user