Compare commits

..

1 Commits

Author SHA1 Message Date
haruncan 8e8c9d17d0 Initial commit 2026-03-23 02:31:54 +03:00
119 changed files with 748 additions and 8807 deletions
-2
View File
@@ -1,2 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto
+36
View File
@@ -0,0 +1,36 @@
name: Backend Deploy 🚀
run-name: ${{ gitea.actor }} backend güncelliyor...
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Kodu Çek
uses: actions/checkout@v3
- name: Docker Build
# Dockerfile'ı kullanarak imajı oluşturuyoruz
run: docker build -t backend-proje:latest .
- name: Eski Konteyneri Sil
# İlk seferde hata vermemesi için '|| true' ekledik
run: docker rm -f backend-container || true
- name: Yeni Versiyonu Başlat
# Secrets kısmından şifreleri alıp konteynere veriyoruz
run: |
docker run -d \
--name backend-container \
--restart always \
--network gitea-server_gitea \
-p 1501:3000 \
-e DATABASE_URL='${{ secrets.DATABASE_URL }}' \
-e JWT_SECRET='${{ secrets.JWT_SECRET }}' \
-e REDIS_HOST='${{ secrets.REDIS_HOST }}' \
-e REDIS_PORT='6379' \
backend-proje:latest
+25 -18
View File
@@ -1,49 +1,56 @@
# Build stage
# --- Build Stage ---
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Raspberry Pi ve Prisma uyumluluğu için gerekli kütüphaneler
RUN apk add --no-cache openssl libc6-compat
# Install dependencies
# Paket dosyalarını kopyala
COPY package*.json ./
RUN npm ci
# Copy source code
# Kaynak kodları kopyala
COPY . .
# Generate Prisma client
# Prisma client üret (Database şeman için şart)
RUN npx prisma generate
# Build the application
# Build al (NestJS/Backend için)
RUN npm run build
# Production stage
# --- Production Stage (Canlı Sistem) ---
FROM node:20-alpine AS production
# Prisma için gerekli kütüphaneleri buraya da ekliyoruz
RUN apk add --no-cache openssl libc6-compat
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies only
# Sadece production (canlıda lazım olan) paketleri kur
RUN npm ci --only=production
# Copy Prisma schema and generate client
# Prisma şemasını taşı ve client üret
COPY prisma ./prisma
RUN npx prisma generate
# Copy built application
COPY --from=builder /app/dist ./dist
# Build edilen dosyaları taşı (Senin Dockerfile'ındaki yapıya sadık kaldım)
# Güvenlik için dosyaları 'node' kullanıcısına zimmetliyoruz
COPY --chown=node:node --from=builder /app/dist ./dist
# Copy i18n files
COPY --from=builder /app/src/i18n ./dist/i18n
# Eğer i18n varsa onu da taşı
COPY --chown=node:node --from=builder /app/src/i18n ./dist/i18n
# Set environment
# Ortam değişkeni
ENV NODE_ENV=production
# Expose port
# Portu aç
EXPOSE 3000
# Start the application
# Güvenlik: Root yerine 'node' kullanıcısına geç
USER node
# Uygulamayı başlat
CMD ["node", "dist/main.js"]
-21
View File
@@ -1,21 +0,0 @@
module.exports = {
apps: [{
name: 'skriptai-backend',
script: 'dist/main.js',
instances: 1,
exec_mode: 'cluster',
env_production: {
NODE_ENV: 'production',
PORT: 3000
}
}, {
name: 'skriptai-frontend',
script: 'npm',
args: 'start -- -p 3001',
cwd: '/home/haruncan/apps/skriptai/frontend',
env: {
NODE_ENV: 'production',
PORT: 3001
}
}]
};
+146
View File
@@ -0,0 +1,146 @@
```markdown
# 🚀 Raspberry Pi Proje Dağıtım Rehberi (SSL Güncellemeli)
Bu rehber, mevcut merkezi altyapıyı (Gitea, Runner, Central Database, Nginx) kullanarak yeni NestJS (Backend) ve Next.js (Frontend) projelerini nasıl ayağa kaldıracağını ve **SSL (HTTPS) sorunları yaşamadan** nasıl yayına alacağını adım adım açıklar.
---
## 🏗 1. Altyapı Hazırlığı (Infrastructure)
### A. Veritabanı Oluşturma
Her yeni proje için merkezi PostgreSQL konteynerinde yeni bir veritabanı açılmalıdır.
```bash
# PROJE_ADI kısmını küçük harf ve boşluksuz yaz (örn: e_ticaret_db)
docker exec -it backend_db createdb -U 'Rub1c0N-UseR.!' PROJE_ADI_db
```
### B. DNS Ayarları
Domain panelinden (Cloudflare vb.) yeni subdomain'leri Raspberry Pi'nin dış IP'sine yönlendir:
* `api-proje.bilgich.com` -> **Raspberry Pi IP**
* `ui-proje.bilgich.com` -> **Raspberry Pi IP**
---
## 🔐 2. Gitea Secret Ayarları
**⚠️ Önemli:** Proje canlıya çıkarken SSL kullanacağımız için URL'leri şimdiden **https** olarak tanımlıyoruz.
Gitea reponuzda **Settings > Actions > Secrets** yolunu izleyerek aşağıdaki anahtarları tanımlayın.
### Backend İçin:
| Key | Value Örneği | Açıklama |
| :--- | :--- | :--- |
| `DATABASE_URL` | `postgresql://Rub1c0N-UseR.%21:SIFRE%3D@backend_db:5432/PROJE_ADI_db?schema=public` | Özel karakterler encode edilmeli (!=%21, =%3D) |
| `JWT_SECRET` | `rastgele_uzun_string` | Güvenlik anahtarı |
### Frontend İçin:
| Key | Value Örneği | Açıklama |
| :--- | :--- | :--- |
| `NEXT_PUBLIC_API_URL` | `https://api-proje.bilgich.com/api` | **https** olmasına dikkat et |
| `NEXTAUTH_URL` | `https://ui-proje.bilgich.com` | **https** olmasına dikkat et |
| `NEXTAUTH_SECRET` | `openssl_rand_base64_32_cikti` | Auth güvenliği için |
---
## 🛠 3. Backend (NestJS) Proje Ayarları
1. **main.ts:** Global prefix ve Swagger yollarını kontrol et.
2. **Dockerfile:** Mevcut çalışan NestJS Dockerfile'ı kullan.
3. **deploy.yml Değişiklikleri:**
* `--name backend-PROJE-container` (Her proje için unique isim)
* `-p 150X:3000` (Sıradaki boş port: 1502, 1503...)
* `--network gitea-server_gitea` (Database'e erişim için şart)
---
## 🎨 4. Frontend (Next.js) Proje Ayarları
1. **next.config.js:** `output: 'standalone'` satırını ekle.
2. **Dockerfile:** `ARG` ve `ENV` satırlarına `NEXT_PUBLIC_` değişkenlerini ekle.
3. **deploy-ui.yml Değişiklikleri:**
* `--build-arg` ile tüm Gitea secret'larını build aşamasına geç.
* `--name ui-PROJE-container` (Unique isim)
* `-p 180X:3000` (Sıradaki boş port: 1801, 1802...)
* `-e NEXTAUTH_URL` ve `NEXTAUTH_SECRET` runtime değişkenlerini ekle.
---
## 🚦 5. Nginx ve SSL Yönlendirmesi (KRİTİK ADIM)
Diğer projelerle çakışma olmaması için her yeni domain'e mutlaka SSL kurulmalıdır.
### A. Konfigürasyon Dosyası Oluştur
Backend veya Frontend için dosya oluşturun:
```bash
sudo nano /etc/nginx/sites-available/proje-api
# Veya
sudo nano /etc/nginx/sites-available/proje-ui
```
### B. İçeriği Yapıştır (Sadece HTTP)
İlk aşamada sadece 80 portunu dinleyen şu bloğu yapıştırın:
```nginx
server {
listen 80;
server_name api-proje.bilgich.com; # Domain adını buraya yaz
location / {
proxy_pass http://127.0.0.1:150X; # Uygulamanın dış portu (1502, 1802 vb.)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Gerçek IP Logları
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
### C. Nginx'i Aktif Et
Dosyayı `sites-enabled` klasörüne linkleyin ve Nginx'i reload edin:
```bash
sudo ln -s /etc/nginx/sites-available/proje-api /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
### D. SSL Sertifikası Kur (Certbot)
Bu komut Nginx ayarlarını otomatik güncelleyip 443 portunu açacaktır. Bu adımı yapmazsanız HTTPS girişleri başka projelere yönlenir!
```bash
sudo certbot --nginx -d api-proje.bilgich.com
```
*Soru sorarsa "2" (Redirect) seçeneğini seçin.*
---
## 💡 "Senior" İpuçları & Bakım
### Port Yönetimi (Defteri Kebir)
Kullandığın portları çakışmaması için mutlaka not et:
* **1501:** Digicraft BE
* **1502:** YeniProje BE
* **1800:** Digicraft UI
* **1801:** YeniProje UI
### Disk Temizliği
Raspberry Pi diski dolmaması için haftalık/aylık temizlik yap:
```bash
docker system prune -f
```
### Log Takibi
Bir sorun olduğunda ilk buraya bak:
```bash
docker logs -f KONTEYNER_ADI
```
```
+65
View File
@@ -0,0 +1,65 @@
# Learned Protocols & Standards
This document serves as the persistent memory of the protocols, standards, and personas learned from the `skript-be` and `skript-ui` repositories.
## 1. Frontend Standards (skript-ui)
### Design & Aesthetics (`frontend-design`)
- **Anti-AI Slop:** Avoid generic, cookie-cutter "AI" aesthetics (e.g., standard purple gradients, predictable layouts).
- **Boldness:** Commit to a specific aesthetic direction (Minimalist, Brutalist, Magazine, etc.).
- **Typography:** Use distinctive fonts; avoid system defaults like Arial/Inter unless intentional.
- **Micro-interactions:** Prioritize one or two high-impact animations over scattered noise.
- **Creativity:** Use noise textures, gradient meshes, asymmetry, and overlapping elements.
### Architecture (`senior-frontend` & `nextjs-architecture-expert`)
- **Next.js App Router:** STRICT adherence to App Router patterns (layouts, error.tsx, loading.tsx).
- **Server Components (RSC):** Default to Server Components. Use Client Components ('use client') only when interactivity is required.
- **State Management:** component-first thinking; use Context/Zustand for global state, local state for UI.
- **Performance:** Aim for sub-3s load times. Use `next/image`, code splitting, and lazy loading.
- **Tailwind CSS:** Use correctly; avoid long string pollution where possible (use utils/cva).
### Quality Assurance (`senior-qa`)
- **E2E Testing:** Critical flows must be tested.
- **Coverage:** High unit test coverage for utilities and complex logic.
## 2. Backend Standards (skript-be)
### Code Quality (`code-reviewer`)
- **Review:** Verify BEFORE implementing.
- **Simplicity:** No over-engineering.
- **Security:** No secrets in code. Input validation is mandatory.
- **YAGNI:** "You Aren't Gonna Need It" - don't build features "just in case".
### Security (`security-engineer` & `api-security-audit`)
- **Zero Trust:** Verify every request.
- **OWASP:** Check against Top 10 (Injection, Broken Auth, etc.).
- **Data:** Validate all inputs using libraries (e.g., Zod, Joi).
- **Logging:** Sanitize logs (no PII/secrets).
### Database (`database-optimizer`)
- **N+1:** Watch out for N+1 queries in loops/ORMs.
- **Indexing:** Index foreign keys and search columns.
- **Explain:** Check execution plans for complex queries.
### General Engineering
- **TypeScript:** Strict mode enabled. No `any`. Use generics and utility types (`typescript-pro`).
- **Feedback:** "Receive Code Review" protocol technical correctness > polite agreement. Verify suggestions before applying.
### TypeScript Expertise (`typescript-pro`)
- **Seniority:** I write *Senior-level* code. This means focusing on maintainability, scalability, and robustness, not just "making it work".
- **Modern Techniques:** I utilize the latest TypeScript features:
- **Advanced Types:** Conditional types, Template Literal Types, Mapped Types.
- **Utility Types:** `Pick`, `Omit`, `Partial`, `Readonly`, `ReturnType`, `Parameters`, etc.
- **Generics:** Proper constraints (`T extends ...`) and defaults.
- **Type Inference:** Leveraging inference where clean, explicit typing where necessary for clarity.
- **Strictness:**
- `noImplicitAny` is law.
- Avoid `any` at all costs; use `unknown` with type narrowing/guards if dynamic typing is truly needed.
- Strict null checks always on.
- **Architecture:** Value objects, opaque types, and branded types for domain safety.
## 3. Operational Protocols
- **Agent Persona:** I act as the specific specialist required for the task (e.g., if debugging, I am `debugger`; if designing, I am `frontend-developer`).
- **Proactiveness:** I do not wait for permission to fix obvious bugs or improve clear performace bottlenecks if they are within scope.
- **Persistence:** These rules apply to ALL future tasks in this session.
+422 -1178
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,5 +1,5 @@
{
"name": "skriptAI-be",
"name": "bbb",
"version": "0.0.1",
"description": "Generated by Antigravity CLI",
"private": true,
@@ -19,8 +19,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1014.0",
"@aws-sdk/lib-storage": "^3.1014.0",
"@aws-sdk/client-s3": "^3.964.0",
"@google/genai": "^1.35.0",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
@@ -53,7 +52,7 @@
"prisma": "^5.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^4.3.6"
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
-191
View File
@@ -25,7 +25,6 @@ model User {
// Relations
roles UserRole[]
refreshTokens RefreshToken[]
projects ScriptProject[]
// Multi-tenancy (optional)
tenantId String?
@@ -161,193 +160,3 @@ model Translation {
@@index([locale])
@@index([namespace])
}
// ============================================
// SkriptAI Models
// ============================================
model ScriptProject {
id String @id @default(uuid())
userId String?
topic String
contentType String // ContentFormat enum value
targetAudience String[] // Array of TargetAudience values
speechStyle String[] // Array of SpeechStyle values
targetDuration String
userNotes String? @db.Text
tone String?
language String @default("tr")
logline String? @db.Text
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
seoTags String[]
thumbnailIdeas String[]
// Analysis Results (stored as JSON)
neuroAnalysis Json?
youtubeAudit Json?
postProduction Json?
commercialBrief Json?
// Timestamps & Soft Delete
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
// Relations
user User? @relation(fields: [userId], references: [id])
segments ScriptSegment[]
sources ResearchSource[]
characters CharacterProfile[]
briefItems BriefItem[]
visualAssets VisualAsset[]
versions ScriptVersion[]
@@index([userId])
@@index([topic])
@@index([status])
}
model ScriptSegment {
id String @id @default(uuid())
projectId String
segmentType String // Hook, Intro, Body, Ad/Sponsor, CTA, Outro, Scene, Dialogue, Section, Headline
timeStart String
duration String
visualDescription String? @db.Text
narratorScript String? @db.Text
editorNotes String? @db.Text
generalNotes String? @db.Text
audioCues String?
onScreenText String?
stockQuery String?
videoPrompt String? @db.Text
imagePrompt String? @db.Text
citationIndexes Int[]
generatedImageUrl String?
sortOrder Int @default(0)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project ScriptProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
@@index([sortOrder])
}
model ResearchSource {
id String @id @default(uuid())
projectId String
title String
url String
snippet String? @db.Text
type String // article, video, interview, academic, book, document
selected Boolean @default(true)
isNew Boolean @default(false)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project ScriptProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
}
model CharacterProfile {
id String @id @default(uuid())
projectId String
name String
role String // Protagonist, Antagonist, Guide/Mentor, Sidekick, Narrator
values String? @db.Text
traits String? @db.Text
mannerisms String? @db.Text
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project ScriptProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
}
model BriefItem {
id String @id @default(uuid())
projectId String
question String @db.Text
answer String @db.Text
sortOrder Int @default(0)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project ScriptProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
}
model VisualAsset {
id String @id @default(uuid())
projectId String
url String
desc String?
selected Boolean @default(true)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project ScriptProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@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])
}
-32
View File
@@ -1,32 +0,0 @@
upstream skriptai_backend {
server 127.0.0.1:3000;
}
upstream skriptai_frontend {
server 127.0.0.1:3001;
}
server {
listen 80;
server_name skript.bilgich.com 192.168.1.199;
# Frontend (Root)
location / {
proxy_pass http://skriptai_frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Backend API
location /api {
proxy_pass http://skriptai_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-32
View File
@@ -1,32 +0,0 @@
upstream skriptai_backend {
server 127.0.0.1:3000;
}
upstream skriptai_frontend {
server 127.0.0.1:3001;
}
server {
listen 80 default_server;
server_name _;
# Frontend
location / {
proxy_pass http://skriptai_frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Backend API
location /api {
proxy_pass http://skriptai_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-24
View File
@@ -1,24 +0,0 @@
upstream skriptai_backend {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name skript.bilgich.com 192.168.1.199;
root /var/www/skriptai;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://skriptai_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-20
View File
@@ -3,7 +3,6 @@ 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 {
@@ -23,7 +22,6 @@ import {
i18nConfig,
featuresConfig,
throttleConfig,
storageConfig,
} from './config/configuration';
import { geminiConfig } from './modules/gemini/gemini.config';
import { validateEnv } from './config/env.validation';
@@ -41,8 +39,6 @@ import { UsersModule } from './modules/users/users.module';
import { AdminModule } from './modules/admin/admin.module';
import { HealthModule } from './modules/health/health.module';
import { GeminiModule } from './modules/gemini/gemini.module';
import { SkriptaiModule } from './modules/skriptai/skriptai.module';
import { StorageModule } from './modules/storage/storage.module';
// Guards
import {
@@ -66,23 +62,9 @@ import {
featuresConfig,
throttleConfig,
geminiConfig,
storageConfig,
],
}),
// 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],
@@ -177,8 +159,6 @@ import {
// Optional Modules (controlled by env variables)
GeminiModule,
SkriptaiModule,
StorageModule,
HealthModule,
],
providers: [
@@ -80,7 +80,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
});
// Only update if translation exists (key is different from result)
if (translatedMessage !== `errors.${message}`) {
message = translatedMessage;
message = translatedMessage as string;
}
}
} catch {
-96
View File
@@ -1,96 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
/**
* Pagination & Search Helpers
*
* Standardized pagination support and full-text search for projects.
*
* TR: Sayfalama ve tam metin arama yardımcıları.
*/
export interface PaginationParams {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
export interface SearchParams extends PaginationParams {
query?: string;
status?: string;
contentType?: string;
}
/**
* Build standard pagination options for Prisma
*/
export function buildPaginationOptions(params: PaginationParams) {
const page = Math.max(1, params.page || 1);
const limit = Math.min(100, Math.max(1, params.limit || 20));
const skip = (page - 1) * limit;
const orderBy: Record<string, 'asc' | 'desc'> = {};
if (params.sortBy) {
orderBy[params.sortBy] = params.sortOrder || 'desc';
} else {
orderBy['updatedAt'] = 'desc';
}
return { skip, take: limit, orderBy, page, limit };
}
/**
* Build paginated result from data and total count
*/
export function buildPaginatedResult<T>(
data: T[],
total: number,
page: number,
limit: number,
): PaginatedResult<T> {
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
};
}
/**
* Build PostgreSQL full-text search condition
*
* Uses Prisma's contains with mode: 'insensitive' for compatibility.
* For production, consider PostgreSQL tsvector for true FTS.
*/
export function buildSearchCondition(query?: string) {
if (!query || query.trim().length === 0) return {};
const search = query.trim();
return {
OR: [
{ topic: { contains: search, mode: 'insensitive' as const } },
{ logline: { contains: search, mode: 'insensitive' as const } },
{ seoTitle: { contains: search, mode: 'insensitive' as const } },
{ seoDescription: { contains: search, mode: 'insensitive' as const } },
],
};
}
@@ -1,99 +0,0 @@
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
/**
* Correlation ID Middleware
*
* Assigns a unique correlation ID to every incoming request.
* The ID is:
* 1. Read from `x-correlation-id` header (if provided by client/gateway)
* 2. Or auto-generated as a UUID
* 3. Set on the response header
* 4. Attached to the request object for downstream logging
*
* TR: Her isteğe benzersiz korelasyon ID'si atar.
* Loglarda istekleri takip etmek için kullanılır.
*/
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
private readonly logger = new Logger(CorrelationIdMiddleware.name);
use(req: Request, res: Response, next: NextFunction) {
const correlationId =
(req.headers['x-correlation-id'] as string) || randomUUID();
// Attach to request for downstream use
(req as any).correlationId = correlationId;
// Set on response header
res.setHeader('x-correlation-id', correlationId);
next();
}
}
/**
* AI Metrics Logger
*
* Structured logging helper for AI operations.
* Logs:
* - Operation type (generateJSON, generateText, etc.)
* - Model used
* - Token usage (input/output)
* - Duration
* - Success/failure
* - Correlation ID
*
* TR: AI işlemleri için yapılandırılmış log kaydı.
*/
export interface AIMetrics {
operation: string;
model: string;
inputTokens?: number;
outputTokens?: number;
durationMs: number;
success: boolean;
error?: string;
projectId?: string;
correlationId?: string;
}
export function logAIMetrics(logger: Logger, metrics: AIMetrics): void {
const { operation, model, inputTokens, outputTokens, durationMs, success } =
metrics;
const tokenInfo =
inputTokens !== undefined
? ` | tokens: ${inputTokens}${outputTokens || '?'}`
: '';
const status = success ? '✅' : '❌';
logger.log(
`${status} AI ${operation} | model: ${model} | ${durationMs}ms${tokenInfo}${metrics.projectId ? ` | project: ${metrics.projectId}` : ''}${metrics.correlationId ? ` | cid: ${metrics.correlationId}` : ''}`,
);
if (!success && metrics.error) {
logger.error(`AI ${operation} error: ${metrics.error}`);
}
}
/**
* Log levels used across the application
*
* - DEBUG: Development details, verbose data
* - INFO: Normal operations, startup, connections
* - WARN: Recoverable issues, fallbacks, deprecations
* - ERROR: Failures that need attention
* - FATAL: Critical failures, shutdown required
*/
export const LOG_LEVELS = {
AI_CALL: 'info',
CACHE_HIT: 'debug',
CACHE_MISS: 'debug',
QUEUE_JOB: 'info',
WEBSOCKET_EVENT: 'debug',
STORAGE_UPLOAD: 'info',
AUTH_EVENT: 'info',
} as const;
@@ -1,157 +0,0 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
/**
* CacheStrategyService
*
* Centralized cache management for SkriptAI with tagged invalidation.
*
* Strategies:
* - AI Response Cache: Cache expensive AI calls (keyed by prompt hash)
* - Project Data Cache: Cache project details with smart invalidation
* - Rate Limiting: Track API call counts per user
*
* TR: Merkezi cache yönetimi — AI yanıt cache, proje cache, oran sınırlama.
*/
@Injectable()
export class CacheStrategyService {
private readonly logger = new Logger(CacheStrategyService.name);
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
// ========== AI RESPONSE CACHE ==========
/**
* Cache an AI response with a prompt-based key
*
* @param promptHash - MD5 or similar hash of the prompt
* @param data - AI response data
* @param ttlMs - Time to live in ms (default: 30 min)
*/
async cacheAIResponse(
promptHash: string,
data: any,
ttlMs: number = 30 * 60 * 1000,
): Promise<void> {
const key = `ai:${promptHash}`;
try {
await this.cache.set(key, JSON.stringify(data), ttlMs);
this.logger.debug(`AI response cached: ${key}`);
} catch (error) {
this.logger.warn(`Cache set failed: ${key}`, error);
}
}
/**
* Get a cached AI response
*/
async getCachedAIResponse<T = any>(promptHash: string): Promise<T | null> {
const key = `ai:${promptHash}`;
try {
const cached = await this.cache.get<string>(key);
if (cached) {
this.logger.debug(`AI cache hit: ${key}`);
return JSON.parse(cached);
}
} catch (error) {
this.logger.warn(`Cache get failed: ${key}`, error);
}
return null;
}
// ========== PROJECT DATA CACHE ==========
/**
* Cache project data
*/
async cacheProject(
projectId: string,
data: any,
ttlMs: number = 5 * 60 * 1000,
): Promise<void> {
const key = `project:${projectId}`;
try {
await this.cache.set(key, JSON.stringify(data), ttlMs);
} catch {
/* silent */
}
}
/**
* Get cached project data
*/
async getCachedProject<T = any>(projectId: string): Promise<T | null> {
const key = `project:${projectId}`;
try {
const cached = await this.cache.get<string>(key);
return cached ? JSON.parse(cached) : null;
} catch {
return null;
}
}
/**
* Invalidate project cache (call after any project mutation)
*/
async invalidateProject(projectId: string): Promise<void> {
try {
await this.cache.del(`project:${projectId}`);
this.logger.debug(`Project cache invalidated: ${projectId}`);
} catch {
/* silent */
}
}
// ========== RATE LIMITING ==========
/**
* Check and increment rate limit counter
*
* @param userId - User identifier
* @param action - Action name (e.g., 'ai-call')
* @param maxPerWindow - Max calls per window
* @param windowMs - Window duration in ms (default: 1 min)
* @returns { allowed, remaining, resetIn }
*/
async checkRateLimit(
userId: string,
action: string,
maxPerWindow: number = 10,
windowMs: number = 60 * 1000,
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
const key = `rate:${userId}:${action}`;
try {
const current = await this.cache.get<string>(key);
const count = current ? parseInt(current, 10) : 0;
if (count >= maxPerWindow) {
return { allowed: false, remaining: 0, resetIn: windowMs };
}
await this.cache.set(key, String(count + 1), windowMs);
return {
allowed: true,
remaining: maxPerWindow - count - 1,
resetIn: windowMs,
};
} catch {
return { allowed: true, remaining: maxPerWindow, resetIn: windowMs };
}
}
// ========== UTILITY ==========
/**
* Generate a simple hash from prompt text (deterministic)
*/
hashPrompt(prompt: string): string {
let hash = 0;
for (let i = 0; i < prompt.length; i++) {
const char = prompt.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
}
-9
View File
@@ -55,12 +55,3 @@ export const throttleConfig = registerAs('throttle', () => ({
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
}));
export const storageConfig = registerAs('storage', () => ({
enabled: process.env.STORAGE_ENABLED === 'true',
endpoint: process.env.STORAGE_ENDPOINT || 'http://192.168.1.199:9000',
accessKey: process.env.STORAGE_ACCESS_KEY || 'minioadmin',
secretKey: process.env.STORAGE_SECRET_KEY || 'minioadmin',
bucket: process.env.STORAGE_BUCKET || 'skriptai-assets',
publicUrl: process.env.STORAGE_PUBLIC_URL || 'http://192.168.1.199:9000',
}));
+1 -1
View File
@@ -24,7 +24,7 @@ export const envSchema = z.object({
// JWT
JWT_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRATION: z.string().default('24h'),
JWT_ACCESS_EXPIRATION: z.string().default('15m'),
JWT_REFRESH_EXPIRATION: z.string().default('7d'),
// Redis
-67
View File
@@ -1,67 +0,0 @@
/**
* Supported Languages Configuration
*
* Faz 5.1 — Çoklu dil genişletme altyapısı.
* Yeni diller eklemek için bu dosyaya ekleme yapın.
*
* TR: Desteklenen diller ve RTL yapılandırması.
*/
export interface LanguageConfig {
code: string;
name: string;
nativeName: string;
flag: string;
rtl: boolean;
enabled: boolean;
}
export const SUPPORTED_LANGUAGES: LanguageConfig[] = [
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷', rtl: false, enabled: true },
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧', rtl: false, enabled: true },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', rtl: true, enabled: false },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', rtl: false, enabled: false },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', rtl: false, enabled: false },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', rtl: false, enabled: false },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵', rtl: false, enabled: false },
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷', rtl: false, enabled: false },
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', rtl: false, enabled: false },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷', rtl: false, enabled: false },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', rtl: false, enabled: false },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', rtl: false, enabled: false },
];
/**
* Get only enabled languages
*/
export function getEnabledLanguages(): LanguageConfig[] {
return SUPPORTED_LANGUAGES.filter((l) => l.enabled);
}
/**
* Check if language is RTL
*/
export function isRTL(code: string): boolean {
return SUPPORTED_LANGUAGES.find((l) => l.code === code)?.rtl ?? false;
}
/**
* Get language config by code
*/
export function getLanguageConfig(code: string): LanguageConfig | undefined {
return SUPPORTED_LANGUAGES.find((l) => l.code === code);
}
/**
* AI Prompt language instruction map
* Used to instruct the AI about output language characteristics
*/
export const LANGUAGE_INSTRUCTIONS: Record<string, string> = {
tr: 'Doğal, akıcı Türkçe kullan. Argo ve günlük dil kullanımına dikkat et.',
en: 'Use natural, fluent English. Match the requested tone and style.',
ar: 'استخدم اللغة العربية الفصحى الحديثة مع مراعاة الأسلوب المطلوب',
es: 'Utiliza español natural y fluido. Adapta el tono según lo solicitado.',
de: 'Verwende natürliches, flüssiges Deutsch. Passe den Ton an den gewünschten Stil an.',
fr: 'Utilise un français naturel et fluide. Adapte le ton au style demandé.',
ja: '自然で流暢な日本語を使用してください。要求されたトーンとスタイルに合わせてください。',
};
-18
View File
@@ -1,18 +0,0 @@
{
"PROJECT_CREATED": "Project created successfully",
"PROJECT_UPDATED": "Project updated",
"PROJECT_DELETED": "Project deleted",
"PROJECT_DUPLICATED": "Project duplicated",
"SCRIPT_GENERATED": "Script generated successfully",
"SCRIPT_REWRITTEN": "Segment rewritten",
"RESEARCH_COMPLETE": "Research completed",
"SOURCES_ADDED": "Sources added",
"BRIEF_UPDATED": "Brief updated",
"CHARACTERS_GENERATED": "Characters generated",
"LOGLINE_GENERATED": "Logline and high concept generated",
"NEURO_ANALYSIS_COMPLETE": "Neuro marketing analysis completed",
"YOUTUBE_AUDIT_COMPLETE": "YouTube audit completed",
"COMMERCIAL_BRIEF_READY": "Commercial brief ready",
"EXPORT_READY": "Export ready",
"VISUAL_ASSETS_GENERATED": "Visual assets generated"
}
-18
View File
@@ -1,18 +0,0 @@
{
"PROJECT_CREATED": "Proje başarıyla oluşturuldu",
"PROJECT_UPDATED": "Proje güncellendi",
"PROJECT_DELETED": "Proje silindi",
"PROJECT_DUPLICATED": "Proje kopyalandı",
"SCRIPT_GENERATED": "Script başarıyla oluşturuldu",
"SCRIPT_REWRITTEN": "Segment yeniden yazıldı",
"RESEARCH_COMPLETE": "Araştırma tamamlandı",
"SOURCES_ADDED": "Kaynaklar eklendi",
"BRIEF_UPDATED": "Brief güncellendi",
"CHARACTERS_GENERATED": "Karakterler oluşturuldu",
"LOGLINE_GENERATED": "Logline ve high concept oluşturuldu",
"NEURO_ANALYSIS_COMPLETE": "Nöro pazarlama analizi tamamlandı",
"YOUTUBE_AUDIT_COMPLETE": "YouTube denetimi tamamlandı",
"COMMERCIAL_BRIEF_READY": "Ticari brief hazır",
"EXPORT_READY": "Dışa aktarım hazır",
"VISUAL_ASSETS_GENERATED": "Görsel varlıklar oluşturuldu"
}
+4 -1
View File
@@ -18,7 +18,10 @@ async function bootstrap() {
app.useGlobalInterceptors(new LoggerErrorInterceptor());
// Security Headers
app.use(helmet());
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
}));
// Graceful Shutdown (Prisma & Docker)
app.enableShutdownHooks();
@@ -1,292 +0,0 @@
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;
}
}
+2 -9
View File
@@ -1,8 +1,6 @@
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';
/**
@@ -10,16 +8,11 @@ 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, ContextManagerService, MapReduceService],
exports: [GeminiService, ContextManagerService, MapReduceService],
providers: [GeminiService],
exports: [GeminiService],
})
export class GeminiModule {}
+41 -456
View File
@@ -1,18 +1,12 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenAI } from '@google/genai';
import { ZodSchema, ZodError } from 'zod';
// ============================================
// Types & Interfaces
// ============================================
export interface GeminiGenerateOptions {
model?: string;
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
tools?: any[];
}
export interface GeminiChatMessage {
@@ -20,72 +14,30 @@ export interface GeminiChatMessage {
content: string;
}
export interface GeminiJSONOptions<T = any> extends GeminiGenerateOptions {
/** Zod schema for runtime validation of the AI response */
zodSchema?: ZodSchema<T>;
/** Max retry attempts for JSON generation (default: 3) */
maxRetries?: number;
}
/**
* Error types for Gemini API failures
*/
export enum GeminiErrorType {
RATE_LIMIT = 'RATE_LIMIT',
QUOTA_EXCEEDED = 'QUOTA_EXCEEDED',
SAFETY_BLOCKED = 'SAFETY_BLOCKED',
INVALID_RESPONSE = 'INVALID_RESPONSE',
JSON_PARSE_FAILED = 'JSON_PARSE_FAILED',
TIMEOUT = 'TIMEOUT',
UNAVAILABLE = 'UNAVAILABLE',
UNKNOWN = 'UNKNOWN',
}
/**
* Custom exception for Gemini AI errors with rich context
*/
export class GeminiException extends Error {
constructor(
message: string,
public readonly type: GeminiErrorType,
public readonly originalError?: any,
public readonly retryable: boolean = false,
) {
super(message);
this.name = 'GeminiException';
}
}
// ============================================
// Service
// ============================================
/**
* Gemini AI Service — Enhanced with Retry, JSON Recovery & Validation
* Gemini AI Service
*
* Provides AI-powered text/JSON/image generation using Google Gemini API.
* Provides AI-powered text generation using Google Gemini API.
* This service is globally available when ENABLE_GEMINI=true.
*
* Key improvements over v1:
* - responseMimeType: "application/json" for native JSON output
* - Exponential backoff retry (up to 3 attempts)
* - Multi-strategy JSON extraction & recovery
* - Optional Zod schema validation
* - Typed GeminiException with error classification
* - AI usage metrics logging
*
* @example
* ```typescript
* // Simple text generation
* const response = await geminiService.generateText('Write a poem about coding');
*
* // JSON generation with Zod validation
* import { z } from 'zod';
* const schema = z.object({ title: z.string(), score: z.number() });
* const result = await geminiService.generateJSON(
* 'Analyze this script', '{ title, score }',
* { zodSchema: schema }
* );
* // With options
* const response = await geminiService.generateText('Translate to Turkish', {
* temperature: 0.7,
* systemPrompt: 'You are a professional translator',
* });
*
* // Chat conversation
* const messages = [
* { role: 'user', content: 'Hello!' },
* { role: 'model', content: 'Hi there!' },
* { role: 'user', content: 'What is 2+2?' },
* ];
* const response = await geminiService.chat(messages);
* ```
*/
@Injectable()
@@ -134,10 +86,6 @@ export class GeminiService implements OnModuleInit {
return this.isEnabled && this.client !== null;
}
// ============================================
// Text Generation
// ============================================
/**
* Generate text content from a prompt
*
@@ -149,10 +97,11 @@ export class GeminiService implements OnModuleInit {
prompt: string,
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
this.ensureAvailable();
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const model = options.model || this.defaultModel;
const startTime = Date.now();
try {
const contents: any[] = [];
@@ -180,31 +129,19 @@ export class GeminiService implements OnModuleInit {
config: {
temperature: options.temperature,
maxOutputTokens: options.maxTokens,
tools: options.tools,
},
});
const durationMs = Date.now() - startTime;
this.logUsage('generateText', model, response.usageMetadata, durationMs);
return {
text: (response.text || '').trim(),
usage: response.usageMetadata,
};
} catch (error) {
const durationMs = Date.now() - startTime;
this.logger.error(
`Gemini generation failed after ${durationMs}ms`,
error,
);
throw this.classifyError(error);
this.logger.error('Gemini generation failed', error);
throw error;
}
}
// ============================================
// Chat
// ============================================
/**
* Have a multi-turn chat conversation
*
@@ -216,10 +153,11 @@ export class GeminiService implements OnModuleInit {
messages: GeminiChatMessage[],
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
this.ensureAvailable();
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const model = options.model || this.defaultModel;
const startTime = Date.now();
try {
const contents = messages.map((msg) => ({
@@ -250,406 +188,53 @@ export class GeminiService implements OnModuleInit {
},
});
const durationMs = Date.now() - startTime;
this.logUsage('chat', model, response.usageMetadata, durationMs);
return {
text: (response.text || '').trim(),
usage: response.usageMetadata,
};
} catch (error) {
this.logger.error('Gemini chat failed', error);
throw this.classifyError(error);
throw error;
}
}
// ============================================
// JSON Generation (Enhanced)
// ============================================
/**
* Generate structured JSON output with retry, recovery, and optional Zod validation.
*
* Strategy:
* 1. First attempt uses `responseMimeType: "application/json"` for native JSON
* 2. If that fails, falls back to prompt-based JSON with multi-strategy extraction
* 3. Up to `maxRetries` attempts with exponential backoff
* 4. Optional Zod schema validation on the parsed result
* Generate structured JSON output
*
* @param prompt - The prompt describing what JSON to generate
* @param schema - JSON schema description for the expected output (human readable)
* @param options - Optional configuration including zodSchema and maxRetries
* @returns Parsed and optionally validated JSON object
* @param schema - JSON schema description for the expected output
* @param options - Optional configuration for the generation
* @returns Parsed JSON object
*/
async generateJSON<T = any>(
prompt: string,
schema: string,
options: GeminiJSONOptions<T> = {},
options: GeminiGenerateOptions = {},
): Promise<{ data: T; usage?: any }> {
this.ensureAvailable();
const maxRetries = options.maxRetries ?? 3;
const model = options.model || this.defaultModel;
let lastError: Error | null = null;
let lastUsage: any = undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const startTime = Date.now();
try {
// Build the full prompt
const fullPrompt = `${prompt}
const fullPrompt = `${prompt}
Output the result as valid JSON that matches this schema:
${schema}
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
const contents: any[] = [];
if (options.systemPrompt) {
contents.push({
role: 'user',
parts: [{ text: options.systemPrompt }],
});
contents.push({
role: 'model',
parts: [
{ text: 'Understood. I will follow these instructions.' },
],
});
}
contents.push({
role: 'user',
parts: [{ text: fullPrompt }],
});
// Configure responseMimeType for native JSON (only when no tools — tools don't support it)
const config: any = {
temperature: options.temperature,
maxOutputTokens: options.maxTokens,
};
if (!options.tools || options.tools.length === 0) {
config.responseMimeType = 'application/json';
}
if (options.tools) {
config.tools = options.tools;
}
const response = await this.client!.models.generateContent({
model,
contents,
config,
});
const durationMs = Date.now() - startTime;
lastUsage = response.usageMetadata;
this.logUsage(
`generateJSON (attempt ${attempt}/${maxRetries})`,
model,
response.usageMetadata,
durationMs,
);
const rawText = (response.text || '').trim();
// Try to extract and parse JSON
const jsonStr = this.extractJSON(rawText);
const data = JSON.parse(jsonStr) as T;
// Validate with Zod schema if provided
if (options.zodSchema) {
const validated = options.zodSchema.parse(data);
return { data: validated as T, usage: lastUsage };
}
return { data, usage: lastUsage };
} catch (error) {
lastError = error as Error;
const isParseError =
error instanceof SyntaxError ||
(error instanceof ZodError) ||
(error instanceof Error &&
error.message.includes('Failed to extract JSON'));
const isRetryable = isParseError || this.isRetryableError(error);
if (isRetryable && attempt < maxRetries) {
const backoffMs = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
this.logger.warn(
`JSON generation attempt ${attempt}/${maxRetries} failed (${error instanceof Error ? error.message : 'unknown'}). Retrying in ${backoffMs}ms...`,
);
await this.sleep(backoffMs);
continue;
}
// Log failure details
if (error instanceof ZodError) {
this.logger.error(
`Zod validation failed after ${attempt} attempts: ${error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
);
}
}
}
// All retries exhausted
throw new GeminiException(
`Failed to generate valid JSON after ${maxRetries} attempts: ${lastError?.message}`,
GeminiErrorType.JSON_PARSE_FAILED,
lastError,
false,
);
}
// ============================================
// Image Generation
// ============================================
/**
* Generate an image using Google Imagen (Nano Banana)
*
* @param prompt - Image description
* @returns Base64 encoded image data URI
*/
async generateImage(prompt: string): Promise<string> {
this.ensureAvailable();
const startTime = Date.now();
const response = await this.generateText(fullPrompt, options);
try {
// Use Imagen 3.0 (Nano Banana Pro)
const model = 'imagen-3.0-generate-001';
// Try to extract JSON from the response
let jsonStr = response.text;
const response = (await this.client!.models.generateImages({
model,
prompt,
config: {
numberOfImages: 1,
aspectRatio: '16:9',
},
})) as any;
const durationMs = Date.now() - startTime;
this.logger.log(
`Image generated in ${durationMs}ms (model: ${model})`,
);
if (
response.images &&
response.images.length > 0 &&
response.images[0].image
) {
// Return as Data URI
return `data:image/png;base64,${response.images[0].image}`;
// Remove potential markdown code blocks
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
throw new GeminiException(
'No image returned from Gemini',
GeminiErrorType.INVALID_RESPONSE,
);
const data = JSON.parse(jsonStr) as T;
return { data, usage: response.usage };
} catch (error) {
if (error instanceof GeminiException) throw error;
this.logger.error('Gemini image generation failed', error);
throw this.classifyError(error);
this.logger.error('Failed to parse JSON response', error);
throw new Error('Failed to parse AI response as JSON');
}
}
// ============================================
// Private Helpers
// ============================================
/**
* Ensure Gemini client is available, throw typed exception if not
*/
private ensureAvailable(): void {
if (!this.isAvailable()) {
throw new GeminiException(
'Gemini AI is not available. Check your configuration.',
GeminiErrorType.UNAVAILABLE,
);
}
}
/**
* Extract JSON from a raw AI response using multiple strategies:
* 1. Direct parse (cleanest case)
* 2. Strip markdown code blocks
* 3. Find first { or [ and match to closing bracket
* 4. Remove trailing commas and retry
*/
private extractJSON(raw: string): string {
// Strategy 1: Direct parse attempt
try {
JSON.parse(raw);
return raw;
} catch {
// Continue to next strategy
}
// Strategy 2: Strip markdown code blocks (```json ... ``` or ``` ... ```)
const codeBlockMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
const extracted = codeBlockMatch[1].trim();
try {
JSON.parse(extracted);
return extracted;
} catch {
// Continue with the extracted content for further cleaning
raw = extracted;
}
}
// Strategy 3: Find the first { or [ and match to the last } or ]
const objectStart = raw.indexOf('{');
const arrayStart = raw.indexOf('[');
let start = -1;
let endChar = '';
if (objectStart >= 0 && (arrayStart < 0 || objectStart < arrayStart)) {
start = objectStart;
endChar = '}';
} else if (arrayStart >= 0) {
start = arrayStart;
endChar = ']';
}
if (start >= 0) {
const end = raw.lastIndexOf(endChar);
if (end > start) {
const candidate = raw.substring(start, end + 1);
try {
JSON.parse(candidate);
return candidate;
} catch {
// Strategy 4: Remove trailing commas and retry
const cleaned = candidate
.replace(/,\s*([\]}])/g, '$1') // Remove trailing commas
.replace(/'/g, '"') // Replace single quotes with double quotes
.replace(/(\w+)\s*:/g, '"$1":') // Quote unquoted keys
.replace(/""(\w+)""/g, '"$1"'); // Fix double-quoted keys
try {
JSON.parse(cleaned);
return cleaned;
} catch {
// Last resort: return the candidate anyway, caller will handle error
}
}
}
}
throw new Error(
`Failed to extract JSON from AI response (length: ${raw.length})`,
);
}
/**
* Classify an error into a typed GeminiException
*/
private classifyError(error: any): GeminiException {
if (error instanceof GeminiException) return error;
const message = error?.message || String(error);
const status = error?.status || error?.statusCode;
// Rate limiting
if (status === 429 || message.includes('429') || message.includes('RATE_LIMIT') || message.includes('rate limit')) {
return new GeminiException(
'Gemini API rate limit exceeded. Please wait before retrying.',
GeminiErrorType.RATE_LIMIT,
error,
true,
);
}
// Quota
if (message.includes('QUOTA') || message.includes('quota') || status === 403) {
return new GeminiException(
'Gemini API quota exceeded.',
GeminiErrorType.QUOTA_EXCEEDED,
error,
false,
);
}
// Safety
if (message.includes('SAFETY') || message.includes('safety') || message.includes('blocked')) {
return new GeminiException(
'Content was blocked by safety filters. Try rephrasing the prompt.',
GeminiErrorType.SAFETY_BLOCKED,
error,
false,
);
}
// Timeout
if (message.includes('TIMEOUT') || message.includes('timeout') || message.includes('DEADLINE_EXCEEDED')) {
return new GeminiException(
'Gemini API request timed out.',
GeminiErrorType.TIMEOUT,
error,
true,
);
}
// Generic
return new GeminiException(
`Gemini API error: ${message}`,
GeminiErrorType.UNKNOWN,
error,
true,
);
}
/**
* Check if an error is retryable
*/
private isRetryableError(error: any): boolean {
if (error instanceof GeminiException) return error.retryable;
const message = error?.message || '';
return (
message.includes('429') ||
message.includes('RATE_LIMIT') ||
message.includes('TIMEOUT') ||
message.includes('DEADLINE_EXCEEDED') ||
message.includes('UNAVAILABLE') ||
message.includes('INTERNAL')
);
}
/**
* Log AI usage metrics for monitoring
*/
private logUsage(
operation: string,
model: string,
usage: any,
durationMs: number,
): void {
if (usage) {
this.logger.log(
`AI Usage [${operation}] model=${model} ` +
`prompt=${usage.promptTokenCount || '?'} ` +
`completion=${usage.candidatesTokenCount || '?'} ` +
`total=${usage.totalTokenCount || '?'} ` +
`duration=${durationMs}ms`,
);
} else {
this.logger.log(
`AI Usage [${operation}] model=${model} duration=${durationMs}ms`,
);
}
}
/**
* Sleep helper for retry backoff
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
-169
View File
@@ -1,169 +0,0 @@
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<T = any>(
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<any>(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<T>(
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;
}
}
-189
View File
@@ -1,189 +0,0 @@
/**
* 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<TaskCategory, { flash: string; pro: string }> = {
// 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;
}
-152
View File
@@ -1,152 +0,0 @@
/**
* 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,
};
}
@@ -1,75 +0,0 @@
import {
Controller,
Post,
Body,
Param,
UseGuards,
Query,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AnalysisService } from '../services';
import { JwtAuthGuard } from '../../auth/guards';
/**
* AnalysisController
*
* REST API controller for content analysis endpoints.
*
* TR: İçerik analizi endpoint'leri için REST API controller.
* EN: REST API controller for content analysis endpoints.
*/
@ApiTags('SkriptAI - Analysis')
@Controller('skriptai/analysis')
export class AnalysisController {
constructor(private readonly analysisService: AnalysisService) {}
@Post(':projectId/neuro')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Perform Neuro Marketing Analysis' })
@ApiResponse({ status: 201, description: 'Analysis completed' })
async analyzeNeuroMarketing(@Param('projectId') projectId: string) {
return this.analysisService.analyzeNeuroMarketing(projectId);
}
@Post(':projectId/youtube-audit')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Perform YouTube Algorithm Audit' })
@ApiResponse({ status: 201, description: 'Audit completed' })
async performYoutubeAudit(@Param('projectId') projectId: string) {
return this.analysisService.performYoutubeAudit(projectId);
}
@Post(':projectId/commercial-brief')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate Commercial Brief (Sponsorship Analysis)' })
@ApiResponse({ status: 201, description: 'Brief generated' })
async generateCommercialBrief(@Param('projectId') projectId: string) {
return this.analysisService.generateCommercialBrief(projectId);
}
@Post(':projectId/visual-assets')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate visual assets for project' })
async generateVisualAssets(
@Param('projectId') projectId: string,
@Query('count') count: number = 5,
) {
return this.analysisService.generateVisualAssets(projectId, count);
}
@Post('thumbnail')
@ApiOperation({ summary: 'Generate a thumbnail image from prompt' })
async generateThumbnail(@Body() body: { prompt: string }) {
const url = await this.analysisService.generateThumbnailImage(body.prompt);
return { url };
}
}
@@ -1,6 +0,0 @@
export * from './projects.controller';
export * from './scripts.controller';
export * from './research.controller';
export * from './analysis.controller';
export * from './versions.controller';
export * from './jobs.controller';
@@ -1,208 +0,0 @@
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<string, any>;
},
) {
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;
}
}
}
@@ -1,87 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { ProjectsService } from '../services';
import { CreateProjectDto, UpdateProjectDto } from '../dto';
import { JwtAuthGuard } from '../../auth/guards';
import { CurrentUser } from '../../../common/decorators';
/**
* ProjectsController
*
* REST API controller for script project management.
*
* TR: Script projesi yönetimi için REST API controller.
* EN: REST API controller for script project management.
*/
@ApiTags('SkriptAI - Projects')
@Controller('skriptai/projects')
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new script project' })
@ApiResponse({ status: 201, description: 'Project created successfully' })
async create(@Body() createDto: CreateProjectDto, @CurrentUser() user: any) {
return this.projectsService.create(createDto, user?.id);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all projects for current user' })
async findAll(@CurrentUser() user: any) {
return this.projectsService.findAll(user?.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a project by ID' })
async findOne(@Param('id') id: string) {
return this.projectsService.findOne(id);
}
@Put(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a project' })
async update(@Param('id') id: string, @Body() updateDto: UpdateProjectDto) {
return this.projectsService.update(id, updateDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Soft delete a project' })
async remove(@Param('id') id: string) {
return this.projectsService.remove(id);
}
@Post(':id/duplicate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Duplicate a project with all content' })
async duplicate(@Param('id') id: string, @CurrentUser() user: any) {
return this.projectsService.duplicate(id, user?.id);
}
@Get(':id/export')
@ApiOperation({ summary: 'Export project to JSON format' })
async exportToJson(@Param('id') id: string) {
return this.projectsService.exportToJson(id);
}
}
@@ -1,166 +0,0 @@
import {
Controller,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { ResearchService } from '../services';
import {
CreateSourceDto,
CreateBriefItemDto,
CreateCharacterDto,
PerformResearchDto,
GenerateDiscoveryQuestionsDto,
GenerateLoglineDto,
GenerateCharactersDto,
} from '../dto';
import { JwtAuthGuard } from '../../auth/guards';
/**
* ResearchController
*
* REST API controller for research sources, creative brief, and characters.
*
* TR: Araştırma kaynakları, yaratıcı brief ve karakterler için REST API controller.
* EN: REST API controller for research sources, creative brief, and characters.
*/
@ApiTags('SkriptAI - Research')
@Controller('skriptai/research')
export class ResearchController {
constructor(private readonly researchService: ResearchService) {}
// ========== SOURCES ==========
@Post('sources')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Add a research source' })
async addSource(@Body() createDto: CreateSourceDto) {
return this.researchService.addSource(createDto);
}
@Put('sources/:id/toggle')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Toggle source selection' })
async toggleSource(@Param('id') id: string) {
return this.researchService.toggleSourceSelection(id);
}
@Delete('sources/:id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a research source' })
async deleteSource(@Param('id') id: string) {
return this.researchService.deleteSource(id);
}
@Post('deep-research')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Perform deep research using AI' })
@ApiResponse({ status: 201, description: 'Research completed' })
async performDeepResearch(@Body() researchDto: PerformResearchDto) {
return this.researchService.performDeepResearch(
researchDto.projectId,
researchDto.additionalQuery,
);
}
// ========== BRIEF ITEMS ==========
@Post('brief-items')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Add a brief item (Q&A)' })
async addBriefItem(@Body() createDto: CreateBriefItemDto) {
return this.researchService.addBriefItem(createDto);
}
@Put('brief-items/:id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a brief item answer' })
async updateBriefItem(
@Param('id') id: string,
@Body() body: { answer: string },
) {
return this.researchService.updateBriefItem(id, body.answer);
}
@Delete('brief-items/:id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a brief item' })
async deleteBriefItem(@Param('id') id: string) {
return this.researchService.deleteBriefItem(id);
}
@Post('discovery-questions')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate discovery questions using AI' })
async generateDiscoveryQuestions(@Body() dto: GenerateDiscoveryQuestionsDto) {
return this.researchService.generateDiscoveryQuestions(
dto.topic,
dto.language,
dto.existingQuestions,
);
}
// ========== CHARACTERS ==========
@Post('characters')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Add a character profile' })
async addCharacter(@Body() createDto: CreateCharacterDto) {
return this.researchService.addCharacter(createDto);
}
@Put('characters/:id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a character profile' })
async updateCharacter(
@Param('id') id: string,
@Body() updateDto: Partial<CreateCharacterDto>,
) {
return this.researchService.updateCharacter(id, updateDto);
}
@Delete('characters/:id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a character profile' })
async deleteCharacter(@Param('id') id: string) {
return this.researchService.deleteCharacter(id);
}
@Post('characters/generate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Auto-generate character profiles using AI' })
async generateCharacters(@Body() dto: GenerateCharactersDto) {
return this.researchService.generateCharacters(dto.projectId);
}
// ========== LOGLINE ==========
@Post('logline')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate logline and high concept' })
async generateLogline(@Body() dto: GenerateLoglineDto) {
return this.researchService.generateLogline(dto.projectId);
}
}
@@ -1,164 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { ScriptsService } from '../services';
import {
CreateSegmentDto,
UpdateSegmentDto,
RewriteSegmentDto,
GenerateScriptDto,
} from '../dto';
import { JwtAuthGuard } from '../../auth/guards';
/**
* ScriptsController
*
* REST API controller for script segments and AI generation.
*
* TR: Script segmentleri ve AI üretimi için REST API controller.
* EN: REST API controller for script segments and AI generation.
*/
@ApiTags('SkriptAI - Scripts')
@Controller('skriptai/scripts')
export class ScriptsController {
constructor(private readonly scriptsService: ScriptsService) {}
@Post('segments')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new script segment' })
async createSegment(@Body() createDto: CreateSegmentDto) {
return this.scriptsService.createSegment(createDto);
}
@Put('segments/:id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a script segment' })
async updateSegment(
@Param('id') id: string,
@Body() updateDto: UpdateSegmentDto,
) {
return this.scriptsService.updateSegment(id, updateDto);
}
@Delete('segments/:id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a script segment' })
async deleteSegment(@Param('id') id: string) {
return this.scriptsService.deleteSegment(id);
}
@Post('segments/reorder')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Reorder segments in a project' })
async reorderSegments(
@Body() body: { projectId: string; segmentIds: string[] },
) {
return this.scriptsService.reorderSegments(body.projectId, body.segmentIds);
}
@Post('generate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate full script using AI' })
@ApiResponse({ status: 201, description: 'Script generated successfully' })
async generateScript(@Body() generateDto: GenerateScriptDto) {
return this.scriptsService.generateScript(generateDto.projectId);
}
@Post('rewrite')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Rewrite a segment with new style' })
async rewriteSegment(@Body() rewriteDto: RewriteSegmentDto) {
return this.scriptsService.rewriteSegment(
rewriteDto.segmentId,
rewriteDto.newStyle,
);
}
@Post('segments/:id/image')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate image for a segment' })
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,
);
}
// ========== 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,
);
}
}
@@ -1,96 +0,0 @@
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);
}
}
-3
View File
@@ -1,3 +0,0 @@
export * from './project.dto';
export * from './segment.dto';
export * from './research.dto';
-143
View File
@@ -1,143 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsArray,
IsBoolean,
ArrayMinSize,
} from 'class-validator';
// Types are defined as string unions in skriptai.types.ts
// Using string here to avoid emitDecoratorMetadata issues
/**
* CreateProjectDto
*
* DTO for creating a new script project.
*
* TR: Yeni bir script projesi oluşturmak için DTO.
* EN: DTO for creating a new script project.
*/
export class CreateProjectDto {
@ApiProperty({ description: 'Main topic of the project' })
@IsString()
topic: string;
@ApiProperty({ description: 'Content format type' })
@IsString()
contentType: string;
@ApiProperty({ description: 'Target audience list', isArray: true })
@IsArray()
@ArrayMinSize(1)
targetAudience: string[];
@ApiProperty({ description: 'Speech style list', isArray: true })
@IsArray()
@ArrayMinSize(1)
speechStyle: string[];
@ApiProperty({ description: 'Target video duration' })
@IsString()
targetDuration: string;
@ApiPropertyOptional({ description: 'Additional user notes' })
@IsOptional()
@IsString()
userNotes?: string;
@ApiPropertyOptional({ description: 'Content tone' })
@IsOptional()
@IsString()
tone?: string;
@ApiProperty({ description: 'Content language', default: 'tr' })
@IsString()
language: string = 'tr';
@ApiPropertyOptional({ description: 'Include interview segments' })
@IsOptional()
@IsBoolean()
includeInterviews?: boolean;
}
/**
* UpdateProjectDto
*
* DTO for updating an existing project.
* All fields are optional.
*
* TR: Mevcut bir projeyi güncellemek için DTO.
* EN: DTO for updating an existing project.
*/
export class UpdateProjectDto {
@ApiPropertyOptional({ description: 'Main topic of the project' })
@IsOptional()
@IsString()
topic?: string;
@ApiPropertyOptional({ description: 'Content format type' })
@IsOptional()
@IsString()
contentType?: string;
@ApiPropertyOptional({ description: 'Target audience list', isArray: true })
@IsOptional()
@IsArray()
targetAudience?: string[];
@ApiPropertyOptional({ description: 'Speech style list', isArray: true })
@IsOptional()
@IsArray()
speechStyle?: string[];
@ApiPropertyOptional({ description: 'Target video duration' })
@IsOptional()
@IsString()
targetDuration?: string;
@ApiPropertyOptional({ description: 'Additional user notes' })
@IsOptional()
@IsString()
userNotes?: string;
@ApiPropertyOptional({ description: 'Content tone' })
@IsOptional()
@IsString()
tone?: string;
@ApiPropertyOptional({ description: 'Content language' })
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({ description: 'Project logline' })
@IsOptional()
@IsString()
logline?: string;
@ApiPropertyOptional({ description: 'High concept description' })
@IsOptional()
@IsString()
highConcept?: string;
@ApiPropertyOptional({ description: 'Include interview segments' })
@IsOptional()
@IsBoolean()
includeInterviews?: boolean;
@ApiPropertyOptional({ description: 'SEO title' })
@IsOptional()
@IsString()
seoTitle?: string;
@ApiPropertyOptional({ description: 'SEO description' })
@IsOptional()
@IsString()
seoDescription?: string;
@ApiPropertyOptional({ description: 'SEO tags', isArray: true })
@IsOptional()
@IsArray()
seoTags?: string[];
}
-175
View File
@@ -1,175 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, IsArray } from 'class-validator';
// SourceType and CharacterRole are string unions - using string here for decorator compatibility
/**
* CreateSourceDto
*
* DTO for adding a research source.
*
* TR: Araştırma kaynağı eklemek için DTO.
* EN: DTO for adding a research source.
*/
export class CreateSourceDto {
@ApiProperty({ description: 'Project ID' })
@IsString()
projectId: string;
@ApiProperty({ description: 'Source title' })
@IsString()
title: string;
@ApiProperty({ description: 'Source URL' })
@IsString()
url: string;
@ApiPropertyOptional({ description: 'Source snippet/summary' })
@IsOptional()
@IsString()
snippet?: string;
@ApiProperty({ description: 'Source type' })
@IsString()
type: string; // article, video, interview, etc.
@ApiPropertyOptional({ description: 'Whether source is selected' })
@IsOptional()
@IsBoolean()
selected?: boolean;
}
/**
* CreateBriefItemDto
*
* DTO for adding a brief question/answer.
*
* TR: Brief sorusu/cevabı eklemek için DTO.
* EN: DTO for adding a brief question/answer.
*/
export class CreateBriefItemDto {
@ApiProperty({ description: 'Project ID' })
@IsString()
projectId: string;
@ApiProperty({ description: 'Question text' })
@IsString()
question: string;
@ApiProperty({ description: 'Answer text' })
@IsString()
answer: string;
@ApiPropertyOptional({ description: 'Sort order' })
@IsOptional()
sortOrder?: number;
}
/**
* CreateCharacterDto
*
* DTO for creating a character profile.
*
* TR: Karakter profili oluşturmak için DTO.
* EN: DTO for creating a character profile.
*/
export class CreateCharacterDto {
@ApiProperty({ description: 'Project ID' })
@IsString()
projectId: string;
@ApiProperty({ description: 'Character name' })
@IsString()
name: string;
@ApiProperty({ description: 'Character role' })
@IsString()
role: string; // Protagonist, Antagonist, etc.
@ApiPropertyOptional({ description: 'Character values (inner beliefs)' })
@IsOptional()
@IsString()
values?: string;
@ApiPropertyOptional({ description: 'Character traits (personality)' })
@IsOptional()
@IsString()
traits?: string;
@ApiPropertyOptional({
description: 'Character mannerisms (external behavior)',
})
@IsOptional()
@IsString()
mannerisms?: string;
}
/**
* PerformResearchDto
*
* DTO for performing deep research.
*
* TR: Derin araştırma yapmak için DTO.
* EN: DTO for performing deep research.
*/
export class PerformResearchDto {
@ApiProperty({ description: 'Project ID' })
@IsString()
projectId: string;
@ApiPropertyOptional({ description: 'Additional research query' })
@IsOptional()
@IsString()
additionalQuery?: string;
}
/**
* GenerateDiscoveryQuestionsDto
*
* DTO for generating creative brief discovery questions.
*
* TR: Yaratıcı brief keşif soruları oluşturmak için DTO.
* EN: DTO for generating creative brief discovery questions.
*/
export class GenerateDiscoveryQuestionsDto {
@ApiProperty({ description: 'Topic to generate questions for' })
@IsString()
topic: string;
@ApiProperty({ description: 'Language for questions' })
@IsString()
language: string;
@ApiPropertyOptional({ description: 'Existing questions to avoid' })
@IsOptional()
@IsArray()
existingQuestions?: string[];
}
/**
* GenerateLoglineDto
*
* DTO for generating logline and high concept.
*
* TR: Logline ve high concept oluşturmak için DTO.
* EN: DTO for generating logline and high concept.
*/
export class GenerateLoglineDto {
@ApiProperty({ description: 'Project ID' })
@IsString()
projectId: string;
}
/**
* GenerateCharactersDto
*
* DTO for auto-generating character profiles.
*
* TR: Otomatik karakter profilleri oluşturmak için DTO.
* EN: DTO for auto-generating character profiles.
*/
export class GenerateCharactersDto {
@ApiProperty({ description: 'Project ID' })
@IsString()
projectId: string;
}
-191
View File
@@ -1,191 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
// SegmentType and SpeechStyle are string unions - using string here for decorator compatibility
/**
* CreateSegmentDto
*
* DTO for creating a new script segment.
*
* TR: Yeni bir script segmenti oluşturmak için DTO.
* EN: DTO for creating a new script segment.
*/
export class CreateSegmentDto {
@ApiProperty({ description: 'Project ID this segment belongs to' })
@IsString()
projectId: string;
@ApiProperty({ description: 'Segment type (Hook, Intro, Body, etc.)' })
@IsString()
segmentType: string; // Hook, Intro, Body, etc.
@ApiProperty({ description: 'Start time in format MM:SS' })
@IsString()
timeStart: string;
@ApiProperty({ description: 'Duration in seconds (e.g., "30s")' })
@IsString()
duration: string;
@ApiPropertyOptional({ description: 'Visual description for the segment' })
@IsOptional()
@IsString()
visualDescription?: string;
@ApiPropertyOptional({ description: 'Narrator script text' })
@IsOptional()
@IsString()
narratorScript?: string;
@ApiPropertyOptional({ description: 'Editor notes' })
@IsOptional()
@IsString()
editorNotes?: string;
@ApiPropertyOptional({ description: 'Audio cues' })
@IsOptional()
@IsString()
audioCues?: string;
@ApiPropertyOptional({ description: 'On-screen text overlay' })
@IsOptional()
@IsString()
onScreenText?: string;
@ApiPropertyOptional({ description: 'Stock footage search query' })
@IsOptional()
@IsString()
stockQuery?: string;
@ApiPropertyOptional({ description: 'Video generation prompt (VEO/Runway)' })
@IsOptional()
@IsString()
videoPrompt?: string;
@ApiPropertyOptional({
description: 'Image generation prompt (Midjourney/Flux)',
})
@IsOptional()
@IsString()
imagePrompt?: string;
@ApiPropertyOptional({ description: 'Sort order in the script' })
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}
/**
* UpdateSegmentDto
*
* DTO for updating an existing segment.
*
* TR: Mevcut bir segmenti güncellemek için DTO.
* EN: DTO for updating an existing segment.
*/
export class UpdateSegmentDto {
@ApiPropertyOptional({ description: 'Segment type' })
@IsOptional()
@IsString()
segmentType?: string;
@ApiPropertyOptional({ description: 'Start time' })
@IsOptional()
@IsString()
timeStart?: string;
@ApiPropertyOptional({ description: 'Duration' })
@IsOptional()
@IsString()
duration?: string;
@ApiPropertyOptional({ description: 'Visual description' })
@IsOptional()
@IsString()
visualDescription?: string;
@ApiPropertyOptional({ description: 'Narrator script' })
@IsOptional()
@IsString()
narratorScript?: string;
@ApiPropertyOptional({ description: 'Editor notes' })
@IsOptional()
@IsString()
editorNotes?: string;
@ApiPropertyOptional({ description: 'General notes' })
@IsOptional()
@IsString()
generalNotes?: string;
@ApiPropertyOptional({ description: 'Audio cues' })
@IsOptional()
@IsString()
audioCues?: string;
@ApiPropertyOptional({ description: 'On-screen text' })
@IsOptional()
@IsString()
onScreenText?: string;
@ApiPropertyOptional({ description: 'Stock query' })
@IsOptional()
@IsString()
stockQuery?: string;
@ApiPropertyOptional({ description: 'Video prompt' })
@IsOptional()
@IsString()
videoPrompt?: string;
@ApiPropertyOptional({ description: 'Image prompt' })
@IsOptional()
@IsString()
imagePrompt?: string;
@ApiPropertyOptional({ description: 'Generated image URL' })
@IsOptional()
@IsString()
generatedImageUrl?: string;
@ApiPropertyOptional({ description: 'Sort order' })
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}
/**
* RewriteSegmentDto
*
* DTO for rewriting a segment with a new style.
*
* TR: Bir segmenti yeni bir stille yeniden yazmak için DTO.
* EN: DTO for rewriting a segment with a new style.
*/
export class RewriteSegmentDto {
@ApiProperty({ description: 'Segment ID to rewrite' })
@IsString()
segmentId: string;
@ApiProperty({ description: 'New style to apply' })
@IsString()
newStyle: string; // SpeechStyle or 'Make it Longer' | 'Make it Shorter'
}
/**
* GenerateScriptDto
*
* DTO for generating a full script from project data.
*
* TR: Proje verisinden tam bir script oluşturmak için DTO.
* EN: DTO for generating a full script from project data.
*/
export class GenerateScriptDto {
@ApiProperty({ description: 'Project ID to generate script for' })
@IsString()
projectId: string;
}
-3
View File
@@ -1,3 +0,0 @@
export * from './ws-events';
export * from './skriptai.gateway';
export * from './queue-event-bridge';
@@ -1,91 +0,0 @@
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
}
});
}
}
@@ -1,123 +0,0 @@
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);
}
}
-66
View File
@@ -1,66 +0,0 @@
/**
* 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;
}
-5
View File
@@ -1,5 +0,0 @@
export * from './skriptai.module';
export * from './services';
export * from './controllers';
export * from './dto';
export * from './types/skriptai.types';
@@ -1,191 +0,0 @@
/**
* Analysis Prompt Builders
*
* Prompts for AI-powered content analysis:
* - Neuro Marketing Analysis (Cialdini's 6 Principles)
* - YouTube Algorithm Audit
* - Commercial Brief (Sponsorship Analysis)
* - Visual Asset Keywords
*
* Used in: AnalysisService
*/
// ============================================
// Neuro Marketing Analysis
// ============================================
export interface NeuroAnalysisInput {
fullScript: string;
}
export function buildNeuroAnalysisPrompt(input: NeuroAnalysisInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Analyze this script using Consumer Neuroscience and Cialdini's 6 Principles of Persuasion.
Script:
${input.fullScript.substring(0, 10000)}
Provide:
1. Engagement Score (0-100): How well does it capture attention?
2. Dopamine Score (0-100): Does it create anticipation & reward loops?
3. Clarity Score (0-100): Is the message clear and memorable?
4. Cialdini's Persuasion Metrics (0-100 each):
- Reciprocity: Does it give value first?
- Scarcity: Does it create urgency?
- Authority: Does it establish credibility?
- Consistency: Does it align with viewer beliefs?
- Liking: Is the tone likeable/relatable?
- Social Proof: Does it reference others' actions?
5. Neuro Metrics:
- Attention Hooks: Moments that grab attention
- Emotional Triggers: Points that evoke emotion
- Memory Anchors: Unique/memorable elements
- Action Drivers: CTAs or challenges
6. Suggestions: 3-5 specific improvements`,
temperature: 0.6,
schema: `{
"engagementScore": 0,
"dopamineScore": 0,
"clarityScore": 0,
"persuasionMetrics": {
"reciprocity": 0, "scarcity": 0, "authority": 0,
"consistency": 0, "liking": 0, "socialProof": 0
},
"neuroMetrics": {
"attentionHooks": ["..."], "emotionalTriggers": ["..."],
"memoryAnchors": ["..."], "actionDrivers": ["..."]
},
"suggestions": ["..."]
}`,
};
}
// ============================================
// YouTube Audit
// ============================================
export interface YoutubeAuditInput {
topic: string;
fullScript: string;
}
export function buildYoutubeAuditPrompt(input: YoutubeAuditInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Perform a YouTube Algorithm Audit on this script for topic "${input.topic}".
Script:
${input.fullScript.substring(0, 10000)}
Analyze and provide:
1. Hook Score (0-100): First 10 seconds effectiveness
2. Pacing Score (0-100): Does it maintain momentum?
3. Viral Potential (0-100): Shareability factor
4. Retention Analysis: 3-5 potential drop-off points with time, issue, suggestion, severity (High/Medium/Low)
5. Thumbnail Concepts: 3 high-CTR thumbnail ideas with:
- Concept name, Visual description, Text overlay
- Color psychology, Emotion target, AI generation prompt
6. Title Options: 5 clickable titles (curiosity gap, numbers, power words)
7. Community Post: Engaging post to tease the video
8. Pinned Comment: Engagement-driving first comment
9. SEO Description: Optimized video description with keywords
10. Keywords: 10 relevant search keywords`,
temperature: 0.7,
schema: `{
"hookScore": 0, "pacingScore": 0, "viralPotential": 0,
"retentionAnalysis": [{ "time": "0:30", "issue": "...", "suggestion": "...", "severity": "High" }],
"thumbnails": [{ "conceptName": "...", "visualDescription": "...", "textOverlay": "...", "colorPsychology": "...", "emotionTarget": "...", "aiPrompt": "..." }],
"titles": ["..."],
"communityPost": "...", "pinnedComment": "...",
"description": "...", "keywords": ["..."]
}`,
};
}
// ============================================
// Commercial Brief
// ============================================
export interface CommercialBriefInput {
topic: string;
targetAudience: string[];
contentType: string;
fullScript: string;
}
export function buildCommercialBriefPrompt(input: CommercialBriefInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Analyze this content for commercial viability and sponsorship opportunities.
Topic: "${input.topic}"
Audience: ${input.targetAudience.join(', ')}
Content Type: ${input.contentType}
Script excerpt:
${input.fullScript.substring(0, 5000)}
Provide:
1. Viability Score (1-10 scale as string): "8/10"
2. Viability Reason: Why this content is commercially viable
3. Sponsor Suggestions (3-5 potential sponsors):
- Company name, Industry
- Match reason (why this sponsor fits)
- Email draft (outreach template)`,
temperature: 0.6,
schema: `{
"viabilityScore": "8/10",
"viabilityReason": "...",
"sponsors": [{ "name": "...", "industry": "...", "matchReason": "...", "emailDraft": "..." }]
}`,
};
}
// ============================================
// Visual Asset Keywords
// ============================================
export interface VisualAssetKeywordsInput {
topic: string;
count: number;
}
export function buildVisualAssetKeywordsPrompt(
input: VisualAssetKeywordsInput,
): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Generate ${input.count} specific, simple visual keywords for an image generator about "${input.topic}".
Format: "subject action context style". Keep it English, concise, no special chars.`,
temperature: 0.8,
schema: '["keyword1", "keyword2", ...]',
};
}
@@ -1,38 +0,0 @@
/**
* Character Generation Prompt Builder
*
* Uses Alan C. Hueth's "Triunity of Character" model to create
* rich character profiles for video content.
*
* Used in: ResearchService.generateCharacters()
*/
export interface CharacterGenerationInput {
contentType: string;
topic: string;
language: string;
}
export function buildCharacterGenerationPrompt(
input: CharacterGenerationInput,
): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Create Character Profiles for a ${input.contentType} about "${input.topic}".
Use Alan C. Hueth's "Triunity of Character" model:
1. Values (Inner belief)
2. Traits (Personality)
3. Mannerisms (External behavior)
If format is non-fiction (Youtube Doc), create a 'Host/Narrator' persona and potentially an 'Antagonist' (e.g., The Problem, Time, A Rival).
Language: ${input.language}.`,
temperature: 0.8,
schema:
'[{ "name": "Name", "role": "Protagonist", "values": "...", "traits": "...", "mannerisms": "..." }]',
};
}
@@ -1,103 +0,0 @@
/**
* 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) };
}
@@ -1,51 +0,0 @@
/**
* Deep Research Prompt Builders
*
* Two-stage prompts:
* 1. Generate search queries for a topic
* 2. Find high-quality web sources for each query
*
* Used in: ResearchService.performDeepResearch()
*/
export interface SearchQueryInput {
topic: string;
briefContext: string;
language: string;
}
export function buildSearchQueryPrompt(input: SearchQueryInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Generate 5 specific Google Search queries for "${input.topic}".
Context: ${input.briefContext}. Language: ${input.language}.
Return strictly a JSON array of strings.`,
temperature: 0.7,
schema: '["query1", "query2", ...]',
};
}
export interface SourceSearchInput {
query: string;
language: string;
}
export function buildSourceSearchPrompt(input: SourceSearchInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Find 3 high-quality web sources for: ${input.query}. Language: ${input.language}.
Return JSON array: [{ "title": string, "url": string, "snippet": string, "type": "article" }]`,
temperature: 0.5,
schema: '[{ "title": "...", "url": "...", "snippet": "...", "type": "article" }]',
};
}
@@ -1,53 +0,0 @@
/**
* Discovery Questions Prompt Builder
*
* Generates provocative "Screenwriter's Room" style questions
* to help shape the narrative arc for a given topic.
*
* Used in: ResearchService.generateDiscoveryQuestions()
*/
export interface DiscoveryQuestionsInput {
topic: string;
language: string;
existingQuestions?: string[];
}
export function buildDiscoveryQuestionsPrompt(
input: DiscoveryQuestionsInput,
): {
prompt: string;
temperature: number;
schema: string;
} {
const existingContext =
input.existingQuestions && input.existingQuestions.length > 0
? `Avoid these questions: ${input.existingQuestions.join(', ')}`
: '';
return {
prompt: `You are an expert Screenwriter and Creative Director. Topic: "${input.topic}".
PHASE 1: DEEP DIVE
Think like a filmmaker. We are not just making a video; we are telling a story.
Analyze the topic "${input.topic}" to find the drama, the conflict, and the human element.
PHASE 2: INTERROGATION
Ask 3-4 provocative, "Screenwriter's Room" style questions to help shape the narrative arc.
DO NOT ASK: "What is the goal?" or "Who is the audience?".
INSTEAD ASK (Examples):
- "What is the 'Inciting Incident' that makes this topic urgent right now?"
- "If this topic was a character, what would be its fatal flaw?"
- "What is the 'Villain' (opposing force or misconception) we are fighting against?"
- "What is the emotional climax you want the viewer to feel at the end?"
${existingContext}
Output Language: ${input.language}.`,
temperature: 0.9,
schema: '{ "questions": ["Question 1", "Question 2", "Question 3", "Question 4"] }',
};
}
-70
View File
@@ -1,70 +0,0 @@
/**
* SkriptAI Prompt Index
*
* Centralized exports for all AI prompt builders.
* Each prompt is a pure function that takes typed input and returns
* { prompt, temperature, schema } ready to pass to GeminiService methods.
*/
// Discovery & Research
export {
buildDiscoveryQuestionsPrompt,
type DiscoveryQuestionsInput,
} from './discovery-questions.prompt';
export {
buildSearchQueryPrompt,
buildSourceSearchPrompt,
type SearchQueryInput,
type SourceSearchInput,
} from './deep-research.prompt';
// Characters & Logline
export {
buildCharacterGenerationPrompt,
type CharacterGenerationInput,
} from './character-generation.prompt';
export { buildLoglinePrompt, type LoglineInput } from './logline.prompt';
// Script Generation
export {
buildScriptOutlinePrompt,
buildChapterSegmentPrompt,
buildSegmentRewritePrompt,
buildSegmentImagePrompt,
calculateTargetWordCount,
calculateEstimatedChapters,
type ScriptOutlineInput,
type ChapterSegmentInput,
type SegmentRewriteInput,
type SegmentImagePromptInput,
} from './script-generation.prompt';
// Analysis
export {
buildNeuroAnalysisPrompt,
buildYoutubeAuditPrompt,
buildCommercialBriefPrompt,
buildVisualAssetKeywordsPrompt,
type NeuroAnalysisInput,
type YoutubeAuditInput,
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';
@@ -1,30 +0,0 @@
/**
* Logline & High Concept Prompt Builder
*
* Uses Hollywood Producer persona with Dallas Jones formula
* to create compelling loglines and high concept premises.
*
* Used in: ResearchService.generateLogline()
*/
export interface LoglineInput {
topic: string;
sourceContext: string;
language: string;
}
export function buildLoglinePrompt(input: LoglineInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Act as a Hollywood Producer. Topic: ${input.topic}. Material: ${input.sourceContext}.
Create a "High Concept" premise and a "Logline" (Max 25 words, Dallas Jones formula).
Language: ${input.language}.`,
temperature: 0.9,
schema: '{ "logline": "...", "highConcept": "..." }',
};
}
@@ -1,191 +0,0 @@
/**
* Script Generation Prompt Builders
*
* Two-phase script generation:
* - Phase 1: Content outline (chapters, SEO, thumbnails)
* - Phase 2: Per-chapter segment generation
* - Segment rewrite with style change
* - Segment image prompt generation
*
* Used in: ScriptsService.generateScript(), rewriteSegment(), generateSegmentImage()
*/
// ============================================
// Phase 1: Outline
// ============================================
export interface ScriptOutlineInput {
topic: string;
logline: string;
characterContext: string;
speechStyles: string[];
targetAudience: string[];
contentType: string;
targetDuration: string;
targetWordCount: number;
estimatedChapters: number;
sourceContext: string;
briefContext: string;
}
export function buildScriptOutlinePrompt(input: ScriptOutlineInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Create a CONTENT OUTLINE.
Topic: "${input.topic}"
Logline: "${input.logline}"
Characters: ${input.characterContext}
Styles: ${input.speechStyles.join(', ')}. Audience: ${input.targetAudience.join(', ')}.
Format: ${input.contentType}. Target Duration: ${input.targetDuration}. Target Total Word Count: ${input.targetWordCount}.
Generate exactly ${input.estimatedChapters} chapters.
Material: ${input.sourceContext.substring(0, 15000)}
Brief: ${input.briefContext}`,
temperature: 0.7,
schema: `{
"title": "Title",
"seoDescription": "Desc",
"tags": ["tag1"],
"thumbnailIdeas": ["Idea 1"],
"chapters": [{ "title": "Chap 1", "focus": "Summary", "type": "Intro" }]
}`,
};
}
// ============================================
// Phase 2: Chapter → Segments
// ============================================
export interface ChapterSegmentInput {
chapterIndex: number;
totalChapters: number;
chapterTitle: string;
chapterFocus: string;
chapterType: string;
speechStyles: string[];
targetAudience: string[];
characterContext: string;
language: string;
}
export function buildChapterSegmentPrompt(input: ChapterSegmentInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Write Script Segment ${input.chapterIndex + 1}/${input.totalChapters}.
Chapter: "${input.chapterTitle}". Focus: ${input.chapterFocus}.
Style: ${input.speechStyles.join(', ')}.
Audience: ${input.targetAudience.join(', ')}.
Characters: ${input.characterContext}.
Target Length: ~200 words.
Language: ${input.language}.`,
temperature: 0.8,
schema: `[{
"segmentType": "${input.chapterType || 'Body'}",
"narratorScript": "Full text...",
"visualDescription": "Detailed visual explanation...",
"videoPrompt": "Cinematic shot of [subject], 4k...",
"imagePrompt": "Hyper-realistic photo of [subject]...",
"onScreenText": "Overlay text...",
"stockQuery": "Pexels keyword",
"audioCues": "SFX..."
}]`,
};
}
// ============================================
// Segment Rewrite
// ============================================
export interface SegmentRewriteInput {
currentScript: string;
newStyle: string;
topic: string;
language: string;
}
export function buildSegmentRewritePrompt(input: SegmentRewriteInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Rewrite this script segment.
Current Text: "${input.currentScript}"
Goal: Change style to "${input.newStyle}".
Context: Topic is "${input.topic}". Language: ${input.language}.
Principles: Show Don't Tell, Subtext.`,
temperature: 0.85,
schema: `{
"narratorScript": "New text...",
"visualDescription": "Updated visual...",
"onScreenText": "Updated overlay...",
"audioCues": "Updated audio..."
}`,
};
}
// ============================================
// Segment Image Prompt
// ============================================
export interface SegmentImagePromptInput {
topic: string;
narratorScript: string;
visualDescription: string;
}
export function buildSegmentImagePrompt(input: SegmentImagePromptInput): {
prompt: string;
temperature: number;
schema: string;
} {
return {
prompt: `Create a detailed AI Image Generation Prompt and a Video Generation Prompt for this script segment.
Topic: "${input.topic}"
Segment Content: "${input.narratorScript}"
Visual Context: "${input.visualDescription}"
Goal: Create a highly detailed, cinematic, and artistic prompt optimized for tools like Midjourney, Flux, or Runway.
Style: Cinematic, highly detailed, 8k, professional lighting.`,
temperature: 0.7,
schema: `{
"imagePrompt": "Full detailed image prompt...",
"videoPrompt": "Full detailed video prompt..."
}`,
};
}
// ============================================
// Helpers
// ============================================
/**
* Calculate target word count based on duration string
*/
export function calculateTargetWordCount(targetDuration: string): number {
if (targetDuration.includes('Short')) return 140;
if (targetDuration.includes('Standard')) return 840;
if (targetDuration.includes('Long')) return 1680;
if (targetDuration.includes('Deep Dive')) return 2800;
return 840;
}
/**
* Calculate estimated chapters based on word count
*/
export function calculateEstimatedChapters(targetWordCount: number): number {
return Math.ceil(targetWordCount / 200);
}
@@ -1,91 +0,0 @@
/**
* 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) };
}
@@ -1,80 +0,0 @@
/**
* 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) };
}

Some files were not shown because too many files have changed in this diff Show More