From 64dec0df2b464c2dfa8de2eef57bb56ec18a9a65 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sun, 12 Apr 2026 16:07:18 +0800 Subject: [PATCH] recall: sectioned recentMessages with context/target split for LLM prompt - prompt-builder.js: add RECALL_TARGET_CONTENT_HEADER, update splitSectionedTranscriptPayloadMessage to recognize recall-specific target header - retriever.js: add buildRecallSectionedTranscript helper, format recentMessages as sectioned transcript with context-review and recall-target headers for prompt building while keeping flat string[] for ranking - p0-regressions.mjs: add testRecallUsesSectionedPromptMessagesForContextAndTarget regression asserting two system messages with correct transcriptSection and headers --- prompting/prompt-builder.js | 22 ++++++---- retrieval/retriever.js | 34 ++++++++++++++- tests/p0-regressions.mjs | 84 +++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js index 62df4b5..faaa446 100644 --- a/prompting/prompt-builder.js +++ b/prompting/prompt-builder.js @@ -328,10 +328,12 @@ function isPromptMessageArray(value) { ); } -const EXTRACTION_CONTEXT_REVIEW_HEADER = +export const EXTRACTION_CONTEXT_REVIEW_HEADER = "--- 以下是上下文回顾(已提取过),仅供理解剧情 ---"; -const EXTRACTION_TARGET_CONTENT_HEADER = +export const EXTRACTION_TARGET_CONTENT_HEADER = "--- 以下是本次需要提取记忆的新对话内容 ---"; +export const RECALL_TARGET_CONTENT_HEADER = + "--- 以下是本次需要召回记忆的新对话内容 ---"; function getPromptMessageContextGroup(value) { const descriptor = getPromptMessageLikeDescriptor(value); @@ -1927,11 +1929,16 @@ function splitSectionedTranscriptPayloadMessage(message = {}) { const normalizedRole = normalizeRole(message?.role); const sourceKey = String(message?.sourceKey || "").trim(); const content = String(message?.content || "").trim(); + const targetSectionHeader = content.includes(RECALL_TARGET_CONTENT_HEADER) + ? RECALL_TARGET_CONTENT_HEADER + : content.includes(EXTRACTION_TARGET_CONTENT_HEADER) + ? EXTRACTION_TARGET_CONTENT_HEADER + : ""; if ( normalizedRole !== "system" || !["recentMessages", "dialogueText"].includes(sourceKey) || !content.includes(EXTRACTION_CONTEXT_REVIEW_HEADER) || - !content.includes(EXTRACTION_TARGET_CONTENT_HEADER) + !targetSectionHeader ) { return [message]; } @@ -1943,10 +1950,9 @@ function splitSectionedTranscriptPayloadMessage(message = {}) { EXTRACTION_CONTEXT_REVIEW_HEADER, searchIndex, ); - const targetIndex = content.indexOf( - EXTRACTION_TARGET_CONTENT_HEADER, - searchIndex, - ); + const targetIndex = targetSectionHeader + ? content.indexOf(targetSectionHeader, searchIndex) + : -1; let nextIndex = -1; let nextHeader = ""; if (contextIndex >= 0 && (targetIndex < 0 || contextIndex <= targetIndex)) { @@ -1954,7 +1960,7 @@ function splitSectionedTranscriptPayloadMessage(message = {}) { nextHeader = EXTRACTION_CONTEXT_REVIEW_HEADER; } else if (targetIndex >= 0) { nextIndex = targetIndex; - nextHeader = EXTRACTION_TARGET_CONTENT_HEADER; + nextHeader = targetSectionHeader; } if (nextIndex < 0 || !nextHeader) { break; diff --git a/retrieval/retriever.js b/retrieval/retriever.js index abe37a9..ea97f90 100644 --- a/retrieval/retriever.js +++ b/retrieval/retriever.js @@ -16,6 +16,8 @@ import { buildTaskExecutionDebugContext, buildTaskLlmPayload, buildTaskPrompt, + EXTRACTION_CONTEXT_REVIEW_HEADER, + RECALL_TARGET_CONTENT_HEADER, } from "../prompting/prompt-builder.js"; import { applyCooccurrenceBoost, @@ -93,6 +95,32 @@ function resolveTaskLlmSystemPrompt(promptPayload, fallbackSystemPrompt = "") { return String(promptPayload?.systemPrompt || fallbackSystemPrompt || ""); } +function buildRecallSectionedTranscript(recentMessages = []) { + const lines = (Array.isArray(recentMessages) ? recentMessages : []) + .map((line) => String(line || "").trim()) + .filter(Boolean); + if (lines.length === 0) { + return ""; + } + + const targetLines = [lines[lines.length - 1]].filter(Boolean); + const contextLines = lines.slice(0, -1).filter(Boolean); + const sections = []; + + if (contextLines.length > 0) { + sections.push( + `${EXTRACTION_CONTEXT_REVIEW_HEADER}\n\n${contextLines.join("\n---\n")}`, + ); + } + if (targetLines.length > 0) { + sections.push( + `${RECALL_TARGET_CONTENT_HEADER}\n\n${targetLines.join("\n---\n")}`, + ); + } + + return sections.join("\n\n"); +} + function buildRecallFallbackReason(llmResult) { const failureType = String(llmResult?.errorType || "").trim(); const failureReason = String(llmResult?.failureReason || "").trim(); @@ -2153,6 +2181,8 @@ async function llmRecall( ) { throwIfAborted(signal); const contextStr = recentMessages.join("\n---\n"); + const sectionedContextStr = + buildRecallSectionedTranscript(recentMessages) || contextStr; const sceneOwnerCandidateText = buildSceneOwnerCandidateText(sceneOwnerCandidates); const { candidateKeyToNodeId, @@ -2177,7 +2207,7 @@ async function llmRecall( const recallPromptBuild = await buildTaskPrompt(settings, "recall", { taskName: "recall", - recentMessages: contextStr || "(无)", + recentMessages: sectionedContextStr || "(无)", userMessage, candidateNodes: candidateDescriptions, candidateText: candidateDescriptions, @@ -2212,7 +2242,7 @@ async function llmRecall( activeStoryTimeLabel || "(未确定)", "", "## 最近对话上下文", - contextStr || "(无)", + sectionedContextStr || contextStr || "(无)", "", "## 用户最新输入", userMessage, diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 4fb3876..ece6400 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -162,6 +162,7 @@ const { generateSynopsis, } = await import("../maintenance/extractor.js"); const { consolidateMemories } = await import("../maintenance/consolidator.js"); +const { retrieve } = await import("../retrieval/retriever.js"); const { createBatchJournalEntry, buildReverseJournalRecoveryPlan, @@ -169,6 +170,10 @@ const { rollbackBatch, } = await import("../runtime/runtime-state.js"); const { createDefaultTaskProfiles } = await import("../prompting/prompt-profiles.js"); +const { + EXTRACTION_CONTEXT_REVIEW_HEADER, + RECALL_TARGET_CONTENT_HEADER, +} = await import("../prompting/prompt-builder.js"); const extensionsApi = await import("../../../../extensions.js"); const llm = await import("../llm/llm.js"); const embedding = await import("../vector/embedding.js"); @@ -6238,6 +6243,84 @@ async function testSynopsisUsesPromptMessagesWithoutFallbackSystemPrompt() { } } +async function testRecallUsesSectionedPromptMessagesForContextAndTarget() { + const graph = createEmptyGraph(); + addNode(graph, makeEvent(1, "仓库争执")); + addNode(graph, makeEvent(2, "走廊追问")); + + const captured = []; + const restoreOverrides = pushTestOverrides({ + llm: { + async callLLMForJSON(params = {}) { + captured.push(params); + return { + selected_keys: ["R1"], + reason: "R1: 与当前追问直接相关", + active_owner_keys: [], + active_owner_scores: [], + }; + }, + }, + }); + + try { + const result = await retrieve({ + graph, + userMessage: "她为什么突然改口?", + recentMessages: [ + "[assistant]: 她先否认自己去过仓库。", + "[user]: 我记得她当时很紧张。", + "[user]: 她为什么突然改口?", + ], + embeddingConfig: null, + schema, + settings: { + taskProfilesVersion: 3, + taskProfiles: createDefaultTaskProfiles(), + }, + options: { + topK: 4, + maxRecallNodes: 2, + enableLLMRecall: true, + enableVectorPrefilter: false, + enableGraphDiffusion: false, + llmCandidatePool: 2, + enableScopedMemory: false, + enablePovMemory: false, + enableRegionScopedObjective: false, + enableCognitiveMemory: false, + enableSpatialAdjacency: false, + enableStoryTimeline: false, + injectStoryTimeLabel: false, + injectUserPovMemory: false, + injectObjectiveGlobalMemory: false, + enableContextQueryBlend: true, + }, + }); + + assert.ok(Array.isArray(result?.selectedNodeIds)); + assert.equal(captured.length, 1); + const promptMessages = Array.isArray(captured[0].promptMessages) + ? captured[0].promptMessages + : []; + const recentMessageSections = promptMessages.filter( + (message) => message.sourceKey === "recentMessages", + ); + assert.equal(recentMessageSections.length, 2); + assert.equal(recentMessageSections[0].role, "system"); + assert.equal(recentMessageSections[1].role, "system"); + assert.equal(recentMessageSections[0].transcriptSection, "context"); + assert.equal(recentMessageSections[1].transcriptSection, "target"); + assert.match(recentMessageSections[0].content, new RegExp(EXTRACTION_CONTEXT_REVIEW_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(recentMessageSections[1].content, new RegExp(RECALL_TARGET_CONTENT_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(recentMessageSections[0].content, /她先否认自己去过仓库/); + assert.match(recentMessageSections[0].content, /我记得她当时很紧张/); + assert.match(recentMessageSections[1].content, /她为什么突然改口/); + } finally { + restoreOverrides(); + } +} + async function testReflectionUsesPromptMessagesWithoutFallbackSystemPrompt() { const graph = createEmptyGraph(); addNode( @@ -6736,6 +6819,7 @@ await testLlmDebugSnapshotRedactsSecretsBeforeStorage(); await testEmbeddingUsesConfigTimeoutInsteadOfDefault(); await testLlmOutputRegexCleansResponseBeforeJsonParse(); await testSynopsisUsesPromptMessagesWithoutFallbackSystemPrompt(); +await testRecallUsesSectionedPromptMessagesForContextAndTarget(); await testReflectionUsesPromptMessagesWithoutFallbackSystemPrompt(); await testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan(); await testManualCompressUsesForcedCompressionAndPersistsRealMutation();