diff --git a/src/app.module.ts b/src/app.module.ts index 48e0c06..273812e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { CacheModule } from '@nestjs/cache-manager'; +import { BullModule } from '@nestjs/bullmq'; import { redisStore } from 'cache-manager-redis-yet'; import { LoggerModule } from 'nestjs-pino'; import { @@ -66,6 +67,19 @@ import { ], }), + // BullMQ (Queue System) + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('redis.host', 'localhost'), + port: configService.get('redis.port', 6379), + password: configService.get('redis.password', undefined), + }, + }), + }), + // Logger (Structured Logging with Pino) LoggerModule.forRootAsync({ imports: [ConfigModule], diff --git a/src/modules/gemini/context-manager.service.ts b/src/modules/gemini/context-manager.service.ts new file mode 100644 index 0000000..5a0f694 --- /dev/null +++ b/src/modules/gemini/context-manager.service.ts @@ -0,0 +1,292 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + estimateTokens, + estimateTokensForSegments, + getModelLimits, + analyzeTokenUsage, + TokenUsageReport, +} from './token-counter'; + +/** + * Context Priority Levels + * Higher priority = kept during trimming, lower = removed first + */ +export enum ContextPriority { + CRITICAL = 100, // System instructions, schema + HIGH = 80, // Topic, logline, key brief items + MEDIUM = 60, // Sources, characters + LOW = 40, // Extended notes, enrichment data + OPTIONAL = 20, // Visual descriptions, editor notes +} + +export interface ContextBlock { + id: string; + content: string; + priority: ContextPriority; + estimatedTokens: number; + label: string; + truncatable: boolean; +} + +/** + * ContextManagerService + * + * Manages the context window for AI prompts. Intelligently assembles + * context blocks within token limits, trimming low-priority content first. + * + * Strategy: + * 1. Each piece of context is tagged with a priority level + * 2. Blocks are sorted by priority (highest first) + * 3. Blocks are added until the budget is reached + * 4. Truncatable blocks can be partially included + * + * TR: AI prompt'ları için bağlam penceresi yöneticisi. + * Öncelik sırasına göre akıllı kırpma yapar. + */ +@Injectable() +export class ContextManagerService { + private readonly logger = new Logger(ContextManagerService.name); + + /** + * Build optimized context string from blocks within token budget + * + * @param blocks - Array of context blocks + * @param model - Model name for limit lookup + * @param language - Language for token estimation + * @param reserveForOutput - Reserve tokens for AI output (default: 8000) + * @returns Assembled context within budget + */ + assembleContext( + blocks: ContextBlock[], + model: string, + language: string = 'en', + reserveForOutput: number = 8000, + ): { + context: string; + includedBlocks: string[]; + excludedBlocks: string[]; + report: TokenUsageReport; + } { + const limits = getModelLimits(model); + const budget = limits.safeInput - reserveForOutput; + + // Sort by priority (highest first) + const sorted = [...blocks].sort((a, b) => b.priority - a.priority); + + let currentTokens = 0; + const includedParts: string[] = []; + const includedIds: string[] = []; + const excludedIds: string[] = []; + + for (const block of sorted) { + if (currentTokens + block.estimatedTokens <= budget) { + // Full include + includedParts.push(block.content); + includedIds.push(block.id); + currentTokens += block.estimatedTokens; + } else if (block.truncatable && currentTokens < budget) { + // Partial include — truncate to fit + const remainingBudget = budget - currentTokens; + const truncated = this.truncateToTokens( + block.content, + remainingBudget, + language, + ); + if (truncated.length > 0) { + includedParts.push(truncated + '\n[... içerik kırpıldı ...]'); + includedIds.push(`${block.id} (kırpılmış)`); + currentTokens += estimateTokens(truncated, language); + } else { + excludedIds.push(block.id); + } + } else { + excludedIds.push(block.id); + } + } + + const assembledContext = includedParts.join('\n\n'); + const report = analyzeTokenUsage(assembledContext, model, language); + + if (excludedIds.length > 0) { + this.logger.warn( + `Context trimmed: excluded ${excludedIds.length} blocks — ${excludedIds.join(', ')}`, + ); + } + + return { + context: assembledContext, + includedBlocks: includedIds, + excludedBlocks: excludedIds, + report, + }; + } + + /** + * Create context blocks from project data + * Standardized way to build context for any AI operation + */ + buildProjectContextBlocks(project: { + topic: string; + logline?: string | null; + contentType: string; + targetAudience: string[]; + speechStyle: string[]; + language: string; + userNotes?: string | null; + sources?: { title: string; snippet: string; type: string }[]; + briefItems?: { question: string; answer: string }[]; + characters?: { name: string; role: string; values: string; traits: string; mannerisms: string }[]; + segments?: { narratorScript?: string | null; visualDescription?: string | null; segmentType: string }[]; + }): ContextBlock[] { + const lang = project.language || 'en'; + const blocks: ContextBlock[] = []; + + // CRITICAL: Topic & Core Info + const coreInfo = [ + `Konu: ${project.topic}`, + project.logline ? `Logline: ${project.logline}` : '', + `İçerik Tipi: ${project.contentType}`, + `Hedef Kitle: ${project.targetAudience.join(', ')}`, + `Konuşma Stili: ${project.speechStyle.join(', ')}`, + `Dil: ${project.language}`, + ] + .filter(Boolean) + .join('\n'); + + blocks.push({ + id: 'core-info', + content: coreInfo, + priority: ContextPriority.CRITICAL, + estimatedTokens: estimateTokens(coreInfo, lang), + label: 'Core Project Info', + truncatable: false, + }); + + // HIGH: Brief items + if (project.briefItems?.length) { + const briefText = project.briefItems + .map((b) => `S: ${b.question}\nC: ${b.answer}`) + .join('\n\n'); + blocks.push({ + id: 'brief-items', + content: briefText, + priority: ContextPriority.HIGH, + estimatedTokens: estimateTokens(briefText, lang), + label: 'Brief Items', + truncatable: true, + }); + } + + // MEDIUM: Characters + if (project.characters?.length) { + const charText = project.characters + .map( + (c) => + `${c.name} (${c.role}): Değerler[${c.values}] Özellikler[${c.traits}] Tavırlar[${c.mannerisms}]`, + ) + .join('\n'); + blocks.push({ + id: 'characters', + content: charText, + priority: ContextPriority.MEDIUM, + estimatedTokens: estimateTokens(charText, lang), + label: 'Characters', + truncatable: true, + }); + } + + // MEDIUM: Sources + if (project.sources?.length) { + const srcText = project.sources + .slice(0, 5) + .map( + (s, i) => + `[Kaynak ${i + 1}] (${s.type}): ${s.title} — ${s.snippet}`, + ) + .join('\n'); + blocks.push({ + id: 'sources', + content: srcText, + priority: ContextPriority.MEDIUM, + estimatedTokens: estimateTokens(srcText, lang), + label: 'Research Sources', + truncatable: true, + }); + } + + // LOW: User notes + if (project.userNotes) { + blocks.push({ + id: 'user-notes', + content: project.userNotes, + priority: ContextPriority.LOW, + estimatedTokens: estimateTokens(project.userNotes, lang), + label: 'User Notes', + truncatable: true, + }); + } + + // OPTIONAL: Existing segments (for context in regeneration) + if (project.segments?.length) { + const segText = project.segments + .map( + (s, i) => + `[Segment ${i + 1} — ${s.segmentType}]: ${s.narratorScript || ''}`, + ) + .join('\n'); + blocks.push({ + id: 'existing-segments', + content: segText, + priority: ContextPriority.OPTIONAL, + estimatedTokens: estimateTokens(segText, lang), + label: 'Existing Segments', + truncatable: true, + }); + } + + return blocks; + } + + /** + * Get token usage report for a text + */ + getUsageReport( + text: string, + model: string, + language: string = 'en', + ): TokenUsageReport { + return analyzeTokenUsage(text, model, language); + } + + /** + * Estimate tokens for segments + */ + estimateSegmentTokens( + segments: { narratorScript?: string; visualDescription?: string }[], + language: string = 'en', + ): number { + return estimateTokensForSegments(segments, language); + } + + // ========== HELPERS ========== + + private truncateToTokens( + text: string, + maxTokens: number, + language: string, + ): string { + // Estimate ratio and truncate by sentences to avoid cutting mid-sentence + const sentences = text.split(/(?<=[.!?。?!])\s+/); + let result = ''; + let currentTokens = 0; + + for (const sentence of sentences) { + const sentenceTokens = estimateTokens(sentence, language); + if (currentTokens + sentenceTokens > maxTokens) break; + result += (result ? ' ' : '') + sentence; + currentTokens += sentenceTokens; + } + + return result; + } +} diff --git a/src/modules/gemini/gemini.module.ts b/src/modules/gemini/gemini.module.ts index 65ccd5a..3eb7aa3 100644 --- a/src/modules/gemini/gemini.module.ts +++ b/src/modules/gemini/gemini.module.ts @@ -1,6 +1,8 @@ import { Module, Global } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { GeminiService } from './gemini.service'; +import { ContextManagerService } from './context-manager.service'; +import { MapReduceService } from './map-reduce.service'; import { geminiConfig } from './gemini.config'; /** @@ -8,11 +10,16 @@ import { geminiConfig } from './gemini.config'; * * Optional module for AI-powered features using Google Gemini API. * Enable by setting ENABLE_GEMINI=true in your .env file. + * + * Includes: + * - GeminiService: Core AI text/JSON/image generation + * - ContextManagerService: Token-aware context window management + * - MapReduceService: Large content analysis via chunking */ @Global() @Module({ imports: [ConfigModule.forFeature(geminiConfig)], - providers: [GeminiService], - exports: [GeminiService], + providers: [GeminiService, ContextManagerService, MapReduceService], + exports: [GeminiService, ContextManagerService, MapReduceService], }) export class GeminiModule {} diff --git a/src/modules/gemini/map-reduce.service.ts b/src/modules/gemini/map-reduce.service.ts new file mode 100644 index 0000000..958ab5d --- /dev/null +++ b/src/modules/gemini/map-reduce.service.ts @@ -0,0 +1,169 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { GeminiService } from './gemini.service'; +import { estimateTokens, getModelLimits } from './token-counter'; + +/** + * MapReduceService + * + * Handles analysis of content that exceeds the context window by: + * 1. MAP: Splitting content into digestible chunks and analyzing each + * 2. REDUCE: Combining individual analyses into a final summary + * + * Use cases: + * - Consistency check on very long scripts (50+ segments) + * - Deep analysis when total script tokens exceed safe limits + * - Aggregated quality scoring across large content sets + * + * TR: Bağlam penceresini aşan içerikler için map-reduce analiz. + * İçeriği parçalara böler, her birini ayrı analiz eder, sonuçları birleştirir. + */ +@Injectable() +export class MapReduceService { + private readonly logger = new Logger(MapReduceService.name); + + constructor(private readonly gemini: GeminiService) {} + + /** + * Map-Reduce text analysis + * + * @param chunks - Array of text chunks to analyze + * @param mapPrompt - Prompt template for each chunk (use {{CHUNK}} placeholder) + * @param reducePrompt - Prompt template for combining results (use {{RESULTS}} placeholder) + * @param schema - JSON schema string for expected output + * @param options - Optional config + * @returns Combined analysis result + */ + async analyze( + chunks: string[], + mapPrompt: string, + reducePrompt: string, + schema: string, + options: { + model?: string; + language?: string; + temperature?: number; + maxChunkTokens?: number; + } = {}, + ): Promise<{ data: T; mapResults: any[]; chunkCount: number }> { + const { + model, + language = 'en', + temperature = 0.3, + maxChunkTokens = 15000, + } = options; + + this.logger.log( + `Map-Reduce: ${chunks.length} chunks, maxChunkTokens: ${maxChunkTokens}`, + ); + + // ===== MAP PHASE ===== + const mapResults: any[] = []; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const prompt = mapPrompt.replace('{{CHUNK}}', chunk); + + this.logger.debug( + `MAP phase: chunk ${i + 1}/${chunks.length} (${estimateTokens(chunk, language)} tokens)`, + ); + + try { + const resp = await this.gemini.generateJSON(prompt, schema, { + model, + temperature, + }); + mapResults.push(resp.data); + } catch (error) { + this.logger.warn(`MAP failed for chunk ${i + 1}: ${error}`); + mapResults.push({ error: `Chunk ${i + 1} failed`, skipped: true }); + } + } + + // ===== REDUCE PHASE ===== + if (mapResults.length === 1) { + return { data: mapResults[0], mapResults, chunkCount: chunks.length }; + } + + const resultsJson = JSON.stringify(mapResults, null, 2); + const finalPrompt = reducePrompt.replace('{{RESULTS}}', resultsJson); + + this.logger.debug( + `REDUCE phase: combining ${mapResults.length} results`, + ); + + const reduceResp = await this.gemini.generateJSON( + finalPrompt, + schema, + { model, temperature }, + ); + + return { + data: reduceResp.data, + mapResults, + chunkCount: chunks.length, + }; + } + + /** + * Split segments into token-limited chunks + * + * Groups segments so each chunk stays within the token budget. + * Maintains segment order and includes segment index metadata. + */ + chunkSegments( + segments: { + narratorScript?: string | null; + visualDescription?: string | null; + segmentType: string; + }[], + maxTokensPerChunk: number = 15000, + language: string = 'en', + ): string[] { + const chunks: string[] = []; + let currentChunk: string[] = []; + let currentTokens = 0; + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const segText = `[Segment ${i + 1} — ${seg.segmentType}]\n${seg.narratorScript || ''}\nVisual: ${seg.visualDescription || 'N/A'}`; + const segTokens = estimateTokens(segText, language); + + if (currentTokens + segTokens > maxTokensPerChunk && currentChunk.length > 0) { + chunks.push(currentChunk.join('\n\n')); + currentChunk = []; + currentTokens = 0; + } + + currentChunk.push(segText); + currentTokens += segTokens; + } + + if (currentChunk.length > 0) { + chunks.push(currentChunk.join('\n\n')); + } + + this.logger.log( + `Chunked ${segments.length} segments into ${chunks.length} chunks`, + ); + + return chunks; + } + + /** + * Check if content needs map-reduce (exceeds safe context) + */ + needsMapReduce( + segments: { narratorScript?: string | null }[], + model: string = 'gemini-2.5-flash', + language: string = 'en', + ): boolean { + const totalText = segments + .map((s) => s.narratorScript || '') + .join('\n'); + const tokens = estimateTokens(totalText, language); + const limits = getModelLimits(model); + + // If content takes more than 60% of safe input, use map-reduce + return tokens > limits.safeInput * 0.6; + } +} diff --git a/src/modules/gemini/model-selector.ts b/src/modules/gemini/model-selector.ts new file mode 100644 index 0000000..252ee23 --- /dev/null +++ b/src/modules/gemini/model-selector.ts @@ -0,0 +1,189 @@ +/** + * Model Selector + * + * Task-based model selection strategy for Gemini AI operations. + * + * Strategy: + * - Flash models: Fast, cost-effective — ideal for drafts, summaries, simple tasks + * - Pro models: Higher quality — ideal for final scripts, analysis, critique + * + * Users can override with a quality preference: + * - 'fast': Always use flash + * - 'balanced': Task-based auto-selection (default) + * - 'quality': Always use pro + * + * TR: Görev bazında model seçim stratejisi. Hız/kalite tercihi ile otomatik model seçimi. + */ + +export type QualityPreference = 'fast' | 'balanced' | 'quality'; + +/** + * Task categories that map to model selection + */ +export enum TaskCategory { + // Quick/Draft tasks → Flash + TOPIC_ENRICHMENT = 'TOPIC_ENRICHMENT', + DISCOVERY_QUESTIONS = 'DISCOVERY_QUESTIONS', + SEARCH_QUERY = 'SEARCH_QUERY', + CHARACTER_GENERATION = 'CHARACTER_GENERATION', + LOGLINE_GENERATION = 'LOGLINE_GENERATION', + OUTLINE_GENERATION = 'OUTLINE_GENERATION', + SEGMENT_IMAGE_PROMPT = 'SEGMENT_IMAGE_PROMPT', + + // Core generation → Balanced (Pro in quality mode) + CHAPTER_GENERATION = 'CHAPTER_GENERATION', + SEGMENT_REWRITE = 'SEGMENT_REWRITE', + DEEP_RESEARCH = 'DEEP_RESEARCH', + VISUAL_ASSETS = 'VISUAL_ASSETS', + + // Analysis/Critique → Pro preferred + NEURO_ANALYSIS = 'NEURO_ANALYSIS', + YOUTUBE_AUDIT = 'YOUTUBE_AUDIT', + COMMERCIAL_BRIEF = 'COMMERCIAL_BRIEF', + CONSISTENCY_CHECK = 'CONSISTENCY_CHECK', + SELF_CRITIQUE = 'SELF_CRITIQUE', +} + +// Default model assignments per task +const TASK_MODELS: Record = { + // Fast tasks + [TaskCategory.TOPIC_ENRICHMENT]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.DISCOVERY_QUESTIONS]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-flash', + }, + [TaskCategory.SEARCH_QUERY]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-flash', + }, + [TaskCategory.CHARACTER_GENERATION]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.LOGLINE_GENERATION]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.OUTLINE_GENERATION]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.SEGMENT_IMAGE_PROMPT]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-flash', + }, + + // Core generation + [TaskCategory.CHAPTER_GENERATION]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.SEGMENT_REWRITE]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.DEEP_RESEARCH]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.VISUAL_ASSETS]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-flash', + }, + + // Analysis/Critique — Pro preferred + [TaskCategory.NEURO_ANALYSIS]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.YOUTUBE_AUDIT]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.COMMERCIAL_BRIEF]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.CONSISTENCY_CHECK]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, + [TaskCategory.SELF_CRITIQUE]: { + flash: 'gemini-2.5-flash', + pro: 'gemini-2.5-pro', + }, +}; + +/** + * Select the best model for a given task and quality preference. + * + * @param task - The task category + * @param preference - User quality preference + * @returns Model identifier string + */ +export function selectModel( + task: TaskCategory, + preference: QualityPreference = 'balanced', +): string { + const models = TASK_MODELS[task]; + + switch (preference) { + case 'fast': + return models.flash; + + case 'quality': + return models.pro; + + case 'balanced': + default: + // For analysis/critique tasks, prefer pro even in balanced mode + if ( + task === TaskCategory.NEURO_ANALYSIS || + task === TaskCategory.YOUTUBE_AUDIT || + task === TaskCategory.CONSISTENCY_CHECK || + task === TaskCategory.SELF_CRITIQUE + ) { + return models.pro; + } + return models.flash; + } +} + +/** + * Get model recommendation info + */ +export function getModelInfo( + task: TaskCategory, + preference: QualityPreference = 'balanced', +): { + model: string; + isFlash: boolean; + reason: string; +} { + const model = selectModel(task, preference); + const isFlash = model.includes('flash'); + + let reason = ''; + if (preference === 'fast') { + reason = 'Hızlı mod seçildi — Flash model kullanılıyor'; + } else if (preference === 'quality') { + reason = 'Kaliteli mod seçildi — Pro model kullanılıyor'; + } else { + reason = isFlash + ? 'Bu görev için Flash yeterli — hız optimizasyonu' + : 'Bu görev yüksek kalite gerektiriyor — Pro model seçildi'; + } + + return { model, isFlash, reason }; +} + +/** + * Estimate relative cost multiplier for a model + * Flash ≈ 1x, Pro ≈ 4x + */ +export function getModelCostMultiplier(model: string): number { + return model.includes('pro') ? 4.0 : 1.0; +} diff --git a/src/modules/gemini/token-counter.ts b/src/modules/gemini/token-counter.ts new file mode 100644 index 0000000..e3d82fe --- /dev/null +++ b/src/modules/gemini/token-counter.ts @@ -0,0 +1,152 @@ +/** + * Token Counter Utility + * + * Estimates token counts for text content. Uses a heuristic-based approach + * that is reasonably accurate for Gemini models without requiring + * an external tokenizer dependency. + * + * Gemini tokenization rules of thumb: + * - English: ~4 characters per token (≈ 0.75 words per token) + * - Turkish: ~3.5 characters per token (morphologically richer) + * - Code/JSON: ~3 characters per token + * - Punctuation: usually 1 token each + * + * TR: Token sayımı için yardımcı araç. Harici tokenizer gerektirmeden + * sezgisel yaklaşımla makul doğrulukta tahmin yapar. + */ + +// Model context window limits (input + output) +export const MODEL_LIMITS = { + 'gemini-2.5-flash': { + maxInput: 1_048_576, // 1M tokens + maxOutput: 65_536, // 65K tokens + safeInput: 800_000, // Safe limit with margin + }, + 'gemini-2.5-pro': { + maxInput: 1_048_576, + maxOutput: 65_536, + safeInput: 800_000, + }, + 'gemini-2.0-flash': { + maxInput: 1_048_576, + maxOutput: 8_192, + safeInput: 900_000, + }, + // Fallback for unknown models + default: { + maxInput: 128_000, + maxOutput: 8_192, + safeInput: 100_000, + }, +} as const; + +export type ModelName = keyof typeof MODEL_LIMITS; + +/** + * Estimate token count for a given text. + * + * @param text - The text to estimate tokens for + * @param language - Language hint ('tr', 'en', etc.) + * @returns Estimated token count + */ +export function estimateTokens(text: string, language: string = 'en'): number { + if (!text) return 0; + + // Base: character-based estimation + const charCount = text.length; + + // Language-specific multipliers + const charsPerToken = language === 'tr' ? 3.5 : 4.0; + + // Adjust for special content + const jsonMatches = text.match(/[{}\[\]:,"]/g); + const jsonPenalty = jsonMatches ? jsonMatches.length * 0.3 : 0; + + // Newlines and whitespace + const newlineCount = (text.match(/\n/g) || []).length; + + const baseTokens = charCount / charsPerToken; + const estimated = baseTokens + jsonPenalty + newlineCount * 0.5; + + return Math.ceil(estimated); +} + +/** + * Estimate tokens for an array of text segments + */ +export function estimateTokensForSegments( + segments: { narratorScript?: string; visualDescription?: string }[], + language: string = 'en', +): number { + return segments.reduce((total, seg) => { + return ( + total + + estimateTokens(seg.narratorScript || '', language) + + estimateTokens(seg.visualDescription || '', language) + ); + }, 0); +} + +/** + * Get model limits for a given model name + */ +export function getModelLimits(model: string) { + return (MODEL_LIMITS as any)[model] || MODEL_LIMITS.default; +} + +/** + * Calculate remaining token budget for output + */ +export function getRemainingBudget( + model: string, + inputTokens: number, +): { remainingInput: number; maxOutput: number; isOverBudget: boolean } { + const limits = getModelLimits(model); + const remainingInput = limits.safeInput - inputTokens; + return { + remainingInput, + maxOutput: limits.maxOutput, + isOverBudget: remainingInput < 0, + }; +} + +/** + * Token usage report + */ +export interface TokenUsageReport { + estimatedInputTokens: number; + modelLimit: number; + safeLimit: number; + usagePercentage: number; + isOverBudget: boolean; + recommendation: 'ok' | 'trim' | 'map-reduce'; +} + +/** + * Analyze token usage and provide recommendations + */ +export function analyzeTokenUsage( + inputText: string, + model: string, + language: string = 'en', +): TokenUsageReport { + const estimated = estimateTokens(inputText, language); + const limits = getModelLimits(model); + const usagePercentage = (estimated / limits.safeInput) * 100; + + let recommendation: 'ok' | 'trim' | 'map-reduce' = 'ok'; + if (usagePercentage > 90) { + recommendation = 'map-reduce'; + } else if (usagePercentage > 70) { + recommendation = 'trim'; + } + + return { + estimatedInputTokens: estimated, + modelLimit: limits.maxInput, + safeLimit: limits.safeInput, + usagePercentage: Math.round(usagePercentage * 10) / 10, + isOverBudget: estimated > limits.safeInput, + recommendation, + }; +} diff --git a/src/modules/skriptai/controllers/index.ts b/src/modules/skriptai/controllers/index.ts index 92164db..ca262c7 100644 --- a/src/modules/skriptai/controllers/index.ts +++ b/src/modules/skriptai/controllers/index.ts @@ -3,3 +3,4 @@ export * from './scripts.controller'; export * from './research.controller'; export * from './analysis.controller'; export * from './versions.controller'; +export * from './jobs.controller'; diff --git a/src/modules/skriptai/controllers/jobs.controller.ts b/src/modules/skriptai/controllers/jobs.controller.ts new file mode 100644 index 0000000..f571c26 --- /dev/null +++ b/src/modules/skriptai/controllers/jobs.controller.ts @@ -0,0 +1,208 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { + QUEUES, + JobType, + JobStatus, +} from '../queue/queue.constants'; + +/** + * JobsController + * + * REST API for managing async AI jobs. + * + * Endpoints: + * - POST /jobs/submit — Submit a new async job + * - GET /jobs/:id/status — Check job status & progress + * - GET /jobs/:id/result — Get job result + * + * TR: Asenkron AI işlerini yönetmek için REST API. + */ +@ApiTags('SkriptAI - Jobs') +@ApiBearerAuth() +@Controller('skriptai/jobs') +export class JobsController { + private readonly logger = new Logger(JobsController.name); + + constructor( + @InjectQueue(QUEUES.SCRIPT_GENERATION) + private readonly scriptQueue: Queue, + @InjectQueue(QUEUES.DEEP_RESEARCH) + private readonly researchQueue: Queue, + @InjectQueue(QUEUES.ANALYSIS) + private readonly analysisQueue: Queue, + @InjectQueue(QUEUES.IMAGE_GENERATION) + private readonly imageQueue: Queue, + ) {} + + /** + * Submit a new async job + */ + @Post('submit') + @ApiOperation({ summary: 'Submit an async AI job' }) + async submitJob( + @Body() + body: { + type: JobType; + payload: Record; + }, + ) { + const { type, payload } = body; + const queue = this.getQueueForJobType(type); + + const job = await queue.add(type, payload, { + attempts: 2, + backoff: { type: 'exponential', delay: 5000 }, + removeOnComplete: { age: 3600 }, // 1 hour + removeOnFail: { age: 86400 }, // 24 hours + }); + + this.logger.log( + `Job submitted: ${job.id} (${type}) — payload: ${JSON.stringify(payload)}`, + ); + + return { + jobId: job.id, + type, + status: JobStatus.QUEUED, + }; + } + + /** + * Check job status and progress + */ + @Get(':id/status') + @ApiOperation({ summary: 'Check job status & progress' }) + async getJobStatus(@Param('id') jobId: string) { + const job = await this.findJobById(jobId); + + if (!job) { + throw new NotFoundException(`Job ${jobId} not found`); + } + + const state = await job.getState(); + const progress = job.progress; + + return { + jobId: job.id, + type: job.name, + status: this.mapBullState(state), + progress: progress || null, + createdAt: new Date(job.timestamp).toISOString(), + processedOn: job.processedOn + ? new Date(job.processedOn).toISOString() + : null, + finishedOn: job.finishedOn + ? new Date(job.finishedOn).toISOString() + : null, + failedReason: job.failedReason || null, + }; + } + + /** + * Get job result + */ + @Get(':id/result') + @ApiOperation({ summary: 'Get completed job result' }) + async getJobResult(@Param('id') jobId: string) { + const job = await this.findJobById(jobId); + + if (!job) { + throw new NotFoundException(`Job ${jobId} not found`); + } + + const state = await job.getState(); + + if (state !== 'completed') { + return { + jobId: job.id, + status: this.mapBullState(state), + result: null, + message: 'Job has not completed yet', + }; + } + + return { + jobId: job.id, + status: JobStatus.COMPLETED, + result: job.returnvalue, + }; + } + + // ========== HELPERS ========== + + private getQueueForJobType(type: JobType): Queue { + if ( + type === JobType.GENERATE_SCRIPT || + type === JobType.REGENERATE_SEGMENT || + type === JobType.REGENERATE_PARTIAL || + type === JobType.REWRITE_SEGMENT + ) { + return this.scriptQueue; + } + + if ( + type === JobType.DEEP_RESEARCH || + type === JobType.DISCOVER_QUESTIONS + ) { + return this.researchQueue; + } + + if ( + type === JobType.NEURO_ANALYSIS || + type === JobType.YOUTUBE_AUDIT || + type === JobType.COMMERCIAL_BRIEF || + type === JobType.GENERATE_VISUAL_ASSETS + ) { + return this.analysisQueue; + } + + if ( + type === JobType.GENERATE_SEGMENT_IMAGE || + type === JobType.GENERATE_THUMBNAIL + ) { + return this.imageQueue; + } + + throw new Error(`Unknown job type: ${type}`); + } + + private async findJobById(jobId: string) { + const queues = [ + this.scriptQueue, + this.researchQueue, + this.analysisQueue, + this.imageQueue, + ]; + + for (const queue of queues) { + const job = await queue.getJob(jobId); + if (job) return job; + } + + return null; + } + + private mapBullState(state: string): JobStatus { + switch (state) { + case 'completed': + return JobStatus.COMPLETED; + case 'failed': + return JobStatus.FAILED; + case 'active': + return JobStatus.PROCESSING; + default: + return JobStatus.QUEUED; + } + } +} diff --git a/src/modules/skriptai/controllers/scripts.controller.ts b/src/modules/skriptai/controllers/scripts.controller.ts index bb48aa9..65493b0 100644 --- a/src/modules/skriptai/controllers/scripts.controller.ts +++ b/src/modules/skriptai/controllers/scripts.controller.ts @@ -1,5 +1,6 @@ import { Controller, + Get, Post, Put, Delete, @@ -120,4 +121,44 @@ export class ScriptsController { body.segmentIds, ); } + + // ========== ENHANCED PIPELINE (Faz 2.2) ========== + + @Post(':projectId/enrich-topic') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Phase 0: Enrich and expand topic with AI' }) + async enrichTopic(@Param('projectId') projectId: string) { + return this.scriptsService.enrichTopic(projectId); + } + + @Get(':projectId/outline-review') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Generate outline for user review (no segments created)' }) + async getOutlineForReview(@Param('projectId') projectId: string) { + return this.scriptsService.generateOutlineForReview(projectId); + } + + @Post(':projectId/consistency-check') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Phase 3: AI consistency & quality review' }) + async checkConsistency(@Param('projectId') projectId: string) { + return this.scriptsService.checkConsistency(projectId); + } + + @Post(':projectId/self-critique') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Phase 4: AI self-critique and auto-rewrite' }) + async selfCritique( + @Param('projectId') projectId: string, + @Body() body?: { threshold?: number }, + ) { + return this.scriptsService.selfCritiqueAndRewrite( + projectId, + body?.threshold, + ); + } } diff --git a/src/modules/skriptai/gateway/index.ts b/src/modules/skriptai/gateway/index.ts new file mode 100644 index 0000000..ed3aaa5 --- /dev/null +++ b/src/modules/skriptai/gateway/index.ts @@ -0,0 +1,3 @@ +export * from './ws-events'; +export * from './skriptai.gateway'; +export * from './queue-event-bridge'; diff --git a/src/modules/skriptai/gateway/queue-event-bridge.ts b/src/modules/skriptai/gateway/queue-event-bridge.ts new file mode 100644 index 0000000..9369f23 --- /dev/null +++ b/src/modules/skriptai/gateway/queue-event-bridge.ts @@ -0,0 +1,91 @@ +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue, QueueEvents } from 'bullmq'; +import { SkriptaiGateway } from './skriptai.gateway'; +import { QUEUES } from '../queue/queue.constants'; + +/** + * BullMQ → WebSocket Event Bridge + * + * Listens to BullMQ queue events and forwards them to the WebSocket gateway. + * This enables real-time progress notifications for all async jobs. + * + * TR: BullMQ kuyruk eventlerini dinler ve WebSocket gateway'e yönlendirir. + * Böylece tüm asenkron işler için gerçek zamanlı ilerleme bildirimleri sağlanır. + */ +@Injectable() +export class QueueEventBridge implements OnModuleInit { + private readonly logger = new Logger(QueueEventBridge.name); + + constructor( + private readonly gateway: SkriptaiGateway, + @InjectQueue(QUEUES.SCRIPT_GENERATION) + private readonly scriptQueue: Queue, + @InjectQueue(QUEUES.DEEP_RESEARCH) + private readonly researchQueue: Queue, + @InjectQueue(QUEUES.ANALYSIS) + private readonly analysisQueue: Queue, + @InjectQueue(QUEUES.IMAGE_GENERATION) + private readonly imageQueue: Queue, + ) {} + + onModuleInit() { + this.attachListeners(this.scriptQueue); + this.attachListeners(this.researchQueue); + this.attachListeners(this.analysisQueue); + this.attachListeners(this.imageQueue); + this.logger.log('✅ BullMQ → WebSocket event bridge initialized'); + } + + private attachListeners(queue: Queue) { + const events = new QueueEvents(queue.name, { + connection: queue.opts?.connection as any, + }); + + events.on('progress', ({ jobId, data }) => { + const progress = data as any; + if (progress && progress.projectId) { + this.gateway.emitJobProgress({ + jobId, + jobType: '', + projectId: progress.projectId, + step: progress.step || 0, + totalSteps: progress.totalSteps || 0, + message: progress.message || '', + percentage: progress.percentage || 0, + }); + } + }); + + events.on('completed', async ({ jobId }) => { + try { + const job = await queue.getJob(jobId); + if (job) { + this.gateway.emitJobCompleted({ + jobId, + jobType: job.name, + projectId: job.data.projectId || '', + }); + } + } catch { + // Job may have been removed + } + }); + + events.on('failed', async ({ jobId, failedReason }) => { + try { + const job = await queue.getJob(jobId); + if (job) { + this.gateway.emitJobFailed({ + jobId, + jobType: job.name, + projectId: job.data.projectId || '', + reason: failedReason || 'Unknown error', + }); + } + } catch { + // Job may have been removed + } + }); + } +} diff --git a/src/modules/skriptai/gateway/skriptai.gateway.ts b/src/modules/skriptai/gateway/skriptai.gateway.ts new file mode 100644 index 0000000..e08a69e --- /dev/null +++ b/src/modules/skriptai/gateway/skriptai.gateway.ts @@ -0,0 +1,123 @@ +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { + WS_EVENTS, + JobProgressEvent, + JobCompletedEvent, + JobFailedEvent, + SegmentEvent, + VersionEvent, + ProjectStatusEvent, +} from './ws-events'; + +/** + * SkriptAI WebSocket Gateway + * + * Socket.IO gateway for real-time notifications. + * Clients join project-specific rooms to receive updates. + * + * TR: Gerçek zamanlı bildirimler için Socket.IO gateway. + * İstemciler proje odalarına katılarak güncellemeler alır. + */ +@WebSocketGateway({ + namespace: '/skriptai', + cors: { + origin: '*', + credentials: true, + }, +}) +export class SkriptaiGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(SkriptaiGateway.name); + + handleConnection(client: Socket) { + this.logger.log(`Client connected: ${client.id}`); + } + + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`); + } + + /** + * Client joins a project room to receive project-specific events + */ + @SubscribeMessage('join:project') + handleJoinProject(client: Socket, projectId: string) { + const room = `project:${projectId}`; + client.join(room); + this.logger.debug(`Client ${client.id} joined room ${room}`); + return { status: 'ok', room }; + } + + /** + * Client leaves a project room + */ + @SubscribeMessage('leave:project') + handleLeaveProject(client: Socket, projectId: string) { + const room = `project:${projectId}`; + client.leave(room); + this.logger.debug(`Client ${client.id} left room ${room}`); + return { status: 'ok' }; + } + + // ========== EMIT METHODS (called by processors/services) ========== + + /** + * Emit job progress to all clients in the project room + */ + emitJobProgress(event: JobProgressEvent) { + const room = `project:${event.projectId}`; + this.server.to(room).emit(WS_EVENTS.JOB_PROGRESS, event); + } + + /** + * Emit job completed + */ + emitJobCompleted(event: JobCompletedEvent) { + const room = `project:${event.projectId}`; + this.server.to(room).emit(WS_EVENTS.JOB_COMPLETED, event); + } + + /** + * Emit job failed + */ + emitJobFailed(event: JobFailedEvent) { + const room = `project:${event.projectId}`; + this.server.to(room).emit(WS_EVENTS.JOB_FAILED, event); + } + + /** + * Emit segment generated/updated + */ + emitSegmentEvent(eventName: string, event: SegmentEvent) { + const room = `project:${event.projectId}`; + this.server.to(room).emit(eventName, event); + } + + /** + * Emit version created/restored + */ + emitVersionEvent(eventName: string, event: VersionEvent) { + const room = `project:${event.projectId}`; + this.server.to(room).emit(eventName, event); + } + + /** + * Emit project status change + */ + emitProjectStatusChanged(event: ProjectStatusEvent) { + const room = `project:${event.projectId}`; + this.server.to(room).emit(WS_EVENTS.PROJECT_STATUS_CHANGED, event); + } +} diff --git a/src/modules/skriptai/gateway/ws-events.ts b/src/modules/skriptai/gateway/ws-events.ts new file mode 100644 index 0000000..86061d6 --- /dev/null +++ b/src/modules/skriptai/gateway/ws-events.ts @@ -0,0 +1,66 @@ +/** + * WebSocket Event Constants + * + * All WebSocket event names used across the system. + * + * TR: Sistemde kullanılan tüm WebSocket event isimleri. + */ +export const WS_EVENTS = { + // Job lifecycle events + JOB_PROGRESS: 'job:progress', + JOB_COMPLETED: 'job:completed', + JOB_FAILED: 'job:failed', + + // Content events + SEGMENT_GENERATED: 'segment:generated', + SEGMENT_UPDATED: 'segment:updated', + VERSION_CREATED: 'version:created', + VERSION_RESTORED: 'version:restored', + + // Project events + PROJECT_STATUS_CHANGED: 'project:status-changed', +} as const; + +// Payload types +export interface JobProgressEvent { + jobId: string; + jobType: string; + projectId: string; + step: number; + totalSteps: number; + message: string; + percentage: number; +} + +export interface JobCompletedEvent { + jobId: string; + jobType: string; + projectId: string; + result?: any; +} + +export interface JobFailedEvent { + jobId: string; + jobType: string; + projectId: string; + reason: string; +} + +export interface SegmentEvent { + segmentId: string; + projectId: string; + segmentType?: string; +} + +export interface VersionEvent { + versionId: string; + projectId: string; + versionNumber: number; + label?: string; +} + +export interface ProjectStatusEvent { + projectId: string; + status: string; + previousStatus?: string; +} diff --git a/src/modules/skriptai/prompts/consistency-check.prompt.ts b/src/modules/skriptai/prompts/consistency-check.prompt.ts new file mode 100644 index 0000000..5d46902 --- /dev/null +++ b/src/modules/skriptai/prompts/consistency-check.prompt.ts @@ -0,0 +1,103 @@ +/** + * Consistency Check Prompt Builder + * + * Phase 3: After all segments are generated, AI reviews the entire + * script for tone consistency, flow, pacing, and logical connections. + * + * TR: Tutarlılık kontrolü — tüm segmentler üretildikten sonra ton, akış ve mantık kontrolü. + */ + +export interface ConsistencyCheckInput { + segments: { + type: string; + narratorScript: string; + visualDescription?: string; + }[]; + speechStyles: string[]; + targetAudience: string[]; + topic: string; + language: string; +} + +export function buildConsistencyCheckPrompt(input: ConsistencyCheckInput) { + const segmentText = input.segments + .map( + (s, i) => + `[Segment ${i + 1} — ${s.type}]\n${s.narratorScript}\nVisual: ${s.visualDescription || 'N/A'}`, + ) + .join('\n\n'); + + const prompt = `You are a senior script editor and quality assurance specialist. + +TASK: Review the entire script below for consistency, quality, and flow. + +TOPIC: "${input.topic}" +SPEECH STYLE: ${input.speechStyles.join(', ')} +TARGET AUDIENCE: ${input.targetAudience.join(', ')} +LANGUAGE: ${input.language} + +FULL SCRIPT: +${segmentText} + +EVALUATE AND PROVIDE: +1. "overallScore" — Quality score 1-100 +2. "toneConsistency" — Score 1-10 for consistent tone/voice throughout +3. "flowScore" — Score 1-10 for smooth transitions and logical progression +4. "pacingScore" — Score 1-10 for good pacing (not too fast/slow) +5. "engagementScore" — Score 1-10 for how engaging the content is +6. "issues" — Array of specific issues found: + - "segmentIndex": which segment (0-based) + - "type": "tone_mismatch" | "flow_break" | "pacing_issue" | "repetition" | "logic_gap" | "weak_content" + - "description": human-readable explanation + - "severity": "low" | "medium" | "high" + - "suggestedFix": how to fix this issue +7. "segmentsToRewrite" — Array of segment indexes (0-based) that should be rewritten +8. "generalSuggestions" — Overall improvement suggestions (max 5) + +Be rigorous but fair. Only flag genuine issues that would impact the audience experience. +Respond in ${input.language}.`; + + const schema = { + type: 'object' as const, + properties: { + overallScore: { type: 'number' as const }, + toneConsistency: { type: 'number' as const }, + flowScore: { type: 'number' as const }, + pacingScore: { type: 'number' as const }, + engagementScore: { type: 'number' as const }, + issues: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + segmentIndex: { type: 'number' as const }, + type: { type: 'string' as const }, + description: { type: 'string' as const }, + severity: { type: 'string' as const }, + suggestedFix: { type: 'string' as const }, + }, + }, + }, + segmentsToRewrite: { + type: 'array' as const, + items: { type: 'number' as const }, + }, + generalSuggestions: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + }, + required: [ + 'overallScore', + 'toneConsistency', + 'flowScore', + 'pacingScore', + 'engagementScore', + 'issues', + 'segmentsToRewrite', + 'generalSuggestions', + ], + }; + + return { prompt, temperature: 0.3, schema: JSON.stringify(schema) }; +} diff --git a/src/modules/skriptai/prompts/index.ts b/src/modules/skriptai/prompts/index.ts index 4edbd49..1822ecf 100644 --- a/src/modules/skriptai/prompts/index.ts +++ b/src/modules/skriptai/prompts/index.ts @@ -52,3 +52,19 @@ export { type CommercialBriefInput, type VisualAssetKeywordsInput, } from './analysis.prompt'; + +// Pipeline Enhancements (Faz 2.2) +export { + buildTopicEnrichmentPrompt, + type TopicEnrichmentInput, +} from './topic-enrichment.prompt'; + +export { + buildConsistencyCheckPrompt, + type ConsistencyCheckInput, +} from './consistency-check.prompt'; + +export { + buildSelfCritiquePrompt, + type SelfCritiqueInput, +} from './self-critique.prompt'; diff --git a/src/modules/skriptai/prompts/self-critique.prompt.ts b/src/modules/skriptai/prompts/self-critique.prompt.ts new file mode 100644 index 0000000..13c434b --- /dev/null +++ b/src/modules/skriptai/prompts/self-critique.prompt.ts @@ -0,0 +1,91 @@ +/** + * Self-Critique Prompt Builder + * + * Phase 4: AI critiques individual segments, scoring them on multiple + * dimensions. Low-scoring segments are automatically flagged for rewrite. + * + * TR: Öz-eleştiri — AI her segmenti birden fazla boyutta puanlar, düşük puanlıları yeniden yazmak üzere işaretler. + */ + +export interface SelfCritiqueInput { + segment: { + type: string; + narratorScript: string; + visualDescription?: string; + onScreenText?: string; + }; + segmentIndex: number; + topic: string; + speechStyles: string[]; + targetAudience: string[]; + language: string; +} + +export function buildSelfCritiquePrompt(input: SelfCritiqueInput) { + const prompt = `You are a ruthless but fair content critic and quality scorer. + +TASK: Score the following script segment in multiple dimensions and provide rewrite recommendations if quality is low. + +TOPIC: "${input.topic}" +SEGMENT INDEX: ${input.segmentIndex} +SEGMENT TYPE: ${input.segment.type} +SPEECH STYLE: ${input.speechStyles.join(', ')} +TARGET AUDIENCE: ${input.targetAudience.join(', ')} +LANGUAGE: ${input.language} + +SEGMENT CONTENT: +--- +NARRATOR: ${input.segment.narratorScript} +VISUAL: ${input.segment.visualDescription || 'Not specified'} +ON-SCREEN TEXT: ${input.segment.onScreenText || 'None'} +--- + +SCORE EACH DIMENSION (1-10): +1. "clarity" — Is the message clear and easy to understand? +2. "engagement" — Does it hook and maintain attention? +3. "originality" — Is it fresh and not generic? +4. "audienceMatch" — Does it match the target audience tone? +5. "visualAlignment" — Do script and visual description complement each other? +6. "emotionalImpact" — Does it evoke the intended emotion? + +ALSO PROVIDE: +7. "averageScore" — Average of all scores +8. "shouldRewrite" — true if averageScore < 6.5 +9. "weaknesses" — Array of specific weaknesses (max 3) +10. "rewriteInstructions" — If shouldRewrite is true, specific instructions for improvement + +Be honest and critical. Don't inflate scores. +Respond in ${input.language}.`; + + const schema = { + type: 'object' as const, + properties: { + clarity: { type: 'number' as const }, + engagement: { type: 'number' as const }, + originality: { type: 'number' as const }, + audienceMatch: { type: 'number' as const }, + visualAlignment: { type: 'number' as const }, + emotionalImpact: { type: 'number' as const }, + averageScore: { type: 'number' as const }, + shouldRewrite: { type: 'boolean' as const }, + weaknesses: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + rewriteInstructions: { type: 'string' as const }, + }, + required: [ + 'clarity', + 'engagement', + 'originality', + 'audienceMatch', + 'visualAlignment', + 'emotionalImpact', + 'averageScore', + 'shouldRewrite', + 'weaknesses', + ], + }; + + return { prompt, temperature: 0.2, schema: JSON.stringify(schema) }; +} diff --git a/src/modules/skriptai/prompts/topic-enrichment.prompt.ts b/src/modules/skriptai/prompts/topic-enrichment.prompt.ts new file mode 100644 index 0000000..00d9f10 --- /dev/null +++ b/src/modules/skriptai/prompts/topic-enrichment.prompt.ts @@ -0,0 +1,80 @@ +/** + * Topic Enrichment Prompt Builder + * + * Phase 0: Before outline generation, AI expands and refines the topic. + * Provides additional angles, sub-topics, and creative directions. + * + * TR: Konu zenginleştirme — outline üretilmeden önce konuyu AI ile genişletir. + */ + +export interface TopicEnrichmentInput { + topic: string; + contentType: string; + targetAudience: string[]; + language: string; + userNotes?: string; +} + +export function buildTopicEnrichmentPrompt(input: TopicEnrichmentInput) { + const prompt = `You are a world-class content strategist and creative director. + +TASK: Enrich and expand the following topic into a comprehensive content brief. + +TOPIC: "${input.topic}" +CONTENT TYPE: ${input.contentType} +TARGET AUDIENCE: ${input.targetAudience.join(', ')} +LANGUAGE: ${input.language} +${input.userNotes ? `USER NOTES: ${input.userNotes}` : ''} + +REQUIREMENTS: +1. "enrichedTopic" — A refined, more compelling version of the topic (catchy, SEO-friendly) +2. "angles" — 3-5 unique angles/perspectives to approach this topic +3. "subTopics" — 5-8 key sub-topics that should be covered +4. "hookIdeas" — 3 powerful hook ideas to start the content +5. "emotionalCore" — The primary emotional journey the audience should feel +6. "uniqueValue" — What makes this content different from existing content on this topic +7. "keyQuestions" — 5-7 questions the audience would want answered +8. "controversialTakes" — 2-3 thought-provoking or controversial perspectives (optional, if relevant) + +Respond in ${input.language}. Be creative and think beyond the obvious.`; + + const schema = { + type: 'object' as const, + properties: { + enrichedTopic: { type: 'string' as const }, + angles: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + subTopics: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + hookIdeas: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + emotionalCore: { type: 'string' as const }, + uniqueValue: { type: 'string' as const }, + keyQuestions: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + controversialTakes: { + type: 'array' as const, + items: { type: 'string' as const }, + }, + }, + required: [ + 'enrichedTopic', + 'angles', + 'subTopics', + 'hookIdeas', + 'emotionalCore', + 'uniqueValue', + 'keyQuestions', + ], + }; + + return { prompt, temperature: 0.9, schema: JSON.stringify(schema) }; +} diff --git a/src/modules/skriptai/queue/analysis.processor.ts b/src/modules/skriptai/queue/analysis.processor.ts new file mode 100644 index 0000000..0f1ecf7 --- /dev/null +++ b/src/modules/skriptai/queue/analysis.processor.ts @@ -0,0 +1,80 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { AnalysisService } from '../services/analysis.service'; +import { + QUEUES, + JobType, + AnalysisPayload, + JobResult, +} from './queue.constants'; + +/** + * Analysis Queue Processor + * + * Handles async analysis jobs: neuro, youtube audit, commercial brief, visual assets. + * + * TR: Asenkron analiz işlerini yönetir. + */ +@Processor(QUEUES.ANALYSIS) +export class AnalysisProcessor extends WorkerHost { + private readonly logger = new Logger(AnalysisProcessor.name); + + constructor(private readonly analysisService: AnalysisService) { + super(); + } + + async process(job: Job): Promise { + this.logger.log(`Processing analysis job ${job.id} — type: ${job.name}`); + + try { + switch (job.name) { + case JobType.NEURO_ANALYSIS: + return await this.handleNeuro(job as Job); + + case JobType.YOUTUBE_AUDIT: + return await this.handleYoutube(job as Job); + + case JobType.COMMERCIAL_BRIEF: + return await this.handleCommercial(job as Job); + + case JobType.GENERATE_VISUAL_ASSETS: + return await this.handleVisualAssets(job as Job); + + default: + throw new Error(`Unknown analysis job type: ${job.name}`); + } + } catch (error: any) { + this.logger.error(`Analysis job ${job.id} failed: ${error.message}`); + return { success: false, error: error.message }; + } + } + + private async handleNeuro(job: Job): Promise { + await job.updateProgress({ step: 1, totalSteps: 2, message: 'Nöro-pazarlama analizi yapılıyor...', percentage: 30 }); + const result = await this.analysisService.analyzeNeuroMarketing(job.data.projectId); + await job.updateProgress({ step: 2, totalSteps: 2, message: 'Analiz tamamlandı!', percentage: 100 }); + return { success: true, data: result }; + } + + private async handleYoutube(job: Job): Promise { + await job.updateProgress({ step: 1, totalSteps: 2, message: 'YouTube audit yapılıyor...', percentage: 30 }); + const result = await this.analysisService.performYoutubeAudit(job.data.projectId); + await job.updateProgress({ step: 2, totalSteps: 2, message: 'Audit tamamlandı!', percentage: 100 }); + return { success: true, data: result }; + } + + private async handleCommercial(job: Job): Promise { + await job.updateProgress({ step: 1, totalSteps: 2, message: 'Ticari brief oluşturuluyor...', percentage: 30 }); + const result = await this.analysisService.generateCommercialBrief(job.data.projectId); + await job.updateProgress({ step: 2, totalSteps: 2, message: 'Brief tamamlandı!', percentage: 100 }); + return { success: true, data: result }; + } + + private async handleVisualAssets(job: Job): Promise { + await job.updateProgress({ step: 1, totalSteps: 2, message: 'Görsel varlıklar üretiliyor...', percentage: 30 }); + const result = await this.analysisService.generateVisualAssets(job.data.projectId); + await job.updateProgress({ step: 2, totalSteps: 2, message: 'Tamamlandı!', percentage: 100 }); + return { success: true, data: result }; + } +} diff --git a/src/modules/skriptai/queue/index.ts b/src/modules/skriptai/queue/index.ts new file mode 100644 index 0000000..4b37ff9 --- /dev/null +++ b/src/modules/skriptai/queue/index.ts @@ -0,0 +1,4 @@ +export * from './queue.constants'; +export * from './script.processor'; +export * from './research.processor'; +export * from './analysis.processor'; diff --git a/src/modules/skriptai/queue/queue.constants.ts b/src/modules/skriptai/queue/queue.constants.ts new file mode 100644 index 0000000..3cabaa9 --- /dev/null +++ b/src/modules/skriptai/queue/queue.constants.ts @@ -0,0 +1,96 @@ +/** + * Queue Constants + * + * Central definition of all BullMQ queue names and job types. + * + * TR: Tüm BullMQ kuyruk adları ve iş tipleri merkezi tanımı. + */ + +// Queue names +export const QUEUES = { + SCRIPT_GENERATION: 'script-generation', + DEEP_RESEARCH: 'deep-research', + ANALYSIS: 'analysis', + IMAGE_GENERATION: 'image-generation', +} as const; + +// Job type discriminators +export enum JobType { + // Script + GENERATE_SCRIPT = 'generate-script', + REGENERATE_SEGMENT = 'regenerate-segment', + REGENERATE_PARTIAL = 'regenerate-partial', + REWRITE_SEGMENT = 'rewrite-segment', + + // Research + DEEP_RESEARCH = 'deep-research', + DISCOVER_QUESTIONS = 'discover-questions', + + // Analysis + NEURO_ANALYSIS = 'neuro-analysis', + YOUTUBE_AUDIT = 'youtube-audit', + COMMERCIAL_BRIEF = 'commercial-brief', + GENERATE_VISUAL_ASSETS = 'generate-visual-assets', + + // Image + GENERATE_SEGMENT_IMAGE = 'generate-segment-image', + GENERATE_THUMBNAIL = 'generate-thumbnail', +} + +// Job status for tracking +export enum JobStatus { + QUEUED = 'QUEUED', + PROCESSING = 'PROCESSING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', +} + +// Job payload interfaces +export interface ScriptGenerationPayload { + projectId: string; + userId?: string; +} + +export interface SegmentRegeneratePayload { + segmentId: string; + projectId: string; +} + +export interface PartialRegeneratePayload { + projectId: string; + segmentIds: string[]; +} + +export interface RewriteSegmentPayload { + segmentId: string; + newStyle: string; + projectId: string; +} + +export interface DeepResearchPayload { + projectId: string; +} + +export interface AnalysisPayload { + projectId: string; +} + +export interface ImageGenerationPayload { + segmentId: string; + projectId: string; +} + +// Job result +export interface JobResult { + success: boolean; + data?: any; + error?: string; +} + +// Job progress detail +export interface JobProgress { + step: number; + totalSteps: number; + message: string; + percentage: number; +} diff --git a/src/modules/skriptai/queue/research.processor.ts b/src/modules/skriptai/queue/research.processor.ts new file mode 100644 index 0000000..57c7f79 --- /dev/null +++ b/src/modules/skriptai/queue/research.processor.ts @@ -0,0 +1,78 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { ResearchService } from '../services/research.service'; +import { + QUEUES, + JobType, + DeepResearchPayload, + JobResult, +} from './queue.constants'; + +/** + * Research Queue Processor + * + * Handles async research jobs: deep research, discovery questions. + * + * TR: Asenkron araştırma işlerini yönetir. + */ +@Processor(QUEUES.DEEP_RESEARCH) +export class ResearchProcessor extends WorkerHost { + private readonly logger = new Logger(ResearchProcessor.name); + + constructor(private readonly researchService: ResearchService) { + super(); + } + + async process(job: Job): Promise { + this.logger.log(`Processing research job ${job.id} — type: ${job.name}`); + + try { + switch (job.name) { + case JobType.DEEP_RESEARCH: + return await this.handleDeepResearch( + job as Job, + ); + + case JobType.DISCOVER_QUESTIONS: + return await this.handleDiscoverQuestions( + job as Job, + ); + + default: + throw new Error(`Unknown research job type: ${job.name}`); + } + } catch (error: any) { + this.logger.error(`Research job ${job.id} failed: ${error.message}`); + return { success: false, error: error.message }; + } + } + + private async handleDeepResearch( + job: Job, + ): Promise { + const { projectId } = job.data; + + await job.updateProgress({ step: 1, totalSteps: 3, message: 'Araştırma başlatılıyor...', percentage: 10 }); + + const result = await this.researchService.performDeepResearch(projectId); + + await job.updateProgress({ step: 3, totalSteps: 3, message: 'Araştırma tamamlandı!', percentage: 100 }); + + return { success: true, data: result }; + } + + private async handleDiscoverQuestions( + job: Job, + ): Promise { + const { projectId } = job.data; + + await job.updateProgress({ step: 1, totalSteps: 2, message: 'Keşif soruları üretiliyor...', percentage: 30 }); + + const result = await this.researchService.generateDiscoveryQuestions(projectId, ''); + + await job.updateProgress({ step: 2, totalSteps: 2, message: 'Tamamlandı!', percentage: 100 }); + + return { success: true, data: result }; + } +} diff --git a/src/modules/skriptai/queue/script.processor.ts b/src/modules/skriptai/queue/script.processor.ts new file mode 100644 index 0000000..4aac374 --- /dev/null +++ b/src/modules/skriptai/queue/script.processor.ts @@ -0,0 +1,144 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { ScriptsService } from '../services/scripts.service'; +import { VersionsService } from '../services/versions.service'; +import { + QUEUES, + JobType, + ScriptGenerationPayload, + SegmentRegeneratePayload, + PartialRegeneratePayload, + RewriteSegmentPayload, + JobResult, +} from './queue.constants'; + +/** + * Script Queue Processor + * + * Handles all script-related async jobs: + * - Full script generation + * - Single segment regeneration + * - Partial regeneration + * - Segment rewrite + * + * TR: Script ile ilgili tüm asenkron işleri yönetir. + */ +@Processor(QUEUES.SCRIPT_GENERATION) +export class ScriptProcessor extends WorkerHost { + private readonly logger = new Logger(ScriptProcessor.name); + + constructor( + private readonly scriptsService: ScriptsService, + private readonly versionsService: VersionsService, + ) { + super(); + } + + async process(job: Job): Promise { + this.logger.log(`Processing job ${job.id} — type: ${job.name}`); + + try { + switch (job.name) { + case JobType.GENERATE_SCRIPT: + return await this.handleGenerateScript( + job as Job, + ); + + case JobType.REGENERATE_SEGMENT: + return await this.handleRegenerateSegment( + job as Job, + ); + + case JobType.REGENERATE_PARTIAL: + return await this.handleRegeneratePartial( + job as Job, + ); + + case JobType.REWRITE_SEGMENT: + return await this.handleRewriteSegment( + job as Job, + ); + + default: + throw new Error(`Unknown job type: ${job.name}`); + } + } catch (error: any) { + this.logger.error(`Job ${job.id} failed: ${error.message}`); + return { success: false, error: error.message }; + } + } + + private async handleGenerateScript( + job: Job, + ): Promise { + const { projectId } = job.data; + + await job.updateProgress({ step: 1, totalSteps: 3, message: 'Script hazırlanıyor...', percentage: 10 }); + + const result = await this.scriptsService.generateScript(projectId); + + await job.updateProgress({ step: 3, totalSteps: 3, message: 'Script tamamlandı!', percentage: 100 }); + + return { success: true, data: result }; + } + + private async handleRegenerateSegment( + job: Job, + ): Promise { + const { segmentId } = job.data; + + await job.updateProgress({ step: 1, totalSteps: 2, message: 'Segment yeniden yazılıyor...', percentage: 30 }); + + const result = await this.scriptsService.regenerateSegment(segmentId); + + await job.updateProgress({ step: 2, totalSteps: 2, message: 'Tamamlandı!', percentage: 100 }); + + return { success: true, data: result }; + } + + private async handleRegeneratePartial( + job: Job, + ): Promise { + const { projectId, segmentIds } = job.data; + const total = segmentIds.length; + + await job.updateProgress({ + step: 0, + totalSteps: total, + message: `${total} segment yeniden yazılacak...`, + percentage: 5, + }); + + const result = await this.scriptsService.regeneratePartial( + projectId, + segmentIds, + ); + + await job.updateProgress({ + step: total, + totalSteps: total, + message: 'Tüm segmentler tamamlandı!', + percentage: 100, + }); + + return { success: true, data: result }; + } + + private async handleRewriteSegment( + job: Job, + ): Promise { + const { segmentId, newStyle } = job.data; + + await job.updateProgress({ step: 1, totalSteps: 2, message: `"${newStyle}" stiliyle yeniden yazılıyor...`, percentage: 30 }); + + const result = await this.scriptsService.rewriteSegment( + segmentId, + newStyle, + ); + + await job.updateProgress({ step: 2, totalSteps: 2, message: 'Tamamlandı!', percentage: 100 }); + + return { success: true, data: result }; + } +} diff --git a/src/modules/skriptai/services/scripts.service.ts b/src/modules/skriptai/services/scripts.service.ts index cd57c10..ebf9b5c 100644 --- a/src/modules/skriptai/services/scripts.service.ts +++ b/src/modules/skriptai/services/scripts.service.ts @@ -9,6 +9,9 @@ import { buildChapterSegmentPrompt, buildSegmentRewritePrompt, buildSegmentImagePrompt, + buildTopicEnrichmentPrompt, + buildConsistencyCheckPrompt, + buildSelfCritiquePrompt, calculateTargetWordCount, calculateEstimatedChapters, } from '../prompts'; @@ -479,4 +482,325 @@ export class ScriptsService { return results; } + + // ========== ENHANCED PIPELINE (Faz 2.2) ========== + + /** + * Phase 0: Enrich and expand the topic before script generation. + * + * TR: Konu zenginleştirme — AI ile konuyu genişletir ve derinleştirir. + */ + async enrichTopic(projectId: string) { + this.logger.log(`Enriching topic for project: ${projectId}`); + + const project = await this.prisma.scriptProject.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`); + } + + const promptData = buildTopicEnrichmentPrompt({ + topic: project.topic, + contentType: project.contentType, + targetAudience: project.targetAudience, + language: project.language, + userNotes: project.userNotes || undefined, + }); + + const resp = await this.gemini.generateJSON<{ + enrichedTopic: string; + angles: string[]; + subTopics: string[]; + hookIdeas: string[]; + emotionalCore: string; + uniqueValue: string; + keyQuestions: string[]; + controversialTakes?: string[]; + }>(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); + + // Store enrichment data as user notes supplement + const enrichmentSummary = [ + `🎯 Zenginleştirilmiş Konu: ${resp.data.enrichedTopic}`, + `\n📐 Açılar: ${resp.data.angles.join(' | ')}`, + `\n📚 Alt Konular: ${resp.data.subTopics.join(', ')}`, + `\n🎣 Hook Fikirleri: ${resp.data.hookIdeas.join(' | ')}`, + `\n💫 Duygusal Çekirdek: ${resp.data.emotionalCore}`, + `\n🔑 Ayırt Edici Değer: ${resp.data.uniqueValue}`, + ].join(''); + + await this.prisma.scriptProject.update({ + where: { id: projectId }, + data: { + userNotes: project.userNotes + ? `${project.userNotes}\n\n--- AI Zenginleştirme ---\n${enrichmentSummary}` + : enrichmentSummary, + }, + }); + + return resp.data; + } + + /** + * Get the current outline for user review/editing before generation. + * Returns the outline without generating any segments. + * + * TR: Outline'ı üretip kullanıcıya gönderir, henüz segment oluşturmaz. + */ + async generateOutlineForReview(projectId: string) { + this.logger.log(`Generating outline for review: ${projectId}`); + + const project = await this.prisma.scriptProject.findUnique({ + where: { id: projectId }, + include: { + sources: { where: { selected: true } }, + briefItems: { orderBy: { sortOrder: 'asc' } }, + characters: true, + }, + }); + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`); + } + + const sourceContext = project.sources + .slice(0, 5) + .map((s, i) => `[Source ${i + 1}] (${s.type}): ${s.title} - ${s.snippet}`) + .join('\n'); + + const briefContext = project.briefItems + .map((b) => `Q: ${b.question}\nA: ${b.answer}`) + .join('\n'); + + const characterContext = project.characters + .map((c) => `${c.name} (${c.role}): Values[${c.values}] Traits[${c.traits}]`) + .join('\n'); + + const targetWordCount = calculateTargetWordCount(project.targetDuration); + const estimatedChapters = calculateEstimatedChapters(targetWordCount); + + const outlinePromptData = buildScriptOutlinePrompt({ + topic: project.topic, + logline: project.logline || '', + characterContext, + speechStyles: project.speechStyle, + targetAudience: project.targetAudience, + contentType: project.contentType, + targetDuration: project.targetDuration, + targetWordCount, + estimatedChapters, + sourceContext, + briefContext, + }); + + const outlineResp = await this.gemini.generateJSON<{ + title: string; + seoDescription: string; + tags: string[]; + thumbnailIdeas: string[]; + chapters: { title: string; focus: string; type: string }[]; + }>(outlinePromptData.prompt, outlinePromptData.schema, { + temperature: outlinePromptData.temperature, + }); + + return { + outline: outlineResp.data, + targetWordCount, + estimatedChapters, + }; + } + + /** + * Phase 3: Consistency check — AI reviews the entire script. + * + * TR: Tutarlılık kontrolü — tüm scripti ton, akış ve mantık açısından inceler. + */ + async checkConsistency(projectId: string) { + this.logger.log(`Running consistency check for project: ${projectId}`); + + const project = await this.prisma.scriptProject.findUnique({ + where: { id: projectId }, + include: { + segments: { orderBy: { sortOrder: 'asc' } }, + }, + }); + + if (!project || !project.segments.length) { + throw new NotFoundException('Project or segments not found'); + } + + const promptData = buildConsistencyCheckPrompt({ + segments: project.segments.map((s) => ({ + type: s.segmentType, + narratorScript: s.narratorScript || '', + visualDescription: s.visualDescription || undefined, + })), + speechStyles: project.speechStyle, + targetAudience: project.targetAudience, + topic: project.topic, + language: project.language, + }); + + const resp = await this.gemini.generateJSON<{ + overallScore: number; + toneConsistency: number; + flowScore: number; + pacingScore: number; + engagementScore: number; + issues: { + segmentIndex: number; + type: string; + description: string; + severity: string; + suggestedFix: string; + }[]; + segmentsToRewrite: number[]; + generalSuggestions: string[]; + }>(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); + + return resp.data; + } + + /** + * Phase 4: Self-critique and auto-rewrite low-quality segments. + * + * AI scores each segment and automatically rewrites segments with + * averageScore < threshold (default: 6.5). + * + * TR: Öz-eleştiri — her segmenti puanlar, düşük puanlıları otomatik yeniden yazar. + */ + async selfCritiqueAndRewrite( + projectId: string, + threshold: number = 6.5, + ) { + this.logger.log(`Running self-critique for project: ${projectId} (threshold: ${threshold})`); + + const project = await this.prisma.scriptProject.findUnique({ + where: { id: projectId }, + include: { + segments: { orderBy: { sortOrder: 'asc' } }, + }, + }); + + if (!project || !project.segments.length) { + throw new NotFoundException('Project or segments not found'); + } + + // Auto-snapshot before self-critique rewrites + await this.versionsService.createSnapshot( + projectId, + 'AUTO_SAVE', + undefined, + 'Auto-save before self-critique', + ).catch(() => {}); + + const critiqueResults: { + segmentIndex: number; + segmentId: string; + scores: { + clarity: number; + engagement: number; + originality: number; + audienceMatch: number; + visualAlignment: number; + emotionalImpact: number; + averageScore: number; + }; + shouldRewrite: boolean; + weaknesses: string[]; + wasRewritten: boolean; + }[] = []; + + for (let i = 0; i < project.segments.length; i++) { + const segment = project.segments[i]; + + const promptData = buildSelfCritiquePrompt({ + segment: { + type: segment.segmentType, + narratorScript: segment.narratorScript || '', + visualDescription: segment.visualDescription || undefined, + onScreenText: segment.onScreenText || undefined, + }, + segmentIndex: i, + topic: project.topic, + speechStyles: project.speechStyle, + targetAudience: project.targetAudience, + language: project.language, + }); + + try { + const resp = await this.gemini.generateJSON<{ + clarity: number; + engagement: number; + originality: number; + audienceMatch: number; + visualAlignment: number; + emotionalImpact: number; + averageScore: number; + shouldRewrite: boolean; + weaknesses: string[]; + rewriteInstructions?: string; + }>(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); + + const critique = resp.data; + let wasRewritten = false; + + // Auto-rewrite if below threshold + if (critique.averageScore < threshold && critique.rewriteInstructions) { + try { + await this.rewriteSegment( + segment.id, + critique.rewriteInstructions, + ); + wasRewritten = true; + this.logger.log( + `Segment ${i + 1} rewritten (score: ${critique.averageScore})`, + ); + } catch { + this.logger.warn(`Failed to rewrite segment ${i + 1}`); + } + } + + critiqueResults.push({ + segmentIndex: i, + segmentId: segment.id, + scores: { + clarity: critique.clarity, + engagement: critique.engagement, + originality: critique.originality, + audienceMatch: critique.audienceMatch, + visualAlignment: critique.visualAlignment, + emotionalImpact: critique.emotionalImpact, + averageScore: critique.averageScore, + }, + shouldRewrite: critique.shouldRewrite, + weaknesses: critique.weaknesses, + wasRewritten, + }); + } catch (error) { + this.logger.warn(`Self-critique failed for segment ${i + 1}: ${error}`); + } + } + + const rewrittenCount = critiqueResults.filter((r) => r.wasRewritten).length; + const avgScore = + critiqueResults.length > 0 + ? critiqueResults.reduce((sum, r) => sum + r.scores.averageScore, 0) / + critiqueResults.length + : 0; + + return { + overallAverageScore: Math.round(avgScore * 10) / 10, + totalSegments: project.segments.length, + segmentsRewritten: rewrittenCount, + critiques: critiqueResults, + }; + } } diff --git a/src/modules/skriptai/skriptai.module.ts b/src/modules/skriptai/skriptai.module.ts index 419a2d9..5d59f9f 100644 --- a/src/modules/skriptai/skriptai.module.ts +++ b/src/modules/skriptai/skriptai.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; import { DatabaseModule } from '../../database/database.module'; import { GeminiModule } from '../gemini/gemini.module'; @@ -10,6 +11,7 @@ import { AnalysisController, VersionsController, } from './controllers'; +import { JobsController } from './controllers/jobs.controller'; // Services import { @@ -20,6 +22,16 @@ import { VersionsService, } from './services'; +// Queue +import { QUEUES } from './queue/queue.constants'; +import { ScriptProcessor } from './queue/script.processor'; +import { ResearchProcessor } from './queue/research.processor'; +import { AnalysisProcessor } from './queue/analysis.processor'; + +// Gateway (WebSocket) +import { SkriptaiGateway } from './gateway/skriptai.gateway'; +import { QueueEventBridge } from './gateway/queue-event-bridge'; + /** * SkriptAI Module * @@ -33,18 +45,30 @@ import { * - YouTube audit * - Commercial brief generation * - Version history & content management + * - BullMQ async job processing * * TR: SkriptAI ana modülü - AI destekli video script üretimi. - * EN: Main module for the SkriptAI feature - AI-powered video script generation. */ @Module({ - imports: [DatabaseModule, GeminiModule], + imports: [ + DatabaseModule, + GeminiModule, + + // BullMQ Queues + BullModule.registerQueue( + { name: QUEUES.SCRIPT_GENERATION }, + { name: QUEUES.DEEP_RESEARCH }, + { name: QUEUES.ANALYSIS }, + { name: QUEUES.IMAGE_GENERATION }, + ), + ], controllers: [ ProjectsController, ScriptsController, ResearchController, AnalysisController, VersionsController, + JobsController, ], providers: [ ProjectsService, @@ -52,6 +76,15 @@ import { ResearchService, AnalysisService, VersionsService, + + // Queue Processors + ScriptProcessor, + ResearchProcessor, + AnalysisProcessor, + + // WebSocket + SkriptaiGateway, + QueueEventBridge, ], exports: [ ProjectsService,