创作一轮改为先选题再填写,减少工具空转和重复覆盖。
上下文按规则、历史、产物分段拼装,便于吃提示缓存并核对费用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1230,8 +1230,12 @@ modules:
|
||||
expect(sections.blocks.task).toContain("默认只生成 1 条");
|
||||
expect(sections.blocks.task).toContain("@玩家");
|
||||
expect(sections.blocks.task).toContain("随机");
|
||||
expect(sections.blocks.output).toContain('"默认候选id"');
|
||||
expect(sections.blocks.output).toContain('"开场白候选"');
|
||||
expect(module?.artifact).toBe("设计.开场白");
|
||||
expect(sections.blocks.task).toContain("设计.开场白#opening-cycle2");
|
||||
expect(sections.blocks.task).not.toContain("写入 `设计.开场白与开场变量`");
|
||||
expect(sections.blocks.output).toContain("设计.开场白#场景短码");
|
||||
expect(sections.blocks.output).toContain('"开场变量"');
|
||||
expect(sections.blocks.output).not.toContain('"开场白候选"');
|
||||
expect(sections.blocks.output).not.toContain('"开场白全文"');
|
||||
expect(sections.blocks.output).toContain("present.v1");
|
||||
expect(sections.blocks.output).not.toContain('"自评"');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CREATION_PLAN_TAG,
|
||||
buildDictateSystemPrompt,
|
||||
formatDictateRoundTail,
|
||||
creationPlanModules,
|
||||
formatCreationPlanCapabilityCatalog,
|
||||
formatCreationPlanProgress,
|
||||
@@ -9,7 +10,9 @@ import {
|
||||
validateCreationPlan,
|
||||
type CreationPlan,
|
||||
} from "../src/dictate/index.js";
|
||||
import { moduleBrowseGroup } from "../src/dictate/creation-plan.js";
|
||||
import {
|
||||
catalogModulesForIntake,
|
||||
formatModuleCatalogForDictate,
|
||||
loadModuleCatalog,
|
||||
} from "../src/skills/creation-flow.js";
|
||||
@@ -65,6 +68,32 @@ describe("creation plan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("groups dictate nodes by runtime role and omits the recipe-only merge", async () => {
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
expect(catalog).toBeTruthy();
|
||||
const dictate = catalogModulesForIntake(catalog!, "dictate");
|
||||
const ids = dictate.map((module) => module.id);
|
||||
expect(ids).not.toContain("narrative");
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "creation-plan")!)).toBe(
|
||||
"plan",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "narrative-guide")!)).toBe(
|
||||
"content",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "reply-format")!)).toBe(
|
||||
"present",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "zero-layer-status")!)).toBe(
|
||||
"present",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "refine")!)).toBe(
|
||||
"program",
|
||||
);
|
||||
expect(new Set(dictate.map((module) => moduleBrowseGroup(module)))).toEqual(
|
||||
new Set(["plan", "content", "present", "program"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects exactly one presentation chain", async () => {
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
expect(catalog).toBeTruthy();
|
||||
@@ -160,17 +189,19 @@ describe("creation plan", () => {
|
||||
expect(unusedSection).not.toContain("何时用:");
|
||||
|
||||
const oldFullGuide = formatModuleCatalogForDictate(catalog);
|
||||
const before = buildDictateSystemPrompt({
|
||||
const fixed = buildDictateSystemPrompt();
|
||||
const before = formatDictateRoundTail({
|
||||
capabilityCatalog: shortCatalog,
|
||||
recipeExamples: "- text-generator:文本生成案例",
|
||||
});
|
||||
const after = buildDictateSystemPrompt({ creationPlanState: progress });
|
||||
const after = formatDictateRoundTail({ creationPlanState: progress });
|
||||
expect(fixed).not.toContain("当前决策");
|
||||
expect(before).toContain("尚无本局创作方案");
|
||||
expect(after).toContain("当前决策");
|
||||
expect(after).toContain("收口前的核对清单");
|
||||
expect(after).not.toContain("旧配方案例目录");
|
||||
expect(after.length).toBeLessThan(
|
||||
buildDictateSystemPrompt({ moduleGuide: oldFullGuide }).length,
|
||||
formatDictateRoundTail({ moduleGuide: oldFullGuide }).length,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
applyDictatePlayBind,
|
||||
buildDictateContextOrder,
|
||||
bindDictateProductsToPlaySpec,
|
||||
collectDictateBindProducts,
|
||||
DICTATE_PLAY_BIND_INPUT_META,
|
||||
isDictateStyleBindTag,
|
||||
} from "../src/dictate/play-bind.js";
|
||||
import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js";
|
||||
@@ -215,4 +217,51 @@ describe("dictate play-bind", () => {
|
||||
expect(refs).toContain("上下文.角色态度");
|
||||
expect(refs).not.toContain("设计.变量映射");
|
||||
});
|
||||
|
||||
it("skips rebind on save when mounted products are unchanged", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({ tag: "用户.需求", content: "雨夜", source: "test" });
|
||||
const first = applyDictatePlayBind(board);
|
||||
expect(first.skipped).toBe(false);
|
||||
const orderUpdated = board.getLatestByTag("设计.上下文投影排序")?.updatedAt;
|
||||
const workerUpdated = board.getLatestByTag("设计.worker集")?.updatedAt;
|
||||
expect(board.getLatestByTag("设计.上下文投影排序")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META]).toEqual(
|
||||
board.getLatestByTag("设计.worker集")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META],
|
||||
);
|
||||
|
||||
const second = applyDictatePlayBind(board);
|
||||
expect(second.skipped).toBe(true);
|
||||
expect(second.changed).toBe(false);
|
||||
expect(board.getLatestByTag("设计.上下文投影排序")?.updatedAt).toBe(orderUpdated);
|
||||
expect(board.getLatestByTag("设计.worker集")?.updatedAt).toBe(workerUpdated);
|
||||
});
|
||||
|
||||
it("keeps a hand-edited order until a mounted product changes", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({ tag: "用户.需求", content: "雨夜", source: "test" });
|
||||
applyDictatePlayBind(board);
|
||||
|
||||
const orderItem = board.getLatestByTag("设计.上下文投影排序")!;
|
||||
const doc = JSON.parse(orderItem.content) as { brief?: string };
|
||||
doc.brief = "手改排序";
|
||||
const handEdited = JSON.stringify(doc, null, 2);
|
||||
board.write({
|
||||
tag: "设计.上下文投影排序",
|
||||
content: handEdited,
|
||||
source: "user",
|
||||
metadata: orderItem.metadata,
|
||||
});
|
||||
const kept = applyDictatePlayBind(board);
|
||||
expect(kept.skipped).toBe(true);
|
||||
expect(board.getContentByTag("设计.上下文投影排序")).toBe(handEdited);
|
||||
|
||||
board.write({ tag: "设计.叙事指南", content: "短句", source: "test" });
|
||||
const rebound = applyDictatePlayBind(board);
|
||||
expect(rebound.skipped).toBe(false);
|
||||
expect(rebound.boundTags).toContain("设计.叙事指南");
|
||||
const rewritten = JSON.parse(
|
||||
board.getContentByTag("设计.上下文投影排序") ?? "{}",
|
||||
) as { brief?: string };
|
||||
expect(rewritten.brief).not.toBe("手改排序");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 5 },
|
||||
{ name: "美学纲领", score: 5 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -82,7 +83,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 5 },
|
||||
{ name: "美学纲领", score: 5 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 3, gap: "规则因果仍需确认" },
|
||||
],
|
||||
},
|
||||
@@ -205,7 +207,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 3 },
|
||||
{ name: "美学纲领", score: 3 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -226,7 +229,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 3, gap: "发生方式仍偏宽" },
|
||||
{ name: "美学纲领", score: 3, gap: "发生方式仍偏宽" },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -254,13 +258,114 @@ describe("dictate read_module", () => {
|
||||
layer: "final",
|
||||
selfScore: {
|
||||
dims: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "核心体验", score: 3 }),
|
||||
expect.objectContaining({ name: "美学纲领", score: 3 }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening uses the reply contract", () => {
|
||||
const contract = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
数据契约: {
|
||||
blocks: {
|
||||
body: { 类型: "markdown", 来源: "model_generated" },
|
||||
forum_feed: { 类型: "list", 来源: "model_generated" },
|
||||
},
|
||||
},
|
||||
frontend: { html: `<div id="app"></div>`, css: "", js: "" },
|
||||
},
|
||||
});
|
||||
const scores = {
|
||||
dims: [
|
||||
{ name: "开场必要", score: 5 },
|
||||
{ name: "前端符合", score: 5 },
|
||||
{ name: "状态自洽", score: 5 },
|
||||
{ name: "角色占位", score: 5 },
|
||||
{ name: "收口可切换", score: 5 },
|
||||
],
|
||||
};
|
||||
|
||||
it("returns the live contract when the opening module is read", async () => {
|
||||
const { catalog, moduleReadIndex } = await setup();
|
||||
const tags = new Map<string, string>([["设计.正文组成", contract]]);
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "r1",
|
||||
name: "read_module",
|
||||
arguments: JSON.stringify({ module_id: "opening-setup" }),
|
||||
},
|
||||
]);
|
||||
if (outcome.kind === "continue") {
|
||||
const payload = JSON.parse(outcome.results[0]!.content);
|
||||
expect(payload.reply_contract.model_blocks.map((block: { id: string }) => block.id)).toEqual([
|
||||
"body",
|
||||
"forum_feed",
|
||||
]);
|
||||
}
|
||||
return { iterations: 1, stop: { kind: "text", content: "已读" } };
|
||||
},
|
||||
};
|
||||
await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex,
|
||||
driver,
|
||||
handlers: handlers(tags),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an opening that only fills the old body shell", async () => {
|
||||
const { catalog, moduleReadIndex } = await setup();
|
||||
const tags = new Map<string, string>([["设计.正文组成", contract]]);
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
await input.handleStep([
|
||||
{
|
||||
id: "r1",
|
||||
name: "read_module",
|
||||
arguments: JSON.stringify({ module_id: "opening-setup" }),
|
||||
},
|
||||
]);
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "i1",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.开场白",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
shell: "prose",
|
||||
blocks: { body: "@玩家推开门。" },
|
||||
}),
|
||||
self_score: scores,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
if (outcome.kind === "continue") {
|
||||
const payload = JSON.parse(outcome.results[0]!.content);
|
||||
expect(payload.error).toContain("forum_feed");
|
||||
}
|
||||
return { iterations: 1, stop: { kind: "text", content: "未写入" } };
|
||||
},
|
||||
};
|
||||
const result = await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex,
|
||||
driver,
|
||||
handlers: handlers(tags),
|
||||
});
|
||||
expect(result.wroteTags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function handlers(tags: Map<string, string>) {
|
||||
return {
|
||||
listProducts: () =>
|
||||
|
||||
@@ -0,0 +1,869 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DICTATE_MODULE_READ_STATE_TAG,
|
||||
buildDictateContext,
|
||||
parseDictateModuleReadState,
|
||||
runDictateTurn,
|
||||
type DictateModuleReadPayload,
|
||||
} from "../src/dictate/index.js";
|
||||
import type { LlmProvider } from "../src/llm/client.js";
|
||||
import type { ModuleCatalog } from "../src/skills/creation-flow.js";
|
||||
|
||||
function scriptedLlm(steps: unknown[]): LlmProvider {
|
||||
let index = 0;
|
||||
return {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools() {
|
||||
const body = steps[index++] ?? {};
|
||||
const select = index === 1;
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, select),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toToolCalls(body: unknown, select: boolean) {
|
||||
const row = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
if (select) {
|
||||
return [{ id: "select", name: "select_nodes", arguments: JSON.stringify(row) }];
|
||||
}
|
||||
const calls: Array<{ id: string; name: string; arguments: string }> = [];
|
||||
const products = row.products;
|
||||
if (Array.isArray(products)) {
|
||||
products.forEach((item, index) => {
|
||||
const rec = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||
if ("tag" in rec || "position" in rec || "content" in rec || "正文" in rec) {
|
||||
calls.push({
|
||||
id: `ins-${index}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: rec.tag ?? rec.position,
|
||||
content: rec.content ?? rec.正文 ?? "",
|
||||
...(rec.self_score !== undefined ? { self_score: rec.self_score } : {}),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Object.entries(rec).forEach(([key, value], piece) => {
|
||||
const content =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value && typeof value === "object"
|
||||
? String((value as Record<string, unknown>).内容 ?? (value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).content ?? "")
|
||||
: "";
|
||||
calls.push({
|
||||
id: `ins-${index}-${piece}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: key, content }),
|
||||
});
|
||||
});
|
||||
});
|
||||
} else if (products && typeof products === "object") {
|
||||
Object.entries(products as Record<string, unknown>).forEach(([key, value], index) => {
|
||||
const content =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value && typeof value === "object"
|
||||
? String((value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).content ?? "")
|
||||
: "";
|
||||
calls.push({
|
||||
id: `ins-${index}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: key.startsWith("设计.") || key.startsWith("用户.") ? key : `设计.${key}`, content }),
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (key === "products" || key === "reply" || key === "operations") continue;
|
||||
if (typeof value === "string" && (key.startsWith("设计.") || key.startsWith("用户."))) {
|
||||
calls.push({
|
||||
id: `extra-${key}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: key, content: value }),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(row.operations)) {
|
||||
row.operations.forEach((item, index) => {
|
||||
const rec = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||
const nested =
|
||||
rec.arguments && typeof rec.arguments === "object"
|
||||
? (rec.arguments as Record<string, unknown>)
|
||||
: rec;
|
||||
const content = String(nested.content ?? "");
|
||||
if (!content.trim()) return;
|
||||
calls.push({
|
||||
id: `op-${index}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: nested.position ?? nested.tag, content }),
|
||||
});
|
||||
});
|
||||
}
|
||||
if (typeof row.reply === "string") {
|
||||
calls.push({
|
||||
id: "reply",
|
||||
name: "reply_user",
|
||||
arguments: JSON.stringify({ text: row.reply }),
|
||||
});
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
modules: [
|
||||
{
|
||||
id: "mood",
|
||||
name: "氛围",
|
||||
artifact: "设计.氛围",
|
||||
declaration: "细雨",
|
||||
},
|
||||
],
|
||||
} as ModuleCatalog;
|
||||
|
||||
const modulePayload: DictateModuleReadPayload = {
|
||||
module_id: "mood",
|
||||
name: "氛围",
|
||||
artifact: "设计.氛围",
|
||||
version: "v-test",
|
||||
task: "写氛围",
|
||||
principles: "短句",
|
||||
output: "一段话",
|
||||
score: "",
|
||||
score_dimensions: [],
|
||||
libraries: [],
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("dictate planned round", () => {
|
||||
it("puts earlier dialogue before products and the last user turn at the end", () => {
|
||||
const built = buildDictateContext({
|
||||
systemPrompt: "规则",
|
||||
products: [{ tag: "设计.氛围", content: "旧稿", order: 0 }],
|
||||
dialogue: [
|
||||
{ role: "user", text: "第一句" },
|
||||
{ role: "assistant", text: "记下了" },
|
||||
{ role: "user", text: "改成细雨" },
|
||||
],
|
||||
phase: "fill",
|
||||
injected: "#### task\n写氛围",
|
||||
tail: "方案进度",
|
||||
});
|
||||
const body = built.messages[1]?.content ?? "";
|
||||
expect(body.indexOf("必须调用 reply_user")).toBeLessThan(body.indexOf("第一句"));
|
||||
expect(body.indexOf("第一句")).toBeLessThan(body.indexOf("设计.氛围"));
|
||||
expect(body.indexOf("设计.氛围")).toBeLessThan(body.indexOf("写氛围"));
|
||||
expect(body.indexOf("写氛围")).toBeLessThan(body.indexOf("改成细雨"));
|
||||
expect(body.indexOf("方案进度")).toBeLessThan(body.indexOf("改成细雨"));
|
||||
expect(body.indexOf("写完下列节点后")).toBeLessThan(body.indexOf("写氛围"));
|
||||
expect(built.segmentChars.products).toBeGreaterThan(0);
|
||||
expect(built.segmentChars.rules).toBe("规则".length);
|
||||
|
||||
const select = buildDictateContext({
|
||||
systemPrompt: "规则",
|
||||
products: [{ tag: "设计.氛围", content: "旧稿" }],
|
||||
dialogue: [
|
||||
{ role: "user", text: "第一句" },
|
||||
{ role: "user", text: "改成细雨" },
|
||||
],
|
||||
phase: "select",
|
||||
tail: "方案进度",
|
||||
});
|
||||
const selectBody = select.messages[1]?.content ?? "";
|
||||
expect(selectBody).not.toContain("旧稿");
|
||||
expect(selectBody).toContain("第一句");
|
||||
expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨"));
|
||||
});
|
||||
|
||||
it("injects the spec, stamps the read credential, and rewrites only the missing tag", async () => {
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([
|
||||
{
|
||||
clarify: [],
|
||||
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
||||
deletes: [],
|
||||
},
|
||||
{ products: [], reply: "还没写" },
|
||||
{
|
||||
products: [{ tag: "设计.氛围", content: "细雨" }],
|
||||
reply: "已写成细雨",
|
||||
},
|
||||
]),
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", modulePayload],
|
||||
["氛围", modulePayload],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
const existing = products.find((item) => item.tag === tag);
|
||||
if (existing) existing.content = content;
|
||||
else products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||
expect(products).toEqual([{ tag: "设计.氛围", content: "细雨" }]);
|
||||
expect(result.reply).toContain("已写成细雨");
|
||||
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
|
||||
expect(state.modules.mood).toBe("v-test");
|
||||
});
|
||||
|
||||
it("stops after the select step when the plan is a clarification", async () => {
|
||||
let writes = 0;
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([
|
||||
{
|
||||
clarify: ["沿用冷色", "改成暖色"],
|
||||
inserts: [],
|
||||
deletes: [],
|
||||
},
|
||||
]),
|
||||
dialogue: [{ role: "user", text: "氛围还没定" }],
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => {
|
||||
writes += 1;
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(writes).toBe(0);
|
||||
expect(result.iterations).toBe(1);
|
||||
expect(result.reply).toContain("沿用冷色");
|
||||
expect(result.reply).toContain("改成暖色");
|
||||
});
|
||||
|
||||
it("says the returned fields when the select plan writes nothing", async () => {
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([{ note: "看世界" }]),
|
||||
dialogue: [{ role: "user", text: "超级英雄" }],
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => undefined,
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(result.wroteTags).toEqual([]);
|
||||
expect(result.iterations).toBe(1);
|
||||
expect(result.reply).toContain("note");
|
||||
expect(result.reply).toContain("没有写入产物");
|
||||
});
|
||||
|
||||
it("follows a select result with a fill request that carries specs and products", async () => {
|
||||
const seen: string[] = [];
|
||||
const steps = [
|
||||
{
|
||||
schema: "creation-plan.v1",
|
||||
items: [{ module_id: "mood", tier: "必须", why: "细雨", missing: "", instances: [] }],
|
||||
},
|
||||
{
|
||||
products: [{ tag: "设计.本局创作方案", content: "{\"schema\":\"creation-plan.v1\"}" }],
|
||||
reply: "已写方案",
|
||||
},
|
||||
];
|
||||
let index = 0;
|
||||
const llm: LlmProvider = {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body = steps[index++] ?? {};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, index === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
const products = [{ tag: "设计.舞台", content: "已有舞台" }];
|
||||
await runDictateTurn({
|
||||
llm,
|
||||
dialogue: [{ role: "user", text: "超级英雄的世界" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([
|
||||
[
|
||||
"creation-plan",
|
||||
{
|
||||
...modulePayload,
|
||||
module_id: "creation-plan",
|
||||
artifact: "设计.本局创作方案",
|
||||
task: "规格任务正文",
|
||||
principles: "规格原则正文",
|
||||
},
|
||||
],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
const existing = products.find((item) => item.tag === tag);
|
||||
if (existing) existing.content = content;
|
||||
else products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||
expect(seen[0]).not.toContain("已有舞台");
|
||||
expect(seen[0]).not.toContain("规格任务正文");
|
||||
expect(seen[1]).toContain("已有舞台");
|
||||
expect(seen[1]).toContain("规格任务正文");
|
||||
expect(seen[1]).toContain("规格原则正文");
|
||||
expect(seen[1]).toContain("#### output");
|
||||
expect(seen[1]).toContain("设计.本局创作方案");
|
||||
});
|
||||
|
||||
it("treats operations as the selection, injects the node table, and writes every returned item", async () => {
|
||||
const seen: string[] = [];
|
||||
const steps = [
|
||||
{
|
||||
operations: [
|
||||
{ name: "insert", arguments: { position: "设计.氛围", module_id: "mood" } },
|
||||
],
|
||||
reply: "先写氛围",
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{ position: "设计.氛围", content: "细雨" },
|
||||
{ tag: "设计.旁白", content: "旁白一句" },
|
||||
],
|
||||
reply: "写了两份",
|
||||
},
|
||||
];
|
||||
let index = 0;
|
||||
const llm: LlmProvider = {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body = steps[index++] ?? {};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, index === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [
|
||||
{ tag: "设计.舞台", content: "已有舞台" },
|
||||
];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm,
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
const existing = products.find((item) => item.tag === tag);
|
||||
if (existing) existing.content = content;
|
||||
else products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||
expect(seen[0]).not.toContain("已有舞台");
|
||||
expect(seen[0]).not.toContain("写氛围");
|
||||
expect(seen[1]).toContain("本轮节点表");
|
||||
expect(seen[1]).toContain("写氛围");
|
||||
expect(seen[1]).toContain("已有舞台");
|
||||
expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"]));
|
||||
expect(result.reply).toContain("写了两份");
|
||||
});
|
||||
|
||||
it("persists products named by module or keyed outside the products array", async () => {
|
||||
const resultProducts: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([
|
||||
{
|
||||
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
||||
deletes: [],
|
||||
},
|
||||
{
|
||||
reply: "写了",
|
||||
products: { 氛围: { 正文: "细雨连绵" } },
|
||||
"设计.旁白": "旁白一句",
|
||||
},
|
||||
]),
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => resultProducts,
|
||||
writeProduct: (tag, content) => {
|
||||
resultProducts.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(resultProducts.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]);
|
||||
expect(resultProducts.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵");
|
||||
expect(result.reply).toContain("写了");
|
||||
expect(result.reply).not.toContain("未给出");
|
||||
});
|
||||
|
||||
it("selects node ids first, then writes them with the user text and the node specs", async () => {
|
||||
const seen: string[] = [];
|
||||
const steps = [
|
||||
{ nodes: [{ module_id: "mood" }] },
|
||||
{
|
||||
products: [{ tag: "设计.氛围", content: "细雨" }],
|
||||
reply: "已写氛围。可能还缺主角设定。",
|
||||
},
|
||||
];
|
||||
let index = 0;
|
||||
const llm: LlmProvider = {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body = steps[index++] ?? {};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, index === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm,
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen[0]).toContain("只做选题");
|
||||
expect(seen[0]).toContain("写成细雨");
|
||||
expect(seen[0]).not.toContain("declare_variable");
|
||||
expect(seen[1]).toContain("写成细雨");
|
||||
expect(seen[1]).toContain("写氛围");
|
||||
expect(seen[1]).toContain("第一轮选定的节点:mood");
|
||||
expect(seen[1]).toContain("还可能缺哪些节点");
|
||||
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||
expect(result.reply).toContain("可能还缺主角设定");
|
||||
});
|
||||
|
||||
it("maps a user-prefixed tag onto the catalog artifact and reads Chinese content fields", async () => {
|
||||
const narration: DictateModuleReadPayload = {
|
||||
...modulePayload,
|
||||
module_id: "narration",
|
||||
name: "旁白",
|
||||
artifact: "设计.旁白",
|
||||
task: "写旁白",
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const result = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const step = seen.length;
|
||||
const body =
|
||||
step === 1
|
||||
? { nodes: [{ tag: "用户.氛围" }, { module_id: "narration" }] }
|
||||
: {
|
||||
products: [{ "用户.氛围": { 内容: "细雨连绵" }, "设计.旁白": "旁白一句" }],
|
||||
reply: "写了两份",
|
||||
};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, step === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: {
|
||||
modules: [
|
||||
...catalog.modules,
|
||||
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
|
||||
],
|
||||
} as ModuleCatalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", modulePayload],
|
||||
["narration", narration],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen[1]).toContain("产物 tag:设计.氛围");
|
||||
expect(seen[1]).toContain("产物 tag:设计.旁白");
|
||||
expect(products.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]);
|
||||
expect(products.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵");
|
||||
expect(result.reply).not.toContain("未给出");
|
||||
});
|
||||
|
||||
it("asks for self_score dimensions before a write can land", async () => {
|
||||
const scored: DictateModuleReadPayload = {
|
||||
...modulePayload,
|
||||
score_dimensions: ["短句"],
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const result = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body =
|
||||
seen.length === 1
|
||||
? { nodes: [{ module_id: "mood" }] }
|
||||
: { products: [{ tag: "设计.氛围", content: "细雨" }], reply: "已落盘" };
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, seen.length === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", scored]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句");
|
||||
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
||||
expect(seen[2]).toContain("还有未通过");
|
||||
expect(products).toHaveLength(0);
|
||||
expect(result.reply).toContain("self_score 必填");
|
||||
expect(result.reply).toContain("短句");
|
||||
expect(result.reply).not.toContain("未给出");
|
||||
});
|
||||
|
||||
it("keeps the node table and refuses to rewrite a tag that already passed", async () => {
|
||||
const narration: DictateModuleReadPayload = {
|
||||
...modulePayload,
|
||||
module_id: "narration",
|
||||
name: "旁白",
|
||||
artifact: "设计.旁白",
|
||||
task: "写旁白",
|
||||
score_dimensions: ["一句"],
|
||||
};
|
||||
const writes: string[] = [];
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const rounds = [
|
||||
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }, { module_id: "narration" }] }) }],
|
||||
[
|
||||
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
||||
{ id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) },
|
||||
{ id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "都写了" }) },
|
||||
],
|
||||
[
|
||||
{ id: "a2", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "改掉细雨" }) },
|
||||
{
|
||||
id: "b2",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.旁白",
|
||||
content: "旁白",
|
||||
self_score: { dims: [{ name: "一句", score: 4 }] },
|
||||
}),
|
||||
},
|
||||
],
|
||||
[{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "旁白补上了" }) }],
|
||||
];
|
||||
let index = 0;
|
||||
await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: rounds[index++] ?? [],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: {
|
||||
modules: [
|
||||
...catalog.modules,
|
||||
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
|
||||
],
|
||||
} as ModuleCatalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", modulePayload],
|
||||
["narration", narration],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: (tag, content) => {
|
||||
writes.push(`${tag}:${content}`);
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
||||
expect(seen[2]).toContain("写旁白");
|
||||
expect(writes).toEqual(["设计.氛围:细雨", "设计.旁白:旁白"]);
|
||||
});
|
||||
|
||||
it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => {
|
||||
const seen: string[] = [];
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const rounds = [
|
||||
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
|
||||
[
|
||||
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
||||
{ id: "r", name: "reply_user", arguments: JSON.stringify({ text: "细雨留下了" }) },
|
||||
],
|
||||
];
|
||||
let index = 0;
|
||||
const tags = new Map<string, string>();
|
||||
await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: rounds[index++] ?? [],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen).toHaveLength(2);
|
||||
const fill = seen[1] ?? "";
|
||||
expect(fill.indexOf("填写收尾")).toBeLessThan(fill.indexOf("写入规则"));
|
||||
expect(fill.indexOf("本步收尾")).toBeLessThan(fill.indexOf("更早的对话"));
|
||||
expect(fill.indexOf("同一次请求必须调用 reply_user")).toBeLessThan(fill.indexOf("写氛围"));
|
||||
expect(fill).toContain("分数一般或还有更好写法的缺口");
|
||||
expect(fill).toContain("下一步可以提及哪些");
|
||||
expect(fill).not.toContain("本轮刚落盘");
|
||||
expect(fill).not.toContain("你只生成给用户看的综合回复");
|
||||
});
|
||||
|
||||
it("silently asks for reply_user with only this round's products when fill omitted it", async () => {
|
||||
const products: Array<{ tag: string; content: string }> = [
|
||||
{ tag: "设计.舞台", content: "旧舞台不应进入回复请求" },
|
||||
];
|
||||
const seen: string[] = [];
|
||||
const choices: Array<unknown> = [];
|
||||
let index = 0;
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages, options) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
choices.push(options?.toolChoice);
|
||||
const step = index++;
|
||||
if (step === 0) {
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "select",
|
||||
name: "select_nodes",
|
||||
arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
return {
|
||||
content: "细雨留下了,接下来可以定主角怎么进城。",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "a",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.氛围",
|
||||
content: "细雨",
|
||||
self_score: {
|
||||
dims: [{ name: "短句", score: 3, gap: "还缺进城方式", options: ["步行", "马车"] }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "r",
|
||||
name: "reply_user",
|
||||
arguments: JSON.stringify({ text: "细雨已落盘,接下来可以定进城方式。" }),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", { ...modulePayload, score_dimensions: ["短句"] }],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content, _order, metadata) => {
|
||||
products.push({
|
||||
tag,
|
||||
content,
|
||||
...(metadata?.selfScore ? { selfScore: metadata.selfScore } : {}),
|
||||
});
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
lookupInsertFeedback: (tag) =>
|
||||
tag === "设计.氛围"
|
||||
? `# 本轮关注 · 氛围\n\n## 写入用户回复时做什么\n1. 自然回应\n3. 结合 self_score 说明最强处与关键缺口\n\n## 自评关注\n- 短句`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.reply).toBe("细雨已落盘,接下来可以定进城方式。");
|
||||
expect(result.iterations).toBe(3);
|
||||
expect(seen).toHaveLength(3);
|
||||
const replyReq = seen[2] ?? "";
|
||||
expect(replyReq).toContain("本轮刚落盘");
|
||||
expect(replyReq).toContain("细雨");
|
||||
expect(replyReq).toContain("self_score");
|
||||
expect(replyReq).toContain("还缺进城方式");
|
||||
expect(replyReq).toContain("本轮关注");
|
||||
expect(replyReq).toContain("结合 self_score 说明最强处与关键缺口");
|
||||
expect(replyReq).toContain("每个缺口写三样");
|
||||
expect(replyReq).toContain("2~3 个可以接着说的选项");
|
||||
expect(replyReq).not.toContain("旧舞台不应进入回复请求");
|
||||
expect(replyReq).not.toContain("#### task");
|
||||
expect(choices[2]).toEqual({ type: "function", function: { name: "reply_user" } });
|
||||
});
|
||||
});
|
||||
+53
-15
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDictateMessages,
|
||||
buildDictateSystemPrompt,
|
||||
formatDictateRoundTail,
|
||||
buildDictateInsertFeedbackIndex,
|
||||
defaultDictateOrder,
|
||||
extractDictateDialogue,
|
||||
@@ -47,7 +48,7 @@ describe("dictate context", () => {
|
||||
});
|
||||
const body = messages[1]?.content ?? "";
|
||||
expect(body).toContain("冷色短句");
|
||||
expect(body).toContain("尚无对话");
|
||||
expect(body).toContain("尚无更早对话");
|
||||
});
|
||||
|
||||
it("extracts only user + dictate_reply turns", () => {
|
||||
@@ -73,8 +74,9 @@ describe("dictate context", () => {
|
||||
expect(isDictateModeValue("recipe")).toBe(false);
|
||||
});
|
||||
|
||||
it("system prompt starts from a plan and keeps generic write tools", () => {
|
||||
const prompt = buildDictateSystemPrompt({
|
||||
it("system prompt keeps stable rules; catalog and plan progress sit in the tail", () => {
|
||||
const prompt = buildDictateSystemPrompt();
|
||||
const tail = formatDictateRoundTail({
|
||||
recipeName: "数据化跑团体验",
|
||||
recipeBrief:
|
||||
"设定 + 真值 + 分档映射\n怎么做:\n- declare_variable\n- declare_map",
|
||||
@@ -90,17 +92,31 @@ describe("dictate context", () => {
|
||||
expect(prompt).toContain("declare_map");
|
||||
expect(prompt).toContain("remove_map");
|
||||
expect(prompt).toContain("同 position 再 insert");
|
||||
expect(prompt).toContain("数据化跑团体验");
|
||||
expect(prompt).not.toContain("数据化跑团体验");
|
||||
expect(prompt).toMatch(/order/);
|
||||
expect(prompt).toContain("【能力 · 何时落盘】");
|
||||
expect(prompt).toContain("设计.美学纲领与交互范式");
|
||||
expect(prompt).not.toContain("【能力 · 何时落盘】");
|
||||
expect(prompt).toContain("通常 2~3 个");
|
||||
expect(prompt).toContain("给 2~3 个选项来问");
|
||||
expect(prompt).toContain("不是 DAG");
|
||||
expect(prompt).toContain("不是用户已选路径");
|
||||
expect(prompt).not.toContain("不是用户已选路径");
|
||||
expect(prompt).toContain("自然回应");
|
||||
expect(prompt.indexOf("填写收尾")).toBeLessThan(prompt.indexOf("写入规则"));
|
||||
expect(prompt).not.toContain("体验锚定(程序打分)");
|
||||
expect(prompt).not.toContain("## 本轮落什么");
|
||||
expect(tail).toContain("数据化跑团体验");
|
||||
expect(tail).toContain("【能力 · 何时落盘】");
|
||||
expect(tail).toContain("不是用户已选路径");
|
||||
expect(tail).toContain("尚无本局创作方案");
|
||||
});
|
||||
|
||||
it("puts the fill reply_user duty before plan progress in the tail", () => {
|
||||
const fillTail = formatDictateRoundTail({
|
||||
phase: "fill",
|
||||
creationPlanState: "【当前决策】\n- mood:已落",
|
||||
checklist: "本轮写入:设计.氛围",
|
||||
});
|
||||
expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("当前决策"));
|
||||
expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("本轮写入"));
|
||||
});
|
||||
|
||||
it("formats module catalog for dictate with land tags and when", async () => {
|
||||
@@ -172,6 +188,12 @@ describe("dictate context", () => {
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白#opening-cycle2", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白与开场变量", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id,
|
||||
).toBe("concrete-instances");
|
||||
@@ -244,31 +266,47 @@ describe("dictate context", () => {
|
||||
expect("error" in overwriteBare).toBe(true);
|
||||
|
||||
const openingFamilies = new Set(["设计.开场白", "设计.主角设定"]);
|
||||
const firstOpen = resolveRepeatableInsertTag({
|
||||
const bareOpen = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白",
|
||||
content: "@玩家推开门。",
|
||||
existingTags: [],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in firstOpen && firstOpen.tag).toBe("设计.开场白");
|
||||
const overwriteFirst = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白",
|
||||
content: "@玩家改写第一幕。",
|
||||
expect("error" in bareOpen).toBe(true);
|
||||
const namedFromBundle = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白与开场变量",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
id: "opening-cycle2",
|
||||
blocks: { body: "@玩家推开门。" },
|
||||
}),
|
||||
existingTags: ["设计.开场白"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in overwriteFirst && overwriteFirst.tag).toBe("设计.开场白");
|
||||
expect("tag" in namedFromBundle && namedFromBundle.tag).toBe(
|
||||
"设计.开场白#opening-cycle2",
|
||||
);
|
||||
const keepNamed = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白#opening-cycle2",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
blocks: { body: "@玩家改写这一幕。" },
|
||||
}),
|
||||
existingTags: ["设计.开场白", "设计.开场白与开场变量", "设计.开场白#opening-cycle2"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in keepNamed && keepNamed.tag).toBe("设计.开场白#opening-cycle2");
|
||||
const secondOpen = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白#gate",
|
||||
content: "@玩家在闸机前。",
|
||||
existingTags: ["设计.开场白"],
|
||||
existingTags: ["设计.开场白#opening-cycle2"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in secondOpen && secondOpen.tag).toBe("设计.开场白#gate");
|
||||
const bareOpenConflict = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白",
|
||||
content: "又写一条",
|
||||
existingTags: ["设计.开场白", "设计.开场白#gate"],
|
||||
existingTags: ["设计.开场白#opening-cycle2", "设计.开场白#gate"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("error" in bareOpenConflict).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user