feat: MVU规则模块+世界书MVU过滤+prompt组装MVU清洗+端到端测试

This commit is contained in:
Youzini-afk
2026-03-27 15:22:00 +08:00
parent a5ab93e701
commit cf4a73e0a8
10 changed files with 1341 additions and 28 deletions

56
tests/mvu-compat.mjs Normal file
View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
const {
isLikelyMvuWorldInfoContent,
isMvuTaggedWorldInfoNameOrComment,
sanitizeMvuContent,
} = await import("../mvu-compat.js");
assert.equal(
isMvuTaggedWorldInfoNameOrComment("[mvu_update] 状态", ""),
true,
);
assert.equal(
isMvuTaggedWorldInfoNameOrComment("普通条目", "[initvar]"),
true,
);
assert.equal(
isLikelyMvuWorldInfoContent(
"变量更新规则:\ntype: state\n当前时间: 12:00",
),
true,
);
assert.equal(isLikelyMvuWorldInfoContent("正常世界设定"), false);
const aggressive = sanitizeMvuContent(
"正文\n<updatevariable>hp=1</updatevariable>\n<status_current_variable>secret</status_current_variable>",
{
mode: "aggressive",
},
);
assert.equal(aggressive.text, "");
assert.equal(aggressive.dropped, true);
assert.deepEqual(
aggressive.reasons.sort(),
["artifact_stripped", "likely_mvu_content"].sort(),
);
const finalSafe = sanitizeMvuContent(
"说明文字\n<updatevariable>hp=1</updatevariable>\n尾巴",
{
mode: "final-safe",
},
);
assert.equal(finalSafe.dropped, false);
assert.equal(finalSafe.text, "说明文字\n尾巴");
assert.deepEqual(finalSafe.reasons, ["artifact_stripped"]);
const blocked = sanitizeMvuContent("前缀\n被拦截条目\n后缀", {
mode: "final-safe",
blockedContents: ["被拦截条目"],
});
assert.equal(blocked.text, "前缀\n\n后缀");
assert.equal(blocked.blockedHitCount, 1);
assert.deepEqual(blocked.reasons, ["blocked_content_removed"]);
console.log("mvu-compat tests passed");

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";
import { createRequire, registerHooks } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
const extensionsShimSource = [
@@ -34,6 +35,8 @@ const scriptShimUrl = `data:text/javascript,${encodeURIComponent(
const openAiShimUrl = `data:text/javascript,${encodeURIComponent(
openAiShimSource,
)}`;
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const indexPath = path.resolve(moduleDir, "../index.js");
registerHooks({
resolve(specifier, context, nextResolve) {
@@ -138,7 +141,6 @@ const schema = [
];
function createBatchStageHarness() {
const indexPath = path.resolve("./index.js");
return fs.readFile(indexPath, "utf8").then((source) => {
const marker = "function isAssistantChatMessage(message) {";
const start = source.indexOf("function shouldAdvanceProcessedHistory(");
@@ -181,7 +183,6 @@ function createBatchStageHarness() {
}
function createGenerationRecallHarness() {
const indexPath = path.resolve("./index.js");
return fs.readFile(indexPath, "utf8").then((source) => {
const start = source.indexOf("const RECALL_INPUT_RECORD_TTL_MS = 60000;");
const end = source.indexOf("function onMessageReceived() {");
@@ -243,7 +244,6 @@ function createGenerationRecallHarness() {
}
function createRerollHarness() {
const indexPath = path.resolve("./index.js");
return fs.readFile(indexPath, "utf8").then((source) => {
const helperStart = source.indexOf(
"function pruneProcessedMessageHashesFromFloor(",

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { registerHooks } from "node:module";
const extensionsShimSource = [
"export const extension_settings = {};",
"export function getContext() {",
" return {",
" chat: [],",

View File

@@ -0,0 +1,333 @@
import assert from "node:assert/strict";
import { createRequire, registerHooks } from "node:module";
const extensionsShimSource = [
"export const extension_settings = globalThis.__promptBuilderMvuExtensionSettings || {};",
"export function getContext() {",
" return globalThis.__promptBuilderMvuContext || {",
" chat: [],",
" chatMetadata: {},",
" extensionSettings: {},",
" powerUserSettings: {},",
" characters: [],",
" characterId: null,",
" name1: '',",
" name2: '',",
" chatId: 'mvu-test-chat',",
" };",
"}",
].join("\n");
const scriptShimSource = [
"export function getRequestHeaders() {",
" return { 'Content-Type': 'application/json' };",
"}",
].join("\n");
const openAiShimSource = [
"export const chat_completion_sources = { CUSTOM: 'custom', OPENAI: 'openai' };",
"export async function sendOpenAIRequest(...args) {",
" if (typeof globalThis.__promptBuilderMvuSendOpenAIRequest === 'function') {",
" return await globalThis.__promptBuilderMvuSendOpenAIRequest(...args);",
" }",
" return { choices: [{ message: { content: '{}' } }] };",
"}",
].join("\n");
registerHooks({
resolve(specifier, context, nextResolve) {
if (
specifier === "../../../extensions.js" ||
specifier === "../../../../extensions.js"
) {
return {
shortCircuit: true,
url: `data:text/javascript,${encodeURIComponent(extensionsShimSource)}`,
};
}
if (specifier === "../../../../script.js") {
return {
shortCircuit: true,
url: `data:text/javascript,${encodeURIComponent(scriptShimSource)}`,
};
}
if (specifier === "../../../openai.js") {
return {
shortCircuit: true,
url: `data:text/javascript,${encodeURIComponent(openAiShimSource)}`,
};
}
return nextResolve(specifier, context);
},
});
const require = createRequire(import.meta.url);
const originalRequire = globalThis.require;
const originalExtensionSettings = globalThis.__promptBuilderMvuExtensionSettings;
const originalContext = globalThis.__promptBuilderMvuContext;
const originalSendOpenAIRequest = globalThis.__promptBuilderMvuSendOpenAIRequest;
const originalFetch = globalThis.fetch;
globalThis.require = require;
globalThis.__promptBuilderMvuExtensionSettings = {
st_bme: {},
};
globalThis.__promptBuilderMvuContext = {
chat: [],
chatMetadata: {},
extensionSettings: {},
powerUserSettings: {},
characters: [],
characterId: null,
name1: "User",
name2: "Alice",
chatId: "mvu-test-chat",
};
try {
const extensionsApi = await import("../../../../extensions.js");
const { createDefaultTaskProfiles } = await import("../prompt-profiles.js");
const {
buildTaskExecutionDebugContext,
buildTaskLlmPayload,
buildTaskPrompt,
} = await import("../prompt-builder.js");
const llm = await import("../llm.js");
function createRule(id, findRegex, replaceString) {
return {
id,
script_name: id,
enabled: true,
find_regex: findRegex,
replace_string: replaceString,
source: {
user_input: true,
ai_output: true,
},
destination: {
prompt: true,
display: false,
},
};
}
function buildSettings() {
const taskProfiles = createDefaultTaskProfiles();
const recallProfile = taskProfiles.recall.profiles[0];
recallProfile.generation = {
...recallProfile.generation,
stream: false,
};
recallProfile.regex = {
enabled: true,
inheritStRegex: false,
sources: {
global: false,
preset: false,
character: false,
},
stages: {
"input.userMessage": true,
"input.recentMessages": true,
"input.candidateText": true,
"input.finalPrompt": true,
},
localRules: [
createRule("user-rule", "/BAD_USER/g", "GOOD_USER"),
createRule("recent-rule", "/BAD_RECENT/g", "GOOD_RECENT"),
createRule("candidate-rule", "/BAD_CANDIDATE/g", "GOOD_CANDIDATE"),
createRule("final-rule", "/FINAL_BAD/g", "FINAL_GOOD"),
],
};
recallProfile.blocks.push({
id: "mvu-final-custom",
name: "最终检查块",
type: "custom",
enabled: true,
role: "system",
sourceKey: "",
sourceField: "",
content: "FINAL_BAD",
injectionMode: "append",
order: recallProfile.blocks.length,
});
return {
llmApiUrl: "https://example.com/v1",
llmApiKey: "sk-mvu-secret",
llmModel: "gpt-mvu-test",
timeoutMs: 4321,
taskProfilesVersion: 3,
taskProfiles,
};
}
const settings = buildSettings();
extensionsApi.extension_settings.st_bme = settings;
delete globalThis.__stBmeRuntimeDebugState;
const promptBuild = await buildTaskPrompt(settings, "recall", {
taskName: "recall",
charDescription: "角色设定 <StatusPlaceHolderImpl/> BAD_RECENT",
userPersona: "变量更新规则:\ntype: state\n当前时间: 12:00",
recentMessages:
"最近消息 <status_current_variable>hp=3</status_current_variable> BAD_RECENT",
userMessage:
"用户输入 <updatevariable>secret</updatevariable> BAD_USER",
candidateNodes: "候选节点 BAD_CANDIDATE",
candidateText: "候选节点 BAD_CANDIDATE",
graphStats: "candidate_count=1",
});
assert.match(promptBuild.systemPrompt, /GOOD_RECENT/);
assert.match(JSON.stringify(promptBuild.executionMessages), /GOOD_USER/);
assert.match(JSON.stringify(promptBuild.executionMessages), /GOOD_CANDIDATE/);
assert.match(promptBuild.systemPrompt, /FINAL_GOOD/);
assert.doesNotMatch(
JSON.stringify(promptBuild),
/status_current_variable|updatevariable|StatusPlaceHolderImpl/i,
);
assert.equal(promptBuild.debug.mvu.sanitizedFieldCount >= 4, true);
assert.equal(promptBuild.debug.mvu.finalMessageStripCount >= 1, true);
assert.equal(Array.isArray(promptBuild.regexInput?.entries), true);
assert.equal(promptBuild.regexInput.entries.length > 0, true);
const systemOnlySettings = buildSettings();
systemOnlySettings.taskProfiles.recall = {
activeProfileId: "system-only",
profiles: [
{
id: "system-only",
name: "system only",
taskType: "recall",
builtin: false,
blocks: [
{
id: "only-system",
name: "Only System",
type: "custom",
enabled: true,
role: "system",
sourceKey: "",
sourceField: "",
content: "系统块",
injectionMode: "append",
order: 0,
},
],
generation: createDefaultTaskProfiles().recall.profiles[0].generation,
regex: {
enabled: false,
inheritStRegex: false,
stages: {},
localRules: [],
},
},
],
};
const systemOnlyPromptBuild = await buildTaskPrompt(systemOnlySettings, "recall", {
taskName: "recall",
});
const systemOnlyPayload = buildTaskLlmPayload(
systemOnlyPromptBuild,
"fallback <updatevariable>hidden</updatevariable> text",
);
assert.equal(systemOnlyPayload.userPrompt, "fallback text");
const capturedBodies = [];
globalThis.fetch = async (_url, options = {}) => {
capturedBodies.push(JSON.parse(String(options.body || "{}")));
return new Response(
JSON.stringify({
choices: [
{
message: {
content: '{"ok":true}',
},
finish_reason: "stop",
},
],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
);
};
const payload = buildTaskLlmPayload(promptBuild, "unused fallback");
const result = await llm.callLLMForJSON({
systemPrompt: payload.systemPrompt,
userPrompt: payload.userPrompt,
maxRetries: 0,
taskType: "recall",
promptMessages: payload.promptMessages,
additionalMessages: payload.additionalMessages,
debugContext: buildTaskExecutionDebugContext(promptBuild),
});
assert.deepEqual(result, { ok: true });
assert.equal(capturedBodies.length, 1);
assert.doesNotMatch(
JSON.stringify(capturedBodies[0].messages),
/status_current_variable|updatevariable|StatusPlaceHolderImpl/i,
);
const runtimePromptBuild =
globalThis.__stBmeRuntimeDebugState?.taskPromptBuilds?.recall || null;
const runtimeLlmRequest =
globalThis.__stBmeRuntimeDebugState?.taskLlmRequests?.recall || null;
assert.ok(runtimePromptBuild);
assert.ok(runtimeLlmRequest);
assert.doesNotMatch(
JSON.stringify(runtimePromptBuild.executionMessages),
/status_current_variable|updatevariable|StatusPlaceHolderImpl/i,
);
assert.doesNotMatch(
JSON.stringify(runtimeLlmRequest.messages),
/status_current_variable|updatevariable|StatusPlaceHolderImpl/i,
);
assert.doesNotMatch(
JSON.stringify(runtimeLlmRequest.requestBody?.messages || []),
/status_current_variable|updatevariable|StatusPlaceHolderImpl/i,
);
assert.deepEqual(
runtimeLlmRequest.messages,
runtimeLlmRequest.requestBody.messages,
);
assert.equal(
runtimeLlmRequest.promptExecution?.mvu?.sanitizedFieldCount,
promptBuild.debug.mvu.sanitizedFieldCount,
);
console.log("prompt-builder-mvu tests passed");
} finally {
if (originalRequire === undefined) {
delete globalThis.require;
} else {
globalThis.require = originalRequire;
}
if (originalExtensionSettings === undefined) {
delete globalThis.__promptBuilderMvuExtensionSettings;
} else {
globalThis.__promptBuilderMvuExtensionSettings = originalExtensionSettings;
}
if (originalContext === undefined) {
delete globalThis.__promptBuilderMvuContext;
} else {
globalThis.__promptBuilderMvuContext = originalContext;
}
if (originalSendOpenAIRequest === undefined) {
delete globalThis.__promptBuilderMvuSendOpenAIRequest;
} else {
globalThis.__promptBuilderMvuSendOpenAIRequest = originalSendOpenAIRequest;
}
globalThis.fetch = originalFetch;
}

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { registerHooks } from "node:module";
const extensionsShimSource = [
"export const extension_settings = {};",
"export function getContext(...args) {",
" return globalThis.SillyTavern?.getContext?.(...args) || null;",
"}",
@@ -147,6 +148,30 @@ const atDepthEntry = createWorldbookEntry({
order: 5,
});
const mvuTaggedEntry = createWorldbookEntry({
uid: 9,
name: "[mvu_update] 状态同步",
comment: "MVU tagged",
content: "这一条不应该进入结果。",
order: 28,
});
const mvuHeuristicEntry = createWorldbookEntry({
uid: 10,
name: "MVU 启发式条目",
comment: "MVU heuristic",
content: "<status_current_variable>secret=true</status_current_variable>",
order: 29,
});
const mvuLazyProbeEntry = createWorldbookEntry({
uid: 11,
name: "MVU 懒加载探测",
comment: "MVU 懒加载探测",
content: 'MVU lazy: <%= await getwi("bonus-book", "Bonus MVU") %>',
order: 27,
});
const bonusEntry = createWorldbookEntry({
uid: 101,
name: "Bonus 条目",
@@ -155,6 +180,14 @@ const bonusEntry = createWorldbookEntry({
order: 10,
});
const bonusMvuEntry = createWorldbookEntry({
uid: 102,
name: "Bonus MVU",
comment: "Bonus MVU",
content: "变量更新规则:\ntype: sync\n当前时间: 12:00",
order: 20,
});
const worldbooksByName = {
"main-book": [
constantEntry,
@@ -162,11 +195,14 @@ const worldbooksByName = {
inlineSummaryEntry,
extensionLiteralEntry,
externalInlineEntry,
mvuLazyProbeEntry,
forceControlEntry,
forcedAfterEntry,
atDepthEntry,
mvuTaggedEntry,
mvuHeuristicEntry,
],
"bonus-book": [bonusEntry],
"bonus-book": [bonusEntry, bonusMvuEntry],
};
try {
@@ -217,16 +253,18 @@ try {
assert.deepEqual(
worldInfo.beforeEntries.map((entry) => entry.name),
["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总"],
["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总", "MVU 懒加载探测"],
);
assert.deepEqual(worldInfo.afterEntries.map((entry) => entry.name), ["强制后置"]);
assert.equal(worldInfo.additionalMessages.length, 1);
assert.equal(worldInfo.additionalMessages[0].content, "这是一条 atDepth 消息。");
assert.match(worldInfo.beforeText, /控制摘要隐藏线索Alice 正在调查。/);
assert.match(worldInfo.beforeText, /外部补充:来自 bonus-book 的补充内容。/);
assert.match(worldInfo.beforeText, /MVU lazy:/);
assert.match(worldInfo.beforeText, /@@generate/);
assert.match(worldInfo.beforeText, /\[GENERATE:Test\]/);
assert.doesNotMatch(worldInfo.beforeText, /getwi|<%=?/);
assert.doesNotMatch(worldInfo.beforeText, /status_current_variable|变量更新规则|updatevariable/i);
assert.equal(worldInfo.debug.ejsInlinePullCount, 2);
assert.equal(worldInfo.debug.ejsForcedActivationCount, 1);
assert.equal(worldInfo.debug.resolvePassCount >= 2, true);
@@ -238,10 +276,23 @@ try {
["Bonus 条目", "线索条目"].sort(),
);
assert.deepEqual(worldInfo.debug.lazyLoadedWorldbooks, ["bonus-book"]);
assert.equal(worldInfo.debug.mvu.filteredEntryCount, 2);
assert.equal(worldInfo.debug.mvu.lazyFilteredEntryCount, 1);
assert.equal(worldInfo.debug.mvu.blockedContentsCount, 3);
assert.deepEqual(
worldInfo.debug.mvu.filteredEntries.map((entry) => entry.sourceName).sort(),
["[mvu_update] 状态同步", "MVU 启发式条目", "Bonus MVU"].sort(),
);
assert.equal(
worldInfo.debug.warnings.some((warning) => warning.includes("旧 EW 命名条目")),
true,
);
assert.equal(
worldInfo.debug.recursionWarnings.some((warning) =>
warning.includes("mvu filtered world info blocked"),
),
true,
);
const settings = {
taskProfiles: {
@@ -299,7 +350,9 @@ try {
assert.match(promptBuild.systemPrompt, /控制摘要隐藏线索Alice 正在调查/);
assert.match(promptBuild.systemPrompt, /扩展语义只是普通文本/);
assert.match(promptBuild.systemPrompt, /来自 bonus-book 的补充内容/);
assert.match(promptBuild.systemPrompt, /MVU lazy:/);
assert.doesNotMatch(promptBuild.systemPrompt, /getwi|<%=?/);
assert.doesNotMatch(promptBuild.systemPrompt, /status_current_variable|变量更新规则|updatevariable/i);
assert.equal(
promptBuild.privateTaskMessages.length,
2,
@@ -311,7 +364,7 @@ try {
);
assert.deepEqual(
promptBuild.hostInjections.before.map((entry) => entry.name),
["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总"],
["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总", "MVU 懒加载探测"],
);
assert.deepEqual(
promptBuild.hostInjections.after.map((entry) => entry.name),
@@ -327,6 +380,7 @@ try {
"EJS 汇总",
"扩展语义正文",
"外部书汇总",
"MVU 懒加载探测",
]);
assert.equal(promptBuild.hostInjectionPlan.after.length, 1);
assert.equal(promptBuild.hostInjectionPlan.after[0].blockId, "b2");
@@ -346,6 +400,7 @@ try {
);
assert.equal(promptBuild.additionalMessages.length, 1);
assert.equal(promptBuild.additionalMessages[0].content, "这是一条 atDepth 消息。");
assert.equal(promptBuild.debug.mvu.sanitizedFieldCount >= 0, true);
const { initializeHostAdapter } = await import("../host-adapter/index.js");
const partialBridgeCalls = [];