对话落盘同一轮填写失败后可以按次数补写。
游玩投影不再送进只给程序用的壳源码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -51,6 +51,18 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM
|
||||
|
||||
**不是** agent 在游玩时自由往 prompt 里插段落;agent 只决定 **invoke 哪个 skill**;该 skill 的契约决定看见什么。
|
||||
|
||||
### 2.1 落档不进游玩投影
|
||||
|
||||
对话落盘绑定时(`src/dictate/play-bind.ts`)下列 tag **不**写入 `设计.上下文投影排序`:
|
||||
|
||||
| 类别 | 例 | 原因 |
|
||||
|------|----|------|
|
||||
| 创作中转 `feeds: design_only` | `设计.本局创作方案`、`设计.美学纲领与交互范式` | 只指导后续写作 |
|
||||
| 呈现壳 `feeds: program` | `设计.正文组成`、`设计.0层正文组成` | 程序持有 html/css/js;模型只经 `contractOutputInstruction` 看见数据契约 |
|
||||
| 开场 / 编排 | `设计.开场白#*`、`设计.worker集`、排序表自身 | 开场进历史;编排不重复挂 |
|
||||
|
||||
游玩填楼时:程序读壳并灌 `present.v1`;主世界/转述 system 里只追加契约区块列表,不附 frontend 源码。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三分工
|
||||
|
||||
@@ -17,11 +17,18 @@ export type AppSettings = {
|
||||
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
|
||||
*/
|
||||
preferenceCollectEveryTurns?: number;
|
||||
/**
|
||||
* 对话落盘同一轮里,第一次填写失败后最多再补写几次。
|
||||
* 0 = 只填一次,失败留给下一句用户消息。默认 0。
|
||||
*/
|
||||
dictateFillRetries?: number;
|
||||
};
|
||||
|
||||
const FILE_NAME = "settings.json";
|
||||
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
|
||||
const DEFAULT_PREF_COLLECT_EVERY = 10;
|
||||
const DEFAULT_DICTATE_FILL_RETRIES = 0;
|
||||
const MAX_DICTATE_FILL_RETRIES = 12;
|
||||
|
||||
function settingsPath(): string {
|
||||
return path.join(getUserDataDir(), FILE_NAME);
|
||||
@@ -34,6 +41,7 @@ function defaultSettings(): AppSettings {
|
||||
activePresetId: null,
|
||||
contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP,
|
||||
preferenceCollectEveryTurns: DEFAULT_PREF_COLLECT_EVERY,
|
||||
dictateFillRetries: DEFAULT_DICTATE_FILL_RETRIES,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +57,13 @@ export function normalizePreferenceCollectEveryTurns(raw: unknown): number {
|
||||
return Math.min(100, Math.floor(n));
|
||||
}
|
||||
|
||||
/** 同轮补写次数。0 = 只填一次;非法值回落到 0。 */
|
||||
export function normalizeDictateFillRetries(raw: unknown): number {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return DEFAULT_DICTATE_FILL_RETRIES;
|
||||
return Math.min(MAX_DICTATE_FILL_RETRIES, Math.floor(n));
|
||||
}
|
||||
|
||||
export function loadAppSettings(): AppSettings {
|
||||
ensureUserDataDirs();
|
||||
try {
|
||||
@@ -65,6 +80,9 @@ export function loadAppSettings(): AppSettings {
|
||||
preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns(
|
||||
parsed.preferenceCollectEveryTurns ?? DEFAULT_PREF_COLLECT_EVERY,
|
||||
),
|
||||
dictateFillRetries: normalizeDictateFillRetries(
|
||||
parsed.dictateFillRetries ?? DEFAULT_DICTATE_FILL_RETRIES,
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return defaultSettings();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
catalogModulesForIntake,
|
||||
isDictateProgramModule,
|
||||
moduleOwnsArtifactTag,
|
||||
type ModuleCatalog,
|
||||
type ModuleCatalogEntry,
|
||||
} from "../skills/creation-flow.js";
|
||||
@@ -318,8 +319,8 @@ export function formatCreationPlanProgress(params: {
|
||||
for (const item of params.plan.items) {
|
||||
const module = modules.get(item.module_id);
|
||||
if (!module || !CREATION_PLAN_TIERS.includes(item.tier)) continue;
|
||||
const landedTags = productTags.filter(
|
||||
(tag) => tag === module.artifact || tag.startsWith(`${module.artifact}#`),
|
||||
const landedTags = productTags.filter((tag) =>
|
||||
moduleOwnsArtifactTag(module, tag),
|
||||
);
|
||||
const scores = landedTags
|
||||
.map((tag) => {
|
||||
@@ -382,12 +383,11 @@ export function requiredCreationPlanGaps(params: {
|
||||
return plan.items
|
||||
.filter((item) => item.tier === "必须")
|
||||
.filter((item) => {
|
||||
const artifact = modules.get(item.module_id)?.artifact;
|
||||
const module = modules.get(item.module_id);
|
||||
return (
|
||||
!artifact ||
|
||||
!params.products.some(
|
||||
(product) =>
|
||||
product.tag === artifact || product.tag.startsWith(`${artifact}#`),
|
||||
!module ||
|
||||
!params.products.some((product) =>
|
||||
moduleOwnsArtifactTag(module, product.tag),
|
||||
)
|
||||
);
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
|
||||
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领与交互范式";
|
||||
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领";
|
||||
|
||||
export const EXPERIENCE_ANCHOR_RECIPE_NAMES = ["快穿短局", "数据化跑团体验"] as const;
|
||||
|
||||
@@ -94,7 +94,7 @@ export function formatExperienceAnchorBlock(score: ExperienceAnchorScore): strin
|
||||
lines.push(
|
||||
score.anchored
|
||||
? "3/3:各能力何时用里的「已有美学」成立。开场白仍须等其它何时用已成立的能力落完。"
|
||||
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领与交互范式」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
|
||||
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function experienceAnchorToolPayload(score: ExperienceAnchorScore): {
|
||||
|
||||
export function openingBlockedByAnchorMessage(score: ExperienceAnchorScore): string {
|
||||
const missing = score.items.filter((item) => !item.ok).map((item) => item.id);
|
||||
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领与交互范式」的「体验锚定」,再写开场。`;
|
||||
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领」的「体验锚定」,再写开场。`;
|
||||
}
|
||||
|
||||
function scoreStance(raw: string | undefined): ExperienceAnchorItem {
|
||||
|
||||
@@ -93,6 +93,7 @@ export {
|
||||
bindDictateProductsToPlaySpec,
|
||||
buildDictateContextOrder,
|
||||
collectDictateBindProducts,
|
||||
isExcludedFromPlayBind,
|
||||
type DictatePlayBindResult,
|
||||
} from "./play-bind.js";
|
||||
export {
|
||||
|
||||
@@ -17,8 +17,12 @@ export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
|
||||
"设计.开场白": "opening-setup",
|
||||
"设计.开场白与开场变量": "opening-setup",
|
||||
"设计.美学纲领": "aesthetics-interaction",
|
||||
"设计.美学纲领与交互范式": "aesthetics-interaction",
|
||||
"设计.故事推进": "story-progression",
|
||||
"设计.故事推进与交互范式": "story-progression",
|
||||
"设计.文风": "narrative-guide",
|
||||
"设计.监控栏": "status-bar",
|
||||
"设计.实现机制": "mechanism",
|
||||
};
|
||||
|
||||
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */
|
||||
|
||||
@@ -68,10 +68,32 @@ const SKIP_BIND_TAGS = new Set([
|
||||
MAINTENANCE_STRATEGY_TAG,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 创作中转(模块 meta `feeds: design_only`)。
|
||||
* 只指导后续写作,不进游玩每轮投影。
|
||||
*/
|
||||
const DESIGN_ONLY_BIND_TAGS = new Set([
|
||||
"设计.本局创作方案",
|
||||
"设计.美学纲领与交互范式",
|
||||
"设计.美学纲领",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 呈现壳由程序持有:游玩读 frontend 灌 present.v1,
|
||||
* 模型只经 `contractOutputInstruction` 看见数据契约,不把 html/css/js 塞进上下文。
|
||||
*/
|
||||
const REPLY_SHELL_BIND_TAGS = new Set([
|
||||
"设计.正文组成",
|
||||
"设计.0层正文组成",
|
||||
"设计.回复格式",
|
||||
]);
|
||||
|
||||
function shouldSkipBindTag(tag: string): boolean {
|
||||
const t = tag.trim();
|
||||
if (!t) return true;
|
||||
if (SKIP_BIND_TAGS.has(t)) return true;
|
||||
if (DESIGN_ONLY_BIND_TAGS.has(t)) return true;
|
||||
if (REPLY_SHELL_BIND_TAGS.has(t)) return true;
|
||||
if (t.startsWith("设计.开场白#")) return true;
|
||||
if (t.startsWith("输出.")) return true;
|
||||
if (t.startsWith("运行.")) return true;
|
||||
@@ -79,9 +101,14 @@ function shouldSkipBindTag(tag: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */
|
||||
/** 是否不应挂进游玩投影(创作中转、呈现壳、开场等) */
|
||||
export function isExcludedFromPlayBind(tag: string): boolean {
|
||||
return shouldSkipBindTag(tag);
|
||||
}
|
||||
|
||||
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层(壳与 design_only 已在收集阶段剔除) */
|
||||
const STYLE_TAG_RE =
|
||||
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复呈现|回复格式|监控栏|篇幅|结构/;
|
||||
/叙事指南|故事推进|文风|禁忌|用户约束|用户需求|示例|模仿|回复呈现|监控栏|篇幅|结构/;
|
||||
|
||||
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
|
||||
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
|
||||
@@ -102,6 +129,20 @@ function isStyleProductTag(tag: string): boolean {
|
||||
return STYLE_TAG_RE.test(tag);
|
||||
}
|
||||
|
||||
/** 只挂旁观的产物。主世界层与转述不读。 */
|
||||
const AUDITOR_ONLY_TAG_RE = /画面与生图/;
|
||||
|
||||
function isAuditorOnlyProduct(product: DictateProduct): boolean {
|
||||
if (AUDITOR_ONLY_TAG_RE.test(product.tag)) return true;
|
||||
const mounts = parseContextFragment(product.content)?.mount ?? [];
|
||||
if (!mounts.length) return false;
|
||||
const set = new Set(mounts);
|
||||
const onAuditor = set.has("auditor");
|
||||
const onGm = set.has("world-simulator") || set.has("gm");
|
||||
const onNarrator = set.has("narrator");
|
||||
return onAuditor && !onGm && !onNarrator;
|
||||
}
|
||||
|
||||
function isFrequentlyChangingProduct(product: DictateProduct): boolean {
|
||||
const stability = parseContextFragment(product.content)?.稳变;
|
||||
return stability === "semi" || stability === "volatile";
|
||||
@@ -178,7 +219,9 @@ function buildGmInserts(params: {
|
||||
];
|
||||
let o = 1;
|
||||
const included = params.products.filter(
|
||||
(p) => params.includeStyle || !isStyleProductTag(p.tag),
|
||||
(p) =>
|
||||
(params.includeStyle || !isStyleProductTag(p.tag)) &&
|
||||
!isAuditorOnlyProduct(p),
|
||||
);
|
||||
const stable = included.filter((p) => !isFrequentlyChangingProduct(p));
|
||||
const changing = included.filter(isFrequentlyChangingProduct);
|
||||
@@ -243,7 +286,7 @@ function buildAuditorInserts(params: {
|
||||
];
|
||||
let o = 1;
|
||||
for (const p of params.products) {
|
||||
if (!/变量|生成规则/.test(p.tag)) continue;
|
||||
if (!/变量|生成规则/.test(p.tag) && !isAuditorOnlyProduct(p)) continue;
|
||||
inserts.push(insert(o++, p.tag, "full"));
|
||||
}
|
||||
for (const tag of params.runtimeTags) {
|
||||
@@ -263,6 +306,7 @@ export function buildDictateContextOrder(params: {
|
||||
runtimeTags?: string[];
|
||||
}): ContextOrderDoc {
|
||||
const runtimeTags = params.runtimeTags ?? [];
|
||||
const products = params.products.filter((p) => !shouldSkipBindTag(p.tag));
|
||||
const slots: ContextOrderSlot[] = [];
|
||||
|
||||
for (const slotId of enabledPlaySlotIds(params.playSlots)) {
|
||||
@@ -272,7 +316,7 @@ export function buildDictateContextOrder(params: {
|
||||
ref,
|
||||
label: "主世界层",
|
||||
inserts: buildGmInserts({
|
||||
products: params.products,
|
||||
products,
|
||||
runtimeTags,
|
||||
// 主世界推进剧情:固定产物全挂(含文风);有转述时转述另挂文风切片
|
||||
includeStyle: true,
|
||||
@@ -284,7 +328,7 @@ export function buildDictateContextOrder(params: {
|
||||
slots.push({
|
||||
ref,
|
||||
label: "叙事转述",
|
||||
inserts: buildNarratorInserts(params.products),
|
||||
inserts: buildNarratorInserts(products),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -293,7 +337,7 @@ export function buildDictateContextOrder(params: {
|
||||
ref,
|
||||
label: "旁观维护",
|
||||
inserts: buildAuditorInserts({
|
||||
products: params.products,
|
||||
products,
|
||||
runtimeTags,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -23,12 +23,13 @@ export type DictatePromptTailOpts = {
|
||||
|
||||
/** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
|
||||
export function buildDictateSelectPrompt(): string {
|
||||
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
|
||||
return `你只做选题。对照能力目录和用户这句话,只调用一个工具。
|
||||
|
||||
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
|
||||
若末尾 checklist 标了「失败待补」,这些节点必须再次纳入 nodes(可与新选的一起)。
|
||||
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
|
||||
这一步不写产物正文,不回复用户。`;
|
||||
这段话已经说清要落盘时,调用 select_nodes,nodes 里放目录中的 module_id,通常 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
|
||||
若末尾 checklist 标了「失败待补」,且用户这句仍在推进落盘,这些节点必须再次纳入 nodes(可与新选的一起)。
|
||||
用户只要讨论、核对,或明确先不要落盘时,调用 reply_user:直接回答,或写成 1~2 个问题,每个问题给 2~3 个可接着说的选项。这种一轮不落盘,也不把失败待补写回去。
|
||||
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或世界法则。
|
||||
select_nodes 不写产物正文。`;
|
||||
}
|
||||
|
||||
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
|
||||
@@ -116,8 +117,8 @@ ${legacyExample}`;
|
||||
|
||||
const phase =
|
||||
opts?.phase === "fill"
|
||||
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准),写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
|
||||
: "本步是第一次请求。只调用 select_nodes:这段话用于填充哪些节点。这一步不写产物正文,不回复用户。";
|
||||
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准)。失败待补若附了底稿,在底稿上只改失败点,已写对的段落、字段和已接受的 self_score 维度保持原样。写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
|
||||
: "本步只调用一个工具。要落盘就调用 select_nodes,只填 nodes。只要讨论或先不要落盘,就调用 reply_user 直接回答或提问。";
|
||||
|
||||
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
|
||||
return opts?.phase === "fill"
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
getPreference,
|
||||
listActivePreferences,
|
||||
} from "../preference/store.js";
|
||||
import {
|
||||
getImageWorkflow,
|
||||
listImageWorkflows,
|
||||
} from "../image-workflow/store.js";
|
||||
import {
|
||||
getStylePack,
|
||||
listActiveStylePacks,
|
||||
@@ -26,6 +30,13 @@ import {
|
||||
|
||||
export const DICTATE_MODULE_READ_STATE_TAG = "运行.对话落盘.已读能力";
|
||||
|
||||
/** 改名后旧目录名仍能 read_module,已有对话不用重学 id。 */
|
||||
const MODULE_READ_NAME_ALIASES: Record<string, readonly string[]> = {
|
||||
"aesthetics-interaction": ["美学纲领与交互范式"],
|
||||
"story-progression": ["故事推进"],
|
||||
mechanism: ["实现机制"],
|
||||
};
|
||||
|
||||
export type DictateModuleReadPayload = {
|
||||
module_id: string;
|
||||
name: string;
|
||||
@@ -99,6 +110,9 @@ export async function buildDictateModuleReadIndex(params: {
|
||||
};
|
||||
index.set(module.id, payload);
|
||||
index.set(module.name, payload);
|
||||
for (const alias of MODULE_READ_NAME_ALIASES[module.id] ?? []) {
|
||||
if (!index.has(alias)) index.set(alias, payload);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -171,6 +185,19 @@ export function readLibraryEntries(params: {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (libraryId === "image-workflows") {
|
||||
const entries = ids.map(getImageWorkflow);
|
||||
const missing = ids.filter((_, index) => !entries[index]);
|
||||
if (missing.length) return { error: `工作流库无条目:${missing.join("、")}` };
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
id: entry!.id,
|
||||
name: entry!.name,
|
||||
summary: entry!.summary,
|
||||
...(entry!.promptNotes ? { prompt_notes: entry!.promptNotes } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { error: `库 ${libraryId} 尚不支持按条目读取` };
|
||||
}
|
||||
|
||||
@@ -205,6 +232,17 @@ function libraryDirectories(module: ModuleCatalogEntry): DictateModuleReadPayloa
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (id === "image-workflows") {
|
||||
return {
|
||||
library_id: id,
|
||||
label: provider?.label ?? id,
|
||||
entries: listImageWorkflows().map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
summary: entry.summary,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { library_id: id, label: provider?.label ?? id, entries: [] };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 同轮不再 fill-retry:失败项写入「待补」状态,下轮 select/fill 继续补。
|
||||
* 同轮补写用尽后,失败项写入「待补」状态,下轮 select/fill 继续补。
|
||||
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
|
||||
* draft / keptDims 只给下一枪做增量:底稿上改失败点,已接受的自评维度带回去。
|
||||
*/
|
||||
|
||||
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
|
||||
@@ -8,11 +9,23 @@ export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const;
|
||||
|
||||
export type DictateRemediateKind = "insert" | "delete";
|
||||
|
||||
/** 已经合法、可在下一枪原样带回的自评维度。 */
|
||||
export type DictateKeptDim = {
|
||||
name: string;
|
||||
score: number;
|
||||
gap?: string;
|
||||
options?: string[];
|
||||
};
|
||||
|
||||
export type DictateRemediateItem = {
|
||||
tag: string;
|
||||
moduleId?: string;
|
||||
error: string;
|
||||
kind: DictateRemediateKind;
|
||||
/** 未通过时的正文底稿 */
|
||||
draft?: string;
|
||||
/** 这次没整份通过、但单项已经合法的自评维度 */
|
||||
keptDims?: DictateKeptDim[];
|
||||
};
|
||||
|
||||
export type DictateRemediateDoc = {
|
||||
@@ -34,11 +47,15 @@ export function parseDictateRemediate(raw: string | undefined | null): DictateRe
|
||||
const kind = rec.kind === "delete" ? "delete" : "insert";
|
||||
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
|
||||
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
|
||||
const draft = String(rec.draft ?? "").trim();
|
||||
const keptDims = parseKeptDims(rec.keptDims ?? rec.kept_dims);
|
||||
parsed.push({
|
||||
tag,
|
||||
error,
|
||||
kind,
|
||||
...(moduleId ? { moduleId } : {}),
|
||||
...(draft ? { draft } : {}),
|
||||
...(keptDims.length ? { keptDims } : {}),
|
||||
});
|
||||
}
|
||||
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
|
||||
@@ -70,6 +87,31 @@ export function formatRemediateChecklist(items: readonly DictateRemediateItem[])
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseKeptDims(raw: unknown): DictateKeptDim[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: DictateKeptDim[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const name = String(row.name ?? "").trim();
|
||||
const score = Number(row.score);
|
||||
if (!name || seen.has(name) || !Number.isInteger(score) || score < 1 || score > 5) continue;
|
||||
const gap = String(row.gap ?? "").trim();
|
||||
const options = Array.isArray(row.options)
|
||||
? row.options.map((option) => String(option).trim()).filter(Boolean)
|
||||
: [];
|
||||
seen.add(name);
|
||||
out.push({
|
||||
name,
|
||||
score,
|
||||
...(gap ? { gap } : {}),
|
||||
...(options.length ? { options } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DictateRemediateItem[] = [];
|
||||
|
||||
+282
-47
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
||||
* 再单次 fill 写入。已通过的不再覆盖。
|
||||
* 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。
|
||||
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,再 fill 写入。
|
||||
* 已通过的不再覆盖。未通过的可在同轮按设置补写若干次:底稿上只改失败点,已接受的自评维度带回。
|
||||
* 补写次数用尽后,失败项记入「失败待补」,下轮继续。
|
||||
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
||||
* 选题与回复二选一:调用 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 { LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||||
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||||
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||||
import { normalizeDictateFillRetries } from "../config/settings.js";
|
||||
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||||
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
||||
import {
|
||||
CREATION_PLAN_SCHEMA,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
formatRemediateChecklist,
|
||||
parseDictateRemediate,
|
||||
serializeDictateRemediate,
|
||||
type DictateKeptDim,
|
||||
type DictateRemediateItem,
|
||||
} from "./remediate.js";
|
||||
import { resolveModuleForInsertTag } from "./insert-feedback.js";
|
||||
@@ -81,6 +84,11 @@ export type PlannedRoundParams = {
|
||||
systemPrompt?: string;
|
||||
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
|
||||
historyTurns?: number;
|
||||
/**
|
||||
* 第一次填写失败后,同轮最多再补写几次。
|
||||
* 缺省 0:只填一次,失败留给下一句。
|
||||
*/
|
||||
fillRetries?: number;
|
||||
};
|
||||
|
||||
type PlanInsert = {
|
||||
@@ -92,11 +100,12 @@ type PlanInsert = {
|
||||
};
|
||||
|
||||
type SelectPlan = {
|
||||
/** 本轮不落盘时的提问 */
|
||||
clarify: string[];
|
||||
/** 本轮不落盘时的回答 */
|
||||
reply: string;
|
||||
inserts: PlanInsert[];
|
||||
deletes: string[];
|
||||
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
|
||||
keys: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -137,31 +146,31 @@ async function runPlannedDictateRound(
|
||||
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
||||
caller: "dictate_agent",
|
||||
label: "对话落盘 选题",
|
||||
tools: [DICTATE_SELECT_TOOL],
|
||||
toolChoice: { type: "function", function: { name: "select_nodes" } },
|
||||
tools: [DICTATE_SELECT_TOOL, DICTATE_SELECT_REPLY_TOOL],
|
||||
toolChoice: "required",
|
||||
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);
|
||||
mergeRemediationsIntoPlan(plan, remediations.items, 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"),
|
||||
};
|
||||
const replyCall = selected.toolCalls.find((call) => call.name === DICTATE_REPLY_TOOL.function.name);
|
||||
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes");
|
||||
const earlyReply = replyCall
|
||||
? replyText({
|
||||
id: replyCall.id || "reply",
|
||||
name: replyCall.name,
|
||||
arguments: replyCall.arguments,
|
||||
})
|
||||
: "";
|
||||
if (earlyReply && !selectCall) {
|
||||
return { modelCalls: 1, text: earlyReply };
|
||||
}
|
||||
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
||||
const noWork = plan.inserts.length === 0 && plan.deletes.length === 0;
|
||||
const speaking = noWork && (plan.clarify.length > 0 || plan.reply.length > 0);
|
||||
if (!speaking && !earlyReply) mergeRemediationsIntoPlan(plan, remediations.items, params);
|
||||
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||||
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
|
||||
return {
|
||||
modelCalls: 1,
|
||||
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
|
||||
};
|
||||
const text = earlyReply || (speaking ? formatSpeak(plan) : "") || selected.content?.trim() || "这轮先不落盘。";
|
||||
return { modelCalls: 1, text };
|
||||
}
|
||||
|
||||
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
|
||||
@@ -177,6 +186,13 @@ async function runPlannedDictateRound(
|
||||
[...rest, ...openings].some((row) => row.tag === item.tag) ||
|
||||
plan.deletes.includes(item.tag),
|
||||
);
|
||||
const drafts = new Map<string, string>();
|
||||
const keptDims = new Map<string, DictateKeptDim[]>();
|
||||
for (const item of priorFailures) {
|
||||
if (item.kind === "delete") continue;
|
||||
if (item.draft) drafts.set(item.tag, item.draft);
|
||||
if (item.keptDims?.length) keptDims.set(item.tag, item.keptDims);
|
||||
}
|
||||
const tail = formatDictateRoundTail({
|
||||
...tailOpts,
|
||||
phase: "fill",
|
||||
@@ -189,6 +205,7 @@ async function runPlannedDictateRound(
|
||||
moduleId: item.moduleId,
|
||||
kind: item.kind,
|
||||
})),
|
||||
drafts,
|
||||
),
|
||||
});
|
||||
const ctx = buildDictateContext({
|
||||
@@ -207,35 +224,64 @@ async function runPlannedDictateRound(
|
||||
let replyFromTool = "";
|
||||
let lastProse = "";
|
||||
let modelCalls = 1;
|
||||
const fillRetries = normalizeDictateFillRetries(params.fillRetries);
|
||||
let messages: ChatMessage[] = ctx.messages;
|
||||
let fillAttempts = 0;
|
||||
|
||||
// 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。
|
||||
if (pending.size > 0 || pendingDeletes.size > 0) {
|
||||
const filled = await params.llm.completeWithTools(ctx.messages, {
|
||||
while (pending.size > 0 || pendingDeletes.size > 0) {
|
||||
if (fillAttempts > fillRetries) break;
|
||||
const filled = await params.llm.completeWithTools(messages, {
|
||||
caller: "dictate_agent",
|
||||
label: "对话落盘 填写",
|
||||
label: fillAttempts === 0 ? "对话落盘 填写" : `对话落盘 补写 ${fillAttempts}`,
|
||||
tools: DICTATE_WRITE_LOOP_TOOLS,
|
||||
toolChoice: "required",
|
||||
usageDetail: {
|
||||
...detail(turnId, "fill", 0, ctx.segmentChars),
|
||||
...detail(
|
||||
turnId,
|
||||
fillAttempts === 0 ? "fill" : "fill-retry",
|
||||
fillAttempts,
|
||||
ctx.segmentChars,
|
||||
),
|
||||
plannedTags: [...pending, ...pendingDeletes],
|
||||
},
|
||||
usageSink: sinkFor(turnId, "fill"),
|
||||
usageSink: sinkFor(turnId, fillAttempts === 0 ? "fill" : "fill-retry"),
|
||||
});
|
||||
fillAttempts += 1;
|
||||
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-0-${index}`,
|
||||
id: call.id || `call-${fillAttempts}-${index}`,
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
}));
|
||||
if (calls.length > 0) {
|
||||
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
||||
const settled =
|
||||
calls.length > 0
|
||||
? await settleWriteStep(
|
||||
params,
|
||||
apply,
|
||||
calls,
|
||||
passed,
|
||||
pending,
|
||||
pendingDeletes,
|
||||
errors,
|
||||
drafts,
|
||||
keptDims,
|
||||
)
|
||||
: { results: [], reply: "" };
|
||||
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||||
replyFromTool = settled.reply;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fillAttempts > fillRetries) break;
|
||||
messages = appendFillContinuation(
|
||||
messages,
|
||||
filled.content,
|
||||
calls,
|
||||
settled.results,
|
||||
incrementalRetryNote(passed, pending, pendingDeletes, errors),
|
||||
);
|
||||
}
|
||||
|
||||
const moduleByTag = new Map(
|
||||
@@ -247,11 +293,13 @@ async function runPlannedDictateRound(
|
||||
errors,
|
||||
moduleByTag,
|
||||
prior: remediations.items,
|
||||
drafts,
|
||||
keptDims,
|
||||
});
|
||||
|
||||
let reply = replyFromTool;
|
||||
let reply = replyFromTool || earlyReply;
|
||||
if (
|
||||
!replyFromTool &&
|
||||
!reply &&
|
||||
pending.size === 0 &&
|
||||
pendingDeletes.size === 0 &&
|
||||
passed.size > 0
|
||||
@@ -266,10 +314,11 @@ async function runPlannedDictateRound(
|
||||
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
|
||||
);
|
||||
const pendingLines = [...blockedLines, ...failed];
|
||||
const retryNote = fillAttempts > 1 ? `已同轮补写 ${fillAttempts - 1} 次。` : "";
|
||||
const text = pendingLines.length
|
||||
? [
|
||||
reply,
|
||||
`失败待补(下轮继续):${pendingLines.join("、")}。`,
|
||||
`失败待补(下轮继续):${pendingLines.join("、")}。${retryNote}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
@@ -277,6 +326,15 @@ async function runPlannedDictateRound(
|
||||
return { text, modelCalls };
|
||||
}
|
||||
|
||||
function formatSpeak(plan: SelectPlan): string {
|
||||
const questions =
|
||||
plan.clarify.length <= 1
|
||||
? plan.clarify[0] ?? ""
|
||||
: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
||||
if (questions && plan.reply && questions !== plan.reply) return `${questions}\n\n${plan.reply}`;
|
||||
return questions || plan.reply;
|
||||
}
|
||||
|
||||
async function runLeanReply(
|
||||
params: PlannedRoundParams,
|
||||
turnId: string,
|
||||
@@ -359,6 +417,8 @@ async function settleWriteStep(
|
||||
pending: Set<string>,
|
||||
pendingDeletes: Set<string>,
|
||||
errors: Map<string, string>,
|
||||
drafts: Map<string, string>,
|
||||
keptDims: Map<string, DictateKeptDim[]>,
|
||||
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
|
||||
const results: Array<{ callId: string; content: string }> = [];
|
||||
const forward: DriverToolCall[] = [];
|
||||
@@ -382,7 +442,12 @@ async function settleWriteStep(
|
||||
}
|
||||
if (call.name === "insert" && tag) {
|
||||
const args = asRecord(safeJson(call.arguments));
|
||||
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
|
||||
const merged = mergeKeptDimsIntoArgs(
|
||||
args,
|
||||
keptDims.get(tag),
|
||||
scoreDimensionsForTag(params, tag),
|
||||
);
|
||||
forward.push({ ...call, arguments: JSON.stringify({ ...merged, position: tag }) });
|
||||
continue;
|
||||
}
|
||||
forward.push(call);
|
||||
@@ -409,7 +474,11 @@ async function settleWriteStep(
|
||||
const planned = [...pending, ...pendingDeletes].find(
|
||||
(item) => item === tag || tag.startsWith(`${item}#`),
|
||||
);
|
||||
errors.set(planned ?? tag, payload.error);
|
||||
const plannedTag = planned ?? tag;
|
||||
errors.set(plannedTag, payload.error);
|
||||
if (call?.name === "insert") {
|
||||
rememberFailedInsert(call, plannedTag, params, drafts, keptDims);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,6 +513,137 @@ function replyText(call: DriverToolCall): string {
|
||||
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
|
||||
}
|
||||
|
||||
const MAX_FAILED_DRAFT_CHARS = 24_000;
|
||||
|
||||
function rememberFailedInsert(
|
||||
call: DriverToolCall,
|
||||
tag: string,
|
||||
params: PlannedRoundParams,
|
||||
drafts: Map<string, string>,
|
||||
keptDims: Map<string, DictateKeptDim[]>,
|
||||
): void {
|
||||
const args = asRecord(safeJson(call.arguments));
|
||||
const content = String(args.content ?? "").trim();
|
||||
if (content) {
|
||||
drafts.set(
|
||||
tag,
|
||||
content.length > MAX_FAILED_DRAFT_CHARS
|
||||
? `${content.slice(0, MAX_FAILED_DRAFT_CHARS)}\n…(底稿已截断)`
|
||||
: content,
|
||||
);
|
||||
}
|
||||
const expected = scoreDimensionsForTag(params, tag);
|
||||
const harvested = harvestKeptDims(args.self_score, expected);
|
||||
if (!harvested.length) return;
|
||||
const merged = new Map((keptDims.get(tag) ?? []).map((dim) => [dim.name, dim] as const));
|
||||
for (const dim of harvested) merged.set(dim.name, dim);
|
||||
keptDims.set(tag, [...merged.values()]);
|
||||
}
|
||||
|
||||
function scoreDimensionsForTag(params: PlannedRoundParams, tag: string): string[] {
|
||||
const module = resolveModuleForInsertTag(tag, params.moduleCatalog);
|
||||
if (!module) return [];
|
||||
return params.moduleReadIndex?.get(module.id)?.score_dimensions ?? [];
|
||||
}
|
||||
|
||||
function mergeKeptDimsIntoArgs(
|
||||
args: Record<string, unknown>,
|
||||
kept: readonly DictateKeptDim[] | undefined,
|
||||
expected: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!kept?.length || !expected.length) return args;
|
||||
const score = asRecord(args.self_score);
|
||||
const dims = Array.isArray(score.dims) ? [...score.dims] : [];
|
||||
const names = new Set(
|
||||
dims.map((item) => String(asRecord(item).name ?? "").trim()).filter(Boolean),
|
||||
);
|
||||
const additions = kept.filter((dim) => expected.includes(dim.name) && !names.has(dim.name));
|
||||
if (!additions.length) return args;
|
||||
return { ...args, self_score: { ...score, dims: [...dims, ...additions] } };
|
||||
}
|
||||
|
||||
function harvestKeptDims(raw: unknown, expected: readonly string[]): DictateKeptDim[] {
|
||||
if (!expected.length) return [];
|
||||
const dims = Array.isArray(asRecord(raw).dims) ? asRecord(raw).dims : [];
|
||||
const out: DictateKeptDim[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of dims) {
|
||||
const row = asRecord(item);
|
||||
const name = String(row.name ?? "").trim();
|
||||
const score = Number(row.score);
|
||||
if (!name || !expected.includes(name) || seen.has(name)) continue;
|
||||
if (!Number.isInteger(score) || score < 1 || score > 5) continue;
|
||||
const gap = String(row.gap ?? "").trim();
|
||||
if (score <= 3 && !gap) continue;
|
||||
const options = Array.isArray(row.options)
|
||||
? row.options.map((option) => String(option).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (options.length > 0 && (options.length < 2 || options.length > 3)) continue;
|
||||
seen.add(name);
|
||||
out.push({
|
||||
name,
|
||||
score,
|
||||
...(gap ? { gap } : {}),
|
||||
...(options.length ? { options } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function appendFillContinuation(
|
||||
messages: ChatMessage[],
|
||||
assistantContent: string | null,
|
||||
calls: DriverToolCall[],
|
||||
results: Array<{ callId: string; content: string }>,
|
||||
note: string,
|
||||
): ChatMessage[] {
|
||||
const next = [...messages];
|
||||
if (calls.length === 0) {
|
||||
next.push({ role: "user", content: note });
|
||||
return next;
|
||||
}
|
||||
next.push({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
tool_calls: calls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function" as const,
|
||||
function: { name: call.name, arguments: call.arguments },
|
||||
})),
|
||||
});
|
||||
const byId = new Map(results.map((item) => [item.callId, item.content]));
|
||||
for (const call of calls) {
|
||||
next.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: byId.get(call.id) ?? JSON.stringify({ error: "无结果" }),
|
||||
});
|
||||
}
|
||||
next.push({ role: "user", content: note });
|
||||
return next;
|
||||
}
|
||||
|
||||
function incrementalRetryNote(
|
||||
passed: ReadonlySet<string>,
|
||||
pending: ReadonlySet<string>,
|
||||
pendingDeletes: ReadonlySet<string>,
|
||||
errors: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const kept = [...passed].filter((tag) => !tag.startsWith("del:"));
|
||||
const deleted = [...passed].filter((tag) => tag.startsWith("del:")).map((tag) => tag.slice(4));
|
||||
return [
|
||||
"同轮补写:只改未通过项。",
|
||||
kept.length ? `已通过,不要再 insert:${kept.join("、")}` : "",
|
||||
deleted.length ? `已删除,不要再 delete:${deleted.join("、")}` : "",
|
||||
"未通过:",
|
||||
...[...pending].map((tag) => `- 写入 ${tag}:${errors.get(tag) ?? "尚未通过"}`),
|
||||
...[...pendingDeletes].map((tag) => `- 删除 ${tag}:${errors.get(tag) ?? "尚未通过"}`),
|
||||
"未通过的正文以上一次 insert 为底稿,只改失败点。已写对的段落、字段,以及已接受的 self_score 维度,保持原样。不要整份重写。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function sinkFor(_turnId: string, _step: string): UsageSink {
|
||||
return {};
|
||||
}
|
||||
@@ -483,7 +683,13 @@ function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
|
||||
|
||||
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
||||
const row = asRecord(raw);
|
||||
const clarify = stringList(row.clarify);
|
||||
const clarify = [
|
||||
...askList(row.ask),
|
||||
...askList(row.askUser),
|
||||
...askList(row.ask_user),
|
||||
...stringList(row.clarify),
|
||||
].filter((item, index, all) => all.indexOf(item) === index);
|
||||
const reply = String(row.reply ?? "").trim();
|
||||
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
|
||||
const inserts: PlanInsert[] = [];
|
||||
let items = Array.isArray(row.nodes)
|
||||
@@ -491,7 +697,7 @@ function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
||||
: Array.isArray(row.inserts)
|
||||
? row.inserts
|
||||
: [];
|
||||
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
|
||||
if (items.length === 0 && deletes.length === 0) {
|
||||
const recovered = recoverSelectWork(row);
|
||||
items = recovered.items;
|
||||
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
|
||||
@@ -549,7 +755,7 @@ function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
||||
reason: reason || undefined,
|
||||
});
|
||||
}
|
||||
return { clarify, inserts, deletes, keys: Object.keys(row) };
|
||||
return { clarify, reply, inserts, deletes };
|
||||
}
|
||||
|
||||
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
|
||||
@@ -699,7 +905,12 @@ function canonicalProductTag(
|
||||
}
|
||||
|
||||
|
||||
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
||||
function formatChecklist(
|
||||
group: PlanInsert[],
|
||||
deletes: string[],
|
||||
errors: ItemFailure[],
|
||||
drafts?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
||||
const failedTags = new Set(errors.map((item) => item.tag));
|
||||
const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag);
|
||||
@@ -709,9 +920,15 @@ function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFai
|
||||
pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
|
||||
pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
|
||||
errors.length
|
||||
? `失败待补:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
|
||||
? `失败待补:\n${errors
|
||||
.map((item) => {
|
||||
const draft = item.kind === "delete" ? "" : drafts?.get(item.tag)?.trim() ?? "";
|
||||
if (!draft) return `- ${item.tag}:${item.error}`;
|
||||
return `- ${item.tag}:${item.error}\n 底稿(只改失败点,已写对的段落和字段保持原样):\n${draft}`;
|
||||
})
|
||||
.join("\n")}`
|
||||
: "",
|
||||
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准,不要无故整份重写。",
|
||||
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准。有底稿时只改失败点,不要整份重写。",
|
||||
];
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
@@ -753,6 +970,8 @@ function persistRemediations(
|
||||
errors: Map<string, string>;
|
||||
moduleByTag: Map<string, string>;
|
||||
prior: readonly DictateRemediateItem[];
|
||||
drafts: ReadonlyMap<string, string>;
|
||||
keptDims: ReadonlyMap<string, DictateKeptDim[]>;
|
||||
},
|
||||
): void {
|
||||
const priorByKey = new Map(
|
||||
@@ -761,6 +980,8 @@ function persistRemediations(
|
||||
const next: DictateRemediateItem[] = [];
|
||||
for (const tag of state.pending) {
|
||||
const prev = priorByKey.get(`insert:${tag}`);
|
||||
const draft = state.drafts.get(tag) ?? prev?.draft;
|
||||
const kept = state.keptDims.get(tag) ?? prev?.keptDims;
|
||||
next.push({
|
||||
tag,
|
||||
kind: "insert",
|
||||
@@ -768,6 +989,8 @@ function persistRemediations(
|
||||
...(state.moduleByTag.get(tag) || prev?.moduleId
|
||||
? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId }
|
||||
: {}),
|
||||
...(draft ? { draft } : {}),
|
||||
...(kept?.length ? { keptDims: kept } : {}),
|
||||
});
|
||||
}
|
||||
for (const tag of state.pendingDeletes) {
|
||||
@@ -823,6 +1046,18 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function askList(value: unknown): string[] {
|
||||
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item.trim();
|
||||
const rec = asRecord(item);
|
||||
return String(rec.prompt ?? rec.text ?? rec.question ?? "").trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
|
||||
|
||||
+23
-2
@@ -3,13 +3,13 @@ import type { ToolDefinition } from "../llm/client.js";
|
||||
/**
|
||||
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
|
||||
*/
|
||||
/** 第一次请求仅此一个工具:点出下一步要写的节点。 */
|
||||
/** 第一次请求与 reply_user 二选一:点出下一步要写的节点。 */
|
||||
export const DICTATE_SELECT_TOOL: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "select_nodes",
|
||||
description:
|
||||
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。",
|
||||
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。本轮只回答或只提问时不要调用本工具,改调 reply_user。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -32,6 +32,27 @@ export const DICTATE_SELECT_TOOL: ToolDefinition = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 选题步的回复。与 select_nodes 二选一;调用后不再填写、不再另起回复。 */
|
||||
export const DICTATE_SELECT_REPLY_TOOL: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "reply_user",
|
||||
description:
|
||||
"本轮不写入、不删除。直接回答,或提出 1~2 个问题,每个问题给 2~3 个可接着说的选项。与 select_nodes 二选一。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
text: {
|
||||
type: "string",
|
||||
description: "给用户的回复。提问时写成 1~2 个问题,每个问题给 2~3 个可接着说的选项。",
|
||||
},
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 写入循环的收尾。未通过的节点还在时,程序不把这份正文给用户。 */
|
||||
export const DICTATE_REPLY_TOOL: ToolDefinition = {
|
||||
type: "function",
|
||||
|
||||
+14
-1
@@ -173,6 +173,8 @@ export async function runDictateTurn(params: {
|
||||
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
||||
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
|
||||
historyTurns?: number;
|
||||
/** 第一次填写失败后同轮最多再补写几次;缺省 0 */
|
||||
fillRetries?: number;
|
||||
driver?: AgentDriver;
|
||||
}): Promise<DictateTurnResult> {
|
||||
const driver =
|
||||
@@ -198,6 +200,7 @@ export async function runDictateTurn(params: {
|
||||
recipeBrief: params.recipeBrief,
|
||||
systemPrompt: params.systemPrompt,
|
||||
historyTurns: params.historyTurns,
|
||||
fillRetries: params.fillRetries,
|
||||
});
|
||||
const wroteTags: string[] = [];
|
||||
const deletedTags: string[] = [];
|
||||
@@ -1074,8 +1077,18 @@ function parseAndValidateSelfScore(
|
||||
expectedNames.some((name) => !actual.includes(name)) ||
|
||||
actual.some((name) => !expectedNames.includes(name))
|
||||
) {
|
||||
const kept = actual.filter((name) => expectedNames.includes(name));
|
||||
const missing = expectedNames.filter((name) => !actual.includes(name));
|
||||
const extra = actual.filter((name) => !expectedNames.includes(name));
|
||||
return {
|
||||
error: `self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
|
||||
error: [
|
||||
`self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
|
||||
kept.length ? `已接受、请原样保留:${kept.join("、")}` : "",
|
||||
missing.length ? `缺少:${missing.join("、")}` : "",
|
||||
extra.length ? `多余:${extra.join("、")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";"),
|
||||
};
|
||||
}
|
||||
return { value: { dims: parsed } };
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import {
|
||||
loadAppSettings,
|
||||
normalizeContextTraceKeepLatest,
|
||||
normalizeDictateFillRetries,
|
||||
normalizePreferenceCollectEveryTurns,
|
||||
} from "../config/settings.js";
|
||||
import { isAbortError, runWithAbortSignal } from "../llm/run-abort.js";
|
||||
@@ -139,6 +140,7 @@ import {
|
||||
formatRecipeExamplesForCreationPlan,
|
||||
formatModuleCatalogForDictate,
|
||||
findModuleByName,
|
||||
readModuleArtifactContent,
|
||||
findStepByRef,
|
||||
type FlowStepTitleHint,
|
||||
isProgressPointerTag,
|
||||
@@ -597,10 +599,13 @@ export class SessionManager {
|
||||
const s = this.require(sessionId);
|
||||
const trimmedTag = tag.trim();
|
||||
if (!trimmedTag) throw new Error("tag 不能为空");
|
||||
s.runtime.getBlackboard().write({
|
||||
const board = s.runtime.getBlackboard();
|
||||
const existing = board.getLatestByTag(trimmedTag);
|
||||
board.write({
|
||||
tag: trimmedTag,
|
||||
content: content ?? "",
|
||||
source: "user",
|
||||
...(existing?.metadata ? { metadata: existing.metadata } : {}),
|
||||
});
|
||||
if (s.bookId) this.persist(s);
|
||||
return this.toView(sessionId);
|
||||
@@ -1145,6 +1150,7 @@ export class SessionManager {
|
||||
moduleCatalog,
|
||||
moduleReadIndex,
|
||||
repeatableFamilies,
|
||||
fillRetries: normalizeDictateFillRetries(loadAppSettings().dictateFillRetries),
|
||||
handlers: {
|
||||
listProducts: () => this.listDictateProducts(s),
|
||||
writeProduct: (tag, content, order, metadata) => {
|
||||
@@ -3123,11 +3129,15 @@ export class SessionManager {
|
||||
const step = findStepByRef(cleaned, stepId.trim());
|
||||
if (!step) throw new Error(`找不到节点:${stepId}`);
|
||||
const mod = findModuleByName(catalog, step.name);
|
||||
const tag = mod?.artifact?.trim() || null;
|
||||
const content = tag
|
||||
? s.runtime.getBlackboard().getContentByTag(tag)?.trim() || ""
|
||||
: "";
|
||||
return { stepId: step.id, name: step.name, tag, content };
|
||||
const stored = readModuleArtifactContent(mod, (tag) =>
|
||||
s.runtime.getBlackboard().getContentByTag(tag),
|
||||
);
|
||||
return {
|
||||
stepId: step.id,
|
||||
name: step.name,
|
||||
tag: stored?.tag ?? null,
|
||||
content: stored?.content ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async spawnStep(id: string, moduleName: string): Promise<SessionView> {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ensureActiveProfileDefault,
|
||||
loadAppSettings,
|
||||
normalizeContextTraceKeepLatest,
|
||||
normalizeDictateFillRetries,
|
||||
normalizePreferenceCollectEveryTurns,
|
||||
saveAppSettings,
|
||||
setActivePresetId,
|
||||
@@ -138,6 +139,7 @@ export async function handleSettingsApi(
|
||||
activePresetId?: string | null;
|
||||
contextTraceKeepLatest?: number;
|
||||
preferenceCollectEveryTurns?: number;
|
||||
dictateFillRetries?: number;
|
||||
};
|
||||
const settings = loadAppSettings();
|
||||
if (body.activeProfileId !== undefined) {
|
||||
@@ -156,6 +158,9 @@ export async function handleSettingsApi(
|
||||
body.preferenceCollectEveryTurns,
|
||||
);
|
||||
}
|
||||
if (body.dictateFillRetries !== undefined) {
|
||||
settings.dictateFillRetries = normalizeDictateFillRetries(body.dictateFillRetries);
|
||||
}
|
||||
saveAppSettings(settings);
|
||||
json(res, 200, { settings });
|
||||
return true;
|
||||
|
||||
@@ -46,7 +46,7 @@ async function fixture() {
|
||||
describe("creation plan", () => {
|
||||
it("covers all current content modules and excludes approved mechanical or legacy nodes", async () => {
|
||||
const { modules } = await fixture();
|
||||
expect(modules).toHaveLength(14);
|
||||
expect(modules).toHaveLength(15);
|
||||
expect(modules.map((module) => module.id)).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
"creation-plan",
|
||||
@@ -99,7 +99,7 @@ describe("creation plan", () => {
|
||||
expect(catalog).toBeTruthy();
|
||||
const zeroModules = creationPlanModules(catalog!, "zero_layer");
|
||||
const ids = zeroModules.map((module) => module.id);
|
||||
expect(zeroModules).toHaveLength(14);
|
||||
expect(zeroModules).toHaveLength(15);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"zero-layer-status",
|
||||
|
||||
@@ -14,22 +14,58 @@ import { defaultPlaySlots } from "../src/skills/play-slots.js";
|
||||
|
||||
describe("dictate play-bind", () => {
|
||||
it("classifies style tags", () => {
|
||||
expect(isDictateStyleBindTag("设计.美学纲领")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.正文组成")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.模仿范例")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.模仿要点")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.叙事指南")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.故事推进")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.用户需求")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.用户约束")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.监控栏")).toBe(true);
|
||||
expect(isDictateStyleBindTag("用户.需求")).toBe(false);
|
||||
// 中转与壳不进游玩文风挂载
|
||||
expect(isDictateStyleBindTag("设计.美学纲领")).toBe(false);
|
||||
expect(isDictateStyleBindTag("设计.正文组成")).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes design_only and reply shell from play bind products", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: "设计.实现机制",
|
||||
content: "因果",
|
||||
source: "dictate",
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: 10 },
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.本局创作方案",
|
||||
content: '{"schema":"creation-plan.v1","items":[]}',
|
||||
source: "dictate",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.美学纲领与交互范式",
|
||||
content: "体验",
|
||||
source: "dictate",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.正文组成",
|
||||
content: JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
frontend: { html: "<div>", css: "x", js: "y" },
|
||||
数据契约: { blocks: { body: { 类型: "markdown", 来源: "model_generated" } } },
|
||||
},
|
||||
}),
|
||||
source: "dictate",
|
||||
});
|
||||
const tags = collectDictateBindProducts(board).map((p) => p.tag);
|
||||
expect(tags).toEqual(["设计.实现机制"]);
|
||||
});
|
||||
|
||||
it("builds gm context_order: persona → products → history → input", () => {
|
||||
const order = buildDictateContextOrder({
|
||||
products: [
|
||||
{ tag: "用户.需求", content: "雨夜", order: -40 },
|
||||
{ tag: "设计.美学纲领", content: "冷色", order: -30 },
|
||||
{ tag: "设计.叙事指南", content: "冷色短句", order: -30 },
|
||||
{ tag: "设计.本局创作方案", content: "{}", order: -50 },
|
||||
{ tag: "设计.正文组成", content: "{}", order: 0 },
|
||||
],
|
||||
playSlots: defaultPlaySlots(),
|
||||
@@ -39,13 +75,44 @@ describe("dictate play-bind", () => {
|
||||
const refs = gm!.inserts.map((i) => i.ref);
|
||||
expect(refs[0]).toBe(WORKER_PERSONA_REF);
|
||||
expect(refs).toContain("用户.需求");
|
||||
expect(refs).toContain("设计.美学纲领");
|
||||
expect(refs).toContain("设计.正文组成");
|
||||
expect(refs).toContain("设计.叙事指南");
|
||||
expect(refs).not.toContain("设计.本局创作方案");
|
||||
expect(refs).not.toContain("设计.正文组成");
|
||||
expect(refs).toContain(DIALOGUE_HISTORY_TAG);
|
||||
expect(refs[refs.length - 1]).toBe("用户.最新输入");
|
||||
const histIdx = refs.indexOf(DIALOGUE_HISTORY_TAG);
|
||||
expect(refs.indexOf("用户.需求")).toBeLessThan(histIdx);
|
||||
expect(refs.indexOf("设计.正文组成")).toBeLessThan(histIdx);
|
||||
expect(refs.indexOf("设计.叙事指南")).toBeLessThan(histIdx);
|
||||
});
|
||||
|
||||
it("keeps image prompt on the auditor and off the main world", () => {
|
||||
const image = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "画面与生图",
|
||||
brief: "西幻",
|
||||
mount: ["auditor"],
|
||||
稳变: "stable",
|
||||
正文: { 工作流: "western-fantasy" },
|
||||
});
|
||||
const order = buildDictateContextOrder({
|
||||
products: [
|
||||
{ tag: "设计.舞台骨架", content: "舞台", order: 1 },
|
||||
{ tag: "设计.画面与生图", content: image, order: 2 },
|
||||
{ tag: "设计.变量设计与更新规则", content: "真值", order: 3 },
|
||||
],
|
||||
playSlots: { ...defaultPlaySlots(), auditor: true },
|
||||
});
|
||||
const gm = order.slots
|
||||
.find((s) => s.ref === "world-simulator")!
|
||||
.inserts.map((i) => i.ref);
|
||||
const auditor = order.slots
|
||||
.find((s) => s.ref === "auditor")!
|
||||
.inserts.map((i) => i.ref);
|
||||
expect(gm).toContain("设计.舞台骨架");
|
||||
expect(gm).not.toContain("设计.画面与生图");
|
||||
expect(auditor).toContain("设计.画面与生图");
|
||||
expect(auditor).toContain("设计.变量设计与更新规则");
|
||||
expect(auditor).not.toContain("设计.舞台骨架");
|
||||
});
|
||||
|
||||
it("groups context by change frequency before applying importance order", () => {
|
||||
@@ -84,7 +151,7 @@ describe("dictate play-bind", () => {
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: -40 },
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.美学纲领",
|
||||
tag: "设计.叙事指南",
|
||||
content: "冷色短句",
|
||||
source: "dictate",
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: -30 },
|
||||
@@ -103,14 +170,14 @@ describe("dictate play-bind", () => {
|
||||
});
|
||||
|
||||
const products = collectDictateBindProducts(board);
|
||||
expect(products.map((p) => p.tag)).toEqual(["用户.需求", "设计.美学纲领"]);
|
||||
expect(products.map((p) => p.tag)).toEqual(["用户.需求", "设计.叙事指南"]);
|
||||
expect(products.every((p) => p.tag !== "设计.开场白")).toBe(true);
|
||||
|
||||
const result = bindDictateProductsToPlaySpec(board);
|
||||
const narr = result.contextOrder.slots.find((s) => s.ref === "narrator");
|
||||
expect(narr).toBeTruthy();
|
||||
const narrRefs = narr!.inserts.map((i) => i.ref);
|
||||
expect(narrRefs).toContain("设计.美学纲领");
|
||||
expect(narrRefs).toContain("设计.叙事指南");
|
||||
expect(narrRefs).not.toContain("用户.需求");
|
||||
expect(narrRefs).toContain("输出.用户展示");
|
||||
expect(narrRefs).not.toContain("运行.本轮.裁决");
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("dictate read_module", () => {
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "美学纲领", score: 5 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "体验锚定", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -84,7 +84,7 @@ describe("dictate read_module", () => {
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "美学纲领", score: 5 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "体验锚定", score: 5 },
|
||||
{ name: "背景与规则", score: 3, gap: "规则因果仍需确认" },
|
||||
],
|
||||
},
|
||||
@@ -208,7 +208,7 @@ describe("dictate read_module", () => {
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "美学纲领", score: 3 },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "体验锚定", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
@@ -230,7 +230,7 @@ describe("dictate read_module", () => {
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "美学纲领", score: 3, gap: "发生方式仍偏宽" },
|
||||
{ name: "交互范式", score: 5 },
|
||||
{ name: "体验锚定", score: 5 },
|
||||
{ name: "背景与规则", score: 5 },
|
||||
],
|
||||
},
|
||||
|
||||
+309
-5
@@ -253,6 +253,7 @@ describe("dictate planned round", () => {
|
||||
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||
expect(products).toEqual([{ tag: "设计.氛围", content: "细雨" }]);
|
||||
expect(result.reply).toContain("已写成细雨");
|
||||
expect(result.iterations).toBe(2);
|
||||
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
|
||||
expect(state.modules.mood).toBe("v-test");
|
||||
expect(tags.get(DICTATE_REMEDIATE_TAG) ?? "").toBe("");
|
||||
@@ -287,10 +288,75 @@ describe("dictate planned round", () => {
|
||||
expect(result.reply).toContain("改成暖色");
|
||||
});
|
||||
|
||||
it("says the returned fields when the select plan writes nothing", async () => {
|
||||
it("returns an answer without writing when the first call is reply_user", async () => {
|
||||
let writes = 0;
|
||||
let calls = 0;
|
||||
const result = await runDictateTurn({
|
||||
llm: scriptedLlm([{ note: "看世界" }]),
|
||||
dialogue: [{ role: "user", text: "超级英雄" }],
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools() {
|
||||
calls += 1;
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "reply",
|
||||
name: "reply_user",
|
||||
arguments: JSON.stringify({ text: "下风位最适合先改顾晚棠。" }),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "先不要落盘,告诉我该改谁" }],
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => {
|
||||
writes += 1;
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(writes).toBe(0);
|
||||
expect(calls).toBe(1);
|
||||
expect(result.wroteTags).toEqual([]);
|
||||
expect(result.iterations).toBe(1);
|
||||
expect(result.reply).toContain("顾晚棠");
|
||||
expect(result.reply).not.toContain("没有写入产物");
|
||||
});
|
||||
|
||||
it("returns a question without writing when the first call is reply_user", async () => {
|
||||
let calls = 0;
|
||||
const result = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools() {
|
||||
calls += 1;
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "reply",
|
||||
name: "reply_user",
|
||||
arguments: JSON.stringify({
|
||||
text: "先改顾晚棠,还是先改表世界的同辈?",
|
||||
}),
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "先讨论,不要落盘" }],
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => undefined,
|
||||
@@ -301,10 +367,10 @@ describe("dictate planned round", () => {
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(result.wroteTags).toEqual([]);
|
||||
expect(result.iterations).toBe(1);
|
||||
expect(result.reply).toContain("note");
|
||||
expect(result.reply).toContain("没有写入产物");
|
||||
expect(result.reply).toContain("顾晚棠");
|
||||
});
|
||||
|
||||
it("follows a select result with a fill request that carries specs and products", async () => {
|
||||
@@ -973,4 +1039,242 @@ describe("dictate planned round", () => {
|
||||
expect(replyReq).not.toContain("#### task");
|
||||
expect(choices[2]).toEqual({ type: "function", function: { name: "reply_user" } });
|
||||
});
|
||||
|
||||
it("retries a failed fill in the same turn and patches from the previous draft", async () => {
|
||||
const products: Array<{ tag: string; content: string }> = [];
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const rounds = [
|
||||
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
|
||||
[
|
||||
{
|
||||
id: "a",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.氛围",
|
||||
content: "细雨连绵,石板发亮",
|
||||
self_score: { dims: [{ name: "短句", score: 4 }] },
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "b",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.氛围",
|
||||
content: "细雨连绵,石板发亮",
|
||||
self_score: {
|
||||
dims: [
|
||||
{ name: "短句", score: 4 },
|
||||
{ name: "画面", score: 5 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ id: "r", name: "reply_user", arguments: JSON.stringify({ text: "氛围补上了" }) },
|
||||
],
|
||||
];
|
||||
let index = 0;
|
||||
const result = await runDictateTurn({
|
||||
fillRetries: 1,
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools(messages) {
|
||||
seen.push(JSON.stringify(messages));
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: rounds[index++] ?? [],
|
||||
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) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: (tag) => tags.delete(tag),
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(seen).toHaveLength(3);
|
||||
expect(seen[2]).toContain("同轮补写");
|
||||
expect(seen[2]).toContain("细雨连绵,石板发亮");
|
||||
expect(seen[2]).toContain("只改失败点");
|
||||
expect(seen[2]).toContain("已接受、请原样保留:短句");
|
||||
expect(products).toEqual([{ tag: "设计.氛围", content: "细雨连绵,石板发亮" }]);
|
||||
expect(result.reply).toContain("氛围补上了");
|
||||
expect(tags.has(DICTATE_REMEDIATE_TAG)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps accepted self_score dims when a later attempt drops them", async () => {
|
||||
const scores: unknown[] = [];
|
||||
const rounds = [
|
||||
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
|
||||
[
|
||||
{
|
||||
id: "a",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.氛围",
|
||||
content: "细雨",
|
||||
self_score: { dims: [{ name: "短句", score: 4 }] },
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "b",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.氛围",
|
||||
content: "细雨",
|
||||
self_score: { dims: [{ name: "画面", score: 5 }] },
|
||||
}),
|
||||
},
|
||||
{ id: "r", name: "reply_user", arguments: JSON.stringify({ text: "补上了" }) },
|
||||
],
|
||||
];
|
||||
let index = 0;
|
||||
const tags = new Map<string, string>();
|
||||
await runDictateTurn({
|
||||
fillRetries: 1,
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools() {
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: rounds[index++] ?? [],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([
|
||||
["mood", { ...modulePayload, score_dimensions: ["短句", "画面"] }],
|
||||
]),
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: (_tag, _content, _order, metadata) => {
|
||||
scores.push(metadata?.selfScore);
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(scores).toHaveLength(1);
|
||||
const dims = (scores[0] as { dims: Array<{ name: string }> }).dims.map((dim) => dim.name).sort();
|
||||
expect(dims).toEqual(["画面", "短句"]);
|
||||
});
|
||||
|
||||
it("stores the failed draft and feeds it to the next turn", async () => {
|
||||
const tags = new Map<string, string>();
|
||||
const seen: string[] = [];
|
||||
const first = [
|
||||
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
|
||||
[
|
||||
{
|
||||
id: "a",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({ position: "设计.氛围", content: "细雨连绵" }),
|
||||
},
|
||||
],
|
||||
];
|
||||
let index = 0;
|
||||
const handlers = {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => undefined,
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag: string) => tags.get(tag),
|
||||
writeTag: (tag: string, content: string) => {
|
||||
tags.set(tag, content);
|
||||
},
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
};
|
||||
const failed = await runDictateTurn({
|
||||
llm: {
|
||||
async complete() {
|
||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||
},
|
||||
async completeWithTools() {
|
||||
return {
|
||||
content: null,
|
||||
toolCalls: first[index++] ?? [],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", { ...modulePayload, score_dimensions: ["短句"] }]]),
|
||||
handlers,
|
||||
});
|
||||
expect(failed.reply).toContain("失败待补(下轮继续)");
|
||||
expect(failed.reply).not.toContain("已同轮补写");
|
||||
const stored = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG));
|
||||
expect(stored.items[0]?.draft).toBe("细雨连绵");
|
||||
|
||||
const second = [
|
||||
[{ id: "select2", name: "select_nodes", arguments: JSON.stringify({ nodes: [] }) }],
|
||||
[
|
||||
{
|
||||
id: "b",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.氛围",
|
||||
content: "细雨连绵",
|
||||
self_score: { dims: [{ name: "短句", score: 4 }] },
|
||||
}),
|
||||
},
|
||||
{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "补上了" }) },
|
||||
],
|
||||
];
|
||||
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: second[index++] ?? [],
|
||||
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||
};
|
||||
},
|
||||
},
|
||||
dialogue: [{ role: "user", text: "继续" }],
|
||||
moduleCatalog: catalog,
|
||||
moduleReadIndex: new Map([["mood", { ...modulePayload, score_dimensions: ["短句"] }]]),
|
||||
handlers,
|
||||
});
|
||||
expect(seen[1]).toContain("底稿");
|
||||
expect(seen[1]).toContain("细雨连绵");
|
||||
expect(seen[1]).toContain("只改失败点");
|
||||
});
|
||||
});
|
||||
|
||||
+13
-9
@@ -124,8 +124,8 @@ describe("dictate context", () => {
|
||||
expect(catalog).toBeTruthy();
|
||||
const block = formatModuleCatalogForDictate(catalog!);
|
||||
expect(block).toContain("【能力 · 何时落盘】");
|
||||
expect(block).toContain("美学纲领与交互范式");
|
||||
expect(block).toContain("落盘:insert 「设计.美学纲领与交互范式」");
|
||||
expect(block).toContain("美学纲领:");
|
||||
expect(block).toContain("落盘:insert 「设计.美学纲领」");
|
||||
expect(block).toContain("何时用");
|
||||
expect(block).toContain("何时不用");
|
||||
expect(block).toContain("declare_variable");
|
||||
@@ -136,7 +136,7 @@ describe("dictate context", () => {
|
||||
expect(block).toContain("可增殖");
|
||||
expect(block).not.toContain("role=prototype");
|
||||
expect(block).toContain("落盘:insert 「设计.叙事指南」");
|
||||
expect(block).toContain("落盘:insert 「设计.故事推进」");
|
||||
expect(block).toContain("落盘:insert 「设计.故事推进与交互范式」");
|
||||
expect(block).not.toMatch(/^- 叙事指南与故事推进/m);
|
||||
});
|
||||
|
||||
@@ -180,11 +180,12 @@ describe("dictate context", () => {
|
||||
it("resolves insert tag to module and builds user-facing brief", async () => {
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
expect(catalog).toBeTruthy();
|
||||
const mod = resolveModuleForInsertTag(
|
||||
"设计.美学纲领与交互范式",
|
||||
catalog!,
|
||||
expect(resolveModuleForInsertTag("设计.美学纲领", catalog!)?.id).toBe(
|
||||
"aesthetics-interaction",
|
||||
);
|
||||
expect(mod?.id).toBe("aesthetics-interaction");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.美学纲领与交互范式", catalog!)?.id,
|
||||
).toBe("aesthetics-interaction");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
@@ -203,6 +204,9 @@ describe("dictate context", () => {
|
||||
expect(resolveModuleForInsertTag("设计.叙事指南", catalog!)?.id).toBe(
|
||||
"narrative-guide",
|
||||
);
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.故事推进与交互范式", catalog!)?.id,
|
||||
).toBe("story-progression");
|
||||
expect(resolveModuleForInsertTag("设计.故事推进", catalog!)?.id).toBe(
|
||||
"story-progression",
|
||||
);
|
||||
@@ -214,9 +218,9 @@ describe("dictate context", () => {
|
||||
catalog: catalog!,
|
||||
skillPackRoot: "dialogue/world-simulator",
|
||||
});
|
||||
const brief = index.get("设计.美学纲领与交互范式");
|
||||
const brief = index.get("设计.美学纲领");
|
||||
expect(brief).toBeTruthy();
|
||||
expect(brief!).toContain("# 本轮关注 · 美学纲领与交互范式");
|
||||
expect(brief!).toContain("# 本轮关注 · 美学纲领");
|
||||
expect(brief!).toContain("不要求固定标题");
|
||||
expect(brief!).toContain("本模块内容包括");
|
||||
expect(brief!).toContain("## 写入用户回复时做什么");
|
||||
|
||||
@@ -1405,6 +1405,10 @@ code {
|
||||
background: var(--st-surface);
|
||||
}
|
||||
|
||||
.settings-section .storage-card + .storage-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.storage-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<a class="st-brand" href="/">Writing Agent</a>
|
||||
<nav class="st-nav" aria-label="主导航">
|
||||
<a href="/">对话</a>
|
||||
<a href="/chat.html">直聊</a>
|
||||
<a href="/modules.html">技能</a>
|
||||
<a href="/libraries.html">库</a>
|
||||
<a href="/stats.html">Token</a>
|
||||
@@ -133,6 +134,27 @@
|
||||
<button type="button" class="btn-secondary danger-outline" id="btn-clear-context">清除全部痕迹</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card storage-card">
|
||||
<h3>对话落盘补写</h3>
|
||||
<p class="settings-help">
|
||||
同一句用户消息里,填写没通过时可以再自动补几枪。每一枪只改失败点:已经通过的产物不动,没通过的用上一份正文当底稿,已经填对的自评维度会带回去。次数用尽后仍标成失败待补,等下一句继续。
|
||||
</p>
|
||||
<label class="field">
|
||||
同轮最多补写次数
|
||||
<input
|
||||
type="number"
|
||||
id="dictate-fill-retries"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value="0"
|
||||
/>
|
||||
</label>
|
||||
<p class="settings-help muted">0 = 只填一次。最大 12。例如设为 7,就是第一次填写之外最多再试 7 次。</p>
|
||||
<div class="storage-actions">
|
||||
<button type="button" class="btn-primary" id="btn-save-fill-retries">保存次数</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+23
-1
@@ -62,6 +62,7 @@ const preferenceCollectEveryEl = document.getElementById("preference-collect-eve
|
||||
const activeSettingsBarEl = document.getElementById("active-settings-bar");
|
||||
const settingsToastEl = document.getElementById("settings-toast");
|
||||
const contextTraceKeepEl = document.getElementById("context-trace-keep");
|
||||
const dictateFillRetriesEl = document.getElementById("dictate-fill-retries");
|
||||
const probeDialog = document.getElementById("preset-probe-dialog");
|
||||
const probePresetNameEl = document.getElementById("probe-preset-name");
|
||||
const probeMessageEl = document.getElementById("probe-message");
|
||||
@@ -193,7 +194,7 @@ function renderPanelHeader() {
|
||||
panelActionsEl.appendChild(btn);
|
||||
} else {
|
||||
panelTitleEl.textContent = "上下文";
|
||||
panelSubtitleEl.textContent = "控制每个对话保留多少条完整 prompt 痕迹";
|
||||
panelSubtitleEl.textContent = "上下文痕迹,以及对话落盘同轮补写次数";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,6 +777,9 @@ async function loadAll() {
|
||||
state.settings.contextTraceKeepLatest ?? 5,
|
||||
);
|
||||
}
|
||||
if (dictateFillRetriesEl) {
|
||||
dictateFillRetriesEl.value = String(state.settings.dictateFillRetries ?? 0);
|
||||
}
|
||||
renderActiveBar();
|
||||
renderProfiles();
|
||||
renderPersonas();
|
||||
@@ -1798,6 +1802,24 @@ probeMessageEl?.addEventListener("keydown", (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-save-fill-retries")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const n = Number(dictateFillRetriesEl?.value ?? 0);
|
||||
const data = await api("/api/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ dictateFillRetries: n }),
|
||||
});
|
||||
state.settings = data.settings;
|
||||
if (dictateFillRetriesEl) {
|
||||
dictateFillRetriesEl.value = String(data.settings.dictateFillRetries ?? 0);
|
||||
}
|
||||
const saved = data.settings.dictateFillRetries ?? 0;
|
||||
showToast(saved === 0 ? "已保存:对话落盘只填一次" : `已保存:同轮最多再补写 ${saved} 次`);
|
||||
} catch (err) {
|
||||
showToast(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const n = Number(contextTraceKeepEl?.value ?? 5);
|
||||
|
||||
Reference in New Issue
Block a user