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
+26 -19
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
CMD ["node", "dist/main.js"]
# 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