Files
ai/backend/src/episodes/episodes.service.ts
T

2656 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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<string, unknown>
},
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<EpisodePlanContext> {
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<EpisodeDraft[] | null> {
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_ids3arc_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<EpisodeCountRecommendation> {
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<string, unknown> | 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} 集。只能输出这个范围内的 episodesepisode_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<EpisodeDraft[]> {
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<EpisodeDraft[]> {
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<string, unknown> | 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<string, unknown> | 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<string, unknown>): 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<string, unknown>) {
await this.updateEpisodePlanCostSummary(taskId, meta);
}
private async updateEpisodePlanCostSummary(taskId: bigint, meta: Record<string, unknown> = {}) {
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<string, number> = {};
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<string, unknown> | 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<Prisma.EpisodeUncheckedUpdateInput> {
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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
private storyBibleProductionContext(storyBible: StoryBible, keys: string[]) {
const bible = this.readObject(storyBible.production_bible_json);
if (Object.keys(bible).length === 0) {
return '';
}
const selected: Record<string, unknown> = {};
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<string, unknown> | 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<string, unknown> | 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(',')}}`;
}
}