Files
writing-agent/tests/preset-conformance.test.ts
T
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

247 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 预设保真度(conformance):预设声明什么,发出去的 messages[] 就得是什么。
*
* 每个用例对应一条「预设是否真的实现了」的断言:
* 顺序 / 双过滤 / 未引用条目 / 角色 / 宏 / 历史多轮 / 深度插入 / 生成参数 / squash
*/
import { describe, expect, it } from "vitest";
import type {
ChatMessage,
CompleteResult,
LlmProvider,
} from "../src/llm/client.js";
import { wrapLlmForSession, resolveSquashForPreset } from "../src/llm/preset-wrapper.js";
import { importSillyTavernPreset, readSquashMode } from "../src/preset/importer.js";
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
type RawPreset = Record<string, unknown>;
/** 一份「把该有的都放上」的 ST 预设:顺序、双过滤、宏、深度插入、未引用条目。 */
function richPreset(extra: RawPreset = {}): RawPreset {
return {
name: "保真度样本",
temperature: 0.77,
top_p: 0.9,
openai_max_tokens: 777,
squash_system_messages: false,
prompts: [
{ identifier: "main", name: "主提示", role: "system", content: "MAIN {{char}}" },
{ identifier: "style", name: "文风", role: "system", content: "文风:克制" },
{
identifier: "personaDescription",
name: "用户角色",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "历史",
role: "system",
marker: true,
},
{
identifier: "deep",
name: "历史里的破限",
role: "system",
content: "DEEP",
injection_position: 1,
injection_depth: 1,
injection_order: 0,
},
{
identifier: "jailbreak",
name: "越狱",
role: "system",
content: "PHI {{user}} / {{persona}}",
},
{ identifier: "vars", name: "变量", role: "system", content: "{{setvar::hp::3}}" },
{
identifier: "readvar",
name: "读变量",
role: "system",
content: "HP={{getvar::hp}}",
},
{ identifier: "off-entry", name: "关掉的条目", role: "system", content: "OFF" },
{
identifier: "order-off",
name: "顺序里关掉",
role: "system",
content: "ORDER_OFF",
},
{ identifier: "orphan", name: "没被顺序引用", role: "system", content: "ORPHAN" },
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "style", enabled: true },
{ identifier: "personaDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "deep", enabled: true },
{ identifier: "jailbreak", enabled: true },
{ identifier: "vars", enabled: true },
{ identifier: "readvar", enabled: true },
{ identifier: "off-entry", enabled: true },
{ identifier: "order-off", enabled: false },
// orphan 不在这里 → 必须不出现
],
},
],
...extra,
};
}
const pack: WorldInfoPack = {
worldBookBefore: [{ id: "lore", name: "", content: "舞台:古镇夜雨" }],
history: [
{ role: "assistant", content: "门开了。" },
{ role: "user", content: "我推门。" },
],
worldBookAfter: [{ id: "state", name: "", content: "HP 3" }],
turn: "我抬头",
postTurn: [],
};
function build(raw: RawPreset, opts: { charName?: string } = {}): ChatMessage[] {
const report = importSillyTavernPreset(raw);
return assemblePlayWorkerMessages({
systemPrompt: "",
preset: report.preset,
pack,
persona: { name: "林晚", description: "二十岁,缄默" },
charName: opts.charName ?? "长夜行",
});
}
describe("预设保真度:结构", () => {
it("按 prompt_order 顺序、按条目 role 发消息(squash=none 时原样)", () => {
const messages = build(richPreset());
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "MAIN 长夜行"],
["system", "文风:克制"],
["system", "二十岁,缄默"],
["system", "舞台:古镇夜雨"],
["assistant", "门开了。"],
["system", "DEEP"], // depth=1 → 插在历史倒数第一条之前
["user", "我推门。"],
["system", "HP 3"],
["user", "我抬头"],
["system", "PHI 林晚 / 二十岁,缄默"],
["system", "HP=3"],
["system", "OFF"],
]);
});
it("order.enabled=false 才不发;prompt.enabled=false 照发(跟酒馆一致)", () => {
const raw = richPreset();
const prompts = raw.prompts as Array<Record<string, unknown>>;
prompts.find((p) => p.identifier === "off-entry")!.enabled = false;
const text = build(raw)
.map((m) => m.content)
.join("\n");
expect(text).toContain("OFF");
expect(text).not.toContain("ORDER_OFF");
});
it("prompt_order 没引用的条目一律不发", () => {
const text = build(richPreset())
.map((m) => m.content)
.join("\n");
expect(text).not.toContain("ORPHAN");
});
it("宏在装配期展开,跨条目共享一份变量表", () => {
const text = build(richPreset())
.map((m) => m.content)
.join("\n");
expect(text).toContain("MAIN 长夜行");
expect(text).toContain("PHI 林晚 / 二十岁,缄默");
// setvar 在 vars 条目、getvar 在后面的 readvar 条目
expect(text).toContain("HP=3");
});
it("历史洞展开成真多轮,不是一段文本", () => {
const messages = build(richPreset());
// 两轮各自一条 message,且没有哪一条把两轮揉在一起
expect(messages.some((m) => m.role === "assistant" && m.content === "门开了。"))
.toBe(true);
expect(messages.some((m) => m.role === "user" && m.content === "我推门。"))
.toBe(true);
expect(messages.some((m) => m.content.includes("门开了") && m.content.includes("我推门")))
.toBe(false);
// 变成一条 system 文本也不算:历史里的轮次必须保留 user / assistant
const assistantTurns = messages.filter((m) => m.role === "assistant");
expect(assistantTurns.length).toBeGreaterThan(0);
});
it("injection_position=1 按 depth 从历史底部插", () => {
const messages = build(richPreset());
const index = messages.findIndex((m) => m.content === "DEEP");
const histStart = messages.findIndex((m) => m.content === "门开了。");
const histEnd = messages.findIndex((m) => m.content === "我推门。");
expect(index).toBeGreaterThan(histStart);
expect(index).toBeLessThan(histEnd);
});
it("depth=0 贴在历史末尾(本轮输入之前)", () => {
const raw = richPreset();
const prompts = raw.prompts as Array<Record<string, unknown>>;
prompts.find((p) => p.identifier === "deep")!.injection_depth = 0;
const messages = build(raw);
const order = messages.map((m) => m.content);
expect(order.indexOf("DEEP")).toBe(order.indexOf("我推门。") + 1);
expect(order.indexOf("我抬头")).toBeGreaterThan(order.indexOf("DEEP"));
});
});
describe("预设保真度:squash 与生成参数", () => {
it("读 squash_system_messages:bool / 枚举 / 缺失", () => {
expect(readSquashMode(true)).toBe("system");
expect(readSquashMode(false)).toBe("none");
expect(readSquashMode("strict")).toBe("strict");
expect(readSquashMode(2)).toBe("semi");
expect(readSquashMode(undefined)).toBeUndefined();
expect(readSquashMode("weird")).toBeUndefined();
});
it("预设写了 squash 就跟预设;没写落回缺省 strict", () => {
const withNone = importSillyTavernPreset(richPreset()).preset;
expect(withNone.messagePolicy?.squash).toBe("none");
expect(resolveSquashForPreset(withNone, "follow-preset")).toBe("none");
const withTrue = importSillyTavernPreset(
richPreset({ squash_system_messages: true }),
).preset;
expect(resolveSquashForPreset(withTrue, "follow-preset")).toBe("system");
const rawDefault = richPreset();
delete rawDefault.squash_system_messages;
const byDefault = importSillyTavernPreset(rawDefault).preset;
expect(byDefault.messagePolicy).toBeUndefined();
expect(resolveSquashForPreset(byDefault, "follow-preset")).toBe("strict");
// 设置页显式选的优先于预设
expect(resolveSquashForPreset(withTrue, "semi")).toBe("semi");
});
it("生成参数从预设进请求", async () => {
const seen: Array<Record<string, unknown>> = [];
const provider: LlmProvider = {
async complete(_messages, options): Promise<CompleteResult> {
seen.push((options?.generation ?? {}) as Record<string, unknown>);
return { content: "ok", model: "m" };
},
async completeWithTools() {
return { content: "ok", toolCalls: [], model: "m" };
},
};
const preset = importSillyTavernPreset(richPreset()).preset;
await wrapLlmForSession(provider, undefined, () => preset).complete([
{ role: "user", content: "TASK" },
]);
expect(seen[0]).toMatchObject({ temperature: 0.77, topP: 0.9, maxOutputTokens: 777 });
});
});