mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 14:20:35 +08:00
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
469 lines
14 KiB
JavaScript
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");
|