2656 lines
98 KiB
TypeScript
2656 lines
98 KiB
TypeScript
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_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<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} 集。只能输出这个范围内的 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<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(',')}}`;
|
||
}
|
||
}
|