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

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

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
+84
View File
@@ -0,0 +1,84 @@
# 创作轮次与前缀花费
> 状态:结论已记录。缓存粘滞已接入:本局 `sessionId` 在 OpenRouter 请求体写成 `session_id`。不手选节点导致的「先选再写」尚无做法。
> 记录:2026-10-01。用量取自本地 `~/.writing-agent/stats`,2026-09-20 起。
> 相关:`src/dictate/prompt.ts`、`src/dictate/turn.ts`、`src/dictate/read-module.ts`、`src/llm/client.ts`。
## 已记录的花费
9 月 20 日以来,OpenRouter 上的 token 主要在创作落盘的输入,不在游玩输出。
| 路径 | 请求 | 会话约 | prompt | 其中缓存 | 未缓存 | 输出 |
|---|---:|---:|---:|---:|---:|---:|
| 创作落盘 `dictate_agent` | 416 | 5 | 1198 万 | 782 万 | 417 万 | 38 万 |
| 游玩 `worker:world-simulator` | 28 | 2 | 77 万 | 0 | 77 万 | 7 万 |
输出大约是 prompt 的 3%–9%。输出单价更高,仍然要算,但排在输入后面。缓存命中仍按折扣价计,缩体积会同时降低全价和折扣价。
每轮花费可以看成:
```text
请求次数 ×(未缓存输入 × 全价 + 缓存输入 × 折扣价 + 输出 × 输出价)
```
创作这 416 次是最大的一块。大约五个会话,一句用户话后面跟着一串读和写。
## 缓存粘滞:必做
OpenRouter 请求体现由统计层带上本局 `sessionId`,字段名 `session_id`(最长 256 字)。直连其它渠道不写这个字段。有 `session_id` 后,粘滞从该会话第一次成功请求就开始,不再等缓存命中,也不再因为第一条 user 被重写而换上游。`provider.order` 仍不发送,它会关掉这层粘滞。
同一条前缀,落点稳定时能命中,换上游就是 0:
- 创作工具循环间隔大约 7–15 秒。Gemini 上同一轮里缓存从约 3.8k 爬到 7.6k、11.5k、15k,整段 prompt 大约 50%–68% 命中。burst 的第一下经常是 0。中间偶尔整段掉回 0,是换了上游。
- 游玩 world-simulator 在 OpenRouter 上 28 次全是 0。gemini-3.7 间隔中位数约 5.5 分钟,gemini-3.8 约 22 分钟,prompt 1.8 万–3.4 万。
- 直连 glm-5.3 的游玩,间隔 8–30 分钟仍常见 80%–91%。某一轮从约 11k 掉到 6656 并停住,说明前缀中段改过,只保住开头约 6.6k。
因此缓存粘滞是必做项。不传稳定的 `session_id`,OpenRouter 每一轮按新开头重新选上游,游玩前缀缓存等于没有。`provider.order` 会关掉这层粘滞,不能拿来代替 `session_id`。
上下文排序对游玩前缀是有效的:人设和静态产物在前,历史居中,变量和本轮输入在后。它只在同一上游上表现得出来。
## 未决:不手选节点,选和写至少两轮
用户不手动指定本轮写哪个能力。系统提示里只有能力目录和短说明(何时用、何时不用、档位)。`prompt.md` 里的任务、原则、产物规格、评分不在这条提示里。
模型选定能力后调用 `read_module`。程序把这些段落放进工具结果,并在 `运行.对话落盘.已读能力` 记下该能力当前版本。版本是这些段落的哈希。随后的 `insert` 要核对这个账:没读过,或磁盘上的提示已经改过,就拒绝写入。`self_score` 也按这次读到的评分维度检查。`narrative`、`variable-context`、`context-order`、`worker-spec`、`refine` 以及 `auto` 能力不走这道门,其余内容能力都走。库条目同理:目录只是摘要,正文要等 `read_library_entry` 回来。
读的那一轮,完整写法还不在上下文里,模型给不出合格正文。同一项能力至少是:
```text
第 1 次请求:凭短说明选定,并 read_module
第 2 次请求:看见任务 / 原则 / 规格 / 评分之后 insert
```
前缀发送两遍。第二遍还多带上那份写法。手选节点时,程序可以把该节点的完整写法放进唯一一次请求,模型当场写完。不手选时,第一次请求不能把所有节点的完整提示都放进去,这第二次省不掉。
一次只返回一个工具时,倍数再乘上去。提示要求一轮通常写 2~3 个已经说清的能力,模型常做成「读甲、写甲、读乙、写乙」,最后再单独回用户一句。每一拍都把已经变长的前缀再送一遍。「选和写拆成两次」是翻倍的原因;「每次只动一个节点」把两倍变成好几倍。
驱动程序已经会在同一步执行模型这一次返回的全部工具。接口没有禁止一轮多个 `insert`。日志里偶尔同轮两次写入。多数时候模型一次只吐一个工具。
结构化输出可以把一次生成拆成多份产物:一份 JSON 里多条 `{ position, content }`,程序再落盘,用户可见回复也可以放在同一份里。这省的是写入侧重复发送前缀。规格说明仍须在生成这批产物之前到达模型;否则模型是在没看见写法的情况下填内容。读和写如何合成更少的请求,本文不规定做法。
## 一本书上的拆账
本机作品 `4c13b2b4` 的当前会话(25 份产物、22 轮用户话)按现在的 `buildDictateMessages` 拼出来,不发请求。字数大约按 2 字 1 token 对照:同书末次 OpenRouter 请求 prompt 为 46897 token。
| 块 | 字数 | 占当前 messages |
|---|---:|---:|
| 已有产物合计 | 60518 | 69% |
| 全量对话 | 19975 | 23% |
| 系统提示(含方案进度 4392) | 6695 | 8% |
| 工具定义 JSON | 4917 | 另计,约等于系统提示 |
| 当前选用预设里启用的正文 | 362 | 可忽略 |
产物没有一份独自占满。最大的是 `设计.正文组成` 8879 字,其次变量规则 6005、实现机制 4569。其余多为两三千字,加在一起才是大头。会话中段的检查点(第 119 条消息之前)产物已经是 56102 字,和结尾同一量级。
按前面两段收窄再量同一份材料:
| 方案 | 字数 |
|---|---:|
| 选择:方案进度 + 用户最后一句(130 字) | 4522 |
| 填写若仍带全部产物 + 最近 4 条对话(1770 字) | 约 6.2 万 |
| 再注入 1 份节点提示(正文约 5 千字,叙事指南 5557) | 约 6.7 万 |
| 现在这一次请求的 messages | 88218 |
选择请求很小,目录也稳定,缓存几乎打在整段上。填写请求的大头仍是已有产物,不是对话,也不是刚插入的节点提示。砍掉全量对话大约省两成,改不了产物那七成。节点提示每份约 5 千字,一次写两三份会在产物之上再加一两成,但只发送一次。
Binary file not shown.
+1 -1
View File
@@ -288,7 +288,7 @@
写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。 写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。
写正文组成或 0 层正文组成时,回复里直接给出一份隔离内容供预览:与本局前端同一套骨架,示例已经写进去。完整 JSON 仍由产物卡展示。 正文组成、0 层正文组成和开场的画面由产物卡按 frontend 渲染。完整 JSON 仍由产物卡展示。
## 6. 工作包 D · 游玩投影编译〔未开始〕 ## 6. 工作包 D · 游玩投影编译〔未开始〕
+161 -29
View File
@@ -6,42 +6,140 @@ import {
type DictateProduct, type DictateProduct,
} from "./types.js"; } from "./types.js";
export type DictateContextPhase = "select" | "fill" | "reply";
/** 五段字数,只给用量统计,不另存正文。 */
export type DictateSegmentChars = {
rules: number;
history: number;
products: number;
injected: number;
tail: number;
};
export type DictateBuiltContext = {
messages: ChatMessage[];
segmentChars: DictateSegmentChars;
};
/** /**
* 拼给模型的上下文:系统提示 + 全部产物(按相对序)+ 全量对话。 * 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
* 清空对话后,只剩产物块,产物即浓缩上下文。 * 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
* 更早对话只追加,最后一轮用户输入放在末尾。
*/ */
export function buildDictateContext(params: {
systemPrompt: string;
products: DictateProduct[];
dialogue: DictateChatTurn[];
phase?: DictateContextPhase;
injected?: string;
tail?: string;
}): DictateBuiltContext {
const phase = params.phase ?? "fill";
if (phase === "reply") {
return buildReplyContext(params);
}
const { earlier, lastUser } = splitEarlierDialogue(params.dialogue);
const history = formatEarlierDialogue(earlier);
const products =
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
const injected = phase === "fill" ? params.injected?.trim() ?? "" : "";
const tail = joinBlocks([params.tail?.trim() ?? "", lastUser ? `最后一轮用户输入\n${lastUser}` : ""]);
const fillDuty =
phase === "fill"
? "## 本步收尾\n\n写完本轮节点表后,同一次请求必须调用 reply_user。只 insert 不算完成。"
: "";
const sections = [
fillDuty,
history,
products ? `## 当前产物(已写入黑板,按相对序;对话清空后仍保留)\n\n${products}` : "",
injected
? `## 本轮注入\n\n写完下列节点后,同一次请求必须调用 reply_user。\n\n本轮节点表\n\n${injected}`
: "",
tail ? `## 本轮\n\n${tail}` : "",
].filter(Boolean);
const user = sections.join("\n\n");
return {
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: user },
],
segmentChars: {
rules: params.systemPrompt.length,
history: history.length,
products: products.length,
injected: injected.length,
tail: tail.length,
},
};
}
/** 回复兜底:只给本轮产物 + 末句,不带全黑板与节点规格。 */
function buildReplyContext(params: {
systemPrompt: string;
products: DictateProduct[];
dialogue: DictateChatTurn[];
tail?: string;
}): DictateBuiltContext {
const { lastUser } = splitEarlierDialogue(params.dialogue);
const products = formatRoundProducts(params.products);
const tail = joinBlocks([
params.tail?.trim() ?? "",
lastUser ? `最后一轮用户输入\n${lastUser}` : "",
]);
const sections = [
products ? `## 本轮刚落盘\n\n${products}` : "## 本轮刚落盘\n\n(无正文产物;按删除与用户原话回复)",
tail ? `## 本轮\n\n${tail}` : "",
].filter(Boolean);
const user = sections.join("\n\n");
return {
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: user },
],
segmentChars: {
rules: params.systemPrompt.length,
history: 0,
products: products.length,
injected: 0,
tail: tail.length,
},
};
}
function formatRoundProducts(products: DictateProduct[]): string {
if (products.length === 0) return "";
return sortDictateProducts(products)
.map((product) => {
const score = formatSelfScore(product);
return [`### ${product.tag}`, score, product.content].filter(Boolean).join("\n");
})
.join("\n\n");
}
function formatSelfScore(product: DictateProduct): string {
const dims = product.selfScore?.dims;
if (!dims?.length) return "";
const lines = dims.map((dim) => {
const gap = dim.gap?.trim() ? `;缺口:${dim.gap.trim()}` : "";
const options =
dim.options?.length ? `;选项:${dim.options.map((item) => item.trim()).filter(Boolean).join(" / ")}` : "";
return `- ${dim.name}:${dim.score}${gap}${options}`;
});
return `self_score:\n${lines.join("\n")}`;
}
export function buildDictateMessages(params: { export function buildDictateMessages(params: {
systemPrompt: string; systemPrompt: string;
products: DictateProduct[]; products: DictateProduct[];
dialogue: DictateChatTurn[]; dialogue: DictateChatTurn[];
phase?: DictateContextPhase;
injected?: string;
tail?: string;
}): ChatMessage[] { }): ChatMessage[] {
const parts: string[] = []; return buildDictateContext(params).messages;
const products = sortDictateProducts(params.products);
if (products.length > 0) {
parts.push("## 当前产物(已写入黑板,按相对序;对话清空后仍保留)");
for (const p of products) {
const ord = effectiveDictateOrder(p);
parts.push(`### ${p.tag} 〔order=${ord}〕\n${p.content}`);
}
} else {
parts.push("## 当前产物\n(尚无)");
}
parts.push("## 对话(全量)");
if (params.dialogue.length === 0) {
parts.push("(尚无对话;等用户开口)");
} else {
for (const turn of params.dialogue) {
const who = turn.role === "user" ? "用户" : "助手";
parts.push(`【${who}】\n${turn.text}`);
}
}
return [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: parts.join("\n\n") },
];
} }
/** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */ /** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */
@@ -62,3 +160,37 @@ export function extractDictateDialogue(
} }
return out; return out;
} }
function splitEarlierDialogue(dialogue: DictateChatTurn[]): {
earlier: DictateChatTurn[];
lastUser: string;
} {
const last = dialogue.at(-1);
if (last?.role === "user") {
return { earlier: dialogue.slice(0, -1), lastUser: last.text.trim() };
}
return { earlier: dialogue, lastUser: "" };
}
function formatEarlierDialogue(dialogue: DictateChatTurn[]): string {
if (dialogue.length === 0) return "## 更早的对话\n\n(尚无更早对话)";
const lines = dialogue.map((turn) => {
const who = turn.role === "user" ? "用户" : "助手";
return `【${who}】\n${turn.text}`;
});
return `## 更早的对话\n\n${lines.join("\n\n")}`;
}
function formatProducts(products: DictateProduct[]): string {
if (products.length === 0) return "(尚无)";
return products
.map((product) => {
const ord = effectiveDictateOrder(product);
return `### ${product.tag} 〔order=${ord}〕\n${product.content}`;
})
.join("\n\n");
}
function joinBlocks(blocks: string[]): string {
return blocks.map((block) => block.trim()).filter(Boolean).join("\n\n");
}
+48
View File
@@ -1,5 +1,6 @@
import { import {
catalogModulesForIntake, catalogModulesForIntake,
isDictateProgramModule,
type ModuleCatalog, type ModuleCatalog,
type ModuleCatalogEntry, type ModuleCatalogEntry,
} from "../skills/creation-flow.js"; } from "../skills/creation-flow.js";
@@ -33,6 +34,53 @@ const ALL_PRESENTATION_MODULE_IDS = new Set(
Object.values(PRESENTATION_MODULE_IDS).flat(), Object.values(PRESENTATION_MODULE_IDS).flat(),
); );
/** 技能页按节点在对话落盘里的角色分组,不按旧配方包。 */
export const MODULE_BROWSE_GROUPS = [
{
id: "plan",
label: "本局方案",
hint: "抽出体验并给内容节点分档,不进入游玩",
},
{
id: "content",
label: "内容节点",
hint: "本局方案按档取用;这句话说清了再写",
},
{
id: "present",
label: "呈现链",
hint: "普通楼层与 0 层卡各用一套,两套不混用",
},
{
id: "program",
label: "落档程序",
hint: "开玩前由程序编译,创作对话不写入",
},
] as const;
export type ModuleBrowseGroupId = (typeof MODULE_BROWSE_GROUPS)[number]["id"];
export function moduleBrowseGroup(module: {
id: string;
auto?: boolean;
}): ModuleBrowseGroupId {
if (module.id === "creation-plan") return "plan";
if (isDictateProgramModule(module)) return "program";
if (ALL_PRESENTATION_MODULE_IDS.has(module.id)) return "present";
return "content";
}
export function modulePresentationMode(
moduleId: string,
): CreationPresentationMode | null {
for (const mode of ["message", "zero_layer"] as const) {
if ((PRESENTATION_MODULE_IDS[mode] as readonly string[]).includes(moduleId)) {
return mode;
}
}
return null;
}
export type CreationPlanItem = { export type CreationPlanItem = {
module_id: string; module_id: string;
tier: CreationPlanTier; tier: CreationPlanTier;
+12 -5
View File
@@ -1,4 +1,15 @@
export { buildDictateSystemPrompt, DICTATE_SYSTEM_PROMPT } from "./prompt.js"; export {
buildDictateReplyPrompt,
buildDictateSelectPrompt,
buildDictateSystemPrompt,
DICTATE_SYSTEM_PROMPT,
formatDictateRoundTail,
} from "./prompt.js";
export {
buildDictateMessages,
buildDictateContext,
extractDictateDialogue,
} from "./context.js";
export { export {
CREATION_INTAKE_MODE_TAG, CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE, DICTATE_MODE_VALUE,
@@ -16,10 +27,6 @@ export {
type DictateSelfScore, type DictateSelfScore,
type DictateChatTurn, type DictateChatTurn,
} from "./types.js"; } from "./types.js";
export {
buildDictateMessages,
extractDictateDialogue,
} from "./context.js";
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
export { runDictateTurn, type DictateTurnResult } from "./turn.js"; export { runDictateTurn, type DictateTurnResult } from "./turn.js";
export { export {
+3 -1
View File
@@ -15,6 +15,7 @@ import {
/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */ /** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */
export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = { export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
"设计.开场白": "opening-setup", "设计.开场白": "opening-setup",
"设计.开场白与开场变量": "opening-setup",
"设计.美学纲领": "aesthetics-interaction", "设计.美学纲领": "aesthetics-interaction",
"设计.文风": "narrative-guide", "设计.文风": "narrative-guide",
"设计.监控栏": "status-bar", "设计.监控栏": "status-bar",
@@ -54,7 +55,8 @@ export function resolveModuleForInsertTag(
if (byFamily) return byFamily; if (byFamily) return byFamily;
} }
const aliasId = DICTATE_INSERT_TAG_ALIASES[t]; const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t;
const aliasId = DICTATE_INSERT_TAG_ALIASES[t] ?? DICTATE_INSERT_TAG_ALIASES[family];
if (aliasId) { if (aliasId) {
return catalog.modules.find((m) => m.id === aliasId) ?? null; return catalog.modules.find((m) => m.id === aliasId) ?? null;
} }
+96 -3
View File
@@ -1,7 +1,9 @@
/** /**
* 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。 * 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
* 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。 * 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。
* 保存时按上游产物指纹决定是否重跑:没改过就保留已有排序和运行配置。
*/ */
import { createHash } from "node:crypto";
import type { Blackboard } from "../blackboard/blackboard.js"; import type { Blackboard } from "../blackboard/blackboard.js";
import { import {
CONTEXT_ORDER_SCHEMA, CONTEXT_ORDER_SCHEMA,
@@ -38,6 +40,12 @@ import {
VALUE_MAP_TAG, VALUE_MAP_TAG,
listValueMapTargetTags, listValueMapTargetTags,
} from "../skills/value-map.js"; } from "../skills/value-map.js";
import {
applyMaintenanceStrategyToPlaySlots,
MAINTENANCE_STRATEGY_TAG,
resolveMaintenanceStrategyFromBoard,
writeMaintenanceStrategy,
} from "../skills/maintenance-strategy.js";
import { import {
DICTATE_ORDER_META_KEY, DICTATE_ORDER_META_KEY,
effectiveDictateOrder, effectiveDictateOrder,
@@ -57,6 +65,7 @@ const SKIP_BIND_TAGS = new Set([
CREATION_SELECTED_RECIPE_TAG, CREATION_SELECTED_RECIPE_TAG,
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
VALUE_MAP_TAG, VALUE_MAP_TAG,
MAINTENANCE_STRATEGY_TAG,
]); ]);
function shouldSkipBindTag(tag: string): boolean { function shouldSkipBindTag(tag: string): boolean {
@@ -77,11 +86,16 @@ const STYLE_TAG_RE =
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */ /** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]); const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
/** 上次编译时所依据的上游指纹;两边产物一致且未过期则跳过重跑 */
export const DICTATE_PLAY_BIND_INPUT_META = "dictatePlayBindInput";
export type DictatePlayBindResult = { export type DictatePlayBindResult = {
workerSetJson: string; workerSetJson: string;
contextOrder: ContextOrderDoc; contextOrder: ContextOrderDoc;
boundTags: string[]; boundTags: string[];
changed: boolean; changed: boolean;
/** 上游产物未改,保留已有上下文排序与运行配置 */
skipped: boolean;
}; };
function isStyleProductTag(tag: string): boolean { function isStyleProductTag(tag: string): boolean {
@@ -314,7 +328,7 @@ function resolvePlaySlots(
const parsed = existingRaw?.trim() const parsed = existingRaw?.trim()
? parseWorkerSetYaml(existingRaw) ? parseWorkerSetYaml(existingRaw)
: null; : null;
const slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) }; let slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) };
const recipeRef = parseSelectedRecipeRef( const recipeRef = parseSelectedRecipeRef(
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG), board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
); );
@@ -322,6 +336,9 @@ function resolvePlaySlots(
slots.gm = true; slots.gm = true;
slots.narrator = true; slots.narrator = true;
} }
// 变量设计.维护复杂度 → auditor true/false(内联则关旁观)
const strategy = resolveMaintenanceStrategyFromBoard(board);
slots = applyMaintenanceStrategyToPlaySlots(slots, strategy);
return slots; return slots;
} }
@@ -336,6 +353,8 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() || board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
""; "";
const playSlots = resolvePlaySlots(existing || undefined, board); const playSlots = resolvePlaySlots(existing || undefined, board);
const strategy = resolveMaintenanceStrategyFromBoard(board);
writeMaintenanceStrategy(board, strategy, "runtime:play-bind");
const runtimeTags = collectRuntimeStateTags(board); const runtimeTags = collectRuntimeStateTags(board);
const contextOrder = buildDictateContextOrder({ const contextOrder = buildDictateContextOrder({
products, products,
@@ -371,26 +390,100 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
? row.brief ? row.brief
: "对话落盘:主世界出内容,叙事转述出文风"; : "对话落盘:主世界出内容,叙事转述出文风";
} }
if (playSlots.auditor) {
const base =
typeof row.brief === "string" && row.brief.trim() ? String(row.brief) : "对话落盘落档自动生成";
if (!base.includes("旁观")) {
row.brief = `${base};旁观副 LLM 填表(固定)`;
}
}
const withSlots = JSON.stringify(row, null, 2); const withSlots = JSON.stringify(row, null, 2);
const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder); const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder);
const boundTags = products.map((p) => p.tag); const boundTags = products.map((p) => p.tag);
const changed = workerSetJson.trim() !== existing.trim(); const changed = workerSetJson.trim() !== existing.trim();
return { workerSetJson, contextOrder, boundTags, changed }; return { workerSetJson, contextOrder, boundTags, changed, skipped: false };
} }
/** 写入黑板:设计.worker集 + 设计.上下文投影排序 */ /**
* 上游指纹:会挂进游玩上下文的产物正文与次序,加上会改变槽位的配方、变量目录、映射和已启用槽。
* 不含投影结果(变量.当前、上下文排序、worker 集正文),避免保存后自己把自己标脏。
*/
export function dictatePlayBindInputKey(board: Blackboard): string {
const products = collectDictateBindProducts(board).map((product) => ({
tag: product.tag,
order: product.order ?? null,
content: product.content,
}));
const existing =
board.getContentByTag(WORKER_SET_FINAL_TAG)?.trim() ||
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
"";
const payload = {
products,
recipe: board.getContentByTag(CREATION_SELECTED_RECIPE_TAG)?.trim() ?? "",
catalog: board.getContentByTag(VARIABLE_CATALOG_TAG)?.trim() ?? "",
maps: board.getContentByTag(VALUE_MAP_TAG)?.trim() ?? "",
playSlots: resolvePlaySlots(existing || undefined, board),
};
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
function storedPlayBindKey(
board: Blackboard,
tag: string,
): string | undefined {
const stamp = board.getLatestByTag(tag)?.metadata?.[DICTATE_PLAY_BIND_INPUT_META];
return typeof stamp === "string" && stamp.trim() ? stamp : undefined;
}
function freshPlayBind(
board: Blackboard,
key: string,
): DictatePlayBindResult | null {
const orderItem = board.getLatestByTag(CONTEXT_ORDER_TAG);
const workerItem = board.getLatestByTag(WORKER_SET_FINAL_TAG);
const orderRaw = orderItem?.content?.trim() ?? "";
const workerRaw = workerItem?.content?.trim() ?? "";
if (!orderRaw || !workerRaw) return null;
if (storedPlayBindKey(board, CONTEXT_ORDER_TAG) !== key) return null;
if (storedPlayBindKey(board, WORKER_SET_FINAL_TAG) !== key) return null;
try {
const contextOrder = JSON.parse(orderRaw) as ContextOrderDoc;
if (!contextOrder || typeof contextOrder !== "object") return null;
return {
workerSetJson: workerItem!.content,
contextOrder,
boundTags: collectDictateBindProducts(board).map((product) => product.tag),
changed: false,
skipped: true,
};
} catch {
return null;
}
}
/** 写入黑板:设计.worker集 + 设计.上下文投影排序。上游未改则不覆盖。 */
export function applyDictatePlayBind(board: Blackboard): DictatePlayBindResult { export function applyDictatePlayBind(board: Blackboard): DictatePlayBindResult {
const key = dictatePlayBindInputKey(board);
const fresh = freshPlayBind(board, key);
if (fresh) return fresh;
const result = bindDictateProductsToPlaySpec(board); const result = bindDictateProductsToPlaySpec(board);
const orderItem = board.getLatestByTag(CONTEXT_ORDER_TAG);
const workerItem = board.getLatestByTag(WORKER_SET_FINAL_TAG);
const stamp = { [DICTATE_PLAY_BIND_INPUT_META]: key };
board.write({ board.write({
tag: WORKER_SET_FINAL_TAG, tag: WORKER_SET_FINAL_TAG,
content: result.workerSetJson, content: result.workerSetJson,
source: "runtime", source: "runtime",
metadata: { ...(workerItem?.metadata ?? {}), ...stamp },
}); });
board.write({ board.write({
tag: CONTEXT_ORDER_TAG, tag: CONTEXT_ORDER_TAG,
content: JSON.stringify(result.contextOrder, null, 2), content: JSON.stringify(result.contextOrder, null, 2),
source: "runtime", source: "runtime",
metadata: { ...(orderItem?.metadata ?? {}), ...stamp },
}); });
return result; return result;
} }
+99 -42
View File
@@ -1,16 +1,13 @@
/** /**
* 对话落盘 · 创作进料专用系统提示 * 对话落盘 · 创作系统提示。
* * 系统侧只放跨轮不变的规则。方案进度、能力目录和本轮输入放在请求末尾。
* 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。
* 仅用于创作层;开玩后由游玩管线接棒。
*/ */
export function buildDictateSystemPrompt(opts?: { export type DictatePromptTailOpts = {
/** 已有方案 + 分档状态;存在时不再注入全能力目录 */ /** 已有方案的短进度;放在末尾,不进系统提示 */
creationPlanState?: string; creationPlanState?: string;
/** 尚无方案时使用的短能力目录 */ /** 尚无方案时的短能力目录 */
capabilityCatalog?: string; capabilityCatalog?: string;
/** 尚无方案时可引用的旧配方案例目录 */
recipeExamples?: string; recipeExamples?: string;
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */ /** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
recipeName?: string; recipeName?: string;
@@ -18,43 +15,38 @@ export function buildDictateSystemPrompt(opts?: {
recipeBrief?: string; recipeBrief?: string;
/** @deprecated 使用 capabilityCatalog */ /** @deprecated 使用 capabilityCatalog */
moduleGuide?: string; moduleGuide?: string;
}): string { /** select:只定计划。fill:按注入规格写正文。reply:只交用户可见回复。 */
const legacyExample = phase?: "select" | "fill" | "reply";
opts?.recipeName || opts?.recipeBrief /** 本轮要落实或重写的 tag 与错误 */
? ` checklist?: string;
【旧配方案例 · 兼容参考】 };
${opts.recipeName ? `- ${opts.recipeName}` : ""}
${opts.recipeBrief ?? ""}
它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。
`
: "";
const decisionBlock = opts?.creationPlanState?.trim()
? `${opts.creationPlanState.trim()}
方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个:先 read_module,再连续 insert。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,按已有材料给 2~3 个选项来问,选定或改写后再写入。写开场或结束创作前,列出仍未落的「必须」和「有必要」,问用户哪些还没设计、要补哪几项或明确不要。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`;使用条件仍留在能力旁。` /** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
: `【当前状态 · 尚无本局创作方案】 export function buildDictateSelectPrompt(): string {
先抽出用户要的核心体验,再对照能力目录主动标出有帮助的能力,生成 \`creation-plan.v1\`,用 insert 写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献:用户没点名、但缺了体验会明显变差的,标进「必须」或「有必要」,具体还没定的选择写进 missing。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。 return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"} nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
${opts?.recipeExamples?.trim() ? `\n\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""} 用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
${legacyExample}`; 这一步不写产物正文,不回复用户。`;
}
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。 return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。 **本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
${decisionBlock} **填写收尾:** 节点表产物写完后,同一次请求必须再调用 reply_user。只 insert、把话写在普通正文里,都不算完成。回复写法见文末「用户可见回复」。
## 写入规则 ## 写入规则
设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。 设定、变量、映射和删除通过本步的工具调用落盘。reply_user 只写给用户看的回复。
### insert — 写入/覆盖固定产物 ### insert — 写入/覆盖固定产物
- position:\`用户.*\` / \`设计.*\` - position:\`用户.*\` / \`设计.*\`
- content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大) - content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大)
- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map - 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map
- 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag - 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag
- 只有方案中该填的内容都完成后才写开场白;先单独调用 prepare_opening,让程序编译信息可见范围、游玩执行方式、上下文排序和运行配置;工具成功后的下一步再生成并 insert 开场白 - 只有方案中该填的内容都完成后才写开场白。程序在这次填写前编译信息可见范围、游玩执行方式、上下文排序和运行配置;本步按清单写出开场正文
- 开场白与生成规则、具体实例一样可增殖:每条入口 insert \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题 - 开场白与生成规则、具体实例一样可增殖:每条入口写入 \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题
### delete — 删除固定产物 ### delete — 删除固定产物
- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除 - position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除
@@ -73,22 +65,87 @@ ${decisionBlock}
- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写) - 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写)
## 推进原则 ## 推进原则
1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。有方案时,写哪项先对照该能力的何时用 / 何时不用。 1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。尚无方案时,按末尾目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。有方案时,写哪项先对照该能力的何时用 / 何时不用。
2. 写任何内容能力前先调用 read_module 读取当前提示版本;需要库条目时,再用 read_library_entry 读取当前能力绑定库条目(默认 1~2;确需更多须给 reason)。不要凭目录短说明猜产物。 2. 本轮只处理用户这句话已经说清的能力,通常 2~3 个。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。
3. 本轮只写入用户这句话已经说清的能力,通常 2~3 个。对这些能力先 read_module,再 insert / declare_*。每次 insert 都是独立产物写入。 3. 先定范围再写。用户的话按最窄的成立范围落盘:体验目标说明玩家追求的对象,举例和比方说明用户想要的感觉或基底,都不说明整个世界都是这样。例:体验是「在各个世界征服高洁女性、打败恶役反派」,落成每个世界里有这样的目标人物和对手;世界本身仍按题材常识(如普通西幻),不写成全世界女性都高洁,也不为此发明神殿戒律。用「和现实一样」时顺口提到的现实事实,只确认基底是现实,不单独成条。宽窄拿不准、且会写出不同产物时,按最窄范围写或先不写,在回复里请用户确认范围。
4. 写某能力前以 read_module 返回的 task / principles / output / score 为准;尚未读到专用提示时,不猜造其复杂结构。能力提示里的「材料足够便直接成稿」只用于这句已经说清的能力。 4. 填写时以「本轮注入」里的 task、principles、output、score 和库正文为规格。注入尚未给出的能力,不猜造其复杂结构。
5. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。 5. 变量和映射不写入普通产物。每次产物写入都是独立的一份。
6. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。 6. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。
7. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。
## 用户可见回复 ## 用户可见回复
自然回应用户本轮有效判断、矛盾和值得保留之处,再简要说明形成或改写了什么。完整 JSON 由产物卡展示,不粘贴到回复。 ${dictateReplyGuide()}
写正文组成或 0 层正文组成时,在回复里直接给出一份隔离内容供预览:与写入的 frontend 同一套骨架,示例内容已经放进去,单独可看。产物仍只保存 frontend 与数据契约。
这句话没覆盖到的「必须」或「有必要」,用 2~3 个从用户原话推出的选项询问,不先写成产物。已经说清并写入的,说明形成了什么,不再另找问题。同轮多个已说清产物只做一次综合回应,不逐模块套固定标题。
## 其它
对话明显过长时 clear_dialogue(产物与变量/映射保留)。
`; `;
} }
/** 方案进度、目录和本轮任务。拼在请求末尾。 */
export function formatDictateRoundTail(opts?: DictatePromptTailOpts): string {
const legacyExample =
opts?.recipeName || opts?.recipeBrief
? `【旧配方案例 · 兼容参考】
${opts.recipeName ? `- ${opts.recipeName}` : ""}
${opts.recipeBrief ?? ""}
它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。`
: "";
const state = opts?.creationPlanState?.trim()
? `${opts.creationPlanState.trim()}
方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,给 2~3 个选项来问。写开场或结束创作前,列出仍未落的「必须」和「有必要」。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`。`
: `【当前状态 · 尚无本局创作方案】
先抽出用户要的核心体验,再对照能力目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。
${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"}
${opts?.recipeExamples?.trim() ? `\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""}
${legacyExample}`;
if (opts?.phase === "reply") {
const state = opts?.creationPlanState?.trim()
? `${opts.creationPlanState.trim()}
写回复时用这份进度核对:用户这句没覆盖到的「必须」或「有必要」,给 2~3 个选项来问;已写入的不再强行追问。`
: "";
return [
"本步只调用 reply_user。对照本轮刚落盘产物和「本轮关注」写综合回复,含自评最稳处、缺口例子与选项。",
opts?.checklist?.trim() ?? "",
state,
]
.filter(Boolean)
.join("\n\n");
}
const phase =
opts?.phase === "fill"
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准),写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
: "本步是第一次请求。只调用 select_nodes:这段话用于填充哪些节点。这一步不写产物正文,不回复用户。";
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
return opts?.phase === "fill"
? [phase, opts?.checklist?.trim() ?? "", state.trim()].filter(Boolean).join("\n\n")
: [state.trim(), opts?.checklist?.trim() ?? "", phase].filter(Boolean).join("\n\n");
}
/** @deprecated 用 buildDictateSystemPrompt() */ /** @deprecated 用 buildDictateSystemPrompt() */
export const DICTATE_SYSTEM_PROMPT = buildDictateSystemPrompt(); export const DICTATE_SYSTEM_PROMPT = buildDictateSystemPrompt();
/** 用户可见回复正文要求(评分、缺口、建议)。填写与回复兜底共用。 */
export function dictateReplyBodyGuide(): string {
return `自然回应用户这句里值得保留、要纠正或互相矛盾的地方,再说明形成了什么。产物正文留在产物卡。正文组成、0 层正文组成和开场的画面也以产物卡为准。
用 self_score 点出最稳的一处,以及分数一般或还有更好写法的缺口。每个缺口写三样:一个从原话推出来的短例子、一句它会让体验变成什么样、2~3 个可以接着说的选项。这份产物没有 self_score 时,用这句里还没定的具体点代替缺口。
本轮按最窄范围写下、或因范围不明先没写的点,排在缺口最前面问:说明你按什么范围理解,给 2~3 个宽窄选项(如只是这个目标人物 / 这一类人物都这样 / 整个世界的规则)。
这句话没覆盖到的「必须」或「有必要」,写出下一步可以提及哪些,还可能缺哪些节点也写在这里;还没有方案时,按这句已经说清的设定给 2~3 个可继续的方向。
最后补一个和这句相关、用户可能想接着定的点,放在回复里,这一步不为它调用 insert。`;
}
/** 用户可见回复。只放在默认系统提示里,填写尾巴和工具说明不再抄一遍。 */
export function dictateReplyGuide(): string {
return `insert 全部完成后,同一次请求必须调用 reply_user,生成给用户看的综合回复。多个产物合成一段。
${dictateReplyBodyGuide()}`;
}
/** 程序侧兜底回复请求:只看本轮已落盘产物与能力回复口径,只调 reply_user。不向填写步透露。 */
export function buildDictateReplyPrompt(): string {
return `你只生成给用户看的综合回复。对照本轮刚落盘的产物、附带的「本轮关注」和用户原话,调用 reply_user。
多个产物合成一段,不要逐模块套标题。不要再 insert、delete 或改设定。
${dictateReplyBodyGuide()}`;
}
+83 -6
View File
@@ -143,6 +143,74 @@ function bodyOfFragment(content: string): Record<string, unknown> | null {
return body as Record<string, unknown>; return body as Record<string, unknown>;
} }
/** 多开场切换用的落盘族。每条必须是 基名#场景短码。 */
export const OPENING_SLOT_FAMILY = "设计.开场白";
/** 旧整包 tag。insert 时改写到 OPENING_SLOT_FAMILY#槽,避免再并列一份。 */
export const LEGACY_OPENING_BUNDLE_FAMILY = "设计.开场白与开场变量";
const OPENING_SLOT_REQUIRED =
"开场每条单独落盘,position 用「设计.开场白#场景短码」(例如 设计.开场白#opening-cycle2),供之后左右切换。不要写裸「设计.开场白」,也不要写「设计.开场白与开场变量」。改某一条则 insert 同一完整 tag。没有变量的卡不写开场变量,tag 仍然带场景短码。";
/** 旧整包族改写成开场族,槽位原样保留。 */
export function rewriteLegacyOpeningTag(tag: string): string {
const raw = tag.trim();
if (dictateProductFamily(raw) !== LEGACY_OPENING_BUNDLE_FAMILY) return raw;
const slot = dictateProductSlot(raw);
return slot ? buildRepeatableProductTag(OPENING_SLOT_FAMILY, slot) : OPENING_SLOT_FAMILY;
}
function openingCandidateRows(body: Record<string, unknown>): Record<string, unknown>[] {
const raw = body.开场白候选 ?? body.候选;
if (!Array.isArray(raw)) return [];
return raw.filter(
(row): row is Record<string, unknown> =>
Boolean(row) && typeof row === "object" && !Array.isArray(row),
);
}
/**
* 从单张开场正文抽出场景短码。多条候选挤在一份正文里时拒绝,要求拆成多次 insert。
*/
export function extractOpeningSlotFromContent(
content: string,
): { slot?: string; error?: string } {
const parsed = tryParseJsonDoc(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const doc = parsed as Record<string, unknown>;
const nested = bodyOfFragment(content);
const body = nested ?? doc;
const direct =
asString(doc.id) ??
asString(doc.候选id) ??
asString(doc.场景短码) ??
asString(body.id) ??
asString(body.候选id) ??
asString(body.场景短码);
if (direct) {
const slot = sanitizeRepeatableSlot(direct);
return slot ? { slot } : {};
}
const rows = openingCandidateRows(body);
if (rows.length > 1) {
return {
error:
"多条开场不要写进同一份产物。每条 insert 一次「设计.开场白#场景短码」。",
};
}
const one = rows[0];
const fromOne = one
? asString(one.id) ?? asString(one.候选id) ?? asString(one.场景短码)
: undefined;
const fallback =
rows.length <= 1
? asString(body.默认候选id) ?? asString(body.默认候选ID)
: undefined;
const picked = fromOne ?? fallback;
if (!picked) return {};
const slot = sanitizeRepeatableSlot(picked);
return slot ? { slot } : {};
}
/** /**
* 从正文抽出建议槽位:生成规则 → 规则名;具体实例 → 名称 / 记录里的姓名。 * 从正文抽出建议槽位:生成规则 → 规则名;具体实例 → 名称 / 记录里的姓名。
*/ */
@@ -207,8 +275,9 @@ export function resolveRepeatableInsertTag(params: {
existingTags: readonly string[]; existingTags: readonly string[];
repeatableFamilies: ReadonlySet<string>; repeatableFamilies: ReadonlySet<string>;
}): ResolveRepeatableInsertResult { }): ResolveRepeatableInsertResult {
const raw = params.tag.trim(); const requested = params.tag.trim();
if (!raw) return { error: "position 为空" }; if (!requested) return { error: "position 为空" };
const raw = rewriteLegacyOpeningTag(requested);
const family = dictateProductFamily(raw); const family = dictateProductFamily(raw);
if (!params.repeatableFamilies.has(family)) { if (!params.repeatableFamilies.has(family)) {
@@ -217,13 +286,21 @@ export function resolveRepeatableInsertTag(params: {
const explicit = dictateProductSlot(raw); const explicit = dictateProductSlot(raw);
const extracted = extractRepeatableSlotFromContent(family, params.content); const extracted = extractRepeatableSlotFromContent(family, params.content);
const slot = explicit || extracted || ""; const openingSlot =
family === OPENING_SLOT_FAMILY && !explicit
? extractOpeningSlotFromContent(params.content)
: {};
if (openingSlot.error) return { error: openingSlot.error };
const slot = explicit || extracted || openingSlot.slot || "";
const siblings = params.existingTags const siblings = params.existingTags
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t && dictateProductFamily(t) === family); .filter((t) => t && dictateProductFamily(t) === family);
if (!slot) { if (!slot) {
if (family === OPENING_SLOT_FAMILY) {
return { error: OPENING_SLOT_REQUIRED };
}
// 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id) // 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id)
const otherSiblings = siblings.filter((t) => t !== family); const otherSiblings = siblings.filter((t) => t !== family);
if (otherSiblings.length > 0 && raw === family) { if (otherSiblings.length > 0 && raw === family) {
@@ -235,11 +312,11 @@ export function resolveRepeatableInsertTag(params: {
} }
const resolved = buildRepeatableProductTag(family, slot); const resolved = buildRepeatableProductTag(family, slot);
if (raw === family || raw !== resolved) { if (requested !== resolved) {
return { return {
tag: resolved, tag: resolved,
remapped: raw !== resolved, remapped: true,
note: raw !== resolved ? `可增殖已拆到 ${resolved}` : undefined, note: `已写入 ${resolved}。之后改这一条用同一完整 tag。`,
}; };
} }
return { tag: resolved }; return { tag: resolved };
+721
View File
@@ -0,0 +1,721 @@
/**
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
*/
import { randomUUID } from "node:crypto";
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
import type { AgentDriver } from "../runtime/driver.js";
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
import {
CREATION_PLAN_SCHEMA,
CREATION_PLAN_TAG,
formatCreationPlanCapabilityCatalog,
formatCreationPlanProgress,
parseCreationPlan,
} from "./creation-plan.js";
import { isDictateOpeningTag } from "./experience-anchor.js";
import {
buildDictateReplyPrompt,
buildDictateSelectPrompt,
buildDictateSystemPrompt,
formatDictateRoundTail,
type DictatePromptTailOpts,
} from "./prompt.js";
import {
parseDictateModuleReadState,
readLibraryEntries,
serializeDictateModuleReadState,
DICTATE_MODULE_READ_STATE_TAG,
type DictateModuleReadPayload,
} from "./read-module.js";
import { resolveModuleForInsertTag } from "./insert-feedback.js";
import type { DictateChatTurn, DictateProduct } from "./types.js";
import { isAllowedProductTag } from "./types.js";
import { VALUE_MAP_TAG } from "../skills/value-map.js";
import { VARIABLE_CATALOG_TAG } from "../skills/variable-catalog.js";
const READ_EXEMPT = new Set([
"narrative",
"variable-context",
"context-order",
"worker-spec",
"refine",
]);
type Apply = (calls: DriverToolCall[]) => DriverStepOutcome | Promise<DriverStepOutcome>;
export type PlannedRoundParams = {
llm: LlmProvider;
dialogue: DictateChatTurn[];
handlers: {
listProducts: () => DictateProduct[];
readTag: (tag: string) => string | undefined;
writeTag: (tag: string, content: string) => void;
prepareOpening?: () => void;
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
lookupInsertFeedback?: (tag: string) => string | undefined;
onThinkingDelta?: (delta: string) => void;
onThinkingDone?: (text: string) => void;
};
moduleCatalog?: import("../skills/creation-flow.js").ModuleCatalog | null;
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
moduleGuide?: string;
recipeExamples?: string;
recipeName?: string;
recipeBrief?: string;
systemPrompt?: string;
};
type PlanInsert = {
tag: string;
moduleId: string;
libraryId?: string;
entryIds: string[];
reason?: string;
};
type SelectPlan = {
clarify: string[];
inserts: PlanInsert[];
deletes: string[];
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
keys: string[];
};
type ItemFailure = { tag: string; error: string };
const INSERT_ATTEMPTS = 4;
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
return {
async run(input) {
const reply = await runPlannedDictateRound(params, input.handleStep);
return { iterations: reply.modelCalls, stop: { kind: "text", content: reply.text } };
},
};
}
async function runPlannedDictateRound(
params: PlannedRoundParams,
apply: Apply,
): Promise<{ text: string; modelCalls: number }> {
const turnId = randomUUID();
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
const tailOpts = tailState(params);
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
const selectCtx = buildDictateContext({
systemPrompt: buildDictateSelectPrompt(),
products: params.handlers.listProducts(),
dialogue: params.dialogue,
phase: "select",
tail: selectTail,
});
const selected = await params.llm.completeWithTools(selectCtx.messages, {
caller: "dictate_agent",
label: "对话落盘 选题",
tools: [DICTATE_SELECT_TOOL],
toolChoice: { type: "function", function: { name: "select_nodes" } },
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
usageSink: sinkFor(turnId, "select"),
});
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) {
return {
modelCalls: 1,
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
};
}
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
return {
modelCalls: 1,
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
};
}
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
const inserts = plan.inserts.filter((item) => !item.tag.startsWith("!"));
const openings = inserts.filter((item) => isDictateOpeningTag(item.tag));
const rest = inserts.filter((item) => !isDictateOpeningTag(item.tag));
const blockedLines = blocked.map(
(item) => `${item.tag.slice(1)}(${item.reason || "计划未通过程序核验"})`,
);
if (openings.length > 0) params.handlers.prepareOpening?.();
const injected = injectGroup(params, [...rest, ...openings]);
const tail = formatDictateRoundTail({
...tailOpts,
phase: "fill",
checklist: formatChecklist([...rest, ...openings], plan.deletes, []),
});
const ctx = buildDictateContext({
systemPrompt: fillSystem,
products: params.handlers.listProducts(),
dialogue: params.dialogue,
phase: "fill",
injected: injected.text,
tail,
});
const messages: ChatMessage[] = [...ctx.messages];
const passed = new Set<string>();
const pending = new Set([...rest, ...openings].map((item) => item.tag));
const pendingDeletes = new Set(plan.deletes);
const errors = new Map<string, string>();
let replyFromTool = "";
let lastProse = "";
let modelCalls = 1;
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
const step = attempt === 0 ? "fill" : "fill-retry";
const filled = await params.llm.completeWithTools(messages, {
caller: "dictate_agent",
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
tools: DICTATE_WRITE_LOOP_TOOLS,
toolChoice: "required",
usageDetail: {
...detail(turnId, step, attempt, ctx.segmentChars),
plannedTags: [...pending, ...pendingDeletes],
},
usageSink: sinkFor(turnId, step),
});
modelCalls += 1;
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
const prose = filled.content?.trim() ?? "";
if (prose) lastProse = prose;
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
id: call.id || `call-${attempt}-${index}`,
name: call.name,
arguments: call.arguments,
}));
if (calls.length === 0) break;
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
messages.push(assistantToolMessage(filled.content, calls));
for (const item of settled.results) {
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId });
}
if (pending.size === 0 && pendingDeletes.size === 0) {
replyFromTool = settled.reply;
break;
}
}
let reply = replyFromTool;
if (
!replyFromTool &&
pending.size === 0 &&
pendingDeletes.size === 0 &&
passed.size > 0
) {
reply = await runLeanReply(params, turnId, passed, plan.deletes, lastProse);
modelCalls += 1;
} else if (!reply) {
reply = lastProse;
}
const failed = [...pending, ...pendingDeletes].map(
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
);
const pendingLines = [...blockedLines, ...failed];
const text = pendingLines.length
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
: reply;
return { text, modelCalls };
}
async function runLeanReply(
params: PlannedRoundParams,
turnId: string,
passed: Set<string>,
plannedDeletes: string[],
fallbackProse: string,
): Promise<string> {
const writtenTags = [...passed].filter((tag) => !tag.startsWith("del:"));
const deletedTags = [...passed]
.filter((tag) => tag.startsWith("del:"))
.map((tag) => tag.slice(4))
.filter(Boolean);
const products = params.handlers
.listProducts()
.filter((product) => writtenTags.some((tag) => product.tag === tag || product.tag.startsWith(`${tag}#`)));
const feedbackBlocks = writtenTags
.map((tag) => params.handlers.lookupInsertFeedback?.(tag)?.trim())
.filter((text): text is string => Boolean(text));
if (writtenTags.length > 1 && feedbackBlocks.length > 1) {
feedbackBlocks.unshift(
"本轮写入了多个产物。用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。",
);
}
const tailOpts = tailState(params);
const deleteNote =
deletedTags.length || plannedDeletes.length
? `本轮已删除:${(deletedTags.length ? deletedTags : plannedDeletes).join("、")}`
: "";
const replyTail = [
formatDictateRoundTail({
...tailOpts,
phase: "reply",
checklist: [
writtenTags.length ? `本轮已写入:${writtenTags.join("、")}` : "",
deleteNote,
]
.filter(Boolean)
.join("\n"),
}),
feedbackBlocks.length ? `## 本轮关注(按能力回复口径)\n\n${feedbackBlocks.join("\n\n")}` : "",
]
.filter(Boolean)
.join("\n\n");
const replyCtx = buildDictateContext({
systemPrompt: buildDictateReplyPrompt(),
products,
dialogue: params.dialogue,
phase: "reply",
tail: replyTail,
});
const filled = await params.llm.completeWithTools(replyCtx.messages, {
caller: "dictate_agent",
label: "对话落盘 回复",
tools: [DICTATE_REPLY_TOOL],
toolChoice: { type: "function", function: { name: DICTATE_REPLY_TOOL.function.name } },
usageDetail: {
...detail(turnId, "reply", 0, replyCtx.segmentChars),
plannedTags: writtenTags,
},
usageSink: sinkFor(turnId, "reply"),
});
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
const call = filled.toolCalls.find((item) => item.name === DICTATE_REPLY_TOOL.function.name);
if (call) {
const text = replyText({
id: call.id || "reply",
name: call.name,
arguments: call.arguments,
});
if (text) return text;
}
return filled.content?.trim() || fallbackProse;
}
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
return {
role: "assistant",
content,
tool_calls: calls.map((call) => ({
id: call.id,
type: "function" as const,
function: { name: call.name, arguments: call.arguments },
})),
};
}
async function settleWriteStep(
params: PlannedRoundParams,
apply: Apply,
calls: DriverToolCall[],
passed: Set<string>,
pending: Set<string>,
pendingDeletes: Set<string>,
errors: Map<string, string>,
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
const results: Array<{ callId: string; content: string }> = [];
const forward: DriverToolCall[] = [];
let reply = "";
for (const call of calls) {
if (call.name === DICTATE_REPLY_TOOL.function.name) continue;
const tag = callTag(call, params);
if (call.name === "insert" && tag && passed.has(tag)) {
results.push({
callId: call.id,
content: JSON.stringify({ error: "已通过,本步不要再写", tag }),
});
continue;
}
if (call.name === "delete" && tag && passed.has(`del:${tag}`)) {
results.push({
callId: call.id,
content: JSON.stringify({ error: "已通过,本步不要再删除", tag }),
});
continue;
}
if (call.name === "insert" && tag) {
const args = asRecord(safeJson(call.arguments));
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
continue;
}
forward.push(call);
}
const outcome = await apply(forward);
if (outcome.kind === "continue") {
for (const result of outcome.results) {
results.push(result);
const call = forward.find((item) => item.id === result.callId);
const payload = asRecord(safeJson(result.content));
const tag = call ? callTag(call, params) : "";
if (payload.ok === true && tag) {
if (call?.name === "delete") {
passed.add(`del:${tag}`);
pendingDeletes.delete(tag);
} else {
passed.add(tag);
for (const planned of [...pending]) {
if (planned === tag || tag.startsWith(`${planned}#`)) pending.delete(planned);
}
}
errors.delete(tag);
} else if (typeof payload.error === "string" && tag) {
const planned = [...pending, ...pendingDeletes].find(
(item) => item === tag || tag.startsWith(`${item}#`),
);
errors.set(planned ?? tag, payload.error);
}
}
}
for (const call of calls) {
if (call.name !== DICTATE_REPLY_TOOL.function.name) continue;
const text = replyText(call);
if (pending.size > 0 || pendingDeletes.size > 0) {
const left = [...pending, ...pendingDeletes].join("、");
results.push({
callId: call.id,
content: JSON.stringify({
error: `还有未通过:${left}。这份回复先不给用户。只再写入这些。`,
}),
});
continue;
}
reply = text;
results.push({ callId: call.id, content: JSON.stringify({ ok: true }) });
}
return { results, reply };
}
function callTag(call: DriverToolCall, params: PlannedRoundParams): string {
const args = asRecord(safeJson(call.arguments));
const raw = String(args.position ?? args.tag ?? "").trim();
if (!raw) return "";
return canonicalProductTag(raw, "", params.moduleCatalog)?.tag ?? raw;
}
function replyText(call: DriverToolCall): string {
const args = asRecord(safeJson(call.arguments));
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
}
function sinkFor(_turnId: string, _step: string): UsageSink {
return {};
}
function detail(
turnId: string,
step: UsageDetail["step"],
retryIndex: number,
segmentChars: DictateSegmentChars,
): UsageDetail {
return { turnId, step, retryIndex, segmentChars };
}
function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
const products = params.handlers.listProducts();
const plan = parseCreationPlan(
products.find((product) => product.tag === CREATION_PLAN_TAG)?.content,
);
if (plan) {
return {
creationPlanState: formatCreationPlanProgress({
plan,
catalog: params.moduleCatalog,
products,
}),
};
}
return {
capabilityCatalog:
formatCreationPlanCapabilityCatalog(params.moduleCatalog) || params.moduleGuide,
recipeExamples: params.recipeExamples,
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
moduleGuide: params.moduleGuide,
};
}
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
const row = asRecord(raw);
const clarify = stringList(row.clarify);
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
const inserts: PlanInsert[] = [];
let items = Array.isArray(row.nodes)
? row.nodes
: Array.isArray(row.inserts)
? row.inserts
: [];
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
const recovered = recoverSelectWork(row);
items = recovered.items;
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
}
for (const item of items) {
const rec = asRecord(item);
const moduleIdHint = String(rec.module_id ?? rec.moduleId ?? "").trim();
let tag = String(rec.tag ?? rec.position ?? "").trim();
if (!tag && moduleIdHint) {
tag =
params.moduleCatalog?.modules.find((module) => module.id === moduleIdHint)?.artifact?.trim() ??
"";
}
if (!tag) continue;
const canonical = canonicalProductTag(tag, moduleIdHint, params.moduleCatalog);
if (canonical) tag = canonical.tag;
const resolvedHint = canonical?.moduleId || moduleIdHint;
const module =
params.moduleReadIndex?.get(resolvedHint) ??
params.moduleReadIndex?.get(tag) ??
undefined;
const fromTag = resolveModuleForInsertTag(tag, params.moduleCatalog);
const moduleId = module?.module_id || canonical?.moduleId || fromTag?.id || moduleIdHint;
const entryIds = stringList(rec.entry_ids);
const reason = String(rec.reason ?? "").trim();
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
inserts.push({ tag: `!${tag || "空"}`, moduleId, entryIds, reason: "产物 tag 不合法" });
continue;
}
const payload = moduleId ? params.moduleReadIndex?.get(moduleId) : undefined;
if (payload && !READ_EXEMPT.has(payload.module_id) && payload.libraries.length) {
const libraryId = String(rec.library_id ?? payload.libraries[0]?.library_id ?? "").trim();
if (entryIds.length > 2 && !reason) {
inserts.push({
tag: `!${tag}`,
moduleId,
entryIds,
reason: "默认最多两个库条目",
});
continue;
}
if (libraryId && entryIds.length) {
const bound = payload.libraries.some((library) => library.library_id === libraryId);
if (!bound) {
inserts.push({ tag: `!${tag}`, moduleId, entryIds, reason: `能力未绑定库 ${libraryId}` });
continue;
}
}
}
inserts.push({
tag,
moduleId,
libraryId: String(rec.library_id ?? "").trim() || undefined,
entryIds,
reason: reason || undefined,
});
}
return { clarify, inserts, deletes, keys: Object.keys(row) };
}
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
function recoverSelectWork(row: Record<string, unknown>): {
items: unknown[];
deletes: string[];
} {
if (isCreationPlanShape(row)) {
return { items: [{ tag: CREATION_PLAN_TAG, module_id: "creation-plan" }], deletes: [] };
}
const items: unknown[] = [];
const deletes: string[] = [];
for (const list of [row.modules, row.selections, row.selected, row.writes, row.products, row.operations]) {
for (const item of asItemList(list)) {
const rec = flattenItem(item);
if (
typeof rec.tier === "string" &&
typeof rec.why === "string" &&
rec.tag == null &&
rec.position == null
) {
continue;
}
const op = operationName(rec);
if (op === "read_module" || op === "read_library_entry" || op === "prepare_opening") {
continue;
}
const moduleId = String(rec.module_id ?? rec.moduleId ?? "").trim();
const tag = String(rec.tag ?? rec.position ?? rec.artifact ?? "").trim();
if (op === "delete" || op === "remove") {
if (tag) deletes.push(tag);
continue;
}
if (
op === "declare_variable" ||
op === "undeclare_variable" ||
op === "declare_map" ||
op === "remove_map"
) {
continue;
}
if (!tag && !moduleId) continue;
items.push({
tag,
module_id: moduleId,
...(rec.library_id != null ? { library_id: rec.library_id } : {}),
...(rec.entry_ids != null ? { entry_ids: rec.entry_ids } : {}),
...(rec.reason != null ? { reason: rec.reason } : {}),
});
}
}
return { items, deletes };
}
function isCreationPlanShape(row: Record<string, unknown>): boolean {
if (row.schema === CREATION_PLAN_SCHEMA) return true;
if (!Array.isArray(row.items) || row.items.length === 0) return false;
return row.items.every((item) => {
const rec = asRecord(item);
return (
typeof rec.module_id === "string" &&
rec.module_id.trim().length > 0 &&
typeof rec.tier === "string" &&
rec.tag == null &&
rec.position == null
);
});
}
function injectGroup(
params: PlannedRoundParams,
group: PlanInsert[],
): { text: string } {
const blocks: string[] = [];
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
let changed = false;
for (const item of group) {
const payload = item.moduleId ? params.moduleReadIndex?.get(item.moduleId) : undefined;
if (!payload) continue;
const catalogEntry = resolveModuleForInsertTag(item.tag, params.moduleCatalog);
if (!READ_EXEMPT.has(payload.module_id) && !catalogEntry?.auto) {
state.modules[payload.module_id] = payload.version;
state.active_module_id = payload.module_id;
changed = true;
}
const parts = [
`### ${payload.module_id} @ ${payload.version}`,
`产物 tag:${item.tag}`,
payload.score_dimensions.length
? `self_score 维度(这次写入必填,name 必须一致):${payload.score_dimensions.join("、")}`
: "",
`#### task\n${payload.task}`,
`#### principles\n${payload.principles}`,
`#### output\n${payload.output}`,
`#### score\n${payload.score}`,
];
if (item.libraryId && item.entryIds.length) {
const loaded = readLibraryEntries({
module: payload,
libraryId: item.libraryId,
entryIds: item.entryIds,
reason: item.reason,
});
if (loaded.entries) {
for (const entry of loaded.entries) {
const row = entry as { id?: string; content?: string; name?: string };
parts.push(`#### 库 ${item.libraryId} / ${row.id ?? ""}\n${row.content ?? ""}`);
}
} else if (loaded.error) {
parts.push(`#### 库读取失败\n${loaded.error}`);
}
}
blocks.push(parts.filter(Boolean).join("\n\n"));
}
if (changed) {
params.handlers.writeTag(DICTATE_MODULE_READ_STATE_TAG, serializeDictateModuleReadState(state));
}
return { text: blocks.join("\n\n") };
}
function canonicalProductTag(
tag: string,
moduleIdHint: string,
catalog: PlannedRoundParams["moduleCatalog"],
): { tag: string; moduleId: string } | null {
const bare = tag.replace(/^(?:用户|设计)\./, "").split("#")[0]?.trim() ?? "";
const entry =
catalog?.modules.find((module) => module.id === moduleIdHint) ??
resolveModuleForInsertTag(tag, catalog) ??
catalog?.modules.find(
(module) =>
module.name === bare ||
module.id === bare ||
module.artifact === tag ||
module.artifact.endsWith(`.${bare}`),
);
if (!entry?.artifact?.trim()) return null;
const hash = tag.indexOf("#");
const suffix = hash > 0 ? tag.slice(hash) : "";
const next =
entry.repeatable && suffix.startsWith("#") && suffix.length > 1
? `${entry.artifact.trim()}${suffix}`
: entry.artifact.trim();
return { tag: next, moduleId: entry.id };
}
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
const lines = [
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
errors.length
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
: "",
];
return lines.filter(Boolean).join("\n");
}
function asItemList(value: unknown): unknown[] {
if (Array.isArray(value)) return value;
const rec = asRecord(value);
const values = Object.values(rec);
if (values.length > 0 && values.every((item) => item && typeof item === "object")) {
return values;
}
return [];
}
function flattenItem(item: unknown): Record<string, unknown> {
if (typeof item === "string") {
const text = item.trim();
if (!text) return {};
if (text.startsWith("设计.") || text.startsWith("用户.")) return { tag: text };
return { module_id: text };
}
const rec = asRecord(item);
const nested = asRecord(rec.arguments ?? rec.args ?? rec.payload);
return { ...rec, ...nested };
}
function operationName(rec: Record<string, unknown>): string {
return String(rec.op ?? rec.name ?? rec.type ?? rec.action ?? "insert").trim();
}
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
+68 -2
View File
@@ -3,6 +3,66 @@ import type { ToolDefinition } from "../llm/client.js";
/** /**
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。 * 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
*/ */
/** 第一次请求仅此一个工具:点出下一步要写的节点。 */
export const DICTATE_SELECT_TOOL: ToolDefinition = {
type: "function",
function: {
name: "select_nodes",
description:
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。",
parameters: {
type: "object",
additionalProperties: false,
properties: {
nodes: {
type: "array",
description: "这段用户话要填充的节点。",
items: {
type: "object",
additionalProperties: false,
properties: {
module_id: { type: "string", description: "能力目录中的 id" },
},
required: ["module_id"],
},
},
},
required: ["nodes"],
},
},
};
/** 写入循环的收尾。未通过的节点还在时,程序不把这份正文给用户。 */
export const DICTATE_REPLY_TOOL: ToolDefinition = {
type: "function",
function: {
name: "reply_user",
description:
"本步必调。与 insert 放在同一次请求:节点表写完后立刻提交给用户的综合回复。正文按系统提示「用户可见回复」写。",
parameters: {
type: "object",
additionalProperties: false,
properties: {
text: {
type: "string",
description:
"综合回复。先回应原话并说明形成了什么,再写自评最稳处;每个分数一般的缺口含短例子、一句体验变化和 2~3 个可接着说的选项;接着写下一步可以提及哪些,并补一个相关设定。多个产物合成一段。",
},
},
required: ["text"],
},
},
};
const WRITE_LOOP_TOOL_NAMES = new Set([
"insert",
"delete",
"declare_variable",
"undeclare_variable",
"declare_map",
"remove_map",
]);
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
{ {
type: "function", type: "function",
@@ -80,12 +140,12 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
position: { position: {
type: "string", type: "string",
description: description:
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白、设计.开场白#dorm;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。勿用本工具写变量目录/映射。", "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白#opening-cycle2;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。开场必须带场景短码,不要写裸「设计.开场白」或「设计.开场白与开场变量」。勿用本工具写变量目录/映射。",
}, },
content: { content: {
type: "string", type: "string",
description: description:
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白[#短码]=叙事正文(有正文组成时为 present.v1 JSON;否则 Markdown),必须含字面 @玩家,禁止写入用户角色.名字/简介;设计.主角设定[#同短码]=名字/背景/特殊设定 JSON,与开场同短码即绑定该开局", "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白#场景短码=这一张开场的 present.v1,blocks 必须含正文组成数据契约里的全部模型生成区(用来灌进已固定前端并替换示例),不要只写 shell+body,也不要填程序投影区;必须含字面 @玩家,名字不进 blocks。这条开场的玩家角色写在 meta.用户角色(名字+一到三句客观事实),程序同步为同短码主角设定。这张开场有初值才在同一份正文里写开场变量,没有就不写。设计.主角设定=共通的名字/背景/特殊设定 JSON;各开场不同的玩家角色写进开场 meta,不另写主角设定#短码",
}, },
order: { order: {
type: "number", type: "number",
@@ -283,3 +343,9 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
}, },
}, },
]; ];
/** 第二次起的工具。不含 read_module:写法已由程序注入。同一轮重试不改变这份清单。 */
export const DICTATE_WRITE_LOOP_TOOLS: ToolDefinition[] = [
...DICTATE_TOOL_DEFINITIONS.filter((tool) => WRITE_LOOP_TOOL_NAMES.has(tool.function.name)),
DICTATE_REPLY_TOOL,
];
+146 -3
View File
@@ -1,9 +1,9 @@
import type { LlmProvider } from "../llm/client.js"; import type { LlmProvider } from "../llm/client.js";
import { import {
createLocalLlmDriver,
DriverIterationLimitError, DriverIterationLimitError,
type AgentDriver, type AgentDriver,
} from "../runtime/driver.js"; } from "../runtime/driver.js";
import { createPlannedDictateDriver } from "./round.js";
import { buildDictateMessages } from "./context.js"; import { buildDictateMessages } from "./context.js";
import { buildDictateSystemPrompt } from "./prompt.js"; import { buildDictateSystemPrompt } from "./prompt.js";
import { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; import { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
@@ -41,8 +41,14 @@ import {
serializeVariableCatalog, serializeVariableCatalog,
upsertVariableField, upsertVariableField,
removeVariableField, removeVariableField,
seedVariablesFromCatalog,
type UpsertVariableInput, type UpsertVariableInput,
} from "../skills/variable-catalog.js"; } from "../skills/variable-catalog.js";
import {
VARIABLE_DESIGN_TAG,
syncVariableDesignToRuntime,
writeVariableDesignSync,
} from "../skills/variable-design-compile.js";
import { import {
VALUE_MAP_TAG, VALUE_MAP_TAG,
parseValueMapDoc, parseValueMapDoc,
@@ -57,6 +63,46 @@ import {
} from "../blackboard/table-cells.js"; } from "../blackboard/table-cells.js";
import { isDictateOpeningTag } from "./experience-anchor.js"; import { isDictateOpeningTag } from "./experience-anchor.js";
import { canonicalizeInsertedContent } from "../parse/json-doc.js"; import { canonicalizeInsertedContent } from "../parse/json-doc.js";
import {
openingContractError,
parseReplyDataContract,
replyContractSummary,
type ReplyDataContract,
} from "../skills/reply-contract.js";
function seedCurrentFromCatalog(handlers: DictateTurnHandlers): string[] {
const seeded = seedVariablesFromCatalog({
catalogRaw: handlers.readTag(VARIABLE_CATALOG_TAG),
currentRaw: handlers.readTag("变量.当前"),
initialRaw: handlers.readTag("运行.初始变量"),
actor: "system:declare_variable",
});
const wrote: string[] = [];
if (seeded.initial) {
handlers.writeTag("运行.初始变量", seeded.initial);
wrote.push("运行.初始变量");
}
if (seeded.current) {
handlers.writeTag("变量.当前", seeded.current);
wrote.push("变量.当前");
}
return wrote;
}
function syncDesignProductToTable(
handlers: DictateTurnHandlers,
designContent?: string,
): string[] {
const sync = syncVariableDesignToRuntime({
designRaw: designContent ?? handlers.readTag(VARIABLE_DESIGN_TAG),
catalogRaw: handlers.readTag(VARIABLE_CATALOG_TAG),
currentRaw: handlers.readTag("变量.当前"),
initialRaw: handlers.readTag("运行.初始变量"),
});
return writeVariableDesignSync(sync, (tag, content) =>
handlers.writeTag(tag, content),
);
}
export type DictateTurnHandlers = { export type DictateTurnHandlers = {
listProducts: () => DictateProduct[]; listProducts: () => DictateProduct[];
@@ -127,7 +173,28 @@ export async function runDictateTurn(params: {
repeatableFamilies?: ReadonlySet<string> | readonly string[]; repeatableFamilies?: ReadonlySet<string> | readonly string[];
driver?: AgentDriver; driver?: AgentDriver;
}): Promise<DictateTurnResult> { }): Promise<DictateTurnResult> {
const driver = params.driver ?? createLocalLlmDriver(params.llm); const driver =
params.driver ??
createPlannedDictateDriver({
llm: params.llm,
dialogue: params.dialogue,
handlers: {
listProducts: params.handlers.listProducts,
readTag: params.handlers.readTag,
writeTag: params.handlers.writeTag,
prepareOpening: params.handlers.prepareOpening,
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
onThinkingDelta: params.handlers.onThinkingDelta,
onThinkingDone: params.handlers.onThinkingDone,
},
moduleCatalog: params.moduleCatalog,
moduleReadIndex: params.moduleReadIndex,
moduleGuide: params.moduleGuide,
recipeExamples: params.recipeExamples,
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
systemPrompt: params.systemPrompt,
});
const wroteTags: string[] = []; const wroteTags: string[] = [];
const deletedTags: string[] = []; const deletedTags: string[] = [];
let clearedDialogue = false; let clearedDialogue = false;
@@ -217,9 +284,31 @@ export async function runDictateTurn(params: {
call.name, call.name,
`${module.module_id}@${module.version}`, `${module.module_id}@${module.version}`,
); );
const replyContract =
module.module_id === "opening-setup" ||
module.module_id === "zero-layer-opening-setup"
? contractForOpeningTag(
module.module_id === "zero-layer-opening-setup"
? "设计.0层开场白与初态"
: "设计.开场白",
params.handlers.listProducts(),
)
: undefined;
return { return {
callId: call.id, callId: call.id,
content: JSON.stringify({ ok: true, ...module }), content: JSON.stringify({
ok: true,
...module,
...(replyContract
? { reply_contract: replyContractSummary(replyContract) }
: module.module_id === "opening-setup" ||
module.module_id === "zero-layer-opening-setup"
? {
reply_contract_missing:
"还没有可执行的正文组成数据契约。先写正文组成,再写开场。",
}
: {}),
}),
}; };
} }
if (call.name === "read_library_entry") { if (call.name === "read_library_entry") {
@@ -385,6 +474,18 @@ export async function runDictateTurn(params: {
const order = parseDictateOrder(args.order); const order = parseDictateOrder(args.order);
const productsBeforeWrite = params.handlers.listProducts(); const productsBeforeWrite = params.handlers.listProducts();
const isOpening = isDictateOpeningTag(pieceTag); const isOpening = isDictateOpeningTag(pieceTag);
const contractMiss = openingContractMiss(
pieceTag,
piece.content,
params.handlers.listProducts(),
);
if (contractMiss) {
params.handlers.onToolCall?.(call.name, contractMiss.error);
return {
callId: call.id,
content: JSON.stringify(contractMiss),
};
}
const explicitlyPrepared = openingPrepared; const explicitlyPrepared = openingPrepared;
if (isOpening && !explicitlyPrepared) { if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.(); params.handlers.prepareOpening?.();
@@ -464,6 +565,18 @@ export async function runDictateTurn(params: {
const order = parseDictateOrder(args.order); const order = parseDictateOrder(args.order);
const productsBeforeWrite = params.handlers.listProducts(); const productsBeforeWrite = params.handlers.listProducts();
const isOpening = isDictateOpeningTag(tag); const isOpening = isDictateOpeningTag(tag);
const contractMiss = openingContractMiss(
tag,
content,
params.handlers.listProducts(),
);
if (contractMiss) {
params.handlers.onToolCall?.(call.name, contractMiss.error);
return {
callId: call.id,
content: JSON.stringify(contractMiss),
};
}
const explicitlyPrepared = openingPrepared; const explicitlyPrepared = openingPrepared;
if (isOpening && !explicitlyPrepared) { if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.(); params.handlers.prepareOpening?.();
@@ -480,6 +593,10 @@ export async function runDictateTurn(params: {
: {}), : {}),
...(selfScore.value ? { selfScore: selfScore.value } : {}), ...(selfScore.value ? { selfScore: selfScore.value } : {}),
}); });
if (tag === VARIABLE_DESIGN_TAG || tag.startsWith(`${VARIABLE_DESIGN_TAG}#`)) {
const synced = syncDesignProductToTable(params.handlers, content);
wroteTags.push(...synced);
}
if (!isOpening) openingPrepared = false; if (!isOpening) openingPrepared = false;
wroteTags.push(tag); wroteTags.push(tag);
const detail = const detail =
@@ -557,6 +674,8 @@ export async function runDictateTurn(params: {
serializeVariableCatalog(doc), serializeVariableCatalog(doc),
); );
wroteTags.push(VARIABLE_CATALOG_TAG); wroteTags.push(VARIABLE_CATALOG_TAG);
const seeded = seedCurrentFromCatalog(params.handlers);
wroteTags.push(...seeded);
const detail = `${input.key} visible=${doc.fields.find((f) => f.key === input.key)?.user_visible !== false}`; const detail = `${input.key} visible=${doc.fields.find((f) => f.key === input.key)?.user_visible !== false}`;
params.handlers.onToolCall?.(call.name, detail); params.handlers.onToolCall?.(call.name, detail);
return { return {
@@ -826,6 +945,30 @@ function safeParseArgs(raw: string): Record<string, unknown> {
} }
} }
function contractForOpeningTag(
tag: string,
products: DictateProduct[],
): ReplyDataContract | undefined {
const want = tag.includes("0层") ? "设计.0层正文组成" : "设计.正文组成";
const raw =
products.find((product) => product.tag === want)?.content ??
products.find((product) => product.tag === "设计.回复格式")?.content;
return parseReplyDataContract(raw);
}
function openingContractMiss(
tag: string,
content: string,
products: DictateProduct[],
): { error: string; reply_contract?: ReturnType<typeof replyContractSummary> } | undefined {
if (!isDictateOpeningTag(tag)) return undefined;
const contract = contractForOpeningTag(tag, products);
if (!contract) return undefined;
const error = openingContractError(content, contract);
if (!error) return undefined;
return { error, reply_contract: replyContractSummary(contract) };
}
function requiresModuleRead(moduleId: string, auto?: boolean): boolean { function requiresModuleRead(moduleId: string, auto?: boolean): boolean {
if (auto) return false; if (auto) return false;
return !new Set([ return !new Set([
+17 -6
View File
@@ -1423,6 +1423,14 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
"refine", "refine",
]); ]);
/** 落档程序或目录标了 auto 的节点:创作对话不 insert */
export function isDictateProgramModule(module: {
id: string;
auto?: boolean;
}): boolean {
return Boolean(module.auto) || DICTATE_PROGRAM_SIDE_MODULE_IDS.has(module.id);
}
/** /**
* 注入对话落盘:能力何时落盘(meta when/when_not)+ insert 目标 tag。 * 注入对话落盘:能力何时落盘(meta when/when_not)+ insert 目标 tag。
* 不写 DAG / design-step 话术;不塞执行全文。 * 不写 DAG / design-step 话术;不塞执行全文。
@@ -1430,7 +1438,7 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
const dictateModules = catalogModulesForIntake(catalog, "dictate"); const dictateModules = catalogModulesForIntake(catalog, "dictate");
const lines = dictateModules.map((m) => { const lines = dictateModules.map((m) => {
const programSide = DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto; const programSide = isDictateProgramModule(m);
const flags = [ const flags = [
m.repeatable ? "〔可反复〕" : "", m.repeatable ? "〔可反复〕" : "",
m.closer ? "〔收口〕" : "", m.closer ? "〔收口〕" : "",
@@ -1439,7 +1447,7 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
] ]
.filter(Boolean) .filter(Boolean)
.join(""); .join("");
const land = dictateLandHint(m); const land = dictateModuleLandHint(m);
const parts: string[] = [ const parts: string[] = [
`- ${m.name}${flags}:${m.declaration}`, `- ${m.name}${flags}:${m.declaration}`,
` 落盘:${land}`, ` 落盘:${land}`,
@@ -1455,7 +1463,7 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
"「必须」和「有必要」里用户还没提到的:本轮不落盘,按已有材料给 2~3 个选项询问,选定后再写。含糊或未到时机的下游同样先问,不先写。", "「必须」和「有必要」里用户还没提到的:本轮不落盘,按已有材料给 2~3 个选项询问,选定后再写。含糊或未到时机的下游同样先问,不先写。",
"同轮写入的每份产物各回 reply_module,可见回复综合成一次。", "同轮写入的每份产物各回 reply_module,可见回复综合成一次。",
"标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。", "标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。",
"标〔收口〕的开场:写开场前,列出仍未落的「必须」和「有必要」,问用户这些还没设计要补哪几项或明确不要。用户这句已经说清的再写入。普通模式使用回复呈现/正文组成,0 层模式使用对应 0 层节点,不得混用。用户确认可以收口后,程序依次编译信息可见范围、游玩执行方式、上下文排序与运行配置,再写所选模式的开场产物。", "标〔收口〕的开场:写开场前,列出仍未落的「必须」和「有必要」,问用户这些还没设计要补哪几项或明确不要。用户这句已经说清的再写入。普通模式使用回复呈现/正文组成,0 层模式使用对应 0 层节点,不得混用。用户确认可以收口后,程序依次按有无变量真值固定开旁观填表、编译信息可见范围、游玩执行方式、上下文排序与运行配置,再写所选模式的开场产物。",
"标〔可反复〕的(生成规则、具体实例、开场白、拓扑等):每条单独落盘,用「基名#唯一id」(生成规则用规则名,具体实例用名称,开场白用场景短码)。同完整 tag 才覆盖,禁止反复写基名盖掉别的条。开场全部保留;游玩时左右切换,选中的一条作为 0 层。", "标〔可反复〕的(生成规则、具体实例、开场白、拓扑等):每条单独落盘,用「基名#唯一id」(生成规则用规则名,具体实例用名称,开场白用场景短码)。同完整 tag 才覆盖,禁止反复写基名盖掉别的条。开场全部保留;游玩时左右切换,选中的一条作为 0 层。",
"能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。", "能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。",
lines.join("\n"), lines.join("\n"),
@@ -1464,12 +1472,15 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
return head; return head;
} }
function dictateLandHint(m: ModuleCatalogEntry): string { export function dictateModuleLandHint(m: ModuleCatalogEntry): string {
if (DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto) { if (isDictateProgramModule(m)) {
if (m.id === "worker-spec") {
return "落档/开玩程序处理:有变量真值时固定开旁观填表,创作期一般不 insert";
}
return "落档/开玩程序处理;创作期一般不 insert"; return "落档/开玩程序处理;创作期一般不 insert";
} }
if (m.id === "variable-design") { if (m.id === "variable-design") {
return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」"; return "insert 「设计.变量设计与更新规则」(有真值时程序固定旁观副 LLM 填表,并同步目录与真表);或用 declare_variable / declare_map 逐条钉目录";
} }
if (m.id === "opening-setup") { if (m.id === "opening-setup") {
return "insert 「设计.开场白#场景短码」——可增殖:每条开场各占一 tag;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层"; return "insert 「设计.开场白#场景短码」——可增殖:每条开场各占一 tag;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
+6 -2
View File
@@ -1230,8 +1230,12 @@ modules:
expect(sections.blocks.task).toContain("默认只生成 1 条"); expect(sections.blocks.task).toContain("默认只生成 1 条");
expect(sections.blocks.task).toContain("@玩家"); expect(sections.blocks.task).toContain("@玩家");
expect(sections.blocks.task).toContain("随机"); expect(sections.blocks.task).toContain("随机");
expect(sections.blocks.output).toContain('"默认候选id"'); expect(module?.artifact).toBe("设计.开场白");
expect(sections.blocks.output).toContain('"开场白候选"'); 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).not.toContain('"开场白全文"');
expect(sections.blocks.output).toContain("present.v1"); expect(sections.blocks.output).toContain("present.v1");
expect(sections.blocks.output).not.toContain('"自评"'); expect(sections.blocks.output).not.toContain('"自评"');
+34 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
CREATION_PLAN_TAG, CREATION_PLAN_TAG,
buildDictateSystemPrompt, buildDictateSystemPrompt,
formatDictateRoundTail,
creationPlanModules, creationPlanModules,
formatCreationPlanCapabilityCatalog, formatCreationPlanCapabilityCatalog,
formatCreationPlanProgress, formatCreationPlanProgress,
@@ -9,7 +10,9 @@ import {
validateCreationPlan, validateCreationPlan,
type CreationPlan, type CreationPlan,
} from "../src/dictate/index.js"; } from "../src/dictate/index.js";
import { moduleBrowseGroup } from "../src/dictate/creation-plan.js";
import { import {
catalogModulesForIntake,
formatModuleCatalogForDictate, formatModuleCatalogForDictate,
loadModuleCatalog, loadModuleCatalog,
} from "../src/skills/creation-flow.js"; } 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 () => { it("selects exactly one presentation chain", async () => {
const catalog = await loadModuleCatalog("dialogue/world-simulator"); const catalog = await loadModuleCatalog("dialogue/world-simulator");
expect(catalog).toBeTruthy(); expect(catalog).toBeTruthy();
@@ -160,17 +189,19 @@ describe("creation plan", () => {
expect(unusedSection).not.toContain("何时用:"); expect(unusedSection).not.toContain("何时用:");
const oldFullGuide = formatModuleCatalogForDictate(catalog); const oldFullGuide = formatModuleCatalogForDictate(catalog);
const before = buildDictateSystemPrompt({ const fixed = buildDictateSystemPrompt();
const before = formatDictateRoundTail({
capabilityCatalog: shortCatalog, capabilityCatalog: shortCatalog,
recipeExamples: "- text-generator:文本生成案例", recipeExamples: "- text-generator:文本生成案例",
}); });
const after = buildDictateSystemPrompt({ creationPlanState: progress }); const after = formatDictateRoundTail({ creationPlanState: progress });
expect(fixed).not.toContain("当前决策");
expect(before).toContain("尚无本局创作方案"); expect(before).toContain("尚无本局创作方案");
expect(after).toContain("当前决策"); expect(after).toContain("当前决策");
expect(after).toContain("收口前的核对清单"); expect(after).toContain("收口前的核对清单");
expect(after).not.toContain("旧配方案例目录"); expect(after).not.toContain("旧配方案例目录");
expect(after.length).toBeLessThan( 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 { describe, expect, it } from "vitest";
import { Blackboard } from "../src/blackboard/blackboard.js"; import { Blackboard } from "../src/blackboard/blackboard.js";
import { import {
applyDictatePlayBind,
buildDictateContextOrder, buildDictateContextOrder,
bindDictateProductsToPlaySpec, bindDictateProductsToPlaySpec,
collectDictateBindProducts, collectDictateBindProducts,
DICTATE_PLAY_BIND_INPUT_META,
isDictateStyleBindTag, isDictateStyleBindTag,
} from "../src/dictate/play-bind.js"; } from "../src/dictate/play-bind.js";
import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js"; import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js";
@@ -215,4 +217,51 @@ describe("dictate play-bind", () => {
expect(refs).toContain("上下文.角色态度"); expect(refs).toContain("上下文.角色态度");
expect(refs).not.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: "{}", content: "{}",
self_score: { self_score: {
dims: [ dims: [
{ name: "核心体验", score: 5 }, { name: "美学纲领", score: 5 },
{ name: "交互范式", score: 5 },
{ name: "背景与规则", score: 5 }, { name: "背景与规则", score: 5 },
], ],
}, },
@@ -82,7 +83,8 @@ describe("dictate read_module", () => {
content: "{}", content: "{}",
self_score: { self_score: {
dims: [ dims: [
{ name: "核心体验", score: 5 }, { name: "美学纲领", score: 5 },
{ name: "交互范式", score: 5 },
{ name: "背景与规则", score: 3, gap: "规则因果仍需确认" }, { name: "背景与规则", score: 3, gap: "规则因果仍需确认" },
], ],
}, },
@@ -205,7 +207,8 @@ describe("dictate read_module", () => {
content: "{}", content: "{}",
self_score: { self_score: {
dims: [ dims: [
{ name: "核心体验", score: 3 }, { name: "美学纲领", score: 3 },
{ name: "交互范式", score: 5 },
{ name: "背景与规则", score: 5 }, { name: "背景与规则", score: 5 },
], ],
}, },
@@ -226,7 +229,8 @@ describe("dictate read_module", () => {
content: "{}", content: "{}",
self_score: { self_score: {
dims: [ dims: [
{ name: "核心体验", score: 3, gap: "发生方式仍偏宽" }, { name: "美学纲领", score: 3, gap: "发生方式仍偏宽" },
{ name: "交互范式", score: 5 },
{ name: "背景与规则", score: 5 }, { name: "背景与规则", score: 5 },
], ],
}, },
@@ -254,13 +258,114 @@ describe("dictate read_module", () => {
layer: "final", layer: "final",
selfScore: { selfScore: {
dims: expect.arrayContaining([ 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>) { function handlers(tags: Map<string, string>) {
return { return {
listProducts: () => 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 { import {
buildDictateMessages, buildDictateMessages,
buildDictateSystemPrompt, buildDictateSystemPrompt,
formatDictateRoundTail,
buildDictateInsertFeedbackIndex, buildDictateInsertFeedbackIndex,
defaultDictateOrder, defaultDictateOrder,
extractDictateDialogue, extractDictateDialogue,
@@ -47,7 +48,7 @@ describe("dictate context", () => {
}); });
const body = messages[1]?.content ?? ""; const body = messages[1]?.content ?? "";
expect(body).toContain("冷色短句"); expect(body).toContain("冷色短句");
expect(body).toContain("尚无对话"); expect(body).toContain("尚无更早对话");
}); });
it("extracts only user + dictate_reply turns", () => { it("extracts only user + dictate_reply turns", () => {
@@ -73,8 +74,9 @@ describe("dictate context", () => {
expect(isDictateModeValue("recipe")).toBe(false); expect(isDictateModeValue("recipe")).toBe(false);
}); });
it("system prompt starts from a plan and keeps generic write tools", () => { it("system prompt keeps stable rules; catalog and plan progress sit in the tail", () => {
const prompt = buildDictateSystemPrompt({ const prompt = buildDictateSystemPrompt();
const tail = formatDictateRoundTail({
recipeName: "数据化跑团体验", recipeName: "数据化跑团体验",
recipeBrief: recipeBrief:
"设定 + 真值 + 分档映射\n怎么做:\n- declare_variable\n- declare_map", "设定 + 真值 + 分档映射\n怎么做:\n- declare_variable\n- declare_map",
@@ -90,17 +92,31 @@ describe("dictate context", () => {
expect(prompt).toContain("declare_map"); expect(prompt).toContain("declare_map");
expect(prompt).toContain("remove_map"); expect(prompt).toContain("remove_map");
expect(prompt).toContain("同 position 再 insert"); expect(prompt).toContain("同 position 再 insert");
expect(prompt).toContain("数据化跑团体验"); expect(prompt).not.toContain("数据化跑团体验");
expect(prompt).toMatch(/order/); expect(prompt).toMatch(/order/);
expect(prompt).toContain("【能力 · 何时落盘】"); expect(prompt).not.toContain("【能力 · 何时落盘】");
expect(prompt).toContain("设计.美学纲领与交互范式");
expect(prompt).toContain("通常 2~3 个"); expect(prompt).toContain("通常 2~3 个");
expect(prompt).toContain("给 2~3 个选项来问"); expect(prompt).toContain("给 2~3 个选项来问");
expect(prompt).toContain("不是 DAG"); expect(prompt).toContain("不是 DAG");
expect(prompt).toContain("不是用户已选路径"); expect(prompt).not.toContain("不是用户已选路径");
expect(prompt).toContain("自然回应"); expect(prompt).toContain("自然回应");
expect(prompt.indexOf("填写收尾")).toBeLessThan(prompt.indexOf("写入规则"));
expect(prompt).not.toContain("体验锚定(程序打分)"); expect(prompt).not.toContain("体验锚定(程序打分)");
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 () => { it("formats module catalog for dictate with land tags and when", async () => {
@@ -172,6 +188,12 @@ describe("dictate context", () => {
expect( expect(
resolveModuleForInsertTag("设计.开场白", catalog!)?.id, resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
).toBe("opening-setup"); ).toBe("opening-setup");
expect(
resolveModuleForInsertTag("设计.开场白#opening-cycle2", catalog!)?.id,
).toBe("opening-setup");
expect(
resolveModuleForInsertTag("设计.开场白与开场变量", catalog!)?.id,
).toBe("opening-setup");
expect( expect(
resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id, resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id,
).toBe("concrete-instances"); ).toBe("concrete-instances");
@@ -244,31 +266,47 @@ describe("dictate context", () => {
expect("error" in overwriteBare).toBe(true); expect("error" in overwriteBare).toBe(true);
const openingFamilies = new Set(["设计.开场白", "设计.主角设定"]); const openingFamilies = new Set(["设计.开场白", "设计.主角设定"]);
const firstOpen = resolveRepeatableInsertTag({ const bareOpen = resolveRepeatableInsertTag({
tag: "设计.开场白", tag: "设计.开场白",
content: "@玩家推开门。", content: "@玩家推开门。",
existingTags: [], existingTags: [],
repeatableFamilies: openingFamilies, repeatableFamilies: openingFamilies,
}); });
expect("tag" in firstOpen && firstOpen.tag).toBe("设计.开场白"); expect("error" in bareOpen).toBe(true);
const overwriteFirst = resolveRepeatableInsertTag({ const namedFromBundle = resolveRepeatableInsertTag({
tag: "设计.开场白", tag: "设计.开场白与开场变量",
content: "@玩家改写第一幕。", content: JSON.stringify({
schema: "present.v1",
id: "opening-cycle2",
blocks: { body: "@玩家推开门。" },
}),
existingTags: ["设计.开场白"], existingTags: ["设计.开场白"],
repeatableFamilies: openingFamilies, 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({ const secondOpen = resolveRepeatableInsertTag({
tag: "设计.开场白#gate", tag: "设计.开场白#gate",
content: "@玩家在闸机前。", content: "@玩家在闸机前。",
existingTags: ["设计.开场白"], existingTags: ["设计.开场白#opening-cycle2"],
repeatableFamilies: openingFamilies, repeatableFamilies: openingFamilies,
}); });
expect("tag" in secondOpen && secondOpen.tag).toBe("设计.开场白#gate"); expect("tag" in secondOpen && secondOpen.tag).toBe("设计.开场白#gate");
const bareOpenConflict = resolveRepeatableInsertTag({ const bareOpenConflict = resolveRepeatableInsertTag({
tag: "设计.开场白", tag: "设计.开场白",
content: "又写一条", content: "又写一条",
existingTags: ["设计.开场白", "设计.开场白#gate"], existingTags: ["设计.开场白#opening-cycle2", "设计.开场白#gate"],
repeatableFamilies: openingFamilies, repeatableFamilies: openingFamilies,
}); });
expect("error" in bareOpenConflict).toBe(true); expect("error" in bareOpenConflict).toBe(true);