From 1cc4ae557a03c8f91dac83f4fb82b96264bc5a83 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 7 Oct 2026 23:48:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9B=E4=BD=9C=E4=B8=80=E8=BD=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=85=88=E9=80=89=E9=A2=98=E5=86=8D=E5=A1=AB=E5=86=99?= =?UTF-8?q?=EF=BC=8C=E5=87=8F=E5=B0=91=E5=B7=A5=E5=85=B7=E7=A9=BA=E8=BD=AC?= =?UTF-8?q?=E5=92=8C=E9=87=8D=E5=A4=8D=E8=A6=86=E7=9B=96=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上下文按规则、历史、产物分段拼装,便于吃提示缓存并核对费用。 Co-authored-by: Cursor --- docs/briefs/creation-round-cost.md | 84 +++ docs/briefs/creation-round-request.docx | Bin 0 -> 6997 bytes docs/briefs/dictate-only-creation.md | 2 +- src/dictate/context.ts | 190 +++++- src/dictate/creation-plan.ts | 48 ++ src/dictate/index.ts | 17 +- src/dictate/insert-feedback.ts | 4 +- src/dictate/play-bind.ts | 99 ++- src/dictate/prompt.ts | 141 ++-- src/dictate/repeatable-tags.ts | 89 ++- src/dictate/round.ts | 721 ++++++++++++++++++++ src/dictate/tools.ts | 70 +- src/dictate/turn.ts | 149 +++- src/skills/creation-flow.ts | 23 +- tests/creation-flow.test.ts | 8 +- tests/creation-plan.test.ts | 37 +- tests/dictate-play-bind.test.ts | 49 ++ tests/dictate-read-module.test.ts | 115 +++- tests/dictate-round.test.ts | 869 ++++++++++++++++++++++++ tests/dictate.test.ts | 68 +- 20 files changed, 2660 insertions(+), 123 deletions(-) create mode 100644 docs/briefs/creation-round-cost.md create mode 100644 docs/briefs/creation-round-request.docx create mode 100644 src/dictate/round.ts create mode 100644 tests/dictate-round.test.ts diff --git a/docs/briefs/creation-round-cost.md b/docs/briefs/creation-round-cost.md new file mode 100644 index 0000000..6ba674d --- /dev/null +++ b/docs/briefs/creation-round-cost.md @@ -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 千字,一次写两三份会在产物之上再加一两成,但只发送一次。 diff --git a/docs/briefs/creation-round-request.docx b/docs/briefs/creation-round-request.docx new file mode 100644 index 0000000000000000000000000000000000000000..7714e671317cb61c10d61387f9506972bfb15f50 GIT binary patch literal 6997 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 F{sZ;YDK`KB literal 0 HcmV?d00001 diff --git a/docs/briefs/dictate-only-creation.md b/docs/briefs/dictate-only-creation.md index d2ecf24..1795275 100644 --- a/docs/briefs/dictate-only-creation.md +++ b/docs/briefs/dictate-only-creation.md @@ -288,7 +288,7 @@ 写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。 -写正文组成或 0 层正文组成时,回复里直接给出一份隔离内容供预览:与本局前端同一套骨架,示例已经写进去。完整 JSON 仍由产物卡展示。 +正文组成、0 层正文组成和开场的画面由产物卡按 frontend 渲染。完整 JSON 仍由产物卡展示。 ## 6. 工作包 D · 游玩投影编译〔未开始〕 diff --git a/src/dictate/context.ts b/src/dictate/context.ts index af05ec4..26c5a01 100644 --- a/src/dictate/context.ts +++ b/src/dictate/context.ts @@ -6,42 +6,140 @@ import { type DictateProduct, } 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: { systemPrompt: string; products: DictateProduct[]; dialogue: DictateChatTurn[]; + phase?: DictateContextPhase; + injected?: string; + tail?: string; }): ChatMessage[] { - const parts: string[] = []; - 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") }, - ]; + return buildDictateContext(params).messages; } /** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */ @@ -62,3 +160,37 @@ export function extractDictateDialogue( } 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"); +} diff --git a/src/dictate/creation-plan.ts b/src/dictate/creation-plan.ts index aae3ecd..1189123 100644 --- a/src/dictate/creation-plan.ts +++ b/src/dictate/creation-plan.ts @@ -1,5 +1,6 @@ import { catalogModulesForIntake, + isDictateProgramModule, type ModuleCatalog, type ModuleCatalogEntry, } from "../skills/creation-flow.js"; @@ -33,6 +34,53 @@ const ALL_PRESENTATION_MODULE_IDS = new Set( 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 = { module_id: string; tier: CreationPlanTier; diff --git a/src/dictate/index.ts b/src/dictate/index.ts index aff7f4c..57d35c1 100644 --- a/src/dictate/index.ts +++ b/src/dictate/index.ts @@ -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 { CREATION_INTAKE_MODE_TAG, DICTATE_MODE_VALUE, @@ -16,10 +27,6 @@ export { type DictateSelfScore, type DictateChatTurn, } from "./types.js"; -export { - buildDictateMessages, - extractDictateDialogue, -} from "./context.js"; export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; export { runDictateTurn, type DictateTurnResult } from "./turn.js"; export { diff --git a/src/dictate/insert-feedback.ts b/src/dictate/insert-feedback.ts index c625008..96fcffb 100644 --- a/src/dictate/insert-feedback.ts +++ b/src/dictate/insert-feedback.ts @@ -15,6 +15,7 @@ import { /** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */ export const DICTATE_INSERT_TAG_ALIASES: Readonly> = { "设计.开场白": "opening-setup", + "设计.开场白与开场变量": "opening-setup", "设计.美学纲领": "aesthetics-interaction", "设计.文风": "narrative-guide", "设计.监控栏": "status-bar", @@ -54,7 +55,8 @@ export function resolveModuleForInsertTag( 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) { return catalog.modules.find((m) => m.id === aliasId) ?? null; } diff --git a/src/dictate/play-bind.ts b/src/dictate/play-bind.ts index 8024121..b667fcd 100644 --- a/src/dictate/play-bind.ts +++ b/src/dictate/play-bind.ts @@ -1,7 +1,9 @@ /** * 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。 * 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。 + * 保存时按上游产物指纹决定是否重跑:没改过就保留已有排序和运行配置。 */ +import { createHash } from "node:crypto"; import type { Blackboard } from "../blackboard/blackboard.js"; import { CONTEXT_ORDER_SCHEMA, @@ -38,6 +40,12 @@ import { VALUE_MAP_TAG, listValueMapTargetTags, } from "../skills/value-map.js"; +import { + applyMaintenanceStrategyToPlaySlots, + MAINTENANCE_STRATEGY_TAG, + resolveMaintenanceStrategyFromBoard, + writeMaintenanceStrategy, +} from "../skills/maintenance-strategy.js"; import { DICTATE_ORDER_META_KEY, effectiveDictateOrder, @@ -57,6 +65,7 @@ const SKIP_BIND_TAGS = new Set([ CREATION_SELECTED_RECIPE_TAG, VARIABLE_CATALOG_TAG, VALUE_MAP_TAG, + MAINTENANCE_STRATEGY_TAG, ]); function shouldSkipBindTag(tag: string): boolean { @@ -77,11 +86,16 @@ const STYLE_TAG_RE = /** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */ const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]); +/** 上次编译时所依据的上游指纹;两边产物一致且未过期则跳过重跑 */ +export const DICTATE_PLAY_BIND_INPUT_META = "dictatePlayBindInput"; + export type DictatePlayBindResult = { workerSetJson: string; contextOrder: ContextOrderDoc; boundTags: string[]; changed: boolean; + /** 上游产物未改,保留已有上下文排序与运行配置 */ + skipped: boolean; }; function isStyleProductTag(tag: string): boolean { @@ -314,7 +328,7 @@ function resolvePlaySlots( const parsed = existingRaw?.trim() ? parseWorkerSetYaml(existingRaw) : null; - const slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) }; + let slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) }; const recipeRef = parseSelectedRecipeRef( board.getContentByTag(CREATION_SELECTED_RECIPE_TAG), ); @@ -322,6 +336,9 @@ function resolvePlaySlots( slots.gm = true; slots.narrator = true; } + // 变量设计.维护复杂度 → auditor true/false(内联则关旁观) + const strategy = resolveMaintenanceStrategyFromBoard(board); + slots = applyMaintenanceStrategyToPlaySlots(slots, strategy); return slots; } @@ -336,6 +353,8 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() || ""; const playSlots = resolvePlaySlots(existing || undefined, board); + const strategy = resolveMaintenanceStrategyFromBoard(board); + writeMaintenanceStrategy(board, strategy, "runtime:play-bind"); const runtimeTags = collectRuntimeStateTags(board); const contextOrder = buildDictateContextOrder({ products, @@ -371,26 +390,100 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin ? 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 workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder); const boundTags = products.map((p) => p.tag); 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 { + const key = dictatePlayBindInputKey(board); + const fresh = freshPlayBind(board, key); + if (fresh) return fresh; + 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({ tag: WORKER_SET_FINAL_TAG, content: result.workerSetJson, source: "runtime", + metadata: { ...(workerItem?.metadata ?? {}), ...stamp }, }); board.write({ tag: CONTEXT_ORDER_TAG, content: JSON.stringify(result.contextOrder, null, 2), source: "runtime", + metadata: { ...(orderItem?.metadata ?? {}), ...stamp }, }); return result; } diff --git a/src/dictate/prompt.ts b/src/dictate/prompt.ts index a20b1a3..cf222f8 100644 --- a/src/dictate/prompt.ts +++ b/src/dictate/prompt.ts @@ -1,16 +1,13 @@ /** - * 对话落盘 · 创作进料专用系统提示 - * - * 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。 - * 仅用于创作层;开玩后由游玩管线接棒。 + * 对话落盘 · 创作系统提示。 + * 系统侧只放跨轮不变的规则。方案进度、能力目录和本轮输入放在请求末尾。 */ -export function buildDictateSystemPrompt(opts?: { - /** 已有方案 + 分档状态;存在时不再注入全能力目录 */ +export type DictatePromptTailOpts = { + /** 已有方案的短进度;放在末尾,不进系统提示 */ creationPlanState?: string; - /** 尚无方案时使用的短能力目录 */ + /** 尚无方案时的短能力目录 */ capabilityCatalog?: string; - /** 尚无方案时可引用的旧配方案例目录 */ recipeExamples?: string; /** @deprecated 仅兼容旧调用,作为案例而非已选路径 */ recipeName?: string; @@ -18,43 +15,38 @@ export function buildDictateSystemPrompt(opts?: { recipeBrief?: string; /** @deprecated 使用 capabilityCatalog */ moduleGuide?: string; -}): string { - const legacyExample = - opts?.recipeName || opts?.recipeBrief - ? ` -【旧配方案例 · 兼容参考】 -${opts.recipeName ? `- ${opts.recipeName}` : ""} -${opts.recipeBrief ?? ""} -它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。 -` - : ""; - const decisionBlock = opts?.creationPlanState?.trim() - ? `${opts.creationPlanState.trim()} + /** select:只定计划。fill:按注入规格写正文。reply:只交用户可见回复。 */ + phase?: "select" | "fill" | "reply"; + /** 本轮要落实或重写的 tag 与错误 */ + checklist?: string; +}; -方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个:先 read_module,再连续 insert。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,按已有材料给 2~3 个选项来问,选定或改写后再写入。写开场或结束创作前,列出仍未落的「必须」和「有必要」,问用户哪些还没设计、要补哪几项或明确不要。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`;使用条件仍留在能力旁。` - : `【当前状态 · 尚无本局创作方案】 -先抽出用户要的核心体验,再对照能力目录主动标出有帮助的能力,生成 \`creation-plan.v1\`,用 insert 写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献:用户没点名、但缺了体验会明显变差的,标进「必须」或「有必要」,具体还没定的选择写进 missing。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。 +/** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */ +export function buildDictateSelectPrompt(): string { + return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。 -${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"} -${opts?.recipeExamples?.trim() ? `\n\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""} -${legacyExample}`; +nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。 +用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。 +这一步不写产物正文,不回复用户。`; +} +export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string { return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。 **本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。 -${decisionBlock} +**填写收尾:** 节点表产物写完后,同一次请求必须再调用 reply_user。只 insert、把话写在普通正文里,都不算完成。回复写法见文末「用户可见回复」。 ## 写入规则 -设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。 +设定、变量、映射和删除通过本步的工具调用落盘。reply_user 只写给用户看的回复。 ### insert — 写入/覆盖固定产物 - position:\`用户.*\` / \`设计.*\` - content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大) - 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map - 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag -- 只有方案中该填的内容都完成后才写开场白;先单独调用 prepare_opening,让程序编译信息可见范围、游玩执行方式、上下文排序和运行配置;工具成功后的下一步再生成并 insert 开场白 -- 开场白与生成规则、具体实例一样可增殖:每条入口 insert \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题 +- 只有方案中该填的内容都完成后才写开场白。程序在这次填写前编译信息可见范围、游玩执行方式、上下文排序和运行配置;本步按清单写出开场正文 +- 开场白与生成规则、具体实例一样可增殖:每条入口写入 \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题 ### delete — 删除固定产物 - position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除 @@ -73,22 +65,87 @@ ${decisionBlock} - 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写) ## 推进原则 -1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。有方案时,写哪项先对照该能力的何时用 / 何时不用。 -2. 写任何内容能力前先调用 read_module 读取当前提示版本;需要库条目时,再用 read_library_entry 读取当前能力绑定库条目(默认 1~2;确需更多须给 reason)。不要凭目录短说明猜产物。 -3. 本轮只写入用户这句话已经说清的能力,通常 2~3 个。对这些能力先 read_module,再 insert / declare_*。每次 insert 都是独立产物写入。 -4. 写某能力前以 read_module 返回的 task / principles / output / score 为准;尚未读到专用提示时,不猜造其复杂结构。能力提示里的「材料足够便直接成稿」只用于这句已经说清的能力。 -5. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。 -6. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。 +1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。尚无方案时,按末尾目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。有方案时,写哪项先对照该能力的何时用 / 何时不用。 +2. 本轮只处理用户这句话已经说清的能力,通常 2~3 个。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。 +3. 先定范围再写。用户的话按最窄的成立范围落盘:体验目标说明玩家追求的对象,举例和比方说明用户想要的感觉或基底,都不说明整个世界都是这样。例:体验是「在各个世界征服高洁女性、打败恶役反派」,落成每个世界里有这样的目标人物和对手;世界本身仍按题材常识(如普通西幻),不写成全世界女性都高洁,也不为此发明神殿戒律。用「和现实一样」时顺口提到的现实事实,只确认基底是现实,不单独成条。宽窄拿不准、且会写出不同产物时,按最窄范围写或先不写,在回复里请用户确认范围。 +4. 填写时以「本轮注入」里的 task、principles、output、score 和库正文为规格。注入尚未给出的能力,不猜造其复杂结构。 +5. 变量和映射不写入普通产物。每次产物写入都是独立的一份。 +6. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。 +7. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。 ## 用户可见回复 -自然回应用户本轮有效判断、矛盾和值得保留之处,再简要说明形成或改写了什么。完整 JSON 由产物卡展示,不粘贴到回复。 -写正文组成或 0 层正文组成时,在回复里直接给出一份隔离内容供预览:与写入的 frontend 同一套骨架,示例内容已经放进去,单独可看。产物仍只保存 frontend 与数据契约。 -这句话没覆盖到的「必须」或「有必要」,用 2~3 个从用户原话推出的选项询问,不先写成产物。已经说清并写入的,说明形成了什么,不再另找问题。同轮多个已说清产物只做一次综合回应,不逐模块套固定标题。 - -## 其它 -对话明显过长时 clear_dialogue(产物与变量/映射保留)。 +${dictateReplyGuide()} `; } +/** 方案进度、目录和本轮任务。拼在请求末尾。 */ +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() */ 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()}`; +} diff --git a/src/dictate/repeatable-tags.ts b/src/dictate/repeatable-tags.ts index 8a700f6..70335ae 100644 --- a/src/dictate/repeatable-tags.ts +++ b/src/dictate/repeatable-tags.ts @@ -143,6 +143,74 @@ function bodyOfFragment(content: string): Record | null { return body as Record; } +/** 多开场切换用的落盘族。每条必须是 基名#场景短码。 */ +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): Record[] { + const raw = body.开场白候选 ?? body.候选; + if (!Array.isArray(raw)) return []; + return raw.filter( + (row): row is Record => + 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; + 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[]; repeatableFamilies: ReadonlySet; }): ResolveRepeatableInsertResult { - const raw = params.tag.trim(); - if (!raw) return { error: "position 为空" }; + const requested = params.tag.trim(); + if (!requested) return { error: "position 为空" }; + const raw = rewriteLegacyOpeningTag(requested); const family = dictateProductFamily(raw); if (!params.repeatableFamilies.has(family)) { @@ -217,13 +286,21 @@ export function resolveRepeatableInsertTag(params: { const explicit = dictateProductSlot(raw); 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 .map((t) => t.trim()) .filter((t) => t && dictateProductFamily(t) === family); if (!slot) { + if (family === OPENING_SLOT_FAMILY) { + return { error: OPENING_SLOT_REQUIRED }; + } // 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id) const otherSiblings = siblings.filter((t) => t !== family); if (otherSiblings.length > 0 && raw === family) { @@ -235,11 +312,11 @@ export function resolveRepeatableInsertTag(params: { } const resolved = buildRepeatableProductTag(family, slot); - if (raw === family || raw !== resolved) { + if (requested !== resolved) { return { tag: resolved, - remapped: raw !== resolved, - note: raw !== resolved ? `可增殖已拆到 ${resolved}` : undefined, + remapped: true, + note: `已写入 ${resolved}。之后改这一条用同一完整 tag。`, }; } return { tag: resolved }; diff --git a/src/dictate/round.ts b/src/dictate/round.ts new file mode 100644 index 0000000..94111af --- /dev/null +++ b/src/dictate/round.ts @@ -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; + +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; + 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(); + const pending = new Set([...rest, ...openings].map((item) => item.tag)); + const pendingDeletes = new Set(plan.deletes); + const errors = new Map(); + 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, + plannedDeletes: string[], + fallbackProse: string, +): Promise { + 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, + pending: Set, + pendingDeletes: Set, + errors: Map, +): 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): { + 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): 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 { + 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 { + return String(rec.op ?? rec.name ?? rec.type ?? rec.action ?? "insert").trim(); +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +} + +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 {}; + } +} diff --git a/src/dictate/tools.ts b/src/dictate/tools.ts index f569633..5597931 100644 --- a/src/dictate/tools.ts +++ b/src/dictate/tools.ts @@ -3,6 +3,66 @@ import type { ToolDefinition } from "../llm/client.js"; /** * 对话落盘创作期工具:产物 / 变量 / 映射均须 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[] = [ { type: "function", @@ -80,12 +140,12 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ position: { type: "string", description: - "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白、设计.开场白#dorm;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。勿用本工具写变量目录/映射。", + "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白#opening-cycle2;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。开场必须带场景短码,不要写裸「设计.开场白」或「设计.开场白与开场变量」。勿用本工具写变量目录/映射。", }, content: { type: "string", description: - "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白[#短码]=叙事正文(有正文组成时为 present.v1 JSON;否则 Markdown),必须含字面 @玩家,禁止写入用户角色.名字/简介;设计.主角设定[#同短码]=名字/背景/特殊设定 JSON,与开场同短码即绑定该开局", + "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白#场景短码=这一张开场的 present.v1,blocks 必须含正文组成数据契约里的全部模型生成区(用来灌进已固定前端并替换示例),不要只写 shell+body,也不要填程序投影区;必须含字面 @玩家,名字不进 blocks。这条开场的玩家角色写在 meta.用户角色(名字+一到三句客观事实),程序同步为同短码主角设定。这张开场有初值才在同一份正文里写开场变量,没有就不写。设计.主角设定=共通的名字/背景/特殊设定 JSON;各开场不同的玩家角色写进开场 meta,不另写主角设定#短码", }, order: { 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, +]; diff --git a/src/dictate/turn.ts b/src/dictate/turn.ts index 2700fbe..ba31393 100644 --- a/src/dictate/turn.ts +++ b/src/dictate/turn.ts @@ -1,9 +1,9 @@ import type { LlmProvider } from "../llm/client.js"; import { - createLocalLlmDriver, DriverIterationLimitError, type AgentDriver, } from "../runtime/driver.js"; +import { createPlannedDictateDriver } from "./round.js"; import { buildDictateMessages } from "./context.js"; import { buildDictateSystemPrompt } from "./prompt.js"; import { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; @@ -41,8 +41,14 @@ import { serializeVariableCatalog, upsertVariableField, removeVariableField, + seedVariablesFromCatalog, type UpsertVariableInput, } from "../skills/variable-catalog.js"; +import { + VARIABLE_DESIGN_TAG, + syncVariableDesignToRuntime, + writeVariableDesignSync, +} from "../skills/variable-design-compile.js"; import { VALUE_MAP_TAG, parseValueMapDoc, @@ -57,6 +63,46 @@ import { } from "../blackboard/table-cells.js"; import { isDictateOpeningTag } from "./experience-anchor.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 = { listProducts: () => DictateProduct[]; @@ -127,7 +173,28 @@ export async function runDictateTurn(params: { repeatableFamilies?: ReadonlySet | readonly string[]; driver?: AgentDriver; }): Promise { - 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 deletedTags: string[] = []; let clearedDialogue = false; @@ -217,9 +284,31 @@ export async function runDictateTurn(params: { call.name, `${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 { 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") { @@ -385,6 +474,18 @@ export async function runDictateTurn(params: { const order = parseDictateOrder(args.order); const productsBeforeWrite = params.handlers.listProducts(); 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; if (isOpening && !explicitlyPrepared) { params.handlers.prepareOpening?.(); @@ -464,6 +565,18 @@ export async function runDictateTurn(params: { const order = parseDictateOrder(args.order); const productsBeforeWrite = params.handlers.listProducts(); 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; if (isOpening && !explicitlyPrepared) { params.handlers.prepareOpening?.(); @@ -480,6 +593,10 @@ export async function runDictateTurn(params: { : {}), ...(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; wroteTags.push(tag); const detail = @@ -557,6 +674,8 @@ export async function runDictateTurn(params: { serializeVariableCatalog(doc), ); 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}`; params.handlers.onToolCall?.(call.name, detail); return { @@ -826,6 +945,30 @@ function safeParseArgs(raw: string): Record { } } +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 } | 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 { if (auto) return false; return !new Set([ diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts index a445a12..7b3b667 100644 --- a/src/skills/creation-flow.ts +++ b/src/skills/creation-flow.ts @@ -1423,6 +1423,14 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([ "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。 * 不写 DAG / design-step 话术;不塞执行全文。 @@ -1430,7 +1438,7 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { const dictateModules = catalogModulesForIntake(catalog, "dictate"); const lines = dictateModules.map((m) => { - const programSide = DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto; + const programSide = isDictateProgramModule(m); const flags = [ m.repeatable ? "〔可反复〕" : "", m.closer ? "〔收口〕" : "", @@ -1439,7 +1447,7 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { ] .filter(Boolean) .join(""); - const land = dictateLandHint(m); + const land = dictateModuleLandHint(m); const parts: string[] = [ `- ${m.name}${flags}:${m.declaration}`, ` 落盘:${land}`, @@ -1455,7 +1463,7 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { "「必须」和「有必要」里用户还没提到的:本轮不落盘,按已有材料给 2~3 个选项询问,选定后再写。含糊或未到时机的下游同样先问,不先写。", "同轮写入的每份产物各回 reply_module,可见回复综合成一次。", "标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。", - "标〔收口〕的开场:写开场前,列出仍未落的「必须」和「有必要」,问用户这些还没设计要补哪几项或明确不要。用户这句已经说清的再写入。普通模式使用回复呈现/正文组成,0 层模式使用对应 0 层节点,不得混用。用户确认可以收口后,程序依次编译信息可见范围、游玩执行方式、上下文排序与运行配置,再写所选模式的开场产物。", + "标〔收口〕的开场:写开场前,列出仍未落的「必须」和「有必要」,问用户这些还没设计要补哪几项或明确不要。用户这句已经说清的再写入。普通模式使用回复呈现/正文组成,0 层模式使用对应 0 层节点,不得混用。用户确认可以收口后,程序依次按有无变量真值固定开旁观填表、编译信息可见范围、游玩执行方式、上下文排序与运行配置,再写所选模式的开场产物。", "标〔可反复〕的(生成规则、具体实例、开场白、拓扑等):每条单独落盘,用「基名#唯一id」(生成规则用规则名,具体实例用名称,开场白用场景短码)。同完整 tag 才覆盖,禁止反复写基名盖掉别的条。开场全部保留;游玩时左右切换,选中的一条作为 0 层。", "能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。", lines.join("\n"), @@ -1464,12 +1472,15 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { return head; } -function dictateLandHint(m: ModuleCatalogEntry): string { - if (DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto) { +export function dictateModuleLandHint(m: ModuleCatalogEntry): string { + if (isDictateProgramModule(m)) { + if (m.id === "worker-spec") { + return "落档/开玩程序处理:有变量真值时固定开旁观填表,创作期一般不 insert"; + } return "落档/开玩程序处理;创作期一般不 insert"; } if (m.id === "variable-design") { - return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」"; + return "insert 「设计.变量设计与更新规则」(有真值时程序固定旁观副 LLM 填表,并同步目录与真表);或用 declare_variable / declare_map 逐条钉目录"; } if (m.id === "opening-setup") { return "insert 「设计.开场白#场景短码」——可增殖:每条开场各占一 tag;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层"; diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts index 1b2544c..39c89cb 100644 --- a/tests/creation-flow.test.ts +++ b/tests/creation-flow.test.ts @@ -1230,8 +1230,12 @@ modules: expect(sections.blocks.task).toContain("默认只生成 1 条"); expect(sections.blocks.task).toContain("@玩家"); expect(sections.blocks.task).toContain("随机"); - expect(sections.blocks.output).toContain('"默认候选id"'); - expect(sections.blocks.output).toContain('"开场白候选"'); + expect(module?.artifact).toBe("设计.开场白"); + expect(sections.blocks.task).toContain("设计.开场白#opening-cycle2"); + expect(sections.blocks.task).not.toContain("写入 `设计.开场白与开场变量`"); + expect(sections.blocks.output).toContain("设计.开场白#场景短码"); + expect(sections.blocks.output).toContain('"开场变量"'); + expect(sections.blocks.output).not.toContain('"开场白候选"'); expect(sections.blocks.output).not.toContain('"开场白全文"'); expect(sections.blocks.output).toContain("present.v1"); expect(sections.blocks.output).not.toContain('"自评"'); diff --git a/tests/creation-plan.test.ts b/tests/creation-plan.test.ts index 2adf4f8..69cd607 100644 --- a/tests/creation-plan.test.ts +++ b/tests/creation-plan.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CREATION_PLAN_TAG, buildDictateSystemPrompt, + formatDictateRoundTail, creationPlanModules, formatCreationPlanCapabilityCatalog, formatCreationPlanProgress, @@ -9,7 +10,9 @@ import { validateCreationPlan, type CreationPlan, } from "../src/dictate/index.js"; +import { moduleBrowseGroup } from "../src/dictate/creation-plan.js"; import { + catalogModulesForIntake, formatModuleCatalogForDictate, loadModuleCatalog, } from "../src/skills/creation-flow.js"; @@ -65,6 +68,32 @@ describe("creation plan", () => { ); }); + it("groups dictate nodes by runtime role and omits the recipe-only merge", async () => { + const catalog = await loadModuleCatalog("dialogue/world-simulator"); + expect(catalog).toBeTruthy(); + const dictate = catalogModulesForIntake(catalog!, "dictate"); + const ids = dictate.map((module) => module.id); + expect(ids).not.toContain("narrative"); + expect(moduleBrowseGroup(dictate.find((module) => module.id === "creation-plan")!)).toBe( + "plan", + ); + expect(moduleBrowseGroup(dictate.find((module) => module.id === "narrative-guide")!)).toBe( + "content", + ); + expect(moduleBrowseGroup(dictate.find((module) => module.id === "reply-format")!)).toBe( + "present", + ); + expect(moduleBrowseGroup(dictate.find((module) => module.id === "zero-layer-status")!)).toBe( + "present", + ); + expect(moduleBrowseGroup(dictate.find((module) => module.id === "refine")!)).toBe( + "program", + ); + expect(new Set(dictate.map((module) => moduleBrowseGroup(module)))).toEqual( + new Set(["plan", "content", "present", "program"]), + ); + }); + it("selects exactly one presentation chain", async () => { const catalog = await loadModuleCatalog("dialogue/world-simulator"); expect(catalog).toBeTruthy(); @@ -160,17 +189,19 @@ describe("creation plan", () => { expect(unusedSection).not.toContain("何时用:"); const oldFullGuide = formatModuleCatalogForDictate(catalog); - const before = buildDictateSystemPrompt({ + const fixed = buildDictateSystemPrompt(); + const before = formatDictateRoundTail({ capabilityCatalog: shortCatalog, recipeExamples: "- text-generator:文本生成案例", }); - const after = buildDictateSystemPrompt({ creationPlanState: progress }); + const after = formatDictateRoundTail({ creationPlanState: progress }); + expect(fixed).not.toContain("当前决策"); expect(before).toContain("尚无本局创作方案"); expect(after).toContain("当前决策"); expect(after).toContain("收口前的核对清单"); expect(after).not.toContain("旧配方案例目录"); expect(after.length).toBeLessThan( - buildDictateSystemPrompt({ moduleGuide: oldFullGuide }).length, + formatDictateRoundTail({ moduleGuide: oldFullGuide }).length, ); }); diff --git a/tests/dictate-play-bind.test.ts b/tests/dictate-play-bind.test.ts index 2f9f028..facfe7e 100644 --- a/tests/dictate-play-bind.test.ts +++ b/tests/dictate-play-bind.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { Blackboard } from "../src/blackboard/blackboard.js"; import { + applyDictatePlayBind, buildDictateContextOrder, bindDictateProductsToPlaySpec, collectDictateBindProducts, + DICTATE_PLAY_BIND_INPUT_META, isDictateStyleBindTag, } from "../src/dictate/play-bind.js"; import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js"; @@ -215,4 +217,51 @@ describe("dictate play-bind", () => { expect(refs).toContain("上下文.角色态度"); expect(refs).not.toContain("设计.变量映射"); }); + + it("skips rebind on save when mounted products are unchanged", () => { + const board = new Blackboard(); + board.write({ tag: "用户.需求", content: "雨夜", source: "test" }); + const first = applyDictatePlayBind(board); + expect(first.skipped).toBe(false); + const orderUpdated = board.getLatestByTag("设计.上下文投影排序")?.updatedAt; + const workerUpdated = board.getLatestByTag("设计.worker集")?.updatedAt; + expect(board.getLatestByTag("设计.上下文投影排序")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META]).toEqual( + board.getLatestByTag("设计.worker集")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META], + ); + + const second = applyDictatePlayBind(board); + expect(second.skipped).toBe(true); + expect(second.changed).toBe(false); + expect(board.getLatestByTag("设计.上下文投影排序")?.updatedAt).toBe(orderUpdated); + expect(board.getLatestByTag("设计.worker集")?.updatedAt).toBe(workerUpdated); + }); + + it("keeps a hand-edited order until a mounted product changes", () => { + const board = new Blackboard(); + board.write({ tag: "用户.需求", content: "雨夜", source: "test" }); + applyDictatePlayBind(board); + + const orderItem = board.getLatestByTag("设计.上下文投影排序")!; + const doc = JSON.parse(orderItem.content) as { brief?: string }; + doc.brief = "手改排序"; + const handEdited = JSON.stringify(doc, null, 2); + board.write({ + tag: "设计.上下文投影排序", + content: handEdited, + source: "user", + metadata: orderItem.metadata, + }); + const kept = applyDictatePlayBind(board); + expect(kept.skipped).toBe(true); + expect(board.getContentByTag("设计.上下文投影排序")).toBe(handEdited); + + board.write({ tag: "设计.叙事指南", content: "短句", source: "test" }); + const rebound = applyDictatePlayBind(board); + expect(rebound.skipped).toBe(false); + expect(rebound.boundTags).toContain("设计.叙事指南"); + const rewritten = JSON.parse( + board.getContentByTag("设计.上下文投影排序") ?? "{}", + ) as { brief?: string }; + expect(rewritten.brief).not.toBe("手改排序"); + }); }); diff --git a/tests/dictate-read-module.test.ts b/tests/dictate-read-module.test.ts index 235fdcb..43b4c37 100644 --- a/tests/dictate-read-module.test.ts +++ b/tests/dictate-read-module.test.ts @@ -32,7 +32,8 @@ describe("dictate read_module", () => { content: "{}", self_score: { dims: [ - { name: "核心体验", score: 5 }, + { name: "美学纲领", score: 5 }, + { name: "交互范式", score: 5 }, { name: "背景与规则", score: 5 }, ], }, @@ -82,7 +83,8 @@ describe("dictate read_module", () => { content: "{}", self_score: { dims: [ - { name: "核心体验", score: 5 }, + { name: "美学纲领", score: 5 }, + { name: "交互范式", score: 5 }, { name: "背景与规则", score: 3, gap: "规则因果仍需确认" }, ], }, @@ -205,7 +207,8 @@ describe("dictate read_module", () => { content: "{}", self_score: { dims: [ - { name: "核心体验", score: 3 }, + { name: "美学纲领", score: 3 }, + { name: "交互范式", score: 5 }, { name: "背景与规则", score: 5 }, ], }, @@ -226,7 +229,8 @@ describe("dictate read_module", () => { content: "{}", self_score: { dims: [ - { name: "核心体验", score: 3, gap: "发生方式仍偏宽" }, + { name: "美学纲领", score: 3, gap: "发生方式仍偏宽" }, + { name: "交互范式", score: 5 }, { name: "背景与规则", score: 5 }, ], }, @@ -254,13 +258,114 @@ describe("dictate read_module", () => { layer: "final", selfScore: { dims: expect.arrayContaining([ - expect.objectContaining({ name: "核心体验", score: 3 }), + expect.objectContaining({ name: "美学纲领", score: 3 }), ]), }, }); }); }); +describe("opening uses the reply contract", () => { + const contract = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + 数据契约: { + blocks: { + body: { 类型: "markdown", 来源: "model_generated" }, + forum_feed: { 类型: "list", 来源: "model_generated" }, + }, + }, + frontend: { html: `
`, 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([["设计.正文组成", 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([["设计.正文组成", 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) { return { listProducts: () => diff --git a/tests/dictate-round.test.ts b/tests/dictate-round.test.ts new file mode 100644 index 0000000..40581c4 --- /dev/null +++ b/tests/dictate-round.test.ts @@ -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) : {}; + 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) : {}; + 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).内容 ?? (value as Record).正文 ?? (value as Record).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).forEach(([key, value], index) => { + const content = + typeof value === "string" + ? value + : value && typeof value === "object" + ? String((value as Record).正文 ?? (value as Record).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) : {}; + const nested = + rec.arguments && typeof rec.arguments === "object" + ? (rec.arguments as Record) + : 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 = []; + let index = 0; + const tags = new Map(); + 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" } }); + }); +}); diff --git a/tests/dictate.test.ts b/tests/dictate.test.ts index 3fb133d..0d2f7b7 100644 --- a/tests/dictate.test.ts +++ b/tests/dictate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildDictateMessages, buildDictateSystemPrompt, + formatDictateRoundTail, buildDictateInsertFeedbackIndex, defaultDictateOrder, extractDictateDialogue, @@ -47,7 +48,7 @@ describe("dictate context", () => { }); const body = messages[1]?.content ?? ""; expect(body).toContain("冷色短句"); - expect(body).toContain("尚无对话"); + expect(body).toContain("尚无更早对话"); }); it("extracts only user + dictate_reply turns", () => { @@ -73,8 +74,9 @@ describe("dictate context", () => { expect(isDictateModeValue("recipe")).toBe(false); }); - it("system prompt starts from a plan and keeps generic write tools", () => { - const prompt = buildDictateSystemPrompt({ + it("system prompt keeps stable rules; catalog and plan progress sit in the tail", () => { + const prompt = buildDictateSystemPrompt(); + const tail = formatDictateRoundTail({ recipeName: "数据化跑团体验", recipeBrief: "设定 + 真值 + 分档映射\n怎么做:\n- declare_variable\n- declare_map", @@ -90,17 +92,31 @@ describe("dictate context", () => { expect(prompt).toContain("declare_map"); expect(prompt).toContain("remove_map"); expect(prompt).toContain("同 position 再 insert"); - expect(prompt).toContain("数据化跑团体验"); + expect(prompt).not.toContain("数据化跑团体验"); expect(prompt).toMatch(/order/); - expect(prompt).toContain("【能力 · 何时落盘】"); - expect(prompt).toContain("设计.美学纲领与交互范式"); + expect(prompt).not.toContain("【能力 · 何时落盘】"); expect(prompt).toContain("通常 2~3 个"); expect(prompt).toContain("给 2~3 个选项来问"); expect(prompt).toContain("不是 DAG"); - expect(prompt).toContain("不是用户已选路径"); + expect(prompt).not.toContain("不是用户已选路径"); expect(prompt).toContain("自然回应"); + expect(prompt.indexOf("填写收尾")).toBeLessThan(prompt.indexOf("写入规则")); expect(prompt).not.toContain("体验锚定(程序打分)"); expect(prompt).not.toContain("## 本轮落什么"); + expect(tail).toContain("数据化跑团体验"); + expect(tail).toContain("【能力 · 何时落盘】"); + expect(tail).toContain("不是用户已选路径"); + expect(tail).toContain("尚无本局创作方案"); + }); + + it("puts the fill reply_user duty before plan progress in the tail", () => { + const fillTail = formatDictateRoundTail({ + phase: "fill", + creationPlanState: "【当前决策】\n- mood:已落", + checklist: "本轮写入:设计.氛围", + }); + expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("当前决策")); + expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("本轮写入")); }); it("formats module catalog for dictate with land tags and when", async () => { @@ -172,6 +188,12 @@ describe("dictate context", () => { expect( resolveModuleForInsertTag("设计.开场白", catalog!)?.id, ).toBe("opening-setup"); + expect( + resolveModuleForInsertTag("设计.开场白#opening-cycle2", catalog!)?.id, + ).toBe("opening-setup"); + expect( + resolveModuleForInsertTag("设计.开场白与开场变量", catalog!)?.id, + ).toBe("opening-setup"); expect( resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id, ).toBe("concrete-instances"); @@ -244,31 +266,47 @@ describe("dictate context", () => { expect("error" in overwriteBare).toBe(true); const openingFamilies = new Set(["设计.开场白", "设计.主角设定"]); - const firstOpen = resolveRepeatableInsertTag({ + const bareOpen = resolveRepeatableInsertTag({ tag: "设计.开场白", content: "@玩家推开门。", existingTags: [], repeatableFamilies: openingFamilies, }); - expect("tag" in firstOpen && firstOpen.tag).toBe("设计.开场白"); - const overwriteFirst = resolveRepeatableInsertTag({ - tag: "设计.开场白", - content: "@玩家改写第一幕。", + expect("error" in bareOpen).toBe(true); + const namedFromBundle = resolveRepeatableInsertTag({ + tag: "设计.开场白与开场变量", + content: JSON.stringify({ + schema: "present.v1", + id: "opening-cycle2", + blocks: { body: "@玩家推开门。" }, + }), existingTags: ["设计.开场白"], repeatableFamilies: openingFamilies, }); - expect("tag" in overwriteFirst && overwriteFirst.tag).toBe("设计.开场白"); + expect("tag" in namedFromBundle && namedFromBundle.tag).toBe( + "设计.开场白#opening-cycle2", + ); + const keepNamed = resolveRepeatableInsertTag({ + tag: "设计.开场白#opening-cycle2", + content: JSON.stringify({ + schema: "present.v1", + blocks: { body: "@玩家改写这一幕。" }, + }), + existingTags: ["设计.开场白", "设计.开场白与开场变量", "设计.开场白#opening-cycle2"], + repeatableFamilies: openingFamilies, + }); + expect("tag" in keepNamed && keepNamed.tag).toBe("设计.开场白#opening-cycle2"); const secondOpen = resolveRepeatableInsertTag({ tag: "设计.开场白#gate", content: "@玩家在闸机前。", - existingTags: ["设计.开场白"], + existingTags: ["设计.开场白#opening-cycle2"], repeatableFamilies: openingFamilies, }); expect("tag" in secondOpen && secondOpen.tag).toBe("设计.开场白#gate"); const bareOpenConflict = resolveRepeatableInsertTag({ tag: "设计.开场白", content: "又写一条", - existingTags: ["设计.开场白", "设计.开场白#gate"], + existingTags: ["设计.开场白#opening-cycle2", "设计.开场白#gate"], repeatableFamilies: openingFamilies, }); expect("error" in bareOpenConflict).toBe(true);