feat: improve retrieval recall and maintenance undo

This commit is contained in:
Youzini-afk
2026-04-01 22:37:29 +08:00
parent 1dc87245a7
commit 6f8554e11a
10 changed files with 1550 additions and 63 deletions

View File

@@ -53,19 +53,31 @@ export function timeDecayFactor(createdTime, now = Date.now()) {
export function hybridScore({
graphScore = 0,
vectorScore = 0,
lexicalScore = 0,
importance = 5,
createdTime = Date.now(),
}, weights = {}) {
const alpha = weights.graphWeight ?? 0.6;
const beta = weights.vectorWeight ?? 0.3;
const gamma = weights.importanceWeight ?? 0.1;
const delta = weights.lexicalWeight ?? 0;
// 归一化
const normGraph = Math.max(0, Math.min(1, graphScore / 2.0)); // PEDSA 能量范围 [-2, 2] → [0, 1]
const normVec = Math.max(0, Math.min(1, vectorScore));
const normLexical = Math.max(0, Math.min(1, lexicalScore));
const normImportance = Math.max(0, Math.min(1, importance / 10.0));
const totalWeight = Math.max(
1e-6,
Math.max(0, alpha) + Math.max(0, beta) + Math.max(0, gamma) + Math.max(0, delta),
);
const baseScore = normGraph * alpha + normVec * beta + normImportance * gamma;
const baseScore =
(normGraph * alpha +
normVec * beta +
normLexical * delta +
normImportance * gamma) /
totalWeight;
const decay = timeDecayFactor(createdTime);
return baseScore * decay;

376
index.js
View File

@@ -136,16 +136,19 @@ import { resolveConfiguredTimeoutMs } from "./request-timeout.js";
import { retrieve } from "./retriever.js";
import {
appendBatchJournal,
appendMaintenanceJournal,
buildRecoveryResult,
buildReverseJournalRecoveryPlan,
clearHistoryDirty,
cloneGraphSnapshot,
createBatchJournalEntry,
createMaintenanceJournalEntry,
detectHistoryMutation,
findJournalRecoveryPoint,
markHistoryDirty,
normalizeGraphRuntimeState,
snapshotProcessedMessageHashes,
undoLatestMaintenance,
} from "./runtime-state.js";
import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js";
import {
@@ -157,6 +160,7 @@ import {
onManualEvolveController,
onManualSleepController,
onManualSynopsisController,
onUndoLastMaintenanceController,
onRebuildController,
onRebuildVectorIndexController,
onReembedDirectController,
@@ -249,6 +253,10 @@ function getRuntimeDebugState() {
taskPromptBuilds: {},
taskLlmRequests: {},
injections: {},
maintenance: {
lastAction: null,
lastUndoResult: null,
},
graphPersistence: null,
updatedAt: "",
};
@@ -281,6 +289,18 @@ function recordGraphPersistenceSnapshot(snapshot = null) {
state.graphPersistence = cloneRuntimeDebugValue(snapshot, null);
}
function recordMaintenanceDebugSnapshot(patch = {}) {
const state = touchRuntimeDebugState();
const previous = state.maintenance || {
lastAction: null,
lastUndoResult: null,
};
state.maintenance = {
...previous,
...cloneRuntimeDebugValue(patch, {}),
};
}
function readRuntimeDebugSnapshot() {
const state = getRuntimeDebugState();
return cloneRuntimeDebugValue(
@@ -289,6 +309,7 @@ function readRuntimeDebugSnapshot() {
taskPromptBuilds: state.taskPromptBuilds,
taskLlmRequests: state.taskLlmRequests,
injections: state.injections,
maintenance: state.maintenance,
graphPersistence: state.graphPersistence,
updatedAt: state.updatedAt,
},
@@ -297,6 +318,10 @@ function readRuntimeDebugSnapshot() {
taskPromptBuilds: {},
taskLlmRequests: {},
injections: {},
maintenance: {
lastAction: null,
lastUndoResult: null,
},
graphPersistence: null,
updatedAt: "",
},
@@ -325,6 +350,11 @@ const defaultSettings = {
recallLlmContextMessages: 4, // 传给 LLM 精排的最近非系统消息数
recallEnableMultiIntent: true,
recallMultiIntentMaxSegments: 4,
recallEnableContextQueryBlend: true,
recallContextAssistantWeight: 0.2,
recallContextPreviousUserWeight: 0.1,
recallEnableLexicalBoost: true,
recallLexicalWeight: 0.18,
recallTeleportAlpha: 0.15,
recallEnableTemporalLinks: true,
recallTemporalLinkStrength: 0.2,
@@ -412,6 +442,7 @@ const defaultSettings = {
// ⑩ 反思条目P2
enableReflection: true, // 启用反思
reflectEveryN: 10, // 每 N 次提取后反思
maintenanceAutoMinNewNodes: 3,
// UI 面板
panelTheme: "crimson", // 面板主题 crimson|cyan|amber|violet
@@ -4148,6 +4179,117 @@ async function recordGraphMutation({
return vectorSync;
}
function noteMaintenanceGate(status, action, reason) {
if (!status || typeof status !== "object") return;
const normalizedAction = String(action || "").trim() || "unknown";
const normalizedReason = String(reason || "").trim();
if (!normalizedReason) return;
const nextDetail = {
action: normalizedAction,
reason: normalizedReason,
};
const previousDetails = Array.isArray(status.maintenanceGateDetails)
? status.maintenanceGateDetails
: [];
status.maintenanceGateApplied = true;
status.maintenanceGateDetails = [...previousDetails, nextDetail];
status.maintenanceGateReason = status.maintenanceGateDetails
.map((item) => `${item.action}: ${item.reason}`)
.join(" | ");
}
function evaluateAutoMaintenanceGate(action, newNodeCount, settings = {}) {
const normalizedAction = String(action || "").trim();
if (!["consolidate", "compress"].includes(normalizedAction)) {
return { blocked: false, reason: "", minNewNodes: 0 };
}
if (settings?.maintenanceAutoMinNewNodes == null) {
return { blocked: false, reason: "", minNewNodes: 0 };
}
const minNewNodes = clampInt(settings.maintenanceAutoMinNewNodes, 3, 1, 50);
const safeNewNodeCount = Math.max(0, Number(newNodeCount) || 0);
if (safeNewNodeCount >= minNewNodes) {
return { blocked: false, reason: "", minNewNodes };
}
return {
blocked: true,
minNewNodes,
reason: `本批只新增 ${safeNewNodeCount} 个节点,低于门槛 ${minNewNodes}`,
};
}
function buildMaintenanceSummary(action, result, mode = "manual") {
const prefix = mode === "auto" ? "自动" : "手动";
switch (String(action || "")) {
case "compress":
return `${prefix}压缩:新建 ${result?.created || 0},归档 ${result?.archived || 0}`;
case "consolidate":
return `${prefix}整合:合并 ${result?.merged || 0},跳过 ${result?.skipped || 0},保留 ${result?.kept || 0},进化 ${result?.evolved || 0},新链接 ${result?.connections || 0},回溯更新 ${result?.updates || 0}`;
case "sleep":
return `${prefix}遗忘:归档 ${result?.forgotten || 0} 个节点`;
default:
return `${prefix}维护已执行`;
}
}
function recordMaintenanceAction({
action,
beforeSnapshot,
mode = "manual",
summary = "",
} = {}) {
if (!currentGraph || !beforeSnapshot) return null;
ensureCurrentGraphRuntimeState();
const entry = createMaintenanceJournalEntry(
beforeSnapshot,
cloneGraphSnapshot(currentGraph),
{
action,
mode,
summary,
},
);
if (!entry) return null;
appendMaintenanceJournal(currentGraph, entry);
recordMaintenanceDebugSnapshot({
lastAction: {
id: entry.id,
action: entry.action,
mode: entry.mode,
summary: entry.summary,
createdAt: entry.createdAt,
maintenanceJournalSize: currentGraph.maintenanceJournal?.length || 0,
},
});
return entry;
}
function undoLastMaintenanceAction() {
if (!currentGraph) {
return { ok: false, reason: "当前没有加载的图谱", entry: null };
}
ensureCurrentGraphRuntimeState();
const result = undoLatestMaintenance(currentGraph);
recordMaintenanceDebugSnapshot({
lastUndoResult: {
ok: Boolean(result?.ok),
reason: String(result?.reason || ""),
action: result?.entry?.action || "",
summary: result?.entry?.summary || "",
createdAt: result?.entry?.createdAt || 0,
maintenanceJournalSize: currentGraph.maintenanceJournal?.length || 0,
updatedAt: new Date().toISOString(),
},
});
return result;
}
function markVectorStateDirty(reason = "向量状态已标记为待重建") {
if (!currentGraph) return;
ensureCurrentGraphRuntimeState();
@@ -6027,6 +6169,78 @@ async function handleExtractionSuccess(
}),
) {
const postProcessArtifacts = [];
const newNodeCount = Array.isArray(result?.newNodeIds)
? result.newNodeIds.length
: 0;
const resolveAutoMaintenanceGate =
typeof evaluateAutoMaintenanceGate === "function"
? evaluateAutoMaintenanceGate
: (action, count, localSettings = {}) => {
const normalizedAction = String(action || "").trim();
if (!["consolidate", "compress"].includes(normalizedAction)) {
return { blocked: false, reason: "", minNewNodes: 0 };
}
if (localSettings?.maintenanceAutoMinNewNodes == null) {
return { blocked: false, reason: "", minNewNodes: 0 };
}
const parsedMinNewNodes = Math.floor(
Number(localSettings.maintenanceAutoMinNewNodes),
);
const minNewNodes =
Number.isFinite(parsedMinNewNodes) && parsedMinNewNodes >= 1
? Math.min(50, parsedMinNewNodes)
: 3;
const safeCount = Math.max(0, Number(count) || 0);
return safeCount >= minNewNodes
? { blocked: false, reason: "", minNewNodes }
: {
blocked: true,
minNewNodes,
reason: `本批只新增 ${safeCount} 个节点,低于门槛 ${minNewNodes}`,
};
};
const applyMaintenanceGateNote =
typeof noteMaintenanceGate === "function"
? noteMaintenanceGate
: (batchStatus, action, reason) => {
if (!batchStatus || !reason) return;
batchStatus.maintenanceGateApplied = true;
const details = Array.isArray(batchStatus.maintenanceGateDetails)
? batchStatus.maintenanceGateDetails
: [];
details.push({
action: String(action || "").trim() || "unknown",
reason: String(reason || ""),
});
batchStatus.maintenanceGateDetails = details;
batchStatus.maintenanceGateReason = details
.map((item) => `${item.action}: ${item.reason}`)
.join(" | ");
};
const summarizeMaintenance =
typeof buildMaintenanceSummary === "function"
? buildMaintenanceSummary
: (action, maintenanceResult, mode = "manual") => {
const prefix = mode === "auto" ? "自动" : "手动";
switch (String(action || "")) {
case "compress":
return `${prefix}压缩:新建 ${maintenanceResult?.created || 0},归档 ${maintenanceResult?.archived || 0}`;
case "consolidate":
return `${prefix}整合:合并 ${maintenanceResult?.merged || 0},跳过 ${maintenanceResult?.skipped || 0},保留 ${maintenanceResult?.kept || 0},进化 ${maintenanceResult?.evolved || 0},新链接 ${maintenanceResult?.connections || 0},回溯更新 ${maintenanceResult?.updates || 0}`;
case "sleep":
return `${prefix}遗忘:归档 ${maintenanceResult?.forgotten || 0} 个节点`;
default:
return `${prefix}维护已执行`;
}
};
const cloneMaintenanceSnapshot =
typeof cloneGraphSnapshot === "function"
? cloneGraphSnapshot
: (value) => JSON.parse(JSON.stringify(value ?? null));
const persistMaintenanceAction =
typeof recordMaintenanceAction === "function"
? recordMaintenanceAction
: () => null;
throwIfAborted(signal, "提取已终止");
extractionCount++;
ensureCurrentGraphRuntimeState();
@@ -6035,30 +6249,51 @@ async function handleExtractionSuccess(
setBatchStageOutcome(status, "core", "success");
if (settings.enableConsolidation && result.newNodeIds?.length > 0) {
try {
await consolidateMemories({
graph: currentGraph,
newNodeIds: result.newNodeIds,
embeddingConfig: getEmbeddingConfig(),
options: {
neighborCount: settings.consolidationNeighborCount,
conflictThreshold: settings.consolidationThreshold,
},
settings,
signal,
});
postProcessArtifacts.push("consolidation");
pushBatchStageArtifact(status, "structural", "consolidation");
} catch (e) {
if (isAbortError(e)) throw e;
const message = e?.message || String(e) || "记忆整合阶段失败";
setBatchStageOutcome(
status,
"structural",
"partial",
`记忆整合失败: ${message}`,
);
console.error("[ST-BME] 记忆整合失败:", e);
const gate = resolveAutoMaintenanceGate(
"consolidate",
newNodeCount,
settings,
);
if (gate.blocked) {
applyMaintenanceGateNote(status, "consolidate", gate.reason);
pushBatchStageArtifact(status, "structural", "consolidation-skipped");
} else {
try {
const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph);
const consolidationResult = await consolidateMemories({
graph: currentGraph,
newNodeIds: result.newNodeIds,
embeddingConfig: getEmbeddingConfig(),
options: {
neighborCount: settings.consolidationNeighborCount,
conflictThreshold: settings.consolidationThreshold,
},
settings,
signal,
});
persistMaintenanceAction({
action: "consolidate",
beforeSnapshot,
mode: "auto",
summary: summarizeMaintenance(
"consolidate",
consolidationResult,
"auto",
),
});
postProcessArtifacts.push("consolidation");
pushBatchStageArtifact(status, "structural", "consolidation");
} catch (e) {
if (isAbortError(e)) throw e;
const message = e?.message || String(e) || "记忆整合阶段失败";
setBatchStageOutcome(
status,
"structural",
"partial",
`记忆整合失败: ${message}`,
);
console.error("[ST-BME] 记忆整合失败:", e);
}
}
}
@@ -6120,9 +6355,18 @@ async function handleExtractionSuccess(
extractionCount % settings.sleepEveryN === 0
) {
try {
sleepCycle(currentGraph, settings);
postProcessArtifacts.push("sleep");
pushBatchStageArtifact(status, "semantic", "sleep");
const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph);
const sleepResult = sleepCycle(currentGraph, settings);
if ((sleepResult?.forgotten || 0) > 0) {
persistMaintenanceAction({
action: "sleep",
beforeSnapshot,
mode: "auto",
summary: summarizeMaintenance("sleep", sleepResult, "auto"),
});
postProcessArtifacts.push("sleep");
pushBatchStageArtifact(status, "semantic", "sleep");
}
} catch (e) {
const message = e?.message || String(e) || "主动遗忘阶段失败";
setBatchStageOutcome(
@@ -6137,18 +6381,39 @@ async function handleExtractionSuccess(
try {
throwIfAborted(signal, "提取已终止");
const compressionResult = await compressAll(
currentGraph,
getSchema(),
getEmbeddingConfig(),
false,
undefined,
signal,
const gate = resolveAutoMaintenanceGate(
"compress",
newNodeCount,
settings,
);
if (compressionResult.created > 0 || compressionResult.archived > 0) {
postProcessArtifacts.push("compression");
pushBatchStageArtifact(status, "structural", "compression");
if (gate.blocked) {
applyMaintenanceGateNote(status, "compress", gate.reason);
pushBatchStageArtifact(status, "structural", "compression-skipped");
} else {
const beforeSnapshot = cloneMaintenanceSnapshot(currentGraph);
const compressionResult = await compressAll(
currentGraph,
getSchema(),
getEmbeddingConfig(),
false,
undefined,
signal,
settings,
);
if (compressionResult.created > 0 || compressionResult.archived > 0) {
persistMaintenanceAction({
action: "compress",
beforeSnapshot,
mode: "auto",
summary: summarizeMaintenance(
"compress",
compressionResult,
"auto",
),
});
postProcessArtifacts.push("compression");
pushBatchStageArtifact(status, "structural", "compression");
}
}
} catch (error) {
if (isAbortError(error)) throw error;
@@ -6198,6 +6463,18 @@ async function handleExtractionSuccess(
setBatchStageOutcome(status, "finalize", "success");
}
status.maintenanceJournalSize =
currentGraph?.maintenanceJournal?.length || 0;
if (
status.maintenanceGateApplied &&
!status.maintenanceGateReason &&
Array.isArray(status.maintenanceGateDetails)
) {
status.maintenanceGateReason = status.maintenanceGateDetails
.map((item) => `${item.action}: ${item.reason}`)
.join(" | ");
}
return {
postProcessArtifacts,
vectorHashesInserted: vectorSync?.insertedHashes || [],
@@ -7107,6 +7384,12 @@ function buildRecallRetrieveOptions(settings, context) {
probRecallChance: settings.probRecallChance ?? 0.15,
enableMultiIntent: settings.recallEnableMultiIntent ?? true,
multiIntentMaxSegments: settings.recallMultiIntentMaxSegments ?? 4,
enableContextQueryBlend: settings.recallEnableContextQueryBlend ?? true,
contextAssistantWeight: settings.recallContextAssistantWeight ?? 0.2,
contextPreviousUserWeight:
settings.recallContextPreviousUserWeight ?? 0.1,
enableLexicalBoost: settings.recallEnableLexicalBoost ?? true,
lexicalWeight: settings.recallLexicalWeight ?? 0.18,
teleportAlpha: settings.recallTeleportAlpha ?? 0.15,
enableTemporalLinks: settings.recallEnableTemporalLinks ?? true,
temporalLinkStrength: settings.recallTemporalLinkStrength ?? 0.2,
@@ -7426,6 +7709,7 @@ async function onRebuild() {
async function onManualCompress() {
return await onManualCompressController({
buildMaintenanceSummary,
cloneGraphSnapshot,
compressAll,
ensureGraphMutationReady,
@@ -7433,6 +7717,7 @@ async function onManualCompress() {
getEmbeddingConfig,
getSchema,
getSettings,
recordMaintenanceAction,
recordGraphMutation,
toastr,
});
@@ -7577,10 +7862,12 @@ async function onReroll({ fromFloor } = {}) {
async function onManualSleep() {
return await onManualSleepController({
buildMaintenanceSummary,
cloneGraphSnapshot,
ensureGraphMutationReady,
getCurrentGraph: () => currentGraph,
getSettings,
recordMaintenanceAction,
recordGraphMutation,
sleepCycle,
toastr,
@@ -7603,6 +7890,7 @@ async function onManualSynopsis() {
async function onManualEvolve() {
return await onManualEvolveController({
buildMaintenanceSummary,
cloneGraphSnapshot,
consolidateMemories,
ensureGraphMutationReady,
@@ -7610,11 +7898,24 @@ async function onManualEvolve() {
getEmbeddingConfig,
getLastExtractedItems: () => lastExtractedItems,
getSettings,
recordMaintenanceAction,
recordGraphMutation,
toastr,
});
}
async function onUndoLastMaintenance() {
return await onUndoLastMaintenanceController({
ensureGraphMutationReady,
getCurrentGraph: () => currentGraph,
markVectorStateDirty,
refreshPanelLiveState,
saveGraphToChat,
toastr,
undoLastMaintenance: undoLastMaintenanceAction,
});
}
async function onRebuildVectorIndex(range = null) {
return await onRebuildVectorIndexController(
{
@@ -7713,6 +8014,7 @@ async function onReembedDirect() {
import: onImportGraph,
rebuild: onRebuild,
evolve: onManualEvolve,
undoMaintenance: onUndoLastMaintenance,
testEmbedding: onTestEmbedding,
testMemoryLLM: onTestMemoryLLM,
fetchMemoryLLMModels: onFetchMemoryLLMModels,

View File

@@ -269,6 +269,10 @@
<i class="fa-solid fa-moon"></i>
<span>执行遗忘</span>
</button>
<button class="bme-action-btn" id="bme-act-undo-maintenance" type="button">
<i class="fa-solid fa-rotate-left"></i>
<span>撤销最近维护</span>
</button>
<button class="bme-action-btn" id="bme-act-reroll" type="button">
<i class="fa-solid fa-rotate"></i>
<span>重新提取</span>
@@ -1187,13 +1191,78 @@
<div>
<div class="bme-config-subgroup-title">更多高级项</div>
<div class="bme-config-subgroup-desc">
从 DPP 多样性去重开始,收纳共现补强和弱信号残差召回。
收纳上下文混合查询、文字补分,以及 DPP、共现补强和弱信号残差召回。
</div>
</div>
<span class="bme-collapsible-indicator" aria-hidden="true">
<i class="fa-solid fa-chevron-down"></i>
</span>
</summary>
<div class="bme-config-subgroup">
<div class="bme-config-subgroup-title">查询纠偏</div>
<div class="bme-config-subgroup-desc">
让召回在“那后来呢”“他为什么这么做”这类追问里,也能借最近上下文稳一点。
</div>
<label
class="bme-inline-checkbox"
for="bme-setting-recall-context-query-blend-enabled"
>
<input
id="bme-setting-recall-context-query-blend-enabled"
type="checkbox"
/>
<span>启用上下文混合查询</span>
</label>
<div class="bme-config-row">
<label for="bme-setting-recall-context-assistant-weight"
>最近 assistant 权重</label
>
<input
id="bme-setting-recall-context-assistant-weight"
class="bme-config-input"
type="number"
min="0"
max="1"
step="0.01"
/>
</div>
<div class="bme-config-row">
<label for="bme-setting-recall-context-previous-user-weight"
>上一条 user 权重</label
>
<input
id="bme-setting-recall-context-previous-user-weight"
class="bme-config-input"
type="number"
min="0"
max="1"
step="0.01"
/>
</div>
<label
class="bme-inline-checkbox"
for="bme-setting-recall-lexical-boost-enabled"
>
<input
id="bme-setting-recall-lexical-boost-enabled"
type="checkbox"
/>
<span>启用文字命中补分</span>
</label>
<div class="bme-config-row">
<label for="bme-setting-recall-lexical-weight"
>文字补分权重</label
>
<input
id="bme-setting-recall-lexical-weight"
class="bme-config-input"
type="number"
min="0"
max="1"
step="0.01"
/>
</div>
</div>
<div class="bme-config-subgroup">
<div class="bme-config-subgroup-title">DPP 与共现补强</div>
<div class="bme-config-subgroup-desc">
@@ -1615,6 +1684,29 @@
</div>
</div>
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">自动维护门槛</div>
<div class="bme-config-card-subtitle">
新增节点太少时,自动整合和自动压缩直接跳过,避免小批次也跑重维护。
</div>
</div>
</div>
<div class="bme-config-row">
<label for="bme-setting-maintenance-auto-min-new-nodes"
>最少新增节点数</label
>
<input
id="bme-setting-maintenance-auto-min-new-nodes"
class="bme-config-input"
type="number"
min="1"
max="50"
/>
</div>
</div>
<div
class="bme-config-card bme-guarded-card"
data-guard-settings="enableProbRecall"

View File

@@ -78,6 +78,7 @@ const GRAPH_WRITE_ACTION_IDS = [
"bme-act-sleep",
"bme-act-synopsis",
"bme-act-evolve",
"bme-act-undo-maintenance",
"bme-act-import",
"bme-act-rebuild",
"bme-act-vector-rebuild",
@@ -1255,6 +1256,7 @@ function _bindActions() {
"bme-act-import": "import",
"bme-act-rebuild": "rebuild",
"bme-act-evolve": "evolve",
"bme-act-undo-maintenance": "undoMaintenance",
"bme-act-vector-rebuild": "rebuildVectorIndex",
"bme-act-vector-reembed": "reembedDirect",
};
@@ -1268,6 +1270,7 @@ function _bindActions() {
import: "导入图谱",
rebuild: "重建图谱",
evolve: "强制进化",
undoMaintenance: "撤销最近维护",
rebuildVectorIndex: "重建向量",
reembedDirect: "直连重嵌",
};
@@ -1434,6 +1437,14 @@ function _refreshConfigTab() {
"bme-setting-recall-multi-intent-enabled",
settings.recallEnableMultiIntent ?? true,
);
_setCheckboxValue(
"bme-setting-recall-context-query-blend-enabled",
settings.recallEnableContextQueryBlend ?? true,
);
_setCheckboxValue(
"bme-setting-recall-lexical-boost-enabled",
settings.recallEnableLexicalBoost ?? true,
);
_setCheckboxValue(
"bme-setting-recall-temporal-links-enabled",
settings.recallEnableTemporalLinks ?? true,
@@ -1506,6 +1517,18 @@ function _refreshConfigTab() {
"bme-setting-recall-multi-intent-max-segments",
settings.recallMultiIntentMaxSegments ?? 4,
);
_setInputValue(
"bme-setting-recall-context-assistant-weight",
settings.recallContextAssistantWeight ?? 0.2,
);
_setInputValue(
"bme-setting-recall-context-previous-user-weight",
settings.recallContextPreviousUserWeight ?? 0.1,
);
_setInputValue(
"bme-setting-recall-lexical-weight",
settings.recallLexicalWeight ?? 0.18,
);
_setInputValue(
"bme-setting-recall-teleport-alpha",
settings.recallTeleportAlpha ?? 0.15,
@@ -1578,6 +1601,10 @@ function _refreshConfigTab() {
"bme-setting-forget-threshold",
settings.forgetThreshold ?? 0.5,
);
_setInputValue(
"bme-setting-maintenance-auto-min-new-nodes",
settings.maintenanceAutoMinNewNodes ?? 3,
);
_setInputValue("bme-setting-sleep-every", settings.sleepEveryN ?? 10);
_setInputValue(
"bme-setting-prob-recall-chance",
@@ -1689,6 +1716,12 @@ function _bindConfigControls() {
bindCheckbox("bme-setting-recall-multi-intent-enabled", (checked) => {
_patchSettings({ recallEnableMultiIntent: checked });
});
bindCheckbox("bme-setting-recall-context-query-blend-enabled", (checked) => {
_patchSettings({ recallEnableContextQueryBlend: checked });
});
bindCheckbox("bme-setting-recall-lexical-boost-enabled", (checked) => {
_patchSettings({ recallEnableLexicalBoost: checked });
});
bindCheckbox("bme-setting-recall-temporal-links-enabled", (checked) => {
_patchSettings({ recallEnableTemporalLinks: checked });
});
@@ -1760,6 +1793,23 @@ function _bindConfigControls() {
8,
(value) => _patchSettings({ recallMultiIntentMaxSegments: value }),
);
bindFloat(
"bme-setting-recall-context-assistant-weight",
0.2,
0,
1,
(value) => _patchSettings({ recallContextAssistantWeight: value }),
);
bindFloat(
"bme-setting-recall-context-previous-user-weight",
0.1,
0,
1,
(value) => _patchSettings({ recallContextPreviousUserWeight: value }),
);
bindFloat("bme-setting-recall-lexical-weight", 0.18, 0, 1, (value) =>
_patchSettings({ recallLexicalWeight: value }),
);
bindFloat("bme-setting-recall-teleport-alpha", 0.15, 0, 1, (value) =>
_patchSettings({ recallTeleportAlpha: value }),
);
@@ -1843,6 +1893,13 @@ function _bindConfigControls() {
bindFloat("bme-setting-forget-threshold", 0.5, 0.1, 1, (value) =>
_patchSettings({ forgetThreshold: value }),
);
bindNumber(
"bme-setting-maintenance-auto-min-new-nodes",
3,
1,
50,
(value) => _patchSettings({ maintenanceAutoMinNewNodes: value }),
);
bindNumber("bme-setting-sleep-every", 10, 1, 200, (value) =>
_patchSettings({ sleepEveryN: value }),
);
@@ -2903,6 +2960,7 @@ function _renderTaskDebugTab(state) {
const promptBuild = runtimeDebug?.taskPromptBuilds?.[state.taskType] || null;
const llmRequest = runtimeDebug?.taskLlmRequests?.[state.taskType] || null;
const recallInjection = runtimeDebug?.injections?.recall || null;
const maintenanceDebug = runtimeDebug?.maintenance || null;
const graphPersistence = runtimeDebug?.graphPersistence || null;
return `
@@ -2923,6 +2981,9 @@ function _renderTaskDebugTab(state) {
<div class="bme-config-card">
${_renderTaskDebugGraphPersistenceCard(graphPersistence)}
</div>
<div class="bme-config-card">
${_renderTaskDebugMaintenanceCard(maintenanceDebug)}
</div>
<div class="bme-config-card">
${_renderTaskDebugPromptCard(state.taskType, promptBuild)}
</div>
@@ -2937,6 +2998,32 @@ function _renderTaskDebugTab(state) {
`;
}
function _renderTaskDebugMaintenanceCard(maintenanceDebug) {
const lastAction = maintenanceDebug?.lastAction || null;
const lastUndoResult = maintenanceDebug?.lastUndoResult || null;
if (!lastAction && !lastUndoResult) {
return `
<div class="bme-config-card-title">维护账本状态</div>
<div class="bme-config-help">当前还没有最近维护或撤销快照。</div>
`;
}
return `
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">维护账本状态</div>
<div class="bme-config-card-subtitle">
最近一次维护记录和最近一次撤销结果。
</div>
</div>
<span class="bme-task-pill">${_escHtml(lastAction?.action || lastUndoResult?.action || "maintenance")}</span>
</div>
${_renderDebugDetails("最近维护", lastAction)}
${_renderDebugDetails("最近撤销", lastUndoResult)}
`;
}
function _renderTaskDebugGraphPersistenceCard(graphPersistence) {
if (!graphPersistence) {
return `

View File

@@ -126,10 +126,15 @@ function createRetrievalMeta(enableLLMRecall) {
diffusionHits: 0,
scoredCandidates: 0,
segmentsUsed: [],
queryBlendActive: false,
queryBlendParts: [],
queryBlendWeights: {},
vectorMergedHits: 0,
seedCount: 0,
temporalSyntheticEdgeCount: 0,
teleportAlpha: 0,
lexicalBoostedNodes: 0,
lexicalTopHits: [],
cooccurrenceBoostedNodes: 0,
candidatePoolBeforeDpp: 0,
candidatePoolAfterDpp: 0,
@@ -159,6 +164,421 @@ function clampRange(value, fallback, min = 0, max = 1) {
return Math.max(min, Math.min(max, parsed));
}
function normalizeQueryText(value, maxLength = 400) {
const normalized = String(value ?? "")
.replace(/\r\n/g, "\n")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return "";
return normalized.slice(0, Math.max(1, maxLength));
}
function createTextPreview(text, maxLength = 120) {
const normalized = normalizeQueryText(text, maxLength + 4);
if (!normalized) return "";
return normalized.length > maxLength
? `${normalized.slice(0, maxLength)}...`
: normalized;
}
function roundBlendWeight(value) {
return Math.round((Number(value) || 0) * 1000) / 1000;
}
function uniqueStrings(values = [], maxLength = 400) {
const result = [];
const seen = new Set();
for (const value of values) {
const text = normalizeQueryText(value, maxLength);
const key = text.toLowerCase();
if (!text || seen.has(key)) continue;
seen.add(key);
result.push(text);
}
return result;
}
function parseRecallContextLine(line = "") {
const raw = String(line ?? "").trim();
if (!raw) return null;
const bracketMatch = raw.match(/^\[(user|assistant)\]\s*:\s*([\s\S]*)$/i);
if (bracketMatch) {
const role = String(bracketMatch[1] || "").toLowerCase();
const text = normalizeQueryText(bracketMatch[2] || "");
return text ? { role, text } : null;
}
const plainMatch = raw.match(
/^(user|assistant|用户|助手|ai)\s*[:]\s*([\s\S]*)$/i,
);
if (!plainMatch) return null;
const roleToken = String(plainMatch[1] || "").toLowerCase();
const role =
roleToken === "assistant" || roleToken === "助手" || roleToken === "ai"
? "assistant"
: "user";
const text = normalizeQueryText(plainMatch[2] || "");
return text ? { role, text } : null;
}
function buildContextQueryBlend(
userMessage,
recentMessages = [],
{
enabled = true,
assistantWeight = 0.2,
previousUserWeight = 0.1,
maxTextLength = 400,
} = {},
) {
const currentText = normalizeQueryText(userMessage, maxTextLength);
const normalizedAssistantWeight = clampRange(assistantWeight, 0.2, 0, 1);
const normalizedPreviousUserWeight = clampRange(
previousUserWeight,
0.1,
0,
1,
);
const currentWeight = Math.max(
0,
1 - normalizedAssistantWeight - normalizedPreviousUserWeight,
);
let assistantText = "";
let previousUserText = "";
const parsedMessages = Array.isArray(recentMessages)
? recentMessages.map((line) => parseRecallContextLine(line)).filter(Boolean)
: [];
for (let index = parsedMessages.length - 1; index >= 0; index--) {
const item = parsedMessages[index];
if (!assistantText && item.role === "assistant") {
assistantText = normalizeQueryText(item.text, maxTextLength);
}
if (
!previousUserText &&
item.role === "user" &&
normalizeQueryText(item.text, maxTextLength).toLowerCase() !==
currentText.toLowerCase()
) {
previousUserText = normalizeQueryText(item.text, maxTextLength);
}
if (assistantText && previousUserText) break;
}
const rawParts = [
{
kind: "currentUser",
label: "当前用户消息",
text: currentText,
weight: enabled ? currentWeight : 1,
},
];
if (enabled && assistantText) {
rawParts.push({
kind: "assistantContext",
label: "最近 assistant 回复",
text: assistantText,
weight: normalizedAssistantWeight,
});
}
if (enabled && previousUserText) {
rawParts.push({
kind: "previousUser",
label: "上一条 user 消息",
text: previousUserText,
weight: normalizedPreviousUserWeight,
});
}
const dedupedParts = [];
const seen = new Set();
for (const part of rawParts) {
const text = normalizeQueryText(part.text, maxTextLength);
const key = text.toLowerCase();
if (!text || seen.has(key)) continue;
seen.add(key);
dedupedParts.push({
...part,
text,
});
}
if (dedupedParts.length === 0) {
return {
active: false,
parts: [],
currentText: "",
assistantText: "",
previousUserText: "",
combinedText: "",
};
}
const totalWeight = dedupedParts.reduce(
(sum, part) => sum + Math.max(0, Number(part.weight) || 0),
0,
);
const normalizedParts = dedupedParts.map((part) => ({
...part,
weight:
totalWeight > 0
? roundBlendWeight((Math.max(0, Number(part.weight) || 0) || 0) / totalWeight)
: roundBlendWeight(1 / dedupedParts.length),
}));
const combinedText =
normalizedParts.length <= 1
? normalizedParts[0]?.text || ""
: normalizedParts
.map((part) => `${part.label}:\n${part.text}`)
.join("\n\n");
return {
active: enabled && normalizedParts.length > 1,
parts: normalizedParts,
currentText: currentText || normalizedParts[0]?.text || "",
assistantText,
previousUserText,
combinedText,
};
}
function buildVectorQueryPlan(
blendPlan,
{ enableMultiIntent = true, maxSegments = 4 } = {},
) {
const plan = [];
let currentSegments = [];
for (const part of blendPlan?.parts || []) {
let queries = [part.text];
if (part.kind === "currentUser" && enableMultiIntent) {
currentSegments = splitIntentSegments(part.text, { maxSegments });
queries = uniqueStrings([
part.text,
...currentSegments.filter((item) => item !== part.text),
]);
} else {
queries = uniqueStrings([part.text]);
}
plan.push({
kind: part.kind,
label: part.label,
weight: part.weight,
queries,
});
}
return {
plan,
currentSegments,
};
}
function buildLexicalQuerySources(
userMessage,
{ enableMultiIntent = true, maxSegments = 4 } = {},
) {
const currentText = normalizeQueryText(userMessage, 400);
const segments = enableMultiIntent
? splitIntentSegments(currentText, { maxSegments })
: [];
return {
sources: uniqueStrings([currentText, ...segments]),
segments,
};
}
function normalizeLexicalText(value = "") {
return normalizeQueryText(value, 600).toLowerCase();
}
function buildLexicalUnits(text = "") {
const normalized = normalizeLexicalText(text);
if (!normalized) return [];
const rawTokens = normalized.match(/[a-z0-9]+|[\u4e00-\u9fff]+/g) || [];
const units = [];
for (const token of rawTokens) {
if (token.length >= 2) {
units.push(token);
}
if (/[\u4e00-\u9fff]/.test(token) && token.length > 2) {
for (let index = 0; index < token.length - 1; index++) {
units.push(token.slice(index, index + 2));
}
}
}
return [...new Set(units)];
}
function computeTokenOverlapScore(sourceUnits = [], targetUnits = []) {
if (!sourceUnits.length || !targetUnits.length) return 0;
const targetSet = new Set(targetUnits);
let overlap = 0;
for (const unit of sourceUnits) {
if (targetSet.has(unit)) {
overlap += 1;
}
}
return overlap / Math.max(1, sourceUnits.length);
}
function scoreFieldMatch(
fieldText,
querySources = [],
{ exact = 1, includes = 0.9, overlap = 0.6 } = {},
) {
const normalizedField = normalizeLexicalText(fieldText);
if (!normalizedField) return 0;
const fieldUnits = buildLexicalUnits(normalizedField);
let best = 0;
for (const sourceText of querySources) {
const normalizedSource = normalizeLexicalText(sourceText);
if (!normalizedSource) continue;
if (normalizedSource === normalizedField) {
best = Math.max(best, exact);
continue;
}
if (
Math.min(normalizedSource.length, normalizedField.length) >= 2 &&
(normalizedSource.includes(normalizedField) ||
normalizedField.includes(normalizedSource))
) {
best = Math.max(best, includes);
}
const overlapScore = computeTokenOverlapScore(
buildLexicalUnits(normalizedSource),
fieldUnits,
);
best = Math.max(best, overlapScore * overlap);
}
return Math.min(1, best);
}
function collectNodeLexicalTexts(node, fieldNames = []) {
const values = [];
for (const fieldName of fieldNames) {
const value = node?.fields?.[fieldName];
if (typeof value === "string" && value.trim()) {
values.push(value.trim());
} else if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === "string" && item.trim()) {
values.push(item.trim());
}
}
}
}
return values;
}
function computeLexicalScore(node, querySources = []) {
if (!node || !Array.isArray(querySources) || querySources.length === 0) {
return 0;
}
const primaryTexts = collectNodeLexicalTexts(node, ["name", "title"]);
const secondaryTexts = collectNodeLexicalTexts(node, [
"summary",
"insight",
"state",
"traits",
"participants",
"status",
]);
const combinedText = [...primaryTexts, ...secondaryTexts].join(" ");
const primaryScore = primaryTexts.reduce(
(best, value) =>
Math.max(
best,
scoreFieldMatch(value, querySources, {
exact: 1,
includes: 0.92,
overlap: 0.72,
}),
),
0,
);
const secondaryScore = secondaryTexts.reduce(
(best, value) =>
Math.max(
best,
scoreFieldMatch(value, querySources, {
exact: 0.82,
includes: 0.68,
overlap: 0.52,
}),
),
0,
);
const tokenScore = scoreFieldMatch(combinedText, querySources, {
exact: 0.65,
includes: 0.55,
overlap: 0.45,
});
if (primaryScore <= 0 && secondaryScore <= 0 && tokenScore <= 0) {
return 0;
}
return Math.min(
1,
Math.max(
primaryScore,
secondaryScore * 0.82,
tokenScore * 0.7,
primaryScore * 0.75 + secondaryScore * 0.35 + tokenScore * 0.2,
),
);
}
function buildLexicalTopHits(scoredNodes = [], maxCount = 5) {
return scoredNodes
.filter((item) => (Number(item?.lexicalScore) || 0) > 0)
.sort((a, b) => {
const lexicalDelta =
(Number(b?.lexicalScore) || 0) - (Number(a?.lexicalScore) || 0);
if (lexicalDelta !== 0) return lexicalDelta;
return (Number(b?.finalScore) || 0) - (Number(a?.finalScore) || 0);
})
.slice(0, Math.max(1, maxCount))
.map((item) => ({
nodeId: item.nodeId,
type: item.node?.type || "",
label:
item.node?.fields?.name ||
item.node?.fields?.title ||
item.node?.fields?.summary ||
item.nodeId,
lexicalScore: Math.round((Number(item.lexicalScore) || 0) * 1000) / 1000,
finalScore: Math.round((Number(item.finalScore) || 0) * 1000) / 1000,
}));
}
function scaleVectorResults(results = [], weight = 1) {
return (Array.isArray(results) ? results : []).map((item) => ({
...item,
score: (Number(item?.score) || 0) * Math.max(0, Number(weight) || 0),
}));
}
/**
* 三层混合检索管线
*
@@ -248,6 +668,21 @@ export async function retrieve({
10,
);
const residualTopK = clampPositiveInt(options.residualTopK, 5);
const enableContextQueryBlend = options.enableContextQueryBlend ?? true;
const contextAssistantWeight = clampRange(
options.contextAssistantWeight,
0.2,
0,
1,
);
const contextPreviousUserWeight = clampRange(
options.contextPreviousUserWeight,
0.1,
0,
1,
);
const enableLexicalBoost = options.enableLexicalBoost ?? true;
const lexicalWeight = clampRange(options.lexicalWeight, 0.18, 0, 10);
let activeNodes = getActiveNodes(graph).filter(
(node) =>
@@ -270,6 +705,29 @@ export async function retrieve({
);
const vectorValidation = validateVectorConfig(embeddingConfig);
const retrievalMeta = createRetrievalMeta(enableLLMRecall);
const contextQueryBlend = buildContextQueryBlend(userMessage, recentMessages, {
enabled: enableContextQueryBlend,
assistantWeight: contextAssistantWeight,
previousUserWeight: contextPreviousUserWeight,
});
retrievalMeta.queryBlendActive = contextQueryBlend.active;
retrievalMeta.queryBlendParts = (contextQueryBlend.parts || []).map((part) => ({
kind: part.kind,
label: part.label,
weight: part.weight,
text: createTextPreview(part.text),
length: part.text.length,
}));
retrievalMeta.queryBlendWeights = Object.fromEntries(
(contextQueryBlend.parts || []).map((part) => [part.kind, part.weight]),
);
const lexicalQuery = buildLexicalQuerySources(
contextQueryBlend.currentText || userMessage,
{
enableMultiIntent,
maxSegments: multiIntentMaxSegments,
},
);
console.log(
`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? " (认知边界已启用)" : ""}`,
);
@@ -299,25 +757,25 @@ export async function retrieve({
const vectorStartedAt = nowMs();
if (enableVectorPrefilter && vectorValidation.valid) {
console.log("[ST-BME] 第1层: 向量预筛");
const segments = enableMultiIntent
? splitIntentSegments(userMessage, {
maxSegments: multiIntentMaxSegments,
})
: [];
const queries = [userMessage, ...segments.filter((item) => item !== userMessage)];
const queryPlan = buildVectorQueryPlan(contextQueryBlend, {
enableMultiIntent,
maxSegments: multiIntentMaxSegments,
});
const groups = [];
retrievalMeta.segmentsUsed = segments;
for (const queryText of queries) {
const results = await vectorPreFilter(
graph,
queryText,
activeNodes,
embeddingConfig,
normalizedTopK,
signal,
);
groups.push(results);
retrievalMeta.segmentsUsed = queryPlan.currentSegments;
for (const part of queryPlan.plan) {
for (const queryText of part.queries) {
const results = await vectorPreFilter(
graph,
queryText,
activeNodes,
embeddingConfig,
normalizedTopK,
signal,
);
groups.push(scaleVectorResults(results, part.weight || 1));
}
}
const merged = mergeVectorResults(
@@ -332,7 +790,12 @@ export async function retrieve({
}
retrievalMeta.timings.vector = roundMs(nowMs() - vectorStartedAt);
exactEntityAnchors.push(...extractEntityAnchors(userMessage, activeNodes));
exactEntityAnchors.push(
...extractEntityAnchors(
contextQueryBlend.currentText || userMessage,
activeNodes,
),
);
supplementalAnchorNodeIds = collectSupplementalAnchorNodeIds(
graph,
vectorResults,
@@ -354,7 +817,7 @@ export async function retrieve({
residualBasisMaxNodes,
);
residualResult = await runResidualRecall({
queryText: userMessage,
queryText: contextQueryBlend.combinedText || userMessage,
graph,
embeddingConfig,
basisNodes,
@@ -514,22 +977,39 @@ export async function retrieve({
for (const [nodeId, scores] of scoreMap) {
const node = getNode(graph, nodeId);
if (!node || node.archived) continue;
const lexicalScore = enableLexicalBoost
? computeLexicalScore(node, lexicalQuery.sources)
: 0;
const finalScore = hybridScore(
{
graphScore: scores.graphScore,
vectorScore: scores.vectorScore,
lexicalScore,
importance: node.importance,
createdTime: node.createdTime,
},
weights,
{
...weights,
lexicalWeight: enableLexicalBoost ? lexicalWeight : 0,
},
);
scoredNodes.push({ nodeId, node, finalScore, ...scores });
scoredNodes.push({
nodeId,
node,
finalScore,
lexicalScore,
...scores,
});
}
scoredNodes.sort((a, b) => b.finalScore - a.finalScore);
retrievalMeta.scoredCandidates = scoredNodes.length;
retrievalMeta.lexicalBoostedNodes = scoredNodes.filter(
(item) => (Number(item.lexicalScore) || 0) > 0,
).length;
retrievalMeta.lexicalTopHits = buildLexicalTopHits(scoredNodes);
retrievalMeta.timings.scoring = roundMs(nowMs() - scoringStartedAt);
let selectedNodeIds;

View File

@@ -1,6 +1,7 @@
// ST-BME: 运行时状态与历史恢复辅助
const BATCH_JOURNAL_LIMIT = 96;
const MAINTENANCE_JOURNAL_LIMIT = 20;
export const BATCH_JOURNAL_VERSION = 2;
export function buildVectorCollectionId(chatId) {
@@ -49,6 +50,10 @@ export function createDefaultBatchJournal() {
return [];
}
export function createDefaultMaintenanceJournal() {
return [];
}
export function normalizeGraphRuntimeState(graph, chatId = "") {
if (!graph || typeof graph !== "object") {
return graph;
@@ -165,6 +170,11 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
graph.batchJournal = Array.isArray(graph.batchJournal)
? graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT)
: createDefaultBatchJournal();
graph.maintenanceJournal = Array.isArray(graph.maintenanceJournal)
? graph.maintenanceJournal
.filter((entry) => entry && typeof entry === "object")
.slice(-MAINTENANCE_JOURNAL_LIMIT)
: createDefaultMaintenanceJournal();
graph.lastProcessedSeq = historyState.lastProcessedAssistantFloor;
return graph;
}
@@ -549,6 +559,212 @@ export function appendBatchJournal(graph, entry) {
}
}
export function createMaintenanceJournalEntry(
snapshotBefore,
snapshotAfter,
meta = {},
) {
const beforeNodes = buildNodeMap(snapshotBefore?.nodes || []);
const afterNodes = buildNodeMap(snapshotAfter?.nodes || []);
const beforeEdges = buildEdgeMap(snapshotBefore?.edges || []);
const afterEdges = buildEdgeMap(snapshotAfter?.edges || []);
const restoreNodes = [];
const restoreEdges = [];
const deleteNodeIds = [];
const deleteEdgeIds = [];
const postNodes = [];
const postEdges = [];
for (const [nodeId, beforeNode] of beforeNodes.entries()) {
const afterNode = afterNodes.get(nodeId);
if (!afterNode) {
restoreNodes.push(cloneGraphSnapshot(beforeNode));
continue;
}
if (!hasMeaningfulNodeChange(beforeNode, afterNode)) continue;
restoreNodes.push(cloneGraphSnapshot(beforeNode));
postNodes.push(cloneGraphSnapshot(afterNode));
}
for (const [nodeId, afterNode] of afterNodes.entries()) {
if (beforeNodes.has(nodeId)) continue;
deleteNodeIds.push(nodeId);
postNodes.push(cloneGraphSnapshot(afterNode));
}
for (const [edgeId, beforeEdge] of beforeEdges.entries()) {
const afterEdge = afterEdges.get(edgeId);
if (!afterEdge) {
restoreEdges.push(cloneGraphSnapshot(beforeEdge));
continue;
}
if (!hasMeaningfulEdgeChange(beforeEdge, afterEdge)) continue;
restoreEdges.push(cloneGraphSnapshot(beforeEdge));
postEdges.push(cloneGraphSnapshot(afterEdge));
}
for (const [edgeId, afterEdge] of afterEdges.entries()) {
if (beforeEdges.has(edgeId)) continue;
deleteEdgeIds.push(edgeId);
postEdges.push(cloneGraphSnapshot(afterEdge));
}
if (
restoreNodes.length === 0 &&
restoreEdges.length === 0 &&
deleteNodeIds.length === 0 &&
deleteEdgeIds.length === 0
) {
return null;
}
return {
id: `maintenance-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
createdAt: Date.now(),
action: String(meta.action || "unknown"),
mode:
meta.mode === "auto" || meta.mode === "manual" ? meta.mode : "manual",
summary: String(meta.summary || ""),
inversePatch: {
restoreNodes,
restoreEdges,
deleteNodeIds,
deleteEdgeIds,
},
postCheck: {
nodes: postNodes,
edges: postEdges,
},
};
}
export function appendMaintenanceJournal(graph, entry) {
if (!entry || typeof entry !== "object") return;
normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || "");
graph.maintenanceJournal.push(entry);
if (graph.maintenanceJournal.length > MAINTENANCE_JOURNAL_LIMIT) {
graph.maintenanceJournal = graph.maintenanceJournal.slice(
-MAINTENANCE_JOURNAL_LIMIT,
);
}
}
export function getLatestMaintenanceJournalEntry(graph) {
normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || "");
const journal = Array.isArray(graph?.maintenanceJournal)
? graph.maintenanceJournal
: [];
return journal.length > 0 ? journal[journal.length - 1] : null;
}
function validateMaintenanceUndoState(graph, entry) {
const currentNodes = buildNodeMap(graph?.nodes || []);
const currentEdges = buildEdgeMap(graph?.edges || []);
const expectedNodes = entry?.postCheck?.nodes || [];
const expectedEdges = entry?.postCheck?.edges || [];
for (const snapshot of expectedNodes) {
const current = currentNodes.get(snapshot?.id);
if (!current) {
return {
ok: false,
reason: `节点 ${snapshot?.id || "unknown"} 已被后续操作改写`,
};
}
if (JSON.stringify(current) !== JSON.stringify(snapshot)) {
return {
ok: false,
reason: `节点 ${snapshot?.id || "unknown"} 当前状态已变化,无法安全撤销`,
};
}
}
for (const snapshot of expectedEdges) {
const current = currentEdges.get(snapshot?.id);
if (!current) {
return {
ok: false,
reason: `${snapshot?.id || "unknown"} 已被后续操作改写`,
};
}
if (JSON.stringify(current) !== JSON.stringify(snapshot)) {
return {
ok: false,
reason: `${snapshot?.id || "unknown"} 当前状态已变化,无法安全撤销`,
};
}
}
return { ok: true, reason: "" };
}
export function applyMaintenanceInversePatch(graph, inversePatch = {}) {
if (!graph || !inversePatch || typeof inversePatch !== "object") {
return graph;
}
normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || "");
const deleteNodeIds = new Set(inversePatch.deleteNodeIds || []);
const deleteEdgeIds = new Set(inversePatch.deleteEdgeIds || []);
const restoreNodes = Array.isArray(inversePatch.restoreNodes)
? inversePatch.restoreNodes
: [];
const restoreEdges = Array.isArray(inversePatch.restoreEdges)
? inversePatch.restoreEdges
: [];
graph.edges = (graph.edges || []).filter(
(edge) =>
!deleteEdgeIds.has(edge.id) &&
!deleteNodeIds.has(edge.fromId) &&
!deleteNodeIds.has(edge.toId),
);
graph.nodes = (graph.nodes || []).filter((node) => !deleteNodeIds.has(node.id));
for (const nodeSnapshot of restoreNodes) {
upsertById(graph.nodes, cloneGraphSnapshot(nodeSnapshot));
}
for (const edgeSnapshot of restoreEdges) {
upsertById(graph.edges, cloneGraphSnapshot(edgeSnapshot));
}
sanitizeGraphReferences(graph);
return graph;
}
export function undoLatestMaintenance(graph) {
normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || "");
const entry = getLatestMaintenanceJournalEntry(graph);
if (!entry) {
return {
ok: false,
reason: "当前没有可撤销的维护记录",
entry: null,
};
}
const validation = validateMaintenanceUndoState(graph, entry);
if (!validation.ok) {
return {
ok: false,
reason: validation.reason,
entry,
};
}
applyMaintenanceInversePatch(graph, entry.inversePatch || {});
graph.maintenanceJournal = graph.maintenanceJournal.slice(0, -1);
return {
ok: true,
reason: "",
entry,
remaining: graph.maintenanceJournal.length,
};
}
function upsertById(list, item) {
const index = list.findIndex((entry) => entry.id === item.id);
if (index >= 0) {

View File

@@ -46,6 +46,11 @@ assert.equal(defaultSettings.recallLlmCandidatePool, 30);
assert.equal(defaultSettings.recallLlmContextMessages, 4);
assert.equal(defaultSettings.recallEnableMultiIntent, true);
assert.equal(defaultSettings.recallMultiIntentMaxSegments, 4);
assert.equal(defaultSettings.recallEnableContextQueryBlend, true);
assert.equal(defaultSettings.recallContextAssistantWeight, 0.2);
assert.equal(defaultSettings.recallContextPreviousUserWeight, 0.1);
assert.equal(defaultSettings.recallEnableLexicalBoost, true);
assert.equal(defaultSettings.recallLexicalWeight, 0.18);
assert.equal(defaultSettings.recallTeleportAlpha, 0.15);
assert.equal(defaultSettings.recallEnableTemporalLinks, true);
assert.equal(defaultSettings.recallTemporalLinkStrength, 0.2);
@@ -64,6 +69,7 @@ assert.equal(defaultSettings.recallResidualTopK, 5);
assert.equal(defaultSettings.injectDepth, 9999);
assert.equal(defaultSettings.enabled, true);
assert.equal(defaultSettings.enableReflection, true);
assert.equal(defaultSettings.maintenanceAutoMinNewNodes, 3);
assert.equal(defaultSettings.embeddingTransportMode, "direct");
assert.equal(defaultSettings.taskProfilesVersion, 3);
assert.ok(defaultSettings.taskProfiles);

View File

@@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import {
appendMaintenanceJournal,
createMaintenanceJournalEntry,
normalizeGraphRuntimeState,
undoLatestMaintenance,
} from "../runtime-state.js";
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function buildNode(id, extra = {}) {
return {
id,
type: "character",
archived: false,
seq: 1,
seqRange: [1, 1],
importance: 5,
fields: {},
childIds: [],
parentId: null,
prevId: null,
nextId: null,
...extra,
};
}
function buildEdge(id, fromId, toId, extra = {}) {
return {
id,
fromId,
toId,
relation: "related",
strength: 1,
...extra,
};
}
{
const before = {
nodes: [buildNode("sleep-1")],
edges: [],
};
const after = clone(before);
after.nodes[0].archived = true;
const graph = normalizeGraphRuntimeState(clone(after), "chat-sleep");
const entry = createMaintenanceJournalEntry(before, after, {
action: "sleep",
mode: "manual",
summary: "手动遗忘:归档 1 个节点",
});
appendMaintenanceJournal(graph, entry);
const result = undoLatestMaintenance(graph);
assert.equal(result.ok, true);
assert.equal(graph.nodes[0].archived, false);
assert.equal(graph.maintenanceJournal.length, 0);
}
{
const before = {
nodes: [
buildNode("child-1"),
buildNode("child-2"),
buildNode("location-1", { type: "location", fields: { title: "大厅" } }),
],
edges: [buildEdge("edge-old", "child-1", "location-1")],
};
const after = clone(before);
after.nodes[0].archived = true;
after.nodes[0].parentId = "parent-1";
after.nodes[1].archived = true;
after.nodes[1].parentId = "parent-1";
after.nodes.push(
buildNode("parent-1", {
level: 1,
fields: { summary: "压缩父节点" },
childIds: ["child-1", "child-2"],
}),
);
after.edges.push(buildEdge("edge-new", "parent-1", "location-1"));
const graph = normalizeGraphRuntimeState(clone(after), "chat-compress");
const entry = createMaintenanceJournalEntry(before, after, {
action: "compress",
mode: "manual",
summary: "手动压缩:新建 1归档 2",
});
appendMaintenanceJournal(graph, entry);
const result = undoLatestMaintenance(graph);
assert.equal(result.ok, true);
assert.equal(graph.nodes.some((node) => node.id === "parent-1"), false);
assert.equal(
graph.edges.some((edge) => edge.id === "edge-new"),
false,
);
assert.equal(
graph.nodes.find((node) => node.id === "child-1")?.archived,
false,
);
assert.equal(
graph.nodes.find((node) => node.id === "child-2")?.archived,
false,
);
}
{
const before = {
nodes: [
buildNode("new-1", { fields: { summary: "新线索" } }),
buildNode("old-1", { fields: { summary: "旧描述" } }),
],
edges: [],
};
const after = clone(before);
after.nodes[0].archived = true;
after.nodes[1].fields.summary = "被新信息修正后的旧描述";
after.edges.push(buildEdge("edge-merge", "new-1", "old-1"));
const graph = normalizeGraphRuntimeState(clone(after), "chat-consolidate");
const entry = createMaintenanceJournalEntry(before, after, {
action: "consolidate",
mode: "manual",
summary: "手动整合:合并 1更新 1",
});
appendMaintenanceJournal(graph, entry);
const result = undoLatestMaintenance(graph);
assert.equal(result.ok, true);
assert.equal(
graph.nodes.find((node) => node.id === "new-1")?.archived,
false,
);
assert.equal(
graph.nodes.find((node) => node.id === "old-1")?.fields?.summary,
"旧描述",
);
assert.equal(
graph.edges.some((edge) => edge.id === "edge-merge"),
false,
);
}
{
const before = {
nodes: [buildNode("sleep-2")],
edges: [],
};
const after = clone(before);
after.nodes[0].archived = true;
const graph = normalizeGraphRuntimeState(clone(after), "chat-diverged");
const entry = createMaintenanceJournalEntry(before, after, {
action: "sleep",
mode: "manual",
summary: "手动遗忘:归档 1 个节点",
});
appendMaintenanceJournal(graph, entry);
graph.nodes[0].importance = 9;
const result = undoLatestMaintenance(graph);
assert.equal(result.ok, false);
assert.match(result.reason, /当前状态已变化|已被后续操作改写/);
assert.equal(graph.maintenanceJournal.length, 1);
}
console.log("maintenance-journal tests passed");

View File

@@ -145,8 +145,12 @@ const retrieve = await loadRetrieve({
async runResidualRecall() {
return { triggered: false, hits: [], skipReason: "residual-disabled-test" };
},
hybridScore: ({ graphScore = 0, vectorScore = 0, importance = 0 }) =>
graphScore + vectorScore + importance,
hybridScore: ({
graphScore = 0,
vectorScore = 0,
lexicalScore = 0,
importance = 0,
}) => graphScore + vectorScore + lexicalScore + importance,
reinforceAccessBatch() {},
validateVectorConfig() {
return { valid: true };
@@ -214,6 +218,32 @@ assert.equal(state.diffusionCalls.length, 0);
assert.equal(state.llmCalls.length, 0);
assert.deepEqual(Array.from(noStageResult.selectedNodeIds), ["rule-2", "rule-1"]);
state.vectorCalls.length = 0;
await retrieve({
graph,
userMessage: "他后来怎么做?",
recentMessages: [
"[assistant]: 他提到了规则二的限制",
"[user]: 我们先看规则一",
"[user]: 他后来怎么做?",
],
embeddingConfig: {},
schema,
options: {
topK: 4,
maxRecallNodes: 2,
enableVectorPrefilter: true,
enableGraphDiffusion: false,
enableLLMRecall: false,
enableMultiIntent: false,
enableContextQueryBlend: true,
},
});
assert.deepEqual(
state.vectorCalls.map((item) => item.message),
["他后来怎么做?", "他提到了规则二的限制", "我们先看规则一"],
);
state.vectorCalls.length = 0;
state.diffusionCalls.length = 0;
state.llmCalls.length = 0;
@@ -235,7 +265,10 @@ const llmPoolResult = await retrieve({
llmCandidatePool: 2,
},
});
assert.deepEqual(state.vectorCalls, [{ topK: 4, message: "请根据规则给出结论" }]);
assert.deepEqual(state.vectorCalls, [
{ topK: 4, message: "请根据规则给出结论" },
{ topK: 4, message: "现在该怎么做?" },
]);
assert.equal(state.diffusionCalls.length, 0);
assert.equal(state.llmCandidateCount, 2);
assert.deepEqual(Array.from(llmPoolResult.selectedNodeIds), ["rule-2", "rule-1"]);
@@ -366,4 +399,49 @@ const cappedResult = await retrieve({
});
assert.equal(cappedResult.selectedNodeIds.length, 1);
const lexicalGraph = {
nodes: [
{
id: "char-1",
type: "character",
importance: 1,
createdTime: 1,
archived: false,
fields: { name: "Alice", summary: "常驻角色" },
seqRange: [1, 1],
},
{
id: "char-2",
type: "character",
importance: 1,
createdTime: 1,
archived: false,
fields: { name: "Bob", summary: "常驻角色" },
seqRange: [1, 1],
},
],
edges: [],
};
const lexicalSchema = [{ id: "character", label: "角色", alwaysInject: false }];
const lexicalResult = await retrieve({
graph: lexicalGraph,
userMessage: "Alice 现在怎么样了",
recentMessages: [],
embeddingConfig: {},
schema: lexicalSchema,
options: {
topK: 2,
maxRecallNodes: 1,
enableVectorPrefilter: false,
enableGraphDiffusion: false,
enableLLMRecall: false,
enableDiversitySampling: false,
enableLexicalBoost: true,
},
});
assert.deepEqual(Array.from(lexicalResult.selectedNodeIds), ["char-1"]);
assert.equal(lexicalResult.meta.retrieval.queryBlendActive, false);
assert.equal(lexicalResult.meta.retrieval.lexicalBoostedNodes, 1);
assert.equal(lexicalResult.meta.retrieval.lexicalTopHits[0]?.nodeId, "char-1");
console.log("retrieval-config tests passed");

View File

@@ -101,6 +101,12 @@ export async function onManualCompressController(runtime) {
undefined,
runtime.getSettings(),
);
runtime.recordMaintenanceAction?.({
action: "compress",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["compression"],
@@ -386,6 +392,12 @@ export async function onManualSleepController(runtime) {
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const result = runtime.sleepCycle(graph, runtime.getSettings());
runtime.recordMaintenanceAction?.({
action: "sleep",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["sleep"],
@@ -439,6 +451,12 @@ export async function onManualEvolveController(runtime) {
conflictThreshold: settings.consolidationThreshold,
},
});
runtime.recordMaintenanceAction?.({
action: "consolidate",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("consolidate", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["consolidation"],
@@ -447,3 +465,26 @@ export async function onManualEvolveController(runtime) {
`整合完成:合并 ${result.merged},跳过 ${result.skipped},保留 ${result.kept},进化 ${result.evolved},新链接 ${result.connections},回溯更新 ${result.updates}`,
);
}
export async function onUndoLastMaintenanceController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) return;
if (!runtime.ensureGraphMutationReady("撤销最近维护")) return;
const result = runtime.undoLastMaintenance?.();
if (!result?.ok) {
runtime.toastr.warning(result?.reason || "撤销最近维护失败");
return { handledToast: true };
}
runtime.markVectorStateDirty?.("撤销维护后需要重建向量索引");
runtime.saveGraphToChat?.({ reason: "maintenance-undo-complete" });
runtime.refreshPanelLiveState?.();
runtime.toastr.success(
`已撤销最近维护:${result.entry?.summary || result.entry?.action || "未知操作"}`,
);
return {
handledToast: true,
result,
};
}