diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js index 7b84777..4c9f34c 100644 --- a/prompting/prompt-builder.js +++ b/prompting/prompt-builder.js @@ -296,6 +296,8 @@ function getPromptMessageLikeDescriptor(value) { role: role === "user" ? "user" : "assistant", seq: getOptionalFiniteNumber(value.seq), speaker, + isContextOnly: + typeof value.isContextOnly === "boolean" ? value.isContextOnly : null, }; } @@ -308,6 +310,8 @@ function getPromptMessageLikeDescriptor(value) { role: value.is_user === true ? "user" : "assistant", seq: getOptionalFiniteNumber(value.seq), speaker, + isContextOnly: + typeof value.isContextOnly === "boolean" ? value.isContextOnly : null, }; } @@ -322,23 +326,49 @@ function isPromptMessageArray(value) { ); } +function getPromptMessageContextGroup(value) { + const descriptor = getPromptMessageLikeDescriptor(value); + if (!descriptor || typeof descriptor.isContextOnly !== "boolean") { + return null; + } + return descriptor.isContextOnly ? "context" : "target"; +} + function formatPromptMessageTranscript(value) { const entries = Array.isArray(value) ? value : [value]; - return entries - .map((entry, index) => { - const descriptor = getPromptMessageLikeDescriptor(entry); - if (!descriptor) { - return ""; - } - const seqLabel = - descriptor.seq != null ? `#${descriptor.seq}` : `#${index + 1}`; - const speakerLabel = descriptor.speaker - ? `|${descriptor.speaker}` - : ""; - return `${seqLabel} [${descriptor.role}${speakerLabel}]: ${descriptor.content}`; - }) - .filter(Boolean) - .join("\n\n"); + const hasContextMessages = entries.some( + (entry) => getPromptMessageContextGroup(entry) === "context", + ); + const hasTargetMessages = entries.some( + (entry) => getPromptMessageContextGroup(entry) === "target", + ); + const lines = []; + let activeGroup = null; + + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const descriptor = getPromptMessageLikeDescriptor(entry); + if (!descriptor) { + continue; + } + const group = getPromptMessageContextGroup(entry); + if (hasContextMessages && hasTargetMessages && group && group !== activeGroup) { + lines.push( + group === "context" + ? "--- 以下是上下文回顾(已提取过),仅供理解剧情 ---" + : "--- 以下是本次需要提取记忆的新对话内容 ---", + ); + activeGroup = group; + } + const seqLabel = + descriptor.seq != null ? `#${descriptor.seq}` : `#${index + 1}`; + const speakerLabel = descriptor.speaker + ? `|${descriptor.speaker}` + : ""; + lines.push(`${seqLabel} [${descriptor.role}${speakerLabel}]: ${descriptor.content}`); + } + + return lines.filter(Boolean).join("\n\n"); } function stringifyInterpolatedValue(value) { diff --git a/tests/extractor-phase3-layered-context.mjs b/tests/extractor-phase3-layered-context.mjs index f83a305..5d83646 100644 --- a/tests/extractor-phase3-layered-context.mjs +++ b/tests/extractor-phase3-layered-context.mjs @@ -165,6 +165,81 @@ function collectAllPromptContent(captured) { } } +{ + const graph = createEmptyGraph(); + let captured = null; + const restore = setTestOverrides({ + llm: { + async callLLMForJSON(payload) { + captured = payload; + return { operations: [], cognitionUpdates: [], regionUpdates: {} }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: [ + { + seq: 10, + role: "user", + content: "第一轮消息", + name: "玩家", + speaker: "玩家", + isContextOnly: true, + }, + { + seq: 11, + role: "assistant", + content: "第一轮回复", + name: "艾琳", + speaker: "艾琳", + isContextOnly: true, + }, + { + seq: 12, + role: "user", + content: "第二轮消息", + name: "玩家", + speaker: "玩家", + isContextOnly: false, + }, + { + seq: 13, + role: "assistant", + content: "第二轮回复", + name: "艾琳", + speaker: "艾琳", + isContextOnly: false, + }, + ], + startSeq: 12, + endSeq: 13, + schema: DEFAULT_NODE_SCHEMA, + embeddingConfig: null, + settings: { ...defaultSettings }, + }); + + assert.equal(result.success, true); + assert.ok(captured); + + const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( + (m) => m.sourceKey === "recentMessages", + ); + assert.ok(recentBlock, "recentMessages block should exist"); + const recentContent = String(recentBlock?.content || ""); + assert.match(recentContent, /以下是上下文回顾(已提取过),仅供理解剧情/); + assert.match(recentContent, /以下是本次需要提取记忆的新对话内容/); + assert.ok( + recentContent.indexOf("已提取过") < recentContent.indexOf("本次需要提取"), + "context review should appear before extraction target section", + ); + } finally { + restore(); + } +} + // ── Test 2: extractRecentMessageCap limits messages ── { const graph = createEmptyGraph(); diff --git a/tests/prompt-builder-mixed-transcript.mjs b/tests/prompt-builder-mixed-transcript.mjs index fa2bd83..de9c95e 100644 --- a/tests/prompt-builder-mixed-transcript.mjs +++ b/tests/prompt-builder-mixed-transcript.mjs @@ -117,6 +117,7 @@ const promptBuild = await buildTaskPrompt(settings, "extract", { content: "继续说明", name: "艾琳", speaker: "艾琳", + isContextOnly: true, }, { seq: 42, @@ -124,6 +125,7 @@ const promptBuild = await buildTaskPrompt(settings, "extract", { content: "用户输入", name: "玩家", speaker: "玩家", + isContextOnly: false, }, ], graphStats: "node_count=1", @@ -134,6 +136,14 @@ const payload = buildTaskLlmPayload(promptBuild, "fallback-user"); const recentBlock = payload.promptMessages.find( (message) => message.sourceKey === "recentMessages", ); +assert.match( + String(recentBlock?.content || ""), + /以下是上下文回顾(已提取过),仅供理解剧情/, +); +assert.match( + String(recentBlock?.content || ""), + /以下是本次需要提取记忆的新对话内容/, +); assert.match(String(recentBlock?.content || ""), /#41 \[assistant\|艾琳\]: 助手已净化/); assert.match(String(recentBlock?.content || ""), /#42 \[user\|玩家\]: 用户已净化/); assert.doesNotMatch(