diff --git a/maintenance/extraction-context.js b/maintenance/extraction-context.js index 0658fe9..15eecec 100644 --- a/maintenance/extraction-context.js +++ b/maintenance/extraction-context.js @@ -289,6 +289,32 @@ function resolveSpeakerName(message = {}, role = "assistant", names = {}) { return role || "assistant"; } +function shouldHideSpeakerLabel(message = {}, role = "assistant", names = {}) { + if (message?.hideSpeakerLabel === true) { + return true; + } + if (message?.hideSpeakerLabel === false) { + return false; + } + if (role !== "assistant") { + return false; + } + if (String(message?.source || "").trim() === "worldInfo-atDepth") { + return false; + } + const explicitSpeaker = String( + message?.speaker ?? message?.name ?? message?.displayName ?? "", + ).trim(); + if (!explicitSpeaker) { + return true; + } + const activeCharName = String(names?.charName || "").trim(); + if (!activeCharName) { + return false; + } + return explicitSpeaker === activeCharName; +} + function normalizeExtractionMessage(message = {}, index = 0, names = {}) { const role = normalizeRole( message?.role ?? (message?.is_user === true ? "user" : "assistant"), @@ -296,6 +322,7 @@ function normalizeExtractionMessage(message = {}, index = 0, names = {}) { const content = String(resolveMessageContent(message) || "").trim(); const rawContent = String(resolveMessageRawContent(message) || content).trim(); const speaker = resolveSpeakerName(message, role, names); + const hideSpeakerLabel = shouldHideSpeakerLabel(message, role, names); const seq = Number.isFinite(Number(message?.seq)) ? Number(message.seq) : null; return { @@ -304,6 +331,7 @@ function normalizeExtractionMessage(message = {}, index = 0, names = {}) { role, speaker, name: speaker, + hideSpeakerLabel, content, rawContent, sourceType: role === "user" ? "user_input" : "ai_output", @@ -347,7 +375,8 @@ export function formatExtractionTranscript(messages = []) { : `#${index + 1}`; const role = normalizeRole(message?.role || "assistant"); const speaker = String(message?.speaker || message?.name || "").trim(); - const speakerLabel = speaker ? `|${speaker}` : ""; + const speakerLabel = + message?.hideSpeakerLabel === true || !speaker ? "" : `|${speaker}`; const line = `${seqLabel} [${role}${speakerLabel}]: ${String(message?.content || "")}`; if (String(line || "").trim()) { lines.push(line); diff --git a/maintenance/extractor.js b/maintenance/extractor.js index 97c82ee..c5bcfe2 100644 --- a/maintenance/extractor.js +++ b/maintenance/extractor.js @@ -873,6 +873,7 @@ export async function extractMemories({ content: message?.content, speaker: message?.speaker, name: message?.name, + hideSpeakerLabel: message?.hideSpeakerLabel === true, isContextOnly: message?.isContextOnly === true, })) : []; diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js index 4c9f34c..62df4b5 100644 --- a/prompting/prompt-builder.js +++ b/prompting/prompt-builder.js @@ -296,6 +296,7 @@ function getPromptMessageLikeDescriptor(value) { role: role === "user" ? "user" : "assistant", seq: getOptionalFiniteNumber(value.seq), speaker, + hideSpeakerLabel: value?.hideSpeakerLabel === true, isContextOnly: typeof value.isContextOnly === "boolean" ? value.isContextOnly : null, }; @@ -310,6 +311,7 @@ function getPromptMessageLikeDescriptor(value) { role: value.is_user === true ? "user" : "assistant", seq: getOptionalFiniteNumber(value.seq), speaker, + hideSpeakerLabel: value?.hideSpeakerLabel === true, isContextOnly: typeof value.isContextOnly === "boolean" ? value.isContextOnly : null, }; @@ -326,6 +328,11 @@ function isPromptMessageArray(value) { ); } +const EXTRACTION_CONTEXT_REVIEW_HEADER = + "--- 以下是上下文回顾(已提取过),仅供理解剧情 ---"; +const EXTRACTION_TARGET_CONTENT_HEADER = + "--- 以下是本次需要提取记忆的新对话内容 ---"; + function getPromptMessageContextGroup(value) { const descriptor = getPromptMessageLikeDescriptor(value); if (!descriptor || typeof descriptor.isContextOnly !== "boolean") { @@ -334,6 +341,16 @@ function getPromptMessageContextGroup(value) { return descriptor.isContextOnly ? "context" : "target"; } +function getPromptMessageContextHeader(group = "") { + if (group === "context") { + return EXTRACTION_CONTEXT_REVIEW_HEADER; + } + if (group === "target") { + return EXTRACTION_TARGET_CONTENT_HEADER; + } + return ""; +} + function formatPromptMessageTranscript(value) { const entries = Array.isArray(value) ? value : [value]; const hasContextMessages = entries.some( @@ -353,16 +370,12 @@ function formatPromptMessageTranscript(value) { } const group = getPromptMessageContextGroup(entry); if (hasContextMessages && hasTargetMessages && group && group !== activeGroup) { - lines.push( - group === "context" - ? "--- 以下是上下文回顾(已提取过),仅供理解剧情 ---" - : "--- 以下是本次需要提取记忆的新对话内容 ---", - ); + lines.push(getPromptMessageContextHeader(group)); activeGroup = group; } const seqLabel = descriptor.seq != null ? `#${descriptor.seq}` : `#${index + 1}`; - const speakerLabel = descriptor.speaker + const speakerLabel = !descriptor.hideSpeakerLabel && descriptor.speaker ? `|${descriptor.speaker}` : ""; lines.push(`${seqLabel} [${descriptor.role}${speakerLabel}]: ${descriptor.content}`); @@ -1910,6 +1923,87 @@ function clonePayloadMessage(message = {}) { }); } +function splitSectionedTranscriptPayloadMessage(message = {}) { + const normalizedRole = normalizeRole(message?.role); + const sourceKey = String(message?.sourceKey || "").trim(); + const content = String(message?.content || "").trim(); + if ( + normalizedRole !== "system" || + !["recentMessages", "dialogueText"].includes(sourceKey) || + !content.includes(EXTRACTION_CONTEXT_REVIEW_HEADER) || + !content.includes(EXTRACTION_TARGET_CONTENT_HEADER) + ) { + return [message]; + } + + const headerMatches = []; + let searchIndex = 0; + while (searchIndex < content.length) { + const contextIndex = content.indexOf( + EXTRACTION_CONTEXT_REVIEW_HEADER, + searchIndex, + ); + const targetIndex = content.indexOf( + EXTRACTION_TARGET_CONTENT_HEADER, + searchIndex, + ); + let nextIndex = -1; + let nextHeader = ""; + if (contextIndex >= 0 && (targetIndex < 0 || contextIndex <= targetIndex)) { + nextIndex = contextIndex; + nextHeader = EXTRACTION_CONTEXT_REVIEW_HEADER; + } else if (targetIndex >= 0) { + nextIndex = targetIndex; + nextHeader = EXTRACTION_TARGET_CONTENT_HEADER; + } + if (nextIndex < 0 || !nextHeader) { + break; + } + headerMatches.push({ + index: nextIndex, + header: nextHeader, + }); + searchIndex = nextIndex + nextHeader.length; + } + + if (headerMatches.length < 2 || headerMatches[0].index !== 0) { + return [message]; + } + + const { role: _role, content: _content, ...sharedMeta } = message; + const splitMessages = []; + + for (let index = 0; index < headerMatches.length; index += 1) { + const current = headerMatches[index]; + const next = headerMatches[index + 1]; + const sectionBody = content + .slice(current.index + current.header.length, next ? next.index : content.length) + .trim(); + const transcriptSection = + current.header === EXTRACTION_CONTEXT_REVIEW_HEADER ? "context" : "target"; + splitMessages.push( + createExecutionMessage( + "system", + sectionBody ? `${current.header}\n\n${sectionBody}` : current.header, + { + ...sharedMeta, + sourceKey, + transcriptSection, + transcriptSectionPart: "section", + }, + ), + ); + } + + return splitMessages.filter(Boolean); +} + +function expandSectionedTranscriptPayloadMessages(messages = []) { + return (Array.isArray(messages) ? messages : []).flatMap((message) => + splitSectionedTranscriptPayloadMessage(message), + ); +} + function collectPayloadUserMessageTexts(messages = []) { return (Array.isArray(messages) ? messages : []) .filter((message) => String(message?.role || "").trim().toLowerCase() === "user") @@ -2008,8 +2102,11 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") !(isCustomFilter && messageUsesWorldInfoContent(message)), }, ); + const expandedExecutionMessages = expandSectionedTranscriptPayloadMessages( + executionMessages, + ); - const hasUserMessage = executionMessages.some( + const hasUserMessage = expandedExecutionMessages.some( (message) => message.role === "user", ); if (!hasUserMessage && rawExecutionMessages.length > 0) { @@ -2028,7 +2125,7 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") `after recreate=${userBlocksAfterRaw.length}, ` + `after sanitize=${userBlocksAfterSanitize.length}, ` + `blockedContents count=${blockedContents.length}, ` + - `total executionMessages=${executionMessages.length}`, + `total executionMessages=${expandedExecutionMessages.length}`, ); if (userBlocksBefore.length > 0) { for (const block of userBlocksBefore) { @@ -2046,17 +2143,19 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") } } const additionalMessages = - executionMessages.length > 0 + expandedExecutionMessages.length > 0 ? [] - : sanitizePromptMessages( - settings, - taskType, - rawPrivateTaskMessages, - { - blockedContents, - applySanitizer: (message) => - !(isCustomFilter && messageUsesWorldInfoContent(message)), - }, + : expandSectionedTranscriptPayloadMessages( + sanitizePromptMessages( + settings, + taskType, + rawPrivateTaskMessages, + { + blockedContents, + applySanitizer: (message) => + !(isCustomFilter && messageUsesWorldInfoContent(message)), + }, + ), ); const hasAdditionalUserMessage = additionalMessages.some( (message) => message.role === "user", @@ -2078,9 +2177,11 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") return { systemPrompt: - executionMessages.length > 0 ? "" : String(promptBuild?.systemPrompt || ""), + expandedExecutionMessages.length > 0 + ? "" + : String(promptBuild?.systemPrompt || ""), userPrompt: fallbackUserPromptResult.text, - promptMessages: executionMessages, + promptMessages: expandedExecutionMessages, additionalMessages, fallbackUserPromptSource: fallbackUserPromptResult.source, fallbackUserPromptApplied: Boolean(fallbackUserPromptResult.text), diff --git a/tests/extraction-context-only-flag.mjs b/tests/extraction-context-only-flag.mjs index 8bf703a..10e517a 100644 --- a/tests/extraction-context-only-flag.mjs +++ b/tests/extraction-context-only-flag.mjs @@ -76,9 +76,23 @@ const chat = [ { const mixed = [ { seq: 1, role: "user", content: "context user", speaker: "A", isContextOnly: true }, - { seq: 2, role: "assistant", content: "context ai", speaker: "B", isContextOnly: true }, + { + seq: 2, + role: "assistant", + content: "context ai", + speaker: "B", + hideSpeakerLabel: true, + isContextOnly: true, + }, { seq: 3, role: "user", content: "target user", speaker: "A", isContextOnly: false }, - { seq: 4, role: "assistant", content: "target ai", speaker: "B", isContextOnly: false }, + { + seq: 4, + role: "assistant", + content: "target ai", + speaker: "B", + hideSpeakerLabel: true, + isContextOnly: false, + }, ]; const transcript = formatExtractionTranscript(mixed); assert.match(transcript, /已提取过/, "transcript should contain context review header"); @@ -89,6 +103,8 @@ const chat = [ ); assert.match(transcript, /#1.*context user/, "context message should appear"); assert.match(transcript, /#3.*target user/, "target message should appear"); + assert.match(transcript, /#2 \[assistant\]: context ai/, "assistant card name should be hidden"); + assert.doesNotMatch(transcript, /#2 \[assistant\|B\]:/, "assistant card name should not be rendered"); console.log(" ✓ formatExtractionTranscript: section dividers for mixed context/target"); } @@ -132,8 +148,20 @@ const chat = [ const targetFiltered = result.filteredMessages.filter((m) => !m.isContextOnly); assert.equal(contextFiltered.length, 2, "context messages propagated through filtering"); assert.equal(targetFiltered.length, 2, "target messages propagated through filtering"); + assert.equal( + result.filteredMessages.find((m) => m.seq === 2)?.hideSpeakerLabel, + true, + "active character assistant label should be hidden", + ); + assert.equal( + result.filteredMessages.find((m) => m.seq === 1)?.hideSpeakerLabel, + false, + "user label should remain visible", + ); assert.match(result.filteredTranscript, /已提取过/, "transcript includes context header"); assert.match(result.filteredTranscript, /本次需要提取/, "transcript includes target header"); + assert.match(result.filteredTranscript, /#2 \[assistant\]: old answer/, "assistant transcript should hide character name"); + assert.doesNotMatch(result.filteredTranscript, /#2 \[assistant\|B\]:/, "assistant transcript should not show character name"); console.log(" ✓ buildExtractionInputContext: isContextOnly propagated to filteredMessages and transcript"); } diff --git a/tests/extractor-input-context.mjs b/tests/extractor-input-context.mjs index 683e3ee..6ffd237 100644 --- a/tests/extractor-input-context.mjs +++ b/tests/extractor-input-context.mjs @@ -141,8 +141,9 @@ try { (message) => message.sourceKey === "recentMessages", ); assert.ok(recentBlock); - assert.match(String(recentBlock?.content || ""), /#10 \[assistant\|艾琳\]: 继续说明/); + assert.match(String(recentBlock?.content || ""), /#10 \[assistant\]: 继续说明/); assert.match(String(recentBlock?.content || ""), /#11 \[user\|玩家\]: 用户输入/); + assert.doesNotMatch(String(recentBlock?.content || ""), /#10 \[assistant\|艾琳\]:/); assert.doesNotMatch(String(recentBlock?.content || ""), /隐式思维|/); } finally { restore(); diff --git a/tests/extractor-phase3-layered-context.mjs b/tests/extractor-phase3-layered-context.mjs index 5d83646..8eade4b 100644 --- a/tests/extractor-phase3-layered-context.mjs +++ b/tests/extractor-phase3-layered-context.mjs @@ -224,16 +224,25 @@ function collectAllPromptContent(captured) { assert.equal(result.success, true); assert.ok(captured); - const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find( + const recentMessages = (Array.isArray(captured.promptMessages) + ? captured.promptMessages + : [] + ).filter( (m) => m.sourceKey === "recentMessages", ); - assert.ok(recentBlock, "recentMessages block should exist"); - const recentContent = String(recentBlock?.content || ""); - assert.match(recentContent, /以下是上下文回顾(已提取过),仅供理解剧情/); - assert.match(recentContent, /以下是本次需要提取记忆的新对话内容/); + assert.equal(recentMessages.length, 2, "recentMessages should split into 2 section system messages"); + assert.equal(recentMessages[0]?.role, "system"); + assert.equal(recentMessages[0]?.transcriptSection, "context"); + assert.match(String(recentMessages[0]?.content || ""), /^--- 以下是上下文回顾(已提取过),仅供理解剧情 ---/); + assert.match(String(recentMessages[0]?.content || ""), /#10 \[user\|玩家\]: 第一轮消息/); + assert.equal(recentMessages[1]?.role, "system"); + assert.equal(recentMessages[1]?.transcriptSection, "target"); + assert.match(String(recentMessages[1]?.content || ""), /^--- 以下是本次需要提取记忆的新对话内容 ---/); + assert.match(String(recentMessages[1]?.content || ""), /#12 \[user\|玩家\]: 第二轮消息/); assert.ok( - recentContent.indexOf("已提取过") < recentContent.indexOf("本次需要提取"), - "context review should appear before extraction target section", + recentMessages[0].content.includes("已提取过") && + recentMessages[1].content.includes("本次需要提取"), + "context and target sections should each be emitted as a single system message", ); } finally { restore(); diff --git a/tests/extractor-phase5-context-fidelity.mjs b/tests/extractor-phase5-context-fidelity.mjs index ebb15bc..37d53bd 100644 --- a/tests/extractor-phase5-context-fidelity.mjs +++ b/tests/extractor-phase5-context-fidelity.mjs @@ -286,7 +286,7 @@ try { ).find((message) => message.sourceKey === "recentMessages"); assert.ok(recentBlock, "recentMessages block should exist"); const recentContent = String(recentBlock?.content || ""); - assert.match(recentContent, /#30 \[assistant\|艾琳\]: 艾琳说:去调查蓝钥匙。/); + assert.match(recentContent, /#30 \[assistant\]: 艾琳说:去调查蓝钥匙。/); assert.match( recentContent, /#31 \[assistant\|旁白\]: 旁白补充:雨夜<\/status>巷子很安静。/, @@ -351,7 +351,7 @@ try { : [] ).find((message) => message.sourceKey === "recentMessages"); assert.ok(recentBlock, "recentMessages block should still exist when worldbook is disabled"); - assert.match(String(recentBlock?.content || ""), /#30 \[assistant\|艾琳\]: 艾琳说:去调查蓝钥匙。/); + assert.match(String(recentBlock?.content || ""), /#30 \[assistant\]: 艾琳说:去调查蓝钥匙。/); } finally { restore(); } diff --git a/tests/prompt-builder-mixed-transcript.mjs b/tests/prompt-builder-mixed-transcript.mjs index de9c95e..eda3cd4 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: "艾琳", + hideSpeakerLabel: true, isContextOnly: true, }, { @@ -133,25 +134,41 @@ const promptBuild = await buildTaskPrompt(settings, "extract", { currentRange: "41 ~ 42", }); const payload = buildTaskLlmPayload(promptBuild, "fallback-user"); -const recentBlock = payload.promptMessages.find( +const recentMessages = payload.promptMessages.filter( (message) => message.sourceKey === "recentMessages", ); -assert.match( - String(recentBlock?.content || ""), - /以下是上下文回顾(已提取过),仅供理解剧情/, +assert.deepEqual( + recentMessages.map((message) => ({ + role: message.role, + sourceKey: message.sourceKey, + transcriptSection: message.transcriptSection, + transcriptSectionPart: message.transcriptSectionPart, + })), + [ + { + role: "system", + sourceKey: "recentMessages", + transcriptSection: "context", + transcriptSectionPart: "section", + }, + { + role: "system", + sourceKey: "recentMessages", + transcriptSection: "target", + transcriptSectionPart: "section", + }, + ], ); -assert.match( - String(recentBlock?.content || ""), - /以下是本次需要提取记忆的新对话内容/, -); -assert.match(String(recentBlock?.content || ""), /#41 \[assistant\|艾琳\]: 助手已净化/); -assert.match(String(recentBlock?.content || ""), /#42 \[user\|玩家\]: 用户已净化/); +assert.match(String(recentMessages[0]?.content || ""), /^--- 以下是上下文回顾(已提取过),仅供理解剧情 ---/); +assert.match(String(recentMessages[0]?.content || ""), /#41 \[assistant\]: 助手已净化/); +assert.match(String(recentMessages[1]?.content || ""), /^--- 以下是本次需要提取记忆的新对话内容 ---/); +assert.match(String(recentMessages[1]?.content || ""), /#42 \[user\|玩家\]: 用户已净化/); assert.doesNotMatch( - String(recentBlock?.content || ""), - /#41 \[assistant\|艾琳\]: 用户已净化/, + String(recentMessages[0]?.content || ""), + /#41 \[assistant\|艾琳\]:/, ); assert.doesNotMatch( - String(recentBlock?.content || ""), + String(recentMessages[1]?.content || ""), /#42 \[user\|玩家\]: 助手已净化/, );