generated from fahricansecer/boilerplate-be
Compare commits
1 Commits
main
..
8e8c9d17d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e8c9d17d0 |
@@ -1,2 +0,0 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Backend Deploy 🚀
|
||||
run-name: ${{ gitea.actor }} backend güncelliyor...
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Kodu Çek
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker Build
|
||||
# Dockerfile'ı kullanarak imajı oluşturuyoruz
|
||||
run: docker build -t backend-proje:latest .
|
||||
|
||||
- name: Eski Konteyneri Sil
|
||||
# İlk seferde hata vermemesi için '|| true' ekledik
|
||||
run: docker rm -f backend-container || true
|
||||
|
||||
- name: Yeni Versiyonu Başlat
|
||||
# Secrets kısmından şifreleri alıp konteynere veriyoruz
|
||||
run: |
|
||||
docker run -d \
|
||||
--name backend-container \
|
||||
--restart always \
|
||||
--network gitea-server_gitea \
|
||||
-p 1501:3000 \
|
||||
-e DATABASE_URL='${{ secrets.DATABASE_URL }}' \
|
||||
-e JWT_SECRET='${{ secrets.JWT_SECRET }}' \
|
||||
-e REDIS_HOST='${{ secrets.REDIS_HOST }}' \
|
||||
-e REDIS_PORT='6379' \
|
||||
backend-proje:latest
|
||||
+25
-18
@@ -1,49 +1,56 @@
|
||||
# Build stage
|
||||
# --- Build Stage ---
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
# Raspberry Pi ve Prisma uyumluluğu için gerekli kütüphaneler
|
||||
RUN apk add --no-cache openssl libc6-compat
|
||||
|
||||
# Install dependencies
|
||||
# Paket dosyalarını kopyala
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
# Kaynak kodları kopyala
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
# Prisma client üret (Database şeman için şart)
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
# Build al (NestJS/Backend için)
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
# --- Production Stage (Canlı Sistem) ---
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Prisma için gerekli kütüphaneleri buraya da ekliyoruz
|
||||
RUN apk add --no-cache openssl libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
# Sadece production (canlıda lazım olan) paketleri kur
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy Prisma schema and generate client
|
||||
# Prisma şemasını taşı ve client üret
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
# Build edilen dosyaları taşı (Senin Dockerfile'ındaki yapıya sadık kaldım)
|
||||
# Güvenlik için dosyaları 'node' kullanıcısına zimmetliyoruz
|
||||
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||
|
||||
# Copy i18n files
|
||||
COPY --from=builder /app/src/i18n ./dist/i18n
|
||||
# Eğer i18n varsa onu da taşı
|
||||
COPY --chown=node:node --from=builder /app/src/i18n ./dist/i18n
|
||||
|
||||
# Set environment
|
||||
# Ortam değişkeni
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Expose port
|
||||
# Portu aç
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
# Güvenlik: Root yerine 'node' kullanıcısına geç
|
||||
USER node
|
||||
|
||||
# Uygulamayı başlat
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
```
|
||||
@@ -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.
|
||||
Generated
+422
-1178
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '自然で流暢な日本語を使用してください。要求されたトーンとスタイルに合わせてください。',
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,54 +188,29 @@ 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}
|
||||
|
||||
Output the result as valid JSON that matches this schema:
|
||||
@@ -305,351 +218,23 @@ ${schema}
|
||||
|
||||
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
|
||||
const contents: any[] = [];
|
||||
const response = await this.generateText(fullPrompt, options);
|
||||
|
||||
if (options.systemPrompt) {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: options.systemPrompt }],
|
||||
});
|
||||
contents.push({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Understood. I will follow these instructions.' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
let jsonStr = response.text;
|
||||
|
||||
// Remove potential markdown code blocks
|
||||
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1].trim();
|
||||
}
|
||||
|
||||
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 };
|
||||
return { data, usage: response.usage };
|
||||
} 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;
|
||||
this.logger.error('Failed to parse JSON response', error);
|
||||
throw new Error('Failed to parse AI response as JSON');
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
try {
|
||||
// Use Imagen 3.0 (Nano Banana Pro)
|
||||
const model = 'imagen-3.0-generate-001';
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
throw new GeminiException(
|
||||
'No image returned from Gemini',
|
||||
GeminiErrorType.INVALID_RESPONSE,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof GeminiException) throw error;
|
||||
this.logger.error('Gemini image generation failed', error);
|
||||
throw this.classifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './project.dto';
|
||||
export * from './segment.dto';
|
||||
export * from './research.dto';
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"] }',
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user