创作一轮改为先选题再填写,减少工具空转和重复覆盖。
上下文按规则、历史、产物分段拼装,便于吃提示缓存并核对费用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# 创作轮次与前缀花费
|
||||
|
||||
> 状态:结论已记录。缓存粘滞已接入:本局 `sessionId` 在 OpenRouter 请求体写成 `session_id`。不手选节点导致的「先选再写」尚无做法。
|
||||
> 记录:2026-10-01。用量取自本地 `~/.writing-agent/stats`,2026-09-20 起。
|
||||
> 相关:`src/dictate/prompt.ts`、`src/dictate/turn.ts`、`src/dictate/read-module.ts`、`src/llm/client.ts`。
|
||||
|
||||
## 已记录的花费
|
||||
|
||||
9 月 20 日以来,OpenRouter 上的 token 主要在创作落盘的输入,不在游玩输出。
|
||||
|
||||
| 路径 | 请求 | 会话约 | prompt | 其中缓存 | 未缓存 | 输出 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 创作落盘 `dictate_agent` | 416 | 5 | 1198 万 | 782 万 | 417 万 | 38 万 |
|
||||
| 游玩 `worker:world-simulator` | 28 | 2 | 77 万 | 0 | 77 万 | 7 万 |
|
||||
|
||||
输出大约是 prompt 的 3%–9%。输出单价更高,仍然要算,但排在输入后面。缓存命中仍按折扣价计,缩体积会同时降低全价和折扣价。
|
||||
|
||||
每轮花费可以看成:
|
||||
|
||||
```text
|
||||
请求次数 ×(未缓存输入 × 全价 + 缓存输入 × 折扣价 + 输出 × 输出价)
|
||||
```
|
||||
|
||||
创作这 416 次是最大的一块。大约五个会话,一句用户话后面跟着一串读和写。
|
||||
|
||||
## 缓存粘滞:必做
|
||||
|
||||
OpenRouter 请求体现由统计层带上本局 `sessionId`,字段名 `session_id`(最长 256 字)。直连其它渠道不写这个字段。有 `session_id` 后,粘滞从该会话第一次成功请求就开始,不再等缓存命中,也不再因为第一条 user 被重写而换上游。`provider.order` 仍不发送,它会关掉这层粘滞。
|
||||
|
||||
同一条前缀,落点稳定时能命中,换上游就是 0:
|
||||
|
||||
- 创作工具循环间隔大约 7–15 秒。Gemini 上同一轮里缓存从约 3.8k 爬到 7.6k、11.5k、15k,整段 prompt 大约 50%–68% 命中。burst 的第一下经常是 0。中间偶尔整段掉回 0,是换了上游。
|
||||
- 游玩 world-simulator 在 OpenRouter 上 28 次全是 0。gemini-3.7 间隔中位数约 5.5 分钟,gemini-3.8 约 22 分钟,prompt 1.8 万–3.4 万。
|
||||
- 直连 glm-5.3 的游玩,间隔 8–30 分钟仍常见 80%–91%。某一轮从约 11k 掉到 6656 并停住,说明前缀中段改过,只保住开头约 6.6k。
|
||||
|
||||
因此缓存粘滞是必做项。不传稳定的 `session_id`,OpenRouter 每一轮按新开头重新选上游,游玩前缀缓存等于没有。`provider.order` 会关掉这层粘滞,不能拿来代替 `session_id`。
|
||||
|
||||
上下文排序对游玩前缀是有效的:人设和静态产物在前,历史居中,变量和本轮输入在后。它只在同一上游上表现得出来。
|
||||
|
||||
## 未决:不手选节点,选和写至少两轮
|
||||
|
||||
用户不手动指定本轮写哪个能力。系统提示里只有能力目录和短说明(何时用、何时不用、档位)。`prompt.md` 里的任务、原则、产物规格、评分不在这条提示里。
|
||||
|
||||
模型选定能力后调用 `read_module`。程序把这些段落放进工具结果,并在 `运行.对话落盘.已读能力` 记下该能力当前版本。版本是这些段落的哈希。随后的 `insert` 要核对这个账:没读过,或磁盘上的提示已经改过,就拒绝写入。`self_score` 也按这次读到的评分维度检查。`narrative`、`variable-context`、`context-order`、`worker-spec`、`refine` 以及 `auto` 能力不走这道门,其余内容能力都走。库条目同理:目录只是摘要,正文要等 `read_library_entry` 回来。
|
||||
|
||||
读的那一轮,完整写法还不在上下文里,模型给不出合格正文。同一项能力至少是:
|
||||
|
||||
```text
|
||||
第 1 次请求:凭短说明选定,并 read_module
|
||||
第 2 次请求:看见任务 / 原则 / 规格 / 评分之后 insert
|
||||
```
|
||||
|
||||
前缀发送两遍。第二遍还多带上那份写法。手选节点时,程序可以把该节点的完整写法放进唯一一次请求,模型当场写完。不手选时,第一次请求不能把所有节点的完整提示都放进去,这第二次省不掉。
|
||||
|
||||
一次只返回一个工具时,倍数再乘上去。提示要求一轮通常写 2~3 个已经说清的能力,模型常做成「读甲、写甲、读乙、写乙」,最后再单独回用户一句。每一拍都把已经变长的前缀再送一遍。「选和写拆成两次」是翻倍的原因;「每次只动一个节点」把两倍变成好几倍。
|
||||
|
||||
驱动程序已经会在同一步执行模型这一次返回的全部工具。接口没有禁止一轮多个 `insert`。日志里偶尔同轮两次写入。多数时候模型一次只吐一个工具。
|
||||
|
||||
结构化输出可以把一次生成拆成多份产物:一份 JSON 里多条 `{ position, content }`,程序再落盘,用户可见回复也可以放在同一份里。这省的是写入侧重复发送前缀。规格说明仍须在生成这批产物之前到达模型;否则模型是在没看见写法的情况下填内容。读和写如何合成更少的请求,本文不规定做法。
|
||||
|
||||
## 一本书上的拆账
|
||||
|
||||
本机作品 `4c13b2b4` 的当前会话(25 份产物、22 轮用户话)按现在的 `buildDictateMessages` 拼出来,不发请求。字数大约按 2 字 1 token 对照:同书末次 OpenRouter 请求 prompt 为 46897 token。
|
||||
|
||||
| 块 | 字数 | 占当前 messages |
|
||||
|---|---:|---:|
|
||||
| 已有产物合计 | 60518 | 69% |
|
||||
| 全量对话 | 19975 | 23% |
|
||||
| 系统提示(含方案进度 4392) | 6695 | 8% |
|
||||
| 工具定义 JSON | 4917 | 另计,约等于系统提示 |
|
||||
| 当前选用预设里启用的正文 | 362 | 可忽略 |
|
||||
|
||||
产物没有一份独自占满。最大的是 `设计.正文组成` 8879 字,其次变量规则 6005、实现机制 4569。其余多为两三千字,加在一起才是大头。会话中段的检查点(第 119 条消息之前)产物已经是 56102 字,和结尾同一量级。
|
||||
|
||||
按前面两段收窄再量同一份材料:
|
||||
|
||||
| 方案 | 字数 |
|
||||
|---|---:|
|
||||
| 选择:方案进度 + 用户最后一句(130 字) | 4522 |
|
||||
| 填写若仍带全部产物 + 最近 4 条对话(1770 字) | 约 6.2 万 |
|
||||
| 再注入 1 份节点提示(正文约 5 千字,叙事指南 5557) | 约 6.7 万 |
|
||||
| 现在这一次请求的 messages | 88218 |
|
||||
|
||||
选择请求很小,目录也稳定,缓存几乎打在整段上。填写请求的大头仍是已有产物,不是对话,也不是刚插入的节点提示。砍掉全量对话大约省两成,改不了产物那七成。节点提示每份约 5 千字,一次写两三份会在产物之上再加一两成,但只发送一次。
|
||||
Binary file not shown.
@@ -288,7 +288,7 @@
|
||||
|
||||
写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。
|
||||
|
||||
写正文组成或 0 层正文组成时,回复里直接给出一份隔离内容供预览:与本局前端同一套骨架,示例已经写进去。完整 JSON 仍由产物卡展示。
|
||||
正文组成、0 层正文组成和开场的画面由产物卡按 frontend 渲染。完整 JSON 仍由产物卡展示。
|
||||
|
||||
## 6. 工作包 D · 游玩投影编译〔未开始〕
|
||||
|
||||
|
||||
+161
-29
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+12
-5
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */
|
||||
export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
|
||||
"设计.开场白": "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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+99
-42
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,74 @@ function bodyOfFragment(content: string): Record<string, unknown> | null {
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 多开场切换用的落盘族。每条必须是 基名#场景短码。 */
|
||||
export const OPENING_SLOT_FAMILY = "设计.开场白";
|
||||
/** 旧整包 tag。insert 时改写到 OPENING_SLOT_FAMILY#槽,避免再并列一份。 */
|
||||
export const LEGACY_OPENING_BUNDLE_FAMILY = "设计.开场白与开场变量";
|
||||
|
||||
const OPENING_SLOT_REQUIRED =
|
||||
"开场每条单独落盘,position 用「设计.开场白#场景短码」(例如 设计.开场白#opening-cycle2),供之后左右切换。不要写裸「设计.开场白」,也不要写「设计.开场白与开场变量」。改某一条则 insert 同一完整 tag。没有变量的卡不写开场变量,tag 仍然带场景短码。";
|
||||
|
||||
/** 旧整包族改写成开场族,槽位原样保留。 */
|
||||
export function rewriteLegacyOpeningTag(tag: string): string {
|
||||
const raw = tag.trim();
|
||||
if (dictateProductFamily(raw) !== LEGACY_OPENING_BUNDLE_FAMILY) return raw;
|
||||
const slot = dictateProductSlot(raw);
|
||||
return slot ? buildRepeatableProductTag(OPENING_SLOT_FAMILY, slot) : OPENING_SLOT_FAMILY;
|
||||
}
|
||||
|
||||
function openingCandidateRows(body: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const raw = body.开场白候选 ?? body.候选;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(row): row is Record<string, unknown> =>
|
||||
Boolean(row) && typeof row === "object" && !Array.isArray(row),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单张开场正文抽出场景短码。多条候选挤在一份正文里时拒绝,要求拆成多次 insert。
|
||||
*/
|
||||
export function extractOpeningSlotFromContent(
|
||||
content: string,
|
||||
): { slot?: string; error?: string } {
|
||||
const parsed = tryParseJsonDoc(content);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const doc = parsed as Record<string, unknown>;
|
||||
const nested = bodyOfFragment(content);
|
||||
const body = nested ?? doc;
|
||||
const direct =
|
||||
asString(doc.id) ??
|
||||
asString(doc.候选id) ??
|
||||
asString(doc.场景短码) ??
|
||||
asString(body.id) ??
|
||||
asString(body.候选id) ??
|
||||
asString(body.场景短码);
|
||||
if (direct) {
|
||||
const slot = sanitizeRepeatableSlot(direct);
|
||||
return slot ? { slot } : {};
|
||||
}
|
||||
const rows = openingCandidateRows(body);
|
||||
if (rows.length > 1) {
|
||||
return {
|
||||
error:
|
||||
"多条开场不要写进同一份产物。每条 insert 一次「设计.开场白#场景短码」。",
|
||||
};
|
||||
}
|
||||
const one = rows[0];
|
||||
const fromOne = one
|
||||
? asString(one.id) ?? asString(one.候选id) ?? asString(one.场景短码)
|
||||
: undefined;
|
||||
const fallback =
|
||||
rows.length <= 1
|
||||
? asString(body.默认候选id) ?? asString(body.默认候选ID)
|
||||
: undefined;
|
||||
const picked = fromOne ?? fallback;
|
||||
if (!picked) return {};
|
||||
const slot = sanitizeRepeatableSlot(picked);
|
||||
return slot ? { slot } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从正文抽出建议槽位:生成规则 → 规则名;具体实例 → 名称 / 记录里的姓名。
|
||||
*/
|
||||
@@ -207,8 +275,9 @@ export function resolveRepeatableInsertTag(params: {
|
||||
existingTags: readonly string[];
|
||||
repeatableFamilies: ReadonlySet<string>;
|
||||
}): 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 };
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
||||
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
|
||||
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
||||
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
||||
import type { AgentDriver } from "../runtime/driver.js";
|
||||
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||||
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||||
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
||||
import {
|
||||
CREATION_PLAN_SCHEMA,
|
||||
CREATION_PLAN_TAG,
|
||||
formatCreationPlanCapabilityCatalog,
|
||||
formatCreationPlanProgress,
|
||||
parseCreationPlan,
|
||||
} from "./creation-plan.js";
|
||||
import { isDictateOpeningTag } from "./experience-anchor.js";
|
||||
import {
|
||||
buildDictateReplyPrompt,
|
||||
buildDictateSelectPrompt,
|
||||
buildDictateSystemPrompt,
|
||||
formatDictateRoundTail,
|
||||
type DictatePromptTailOpts,
|
||||
} from "./prompt.js";
|
||||
import {
|
||||
parseDictateModuleReadState,
|
||||
readLibraryEntries,
|
||||
serializeDictateModuleReadState,
|
||||
DICTATE_MODULE_READ_STATE_TAG,
|
||||
type DictateModuleReadPayload,
|
||||
} from "./read-module.js";
|
||||
import { resolveModuleForInsertTag } from "./insert-feedback.js";
|
||||
import type { DictateChatTurn, DictateProduct } from "./types.js";
|
||||
import { isAllowedProductTag } from "./types.js";
|
||||
import { VALUE_MAP_TAG } from "../skills/value-map.js";
|
||||
import { VARIABLE_CATALOG_TAG } from "../skills/variable-catalog.js";
|
||||
|
||||
const READ_EXEMPT = new Set([
|
||||
"narrative",
|
||||
"variable-context",
|
||||
"context-order",
|
||||
"worker-spec",
|
||||
"refine",
|
||||
]);
|
||||
|
||||
type Apply = (calls: DriverToolCall[]) => DriverStepOutcome | Promise<DriverStepOutcome>;
|
||||
|
||||
export type PlannedRoundParams = {
|
||||
llm: LlmProvider;
|
||||
dialogue: DictateChatTurn[];
|
||||
handlers: {
|
||||
listProducts: () => DictateProduct[];
|
||||
readTag: (tag: string) => string | undefined;
|
||||
writeTag: (tag: string, content: string) => void;
|
||||
prepareOpening?: () => void;
|
||||
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
|
||||
lookupInsertFeedback?: (tag: string) => string | undefined;
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onThinkingDone?: (text: string) => void;
|
||||
};
|
||||
moduleCatalog?: import("../skills/creation-flow.js").ModuleCatalog | null;
|
||||
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
|
||||
moduleGuide?: string;
|
||||
recipeExamples?: string;
|
||||
recipeName?: string;
|
||||
recipeBrief?: string;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
type PlanInsert = {
|
||||
tag: string;
|
||||
moduleId: string;
|
||||
libraryId?: string;
|
||||
entryIds: string[];
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type SelectPlan = {
|
||||
clarify: string[];
|
||||
inserts: PlanInsert[];
|
||||
deletes: string[];
|
||||
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
|
||||
keys: string[];
|
||||
};
|
||||
|
||||
|
||||
type ItemFailure = { tag: string; error: string };
|
||||
|
||||
|
||||
const INSERT_ATTEMPTS = 4;
|
||||
|
||||
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
|
||||
return {
|
||||
async run(input) {
|
||||
const reply = await runPlannedDictateRound(params, input.handleStep);
|
||||
return { iterations: reply.modelCalls, stop: { kind: "text", content: reply.text } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runPlannedDictateRound(
|
||||
params: PlannedRoundParams,
|
||||
apply: Apply,
|
||||
): Promise<{ text: string; modelCalls: number }> {
|
||||
const turnId = randomUUID();
|
||||
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
|
||||
const tailOpts = tailState(params);
|
||||
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
|
||||
const selectCtx = buildDictateContext({
|
||||
systemPrompt: buildDictateSelectPrompt(),
|
||||
products: params.handlers.listProducts(),
|
||||
dialogue: params.dialogue,
|
||||
phase: "select",
|
||||
tail: selectTail,
|
||||
});
|
||||
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
||||
caller: "dictate_agent",
|
||||
label: "对话落盘 选题",
|
||||
tools: [DICTATE_SELECT_TOOL],
|
||||
toolChoice: { type: "function", function: { name: "select_nodes" } },
|
||||
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
|
||||
usageSink: sinkFor(turnId, "select"),
|
||||
});
|
||||
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
||||
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
|
||||
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
||||
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||||
return {
|
||||
modelCalls: 1,
|
||||
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
|
||||
};
|
||||
}
|
||||
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||||
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
|
||||
return {
|
||||
modelCalls: 1,
|
||||
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
|
||||
};
|
||||
}
|
||||
|
||||
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
|
||||
const inserts = plan.inserts.filter((item) => !item.tag.startsWith("!"));
|
||||
const openings = inserts.filter((item) => isDictateOpeningTag(item.tag));
|
||||
const rest = inserts.filter((item) => !isDictateOpeningTag(item.tag));
|
||||
const blockedLines = blocked.map(
|
||||
(item) => `${item.tag.slice(1)}(${item.reason || "计划未通过程序核验"})`,
|
||||
);
|
||||
if (openings.length > 0) params.handlers.prepareOpening?.();
|
||||
const injected = injectGroup(params, [...rest, ...openings]);
|
||||
const tail = formatDictateRoundTail({
|
||||
...tailOpts,
|
||||
phase: "fill",
|
||||
checklist: formatChecklist([...rest, ...openings], plan.deletes, []),
|
||||
});
|
||||
const ctx = buildDictateContext({
|
||||
systemPrompt: fillSystem,
|
||||
products: params.handlers.listProducts(),
|
||||
dialogue: params.dialogue,
|
||||
phase: "fill",
|
||||
injected: injected.text,
|
||||
tail,
|
||||
});
|
||||
const messages: ChatMessage[] = [...ctx.messages];
|
||||
const passed = new Set<string>();
|
||||
const pending = new Set([...rest, ...openings].map((item) => item.tag));
|
||||
const pendingDeletes = new Set(plan.deletes);
|
||||
const errors = new Map<string, string>();
|
||||
let replyFromTool = "";
|
||||
let lastProse = "";
|
||||
let modelCalls = 1;
|
||||
|
||||
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
|
||||
const step = attempt === 0 ? "fill" : "fill-retry";
|
||||
const filled = await params.llm.completeWithTools(messages, {
|
||||
caller: "dictate_agent",
|
||||
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
|
||||
tools: DICTATE_WRITE_LOOP_TOOLS,
|
||||
toolChoice: "required",
|
||||
usageDetail: {
|
||||
...detail(turnId, step, attempt, ctx.segmentChars),
|
||||
plannedTags: [...pending, ...pendingDeletes],
|
||||
},
|
||||
usageSink: sinkFor(turnId, step),
|
||||
});
|
||||
modelCalls += 1;
|
||||
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||||
const prose = filled.content?.trim() ?? "";
|
||||
if (prose) lastProse = prose;
|
||||
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
||||
id: call.id || `call-${attempt}-${index}`,
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
}));
|
||||
if (calls.length === 0) break;
|
||||
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
||||
messages.push(assistantToolMessage(filled.content, calls));
|
||||
for (const item of settled.results) {
|
||||
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId });
|
||||
}
|
||||
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||||
replyFromTool = settled.reply;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let reply = replyFromTool;
|
||||
if (
|
||||
!replyFromTool &&
|
||||
pending.size === 0 &&
|
||||
pendingDeletes.size === 0 &&
|
||||
passed.size > 0
|
||||
) {
|
||||
reply = await runLeanReply(params, turnId, passed, plan.deletes, lastProse);
|
||||
modelCalls += 1;
|
||||
} else if (!reply) {
|
||||
reply = lastProse;
|
||||
}
|
||||
|
||||
const failed = [...pending, ...pendingDeletes].map(
|
||||
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
|
||||
);
|
||||
const pendingLines = [...blockedLines, ...failed];
|
||||
const text = pendingLines.length
|
||||
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
|
||||
: reply;
|
||||
return { text, modelCalls };
|
||||
}
|
||||
|
||||
async function runLeanReply(
|
||||
params: PlannedRoundParams,
|
||||
turnId: string,
|
||||
passed: Set<string>,
|
||||
plannedDeletes: string[],
|
||||
fallbackProse: string,
|
||||
): Promise<string> {
|
||||
const writtenTags = [...passed].filter((tag) => !tag.startsWith("del:"));
|
||||
const deletedTags = [...passed]
|
||||
.filter((tag) => tag.startsWith("del:"))
|
||||
.map((tag) => tag.slice(4))
|
||||
.filter(Boolean);
|
||||
const products = params.handlers
|
||||
.listProducts()
|
||||
.filter((product) => writtenTags.some((tag) => product.tag === tag || product.tag.startsWith(`${tag}#`)));
|
||||
const feedbackBlocks = writtenTags
|
||||
.map((tag) => params.handlers.lookupInsertFeedback?.(tag)?.trim())
|
||||
.filter((text): text is string => Boolean(text));
|
||||
if (writtenTags.length > 1 && feedbackBlocks.length > 1) {
|
||||
feedbackBlocks.unshift(
|
||||
"本轮写入了多个产物。用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。",
|
||||
);
|
||||
}
|
||||
const tailOpts = tailState(params);
|
||||
const deleteNote =
|
||||
deletedTags.length || plannedDeletes.length
|
||||
? `本轮已删除:${(deletedTags.length ? deletedTags : plannedDeletes).join("、")}`
|
||||
: "";
|
||||
const replyTail = [
|
||||
formatDictateRoundTail({
|
||||
...tailOpts,
|
||||
phase: "reply",
|
||||
checklist: [
|
||||
writtenTags.length ? `本轮已写入:${writtenTags.join("、")}` : "",
|
||||
deleteNote,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
}),
|
||||
feedbackBlocks.length ? `## 本轮关注(按能力回复口径)\n\n${feedbackBlocks.join("\n\n")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const replyCtx = buildDictateContext({
|
||||
systemPrompt: buildDictateReplyPrompt(),
|
||||
products,
|
||||
dialogue: params.dialogue,
|
||||
phase: "reply",
|
||||
tail: replyTail,
|
||||
});
|
||||
const filled = await params.llm.completeWithTools(replyCtx.messages, {
|
||||
caller: "dictate_agent",
|
||||
label: "对话落盘 回复",
|
||||
tools: [DICTATE_REPLY_TOOL],
|
||||
toolChoice: { type: "function", function: { name: DICTATE_REPLY_TOOL.function.name } },
|
||||
usageDetail: {
|
||||
...detail(turnId, "reply", 0, replyCtx.segmentChars),
|
||||
plannedTags: writtenTags,
|
||||
},
|
||||
usageSink: sinkFor(turnId, "reply"),
|
||||
});
|
||||
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||||
const call = filled.toolCalls.find((item) => item.name === DICTATE_REPLY_TOOL.function.name);
|
||||
if (call) {
|
||||
const text = replyText({
|
||||
id: call.id || "reply",
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
});
|
||||
if (text) return text;
|
||||
}
|
||||
return filled.content?.trim() || fallbackProse;
|
||||
}
|
||||
|
||||
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
tool_calls: calls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function" as const,
|
||||
function: { name: call.name, arguments: call.arguments },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function settleWriteStep(
|
||||
params: PlannedRoundParams,
|
||||
apply: Apply,
|
||||
calls: DriverToolCall[],
|
||||
passed: Set<string>,
|
||||
pending: Set<string>,
|
||||
pendingDeletes: Set<string>,
|
||||
errors: Map<string, string>,
|
||||
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
|
||||
const results: Array<{ callId: string; content: string }> = [];
|
||||
const forward: DriverToolCall[] = [];
|
||||
let reply = "";
|
||||
for (const call of calls) {
|
||||
if (call.name === DICTATE_REPLY_TOOL.function.name) continue;
|
||||
const tag = callTag(call, params);
|
||||
if (call.name === "insert" && tag && passed.has(tag)) {
|
||||
results.push({
|
||||
callId: call.id,
|
||||
content: JSON.stringify({ error: "已通过,本步不要再写", tag }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (call.name === "delete" && tag && passed.has(`del:${tag}`)) {
|
||||
results.push({
|
||||
callId: call.id,
|
||||
content: JSON.stringify({ error: "已通过,本步不要再删除", tag }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (call.name === "insert" && tag) {
|
||||
const args = asRecord(safeJson(call.arguments));
|
||||
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
|
||||
continue;
|
||||
}
|
||||
forward.push(call);
|
||||
}
|
||||
const outcome = await apply(forward);
|
||||
if (outcome.kind === "continue") {
|
||||
for (const result of outcome.results) {
|
||||
results.push(result);
|
||||
const call = forward.find((item) => item.id === result.callId);
|
||||
const payload = asRecord(safeJson(result.content));
|
||||
const tag = call ? callTag(call, params) : "";
|
||||
if (payload.ok === true && tag) {
|
||||
if (call?.name === "delete") {
|
||||
passed.add(`del:${tag}`);
|
||||
pendingDeletes.delete(tag);
|
||||
} else {
|
||||
passed.add(tag);
|
||||
for (const planned of [...pending]) {
|
||||
if (planned === tag || tag.startsWith(`${planned}#`)) pending.delete(planned);
|
||||
}
|
||||
}
|
||||
errors.delete(tag);
|
||||
} else if (typeof payload.error === "string" && tag) {
|
||||
const planned = [...pending, ...pendingDeletes].find(
|
||||
(item) => item === tag || tag.startsWith(`${item}#`),
|
||||
);
|
||||
errors.set(planned ?? tag, payload.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const call of calls) {
|
||||
if (call.name !== DICTATE_REPLY_TOOL.function.name) continue;
|
||||
const text = replyText(call);
|
||||
if (pending.size > 0 || pendingDeletes.size > 0) {
|
||||
const left = [...pending, ...pendingDeletes].join("、");
|
||||
results.push({
|
||||
callId: call.id,
|
||||
content: JSON.stringify({
|
||||
error: `还有未通过:${left}。这份回复先不给用户。只再写入这些。`,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
reply = text;
|
||||
results.push({ callId: call.id, content: JSON.stringify({ ok: true }) });
|
||||
}
|
||||
return { results, reply };
|
||||
}
|
||||
|
||||
function callTag(call: DriverToolCall, params: PlannedRoundParams): string {
|
||||
const args = asRecord(safeJson(call.arguments));
|
||||
const raw = String(args.position ?? args.tag ?? "").trim();
|
||||
if (!raw) return "";
|
||||
return canonicalProductTag(raw, "", params.moduleCatalog)?.tag ?? raw;
|
||||
}
|
||||
|
||||
function replyText(call: DriverToolCall): string {
|
||||
const args = asRecord(safeJson(call.arguments));
|
||||
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
|
||||
}
|
||||
|
||||
function sinkFor(_turnId: string, _step: string): UsageSink {
|
||||
return {};
|
||||
}
|
||||
|
||||
function detail(
|
||||
turnId: string,
|
||||
step: UsageDetail["step"],
|
||||
retryIndex: number,
|
||||
segmentChars: DictateSegmentChars,
|
||||
): UsageDetail {
|
||||
return { turnId, step, retryIndex, segmentChars };
|
||||
}
|
||||
|
||||
function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
|
||||
const products = params.handlers.listProducts();
|
||||
const plan = parseCreationPlan(
|
||||
products.find((product) => product.tag === CREATION_PLAN_TAG)?.content,
|
||||
);
|
||||
if (plan) {
|
||||
return {
|
||||
creationPlanState: formatCreationPlanProgress({
|
||||
plan,
|
||||
catalog: params.moduleCatalog,
|
||||
products,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
capabilityCatalog:
|
||||
formatCreationPlanCapabilityCatalog(params.moduleCatalog) || params.moduleGuide,
|
||||
recipeExamples: params.recipeExamples,
|
||||
recipeName: params.recipeName,
|
||||
recipeBrief: params.recipeBrief,
|
||||
moduleGuide: params.moduleGuide,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
||||
const row = asRecord(raw);
|
||||
const clarify = stringList(row.clarify);
|
||||
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
|
||||
const inserts: PlanInsert[] = [];
|
||||
let items = Array.isArray(row.nodes)
|
||||
? row.nodes
|
||||
: Array.isArray(row.inserts)
|
||||
? row.inserts
|
||||
: [];
|
||||
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
|
||||
const recovered = recoverSelectWork(row);
|
||||
items = recovered.items;
|
||||
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
|
||||
}
|
||||
for (const item of items) {
|
||||
const rec = asRecord(item);
|
||||
const moduleIdHint = String(rec.module_id ?? rec.moduleId ?? "").trim();
|
||||
let tag = String(rec.tag ?? rec.position ?? "").trim();
|
||||
if (!tag && moduleIdHint) {
|
||||
tag =
|
||||
params.moduleCatalog?.modules.find((module) => module.id === moduleIdHint)?.artifact?.trim() ??
|
||||
"";
|
||||
}
|
||||
if (!tag) continue;
|
||||
const canonical = canonicalProductTag(tag, moduleIdHint, params.moduleCatalog);
|
||||
if (canonical) tag = canonical.tag;
|
||||
const resolvedHint = canonical?.moduleId || moduleIdHint;
|
||||
const module =
|
||||
params.moduleReadIndex?.get(resolvedHint) ??
|
||||
params.moduleReadIndex?.get(tag) ??
|
||||
undefined;
|
||||
const fromTag = resolveModuleForInsertTag(tag, params.moduleCatalog);
|
||||
const moduleId = module?.module_id || canonical?.moduleId || fromTag?.id || moduleIdHint;
|
||||
const entryIds = stringList(rec.entry_ids);
|
||||
const reason = String(rec.reason ?? "").trim();
|
||||
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
|
||||
inserts.push({ tag: `!${tag || "空"}`, moduleId, entryIds, reason: "产物 tag 不合法" });
|
||||
continue;
|
||||
}
|
||||
const payload = moduleId ? params.moduleReadIndex?.get(moduleId) : undefined;
|
||||
if (payload && !READ_EXEMPT.has(payload.module_id) && payload.libraries.length) {
|
||||
const libraryId = String(rec.library_id ?? payload.libraries[0]?.library_id ?? "").trim();
|
||||
if (entryIds.length > 2 && !reason) {
|
||||
inserts.push({
|
||||
tag: `!${tag}`,
|
||||
moduleId,
|
||||
entryIds,
|
||||
reason: "默认最多两个库条目",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (libraryId && entryIds.length) {
|
||||
const bound = payload.libraries.some((library) => library.library_id === libraryId);
|
||||
if (!bound) {
|
||||
inserts.push({ tag: `!${tag}`, moduleId, entryIds, reason: `能力未绑定库 ${libraryId}` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
inserts.push({
|
||||
tag,
|
||||
moduleId,
|
||||
libraryId: String(rec.library_id ?? "").trim() || undefined,
|
||||
entryIds,
|
||||
reason: reason || undefined,
|
||||
});
|
||||
}
|
||||
return { clarify, inserts, deletes, keys: Object.keys(row) };
|
||||
}
|
||||
|
||||
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
|
||||
function recoverSelectWork(row: Record<string, unknown>): {
|
||||
items: unknown[];
|
||||
deletes: string[];
|
||||
} {
|
||||
if (isCreationPlanShape(row)) {
|
||||
return { items: [{ tag: CREATION_PLAN_TAG, module_id: "creation-plan" }], deletes: [] };
|
||||
}
|
||||
const items: unknown[] = [];
|
||||
const deletes: string[] = [];
|
||||
for (const list of [row.modules, row.selections, row.selected, row.writes, row.products, row.operations]) {
|
||||
for (const item of asItemList(list)) {
|
||||
const rec = flattenItem(item);
|
||||
if (
|
||||
typeof rec.tier === "string" &&
|
||||
typeof rec.why === "string" &&
|
||||
rec.tag == null &&
|
||||
rec.position == null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const op = operationName(rec);
|
||||
if (op === "read_module" || op === "read_library_entry" || op === "prepare_opening") {
|
||||
continue;
|
||||
}
|
||||
const moduleId = String(rec.module_id ?? rec.moduleId ?? "").trim();
|
||||
const tag = String(rec.tag ?? rec.position ?? rec.artifact ?? "").trim();
|
||||
if (op === "delete" || op === "remove") {
|
||||
if (tag) deletes.push(tag);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
op === "declare_variable" ||
|
||||
op === "undeclare_variable" ||
|
||||
op === "declare_map" ||
|
||||
op === "remove_map"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!tag && !moduleId) continue;
|
||||
items.push({
|
||||
tag,
|
||||
module_id: moduleId,
|
||||
...(rec.library_id != null ? { library_id: rec.library_id } : {}),
|
||||
...(rec.entry_ids != null ? { entry_ids: rec.entry_ids } : {}),
|
||||
...(rec.reason != null ? { reason: rec.reason } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { items, deletes };
|
||||
}
|
||||
|
||||
function isCreationPlanShape(row: Record<string, unknown>): boolean {
|
||||
if (row.schema === CREATION_PLAN_SCHEMA) return true;
|
||||
if (!Array.isArray(row.items) || row.items.length === 0) return false;
|
||||
return row.items.every((item) => {
|
||||
const rec = asRecord(item);
|
||||
return (
|
||||
typeof rec.module_id === "string" &&
|
||||
rec.module_id.trim().length > 0 &&
|
||||
typeof rec.tier === "string" &&
|
||||
rec.tag == null &&
|
||||
rec.position == null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function injectGroup(
|
||||
params: PlannedRoundParams,
|
||||
group: PlanInsert[],
|
||||
): { text: string } {
|
||||
const blocks: string[] = [];
|
||||
const state = parseDictateModuleReadState(
|
||||
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
|
||||
);
|
||||
let changed = false;
|
||||
for (const item of group) {
|
||||
const payload = item.moduleId ? params.moduleReadIndex?.get(item.moduleId) : undefined;
|
||||
if (!payload) continue;
|
||||
const catalogEntry = resolveModuleForInsertTag(item.tag, params.moduleCatalog);
|
||||
if (!READ_EXEMPT.has(payload.module_id) && !catalogEntry?.auto) {
|
||||
state.modules[payload.module_id] = payload.version;
|
||||
state.active_module_id = payload.module_id;
|
||||
changed = true;
|
||||
}
|
||||
const parts = [
|
||||
`### ${payload.module_id} @ ${payload.version}`,
|
||||
`产物 tag:${item.tag}`,
|
||||
payload.score_dimensions.length
|
||||
? `self_score 维度(这次写入必填,name 必须一致):${payload.score_dimensions.join("、")}`
|
||||
: "",
|
||||
`#### task\n${payload.task}`,
|
||||
`#### principles\n${payload.principles}`,
|
||||
`#### output\n${payload.output}`,
|
||||
`#### score\n${payload.score}`,
|
||||
];
|
||||
if (item.libraryId && item.entryIds.length) {
|
||||
const loaded = readLibraryEntries({
|
||||
module: payload,
|
||||
libraryId: item.libraryId,
|
||||
entryIds: item.entryIds,
|
||||
reason: item.reason,
|
||||
});
|
||||
if (loaded.entries) {
|
||||
for (const entry of loaded.entries) {
|
||||
const row = entry as { id?: string; content?: string; name?: string };
|
||||
parts.push(`#### 库 ${item.libraryId} / ${row.id ?? ""}\n${row.content ?? ""}`);
|
||||
}
|
||||
} else if (loaded.error) {
|
||||
parts.push(`#### 库读取失败\n${loaded.error}`);
|
||||
}
|
||||
}
|
||||
blocks.push(parts.filter(Boolean).join("\n\n"));
|
||||
}
|
||||
if (changed) {
|
||||
params.handlers.writeTag(DICTATE_MODULE_READ_STATE_TAG, serializeDictateModuleReadState(state));
|
||||
}
|
||||
return { text: blocks.join("\n\n") };
|
||||
}
|
||||
|
||||
function canonicalProductTag(
|
||||
tag: string,
|
||||
moduleIdHint: string,
|
||||
catalog: PlannedRoundParams["moduleCatalog"],
|
||||
): { tag: string; moduleId: string } | null {
|
||||
const bare = tag.replace(/^(?:用户|设计)\./, "").split("#")[0]?.trim() ?? "";
|
||||
const entry =
|
||||
catalog?.modules.find((module) => module.id === moduleIdHint) ??
|
||||
resolveModuleForInsertTag(tag, catalog) ??
|
||||
catalog?.modules.find(
|
||||
(module) =>
|
||||
module.name === bare ||
|
||||
module.id === bare ||
|
||||
module.artifact === tag ||
|
||||
module.artifact.endsWith(`.${bare}`),
|
||||
);
|
||||
if (!entry?.artifact?.trim()) return null;
|
||||
const hash = tag.indexOf("#");
|
||||
const suffix = hash > 0 ? tag.slice(hash) : "";
|
||||
const next =
|
||||
entry.repeatable && suffix.startsWith("#") && suffix.length > 1
|
||||
? `${entry.artifact.trim()}${suffix}`
|
||||
: entry.artifact.trim();
|
||||
return { tag: next, moduleId: entry.id };
|
||||
}
|
||||
|
||||
|
||||
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
||||
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
||||
const lines = [
|
||||
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
|
||||
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
|
||||
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
|
||||
errors.length
|
||||
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
|
||||
: "",
|
||||
];
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function asItemList(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
const rec = asRecord(value);
|
||||
const values = Object.values(rec);
|
||||
if (values.length > 0 && values.every((item) => item && typeof item === "object")) {
|
||||
return values;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function flattenItem(item: unknown): Record<string, unknown> {
|
||||
if (typeof item === "string") {
|
||||
const text = item.trim();
|
||||
if (!text) return {};
|
||||
if (text.startsWith("设计.") || text.startsWith("用户.")) return { tag: text };
|
||||
return { module_id: text };
|
||||
}
|
||||
const rec = asRecord(item);
|
||||
const nested = asRecord(rec.arguments ?? rec.args ?? rec.payload);
|
||||
return { ...rec, ...nested };
|
||||
}
|
||||
|
||||
function operationName(rec: Record<string, unknown>): string {
|
||||
return String(rec.op ?? rec.name ?? rec.type ?? rec.action ?? "insert").trim();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function safeJson(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
+68
-2
@@ -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,
|
||||
];
|
||||
|
||||
+146
-3
@@ -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<string> | readonly string[];
|
||||
driver?: AgentDriver;
|
||||
}): Promise<DictateTurnResult> {
|
||||
const driver = params.driver ?? createLocalLlmDriver(params.llm);
|
||||
const driver =
|
||||
params.driver ??
|
||||
createPlannedDictateDriver({
|
||||
llm: params.llm,
|
||||
dialogue: params.dialogue,
|
||||
handlers: {
|
||||
listProducts: params.handlers.listProducts,
|
||||
readTag: params.handlers.readTag,
|
||||
writeTag: params.handlers.writeTag,
|
||||
prepareOpening: params.handlers.prepareOpening,
|
||||
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
|
||||
onThinkingDelta: params.handlers.onThinkingDelta,
|
||||
onThinkingDone: params.handlers.onThinkingDone,
|
||||
},
|
||||
moduleCatalog: params.moduleCatalog,
|
||||
moduleReadIndex: params.moduleReadIndex,
|
||||
moduleGuide: params.moduleGuide,
|
||||
recipeExamples: params.recipeExamples,
|
||||
recipeName: params.recipeName,
|
||||
recipeBrief: params.recipeBrief,
|
||||
systemPrompt: params.systemPrompt,
|
||||
});
|
||||
const wroteTags: string[] = [];
|
||||
const 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<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function contractForOpeningTag(
|
||||
tag: string,
|
||||
products: DictateProduct[],
|
||||
): ReplyDataContract | undefined {
|
||||
const want = tag.includes("0层") ? "设计.0层正文组成" : "设计.正文组成";
|
||||
const raw =
|
||||
products.find((product) => product.tag === want)?.content ??
|
||||
products.find((product) => product.tag === "设计.回复格式")?.content;
|
||||
return parseReplyDataContract(raw);
|
||||
}
|
||||
|
||||
function openingContractMiss(
|
||||
tag: string,
|
||||
content: string,
|
||||
products: DictateProduct[],
|
||||
): { error: string; reply_contract?: ReturnType<typeof replyContractSummary> } | undefined {
|
||||
if (!isDictateOpeningTag(tag)) return undefined;
|
||||
const contract = contractForOpeningTag(tag, products);
|
||||
if (!contract) return undefined;
|
||||
const error = openingContractError(content, contract);
|
||||
if (!error) return undefined;
|
||||
return { error, reply_contract: replyContractSummary(contract) };
|
||||
}
|
||||
|
||||
function requiresModuleRead(moduleId: string, auto?: boolean): boolean {
|
||||
if (auto) return false;
|
||||
return !new Set([
|
||||
|
||||
@@ -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 层";
|
||||
|
||||
@@ -1230,8 +1230,12 @@ modules:
|
||||
expect(sections.blocks.task).toContain("默认只生成 1 条");
|
||||
expect(sections.blocks.task).toContain("@玩家");
|
||||
expect(sections.blocks.task).toContain("随机");
|
||||
expect(sections.blocks.output).toContain('"默认候选id"');
|
||||
expect(sections.blocks.output).toContain('"开场白候选"');
|
||||
expect(module?.artifact).toBe("设计.开场白");
|
||||
expect(sections.blocks.task).toContain("设计.开场白#opening-cycle2");
|
||||
expect(sections.blocks.task).not.toContain("写入 `设计.开场白与开场变量`");
|
||||
expect(sections.blocks.output).toContain("设计.开场白#场景短码");
|
||||
expect(sections.blocks.output).toContain('"开场变量"');
|
||||
expect(sections.blocks.output).not.toContain('"开场白候选"');
|
||||
expect(sections.blocks.output).not.toContain('"开场白全文"');
|
||||
expect(sections.blocks.output).toContain("present.v1");
|
||||
expect(sections.blocks.output).not.toContain('"自评"');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CREATION_PLAN_TAG,
|
||||
buildDictateSystemPrompt,
|
||||
formatDictateRoundTail,
|
||||
creationPlanModules,
|
||||
formatCreationPlanCapabilityCatalog,
|
||||
formatCreationPlanProgress,
|
||||
@@ -9,7 +10,9 @@ import {
|
||||
validateCreationPlan,
|
||||
type CreationPlan,
|
||||
} from "../src/dictate/index.js";
|
||||
import { moduleBrowseGroup } from "../src/dictate/creation-plan.js";
|
||||
import {
|
||||
catalogModulesForIntake,
|
||||
formatModuleCatalogForDictate,
|
||||
loadModuleCatalog,
|
||||
} from "../src/skills/creation-flow.js";
|
||||
@@ -65,6 +68,32 @@ describe("creation plan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("groups dictate nodes by runtime role and omits the recipe-only merge", async () => {
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
expect(catalog).toBeTruthy();
|
||||
const dictate = catalogModulesForIntake(catalog!, "dictate");
|
||||
const ids = dictate.map((module) => module.id);
|
||||
expect(ids).not.toContain("narrative");
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "creation-plan")!)).toBe(
|
||||
"plan",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "narrative-guide")!)).toBe(
|
||||
"content",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "reply-format")!)).toBe(
|
||||
"present",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "zero-layer-status")!)).toBe(
|
||||
"present",
|
||||
);
|
||||
expect(moduleBrowseGroup(dictate.find((module) => module.id === "refine")!)).toBe(
|
||||
"program",
|
||||
);
|
||||
expect(new Set(dictate.map((module) => moduleBrowseGroup(module)))).toEqual(
|
||||
new Set(["plan", "content", "present", "program"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects exactly one presentation chain", async () => {
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
expect(catalog).toBeTruthy();
|
||||
@@ -160,17 +189,19 @@ describe("creation plan", () => {
|
||||
expect(unusedSection).not.toContain("何时用:");
|
||||
|
||||
const oldFullGuide = formatModuleCatalogForDictate(catalog);
|
||||
const before = buildDictateSystemPrompt({
|
||||
const fixed = buildDictateSystemPrompt();
|
||||
const before = formatDictateRoundTail({
|
||||
capabilityCatalog: shortCatalog,
|
||||
recipeExamples: "- text-generator:文本生成案例",
|
||||
});
|
||||
const after = buildDictateSystemPrompt({ creationPlanState: progress });
|
||||
const after = formatDictateRoundTail({ creationPlanState: progress });
|
||||
expect(fixed).not.toContain("当前决策");
|
||||
expect(before).toContain("尚无本局创作方案");
|
||||
expect(after).toContain("当前决策");
|
||||
expect(after).toContain("收口前的核对清单");
|
||||
expect(after).not.toContain("旧配方案例目录");
|
||||
expect(after.length).toBeLessThan(
|
||||
buildDictateSystemPrompt({ moduleGuide: oldFullGuide }).length,
|
||||
formatDictateRoundTail({ moduleGuide: oldFullGuide }).length,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
applyDictatePlayBind,
|
||||
buildDictateContextOrder,
|
||||
bindDictateProductsToPlaySpec,
|
||||
collectDictateBindProducts,
|
||||
DICTATE_PLAY_BIND_INPUT_META,
|
||||
isDictateStyleBindTag,
|
||||
} from "../src/dictate/play-bind.js";
|
||||
import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js";
|
||||
@@ -215,4 +217,51 @@ describe("dictate play-bind", () => {
|
||||
expect(refs).toContain("上下文.角色态度");
|
||||
expect(refs).not.toContain("设计.变量映射");
|
||||
});
|
||||
|
||||
it("skips rebind on save when mounted products are unchanged", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({ tag: "用户.需求", content: "雨夜", source: "test" });
|
||||
const first = applyDictatePlayBind(board);
|
||||
expect(first.skipped).toBe(false);
|
||||
const orderUpdated = board.getLatestByTag("设计.上下文投影排序")?.updatedAt;
|
||||
const workerUpdated = board.getLatestByTag("设计.worker集")?.updatedAt;
|
||||
expect(board.getLatestByTag("设计.上下文投影排序")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META]).toEqual(
|
||||
board.getLatestByTag("设计.worker集")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META],
|
||||
);
|
||||
|
||||
const second = applyDictatePlayBind(board);
|
||||
expect(second.skipped).toBe(true);
|
||||
expect(second.changed).toBe(false);
|
||||
expect(board.getLatestByTag("设计.上下文投影排序")?.updatedAt).toBe(orderUpdated);
|
||||
expect(board.getLatestByTag("设计.worker集")?.updatedAt).toBe(workerUpdated);
|
||||
});
|
||||
|
||||
it("keeps a hand-edited order until a mounted product changes", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({ tag: "用户.需求", content: "雨夜", source: "test" });
|
||||
applyDictatePlayBind(board);
|
||||
|
||||
const orderItem = board.getLatestByTag("设计.上下文投影排序")!;
|
||||
const doc = JSON.parse(orderItem.content) as { brief?: string };
|
||||
doc.brief = "手改排序";
|
||||
const handEdited = JSON.stringify(doc, null, 2);
|
||||
board.write({
|
||||
tag: "设计.上下文投影排序",
|
||||
content: handEdited,
|
||||
source: "user",
|
||||
metadata: orderItem.metadata,
|
||||
});
|
||||
const kept = applyDictatePlayBind(board);
|
||||
expect(kept.skipped).toBe(true);
|
||||
expect(board.getContentByTag("设计.上下文投影排序")).toBe(handEdited);
|
||||
|
||||
board.write({ tag: "设计.叙事指南", content: "短句", source: "test" });
|
||||
const rebound = applyDictatePlayBind(board);
|
||||
expect(rebound.skipped).toBe(false);
|
||||
expect(rebound.boundTags).toContain("设计.叙事指南");
|
||||
const rewritten = JSON.parse(
|
||||
board.getContentByTag("设计.上下文投影排序") ?? "{}",
|
||||
) as { brief?: string };
|
||||
expect(rewritten.brief).not.toBe("手改排序");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 5 },
|
||||
{ name: "美学纲领", score: 5 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -82,7 +83,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 5 },
|
||||
{ name: "美学纲领", score: 5 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 3, gap: "规则因果仍需确认" },
|
||||
],
|
||||
},
|
||||
@@ -205,7 +207,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 3 },
|
||||
{ name: "美学纲领", score: 3 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -226,7 +229,8 @@ describe("dictate read_module", () => {
|
||||
content: "{}",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "核心体验", score: 3, gap: "发生方式仍偏宽" },
|
||||
{ name: "美学纲领", score: 3, gap: "发生方式仍偏宽" },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -254,13 +258,114 @@ describe("dictate read_module", () => {
|
||||
layer: "final",
|
||||
selfScore: {
|
||||
dims: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "核心体验", score: 3 }),
|
||||
expect.objectContaining({ name: "美学纲领", score: 3 }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening uses the reply contract", () => {
|
||||
const contract = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
数据契约: {
|
||||
blocks: {
|
||||
body: { 类型: "markdown", 来源: "model_generated" },
|
||||
forum_feed: { 类型: "list", 来源: "model_generated" },
|
||||
},
|
||||
},
|
||||
frontend: { html: `<div id="app"></div>`, css: "", js: "" },
|
||||
},
|
||||
});
|
||||
const scores = {
|
||||
dims: [
|
||||
{ name: "开场必要", score: 5 },
|
||||
{ name: "前端符合", score: 5 },
|
||||
{ name: "状态自洽", score: 5 },
|
||||
{ name: "角色占位", score: 5 },
|
||||
{ name: "收口可切换", score: 5 },
|
||||
],
|
||||
};
|
||||
|
||||
it("returns the live contract when the opening module is read", async () => {
|
||||
const { catalog, moduleReadIndex } = await setup();
|
||||
const tags = new Map<string, string>([["设计.正文组成", contract]]);
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "r1",
|
||||
name: "read_module",
|
||||
arguments: JSON.stringify({ module_id: "opening-setup" }),
|
||||
},
|
||||
]);
|
||||
if (outcome.kind === "continue") {
|
||||
const payload = JSON.parse(outcome.results[0]!.content);
|
||||
expect(payload.reply_contract.model_blocks.map((block: { id: string }) => block.id)).toEqual([
|
||||
"body",
|
||||
"forum_feed",
|
||||
]);
|
||||
}
|
||||
return { iterations: 1, stop: { kind: "text", content: "已读" } };
|
||||
},
|
||||
};
|
||||
await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex,
|
||||
driver,
|
||||
handlers: handlers(tags),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an opening that only fills the old body shell", async () => {
|
||||
const { catalog, moduleReadIndex } = await setup();
|
||||
const tags = new Map<string, string>([["设计.正文组成", contract]]);
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
await input.handleStep([
|
||||
{
|
||||
id: "r1",
|
||||
name: "read_module",
|
||||
arguments: JSON.stringify({ module_id: "opening-setup" }),
|
||||
},
|
||||
]);
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "i1",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.开场白",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
shell: "prose",
|
||||
blocks: { body: "@玩家推开门。" },
|
||||
}),
|
||||
self_score: scores,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
if (outcome.kind === "continue") {
|
||||
const payload = JSON.parse(outcome.results[0]!.content);
|
||||
expect(payload.error).toContain("forum_feed");
|
||||
}
|
||||
return { iterations: 1, stop: { kind: "text", content: "未写入" } };
|
||||
},
|
||||
};
|
||||
const result = await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex,
|
||||
driver,
|
||||
handlers: handlers(tags),
|
||||
});
|
||||
expect(result.wroteTags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function handlers(tags: Map<string, string>) {
|
||||
return {
|
||||
listProducts: () =>
|
||||
|
||||
@@ -0,0 +1,869 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DICTATE_MODULE_READ_STATE_TAG,
|
||||
buildDictateContext,
|
||||
parseDictateModuleReadState,
|
||||
runDictateTurn,
|
||||
type DictateModuleReadPayload,
|
||||
} from "../src/dictate/index.js";
|
||||
import type { LlmProvider } from "../src/llm/client.js";
|
||||
import type { ModuleCatalog } from "../src/skills/creation-flow.js";
|
||||
|
||||
function scriptedLlm(steps: unknown[]): LlmProvider {
|
||||
let index = 0;
|
||||
return {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools() {
|
||||
const body = steps[index++] ?? {};
|
||||
const select = index === 1;
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, select),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toToolCalls(body: unknown, select: boolean) {
|
||||
const row = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
if (select) {
|
||||
return [{ id: "select", name: "select_nodes", arguments: JSON.stringify(row) }];
|
||||
}
|
||||
const calls: Array<{ id: string; name: string; arguments: string }> = [];
|
||||
const products = row.products;
|
||||
if (Array.isArray(products)) {
|
||||
products.forEach((item, index) => {
|
||||
const rec = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||
if ("tag" in rec || "position" in rec || "content" in rec || "正文" in rec) {
|
||||
calls.push({
|
||||
id: `ins-${index}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: rec.tag ?? rec.position,
|
||||
content: rec.content ?? rec.正文 ?? "",
|
||||
...(rec.self_score !== undefined ? { self_score: rec.self_score } : {}),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Object.entries(rec).forEach(([key, value], piece) => {
|
||||
const content =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value && typeof value === "object"
|
||||
? String((value as Record<string, unknown>).内容 ?? (value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).content ?? "")
|
||||
: "";
|
||||
calls.push({
|
||||
id: `ins-${index}-${piece}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: key, content }),
|
||||
});
|
||||
});
|
||||
});
|
||||
} else if (products && typeof products === "object") {
|
||||
Object.entries(products as Record<string, unknown>).forEach(([key, value], index) => {
|
||||
const content =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value && typeof value === "object"
|
||||
? String((value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).content ?? "")
|
||||
: "";
|
||||
calls.push({
|
||||
id: `ins-${index}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: key.startsWith("设计.") || key.startsWith("用户.") ? key : `设计.${key}`, content }),
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (key === "products" || key === "reply" || key === "operations") continue;
|
||||
if (typeof value === "string" && (key.startsWith("设计.") || key.startsWith("用户."))) {
|
||||
calls.push({
|
||||
id: `extra-${key}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: key, content: value }),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(row.operations)) {
|
||||
row.operations.forEach((item, index) => {
|
||||
const rec = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||
const nested =
|
||||
rec.arguments && typeof rec.arguments === "object"
|
||||
? (rec.arguments as Record<string, unknown>)
|
||||
: rec;
|
||||
const content = String(nested.content ?? "");
|
||||
if (!content.trim()) return;
|
||||
calls.push({
|
||||
id: `op-${index}`,
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: nested.position ?? nested.tag, content }),
|
||||
});
|
||||
});
|
||||
}
|
||||
if (typeof row.reply === "string") {
|
||||
calls.push({
|
||||
id: "reply",
|
||||
name: "reply_user",
|
||||
arguments: JSON.stringify({ text: row.reply }),
|
||||
});
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
modules: [
|
||||
{
|
||||
id: "mood",
|
||||
name: "氛围",
|
||||
artifact: "设计.氛围",
|
||||
declaration: "细雨",
|
||||
},
|
||||
],
|
||||
} as ModuleCatalog;
|
||||
|
||||
const modulePayload: DictateModuleReadPayload = {
|
||||
module_id: "mood",
|
||||
name: "氛围",
|
||||
artifact: "设计.氛围",
|
||||
version: "v-test",
|
||||
task: "写氛围",
|
||||
principles: "短句",
|
||||
output: "一段话",
|
||||
score: "",
|
||||
score_dimensions: [],
|
||||
libraries: [],
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("dictate planned round", () => {
|
||||
it("puts earlier dialogue before products and the last user turn at the end", () => {
|
||||
const built = buildDictateContext({
|
||||
systemPrompt: "规则",
|
||||
products: [{ tag: "设计.氛围", content: "旧稿", order: 0 }],
|
||||
dialogue: [
|
||||
{ role: "user", text: "第一句" },
|
||||
{ role: "assistant", text: "记下了" },
|
||||
{ role: "user", text: "改成细雨" },
|
||||
],
|
||||
phase: "fill",
|
||||
injected: "#### task\n写氛围",
|
||||
tail: "方案进度",
|
||||
});
|
||||
const body = built.messages[1]?.content ?? "";
|
||||
expect(body.indexOf("必须调用 reply_user")).toBeLessThan(body.indexOf("第一句"));
|
||||
expect(body.indexOf("第一句")).toBeLessThan(body.indexOf("设计.氛围"));
|
||||
expect(body.indexOf("设计.氛围")).toBeLessThan(body.indexOf("写氛围"));
|
||||
expect(body.indexOf("写氛围")).toBeLessThan(body.indexOf("改成细雨"));
|
||||
expect(body.indexOf("方案进度")).toBeLessThan(body.indexOf("改成细雨"));
|
||||
expect(body.indexOf("写完下列节点后")).toBeLessThan(body.indexOf("写氛围"));
|
||||
expect(built.segmentChars.products).toBeGreaterThan(0);
|
||||
expect(built.segmentChars.rules).toBe("规则".length);
|
||||
|
||||
const select = buildDictateContext({
|
||||
systemPrompt: "规则",
|
||||
products: [{ tag: "设计.氛围", content: "旧稿" }],
|
||||
dialogue: [
|
||||
{ role: "user", text: "第一句" },
|
||||
{ role: "user", text: "改成细雨" },
|
||||
],
|
||||
phase: "select",
|
||||
tail: "方案进度",
|
||||
});
|
||||
const selectBody = select.messages[1]?.content ?? "";
|
||||
expect(selectBody).not.toContain("旧稿");
|
||||
expect(selectBody).toContain("第一句");
|
||||
expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨"));
|
||||
});
|
||||
|
||||
it("injects the spec, stamps the read credential, and rewrites only the missing tag", async () => {
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([
|
||||
{
|
||||
clarify: [],
|
||||
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
||||
deletes: [],
|
||||
},
|
||||
{ products: [], reply: "还没写" },
|
||||
{
|
||||
products: [{ tag: "设计.氛围", content: "细雨" }],
|
||||
reply: "已写成细雨",
|
||||
},
|
||||
]),
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", modulePayload],
|
||||
["氛围", modulePayload],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
const existing = products.find((item) => item.tag === tag);
|
||||
if (existing) existing.content = content;
|
||||
else products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||
expect(products).toEqual([{ tag: "设计.氛围", content: "细雨" }]);
|
||||
expect(result.reply).toContain("已写成细雨");
|
||||
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
|
||||
expect(state.modules.mood).toBe("v-test");
|
||||
});
|
||||
|
||||
it("stops after the select step when the plan is a clarification", async () => {
|
||||
let writes = 0;
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([
|
||||
{
|
||||
clarify: ["沿用冷色", "改成暖色"],
|
||||
inserts: [],
|
||||
deletes: [],
|
||||
},
|
||||
]),
|
||||
dialogue: [{ role: "user", text: "氛围还没定" }],
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => {
|
||||
writes += 1;
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(writes).toBe(0);
|
||||
expect(result.iterations).toBe(1);
|
||||
expect(result.reply).toContain("沿用冷色");
|
||||
expect(result.reply).toContain("改成暖色");
|
||||
});
|
||||
|
||||
it("says the returned fields when the select plan writes nothing", async () => {
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([{ note: "看世界" }]),
|
||||
dialogue: [{ role: "user", text: "超级英雄" }],
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => undefined,
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(result.wroteTags).toEqual([]);
|
||||
expect(result.iterations).toBe(1);
|
||||
expect(result.reply).toContain("note");
|
||||
expect(result.reply).toContain("没有写入产物");
|
||||
});
|
||||
|
||||
it("follows a select result with a fill request that carries specs and products", async () => {
|
||||
const seen: string[] = [];
|
||||
const steps = [
|
||||
{
|
||||
schema: "creation-plan.v1",
|
||||
items: [{ module_id: "mood", tier: "必须", why: "细雨", missing: "", instances: [] }],
|
||||
},
|
||||
{
|
||||
products: [{ tag: "设计.本局创作方案", content: "{\"schema\":\"creation-plan.v1\"}" }],
|
||||
reply: "已写方案",
|
||||
},
|
||||
];
|
||||
let index = 0;
|
||||
const llm: LlmProvider = {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body = steps[index++] ?? {};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, index === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
const products = [{ tag: "设计.舞台", content: "已有舞台" }];
|
||||
await runDictateTurn({
|
||||
llm,
|
||||
dialogue: [{ role: "user", text: "超级英雄的世界" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([
|
||||
[
|
||||
"creation-plan",
|
||||
{
|
||||
...modulePayload,
|
||||
module_id: "creation-plan",
|
||||
artifact: "设计.本局创作方案",
|
||||
task: "规格任务正文",
|
||||
principles: "规格原则正文",
|
||||
},
|
||||
],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
const existing = products.find((item) => item.tag === tag);
|
||||
if (existing) existing.content = content;
|
||||
else products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||
expect(seen[0]).not.toContain("已有舞台");
|
||||
expect(seen[0]).not.toContain("规格任务正文");
|
||||
expect(seen[1]).toContain("已有舞台");
|
||||
expect(seen[1]).toContain("规格任务正文");
|
||||
expect(seen[1]).toContain("规格原则正文");
|
||||
expect(seen[1]).toContain("#### output");
|
||||
expect(seen[1]).toContain("设计.本局创作方案");
|
||||
});
|
||||
|
||||
it("treats operations as the selection, injects the node table, and writes every returned item", async () => {
|
||||
const seen: string[] = [];
|
||||
const steps = [
|
||||
{
|
||||
operations: [
|
||||
{ name: "insert", arguments: { position: "设计.氛围", module_id: "mood" } },
|
||||
],
|
||||
reply: "先写氛围",
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{ position: "设计.氛围", content: "细雨" },
|
||||
{ tag: "设计.旁白", content: "旁白一句" },
|
||||
],
|
||||
reply: "写了两份",
|
||||
},
|
||||
];
|
||||
let index = 0;
|
||||
const llm: LlmProvider = {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body = steps[index++] ?? {};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, index === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [
|
||||
{ tag: "设计.舞台", content: "已有舞台" },
|
||||
];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm,
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
const existing = products.find((item) => item.tag === tag);
|
||||
if (existing) existing.content = content;
|
||||
else products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||
expect(seen[0]).not.toContain("已有舞台");
|
||||
expect(seen[0]).not.toContain("写氛围");
|
||||
expect(seen[1]).toContain("本轮节点表");
|
||||
expect(seen[1]).toContain("写氛围");
|
||||
expect(seen[1]).toContain("已有舞台");
|
||||
expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"]));
|
||||
expect(result.reply).toContain("写了两份");
|
||||
});
|
||||
|
||||
it("persists products named by module or keyed outside the products array", async () => {
|
||||
const resultProducts: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([
|
||||
{
|
||||
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
||||
deletes: [],
|
||||
},
|
||||
{
|
||||
reply: "写了",
|
||||
products: { 氛围: { 正文: "细雨连绵" } },
|
||||
"设计.旁白": "旁白一句",
|
||||
},
|
||||
]),
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => resultProducts,
|
||||
writeProduct: (tag, content) => {
|
||||
resultProducts.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(resultProducts.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]);
|
||||
expect(resultProducts.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵");
|
||||
expect(result.reply).toContain("写了");
|
||||
expect(result.reply).not.toContain("未给出");
|
||||
});
|
||||
|
||||
it("selects node ids first, then writes them with the user text and the node specs", async () => {
|
||||
const seen: string[] = [];
|
||||
const steps = [
|
||||
{ nodes: [{ module_id: "mood" }] },
|
||||
{
|
||||
products: [{ tag: "设计.氛围", content: "细雨" }],
|
||||
reply: "已写氛围。可能还缺主角设定。",
|
||||
},
|
||||
];
|
||||
let index = 0;
|
||||
const llm: LlmProvider = {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body = steps[index++] ?? {};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, index === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm,
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen[0]).toContain("只做选题");
|
||||
expect(seen[0]).toContain("写成细雨");
|
||||
expect(seen[0]).not.toContain("declare_variable");
|
||||
expect(seen[1]).toContain("写成细雨");
|
||||
expect(seen[1]).toContain("写氛围");
|
||||
expect(seen[1]).toContain("第一轮选定的节点:mood");
|
||||
expect(seen[1]).toContain("还可能缺哪些节点");
|
||||
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||
expect(result.reply).toContain("可能还缺主角设定");
|
||||
});
|
||||
|
||||
it("maps a user-prefixed tag onto the catalog artifact and reads Chinese content fields", async () => {
|
||||
const narration: DictateModuleReadPayload = {
|
||||
...modulePayload,
|
||||
module_id: "narration",
|
||||
name: "旁白",
|
||||
artifact: "设计.旁白",
|
||||
task: "写旁白",
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const result = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const step = seen.length;
|
||||
const body =
|
||||
step === 1
|
||||
? { nodes: [{ tag: "用户.氛围" }, { module_id: "narration" }] }
|
||||
: {
|
||||
products: [{ "用户.氛围": { 内容: "细雨连绵" }, "设计.旁白": "旁白一句" }],
|
||||
reply: "写了两份",
|
||||
};
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, step === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: {
|
||||
modules: [
|
||||
...catalog.modules,
|
||||
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
|
||||
],
|
||||
} as ModuleCatalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", modulePayload],
|
||||
["narration", narration],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen[1]).toContain("产物 tag:设计.氛围");
|
||||
expect(seen[1]).toContain("产物 tag:设计.旁白");
|
||||
expect(products.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]);
|
||||
expect(products.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵");
|
||||
expect(result.reply).not.toContain("未给出");
|
||||
});
|
||||
|
||||
it("asks for self_score dimensions before a write can land", async () => {
|
||||
const scored: DictateModuleReadPayload = {
|
||||
...modulePayload,
|
||||
score_dimensions: ["短句"],
|
||||
};
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const result = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
const body =
|
||||
seen.length === 1
|
||||
? { nodes: [{ module_id: "mood" }] }
|
||||
: { products: [{ tag: "设计.氛围", content: "细雨" }], reply: "已落盘" };
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: toToolCalls(body, seen.length === 1),
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", scored]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句");
|
||||
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
||||
expect(seen[2]).toContain("还有未通过");
|
||||
expect(products).toHaveLength(0);
|
||||
expect(result.reply).toContain("self_score 必填");
|
||||
expect(result.reply).toContain("短句");
|
||||
expect(result.reply).not.toContain("未给出");
|
||||
});
|
||||
|
||||
it("keeps the node table and refuses to rewrite a tag that already passed", async () => {
|
||||
const narration: DictateModuleReadPayload = {
|
||||
...modulePayload,
|
||||
module_id: "narration",
|
||||
name: "旁白",
|
||||
artifact: "设计.旁白",
|
||||
task: "写旁白",
|
||||
score_dimensions: ["一句"],
|
||||
};
|
||||
const writes: string[] = [];
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const rounds = [
|
||||
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }, { module_id: "narration" }] }) }],
|
||||
[
|
||||
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
||||
{ id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) },
|
||||
{ id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "都写了" }) },
|
||||
],
|
||||
[
|
||||
{ id: "a2", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "改掉细雨" }) },
|
||||
{
|
||||
id: "b2",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.旁白",
|
||||
content: "旁白",
|
||||
self_score: { dims: [{ name: "一句", score: 4 }] },
|
||||
}),
|
||||
},
|
||||
],
|
||||
[{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "旁白补上了" }) }],
|
||||
];
|
||||
let index = 0;
|
||||
await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: rounds[index++] ?? [],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: {
|
||||
modules: [
|
||||
...catalog.modules,
|
||||
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
|
||||
],
|
||||
} as ModuleCatalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", modulePayload],
|
||||
["narration", narration],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: (tag, content) => {
|
||||
writes.push(`${tag}:${content}`);
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
||||
expect(seen[2]).toContain("写旁白");
|
||||
expect(writes).toEqual(["设计.氛围:细雨", "设计.旁白:旁白"]);
|
||||
});
|
||||
|
||||
it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => {
|
||||
const seen: string[] = [];
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const rounds = [
|
||||
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
|
||||
[
|
||||
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
||||
{ id: "r", name: "reply_user", arguments: JSON.stringify({ text: "细雨留下了" }) },
|
||||
],
|
||||
];
|
||||
let index = 0;
|
||||
const tags = new Map<string, string>();
|
||||
await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: rounds[index++] ?? [],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen).toHaveLength(2);
|
||||
const fill = seen[1] ?? "";
|
||||
expect(fill.indexOf("填写收尾")).toBeLessThan(fill.indexOf("写入规则"));
|
||||
expect(fill.indexOf("本步收尾")).toBeLessThan(fill.indexOf("更早的对话"));
|
||||
expect(fill.indexOf("同一次请求必须调用 reply_user")).toBeLessThan(fill.indexOf("写氛围"));
|
||||
expect(fill).toContain("分数一般或还有更好写法的缺口");
|
||||
expect(fill).toContain("下一步可以提及哪些");
|
||||
expect(fill).not.toContain("本轮刚落盘");
|
||||
expect(fill).not.toContain("你只生成给用户看的综合回复");
|
||||
});
|
||||
|
||||
it("silently asks for reply_user with only this round's products when fill omitted it", async () => {
|
||||
const products: Array<{ tag: string; content: string }> = [
|
||||
{ tag: "设计.舞台", content: "旧舞台不应进入回复请求" },
|
||||
];
|
||||
const seen: string[] = [];
|
||||
const choices: Array<unknown> = [];
|
||||
let index = 0;
|
||||
const tags = new Map<string, string>();
|
||||
const result = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages, options) {
|
||||
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||
choices.push(options?.toolChoice);
|
||||
const step = index++;
|
||||
if (step === 0) {
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "select",
|
||||
name: "select_nodes",
|
||||
arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
return {
|
||||
content: "细雨留下了,接下来可以定主角怎么进城。",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "a",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.氛围",
|
||||
content: "细雨",
|
||||
self_score: {
|
||||
dims: [{ name: "短句", score: 3, gap: "还缺进城方式", options: ["步行", "马车"] }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "r",
|
||||
name: "reply_user",
|
||||
arguments: JSON.stringify({ text: "细雨已落盘,接下来可以定进城方式。" }),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", { ...modulePayload, score_dimensions: ["短句"] }],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content, _order, metadata) => {
|
||||
products.push({
|
||||
tag,
|
||||
content,
|
||||
...(metadata?.selfScore ? { selfScore: metadata.selfScore } : {}),
|
||||
});
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
lookupInsertFeedback: (tag) =>
|
||||
tag === "设计.氛围"
|
||||
? `# 本轮关注 · 氛围\n\n## 写入用户回复时做什么\n1. 自然回应\n3. 结合 self_score 说明最强处与关键缺口\n\n## 自评关注\n- 短句`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.reply).toBe("细雨已落盘,接下来可以定进城方式。");
|
||||
expect(result.iterations).toBe(3);
|
||||
expect(seen).toHaveLength(3);
|
||||
const replyReq = seen[2] ?? "";
|
||||
expect(replyReq).toContain("本轮刚落盘");
|
||||
expect(replyReq).toContain("细雨");
|
||||
expect(replyReq).toContain("self_score");
|
||||
expect(replyReq).toContain("还缺进城方式");
|
||||
expect(replyReq).toContain("本轮关注");
|
||||
expect(replyReq).toContain("结合 self_score 说明最强处与关键缺口");
|
||||
expect(replyReq).toContain("每个缺口写三样");
|
||||
expect(replyReq).toContain("2~3 个可以接着说的选项");
|
||||
expect(replyReq).not.toContain("旧舞台不应进入回复请求");
|
||||
expect(replyReq).not.toContain("#### task");
|
||||
expect(choices[2]).toEqual({ type: "function", function: { name: "reply_user" } });
|
||||
});
|
||||
});
|
||||
+53
-15
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDictateMessages,
|
||||
buildDictateSystemPrompt,
|
||||
formatDictateRoundTail,
|
||||
buildDictateInsertFeedbackIndex,
|
||||
defaultDictateOrder,
|
||||
extractDictateDialogue,
|
||||
@@ -47,7 +48,7 @@ describe("dictate context", () => {
|
||||
});
|
||||
const body = messages[1]?.content ?? "";
|
||||
expect(body).toContain("冷色短句");
|
||||
expect(body).toContain("尚无对话");
|
||||
expect(body).toContain("尚无更早对话");
|
||||
});
|
||||
|
||||
it("extracts only user + dictate_reply turns", () => {
|
||||
@@ -73,8 +74,9 @@ describe("dictate context", () => {
|
||||
expect(isDictateModeValue("recipe")).toBe(false);
|
||||
});
|
||||
|
||||
it("system prompt starts from a plan and keeps generic write tools", () => {
|
||||
const prompt = buildDictateSystemPrompt({
|
||||
it("system prompt keeps stable rules; catalog and plan progress sit in the tail", () => {
|
||||
const prompt = buildDictateSystemPrompt();
|
||||
const tail = formatDictateRoundTail({
|
||||
recipeName: "数据化跑团体验",
|
||||
recipeBrief:
|
||||
"设定 + 真值 + 分档映射\n怎么做:\n- declare_variable\n- declare_map",
|
||||
@@ -90,17 +92,31 @@ describe("dictate context", () => {
|
||||
expect(prompt).toContain("declare_map");
|
||||
expect(prompt).toContain("remove_map");
|
||||
expect(prompt).toContain("同 position 再 insert");
|
||||
expect(prompt).toContain("数据化跑团体验");
|
||||
expect(prompt).not.toContain("数据化跑团体验");
|
||||
expect(prompt).toMatch(/order/);
|
||||
expect(prompt).toContain("【能力 · 何时落盘】");
|
||||
expect(prompt).toContain("设计.美学纲领与交互范式");
|
||||
expect(prompt).not.toContain("【能力 · 何时落盘】");
|
||||
expect(prompt).toContain("通常 2~3 个");
|
||||
expect(prompt).toContain("给 2~3 个选项来问");
|
||||
expect(prompt).toContain("不是 DAG");
|
||||
expect(prompt).toContain("不是用户已选路径");
|
||||
expect(prompt).not.toContain("不是用户已选路径");
|
||||
expect(prompt).toContain("自然回应");
|
||||
expect(prompt.indexOf("填写收尾")).toBeLessThan(prompt.indexOf("写入规则"));
|
||||
expect(prompt).not.toContain("体验锚定(程序打分)");
|
||||
expect(prompt).not.toContain("## 本轮落什么");
|
||||
expect(tail).toContain("数据化跑团体验");
|
||||
expect(tail).toContain("【能力 · 何时落盘】");
|
||||
expect(tail).toContain("不是用户已选路径");
|
||||
expect(tail).toContain("尚无本局创作方案");
|
||||
});
|
||||
|
||||
it("puts the fill reply_user duty before plan progress in the tail", () => {
|
||||
const fillTail = formatDictateRoundTail({
|
||||
phase: "fill",
|
||||
creationPlanState: "【当前决策】\n- mood:已落",
|
||||
checklist: "本轮写入:设计.氛围",
|
||||
});
|
||||
expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("当前决策"));
|
||||
expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("本轮写入"));
|
||||
});
|
||||
|
||||
it("formats module catalog for dictate with land tags and when", async () => {
|
||||
@@ -172,6 +188,12 @@ describe("dictate context", () => {
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白#opening-cycle2", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白与开场变量", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id,
|
||||
).toBe("concrete-instances");
|
||||
@@ -244,31 +266,47 @@ describe("dictate context", () => {
|
||||
expect("error" in overwriteBare).toBe(true);
|
||||
|
||||
const openingFamilies = new Set(["设计.开场白", "设计.主角设定"]);
|
||||
const firstOpen = resolveRepeatableInsertTag({
|
||||
const bareOpen = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白",
|
||||
content: "@玩家推开门。",
|
||||
existingTags: [],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in firstOpen && firstOpen.tag).toBe("设计.开场白");
|
||||
const overwriteFirst = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白",
|
||||
content: "@玩家改写第一幕。",
|
||||
expect("error" in bareOpen).toBe(true);
|
||||
const namedFromBundle = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白与开场变量",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
id: "opening-cycle2",
|
||||
blocks: { body: "@玩家推开门。" },
|
||||
}),
|
||||
existingTags: ["设计.开场白"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in overwriteFirst && overwriteFirst.tag).toBe("设计.开场白");
|
||||
expect("tag" in namedFromBundle && namedFromBundle.tag).toBe(
|
||||
"设计.开场白#opening-cycle2",
|
||||
);
|
||||
const keepNamed = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白#opening-cycle2",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
blocks: { body: "@玩家改写这一幕。" },
|
||||
}),
|
||||
existingTags: ["设计.开场白", "设计.开场白与开场变量", "设计.开场白#opening-cycle2"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in keepNamed && keepNamed.tag).toBe("设计.开场白#opening-cycle2");
|
||||
const secondOpen = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白#gate",
|
||||
content: "@玩家在闸机前。",
|
||||
existingTags: ["设计.开场白"],
|
||||
existingTags: ["设计.开场白#opening-cycle2"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("tag" in secondOpen && secondOpen.tag).toBe("设计.开场白#gate");
|
||||
const bareOpenConflict = resolveRepeatableInsertTag({
|
||||
tag: "设计.开场白",
|
||||
content: "又写一条",
|
||||
existingTags: ["设计.开场白", "设计.开场白#gate"],
|
||||
existingTags: ["设计.开场白#opening-cycle2", "设计.开场白#gate"],
|
||||
repeatableFamilies: openingFamilies,
|
||||
});
|
||||
expect("error" in bareOpenConflict).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user