创作一轮改为先选题再填写,减少工具空转和重复覆盖。

上下文按规则、历史、产物分段拼装,便于吃提示缓存并核对费用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 23:48:20 +08:00
co-authored by Cursor
parent d0a48f9431
commit 1cc4ae557a
20 changed files with 2660 additions and 123 deletions
+6 -2
View File
@@ -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('"自评"');
+34 -3
View File
@@ -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,
);
});
+49
View File
@@ -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("手改排序");
});
});
+110 -5
View File
@@ -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: () =>
+869
View File
@@ -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
View File
@@ -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);