Files
ST-Bionic-Memory-Ecology/tests/extractor-phase3-layered-context.mjs
Youzini-afk 559312c1b6 fix: preserve isContextOnly context/target dividers in final prompt transcript rendering
Root cause: formatPromptMessageTranscript in prompt-builder.js ignored
isContextOnly, so context review and extraction target sections were
flattened into plain transcript even though the flag was correctly set
in intermediate layers. Additionally, userPromptSections (which
contained the dividers) was only a fallback that never reached the
final prompt when block-based profiles had user blocks.

Fix:
- getPromptMessageLikeDescriptor now preserves isContextOnly flag
- formatPromptMessageTranscript now inserts context/target section
  dividers when messages carry isContextOnly, ensuring the final
  LLM prompt always shows the distinction regardless of which
  rendering path (recentMessages, chatMessages, dialogueText) is used

Regression tests:
- prompt-builder-mixed-transcript: verify recentMessages block content
  includes context review and extraction target dividers
- extractor-phase3-layered-context: end-to-end test proving default
  extract profile + default structured mode produces final promptMessages
  with context/target section dividers
2026-04-12 13:04:04 +08:00

469 lines
14 KiB
JavaScript

import assert from "node:assert/strict";
import {
installResolveHooks,
toDataModuleUrl,
} from "./helpers/register-hooks-compat.mjs";
const extensionsShimSource = [
"export const extension_settings = {};",
"export function getContext() {",
" return globalThis.__stBmeTestContext || {",
" chat: [],",
" chatMetadata: {},",
" extensionSettings: {},",
" powerUserSettings: {},",
" characters: {},",
" characterId: null,",
" name1: '玩家',",
" name2: '艾琳',",
" chatId: 'test-chat',",
" };",
"}",
].join("\n");
const scriptShimSource = [
"export function getRequestHeaders() {",
" return {};",
"}",
"export function substituteParamsExtended(value) {",
" return String(value ?? '');",
"}",
].join("\n");
const openAiShimSource = [
"export const chat_completion_sources = {};",
"export async function sendOpenAIRequest() {",
" throw new Error('sendOpenAIRequest should not be called in p3 test');",
"}",
].join("\n");
installResolveHooks([
{
specifiers: [
"../../../extensions.js",
"../../../../extensions.js",
"../../../../../extensions.js",
],
url: toDataModuleUrl(extensionsShimSource),
},
{
specifiers: [
"../../../../script.js",
"../../../../../script.js",
],
url: toDataModuleUrl(scriptShimSource),
},
{
specifiers: [
"../../../../openai.js",
"../../../../../openai.js",
],
url: toDataModuleUrl(openAiShimSource),
},
]);
const { createEmptyGraph, addNode, createNode } = await import("../graph/graph.js");
const { DEFAULT_NODE_SCHEMA } = await import("../graph/schema.js");
const { extractMemories } = await import("../maintenance/extractor.js");
const { appendSummaryEntry } = await import("../graph/summary-state.js");
const { normalizeGraphSummaryState } = await import("../graph/summary-state.js");
const { applyBatchStoryTime } = await import("../graph/story-timeline.js");
const { defaultSettings } = await import("../runtime/settings-defaults.js");
function setTestOverrides(overrides = {}) {
globalThis.__stBmeTestOverrides = overrides;
return () => {
delete globalThis.__stBmeTestOverrides;
};
}
globalThis.__stBmeTestContext = {
chat: [],
chatMetadata: {},
extensionSettings: {},
powerUserSettings: {},
characters: {},
characterId: null,
name1: "玩家",
name2: "艾琳",
chatId: "test-chat",
};
const baseMessages = [
{ seq: 10, role: "user", content: "第一轮消息", name: "玩家", speaker: "玩家" },
{ seq: 11, role: "assistant", content: "第一轮回复", name: "艾琳", speaker: "艾琳" },
{ seq: 12, role: "user", content: "第二轮消息", name: "玩家", speaker: "玩家" },
{ seq: 13, role: "assistant", content: "第二轮回复", name: "艾琳", speaker: "艾琳" },
{ seq: 14, role: "user", content: "第三轮消息", name: "玩家", speaker: "玩家" },
{ seq: 15, role: "assistant", content: "第三轮回复", name: "艾琳", speaker: "艾琳" },
];
function collectAllPromptContent(captured) {
return [
String(captured.systemPrompt || ""),
String(captured.userPrompt || ""),
...(Array.isArray(captured.promptMessages) ? captured.promptMessages : []).map(
(m) => String(m.content || ""),
),
...(Array.isArray(captured.additionalMessages) ? captured.additionalMessages : []).map(
(m) => String(m.content || ""),
),
].join("\n");
}
// ── Test 1: default settings — activeSummaries and storyTimeContext passed ──
{
const graph = createEmptyGraph();
normalizeGraphSummaryState(graph);
const entry = appendSummaryEntry(graph, {
text: "最近的局面总结测试文本",
messageRange: [5, 9],
level: 1,
});
applyBatchStoryTime(graph, { label: "第二天清晨", tense: "ongoing" }, "extract");
let captured = null;
const restore = setTestOverrides({
llm: {
async callLLMForJSON(payload) {
captured = payload;
return { operations: [], cognitionUpdates: [], regionUpdates: {} };
},
},
});
try {
const result = await extractMemories({
graph,
messages: baseMessages.slice(0, 2),
startSeq: 10,
endSeq: 11,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: { ...defaultSettings },
});
assert.equal(result.success, true);
assert.ok(captured, "LLM should be called");
const allContent = collectAllPromptContent(captured);
// activeSummaries should be somewhere in prompt content
assert.match(allContent, /最近的局面总结测试文本/, "active summaries text should appear in prompt");
// storyTimeContext should be somewhere in prompt content
assert.match(allContent, /第二天清晨/, "story time label should appear in prompt");
// recentMessages block should contain the dialogue
const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find(
(m) => m.sourceKey === "recentMessages",
);
assert.ok(recentBlock, "recentMessages block should exist");
assert.match(String(recentBlock.content || ""), /第一轮/, "recentMessages should contain dialogue content");
} finally {
restore();
}
}
{
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();
let captured = null;
const restore = setTestOverrides({
llm: {
async callLLMForJSON(payload) {
captured = payload;
return { operations: [], cognitionUpdates: [], regionUpdates: {} };
},
},
});
try {
const result = await extractMemories({
graph,
messages: baseMessages,
startSeq: 10,
endSeq: 15,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: {
...defaultSettings,
extractRecentMessageCap: 2,
},
});
assert.equal(result.success, true);
assert.ok(captured);
// With cap=2, only the last 2 messages (seq 14, 15) should be in the recentMessages block
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, /第三轮/, "capped messages should contain the last messages");
assert.doesNotMatch(recentContent, /第一轮/, "capped messages should not contain early messages");
} finally {
restore();
}
}
// ── Test 3: extractPromptStructuredMode = "structured" omits dialogueText ──
{
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: baseMessages.slice(0, 2),
startSeq: 10,
endSeq: 11,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: {
...defaultSettings,
extractPromptStructuredMode: "structured",
},
});
assert.equal(result.success, true);
assert.ok(captured);
// In structured mode, recentMessages block should still have structured content
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.ok(recentContent.length > 0, "recentMessages block should have content");
// The full transcript should NOT appear in prompt content
// (structured mode excludes dialogueText)
const allContent = collectAllPromptContent(captured);
// In "structured" mode, the user prompt fallback or blocks may reference structured messages
assert.match(recentContent, /第一轮/, "structured messages should contain dialogue");
} finally {
restore();
}
}
// ── Test 4: extractPromptStructuredMode = "transcript" passes string ──
{
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: baseMessages.slice(0, 2),
startSeq: 10,
endSeq: 11,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: {
...defaultSettings,
extractPromptStructuredMode: "transcript",
},
});
assert.equal(result.success, true);
assert.ok(captured);
// In transcript mode, the content should still be present in some form
const allContent = collectAllPromptContent(captured);
assert.match(allContent, /第一轮/, "transcript mode should have dialogue content");
// recentMessages block should exist and have transcript content
const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find(
(m) => m.sourceKey === "recentMessages",
);
assert.ok(recentBlock, "recentMessages block should exist in transcript mode");
} finally {
restore();
}
}
// ── Test 5: extractIncludeSummaries = false omits summaries ──
{
const graph = createEmptyGraph();
normalizeGraphSummaryState(graph);
appendSummaryEntry(graph, {
text: "这条总结不应出现",
messageRange: [5, 9],
level: 1,
});
let captured = null;
const restore = setTestOverrides({
llm: {
async callLLMForJSON(payload) {
captured = payload;
return { operations: [], cognitionUpdates: [], regionUpdates: {} };
},
},
});
try {
const result = await extractMemories({
graph,
messages: baseMessages.slice(0, 2),
startSeq: 10,
endSeq: 11,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: {
...defaultSettings,
extractIncludeSummaries: false,
},
});
assert.equal(result.success, true);
assert.ok(captured);
const allContent = collectAllPromptContent(captured);
assert.doesNotMatch(allContent, /这条总结不应出现/, "summaries should be excluded when disabled");
} finally {
restore();
}
}
// ── Test 6: extractIncludeStoryTime = false omits story time ──
{
const graph = createEmptyGraph();
applyBatchStoryTime(graph, { label: "隐藏的时间标签", tense: "ongoing" }, "extract");
let captured = null;
const restore = setTestOverrides({
llm: {
async callLLMForJSON(payload) {
captured = payload;
return { operations: [], cognitionUpdates: [], regionUpdates: {} };
},
},
});
try {
const result = await extractMemories({
graph,
messages: baseMessages.slice(0, 2),
startSeq: 10,
endSeq: 11,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: {
...defaultSettings,
extractIncludeStoryTime: false,
},
});
assert.equal(result.success, true);
assert.ok(captured);
const allContent = collectAllPromptContent(captured);
assert.doesNotMatch(allContent, /隐藏的时间标签/, "story time should be excluded when disabled");
} finally {
restore();
}
}
// ── Test 7: new settings exist in defaults ──
{
assert.equal(defaultSettings.extractRecentMessageCap, 0);
assert.equal(defaultSettings.extractPromptStructuredMode, "both");
assert.equal(defaultSettings.extractWorldbookMode, "active");
assert.equal(defaultSettings.extractIncludeStoryTime, true);
assert.equal(defaultSettings.extractIncludeSummaries, true);
}
console.log("extractor-phase3-layered-context tests passed");