From 42bd85b0aa2c58b105d89c14221230deb3db6217 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 20:01:19 +0800 Subject: [PATCH] fix(recall): reuse persisted recall on history rerolls --- index.js | 4 + retrieval/recall-controller.js | 195 ++++++++++++++++++++++- tests/p0-regressions.mjs | 276 ++++++++++++++++++++++++++++++++- 3 files changed, 468 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 966bc4c..4fa56c2 100644 --- a/index.js +++ b/index.js @@ -12462,6 +12462,7 @@ async function runRecall(options = {}) { abortRecallStageWithReason, applyRecallInjection, beginStageAbortController, + bumpPersistedRecallGenerationCount, buildRecallRetrieveOptions, clampInt, console, @@ -12485,10 +12486,12 @@ async function runRecall(options = {}) { isGraphReadable, isGraphReadableForRecall, nextRecallRunSequence: () => ++recallRunSequence, + readPersistedRecallFromUserMessage, recoverHistoryIfNeeded, refreshPanelLiveState, resolveRecallInput, retrieve, + schedulePersistedRecallMessageUiRefresh, setActiveRecallPromise: (value) => { activeRecallPromise = value; }, @@ -12500,6 +12503,7 @@ async function runRecall(options = {}) { pendingRecallSendIntent = value; }, toastr, + triggerChatMetadataSave, waitForActiveRecallToSettle, }, options, diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js index ebb1a7d..d2b915a 100644 --- a/retrieval/recall-controller.js +++ b/retrieval/recall-controller.js @@ -52,6 +52,102 @@ export function getRecallUserMessageSourceLabelController(source) { } } +function buildPersistedRecallReuseResult(record = {}) { + const selectedNodeIds = Array.isArray(record?.selectedNodeIds) + ? record.selectedNodeIds + .map((item) => String(item || "").trim()) + .filter(Boolean) + : []; + return { + injectionText: String(record?.injectionText || "").trim(), + selectedNodeIds, + stats: { + coreCount: 0, + recallCount: selectedNodeIds.length, + }, + meta: { + retrieval: { + vectorHits: 0, + vectorMergedHits: 0, + diffusionHits: 0, + candidatePoolAfterDpp: 0, + persistedReuse: true, + llm: { + status: "persisted", + reason: "复用已持久化召回", + selectionProtocol: "persisted-record-reuse", + rawSelectedKeys: [], + resolvedSelectedKeys: [], + resolvedSelectedNodeIds: selectedNodeIds, + fallbackReason: "", + fallbackType: "", + emptySelectionAccepted: false, + candidateKeyMapPreview: {}, + legacySelectionUsed: false, + candidatePool: 0, + }, + }, + }, + }; +} + +function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { + const generationType = String(recallInput?.generationType || "normal").trim() || "normal"; + if (generationType === "normal") return null; + + const targetUserMessageIndex = Number.isFinite(recallInput?.targetUserMessageIndex) + ? Math.floor(Number(recallInput.targetUserMessageIndex)) + : null; + if (!Number.isFinite(targetUserMessageIndex)) return null; + + const targetMessage = Array.isArray(chat) ? chat[targetUserMessageIndex] : null; + if (!targetMessage?.is_user) return null; + + const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage; + if (typeof readPersistedRecallFromUserMessage !== "function") return null; + + const record = readPersistedRecallFromUserMessage(chat, targetUserMessageIndex); + if (!record?.injectionText) return null; + + const normalizeText = (value = "") => + typeof runtime.normalizeRecallInputText === "function" + ? runtime.normalizeRecallInputText(value) + : String(value ?? "") + .replace(/\r\n/g, "\n") + .trim(); + const currentUserFloorText = normalizeText(targetMessage?.mes || ""); + const currentRecallInputText = normalizeText(recallInput?.userMessage || ""); + const recordRecallInput = normalizeText(record?.recallInput || ""); + const boundUserFloorText = normalizeText(record?.boundUserFloorText || ""); + + const matchesBoundUserFloor = Boolean( + currentUserFloorText && + boundUserFloorText && + currentUserFloorText === boundUserFloorText, + ); + const matchesRecallInput = Boolean( + currentRecallInputText && + recordRecallInput && + currentRecallInputText === recordRecallInput, + ); + const matchesCurrentUserFloor = Boolean( + currentUserFloorText && + recordRecallInput && + currentUserFloorText === recordRecallInput, + ); + + if (record.authoritativeInputUsed) { + if (!matchesBoundUserFloor) return null; + } else if (!matchesRecallInput && !matchesCurrentUserFloor) { + return null; + } + + return { + record, + targetUserMessageIndex, + }; +} + export function resolveRecallInputController( chat, recentContextMessageLimit, @@ -167,12 +263,15 @@ export function applyRecallInjectionController( result, runtime, ) { - const injectionText = runtime - .formatInjection(result, runtime.getSchema()) - .trim(); + const injectionText = String( + typeof result?.injectionText === "string" + ? result.injectionText + : runtime.formatInjection(result, runtime.getSchema()), + ).trim(); runtime.setLastInjectionContent(injectionText); const retrievalMeta = result?.meta?.retrieval || {}; + const isPersistedReuse = Boolean(retrievalMeta.persistedReuse); const llmMeta = retrievalMeta.llm || { status: settings.recallEnableLLM ? "unknown" : "disabled", reason: settings.recallEnableLLM ? "未提供 LLM 状态" : "LLM 精排已关闭", @@ -190,7 +289,7 @@ export function applyRecallInjectionController( const deliveryMode = String(recallInput?.deliveryMode || "immediate").trim() || "immediate"; - if (injectionText) { + if (injectionText && !isPersistedReuse) { const tokens = runtime.estimateTokens(injectionText); debugLog( `[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`, @@ -250,7 +349,9 @@ export function applyRecallInjectionController( runtime.saveGraphToChat({ reason: "recall-result-updated" }); const llmLabel = - llmMeta.status === "llm" + isPersistedReuse + ? "复用召回" + : llmMeta.status === "llm" ? "LLM 精排完成" : llmMeta.status === "fallback" ? "LLM 回退评分" @@ -495,6 +596,90 @@ export async function runRecallController(runtime, options = {}) { }); } + const persistedReuse = resolveReusablePersistedRecallRecord( + chat, + recallInput, + runtime, + ); + if (persistedReuse) { + const normalizedBoundUserFloorText = + typeof runtime.normalizeRecallInputText === "function" + ? runtime.normalizeRecallInputText( + persistedReuse.record.boundUserFloorText || + recallInput.boundUserFloorText || + "", + ) + : String( + persistedReuse.record.boundUserFloorText || + recallInput.boundUserFloorText || + "", + ) + .replace(/\r\n/g, "\n") + .trim(); + const effectiveRecallInput = { + ...recallInput, + source: "persisted-user-floor", + sourceLabel: "复用用户楼层召回", + reason: "persisted-user-floor-reuse", + authoritativeInputUsed: Boolean( + persistedReuse.record.authoritativeInputUsed || + recallInput.authoritativeInputUsed, + ), + boundUserFloorText: normalizedBoundUserFloorText, + }; + const reusedResult = buildPersistedRecallReuseResult(persistedReuse.record); + const applied = runtime.applyRecallInjection( + settings, + effectiveRecallInput, + recentMessages, + reusedResult, + ); + const bumpedRecord = + typeof runtime.bumpPersistedRecallGenerationCount === "function" + ? runtime.bumpPersistedRecallGenerationCount( + chat, + persistedReuse.targetUserMessageIndex, + ) + : null; + if (bumpedRecord) { + runtime.triggerChatMetadataSave?.(context, { immediate: false }); + runtime.schedulePersistedRecallMessageUiRefresh?.(); + } + return runtime.createRecallRunResult("completed", { + reason: "persisted-user-floor-reused", + selectedNodeIds: reusedResult.selectedNodeIds || [], + injectionText: applied?.injectionText || reusedResult.injectionText || "", + retrievalMeta: applied?.retrievalMeta || reusedResult.meta?.retrieval || {}, + llmMeta: + applied?.llmMeta || reusedResult.meta?.retrieval?.llm || {}, + transport: applied?.transport || { + applied: false, + source: "none", + mode: "none", + }, + deliveryMode: + applied?.deliveryMode || + String(effectiveRecallInput?.deliveryMode || "immediate").trim() || + "immediate", + source: effectiveRecallInput.source || "", + sourceLabel: effectiveRecallInput.sourceLabel || "", + authoritativeInputUsed: Boolean( + effectiveRecallInput.authoritativeInputUsed, + ), + boundUserFloorText: String( + effectiveRecallInput.boundUserFloorText || "", + ), + hookName: effectiveRecallInput.hookName || "", + sourceCandidates: Array.isArray(effectiveRecallInput.sourceCandidates) + ? effectiveRecallInput.sourceCandidates.map((candidate) => ({ + ...candidate, + })) + : [], + stats: reusedResult?.stats || {}, + recallInput: String(persistedReuse.record.recallInput || ""), + }); + } + const result = await runtime.retrieve({ graph: runtime.getCurrentGraph(), userMessage, diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 36dbd90..33a6712 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -4524,6 +4524,278 @@ async function testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphR ); } +async function testHistoryGenerationReusesPersistedRecallForStableUserFloor() { + const { runRecallController } = await import("../retrieval/recall-controller.js"); + const chat = [ + { + is_user: true, + mes: "稳定 user 楼层", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "persisted-memory", + selectedNodeIds: ["node-persisted-1"], + recallInput: "发送前权威输入", + recallSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 12, + manuallyEdited: false, + authoritativeInputUsed: true, + boundUserFloorText: "稳定 user 楼层", + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, + { is_user: false, mes: "assistant-tail" }, + ]; + let retrieveCalls = 0; + let metadataSaveCalls = 0; + let recallUiRefreshCalls = 0; + const applyCalls = []; + + const runtime = { + getIsRecalling: () => false, + abortRecallStageWithReason() {}, + waitForActiveRecallToSettle: async () => ({ settled: true }), + getCurrentGraph: () => ({ nodes: [], edges: [] }), + getSettings: () => ({ + enabled: true, + recallEnabled: true, + recallLlmContextMessages: 4, + }), + isGraphReadable: () => true, + isGraphReadableForRecall: () => true, + getGraphMutationBlockReason: () => "", + setLastRecallStatus() {}, + isGraphMetadataWriteAllowed: () => false, + recoverHistoryIfNeeded: async () => true, + getContext: () => ({ chat }), + nextRecallRunSequence: () => 1, + setIsRecalling() {}, + beginStageAbortController: () => ({ + signal: { aborted: false, addEventListener() {} }, + abort() {}, + }), + createAbortError: (message) => new Error(message), + ensureVectorReadyIfNeeded: async () => {}, + clampInt, + resolveRecallInput: () => ({ + userMessage: "稳定 user 楼层", + recentMessages: ["[user]: 稳定 user 楼层"], + source: "chat-last-user", + sourceLabel: "历史最后用户楼层", + generationType: "history", + targetUserMessageIndex: 0, + authoritativeInputUsed: false, + boundUserFloorText: "稳定 user 楼层", + sourceCandidates: [], + }), + console, + getRecallHookLabel: () => "历史生成", + retrieve: async () => { + retrieveCalls += 1; + return { + stats: { recallCount: 1, coreCount: 1 }, + selectedNodeIds: ["fresh-node"], + meta: { + retrieval: { + vectorHits: 1, + diffusionHits: 0, + llm: { status: "disabled", candidatePool: 0 }, + }, + }, + }; + }, + getEmbeddingConfig: () => null, + getSchema: () => schema, + buildRecallRetrieveOptions: () => ({}), + applyRecallInjection: (_settings, recallInput, _recentMessages, result) => { + applyCalls.push({ recallInput: { ...recallInput }, result: { ...result } }); + return { + injectionText: String(result?.injectionText || ""), + retrievalMeta: result?.meta?.retrieval || {}, + llmMeta: result?.meta?.retrieval?.llm || {}, + transport: { + applied: true, + source: "module-injection", + mode: "module-injection", + }, + deliveryMode: String(recallInput?.deliveryMode || "immediate") || "immediate", + }; + }, + createRecallInputRecord, + createRecallRunResult, + isAbortError: () => false, + toastr: { + warning() {}, + error() {}, + }, + finishStageAbortController() {}, + getActiveRecallPromise: () => null, + setActiveRecallPromise() {}, + setPendingRecallSendIntent() {}, + refreshPanelLiveState() {}, + readPersistedRecallFromUserMessage, + bumpPersistedRecallGenerationCount, + triggerChatMetadataSave() { + metadataSaveCalls += 1; + }, + schedulePersistedRecallMessageUiRefresh() { + recallUiRefreshCalls += 1; + }, + }; + + const result = await runRecallController(runtime, { + hookName: "GENERATION_AFTER_COMMANDS", + generationType: "regenerate", + deliveryMode: "immediate", + }); + + assert.equal(retrieveCalls, 0); + assert.equal(result.status, "completed"); + assert.equal(result.reason, "persisted-user-floor-reused"); + assert.equal(result.injectionText, "persisted-memory"); + assert.equal(applyCalls.length, 1); + assert.equal(applyCalls[0].recallInput.source, "persisted-user-floor"); + assert.equal(applyCalls[0].recallInput.authoritativeInputUsed, true); + assert.equal(applyCalls[0].recallInput.boundUserFloorText, "稳定 user 楼层"); + assert.equal( + readPersistedRecallFromUserMessage(chat, 0)?.generationCount, + 1, + ); + assert.equal(metadataSaveCalls, 1); + assert.equal(recallUiRefreshCalls, 1); +} + +async function testHistoryGenerationDoesNotReusePersistedRecallAfterUserFloorEdit() { + const { runRecallController } = await import("../retrieval/recall-controller.js"); + const chat = [ + { + is_user: true, + mes: "已编辑的新 user 楼层", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "stale-persisted-memory", + selectedNodeIds: ["node-stale-1"], + recallInput: "旧 user 楼层", + recallSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 12, + manuallyEdited: false, + authoritativeInputUsed: false, + boundUserFloorText: "旧 user 楼层", + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, + { is_user: false, mes: "assistant-tail" }, + ]; + let retrieveCalls = 0; + + const runtime = { + getIsRecalling: () => false, + abortRecallStageWithReason() {}, + waitForActiveRecallToSettle: async () => ({ settled: true }), + getCurrentGraph: () => ({ nodes: [], edges: [] }), + getSettings: () => ({ + enabled: true, + recallEnabled: true, + recallLlmContextMessages: 4, + }), + isGraphReadable: () => true, + isGraphReadableForRecall: () => true, + getGraphMutationBlockReason: () => "", + setLastRecallStatus() {}, + isGraphMetadataWriteAllowed: () => false, + recoverHistoryIfNeeded: async () => true, + getContext: () => ({ chat }), + nextRecallRunSequence: () => 1, + setIsRecalling() {}, + beginStageAbortController: () => ({ + signal: { aborted: false, addEventListener() {} }, + abort() {}, + }), + createAbortError: (message) => new Error(message), + ensureVectorReadyIfNeeded: async () => {}, + clampInt, + resolveRecallInput: () => ({ + userMessage: "已编辑的新 user 楼层", + recentMessages: ["[user]: 已编辑的新 user 楼层"], + source: "chat-last-user", + sourceLabel: "历史最后用户楼层", + generationType: "history", + targetUserMessageIndex: 0, + authoritativeInputUsed: false, + boundUserFloorText: "已编辑的新 user 楼层", + sourceCandidates: [], + }), + console, + getRecallHookLabel: () => "历史生成", + retrieve: async () => { + retrieveCalls += 1; + return { + stats: { recallCount: 1, coreCount: 1 }, + selectedNodeIds: ["fresh-node"], + meta: { + retrieval: { + vectorHits: 1, + diffusionHits: 0, + llm: { status: "disabled", candidatePool: 0 }, + }, + }, + }; + }, + getEmbeddingConfig: () => null, + getSchema: () => schema, + buildRecallRetrieveOptions: () => ({}), + applyRecallInjection: (_settings, recallInput) => ({ + injectionText: `fresh:${recallInput.userMessage}`, + retrievalMeta: { + vectorHits: 1, + diffusionHits: 0, + llm: { status: "disabled", candidatePool: 0 }, + }, + llmMeta: { status: "disabled", candidatePool: 0 }, + transport: { + applied: true, + source: "module-injection", + mode: "module-injection", + }, + deliveryMode: String(recallInput?.deliveryMode || "immediate") || "immediate", + }), + createRecallInputRecord, + createRecallRunResult, + isAbortError: () => false, + toastr: { + warning() {}, + error() {}, + }, + finishStageAbortController() {}, + getActiveRecallPromise: () => null, + setActiveRecallPromise() {}, + setPendingRecallSendIntent() {}, + refreshPanelLiveState() {}, + readPersistedRecallFromUserMessage, + bumpPersistedRecallGenerationCount, + triggerChatMetadataSave() {}, + schedulePersistedRecallMessageUiRefresh() {}, + }; + + const result = await runRecallController(runtime, { + hookName: "GENERATION_AFTER_COMMANDS", + generationType: "regenerate", + deliveryMode: "immediate", + }); + + assert.equal(retrieveCalls, 1); + assert.equal(result.status, "completed"); + assert.equal(result.reason, "召回完成"); + assert.equal(result.injectionText, "fresh:已编辑的新 user 楼层"); + assert.equal( + readPersistedRecallFromUserMessage(chat, 0)?.generationCount, + 0, + ); +} + async function testPersistentRecallDataLayerLifecycleAndCompatibility() { const chat = [ { is_user: true, mes: "u0" }, @@ -6181,8 +6453,8 @@ await testAutoExtractionDefersWhenAlreadyExtracting(); await testAutoExtractionDefersWhenHistoryRecoveryBusy(); await testRemoveNodeHandlesCyclicChildGraph(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); -await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload(); -await testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput(); +await testHistoryGenerationReusesPersistedRecallForStableUserFloor(); +await testHistoryGenerationDoesNotReusePersistedRecallAfterUserFloorEdit(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting(); await testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor();