From b4e5ba4fcbb87a3b7a23af9554e5cd173aafef29 Mon Sep 17 00:00:00 2001 From: caoty Date: Tue, 28 Apr 2026 18:36:21 +0800 Subject: [PATCH 01/12] fix: stabilize director continuation and quality repair flow --- README.md | 2 +- docs/releases/release-notes.md | 5 +- .../CharacterResourceValidationService.ts | 12 + .../novel/director/NovelDirectorService.ts | 22 +- .../director/autoDirectorValidationService.ts | 60 +++-- ...lDirectorAutoExecutionCheckpointRuntime.ts | 12 +- .../novelDirectorChapterSyncGuards.ts | 35 +++ .../novelDirectorQualityRepairRisk.ts | 31 +-- .../novelDirectorStructuredOutlinePhase.ts | 55 ++++- .../novelDirectorTakeoverExecution.ts | 2 + .../director/novelDirectorTakeoverReset.ts | 20 ++ .../novel/novelCorePipelineService.ts | 18 +- .../services/novel/novelCoreReviewService.ts | 16 -- server/src/services/novel/pipelineJobState.ts | 56 +++-- .../ChapterArtifactBackgroundSyncService.ts | 16 +- .../runtime/ChapterRuntimeCoordinator.ts | 11 +- .../runtime/GenerationContextAssembler.ts | 1 + .../novel/runtime/chapterRuntimeSchema.ts | 13 + .../autoDirectorValidationContract.test.js | 68 ++++++ .../tests/chapterRuntimeCoordinator.test.js | 64 +++++ .../novelDirectorAutoExecutionRuntime.test.js | 12 +- ...rectorStructuredOutlinePersistence.test.js | 173 +++++++++++++ .../tests/novelDirectorTakeoverReset.test.js | 70 ++++++ server/tests/novelPipelineState.test.js | 229 ++++++++++++++++++ server/tests/novelReviewContext.test.js | 70 +++++- server/tests/stateCommitService.test.js | 25 ++ 26 files changed, 965 insertions(+), 133 deletions(-) create mode 100644 server/src/services/novel/director/novelDirectorChapterSyncGuards.ts diff --git a/README.md b/README.md index b133e17c9..d7941db44 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ AI 主驾的章节自动执行和恢复更稳了。继续任务时会按真实 - 章节执行区和节奏规划一致时会保留现有数据,但继续时会补跑范围内最早未执行章节。 - 等待审批的章节批次会按普通继续处理,失败后明确允许跳过审校阻断章时才会跳过当前章。 - 服务重启后,仍在排队或运行中的自动导演会自动进入恢复续跑。 -- 质量修复低风险提醒和重规划建议仍会记录通知并继续推进,非 AI 主驾链路保留人工确认边界。 +- 质量校验不再触发重规划检查点;历史重规划提醒会按普通质量提醒处理,避免自动推进被重规划卡住。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index e7fb98f3b..f6df7d13c 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -10,9 +10,8 @@ - 等待审批的章节批次继续不会再误当成“跳过当前章继续”。系统会区分普通审批继续和失败后允许跳过审校阻断章的恢复动作,减少点击继续后漏掉当前章节的情况。 - 服务重启后,被重启中断的自动导演会自动尝试继续推进。仍在排队或运行中的自动导演会进入恢复续跑,不再默认停到人工恢复列表;等待审批、失败和取消的任务仍保留人工处理边界。 - AI 主驾执行章节时,质量修复后仍低于阈值的低风险提醒会记录通知并继续推进。系统会先完成本章的一次自动修复;如果仍未达标,会提醒用户关注结果,但不再把整条自动执行流程卡在质量修复检查点。 -- AI 主驾遇到重规划建议时会记录提醒并继续后续章节,不会自动执行重规划,也不会因为重规划建议暂停整条流程。提醒内容会明确标记为“重规划提醒已记录”,方便用户后续回看需要人工处理的方向调整。 -- 企业微信、钉钉和自动导演跟进中心会区分“自动通过”和“重规划提醒”。重规划场景不再显示成系统已自动通过或已自动重规划,避免用户误判后续章节已经被重新规划过。 -- 非 AI 主驾的人工审核链路仍会保留重规划检查点。用户选择按阶段确认或手动继续时,重规划建议仍会停在待处理状态,方便先人工确认再推进。 +- 质量校验不再触发重规划。章节审阅、章节流水线和自动导演质量修复都不会因为审计建议生成 `replan_required` 卡点;历史任务里残留的重规划提醒会按普通质量提醒处理,避免后续章节被重规划检查点拦住。 +- 企业微信、钉钉和自动导演跟进中心不再把质量校验结果展示成重规划待处理。用户仍能看到质量修复提醒,但不会误判系统已经自动重规划或还需要先处理重规划才能继续。 ### 2026-04-27 diff --git a/server/src/services/novel/characterResource/CharacterResourceValidationService.ts b/server/src/services/novel/characterResource/CharacterResourceValidationService.ts index 910564f5d..5c9d0123a 100644 --- a/server/src/services/novel/characterResource/CharacterResourceValidationService.ts +++ b/server/src/services/novel/characterResource/CharacterResourceValidationService.ts @@ -9,6 +9,11 @@ import { compactText } from "./characterResourceShared"; const HIGH_RISK_EVENTS = new Set(["lost", "consumed", "destroyed", "damaged"]); const AUTO_DIRECTOR_RESOURCE_SOURCE_TYPES = new Set(["chapter_background_sync"]); +function isAiDriverResourceUpdate(proposal: StateChangeProposal): boolean { + return proposal.sourceType === "chapter_background_sync" + && proposal.sourceStage === "ai_driver_chapter_execution"; +} + function parsePayload(proposal: StateChangeProposal): CharacterResourceUpdatePayload | null { const parsed = characterResourceUpdatePayloadSchema.safeParse(proposal.payload); return parsed.success ? parsed.data : null; @@ -48,6 +53,13 @@ export class CharacterResourceValidationService { || payload.statusAfter === "lost"; if (proposal.riskLevel === "high" || lowConfidence || highImpact) { + if (isAiDriverResourceUpdate(proposal)) { + return { + ...proposal, + status: "committed", + validationNotes: proposal.validationNotes.concat("auto-committed AI-driver resource update"), + }; + } return { ...proposal, status: "pending_review", diff --git a/server/src/services/novel/director/NovelDirectorService.ts b/server/src/services/novel/director/NovelDirectorService.ts index 4c225e8cd..5ba09a431 100644 --- a/server/src/services/novel/director/NovelDirectorService.ts +++ b/server/src/services/novel/director/NovelDirectorService.ts @@ -22,6 +22,7 @@ import type { DirectorTakeoverReadinessResponse, DirectorTakeoverRequest, DirectorTakeoverResponse, + DirectorTakeoverStrategy, } from "@ai-novel/shared/types/novelDirector"; import { BookContractService } from "../BookContractService"; import { CharacterPreparationService } from "../characterPrep/CharacterPreparationService"; @@ -140,6 +141,10 @@ function parseResumeTargetLike(value: unknown) { return null; } +function canTakeoverContinueFromStructuredOutline(step: unknown): boolean { + return step === "chapter_sync" || step === "completed"; +} + function isWorkflowTaskCancelledError(error: unknown): boolean { return error instanceof AppError && error.statusCode === 409 @@ -800,6 +805,7 @@ export class NovelDirectorService { novelId, input: directorInput, startPhase: phase, + takeoverStrategy: "continue_existing", scope: normalizeDirectorMemoryScope({ volumeId: recoveryResumeTarget?.volumeId, chapterId: recoveryResumeTarget?.chapterId, @@ -962,7 +968,8 @@ export class NovelDirectorService { characterCount: takeoverState.snapshot.characterCount, volumeCount: takeoverState.snapshot.volumeCount, hasVolumeStrategyPlan: takeoverState.snapshot.hasVolumeStrategyPlan, - hasStructuredOutline: takeoverState.snapshot.structuredOutlineRecoveryStep === "completed", + hasStructuredOutline: canTakeoverContinueFromStructuredOutline(takeoverState.snapshot.structuredOutlineRecoveryStep) + || Boolean(takeoverState.executableRange), totalChapterCount: takeoverState.snapshot.chapterCount, volumeChapterRanges: takeoverState.snapshot.volumeChapterRanges, structuredOutlineChapterOrders: takeoverState.snapshot.structuredOutlineChapterOrders, @@ -1375,6 +1382,7 @@ export class NovelDirectorService { novelId: string; input: DirectorConfirmRequest; startPhase: "story_macro" | "character_setup" | "volume_strategy" | "structured_outline"; + takeoverStrategy?: DirectorTakeoverStrategy; scope?: string | null; batchAlreadyStartedCount?: number; }) { @@ -1415,7 +1423,9 @@ export class NovelDirectorService { }), batchAlreadyStartedCount: input.batchAlreadyStartedCount, }); - await this.runStructuredOutlinePhase(input.taskId, input.novelId, input.input, volumeWorkspace); + await this.runStructuredOutlinePhase(input.taskId, input.novelId, input.input, volumeWorkspace, { + takeoverStrategy: input.takeoverStrategy, + }); if (this.shouldAutoApproveCheckpoint(input.input, "front10_ready")) { await recordAutoDirectorAutoApprovalFromTask({ taskId: input.taskId, @@ -1444,7 +1454,9 @@ export class NovelDirectorService { }), batchAlreadyStartedCount: input.batchAlreadyStartedCount, }); - await this.runStructuredOutlinePhase(input.taskId, input.novelId, input.input, currentWorkspace); + await this.runStructuredOutlinePhase(input.taskId, input.novelId, input.input, currentWorkspace, { + takeoverStrategy: input.takeoverStrategy, + }); if (this.shouldAutoApproveCheckpoint(input.input, "front10_ready")) { await recordAutoDirectorAutoApprovalFromTask({ taskId: input.taskId, @@ -1594,12 +1606,16 @@ export class NovelDirectorService { novelId: string, input: DirectorConfirmRequest, baseWorkspace: Awaited>, + options: { + takeoverStrategy?: DirectorTakeoverStrategy; + } = {}, ) { await runDirectorStructuredOutlinePhase({ taskId, novelId, request: input, baseWorkspace, + takeoverStrategy: options.takeoverStrategy, dependencies: { workflowService: this.workflowService, novelContextService: this.novelContextService, diff --git a/server/src/services/novel/director/autoDirectorValidationService.ts b/server/src/services/novel/director/autoDirectorValidationService.ts index 30e588740..c37b7919a 100644 --- a/server/src/services/novel/director/autoDirectorValidationService.ts +++ b/server/src/services/novel/director/autoDirectorValidationService.ts @@ -144,6 +144,7 @@ function validateScopeAgainstAssets(input: { entryStep: DirectorTakeoverEntryStep; }): string[] { const reasons: string[] = []; + const affectedScope = input.affectedScope; const totalChapterCount = normalizeChapterOrder(input.assets.totalChapterCount ?? null); const volumeChapterRanges = Array.isArray(input.assets.volumeChapterRanges) ? input.assets.volumeChapterRanges @@ -159,7 +160,38 @@ function validateScopeAgainstAssets(input: { .map((order) => normalizeChapterOrder(order)) .filter((order): order is number => Boolean(order)), ); - const affectedScope = input.affectedScope; + const missingStructuredOutlineOrders: number[] = []; + if (isEntryAtOrAfter(input.entryStep, "chapter") && structuredOutlineChapterOrders.size > 0) { + if (isChapterRangeScope(affectedScope)) { + for (let order = affectedScope.startOrder; order <= affectedScope.endOrder; order += 1) { + if (!structuredOutlineChapterOrders.has(order)) { + missingStructuredOutlineOrders.push(order); + } + } + } + if (isVolumeScope(affectedScope)) { + const range = volumeChapterRanges.find((item) => item.volumeOrder === affectedScope.volumeOrder); + if (range) { + for (let order = range.startOrder; order <= range.endOrder; order += 1) { + if (!structuredOutlineChapterOrders.has(order)) { + missingStructuredOutlineOrders.push(order); + } + } + } + } + } + const structuredOutlineListCoversScope = structuredOutlineChapterOrders.size > 0 + && missingStructuredOutlineOrders.length === 0 + && ( + isChapterRangeScope(affectedScope) + || isVolumeScope(affectedScope) + ); + const getStructuredOutlineMissingMessage = (): string => { + if (structuredOutlineListCoversScope) { + return "目标范围已拆出章节列表,但还没有完成章节细化或同步到执行区,需要先回到节奏 / 拆章补齐执行边界、任务单和场景拆解。"; + } + return "目标范围缺少节奏拆章,需要先完成或重新校验拆章结果。"; + }; if (isChapterRangeScope(affectedScope) && totalChapterCount && affectedScope.endOrder > totalChapterCount) { reasons.push(`目标章节范围超过当前全书规划章节数,请把范围调整到 ${totalChapterCount} 章以内。`); } @@ -187,30 +219,10 @@ function validateScopeAgainstAssets(input: { } } if (isEntryAtOrAfter(input.entryStep, "chapter") && !input.assets.hasStructuredOutline) { - reasons.push("目标范围缺少节奏拆章,需要先完成或重新校验拆章结果。"); + reasons.push(getStructuredOutlineMissingMessage()); } - if (isEntryAtOrAfter(input.entryStep, "chapter") && structuredOutlineChapterOrders.size > 0) { - const missingOrders: number[] = []; - if (isChapterRangeScope(affectedScope)) { - for (let order = affectedScope.startOrder; order <= affectedScope.endOrder; order += 1) { - if (!structuredOutlineChapterOrders.has(order)) { - missingOrders.push(order); - } - } - } - if (isVolumeScope(affectedScope)) { - const range = volumeChapterRanges.find((item) => item.volumeOrder === affectedScope.volumeOrder); - if (range) { - for (let order = range.startOrder; order <= range.endOrder; order += 1) { - if (!structuredOutlineChapterOrders.has(order)) { - missingOrders.push(order); - } - } - } - } - if (missingOrders.length > 0) { - reasons.push(`目标范围缺少节奏拆章明细:第 ${missingOrders.slice(0, 5).join("、")} 章需要先完成或重新校验。`); - } + if (isEntryAtOrAfter(input.entryStep, "chapter") && missingStructuredOutlineOrders.length > 0) { + reasons.push(`目标范围缺少节奏拆章明细:第 ${missingStructuredOutlineOrders.slice(0, 5).join("、")} 章需要先完成或重新校验。`); } return reasons; } diff --git a/server/src/services/novel/director/novelDirectorAutoExecutionCheckpointRuntime.ts b/server/src/services/novel/director/novelDirectorAutoExecutionCheckpointRuntime.ts index 7f6227ab9..4fb332136 100644 --- a/server/src/services/novel/director/novelDirectorAutoExecutionCheckpointRuntime.ts +++ b/server/src/services/novel/director/novelDirectorAutoExecutionCheckpointRuntime.ts @@ -157,6 +157,9 @@ export async function recordQualityRepairCheckpoint( qualityRepairRisk: input.qualityRepairRisk, }; const scopeLabel = buildDirectorAutoExecutionScopeLabelFromState(checkpointState, input.range.totalChapterCount); + const pauseMessage = input.checkpointType === "replan_required" + ? input.pauseMessage + : input.qualityRepairRisk.reason; await deps.workflowService.recordCheckpoint(input.taskId, { stage: "quality_repair", checkpointType: input.checkpointType, @@ -167,7 +170,7 @@ export async function recordQualityRepairCheckpoint( scopeLabel, remainingChapterCount: checkpointState.remainingChapterCount ?? 0, nextChapterOrder: checkpointState.nextChapterOrder ?? null, - failureMessage: input.pauseMessage, + failureMessage: pauseMessage, }), chapterId: checkpointState.nextChapterId ?? input.range.firstChapterId, progress: 0.98, @@ -204,9 +207,8 @@ export async function resolveQualityRepairNoticeAction( checkpointState: DirectorAutoExecutionState; qualityRepairRisk: DirectorQualityRepairRisk; }> { - const checkpointType = input.noticeCode === PIPELINE_REPLAN_NOTICE_CODE - ? "replan_required" - : "chapter_batch_ready"; + const isLegacyReplanNotice = input.noticeCode === PIPELINE_REPLAN_NOTICE_CODE; + const checkpointType = "chapter_batch_ready"; const qualityRepairRisk = buildDirectorQualityRepairRisk({ noticeCode: input.noticeCode, noticeSummary: input.noticeSummary, @@ -226,7 +228,7 @@ export async function resolveQualityRepairNoticeAction( const shouldNotifyAndContinueAiDriverQualityNotice = checkpointType === "chapter_batch_ready" && qualityRepairRisk.autoContinuable && isAiDriverExecution - && hasQualityAlertDetails; + && (hasQualityAlertDetails || isLegacyReplanNotice); const canAutoContinue = checkpointType === "chapter_batch_ready" && qualityRepairRisk.autoContinuable && remainingChapterCount > 0 diff --git a/server/src/services/novel/director/novelDirectorChapterSyncGuards.ts b/server/src/services/novel/director/novelDirectorChapterSyncGuards.ts new file mode 100644 index 000000000..e4291ea9f --- /dev/null +++ b/server/src/services/novel/director/novelDirectorChapterSyncGuards.ts @@ -0,0 +1,35 @@ +import type { VolumePlanDocument } from "@ai-novel/shared/types/novel"; +import { flattenPreparedOutlineChapters } from "./novelDirectorStructuredOutlineRecovery"; + +function normalizeChapterTitle(value: string | null | undefined): string { + return (value ?? "").trim().replace(/\s+/g, " "); +} + +export function executionChapterListMatchesWorkspace(input: { + workspace: VolumePlanDocument; + chapters: Array<{ order: number; title?: string | null }>; + range?: { startOrder: number; endOrder: number } | null; +}): boolean { + const expected = flattenPreparedOutlineChapters(input.workspace) + .filter((chapter) => ( + !input.range + || ( + chapter.chapterOrder >= input.range.startOrder + && chapter.chapterOrder <= input.range.endOrder + ) + )) + .slice() + .sort((left, right) => left.chapterOrder - right.chapterOrder); + const actual = input.chapters + .slice() + .sort((left, right) => left.order - right.order); + if (expected.length === 0 || actual.length !== expected.length) { + return false; + } + return expected.every((chapter, index) => { + const current = actual[index]; + return Boolean(current) + && current.order === chapter.chapterOrder + && normalizeChapterTitle(current.title) === normalizeChapterTitle(chapter.title); + }); +} diff --git a/server/src/services/novel/director/novelDirectorQualityRepairRisk.ts b/server/src/services/novel/director/novelDirectorQualityRepairRisk.ts index 5d4460395..26f190f70 100644 --- a/server/src/services/novel/director/novelDirectorQualityRepairRisk.ts +++ b/server/src/services/novel/director/novelDirectorQualityRepairRisk.ts @@ -25,9 +25,6 @@ function buildReason(input: { affectedChapterCount: number; remainingChapterCount: number; }): string { - if (input.noticeCode === PIPELINE_REPLAN_NOTICE_CODE) { - return "质量检查要求先处理重规划,后续章节需要人工确认后再继续。"; - } if (input.repairMode === "heavy_repair") { return "本次修复属于大范围返工,建议人工确认修复结果后再继续章节执行。"; } @@ -41,34 +38,16 @@ export function buildDirectorQualityRepairRisk( input: DirectorQualityRepairRiskInput, ): DirectorQualityRepairRisk { const payload = parsePipelinePayload(input.payload); - const noticeCode = input.noticeCode?.trim() || null; - const repairMode = payload.repairMode ?? null; - const replanCount = normalizeCount(payload.replanAlertDetails?.length); + const rawNoticeCode = input.noticeCode?.trim() || null; + const isLegacyReplanNotice = rawNoticeCode === PIPELINE_REPLAN_NOTICE_CODE; + const noticeCode = isLegacyReplanNotice ? PIPELINE_QUALITY_NOTICE_CODE : rawNoticeCode; + const repairMode = isLegacyReplanNotice ? null : payload.repairMode ?? null; const qualityCount = normalizeCount(payload.qualityAlertDetails?.length); - const affectedChapterCount = noticeCode === PIPELINE_REPLAN_NOTICE_CODE - ? replanCount - : qualityCount; + const affectedChapterCount = qualityCount; const remainingChapterCount = normalizeCount(input.remainingChapterCount); const totalChapterCount = Math.max(1, normalizeCount(input.totalChapterCount) || remainingChapterCount || 1); const largeScopeThreshold = Math.max(3, Math.ceil(totalChapterCount * 0.25)); - if (noticeCode === PIPELINE_REPLAN_NOTICE_CODE || replanCount > 0) { - return { - riskLevel: "replan", - autoContinuable: false, - reason: buildReason({ - noticeCode: PIPELINE_REPLAN_NOTICE_CODE, - repairMode, - affectedChapterCount: replanCount, - remainingChapterCount, - }), - noticeCode: PIPELINE_REPLAN_NOTICE_CODE, - repairMode, - affectedChapterCount: replanCount, - remainingChapterCount, - }; - } - const isLargeScope = repairMode === "heavy_repair" || affectedChapterCount >= largeScopeThreshold; if (isLargeScope) { return { diff --git a/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts b/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts index 7efa66b5c..013ef6b84 100644 --- a/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts +++ b/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts @@ -2,6 +2,7 @@ import type { VolumePlanDocument } from "@ai-novel/shared/types/novel"; import type { DirectorConfirmRequest, DirectorTaskNotice, + DirectorTakeoverStrategy, } from "@ai-novel/shared/types/novelDirector"; import type { VolumeGenerationPhaseEvent } from "../volume/volumeModels"; import { getChapterTitleDiversityIssue } from "../volume/chapterTitleDiversity"; @@ -33,11 +34,47 @@ import { import { runDirectorTrackedStep } from "./directorProgressTracker"; import type { DirectorPhaseCallbacks, DirectorPhaseDependencies } from "./novelDirectorPhaseTypes"; import { resetDirectorDownstreamChapterState } from "./novelDirectorDownstreamReset"; +import { executionChapterListMatchesWorkspace } from "./novelDirectorChapterSyncGuards"; + +export type StructuredOutlineChapterSyncMode = + | "reset_execution" + | "preserve_matching_execution"; function buildChapterOrderRangeLabel(startOrder: number, endOrder: number): string { return startOrder === endOrder ? `第 ${startOrder} 章` : `第 ${startOrder}-${endOrder} 章`; } +function resolveStructuredOutlineChapterSyncMode(input: { + explicitMode?: StructuredOutlineChapterSyncMode; + takeoverStrategy?: DirectorTakeoverStrategy | null; +}): StructuredOutlineChapterSyncMode { + if (input.explicitMode) { + return input.explicitMode; + } + return input.takeoverStrategy === "continue_existing" + ? "preserve_matching_execution" + : "reset_execution"; +} + +async function shouldPreserveMatchingExecutionChapters(input: { + novelId: string; + workspace: VolumePlanDocument; + syncMode: StructuredOutlineChapterSyncMode; + dependencies: Pick; +}): Promise { + if (input.syncMode !== "preserve_matching_execution") { + return false; + } + const chapters = await input.dependencies.novelContextService.listChapters(input.novelId); + return executionChapterListMatchesWorkspace({ + workspace: input.workspace, + chapters: chapters.map((chapter) => ({ + order: chapter.order, + title: chapter.title, + })), + }); +} + function findMissingSelectedChapterOrders( selectedOrders: number[], range: { startOrder: number; endOrder: number }, @@ -140,10 +177,16 @@ export async function runDirectorStructuredOutlinePhase(input: { novelId: string; request: DirectorConfirmRequest; baseWorkspace: VolumePlanDocument; + chapterSyncMode?: StructuredOutlineChapterSyncMode; + takeoverStrategy?: DirectorTakeoverStrategy; dependencies: DirectorPhaseDependencies; callbacks: DirectorPhaseCallbacks; }): Promise { const { taskId, novelId, request, baseWorkspace, dependencies, callbacks } = input; + const chapterSyncMode = resolveStructuredOutlineChapterSyncMode({ + explicitMode: input.chapterSyncMode, + takeoverStrategy: input.takeoverStrategy, + }); logMemoryUsage({ event: "start", component: "runDirectorStructuredOutlinePhase", @@ -430,9 +473,15 @@ export async function runDirectorStructuredOutlinePhase(input: { entrypoint: "auto_director", }, }); + const preserveMatchingExecutionChapters = await shouldPreserveMatchingExecutionChapters({ + novelId, + workspace: persistedOutlineWorkspace, + syncMode: chapterSyncMode, + dependencies, + }); await dependencies.volumeService.syncVolumeChaptersWithOptions(novelId, { volumes: persistedOutlineWorkspace.volumes, - preserveContent: false, + preserveContent: preserveMatchingExecutionChapters, applyDeletes: true, }, { emitEvent: false, @@ -468,7 +517,9 @@ export async function runDirectorStructuredOutlinePhase(input: { startOrder: selectedChapterOrders[0] ?? 1, endOrder: selectedChapterOrders[selectedChapterOrders.length - 1] ?? selectedChapterOrders[0] ?? 1, }; - await resetDirectorDownstreamChapterState(novelId, downstreamResetRange); + if (!preserveMatchingExecutionChapters) { + await resetDirectorDownstreamChapterState(novelId, downstreamResetRange); + } await callbacks.markDirectorTaskRunning( taskId, diff --git a/server/src/services/novel/director/novelDirectorTakeoverExecution.ts b/server/src/services/novel/director/novelDirectorTakeoverExecution.ts index e27cd2f22..40e2fdef1 100644 --- a/server/src/services/novel/director/novelDirectorTakeoverExecution.ts +++ b/server/src/services/novel/director/novelDirectorTakeoverExecution.ts @@ -81,6 +81,7 @@ interface StartDirectorTakeoverExecutionInput { novelId: string; input: DirectorConfirmRequest; startPhase: "story_macro" | "character_setup" | "volume_strategy" | "structured_outline"; + takeoverStrategy?: "continue_existing" | "restart_current_step"; }) => Promise; assertHighMemoryStartAllowed?: (input: { taskId: string; @@ -354,6 +355,7 @@ export async function startDirectorTakeoverExecution( novelId: input.request.novelId, input: input.directorInput, startPhase: plan.phase ?? plan.startPhase, + takeoverStrategy: selection.strategy, }); }); } else { diff --git a/server/src/services/novel/director/novelDirectorTakeoverReset.ts b/server/src/services/novel/director/novelDirectorTakeoverReset.ts index df70e0348..456387a80 100644 --- a/server/src/services/novel/director/novelDirectorTakeoverReset.ts +++ b/server/src/services/novel/director/novelDirectorTakeoverReset.ts @@ -10,6 +10,7 @@ import { import type { DirectorTakeoverResolvedPlan } from "./novelDirectorTakeover"; import type { DirectorTakeoverLoadedState } from "./novelDirectorTakeoverRuntime"; import { resetDirectorDownstreamChapterState } from "./novelDirectorDownstreamReset"; +import { executionChapterListMatchesWorkspace } from "./novelDirectorChapterSyncGuards"; interface DirectorTakeoverResetDeps { getVolumeWorkspace: (novelId: string) => Promise; @@ -465,5 +466,24 @@ export async function resetDirectorTakeoverDownstreamState(input: { deps: input.deps, }); await cancelActivePipelineJobIfNeeded(input.takeoverState, input.deps); + if (range) { + const [workspace, chapters] = await Promise.all([ + input.deps.getVolumeWorkspace(input.novelId), + prisma.chapter.findMany({ + where: { + novelId: input.novelId, + order: { + gte: range.startOrder, + lte: range.endOrder, + }, + }, + orderBy: { order: "asc" }, + select: { order: true, title: true }, + }), + ]); + if (executionChapterListMatchesWorkspace({ workspace, chapters, range })) { + return; + } + } await resetChapterExecutionOutputs(input.novelId, range); } diff --git a/server/src/services/novel/novelCorePipelineService.ts b/server/src/services/novel/novelCorePipelineService.ts index c934fbfc3..19777ac07 100644 --- a/server/src/services/novel/novelCorePipelineService.ts +++ b/server/src/services/novel/novelCorePipelineService.ts @@ -370,6 +370,7 @@ export class NovelCorePipelineService { provider: options.provider ?? "deepseek", model: options.model ?? "", temperature: options.temperature ?? 0.8, + controlPolicy: options.controlPolicy, workflowTaskId: options.workflowTaskId?.trim() || undefined, taskStyleProfileId: options.taskStyleProfileId?.trim() || undefined, maxRetries: options.maxRetries ?? 1, @@ -552,6 +553,7 @@ export class NovelCorePipelineService { provider: persistedPayload.provider ?? options.provider ?? "deepseek", model: persistedPayload.model ?? options.model ?? "", temperature: persistedPayload.temperature ?? options.temperature ?? 0.8, + controlPolicy: persistedPayload.controlPolicy ?? options.controlPolicy, workflowTaskId: persistedPayload.workflowTaskId ?? options.workflowTaskId, taskStyleProfileId: persistedPayload.taskStyleProfileId ?? options.taskStyleProfileId, maxRetries: persistedPayload.maxRetries ?? options.maxRetries ?? 1, @@ -564,7 +566,6 @@ export class NovelCorePipelineService { }; let totalRetryCount = Math.max(existingJob?.retryCount ?? 0, 0); const qualityAlertDetails = [...(persistedPayload.qualityAlertDetails ?? [])]; - const replanAlertDetails = [...(persistedPayload.replanAlertDetails ?? [])]; try { await runWithLlmUsageTracking({ @@ -677,6 +678,7 @@ export class NovelCorePipelineService { provider: options.provider, model: options.model, temperature: options.temperature, + controlPolicy: runtimePayload.controlPolicy, taskStyleProfileId: runtimePayload.taskStyleProfileId, maxRetries, autoReview: options.autoReview, @@ -711,16 +713,6 @@ export class NovelCorePipelineService { }); } - const replanRecommendation = chapterResult.runtimePackage?.replanRecommendation; - if (replanRecommendation?.recommended) { - const impactedOrders = replanRecommendation.affectedChapterOrders?.length - ? `影响章节=${replanRecommendation.affectedChapterOrders.join(",")}` - : `锚点章节=${replanRecommendation.anchorChapterOrder ?? chapter.order}`; - replanAlertDetails.push( - `第${chapter.order}章需要重规划(${impactedOrders};原因=${replanRecommendation.triggerReason ?? replanRecommendation.reason})`, - ); - } - completed += 1; await this.updateJobSafe(jobId, { completedCount: completed, @@ -730,7 +722,6 @@ export class NovelCorePipelineService { payload: this.stringifyPipelinePayload({ ...runtimePayload, qualityAlertDetails, - replanAlertDetails, }), }); logPipelineInfo("任务进度更新", { @@ -766,7 +757,6 @@ export class NovelCorePipelineService { payload: this.stringifyPipelinePayload({ ...runtimePayload, qualityAlertDetails, - replanAlertDetails, }), }); logPipelineInfo("任务执行结束", { @@ -792,7 +782,6 @@ export class NovelCorePipelineService { payload: this.stringifyPipelinePayload({ ...runtimePayload, qualityAlertDetails, - replanAlertDetails, }), }); void novelEventBus.emit({ @@ -810,7 +799,6 @@ export class NovelCorePipelineService { payload: this.stringifyPipelinePayload({ ...runtimePayload, qualityAlertDetails, - replanAlertDetails, }), }); logPipelineError("任务执行异常", { diff --git a/server/src/services/novel/novelCoreReviewService.ts b/server/src/services/novel/novelCoreReviewService.ts index 9bb427029..f24e8e3c7 100644 --- a/server/src/services/novel/novelCoreReviewService.ts +++ b/server/src/services/novel/novelCoreReviewService.ts @@ -108,22 +108,6 @@ export class NovelCoreReviewService { }, }); await createQualityReport(novelId, chapterId, review.score, review.issues); - const replanRecommendation = plannerService.buildReplanRecommendation({ - auditReports: review.auditReports ?? [], - ledgerSummary: review.contextPackage?.ledgerSummary ?? null, - contextPackage: review.contextPackage ?? null, - }); - if ((review.auditReports?.length ?? 0) > 0 && replanRecommendation.recommended) { - await plannerService.replan(novelId, { - chapterId, - triggerType: "audit_failure", - reason: replanRecommendation.triggerReason || replanRecommendation.reason, - sourceIssueIds: replanRecommendation.blockingIssueIds, - provider: options.provider, - model: options.model, - temperature: options.temperature, - }).catch(() => null); - } return review; } diff --git a/server/src/services/novel/pipelineJobState.ts b/server/src/services/novel/pipelineJobState.ts index 97d76a332..7c1c8842b 100644 --- a/server/src/services/novel/pipelineJobState.ts +++ b/server/src/services/novel/pipelineJobState.ts @@ -102,6 +102,33 @@ function normalizePipelineBackgroundSync(value: unknown): PipelineBackgroundSync return activities.length > 0 ? { activities } : undefined; } +function normalizePipelineControlPolicy(value: unknown): PipelinePayload["controlPolicy"] | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + const kickoffMode = raw.kickoffMode; + const advanceMode = raw.advanceMode; + if ( + (kickoffMode !== "manual_start" && kickoffMode !== "director_start" && kickoffMode !== "takeover_start") + || ( + advanceMode !== "manual" + && advanceMode !== "stage_review" + && advanceMode !== "auto_to_ready" + && advanceMode !== "auto_to_execution" + ) + ) { + return undefined; + } + return { + kickoffMode, + advanceMode, + reviewCheckpoints: Array.isArray(raw.reviewCheckpoints) + ? raw.reviewCheckpoints.filter((item): item is string => typeof item === "string") + : [], + }; +} + export function buildPipelineBackgroundActivityLabels( backgroundSync: PipelineBackgroundSyncState | null | undefined, ): string[] { @@ -176,6 +203,7 @@ export function parsePipelinePayload(payload: string | null | undefined): Pipeli model: typeof parsed.model === "string" ? parsed.model : undefined, temperature: typeof parsed.temperature === "number" ? parsed.temperature : undefined, workflowTaskId: typeof parsed.workflowTaskId === "string" ? parsed.workflowTaskId : undefined, + controlPolicy: normalizePipelineControlPolicy(parsed.controlPolicy), maxRetries: typeof parsed.maxRetries === "number" ? parsed.maxRetries : undefined, runMode: parsed.runMode === "polish" ? "polish" : parsed.runMode === "fast" ? "fast" : undefined, autoReview: typeof parsed.autoReview === "boolean" ? parsed.autoReview : undefined, @@ -192,7 +220,6 @@ export function parsePipelinePayload(payload: string | null | undefined): Pipeli ? parsed.repairMode : undefined, qualityAlertDetails: normalizeStringList(parsed.qualityAlertDetails ?? parsed.failedDetails), - replanAlertDetails: normalizeStringList(parsed.replanAlertDetails), backgroundSync: normalizePipelineBackgroundSync(parsed.backgroundSync), }; } catch { @@ -202,13 +229,13 @@ export function parsePipelinePayload(payload: string | null | undefined): Pipeli export function stringifyPipelinePayload(input: PipelinePayload): string { const qualityAlertDetails = normalizeStringList(input.qualityAlertDetails) ?? []; - const replanAlertDetails = normalizeStringList(input.replanAlertDetails) ?? []; const backgroundSync = normalizePipelineBackgroundSync(input.backgroundSync); return JSON.stringify({ provider: input.provider ?? "deepseek", model: input.model ?? "", temperature: input.temperature ?? 0.8, ...(input.workflowTaskId?.trim() ? { workflowTaskId: input.workflowTaskId.trim() } : {}), + ...(input.controlPolicy ? { controlPolicy: input.controlPolicy } : {}), ...(typeof input.maxRetries === "number" ? { maxRetries: input.maxRetries } : {}), runMode: input.runMode ?? "fast", autoReview: input.autoReview ?? true, @@ -217,7 +244,6 @@ export function stringifyPipelinePayload(input: PipelinePayload): string { qualityThreshold: input.qualityThreshold ?? null, repairMode: input.repairMode ?? "light_repair", ...(qualityAlertDetails.length > 0 ? { qualityAlertDetails } : {}), - ...(replanAlertDetails.length > 0 ? { replanAlertDetails } : {}), ...(backgroundSync?.activities?.length ? { backgroundSync } : {}), }); } @@ -242,32 +268,10 @@ export function getPipelineQualityNotice(details: string[] | undefined): Pipelin }; } -export function getPipelineReplanNotice(details: string[] | undefined): PipelineJobDecorations { - const replanAlertDetails = normalizeStringList(details) ?? []; - if (replanAlertDetails.length === 0) { - return { - displayStatus: null, - noticeCode: null, - noticeSummary: null, - qualityAlertDetails: [], - backgroundActivityLabels: [], - }; - } - return { - displayStatus: "Completed with replan required", - noticeCode: PIPELINE_REPLAN_NOTICE_CODE, - noticeSummary: `State-driven replan is required before continuing: ${replanAlertDetails.join("; ")}`, - qualityAlertDetails: [], - backgroundActivityLabels: [], - }; -} - export function decoratePipelineJob(job: T): DecoratedPipelineJob { const payload = parsePipelinePayload(job.payload); const notice = job.status === "succeeded" - ? (getPipelineReplanNotice(payload.replanAlertDetails).noticeCode - ? getPipelineReplanNotice(payload.replanAlertDetails) - : getPipelineQualityNotice(payload.qualityAlertDetails)) + ? getPipelineQualityNotice(payload.qualityAlertDetails) : { displayStatus: null, noticeCode: null, diff --git a/server/src/services/novel/runtime/ChapterArtifactBackgroundSyncService.ts b/server/src/services/novel/runtime/ChapterArtifactBackgroundSyncService.ts index 5c5e2ca74..09b0d8e52 100644 --- a/server/src/services/novel/runtime/ChapterArtifactBackgroundSyncService.ts +++ b/server/src/services/novel/runtime/ChapterArtifactBackgroundSyncService.ts @@ -38,6 +38,11 @@ function detectChapterChangeFlags(content: string, taskSheet: string | null | un }; } +function isAiDriverPipelinePayload(payload: PipelinePayload): boolean { + return payload.controlPolicy?.kickoffMode === "director_start" + && payload.controlPolicy.advanceMode === "auto_to_execution"; +} + export class ChapterArtifactBackgroundSyncService { private readonly characterDynamicsService = new CharacterDynamicsService(); @@ -85,6 +90,8 @@ export class ChapterArtifactBackgroundSyncService { await Promise.allSettled([stateSyncPromise, dynamicsSyncPromise]); + const isAiDriverSync = await this.hasAiDriverActiveJob(novelId, chapter.order); + const sourceStage = isAiDriverSync ? "ai_driver_chapter_execution" : "chapter_execution"; let characterResourceProposals: StateChangeProposal[] = []; await this.runTrackedActivity(novelId, context, "character_resources", async () => { characterResourceProposals = await characterResourceExtractionService.extractChapterResourceProposals({ @@ -92,7 +99,7 @@ export class ChapterArtifactBackgroundSyncService { chapterId, chapterOrder: chapter.order, sourceType: "chapter_background_sync", - sourceStage: "chapter_execution", + sourceStage, }); }).catch((error) => { console.warn("[chapter-artifact-background-sync] character resource extraction skipped", { @@ -118,7 +125,7 @@ export class ChapterArtifactBackgroundSyncService { chapterId, chapterOrder: chapter.order, sourceType: "chapter_background_sync", - sourceStage: "chapter_execution", + sourceStage, proposals: characterResourceProposals, }); }); @@ -245,6 +252,11 @@ export class ChapterArtifactBackgroundSyncService { }, }); } + + private async hasAiDriverActiveJob(novelId: string, chapterOrder: number): Promise { + const jobRows = await this.findActiveJobsForChapter(novelId, chapterOrder); + return jobRows.some((job) => isAiDriverPipelinePayload(parsePipelinePayload(job.payload))); + } } export const chapterArtifactBackgroundSyncService = new ChapterArtifactBackgroundSyncService(); diff --git a/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts b/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts index 3ae887748..a4394682c 100644 --- a/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts +++ b/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts @@ -183,7 +183,7 @@ export class ChapterRuntimeCoordinator { await this.markChapterStatus(chapterId, "generating"); const assembled = await this.deps.assembler.assemble(novelId, chapterId, request); - this.assertStateDrivenReady(assembled.contextPackage); + this.assertStateDrivenReady(assembled.contextPackage, request); const agentRuntime = this.getAgentRuntime(); let traceRunId: string | null = null; @@ -261,7 +261,7 @@ export class ChapterRuntimeCoordinator { const request = this.deps.validateRequest(options); await this.markChapterStatus(chapterId, "generating"); const assembled = await this.deps.assembler.assemble(novelId, chapterId, request); - this.assertStateDrivenReady(assembled.contextPackage); + this.assertStateDrivenReady(assembled.contextPackage, request); return runPipelineChapterWithRuntime( { validateRequest: () => request, @@ -291,7 +291,12 @@ export class ChapterRuntimeCoordinator { return (this.deps.agentRuntime ?? require("../../../agents").agentRuntime) as AgentRuntimeLike; } - private assertStateDrivenReady(contextPackage: GenerationContextPackage): void { + private assertStateDrivenReady(contextPackage: GenerationContextPackage, request: ChapterRuntimeRequestInput): void { + const isAiDriverExecution = request.controlPolicy?.kickoffMode === "director_start" + && request.controlPolicy.advanceMode === "auto_to_execution"; + if (isAiDriverExecution) { + return; + } if (contextPackage.nextAction === "hold_for_review") { const reasons = [ contextPackage.pendingReviewProposalCount > 0 diff --git a/server/src/services/novel/runtime/GenerationContextAssembler.ts b/server/src/services/novel/runtime/GenerationContextAssembler.ts index c9cd1ec8f..f2509471f 100644 --- a/server/src/services/novel/runtime/GenerationContextAssembler.ts +++ b/server/src/services/novel/runtime/GenerationContextAssembler.ts @@ -402,6 +402,7 @@ export class GenerationContextAssembler { chapterId, chapterOrder: chapter.order, includeCurrentChapterState: false, + policy: request.controlPolicy ?? null, pendingReviewProposalCount, openAuditIssueCount: openAuditIssues.length, hasRepairableDraft: Boolean(chapter.content?.trim()), diff --git a/server/src/services/novel/runtime/chapterRuntimeSchema.ts b/server/src/services/novel/runtime/chapterRuntimeSchema.ts index a72d97f59..7c88cc247 100644 --- a/server/src/services/novel/runtime/chapterRuntimeSchema.ts +++ b/server/src/services/novel/runtime/chapterRuntimeSchema.ts @@ -1,12 +1,25 @@ import { z } from "zod"; import { llmProviderSchema } from "../../../llm/providerSchema"; +const chapterRuntimeControlPolicySchema = z.object({ + kickoffMode: z.enum(["manual_start", "director_start", "takeover_start"]), + advanceMode: z.enum(["manual", "stage_review", "auto_to_ready", "auto_to_execution"]), + reviewCheckpoints: z.array(z.string()).default([]), + autoExecutionRange: z.object({ + mode: z.enum(["front10", "volume", "chapter_range"]), + start: z.number().int().nullable().optional(), + end: z.number().int().nullable().optional(), + volumeOrder: z.number().int().nullable().optional(), + }).nullable().optional(), +}); + export const chapterRuntimeRequestSchema = z.object({ provider: llmProviderSchema.optional(), model: z.string().trim().optional(), temperature: z.number().min(0).max(2).optional(), previousChaptersSummary: z.array(z.string()).optional(), taskStyleProfileId: z.string().trim().optional(), + controlPolicy: chapterRuntimeControlPolicySchema.optional(), }); export type ChapterRuntimeRequestInput = z.infer; diff --git a/server/tests/autoDirectorValidationContract.test.js b/server/tests/autoDirectorValidationContract.test.js index 94515be6a..22aa08a1b 100644 --- a/server/tests/autoDirectorValidationContract.test.js +++ b/server/tests/autoDirectorValidationContract.test.js @@ -169,6 +169,74 @@ test("validateAutoDirectorTakeoverRequest blocks chapter execution when structur assert.match(result.blockingReasons.join("\n"), /节奏拆章|第 5 章/); }); +test("validateAutoDirectorTakeoverRequest accepts chapter execution when structured outline is ready and covers the requested range", () => { + const result = validateAutoDirectorTakeoverRequest({ + source: "takeover", + request: { + novelId: "novel-1", + entryStep: "chapter", + strategy: "continue_existing", + autoExecutionPlan: { + mode: "chapter_range", + startOrder: 1, + endOrder: 2, + }, + }, + assets: { + hasProjectSetup: true, + hasStoryMacroPlan: true, + hasBookContract: true, + characterCount: 3, + volumeCount: 1, + hasVolumeStrategyPlan: true, + hasStructuredOutline: true, + totalChapterCount: 10, + volumeChapterRanges: [ + { volumeOrder: 1, startOrder: 1, endOrder: 10 }, + ], + structuredOutlineChapterOrders: [1, 2], + }, + }); + + assert.equal(result.allowed, true); + assert.equal(result.nextAction, "continue_auto_execution"); +}); + +test("validateAutoDirectorTakeoverRequest reports missing chapter detail instead of missing beat outline when chapter list covers the range", () => { + const result = validateAutoDirectorTakeoverRequest({ + source: "takeover", + request: { + novelId: "novel-1", + entryStep: "chapter", + strategy: "continue_existing", + autoExecutionPlan: { + mode: "chapter_range", + startOrder: 1, + endOrder: 2, + }, + }, + assets: { + hasProjectSetup: true, + hasStoryMacroPlan: true, + hasBookContract: true, + characterCount: 3, + volumeCount: 1, + hasVolumeStrategyPlan: true, + hasStructuredOutline: false, + totalChapterCount: 10, + volumeChapterRanges: [ + { volumeOrder: 1, startOrder: 1, endOrder: 10 }, + ], + structuredOutlineChapterOrders: [1, 2], + }, + }); + + assert.equal(result.allowed, false); + assert.match(result.blockingReasons.join("\n"), /章节列表/); + assert.match(result.blockingReasons.join("\n"), /章节细化|同步到执行区/); + assert.doesNotMatch(result.blockingReasons.join("\n"), /缺少节奏拆章,需要先完成/); +}); + test("validateAutoDirectorTakeoverRequest blocks chapter scope before structured entry", () => { const result = validateAutoDirectorTakeoverRequest({ source: "takeover", diff --git a/server/tests/chapterRuntimeCoordinator.test.js b/server/tests/chapterRuntimeCoordinator.test.js index 35ff20408..bbea74fb9 100644 --- a/server/tests/chapterRuntimeCoordinator.test.js +++ b/server/tests/chapterRuntimeCoordinator.test.js @@ -165,3 +165,67 @@ test("createChapterStream blocks when state-driven decision requires review firs /blocked until review is resolved/i, ); }); + +test("runPipelineChapter lets AI-driver execution continue past pending state proposals", async () => { + const assembled = createAssembledChapter(); + assembled.contextPackage.nextAction = "hold_for_review"; + assembled.contextPackage.pendingReviewProposalCount = 3; + assembled.contextPackage.openAuditIssues = [{ + description: "pending resource proposal", + }]; + let writerCalled = false; + + const coordinator = new ChapterRuntimeCoordinator({ + validateRequest: (input) => input, + ensureNovelCharacters: async () => undefined, + assembler: { + assemble: async () => assembled, + }, + chapterWritingGraph: { + createChapterStream: async () => { + writerCalled = true; + return { + stream: createEmptyStream(), + onDone: async () => ({ finalContent: "正文草稿" }), + }; + }, + }, + artifactSyncService: { + saveDraftAndArtifacts: async () => undefined, + }, + agentRuntime: createAgentRuntime(), + }); + coordinator.markChapterStatus = async () => undefined; + coordinator.finalizeChapterContent = async () => ({ + finalContent: "正文草稿", + runtimePackage: { + audit: { + score: { + coherence: 90, + pacing: 90, + repetition: 0, + engagement: 90, + voice: 90, + overall: 90, + }, + openIssues: [], + reports: [], + hasBlockingIssues: false, + }, + }, + }); + coordinator.markChapterGenerationState = async () => undefined; + + const result = await coordinator.runPipelineChapter("novel-1", "chapter-1", { + controlPolicy: { + kickoffMode: "director_start", + advanceMode: "auto_to_execution", + reviewCheckpoints: ["chapter_batch"], + }, + autoReview: true, + autoRepair: true, + }); + + assert.equal(writerCalled, true); + assert.equal(result.pass, true); +}); diff --git a/server/tests/novelDirectorAutoExecutionRuntime.test.js b/server/tests/novelDirectorAutoExecutionRuntime.test.js index c8493a3ec..4dfe5193b 100644 --- a/server/tests/novelDirectorAutoExecutionRuntime.test.js +++ b/server/tests/novelDirectorAutoExecutionRuntime.test.js @@ -721,7 +721,7 @@ test("runFromReady honors approval selection for low-risk quality repair outside assert.ok(calls.some((call) => call[0] === "recordCheckpoint" && call[2] === "workflow_completed")); }); -test("runFromReady notifies and continues replan notices in AI-driver execution", async () => { +test("runFromReady treats legacy replan notices as quality notices in AI-driver execution", async () => { const calls = []; let phase = "initial"; const runtime = new NovelDirectorAutoExecutionRuntime({ @@ -831,7 +831,7 @@ test("runFromReady notifies and continues replan notices in AI-driver execution" }); assert.equal(calls.some((call) => call[0] === "replanNovel"), false); - assert.ok(calls.some((call) => call[0] === "recordAutoApproval" && call[1] === "replan_required" && call[2] === "replan")); + assert.ok(calls.some((call) => call[0] === "recordAutoApproval" && call[1] === "chapter_batch_ready" && call[2] === "low")); assert.deepEqual(calls.filter((call) => call[0] === "startPipelineJob").map((call) => call.slice(1)), [ [1, 1], [2, 2], @@ -840,7 +840,7 @@ test("runFromReady notifies and continues replan notices in AI-driver execution" assert.ok(calls.some((call) => call[0] === "recordCheckpoint" && call[2] === "workflow_completed")); }); -test("runFromReady records replan_required outside AI-driver execution when pipeline completes with replan notice", async () => { +test("runFromReady records chapter_batch_ready outside AI-driver execution for legacy replan notice", async () => { const calls = []; const runtime = new NovelDirectorAutoExecutionRuntime({ novelContextService: { @@ -920,9 +920,9 @@ test("runFromReady records replan_required outside AI-driver execution when pipe }); assert.equal(calls[5][0], "recordCheckpoint"); - assert.equal(calls[5][2], "replan_required"); - assert.match(String(calls[5][3]), /等待处理重规划建议/); - assert.match(String(calls[5][4]), /replan/i); + assert.equal(calls[5][2], "chapter_batch_ready"); + assert.doesNotMatch(String(calls[5][3]), /重规划/); + assert.doesNotMatch(String(calls[5][4]), /replan|重规划/i); }); test("runFromReady uses the latest auto-execution review toggles instead of stale saved state when starting a new batch", async () => { diff --git a/server/tests/novelDirectorStructuredOutlinePersistence.test.js b/server/tests/novelDirectorStructuredOutlinePersistence.test.js index c8de5f728..25e3a1e78 100644 --- a/server/tests/novelDirectorStructuredOutlinePersistence.test.js +++ b/server/tests/novelDirectorStructuredOutlinePersistence.test.js @@ -309,6 +309,179 @@ test("runDirectorStructuredOutlinePhase persists chapter detail after each compl assert.ok(firstDetailSync[1].sceneCards); }); +test("runDirectorStructuredOutlinePhase preserves matching execution chapter content on continue", async () => { + const originals = { + chapterFindMany: prisma.chapter.findMany, + transaction: prisma.$transaction, + }; + const detailedChapter1 = { + ...createChapter("chapter-1", 1, "Chapter 1"), + beatKey: "opening", + }; + const detailedChapter2 = { + ...createChapter("chapter-2", 2, "Chapter 2"), + beatKey: "opening", + }; + applyCompleteChapterDetail(detailedChapter1); + applyCompleteChapterDetail(detailedChapter2); + + const baseWorkspace = { + novelId: "novel-demo", + workspaceVersion: "v2", + source: "volume", + activeVersionId: "version-1", + derivedOutline: "", + derivedStructuredOutline: "", + readiness: {}, + strategyPlan: null, + critiqueReport: null, + beatSheets: [createBeatSheet()], + rebalanceDecisions: [], + volumes: [ + { + id: "volume-1", + sortOrder: 1, + title: "Volume 1", + summary: "", + openingHook: "", + mainPromise: "", + primaryPressureSource: "", + coreSellingPoint: "", + escalationMode: "", + protagonistChange: "", + midVolumeRisk: "", + climax: "", + payoffType: "", + nextVolumeHook: "", + resetPoint: "", + openPayoffs: [], + status: "draft", + chapters: [detailedChapter1, detailedChapter2], + }, + ], + }; + + const syncCalls = []; + const resetFindManyCalls = []; + let checkpointPayload = null; + let persistedChapters = [ + { + ...mapWorkspaceChapterToExecution(detailedChapter1), + title: detailedChapter1.title, + content: "第一章已经写好的正文", + generationState: "approved", + chapterStatus: "completed", + }, + { + ...mapWorkspaceChapterToExecution(detailedChapter2), + title: detailedChapter2.title, + content: "", + generationState: "planned", + chapterStatus: "unplanned", + }, + ]; + + prisma.chapter.findMany = async (input) => { + resetFindManyCalls.push(input); + return persistedChapters.map((chapter) => ({ id: chapter.id })); + }; + prisma.$transaction = async () => { + throw new Error("matching continue sync should not reset chapter execution content"); + }; + + const volumeService = { + generateVolumes: async (_novelId, options) => clone(options.draftWorkspace), + updateVolumes: async (_novelId, workspace) => clone(workspace), + updateVolumesWithOptions: async (_novelId, workspace) => clone(workspace), + syncVolumeChapters: async (_novelId, input) => { + persistedChapters = input.volumes[0].chapters.map((chapter) => ({ + ...mapWorkspaceChapterToExecution(chapter), + title: chapter.title, + })); + return { creates: [], updates: [], deletes: [] }; + }, + syncVolumeChaptersWithOptions: async (_novelId, input, options) => { + syncCalls.push({ input, options }); + persistedChapters = input.volumes[0].chapters.map((chapter) => { + const existing = persistedChapters.find((item) => item.order === chapter.chapterOrder); + return { + ...mapWorkspaceChapterToExecution(chapter), + title: chapter.title, + content: input.preserveContent ? (existing?.content ?? "") : "", + generationState: input.preserveContent ? (existing?.generationState ?? "planned") : "planned", + chapterStatus: input.preserveContent ? (existing?.chapterStatus ?? "unplanned") : "unplanned", + }; + }); + return { creates: [], updates: [], deletes: [] }; + }, + }; + + const dependencies = { + workflowService: { + bootstrapTask: async () => undefined, + markTaskRunning: async () => undefined, + recordCheckpoint: async (_taskId, input) => { + checkpointPayload = input; + }, + }, + novelContextService: { + listChapters: async () => clone(persistedChapters), + updateNovel: async () => undefined, + }, + characterDynamicsService: { + rebuildDynamics: async () => undefined, + }, + characterPreparationService: {}, + volumeService, + }; + + const callbacks = { + buildDirectorSeedPayload: (_request, novelId, extra) => ({ + novelId, + ...extra, + }), + markDirectorTaskRunning: async () => undefined, + }; + + try { + await runDirectorStructuredOutlinePhase({ + taskId: "task-continue", + novelId: "novel-demo", + request: { + runMode: "auto_to_execution", + provider: "deepseek", + model: "deepseek-chat", + temperature: 0.7, + autoExecutionPlan: { + mode: "chapter_range", + startOrder: 1, + endOrder: 2, + }, + candidate: { + workingTitle: "Demo Novel", + }, + }, + baseWorkspace, + takeoverStrategy: "continue_existing", + dependencies, + callbacks, + }); + } finally { + prisma.chapter.findMany = originals.chapterFindMany; + prisma.$transaction = originals.transaction; + } + + assert.equal(syncCalls.length, 1); + assert.equal(syncCalls[0].input.applyDeletes, true); + assert.equal(syncCalls[0].input.preserveContent, true); + assert.equal(resetFindManyCalls.length, 0); + assert.equal(persistedChapters[0].content, "第一章已经写好的正文"); + assert.equal(checkpointPayload.seedPayload.autoExecution.completedChapterCount, 1); + assert.equal(checkpointPayload.seedPayload.autoExecution.remainingChapterCount, 1); + assert.deepEqual(checkpointPayload.seedPayload.autoExecution.remainingChapterOrders, [2]); + assert.equal(checkpointPayload.seedPayload.autoExecution.nextChapterOrder, 2); +}); + test("runDirectorStructuredOutlinePhase resumes from the next incomplete chapter", async () => { const originals = { chapterFindMany: prisma.chapter.findMany, diff --git a/server/tests/novelDirectorTakeoverReset.test.js b/server/tests/novelDirectorTakeoverReset.test.js index 649185b16..01e4cb010 100644 --- a/server/tests/novelDirectorTakeoverReset.test.js +++ b/server/tests/novelDirectorTakeoverReset.test.js @@ -4,6 +4,7 @@ const assert = require("node:assert/strict"); const { resolveDirectorTakeoverAutoExecutionResetRange, resetDirectorTakeoverCurrentStep, + resetDirectorTakeoverDownstreamState, } = require("../dist/services/novel/director/novelDirectorTakeoverReset.js"); const { prisma } = require("../dist/db/prisma.js"); @@ -79,6 +80,75 @@ test("takeover reset range resolves requested volume from current workspace chap }); }); +test("continue_existing from structured preserves matching execution chapter content", async () => { + const originals = { + chapterFindMany: prisma.chapter.findMany, + transaction: prisma.$transaction, + }; + const findManyCalls = []; + const cancelledJobs = []; + prisma.chapter.findMany = async (input) => { + findManyCalls.push(input); + return [ + { id: "chapter-1", order: 1, title: "Chapter 1", content: "正文1" }, + { id: "chapter-2", order: 2, title: "Chapter 2", content: "" }, + ]; + }; + prisma.$transaction = async () => { + throw new Error("matching continue reset should preserve chapter execution content"); + }; + + try { + await resetDirectorTakeoverDownstreamState({ + novelId: "novel-1", + plan: { + strategy: "continue_existing", + effectiveStep: "structured", + }, + autoExecutionPlan: { + mode: "chapter_range", + startOrder: 1, + endOrder: 2, + }, + takeoverState: { + ...buildTakeoverState(), + activePipelineJob: { id: "pipeline-1", startOrder: 1, endOrder: 2 }, + }, + deps: { + async getVolumeWorkspace() { + return { + volumes: [ + { + id: "volume-1", + sortOrder: 1, + title: "Volume 1", + chapters: [ + { id: "chapter-1", chapterOrder: 1, title: "Chapter 1" }, + { id: "chapter-2", chapterOrder: 2, title: "Chapter 2" }, + ], + }, + ], + beatSheets: [], + }; + }, + async updateVolumeWorkspace() { + throw new Error("continue downstream reset should not rewrite volume workspace"); + }, + async cancelPipelineJob(jobId) { + cancelledJobs.push(jobId); + }, + }, + }); + + assert.deepEqual(cancelledJobs, ["pipeline-1"]); + assert.equal(findManyCalls.length, 1); + assert.deepEqual(findManyCalls[0].where.order, { gte: 1, lte: 2 }); + } finally { + prisma.chapter.findMany = originals.chapterFindMany; + prisma.$transaction = originals.transaction; + } +}); + test("restart_current_step clears structured assets only inside the requested chapter range", async () => { const updates = []; const deletedOrders = []; diff --git a/server/tests/novelPipelineState.test.js b/server/tests/novelPipelineState.test.js index 049856137..870e31837 100644 --- a/server/tests/novelPipelineState.test.js +++ b/server/tests/novelPipelineState.test.js @@ -145,3 +145,232 @@ test("executePipeline preserves persisted quality alerts across resume", async ( novelEventBus.emit = original.emit; } }); + +test("executePipeline keeps director control policy and suppresses replan notices in AI-driver mode", async () => { + const original = { + generationFindUnique: prisma.generationJob.findUnique, + generationUpdate: prisma.generationJob.update, + novelFindUnique: prisma.novel.findUnique, + chapterFindMany: prisma.chapter.findMany, + createQualityReport: reviewService.createQualityReport, + emit: novelEventBus.emit, + }; + + const updates = []; + let capturedRuntimeOptions = null; + prisma.generationJob.findUnique = async (input) => { + if (input.select?.startedAt) { + return { + startedAt: null, + completedCount: 0, + totalCount: 1, + retryCount: 0, + payload: JSON.stringify({ + provider: "openai", + model: "glm-5", + temperature: 0.7, + workflowTaskId: "workflow-1", + runMode: "fast", + autoReview: true, + autoRepair: true, + skipCompleted: true, + qualityThreshold: 75, + repairMode: "light_repair", + controlPolicy: { + kickoffMode: "director_start", + advanceMode: "auto_to_execution", + reviewCheckpoints: ["chapter_batch"], + }, + }), + }; + } + if (input.select?.status) { + return { + status: "running", + cancelRequestedAt: null, + }; + } + throw new Error(`Unexpected generationJob.findUnique call: ${JSON.stringify(input)}`); + }; + prisma.generationJob.update = async (input) => { + updates.push(input); + return input; + }; + prisma.novel.findUnique = async () => ({ + id: "novel-1", + title: "测试小说", + }); + prisma.chapter.findMany = async () => ([ + { id: "chapter-1", order: 1, title: "第一章", content: "" }, + ]); + reviewService.createQualityReport = async () => null; + novelEventBus.emit = async () => null; + + const service = new NovelCorePipelineService(); + service.chapterRuntimeCoordinator.runPipelineChapter = async (_novelId, _chapterId, options) => { + capturedRuntimeOptions = options; + return { + retryCountUsed: 0, + score: { + coherence: 88, + repetition: 8, + pacing: 82, + voice: 80, + engagement: 86, + overall: 84, + }, + issues: [], + pass: true, + reviewExecuted: true, + runtimePackage: { + replanRecommendation: { + recommended: true, + reason: "重规划提醒", + affectedChapterOrders: [2], + anchorChapterOrder: 1, + triggerReason: "overdue payoff", + }, + }, + }; + }; + + try { + await service.executePipeline("job-1", "novel-1", { + startOrder: 1, + endOrder: 1, + provider: "openai", + model: "glm-5", + temperature: 0.7, + runMode: "fast", + autoReview: true, + autoRepair: true, + skipCompleted: true, + qualityThreshold: 75, + repairMode: "light_repair", + }); + + assert.deepEqual(capturedRuntimeOptions.controlPolicy, { + kickoffMode: "director_start", + advanceMode: "auto_to_execution", + reviewCheckpoints: ["chapter_batch"], + }); + const finalUpdate = updates[updates.length - 1]; + assert.equal(finalUpdate.data.status, "succeeded"); + const finalPayload = JSON.parse(finalUpdate.data.payload); + assert.equal(finalPayload.replanAlertDetails, undefined); + } finally { + prisma.generationJob.findUnique = original.generationFindUnique; + prisma.generationJob.update = original.generationUpdate; + prisma.novel.findUnique = original.novelFindUnique; + prisma.chapter.findMany = original.chapterFindMany; + reviewService.createQualityReport = original.createQualityReport; + novelEventBus.emit = original.emit; + } +}); + +test("executePipeline does not persist replan notices from quality review", async () => { + const original = { + generationFindUnique: prisma.generationJob.findUnique, + generationUpdate: prisma.generationJob.update, + novelFindUnique: prisma.novel.findUnique, + chapterFindMany: prisma.chapter.findMany, + createQualityReport: reviewService.createQualityReport, + emit: novelEventBus.emit, + }; + + const updates = []; + prisma.generationJob.findUnique = async (input) => { + if (input.select?.startedAt) { + return { + startedAt: null, + completedCount: 0, + totalCount: 1, + retryCount: 0, + payload: JSON.stringify({ + provider: "deepseek", + model: "deepseek-chat", + temperature: 0.8, + runMode: "fast", + autoReview: true, + autoRepair: true, + skipCompleted: true, + qualityThreshold: 75, + repairMode: "light_repair", + }), + }; + } + if (input.select?.status) { + return { + status: "running", + cancelRequestedAt: null, + }; + } + throw new Error(`Unexpected generationJob.findUnique call: ${JSON.stringify(input)}`); + }; + prisma.generationJob.update = async (input) => { + updates.push(input); + return input; + }; + prisma.novel.findUnique = async () => ({ + id: "novel-1", + title: "测试小说", + }); + prisma.chapter.findMany = async () => ([ + { id: "chapter-1", order: 1, title: "第一章", content: "" }, + ]); + reviewService.createQualityReport = async () => null; + novelEventBus.emit = async () => null; + + const service = new NovelCorePipelineService(); + service.chapterRuntimeCoordinator.runPipelineChapter = async () => ({ + retryCountUsed: 0, + score: { + coherence: 88, + repetition: 8, + pacing: 82, + voice: 80, + engagement: 86, + overall: 84, + }, + issues: [], + pass: true, + reviewExecuted: true, + runtimePackage: { + replanRecommendation: { + recommended: true, + reason: "重规划提醒", + affectedChapterOrders: [2], + anchorChapterOrder: 1, + triggerReason: "overdue payoff", + }, + }, + }); + + try { + await service.executePipeline("job-1", "novel-1", { + startOrder: 1, + endOrder: 1, + provider: "deepseek", + model: "deepseek-chat", + temperature: 0.8, + runMode: "fast", + autoReview: true, + autoRepair: true, + skipCompleted: true, + qualityThreshold: 75, + repairMode: "light_repair", + }); + + const finalUpdate = updates[updates.length - 1]; + assert.equal(finalUpdate.data.status, "succeeded"); + const finalPayload = JSON.parse(finalUpdate.data.payload); + assert.equal(finalPayload.replanAlertDetails, undefined); + } finally { + prisma.generationJob.findUnique = original.generationFindUnique; + prisma.generationJob.update = original.generationUpdate; + prisma.novel.findUnique = original.novelFindUnique; + prisma.chapter.findMany = original.chapterFindMany; + reviewService.createQualityReport = original.createQualityReport; + novelEventBus.emit = original.emit; + } +}); diff --git a/server/tests/novelReviewContext.test.js b/server/tests/novelReviewContext.test.js index f7652db8d..eedf62f2a 100644 --- a/server/tests/novelReviewContext.test.js +++ b/server/tests/novelReviewContext.test.js @@ -167,12 +167,18 @@ function createAssembledContextPackage() { title: "第1章", objective: "推进冲突", expectation: "推进冲突", + targetWordCount: null, planRole: "pressure", hookTarget: "留下下一轮压力", mustAdvance: ["推进冲突"], mustPreserve: ["压迫感"], riskNotes: [], }, + nextAction: "write_chapter", + chapterStateGoal: null, + protectedSecrets: [], + lengthBudget: null, + scenePlan: null, participants: [{ id: "char-1", name: "主角", @@ -290,6 +296,68 @@ test("manual review and manual audit pass assembled chapter review context into } }); +test("manual review does not trigger replanning from audit recommendations", async () => { + const originalChapterFindFirst = prisma.chapter.findFirst; + const originalChapterUpdate = prisma.chapter.update; + const originalQualityReportCreate = prisma.qualityReport.create; + const originalBuildReplanRecommendation = plannerService.buildReplanRecommendation; + const originalReplan = plannerService.replan; + const originalAuditChapter = auditService.auditChapter; + const originalAssemble = GenerationContextAssembler.prototype.assemble; + + let replanCallCount = 0; + prisma.chapter.findFirst = async () => ({ + id: "chapter-1", + title: "第1章", + content: "章节正文", + novel: { title: "测试小说" }, + }); + prisma.chapter.update = async () => null; + prisma.qualityReport.create = async () => null; + plannerService.buildReplanRecommendation = () => ({ + recommended: true, + reason: "存在阻塞问题", + triggerReason: "存在阻塞问题", + blockingIssueIds: ["issue-1"], + }); + plannerService.replan = async () => { + replanCallCount += 1; + return { generatedPlans: [] }; + }; + GenerationContextAssembler.prototype.assemble = async () => ({ + novel: { id: "novel-1", title: "测试小说" }, + chapter: { id: "chapter-1", title: "第1章", order: 1, content: "章节正文", expectation: "推进冲突" }, + contextPackage: createAssembledContextPackage(), + }); + auditService.auditChapter = async () => ({ + score: { + coherence: 85, + repetition: 10, + pacing: 82, + voice: 81, + engagement: 84, + overall: 84, + }, + issues: [], + auditReports: [{ id: "report-1", issues: [] }], + contextPackage: createAssembledContextPackage(), + }); + + try { + const service = new NovelCoreReviewService(); + await service.reviewChapter("novel-1", "chapter-1", {}); + assert.equal(replanCallCount, 0); + } finally { + prisma.chapter.findFirst = originalChapterFindFirst; + prisma.chapter.update = originalChapterUpdate; + prisma.qualityReport.create = originalQualityReportCreate; + plannerService.buildReplanRecommendation = originalBuildReplanRecommendation; + plannerService.replan = originalReplan; + auditService.auditChapter = originalAuditChapter; + GenerationContextAssembler.prototype.assemble = originalAssemble; + } +}); + test("repair stream builds prompt blocks from the assembled repair context package", async () => { const originalNovelFindUnique = prisma.novel.findUnique; const originalChapterFindFirst = prisma.chapter.findFirst; @@ -336,7 +404,7 @@ test("repair stream builds prompt blocks from the assembled repair context packa }); assert.ok(Array.isArray(capturedContextBlocks)); - assert.ok(capturedContextBlocks.some((block) => block.id === "character_dynamics")); + assert.ok(capturedContextBlocks.some((block) => block.id === "chapter_mission")); assert.ok(capturedContextBlocks.some((block) => block.id === "structure_obligations")); assert.ok(capturedContextBlocks.some((block) => block.id === "repair_boundaries")); } finally { diff --git a/server/tests/stateCommitService.test.js b/server/tests/stateCommitService.test.js index 3b06a5363..8321c5793 100644 --- a/server/tests/stateCommitService.test.js +++ b/server/tests/stateCommitService.test.js @@ -142,6 +142,31 @@ test("StateCommitService validate routes risky character resource updates into p assert.match(result.pendingReview[0].validationNotes.join(" "), /low confidence|manual review/); }); +test("StateCommitService validate auto-commits risky AI-driver character resource updates", () => { + const service = new StateCommitService(); + const result = service.validate([ + makeResourceProposal({ + sourceType: "chapter_background_sync", + sourceStage: "ai_driver_chapter_execution", + riskLevel: "high", + payload: { + resourceName: "villain hidden ledger", + narrativeFunction: "hidden_card", + updateType: "destroyed", + statusAfter: "destroyed", + confidence: 0.42, + narrativeImpact: "The villain loses a core blackmail resource.", + }, + }), + ]); + + assert.equal(result.accepted.length, 1); + assert.equal(result.pendingReview.length, 0); + assert.equal(result.rejected.length, 0); + assert.equal(result.accepted[0].status, "committed"); + assert.match(result.accepted[0].validationNotes.join(" "), /auto-committed AI-driver resource update/); +}); + test("StateCommitService validate rejects character resource updates without evidence", () => { const service = new StateCommitService(); const result = service.validate([ From 508a003c4cea12f62887ac26cc942c04fcc0b843 Mon Sep 17 00:00:00 2001 From: caoty Date: Tue, 28 Apr 2026 19:09:25 +0800 Subject: [PATCH 02/12] fix: keep task center listing read-only --- .../src/services/task/RecoveryTaskService.ts | 215 +++++++++++++++--- .../task/adapters/NovelWorkflowTaskAdapter.ts | 40 +--- server/tests/novelWorkflowRuntime.test.js | 74 ++++++ ...velWorkflowTaskAdapterModelBinding.test.js | 4 +- 4 files changed, 262 insertions(+), 71 deletions(-) diff --git a/server/src/services/task/RecoveryTaskService.ts b/server/src/services/task/RecoveryTaskService.ts index 7a596b790..c404ab672 100644 --- a/server/src/services/task/RecoveryTaskService.ts +++ b/server/src/services/task/RecoveryTaskService.ts @@ -2,7 +2,6 @@ import type { RecoverableTaskListResponse, RecoverableTaskSummary, TaskKind, - UnifiedTaskDetail, } from "@ai-novel/shared/types/task"; import { prisma } from "../../db/prisma"; import { AppError } from "../../middleware/errorHandler"; @@ -12,8 +11,13 @@ import { NovelPipelineRuntimeService } from "../novel/NovelPipelineRuntimeServic import { NovelService } from "../novel/NovelService"; import { NovelDirectorService } from "../novel/director/NovelDirectorService"; import { NovelWorkflowRuntimeService } from "../novel/workflow/NovelWorkflowRuntimeService"; +import { + parseResumeTarget, + resumeTargetToRoute, +} from "../novel/workflow/novelWorkflow.shared"; import { styleExtractionTaskService } from "../styleEngine/StyleExtractionTaskService"; -import { taskCenterService } from "./TaskCenterService"; +import { buildWorkflowExplainability } from "./novelWorkflowExplainability"; +import { buildTaskRecoveryHint } from "./taskSupport"; interface RecoveryInitializationDeps { markPendingBookAnalysesForManualRecovery(): Promise; @@ -23,22 +27,22 @@ interface RecoveryInitializationDeps { markPendingStyleTasksForManualRecovery(): Promise; } -function toRecoverableTaskSummary(detail: UnifiedTaskDetail | null): RecoverableTaskSummary | null { - if (!detail || (detail.status !== "queued" && detail.status !== "running")) { +function parseAutoExecutionScopeLabel(seedPayloadJson: string | null | undefined): string | null { + if (!seedPayloadJson?.trim()) { + return null; + } + try { + const parsed = JSON.parse(seedPayloadJson) as { + autoExecution?: { + scopeLabel?: unknown; + }; + }; + return typeof parsed.autoExecution?.scopeLabel === "string" + ? parsed.autoExecution.scopeLabel.trim() || null + : null; + } catch { return null; } - return { - id: detail.id, - kind: detail.kind as RecoverableTaskSummary["kind"], - title: detail.title, - ownerLabel: detail.ownerLabel, - status: detail.status, - currentStage: detail.currentStage, - currentItemLabel: detail.currentItemLabel, - resumeAction: detail.resumeAction, - sourceRoute: detail.sourceRoute, - recoveryHint: detail.lastError?.trim() || detail.recoveryHint, - }; } export class RecoveryTaskService { @@ -92,7 +96,25 @@ export class RecoveryTaskService { status: { in: ["queued", "running"] }, pendingManualRecovery: true, }, - select: { id: true, updatedAt: true }, + select: { + id: true, + title: true, + status: true, + currentStage: true, + currentItemKey: true, + currentItemLabel: true, + checkpointType: true, + resumeTargetJson: true, + seedPayloadJson: true, + lastError: true, + novelId: true, + updatedAt: true, + novel: { + select: { + title: true, + }, + }, + }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], }), prisma.generationJob.findMany({ @@ -100,7 +122,22 @@ export class RecoveryTaskService { status: { in: ["queued", "running"] }, pendingManualRecovery: true, }, - select: { id: true, updatedAt: true }, + select: { + id: true, + novelId: true, + startOrder: true, + endOrder: true, + status: true, + currentStage: true, + currentItemLabel: true, + error: true, + updatedAt: true, + novel: { + select: { + title: true, + }, + }, + }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], }), prisma.bookAnalysis.findMany({ @@ -108,7 +145,21 @@ export class RecoveryTaskService { status: { in: ["queued", "running"] }, pendingManualRecovery: true, }, - select: { id: true, updatedAt: true }, + select: { + id: true, + documentId: true, + title: true, + status: true, + currentStage: true, + currentItemLabel: true, + lastError: true, + updatedAt: true, + document: { + select: { + title: true, + }, + }, + }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], }), prisma.imageGenerationTask.findMany({ @@ -116,7 +167,20 @@ export class RecoveryTaskService { status: { in: ["queued", "running"] }, pendingManualRecovery: true, }, - select: { id: true, updatedAt: true }, + select: { + id: true, + status: true, + baseCharacterId: true, + currentStage: true, + currentItemLabel: true, + error: true, + updatedAt: true, + baseCharacter: { + select: { + name: true, + }, + }, + }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], }), prisma.styleExtractionTask.findMany({ @@ -124,17 +188,109 @@ export class RecoveryTaskService { status: { in: ["queued", "running"] }, pendingManualRecovery: true, }, - select: { id: true, updatedAt: true }, + select: { + id: true, + name: true, + status: true, + currentStage: true, + currentItemLabel: true, + error: true, + updatedAt: true, + }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], }), ]); const rawItems = [ - ...workflowRows.map((row) => ({ kind: "novel_workflow" as const, id: row.id, updatedAt: row.updatedAt })), - ...pipelineRows.map((row) => ({ kind: "novel_pipeline" as const, id: row.id, updatedAt: row.updatedAt })), - ...bookRows.map((row) => ({ kind: "book_analysis" as const, id: row.id, updatedAt: row.updatedAt })), - ...imageRows.map((row) => ({ kind: "image_generation" as const, id: row.id, updatedAt: row.updatedAt })), - ...styleExtractionRows.map((row) => ({ kind: "style_extraction" as const, id: row.id, updatedAt: row.updatedAt })), + ...workflowRows.map((row): RecoverableTaskSummary & { updatedAt: Date } => { + const status = row.status as RecoverableTaskSummary["status"]; + const explainability = buildWorkflowExplainability({ + status, + currentStage: row.currentStage, + currentItemKey: row.currentItemKey, + checkpointType: row.checkpointType as Parameters[0]["checkpointType"], + lastError: row.lastError, + executionScopeLabel: parseAutoExecutionScopeLabel(row.seedPayloadJson), + }); + const resumeTarget = parseResumeTarget(row.resumeTargetJson); + const sourceRoute = resumeTarget + ? resumeTargetToRoute(resumeTarget) + : (row.novelId ? `/novels/${row.novelId}/edit?taskId=${row.id}` : "/tasks"); + return { + id: row.id, + kind: "novel_workflow", + title: row.title, + ownerLabel: row.novel?.title?.trim() || row.title, + status, + currentStage: row.currentStage, + currentItemLabel: row.currentItemLabel, + resumeAction: explainability.resumeAction, + sourceRoute, + recoveryHint: row.lastError?.trim() || buildTaskRecoveryHint("novel_workflow", status), + updatedAt: row.updatedAt, + }; + }), + ...pipelineRows.map((row): RecoverableTaskSummary & { updatedAt: Date } => { + const status = row.status as RecoverableTaskSummary["status"]; + return { + id: row.id, + kind: "novel_pipeline", + title: `${row.novel.title} (${row.startOrder}-${row.endOrder}章)`, + ownerLabel: row.novel.title, + status, + currentStage: row.currentStage, + currentItemLabel: row.currentItemLabel, + sourceRoute: `/novels/${row.novelId}/edit`, + recoveryHint: row.error?.trim() || buildTaskRecoveryHint("novel_pipeline", status), + updatedAt: row.updatedAt, + }; + }), + ...bookRows.map((row): RecoverableTaskSummary & { updatedAt: Date } => { + const status = row.status as RecoverableTaskSummary["status"]; + return { + id: row.id, + kind: "book_analysis", + title: row.title, + ownerLabel: row.document.title, + status, + currentStage: row.currentStage, + currentItemLabel: row.currentItemLabel, + sourceRoute: `/book-analysis?analysisId=${row.id}&documentId=${row.documentId}`, + recoveryHint: row.lastError?.trim() || buildTaskRecoveryHint("book_analysis", status), + updatedAt: row.updatedAt, + }; + }), + ...imageRows.map((row): RecoverableTaskSummary & { updatedAt: Date } => { + const status = row.status as RecoverableTaskSummary["status"]; + const ownerLabel = row.baseCharacter?.name ?? "未关联角色"; + return { + id: row.id, + kind: "image_generation", + title: row.baseCharacter?.name ? `角色图像:${row.baseCharacter.name}` : `图像任务 ${row.id.slice(0, 8)}`, + ownerLabel, + status, + currentStage: row.currentStage, + currentItemLabel: row.currentItemLabel, + sourceRoute: row.baseCharacterId ? `/base-characters?id=${row.baseCharacterId}` : "/base-characters", + recoveryHint: row.error?.trim() || buildTaskRecoveryHint("image_generation", status), + updatedAt: row.updatedAt, + }; + }), + ...styleExtractionRows.map((row): RecoverableTaskSummary & { updatedAt: Date } => { + const status = row.status as RecoverableTaskSummary["status"]; + return { + id: row.id, + kind: "style_extraction", + title: `写法提取:${row.name}`, + ownerLabel: row.name, + status, + currentStage: row.currentStage, + currentItemLabel: row.currentItemLabel, + sourceRoute: "/writing-formula", + recoveryHint: row.error?.trim() || buildTaskRecoveryHint("style_extraction", status), + updatedAt: row.updatedAt, + }; + }), ].sort((left, right) => { const timeDiff = right.updatedAt.getTime() - left.updatedAt.getTime(); if (timeDiff !== 0) { @@ -143,12 +299,7 @@ export class RecoveryTaskService { return right.id.localeCompare(left.id); }); - const items = (await Promise.all( - rawItems.map(async (item) => { - const detail = await taskCenterService.getTaskDetail(item.kind, item.id); - return toRecoverableTaskSummary(detail); - }), - )).filter((item): item is RecoverableTaskSummary => Boolean(item)); + const items = rawItems.map(({ updatedAt: _updatedAt, ...item }) => item); return { items }; } diff --git a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts index b35da72c2..b36f2abad 100644 --- a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts +++ b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts @@ -361,48 +361,12 @@ export class NovelWorkflowTaskAdapter { orderBy: [{ updatedAt: "desc" }, { id: "desc" }], take: input.take, }); - const healed = await Promise.all( - rows.map((row) => this.workflowService.healAutoDirectorTaskState(row.id, row)), - ); - const normalizedRows = healed.some(Boolean) - ? await prisma.novelWorkflowTask.findMany({ - where: { - ...(archivedIds.length - ? { - id: { - notIn: archivedIds, - }, - } - : {}), - lane: "auto_director", - ...(input.status ? { status: input.status } : {}), - ...(input.keyword - ? { - OR: [ - { title: { contains: input.keyword } }, - { id: { contains: input.keyword } }, - { novel: { title: { contains: input.keyword } } }, - ], - } - : {}), - }, - include: { - novel: { - select: { - title: true, - }, - }, - }, - orderBy: [{ updatedAt: "desc" }, { id: "desc" }], - take: input.take, - }) - : rows; - const visibleRows = normalizedRows.filter((row) => { + const visibleRows = rows.filter((row) => { if (row.lane !== "manual_create" || !row.novelId) { return true; } - return !normalizedRows.some((candidate) => + return !rows.some((candidate) => candidate.id !== row.id && candidate.novelId === row.novelId && candidate.lane === "auto_director" diff --git a/server/tests/novelWorkflowRuntime.test.js b/server/tests/novelWorkflowRuntime.test.js index af837dae1..7e914aa58 100644 --- a/server/tests/novelWorkflowRuntime.test.js +++ b/server/tests/novelWorkflowRuntime.test.js @@ -222,3 +222,77 @@ test("startup recovery initialization auto-continues interrupted auto director t ["resume-auto-director"], ]); }); + +test("recovery candidate listing builds lightweight summaries without task details", async () => { + const { RecoveryTaskService } = require("../dist/services/task/RecoveryTaskService.js"); + const { taskCenterService } = require("../dist/services/task/TaskCenterService.js"); + const { prisma } = require("../dist/db/prisma.js"); + const originals = { + workflowFindMany: prisma.novelWorkflowTask.findMany, + pipelineFindMany: prisma.generationJob.findMany, + bookFindMany: prisma.bookAnalysis.findMany, + imageFindMany: prisma.imageGenerationTask.findMany, + styleFindMany: prisma.styleExtractionTask.findMany, + getTaskDetail: taskCenterService.getTaskDetail, + }; + + prisma.novelWorkflowTask.findMany = async () => ([{ + id: "task-recovery-workflow", + title: "AI 自动导演", + status: "running", + currentStage: "节奏 / 拆章", + currentItemKey: "chapter_detail_bundle", + currentItemLabel: "正在细化章节", + checkpointType: null, + resumeTargetJson: JSON.stringify({ + route: "/novels/:id/edit", + novelId: "novel-recovery", + taskId: "task-recovery-workflow", + stage: "structured", + }), + seedPayloadJson: JSON.stringify({ + autoExecution: { + scopeLabel: "全书", + }, + }), + lastError: "服务重启后任务已暂停,等待手动恢复。", + novelId: "novel-recovery", + updatedAt: new Date("2026-04-28T10:00:00.000Z"), + novel: { + title: "恢复测试书", + }, + }]); + prisma.generationJob.findMany = async () => []; + prisma.bookAnalysis.findMany = async () => []; + prisma.imageGenerationTask.findMany = async () => []; + prisma.styleExtractionTask.findMany = async () => []; + taskCenterService.getTaskDetail = async () => { + throw new Error("recovery candidate list must not load task details"); + }; + + try { + const recoveryService = new RecoveryTaskService(); + const result = await recoveryService.listRecoveryCandidates(); + + assert.equal(result.items.length, 1); + assert.deepEqual(result.items[0], { + id: "task-recovery-workflow", + kind: "novel_workflow", + title: "AI 自动导演", + ownerLabel: "恢复测试书", + status: "running", + currentStage: "节奏 / 拆章", + currentItemLabel: "正在细化章节", + resumeAction: "查看当前进度", + sourceRoute: "/novels/novel-recovery/edit?stage=structured&taskId=task-recovery-workflow", + recoveryHint: "服务重启后任务已暂停,等待手动恢复。", + }); + } finally { + prisma.novelWorkflowTask.findMany = originals.workflowFindMany; + prisma.generationJob.findMany = originals.pipelineFindMany; + prisma.bookAnalysis.findMany = originals.bookFindMany; + prisma.imageGenerationTask.findMany = originals.imageFindMany; + prisma.styleExtractionTask.findMany = originals.styleFindMany; + taskCenterService.getTaskDetail = originals.getTaskDetail; + } +}); diff --git a/server/tests/novelWorkflowTaskAdapterModelBinding.test.js b/server/tests/novelWorkflowTaskAdapterModelBinding.test.js index 0ab725854..6fc00c7b0 100644 --- a/server/tests/novelWorkflowTaskAdapterModelBinding.test.js +++ b/server/tests/novelWorkflowTaskAdapterModelBinding.test.js @@ -142,7 +142,9 @@ test("task center list only queries auto director workflow rows", async () => { const adapter = new NovelWorkflowTaskAdapter(); const originalHeal = adapter.workflowService.healAutoDirectorTaskState; - adapter.workflowService.healAutoDirectorTaskState = async () => false; + adapter.workflowService.healAutoDirectorTaskState = async () => { + throw new Error("task list must not heal workflow task state"); + }; try { const list = await adapter.list({ From 1de3dca702a108a5e68a37654152e7082220511f Mon Sep 17 00:00:00 2001 From: caoty Date: Tue, 28 Apr 2026 19:18:50 +0800 Subject: [PATCH 03/12] fix: unblock recovery candidate listing --- README.md | 3 +- docs/releases/release-notes.md | 1 + .../src/services/task/RecoveryTaskService.ts | 2 +- server/tests/novelWorkflowRuntime.test.js | 56 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d7941db44..561fd8b92 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,12 @@ ### 2026-04-28 -AI 主驾的章节自动执行和恢复更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演也会自动尝试续跑,减少人工恢复和跳章风险。 +AI 主驾的章节自动执行、恢复和任务中心加载更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,任务页面也不会被后台恢复流程拖住。 - 章节执行区和节奏规划一致时会保留现有数据,但继续时会补跑范围内最早未执行章节。 - 等待审批的章节批次会按普通继续处理,失败后明确允许跳过审校阻断章时才会跳过当前章。 - 服务重启后,仍在排队或运行中的自动导演会自动进入恢复续跑。 +- 任务中心恢复候选列表不再等待启动恢复流程完成,后台续跑时页面也能更快展示。 - 质量校验不再触发重规划检查点;历史重规划提醒会按普通质量提醒处理,避免自动推进被重规划卡住。 ## 功能预览 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index f6df7d13c..3ca9df8d4 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -9,6 +9,7 @@ - 自动导演继续章节执行时会按真实章节结果重新找最早未完成章节。章节执行区和节奏规划一致时不会清空现有数据,但如果第 5 章已完成、第 6 章未生成、第 7 章曾被误触发,重新继续会先补跑第 6 章,避免跳章。 - 等待审批的章节批次继续不会再误当成“跳过当前章继续”。系统会区分普通审批继续和失败后允许跳过审校阻断章的恢复动作,减少点击继续后漏掉当前章节的情况。 - 服务重启后,被重启中断的自动导演会自动尝试继续推进。仍在排队或运行中的自动导演会进入恢复续跑,不再默认停到人工恢复列表;等待审批、失败和取消的任务仍保留人工处理边界。 +- 任务中心加载恢复候选任务时会直接读取候选摘要,不再等待服务启动后的自动恢复流程全部跑完。即使后台正在续跑被重启中断的自动导演,任务页面也能更快展示。 - AI 主驾执行章节时,质量修复后仍低于阈值的低风险提醒会记录通知并继续推进。系统会先完成本章的一次自动修复;如果仍未达标,会提醒用户关注结果,但不再把整条自动执行流程卡在质量修复检查点。 - 质量校验不再触发重规划。章节审阅、章节流水线和自动导演质量修复都不会因为审计建议生成 `replan_required` 卡点;历史任务里残留的重规划提醒会按普通质量提醒处理,避免后续章节被重规划检查点拦住。 - 企业微信、钉钉和自动导演跟进中心不再把质量校验结果展示成重规划待处理。用户仍能看到质量修复提醒,但不会误判系统已经自动重规划或还需要先处理重规划才能继续。 diff --git a/server/src/services/task/RecoveryTaskService.ts b/server/src/services/task/RecoveryTaskService.ts index c404ab672..97ff86562 100644 --- a/server/src/services/task/RecoveryTaskService.ts +++ b/server/src/services/task/RecoveryTaskService.ts @@ -82,7 +82,6 @@ export class RecoveryTaskService { } async listRecoveryCandidates(): Promise { - await this.waitUntilReady(); const [ workflowRows, pipelineRows, @@ -330,6 +329,7 @@ export class RecoveryTaskService { } async resumeAllRecoveryCandidates(): Promise> { + await this.waitUntilReady(); const { items } = await this.listRecoveryCandidates(); const resumed: Array<{ kind: TaskKind; id: string }> = []; let highMemoryWorkflowStartedCount = 0; diff --git a/server/tests/novelWorkflowRuntime.test.js b/server/tests/novelWorkflowRuntime.test.js index 7e914aa58..c387aeb03 100644 --- a/server/tests/novelWorkflowRuntime.test.js +++ b/server/tests/novelWorkflowRuntime.test.js @@ -296,3 +296,59 @@ test("recovery candidate listing builds lightweight summaries without task detai taskCenterService.getTaskDetail = originals.getTaskDetail; } }); + +test("recovery candidate listing does not wait for startup recovery initialization", async () => { + const { RecoveryTaskService } = require("../dist/services/task/RecoveryTaskService.js"); + const { prisma } = require("../dist/db/prisma.js"); + const originals = { + workflowFindMany: prisma.novelWorkflowTask.findMany, + pipelineFindMany: prisma.generationJob.findMany, + bookFindMany: prisma.bookAnalysis.findMany, + imageFindMany: prisma.imageGenerationTask.findMany, + styleFindMany: prisma.styleExtractionTask.findMany, + }; + + let resumeStarted = false; + const neverResolve = new Promise(() => {}); + prisma.novelWorkflowTask.findMany = async () => []; + prisma.generationJob.findMany = async () => []; + prisma.bookAnalysis.findMany = async () => []; + prisma.imageGenerationTask.findMany = async () => []; + prisma.styleExtractionTask.findMany = async () => []; + + const recoveryService = new RecoveryTaskService( + undefined, + undefined, + undefined, + undefined, + { + async markPendingBookAnalysesForManualRecovery() {}, + async markPendingImageTasksForManualRecovery() {}, + async resumePendingAutoDirectorTasks() { + resumeStarted = true; + return neverResolve; + }, + async markPendingPipelineJobsForManualRecovery() {}, + async markPendingStyleTasksForManualRecovery() {}, + }, + ); + + try { + recoveryService.initializePendingRecoveries(); + await new Promise((resolve) => setImmediate(resolve)); + + const result = await Promise.race([ + recoveryService.listRecoveryCandidates(), + new Promise((_, reject) => setTimeout(() => reject(new Error("listRecoveryCandidates waited for startup recovery")), 50)), + ]); + + assert.equal(resumeStarted, true); + assert.deepEqual(result, { items: [] }); + } finally { + prisma.novelWorkflowTask.findMany = originals.workflowFindMany; + prisma.generationJob.findMany = originals.pipelineFindMany; + prisma.bookAnalysis.findMany = originals.bookFindMany; + prisma.imageGenerationTask.findMany = originals.imageFindMany; + prisma.styleExtractionTask.findMany = originals.styleFindMany; + } +}); From 2d029c0dec332e2a8639ed1394217a4af3fde640 Mon Sep 17 00:00:00 2001 From: caoty Date: Tue, 28 Apr 2026 19:32:06 +0800 Subject: [PATCH 04/12] fix: keep novel list read-only --- README.md | 3 ++- docs/releases/release-notes.md | 1 + server/src/services/novel/novelCoreCrudService.ts | 10 ---------- server/tests/novelListWorkflowSummary.test.js | 3 +++ 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 561fd8b92..2b17ba191 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,12 @@ ### 2026-04-28 -AI 主驾的章节自动执行、恢复和任务中心加载更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,任务页面也不会被后台恢复流程拖住。 +AI 主驾的章节自动执行、恢复和列表加载更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,首页、小说列表和任务页面也不会被后台恢复流程拖住。 - 章节执行区和节奏规划一致时会保留现有数据,但继续时会补跑范围内最早未执行章节。 - 等待审批的章节批次会按普通继续处理,失败后明确允许跳过审校阻断章时才会跳过当前章。 - 服务重启后,仍在排队或运行中的自动导演会自动进入恢复续跑。 +- 首页和小说列表不会在读取项目卡片时触发自动导演状态修复,自动导演任务较多时也能保持轻量加载。 - 任务中心恢复候选列表不再等待启动恢复流程完成,后台续跑时页面也能更快展示。 - 质量校验不再触发重规划检查点;历史重规划提醒会按普通质量提醒处理,避免自动推进被重规划卡住。 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 3ca9df8d4..563f34599 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -10,6 +10,7 @@ - 等待审批的章节批次继续不会再误当成“跳过当前章继续”。系统会区分普通审批继续和失败后允许跳过审校阻断章的恢复动作,减少点击继续后漏掉当前章节的情况。 - 服务重启后,被重启中断的自动导演会自动尝试继续推进。仍在排队或运行中的自动导演会进入恢复续跑,不再默认停到人工恢复列表;等待审批、失败和取消的任务仍保留人工处理边界。 - 任务中心加载恢复候选任务时会直接读取候选摘要,不再等待服务启动后的自动恢复流程全部跑完。即使后台正在续跑被重启中断的自动导演,任务页面也能更快展示。 +- 首页和小说列表读取项目时不会再触发自动导演状态修复。自动导演任务很多或正在后台恢复时,小说卡片仍会显示最近导演摘要,但列表本身会保持轻量加载。 - AI 主驾执行章节时,质量修复后仍低于阈值的低风险提醒会记录通知并继续推进。系统会先完成本章的一次自动修复;如果仍未达标,会提醒用户关注结果,但不再把整条自动执行流程卡在质量修复检查点。 - 质量校验不再触发重规划。章节审阅、章节流水线和自动导演质量修复都不会因为审计建议生成 `replan_required` 卡点;历史任务里残留的重规划提醒会按普通质量提醒处理,避免后续章节被重规划检查点拦住。 - 企业微信、钉钉和自动导演跟进中心不再把质量校验结果展示成重规划待处理。用户仍能看到质量修复提醒,但不会误判系统已经自动重规划或还需要先处理重规划才能继续。 diff --git a/server/src/services/novel/novelCoreCrudService.ts b/server/src/services/novel/novelCoreCrudService.ts index f9647ea8c..8851afaa6 100644 --- a/server/src/services/novel/novelCoreCrudService.ts +++ b/server/src/services/novel/novelCoreCrudService.ts @@ -99,7 +99,6 @@ export class NovelCoreCrudService { private async listLatestVisibleAutoDirectorTasksByNovelIds( novelIds: string[], - allowHealing = true, ): Promise> { const uniqueNovelIds = Array.from(new Set(novelIds.filter((id) => id.trim().length > 0))); if (uniqueNovelIds.length === 0) { @@ -140,15 +139,6 @@ export class NovelCoreCrudService { return new Map(); } - if (allowHealing) { - const healed = await Promise.all( - rows.map((row) => this.workflowService.healAutoDirectorTaskState(row.id, row)), - ); - if (healed.some(Boolean)) { - return this.listLatestVisibleAutoDirectorTasksByNovelIds(uniqueNovelIds, false); - } - } - const archivedTaskIds = await getArchivedTaskIdSet("novel_workflow", rows.map((row) => row.id)); const taskByNovelId = new Map(); for (const row of rows) { diff --git a/server/tests/novelListWorkflowSummary.test.js b/server/tests/novelListWorkflowSummary.test.js index f8f6ec288..16b2e56f1 100644 --- a/server/tests/novelListWorkflowSummary.test.js +++ b/server/tests/novelListWorkflowSummary.test.js @@ -209,6 +209,9 @@ test("listNovels attaches latest visible auto director summary, skips archived t try { const service = new NovelCoreCrudService(); + service.workflowService.healAutoDirectorTaskState = async () => { + throw new Error("listNovels must not heal auto director tasks during read-only listing"); + }; const result = await service.listNovels({ page: 1, limit: 20 }); assert.equal(result.items.length, 2); From 42547102730d673b2bfa881320d1a8156f2b6b5c Mon Sep 17 00:00:00 2001 From: caoty Date: Tue, 28 Apr 2026 20:14:31 +0800 Subject: [PATCH 05/12] fix: keep auto director refresh read-only --- README.md | 4 +- client/src/components/layout/Sidebar.tsx | 6 +- client/src/pages/Home.tsx | 6 +- client/src/pages/novels/NovelList.tsx | 8 +++ client/tests/autoRefreshContracts.test.js | 55 +++++++++++++++ docs/releases/release-notes.md | 2 + server/src/routes/autoDirectorFollowUps.ts | 6 +- server/src/routes/tasks.ts | 3 +- .../task/adapters/NovelWorkflowTaskAdapter.ts | 2 +- .../AutoDirectorFollowUpService.ts | 12 +--- .../tests/autoDirectorFollowUpRoutes.test.js | 4 +- .../tests/autoDirectorFollowUpService.test.js | 15 ++-- ...velWorkflowTaskAdapterModelBinding.test.js | 69 ++++++++++++++++++- 13 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 client/tests/autoRefreshContracts.test.js diff --git a/README.md b/README.md index 2b17ba191..f201b0d31 100644 --- a/README.md +++ b/README.md @@ -126,12 +126,14 @@ ### 2026-04-28 -AI 主驾的章节自动执行、恢复和列表加载更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,首页、小说列表和任务页面也不会被后台恢复流程拖住。 +AI 主驾的章节自动执行、恢复、列表加载和进度同步更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,刷新页面也不会把普通读取变成状态修复。 - 章节执行区和节奏规划一致时会保留现有数据,但继续时会补跑范围内最早未执行章节。 - 等待审批的章节批次会按普通继续处理,失败后明确允许跳过审校阻断章时才会跳过当前章。 - 服务重启后,仍在排队或运行中的自动导演会自动进入恢复续跑。 - 首页和小说列表不会在读取项目卡片时触发自动导演状态修复,自动导演任务较多时也能保持轻量加载。 +- 自动导演跟进中心、任务详情和小说页导演进度的普通刷新保持只读,需要复核或继续时再触发状态修复。 +- 小说列表、首页和任务导航会在自动导演排队、运行或等待审批时持续刷新,后台进度变化会更快同步到页面。 - 任务中心恢复候选列表不再等待启动恢复流程完成,后台续跑时页面也能更快展示。 - 质量校验不再触发重规划检查点;历史重规划提醒会按普通质量提醒处理,避免自动推进被重规划卡住。 diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx index e6924728b..568a831a8 100644 --- a/client/src/components/layout/Sidebar.tsx +++ b/client/src/components/layout/Sidebar.tsx @@ -83,7 +83,11 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { staleTime: 30_000, refetchInterval: (query) => { const overview = query.state.data?.data; - return (overview?.queuedCount ?? 0) > 0 || (overview?.runningCount ?? 0) > 0 ? 4000 : false; + return (overview?.queuedCount ?? 0) > 0 + || (overview?.runningCount ?? 0) > 0 + || (overview?.waitingApprovalCount ?? 0) > 0 + ? 4000 + : false; }, }); diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index d4cffa7f9..afbd6533d 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -108,7 +108,11 @@ export default function Home() { staleTime: 30_000, refetchInterval: (query) => { const overview = query.state.data?.data; - return (overview?.queuedCount ?? 0) > 0 || (overview?.runningCount ?? 0) > 0 ? 4000 : false; + return (overview?.queuedCount ?? 0) > 0 + || (overview?.runningCount ?? 0) > 0 + || (overview?.waitingApprovalCount ?? 0) > 0 + ? 4000 + : false; }, }); diff --git a/client/src/pages/novels/NovelList.tsx b/client/src/pages/novels/NovelList.tsx index fa18b1777..b38a7ac8b 100644 --- a/client/src/pages/novels/NovelList.tsx +++ b/client/src/pages/novels/NovelList.tsx @@ -20,6 +20,7 @@ import { getWorkflowBadge, getWorkflowDescription, isWorkflowRunningInBackground, + LIVE_TASK_STATUSES, requiresCandidateSelection, } from "@/lib/novelWorkflowTaskUi"; import { toast } from "@/components/ui/toast"; @@ -75,6 +76,13 @@ export default function NovelList() { queryKey: queryKeys.novels.list(1, 100), queryFn: () => getNovelList({ page: 1, limit: 100 }), staleTime: 30_000, + refetchInterval: (query) => { + const novels = query.state.data?.data?.items ?? []; + return novels.some((novel) => { + const task = novel.latestAutoDirectorTask; + return Boolean(task && LIVE_TASK_STATUSES.has(task.status)); + }) ? 4000 : false; + }, }); const deleteNovelMutation = useMutation({ diff --git a/client/tests/autoRefreshContracts.test.js b/client/tests/autoRefreshContracts.test.js new file mode 100644 index 000000000..79b8ec8f6 --- /dev/null +++ b/client/tests/autoRefreshContracts.test.js @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const homePage = readFileSync("client/src/pages/Home.tsx", "utf8"); +const novelListPage = readFileSync("client/src/pages/novels/NovelList.tsx", "utf8"); +const taskCenterPage = readFileSync("client/src/pages/tasks/TaskCenterPage.tsx", "utf8"); +const sidebar = readFileSync("client/src/components/layout/Sidebar.tsx", "utf8"); +const novelEditPage = readFileSync("client/src/pages/novels/NovelEdit.tsx", "utf8"); + +function getUseQueryBlock(source, queryKeySnippet) { + const queryKeyIndex = source.indexOf(queryKeySnippet); + assert.notEqual(queryKeyIndex, -1, `${queryKeySnippet} query should exist`); + const blockStart = source.lastIndexOf("useQuery({", queryKeyIndex); + assert.notEqual(blockStart, -1, `${queryKeySnippet} query should use useQuery object syntax`); + + let depth = 0; + for (let index = blockStart; index < source.length; index += 1) { + if (source[index] === "{") { + depth += 1; + } else if (source[index] === "}") { + depth -= 1; + if (depth === 0) { + return source.slice(blockStart, index + 1); + } + } + } + throw new Error(`${queryKeySnippet} query block should be closed`); +} + +test("novel list keeps auto-refreshing while auto director progress is active", () => { + const block = getUseQueryBlock(novelListPage, "queryKeys.novels.list(1, 100)"); + + assert.match(block, /refetchInterval:\s*\(query\)\s*=>/); + assert.match(block, /latestAutoDirectorTask/); + assert.match(block, /LIVE_TASK_STATUSES\.has\(task\.status\)/); +}); + +test("global task overview refresh includes waiting approval work", () => { + const homeTaskBlock = getUseQueryBlock(homePage, "queryKeys.tasks.overview"); + const sidebarTaskBlock = getUseQueryBlock(sidebar, "queryKeys.tasks.overview"); + + assert.match(homeTaskBlock, /waitingApprovalCount/); + assert.match(sidebarTaskBlock, /waitingApprovalCount/); +}); + +test("task center and novel editor keep active auto director detail polling", () => { + const taskListBlock = getUseQueryBlock(taskCenterPage, "queryKeys.tasks.list(listParamsKey)"); + const taskDetailBlock = getUseQueryBlock(taskCenterPage, "queryKeys.tasks.detail(selectedKind ?? \"none\", selectedId ?? \"none\")"); + const autoDirectorBlock = getUseQueryBlock(novelEditPage, "queryKeys.novels.autoDirectorTask(id)"); + + assert.match(taskListBlock, /ACTIVE_STATUSES\.has\(item\.status\)/); + assert.match(taskDetailBlock, /ACTIVE_STATUSES\.has\(task\.status\)/); + assert.match(autoDirectorBlock, /task\.status === "waiting_approval"/); +}); diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 563f34599..7de6dca26 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -11,6 +11,8 @@ - 服务重启后,被重启中断的自动导演会自动尝试继续推进。仍在排队或运行中的自动导演会进入恢复续跑,不再默认停到人工恢复列表;等待审批、失败和取消的任务仍保留人工处理边界。 - 任务中心加载恢复候选任务时会直接读取候选摘要,不再等待服务启动后的自动恢复流程全部跑完。即使后台正在续跑被重启中断的自动导演,任务页面也能更快展示。 - 首页和小说列表读取项目时不会再触发自动导演状态修复。自动导演任务很多或正在后台恢复时,小说卡片仍会显示最近导演摘要,但列表本身会保持轻量加载。 +- 自动导演跟进中心、任务详情和小说页导演进度的普通刷新会保持只读。刷新列表、打开详情或页面轮询不会顺手触发状态修复;需要重新校验时仍可以通过复核、继续、重试等明确动作处理,减少刷新页面时出现长时间等待或网络失败提示。 +- 小说列表、首页和任务导航会在自动导演排队、运行或等待审批时持续刷新。后台章节推进、审批卡点和导演进度变化会更快同步到页面,不需要频繁手动重新加载。 - AI 主驾执行章节时,质量修复后仍低于阈值的低风险提醒会记录通知并继续推进。系统会先完成本章的一次自动修复;如果仍未达标,会提醒用户关注结果,但不再把整条自动执行流程卡在质量修复检查点。 - 质量校验不再触发重规划。章节审阅、章节流水线和自动导演质量修复都不会因为审计建议生成 `replan_required` 卡点;历史任务里残留的重规划提醒会按普通质量提醒处理,避免后续章节被重规划检查点拦住。 - 企业微信、钉钉和自动导演跟进中心不再把质量校验结果展示成重规划待处理。用户仍能看到质量修复提醒,但不会误判系统已经自动重规划或还需要先处理重规划才能继续。 diff --git a/server/src/routes/autoDirectorFollowUps.ts b/server/src/routes/autoDirectorFollowUps.ts index 11eae2edd..63022f7c6 100644 --- a/server/src/routes/autoDirectorFollowUps.ts +++ b/server/src/routes/autoDirectorFollowUps.ts @@ -114,7 +114,9 @@ router.get("/", validate({ query: listQuerySchema }), async (req, res, next) => router.get("/:taskId", validate({ params: taskParamsSchema }), async (req, res, next) => { try { const { taskId } = req.params as z.infer; - const data = await followUpService.getDetail(taskId); + const data = await followUpService.getDetail(taskId, { + heal: false, + }); if (!data) { res.status(404).json({ success: false, @@ -136,7 +138,7 @@ router.get("/:taskId/revalidation", validate({ params: taskParamsSchema }), asyn try { const { taskId } = req.params as z.infer; const data = await followUpService.getDetail(taskId, { - heal: false, + heal: true, }); if (!data) { res.status(404).json({ diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 91ef63ad0..767f826b7 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -118,9 +118,8 @@ router.post("/recovery-candidates/:kind/:id/resume", validate({ params: recovery router.get("/auto-director-follow-ups/:taskId", validate({ params: autoDirectorFollowUpParamsSchema }), async (req, res, next) => { try { const { taskId } = req.params as z.infer; - const readonly = req.query.revalidate === "true"; const data = await autoDirectorFollowUpService.getDetail(taskId, { - heal: !readonly, + heal: req.query.revalidate === "true", }); if (!data) { res.status(404).json({ diff --git a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts index b36f2abad..8b8baf72b 100644 --- a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts +++ b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts @@ -381,7 +381,7 @@ export class NovelWorkflowTaskAdapter { if (await isTaskArchived("novel_workflow", id)) { return null; } - if (options.heal !== false) { + if (options.heal === true) { await this.workflowService.healAutoDirectorTaskState(id); } diff --git a/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpService.ts b/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpService.ts index f04c76594..dd2a5a449 100644 --- a/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpService.ts +++ b/server/src/services/task/autoDirectorFollowUps/AutoDirectorFollowUpService.ts @@ -119,7 +119,8 @@ export class AutoDirectorFollowUpService { return null; } - if (options.heal !== false) { + const shouldHeal = options.heal === true; + if (shouldHeal) { await this.workflowService.healAutoDirectorTaskState(taskId); } @@ -155,7 +156,7 @@ export class AutoDirectorFollowUpService { } const task = await this.workflowTaskAdapter.detail(taskId, { - heal: options.heal, + heal: shouldHeal, }); if (!task) { return null; @@ -242,13 +243,6 @@ export class AutoDirectorFollowUpService { private async loadRows(): Promise { const archivedIds = await getArchivedTaskIds("novel_workflow"); - const rows = await this.fetchRows(archivedIds); - const healed = await Promise.all( - rows.map((row) => this.workflowService.healAutoDirectorTaskState(row.id, row)), - ); - if (!healed.some(Boolean)) { - return rows; - } return this.fetchRows(archivedIds); } diff --git a/server/tests/autoDirectorFollowUpRoutes.test.js b/server/tests/autoDirectorFollowUpRoutes.test.js index fbf2b87d5..7738e77cc 100644 --- a/server/tests/autoDirectorFollowUpRoutes.test.js +++ b/server/tests/autoDirectorFollowUpRoutes.test.js @@ -270,10 +270,12 @@ test("auto director follow-up routes expose overview, list, detail, and action e page: 1, pageSize: 20, }], - ["detail", "task_1", undefined], ["detail", "task_1", { heal: false, }], + ["detail", "task_1", { + heal: true, + }], ["execute", { taskId: "task_1", actionCode: "continue_auto_execution", diff --git a/server/tests/autoDirectorFollowUpService.test.js b/server/tests/autoDirectorFollowUpService.test.js index 8bad3e199..a4e3d5141 100644 --- a/server/tests/autoDirectorFollowUpService.test.js +++ b/server/tests/autoDirectorFollowUpService.test.js @@ -103,7 +103,9 @@ test("auto director follow-up service overview counts actionable rows by reason" const service = new AutoDirectorFollowUpService(); const originalHeal = service.workflowService.healAutoDirectorTaskState; - service.workflowService.healAutoDirectorTaskState = async () => false; + service.workflowService.healAutoDirectorTaskState = async () => { + throw new Error("overview refresh must not heal workflow state"); + }; try { const overview = await service.getOverview(); @@ -189,7 +191,9 @@ test("auto director follow-up service lists recent auto-approved records in auto const service = new AutoDirectorFollowUpService(); const originalHeal = service.workflowService.healAutoDirectorTaskState; - service.workflowService.healAutoDirectorTaskState = async () => false; + service.workflowService.healAutoDirectorTaskState = async () => { + throw new Error("follow-up list refresh must not heal workflow state"); + }; try { const response = await service.list({ @@ -536,7 +540,8 @@ test("auto director follow-up service detail reuses workflow detail and adds fol updatedAt: new Date("2026-04-22T09:20:00.000Z"), }, ]); - NovelWorkflowTaskAdapter.prototype.detail = async function detailMock(taskId) { + NovelWorkflowTaskAdapter.prototype.detail = async function detailMock(taskId, options) { + assert.deepEqual(options, { heal: false }); return { id: taskId, kind: "novel_workflow", @@ -604,7 +609,9 @@ test("auto director follow-up service detail reuses workflow detail and adds fol const service = new AutoDirectorFollowUpService(); const originalHeal = service.workflowService.healAutoDirectorTaskState; - service.workflowService.healAutoDirectorTaskState = async () => false; + service.workflowService.healAutoDirectorTaskState = async () => { + throw new Error("follow-up detail refresh must not heal workflow state"); + }; try { const detail = await service.getDetail("task_detail"); diff --git a/server/tests/novelWorkflowTaskAdapterModelBinding.test.js b/server/tests/novelWorkflowTaskAdapterModelBinding.test.js index 6fc00c7b0..ed32d90cb 100644 --- a/server/tests/novelWorkflowTaskAdapterModelBinding.test.js +++ b/server/tests/novelWorkflowTaskAdapterModelBinding.test.js @@ -52,7 +52,9 @@ test("task detail exposes candidate-stage bound model before directorInput exist const adapter = new NovelWorkflowTaskAdapter(); const originalHeal = adapter.workflowService.healAutoDirectorTaskState; - adapter.workflowService.healAutoDirectorTaskState = async () => false; + adapter.workflowService.healAutoDirectorTaskState = async () => { + throw new Error("task detail refresh must not heal workflow task state"); + }; try { const detail = await adapter.detail("task_candidate_binding"); @@ -70,6 +72,71 @@ test("task detail exposes candidate-stage bound model before directorInput exist } }); +test("task detail only heals workflow state when explicitly requested", async () => { + const originals = { + findUnique: prisma.novelWorkflowTask.findUnique, + }; + const healCalls = []; + + prisma.novelWorkflowTask.findUnique = async () => ({ + id: "task_detail_explicit_heal", + title: "AI 自动导演", + lane: "auto_director", + status: "running", + progress: 0.42, + currentStage: "章节执行", + currentItemKey: "chapter_execution", + currentItemLabel: "正在执行第 6 章", + checkpointType: null, + checkpointSummary: null, + resumeTargetJson: null, + attemptCount: 1, + maxAttempts: 3, + lastError: null, + createdAt: new Date("2026-04-09T09:00:00.000Z"), + updatedAt: new Date("2026-04-09T09:05:00.000Z"), + heartbeatAt: new Date("2026-04-09T09:05:00.000Z"), + promptTokens: 1200, + completionTokens: 600, + totalTokens: 1800, + llmCallCount: 2, + lastTokenRecordedAt: new Date("2026-04-09T09:05:00.000Z"), + novelId: "novel_detail", + novel: { + title: "示例小说", + }, + startedAt: new Date("2026-04-09T09:00:00.000Z"), + finishedAt: null, + cancelRequestedAt: null, + milestonesJson: null, + seedPayloadJson: JSON.stringify({ + provider: "openai", + model: "glm-5", + temperature: 0.7, + }), + }); + + const adapter = new NovelWorkflowTaskAdapter(); + const originalHeal = adapter.workflowService.healAutoDirectorTaskState; + adapter.workflowService.healAutoDirectorTaskState = async (taskId) => { + healCalls.push(taskId); + return false; + }; + + try { + const readOnlyDetail = await adapter.detail("task_detail_explicit_heal"); + assert.ok(readOnlyDetail); + assert.deepEqual(healCalls, []); + + const healedDetail = await adapter.detail("task_detail_explicit_heal", { heal: true }); + assert.ok(healedDetail); + assert.deepEqual(healCalls, ["task_detail_explicit_heal"]); + } finally { + prisma.novelWorkflowTask.findUnique = originals.findUnique; + adapter.workflowService.healAutoDirectorTaskState = originalHeal; + } +}); + test("task center list only queries auto director workflow rows", async () => { const originals = { findMany: prisma.novelWorkflowTask.findMany, From 972c4f63f77817368d0c475ec0e1dbe4b2a46631 Mon Sep 17 00:00:00 2001 From: caoty Date: Tue, 28 Apr 2026 21:33:06 +0800 Subject: [PATCH 06/12] fix: reconcile auto director pipeline recovery --- README.md | 4 +- docs/releases/release-notes.md | 3 + .../novel/director/NovelDirectorService.ts | 25 +++- .../novelDirectorAutoExecutionRuntime.ts | 42 ++++++- .../novel/volume/chapterTitleDiversity.ts | 3 - .../novel/workflow/NovelWorkflowService.ts | 89 ++++++++++++++- .../workflow/autoDirectorStaleTaskRecovery.ts | 10 +- server/tests/autoDirectorMemorySafety.test.js | 18 +++ .../novelDirectorAutoExecutionRuntime.test.js | 96 ++++++++++++++++ server/tests/novelDirectorRetry.test.js | 108 ++++++++++++++++++ ...novelWorkflowRecoveryNormalization.test.js | 103 ++++++++++++++++- .../tests/volumeChapterTitleDiversity.test.js | 55 +++------ 12 files changed, 503 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index f201b0d31..99376e9be 100644 --- a/README.md +++ b/README.md @@ -126,16 +126,18 @@ ### 2026-04-28 -AI 主驾的章节自动执行、恢复、列表加载和进度同步更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,刷新页面也不会把普通读取变成状态修复。 +AI 主驾的章节自动执行、恢复、列表加载和进度同步更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,关联章节流水线需要恢复时也不会再挂成假的运行中。 - 章节执行区和节奏规划一致时会保留现有数据,但继续时会补跑范围内最早未执行章节。 - 等待审批的章节批次会按普通继续处理,失败后明确允许跳过审校阻断章时才会跳过当前章。 - 服务重启后,仍在排队或运行中的自动导演会自动进入恢复续跑。 +- 自动导演关联的章节执行流水线若因重启中断,任务中心会同步显示为可恢复,并在继续时优先接上原任务。 - 首页和小说列表不会在读取项目卡片时触发自动导演状态修复,自动导演任务较多时也能保持轻量加载。 - 自动导演跟进中心、任务详情和小说页导演进度的普通刷新保持只读,需要复核或继续时再触发状态修复。 - 小说列表、首页和任务导航会在自动导演排队、运行或等待审批时持续刷新,后台进度变化会更快同步到页面。 - 任务中心恢复候选列表不再等待启动恢复流程完成,后台续跑时页面也能更快展示。 - 质量校验不再触发重规划检查点;历史重规划提醒会按普通质量提醒处理,避免自动推进被重规划卡住。 +- 历史章节标题结构异常会作为提醒进入恢复处理,新章节列表不会因为标题框架问题被硬阻断。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 7de6dca26..85eb801c3 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -10,12 +10,15 @@ - 等待审批的章节批次继续不会再误当成“跳过当前章继续”。系统会区分普通审批继续和失败后允许跳过审校阻断章的恢复动作,减少点击继续后漏掉当前章节的情况。 - 服务重启后,被重启中断的自动导演会自动尝试继续推进。仍在排队或运行中的自动导演会进入恢复续跑,不再默认停到人工恢复列表;等待审批、失败和取消的任务仍保留人工处理边界。 - 任务中心加载恢复候选任务时会直接读取候选摘要,不再等待服务启动后的自动恢复流程全部跑完。即使后台正在续跑被重启中断的自动导演,任务页面也能更快展示。 +- 自动导演外层任务和章节执行流水线的恢复状态会保持一致。若章节流水线因服务重启进入需恢复状态,任务中心会把自动导演同步显示为可继续处理,不再长期挂成假的“运行中”。 +- 继续自动导演时会优先恢复已关联的章节执行流水线。系统会接上被中断的章节生成、审校或修复任务,而不是因为外层状态仍是运行中就直接返回,减少继续后没有真实推进的情况。 - 首页和小说列表读取项目时不会再触发自动导演状态修复。自动导演任务很多或正在后台恢复时,小说卡片仍会显示最近导演摘要,但列表本身会保持轻量加载。 - 自动导演跟进中心、任务详情和小说页导演进度的普通刷新会保持只读。刷新列表、打开详情或页面轮询不会顺手触发状态修复;需要重新校验时仍可以通过复核、继续、重试等明确动作处理,减少刷新页面时出现长时间等待或网络失败提示。 - 小说列表、首页和任务导航会在自动导演排队、运行或等待审批时持续刷新。后台章节推进、审批卡点和导演进度变化会更快同步到页面,不需要频繁手动重新加载。 - AI 主驾执行章节时,质量修复后仍低于阈值的低风险提醒会记录通知并继续推进。系统会先完成本章的一次自动修复;如果仍未达标,会提醒用户关注结果,但不再把整条自动执行流程卡在质量修复检查点。 - 质量校验不再触发重规划。章节审阅、章节流水线和自动导演质量修复都不会因为审计建议生成 `replan_required` 卡点;历史任务里残留的重规划提醒会按普通质量提醒处理,避免后续章节被重规划检查点拦住。 - 企业微信、钉钉和自动导演跟进中心不再把质量校验结果展示成重规划待处理。用户仍能看到质量修复提醒,但不会误判系统已经自动重规划或还需要先处理重规划才能继续。 +- 历史章节标题结构异常可以被识别为待处理提醒并进入恢复处理。新章节列表仍会通过生成要求引导标题更分散,但不会把标题框架问题作为硬阻断导致自动导演停住。 ### 2026-04-27 diff --git a/server/src/services/novel/director/NovelDirectorService.ts b/server/src/services/novel/director/NovelDirectorService.ts index 5ba09a431..0fc234473 100644 --- a/server/src/services/novel/director/NovelDirectorService.ts +++ b/server/src/services/novel/director/NovelDirectorService.ts @@ -649,6 +649,21 @@ export class NovelDirectorService { } } + private async isLinkedAutoExecutionPipelineWaitingRecovery( + seedPayload: DirectorWorkflowSeedPayload, + ): Promise { + const pipelineJobId = seedPayload.autoExecution?.pipelineJobId?.trim(); + if (!pipelineJobId) { + return false; + } + const pipelineJob = await this.novelService.getPipelineJobById(pipelineJobId).catch(() => null); + return Boolean( + pipelineJob + && pipelineJob.pendingManualRecovery + && (pipelineJob.status === "queued" || pipelineJob.status === "running"), + ); + } + async continueTask(taskId: string, input?: { continuationMode?: DirectorContinuationMode; batchAlreadyStartedCount?: number; @@ -661,11 +676,15 @@ export class NovelDirectorService { await this.workflowService.continueTask(taskId); return; } - if (row.status === "running" && !row.pendingManualRecovery) { - return; - } const seedPayload = parseSeedPayload(row.seedPayloadJson) ?? {}; + if ( + row.status === "running" + && !row.pendingManualRecovery + && !await this.isLinkedAutoExecutionPipelineWaitingRecovery(seedPayload) + ) { + return; + } const directorInput = getDirectorInputFromSeedPayload(seedPayload); const novelId = row.novelId ?? seedPayload.novelId ?? null; const resumedCandidateStage = await this.continueCandidateStageTask(taskId, { diff --git a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts index df50d82fb..f51e58277 100644 --- a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts +++ b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts @@ -95,11 +95,13 @@ interface NovelDirectorAutoExecutionNovelPort { progress: number; currentStage?: string | null; currentItemLabel?: string | null; + pendingManualRecovery?: boolean | null; noticeCode?: string | null; payload?: string | null; noticeSummary?: string | null; error?: string | null; } | null>; + resumePipelineJob?(jobId: string): Promise; cancelPipelineJob(jobId: string): Promise; } @@ -111,7 +113,7 @@ interface NovelDirectorAutoExecutionRuntimeDeps { novelContextService: Pick; novelService: Pick< NovelDirectorAutoExecutionNovelPort, - "startPipelineJob" | "findActivePipelineJobForRange" | "getPipelineJobById" | "cancelPipelineJob" + "startPipelineJob" | "findActivePipelineJobForRange" | "getPipelineJobById" | "resumePipelineJob" | "cancelPipelineJob" >; volumeWorkspaceService?: Pick; workflowService: NovelDirectorAutoExecutionWorkflowPort; @@ -235,6 +237,24 @@ export class NovelDirectorAutoExecutionRuntime { return true; } + private async resumePipelineJobIfWaitingManualRecovery(job: { + id: string; + status: PipelineJobStatus; + pendingManualRecovery?: boolean | null; + }): Promise { + if ( + !job.pendingManualRecovery + || (job.status !== "queued" && job.status !== "running") + ) { + return false; + } + if (!this.deps.novelService.resumePipelineJob) { + throw new Error("章节流水线任务正在等待手动恢复,请先恢复章节流水线。"); + } + await this.deps.novelService.resumePipelineJob(job.id); + return true; + } + async runFromReady(input: { taskId: string; novelId: string; @@ -273,6 +293,8 @@ export class NovelDirectorAutoExecutionRuntime { const existingJob = await this.deps.novelService.getPipelineJobById(pipelineJobId); if (!existingJob || ["failed", "cancelled"].includes(existingJob.status)) { pipelineJobId = ""; + } else { + await this.resumePipelineJobIfWaitingManualRecovery(existingJob); } } @@ -391,6 +413,24 @@ export class NovelDirectorAutoExecutionRuntime { if (!job) { throw new Error("自动执行章节批次时未能找到对应的批量任务。"); } + if (await this.resumePipelineJobIfWaitingManualRecovery(job)) { + ({ range, autoExecution } = await this.resolveRangeAndState({ + novelId: input.novelId, + existingState: autoExecution, + pipelineJobId, + pipelineStatus: job.status, + })); + await syncAutoExecutionTaskState(this.deps, { + taskId: input.taskId, + novelId: input.novelId, + request: input.request, + range, + autoExecution, + isBackgroundRunning: true, + resumeStage: "pipeline", + }); + continue; + } if (job.status === "queued" || job.status === "running") { const runningState = resolveDirectorAutoExecutionWorkflowState(job, range, autoExecution); await this.deps.workflowService.markTaskRunning(input.taskId, { diff --git a/server/src/services/novel/volume/chapterTitleDiversity.ts b/server/src/services/novel/volume/chapterTitleDiversity.ts index 27cd452cc..8985a7736 100644 --- a/server/src/services/novel/volume/chapterTitleDiversity.ts +++ b/server/src/services/novel/volume/chapterTitleDiversity.ts @@ -149,9 +149,6 @@ export function getChapterTitleDiversityIssue(titles: string[]): string | null { } export function isChapterTitleDiversityIssue(message: string | null | undefined): boolean { - if (!ENABLE_CHAPTER_TITLE_DIVERSITY_VALIDATION) { - return false; - } const normalized = message?.trim(); if (!normalized) { return false; diff --git a/server/src/services/novel/workflow/NovelWorkflowService.ts b/server/src/services/novel/workflow/NovelWorkflowService.ts index 698357ab6..9f4abcaad 100644 --- a/server/src/services/novel/workflow/NovelWorkflowService.ts +++ b/server/src/services/novel/workflow/NovelWorkflowService.ts @@ -273,6 +273,29 @@ function isStructuredOutlineItemKey(itemKey: string | null | undefined): boolean || itemKey === "chapter_detail_bundle"; } +function getAutoExecutionPipelineJobId(seedPayloadJson: string | null | undefined): string | null { + const seedPayload = parseSeedPayload(seedPayloadJson); + const pipelineJobId = seedPayload?.autoExecution?.pipelineJobId?.trim(); + return pipelineJobId || null; +} + +function withAutoExecutionPipelineStatus( + seedPayloadJson: string | null | undefined, + pipelineStatus: "queued" | "running", +): string | null | undefined { + const seedPayload = parseSeedPayload(seedPayloadJson); + if (!seedPayload?.autoExecution?.pipelineJobId?.trim()) { + return seedPayloadJson; + } + return JSON.stringify({ + ...seedPayload, + autoExecution: { + ...seedPayload.autoExecution, + pipelineStatus, + }, + }); +} + export class NovelWorkflowService { private readonly volumeService = new NovelVolumeService(); @@ -689,8 +712,11 @@ export class NovelWorkflowService { const front10Healed = await this.healHistoricalAutoDirectorFront10RecoveryFailure(taskId, normalizedRow); const titleDiversityHealed = await this.healChapterTitleDiversitySoftFailure(taskId, normalizedRow); const structuredOutlineHealed = await this.healStaleAutoDirectorStructuredOutlineProgress(taskId, normalizedRow); - const staleRunningHealed = await this.healStaleAutoDirectorRunningTask(taskId, normalizedRow); - const checkpointRow = (brokenSeedHealed || queuedHealed || historicalHealed || front10Healed || titleDiversityHealed || structuredOutlineHealed || staleRunningHealed) + const linkedPipelineRecoveryHealed = await this.healAutoDirectorLinkedPipelineRecovery(taskId, normalizedRow); + const staleRunningHealed = linkedPipelineRecoveryHealed + ? false + : await this.healStaleAutoDirectorRunningTask(taskId, normalizedRow); + const checkpointRow = (brokenSeedHealed || queuedHealed || historicalHealed || front10Healed || titleDiversityHealed || structuredOutlineHealed || linkedPipelineRecoveryHealed || staleRunningHealed) ? await this.getVisibleRowByIdRaw(taskId) : (normalizedRow ?? await this.getVisibleRowByIdRaw(taskId)); const checkpointHealed = isChapterBatchCheckpointRow(checkpointRow) @@ -699,7 +725,64 @@ export class NovelWorkflowService { row: checkpointRow, }) : false; - return brokenSeedHealed || queuedHealed || historicalHealed || front10Healed || titleDiversityHealed || structuredOutlineHealed || staleRunningHealed || checkpointHealed; + return brokenSeedHealed || queuedHealed || historicalHealed || front10Healed || titleDiversityHealed || structuredOutlineHealed || linkedPipelineRecoveryHealed || staleRunningHealed || checkpointHealed; + } + + async healAutoDirectorLinkedPipelineRecovery( + taskId: string, + row = null as { + lane?: string | null; + status?: string | null; + pendingManualRecovery?: boolean | null; + cancelRequestedAt?: Date | null; + seedPayloadJson?: string | null; + } | null, + ): Promise { + const candidate = row ?? await this.getVisibleRowByIdRaw(taskId); + if ( + !candidate + || candidate.lane !== "auto_director" + || candidate.pendingManualRecovery + || isTaskCancellationRequested(candidate) + || (candidate.status !== "queued" && candidate.status !== "running") + ) { + return false; + } + const pipelineJobId = getAutoExecutionPipelineJobId(candidate.seedPayloadJson); + if (!pipelineJobId) { + return false; + } + + const pipelineJob = await prisma.generationJob.findUnique({ + where: { id: pipelineJobId }, + select: { + id: true, + status: true, + pendingManualRecovery: true, + error: true, + }, + }); + if ( + !pipelineJob + || !pipelineJob.pendingManualRecovery + || (pipelineJob.status !== "queued" && pipelineJob.status !== "running") + ) { + return false; + } + + await this.updateTaskWithRetry({ + where: { id: taskId }, + data: { + status: "queued", + pendingManualRecovery: true, + heartbeatAt: null, + finishedAt: null, + cancelRequestedAt: null, + lastError: pipelineJob.error?.trim() || "章节流水线任务已暂停,等待手动恢复。", + seedPayloadJson: withAutoExecutionPipelineStatus(candidate.seedPayloadJson, pipelineJob.status), + }, + }); + return true; } async healStaleAutoDirectorRunningTask( diff --git a/server/src/services/novel/workflow/autoDirectorStaleTaskRecovery.ts b/server/src/services/novel/workflow/autoDirectorStaleTaskRecovery.ts index 9fd47160d..e5b510134 100644 --- a/server/src/services/novel/workflow/autoDirectorStaleTaskRecovery.ts +++ b/server/src/services/novel/workflow/autoDirectorStaleTaskRecovery.ts @@ -14,6 +14,14 @@ function isStructuredOutlineItemKey(itemKey: string | null | undefined): boolean || itemKey === "chapter_detail_bundle"; } +function isAutoExecutionItemKey(itemKey: string | null | undefined): boolean { + return itemKey === "chapter_execution" || itemKey === "quality_repair"; +} + +function isRecoverableRunningItemKey(itemKey: string | null | undefined): boolean { + return isStructuredOutlineItemKey(itemKey) || isAutoExecutionItemKey(itemKey); +} + function resolveLastActivityAt(row: { heartbeatAt?: Date | null; updatedAt?: Date | null; @@ -38,7 +46,7 @@ export function isStaleAutoDirectorRunningTask( || row.status !== "running" || row.pendingManualRecovery || row.cancelRequestedAt - || !isStructuredOutlineItemKey(row.currentItemKey) + || !isRecoverableRunningItemKey(row.currentItemKey) ) { return false; } diff --git a/server/tests/autoDirectorMemorySafety.test.js b/server/tests/autoDirectorMemorySafety.test.js index f04adc3f3..2357176bc 100644 --- a/server/tests/autoDirectorMemorySafety.test.js +++ b/server/tests/autoDirectorMemorySafety.test.js @@ -329,6 +329,24 @@ test("isStaleAutoDirectorRunningTask marks old running structured-outline tasks assert.equal(fresh, false); }); +test("isStaleAutoDirectorRunningTask also covers stale auto-execution stages", () => { + const now = new Date("2026-04-25T12:00:00.000Z"); + + for (const currentItemKey of ["chapter_execution", "quality_repair"]) { + const stale = isStaleAutoDirectorRunningTask({ + status: "running", + lane: "auto_director", + currentItemKey, + heartbeatAt: new Date("2026-04-25T08:00:00.000Z"), + updatedAt: new Date("2026-04-25T08:00:00.000Z"), + pendingManualRecovery: false, + cancelRequestedAt: null, + }, now); + + assert.equal(stale, true); + } +}); + test("resolveHighMemoryVolumeGenerationKey covers direct volume generation high-memory scopes", () => { assert.equal(isHighMemoryVolumeGeneration({ scope: "chapter_list" }), true); assert.equal(isHighMemoryVolumeGeneration({ scope: "strategy" }), false); diff --git a/server/tests/novelDirectorAutoExecutionRuntime.test.js b/server/tests/novelDirectorAutoExecutionRuntime.test.js index 4dfe5193b..0175fcab1 100644 --- a/server/tests/novelDirectorAutoExecutionRuntime.test.js +++ b/server/tests/novelDirectorAutoExecutionRuntime.test.js @@ -290,6 +290,102 @@ test("runFromReady reuses an existing active range job before starting a new pip ]); }); +test("runFromReady resumes an existing pipeline job that is waiting for manual recovery", async () => { + const calls = []; + let lookupCount = 0; + let pipelineCompleted = false; + const runtime = new NovelDirectorAutoExecutionRuntime({ + novelContextService: { + async listChapters() { + return [ + withExecutionDetail({ id: "chapter-1", order: 1, generationState: pipelineCompleted ? "approved" : "draft" }), + withExecutionDetail({ id: "chapter-2", order: 2, generationState: pipelineCompleted ? "approved" : "draft" }), + ]; + }, + }, + novelService: { + async startPipelineJob() { + calls.push(["startPipelineJob"]); + throw new Error("should not start a new pipeline job"); + }, + async findActivePipelineJobForRange(novelId, startOrder, endOrder, preferredJobId) { + calls.push(["findActivePipelineJobForRange", novelId, startOrder, endOrder, preferredJobId]); + return { id: "job-paused", status: "running" }; + }, + async getPipelineJobById(jobId) { + calls.push(["getPipelineJobById", jobId]); + lookupCount += 1; + if (lookupCount === 1) { + return { + id: jobId, + status: "queued", + progress: 0, + currentStage: "queued", + currentItemLabel: null, + pendingManualRecovery: true, + error: "服务重启后任务已暂停,等待手动恢复。", + }; + } + pipelineCompleted = true; + return { + id: jobId, + status: "succeeded", + progress: 1, + currentStage: null, + currentItemLabel: null, + pendingManualRecovery: false, + error: null, + }; + }, + async resumePipelineJob(jobId) { + calls.push(["resumePipelineJob", jobId]); + }, + async cancelPipelineJob() { + calls.push(["cancelPipelineJob"]); + }, + }, + workflowService: { + async bootstrapTask(input) { + calls.push(["bootstrapTask", input.seedPayload.autoExecution.pipelineJobId, input.seedPayload.autoExecution.pipelineStatus]); + }, + async getTaskById() { + return { status: "running" }; + }, + async markTaskRunning() { + calls.push(["markTaskRunning"]); + }, + async recordCheckpoint(taskId, input) { + calls.push(["recordCheckpoint", taskId, input.seedPayload.autoExecution.pipelineJobId, input.seedPayload.autoExecution.pipelineStatus]); + }, + async markTaskFailed() { + calls.push(["markTaskFailed"]); + }, + }, + buildDirectorSeedPayload(_request, _novelId, extra) { + return extra ?? {}; + }, + }); + + await runtime.runFromReady({ + taskId: "task-auto-exec", + novelId: "novel-1", + request: buildRequest(), + existingState: { + enabled: true, + firstChapterId: "chapter-1", + startOrder: 1, + endOrder: 2, + totalChapterCount: 2, + pipelineJobId: "job-paused", + pipelineStatus: "queued", + }, + existingPipelineJobId: "job-paused", + }); + + assert.ok(calls.some((call) => call[0] === "resumePipelineJob" && call[1] === "job-paused")); + assert.equal(calls.some((call) => call[0] === "startPipelineJob"), false); +}); + test("runFromReady records a normal checkpoint when pipeline completes with quality notices", async () => { const calls = []; const runtime = new NovelDirectorAutoExecutionRuntime({ diff --git a/server/tests/novelDirectorRetry.test.js b/server/tests/novelDirectorRetry.test.js index 01bf347ee..ee62e6a23 100644 --- a/server/tests/novelDirectorRetry.test.js +++ b/server/tests/novelDirectorRetry.test.js @@ -627,6 +627,114 @@ test("continueTask resumes auto execution in the background instead of blocking } }); +test("continueTask resumes a running auto director task when its linked pipeline is waiting for recovery", async () => { + const service = new NovelDirectorService(); + const originalContinueCandidateStageTask = service.continueCandidateStageTask; + const originalGetTaskById = service.workflowService.getTaskById; + const originalResolveAssetFirstRecovery = service.resolveAssetFirstRecovery; + const originalGetPipelineJobById = service.novelService.getPipelineJobById; + const originalMarkTaskRunning = service.workflowService.markTaskRunning; + const originalScheduleBackgroundRun = service.scheduleBackgroundRun; + const originalRunFromReady = service.autoExecutionRuntime.runFromReady; + const pipelineLookups = []; + const runningCalls = []; + const scheduledRuns = []; + const runtimeCalls = []; + + service.continueCandidateStageTask = async () => false; + service.resolveAssetFirstRecovery = async () => ({ + type: "auto_execution", + resumeCheckpointType: "chapter_batch_ready", + }); + service.workflowService.getTaskById = async () => ({ + id: "task_parent_running_pipeline_paused", + lane: "auto_director", + status: "running", + pendingManualRecovery: false, + novelId: "novel_parent_running_pipeline_paused", + checkpointType: "chapter_batch_ready", + currentItemKey: "quality_repair", + resumeTargetJson: JSON.stringify({ + stage: "pipeline", + chapterId: "chapter-4", + }), + lastError: null, + seedPayloadJson: JSON.stringify({ + directorInput: buildDirectorInput({ + workflowTaskId: "task_parent_running_pipeline_paused", + runMode: "auto_to_execution", + }), + directorSession: { + runMode: "auto_to_execution", + phase: "front10_ready", + isBackgroundRunning: true, + lockedScopes: ["chapter", "pipeline"], + reviewScope: null, + }, + autoExecution: { + enabled: true, + mode: "front10", + startOrder: 1, + endOrder: 10, + totalChapterCount: 10, + nextChapterId: "chapter-4", + nextChapterOrder: 4, + pipelineJobId: "pipeline_waiting_recovery", + pipelineStatus: "queued", + }, + }), + }); + service.novelService.getPipelineJobById = async (jobId) => { + pipelineLookups.push(jobId); + return { + id: jobId, + status: "queued", + progress: 0.4, + currentStage: "queued", + currentItemLabel: null, + pendingManualRecovery: true, + error: "服务重启后任务已暂停,等待手动恢复。", + }; + }; + service.workflowService.markTaskRunning = async (taskId, input) => { + runningCalls.push({ taskId, ...input }); + return null; + }; + service.scheduleBackgroundRun = (taskId, runner) => { + scheduledRuns.push({ taskId, runner }); + }; + service.autoExecutionRuntime.runFromReady = async (input) => { + runtimeCalls.push(input); + }; + + try { + await service.continueTask("task_parent_running_pipeline_paused", { + continuationMode: "auto_execute_front10", + }); + + assert.deepEqual(pipelineLookups, ["pipeline_waiting_recovery"]); + assert.equal(runningCalls.length, 1); + assert.equal(runningCalls[0].stage, "chapter_execution"); + assert.equal(runningCalls[0].itemKey, "chapter_execution"); + assert.equal(scheduledRuns.length, 1); + assert.equal(runtimeCalls.length, 0); + + await scheduledRuns[0].runner(); + + assert.equal(runtimeCalls.length, 1); + assert.equal(runtimeCalls[0].existingPipelineJobId, "pipeline_waiting_recovery"); + assert.equal(runtimeCalls[0].resumeCheckpointType, "chapter_batch_ready"); + } finally { + service.continueCandidateStageTask = originalContinueCandidateStageTask; + service.workflowService.getTaskById = originalGetTaskById; + service.resolveAssetFirstRecovery = originalResolveAssetFirstRecovery; + service.novelService.getPipelineJobById = originalGetPipelineJobById; + service.workflowService.markTaskRunning = originalMarkTaskRunning; + service.scheduleBackgroundRun = originalScheduleBackgroundRun; + service.autoExecutionRuntime.runFromReady = originalRunFromReady; + } +}); + test("continueTask does not skip the current chapter when approving a waiting auto-execution checkpoint", async () => { const service = new NovelDirectorService(); const originalContinueCandidateStageTask = service.continueCandidateStageTask; diff --git a/server/tests/novelWorkflowRecoveryNormalization.test.js b/server/tests/novelWorkflowRecoveryNormalization.test.js index 00c405c0d..11e4d1c8c 100644 --- a/server/tests/novelWorkflowRecoveryNormalization.test.js +++ b/server/tests/novelWorkflowRecoveryNormalization.test.js @@ -102,9 +102,9 @@ test("healAutoDirectorTaskState also reconciles chapter batch checkpoints when t prisma.novelWorkflowTask.findUnique = async () => currentRow; prisma.chapter.findMany = async () => [ - { id: "chapter-1", order: 1, generationState: "approved" }, - { id: "chapter-2", order: 2, generationState: "reviewed" }, - { id: "chapter-3", order: 3, generationState: "approved" }, + { id: "chapter-1", order: 1, content: "正文内容", generationState: "approved" }, + { id: "chapter-2", order: 2, content: "", generationState: "reviewed", chapterStatus: "pending_review" }, + { id: "chapter-3", order: 3, content: "正文内容", generationState: "approved" }, ]; prisma.novelWorkflowTask.update = async ({ data }) => { currentRow = { @@ -242,6 +242,103 @@ test("healAutoDirectorTaskState revives front10 auto execution tasks that only f } }); +test("healAutoDirectorTaskState marks running auto director tasks recoverable when the linked pipeline paused", async () => { + const originals = { + findUnique: prisma.novelWorkflowTask.findUnique, + generationJobFindUnique: prisma.generationJob.findUnique, + update: prisma.novelWorkflowTask.update, + }; + + let currentRow = { + id: "task_pipeline_paused", + title: "示例项目", + novelId: "novel_demo", + lane: "auto_director", + status: "running", + pendingManualRecovery: false, + progress: 0.972, + currentStage: "质量修复", + currentItemKey: "quality_repair", + currentItemLabel: "正在自动审校前 10 章 · 第 4/10 章 · 示例章节", + checkpointType: null, + checkpointSummary: null, + resumeTargetJson: JSON.stringify({ + route: "/novels/novel_demo/edit", + stage: "pipeline", + novelId: "novel_demo", + taskId: "task_pipeline_paused", + chapterId: "chapter-4", + volumeId: null, + }), + seedPayloadJson: JSON.stringify({ + autoExecution: { + enabled: true, + mode: "front10", + firstChapterId: "chapter-1", + startOrder: 1, + endOrder: 10, + totalChapterCount: 10, + nextChapterId: "chapter-4", + nextChapterOrder: 4, + pipelineJobId: "job-paused", + pipelineStatus: "running", + }, + }), + lastError: null, + finishedAt: null, + heartbeatAt: new Date("2026-04-28T12:39:17.000Z"), + cancelRequestedAt: null, + milestonesJson: null, + }; + + prisma.novelWorkflowTask.findUnique = async () => currentRow; + prisma.generationJob.findUnique = async () => ({ + id: "job-paused", + status: "queued", + pendingManualRecovery: true, + error: "服务重启后任务已暂停,等待手动恢复。", + }); + prisma.novelWorkflowTask.update = async ({ data }) => { + currentRow = { + ...currentRow, + status: data.status ?? currentRow.status, + pendingManualRecovery: Object.prototype.hasOwnProperty.call(data, "pendingManualRecovery") + ? data.pendingManualRecovery + : currentRow.pendingManualRecovery, + heartbeatAt: Object.prototype.hasOwnProperty.call(data, "heartbeatAt") + ? data.heartbeatAt + : currentRow.heartbeatAt, + finishedAt: Object.prototype.hasOwnProperty.call(data, "finishedAt") + ? data.finishedAt + : currentRow.finishedAt, + cancelRequestedAt: Object.prototype.hasOwnProperty.call(data, "cancelRequestedAt") + ? data.cancelRequestedAt + : currentRow.cancelRequestedAt, + lastError: Object.prototype.hasOwnProperty.call(data, "lastError") + ? data.lastError + : currentRow.lastError, + seedPayloadJson: data.seedPayloadJson ?? currentRow.seedPayloadJson, + }; + return currentRow; + }; + + try { + const service = new NovelWorkflowService(); + const healed = await service.healAutoDirectorTaskState("task_pipeline_paused"); + + assert.equal(healed, true); + assert.equal(currentRow.status, "queued"); + assert.equal(currentRow.pendingManualRecovery, true); + assert.equal(currentRow.heartbeatAt, null); + assert.match(currentRow.lastError, /等待手动恢复/); + assert.equal(JSON.parse(currentRow.seedPayloadJson).autoExecution.pipelineStatus, "queued"); + } finally { + prisma.novelWorkflowTask.findUnique = originals.findUnique; + prisma.generationJob.findUnique = originals.generationJobFindUnique; + prisma.novelWorkflowTask.update = originals.update; + } +}); + test("healAutoDirectorTaskState promotes advanced queued auto director tasks back to running and clears stale candidate checkpoints", async () => { const originals = { findUnique: prisma.novelWorkflowTask.findUnique, diff --git a/server/tests/volumeChapterTitleDiversity.test.js b/server/tests/volumeChapterTitleDiversity.test.js index 89a58d4ad..25161d535 100644 --- a/server/tests/volumeChapterTitleDiversity.test.js +++ b/server/tests/volumeChapterTitleDiversity.test.js @@ -115,7 +115,7 @@ function createPromptInput(targetChapterCount = 4) { }; } -test("chapter title diversity detects repeated X的Y framing", () => { +test("chapter title diversity keeps frame detection while new hard validation is disabled", () => { const issue = getChapterTitleDiversityIssue([ "废墟中的发现", "第一株灵植的种子", @@ -125,11 +125,11 @@ test("chapter title diversity detects repeated X的Y framing", () => { "守夜人的来信", ]); - assert.match(issue, /X的Y \/ X中的Y/); + assert.equal(issue, null); assert.equal(detectChapterTitleSurfaceFrame("掠夺者的阴影"), "of_phrase"); }); -test("chapter title diversity detects repeated A,B framing", () => { +test("chapter title diversity no longer blocks repeated comma framing during generation", () => { const issue = getChapterTitleDiversityIssue([ "签下合同,甜蜜同居", "房租超支,紧急筹钱", @@ -139,7 +139,7 @@ test("chapter title diversity detects repeated A,B framing", () => { "机会临门,意外落空", ]); - assert.match(issue, /A,B \/ 四字动作,四字结果/); + assert.equal(issue, null); assert.equal(detectChapterTitleSurfaceFrame("房租超支,紧急筹钱"), "comma_split"); }); @@ -168,44 +168,26 @@ test("volume chapter list prompt render hardens title diversity rules", () => { assert.match(String(messages[0].content), /只能为「开卷抓手」生成 6 章/); assert.match(String(messages[0].content), /beatKey 必须严格等于 open_hook/); assert.match(String(messages[0].content), /chapterCount 与 chapters\.length 必须严格等于 6/); - assert.match(String(messages[0].content), /不能大量重复“X的Y \/ X中的Y \/ 在X中Y”/); + assert.match(String(messages[0].content), /“X的Y \/ X中的Y \/ 在X中Y”这类骨架最多只占约三成/); assert.match(String(messages[0].content), /A,B \/ 四字动作,四字结果/); assert.match(String(messages[0].content), /章名结构过于集中/); }); -test("volume chapter list prompt retries semantically when titles are structurally repetitive", async () => { +test("volume chapter list prompt accepts structurally repetitive titles while hard validation is disabled", async () => { const calls = []; setPromptRunnerStructuredInvokerForTests(async (input) => { calls.push(input); - if (calls.length === 1) { - return { - data: { - beatKey: "open_hook", - beatLabel: "开卷抓手", - chapterCount: 4, - chapters: [ - { beatKey: "open_hook", title: "签下合同,甜蜜同居", summary: "主角暂时稳住住处问题,同时把关系线推进到新阶段。" }, - { beatKey: "open_hook", title: "房租超支,紧急筹钱", summary: "现实压力突然压上来,逼着主角立刻行动。" }, - { beatKey: "open_hook", title: "林晓求职,首战告败", summary: "主角第一次外出求职受挫,确认局面没有想象中轻松。" }, - { beatKey: "open_hook", title: "苏雨追梦,画室坚守", summary: "配角线同步抬升,让现实理想冲突进一步显形。" }, - ], - }, - repairUsed: false, - repairAttempts: 0, - }; - } - return { data: { beatKey: "open_hook", beatLabel: "开卷抓手", chapterCount: 4, chapters: [ - { beatKey: "open_hook", title: "夜探旧温室", summary: "主角夜探温室,确认异常来源并推动探索线正式启动。" }, - { beatKey: "open_hook", title: "掠夺者逼近", summary: "外部威胁压到眼前,当前卷的生存压力第一次真正落地。" }, - { beatKey: "open_hook", title: "谁在回收种子?", summary: "主角发现有人暗中回收灵种,把悬疑线抬到台前。" }, - { beatKey: "open_hook", title: "防线第一次成形", summary: "主角完成阶段性布防,让当前卷第一次出现可见成果。" }, + { beatKey: "open_hook", title: "签下合同,甜蜜同居", summary: "主角暂时稳住住处问题,同时把关系线推进到新阶段。" }, + { beatKey: "open_hook", title: "房租超支,紧急筹钱", summary: "现实压力突然压上来,逼着主角立刻行动。" }, + { beatKey: "open_hook", title: "林晓求职,首战告败", summary: "主角第一次外出求职受挫,确认局面没有想象中轻松。" }, + { beatKey: "open_hook", title: "苏雨追梦,画室坚守", summary: "配角线同步抬升,让现实理想冲突进一步显形。" }, ], }, repairUsed: false, @@ -223,18 +205,14 @@ test("volume chapter list prompt retries semantically when titles are structural promptInput: createPromptInput(4), }); - assert.equal(calls.length, 2); - assert.equal(calls[1].promptMeta.semanticRetryUsed, true); - assert.equal(calls[1].promptMeta.semanticRetryAttempts, 1); - assert.match(String(calls[1].messages[calls[1].messages.length - 1].content), /必须保留原有章节位数/); - assert.match(String(calls[1].messages[calls[1].messages.length - 1].content), /A,B \/ 四字动作,四字结果/); - assert.equal(result.output.chapters[0].title, "夜探旧温室"); + assert.equal(calls.length, 1); + assert.equal(result.output.chapters[0].title, "签下合同,甜蜜同居"); } finally { setPromptRunnerStructuredInvokerForTests(); } }); -test("volume chapter list prompt throws after semantic retries are exhausted", async () => { +test("volume chapter list prompt does not throw solely for repetitive title structure", async () => { const calls = []; setPromptRunnerStructuredInvokerForTests(async (input) => { @@ -257,15 +235,16 @@ test("volume chapter list prompt throws after semantic retries are exhausted", a }); try { - await assert.rejects(() => runStructuredPrompt({ + const result = await runStructuredPrompt({ asset: createVolumeChapterListPrompt({ targetChapterCount: 4, targetBeatKey: "open_hook", targetBeatLabel: "开卷抓手", }), promptInput: createPromptInput(4), - }), /章节标题结构过于集中|章节标题结构重复|X的Y \/ X中的Y/); - assert.equal(calls.length, 3); + }); + assert.equal(calls.length, 1); + assert.equal(result.output.chapters.length, 4); } finally { setPromptRunnerStructuredInvokerForTests(); } From 6b5e660c316cfaa1419c0161c75b0e73d1eb05d5 Mon Sep 17 00:00:00 2001 From: caoty Date: Wed, 29 Apr 2026 00:28:09 +0800 Subject: [PATCH 07/12] fix: route AI director chapter execution models --- README.md | 17 +++-------------- docs/releases/release-notes.md | 4 ++++ .../director/novelDirectorAutoExecution.ts | 7 ------- .../novelDirectorAutoExecutionRuntime.ts | 3 --- server/tests/novelDirectorAutoExecution.test.js | 14 ++++++++++++++ 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 99376e9be..acfe587ad 100644 --- a/README.md +++ b/README.md @@ -124,20 +124,9 @@ 完整历史更新见 [docs/releases/release-notes.md](./docs/releases/release-notes.md)。 -### 2026-04-28 - -AI 主驾的章节自动执行、恢复、列表加载和进度同步更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演会自动尝试续跑,关联章节流水线需要恢复时也不会再挂成假的运行中。 - -- 章节执行区和节奏规划一致时会保留现有数据,但继续时会补跑范围内最早未执行章节。 -- 等待审批的章节批次会按普通继续处理,失败后明确允许跳过审校阻断章时才会跳过当前章。 -- 服务重启后,仍在排队或运行中的自动导演会自动进入恢复续跑。 -- 自动导演关联的章节执行流水线若因重启中断,任务中心会同步显示为可恢复,并在继续时优先接上原任务。 -- 首页和小说列表不会在读取项目卡片时触发自动导演状态修复,自动导演任务较多时也能保持轻量加载。 -- 自动导演跟进中心、任务详情和小说页导演进度的普通刷新保持只读,需要复核或继续时再触发状态修复。 -- 小说列表、首页和任务导航会在自动导演排队、运行或等待审批时持续刷新,后台进度变化会更快同步到页面。 -- 任务中心恢复候选列表不再等待启动恢复流程完成,后台续跑时页面也能更快展示。 -- 质量校验不再触发重规划检查点;历史重规划提醒会按普通质量提醒处理,避免自动推进被重规划卡住。 -- 历史章节标题结构异常会作为提醒进入恢复处理,新章节列表不会因为标题框架问题被硬阻断。 +### 2026-04-29 + +AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型。章节流水线不再沿用导演任务里临时选择的模型,减少单一模型或渠道异常导致整条执行链失败。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 85eb801c3..59f412a59 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -4,6 +4,10 @@ ## 更新历史 +### 2026-04-29 + +- AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线,减少单一模型或渠道异常导致整条执行链失败。 + ### 2026-04-28 - 自动导演继续章节执行时会按真实章节结果重新找最早未完成章节。章节执行区和节奏规划一致时不会清空现有数据,但如果第 5 章已完成、第 6 章未生成、第 7 章曾被误触发,重新继续会先补跑第 6 章,避免跳章。 diff --git a/server/src/services/novel/director/novelDirectorAutoExecution.ts b/server/src/services/novel/director/novelDirectorAutoExecution.ts index 48b3824a3..835c92e3d 100644 --- a/server/src/services/novel/director/novelDirectorAutoExecution.ts +++ b/server/src/services/novel/director/novelDirectorAutoExecution.ts @@ -1,4 +1,3 @@ -import type { LLMProvider } from "@ai-novel/shared/types/llm"; import type { ChapterGenerationState, PipelineJobStatus, @@ -366,9 +365,6 @@ export function buildDirectorAutoExecutionCompletedSummary(input: { } export function buildDirectorAutoExecutionPipelineOptions(input: { - provider?: LLMProvider; - model?: string; - temperature?: number; workflowTaskId?: string; taskStyleProfileId?: string; startOrder: number; @@ -389,9 +385,6 @@ export function buildDirectorAutoExecutionPipelineOptions(input: { skipCompleted: true, qualityThreshold: 75, repairMode: "light_repair" as const, - provider: input.provider, - model: input.model, - temperature: input.temperature, workflowTaskId: input.workflowTaskId, taskStyleProfileId: input.taskStyleProfileId, }; diff --git a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts index f51e58277..92cce6d2f 100644 --- a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts +++ b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts @@ -355,9 +355,6 @@ export class NovelDirectorAutoExecutionRuntime { const job = await this.deps.novelService.startPipelineJob( input.novelId, buildDirectorAutoExecutionPipelineOptions({ - provider: input.request.provider, - model: input.request.model, - temperature: input.request.temperature, workflowTaskId: input.taskId, taskStyleProfileId: input.request.styleProfileId, ...resolveSingleChapterExecutionRange(range, autoExecution), diff --git a/server/tests/novelDirectorAutoExecution.test.js b/server/tests/novelDirectorAutoExecution.test.js index 6ae28b863..44ffb2980 100644 --- a/server/tests/novelDirectorAutoExecution.test.js +++ b/server/tests/novelDirectorAutoExecution.test.js @@ -90,6 +90,20 @@ test("buildDirectorAutoExecutionPipelineOptions uses front10-safe defaults", () assert.equal(options.controlPolicy?.advanceMode, "auto_to_execution"); }); +test("buildDirectorAutoExecutionPipelineOptions leaves model selection to model routes", () => { + const options = buildDirectorAutoExecutionPipelineOptions({ + provider: "openai", + model: "glm-5", + temperature: 0.7, + startOrder: 7, + endOrder: 7, + }); + + assert.equal(Object.hasOwn(options, "provider"), false); + assert.equal(Object.hasOwn(options, "model"), false); + assert.equal(Object.hasOwn(options, "temperature"), false); +}); + test("buildDirectorAutoExecutionPipelineOptions respects review and repair toggles", () => { const options = buildDirectorAutoExecutionPipelineOptions({ startOrder: 11, From cfd43469887e48bfaf0bbd9daabee97bcc8c4b33 Mon Sep 17 00:00:00 2001 From: caoty Date: Wed, 29 Apr 2026 00:57:03 +0800 Subject: [PATCH 08/12] fix: preserve routed structured response format --- README.md | 2 +- docs/releases/release-notes.md | 1 + server/src/llm/structuredInvoke.ts | 2 +- server/tests/structuredInvoke.test.js | 88 +++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index acfe587ad..06e767a5f 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ ### 2026-04-29 -AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型。章节流水线不再沿用导演任务里临时选择的模型,减少单一模型或渠道异常导致整条执行链失败。 +AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型。章节流水线不再沿用导演任务里临时选择的模型,章节细化、任务单生成等结构化调用也会继续遵守路由里的结构化输出格式,减少单一模型或渠道异常导致整条执行链失败。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 59f412a59..4c40a6362 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -7,6 +7,7 @@ ### 2026-04-29 - AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线,减少单一模型或渠道异常导致整条执行链失败。 +- 章节细化、任务单生成等结构化调用会继续遵守模型路由里的结构化输出格式。即使流程内部已经带上了路由选中的模型,也不会丢掉 `json_object`、`json_schema` 或提示词 JSON 的配置偏好。 ### 2026-04-28 diff --git a/server/src/llm/structuredInvoke.ts b/server/src/llm/structuredInvoke.ts index dd6dbcdc2..43cfdcd14 100644 --- a/server/src/llm/structuredInvoke.ts +++ b/server/src/llm/structuredInvoke.ts @@ -137,7 +137,7 @@ async function resolveAttemptTarget(input: { structuredStrategy: input.structuredStrategy, executionMode: "plain", }); - const preferredStrategy = input.structuredStrategy ?? (route + const preferredStrategy = input.structuredStrategy ?? resolved.structuredStrategy ?? (route && resolved.provider === route.provider && resolved.model === route.model ? toStructuredOutputStrategy(route.structuredResponseFormat) diff --git a/server/tests/structuredInvoke.test.js b/server/tests/structuredInvoke.test.js index 728496dbf..1d62e39be 100644 --- a/server/tests/structuredInvoke.test.js +++ b/server/tests/structuredInvoke.test.js @@ -320,6 +320,94 @@ test("invokeStructuredLlmDetailed degrades to prompt JSON before using fallback } }); +test("invokeStructuredLlmDetailed preserves routed structured format for explicit routed provider and model", async () => { + const originalResolveOptions = factory.resolveLLMClientOptions; + const originalCreateLLM = factory.createLLMFromResolvedOptions; + const originalGetFallbackSettings = structuredFallbackSettings.getStructuredFallbackSettings; + const calls = []; + + factory.resolveLLMClientOptions = async (provider, options = {}) => { + const resolvedProvider = provider ?? "openai"; + const resolvedModel = options.model ?? "MiniMax-M2.5"; + const baseURL = options.baseURL ?? "https://api.openai.com/v1"; + const structuredProfile = options.executionMode === "structured" + ? resolveStructuredOutputProfile({ + provider: resolvedProvider, + model: resolvedModel, + baseURL, + executionMode: "structured", + }) + : null; + const inheritedRouteStrategy = options.taskType === "planner" + && resolvedProvider === "openai" + && resolvedModel === "MiniMax-M2.5" + ? "json_object" + : null; + return { + provider: resolvedProvider, + providerName: resolvedProvider, + model: resolvedModel, + temperature: options.temperature ?? 0.3, + apiKey: "test-key", + baseURL, + maxTokens: options.maxTokens, + requestProtocol: "openai_compatible", + reasoningEnabled: !(structuredProfile?.requiresNonThinkingForStructured), + modelKwargs: undefined, + includeRawResponse: false, + executionMode: options.executionMode ?? "plain", + structuredProfile, + structuredStrategy: options.structuredStrategy ?? inheritedRouteStrategy, + reasoningForcedOff: Boolean(structuredProfile?.requiresNonThinkingForStructured), + taskType: options.taskType, + promptMeta: options.promptMeta, + }; + }; + factory.createLLMFromResolvedOptions = (resolved) => ({ + invoke: async () => { + calls.push({ + provider: resolved.provider, + model: resolved.model, + strategy: resolved.structuredStrategy, + }); + return { + content: "{\"value\":\"route-json-object\"}", + }; + }, + }); + structuredFallbackSettings.getStructuredFallbackSettings = async () => ({ + enabled: false, + provider: "deepseek", + model: "deepseek-chat", + temperature: 0.2, + maxTokens: null, + }); + + try { + const result = await structuredInvoke.invokeStructuredLlmDetailed({ + provider: "openai", + model: "MiniMax-M2.5", + label: "structured.invoke.explicit.routed-format", + taskType: "planner", + schema: z.object({ + value: z.string(), + }), + systemPrompt: "只返回 JSON。", + userPrompt: "给我一个 value。", + disableFallbackModel: true, + }); + + assert.deepEqual(result.data, { value: "route-json-object" }); + assert.deepEqual(calls, [ + { provider: "openai", model: "MiniMax-M2.5", strategy: "json_object" }, + ]); + } finally { + factory.resolveLLMClientOptions = originalResolveOptions; + factory.createLLMFromResolvedOptions = originalCreateLLM; + structuredFallbackSettings.getStructuredFallbackSettings = originalGetFallbackSettings; + } +}); + test("invokeStructuredLlmDetailed switches to the configured fallback model after primary transport failure", async () => { const originalResolveOptions = factory.resolveLLMClientOptions; const originalCreateLLM = factory.createLLMFromResolvedOptions; From 4b7e1290580fe70a3298f793f7c812729e1e31da Mon Sep 17 00:00:00 2001 From: caoty Date: Wed, 29 Apr 2026 02:09:45 +0800 Subject: [PATCH 09/12] fix: route auto director model calls --- README.md | 2 +- docs/releases/release-notes.md | 2 +- server/src/llm/factory.ts | 17 ++- server/src/llm/structuredInvoke.ts | 2 +- .../novel/director/NovelDirectorService.ts | 11 +- .../director/novelDirectorCandidateStage.ts | 25 ++-- .../novelDirectorChapterTitleRepair.ts | 14 +-- .../novel/director/novelDirectorHelpers.ts | 46 ++++++- .../director/novelDirectorPipelinePhases.ts | 15 +-- .../director/novelDirectorStoryMacroPhase.ts | 13 +- .../novelDirectorStructuredOutlinePhase.ts | 14 +-- .../novel/novelCorePipelineService.ts | 28 ++--- server/src/services/novel/pipelineJobState.ts | 6 +- .../task/adapters/NovelWorkflowTaskAdapter.ts | 52 +++++++- .../services/title/TitleGenerationService.ts | 9 +- ...DirectorApprovalPreferenceContract.test.js | 41 +++++++ server/tests/novelPipelineState.test.js | 114 ++++++++++++++++++ ...velWorkflowTaskAdapterModelBinding.test.js | 88 ++++++++++++++ server/tests/structuredInvoke.test.js | 73 +++++++++++ server/tests/titleGeneration.test.js | 70 +++++++++++ shared/types/novelDirector.ts | 1 + 21 files changed, 556 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 06e767a5f..e37ff5e45 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ ### 2026-04-29 -AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型。章节流水线不再沿用导演任务里临时选择的模型,章节细化、任务单生成等结构化调用也会继续遵守路由里的结构化输出格式,减少单一模型或渠道异常导致整条执行链失败。 +AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型。章节流水线不再沿用导演任务里临时选择的模型,任务详情会按当前阶段显示最新路由模型,章节细化、任务单生成等结构化调用也会继续遵守路由里的结构化输出格式,减少单一模型或渠道异常导致整条执行链失败。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 4c40a6362..46ad92e5b 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -6,7 +6,7 @@ ### 2026-04-29 -- AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线,减少单一模型或渠道异常导致整条执行链失败。 +- AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线;任务详情也会按当前阶段显示最新路由模型,调整路由后不用重建任务即可看到变更,减少单一模型或渠道异常导致整条执行链失败。 - 章节细化、任务单生成等结构化调用会继续遵守模型路由里的结构化输出格式。即使流程内部已经带上了路由选中的模型,也不会丢掉 `json_object`、`json_schema` 或提示词 JSON 的配置偏好。 ### 2026-04-28 diff --git a/server/src/llm/factory.ts b/server/src/llm/factory.ts index 5f1e87777..1477f71ea 100644 --- a/server/src/llm/factory.ts +++ b/server/src/llm/factory.ts @@ -204,12 +204,17 @@ export async function resolveLLMClientOptions( const hasExplicitProvider = provider != null; const hasExplicitModel = options.model != null; const shouldUseRouteProvider = !hasExplicitProvider && !hasExplicitModel; - const route = await resolveModel(options.taskType, { - ...(shouldUseRouteProvider ? {} : { provider: resolvedProvider }), - ...(options.model != null ? { model: options.model } : {}), - ...(options.temperature != null ? { temperature: options.temperature } : {}), - ...(options.maxTokens != null ? { maxTokens: options.maxTokens } : {}), - }); + const route = await resolveModel( + options.taskType, + shouldUseRouteProvider + ? undefined + : { + provider: resolvedProvider, + ...(options.model != null ? { model: options.model } : {}), + ...(options.temperature != null ? { temperature: options.temperature } : {}), + ...(options.maxTokens != null ? { maxTokens: options.maxTokens } : {}), + }, + ); if (shouldUseRouteProvider) { resolvedProvider = route.provider; } diff --git a/server/src/llm/structuredInvoke.ts b/server/src/llm/structuredInvoke.ts index 43cfdcd14..eac2baf03 100644 --- a/server/src/llm/structuredInvoke.ts +++ b/server/src/llm/structuredInvoke.ts @@ -341,7 +341,7 @@ export async function invokeStructuredLlmDetailed(input: StructuredInvokeInpu model: input.model, apiKey: input.apiKey, baseURL: input.baseURL, - temperature: input.temperature ?? 0.3, + temperature: input.temperature, maxTokens: input.maxTokens, taskType: input.taskType ?? "planner", requestProtocol: input.requestProtocol, diff --git a/server/src/services/novel/director/NovelDirectorService.ts b/server/src/services/novel/director/NovelDirectorService.ts index 0fc234473..2bf325b2b 100644 --- a/server/src/services/novel/director/NovelDirectorService.ts +++ b/server/src/services/novel/director/NovelDirectorService.ts @@ -43,10 +43,11 @@ import { import { NovelDirectorCandidateStageService } from "./novelDirectorCandidateStage"; import { resolveDirectorBookFraming } from "./novelDirectorFraming"; import { + buildRouteFollowingDirectorLlmOptions, + buildTaskModelDirectorLlmOptions, buildDirectorSessionState, buildWorkflowSeedPayload, getDirectorInputFromSeedPayload, - getDirectorLlmOptionsFromSeedPayload, type DirectorWorkflowSeedPayload, normalizeDirectorRunMode, toBookSpec, @@ -461,7 +462,7 @@ export class NovelDirectorService { if (!idea) { return null; } - const llm = getDirectorLlmOptionsFromSeedPayload(seedPayload); + const llm = buildRouteFollowingDirectorLlmOptions(seedPayload); const runMode = typeof seedPayload.runMode === "string" && (DIRECTOR_RUN_MODES as readonly string[]).includes(seedPayload.runMode) ? seedPayload.runMode as (typeof DIRECTOR_RUN_MODES)[number] @@ -882,7 +883,7 @@ export class NovelDirectorService { throw new Error("当前任务指向的目标卷不存在,无法继续 AI 修复章节标题。"); } - const boundLlm = getDirectorLlmOptionsFromSeedPayload(seedPayload); + const boundLlm = buildTaskModelDirectorLlmOptions(seedPayload); const repairRequest: DirectorConfirmRequest = { ...directorInput, provider: boundLlm?.provider ?? directorInput.provider, @@ -1166,9 +1167,7 @@ export class NovelDirectorService { description, suggest: (suggestInput) => novelFramingSuggestionService.suggest({ ...suggestInput, - provider: resolvedInput.provider, - model: resolvedInput.model, - temperature: resolvedInput.temperature, + ...buildRouteFollowingDirectorLlmOptions(resolvedInput), }), }); const directorInput: DirectorConfirmRequest = { diff --git a/server/src/services/novel/director/novelDirectorCandidateStage.ts b/server/src/services/novel/director/novelDirectorCandidateStage.ts index 7e3b73dd3..27b1a8695 100644 --- a/server/src/services/novel/director/novelDirectorCandidateStage.ts +++ b/server/src/services/novel/director/novelDirectorCandidateStage.ts @@ -25,6 +25,7 @@ import { titleGenerationService } from "../../title/TitleGenerationService"; import { isNearDuplicateTitle } from "../../title/titleGeneration.shared"; import type { NovelWorkflowService } from "../workflow/NovelWorkflowService"; import { + buildRouteFollowingDirectorLlmOptions, buildRefinementSummary, buildWorkflowSeedPayload, enhanceCandidateTitles, @@ -183,11 +184,9 @@ export class NovelDirectorCandidateStageService { presets: context.presets, feedback: context.feedback, }), - options: { - provider: context.options.provider, - model: context.options.model, + options: buildRouteFollowingDirectorLlmOptions(context.options, { temperature: clampTemperature(context.options.temperature, 0.45), - }, + }), }); const normalizedCandidates = parsed.output.candidates.map((candidate, index) => normalizeCandidate(candidate, index)); @@ -251,7 +250,7 @@ export class NovelDirectorCandidateStageService { batches: [], presets: [], request: input, - options: input, + options: buildRouteFollowingDirectorLlmOptions(input), workflowTaskId: input.workflowTaskId, }); if (!input.workflowTaskId?.trim()) { @@ -321,7 +320,7 @@ export class NovelDirectorCandidateStageService { presets: input.presets ?? [], feedback: input.feedback, request: input, - options: input, + options: buildRouteFollowingDirectorLlmOptions(input), workflowTaskId: input.workflowTaskId, }); if (!input.workflowTaskId?.trim()) { @@ -411,11 +410,9 @@ export class NovelDirectorCandidateStageService { presets: input.presets ?? [], feedback: input.feedback, }), - options: { - provider: input.provider, - model: input.model, + options: buildRouteFollowingDirectorLlmOptions(input, { temperature: clampTemperature(input.temperature, 0.4), - }, + }), }); await this.markCandidateProgress( @@ -434,7 +431,7 @@ export class NovelDirectorCandidateStageService { presets: input.presets ?? [], feedback: input.feedback, request: input, - options: input, + options: buildRouteFollowingDirectorLlmOptions(input), }); const nextBatch = replaceCandidateInBatch( @@ -524,9 +521,9 @@ export class NovelDirectorCandidateStageService { }), genreId: input.genreId ?? null, count: 4, - provider: input.provider, - model: input.model, - temperature: clampTemperature(input.temperature, 0.85), + ...buildRouteFollowingDirectorLlmOptions(input, { + temperature: clampTemperature(input.temperature, 0.85), + }), }); const titleOptions = mergeTitleOptions(response.titles, targetCandidate); diff --git a/server/src/services/novel/director/novelDirectorChapterTitleRepair.ts b/server/src/services/novel/director/novelDirectorChapterTitleRepair.ts index a28daf46f..8f0d41ad1 100644 --- a/server/src/services/novel/director/novelDirectorChapterTitleRepair.ts +++ b/server/src/services/novel/director/novelDirectorChapterTitleRepair.ts @@ -3,7 +3,10 @@ import { buildNovelEditResumeTarget } from "../workflow/novelWorkflow.shared"; import { getChapterTitleDiversityIssue } from "../volume/chapterTitleDiversity"; import type { NovelVolumeService } from "../volume/NovelVolumeService"; import type { NovelWorkflowService } from "../workflow/NovelWorkflowService"; -import { buildDirectorSessionState } from "./novelDirectorHelpers"; +import { + buildDirectorSessionState, + buildRouteFollowingDirectorLlmOptions, +} from "./novelDirectorHelpers"; import { DIRECTOR_PROGRESS } from "./novelDirectorProgress"; import { buildChapterTitleDiversityTaskNotice } from "./novelDirectorTaskNotice"; @@ -57,12 +60,11 @@ export async function repairDirectorChapterTitles(input: { volumeId: targetVolume.id, }); const currentTask = await input.workflowService.getTaskByIdWithoutHealing(input.taskId); + const routeLlmOptions = buildRouteFollowingDirectorLlmOptions(input.request); let workingWorkspace = currentWorkspace; if (!hasTargetBeatSheet(workingWorkspace, targetVolume.id) || shouldRefreshBeatSheetForRepair(currentTask?.lastError)) { workingWorkspace = await input.volumeService.generateVolumes(input.novelId, { - provider: input.request.provider, - model: input.request.model, - temperature: input.request.temperature, + ...routeLlmOptions, scope: "beat_sheet", targetVolumeId: targetVolume.id, draftWorkspace: workingWorkspace, @@ -78,9 +80,7 @@ export async function repairDirectorChapterTitles(input: { } const repairedWorkspace = await input.volumeService.generateVolumes(input.novelId, { - provider: input.request.provider, - model: input.request.model, - temperature: input.request.temperature, + ...routeLlmOptions, scope: "chapter_list", targetVolumeId: targetVolume.id, draftWorkspace: workingWorkspace, diff --git a/server/src/services/novel/director/novelDirectorHelpers.ts b/server/src/services/novel/director/novelDirectorHelpers.ts index b9b97a538..ee42a5aa1 100644 --- a/server/src/services/novel/director/novelDirectorHelpers.ts +++ b/server/src/services/novel/director/novelDirectorHelpers.ts @@ -34,6 +34,8 @@ import type { DirectorCandidateResponse, } from "./novelDirectorSchemas"; +export type DirectorLlmBindingMode = "route" | "task"; + export type LLMOptions = Pick; export type DirectorCandidateStageMode = @@ -55,6 +57,7 @@ export interface DirectorWorkflowSeedPayload extends Record { provider?: DirectorLLMOptions["provider"] | null; model?: string | null; temperature?: number | null; + llmBindingMode?: DirectorLlmBindingMode; runMode?: DirectorRunMode; autoExecutionPlan?: DirectorAutoExecutionPlan; autoApproval?: DirectorAutoApprovalConfig | null; @@ -227,8 +230,7 @@ export async function enhanceCandidateTitles( brief: buildCandidateTitleBrief(candidate, context), genreId: context.request.genreId ?? null, count: 4, - provider: context.options.provider, - model: context.options.model, + ...buildRouteFollowingDirectorLlmOptions(context.options), }); const mergedOptions = mergeTitleOptions(response.titles, candidate); const primaryTitle = mergedOptions[0]?.title?.trim(); @@ -379,6 +381,7 @@ export function buildWorkflowSeedPayload( input: DirectorProjectContextInput & Pick & { idea: string; autoApproval?: DirectorAutoApprovalConfig; + llmBindingMode?: DirectorLlmBindingMode; }, extra?: Record, ): Record { @@ -439,6 +442,7 @@ export function buildWorkflowSeedPayload( provider: input.provider ?? null, model: input.model?.trim() || null, temperature: typeof input.temperature === "number" ? input.temperature : null, + llmBindingMode: input.llmBindingMode === "task" ? "task" : "route", runMode: input.runMode ?? "auto_to_ready", ...(autoApproval ? { autoApproval } : {}), estimatedChapterCount: basicForm.estimatedChapterCount, @@ -450,12 +454,12 @@ export function buildWorkflowSeedPayload( export function getDirectorInputFromSeedPayload( seedPayload: DirectorWorkflowSeedPayload | null | undefined, -): DirectorConfirmRequest | null { +): (DirectorConfirmRequest & { llmBindingMode?: DirectorLlmBindingMode }) | null { const directorInput = seedPayload?.directorInput; if (!directorInput || typeof directorInput !== "object") { return null; } - return directorInput as DirectorConfirmRequest; + return directorInput as DirectorConfirmRequest & { llmBindingMode?: DirectorLlmBindingMode }; } export function getDirectorLlmOptionsFromSeedPayload( @@ -482,6 +486,38 @@ export function getDirectorLlmOptionsFromSeedPayload( }; } +export function isDirectorTaskModelBinding( + seedPayload: DirectorWorkflowSeedPayload | null | undefined, +): boolean { + return seedPayload?.llmBindingMode === "task"; +} + +export function buildTaskModelDirectorLlmOptions( + seedPayload: DirectorWorkflowSeedPayload | null | undefined, +): Pick | null { + return getDirectorLlmOptionsFromSeedPayload(seedPayload); +} + +export function buildRouteFollowingDirectorLlmOptions( + seedPayloadOrOptions: DirectorWorkflowSeedPayload | Pick | null | undefined, + override?: { + temperature?: number; + }, +): Pick { + if (isDirectorTaskModelBinding(seedPayloadOrOptions as DirectorWorkflowSeedPayload | null | undefined)) { + const taskModel = getDirectorLlmOptionsFromSeedPayload(seedPayloadOrOptions as DirectorWorkflowSeedPayload); + const temperature = typeof override?.temperature === "number" + ? override.temperature + : taskModel?.temperature; + return { + ...(taskModel?.provider ? { provider: taskModel.provider } : {}), + ...(taskModel?.model ? { model: taskModel.model } : {}), + ...(typeof temperature === "number" ? { temperature } : {}), + }; + } + return {}; +} + export function applyDirectorLlmOverride( seedPayload: DirectorWorkflowSeedPayload | null | undefined, llmOverride: Pick, @@ -498,12 +534,14 @@ export function applyDirectorLlmOverride( const nextProvider = llmOverride.provider ?? seedPayload.provider ?? directorInput?.provider ?? null; return { ...seedPayload, + llmBindingMode: "task", provider: nextProvider, model: nextModel, temperature: nextTemperature, directorInput: directorInput ? { ...directorInput, + llmBindingMode: "task", provider: nextProvider ?? directorInput.provider, model: nextModel || directorInput.model, temperature: typeof nextTemperature === "number" diff --git a/server/src/services/novel/director/novelDirectorPipelinePhases.ts b/server/src/services/novel/director/novelDirectorPipelinePhases.ts index d6d582586..1c13d5b38 100644 --- a/server/src/services/novel/director/novelDirectorPipelinePhases.ts +++ b/server/src/services/novel/director/novelDirectorPipelinePhases.ts @@ -4,6 +4,7 @@ import { buildCharacterCastBlockedMessage } from "../characterPrep/characterCast import type { VolumeGenerationPhaseEvent } from "../volume/volumeModels"; import { buildNovelEditResumeTarget } from "../workflow/novelWorkflow.shared"; import { + buildRouteFollowingDirectorLlmOptions, buildDirectorSessionState, buildStoryInput, normalizeDirectorRunMode, @@ -73,6 +74,7 @@ export async function runDirectorCharacterSetupPhase(input: { }), }); const storyInput = buildStoryInput(request, toBookSpec(request.candidate, request.idea, request.estimatedChapterCount)); + const routeLlmOptions = buildRouteFollowingDirectorLlmOptions(request); const reusableOption = await dependencies.characterPreparationService.findReusableCharacterCastOption?.(novelId) ?? null; const targetOption = reusableOption ?? await runDirectorTrackedStep({ taskId, @@ -82,9 +84,7 @@ export async function runDirectorCharacterSetupPhase(input: { progress: DIRECTOR_PROGRESS.characterSetup, callbacks, run: async () => dependencies.characterPreparationService.generateAutoCharacterCastOption(novelId, { - provider: request.provider, - model: request.model, - temperature: request.temperature, + ...routeLlmOptions, storyInput, }), }); @@ -182,6 +182,7 @@ export async function runDirectorVolumeStrategyPhase(input: { callbacks: DirectorPhaseCallbacks; }): Promise { const { taskId, novelId, request, dependencies, callbacks } = input; + const routeLlmOptions = buildRouteFollowingDirectorLlmOptions(request); const directorSession = buildDirectorSessionState({ runMode: request.runMode, phase: "volume_strategy", @@ -210,9 +211,7 @@ export async function runDirectorVolumeStrategyPhase(input: { progress: DIRECTOR_PROGRESS.volumeStrategy, callbacks, run: async ({ updateStatus, signal }) => dependencies.volumeService.generateVolumes(novelId, { - provider: request.provider, - model: request.model, - temperature: request.temperature, + ...routeLlmOptions, scope: "strategy", estimatedChapterCount: request.estimatedChapterCount ?? toBookSpec(request.candidate, request.idea, request.estimatedChapterCount).targetChapterCount, signal, @@ -233,9 +232,7 @@ export async function runDirectorVolumeStrategyPhase(input: { progress: DIRECTOR_PROGRESS.volumeSkeleton, callbacks, run: async ({ updateStatus, signal }) => dependencies.volumeService.generateVolumes(novelId, { - provider: request.provider, - model: request.model, - temperature: request.temperature, + ...routeLlmOptions, scope: "skeleton", estimatedChapterCount: request.estimatedChapterCount ?? toBookSpec(request.candidate, request.idea, request.estimatedChapterCount).targetChapterCount, draftWorkspace: workspace, diff --git a/server/src/services/novel/director/novelDirectorStoryMacroPhase.ts b/server/src/services/novel/director/novelDirectorStoryMacroPhase.ts index beb446db5..3aecb4628 100644 --- a/server/src/services/novel/director/novelDirectorStoryMacroPhase.ts +++ b/server/src/services/novel/director/novelDirectorStoryMacroPhase.ts @@ -9,6 +9,7 @@ import { import { BookContractService } from "../BookContractService"; import { StoryMacroPlanService } from "../storyMacro/StoryMacroPlanService"; import { + buildRouteFollowingDirectorLlmOptions, buildStoryInput, normalizeBookContract, toBookSpec, @@ -71,11 +72,7 @@ async function generateDirectorBookContract(input: { storyMacroPlan, targetChapterCount: request.estimatedChapterCount ?? bookSpec.targetChapterCount, }), - options: { - provider: request.provider, - model: request.model, - temperature, - }, + options: buildRouteFollowingDirectorLlmOptions(request, { temperature }), }); return normalizeBookContract(parsed.output); } @@ -97,7 +94,11 @@ export async function runDirectorStoryMacroPhase(input: { itemLabel: "正在生成故事宏观规划", progress: DIRECTOR_PROGRESS.storyMacro, callbacks, - run: async () => dependencies.storyMacroService.decompose(novelId, storyInput, request), + run: async () => dependencies.storyMacroService.decompose( + novelId, + storyInput, + buildRouteFollowingDirectorLlmOptions(request), + ), }); const hydratedStoryMacroPlan = await runDirectorTrackedStep({ taskId, diff --git a/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts b/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts index 013ef6b84..a9e5d28d1 100644 --- a/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts +++ b/server/src/services/novel/director/novelDirectorStructuredOutlinePhase.ts @@ -9,6 +9,7 @@ import { getChapterTitleDiversityIssue } from "../volume/chapterTitleDiversity"; import { buildNovelEditResumeTarget } from "../workflow/novelWorkflow.shared"; import { logMemoryUsage } from "../../../runtime/memoryTelemetry"; import { + buildRouteFollowingDirectorLlmOptions, buildDirectorSessionState, normalizeDirectorRunMode, } from "./novelDirectorHelpers"; @@ -183,6 +184,7 @@ export async function runDirectorStructuredOutlinePhase(input: { callbacks: DirectorPhaseCallbacks; }): Promise { const { taskId, novelId, request, baseWorkspace, dependencies, callbacks } = input; + const routeLlmOptions = buildRouteFollowingDirectorLlmOptions(request); const chapterSyncMode = resolveStructuredOutlineChapterSyncMode({ explicitMode: input.chapterSyncMode, takeoverStrategy: input.takeoverStrategy, @@ -264,9 +266,7 @@ export async function runDirectorStructuredOutlinePhase(input: { volumeId: targetVolume.id, callbacks, run: async ({ updateStatus, signal }) => dependencies.volumeService.generateVolumes(novelId, { - provider: request.provider, - model: request.model, - temperature: request.temperature, + ...routeLlmOptions, scope: "beat_sheet", targetVolumeId: targetVolume.id, draftWorkspace: workspace, @@ -308,9 +308,7 @@ export async function runDirectorStructuredOutlinePhase(input: { volumeId: targetVolume.id, callbacks, run: async ({ updateStatus, signal }) => dependencies.volumeService.generateVolumes(novelId, { - provider: request.provider, - model: request.model, - temperature: request.temperature, + ...routeLlmOptions, scope: "chapter_list", targetVolumeId: targetVolume.id, draftWorkspace: workspace, @@ -393,9 +391,7 @@ export async function runDirectorStructuredOutlinePhase(input: { volumeId: targetVolumeId, callbacks, run: async ({ signal }) => dependencies.volumeService.generateVolumes(novelId, { - provider: request.provider, - model: request.model, - temperature: request.temperature, + ...routeLlmOptions, scope: "chapter_detail", targetVolumeId, targetChapterId, diff --git a/server/src/services/novel/novelCorePipelineService.ts b/server/src/services/novel/novelCorePipelineService.ts index 19777ac07..bad3da1df 100644 --- a/server/src/services/novel/novelCorePipelineService.ts +++ b/server/src/services/novel/novelCorePipelineService.ts @@ -346,8 +346,8 @@ export class NovelCorePipelineService { matchedChapters: chapters.length, availableRange: `${chapterStats._min.order ?? 1}-${chapterStats._max.order ?? 1}`, maxRetries: options.maxRetries ?? 1, - provider: options.provider ?? "deepseek", - model: options.model ?? "", + provider: options.provider ?? "route", + model: options.model ?? "route", }); const job = await prisma.generationJob.create({ @@ -367,9 +367,9 @@ export class NovelCorePipelineService { maxRetries: options.maxRetries ?? 1, currentStage: "queued", payload: this.stringifyPipelinePayload({ - provider: options.provider ?? "deepseek", - model: options.model ?? "", - temperature: options.temperature ?? 0.8, + provider: options.provider, + model: options.model, + temperature: options.temperature, controlPolicy: options.controlPolicy, workflowTaskId: options.workflowTaskId?.trim() || undefined, taskStyleProfileId: options.taskStyleProfileId?.trim() || undefined, @@ -550,9 +550,9 @@ export class NovelCorePipelineService { }); const persistedPayload = this.parsePipelinePayload(existingJob?.payload); const runtimePayload: PipelinePayload = { - provider: persistedPayload.provider ?? options.provider ?? "deepseek", - model: persistedPayload.model ?? options.model ?? "", - temperature: persistedPayload.temperature ?? options.temperature ?? 0.8, + provider: persistedPayload.provider ?? options.provider, + model: persistedPayload.model ?? options.model, + temperature: persistedPayload.temperature ?? options.temperature, controlPolicy: persistedPayload.controlPolicy ?? options.controlPolicy, workflowTaskId: persistedPayload.workflowTaskId ?? options.workflowTaskId, taskStyleProfileId: persistedPayload.taskStyleProfileId ?? options.taskStyleProfileId, @@ -675,16 +675,16 @@ export class NovelCorePipelineService { novelId, chapter.id, { - provider: options.provider, - model: options.model, - temperature: options.temperature, + provider: runtimePayload.provider, + model: runtimePayload.model, + temperature: runtimePayload.temperature, controlPolicy: runtimePayload.controlPolicy, taskStyleProfileId: runtimePayload.taskStyleProfileId, maxRetries, - autoReview: options.autoReview, - autoRepair: options.autoRepair, + autoReview: runtimePayload.autoReview, + autoRepair: runtimePayload.autoRepair, qualityThreshold, - repairMode: options.repairMode, + repairMode: runtimePayload.repairMode, }, { onCheckCancelled: () => this.ensurePipelineNotCancelled(jobId), diff --git a/server/src/services/novel/pipelineJobState.ts b/server/src/services/novel/pipelineJobState.ts index 7c1c8842b..f6fc9546f 100644 --- a/server/src/services/novel/pipelineJobState.ts +++ b/server/src/services/novel/pipelineJobState.ts @@ -231,9 +231,9 @@ export function stringifyPipelinePayload(input: PipelinePayload): string { const qualityAlertDetails = normalizeStringList(input.qualityAlertDetails) ?? []; const backgroundSync = normalizePipelineBackgroundSync(input.backgroundSync); return JSON.stringify({ - provider: input.provider ?? "deepseek", - model: input.model ?? "", - temperature: input.temperature ?? 0.8, + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model?.trim() ? { model: input.model.trim() } : {}), + ...(typeof input.temperature === "number" ? { temperature: input.temperature } : {}), ...(input.workflowTaskId?.trim() ? { workflowTaskId: input.workflowTaskId.trim() } : {}), ...(input.controlPolicy ? { controlPolicy: input.controlPolicy } : {}), ...(typeof input.maxRetries === "number" ? { maxRetries: input.maxRetries } : {}), diff --git a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts index 8b8baf72b..e2573ed11 100644 --- a/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts +++ b/server/src/services/task/adapters/NovelWorkflowTaskAdapter.ts @@ -11,6 +11,7 @@ import type { TaskStatus, UnifiedTaskDetail, UnifiedTaskSummary } from "@ai-nove import { prisma } from "../../../db/prisma"; import { AppError } from "../../../middleware/errorHandler"; import { NovelDirectorService } from "../../novel/director/NovelDirectorService"; +import { resolveModel, type TaskType } from "../../../llm/modelRouter"; import { buildSkippableAutoExecutionReviewBlockingReason, buildSkippableAutoExecutionReviewCheckpointSummary, @@ -20,7 +21,9 @@ import { } from "../../novel/director/novelDirectorAutoExecutionFailure"; import { NovelWorkflowService } from "../../novel/workflow/NovelWorkflowService"; import { + buildTaskModelDirectorLlmOptions, getDirectorLlmOptionsFromSeedPayload, + isDirectorTaskModelBinding, type DirectorWorkflowSeedPayload, } from "../../novel/director/novelDirectorHelpers"; import { isAutoDirectorRecoveryInProgress } from "../../novel/workflow/novelWorkflowRecoveryHeuristics"; @@ -150,6 +153,48 @@ function parseAutoExecutionState(seedPayloadJson?: string | null): DirectorAutoE return seedPayload.autoExecution as DirectorAutoExecutionState; } +function resolveWorkflowTaskRouteTaskType(input: { + checkpointType?: string | null; + currentStage?: string | null; + currentItemKey?: string | null; +}): TaskType { + if ( + input.checkpointType === "replan_required" + || input.currentItemKey === "quality_repair" + || input.currentStage?.includes("质量") + ) { + return "repair"; + } + if ( + input.currentItemKey === "chapter_execution" + || input.currentStage?.includes("章节执行") + ) { + return "writer"; + } + return "planner"; +} + +async function resolveAutoDirectorDetailLlm(input: { + seedPayload: DirectorWorkflowSeedPayload | null; + checkpointType?: string | null; + currentStage?: string | null; + currentItemKey?: string | null; +}): Promise | null> { + if (isDirectorTaskModelBinding(input.seedPayload)) { + return buildTaskModelDirectorLlmOptions(input.seedPayload); + } + try { + const route = await resolveModel(resolveWorkflowTaskRouteTaskType(input)); + return { + provider: route.provider, + model: route.model, + temperature: route.temperature, + }; + } catch { + return getDirectorLlmOptionsFromSeedPayload(input.seedPayload); + } +} + export function normalizeWorkflowResumeTargetForCandidateSelection(input: { id: string; checkpointType: string | null; @@ -422,7 +467,12 @@ export class NovelWorkflowTaskAdapter { const directorSession = workflowSeedPayload && typeof workflowSeedPayload.directorSession === "object" ? workflowSeedPayload.directorSession : null; - const boundLlm = getDirectorLlmOptionsFromSeedPayload(workflowSeedPayload); + const boundLlm = await resolveAutoDirectorDetailLlm({ + seedPayload: workflowSeedPayload, + checkpointType: row.checkpointType, + currentStage: row.currentStage, + currentItemKey: row.currentItemKey, + }); return { ...summary, diff --git a/server/src/services/title/TitleGenerationService.ts b/server/src/services/title/TitleGenerationService.ts index f2075daab..feb7991f7 100644 --- a/server/src/services/title/TitleGenerationService.ts +++ b/server/src/services/title/TitleGenerationService.ts @@ -36,9 +36,9 @@ export interface GenerateNovelTitlesInput extends TitleGenerationLLMOptions { } async function shouldForceTitleJsonOutput(input: TitleGenerationLLMOptions): Promise { - const resolved = await resolveLLMClientOptions(input.provider ?? "deepseek", { + const resolved = await resolveLLMClientOptions(input.provider, { model: input.model, - temperature: input.temperature ?? 0.85, + temperature: input.temperature, maxTokens: input.maxTokens, taskType: titleGenerationPrompt.taskType, executionMode: "structured", @@ -193,7 +193,6 @@ export class TitleGenerationService { llmOptions: TitleGenerationLLMOptions, blockedTitles: string[] = [], ): Promise<{ titles: TitleFactorySuggestion[] }> { - const provider = llmOptions.provider ?? "deepseek"; const forceJson = await shouldForceTitleJsonOutput(llmOptions); const count = normalizeRequestedCount(promptContext.count, DEFAULT_TITLE_COUNT); @@ -214,9 +213,9 @@ export class TitleGenerationService { retryReason, }, options: { - provider, + provider: llmOptions.provider, model: llmOptions.model, - temperature: llmOptions.temperature ?? 0.85, + temperature: llmOptions.temperature, maxTokens: llmOptions.maxTokens, }, }); diff --git a/server/tests/autoDirectorApprovalPreferenceContract.test.js b/server/tests/autoDirectorApprovalPreferenceContract.test.js index 6aff0dd3e..5a48c4d41 100644 --- a/server/tests/autoDirectorApprovalPreferenceContract.test.js +++ b/server/tests/autoDirectorApprovalPreferenceContract.test.js @@ -8,6 +8,11 @@ const { } = require("../../shared/dist/types/autoDirectorApproval.js"); const { buildWorkflowSeedPayload, + buildRouteFollowingDirectorLlmOptions, + buildTaskModelDirectorLlmOptions, + applyDirectorLlmOverride, + getDirectorLlmOptionsFromSeedPayload, + isDirectorTaskModelBinding, } = require("../dist/services/novel/director/novelDirectorHelpers.js"); test("auto approval config normalizes concrete point codes and ignores invalid values", () => { @@ -66,3 +71,39 @@ test("director seed payload stores book-level auto approval selection", () => { approvalPointCodes: ["chapter_execution_continue"], }); }); + +test("director default model binding follows route while explicit task override preserves task model", () => { + const legacySeed = buildWorkflowSeedPayload({ + idea: "A city sleeps under glass.", + provider: "openai", + model: "legacy-model", + temperature: 0.66, + runMode: "auto_to_execution", + }); + + assert.equal(isDirectorTaskModelBinding(legacySeed), false); + assert.deepEqual(buildRouteFollowingDirectorLlmOptions(legacySeed), {}); + assert.deepEqual(buildTaskModelDirectorLlmOptions(legacySeed), { + provider: "openai", + model: "legacy-model", + temperature: 0.66, + }); + + const overridden = applyDirectorLlmOverride(legacySeed, { + provider: "openai", + model: "route-selected-model", + temperature: 0.2, + }); + + assert.equal(isDirectorTaskModelBinding(overridden), true); + assert.deepEqual(getDirectorLlmOptionsFromSeedPayload(overridden), { + provider: "openai", + model: "route-selected-model", + temperature: 0.2, + }); + assert.deepEqual(buildRouteFollowingDirectorLlmOptions(overridden), { + provider: "openai", + model: "route-selected-model", + temperature: 0.2, + }); +}); diff --git a/server/tests/novelPipelineState.test.js b/server/tests/novelPipelineState.test.js index 870e31837..c721107c3 100644 --- a/server/tests/novelPipelineState.test.js +++ b/server/tests/novelPipelineState.test.js @@ -5,6 +5,10 @@ const { prisma } = require("../dist/db/prisma.js"); const { novelEventBus } = require("../dist/events/index.js"); const reviewService = require("../dist/services/novel/novelCoreReviewService.js"); const { NovelCorePipelineService } = require("../dist/services/novel/novelCorePipelineService.js"); +const { + parsePipelinePayload, + stringifyPipelinePayload, +} = require("../dist/services/novel/pipelineJobState.js"); test("listRecoverablePipelineJobs excludes cancellation-pending jobs", async () => { const originalFindMany = prisma.generationJob.findMany; @@ -45,6 +49,116 @@ test("retryPipelineJob rejects jobs that are still cancelling", async () => { } }); +test("pipeline payload keeps omitted model settings route-following", () => { + const payload = parsePipelinePayload(stringifyPipelinePayload({ + workflowTaskId: "workflow-route", + runMode: "fast", + autoReview: true, + autoRepair: true, + skipCompleted: true, + qualityThreshold: 75, + repairMode: "light_repair", + })); + + assert.equal(payload.provider, undefined); + assert.equal(payload.model, undefined); + assert.equal(payload.temperature, undefined); + assert.equal(payload.workflowTaskId, "workflow-route"); +}); + +test("executePipeline preserves route-following model options for chapter runtime", async () => { + const original = { + generationFindUnique: prisma.generationJob.findUnique, + generationUpdate: prisma.generationJob.update, + novelFindUnique: prisma.novel.findUnique, + chapterFindMany: prisma.chapter.findMany, + createQualityReport: reviewService.createQualityReport, + emit: novelEventBus.emit, + }; + + let capturedRuntimeOptions = null; + prisma.generationJob.findUnique = async (input) => { + if (input.select?.startedAt) { + return { + startedAt: null, + completedCount: 0, + totalCount: 1, + retryCount: 0, + payload: stringifyPipelinePayload({ + workflowTaskId: "workflow-route", + runMode: "fast", + autoReview: true, + autoRepair: true, + skipCompleted: true, + qualityThreshold: 75, + repairMode: "light_repair", + }), + }; + } + if (input.select?.status) { + return { + status: "running", + cancelRequestedAt: null, + }; + } + throw new Error(`Unexpected generationJob.findUnique call: ${JSON.stringify(input)}`); + }; + prisma.generationJob.update = async (input) => input; + prisma.novel.findUnique = async () => ({ + id: "novel-route", + title: "路由小说", + }); + prisma.chapter.findMany = async () => ([ + { id: "chapter-route", order: 1, title: "第一章", content: "" }, + ]); + reviewService.createQualityReport = async () => null; + novelEventBus.emit = async () => null; + + const service = new NovelCorePipelineService(); + service.chapterRuntimeCoordinator.runPipelineChapter = async (_novelId, _chapterId, options) => { + capturedRuntimeOptions = options; + return { + retryCountUsed: 0, + score: { + coherence: 88, + repetition: 8, + pacing: 82, + voice: 80, + engagement: 86, + overall: 84, + }, + issues: [], + pass: true, + }; + }; + + try { + await service.executePipeline("job-route", "novel-route", { + startOrder: 1, + endOrder: 1, + runMode: "fast", + autoReview: true, + autoRepair: true, + skipCompleted: true, + qualityThreshold: 75, + repairMode: "light_repair", + maxRetries: 1, + }); + + assert.ok(capturedRuntimeOptions); + assert.equal(capturedRuntimeOptions.provider, undefined); + assert.equal(capturedRuntimeOptions.model, undefined); + assert.equal(capturedRuntimeOptions.temperature, undefined); + } finally { + prisma.generationJob.findUnique = original.generationFindUnique; + prisma.generationJob.update = original.generationUpdate; + prisma.novel.findUnique = original.novelFindUnique; + prisma.chapter.findMany = original.chapterFindMany; + reviewService.createQualityReport = original.createQualityReport; + novelEventBus.emit = original.emit; + } +}); + test("executePipeline preserves persisted quality alerts across resume", async () => { const original = { generationFindUnique: prisma.generationJob.findUnique, diff --git a/server/tests/novelWorkflowTaskAdapterModelBinding.test.js b/server/tests/novelWorkflowTaskAdapterModelBinding.test.js index ed32d90cb..099039027 100644 --- a/server/tests/novelWorkflowTaskAdapterModelBinding.test.js +++ b/server/tests/novelWorkflowTaskAdapterModelBinding.test.js @@ -8,8 +8,18 @@ const { prisma } = require("../dist/db/prisma.js"); test("task detail exposes candidate-stage bound model before directorInput exists", async () => { const originals = { findUnique: prisma.novelWorkflowTask.findUnique, + routeFindUnique: prisma.modelRouteConfig.findUnique, }; + prisma.modelRouteConfig.findUnique = async ({ where }) => ({ + taskType: where.taskType, + provider: "openai", + model: "route-planner-model", + temperature: 0.31, + maxTokens: null, + requestProtocol: "openai_compatible", + structuredResponseFormat: "json_object", + }); prisma.novelWorkflowTask.findUnique = async () => ({ id: "task_candidate_binding", title: "AI 自动导演", @@ -59,6 +69,83 @@ test("task detail exposes candidate-stage bound model before directorInput exist try { const detail = await adapter.detail("task_candidate_binding"); assert.ok(detail); + assert.equal(detail.provider, "openai"); + assert.equal(detail.model, "route-planner-model"); + assert.deepEqual(detail.meta.llm, { + provider: "openai", + model: "route-planner-model", + temperature: 0.31, + }); + } finally { + prisma.novelWorkflowTask.findUnique = originals.findUnique; + prisma.modelRouteConfig.findUnique = originals.routeFindUnique; + adapter.workflowService.healAutoDirectorTaskState = originalHeal; + } +}); + +test("task detail exposes explicit task model when auto director seed is task-bound", async () => { + const originals = { + findUnique: prisma.novelWorkflowTask.findUnique, + routeFindUnique: prisma.modelRouteConfig.findUnique, + }; + + prisma.modelRouteConfig.findUnique = async ({ where }) => ({ + taskType: where.taskType, + provider: "openai", + model: "route-planner-model", + temperature: 0.31, + maxTokens: null, + requestProtocol: "openai_compatible", + structuredResponseFormat: "json_object", + }); + prisma.novelWorkflowTask.findUnique = async () => ({ + id: "task_bound_model", + title: "AI 自动导演", + lane: "auto_director", + status: "failed", + progress: 0.1, + currentStage: "AI 自动导演", + currentItemKey: "candidate_direction_batch", + currentItemLabel: "正在生成第一批书级方案", + checkpointType: null, + checkpointSummary: null, + resumeTargetJson: null, + attemptCount: 1, + maxAttempts: 3, + lastError: null, + createdAt: new Date("2026-04-09T09:00:00.000Z"), + updatedAt: new Date("2026-04-09T09:05:00.000Z"), + heartbeatAt: new Date("2026-04-09T09:05:00.000Z"), + promptTokens: 1200, + completionTokens: 600, + totalTokens: 1800, + llmCallCount: 2, + lastTokenRecordedAt: new Date("2026-04-09T09:05:00.000Z"), + novelId: null, + novel: null, + startedAt: new Date("2026-04-09T09:00:00.000Z"), + finishedAt: null, + cancelRequestedAt: null, + milestonesJson: null, + seedPayloadJson: JSON.stringify({ + idea: "A courier discovers a hidden rule-bound city underworld.", + provider: "custom_coding_plan", + model: "kimi-k2.5", + temperature: 0.8, + llmBindingMode: "task", + candidateStage: { + mode: "generate", + }, + }), + }); + + const adapter = new NovelWorkflowTaskAdapter(); + const originalHeal = adapter.workflowService.healAutoDirectorTaskState; + adapter.workflowService.healAutoDirectorTaskState = async () => false; + + try { + const detail = await adapter.detail("task_bound_model"); + assert.ok(detail); assert.equal(detail.provider, "custom_coding_plan"); assert.equal(detail.model, "kimi-k2.5"); assert.deepEqual(detail.meta.llm, { @@ -68,6 +155,7 @@ test("task detail exposes candidate-stage bound model before directorInput exist }); } finally { prisma.novelWorkflowTask.findUnique = originals.findUnique; + prisma.modelRouteConfig.findUnique = originals.routeFindUnique; adapter.workflowService.healAutoDirectorTaskState = originalHeal; } }); diff --git a/server/tests/structuredInvoke.test.js b/server/tests/structuredInvoke.test.js index 1d62e39be..0b76b3352 100644 --- a/server/tests/structuredInvoke.test.js +++ b/server/tests/structuredInvoke.test.js @@ -408,6 +408,79 @@ test("invokeStructuredLlmDetailed preserves routed structured format for explici } }); +test("invokeStructuredLlmDetailed leaves route temperature unset when caller does not override it", async () => { + const originalResolveOptions = factory.resolveLLMClientOptions; + const originalCreateLLM = factory.createLLMFromResolvedOptions; + const originalGetFallbackSettings = structuredFallbackSettings.getStructuredFallbackSettings; + const temperatures = []; + + factory.resolveLLMClientOptions = async (provider, options = {}) => { + temperatures.push(options.temperature); + const resolvedProvider = provider ?? "openai"; + const resolvedModel = options.model ?? "glm-5"; + const baseURL = options.baseURL ?? "https://api.openai.com/v1"; + const structuredProfile = options.executionMode === "structured" + ? resolveStructuredOutputProfile({ + provider: resolvedProvider, + model: resolvedModel, + baseURL, + executionMode: "structured", + }) + : null; + return { + provider: resolvedProvider, + providerName: resolvedProvider, + model: resolvedModel, + temperature: options.temperature ?? 0.42, + apiKey: "test-key", + baseURL, + maxTokens: options.maxTokens, + requestProtocol: "openai_compatible", + reasoningEnabled: !(structuredProfile?.requiresNonThinkingForStructured), + modelKwargs: undefined, + includeRawResponse: false, + executionMode: options.executionMode ?? "plain", + structuredProfile, + structuredStrategy: options.structuredStrategy ?? "json_object", + reasoningForcedOff: Boolean(structuredProfile?.requiresNonThinkingForStructured), + taskType: options.taskType, + promptMeta: options.promptMeta, + }; + }; + factory.createLLMFromResolvedOptions = () => ({ + invoke: async () => ({ + content: "{\"value\":\"route-temperature\"}", + }), + }); + structuredFallbackSettings.getStructuredFallbackSettings = async () => ({ + enabled: false, + provider: "deepseek", + model: "deepseek-chat", + temperature: 0.2, + maxTokens: null, + }); + + try { + const result = await structuredInvoke.invokeStructuredLlmDetailed({ + label: "structured.invoke.route-temperature", + taskType: "planner", + schema: z.object({ + value: z.string(), + }), + systemPrompt: "只返回 JSON。", + userPrompt: "给我一个 value。", + disableFallbackModel: true, + }); + + assert.deepEqual(result.data, { value: "route-temperature" }); + assert.equal(temperatures[0], undefined); + } finally { + factory.resolveLLMClientOptions = originalResolveOptions; + factory.createLLMFromResolvedOptions = originalCreateLLM; + structuredFallbackSettings.getStructuredFallbackSettings = originalGetFallbackSettings; + } +}); + test("invokeStructuredLlmDetailed switches to the configured fallback model after primary transport failure", async () => { const originalResolveOptions = factory.resolveLLMClientOptions; const originalCreateLLM = factory.createLLMFromResolvedOptions; diff --git a/server/tests/titleGeneration.test.js b/server/tests/titleGeneration.test.js index 64918e1b2..c092d1423 100644 --- a/server/tests/titleGeneration.test.js +++ b/server/tests/titleGeneration.test.js @@ -9,6 +9,11 @@ const { const { titleGenerationPrompt, } = require("../dist/prompting/prompts/helper/titleGeneration.prompt.js"); +const factory = require("../dist/llm/factory.js"); +const promptRunner = require("../dist/prompting/core/promptRunner.js"); +const { + TitleGenerationService, +} = require("../dist/services/title/TitleGenerationService.js"); test("collectUniqueSuggestions maps legacy title fields into the current schema", () => { const titles = collectUniqueSuggestions([ @@ -100,3 +105,68 @@ test("title prompt render now asks for current output fields and structure diver assert.match(systemPrompt, /句式框架/); assert.match(systemPrompt, /标题句式框架过于集中/); }); + +test("title generation follows model route when no explicit provider is selected", async () => { + const originalResolveOptions = factory.resolveLLMClientOptions; + const originalStructuredInvoker = promptRunner.setPromptRunnerStructuredInvokerForTests; + const capturedResolveProviders = []; + const capturedPromptProviders = []; + + factory.resolveLLMClientOptions = async (provider, options = {}) => { + capturedResolveProviders.push(provider); + return { + provider: provider ?? "openai", + providerName: provider ?? "openai", + model: options.model ?? "route-title-model", + temperature: options.temperature ?? 0.85, + apiKey: "test-key", + baseURL: "https://api.openai.com/v1", + maxTokens: options.maxTokens, + concurrencyLimit: 0, + requestIntervalMs: 0, + requestProtocol: "openai_compatible", + reasoningEnabled: true, + modelKwargs: undefined, + includeRawResponse: false, + executionMode: options.executionMode ?? "plain", + structuredProfile: null, + structuredStrategy: null, + reasoningForcedOff: false, + taskType: options.taskType, + promptMeta: options.promptMeta, + }; + }; + promptRunner.setPromptRunnerStructuredInvokerForTests(async (input) => { + capturedPromptProviders.push(input.provider); + return { + data: { + titles: [ + { title: "灾变超市,每天刷新一座仓库", clickRate: 92, style: "high_concept", angle: "资源刷新", reason: "卖点直接" }, + { title: "别人抢水,我的货架自动补满", clickRate: 90, style: "contrast", angle: "补货反差", reason: "反差清楚" }, + { title: "末日规则:超市老板拥有无限库存", clickRate: 89, style: "suspense", angle: "规则库存", reason: "规则感强" }, + { title: "丧尸围城,我把便利店开成堡垒", clickRate: 88, style: "conflict", angle: "便利店堡垒", reason: "场景鲜明" }, + { title: "当全城断粮,我的收银台亮了", clickRate: 87, style: "literary", angle: "断粮收银台", reason: "画面有钩子" }, + { title: "在废土开店,我卖的是明天", clickRate: 86, style: "fantasy", angle: "废土开店", reason: "记忆点强" }, + ], + }, + repairUsed: false, + repairAttempts: 0, + }; + }); + + try { + const service = new TitleGenerationService(); + const result = await service.generateTitleIdeas({ + mode: "brief", + brief: "末世丧尸题材,主角拥有不断刷新的超市资源。", + count: 6, + }); + + assert.equal(result.titles.length, 6); + assert.deepEqual(capturedResolveProviders, [undefined]); + assert.deepEqual(capturedPromptProviders, [undefined]); + } finally { + factory.resolveLLMClientOptions = originalResolveOptions; + originalStructuredInvoker(); + } +}); diff --git a/shared/types/novelDirector.ts b/shared/types/novelDirector.ts index fb61a5d02..dda00ee58 100644 --- a/shared/types/novelDirector.ts +++ b/shared/types/novelDirector.ts @@ -264,6 +264,7 @@ export interface DirectorLLMOptions { provider?: LLMProvider; model?: string; temperature?: number; + llmBindingMode?: "route" | "task"; runMode?: DirectorRunMode; } From be4edde4b12ad36bcc1ed9d5a7e393d3a3c37bef Mon Sep 17 00:00:00 2001 From: caoty Date: Wed, 29 Apr 2026 02:29:28 +0800 Subject: [PATCH 10/12] fix: advance after low risk quality notice --- README.md | 2 +- docs/releases/release-notes.md | 1 + .../novelDirectorAutoExecutionRuntime.ts | 33 ++++++++++++++++++- .../novelDirectorAutoExecutionRuntime.test.js | 2 +- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e37ff5e45..986eee133 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ ### 2026-04-29 -AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型。章节流水线不再沿用导演任务里临时选择的模型,任务详情会按当前阶段显示最新路由模型,章节细化、任务单生成等结构化调用也会继续遵守路由里的结构化输出格式,减少单一模型或渠道异常导致整条执行链失败。 +AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。章节流水线也会按模型路由选择写作、审校、修复和事实提取模型,任务详情会按当前阶段显示最新路由模型。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 46ad92e5b..a679ea343 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -6,6 +6,7 @@ ### 2026-04-29 +- AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。 - AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线;任务详情也会按当前阶段显示最新路由模型,调整路由后不用重建任务即可看到变更,减少单一模型或渠道异常导致整条执行链失败。 - 章节细化、任务单生成等结构化调用会继续遵守模型路由里的结构化输出格式。即使流程内部已经带上了路由选中的模型,也不会丢掉 `json_object`、`json_schema` 或提示词 JSON 的配置偏好。 diff --git a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts index 92cce6d2f..c7b3f8105 100644 --- a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts +++ b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts @@ -158,6 +158,37 @@ function resolveSingleChapterExecutionRange( }; } +function markCurrentQualityNoticeChapterSkipped( + autoExecution: DirectorAutoExecutionState, +): DirectorAutoExecutionState { + const nextChapterId = autoExecution.nextChapterId?.trim() || null; + const nextChapterOrder = typeof autoExecution.nextChapterOrder === "number" + ? autoExecution.nextChapterOrder + : null; + if (!nextChapterId && nextChapterOrder == null) { + return autoExecution; + } + const skippedChapterIds = Array.from(new Set( + [ + ...(autoExecution.skippedChapterIds ?? []), + ...(nextChapterId ? [nextChapterId] : []), + ], + )); + const skippedChapterOrders = Array.from(new Set( + [ + ...(autoExecution.skippedChapterOrders ?? []), + ...(typeof nextChapterOrder === "number" ? [nextChapterOrder] : []), + ], + )).sort((left, right) => left - right); + return { + ...autoExecution, + skippedChapterIds, + skippedChapterOrders, + pipelineJobId: null, + pipelineStatus: null, + }; +} + export class NovelDirectorAutoExecutionRuntime { constructor(private readonly deps: NovelDirectorAutoExecutionRuntimeDeps) {} @@ -505,7 +536,7 @@ export class NovelDirectorAutoExecutionRuntime { pipelineJobId = ""; ({ range, autoExecution } = await this.resolveRangeAndState({ novelId: input.novelId, - existingState: noticeAction.checkpointState, + existingState: markCurrentQualityNoticeChapterSkipped(noticeAction.checkpointState), pipelineJobId: null, pipelineStatus: "queued", })); diff --git a/server/tests/novelDirectorAutoExecutionRuntime.test.js b/server/tests/novelDirectorAutoExecutionRuntime.test.js index 0175fcab1..ef18972fa 100644 --- a/server/tests/novelDirectorAutoExecutionRuntime.test.js +++ b/server/tests/novelDirectorAutoExecutionRuntime.test.js @@ -496,7 +496,7 @@ test("runFromReady notifies and continues low-risk quality repair in AI-driver e withExecutionDetail({ id: "chapter-2", order: 2, generationState: "planned", chapterStatus: "unplanned", content: "" }), ] : [ - { id: "chapter-1", order: 1, generationState: "repaired", chapterStatus: "completed", content: "正文1" }, + withExecutionDetail({ id: "chapter-1", order: 1, generationState: "reviewed", chapterStatus: "needs_repair", content: "正文1" }), withExecutionDetail({ id: "chapter-2", order: 2, generationState: "planned", chapterStatus: "unplanned", content: "" }), ]; }, From 1469357b6fa8cede35315a6b6e72be55f5958d18 Mon Sep 17 00:00:00 2001 From: caoty Date: Wed, 29 Apr 2026 02:45:19 +0800 Subject: [PATCH 11/12] fix: persist pipeline chapter status --- README.md | 2 +- docs/releases/release-notes.md | 1 + .../novel/runtime/ChapterRuntimeCoordinator.ts | 4 +++- .../services/novel/runtime/chapterRuntimePipeline.ts | 9 +++++++++ server/tests/chapterRuntimePipeline.test.js | 11 +++++++++++ 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 986eee133..e64feaf1f 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ ### 2026-04-29 -AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。章节流水线也会按模型路由选择写作、审校、修复和事实提取模型,任务详情会按当前阶段显示最新路由模型。 +AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。章节流水线会同步更新章节完成或待修复状态,避免自动继续时把已审校章节误判为仍在生成中;同时会按模型路由选择写作、审校、修复和事实提取模型,任务详情会按当前阶段显示最新路由模型。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index a679ea343..1a3981909 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -6,6 +6,7 @@ ### 2026-04-29 +- 章节流水线完成审校后会同步更新章节执行状态。通过审校的章节会标记为已完成,自动修复一次后仍低于质量阈值的章节会标记为待修复;AI 主驾自动继续时不会再把这类章节当成仍在生成中,从而反复回到同一章。 - AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。 - AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线;任务详情也会按当前阶段显示最新路由模型,调整路由后不用重建任务即可看到变更,减少单一模型或渠道异常导致整条执行链失败。 - 章节细化、任务单生成等结构化调用会继续遵守模型路由里的结构化输出格式。即使流程内部已经带上了路由选中的模型,也不会丢掉 `json_object`、`json_schema` 或提示词 JSON 的配置偏好。 diff --git a/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts b/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts index a4394682c..50c151ee4 100644 --- a/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts +++ b/server/src/services/novel/runtime/ChapterRuntimeCoordinator.ts @@ -279,6 +279,8 @@ export class ChapterRuntimeCoordinator { }, markChapterGenerationState: (targetChapterId, generationState) => this.markChapterGenerationState(targetChapterId, generationState), + markChapterStatus: (targetChapterId, chapterStatus) => + this.markChapterStatus(targetChapterId, chapterStatus), }, novelId, chapterId, @@ -710,7 +712,7 @@ export class ChapterRuntimeCoordinator { private async markChapterStatus( chapterId: string, - chapterStatus: "generating" | "pending_review" | "needs_repair", + chapterStatus: "generating" | "pending_review" | "needs_repair" | "completed", ): Promise { await prisma.chapter.update({ where: { id: chapterId }, diff --git a/server/src/services/novel/runtime/chapterRuntimePipeline.ts b/server/src/services/novel/runtime/chapterRuntimePipeline.ts index e15cd0e18..9a8357193 100644 --- a/server/src/services/novel/runtime/chapterRuntimePipeline.ts +++ b/server/src/services/novel/runtime/chapterRuntimePipeline.ts @@ -74,6 +74,10 @@ interface RunPipelineChapterDeps { chapterId: string, generationState: "reviewed" | "approved", ) => Promise; + markChapterStatus: ( + chapterId: string, + chapterStatus: "needs_repair" | "completed", + ) => Promise; } const QUALITY_THRESHOLD = { coherence: 80, repetition: 20, engagement: 75 }; @@ -132,6 +136,7 @@ export async function runPipelineChapterWithRuntime( if (!autoReview) { await deps.markChapterGenerationState(chapterId, "approved"); + await deps.markChapterStatus(chapterId, "completed"); return { reviewExecuted: false, pass: true, @@ -167,6 +172,7 @@ export async function runPipelineChapterWithRuntime( pass = isQualityPass(latestResult.runtimePackage.audit.score, qualityThreshold); if (pass) { await deps.markChapterGenerationState(chapterId, "approved"); + await deps.markChapterStatus(chapterId, "completed"); break; } @@ -195,6 +201,9 @@ export async function runPipelineChapterWithRuntime( if (!latestResult) { throw new Error("Pipeline chapter runtime did not produce a result."); } + if (!pass) { + await deps.markChapterStatus(chapterId, "needs_repair"); + } return { reviewExecuted: true, diff --git a/server/tests/chapterRuntimePipeline.test.js b/server/tests/chapterRuntimePipeline.test.js index 77b4984c5..c222921de 100644 --- a/server/tests/chapterRuntimePipeline.test.js +++ b/server/tests/chapterRuntimePipeline.test.js @@ -38,6 +38,7 @@ function createRuntimePackage(overallScore) { test("runPipelineChapterWithRuntime skips review and repair when autoReview is disabled", async () => { const stages = []; const generationStates = []; + const chapterStatuses = []; const savedDrafts = []; let finalizeCalled = false; @@ -73,6 +74,9 @@ test("runPipelineChapterWithRuntime skips review and repair when autoReview is d async markChapterGenerationState(_chapterId, generationState) { generationStates.push(generationState); }, + async markChapterStatus(_chapterId, chapterStatus) { + chapterStatuses.push(chapterStatus); + }, }, "novel-1", "chapter-1", @@ -94,6 +98,7 @@ test("runPipelineChapterWithRuntime skips review and repair when autoReview is d generationState: "drafted", }]); assert.deepEqual(generationStates, ["approved"]); + assert.deepEqual(chapterStatuses, ["completed"]); assert.equal(result.reviewExecuted, false); assert.equal(result.pass, true); assert.equal(result.retryCountUsed, 0); @@ -144,6 +149,7 @@ test("runPipelineChapterWithRuntime does not save a generated draft twice when w }; }, async markChapterGenerationState() {}, + async markChapterStatus() {}, }, "novel-1", "chapter-1", @@ -164,6 +170,7 @@ test("runPipelineChapterWithRuntime defaults to a single repair pass before stop const finalizeInputs = []; const savedDrafts = []; const generationStates = []; + const chapterStatuses = []; let reviewCount = 0; promptRunner.runTextPrompt = async () => ({ @@ -207,6 +214,9 @@ test("runPipelineChapterWithRuntime defaults to a single repair pass before stop async markChapterGenerationState(_chapterId, generationState) { generationStates.push(generationState); }, + async markChapterStatus(_chapterId, chapterStatus) { + chapterStatuses.push(chapterStatus); + }, }, "novel-1", "chapter-1", @@ -227,6 +237,7 @@ test("runPipelineChapterWithRuntime defaults to a single repair pass before stop assert.equal(result.retryCountUsed, 1); assert.equal(result.pass, false); assert.deepEqual(generationStates, ["reviewed", "reviewed"]); + assert.deepEqual(chapterStatuses, ["needs_repair"]); assert.deepEqual(savedDrafts, [ { content: "生成后的正文", From 8664527a57252bb01cb82f59fcdab1622c59b23a Mon Sep 17 00:00:00 2001 From: caoty Date: Wed, 29 Apr 2026 03:13:22 +0800 Subject: [PATCH 12/12] fix: advance director after repair count --- README.md | 2 +- docs/releases/release-notes.md | 1 + .../director/novelDirectorAutoExecution.ts | 3 +- .../novelDirectorAutoExecutionRuntime.ts | 1 + .../novel/novelCorePipelineService.ts | 54 ++++++++++- server/src/services/novel/novelCoreShared.ts | 2 + server/src/services/novel/pipelineJobState.ts | 6 ++ .../novel/runtime/chapterRuntimePipeline.ts | 5 ++ server/tests/chapterRuntimePipeline.test.js | 89 +++++++++++++++++++ .../tests/novelDirectorAutoExecution.test.js | 16 ++++ server/tests/novelPipelineState.test.js | 3 + 11 files changed, 177 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e64feaf1f..0359ef7a5 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ ### 2026-04-29 -AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。章节流水线会同步更新章节完成或待修复状态,避免自动继续时把已审校章节误判为仍在生成中;同时会按模型路由选择写作、审校、修复和事实提取模型,任务详情会按当前阶段显示最新路由模型。 +AI 主驾自动执行章节会按自动修复次数推进。某章完成允许的一次自动修复后,即使复审或伏笔同步还停留在当前章,系统也会记录提醒并继续下一章,不再因为当前章未再次达标而反复停住。章节流水线会同步更新章节完成或待修复状态,并继续按模型路由选择写作、审校、修复和事实提取模型。 ## 功能预览 ### 功能概览中的95%以上编写都是AI完成 diff --git a/docs/releases/release-notes.md b/docs/releases/release-notes.md index 1a3981909..e55a651bb 100644 --- a/docs/releases/release-notes.md +++ b/docs/releases/release-notes.md @@ -6,6 +6,7 @@ ### 2026-04-29 +- AI 主驾章节执行会按自动修复次数推进。某章完成允许的一次自动修复后,即使复审或伏笔同步还停留在当前章,系统也会把该章作为已处理章节记录提醒,并继续下一章,避免 `nextChapterOrder` 已经前进但流程仍卡在原章节。 - 章节流水线完成审校后会同步更新章节执行状态。通过审校的章节会标记为已完成,自动修复一次后仍低于质量阈值的章节会标记为待修复;AI 主驾自动继续时不会再把这类章节当成仍在生成中,从而反复回到同一章。 - AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。 - AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线;任务详情也会按当前阶段显示最新路由模型,调整路由后不用重建任务即可看到变更,减少单一模型或渠道异常导致整条执行链失败。 diff --git a/server/src/services/novel/director/novelDirectorAutoExecution.ts b/server/src/services/novel/director/novelDirectorAutoExecution.ts index 835c92e3d..bb56909db 100644 --- a/server/src/services/novel/director/novelDirectorAutoExecution.ts +++ b/server/src/services/novel/director/novelDirectorAutoExecution.ts @@ -229,7 +229,7 @@ export function isDirectorAutoExecutionChapterProcessed(chapter: DirectorAutoExe return true; } if (chapter.chapterStatus === "needs_repair") { - return false; + return chapter.generationState === "repaired"; } if (chapter.chapterStatus === "pending_review") { return true; @@ -385,6 +385,7 @@ export function buildDirectorAutoExecutionPipelineOptions(input: { skipCompleted: true, qualityThreshold: 75, repairMode: "light_repair" as const, + advanceAfterAutoRepairLimit: autoReview && (input.autoRepair ?? true), workflowTaskId: input.workflowTaskId, taskStyleProfileId: input.taskStyleProfileId, }; diff --git a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts index c7b3f8105..9486cf2a8 100644 --- a/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts +++ b/server/src/services/novel/director/novelDirectorAutoExecutionRuntime.ts @@ -82,6 +82,7 @@ interface NovelDirectorAutoExecutionNovelPort { skipCompleted: boolean; qualityThreshold: number; repairMode: "light_repair"; + advanceAfterAutoRepairLimit?: boolean; }): Promise<{ id: string; status: PipelineJobStatus }>; findActivePipelineJobForRange( novelId: string, diff --git a/server/src/services/novel/novelCorePipelineService.ts b/server/src/services/novel/novelCorePipelineService.ts index bad3da1df..f1cfed70a 100644 --- a/server/src/services/novel/novelCorePipelineService.ts +++ b/server/src/services/novel/novelCorePipelineService.ts @@ -21,6 +21,24 @@ export { buildPipelineCurrentItemLabel, buildPipelineStageProgress } from "./pip const PIPELINE_HEARTBEAT_INTERVAL_MS = 15000; +function resolveAdvanceAfterAutoRepairLimit(input: { + explicit?: boolean; + controlPolicy?: PipelinePayload["controlPolicy"]; + autoReview?: boolean; + autoRepair?: boolean; +}): boolean | undefined { + if (typeof input.explicit === "boolean") { + return input.explicit; + } + if ( + input.controlPolicy?.kickoffMode !== "director_start" + || input.controlPolicy.advanceMode !== "auto_to_execution" + ) { + return undefined; + } + return (input.autoReview ?? true) && (input.autoRepair ?? true) ? true : undefined; +} + function buildSkipCompletedChapterWhere(): Prisma.ChapterWhereInput { return { NOT: { @@ -278,6 +296,7 @@ export class NovelCorePipelineService { this.schedulePipelineExecution(job.id, job.novelId, { startOrder: job.startOrder, endOrder: job.endOrder, + controlPolicy: payload.controlPolicy, workflowTaskId: payload.workflowTaskId, taskStyleProfileId: payload.taskStyleProfileId, maxRetries: job.maxRetries, @@ -287,6 +306,12 @@ export class NovelCorePipelineService { skipCompleted: job.skipCompleted ?? payload.skipCompleted, qualityThreshold: job.qualityThreshold ?? payload.qualityThreshold, repairMode: job.repairMode ?? payload.repairMode, + advanceAfterAutoRepairLimit: resolveAdvanceAfterAutoRepairLimit({ + explicit: payload.advanceAfterAutoRepairLimit, + controlPolicy: payload.controlPolicy, + autoReview: job.autoReview ?? payload.autoReview, + autoRepair: job.autoRepair ?? payload.autoRepair, + }), provider: payload.provider, model: payload.model, temperature: payload.temperature, @@ -380,6 +405,12 @@ export class NovelCorePipelineService { skipCompleted: options.skipCompleted ?? true, qualityThreshold: options.qualityThreshold, repairMode: options.repairMode ?? "light_repair", + advanceAfterAutoRepairLimit: resolveAdvanceAfterAutoRepairLimit({ + explicit: options.advanceAfterAutoRepairLimit, + controlPolicy: options.controlPolicy, + autoReview: options.autoReview, + autoRepair: options.autoRepair, + }), }), }, }); @@ -423,6 +454,7 @@ export class NovelCorePipelineService { return this.startPipelineJob(job.novelId, { startOrder: job.startOrder, endOrder: job.endOrder, + controlPolicy: payload.controlPolicy, workflowTaskId: payload.workflowTaskId, taskStyleProfileId: payload.taskStyleProfileId, maxRetries: job.maxRetries, @@ -432,6 +464,12 @@ export class NovelCorePipelineService { skipCompleted: job.skipCompleted ?? payload.skipCompleted, qualityThreshold: job.qualityThreshold ?? payload.qualityThreshold, repairMode: job.repairMode ?? payload.repairMode, + advanceAfterAutoRepairLimit: resolveAdvanceAfterAutoRepairLimit({ + explicit: payload.advanceAfterAutoRepairLimit, + controlPolicy: payload.controlPolicy, + autoReview: job.autoReview ?? payload.autoReview, + autoRepair: job.autoRepair ?? payload.autoRepair, + }), provider: payload.provider, model: payload.model, temperature: payload.temperature, @@ -549,20 +587,29 @@ export class NovelCorePipelineService { }, }); const persistedPayload = this.parsePipelinePayload(existingJob?.payload); + const controlPolicy = persistedPayload.controlPolicy ?? options.controlPolicy; + const autoReview = persistedPayload.autoReview ?? options.autoReview ?? true; + const autoRepair = persistedPayload.autoRepair ?? options.autoRepair ?? true; const runtimePayload: PipelinePayload = { provider: persistedPayload.provider ?? options.provider, model: persistedPayload.model ?? options.model, temperature: persistedPayload.temperature ?? options.temperature, - controlPolicy: persistedPayload.controlPolicy ?? options.controlPolicy, + controlPolicy, workflowTaskId: persistedPayload.workflowTaskId ?? options.workflowTaskId, taskStyleProfileId: persistedPayload.taskStyleProfileId ?? options.taskStyleProfileId, maxRetries: persistedPayload.maxRetries ?? options.maxRetries ?? 1, runMode: persistedPayload.runMode ?? options.runMode ?? "fast", - autoReview: persistedPayload.autoReview ?? options.autoReview ?? true, - autoRepair: persistedPayload.autoRepair ?? options.autoRepair ?? true, + autoReview, + autoRepair, skipCompleted: persistedPayload.skipCompleted ?? options.skipCompleted ?? true, qualityThreshold: persistedPayload.qualityThreshold ?? options.qualityThreshold, repairMode: persistedPayload.repairMode ?? options.repairMode ?? "light_repair", + advanceAfterAutoRepairLimit: resolveAdvanceAfterAutoRepairLimit({ + explicit: persistedPayload.advanceAfterAutoRepairLimit ?? options.advanceAfterAutoRepairLimit, + controlPolicy, + autoReview, + autoRepair, + }), }; let totalRetryCount = Math.max(existingJob?.retryCount ?? 0, 0); const qualityAlertDetails = [...(persistedPayload.qualityAlertDetails ?? [])]; @@ -685,6 +732,7 @@ export class NovelCorePipelineService { autoRepair: runtimePayload.autoRepair, qualityThreshold, repairMode: runtimePayload.repairMode, + advanceAfterAutoRepairLimit: runtimePayload.advanceAfterAutoRepairLimit, }, { onCheckCancelled: () => this.ensurePipelineNotCancelled(jobId), diff --git a/server/src/services/novel/novelCoreShared.ts b/server/src/services/novel/novelCoreShared.ts index 9419b6d81..b6f937254 100644 --- a/server/src/services/novel/novelCoreShared.ts +++ b/server/src/services/novel/novelCoreShared.ts @@ -163,6 +163,7 @@ export interface PipelineRunOptions extends LLMGenerateOptions { skipCompleted?: boolean; qualityThreshold?: number; repairMode?: "detect_only" | "light_repair" | "heavy_repair" | "continuity_only" | "character_only" | "ending_only"; + advanceAfterAutoRepairLimit?: boolean; } export type PipelineBackgroundSyncKind = "character_dynamics" | "state_snapshot" | "payoff_ledger" | "character_resources" | "canonical_state"; @@ -194,6 +195,7 @@ export interface PipelinePayload extends LLMGenerateOptions { skipCompleted?: boolean; qualityThreshold?: number; repairMode?: "detect_only" | "light_repair" | "heavy_repair" | "continuity_only" | "character_only" | "ending_only"; + advanceAfterAutoRepairLimit?: boolean; qualityAlertDetails?: string[]; replanAlertDetails?: string[]; backgroundSync?: PipelineBackgroundSyncState; diff --git a/server/src/services/novel/pipelineJobState.ts b/server/src/services/novel/pipelineJobState.ts index f6fc9546f..48c683dbe 100644 --- a/server/src/services/novel/pipelineJobState.ts +++ b/server/src/services/novel/pipelineJobState.ts @@ -210,6 +210,9 @@ export function parsePipelinePayload(payload: string | null | undefined): Pipeli autoRepair: typeof parsed.autoRepair === "boolean" ? parsed.autoRepair : undefined, skipCompleted: typeof parsed.skipCompleted === "boolean" ? parsed.skipCompleted : undefined, qualityThreshold: typeof parsed.qualityThreshold === "number" ? parsed.qualityThreshold : undefined, + advanceAfterAutoRepairLimit: typeof parsed.advanceAfterAutoRepairLimit === "boolean" + ? parsed.advanceAfterAutoRepairLimit + : undefined, repairMode: parsed.repairMode === "detect_only" || parsed.repairMode === "light_repair" @@ -242,6 +245,9 @@ export function stringifyPipelinePayload(input: PipelinePayload): string { autoRepair: input.autoRepair ?? true, skipCompleted: input.skipCompleted ?? true, qualityThreshold: input.qualityThreshold ?? null, + ...(typeof input.advanceAfterAutoRepairLimit === "boolean" + ? { advanceAfterAutoRepairLimit: input.advanceAfterAutoRepairLimit } + : {}), repairMode: input.repairMode ?? "light_repair", ...(qualityAlertDetails.length > 0 ? { qualityAlertDetails } : {}), ...(backgroundSync?.activities?.length ? { backgroundSync } : {}), diff --git a/server/src/services/novel/runtime/chapterRuntimePipeline.ts b/server/src/services/novel/runtime/chapterRuntimePipeline.ts index 9a8357193..d45b00bd6 100644 --- a/server/src/services/novel/runtime/chapterRuntimePipeline.ts +++ b/server/src/services/novel/runtime/chapterRuntimePipeline.ts @@ -18,6 +18,7 @@ export interface PipelineRuntimeInput extends ChapterRuntimeRequestInput { auditMode?: "light" | "full" | "repair_only"; qualityThreshold?: number; repairMode?: "detect_only" | "light_repair" | "heavy_repair" | "continuity_only" | "character_only" | "ending_only"; + advanceAfterAutoRepairLimit?: boolean; } export interface PipelineRuntimeResult { @@ -102,6 +103,7 @@ export async function runPipelineChapterWithRuntime( autoRepair = true, qualityThreshold = 75, repairMode = "light_repair", + advanceAfterAutoRepairLimit = false, ...requestInput } = options; const request = deps.validateRequest(requestInput); @@ -196,6 +198,9 @@ export async function runPipelineChapterWithRuntime( }); retryCountUsed += 1; await deps.saveDraftAndArtifacts(novelId, chapterId, content, "repaired"); + if (advanceAfterAutoRepairLimit && retryCountUsed >= maxRetries) { + break; + } } if (!latestResult) { diff --git a/server/tests/chapterRuntimePipeline.test.js b/server/tests/chapterRuntimePipeline.test.js index c222921de..6ed156ca7 100644 --- a/server/tests/chapterRuntimePipeline.test.js +++ b/server/tests/chapterRuntimePipeline.test.js @@ -252,3 +252,92 @@ test("runPipelineChapterWithRuntime defaults to a single repair pass before stop promptRunner.runTextPrompt = originalRunTextPrompt; } }); + +test("runPipelineChapterWithRuntime can stop after auto repair count is used for director execution", async () => { + const originalRunTextPrompt = promptRunner.runTextPrompt; + const stages = []; + const finalizeInputs = []; + const savedDrafts = []; + const generationStates = []; + const chapterStatuses = []; + + promptRunner.runTextPrompt = async () => ({ + output: "修后正文", + }); + + try { + const result = await runPipelineChapterWithRuntime( + { + validateRequest(input) { + return input; + }, + async ensureNovelCharacters() {}, + async assemble() { + return { + novel: { id: "novel-1", title: "测试小说" }, + chapter: { + id: "chapter-1", + title: "第一章", + order: 1, + content: null, + expectation: null, + }, + contextPackage: {}, + }; + }, + async generateDraftFromWriter() { + return { content: "生成后的正文" }; + }, + async saveDraftAndArtifacts(_novelId, _chapterId, content, generationState) { + savedDrafts.push({ content, generationState }); + }, + async finalizeChapterContent({ content }) { + finalizeInputs.push(content); + return { + finalContent: "初审正文", + runtimePackage: createRuntimePackage(72), + }; + }, + async markChapterGenerationState(_chapterId, generationState) { + generationStates.push(generationState); + }, + async markChapterStatus(_chapterId, chapterStatus) { + chapterStatuses.push(chapterStatus); + }, + }, + "novel-1", + "chapter-1", + { + autoReview: true, + autoRepair: true, + maxRetries: 1, + advanceAfterAutoRepairLimit: true, + }, + { + async onStageChange(stage) { + stages.push(stage); + }, + }, + ); + + assert.deepEqual(stages, ["generating_chapters", "reviewing", "repairing"]); + assert.deepEqual(finalizeInputs, ["生成后的正文"]); + assert.equal(result.retryCountUsed, 1); + assert.equal(result.pass, false); + assert.equal(result.reviewExecuted, true); + assert.deepEqual(generationStates, ["reviewed"]); + assert.deepEqual(chapterStatuses, ["needs_repair"]); + assert.deepEqual(savedDrafts, [ + { + content: "生成后的正文", + generationState: "drafted", + }, + { + content: "修后正文", + generationState: "repaired", + }, + ]); + } finally { + promptRunner.runTextPrompt = originalRunTextPrompt; + } +}); diff --git a/server/tests/novelDirectorAutoExecution.test.js b/server/tests/novelDirectorAutoExecution.test.js index 44ffb2980..318296f89 100644 --- a/server/tests/novelDirectorAutoExecution.test.js +++ b/server/tests/novelDirectorAutoExecution.test.js @@ -86,6 +86,7 @@ test("buildDirectorAutoExecutionPipelineOptions uses front10-safe defaults", () assert.equal(options.skipCompleted, true); assert.equal(options.qualityThreshold, 75); assert.equal(options.repairMode, "light_repair"); + assert.equal(options.advanceAfterAutoRepairLimit, true); assert.equal(options.controlPolicy?.kickoffMode, "director_start"); assert.equal(options.controlPolicy?.advanceMode, "auto_to_execution"); }); @@ -114,6 +115,7 @@ test("buildDirectorAutoExecutionPipelineOptions respects review and repair toggl assert.equal(options.autoReview, false); assert.equal(options.autoRepair, false); + assert.equal(options.advanceAfterAutoRepairLimit, false); }); test("auto execution does not treat empty reviewed chapters as processed", () => { @@ -134,6 +136,20 @@ test("auto execution does not treat empty reviewed chapters as processed", () => assert.equal(isDirectorAutoExecutionChapterProcessed(emptyReviewedChapter), false); assert.equal(isDirectorAutoExecutionChapterProcessed(draftedReviewedChapter), true); + assert.equal(isDirectorAutoExecutionChapterProcessed({ + id: "chapter-repaired-once", + order: 13, + content: "修复后的正文", + generationState: "repaired", + chapterStatus: "needs_repair", + }), true); + assert.equal(isDirectorAutoExecutionChapterProcessed({ + id: "chapter-reviewed-needs-repair", + order: 14, + content: "初审正文", + generationState: "reviewed", + chapterStatus: "needs_repair", + }), false); const state = buildDirectorAutoExecutionState({ range: { diff --git a/server/tests/novelPipelineState.test.js b/server/tests/novelPipelineState.test.js index c721107c3..655db3604 100644 --- a/server/tests/novelPipelineState.test.js +++ b/server/tests/novelPipelineState.test.js @@ -58,12 +58,14 @@ test("pipeline payload keeps omitted model settings route-following", () => { skipCompleted: true, qualityThreshold: 75, repairMode: "light_repair", + advanceAfterAutoRepairLimit: true, })); assert.equal(payload.provider, undefined); assert.equal(payload.model, undefined); assert.equal(payload.temperature, undefined); assert.equal(payload.workflowTaskId, "workflow-route"); + assert.equal(payload.advanceAfterAutoRepairLimit, true); }); test("executePipeline preserves route-following model options for chapter runtime", async () => { @@ -368,6 +370,7 @@ test("executePipeline keeps director control policy and suppresses replan notice advanceMode: "auto_to_execution", reviewCheckpoints: ["chapter_batch"], }); + assert.equal(capturedRuntimeOptions.advanceAfterAutoRepairLimit, true); const finalUpdate = updates[updates.length - 1]; assert.equal(finalUpdate.data.status, "succeeded"); const finalPayload = JSON.parse(finalUpdate.data.payload);