diff --git a/README.md b/README.md index 1a3ee1969d..8c5fdd85b7 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,13 @@ ## 最新更新 -### 2026-08-16 +### 2026-08-21 -- 自动导演发现局部章节计划失配时,会自动调整后续未生成章节的安排并继续创作,已保存正文和手动内容保持不变。 -- 从重规划检查点恢复时,会直接定位到首个未生成章节,避免再次处理已经完成的正文。 -- Windows 桌面版更新至 `0.4.13`,包含本次自动导演连续创作与重规划恢复改进。 +- 当章节缺少正文生成所需的执行合同字段时,任务中心会直接定位到对应章节处理入口,并提供“AI 补齐本章任务单”的处理路径。 +- 章节执行合同的缺失、待复核和可恢复状态会被明确展示,不再只显示无法操作的通用失败提示。 +- 自动导演暂停后可从最近检查点恢复,重复点击和失败重试会保持幂等,不会重复取消或让任务卡在无响应状态。 +- 卷纲质量问题会作为局部质量债继续推进;已有且仍匹配的章节执行合同会被保留,章节规划发生实质变化时则进入可修复状态。 +- 跟进中心会持续刷新排队中、执行中任务及当前详情,恢复后能看到章节、心跳和流水线的实时推进。 完整历史更新见 [docs/releases/release-notes.md](./docs/releases/release-notes.md)。 diff --git a/client/src/pages/autoDirectorFollowUps/AutoDirectorFollowUpCenterPage.tsx b/client/src/pages/autoDirectorFollowUps/AutoDirectorFollowUpCenterPage.tsx index 34b57a35ed..885295026b 100644 --- a/client/src/pages/autoDirectorFollowUps/AutoDirectorFollowUpCenterPage.tsx +++ b/client/src/pages/autoDirectorFollowUps/AutoDirectorFollowUpCenterPage.tsx @@ -50,6 +50,11 @@ const TASK_STATUSES: readonly TaskStatus[] = [ "failed", "cancelled", ]; +const LIVE_EXECUTION_REFETCH_INTERVAL_MS = 4000; + +function isLiveExecutionStatus(status: TaskStatus | null | undefined): boolean { + return status === "queued" || status === "running"; +} function buildListParamsKey(input: { section: AutoDirectorFollowUpSection | ""; @@ -144,7 +149,7 @@ export default function AutoDirectorFollowUpCenterPage() { queryFn: getAutoDirectorFollowUpOverview, refetchInterval: (query) => { const totalCount = query.state.data?.data?.totalCount ?? 0; - return totalCount > 0 ? 4000 : false; + return totalCount > 0 ? LIVE_EXECUTION_REFETCH_INTERVAL_MS : false; }, }); @@ -161,7 +166,11 @@ export default function AutoDirectorFollowUpCenterPage() { }), refetchInterval: (query) => { const items = query.state.data?.data?.items ?? []; - return items.some((item) => item.status === "failed" || item.status === "waiting_approval") ? 4000 : false; + return items.some((item) => ( + isLiveExecutionStatus(item.status) + || item.status === "failed" + || item.status === "waiting_approval" + )) ? LIVE_EXECUTION_REFETCH_INTERVAL_MS : false; }, }); @@ -172,6 +181,11 @@ export default function AutoDirectorFollowUpCenterPage() { queryFn: () => getAutoDirectorFollowUpDetail(selectedDirectorTaskId), enabled: Boolean(selectedDirectorTaskId), retry: false, + refetchInterval: (query) => ( + isLiveExecutionStatus(query.state.data?.data?.task?.status) + ? LIVE_EXECUTION_REFETCH_INTERVAL_MS + : false + ), }); useEffect(() => { diff --git a/client/src/pages/autoDirectorFollowUps/followUpPresentation.ts b/client/src/pages/autoDirectorFollowUps/followUpPresentation.ts index 083704f2cf..aea715514b 100644 --- a/client/src/pages/autoDirectorFollowUps/followUpPresentation.ts +++ b/client/src/pages/autoDirectorFollowUps/followUpPresentation.ts @@ -134,6 +134,9 @@ export function getFollowUpActionConsequence(action: AutoDirectorAction): string if (action.code === "continue_auto_execution") { return "向当前导演任务提交继续命令,并从现有检查点推进自动执行范围。"; } + if (action.code === "pause_auto_execution") { + return "停止后续自动步骤并保留当前进度;之后可从最近检查点恢复。"; + } if (action.code === "continue_generic") { return "向当前导演任务提交恢复命令,并从可恢复位置继续。"; } diff --git a/client/src/pages/novels/NovelEdit.tsx b/client/src/pages/novels/NovelEdit.tsx index 4616db5be4..f50c42672b 100644 --- a/client/src/pages/novels/NovelEdit.tsx +++ b/client/src/pages/novels/NovelEdit.tsx @@ -1999,6 +1999,11 @@ export default function NovelEdit() { if (!id || activeTab !== "structured" || !activeStructuredOutlineChapterId) { return; } + // A task recovery URL carries the exact chapter that needs repair. Do not + // let the latest director snapshot replace an explicit user target. + if (selectedChapterId && selectedChapterId !== activeStructuredOutlineChapterId) { + return; + } const targetVolume = normalizedVolumeDraft.find((volume) => ( volume.chapters.some((chapter) => ( chapter.id === activeStructuredOutlineChapterId @@ -2021,7 +2026,7 @@ export default function NovelEdit() { selectedChapterId: activeStructuredOutlineChapterId, selectedBeatKey: "all", }); - }, [activeStructuredOutlineChapterId, activeTab, id, normalizedVolumeDraft]); + }, [activeStructuredOutlineChapterId, activeTab, id, normalizedVolumeDraft, selectedChapterId]); useEffect(() => { if (!id) { diff --git a/client/src/pages/novels/components/StructuredChapterDetailCard.tsx b/client/src/pages/novels/components/StructuredChapterDetailCard.tsx index 6384929cee..8d27a35a04 100644 --- a/client/src/pages/novels/components/StructuredChapterDetailCard.tsx +++ b/client/src/pages/novels/components/StructuredChapterDetailCard.tsx @@ -204,7 +204,7 @@ export default function StructuredChapterDetailCard(props: StructuredChapterDeta onClick={() => onGenerateChapterDetailBundle(selectedVolume.id, selectedChapter.id)} disabled={isGeneratingChapterDetail || locked} > - {currentBundleRunning ? "当前章细化中..." : "细化当前章"} + {currentBundleRunning ? "正在补齐本章..." : "AI 补齐本章任务单"} ) : null} ) : !overviewQuery.isLoading && hasRecommendedAction ? ( - +
+ + {recommendedTaskNeedsChapterContractRepair ? ( + + ) : null} +
) : undefined} /> @@ -633,7 +690,11 @@ export default function TaskCenterPage() { onSortModeChange={setSortMode} /> -
+
) : null} diff --git a/client/src/pages/tasks/taskCenterUtils.test.mjs b/client/src/pages/tasks/taskCenterUtils.test.mjs index 4ea030590b..8881069e64 100644 --- a/client/src/pages/tasks/taskCenterUtils.test.mjs +++ b/client/src/pages/tasks/taskCenterUtils.test.mjs @@ -7,6 +7,8 @@ import { getTaskNoticeTitle, getTaskQueueLevelLabel, getTaskQueueTone, + isChapterExecutionContractFailure, + isChapterExecutionContractReviewFailure, isTaskMustHandle, } from "./taskCenterUtils.ts"; @@ -27,6 +29,26 @@ test("task queue uses structured failure state as blocker", () => { assert.equal(getTaskQueueLevelLabel(task), "任务失败"); }); +test("chapter execution contract failure keeps a direct repair task blocking", () => { + const task = { ...baseTask, status: "failed", failureCode: "CHAPTER_EXECUTION_CONTRACT_INCOMPLETE" }; + assert.equal(isChapterExecutionContractFailure(task), true); + assert.equal(isChapterExecutionContractReviewFailure(task), true); + assert.equal(getTaskQueueTone(task), "danger"); + assert.equal(isTaskMustHandle(task), true); +}); + +test("legacy chapter sync failure remains actionable without parsing its error text", () => { + const task = { + ...baseTask, + status: "failed", + failureCode: "CHAPTER_EXECUTION_CONTRACT_REVIEW_REQUIRED", + lastError: "任意历史错误文本", + }; + assert.equal(isChapterExecutionContractReviewFailure(task), true); + assert.equal(getTaskQueueTone(task), "danger"); + assert.equal(isTaskMustHandle(task), true); +}); + test("task queue keeps a completed task notice as quality reminder", () => { const task = { ...baseTask, status: "succeeded", noticeCode: "PIPELINE_QUALITY_REVIEW", noticeSummary: "待局部修复" }; assert.equal(getTaskQueueTone(task), "warning"); diff --git a/client/src/pages/tasks/taskCenterUtils.ts b/client/src/pages/tasks/taskCenterUtils.ts index ea6e9e516f..fb3928a73d 100644 --- a/client/src/pages/tasks/taskCenterUtils.ts +++ b/client/src/pages/tasks/taskCenterUtils.ts @@ -29,6 +29,17 @@ type TaskQueuePresentationInput = Pick< const PIPELINE_QUALITY_REVIEW_CODE = "PIPELINE_QUALITY_REVIEW"; const PIPELINE_REPLAN_REQUIRED_CODE = "PIPELINE_REPLAN_REQUIRED"; const CHAPTER_TITLE_DIVERSITY_CODE = "CHAPTER_TITLE_DIVERSITY"; +export const CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE = "CHAPTER_EXECUTION_CONTRACT_INCOMPLETE"; +export const CHAPTER_EXECUTION_CONTRACT_REVIEW_FAILURE_CODE = "CHAPTER_EXECUTION_CONTRACT_REVIEW_REQUIRED"; + +export function isChapterExecutionContractFailure(task: TaskQueuePresentationInput): boolean { + return task.failureCode === CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE; +} + +export function isChapterExecutionContractReviewFailure(task: TaskQueuePresentationInput): boolean { + return isChapterExecutionContractFailure(task) + || task.failureCode === CHAPTER_EXECUTION_CONTRACT_REVIEW_FAILURE_CODE; +} export function isTaskReplanRequired(task: TaskQueuePresentationInput): boolean { return task.checkpointType === "replan_required" diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 26c492764d..a908689536 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -4,6 +4,17 @@ ## 更新历史 +### 2026-08-21(章节执行合同恢复与自动导演暂停恢复) + +- 当章节缺少正文生成所需的执行合同字段时,任务中心会直接定位到对应章节处理入口,并提供“AI 补齐本章任务单”的处理路径。 +- 章节执行合同的缺失、待复核和可恢复状态会被明确展示,不再只显示无法操作的通用失败提示。 +- 自动导演暂停后可从最近检查点恢复,重复点击和失败重试会保持幂等,不会重复取消或让任务卡在无响应状态。 +- 卷纲质量问题会作为局部质量债继续推进;已有且仍匹配的章节执行合同会被保留,章节规划发生实质变化时则进入可修复状态。 +- 跟进中心会持续刷新排队中、执行中任务及当前详情,恢复后能看到章节、心跳和流水线的实时推进。 + +### 2026-08-20(自动导演创建稳定性) + +- 自动导演创建页在暂时没有可选世界样本时也能保持稳定,停留填写开书想法或继续设置时不会因选择控件异常而白屏。 ### 2026-08-16(自动导演连续创作) - 自动导演发现局部章节计划失配时,会自动调整后续未生成章节的安排并继续创作;已保存正文、已确认章节和手动内容保持不变。 diff --git a/docs/wiki/architecture/chapter-identity-and-planning-boundary.md b/docs/wiki/architecture/chapter-identity-and-planning-boundary.md index 0b7f52c116..70cea72846 100644 --- a/docs/wiki/architecture/chapter-identity-and-planning-boundary.md +++ b/docs/wiki/architecture/chapter-identity-and-planning-boundary.md @@ -24,12 +24,20 @@ `/volumes/sync-chapters` 保留为兼容修复和诊断入口。它的首要职责是修复章节连接和补齐执行入口,不应成为新手主流程的必要步骤。 +章节执行合同在生成场景卡前必须有目标字数。旧章节列表若尚未填写该字段,服务层应优先继承同卷距离最近的已设定字数;全卷都没有字数时,使用标准章节执行预算,并将结果随本次细化保存。用户不应因为历史规划缺少内部预算字段而遇到 500 或被要求先手工补数。 + +执行合同门禁失败时,失败状态必须以结构化字段保留 `novelId`、`volumeId`、`chapterId` 和章序;任务投影据此构造章节定位和补齐入口,不能从面向用户的错误文字反向解析章节。 + +自动导演进入正文链路前,应优先调用 AI 章节执行合同生成器补齐缺失字段并再次校验。AI 修复仍不完整时,才将该章标记为 `needs_repair` 并作为质量债务继续全书;自动路由窗口和章节同步不得把这类局部问题重新升级为全局失败。只有明确的重规划、安全或数据完整性问题可以暂停自动导演。 + ## Failure Modes - 如果规划章节有 `chapterId`,不得因为标题相同而误绑定到其他正式章节。 - 如果正式章节已经有正文,拆章重排或连接修复默认不得清空正文或重置执行状态。 - 如果旧数据没有 `chapterId` 且章序/标题无法可靠匹配,应创建新的正式章节并写回连接,而不是静默保持悬空规划。 -- 如果执行合同质量门禁不通过,应阻断连接到章节执行区,并提示具体章节缺少的规划信息。 +- 如果执行合同质量门禁不通过,严格同步入口应阻断连接到章节执行区,并提示具体章节缺少的规划信息;自动导演的章节细化同步使用 `defer_and_continue`。无论同步的是指定范围还是整本书,只要合同完全缺失或不完整,都应把该章标记为 `needs_repair`、不写入不完整的任务单/场景卡,同时继续后续章节,避免局部质量债务阻断全书主链路。 +- 延后同步遇到正式章节已保存的完整执行合同时,只有当前卷纲与同步前已保存卷纲中的章节身份、目标和边界都一致,才可保留该合同;不能从 `Chapter` 表不存在的边界字段推断一致性。标题、章节目标、边界或合同字段发生变化时必须进入 `needs_repair`,不得把新规划与旧任务单、场景卡混合后继续正文生产。 +- 如果失败任务丢失了章节定位,只能提供节奏 / 拆章的兼容入口和清楚操作提示;不得用字符串匹配猜测目标章节。 ## Related Modules diff --git a/docs/wiki/product/task-center-role.md b/docs/wiki/product/task-center-role.md index 754daf1c29..793b1a6b41 100644 --- a/docs/wiki/product/task-center-role.md +++ b/docs/wiki/product/task-center-role.md @@ -16,6 +16,8 @@ 4. 新手引导、普通按钮和页面文案应优先引导用户留在当前创作现场;除异常、恢复或历史查询外,不要把“打开运行记录”作为正常下一步。 5. 自动导演与章节质量债的主入口仍属于小说工作台和导演跟进;运行记录只提供事实查询与来源跳转,不重新裁决创作主状态。 6. 运行记录默认采用任务收件箱心智:先展示需要处理、等待操作和正在推进的任务,再展示当前动作与可执行入口。模型、Token、心跳、完整时间、检查点和细分步骤属于诊断信息,应在选中任务后按需展开,不能长期铺在列表中迫使新手读取内部运行字段。 +7. 当任务保存了章节修复定位时,运行记录应提供直达对应章节的补齐入口;执行合同缺失时,入口要明确说明“AI 补齐本章任务单”。旧记录没有章节定位时,可直达节奏 / 拆章并提示用户按任务提示选择章节。运行记录只负责定位和交接,不在此处隐式重试、重规划或修改小说内容。 +8. 自动导演跟进中心还承担运行控制:`queued/running` 任务必须提供“暂停自动执行”,暂停通过导演取消命令收束后台步骤但保留已保存产物;`cancelled` 状态必须提供从最近检查点恢复的入口。暂停与恢复都要沿用 `directorTaskId`、状态校验和幂等操作日志,不能用手动工作区任务代替。 ## Failure Modes diff --git a/docs/wiki/workflows/auto-director-runtime.md b/docs/wiki/workflows/auto-director-runtime.md index 02303097ed..7e2469aec2 100644 --- a/docs/wiki/workflows/auto-director-runtime.md +++ b/docs/wiki/workflows/auto-director-runtime.md @@ -63,6 +63,8 @@ Web API 只接收命令和返回轻量投影;Worker 负责执行重型生产 - API route 不直接 `await` 自动导演长任务、章节生成、卷拆章、质量修复或 LLM 生产链路。 - 高优先级硬约束:自动导演不是第二套章节生成系统。控制面可以有导演专属 command、projection 和审批策略,但正文生成与正文修复的业务执行链必须与手动单章和批量执行共用同一套 runtime。 - 继续、恢复、重试、接管、审批、取消等用户动作先转为 command,不各自维护独立业务流程。 +- 用户主动暂停运行中的自动导演时,控制面先原子占用同一幂等键,再提交取消 command;处理中重复请求只返回处理中,失败请求允许重试,陈旧占用在租约过期后才可接管。暂停后的任务保留最近检查点,并通过既有取消后重试/恢复入口继续,不能另建一条恢复链。 +- 跟进中心在自动导演处于 `queued` 或 `running` 时,列表和当前任务详情必须持续刷新。恢复命令提交成功只代表已进入后台调度;页面要继续显示后续章节、心跳和执行标签的变化,不能因任务从“待恢复”变为运行中就停止轮询,造成“已恢复但没有执行”的假象。 - `DirectorRunCommand` 表达控制面命令、租约和幂等,不表达业务完成事实。 - `DirectorRun` 是书级导演运行的根状态,`DirectorStepRun` 是步骤执行记录,`DirectorEvent` 和 `DirectorArtifact` 用于投影和恢复。 - StepModule 应声明输入、输出、产物、进度检查和恢复策略;Pipeline 只编排,不直接知道具体业务表和 Prompt 细节。 diff --git a/docs/wiki/workflows/chapter-production-chain.md b/docs/wiki/workflows/chapter-production-chain.md index 41d35eb3e9..5d3c3c8009 100644 --- a/docs/wiki/workflows/chapter-production-chain.md +++ b/docs/wiki/workflows/chapter-production-chain.md @@ -87,6 +87,7 @@ - 所有会改正文的修复入口统一遵循同一条修复规则:先尝试 patch repair;patch repair 因 Schema、定位、命中歧义或补丁无效失败时,只允许自动升级一次 `heavy_repair`;成功后统一走保存正文、资产同步、复审与状态更新;失败后手动修复返回真实失败,批量执行与自动导演记录质量债务或 recoverable failure 后继续后续章节。 - patch repair 的 `targetExcerpt` 必须是正文中唯一可定位的原文片段;`replacement` 表示替换后的内容。删除重复片段时允许 `replacement` 为空字符串,但仍必须满足唯一定位和产生正文变化。 - 已有正文进入复审或质量修复时,不应先把同一份正文重新保存为 `drafted/generating`。正文未变化时只做审校、必要修复和最终资产同步,避免 UI 更新时间、RAG 队列和章节状态被无意义刷新。 +- 手动审核与 AI 完整审校共用同一状态提交:审核结果写入 `AuditReport / QualityReport` 后,必须同步提交 `generationState=reviewed` 与 `chapterStatus=completed | needs_repair`;前端完成请求后必须刷新小说详情,不能只刷新报告列表而继续显示审核前的章节状态。 - 章节执行队列允许移除尚未开始的手动空白章节:它必须仍为 `planned/unplanned`,且没有正文、目标、任务单、场景卡、修复记录或风险标记。删除入口与服务端必须使用同一规则;任何已进入规划、写作、审校或修复链路的章节都不得从此入口删除,以保护已生成内容和下游事实。 - 自动导演的质量循环预算必须真正影响下一轮修复方式:同一失败签名已经尝试过局部修复后,下一轮章节管线要切到 `heavy_repair`,不能继续硬编码 `light_repair`。 - 章节执行失败语义必须区分:正文未生成是 `draft_generation_failed`;正文已生成但未兑现本章义务是 `draft_obligation_unmet`;自动修复后仍有阻塞问题是 `draft_repair_exhausted`;需要调整邻章计划是 `replan_required`。UI 和任务详情应展示真实根因,不再把这些情况统一压成 `chapter.draft.write 未满足其完成标准。` diff --git a/server/src/routes/autoDirectorFollowUps.ts b/server/src/routes/autoDirectorFollowUps.ts index 1e1ab6d58d..c6a2731784 100644 --- a/server/src/routes/autoDirectorFollowUps.ts +++ b/server/src/routes/autoDirectorFollowUps.ts @@ -38,6 +38,7 @@ const taskParamsSchema = z.object({ const singleActionBodySchema = z.object({ actionCode: z.enum([ + "pause_auto_execution", "continue_auto_execution", "continue_generic", "retry_with_task_model", diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 1a75b32cd8..79f1066684 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -53,6 +53,7 @@ const autoDirectorFollowUpParamsSchema = z.object({ const autoDirectorFollowUpActionBodySchema = z.object({ actionCode: z.enum([ + "pause_auto_execution", "continue_auto_execution", "continue_generic", "retry_with_task_model", diff --git a/server/src/services/novel/director/NovelDirectorService.ts b/server/src/services/novel/director/NovelDirectorService.ts index dcd02dea79..3782085677 100644 --- a/server/src/services/novel/director/NovelDirectorService.ts +++ b/server/src/services/novel/director/NovelDirectorService.ts @@ -39,6 +39,7 @@ import { getSharedNovelServices } from "../application/sharedNovelServices"; import { novelFramingSuggestionService } from "../NovelFramingSuggestionService"; import { StoryMacroPlanService } from "../storyMacro/StoryMacroPlanService"; import { NovelVolumeService } from "../volume/NovelVolumeService"; +import { isChapterExecutionContractQualityGateError } from "../volume/ChapterExecutionContractQualityGateError"; import { NovelWorkflowService } from "../workflow/NovelWorkflowService"; import { NovelDirectorCandidateStageService } from "./phases/novelDirectorCandidateStage"; import { resolveDirectorBookFraming } from "./runtime/novelDirectorFraming"; @@ -275,6 +276,16 @@ export class NovelDirectorService { if (isWorkflowTaskCancelledError(error) || isDirectorRuntimeGateError(error)) { return; } + if (isChapterExecutionContractQualityGateError(error)) { + await this.workflowService.markTaskFailed(taskId, error.message, { + stage: "structured_outline", + itemKey: "chapter_execution_contract_repair", + itemLabel: `第 ${error.chapterOrder} 章执行合同待补齐`, + chapterId: error.chapterId, + volumeId: error.volumeId, + }); + return; + } const message = error instanceof Error ? error.message : "自动导演后台任务执行失败。"; await this.workflowService.markTaskFailed(taskId, message); console.error(`[director.background] task failed taskId=${taskId}`, error); diff --git a/server/src/services/novel/director/phases/novelDirectorStructuredOutlinePhase.ts b/server/src/services/novel/director/phases/novelDirectorStructuredOutlinePhase.ts index dd48ce0523..ee65c183d6 100644 --- a/server/src/services/novel/director/phases/novelDirectorStructuredOutlinePhase.ts +++ b/server/src/services/novel/director/phases/novelDirectorStructuredOutlinePhase.ts @@ -36,6 +36,7 @@ import { import { runDirectorTrackedStep } from "../projections/directorProgressTracker"; import type { DirectorPhaseCallbacks, DirectorPhaseDependencies } from "./novelDirectorPhaseTypes"; import { resetDirectorDownstreamChapterState } from "../recovery/novelDirectorDownstreamReset"; +import { isChapterExecutionContractQualityGateError } from "../../volume/ChapterExecutionContractQualityGateError"; function buildChapterOrderRangeLabel(startOrder: number, endOrder: number): string { return startOrder === endOrder ? `第 ${startOrder} 章` : `第 ${startOrder}-${endOrder} 章`; @@ -69,32 +70,79 @@ function findMissingSelectedChapterOrders( async function syncPreparedChapterExecutionContext(input: { novelId: string; + taskId?: string; + provider?: DirectorConfirmRequest["provider"]; + model?: string; + temperature?: number; workspace: VolumePlanDocument; targetVolumeId: string; targetChapterId: string; dependencies: DirectorPhaseDependencies; -}): Promise { +}): Promise { const targetVolume = input.workspace.volumes.find((volume) => volume.id === input.targetVolumeId); const targetChapter = targetVolume?.chapters.find((chapter) => chapter.id === input.targetChapterId); if (!targetChapter) { - return; + return input.workspace; } - if (!targetChapter.taskSheet?.trim() && !targetChapter.sceneCards?.trim()) { - return; + const chapterRange = { + startOrder: targetChapter.chapterOrder, + endOrder: targetChapter.chapterOrder, + }; + let latestWorkspace = input.workspace; + let resolvedTargetChapter = targetChapter; + + // 旧卷纲可能尚未保存 chapterId;先建立正式章节连接,再走同一条 AI 修复链路。 + if (!resolvedTargetChapter.chapterId) { + await input.dependencies.volumeService.syncVolumeChaptersWithOptions(input.novelId, { + volumes: latestWorkspace.volumes, + preserveContent: true, + applyDeletes: false, + executionContractChapterRange: chapterRange, + }, { + emitEvent: false, + syncPayoffLedger: false, + qualityGateMode: "defer_and_continue", + }); + latestWorkspace = await input.dependencies.volumeService.getVolumes(input.novelId); + resolvedTargetChapter = latestWorkspace.volumes + .find((volume) => volume.id === input.targetVolumeId) + ?.chapters.find((chapter) => chapter.id === input.targetChapterId) + ?? resolvedTargetChapter; + } + + // 自动导演优先让同一套 AI 合同生成器补齐缺失字段;失败时再降级为章节质量债务,不能阻断全书。 + if (resolvedTargetChapter.chapterId) { + await input.dependencies.volumeService.ensureChapterExecutionContract( + input.novelId, + resolvedTargetChapter.chapterId, + { + provider: input.provider, + model: input.model, + temperature: input.temperature, + chapterTaskSheetQualityMode: "full_book_autopilot", + entrypoint: "auto_director_contract_repair", + taskId: input.taskId, + }, + ).catch((error) => { + if (!isChapterExecutionContractQualityGateError(error)) { + throw error; + } + return null; + }); } + latestWorkspace = await input.dependencies.volumeService.getVolumes(input.novelId); await input.dependencies.volumeService.syncVolumeChaptersWithOptions(input.novelId, { - volumes: input.workspace.volumes, + volumes: latestWorkspace.volumes, preserveContent: true, applyDeletes: false, - executionContractChapterRange: { - startOrder: targetChapter.chapterOrder, - endOrder: targetChapter.chapterOrder, - }, + executionContractChapterRange: chapterRange, }, { emitEvent: false, syncPayoffLedger: false, + qualityGateMode: "defer_and_continue", }); + return latestWorkspace; } function buildStructuredOutlinePhaseUpdate(event: VolumeGenerationPhaseEvent): { @@ -424,13 +472,17 @@ export async function runDirectorStructuredOutlinePhase(input: { chapterId: recoveryCursor.chapterId, }, }); - await syncPreparedChapterExecutionContext({ + workspace = await syncPreparedChapterExecutionContext({ novelId, workspace, targetVolumeId, - targetChapterId, - dependencies, - }); + targetChapterId, + dependencies, + taskId, + provider: request.provider, + model: request.model, + temperature: request.temperature, + }); continue; } @@ -496,6 +548,7 @@ export async function runDirectorStructuredOutlinePhase(input: { }, { emitEvent: false, syncPayoffLedger: false, + qualityGateMode: "defer_and_continue", }); const syncCursor = resolveStructuredOutlineRecoveryCursor({ workspace: persistedOutlineWorkspace, diff --git a/server/src/services/novel/director/runtime/autoDirectorValidationService.ts b/server/src/services/novel/director/runtime/autoDirectorValidationService.ts index 7d4b84c04b..1eaf740fd6 100644 --- a/server/src/services/novel/director/runtime/autoDirectorValidationService.ts +++ b/server/src/services/novel/director/runtime/autoDirectorValidationService.ts @@ -410,6 +410,9 @@ export function validateAutoDirectorAction(input: AutoDirectorActionValidationIn if (input.actionCode === "continue_auto_execution" && input.task.checkpointType !== "chapter_batch_ready") { blockingReasons.push("当前检查点不能直接继续章节执行,请先查看任务详情。"); } + if (input.actionCode === "pause_auto_execution" && input.task.status !== "queued" && input.task.status !== "running") { + blockingReasons.push("当前任务已经不在自动执行中,请先重新校验任务状态。"); + } if ((input.actionCode === "retry_with_task_model" || input.actionCode === "retry_with_route_model") && input.task.status !== "failed" && input.task.status !== "cancelled") { blockingReasons.push("当前任务没有失败或取消,不需要重试。"); } diff --git a/server/src/services/novel/director/workflowStepRuntime/DirectorCoreStepModuleRuntime.ts b/server/src/services/novel/director/workflowStepRuntime/DirectorCoreStepModuleRuntime.ts index ad6bd9d9f7..89d8d30caa 100644 --- a/server/src/services/novel/director/workflowStepRuntime/DirectorCoreStepModuleRuntime.ts +++ b/server/src/services/novel/director/workflowStepRuntime/DirectorCoreStepModuleRuntime.ts @@ -312,6 +312,7 @@ export class DirectorCoreStepModuleRuntime { { emitEvent: false, syncPayoffLedger: true, + qualityGateMode: "defer_and_continue", }, ); } diff --git a/server/src/services/novel/novelCoreReviewService.ts b/server/src/services/novel/novelCoreReviewService.ts index 958bd98604..a82cc7057a 100644 --- a/server/src/services/novel/novelCoreReviewService.ts +++ b/server/src/services/novel/novelCoreReviewService.ts @@ -188,10 +188,36 @@ export class NovelCoreReviewService { options: ReviewOptions = {}, ) { const contextPackage = await this.assembleAuditContextPackage(novelId, chapterId, options, "audit"); - return auditService.auditChapter(novelId, chapterId, scope, { + const review = await auditService.auditChapter(novelId, chapterId, scope, { ...options, contextPackage, }); + const chapter = await prisma.chapter.findFirst({ + where: { id: chapterId, novelId }, + select: { order: true }, + }); + if (chapter) { + await prisma.chapter.update({ + where: { id: chapterId }, + data: chapterStatePairAfterManualQualityReview(isPass(review.score)), + }); + await createQualityReport(novelId, chapterId, review.score, review.issues); + await chapterQualityLoopService.recordAssessment({ + novelId, + chapterId, + chapterOrder: chapter.order, + score: review.score, + issues: review.issues, + source: "manual_review", + }).catch((error) => { + logPipelineError("Failed to record chapter quality loop assessment.", { + novelId, + chapterId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + return review; } async listChapterAuditReports(novelId: string, chapterId: string) { diff --git a/server/src/services/novel/planning/ChapterRouteWindowService.ts b/server/src/services/novel/planning/ChapterRouteWindowService.ts index 393553ae73..969bf91552 100644 --- a/server/src/services/novel/planning/ChapterRouteWindowService.ts +++ b/server/src/services/novel/planning/ChapterRouteWindowService.ts @@ -112,6 +112,7 @@ export class ChapterRouteWindowService { }, { emitEvent: false, syncPayoffLedger: false, + qualityGateMode: "defer_and_continue", }); extended = true; availableRouteCount = await this.countAvailableRoute(novelId, fromChapterOrder); diff --git a/server/src/services/novel/volume/ChapterExecutionContractQualityGateError.ts b/server/src/services/novel/volume/ChapterExecutionContractQualityGateError.ts new file mode 100644 index 0000000000..3ab84f5222 --- /dev/null +++ b/server/src/services/novel/volume/ChapterExecutionContractQualityGateError.ts @@ -0,0 +1,32 @@ +export const CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE = "CHAPTER_EXECUTION_CONTRACT_INCOMPLETE"; + +interface ChapterExecutionContractQualityGateErrorInput { + novelId: string; + volumeId: string; + chapterId: string; + chapterOrder: number; + message: string; +} + +export class ChapterExecutionContractQualityGateError extends Error { + readonly code = CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE; + readonly novelId: string; + readonly volumeId: string; + readonly chapterId: string; + readonly chapterOrder: number; + + constructor(input: ChapterExecutionContractQualityGateErrorInput) { + super(input.message); + this.name = "ChapterExecutionContractQualityGateError"; + this.novelId = input.novelId; + this.volumeId = input.volumeId; + this.chapterId = input.chapterId; + this.chapterOrder = input.chapterOrder; + } +} + +export function isChapterExecutionContractQualityGateError( + error: unknown, +): error is ChapterExecutionContractQualityGateError { + return error instanceof ChapterExecutionContractQualityGateError; +} diff --git a/server/src/services/novel/volume/ChapterExecutionContractService.ts b/server/src/services/novel/volume/ChapterExecutionContractService.ts index 5bf0223964..79c78cdbe2 100644 --- a/server/src/services/novel/volume/ChapterExecutionContractService.ts +++ b/server/src/services/novel/volume/ChapterExecutionContractService.ts @@ -16,6 +16,7 @@ import { runVolumeWorkspaceTransaction, } from "./volumeWorkspacePersistence"; import { serializeVolumeWorkspaceDocument } from "./volumeWorkspaceDocument"; +import { ChapterExecutionContractQualityGateError } from "./ChapterExecutionContractQualityGateError"; export interface ChapterExecutionContractServiceDeps { storyMacroPlanService: Pick; @@ -81,29 +82,84 @@ export class ChapterExecutionContractService { throw new Error("章节不存在。"); } - const existingScenePlan = parseChapterScenePlan(chapter.sceneCards, { + const databaseScenePlan = parseChapterScenePlan(chapter.sceneCards, { targetWordCount: chapter.targetWordCount ?? undefined, }); - if ( + const hasCompleteDatabaseContract = Boolean( typeof chapter.conflictLevel === "number" && typeof chapter.revealLevel === "number" && typeof chapter.targetWordCount === "number" && chapter.mustAvoid?.trim() && chapter.taskSheet?.trim() - && existingScenePlan - ) { + && databaseScenePlan, + ); + + let workspace: VolumePlanDocument; + let matched: { volumeId: string; volumeChapterId: string }; + try { + workspace = await this.deps.ensureVolumeWorkspace(novelId); + matched = this.deps.findVolumeChapterMatch(workspace, { + order: chapter.order, + title: chapter.title, + }); + } catch (error) { + if (hasCompleteDatabaseContract) { + const styleContract = await this.resolveStyleContract(novelId, chapterId, options.taskStyleProfileId); + return { ...chapter, styleContract }; + } + throw error; + } + + if (hasCompleteDatabaseContract) { + const styleContract = await this.resolveStyleContract(novelId, chapterId, options.taskStyleProfileId); + return { ...chapter, styleContract }; + } + + const existingVolume = workspace.volumes.find((volume) => volume.id === matched.volumeId); + const existingVolumeChapter = existingVolume?.chapters.find((item) => item.id === matched.volumeChapterId); + const existingScenePlan = parseChapterScenePlan(existingVolumeChapter?.sceneCards, { + targetWordCount: existingVolumeChapter?.targetWordCount ?? undefined, + }); + const existingQuality = existingVolumeChapter + ? assessChapterExecutionContractShape({ + novelId, + volumeId: matched.volumeId, + chapterId, + chapterOrder: chapter.order, + title: chapter.title, + summary: existingVolumeChapter.summary, + purpose: existingVolumeChapter.purpose, + exclusiveEvent: existingVolumeChapter.exclusiveEvent, + endingState: existingVolumeChapter.endingState, + nextChapterEntryState: existingVolumeChapter.nextChapterEntryState, + conflictLevel: existingVolumeChapter.conflictLevel, + revealLevel: existingVolumeChapter.revealLevel, + targetWordCount: existingVolumeChapter.targetWordCount, + mustAvoid: existingVolumeChapter.mustAvoid, + payoffRefs: existingVolumeChapter.payoffRefs, + taskSheet: existingVolumeChapter.taskSheet, + sceneCards: existingVolumeChapter.sceneCards, + }) + : null; + if (existingQuality?.canEnterExecution && existingScenePlan) { + const syncedChapter = await prisma.chapter.update({ + where: { id: chapterId }, + data: { + targetWordCount: existingVolumeChapter?.targetWordCount ?? null, + conflictLevel: existingVolumeChapter?.conflictLevel ?? null, + revealLevel: existingVolumeChapter?.revealLevel ?? null, + mustAvoid: existingVolumeChapter?.mustAvoid ?? null, + taskSheet: existingVolumeChapter?.taskSheet?.trim() || null, + sceneCards: existingVolumeChapter?.sceneCards ?? null, + }, + }); const styleContract = await this.resolveStyleContract(novelId, chapterId, options.taskStyleProfileId); return { - ...chapter, + ...syncedChapter, styleContract, }; } - const workspace = await this.deps.ensureVolumeWorkspace(novelId); - const matched = this.deps.findVolumeChapterMatch(workspace, { - order: chapter.order, - title: chapter.title, - }); const generatedDocument = await generateVolumePlanDocument({ novelId, workspace, @@ -122,14 +178,26 @@ export class ChapterExecutionContractService { const targetVolume = generatedDocument.volumes.find((volume) => volume.id === matched.volumeId); const targetChapter = targetVolume?.chapters.find((item) => item.id === matched.volumeChapterId); if (!targetChapter?.taskSheet?.trim() || !targetChapter.sceneCards?.trim()) { - throw new Error("AI 未返回完整的章节执行合同。"); + throw new ChapterExecutionContractQualityGateError({ + novelId, + volumeId: matched.volumeId, + chapterId, + chapterOrder: chapter.order, + message: "AI 未返回完整的章节执行合同。", + }); } const taskSheet = targetChapter.taskSheet.trim(); const scenePlan = parseChapterScenePlan(targetChapter.sceneCards, { targetWordCount: targetChapter.targetWordCount ?? chapter.targetWordCount ?? undefined, }); if (!scenePlan) { - throw new Error("章节执行合同中的场景预算无效。"); + throw new ChapterExecutionContractQualityGateError({ + novelId, + volumeId: matched.volumeId, + chapterId, + chapterOrder: chapter.order, + message: "章节执行合同中的场景预算无效。", + }); } const finalQuality = assessChapterExecutionContractShape({ novelId, @@ -151,7 +219,13 @@ export class ChapterExecutionContractService { sceneCards: serializeChapterScenePlan(scenePlan), }); if (!finalQuality.canEnterExecution) { - throw new Error(formatChapterTaskSheetQualityFailure(finalQuality)); + throw new ChapterExecutionContractQualityGateError({ + novelId, + volumeId: matched.volumeId, + chapterId, + chapterOrder: chapter.order, + message: formatChapterTaskSheetQualityFailure(finalQuality), + }); } const styleContract = await this.resolveStyleContract(novelId, chapterId, options.taskStyleProfileId); diff --git a/server/src/services/novel/volume/NovelVolumeService.ts b/server/src/services/novel/volume/NovelVolumeService.ts index 0366c0a42c..cf92957c30 100644 --- a/server/src/services/novel/volume/NovelVolumeService.ts +++ b/server/src/services/novel/volume/NovelVolumeService.ts @@ -642,6 +642,7 @@ export class NovelVolumeService { emitEvent?: boolean; syncPayoffLedger?: boolean; volumeUpdateReason?: VolumeUpdateReason; + qualityGateMode?: "strict" | "defer_and_continue"; } = {}, ): Promise { return new VolumeChapterSyncService({ diff --git a/server/src/services/novel/volume/VolumeChapterSyncService.ts b/server/src/services/novel/volume/VolumeChapterSyncService.ts index 16297b02f5..1c742f3fa2 100644 --- a/server/src/services/novel/volume/VolumeChapterSyncService.ts +++ b/server/src/services/novel/volume/VolumeChapterSyncService.ts @@ -24,6 +24,7 @@ import { persistActiveVolumeWorkspace, runVolumeWorkspaceTransaction, } from "./volumeWorkspacePersistence"; +import { ChapterExecutionContractQualityGateError } from "./ChapterExecutionContractQualityGateError"; export interface VolumeChapterSyncServiceDeps { ensureVolumeWorkspace: (novelId: string) => Promise; @@ -41,6 +42,7 @@ export interface VolumeChapterSyncOptions { emitEvent?: boolean; syncPayoffLedger?: boolean; volumeUpdateReason?: VolumeUpdateReason; + qualityGateMode?: "strict" | "defer_and_continue"; } export class VolumeChapterSyncService { @@ -72,7 +74,11 @@ export class VolumeChapterSyncService { ): Promise { const workspace = await this.deps.ensureVolumeWorkspace(novelId); const mergedDocument = mergeVolumeWorkspaceInput(novelId, workspace, { volumes: input.volumes }); - this.assertSyncableChapterExecutionContracts(mergedDocument, input.executionContractChapterRange); + const deferredExecutionContractChapterIds = this.assertSyncableChapterExecutionContracts( + mergedDocument, + input.executionContractChapterRange, + options.qualityGateMode, + ); const shouldSyncPayoffLedger = hasPayoffLedgerRelevantPlanChanges(workspace.volumes, mergedDocument.volumes); const existingChapters = await prisma.chapter.findMany({ where: { novelId }, @@ -99,6 +105,8 @@ export class VolumeChapterSyncService { { preserveContent: input.preserveContent !== false, applyDeletes: input.applyDeletes === true, + deferredExecutionContractChapterIds, + previousVolumes: workspace.volumes, }, ); @@ -117,8 +125,9 @@ export class VolumeChapterSyncService { conflictLevel: item.chapter.conflictLevel ?? null, revealLevel: item.chapter.revealLevel ?? null, mustAvoid: item.chapter.mustAvoid ?? null, - taskSheet: item.chapter.taskSheet?.trim() || null, - sceneCards: item.chapter.sceneCards ?? null, + taskSheet: item.deferExecutionContract ? null : item.chapter.taskSheet?.trim() || null, + sceneCards: item.deferExecutionContract ? null : item.chapter.sceneCards ?? null, + chapterStatus: item.deferExecutionContract ? "needs_repair" : "unplanned", }, }); item.chapter.chapterId = created.id; @@ -132,16 +141,31 @@ export class VolumeChapterSyncService { title: item.chapter.title, order: item.chapter.chapterOrder, expectation: item.chapter.purpose?.trim() || item.chapter.summary, - targetWordCount: item.chapter.targetWordCount ?? null, - conflictLevel: item.chapter.conflictLevel ?? null, - revealLevel: item.chapter.revealLevel ?? null, - mustAvoid: item.chapter.mustAvoid ?? null, - taskSheet: item.chapter.taskSheet?.trim() || null, - sceneCards: item.chapter.sceneCards ?? null, - ...(!item.preserveWorkflowState + ...(item.preserveExistingExecutionContract + ? {} + : { + targetWordCount: item.chapter.targetWordCount ?? null, + conflictLevel: item.chapter.conflictLevel ?? null, + revealLevel: item.chapter.revealLevel ?? null, + mustAvoid: item.chapter.mustAvoid ?? null, + }), + ...(item.preserveExistingExecutionContract + ? {} + : item.deferExecutionContract + ? { + // 不让旧执行产物绕过待修复状态继续进入正文链路。 + taskSheet: null, + sceneCards: null, + chapterStatus: "needs_repair" as const, + } + : { + taskSheet: item.chapter.taskSheet?.trim() || null, + sceneCards: item.chapter.sceneCards ?? null, + }), + ...(!item.preserveWorkflowState && !item.preserveExistingExecutionContract ? { generationState: "planned", - chapterStatus: "unplanned", + ...(item.deferExecutionContract ? {} : { chapterStatus: "unplanned" as const }), } : {}), ...(item.clearContent ? { content: "" } : {}), @@ -186,7 +210,9 @@ export class VolumeChapterSyncService { private assertSyncableChapterExecutionContracts( document: VolumePlanDocument, chapterRange?: VolumeSyncInput["executionContractChapterRange"], - ): void { + qualityGateMode: VolumeChapterSyncOptions["qualityGateMode"] = "strict", + ): Set { + const deferredChapterIds = new Set(); for (const volume of document.volumes) { for (const chapter of volume.chapters) { if ( @@ -197,6 +223,9 @@ export class VolumeChapterSyncService { } const hasExecutionArtifact = Boolean(chapter.taskSheet?.trim() || chapter.sceneCards?.trim()); if (!hasExecutionArtifact) { + if (qualityGateMode === "defer_and_continue") { + deferredChapterIds.add(chapter.id); + } continue; } const result = assessChapterExecutionContractShape({ @@ -219,9 +248,21 @@ export class VolumeChapterSyncService { sceneCards: chapter.sceneCards, }); if (!result.canEnterExecution) { - throw new Error(`第 ${chapter.chapterOrder} 章执行合同未通过质量门禁,不能连接到章节执行区。${formatChapterTaskSheetQualityFailure(result)}`); + const error = new ChapterExecutionContractQualityGateError({ + novelId: document.novelId, + volumeId: volume.id, + chapterId: chapter.id, + chapterOrder: chapter.chapterOrder, + message: `第 ${chapter.chapterOrder} 章执行合同未通过质量门禁,不能连接到章节执行区。${formatChapterTaskSheetQualityFailure(result)}`, + }); + if (qualityGateMode === "defer_and_continue") { + deferredChapterIds.add(chapter.id); + continue; + } + throw error; } } } + return deferredChapterIds; } } diff --git a/server/src/services/novel/volume/volumeGenerationHelpers.ts b/server/src/services/novel/volume/volumeGenerationHelpers.ts index fe4f158d47..8a4dfc5014 100644 --- a/server/src/services/novel/volume/volumeGenerationHelpers.ts +++ b/server/src/services/novel/volume/volumeGenerationHelpers.ts @@ -120,6 +120,38 @@ export function getTargetChapter(targetVolume: VolumePlan, targetChapterId?: str return targetChapter; } +const DEFAULT_CHAPTER_TARGET_WORD_COUNT = 2500; + +/** + * Chapter detail generation needs a concrete budget before scene cards can be + * normalized. Older chapter lists may not have one yet, so inherit the + * nearest explicit chapter budget in the same volume and keep the normal + * execution default as the final safety value. + */ +export function resolveChapterTargetWordCount( + volume: VolumePlan, + chapter: VolumePlan["chapters"][number], +): number { + if (typeof chapter.targetWordCount === "number" && chapter.targetWordCount > 0) { + return Math.round(chapter.targetWordCount); + } + + const nearestBudget = volume.chapters + .filter((candidate) => ( + candidate.id !== chapter.id + && typeof candidate.targetWordCount === "number" + && candidate.targetWordCount > 0 + )) + .sort((left, right) => ( + Math.abs(left.chapterOrder - chapter.chapterOrder) - Math.abs(right.chapterOrder - chapter.chapterOrder) + || left.chapterOrder - right.chapterOrder + ))[0]?.targetWordCount; + + return typeof nearestBudget === "number" && nearestBudget > 0 + ? Math.round(nearestBudget) + : DEFAULT_CHAPTER_TARGET_WORD_COUNT; +} + export function getBeatSheet(document: VolumePlanDocument, volumeId: string): VolumeBeatSheet | null { return document.beatSheets.find((sheet) => sheet.volumeId === volumeId && sheet.beats.length > 0) ?? null; } diff --git a/server/src/services/novel/volume/volumeGenerationOrchestrator.ts b/server/src/services/novel/volume/volumeGenerationOrchestrator.ts index 26a39a468d..bda25e47f3 100644 --- a/server/src/services/novel/volume/volumeGenerationOrchestrator.ts +++ b/server/src/services/novel/volume/volumeGenerationOrchestrator.ts @@ -44,6 +44,7 @@ import { mergeSkeleton, mergeStrategyPlan, normalizeScope, + resolveChapterTargetWordCount, } from "./volumeGenerationHelpers"; import type { VolumeGenerateOptions, @@ -487,6 +488,18 @@ async function generateChapterDetail(params: { const { document, novel, workspace, storyMacroPlan, options } = params; const targetVolume = getTargetVolume(document, options.targetVolumeId); const targetChapter = getTargetChapter(targetVolume, options.targetChapterId); + const resolvedTargetWordCount = resolveChapterTargetWordCount(targetVolume, targetChapter); + const resolvedTargetVolume = { + ...targetVolume, + chapters: targetVolume.chapters.map((chapter) => chapter.id === targetChapter.id + ? { ...chapter, targetWordCount: resolvedTargetWordCount } + : chapter), + }; + const resolvedDocument = { + ...document, + volumes: document.volumes.map((volume) => volume.id === targetVolume.id ? resolvedTargetVolume : volume), + }; + const resolvedTargetChapter = getTargetChapter(resolvedTargetVolume, options.targetChapterId); const detailMode = options.detailMode; if (!detailMode) { throw new Error("生成章节细化时必须指定 detailMode。"); @@ -497,9 +510,9 @@ async function generateChapterDetail(params: { workspace, storyMacroPlan, strategyPlan: document.strategyPlan, - targetVolume, - targetBeatSheet: getBeatSheet(document, targetVolume.id), - targetChapter, + targetVolume: resolvedTargetVolume, + targetBeatSheet: getBeatSheet(resolvedDocument, resolvedTargetVolume.id), + targetChapter: resolvedTargetChapter, guidance: options.guidance, detailMode, }; @@ -507,7 +520,7 @@ async function generateChapterDetail(params: { novelId: document.novelId, scope: "chapter_detail", phase: "prompt", - label: `正在细化第 ${targetVolume.sortOrder} 卷第 ${targetChapter.chapterOrder} 章 ${formatChapterDetailModeLabel(detailMode)}`, + label: `正在细化第 ${resolvedTargetVolume.sortOrder} 卷第 ${resolvedTargetChapter.chapterOrder} 章 ${formatChapterDetailModeLabel(detailMode)}`, options, }); const generated = detailMode === "purpose" @@ -522,8 +535,8 @@ async function generateChapterDetail(params: { taskId: options.taskId, entrypoint: options.entrypoint, novelId: document.novelId, - volumeId: targetVolume.id, - chapterId: targetChapter.id, + volumeId: resolvedTargetVolume.id, + chapterId: resolvedTargetChapter.id, stage: "chapter_detail_purpose", itemKey: "chapter_detail_bundle", scope: "chapter_detail", @@ -543,8 +556,8 @@ async function generateChapterDetail(params: { taskId: options.taskId, entrypoint: options.entrypoint, novelId: document.novelId, - volumeId: targetVolume.id, - chapterId: targetChapter.id, + volumeId: resolvedTargetVolume.id, + chapterId: resolvedTargetChapter.id, stage: "chapter_detail_boundary", itemKey: "chapter_detail_bundle", scope: "chapter_detail", @@ -563,9 +576,9 @@ async function generateChapterDetail(params: { }; return mergeChapterDetail({ - document, - targetVolumeId: targetVolume.id, - targetChapterId: targetChapter.id, + document: resolvedDocument, + targetVolumeId: resolvedTargetVolume.id, + targetChapterId: resolvedTargetChapter.id, detailMode, generatedDetail: generated.output as Record, }); diff --git a/server/src/services/novel/volume/volumePlanChangeDetection.ts b/server/src/services/novel/volume/volumePlanChangeDetection.ts index a9ca5a14a0..2f2b2f978a 100644 --- a/server/src/services/novel/volume/volumePlanChangeDetection.ts +++ b/server/src/services/novel/volume/volumePlanChangeDetection.ts @@ -10,6 +10,7 @@ import type { VolumeSyncPreview, VolumeSyncPreviewItem, } from "@ai-novel/shared/types/novel"; +import { parseChapterScenePlan } from "@ai-novel/shared/types/chapterLengthControl"; export interface ExistingChapterRecord { id: string; @@ -39,10 +40,13 @@ export interface VolumeSyncPlan { creates: Array<{ volumeTitle: string; chapter: VolumeChapterPlan; + deferExecutionContract: boolean; }>; updates: Array<{ chapterId: string; chapter: VolumeChapterPlan; + deferExecutionContract: boolean; + preserveExistingExecutionContract: boolean; clearContent: boolean; preserveWorkflowState: boolean; existingGenerationState?: Chapter["generationState"] | null; @@ -56,6 +60,67 @@ export interface VolumeSyncPlan { }>; } +function hasCompleteExecutionContract(existing: ExistingChapterRecord): boolean { + return Boolean( + typeof existing.conflictLevel === "number" + && typeof existing.revealLevel === "number" + && typeof existing.targetWordCount === "number" + && existing.mustAvoid?.trim() + && existing.taskSheet?.trim() + && parseChapterScenePlan(existing.sceneCards, { + targetWordCount: existing.targetWordCount ?? undefined, + }), + ); +} + +function canPreserveExistingExecutionContract( + existing: ExistingChapterRecord, + chapter: VolumeChapterPlan, + previousChapter: VolumeChapterPlan | undefined, +): boolean { + const desiredExpectation = chapter.purpose?.trim() || chapter.summary; + if ( + !previousChapter + || !hasUnchangedExecutionContractPlanning(previousChapter, chapter) + || !compareText(existing.title, chapter.title) + || !compareText(existing.expectation, desiredExpectation) + || chapter.taskSheet?.trim() + || chapter.sceneCards?.trim() + ) { + return false; + } + if (typeof chapter.targetWordCount === "number" && !compareNumber(existing.targetWordCount, chapter.targetWordCount)) { + return false; + } + if (typeof chapter.conflictLevel === "number" && !compareNumber(existing.conflictLevel, chapter.conflictLevel)) { + return false; + } + if (typeof chapter.revealLevel === "number" && !compareNumber(existing.revealLevel, chapter.revealLevel)) { + return false; + } + if (chapter.mustAvoid?.trim() && !compareText(existing.mustAvoid, chapter.mustAvoid)) { + return false; + } + return true; +} + +function hasUnchangedExecutionContractPlanning( + previous: VolumeChapterPlan, + current: VolumeChapterPlan, +): boolean { + return compareText(previous.title, current.title) + && compareText(previous.summary, current.summary) + && compareText(previous.purpose, current.purpose) + && compareText(previous.exclusiveEvent, current.exclusiveEvent) + && compareText(previous.endingState, current.endingState) + && compareText(previous.nextChapterEntryState, current.nextChapterEntryState) + && compareNumber(previous.targetWordCount, current.targetWordCount) + && compareNumber(previous.conflictLevel, current.conflictLevel) + && compareNumber(previous.revealLevel, current.revealLevel) + && compareText(previous.mustAvoid, current.mustAvoid) + && compareStringArray(previous.payoffRefs, current.payoffRefs); +} + function compareText(a: string | null | undefined, b: string | null | undefined): boolean { return (a ?? "").trim() === (b ?? "").trim(); } @@ -242,9 +307,17 @@ export function buildTaskSheetFromVolumeChapter(chapter: VolumeChapterPlan): str export function buildVolumeSyncPlan( volumes: VolumePlan[], existingChapters: ExistingChapterRecord[], - options: { preserveContent: boolean; applyDeletes: boolean }, + options: { + preserveContent: boolean; + applyDeletes: boolean; + deferredExecutionContractChapterIds?: ReadonlySet; + previousVolumes?: VolumePlan[]; + }, ): VolumeSyncPlan { const flattened = flattenVolumeChapters(volumes); + const previousChapterById = new Map( + flattenVolumeChapters(options.previousVolumes ?? []).map(({ chapter }) => [chapter.id, chapter] as const), + ); const existingById = new Map(existingChapters.map((chapter) => [chapter.id, chapter])); const existingByOrder = new Map(existingChapters.map((chapter) => [chapter.order, chapter])); const existingByTitle = new Map(existingChapters.map((chapter) => [normalizeLookupTitle(chapter.title), chapter])); @@ -287,7 +360,11 @@ export function buildVolumeSyncPlan( if (!existing) { createCount += 1; - creates.push({ volumeTitle: volume.title, chapter }); + creates.push({ + volumeTitle: volume.title, + chapter, + deferExecutionContract: options.deferredExecutionContractChapterIds?.has(chapter.id) ?? false, + }); items.push({ action: "create", volumeTitle: volume.title, @@ -310,6 +387,20 @@ export function buildVolumeSyncPlan( if (changedFields.length === 0) { keepCount += 1; + if (options.deferredExecutionContractChapterIds?.has(chapter.id)) { + const preserveExistingExecutionContract = hasCompleteExecutionContract(existing) + && canPreserveExistingExecutionContract(existing, chapter, previousChapterById.get(chapter.id)); + updates.push({ + chapterId: existing.id, + chapter, + deferExecutionContract: !preserveExistingExecutionContract, + preserveExistingExecutionContract, + clearContent: false, + preserveWorkflowState: hasContent && options.preserveContent, + existingGenerationState: existing.generationState ?? null, + existingChapterStatus: existing.chapterStatus ?? null, + }); + } items.push({ action: "keep", volumeTitle: volume.title, @@ -333,9 +424,15 @@ export function buildVolumeSyncPlan( clearContentCount += 1; } } + const preserveExistingExecutionContract = (options.deferredExecutionContractChapterIds?.has(chapter.id) ?? false) + && hasCompleteExecutionContract(existing) + && canPreserveExistingExecutionContract(existing, chapter, previousChapterById.get(chapter.id)); updates.push({ chapterId: existing.id, chapter, + deferExecutionContract: (options.deferredExecutionContractChapterIds?.has(chapter.id) ?? false) + && !preserveExistingExecutionContract, + preserveExistingExecutionContract, clearContent: hasContent && !options.preserveContent, preserveWorkflowState: hasContent && options.preserveContent, existingGenerationState: existing.generationState ?? null, diff --git a/server/src/services/novel/workflow/NovelWorkflowApplicationService.ts b/server/src/services/novel/workflow/NovelWorkflowApplicationService.ts index 776040acfc..f474f76d8e 100644 --- a/server/src/services/novel/workflow/NovelWorkflowApplicationService.ts +++ b/server/src/services/novel/workflow/NovelWorkflowApplicationService.ts @@ -297,14 +297,24 @@ export class NovelWorkflowApplicationService { return existing; } const stage = patch?.stage ?? "auto_director"; - const resumeTarget = parseResumeTarget(existing.resumeTargetJson) ?? this.workflow.buildResumeTarget({ - taskId, - novelId: existing.novelId, - lane: existing.lane, - stage, - chapterId: patch?.chapterId, - volumeId: patch?.volumeId, - }); + const existingResumeTarget = parseResumeTarget(existing.resumeTargetJson); + const resumeTarget = patch?.stage + ? this.workflow.buildResumeTarget({ + taskId, + novelId: existing.novelId, + lane: existing.lane, + stage: patch.stage, + chapterId: patch.chapterId ?? existingResumeTarget?.chapterId ?? null, + volumeId: patch.volumeId ?? existingResumeTarget?.volumeId ?? null, + }) + : existingResumeTarget ?? this.workflow.buildResumeTarget({ + taskId, + novelId: existing.novelId, + lane: existing.lane, + stage, + chapterId: patch?.chapterId, + volumeId: patch?.volumeId, + }); return this.workflow.updateWorkflowTaskWithNotifications({ before: existing, data: { diff --git a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts index a40898699a..a5354895d8 100644 --- a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts +++ b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts @@ -25,6 +25,7 @@ import { } from "../../novel/director/runtime/novelDirectorHelpers"; import { isAutoDirectorRecoveryInProgress } from "../../novel/workflow/novelWorkflowRecoveryHeuristics"; import { + buildNovelEditResumeTarget, buildNovelCreateResumeTarget, parseMilestones, parseSeedPayload, @@ -46,6 +47,9 @@ import { buildNovelWorkflowDetailSteps } from "../novelWorkflowDetailSteps"; import { buildWorkflowExplainability } from "../novelWorkflowExplainability"; import { buildNovelWorkflowNextActionLabel } from "../novelWorkflowTaskSummary"; +const CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE = "CHAPTER_EXECUTION_CONTRACT_INCOMPLETE"; +const CHAPTER_EXECUTION_CONTRACT_REVIEW_FAILURE_CODE = "CHAPTER_EXECUTION_CONTRACT_REVIEW_REQUIRED"; + function buildOwnerLabel(row: { novel?: { title: string } | null; title: string; @@ -285,6 +289,13 @@ function mapSummary(row: { const isSkippableReviewBlockedFailure = status === "failed" && checkpointType === "chapter_batch_ready" && isSkippableAutoExecutionReviewFailure(row.lastError); + const hasChapterExecutionContractFailure = status === "failed" + && row.currentItemKey === "chapter_execution_contract_repair"; + // Older task records were created before the failing chapter could be stored + // on the task. Keep their stable workflow-step signal actionable without + // attempting to infer a chapter from the human-readable error message. + const needsLegacyChapterExecutionContractReview = status === "failed" + && (row.currentItemKey === "chapter_sync" || row.currentItemKey === "chapter_detail_bundle"); const lastError = (isRecoveryInProgress || isSkippableReviewBlockedFailure) ? null : row.lastError; const resumeTarget = normalizeWorkflowResumeTargetForCandidateSelection({ id: row.id, @@ -293,7 +304,18 @@ function mapSummary(row: { resumeTargetJson: row.resumeTargetJson, seedPayloadJson: row.seedPayloadJson, }); - const sourceRoute = resumeTargetToRoute(resumeTarget); + const chapterContractReviewRoute = (hasChapterExecutionContractFailure || needsLegacyChapterExecutionContractReview) + && row.novelId + ? resumeTargetToRoute(buildNovelEditResumeTarget({ + novelId: row.novelId, + taskId: row.id, + lane: "auto_director", + stage: "structured", + chapterId: resumeTarget?.chapterId ?? null, + volumeId: resumeTarget?.volumeId ?? null, + })) + : null; + const sourceRoute = chapterContractReviewRoute ?? resumeTargetToRoute(resumeTarget); const ownerLabel = buildOwnerLabel(row); const linkedPipelineJobId = parseLinkedPipelineJobId(row.seedPayloadJson); const taskNotice = parseTaskNotice(row.seedPayloadJson); @@ -332,6 +354,10 @@ function mapSummary(row: { const failureSummary = status === "failed" ? (isSkippableReviewBlockedFailure ? buildSkippableAutoExecutionReviewFailureSummary(autoExecution) + : hasChapterExecutionContractFailure + ? "当前章节缺少正文生成前的执行信息。系统会先自动补全章节目标、边界和写作约束;若仍未完成,可打开对应章节查看并手动补齐。" + : needsLegacyChapterExecutionContractReview + ? "章节规划还没有完成同步。系统会先自动补齐并同步;若仍未完成,可打开章节规划查看当前章节并手动补齐。" : normalizeFailureSummary(lastError, "Novel workflow stopped without a recorded error.")) : null; const recoveryHint = isSkippableReviewBlockedFailure @@ -374,7 +400,13 @@ function mapSummary(row: { ), noticeCode: taskNotice?.code ?? null, noticeSummary: taskNotice?.summary ?? null, - failureCode: status === "failed" && !isSkippableReviewBlockedFailure ? "NOVEL_WORKFLOW_FAILED" : null, + failureCode: status === "failed" && !isSkippableReviewBlockedFailure + ? (hasChapterExecutionContractFailure + ? CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE + : needsLegacyChapterExecutionContractReview + ? CHAPTER_EXECUTION_CONTRACT_REVIEW_FAILURE_CODE + : "NOVEL_WORKFLOW_FAILED") + : null, failureSummary, recoveryHint, tokenUsage: toTaskTokenUsageSummary({ diff --git a/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpActionExecutor.ts b/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpActionExecutor.ts index e4d60359f1..140961d764 100644 --- a/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpActionExecutor.ts +++ b/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpActionExecutor.ts @@ -28,6 +28,14 @@ import { type WorkflowTaskRow = NonNullable>>; const EXECUTED_ACTION_CACHE = new Map(); +const ACTION_EXECUTION_LOCKS = new Map>(); +const ACTION_PROCESSING_RESULT_CODE = "processing"; +const ACTION_PROCESSING_STALE_MS = 5 * 60 * 1000; + +type ActionLogClaimResult = + | { status: "claimed" } + | { status: "processing" } + | { status: "completed"; resultCode: string; failureReason: string | null }; const BATCH_ALLOWED_ACTIONS = new Set([ "continue_auto_execution", @@ -65,6 +73,13 @@ function isDbUnavailableError(error: unknown): boolean { return code === "P1001" || /can't reach database server/i.test(message); } +function isUniqueConstraintError(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: string }).code === "P2002"; +} + function getExecutionScopeLabel(seedPayloadJson: string | null | undefined): string | null { const scopeLabel = parseSeedPayload(seedPayloadJson)?.autoExecution?.scopeLabel; return typeof scopeLabel === "string" && scopeLabel.trim() ? scopeLabel.trim() : null; @@ -188,6 +203,23 @@ export class AutoDirectorFollowUpActionExecutor { readonly validationService = new AutoDirectorValidationService(); async execute(input: AutoDirectorActionRequest): Promise { + const lockKey = buildExecutedCacheKey(input); + const existingLock = ACTION_EXECUTION_LOCKS.get(lockKey); + if (existingLock) { + return existingLock; + } + const operation = this.executeUnlocked(input); + ACTION_EXECUTION_LOCKS.set(lockKey, operation); + try { + return await operation; + } finally { + if (ACTION_EXECUTION_LOCKS.get(lockKey) === operation) { + ACTION_EXECUTION_LOCKS.delete(lockKey); + } + } + } + + private async executeUnlocked(input: AutoDirectorActionRequest): Promise { const executedCacheKey = buildExecutedCacheKey(input); const cached = EXECUTED_ACTION_CACHE.get(executedCacheKey); if (cached) { @@ -205,6 +237,17 @@ export class AutoDirectorFollowUpActionExecutor { }); return result; } + if ( + input.actionCode === "pause_auto_execution" + && logged + && logged.resultCode === ACTION_PROCESSING_RESULT_CODE + && logged.executedAt >= new Date(Date.now() - ACTION_PROCESSING_STALE_MS) + ) { + return { + ...buildAlreadyProcessedResult(input, await this.safeGetTaskDetail(input.taskId)), + message: "暂停请求仍在处理中,请刷新任务状态。", + }; + } const healed = await this.workflowService.healAutoDirectorTaskState(input.taskId); const row = await this.workflowService.getTaskByIdWithoutHealing(input.taskId); @@ -293,6 +336,28 @@ export class AutoDirectorFollowUpActionExecutor { await this.recordActionLog(input, result); return result; } + if (input.actionCode === "pause_auto_execution") { + const claim = await this.claimActionLog(input); + if (claim.status === "processing") { + return { + ...buildAlreadyProcessedResult(input, await this.safeGetTaskDetail(input.taskId)), + message: "暂停请求仍在处理中,请刷新任务状态。", + }; + } + if (claim.status === "completed") { + if (claim.resultCode === "failed") { + // A failed attempt is retryable. claimActionLog() only returns this + // branch when the failed row could not be reclaimed, so surface the + // persisted failure instead of falsely reporting success. + return buildFailedResult( + input, + claim.failureReason || "上次暂停执行失败", + await this.safeGetTaskDetail(input.taskId), + ); + } + return buildAlreadyProcessedResult(input, await this.safeGetTaskDetail(input.taskId)); + } + } try { const task = await this.executeMutationAction(row, input); @@ -301,7 +366,9 @@ export class AutoDirectorFollowUpActionExecutor { taskId: input.taskId, actionCode: input.actionCode, code: "executed", - message: "执行成功", + message: input.actionCode === "pause_auto_execution" + ? "自动执行已暂停,已保存进度可从最近检查点恢复。" + : "执行成功", task, }; EXECUTED_ACTION_CACHE.set(executedCacheKey, result); @@ -520,6 +587,9 @@ export class AutoDirectorFollowUpActionExecutor { row: WorkflowTaskRow, input: AutoDirectorActionRequest, ): Promise { + if (input.actionCode === "pause_auto_execution") { + return this.workflowTaskAdapter.cancel(input.taskId); + } const batchAlreadyStartedCount = typeof input.metadata?.highMemoryStartedCount === "number" && input.metadata.highMemoryStartedCount > 0 ? input.metadata.highMemoryStartedCount : undefined; @@ -601,6 +671,91 @@ export class AutoDirectorFollowUpActionExecutor { } } + private async claimActionLog(input: AutoDirectorActionRequest): Promise { + try { + await prisma.autoDirectorFollowUpActionLog.create({ + data: { + taskId: input.taskId, + actionCode: input.actionCode, + sourceChannel: input.source, + sourceUser: input.operatorId?.trim() || null, + idempotencyKey: input.idempotencyKey, + resultCode: ACTION_PROCESSING_RESULT_CODE, + failureReason: null, + metadataJson: input.metadata ? JSON.stringify(input.metadata) : null, + executedAt: new Date(), + }, + }); + return { status: "claimed" }; + } catch (error) { + if (isUniqueConstraintError(error)) { + const existing = await this.findLoggedExecution(input.idempotencyKey); + if (!existing) { + // A schema-less/degraded database can report a uniqueness error + // without allowing the follow-up row to be read. Keep the action + // safe and report it as processing rather than issuing a duplicate + // cancel command. + return { status: "processing" }; + } + if (existing.resultCode === ACTION_PROCESSING_RESULT_CODE) { + const staleBefore = new Date(Date.now() - ACTION_PROCESSING_STALE_MS); + if (existing.executedAt < staleBefore) { + const reclaimed = await prisma.autoDirectorFollowUpActionLog.updateMany({ + where: { + idempotencyKey: input.idempotencyKey, + resultCode: ACTION_PROCESSING_RESULT_CODE, + executedAt: { lt: staleBefore }, + }, + data: { + taskId: input.taskId, + actionCode: input.actionCode, + sourceChannel: input.source, + sourceUser: input.operatorId?.trim() || null, + metadataJson: input.metadata ? JSON.stringify(input.metadata) : null, + executedAt: new Date(), + }, + }); + if (reclaimed.count === 1) { + return { status: "claimed" }; + } + } + return { status: "processing" }; + } + if (existing.resultCode === "failed") { + const reclaimed = await prisma.autoDirectorFollowUpActionLog.updateMany({ + where: { + idempotencyKey: input.idempotencyKey, + resultCode: "failed", + }, + data: { + taskId: input.taskId, + actionCode: input.actionCode, + sourceChannel: input.source, + sourceUser: input.operatorId?.trim() || null, + resultCode: ACTION_PROCESSING_RESULT_CODE, + failureReason: null, + metadataJson: input.metadata ? JSON.stringify(input.metadata) : null, + executedAt: new Date(), + }, + }); + if (reclaimed.count === 1) { + return { status: "claimed" }; + } + return { status: "processing" }; + } + return { + status: "completed", + resultCode: existing.resultCode, + failureReason: existing.failureReason ?? null, + }; + } + if (isMissingTableError(error) || isDbUnavailableError(error)) { + return { status: "claimed" }; + } + throw error; + } + } + private async recordActionLog( input: AutoDirectorActionRequest, result: AutoDirectorActionExecutionResult, @@ -612,6 +767,17 @@ export class AutoDirectorFollowUpActionExecutor { }, }); if (existing) { + if (existing.resultCode === ACTION_PROCESSING_RESULT_CODE) { + await prisma.autoDirectorFollowUpActionLog.update({ + where: { idempotencyKey: input.idempotencyKey }, + data: { + resultCode: result.code, + failureReason: result.code === "failed" ? result.message : null, + metadataJson: input.metadata ? JSON.stringify(input.metadata) : null, + executedAt: new Date(), + }, + }); + } return; } await prisma.autoDirectorFollowUpActionLog.create({ @@ -628,6 +794,9 @@ export class AutoDirectorFollowUpActionExecutor { }, }); } catch (error) { + if (isUniqueConstraintError(error)) { + return; + } if (isMissingTableError(error) || isDbUnavailableError(error)) { return; } diff --git a/server/src/services/task/autoDirectorFollowUps/autoDirectorFollowUpReasonResolver.ts b/server/src/services/task/autoDirectorFollowUps/autoDirectorFollowUpReasonResolver.ts index d6b24f064e..f8449e109f 100644 --- a/server/src/services/task/autoDirectorFollowUps/autoDirectorFollowUpReasonResolver.ts +++ b/server/src/services/task/autoDirectorFollowUps/autoDirectorFollowUpReasonResolver.ts @@ -169,6 +169,12 @@ export function resolveAutoDirectorFollowUpReason( reason: "auto_progress_running", priority: "P2", availableActions: [ + mutationAction({ + code: "pause_auto_execution", + label: "暂停自动执行", + riskLevel: "low", + requiresConfirm: true, + }), navigationAction({ code: "open_detail", label: "查看推进详情", diff --git a/server/tests/autoDirectorFollowUpActionExecutor.test.js b/server/tests/autoDirectorFollowUpActionExecutor.test.js index d2bacc44e3..2eb99db1fe 100644 --- a/server/tests/autoDirectorFollowUpActionExecutor.test.js +++ b/server/tests/autoDirectorFollowUpActionExecutor.test.js @@ -162,6 +162,203 @@ test("auto director follow-up action executor continues auto execution and dedup prisma.autoDirectorFollowUpActionLog.create = originals.actionLogCreate; }); +test("auto director follow-up executor serializes concurrent requests with the same idempotency key", async () => { + const executor = new AutoDirectorFollowUpActionExecutor(); + let invocationCount = 0; + executor.executeUnlocked = async () => { + invocationCount += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + directorTaskId: "task_lock", + taskId: "task_lock", + actionCode: "pause_auto_execution", + code: "executed", + message: "自动执行已暂停", + task: null, + }; + }; + + const input = { + taskId: "task_lock", + actionCode: "pause_auto_execution", + source: "web", + operatorId: "anonymous", + idempotencyKey: "same-pause-key", + }; + const [first, second] = await Promise.all([ + executor.execute(input), + executor.execute(input), + ]); + + assert.equal(invocationCount, 1); + assert.equal(first.code, "executed"); + assert.equal(second.code, "executed"); +}); + +test("auto director follow-up pause action cancels the running task and finalizes its idempotency record", async () => { + const executor = new AutoDirectorFollowUpActionExecutor(); + const originals = { + actionLogFindUnique: prisma.autoDirectorFollowUpActionLog.findUnique, + actionLogCreate: prisma.autoDirectorFollowUpActionLog.create, + actionLogUpdate: prisma.autoDirectorFollowUpActionLog.update, + }; + const actionLogs = new Map(); + let cancelCount = 0; + prisma.autoDirectorFollowUpActionLog.findUnique = async ({ where }) => actionLogs.get(where.idempotencyKey) ?? null; + prisma.autoDirectorFollowUpActionLog.create = async ({ data }) => { + if (actionLogs.has(data.idempotencyKey)) { + const error = new Error("duplicate"); + error.code = "P2002"; + throw error; + } + const row = { id: data.idempotencyKey, ...data }; + actionLogs.set(data.idempotencyKey, row); + return row; + }; + prisma.autoDirectorFollowUpActionLog.update = async ({ where, data }) => { + const row = { ...(actionLogs.get(where.idempotencyKey) ?? {}), ...data }; + actionLogs.set(where.idempotencyKey, row); + return row; + }; + executor.workflowService.healAutoDirectorTaskState = async () => false; + executor.workflowService.getTaskByIdWithoutHealing = async () => buildWorkflowRow({ + id: "task_pause", + status: "running", + checkpointType: null, + }); + executor.workflowTaskAdapter.cancel = async (taskId) => { + cancelCount += 1; + return buildTaskDetail(taskId, { status: "cancelled" }); + }; + executor.workflowTaskAdapter.detail = async (taskId) => buildTaskDetail(taskId, { status: "cancelled" }); + + try { + const result = await executor.execute({ + taskId: "task_pause", + actionCode: "pause_auto_execution", + source: "web", + operatorId: "anonymous", + idempotencyKey: "pause-task-pause", + }); + + assert.equal(result.code, "executed"); + assert.equal(cancelCount, 1); + assert.match(result.message, /已暂停/); + assert.equal(actionLogs.get("pause-task-pause").resultCode, "executed"); + } finally { + prisma.autoDirectorFollowUpActionLog.findUnique = originals.actionLogFindUnique; + prisma.autoDirectorFollowUpActionLog.create = originals.actionLogCreate; + prisma.autoDirectorFollowUpActionLog.update = originals.actionLogUpdate; + } +}); + +test("auto director follow-up pause reports a fresh processing lease instead of cancelling twice", async () => { + const executor = new AutoDirectorFollowUpActionExecutor(); + const originalFindUnique = prisma.autoDirectorFollowUpActionLog.findUnique; + const actionKey = "pause-processing-key"; + let cancelCount = 0; + prisma.autoDirectorFollowUpActionLog.findUnique = async () => ({ + idempotencyKey: actionKey, + taskId: "task_processing", + actionCode: "pause_auto_execution", + sourceChannel: "web", + sourceUser: "anonymous", + resultCode: "processing", + failureReason: null, + metadataJson: null, + executedAt: new Date(), + }); + executor.workflowTaskAdapter.detail = async (taskId) => buildTaskDetail(taskId, { status: "running" }); + executor.workflowTaskAdapter.cancel = async () => { + cancelCount += 1; + return buildTaskDetail("task_processing", { status: "cancelled" }); + }; + + try { + const result = await executor.execute({ + taskId: "task_processing", + actionCode: "pause_auto_execution", + source: "web", + operatorId: "anonymous", + idempotencyKey: actionKey, + }); + + assert.equal(result.code, "already_processed"); + assert.match(result.message, /处理中/); + assert.equal(cancelCount, 0); + } finally { + prisma.autoDirectorFollowUpActionLog.findUnique = originalFindUnique; + } +}); + +test("auto director follow-up pause can retry a persisted failed attempt", async () => { + const executor = new AutoDirectorFollowUpActionExecutor(); + const originals = { + findUnique: prisma.autoDirectorFollowUpActionLog.findUnique, + create: prisma.autoDirectorFollowUpActionLog.create, + update: prisma.autoDirectorFollowUpActionLog.update, + updateMany: prisma.autoDirectorFollowUpActionLog.updateMany, + }; + const actionKey = "pause-failed-retry-key"; + const actionLog = { + idempotencyKey: actionKey, + taskId: "task_failed_retry", + actionCode: "pause_auto_execution", + sourceChannel: "web", + sourceUser: "anonymous", + resultCode: "failed", + failureReason: "上次取消失败", + metadataJson: null, + executedAt: new Date(), + }; + let cancelCount = 0; + prisma.autoDirectorFollowUpActionLog.findUnique = async () => actionLog; + prisma.autoDirectorFollowUpActionLog.create = async () => { + const error = new Error("duplicate"); + error.code = "P2002"; + throw error; + }; + prisma.autoDirectorFollowUpActionLog.updateMany = async () => { + actionLog.resultCode = "processing"; + actionLog.failureReason = null; + return { count: 1 }; + }; + prisma.autoDirectorFollowUpActionLog.update = async ({ data }) => { + Object.assign(actionLog, data); + return actionLog; + }; + executor.workflowService.healAutoDirectorTaskState = async () => false; + executor.workflowService.getTaskByIdWithoutHealing = async () => buildWorkflowRow({ + id: "task_failed_retry", + status: "running", + checkpointType: null, + }); + executor.workflowTaskAdapter.cancel = async (taskId) => { + cancelCount += 1; + return buildTaskDetail(taskId, { status: "cancelled" }); + }; + executor.workflowTaskAdapter.detail = async (taskId) => buildTaskDetail(taskId, { status: "cancelled" }); + + try { + const result = await executor.execute({ + taskId: "task_failed_retry", + actionCode: "pause_auto_execution", + source: "web", + operatorId: "anonymous", + idempotencyKey: actionKey, + }); + + assert.equal(result.code, "executed"); + assert.equal(cancelCount, 1); + assert.equal(actionLog.resultCode, "executed"); + } finally { + prisma.autoDirectorFollowUpActionLog.findUnique = originals.findUnique; + prisma.autoDirectorFollowUpActionLog.create = originals.create; + prisma.autoDirectorFollowUpActionLog.update = originals.update; + prisma.autoDirectorFollowUpActionLog.updateMany = originals.updateMany; + } +}); + test("auto director follow-up action executor sends skip_quality_repair for quality-repair checkpoints", async () => { const executor = new AutoDirectorFollowUpActionExecutor(); const calls = []; diff --git a/server/tests/autoDirectorFollowUpReasonResolver.test.js b/server/tests/autoDirectorFollowUpReasonResolver.test.js index b0012222b1..e990cbe75d 100644 --- a/server/tests/autoDirectorFollowUpReasonResolver.test.js +++ b/server/tests/autoDirectorFollowUpReasonResolver.test.js @@ -92,3 +92,25 @@ test("follow-up resolver exposes retry metadata for failed tasks", () => { assert.deepEqual(result.batchActionCodes, ["retry_with_task_model"]); assert.equal(result.supportsBatch, true); }); + +test("follow-up resolver exposes pause and resume state controls for automatic progress", () => { + const running = resolveAutoDirectorFollowUpReason({ + status: "running", + checkpointType: "chapter_batch_ready", + }); + + assert.ok(running); + assert.equal(running.reason, "auto_progress_running"); + assert.deepEqual(actionCodes(running), ["pause_auto_execution", "open_detail"]); + assert.equal(running.availableActions[0].requiresConfirm, true); + + const cancelled = resolveAutoDirectorFollowUpReason({ + status: "cancelled", + checkpointType: "chapter_batch_ready", + }); + + assert.ok(cancelled); + assert.equal(cancelled.reason, "runtime_cancelled"); + assert.equal(cancelled.availableActions[0].code, "retry_with_task_model"); + assert.match(cancelled.availableActions[0].label, /恢复|继续/); +}); diff --git a/server/tests/chapterDetailTargetWordCount.test.js b/server/tests/chapterDetailTargetWordCount.test.js new file mode 100644 index 0000000000..eb13ca35bf --- /dev/null +++ b/server/tests/chapterDetailTargetWordCount.test.js @@ -0,0 +1,77 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + resolveChapterTargetWordCount, +} = require("../dist/services/novel/volume/volumeGenerationHelpers.js"); + +function createChapter(order, targetWordCount) { + return { + id: `chapter-${order}`, + volumeId: "volume-1", + chapterOrder: order, + title: `第${order}章`, + summary: "章节摘要", + purpose: null, + exclusiveEvent: null, + endingState: null, + nextChapterEntryState: null, + conflictLevel: null, + conflictLevelSource: null, + revealLevel: null, + targetWordCount, + mustAvoid: null, + taskSheet: null, + sceneCards: null, + payoffRefs: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; +} + +function createVolume(chapters) { + return { + id: "volume-1", + novelId: "novel-1", + sortOrder: 1, + title: "第一卷", + summary: "测试卷", + openingHook: null, + mainPromise: null, + primaryPressureSource: null, + coreSellingPoint: null, + escalationMode: null, + protagonistChange: null, + midVolumeRisk: null, + climax: null, + payoffType: null, + nextVolumeHook: null, + resetPoint: null, + openPayoffs: [], + status: "active", + sourceVersionId: null, + chapters, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; +} + +test("chapter detail inherits the nearest explicit target word count", () => { + const volume = createVolume([ + createChapter(7, 3000), + createChapter(8, 2500), + createChapter(9, null), + createChapter(10, null), + ]); + + assert.equal(resolveChapterTargetWordCount(volume, volume.chapters[2]), 2500); +}); + +test("chapter detail falls back to the normal execution default when no chapter has a budget", () => { + const volume = createVolume([ + createChapter(1, null), + createChapter(2, null), + ]); + + assert.equal(resolveChapterTargetWordCount(volume, volume.chapters[1]), 2500); +}); diff --git a/server/tests/novelReviewContext.test.js b/server/tests/novelReviewContext.test.js index 3ac99a1e18..c18fe7933f 100644 --- a/server/tests/novelReviewContext.test.js +++ b/server/tests/novelReviewContext.test.js @@ -287,6 +287,11 @@ test("manual review and manual audit pass assembled chapter review context into chapterStatus: "completed", }, }); + assert.ok(chapterUpdateCalls.some((call) => ( + call.where?.id === "chapter-1" + && call.data?.generationState === "reviewed" + && call.data?.chapterStatus === "completed" + ))); } finally { prisma.chapter.findFirst = originalChapterFindFirst; prisma.chapter.update = originalChapterUpdate; diff --git a/server/tests/volumeSyncPlan.test.js b/server/tests/volumeSyncPlan.test.js index 214ae6879f..08a865d6ad 100644 --- a/server/tests/volumeSyncPlan.test.js +++ b/server/tests/volumeSyncPlan.test.js @@ -4,6 +4,7 @@ const { buildTaskSheetFromVolumeChapter, buildVolumeSyncPlan, } = require("../dist/services/novel/volume/volumePlanUtils.js"); +const { VolumeChapterSyncService } = require("../dist/services/novel/volume/VolumeChapterSyncService.js"); function createVolume(chapters) { return [{ @@ -298,3 +299,257 @@ test("buildTaskSheetFromVolumeChapter backfills stable chapter task sheets from assert.match(taskSheet, /目标字数:3600/); assert.match(taskSheet, /兑现关联:伏笔A、伏笔B/); }); + +test("deferred contract chapters without existing execution artifacts are marked for repair", () => { + const volumes = createVolume([{ + id: "volume-chapter-missing-contract", + volumeId: "volume-1", + chapterOrder: 1, + title: "第1章", + summary: "待补齐合同", + purpose: "建立冲突", + conflictLevel: null, + revealLevel: null, + targetWordCount: null, + mustAvoid: null, + taskSheet: null, + sceneCards: null, + payoffRefs: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }]); + const plan = buildVolumeSyncPlan(volumes, [{ + id: "chapter-1", + order: 1, + title: "第1章", + content: "", + generationState: "planned", + chapterStatus: "unplanned", + expectation: "旧摘要", + targetWordCount: null, + conflictLevel: null, + revealLevel: null, + mustAvoid: null, + taskSheet: null, + }], { + preserveContent: true, + applyDeletes: false, + deferredExecutionContractChapterIds: new Set(["volume-chapter-missing-contract"]), + }); + + assert.equal(plan.updates[0].deferExecutionContract, true); +}); + +test("defer sync without a chapter range records completely missing execution contracts", () => { + const service = new VolumeChapterSyncService({}); + const deferredChapterIds = service.assertSyncableChapterExecutionContracts({ + novelId: "novel-1", + volumes: createVolume([{ + id: "volume-chapter-missing-without-range", + volumeId: "volume-1", + chapterOrder: 1, + title: "第1章", + summary: "待补齐合同", + purpose: "建立冲突", + conflictLevel: null, + revealLevel: null, + targetWordCount: null, + mustAvoid: null, + taskSheet: null, + sceneCards: null, + payoffRefs: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }]), + }, undefined, "defer_and_continue"); + + assert.deepEqual([...deferredChapterIds], ["volume-chapter-missing-without-range"]); +}); + +test("buildVolumeSyncPlan marks deferred execution-contract chapters without changing preview actions", () => { + const volumes = createVolume([{ + id: "volume-chapter-deferred", + volumeId: "volume-1", + chapterOrder: 1, + title: "待修复章节", + summary: "局部质量债务", + purpose: "推进主线", + conflictLevel: null, + revealLevel: null, + targetWordCount: null, + mustAvoid: null, + taskSheet: "不完整任务单", + payoffRefs: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }]); + + const plan = buildVolumeSyncPlan(volumes, [], { + preserveContent: true, + applyDeletes: false, + deferredExecutionContractChapterIds: new Set(["volume-chapter-deferred"]), + }); + + assert.equal(plan.preview.createCount, 1); + assert.equal(plan.creates[0].deferExecutionContract, true); +}); + +test("buildVolumeSyncPlan schedules an unchanged deferred chapter for quality-debt marking", () => { + const chapter = { + id: "volume-chapter-unchanged-deferred", + volumeId: "volume-1", + chapterOrder: 1, + title: "待修复章节", + summary: "局部质量债务", + purpose: "推进主线", + conflictLevel: null, + revealLevel: null, + targetWordCount: null, + mustAvoid: null, + taskSheet: "不完整任务单", + payoffRefs: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + const plan = buildVolumeSyncPlan(createVolume([chapter]), [{ + id: "chapter-1", + order: 1, + title: chapter.title, + content: "已有正文", + generationState: "approved", + chapterStatus: "completed", + expectation: chapter.summary, + targetWordCount: chapter.targetWordCount, + conflictLevel: chapter.conflictLevel, + revealLevel: chapter.revealLevel, + mustAvoid: chapter.mustAvoid, + taskSheet: chapter.taskSheet, + }], { + preserveContent: true, + applyDeletes: false, + deferredExecutionContractChapterIds: new Set([chapter.id]), + }); + + assert.equal(plan.preview.items[0].action, "keep"); + assert.equal(plan.updates[0].deferExecutionContract, true); + assert.equal(plan.updates[0].preserveWorkflowState, true); +}); + +test("buildVolumeSyncPlan preserves a complete database execution contract when volume data is deferred", () => { + const databaseScenePlan = JSON.stringify({ + targetWordCount: 3000, + lengthBudget: { + targetWordCount: 3000, + softMinWordCount: 2550, + softMaxWordCount: 3450, + hardMaxWordCount: 3750, + }, + scenes: [1, 2, 3].map((index) => ({ + key: `scene-${index}`, + title: `场景${index}`, + purpose: "推进冲突", + entryState: "进入场景", + exitState: "离开场景", + targetWordCount: 1000, + })), + }); + const chapter = { + id: "volume-chapter-complete-db-contract", + volumeId: "volume-1", + chapterOrder: 1, + title: "第一章", + summary: "卷纲暂缺执行合同", + purpose: "保留数据库合同", + exclusiveEvent: "主角截获一封密信", + endingState: "密信被锁进暗格", + nextChapterEntryState: "追兵已经逼近", + conflictLevel: null, + revealLevel: null, + targetWordCount: null, + mustAvoid: null, + taskSheet: null, + sceneCards: null, + payoffRefs: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + const plan = buildVolumeSyncPlan(createVolume([chapter]), [{ + id: "chapter-1", + order: 1, + title: chapter.title, + content: "", + generationState: "planned", + chapterStatus: "unplanned", + expectation: chapter.purpose, + targetWordCount: 3000, + conflictLevel: 70, + revealLevel: 20, + mustAvoid: "不要提前揭示真相", + taskSheet: "数据库任务单", + sceneCards: databaseScenePlan, + }], { + preserveContent: true, + applyDeletes: false, + deferredExecutionContractChapterIds: new Set([chapter.id]), + previousVolumes: createVolume([chapter]), + }); + + assert.equal(plan.updates[0].deferExecutionContract, false); + assert.equal(plan.updates[0].preserveExistingExecutionContract, true); +}); + +test("buildVolumeSyncPlan does not mix a changed chapter identity with an old execution contract", () => { + const chapter = { + id: "volume-chapter-changed-identity", + volumeId: "volume-1", + chapterOrder: 1, + title: "新标题", + summary: "新的章节目标", + purpose: "新的章节目标", + conflictLevel: null, + revealLevel: null, + targetWordCount: null, + mustAvoid: null, + taskSheet: null, + sceneCards: null, + payoffRefs: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + const plan = buildVolumeSyncPlan(createVolume([chapter]), [{ + id: "chapter-1", + order: 1, + title: "旧标题", + content: "", + expectation: "旧章节目标", + targetWordCount: 3000, + conflictLevel: 70, + revealLevel: 20, + mustAvoid: "不要提前揭示真相", + taskSheet: "数据库任务单", + sceneCards: JSON.stringify({ + targetWordCount: 3000, + lengthBudget: { + targetWordCount: 3000, + softMinWordCount: 2550, + softMaxWordCount: 3450, + hardMaxWordCount: 3750, + }, + scenes: [1, 2, 3].map((index) => ({ + key: `scene-${index}`, + title: `场景${index}`, + purpose: "推进冲突", + entryState: "进入场景", + exitState: "离开场景", + targetWordCount: 1000, + })), + }), + }], { + preserveContent: true, + applyDeletes: false, + deferredExecutionContractChapterIds: new Set([chapter.id]), + }); + + assert.equal(plan.updates[0].preserveExistingExecutionContract, false); + assert.equal(plan.updates[0].deferExecutionContract, true); +}); diff --git a/shared/types/autoDirectorFollowUp.ts b/shared/types/autoDirectorFollowUp.ts index a5a34e7228..8134c868ca 100644 --- a/shared/types/autoDirectorFollowUp.ts +++ b/shared/types/autoDirectorFollowUp.ts @@ -28,6 +28,7 @@ export type AutoDirectorFollowUpPriority = "P0" | "P1" | "P2"; export type AutoDirectorActionRiskLevel = "low" | "medium" | "high"; export type AutoDirectorMutationActionCode = + | "pause_auto_execution" | "continue_auto_execution" | "continue_generic" | "auto_backfill_structured_outline"