main
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-03-23 02:34:03 +03:00
parent 83b0ae61a8
commit e60b6ea526
8 changed files with 667 additions and 1 deletions

View File

@@ -181,6 +181,10 @@ model ScriptProject {
highConcept String? @db.Text
includeInterviews Boolean @default(false)
// Project Status
status String @default("DRAFT") // DRAFT, RESEARCHING, SCRIPTING, ANALYZING, COMPLETED
currentVersionNumber Int @default(0)
// SEO Data (stored as JSON)
seoTitle String?
seoDescription String? @db.Text
@@ -205,9 +209,11 @@ model ScriptProject {
characters CharacterProfile[]
briefItems BriefItem[]
visualAssets VisualAsset[]
versions ScriptVersion[]
@@index([userId])
@@index([topic])
@@index([status])
}
model ScriptSegment {
@@ -312,3 +318,36 @@ model VisualAsset {
@@index([projectId])
}
// ============================================
// Version History
// ============================================
model ScriptVersion {
id String @id @default(uuid())
projectId String
versionNumber Int
label String? // User-defined label, e.g. "Final Draft", "Before Rewrite"
generatedBy String @default("AI") // AI | USER | AUTO_SAVE
// Snapshot data: complete segments at this point in time
snapshotData Json // Array of segment objects
// Optional: SEO snapshot
seoSnapshot Json? // { seoTitle, seoDescription, seoTags, thumbnailIdeas }
// Metadata
segmentCount Int @default(0)
totalWords Int @default(0)
changeNote String? @db.Text // What changed in this version
// Timestamps
createdAt DateTime @default(now())
// Relations
project ScriptProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([projectId, versionNumber])
@@index([projectId])
@@index([createdAt])
}

View File

@@ -2,3 +2,4 @@ export * from './projects.controller';
export * from './scripts.controller';
export * from './research.controller';
export * from './analysis.controller';
export * from './versions.controller';

View File

@@ -99,4 +99,25 @@ export class ScriptsController {
async generateSegmentImage(@Param('id') id: string) {
return this.scriptsService.generateSegmentImage(id);
}
@Post('segments/:id/regenerate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Regenerate a single segment with AI' })
async regenerateSegment(@Param('id') id: string) {
return this.scriptsService.regenerateSegment(id);
}
@Post('regenerate-partial')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Regenerate selected segments with AI' })
async regeneratePartial(
@Body() body: { projectId: string; segmentIds: string[] },
) {
return this.scriptsService.regeneratePartial(
body.projectId,
body.segmentIds,
);
}
}

View File

@@ -0,0 +1,96 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
Query,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
import { VersionsService } from '../services/versions.service';
/**
* VersionsController
*
* REST API for managing script version history.
*
* Endpoints:
* - GET /projects/:projectId/versions — List all versions
* - GET /projects/:projectId/versions/:id — Get version details
* - POST /projects/:projectId/versions — Manual save (create snapshot)
* - POST /projects/:projectId/versions/:id/restore — Restore to version
* - DELETE /projects/:projectId/versions/:id — Delete version
* - GET /projects/:projectId/versions/compare — Compare two versions
*/
@ApiTags('SkriptAI - Versions')
@ApiBearerAuth()
@Controller('skriptai/projects/:projectId/versions')
export class VersionsController {
private readonly logger = new Logger(VersionsController.name);
constructor(private readonly versionsService: VersionsService) {}
@Get()
@ApiOperation({ summary: 'List all versions for a project' })
@ApiParam({ name: 'projectId', description: 'Project ID' })
async listVersions(@Param('projectId') projectId: string) {
return this.versionsService.listVersions(projectId);
}
@Get('compare')
@ApiOperation({ summary: 'Compare two versions' })
async compareVersions(
@Param('projectId') projectId: string,
@Query('versionA') versionAId: string,
@Query('versionB') versionBId: string,
) {
return this.versionsService.compareVersions(
projectId,
versionAId,
versionBId,
);
}
@Get(':id')
@ApiOperation({ summary: 'Get a specific version with full snapshot data' })
async getVersion(
@Param('projectId') projectId: string,
@Param('id') versionId: string,
) {
return this.versionsService.getVersion(projectId, versionId);
}
@Post()
@ApiOperation({ summary: 'Manually save current state as a new version' })
async createSnapshot(
@Param('projectId') projectId: string,
@Body() body: { label?: string; changeNote?: string },
) {
return this.versionsService.createSnapshot(
projectId,
'USER',
body.label,
body.changeNote,
);
}
@Post(':id/restore')
@ApiOperation({ summary: 'Restore project to a specific version' })
async restoreVersion(
@Param('projectId') projectId: string,
@Param('id') versionId: string,
) {
return this.versionsService.restoreVersion(projectId, versionId);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a specific version' })
async deleteVersion(
@Param('projectId') projectId: string,
@Param('id') versionId: string,
) {
return this.versionsService.deleteVersion(projectId, versionId);
}
}

View File

@@ -2,3 +2,4 @@ export * from './projects.service';
export * from './scripts.service';
export * from './research.service';
export * from './analysis.service';
export * from './versions.service';

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../../database/prisma.service';
import { GeminiService } from '../../gemini/gemini.service';
import { CreateSegmentDto, UpdateSegmentDto } from '../dto';
import { AnalysisService } from './analysis.service';
import { VersionsService } from './versions.service';
import {
buildScriptOutlinePrompt,
buildChapterSegmentPrompt,
@@ -28,6 +29,7 @@ export class ScriptsService {
private readonly prisma: PrismaService,
private readonly gemini: GeminiService,
private readonly analysisService: AnalysisService,
private readonly versionsService: VersionsService,
) {}
/**
@@ -113,6 +115,20 @@ export class ScriptsService {
async generateScript(projectId: string) {
this.logger.log(`Generating script for project: ${projectId}`);
// Auto-snapshot current state before regeneration
await this.versionsService.createSnapshot(
projectId,
'AUTO_SAVE',
undefined,
'Auto-save before script generation',
).catch(() => { /* ignore if no segments yet */ });
// Update status
await this.prisma.scriptProject.update({
where: { id: projectId },
data: { status: 'SCRIPTING' },
});
const project = await this.prisma.scriptProject.findUnique({
where: { id: projectId },
include: {
@@ -364,4 +380,103 @@ export class ScriptsService {
},
});
}
// ========== REGENERATE (Tek segment / Partial) ==========
/**
* Regenerate a single segment with AI.
* Auto-snapshots current state before regeneration.
*
* @param segmentId - Segment ID to regenerate
* @returns Updated segment
*/
async regenerateSegment(segmentId: string) {
const segment = await this.prisma.scriptSegment.findUnique({
where: { id: segmentId },
include: { project: { include: { characters: true } } },
});
if (!segment) {
throw new NotFoundException(`Segment with ID ${segmentId} not found`);
}
// Auto-snapshot before regeneration
await this.versionsService.createSnapshot(
segment.projectId,
'AUTO_SAVE',
undefined,
`Auto-save before regenerating segment #${segment.sortOrder + 1}`,
).catch(() => {});
const characterContext = segment.project.characters
.map((c) => `${c.name} (${c.role}): Values[${c.values}] Traits[${c.traits}]`)
.join('\n');
const chapterPromptData = buildChapterSegmentPrompt({
chapterIndex: segment.sortOrder,
totalChapters: 1,
chapterTitle: segment.segmentType,
chapterFocus: segment.visualDescription || segment.segmentType,
chapterType: segment.segmentType,
speechStyles: segment.project.speechStyle,
targetAudience: segment.project.targetAudience,
characterContext,
language: segment.project.language,
});
const resp = await this.gemini.generateJSON<any[]>(
chapterPromptData.prompt,
chapterPromptData.schema,
{ temperature: chapterPromptData.temperature },
);
const newSeg = resp.data[0];
if (!newSeg) return segment;
const words = newSeg.narratorScript ? newSeg.narratorScript.split(' ').length : 0;
const dur = Math.max(5, Math.ceil(words / (140 / 60)));
return this.prisma.scriptSegment.update({
where: { id: segmentId },
data: {
narratorScript: newSeg.narratorScript,
visualDescription: newSeg.visualDescription,
videoPrompt: newSeg.videoPrompt,
imagePrompt: newSeg.imagePrompt,
onScreenText: newSeg.onScreenText,
audioCues: newSeg.audioCues,
stockQuery: newSeg.stockQuery,
duration: `${dur}s`,
},
});
}
/**
* Regenerate multiple selected segments.
*
* @param projectId - Project ID
* @param segmentIds - Array of segment IDs to regenerate
* @returns Updated segments
*/
async regeneratePartial(projectId: string, segmentIds: string[]) {
// Auto-snapshot
await this.versionsService.createSnapshot(
projectId,
'AUTO_SAVE',
undefined,
`Auto-save before partial regeneration (${segmentIds.length} segments)`,
).catch(() => {});
const results: any[] = [];
for (const segId of segmentIds) {
try {
const result = await this.regenerateSegment(segId);
results.push(result);
} catch (error) {
this.logger.warn(`Failed to regenerate segment ${segId}: ${error}`);
}
}
return results;
}
}

View File

@@ -0,0 +1,382 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
/**
* VersionsService
*
* Manages script version history — automatic snapshots, manual saves,
* version restoration, and diff comparison.
*
* TR: Script versiyon geçmişi yönetimi — otomatik snapshot, manuel kayıt,
* versiyona geri dönüş ve karşılaştırma.
*/
@Injectable()
export class VersionsService {
private readonly logger = new Logger(VersionsService.name);
/** Maximum number of versions to keep per project */
private readonly MAX_VERSIONS = 20;
constructor(private readonly prisma: PrismaService) {}
// ========== VERSION LISTING ==========
/**
* List all versions for a project
*/
async listVersions(projectId: string) {
await this.ensureProjectExists(projectId);
return this.prisma.scriptVersion.findMany({
where: { projectId },
orderBy: { versionNumber: 'desc' },
select: {
id: true,
versionNumber: true,
label: true,
generatedBy: true,
segmentCount: true,
totalWords: true,
changeNote: true,
createdAt: true,
},
});
}
/**
* Get a specific version with full snapshot data
*/
async getVersion(projectId: string, versionId: string) {
const version = await this.prisma.scriptVersion.findFirst({
where: { id: versionId, projectId },
});
if (!version) {
throw new NotFoundException(
`Version ${versionId} not found for project ${projectId}`,
);
}
return version;
}
// ========== VERSION CREATION ==========
/**
* Create a snapshot of the current script state.
* Called automatically before AI generation or manually by user.
*
* @param projectId - Project ID
* @param generatedBy - Who created this version: 'AI' | 'USER' | 'AUTO_SAVE'
* @param label - Optional label for the version
* @param changeNote - Optional note about what changed
* @returns Created version
*/
async createSnapshot(
projectId: string,
generatedBy: string = 'AUTO_SAVE',
label?: string,
changeNote?: string,
) {
const project = await this.prisma.scriptProject.findUnique({
where: { id: projectId },
include: {
segments: { orderBy: { sortOrder: 'asc' } },
},
});
if (!project) {
throw new NotFoundException(`Project with ID ${projectId} not found`);
}
// Don't create empty snapshots
if (!project.segments || project.segments.length === 0) {
this.logger.warn(
`No segments to snapshot for project ${projectId}. Skipping.`,
);
return null;
}
// Calculate next version number
const nextVersion = project.currentVersionNumber + 1;
// Build snapshot data (strip relation fields, keep only data)
const snapshotData = project.segments.map((seg) => ({
segmentType: seg.segmentType,
timeStart: seg.timeStart,
duration: seg.duration,
narratorScript: seg.narratorScript,
visualDescription: seg.visualDescription,
videoPrompt: seg.videoPrompt,
imagePrompt: seg.imagePrompt,
onScreenText: seg.onScreenText,
editorNotes: seg.editorNotes,
generalNotes: seg.generalNotes,
audioCues: seg.audioCues,
stockQuery: seg.stockQuery,
generatedImageUrl: seg.generatedImageUrl,
sortOrder: seg.sortOrder,
citationIndexes: seg.citationIndexes,
}));
// Build SEO snapshot
const seoSnapshot = {
seoTitle: project.seoTitle,
seoDescription: project.seoDescription,
seoTags: project.seoTags,
thumbnailIdeas: project.thumbnailIdeas,
};
// Calculate total words
const totalWords = project.segments.reduce((acc, seg) => {
return acc + (seg.narratorScript?.split(' ').length || 0);
}, 0);
// Create version
const version = await this.prisma.scriptVersion.create({
data: {
projectId,
versionNumber: nextVersion,
label:
label ||
`v${nextVersion}${generatedBy === 'AI' ? 'AI Generated' : generatedBy === 'USER' ? 'Manual Save' : 'Auto Save'}`,
generatedBy,
snapshotData: snapshotData as any,
seoSnapshot: seoSnapshot as any,
segmentCount: project.segments.length,
totalWords,
changeNote,
},
});
// Update project's current version number
await this.prisma.scriptProject.update({
where: { id: projectId },
data: { currentVersionNumber: nextVersion },
});
// Cleanup old versions (keep only MAX_VERSIONS)
await this.cleanupOldVersions(projectId);
this.logger.log(
`Created version v${nextVersion} for project ${projectId} (${generatedBy})`,
);
return version;
}
// ========== VERSION RESTORE ==========
/**
* Restore a project to a specific version.
* Creates a snapshot of current state before restoring.
*
* @param projectId - Project ID
* @param versionId - Version ID to restore to
* @returns Restored segments
*/
async restoreVersion(projectId: string, versionId: string) {
const version = await this.getVersion(projectId, versionId);
// First, snapshot current state before restoring
await this.createSnapshot(
projectId,
'AUTO_SAVE',
undefined,
`Auto-save before restoring to v${version.versionNumber}`,
);
// Parse snapshot data
const segments = version.snapshotData as any[];
if (!segments || segments.length === 0) {
throw new Error('Version snapshot has no segment data');
}
// Delete current segments
await this.prisma.scriptSegment.deleteMany({ where: { projectId } });
// Restore segments from snapshot
await this.prisma.scriptSegment.createMany({
data: segments.map((seg) => ({
projectId,
segmentType: seg.segmentType || 'Body',
timeStart: seg.timeStart || '00:00',
duration: seg.duration || '0s',
narratorScript: seg.narratorScript,
visualDescription: seg.visualDescription,
videoPrompt: seg.videoPrompt,
imagePrompt: seg.imagePrompt,
onScreenText: seg.onScreenText,
editorNotes: seg.editorNotes,
generalNotes: seg.generalNotes,
audioCues: seg.audioCues,
stockQuery: seg.stockQuery,
generatedImageUrl: seg.generatedImageUrl,
sortOrder: seg.sortOrder ?? 0,
citationIndexes: seg.citationIndexes || [],
})),
});
// Restore SEO data if present
const seoData = version.seoSnapshot as any;
if (seoData) {
await this.prisma.scriptProject.update({
where: { id: projectId },
data: {
seoTitle: seoData.seoTitle,
seoDescription: seoData.seoDescription,
seoTags: seoData.seoTags || [],
thumbnailIdeas: seoData.thumbnailIdeas || [],
},
});
}
this.logger.log(
`Restored project ${projectId} to version v${version.versionNumber}`,
);
// Return the restored project data
return this.prisma.scriptProject.findUnique({
where: { id: projectId },
include: {
segments: { orderBy: { sortOrder: 'asc' } },
},
});
}
// ========== VERSION DELETION ==========
/**
* Delete a specific version
*/
async deleteVersion(projectId: string, versionId: string) {
const version = await this.getVersion(projectId, versionId);
await this.prisma.scriptVersion.delete({
where: { id: version.id },
});
return { deleted: true, versionNumber: version.versionNumber };
}
// ========== VERSION COMPARISON ==========
/**
* Compare two versions and return differences
*/
async compareVersions(
projectId: string,
versionAId: string,
versionBId: string,
) {
const [versionA, versionB] = await Promise.all([
this.getVersion(projectId, versionAId),
this.getVersion(projectId, versionBId),
]);
const segmentsA = (versionA.snapshotData as any[]) || [];
const segmentsB = (versionB.snapshotData as any[]) || [];
const maxLen = Math.max(segmentsA.length, segmentsB.length);
const diffs: any[] = [];
for (let i = 0; i < maxLen; i++) {
const segA = segmentsA[i] || null;
const segB = segmentsB[i] || null;
if (!segA && segB) {
diffs.push({
index: i,
type: 'added',
segmentType: segB.segmentType,
narratorScript: { before: null, after: segB.narratorScript },
});
} else if (segA && !segB) {
diffs.push({
index: i,
type: 'removed',
segmentType: segA.segmentType,
narratorScript: { before: segA.narratorScript, after: null },
});
} else if (segA && segB) {
const changed =
segA.narratorScript !== segB.narratorScript ||
segA.visualDescription !== segB.visualDescription ||
segA.onScreenText !== segB.onScreenText;
if (changed) {
diffs.push({
index: i,
type: 'modified',
segmentType: segB.segmentType,
narratorScript: {
before: segA.narratorScript,
after: segB.narratorScript,
},
visualDescription: {
before: segA.visualDescription,
after: segB.visualDescription,
},
});
}
}
}
return {
versionA: {
id: versionA.id,
versionNumber: versionA.versionNumber,
label: versionA.label,
createdAt: versionA.createdAt,
},
versionB: {
id: versionB.id,
versionNumber: versionB.versionNumber,
label: versionB.label,
createdAt: versionB.createdAt,
},
totalDiffs: diffs.length,
diffs,
};
}
// ========== HELPERS ==========
/**
* Verify project exists or throw
*/
private async ensureProjectExists(projectId: string) {
const exists = await this.prisma.scriptProject.findUnique({
where: { id: projectId },
select: { id: true },
});
if (!exists) {
throw new NotFoundException(`Project with ID ${projectId} not found`);
}
}
/**
* Remove old versions beyond the max limit per project
*/
private async cleanupOldVersions(projectId: string) {
const versions = await this.prisma.scriptVersion.findMany({
where: { projectId },
orderBy: { versionNumber: 'desc' },
select: { id: true, versionNumber: true },
});
if (versions.length > this.MAX_VERSIONS) {
const toDelete = versions.slice(this.MAX_VERSIONS);
await this.prisma.scriptVersion.deleteMany({
where: {
id: { in: toDelete.map((v) => v.id) },
},
});
this.logger.log(
`Cleaned up ${toDelete.length} old versions for project ${projectId}`,
);
}
}
}

View File

@@ -8,6 +8,7 @@ import {
ScriptsController,
ResearchController,
AnalysisController,
VersionsController,
} from './controllers';
// Services
@@ -16,6 +17,7 @@ import {
ScriptsService,
ResearchService,
AnalysisService,
VersionsService,
} from './services';
/**
@@ -30,6 +32,7 @@ import {
* - Neuro Marketing analysis
* - YouTube audit
* - Commercial brief generation
* - Version history & content management
*
* TR: SkriptAI ana modülü - AI destekli video script üretimi.
* EN: Main module for the SkriptAI feature - AI-powered video script generation.
@@ -41,13 +44,21 @@ import {
ScriptsController,
ResearchController,
AnalysisController,
VersionsController,
],
providers: [
ProjectsService,
ScriptsService,
ResearchService,
AnalysisService,
VersionsService,
],
exports: [
ProjectsService,
ScriptsService,
ResearchService,
AnalysisService,
VersionsService,
],
exports: [ProjectsService, ScriptsService, ResearchService, AnalysisService],
})
export class SkriptaiModule {}