From 7319c797ebf0f99a6eef4b9f2f04372e86aea2d0 Mon Sep 17 00:00:00 2001 From: bighuixiang <472705331@qq.com> Date: Fri, 21 Aug 2026 14:02:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AB=A0=E8=8A=82=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=90=88=E5=90=8C=E7=BC=BA=E5=A4=B1=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E4=B8=8EAI=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题背景:章节执行合同缺失或质量门禁失败时,任务中心只能显示通用失败,用户无法准确定位章节,也无法从任务中心恢复正文生产。\n\n处理内容:\n- 为执行合同质量门禁引入结构化失败信息,保留小说、卷、章节和章序定位。\n- 在任务摘要和任务中心识别合同缺失、待复核和可恢复状态,提供章节处理页与 AI 补齐本章任务单入口。\n- 补齐目标字数与章节合同生成链路的默认和回填逻辑,兼容历史任务。\n- 增加目标字数与任务中心路由测试,确保恢复入口稳定且不依赖错误文本解析。\n\n验证:已完成客户端构建、服务端构建及相关章节合同和任务中心测试。 --- README.md | 7 +- client/src/pages/novels/NovelEdit.tsx | 7 +- .../StructuredChapterDetailCard.tsx | 2 +- client/src/pages/tasks/TaskCenterPage.tsx | 127 +++++++++++++----- .../components/TaskCenterDetailPanel.tsx | 4 +- .../src/pages/tasks/taskCenterUtils.test.mjs | 22 +++ client/src/pages/tasks/taskCenterUtils.ts | 11 ++ docs/releases/release-notes.md | 8 ++ .../chapter-identity-and-planning-boundary.md | 5 + docs/wiki/product/task-center-role.md | 1 + .../novel/director/NovelDirectorService.ts | 11 ++ ...hapterExecutionContractQualityGateError.ts | 32 +++++ .../novel/volume/VolumeChapterSyncService.ts | 9 +- .../novel/volume/volumeGenerationHelpers.ts | 32 +++++ .../volume/volumeGenerationOrchestrator.ts | 35 +++-- .../NovelWorkflowApplicationService.ts | 26 ++-- .../task/adapters/NovelWorkflowTaskAdapter.ts | 36 ++++- .../chapterDetailTargetWordCount.test.js | 77 +++++++++++ 18 files changed, 390 insertions(+), 62 deletions(-) create mode 100644 server/src/services/novel/volume/ChapterExecutionContractQualityGateError.ts create mode 100644 server/tests/chapterDetailTargetWordCount.test.js diff --git a/README.md b/README.md index 1a3ee1969d..1d2c28cca5 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,10 @@ ## 最新更新 -### 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/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..2751412f58 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -4,6 +4,14 @@ ## 更新历史 +### 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..a937f25a6a 100644 --- a/docs/wiki/architecture/chapter-identity-and-planning-boundary.md +++ b/docs/wiki/architecture/chapter-identity-and-planning-boundary.md @@ -24,12 +24,17 @@ `/volumes/sync-chapters` 保留为兼容修复和诊断入口。它的首要职责是修复章节连接和补齐执行入口,不应成为新手主流程的必要步骤。 +章节执行合同在生成场景卡前必须有目标字数。旧章节列表若尚未填写该字段,服务层应优先继承同卷距离最近的已设定字数;全卷都没有字数时,使用标准章节执行预算,并将结果随本次细化保存。用户不应因为历史规划缺少内部预算字段而遇到 500 或被要求先手工补数。 + +执行合同门禁失败时,失败状态必须以结构化字段保留 `novelId`、`volumeId`、`chapterId` 和章序;任务投影据此构造章节定位和补齐入口,不能从面向用户的错误文字反向解析章节。 + ## Failure Modes - 如果规划章节有 `chapterId`,不得因为标题相同而误绑定到其他正式章节。 - 如果正式章节已经有正文,拆章重排或连接修复默认不得清空正文或重置执行状态。 - 如果旧数据没有 `chapterId` 且章序/标题无法可靠匹配,应创建新的正式章节并写回连接,而不是静默保持悬空规划。 - 如果执行合同质量门禁不通过,应阻断连接到章节执行区,并提示具体章节缺少的规划信息。 +- 如果失败任务丢失了章节定位,只能提供节奏 / 拆章的兼容入口和清楚操作提示;不得用字符串匹配猜测目标章节。 ## Related Modules diff --git a/docs/wiki/product/task-center-role.md b/docs/wiki/product/task-center-role.md index 754daf1c29..943db2768f 100644 --- a/docs/wiki/product/task-center-role.md +++ b/docs/wiki/product/task-center-role.md @@ -16,6 +16,7 @@ 4. 新手引导、普通按钮和页面文案应优先引导用户留在当前创作现场;除异常、恢复或历史查询外,不要把“打开运行记录”作为正常下一步。 5. 自动导演与章节质量债的主入口仍属于小说工作台和导演跟进;运行记录只提供事实查询与来源跳转,不重新裁决创作主状态。 6. 运行记录默认采用任务收件箱心智:先展示需要处理、等待操作和正在推进的任务,再展示当前动作与可执行入口。模型、Token、心跳、完整时间、检查点和细分步骤属于诊断信息,应在选中任务后按需展开,不能长期铺在列表中迫使新手读取内部运行字段。 +7. 当任务保存了章节修复定位时,运行记录应提供直达对应章节的补齐入口;执行合同缺失时,入口要明确说明“AI 补齐本章任务单”。旧记录没有章节定位时,可直达节奏 / 拆章并提示用户按任务提示选择章节。运行记录只负责定位和交接,不在此处隐式重试、重规划或修改小说内容。 ## Failure Modes 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/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/VolumeChapterSyncService.ts b/server/src/services/novel/volume/VolumeChapterSyncService.ts index 16297b02f5..c0473efc6b 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; @@ -219,7 +220,13 @@ export class VolumeChapterSyncService { sceneCards: chapter.sceneCards, }); if (!result.canEnterExecution) { - throw new Error(`第 ${chapter.chapterOrder} 章执行合同未通过质量门禁,不能连接到章节执行区。${formatChapterTaskSheetQualityFailure(result)}`); + throw new ChapterExecutionContractQualityGateError({ + novelId: document.novelId, + volumeId: volume.id, + chapterId: chapter.id, + chapterOrder: chapter.chapterOrder, + message: `第 ${chapter.chapterOrder} 章执行合同未通过质量门禁,不能连接到章节执行区。${formatChapterTaskSheetQualityFailure(result)}`, + }); } } } 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/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..d369b0a0bf 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 + ? "当前章节缺少正文生成前的执行信息。打开对应章节后,点击“AI 补齐本章任务单”,系统会补全章节目标、边界和写作约束。" + : needsLegacyChapterExecutionContractReview + ? "章节规划还没有完成同步。打开章节规划后,选择提示中列出的章节,点击“AI 补齐本章任务单”。" : 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/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); +});