import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, OnModuleInit, Optional } from '@nestjs/common'; import type { Character, Episode, NovelChapter, PlotMemory, PlotThread, Prisma, Project, StoryBible } from '@prisma/client'; import { createHash } from 'node:crypto'; import type { AuthRequestUser } from '../auth/auth.types'; import { PrismaService } from '../prisma/prisma.service'; import { assertPromptIsCleanV1, promptRuleLinesV1 } from '../production-kernel'; import { ProvidersService } from '../providers/providers.service'; import { toSafeRenderTask } from '../queues/task.types'; import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto'; import { EPISODE_STATUSES, toSafeEpisode, type EpisodeStatus } from './episode.types'; const MIN_EPISODES = 1; const MAX_EPISODES = 100; const MIN_DURATION = 15; const MAX_DURATION = 600; const DEFAULT_EPISODE_DURATION = 60; const MIN_QUALITY_EPISODE_DURATION = 60; const MAX_QUALITY_EPISODE_DURATION = 180; interface EpisodeDraft { episode_no: number; source_chapter_ids: Prisma.InputJsonValue; title: string; summary: string; opening_hook: string; middle_conflict: string; ending_hook: string; target_duration: number; status: EpisodeStatus; } interface EpisodePlanContext { storyBible: StoryBible; characters: Character[]; chapters: NovelChapter[]; plotMemories: PlotMemory[]; plotThreads: PlotThread[]; } interface EpisodeOverviewItem { episode_no: number; title_hint: string; one_sentence: string; arc_stage: string; source_chapter_ids: string[]; key_hook: string; must_use_memory: string[]; } interface EpisodePlanPromptOverrides { overview?: string; batch?: string; reconcile?: string; } interface EpisodePlanRequestParamsOverride { max_output_tokens?: number; temperature?: number; timeout_ms?: number; } interface EpisodePlanQualityBrief { score: number; issues: string[]; } interface EpisodeCountRecommendation { episode_count: number; reasoning: string; duration_policy: string; split_merge_rules: string[]; source: 'ai' | 'local' | 'fixed'; } @Injectable() export class EpisodesService implements OnModuleInit { private readonly runningEpisodePlanTasks = new Set(); constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Optional() @Inject(ProvidersService) private readonly providersService?: ProvidersService ) {} async onModuleInit() { const tasks = await this.prisma.renderTask.findMany({ where: { task_type: 'episode_plan_generate', status: { in: ['pending', 'running', 'retrying'] } }, orderBy: { created_at: 'asc' }, take: 20 }); for (const task of tasks) { this.ensureEpisodePlanTaskRunning(task.id); } } async generatePlan(user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto) { const project = await this.findProjectForUser(projectId, user); this.assertLegacyWritePath(project); const providerCode = this.normalizeProviderCode(dto.provider_code); if (providerCode) { return this.submitEpisodePlanGeneration(user, projectId, dto, providerCode); } return this.executeGeneratePlan(user, projectId, dto); } async previewPlanRequest(user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto) { const project = await this.findProjectForUser(projectId, user); this.assertLegacyWritePath(project); const context = await this.loadPlanContext(project.id); const referenceCount = this.resolveEpisodeCount(project, dto.target_episode_count); const countMode = this.resolveEpisodeCountMode(dto.episode_count_mode); const countRecommendation = countMode === 'ai_recommend' ? this.estimateEpisodeCountRecommendation(project, context, referenceCount) : { episode_count: referenceCount, reasoning: '使用用户/项目参考集数,不启用 AI 集数建议。', duration_policy: this.episodeDurationPlanningGuide(project), split_merge_rules: ['按固定集数生成分集计划。'], source: 'fixed' as const }; const count = countRecommendation.episode_count; const fallbackDrafts = this.buildEpisodeDrafts(project, count, context); const batchSize = this.resolveEpisodePlanBatchSize(count); const totalBatches = Math.ceil(count / batchSize); const minQualityScore = this.normalizeMinQualityScore(dto.min_quality_score, 96); const promptOverrides = this.normalizeEpisodePlanPromptOverrides(dto.prompt_overrides); const requestParamsOverride = this.normalizeEpisodePlanRequestParamsOverride(dto.request_params_override); const overviewPrompt = this.applyEpisodePlanPromptOverride( this.buildEpisodeOverviewPrompt(project, count, context, fallbackDrafts), promptOverrides.overview, 'overview' ); const fallbackOverview = this.episodeOverviewFromObject(null, count, fallbackDrafts); const firstBatchEnd = Math.min(count, batchSize); const batchPrompt = this.applyEpisodePlanPromptOverride( this.buildEpisodeBatchPrompt( project, context, fallbackOverview, [], fallbackDrafts.slice(0, firstBatchEnd), 1, firstBatchEnd ), promptOverrides.batch, 'batch' ); const localScore = this.scoreEpisodePlanDrafts(fallbackDrafts, context, count, minQualityScore); const reconcilePrompt = this.applyEpisodePlanPromptOverride( this.buildEpisodeReconcilePrompt( project, context, fallbackOverview, fallbackDrafts, fallbackDrafts, minQualityScore, localScore ), promptOverrides.reconcile, 'reconcile' ); return { project_id: project.id.toString(), provider_code: this.normalizeProviderCode(dto.provider_code) || '智能计划', episode_count_mode: countMode, reference_episode_count: referenceCount, recommended_episode_count: countRecommendation.episode_count, episode_count_reasoning: countRecommendation.reasoning, episode_count_duration_policy: countRecommendation.duration_policy, episode_count_split_merge_rules: countRecommendation.split_merge_rules, target_episode_count: count, min_quality_score: minQualityScore, mode: 'overview_batch_reconcile', batch_size: batchSize, total_batches: totalBatches, quality_duration_range: { min: MIN_QUALITY_EPISODE_DURATION, max: MAX_QUALITY_EPISODE_DURATION }, episode_duration_reference: this.resolveEpisodeReferenceDuration(project), request_params_override: requestParamsOverride, prompt_overrides: promptOverrides, stages: [ this.buildEpisodePlanStagePreview( project, 'episode_count_recommendation', 'AI建议真实总集数', this.buildEpisodeCountRecommendationPrompt(project, context, referenceCount), 2500, requestParamsOverride ), this.buildEpisodePlanStagePreview(project, 'overview', '全局分集骨架', overviewPrompt, 6500, requestParamsOverride), this.buildEpisodePlanStagePreview( project, 'batch_1_' + firstBatchEnd, '详细分集批次模板(第1批示例)', batchPrompt, Math.min(7000, Math.max(3500, firstBatchEnd * 520)), requestParamsOverride ), this.buildEpisodePlanStagePreview(project, 'reconcile', '全局校准补丁', reconcilePrompt, 5000, requestParamsOverride) ] }; } private async submitEpisodePlanGeneration( user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto, providerCode: string ) { const project = await this.findProjectForUser(projectId, user); this.assertLegacyWritePath(project); const referenceCount = this.resolveEpisodeCount(project, dto.target_episode_count); const existingConfirmed = await this.prisma.episode.count({ where: { project_id: project.id, status: 'confirmed' } }); if (existingConfirmed > 0 && dto.force !== true) { throw new BadRequestException('Confirmed episodes cannot be regenerated'); } const existing = await this.findActiveEpisodePlanTask(project.id); if (existing) { this.ensureEpisodePlanTaskRunning(existing.id); return { task: toSafeRenderTask(existing), episodes: [], memory_context: null, next_step: 'episode_plan_generating', already_running: true }; } const inputJson: Prisma.InputJsonObject = { project_id: project.id.toString(), provider_code: providerCode, target_episode_count: referenceCount, reference_episode_count: referenceCount, episode_count_mode: this.resolveEpisodeCountMode(dto.episode_count_mode), force: dto.force === true, min_quality_score: this.normalizeMinQualityScore(dto.min_quality_score, 96), prompt_overrides: this.normalizeEpisodePlanPromptOverrides(dto.prompt_overrides) as Prisma.InputJsonObject, request_params_override: this.normalizeEpisodePlanRequestParamsOverride(dto.request_params_override) as Prisma.InputJsonObject, user_id: user.id, user_email: user.email, user_role: user.role, submitted_at: new Date().toISOString() }; const task = await this.prisma.renderTask.create({ data: { project_id: project.id, episode_id: null, shot_id: null, task_type: 'episode_plan_generate', status: 'pending', input_json: inputJson, input_hash: this.hashJson(inputJson), retry_count: 0, max_retry: 1 } }); await this.prisma.project.update({ where: { id: project.id }, data: { status: 'episode_planning' } }); this.ensureEpisodePlanTaskRunning(task.id); return { task: toSafeRenderTask(task), episodes: [], memory_context: null, next_step: 'episode_plan_generating', already_running: false }; } private async executeGeneratePlan( user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto, taskId?: bigint ) { const project = await this.findProjectForUser(projectId, user); const context = await this.loadPlanContext(project.id); const referenceCount = this.resolveEpisodeCount(project, dto.target_episode_count); const countRecommendation = await this.resolveEpisodeCountRecommendation( project, context, referenceCount, dto, taskId ); const count = countRecommendation.episode_count; if (taskId) { await this.updateEpisodePlanTaskMeta(taskId, { episode_count_recommendation: countRecommendation, target_episode_count: count, reference_episode_count: referenceCount }); } const minQualityScore = this.normalizeMinQualityScore(dto.min_quality_score, 96); const promptOverrides = this.normalizeEpisodePlanPromptOverrides(dto.prompt_overrides); const requestParamsOverride = this.normalizeEpisodePlanRequestParamsOverride(dto.request_params_override); const existingConfirmed = await this.prisma.episode.count({ where: { project_id: project.id, status: 'confirmed' } }); if (existingConfirmed > 0 && dto.force !== true) { throw new BadRequestException('Confirmed episodes cannot be regenerated'); } const fallbackDrafts = this.buildEpisodeDrafts(project, count, context); const drafts = (await this.tryGeneratePlanWithProvider( project, count, context, fallbackDrafts, minQualityScore, dto.provider_code, taskId, promptOverrides, requestParamsOverride )) ?? fallbackDrafts; const qualityReport = this.scoreEpisodePlanDrafts(drafts, context, count, minQualityScore); if (taskId) { await this.updateEpisodePlanTaskMeta(taskId, { episode_count_recommendation: countRecommendation, target_episode_count: count, reference_episode_count: referenceCount, quality_report: qualityReport, created_count_preview: { episodes: drafts.length } }); await this.updateEpisodePlanCostSummary(taskId); } if (!qualityReport.passed) { throw new BadRequestException( `分集计划质量未达标:当前 ${qualityReport.score}/${minQualityScore}。${qualityReport.issues.join(';')}` ); } await this.prisma.project.update({ where: { id: project.id }, data: { status: 'episode_planning' } }); const episodes = await this.prisma.$transaction(async (tx) => { await this.clearEpisodePlanForRegeneration(tx, project.id, dto.force === true); await tx.episode.createMany({ data: drafts.map((draft) => ({ project_id: project.id, ...draft })) }); const saved = await tx.episode.findMany({ where: { project_id: project.id }, orderBy: { episode_no: 'asc' } }); await tx.project.update({ where: { id: project.id }, data: { status: 'waiting_episode_confirm' } }); return saved; }); return { episodes: episodes.map(toSafeEpisode), memory_context: { story_bible_id: context.storyBible.id.toString(), locked_character_count: context.characters.length, active_plot_memory_count: context.plotMemories.length, open_thread_count: context.plotThreads.length }, quality_report: qualityReport, next_step: 'episode_confirm' }; } private async runEpisodePlanTask(taskId: bigint) { const key = taskId.toString(); if (this.runningEpisodePlanTasks.has(key)) { return; } this.runningEpisodePlanTasks.add(key); try { const task = await this.prisma.renderTask.findUnique({ where: { id: taskId } }); if (!task || !['pending', 'running', 'retrying'].includes(task.status)) { return; } const input = this.readObject(task.input_json); const userId = this.readString(input.user_id); if (!userId) { throw new Error('EPISODE_PLAN_TASK_USER_MISSING'); } await this.prisma.renderTask.update({ where: { id: task.id }, data: { status: 'running', started_at: task.started_at ?? new Date(), finished_at: null, error_code: null, error_message: null } }); const user: AuthRequestUser = { id: userId, email: this.readString(input.user_email) ?? null, role: this.readString(input.user_role) ?? 'user' }; await this.executeGeneratePlan( user, this.readString(input.project_id) ?? task.project_id.toString(), { provider_code: this.readString(input.provider_code) ?? undefined, target_episode_count: this.readNumber(input.target_episode_count) ?? undefined, episode_count_mode: this.readString(input.episode_count_mode) === 'fixed' ? 'fixed' : 'ai_recommend', force: input.force === true, min_quality_score: this.readNumber(input.min_quality_score) ?? undefined, prompt_overrides: this.normalizeEpisodePlanPromptOverrides(input.prompt_overrides), request_params_override: this.normalizeEpisodePlanRequestParamsOverride(input.request_params_override) as Record }, task.id ); const latest = await this.prisma.renderTask.findUnique({ where: { id: task.id } }); if (latest?.status === 'cancelled') { return; } await this.prisma.renderTask.update({ where: { id: task.id }, data: { status: 'success', error_code: null, error_message: null, finished_at: new Date() } }); } catch (error) { await this.markEpisodePlanTaskFailed(taskId, error); } finally { this.runningEpisodePlanTasks.delete(key); } } async listEpisodes(user: AuthRequestUser, projectId: string) { const project = await this.findProjectForUser(projectId, user); const episodes = await this.prisma.episode.findMany({ where: { project_id: project.id }, orderBy: { episode_no: 'asc' } }); return episodes.map(toSafeEpisode); } private async clearEpisodePlanForRegeneration( tx: Prisma.TransactionClient, projectId: bigint, force: boolean ) { const episodes = await tx.episode.findMany({ where: { project_id: projectId }, select: { id: true, status: true } }); if (episodes.length === 0) return; const confirmedCount = episodes.filter((episode) => episode.status === 'confirmed').length; if (confirmedCount > 0 && !force) { throw new BadRequestException('Confirmed episodes cannot be regenerated'); } const episodeIds = episodes.map((episode) => episode.id); const shots = await tx.storyboardShot.findMany({ where: { project_id: projectId, episode_id: { in: episodeIds } }, select: { id: true } }); const shotIds = shots.map((shot) => shot.id); await tx.renderTask.updateMany({ where: { project_id: projectId, episode_id: { in: episodeIds }, status: { in: ['pending', 'running', 'retrying'] } }, data: { status: 'cancelled', error_code: 'EPISODE_PLAN_REGENERATED', error_message: '分集计划已重做,旧任务自动取消。', finished_at: new Date() } }); await tx.shotImage.deleteMany({ where: { project_id: projectId, OR: [ { episode_id: { in: episodeIds } }, ...(shotIds.length ? [{ shot_id: { in: shotIds } }] : []) ] } }); await tx.videoClip.deleteMany({ where: { project_id: projectId, OR: [ { episode_id: { in: episodeIds } }, ...(shotIds.length ? [{ shot_id: { in: shotIds } }] : []) ] } }); await tx.storyboardShot.deleteMany({ where: { project_id: projectId, episode_id: { in: episodeIds } } }); await tx.episodeScript.deleteMany({ where: { project_id: projectId, episode_id: { in: episodeIds } } }); await tx.continuityCheck.deleteMany({ where: { project_id: projectId, episode_id: { in: episodeIds } } }); await tx.asset.updateMany({ where: { project_id: projectId, asset_type: { in: ['storyboard_image', 'shot_image', 'keyframe', 'video_clip', 'rendered_episode', 'audio', 'subtitle'] } }, data: { status: 'archived' } }); await tx.episode.deleteMany({ where: { project_id: projectId } }); } async updateEpisode(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeDto) { const episode = await this.findEpisodeForUser(episodeId, user); const project = await this.findProjectForUser(episode.project_id.toString(), user); this.assertLegacyWritePath(project); if (episode.status === 'confirmed') { throw new BadRequestException('Confirmed episodes cannot be edited'); } const data = await this.createUpdateData(episode, dto); if (Object.keys(data).length === 0) { throw new BadRequestException('No episode fields to update'); } const updated = await this.prisma.episode.update({ where: { id: episode.id }, data }); await this.prisma.project.update({ where: { id: episode.project_id }, data: { status: 'waiting_episode_confirm' } }); return toSafeEpisode(updated); } async confirmEpisodes(user: AuthRequestUser, projectId: string) { const project = await this.findProjectForUser(projectId, user); this.assertLegacyWritePath(project); const episodes = await this.prisma.episode.findMany({ where: { project_id: project.id }, orderBy: { episode_no: 'asc' } }); this.assertEpisodesReadyForConfirmation(episodes); const confirmed = await this.prisma.$transaction(async (tx) => { await tx.episode.updateMany({ where: { project_id: project.id, status: { in: ['draft', 'generated', 'edited'] } }, data: { status: 'confirmed' } }); const saved = await tx.episode.findMany({ where: { project_id: project.id }, orderBy: { episode_no: 'asc' } }); await tx.project.update({ where: { id: project.id }, data: { status: 'episode_confirmed' } }); return saved; }); return { episodes: confirmed.map(toSafeEpisode), next_step: 'script_generate' }; } private async loadPlanContext(projectId: bigint): Promise { const [storyBible, characters, chapters, plotMemories, plotThreads] = await Promise.all([ this.prisma.storyBible.findFirst({ where: { project_id: projectId, status: 'confirmed' }, orderBy: { version: 'desc' } }), this.prisma.character.findMany({ where: { project_id: projectId, status: 'locked' }, orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] }), this.prisma.novelChapter.findMany({ where: { project_id: projectId }, orderBy: { chapter_no: 'asc' } }), this.prisma.plotMemory.findMany({ where: { project_id: projectId, status: 'active' }, orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] }), this.prisma.plotThread.findMany({ where: { project_id: projectId, status: { in: ['open', 'progressing', 'paused'] } }, orderBy: [{ status: 'asc' }, { id: 'asc' }] }) ]); if (!storyBible) { throw new BadRequestException('Confirmed story bible is required before episode planning'); } if (characters.length === 0) { throw new BadRequestException('Locked characters are required before episode planning'); } if (chapters.length === 0) { throw new BadRequestException('Novel chapters are required before episode planning'); } if (plotMemories.length === 0) { throw new BadRequestException('Long-form plot memories are required before episode planning'); } return { storyBible, characters, chapters, plotMemories, plotThreads }; } private buildEpisodeDrafts( project: Project, count: number, context: EpisodePlanContext ): EpisodeDraft[] { const protagonist = context.characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ?? context.characters[0]; const antagonist = context.characters.find((character) => character.role_type === 'antagonist'); const importantForeshadowing = context.plotMemories.find( (memory) => memory.memory_type === 'foreshadowing' ); const unresolvedConflict = context.plotMemories.find( (memory) => memory.memory_type === 'unresolved_conflict' ); const mainThread = context.plotThreads.find((thread) => thread.thread_type === 'main_plot') ?? context.plotThreads[0]; const referenceDuration = this.resolveEpisodeReferenceDuration(project); return Array.from({ length: count }, (_, index) => { const episodeNo = index + 1; const chapterGroup = this.pickChapterGroup(context.chapters, index, count); const firstChapter = chapterGroup[0] ?? context.chapters[0]; const lastChapter = chapterGroup.at(-1) ?? firstChapter; const chapterSummary = chapterGroup .map((chapter) => chapter.summary || this.compact(chapter.content).slice(0, 70)) .join(';'); const sourceChapterIds = chapterGroup.map((chapter) => chapter.id.toString()); const threadText = mainThread?.description || context.storyBible.main_plot || '主线目标持续推进'; return { episode_no: episodeNo, source_chapter_ids: sourceChapterIds, title: this.buildEpisodeTitle(episodeNo, firstChapter, count), summary: [ `${protagonist.name}围绕${this.compact(threadText).slice(0, 80)}推进第${episodeNo}集。`, chapterSummary, episodeNo === count ? context.storyBible.ending_direction || '阶段性回收关键伏笔,并保留下一阶段入口。' : '本集保留短视频节奏,结尾留下可承接悬念。' ].filter(Boolean).join(' '), opening_hook: episodeNo === 1 ? `${protagonist.name}在高压场景中发现关键转机,观众第一秒进入冲突。` : `承接上一集悬念,${protagonist.name}立刻面对新的选择和压力。`, middle_conflict: unresolvedConflict?.content || `${antagonist?.name ?? '主要对手'}围绕核心利益继续施压,${protagonist.name}必须用证据或行动反击。`, ending_hook: this.buildEndingHook( episodeNo, count, protagonist.name, lastChapter, importantForeshadowing, context.storyBible ), target_duration: this.estimateEpisodeTargetDuration({ project, referenceDuration, episodeNo, count, text: [ chapterSummary, threadText, unresolvedConflict?.content, importantForeshadowing?.content, episodeNo === count ? context.storyBible.ending_direction : '' ].filter(Boolean).join('\n') }), status: 'generated' }; }); } private async tryGeneratePlanWithProvider( project: Project, count: number, context: EpisodePlanContext, fallbackDrafts: EpisodeDraft[], minQualityScore: number, providerCode?: string, taskId?: bigint, promptOverrides: EpisodePlanPromptOverrides = {}, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ): Promise { if (!this.providersService) return null; const selectedProvider = this.normalizeProviderCode(providerCode); if (!selectedProvider) return null; const batchSize = this.resolveEpisodePlanBatchSize(count); const totalBatches = Math.ceil(count / batchSize); if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'overview_batch_reconcile', phase: 'overview', completed_batches: 0, total_batches: totalBatches, batch_size: batchSize, target_episode_count: count, started_at: new Date().toISOString() } }); } try { const overview = await this.generateEpisodeOverviewWithProvider( project, count, context, fallbackDrafts, selectedProvider, taskId, promptOverrides.overview, requestParamsOverride ); const drafts: EpisodeDraft[] = []; for (let batchIndex = 0; batchIndex < totalBatches; batchIndex += 1) { const startNo = batchIndex * batchSize + 1; const endNo = Math.min(count, startNo + batchSize - 1); const fallbackBatch = fallbackDrafts.slice(startNo - 1, endNo); if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'overview_batch_reconcile', phase: 'batch', current_batch: batchIndex + 1, current_range: `${startNo}-${endNo}`, completed_batches: batchIndex, total_batches: totalBatches, batch_size: batchSize, target_episode_count: count, updated_at: new Date().toISOString() } }); } const batchDrafts = await this.generateEpisodeBatchWithProvider( project, context, overview, drafts, fallbackBatch, selectedProvider, startNo, endNo, taskId, promptOverrides.batch, requestParamsOverride ); drafts.push(...batchDrafts); if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'overview_batch_reconcile', phase: 'batch', current_batch: batchIndex + 1, current_range: `${startNo}-${endNo}`, completed_batches: batchIndex + 1, total_batches: totalBatches, batch_size: batchSize, target_episode_count: count, generated_episodes: drafts.length, updated_at: new Date().toISOString() }, created_count_preview: { episodes: drafts.length } }); } } const normalizedDrafts = this.normalizeEpisodeDrafts(drafts, fallbackDrafts, count, project); if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'overview_batch_reconcile', phase: 'reconcile', completed_batches: totalBatches, total_batches: totalBatches, batch_size: batchSize, target_episode_count: count, generated_episodes: normalizedDrafts.length, updated_at: new Date().toISOString() } }); } const reconciledDrafts = await this.reconcileEpisodePlanWithProvider( project, context, overview, normalizedDrafts, fallbackDrafts, minQualityScore, selectedProvider, taskId, promptOverrides.reconcile, requestParamsOverride ); if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'overview_batch_reconcile', phase: 'complete', completed_batches: totalBatches, total_batches: totalBatches, batch_size: batchSize, target_episode_count: count, generated_episodes: reconciledDrafts.length, finished_at: new Date().toISOString() }, created_count_preview: { episodes: reconciledDrafts.length } }); } return reconciledDrafts; } catch (error) { if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'overview_batch_reconcile', phase: 'failed', total_batches: totalBatches, batch_size: batchSize, target_episode_count: count, error_message: this.errorMessage(error), failed_at: new Date().toISOString() } }).catch(() => undefined); } throw new BadRequestException(`选定文本模型生成分集计划失败:${this.errorMessage(error)}`); } } private async generateEpisodeOverviewWithProvider( project: Project, count: number, context: EpisodePlanContext, fallbackDrafts: EpisodeDraft[], selectedProvider: string, taskId?: bigint, promptOverride?: string, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ) { const prompt = this.applyEpisodePlanPromptOverride( this.buildEpisodeOverviewPrompt(project, count, context, fallbackDrafts), promptOverride, 'overview' ); const parsed = await this.executeEpisodePlanJsonProvider( project, selectedProvider, 'overview', prompt, 14000, taskId, requestParamsOverride ); return this.episodeOverviewFromObject(parsed, count, fallbackDrafts); } private buildEpisodeOverviewPrompt( project: Project, count: number, context: EpisodePlanContext, fallbackDrafts: EpisodeDraft[] ) { const sharedPromptRules = promptRuleLinesV1({ stage: 'episode', genre_tags: [project.genre ?? '', project.style_code ?? ''].filter(Boolean) }); assertPromptIsCleanV1(sharedPromptRules.join('\n')); const productionContext = this.storyBibleProductionContext(context.storyBible, [ 'episode_structure', 'episode_rhythm_formula', 'reversal_system', 'emotional_curve', 'core_memory_points', 'adaptation_taboo' ]); return [ '你是短剧总编剧。请先做全局分集骨架,只输出 JSON,不要 Markdown。', 'JSON 格式:{"overview":[{"episode_no":1,"title_hint":"","one_sentence":"","arc_stage":"","source_chapter_ids":[""],"key_hook":"","must_use_memory":[""]}]}', ...sharedPromptRules, `系统当前参考生成 ${count} 条 overview,但这是生产批次参考,不是为了删减剧情的硬性创作目标。每条只写一句话骨架,不写详细分集。目标是给后续分批生成提供全局连续性。`, '要求:1)覆盖所有集数且 episode_no 连续;2)每集必须有 source_chapter_ids;3)arc_stage 表示阶段,例如开局压迫、反击、追证、翻盘、终局;4)key_hook 必须是短剧钩子;5)必须吸收长篇记忆里的伏笔、证据、道具、场景和未解决剧情线;6)不要逐章流水账;7)不要为了贴合固定集数删掉关键剧情,如果剧情密度过高,必须在 one_sentence 或 key_hook 中保留该集核心信息,并在后续 detailed episode 的 target_duration 拉长到 120-180 秒。', '输出长度控制:overview 是全局地图,不是详细剧本。每集 title_hint 不超过12字,one_sentence 不超过42字,key_hook 不超过32字,must_use_memory 最多2条且每条不超过18字。不要把详细事件、对白和制作蓝图写进 overview,详细内容留给 batch 阶段。', `故事圣经:${JSON.stringify({ title: context.storyBible.title, main_plot: context.storyBible.main_plot, core_conflict: context.storyBible.core_conflict, ending_direction: context.storyBible.ending_direction })}`, productionContext ? `制作级分集/节奏规则:${productionContext}` : null, this.createEpisodePlanContextBrief(context), `章节压缩表:${this.createChapterBriefs(context.chapters, 90).join('\n')}`, `本地参考骨架:${JSON.stringify(fallbackDrafts.map((draft) => ({ episode_no: draft.episode_no, title: draft.title, summary: this.compact(draft.summary, 120), source_chapter_ids: draft.source_chapter_ids })))}` ].filter(Boolean).join('\n\n'); } private async resolveEpisodeCountRecommendation( project: Project, context: EpisodePlanContext, referenceCount: number, dto: GenerateEpisodePlanDto, taskId?: bigint ): Promise { const mode = this.resolveEpisodeCountMode(dto.episode_count_mode); if (mode === 'fixed') { return { episode_count: referenceCount, reasoning: '使用用户/项目参考集数,不启用 AI 集数建议。', duration_policy: this.episodeDurationPlanningGuide(project), split_merge_rules: ['按固定集数生成分集计划。'], source: 'fixed' }; } const providerCode = this.normalizeProviderCode(dto.provider_code); if (!providerCode || !this.providersService) { return this.estimateEpisodeCountRecommendation(project, context, referenceCount); } const requestParamsOverride = this.normalizeEpisodePlanRequestParamsOverride(dto.request_params_override); const prompt = this.buildEpisodeCountRecommendationPrompt(project, context, referenceCount); if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'episode_count_overview_batch_reconcile', phase: 'episode_count_recommendation', reference_episode_count: referenceCount, updated_at: new Date().toISOString() } }); } try { const text = await this.executeEpisodePlanTextProvider( project, providerCode, 'episode_count_recommendation', prompt, 6000, taskId, requestParamsOverride ); const parsed = this.parseProviderJson(text) ?? this.parsePartialEpisodeCountRecommendationJson(text); if (!parsed) { throw new BadRequestException('AI 集数推荐 JSON 不完整,已停止生成,避免回退到本地估算。'); } return this.episodeCountRecommendationFromObject(parsed, project, context, referenceCount); } catch (error) { throw new BadRequestException(`AI 集数推荐失败:${this.errorMessage(error)}`); } } private buildEpisodeCountRecommendationPrompt( project: Project, context: EpisodePlanContext, referenceCount: number ) { return [ '你是精品16:9横屏影视短剧总制片和总编剧。请先根据完整故事资料判断真实适合的总集数,只输出 JSON,不要 Markdown。', 'JSON 格式:{"recommended_episode_count":60,"reasoning":"","duration_policy":"","split_merge_rules":[""],"risk_notes":[""]}', `项目当前参考集数:${referenceCount}。这是运营/成本参考,不是硬限制。你的任务是按剧情完整度、章节密度、主线证据链、人物弧光和短剧节奏,给出更合理的 recommended_episode_count。`, `单集精品时长原则:常规 60-180 秒;过渡/铺垫 60-75 秒;常规冲突 75-100 秒;强反转/多证据/情绪爆发 100-140 秒;终局复杂爆点可到 140-180 秒。不要为了固定1分钟或固定总集数删剧情。`, '判断规则:1)不能按“一章一集”机械切;2)不能默认“两章一集”;3)如果章节事件密度低,可以多章合一集;4)如果一章内有多个强爆点/证据反转,可以拆成多集;5)必须保证每集有承接式开场钩子、中段冲突和结尾追看问题;6)如果推荐集数与参考集数差异很大,必须说明原因。', '输出长度控制:reasoning 不超过180字;duration_policy 不超过160字;split_merge_rules 只写6条,每条不超过45字;risk_notes 最多4条,每条不超过40字。必须输出完整闭合 JSON。', `故事圣经:${JSON.stringify({ title: context.storyBible.title, main_plot: context.storyBible.main_plot, core_conflict: context.storyBible.core_conflict, ending_direction: context.storyBible.ending_direction })}`, this.createEpisodePlanContextBrief(context), `章节压缩表:${this.createChapterBriefs(context.chapters, 120).join('\n')}` ].filter(Boolean).join('\n\n'); } private episodeCountRecommendationFromObject( value: Record | null, project: Project, context: EpisodePlanContext, referenceCount: number ): EpisodeCountRecommendation { const local = this.estimateEpisodeCountRecommendation(project, context, referenceCount); const recommended = this.readNumber(value?.recommended_episode_count); const episodeCount = recommended ? this.validatePositiveInt(Math.round(recommended), 'recommended_episode_count', MIN_EPISODES, MAX_EPISODES) : local.episode_count; return { episode_count: episodeCount, reasoning: this.readString(value?.reasoning) ?? local.reasoning, duration_policy: this.readString(value?.duration_policy) ?? local.duration_policy, split_merge_rules: this.readStringArray(value?.split_merge_rules).length ? this.readStringArray(value?.split_merge_rules) : local.split_merge_rules, source: 'ai' }; } private estimateEpisodeCountRecommendation( project: Project, context: EpisodePlanContext, referenceCount: number ): EpisodeCountRecommendation { const chapterCount = context.chapters.length; const memoryPressure = context.plotMemories.filter((memory) => memory.importance_level >= 75).length; const threadPressure = context.plotThreads.length; const avgChapterText = chapterCount > 0 ? context.chapters.reduce((sum, chapter) => sum + (chapter.summary?.length || Math.min(chapter.content.length, 1200)), 0) / chapterCount : 0; const chapterRatio = avgChapterText >= 650 ? 1.35 : avgChapterText >= 360 ? 1.8 : 2.4; const pressureBonus = memoryPressure >= 40 || threadPressure >= 15 ? 1.15 : memoryPressure >= 20 ? 1.05 : 1; const estimated = chapterCount > 0 ? Math.round((chapterCount / chapterRatio) * pressureBonus) : referenceCount; const episodeCount = this.validatePositiveInt( Math.round(Math.max(1, Math.min(MAX_EPISODES, estimated || referenceCount))), 'recommended_episode_count', MIN_EPISODES, MAX_EPISODES ); return { episode_count: episodeCount, reasoning: `本地估算:共 ${chapterCount} 章,重要记忆 ${memoryPressure} 条,开放剧情线 ${threadPressure} 条,参考集数 ${referenceCount};按章节密度和剧情压力估算为 ${episodeCount} 集。`, duration_policy: this.episodeDurationPlanningGuide(project), split_merge_rules: [ '低密度过场可多章合一集。', '单章内如果包含强反转、证据爆点或关系翻盘,可以拆成多集。', '每集必须保留承接式开场钩子、中段冲突和结尾追看问题。' ], source: 'local' }; } private resolveEpisodeCountMode(value: string | undefined) { return value === 'fixed' ? 'fixed' : 'ai_recommend'; } private buildEpisodeBatchPrompt( project: Project, context: EpisodePlanContext, overview: EpisodeOverviewItem[], previousDrafts: EpisodeDraft[], fallbackBatch: EpisodeDraft[], startNo: number, endNo: number ) { const batchOverview = overview.filter((item) => item.episode_no >= startNo && item.episode_no <= endNo); const previousContext = previousDrafts.slice(-2).map((draft) => ({ episode_no: draft.episode_no, title: draft.title, summary: this.compact(draft.summary, 180), ending_hook: draft.ending_hook })); const nextOverview = overview .filter((item) => item.episode_no > endNo && item.episode_no <= endNo + 2) .map((item) => ({ episode_no: item.episode_no, one_sentence: item.one_sentence, key_hook: item.key_hook })); const chapterIds = new Set(batchOverview.flatMap((item) => item.source_chapter_ids)); const relevantChapters = context.chapters.filter((chapter) => chapterIds.has(chapter.id.toString())); const sharedPromptRules = promptRuleLinesV1({ stage: 'episode', genre_tags: [project.genre ?? '', project.style_code ?? ''].filter(Boolean) }); assertPromptIsCleanV1(sharedPromptRules.join('\n')); return [ '你是短剧分集策划。请根据全局骨架生成当前批次详细分集,只输出 JSON,不要 Markdown。', 'JSON 格式:{"episodes":[{"episode_no":1,"title":"","summary":"","opening_hook":"","middle_conflict":"","ending_hook":"","source_chapter_ids":[""],"target_duration":75,"must_keep_events":[""],"must_keep_dialogues":["角色:短句"],"key_props":[""],"key_scenes":[""],"emotional_turn":"","evidence_turn":"","opening_payoff":"","ending_question":"","script_beats":["0-3秒承接式开场钩子:..."]}]}', ...sharedPromptRules, `当前批次:第 ${startNo}-${endNo} 集。只能输出这个范围内的 episodes,episode_no 必须连续。`, this.episodeDurationPlanningGuide(project), '质量目标:每集 opening_hook 必须是“承接式开场钩子”:第1集用身份、危机或反常画面直接开钩子;第2集及以后必须承接上一集 ending_hook,把上集悬念在0-3秒内用强画面、狠对白、证据特写、突发动作、身份揭露、死亡威胁或背叛信号之一兑现并推进,不得另起新剧情,不得跳过上集悬念。middle_conflict 要推进证据/人物关系/权力变化;ending_hook 要自然抛给下一集继续承接。不要重复标题,不要重复钩子,不要编造脱离故事圣经的新世界观。不要为了固定集数或固定时长牺牲剧情完整度,允许单集根据剧情密度在 60-180 秒内变化。', '制作蓝图要求:每集不是普通摘要,必须给后续“生成单集剧本/25宫格分镜/视频镜头”提供可执行蓝图。must_keep_events 写本集绝不能删的3-6个事件;must_keep_dialogues 写必须保留或改写成短剧爆点句的关键台词2-6句;key_props/key_scenes 写后续资产选择必须用到的道具和场景;emotional_turn 写人物情绪/关系从什么变成什么;evidence_turn 写证据链/信息差如何变化;opening_payoff 写本集开头如何兑现上一集悬念;ending_question 写观众追下一集的问题;script_beats 按目标时长写5-12个可拍 beat,每个 beat 都要能拆成后续分镜。', '生产约束:这里是分集蓝图,不是完整单集剧本。summary 控制在120-180字,opening_hook/ending_hook 各控制在35字内,middle_conflict 控制在80-130字,script_beats 每条控制在28字内。不要用长段解释换取信息量,必须用短剧事件、动作、证据和爆点句表达。', this.createEpisodePlanContextBrief(context), `全局骨架:${JSON.stringify(overview.map((item) => ({ episode_no: item.episode_no, title_hint: item.title_hint, one_sentence: item.one_sentence, arc_stage: item.arc_stage, key_hook: item.key_hook })))}`, `当前批次骨架:${JSON.stringify(batchOverview)}`, previousContext.length ? `上一批结尾承接:${JSON.stringify(previousContext)}` : null, nextOverview.length ? `下一批方向预告:${JSON.stringify(nextOverview)}` : null, `当前批次相关章节:${this.createChapterBriefs(relevantChapters.length ? relevantChapters : context.chapters, 140).join('\n')}`, `本地兜底草稿:${JSON.stringify(fallbackBatch)}` ].filter(Boolean).join('\n\n'); } private buildEpisodeReconcilePrompt( project: Project, context: EpisodePlanContext, overview: EpisodeOverviewItem[], drafts: EpisodeDraft[], fallbackDrafts: EpisodeDraft[], minQualityScore: number, localScore: EpisodePlanQualityBrief ) { void fallbackDrafts; return [ '你是短剧分集总审稿。请做全局校准补丁,只输出 JSON,不要 Markdown。', 'JSON 格式:{"patches":[{"episode_no":1,"title":"","summary":"","opening_hook":"","middle_conflict":"","ending_hook":"","source_chapter_ids":[""],"target_duration":75,"must_keep_events":[""],"must_keep_dialogues":["角色:短句"],"key_props":[""],"key_scenes":[""],"emotional_turn":"","evidence_turn":"","opening_payoff":"","ending_question":"","script_beats":["0-3秒承接式开场钩子:..."]}],"global_notes":[""]}', `当前本地评分 ${localScore.score}/${minQualityScore}。请只修改确实有问题的集,不要重写全部。重点修:重复钩子、断裂剧情线、opening_hook 没有承接上一集 ending_hook、没有推进长篇记忆、集长与剧情密度不匹配、缺少 must_keep_events/must_keep_dialogues/key_props/key_scenes/script_beats 制作蓝图。`, '制作蓝图补丁规则:patch 里如果补充 must_keep_events、must_keep_dialogues、key_props、key_scenes、emotional_turn、evidence_turn、opening_payoff、ending_question、script_beats,系统会写入分集文本供后续脚本和分镜读取。不要写空泛词,必须具体到人物、事件、道具、场景、证据和台词。', this.episodeDurationPlanningGuide(project), this.createEpisodePlanContextBrief(context), `全局骨架:${JSON.stringify(overview.map((item) => ({ episode_no: item.episode_no, one_sentence: item.one_sentence, arc_stage: item.arc_stage, key_hook: item.key_hook })))}`, `当前分集:${JSON.stringify(drafts.map((draft) => ({ episode_no: draft.episode_no, title: draft.title, summary: this.compact(draft.summary, 220), opening_hook: draft.opening_hook, middle_conflict: this.compact(draft.middle_conflict, 180), ending_hook: draft.ending_hook, source_chapter_ids: draft.source_chapter_ids, target_duration: draft.target_duration })))}`, `本地质检问题:${JSON.stringify(localScore.issues)}` ].filter(Boolean).join('\n\n'); } private applyEpisodePlanPromptOverride(basePrompt: string, override: string | undefined, stage: string) { const normalized = override?.trim(); if (!normalized) return basePrompt; if (normalized === basePrompt.trim()) return basePrompt; return [ `【用户保存的分集计划 ${stage} 阶段 Prompt 覆盖/补充】`, '以下内容来自前端预审编辑。必须吸收这些要求,但仍然严格输出本阶段要求的 JSON。', normalized, '【系统自动注入的本阶段上下文】', '下面包含故事圣经、长篇记忆、章节压缩表、本地兜底草稿和 JSON 格式约束,不得忽略。', basePrompt ].join('\n\n'); } private buildEpisodePlanProviderInputJson( pipelineStage: string, prompt: string, maxOutputTokens: number, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ): Prisma.InputJsonObject { const maxTokens = requestParamsOverride.max_output_tokens ?? maxOutputTokens; const temperature = requestParamsOverride.temperature ?? (pipelineStage === 'overview' ? 0.2 : 0.24); const timeoutMs = requestParamsOverride.timeout_ms ?? 120000; return { prompt, pipeline_stage: pipelineStage, max_output_tokens: maxTokens, max_tokens: maxTokens, temperature, timeout_ms: timeoutMs }; } private buildEpisodePlanStagePreview( project: Project, pipelineStage: string, title: string, prompt: string, maxOutputTokens: number, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ) { void project; return { key: pipelineStage.startsWith('batch_') ? 'batch' : pipelineStage, pipeline_stage: pipelineStage, title, prompt, input_json: this.buildEpisodePlanProviderInputJson( pipelineStage, prompt, maxOutputTokens, requestParamsOverride ) }; } private async generateEpisodeBatchWithProvider( project: Project, context: EpisodePlanContext, overview: EpisodeOverviewItem[], previousDrafts: EpisodeDraft[], fallbackBatch: EpisodeDraft[], selectedProvider: string, startNo: number, endNo: number, taskId?: bigint, promptOverride?: string, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ): Promise { const prompt = this.applyEpisodePlanPromptOverride( this.buildEpisodeBatchPrompt(project, context, overview, previousDrafts, fallbackBatch, startNo, endNo), promptOverride, 'batch' ); const parsed = await this.executeEpisodePlanJsonProvider( project, selectedProvider, `batch_${startNo}_${endNo}`, prompt, Math.min(12000, Math.max(7000, (endNo - startNo + 1) * 2200)), taskId, requestParamsOverride ); const drafts = this.episodeDraftsFromObject(parsed, fallbackBatch.length, project, fallbackBatch); if (!drafts) { if (startNo < endNo) { return this.generateEpisodeBatchSplitWithProvider( project, context, overview, previousDrafts, fallbackBatch, selectedProvider, startNo, endNo, taskId, promptOverride, requestParamsOverride ); } return this.throwInvalidProviderJson(`第 ${startNo}-${endNo} 集分集计划`); } return drafts.map((draft, index) => ({ ...draft, episode_no: startNo + index })); } private async generateEpisodeBatchSplitWithProvider( project: Project, context: EpisodePlanContext, overview: EpisodeOverviewItem[], previousDrafts: EpisodeDraft[], fallbackBatch: EpisodeDraft[], selectedProvider: string, startNo: number, endNo: number, taskId?: bigint, promptOverride?: string, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ): Promise { const midNo = Math.floor((startNo + endNo) / 2); const leftFallback = fallbackBatch.slice(0, midNo - startNo + 1); const rightFallback = fallbackBatch.slice(midNo - startNo + 1); const leftDrafts: EpisodeDraft[] = await this.generateEpisodeBatchWithProvider( project, context, overview, previousDrafts, leftFallback, selectedProvider, startNo, midNo, taskId, promptOverride, requestParamsOverride ); const rightDrafts: EpisodeDraft[] = await this.generateEpisodeBatchWithProvider( project, context, overview, [...previousDrafts, ...leftDrafts], rightFallback, selectedProvider, midNo + 1, endNo, taskId, promptOverride, requestParamsOverride ); return [...leftDrafts, ...rightDrafts]; } private async reconcileEpisodePlanWithProvider( project: Project, context: EpisodePlanContext, overview: EpisodeOverviewItem[], drafts: EpisodeDraft[], fallbackDrafts: EpisodeDraft[], minQualityScore: number, selectedProvider: string, taskId?: bigint, promptOverride?: string, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ) { const localScore = this.scoreEpisodePlanDrafts(drafts, context, fallbackDrafts.length, minQualityScore); if (localScore.score >= minQualityScore) { return drafts; } const prompt = this.applyEpisodePlanPromptOverride( this.buildEpisodeReconcilePrompt( project, context, overview, drafts, fallbackDrafts, minQualityScore, localScore ), promptOverride, 'reconcile' ); try { const parsed = await this.executeEpisodePlanJsonProvider( project, selectedProvider, 'reconcile', prompt, 5000, taskId, requestParamsOverride ); return this.applyEpisodePlanPatches(parsed, drafts, project); } catch { return drafts; } } private async executeEpisodePlanJsonProvider( project: Project, selectedProvider: string, pipelineStage: string, prompt: string, maxOutputTokens: number, taskId?: bigint, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ) { const text = await this.executeEpisodePlanTextProvider( project, selectedProvider, pipelineStage, prompt, maxOutputTokens, taskId, requestParamsOverride ); return this.parseProviderJson(text); } private async executeEpisodePlanTextProvider( project: Project, selectedProvider: string, pipelineStage: string, prompt: string, maxOutputTokens: number, taskId?: bigint, requestParamsOverride: EpisodePlanRequestParamsOverride = {} ) { if (!this.providersService) { return ''; } const inputJson = this.buildEpisodePlanProviderInputJson(pipelineStage, prompt, maxOutputTokens, requestParamsOverride); const output = await this.providersService.executeProvider({ provider_type: 'TextProvider', preferred_provider_code: selectedProvider, purpose: 'episode_plan_generate', project_id: project.id.toString(), task_id: taskId?.toString(), input_json: inputJson, allow_fallback: false, return_binary: false }); if (taskId) { await this.updateEpisodePlanCostSummary(taskId, { last_provider_stage: pipelineStage }); } return this.extractProviderText(output.result); } private episodeOverviewFromObject( value: Record | null, count: number, fallbackDrafts: EpisodeDraft[] ): EpisodeOverviewItem[] { const items = Array.isArray(value?.overview) ? value.overview : []; const overview = items.slice(0, count).map((item, index) => { const object = this.readObject(item); const fallback = fallbackDrafts[index]; return { episode_no: index + 1, title_hint: this.readString(object.title_hint) ?? fallback.title, one_sentence: this.readString(object.one_sentence) ?? this.compact(fallback.summary, 120), arc_stage: this.readString(object.arc_stage) ?? (index < count * 0.25 ? '开局压迫' : index < count * 0.75 ? '追证反击' : '终局收束'), source_chapter_ids: this.readStringArray(object.source_chapter_ids).length ? this.readStringArray(object.source_chapter_ids) : this.readStringArray(fallback.source_chapter_ids), key_hook: this.readString(object.key_hook) ?? fallback.ending_hook, must_use_memory: this.readStringArray(object.must_use_memory).slice(0, 5) }; }); if (overview.length === count) { return overview; } return fallbackDrafts.map((draft, index) => ({ episode_no: index + 1, title_hint: draft.title, one_sentence: this.compact(draft.summary, 120), arc_stage: index < count * 0.25 ? '开局压迫' : index < count * 0.75 ? '追证反击' : '终局收束', source_chapter_ids: this.readStringArray(draft.source_chapter_ids), key_hook: draft.ending_hook, must_use_memory: [] })); } private normalizeEpisodeDrafts( drafts: EpisodeDraft[], fallbackDrafts: EpisodeDraft[], count: number, project: Project ) { const byEpisodeNo = new Map(drafts.map((draft) => [draft.episode_no, draft])); return Array.from({ length: count }, (_, index) => { const episodeNo = index + 1; const draft = byEpisodeNo.get(episodeNo); const fallback = fallbackDrafts[index]; return { episode_no: episodeNo, source_chapter_ids: this.readStringArray(draft?.source_chapter_ids).length ? this.readStringArray(draft?.source_chapter_ids) : fallback.source_chapter_ids, title: draft?.title || fallback.title, summary: draft?.summary || fallback.summary, opening_hook: draft?.opening_hook || fallback.opening_hook, middle_conflict: draft?.middle_conflict || fallback.middle_conflict, ending_hook: draft?.ending_hook || fallback.ending_hook, target_duration: this.normalizeEpisodeTargetDuration(draft?.target_duration, project, fallback.target_duration), status: 'generated' as EpisodeStatus }; }); } private applyEpisodePlanPatches( value: Record | null, drafts: EpisodeDraft[], project: Project ) { const patches = Array.isArray(value?.patches) ? value.patches : []; const byEpisodeNo = new Map(drafts.map((draft) => [draft.episode_no, draft])); for (const item of patches) { const object = this.readObject(item); const episodeNo = this.readNumber(object.episode_no); if (!episodeNo || !byEpisodeNo.has(episodeNo)) { continue; } const current = byEpisodeNo.get(episodeNo)!; const patched = { ...current, title: this.readString(object.title) ?? current.title, summary: this.readString(object.summary) ?? current.summary, opening_hook: this.readString(object.opening_hook) ?? current.opening_hook, middle_conflict: this.readString(object.middle_conflict) ?? current.middle_conflict, ending_hook: this.readString(object.ending_hook) ?? current.ending_hook, source_chapter_ids: this.readStringArray(object.source_chapter_ids).length ? this.readStringArray(object.source_chapter_ids) : current.source_chapter_ids, target_duration: this.normalizeEpisodeTargetDuration( this.readNumber(object.target_duration), project, current.target_duration ) }; byEpisodeNo.set(episodeNo, this.mergeEpisodeProductionBlueprint(patched, object)); } return [...byEpisodeNo.values()].sort((left, right) => left.episode_no - right.episode_no); } private mergeEpisodeProductionBlueprint(draft: EpisodeDraft, source: Record): EpisodeDraft { const blueprint = this.readObject(source.production_blueprint); const readValue = (key: string) => source[key] ?? blueprint[key]; const mustKeepEvents = this.readEpisodePlanList(readValue('must_keep_events')); const mustKeepDialogues = this.readEpisodePlanList(readValue('must_keep_dialogues')); const keyProps = this.readEpisodePlanList(readValue('key_props')); const keyScenes = this.readEpisodePlanList(readValue('key_scenes')); const scriptBeats = this.readEpisodePlanList(readValue('script_beats')); const emotionalTurn = this.readString(readValue('emotional_turn')); const evidenceTurn = this.readString(readValue('evidence_turn')); const openingPayoff = this.readString(readValue('opening_payoff')); const endingQuestion = this.readString(readValue('ending_question')); const blueprintLines = [ mustKeepEvents.length ? `必保事件:${mustKeepEvents.join(';')}` : null, mustKeepDialogues.length ? `必保对白:${mustKeepDialogues.join(';')}` : null, keyProps.length ? `关键道具:${keyProps.join(';')}` : null, keyScenes.length ? `关键场景:${keyScenes.join(';')}` : null, emotionalTurn ? `情绪转折:${emotionalTurn}` : null, evidenceTurn ? `证据转折:${evidenceTurn}` : null, openingPayoff ? `开场兑现:${openingPayoff}` : null, endingQuestion ? `追看问题:${endingQuestion}` : null, scriptBeats.length ? `脚本Beat:${scriptBeats.join(' / ')}` : null ].filter((line): line is string => Boolean(line)); if (blueprintLines.length === 0) { return draft; } const middleLines = [ draft.middle_conflict, mustKeepEvents.length ? `必保事件:${mustKeepEvents.join(';')}` : null, mustKeepDialogues.length ? `必保对白:${mustKeepDialogues.join(';')}` : null, scriptBeats.length ? `脚本Beat:${scriptBeats.join(' / ')}` : null ].filter(Boolean).join('\n'); return { ...draft, summary: this.appendEpisodePlanSection(draft.summary, '制作蓝图', blueprintLines), opening_hook: openingPayoff ? `${draft.opening_hook}\n开场兑现:${openingPayoff}` : draft.opening_hook, middle_conflict: middleLines, ending_hook: endingQuestion ? `${draft.ending_hook}\n追看问题:${endingQuestion}` : draft.ending_hook }; } private appendEpisodePlanSection(text: string, title: string, lines: string[]) { if (!lines.length || text.includes(`【${title}】`)) return text; return `${text}\n【${title}】\n${lines.join('\n')}`; } private readEpisodePlanList(value: unknown) { if (Array.isArray(value)) { return value .map((item) => { if (typeof item === 'string') return item; const object = this.readObject(item); const parts = [ this.readString(object.beat), this.readString(object.speaker), this.readString(object.line) ?? this.readString(object.text), this.readString(object.event), this.readString(object.scene), this.readString(object.prop), this.readString(object.desc) ?? this.readString(object.description) ].filter(Boolean); return parts.join(':'); }) .map((item) => item.trim()) .filter(Boolean) .slice(0, 12); } if (typeof value === 'string' && value.trim()) { return value .split(/[;\n;]/) .map((item) => item.trim()) .filter(Boolean) .slice(0, 12); } return []; } private createEpisodePlanContextBrief(context: EpisodePlanContext) { return [ `角色:${context.characters.slice(0, 12).map((character) => `${character.name}/${character.role_type}/${this.compact(character.identity_desc ?? '', 80)}`).join(';')}`, `长篇记忆重点:${context.plotMemories .slice() .sort((left, right) => right.importance_level - left.importance_level) .slice(0, 45) .map((memory) => `${memory.memory_type}/${memory.importance_level}:${this.compact(memory.content, 180)}`) .join('\n')}`, `开放剧情线:${context.plotThreads .slice(0, 20) .map((thread) => `${thread.thread_type}/${thread.status}:${thread.thread_name} ${this.compact(thread.description ?? '', 160)}`) .join('\n')}` ].filter(Boolean).join('\n\n'); } private createChapterBriefs(chapters: NovelChapter[], length: number) { return chapters.map((chapter) => `${chapter.id.toString()} 第${chapter.chapter_no}章 ${chapter.title ?? ''}:${chapter.summary ?? this.compact(chapter.content, length)}` ); } private resolveEpisodePlanBatchSize(count: number) { if (count <= 12) return count; return 3; } private ensureEpisodePlanTaskRunning(taskId: bigint) { void this.runEpisodePlanTask(taskId).catch(() => undefined); } private findActiveEpisodePlanTask(projectId: bigint) { return this.prisma.renderTask.findFirst({ where: { project_id: projectId, task_type: 'episode_plan_generate', status: { in: ['pending', 'running', 'retrying'] } }, orderBy: { created_at: 'desc' } }); } private async markEpisodePlanTaskFailed(taskId: bigint, error: unknown) { const latest = await this.prisma.renderTask.findUnique({ where: { id: taskId } }).catch(() => null); const message = this.errorMessage(error); if (!latest) return; await this.updateEpisodePlanCostSummary(taskId, { pipeline_status: { mode: 'overview_batch_reconcile', phase: 'failed', error_message: message, failed_at: new Date().toISOString() } }).catch(() => undefined); if (latest.retry_count < latest.max_retry) { const retrying = await this.prisma.renderTask.update({ where: { id: latest.id }, data: { status: 'retrying', retry_count: latest.retry_count + 1, provider_request_id: null, error_code: null, error_message: null, started_at: null, finished_at: null } }); setTimeout(() => this.ensureEpisodePlanTaskRunning(retrying.id), 0); return; } await this.prisma.renderTask.update({ where: { id: latest.id }, data: { status: 'failed', error_code: this.errorCodeFromMessage(message), error_message: message, finished_at: new Date() } }).catch(() => undefined); } private errorCodeFromMessage(message: string) { return message.replace(/[^a-zA-Z0-9_]+/g, '_').slice(0, 100) || 'EPISODE_PLAN_TASK_FAILED'; } private async updateEpisodePlanTaskMeta(taskId: bigint, meta: Record) { await this.updateEpisodePlanCostSummary(taskId, meta); } private async updateEpisodePlanCostSummary(taskId: bigint, meta: Record = {}) { const task = await this.prisma.renderTask.findUnique({ where: { id: taskId } }); if (!task) return; const input = this.readObject(task.input_json); const logs = await this.prisma.providerLog.findMany({ where: { task_id: task.id }, orderBy: { created_at: 'desc' } }); const usage = this.sumProviderUsage(logs.map((log) => log.response_json)); const costEstimate = logs.reduce((sum, log) => sum + Number(log.cost_estimate ?? 0), 0); const costActual = logs.reduce((sum, log) => sum + Number(log.cost_actual ?? 0), 0); const latestSuccessLog = logs.find((log) => log.status === 'success'); await this.prisma.renderTask.update({ where: { id: task.id }, data: { cost_estimate: Number(costEstimate.toFixed(4)), cost_actual: Number(costActual.toFixed(4)), input_json: { ...input, ...meta, provider_usage: Object.keys(usage).length > 0 ? usage : input.provider_usage ?? null, provider_log_ids: logs.map((log) => log.id.toString()).reverse(), provider_log_id: latestSuccessLog?.id.toString() ?? input.provider_log_id ?? null, cost_summary: { provider_call_count: logs.length, success_call_count: logs.filter((log) => log.status === 'success').length, failed_call_count: logs.filter((log) => log.status === 'failed').length, cost_estimate: Number(costEstimate.toFixed(4)), cost_actual: Number(costActual.toFixed(4)) } } as Prisma.InputJsonObject } }); } private sumProviderUsage(values: (Prisma.JsonValue | null)[]) { const usage: Record = {}; let reasoningTokens = 0; for (const value of values) { const response = this.readObject(value); const item = this.readObject(response.usage); const inputTokens = this.readNumber(item.input_tokens) ?? 0; const outputTokens = this.readNumber(item.output_tokens) ?? 0; const totalTokens = this.readNumber(item.total_tokens) ?? 0; const outputDetails = this.readObject(item.output_tokens_details); const reasoning = this.readNumber(outputDetails.reasoning_tokens) ?? 0; usage.input_tokens = (usage.input_tokens ?? 0) + inputTokens; usage.output_tokens = (usage.output_tokens ?? 0) + outputTokens; usage.total_tokens = (usage.total_tokens ?? 0) + totalTokens; reasoningTokens += reasoning; } if ((usage.input_tokens ?? 0) <= 0 && (usage.output_tokens ?? 0) <= 0 && (usage.total_tokens ?? 0) <= 0) { return {}; } if (!usage.total_tokens) { usage.total_tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0); } if (reasoningTokens > 0) { return { ...usage, output_tokens_details: { reasoning_tokens: reasoningTokens } }; } return usage; } private episodeDraftsFromObject( value: Record | null, count: number, project: Project, fallbackDrafts: EpisodeDraft[] ): EpisodeDraft[] | null { const items = Array.isArray(value?.episodes) ? value.episodes : []; if (items.length === 0) return null; const drafts = items.slice(0, count).map((item, index) => { const object = this.readObject(item); const fallback = fallbackDrafts[index]; const draft = { episode_no: index + 1, source_chapter_ids: this.readStringArray(object.source_chapter_ids).length ? this.readStringArray(object.source_chapter_ids) : fallback.source_chapter_ids, title: this.readString(object.title) ?? fallback.title, summary: this.readString(object.summary) ?? fallback.summary, opening_hook: this.readString(object.opening_hook) ?? fallback.opening_hook, middle_conflict: this.readString(object.middle_conflict) ?? fallback.middle_conflict, ending_hook: this.readString(object.ending_hook) ?? fallback.ending_hook, target_duration: this.normalizeEpisodeTargetDuration( this.readNumber(object.target_duration), project, fallback.target_duration ), status: 'generated' as EpisodeStatus }; return this.mergeEpisodeProductionBlueprint(draft, object); }); return drafts.length === count && drafts.every((draft) => draft.title && draft.summary) ? drafts : null; } private scoreEpisodePlanDrafts( drafts: EpisodeDraft[], context: EpisodePlanContext, expectedCount: number, targetScore: number ) { const draftTexts = drafts.map((draft) => [ draft.title, draft.summary, draft.opening_hook, draft.middle_conflict, draft.ending_hook ].join('\n') ); const fullText = draftTexts.join('\n'); const completeFieldCount = drafts.filter((draft) => draft.title && draft.summary && draft.opening_hook && draft.middle_conflict && draft.ending_hook && draft.target_duration ).length; const hookCount = drafts.filter((draft) => this.hasEpisodeHook(draft.opening_hook) && this.hasEpisodeHook(draft.ending_hook) ).length; const conflictCount = drafts.filter((draft) => /冲突|对峙|证据|反击|施压|逼迫|调查|揭露|真相|反转|翻车|失控|崩盘/u.test( `${draft.middle_conflict}\n${draft.summary}` ) ).length; const sourceChapterIds = new Set( drafts.flatMap((draft) => this.readStringArray(draft.source_chapter_ids)) ); const expectedSourceCount = Math.min(context.chapters.length, Math.max(expectedCount, 1)); const sourceCoverage = expectedSourceCount > 0 ? this.ratioScore(Math.min(sourceChapterIds.size, expectedSourceCount), expectedSourceCount) : 100; const characterNames = context.characters .filter((character) => ['protagonist', 'lead', 'antagonist', 'supporting'].includes(character.role_type)) .slice(0, 8) .map((character) => character.name) .filter(Boolean); const characterCoverage = characterNames.length === 0 ? 100 : this.ratioScore(characterNames.filter((name) => fullText.includes(name)).length, Math.min(characterNames.length, 6)); const memoryKeywords = context.plotMemories .filter((memory) => memory.importance_level >= 75) .flatMap((memory) => this.extractKeywords(memory.content)) .filter(Boolean); const uniqueMemoryKeywords = [...new Set(memoryKeywords)].slice(0, 30); const memoryAbsorption = uniqueMemoryKeywords.length === 0 ? 100 : this.ratioScore(uniqueMemoryKeywords.filter((keyword) => fullText.includes(keyword)).length, Math.min(uniqueMemoryKeywords.length, 12)); const threadKeywords = context.plotThreads .flatMap((thread) => this.extractKeywords(`${thread.thread_name} ${thread.description ?? ''}`)) .filter(Boolean); const uniqueThreadKeywords = [...new Set(threadKeywords)].slice(0, 20); const threadProgress = uniqueThreadKeywords.length === 0 ? 100 : this.ratioScore(uniqueThreadKeywords.filter((keyword) => fullText.includes(keyword)).length, Math.min(uniqueThreadKeywords.length, 8)); const shortDramaTerms = ['下一集', '真相', '证据', '反转', '出现', '揭露', '电话', '监控', '文件', '证人', '崩盘', '翻车']; const rhythmScore = this.ratioScore(shortDramaTerms.filter((term) => fullText.includes(term)).length, 6); const durationValues = drafts .map((draft) => draft.target_duration) .filter((value) => Number.isFinite(value)); const uniqueDurations = new Set(durationValues); const durationAdaptationScore = expectedCount <= 1 ? 100 : durationValues.length !== expectedCount ? 60 : uniqueDurations.size >= Math.min(3, expectedCount) ? 100 : uniqueDurations.size >= 2 ? 88 : 76; const blueprintCount = drafts.filter((draft) => /制作蓝图|必保事件|必保对白|关键道具|关键场景|情绪转折|证据转折|脚本Beat/u.test( `${draft.summary}\n${draft.middle_conflict}\n${draft.opening_hook}\n${draft.ending_hook}` ) ).length; const blueprintScore = this.ratioScore(blueprintCount, expectedCount); const terminalClosure = this.scoreEpisodeTerminalClosure(drafts); const countScore = drafts.length === expectedCount ? 100 : this.ratioScore(drafts.length, expectedCount); const fieldScore = this.ratioScore(completeFieldCount, expectedCount); const hookScore = this.ratioScore(hookCount, expectedCount); const conflictScore = this.ratioScore(conflictCount, expectedCount); const duplicatePenalty = this.duplicateContentPenalty([ ...drafts.map((draft) => draft.title), ...drafts.map((draft) => draft.summary.slice(0, 120)), ...drafts.map((draft) => draft.ending_hook) ]); const metrics = { episode_count: drafts.length, expected_count: expectedCount, count_score: countScore, field_score: fieldScore, hook_score: hookScore, conflict_score: conflictScore, source_coverage: sourceCoverage, character_coverage: characterCoverage, memory_absorption: memoryAbsorption, thread_progress: threadProgress, short_drama_rhythm: rhythmScore, duration_adaptation: durationAdaptationScore, production_blueprint: blueprintScore, terminal_closure: terminalClosure.score, terminal_closure_missing: terminalClosure.missing, duration_values: durationValues, duplicate_penalty: duplicatePenalty }; const score = Math.max(0, Math.min(100, Math.round( countScore * 0.07 + fieldScore * 0.08 + hookScore * 0.10 + conflictScore * 0.10 + sourceCoverage * 0.08 + characterCoverage * 0.06 + memoryAbsorption * 0.11 + threadProgress * 0.07 + rhythmScore * 0.05 + durationAdaptationScore * 0.03 + blueprintScore * 0.11 + terminalClosure.score * 0.14 - duplicatePenalty ))); const strengths: string[] = []; const issues: string[] = []; const suggestions: string[] = []; if (score >= targetScore) { strengths.push(`分集计划通过当前 ${targetScore} 分质量门,可进入脚本生成。`); } if (hookScore >= 95) strengths.push('每集开头和结尾都有可承接钩子。'); if (memoryAbsorption >= 85) strengths.push('已吸收长篇记忆中的伏笔、证据和未解决冲突。'); if (sourceCoverage >= 90) strengths.push('章节来源覆盖较完整,适合长篇压缩改编。'); if (durationAdaptationScore >= 90) strengths.push('集长已按剧情密度动态规划,不再机械固定 60 秒。'); if (blueprintScore >= 90) strengths.push('每集已包含制作蓝图,可直接服务脚本、25宫格分镜和视频镜头。'); if (terminalClosure.score >= 75) strengths.push('终局已交代核心冲突结果、主角选择、状态变化与后续余波。'); if (drafts.length !== expectedCount) { issues.push(`集数不匹配:需要 ${expectedCount} 集,实际 ${drafts.length} 集。`); } if (fieldScore < 100) { issues.push('部分分集缺少标题、摘要、开场钩子、中段冲突、结尾钩子或时长。'); suggestions.push('补齐每集 6 个基础字段后再确认。'); } if (hookScore < 90) { issues.push('部分分集钩子不够强,短剧承接感不足。'); suggestions.push('每集开场写即时冲突,结尾写下一集必须追看的新线索或反转。'); } if (memoryAbsorption < 75) { issues.push('长篇记忆吸收不足,可能漏掉伏笔、证据、道具或人物状态。'); suggestions.push('重新生成时强调使用 plot_memories 和 plot_threads。'); } if (threadProgress < 75) { issues.push('开放剧情线推进不足,后续脚本可能散。'); suggestions.push('每集至少推进一条主线、悬疑线或反派计划。'); } if (durationAdaptationScore < 85 && expectedCount > 1) { issues.push('集长变化不足,可能仍然按固定秒数机械规划。'); suggestions.push('高密度反转集可放宽到 75-90 秒,过渡/信息较少的集控制在 45-60 秒。'); } if (blueprintScore < 90) { issues.push('部分分集缺少制作蓝图,后续脚本/分镜可能继续脑补导致删减或不连贯。'); suggestions.push('为每集补齐必保事件、必保对白、关键道具、关键场景、证据转折和脚本Beat。'); } if (terminalClosure.score < 75) { issues.push(`终局闭环不足,缺少:${terminalClosure.missing.join('、')}。`); suggestions.push('在末段补齐核心冲突结果、主角主动选择、关键人物状态变化和事件余波;具体内容必须来自本项目剧本与设定。'); } if (duplicatePenalty > 0) { issues.push('存在重复标题、摘要或结尾钩子。'); } return { score, target_score: targetScore, passed: score >= targetScore, level: score >= targetScore ? 'excellent' : score >= 90 ? 'ready' : 'needs_review', strengths, issues, suggestions, metrics }; } private hasEpisodeHook(value: string) { return Boolean(value?.trim()) && /下一集|真相|证据|发现|出现|反转|揭露|电话|监控|敲门|追查|逼近|崩盘|翻车|直播|威胁|签署|认罪|死亡|病危|红屏|大屏|审判|法庭|秘密|身份|摔|亮起|弹出|喊/u.test(value); } private scoreEpisodeTerminalClosure(drafts: EpisodeDraft[]) { const terminalText = drafts .slice(Math.max(0, drafts.length - 10)) .map((draft) => [ draft.title, draft.summary, draft.opening_hook, draft.middle_conflict, draft.ending_hook ].join('\n')) .join('\n'); const requirements = [ { label: '核心冲突结果', pattern: /真相.{0,16}(?:公开|揭露|查明)|(?:对手|反派).{0,16}(?:失败|被捕|伏法|付出代价)|(?:危机|冲突).{0,16}(?:解除|解决)|(?:任务|目标).{0,16}(?:完成|失败)|终局|结局/u }, { label: '主角主动选择', pattern: /选择|决定|拒绝|接受|承担|牺牲|放下|反击|揭露|离开|留下|回归/u }, { label: '关键人物状态变化', pattern: /从此|后来|重新|成为|失去|得到|恢复|建立|重建|和解|决裂|获救|死亡/u }, { label: '事件余波或新秩序', pattern: /余波|新生活|新秩序|未来|代价|传开|天下皆知|众人|再也|开始|落幕|结束/u } ]; const missing = requirements .filter((requirement) => !requirement.pattern.test(terminalText)) .map((requirement) => requirement.label); return { score: this.ratioScore(requirements.length - missing.length, requirements.length), missing }; } private ratioScore(value: number, target: number) { if (target <= 0) return 100; return Math.max(0, Math.min(100, Math.round((value / target) * 100))); } private duplicateContentPenalty(values: string[]) { const normalized = values .map((value) => this.compact(value, 160).slice(0, 80)) .filter(Boolean); const unique = new Set(normalized); const duplicateCount = Math.max(0, normalized.length - unique.size); return Math.min(10, duplicateCount * 2); } private extractKeywords(text: string) { const compacted = this.compact(text, 500); const matches = compacted.match(/[\u4e00-\u9fa5]{2,8}/g) ?? []; const stopWords = new Set([ '本集', '下一集', '主要', '目标', '需要', '继续', '推进', '角色', '剧情', '故事', '短剧' ]); return matches .flatMap((match) => (match.length > 4 ? [match.slice(0, 4), match.slice(-4)] : [match])) .filter((match) => match.length >= 2 && !stopWords.has(match)) .slice(0, 20); } private buildEpisodeTitle(episodeNo: number, chapter: NovelChapter, count: number) { const cleaned = chapter.title ?.replace(/^第?[0-9一二三四五六七八九十百千万]+[章节集回话、.\s-]*/u, '') .trim(); const fallback = episodeNo === count ? '真相逼近' : episodeNo === 1 ? '开局反击' : '冲突升级'; return `第${episodeNo}集 ${cleaned || fallback}`; } private buildEndingHook( episodeNo: number, count: number, protagonistName: string, chapter: NovelChapter, foreshadowing: PlotMemory | undefined, storyBible: StoryBible ) { if (episodeNo === count) { return storyBible.ending_direction || `${protagonistName}阶段性赢下对抗,但幕后真相仍未完全揭开。`; } const source = foreshadowing?.content || chapter.summary || chapter.title || '关键线索'; return `${protagonistName}发现${this.compact(source).slice(0, 42)},下一集必须继续追查。`; } private pickChapterGroup(chapters: NovelChapter[], index: number, count: number) { const start = Math.floor((index * chapters.length) / count); const end = Math.max(start + 1, Math.floor(((index + 1) * chapters.length) / count)); return chapters.slice(start, Math.min(end, chapters.length)); } private async createUpdateData( episode: Episode, dto: UpdateEpisodeDto ): Promise { const data: Prisma.EpisodeUncheckedUpdateInput = {}; if ('episode_no' in dto) { data.episode_no = await this.validateEpisodeNoForUpdate(episode, dto.episode_no); } if ('source_chapter_ids' in dto) { data.source_chapter_ids = await this.validateSourceChapterIds( episode.project_id, dto.source_chapter_ids ); } if ('title' in dto) data.title = this.optionalText(dto.title); if ('summary' in dto) data.summary = this.optionalText(dto.summary); if ('opening_hook' in dto) data.opening_hook = this.optionalText(dto.opening_hook); if ('middle_conflict' in dto) data.middle_conflict = this.optionalText(dto.middle_conflict); if ('ending_hook' in dto) data.ending_hook = this.optionalText(dto.ending_hook); if ('target_duration' in dto) { data.target_duration = this.validateDuration(dto.target_duration); } if ('status' in dto) data.status = this.validateStatus(dto.status); if (Object.keys(data).length > 0 && data.status !== 'confirmed') { data.status = data.status ?? 'edited'; } return data; } private async validateEpisodeNoForUpdate(episode: Episode, value: number | undefined) { const episodeNo = this.validatePositiveInt(value, 'episode_no', MIN_EPISODES, MAX_EPISODES); if (episodeNo === episode.episode_no) { return episodeNo; } const existing = await this.prisma.episode.findFirst({ where: { project_id: episode.project_id, episode_no: episodeNo, id: { not: episode.id } } }); if (existing) { throw new BadRequestException('episode_no already exists in this project'); } return episodeNo; } private async validateSourceChapterIds(projectId: bigint, value: string[] | undefined) { if (!Array.isArray(value) || value.length === 0) { throw new BadRequestException('source_chapter_ids must be a non-empty array'); } const ids = value.map((item) => this.parseId(String(item), 'Invalid source chapter id')); const count = await this.prisma.novelChapter.count({ where: { project_id: projectId, id: { in: ids } } }); if (count !== ids.length) { throw new BadRequestException('source_chapter_ids contain chapters outside this project'); } return ids.map((id) => id.toString()); } private assertEpisodesReadyForConfirmation(episodes: Episode[]) { if (episodes.length === 0) { throw new BadRequestException('Episode plan is required before confirmation'); } for (const [index, episode] of episodes.entries()) { if (episode.episode_no !== index + 1) { throw new BadRequestException('Episode numbers must be continuous from 1'); } if ( !episode.title || !episode.summary || !episode.opening_hook || !episode.middle_conflict || !episode.ending_hook || !episode.target_duration ) { throw new BadRequestException('All episodes must include title, hooks, conflict, summary, and duration'); } } } private async findProjectForUser(projectId: string, user: AuthRequestUser) { const project = await this.prisma.project.findUnique({ where: { id: this.parseId(projectId, 'Invalid project id') } }); if (!project) { throw new NotFoundException('Project not found'); } if (project.user_id.toString() !== user.id && user.role !== 'admin') { throw new ForbiddenException('Project is private'); } return project; } private assertLegacyWritePath(project: Project) { if (project.engine_version === 'splus_v1') { throw new BadRequestException('splus_v1 项目必须使用结构化 EpisodePlan 合同,旧分集写接口只读'); } } private async findEpisodeForUser(episodeId: string, user: AuthRequestUser) { const episode = await this.prisma.episode.findUnique({ where: { id: this.parseId(episodeId, 'Invalid episode id') } }); if (!episode) { throw new NotFoundException('Episode not found'); } await this.findProjectForUser(episode.project_id.toString(), user); return episode; } private resolveEpisodeCount(project: Project, value: number | undefined) { return this.validatePositiveInt( value ?? project.target_episode_count ?? (project.input_mode === 'ai_original' ? 3 : 1), 'target_episode_count', MIN_EPISODES, MAX_EPISODES ); } private validateDuration(value: number | undefined) { return this.validatePositiveInt(value, 'target_duration', MIN_DURATION, MAX_DURATION); } private resolveEpisodeReferenceDuration(project: Project) { return this.validateDuration(project.episode_duration ?? DEFAULT_EPISODE_DURATION); } private episodeDurationPlanningGuide(project: Project) { const referenceDuration = this.resolveEpisodeReferenceDuration(project); return [ `集长规则:项目参考单集 ${referenceDuration} 秒,但这不是硬限制。`, `请按剧情密度为每集单独选择 target_duration,常规精品区间 ${MIN_QUALITY_EPISODE_DURATION}-${MAX_QUALITY_EPISODE_DURATION} 秒。`, '建议:过渡/铺垫集 60-75 秒;常规冲突集 75-100 秒;强反转/多证据/情绪爆发集 100-140 秒;终局或复杂爆点集可到 140-180 秒。', '不要为了凑固定 60 秒删掉关键冲突,也不要无意义拉长;target_duration 必须服务剧情节奏。' ].join(' '); } private normalizeEpisodeTargetDuration( value: number | null | undefined, project: Project, fallbackDuration?: number ) { const fallback = fallbackDuration ?? this.resolveEpisodeReferenceDuration(project); const numeric = Number(value); if (!Number.isFinite(numeric)) { return this.validateDuration(fallback); } return this.validateDuration(Math.round(numeric)); } private estimateEpisodeTargetDuration(input: { project: Project; referenceDuration: number; episodeNo: number; count: number; text: string; }) { const text = input.text || ''; const keywordScore = [ /反转|翻车|真相|证据|揭露|曝光|审判|死亡|车祸|崩盘|灭口/u, /亲情|亲子|身份|血缘|旧案|秘密地点|关键载体|账本|录音/u, /对峙|逼迫|威胁|追查|逃离|失控|反击|钩子/u ].reduce((score, pattern) => score + (pattern.test(text) ? 1 : 0), 0); let duration = input.referenceDuration; if (input.referenceDuration === DEFAULT_EPISODE_DURATION) { duration = text.length > 700 || keywordScore >= 3 ? 75 : text.length < 260 && keywordScore <= 1 ? 45 : 60; } else if (keywordScore >= 3 || text.length > 900) { duration += 15; } else if (keywordScore <= 1 && text.length < 260) { duration -= 15; } if (input.episodeNo === 1 || input.episodeNo === input.count) { duration = Math.max(duration, Math.min(MAX_QUALITY_EPISODE_DURATION, input.referenceDuration + 15)); } return this.validateDuration(Math.max( MIN_QUALITY_EPISODE_DURATION, Math.min(MAX_QUALITY_EPISODE_DURATION, duration) )); } private validateStatus(value: string | undefined): EpisodeStatus { if (!value || !EPISODE_STATUSES.includes(value as never)) { throw new BadRequestException('episode status is invalid'); } return value as EpisodeStatus; } private validatePositiveInt(value: unknown, field: string, min: number, max: number) { const numberValue = Number(value); if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); } return numberValue; } private optionalText(value: string | undefined) { const normalized = value?.trim(); return normalized || null; } private compact(value: string, length = 600) { return value.replace(/\s+/g, ' ').trim().slice(0, length); } private readObject(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } private storyBibleProductionContext(storyBible: StoryBible, keys: string[]) { const bible = this.readObject(storyBible.production_bible_json); if (Object.keys(bible).length === 0) { return ''; } const selected: Record = {}; for (const key of keys) { if (bible[key] !== undefined) { selected[key] = bible[key]; } } return this.compact(JSON.stringify(Object.keys(selected).length > 0 ? selected : bible), 7000); } private readString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } private readNumber(value: unknown) { const numberValue = Number(value); return Number.isFinite(numberValue) ? numberValue : null; } private readStringArray(value: unknown) { return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : []; } private normalizeEpisodePlanPromptOverrides(value: unknown): EpisodePlanPromptOverrides { const object = this.readObject(value); const normalizePrompt = (child: unknown) => this.compact(this.readString(child) ?? '', 20000); const normalized: EpisodePlanPromptOverrides = {}; const overview = normalizePrompt(object.overview); const batch = normalizePrompt(object.batch); const reconcile = normalizePrompt(object.reconcile); if (overview) normalized.overview = overview; if (batch) normalized.batch = batch; if (reconcile) normalized.reconcile = reconcile; return normalized; } private normalizeEpisodePlanRequestParamsOverride(value: unknown): EpisodePlanRequestParamsOverride { const object = this.readObject(value); const maxOutputTokens = this.readNumber(object.max_output_tokens); const temperature = this.readNumber(object.temperature); const timeoutMs = this.readNumber(object.timeout_ms); const normalized: EpisodePlanRequestParamsOverride = {}; if (maxOutputTokens !== null) { normalized.max_output_tokens = Math.max(2000, Math.min(20000, Math.round(maxOutputTokens))); } if (temperature !== null) { normalized.temperature = Number(Math.max(0, Math.min(1, temperature)).toFixed(2)); } if (timeoutMs !== null) { normalized.timeout_ms = Math.max(30000, Math.min(300000, Math.round(timeoutMs))); } return normalized; } private extractProviderText(result: unknown) { const object = this.readObject(result); return this.readString(object.text) ?? this.readString(object.chapter_text) ?? ''; } private parseProviderJson(text: string): Record | null { const cleaned = text.replace(/^```(?:json)?/i, '').replace(/```$/i, '').trim(); const jsonText = cleaned.startsWith('{') ? cleaned : cleaned.match(/\{[\s\S]*\}/)?.[0] ?? ''; if (!jsonText) return null; try { const parsed = JSON.parse(jsonText) as unknown; return this.readObject(parsed); } catch { return null; } } private parsePartialEpisodeCountRecommendationJson(text: string): Record | null { const count = this.readNumberFromText(text, /"recommended_episode_count"\s*:\s*(\d+)/u); if (!count) return null; return { recommended_episode_count: count, reasoning: this.readJsonStringFromText(text, 'reasoning') ?? 'AI 已返回推荐集数,但说明字段不完整。', duration_policy: this.readJsonStringFromText(text, 'duration_policy') ?? '', split_merge_rules: this.readJsonArrayStringsFromText(text, 'split_merge_rules'), risk_notes: this.readJsonArrayStringsFromText(text, 'risk_notes') }; } private readNumberFromText(text: string, pattern: RegExp) { const match = text.match(pattern); if (!match?.[1]) return null; const value = Number(match[1]); return Number.isFinite(value) ? value : null; } private readJsonStringFromText(text: string, key: string) { const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\\\\\.|[^"\\\\\\\\])*)`, 'u'); const match = text.match(pattern); if (!match?.[1]) return null; try { return JSON.parse(`"${match[1]}"`) as string; } catch { return match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n'); } } private readJsonArrayStringsFromText(text: string, key: string) { const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([\\s\\S]*?)(?:\\]|$)`, 'u'); const body = text.match(pattern)?.[1]; if (!body) return []; const values: string[] = []; const itemPattern = /"((?:\\.|[^"\\])*)"/gu; let match: RegExpExecArray | null; while ((match = itemPattern.exec(body)) && values.length < 8) { try { values.push(JSON.parse(`"${match[1]}"`) as string); } catch { values.push(match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n')); } } return values; } private normalizeProviderCode(value: string | undefined) { const normalized = value?.trim(); return normalized || undefined; } private normalizeMinQualityScore(value: unknown, fallback: number) { const numberValue = Number(value ?? fallback); if (!Number.isFinite(numberValue)) return fallback; return Math.max(70, Math.min(100, Math.round(numberValue))); } private throwInvalidProviderJson(stage: string): never { throw new BadRequestException(`选定文本模型没有返回可用的${stage} JSON`); } private errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } private parseId(id: string, message: string) { try { return BigInt(id); } catch { throw new BadRequestException(message); } } private hashJson(value: Prisma.InputJsonValue | Prisma.JsonValue | null) { return createHash('sha256').update(this.stableStringify(value)).digest('hex'); } private stableStringify(value: Prisma.InputJsonValue | Prisma.JsonValue | null): string { if (value === null || typeof value !== 'object') { return JSON.stringify(value); } if (Array.isArray(value)) { return `[${value.map((item) => this.stableStringify(item as Prisma.JsonValue)).join(',')}]`; } const entries = Object.entries(value) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, child]) => `${JSON.stringify(key)}:${this.stableStringify(child as Prisma.JsonValue)}`); return `{${entries.join(',')}}`; } }