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
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Raspberry Pi ve Prisma uyumluluğu için gerekli kütüphaneler
|
||||||
COPY package*.json ./
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
# Install dependencies
|
# Paket dosyalarını kopyala
|
||||||
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source code
|
# Kaynak kodları kopyala
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
# Prisma client üret (Database şeman için şart)
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build the application
|
# Build al (NestJS/Backend için)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# --- Production Stage (Canlı Sistem) ---
|
||||||
FROM node:20-alpine AS production
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install production dependencies only
|
# Sadece production (canlıda lazım olan) paketleri kur
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
# Copy Prisma schema and generate client
|
# Prisma şemasını taşı ve client üret
|
||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Copy built application
|
# Build edilen dosyaları taşı (Senin Dockerfile'ındaki yapıya sadık kaldım)
|
||||||
COPY --from=builder /app/dist ./dist
|
# Güvenlik için dosyaları 'node' kullanıcısına zimmetliyoruz
|
||||||
|
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Copy i18n files
|
# Eğer i18n varsa onu da taşı
|
||||||
COPY --from=builder /app/src/i18n ./dist/i18n
|
COPY --chown=node:node --from=builder /app/src/i18n ./dist/i18n
|
||||||
|
|
||||||
# Set environment
|
# Ortam değişkeni
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Expose port
|
# Portu aç
|
||||||
EXPOSE 3000
|
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"]
|
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",
|
"version": "0.0.1",
|
||||||
"description": "Generated by Antigravity CLI",
|
"description": "Generated by Antigravity CLI",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -19,8 +19,7 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1014.0",
|
"@aws-sdk/client-s3": "^3.964.0",
|
||||||
"@aws-sdk/lib-storage": "^3.1014.0",
|
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/cache-manager": "^3.1.0",
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
@@ -53,7 +52,7 @@
|
|||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ model User {
|
|||||||
// Relations
|
// Relations
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
projects ScriptProject[]
|
|
||||||
|
|
||||||
// Multi-tenancy (optional)
|
// Multi-tenancy (optional)
|
||||||
tenantId String?
|
tenantId String?
|
||||||
@@ -161,193 +160,3 @@ model Translation {
|
|||||||
@@index([locale])
|
@@index([locale])
|
||||||
@@index([namespace])
|
@@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 { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
|
||||||
import { redisStore } from 'cache-manager-redis-yet';
|
import { redisStore } from 'cache-manager-redis-yet';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
i18nConfig,
|
i18nConfig,
|
||||||
featuresConfig,
|
featuresConfig,
|
||||||
throttleConfig,
|
throttleConfig,
|
||||||
storageConfig,
|
|
||||||
} from './config/configuration';
|
} from './config/configuration';
|
||||||
import { geminiConfig } from './modules/gemini/gemini.config';
|
import { geminiConfig } from './modules/gemini/gemini.config';
|
||||||
import { validateEnv } from './config/env.validation';
|
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 { AdminModule } from './modules/admin/admin.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { GeminiModule } from './modules/gemini/gemini.module';
|
import { GeminiModule } from './modules/gemini/gemini.module';
|
||||||
import { SkriptaiModule } from './modules/skriptai/skriptai.module';
|
|
||||||
import { StorageModule } from './modules/storage/storage.module';
|
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
import {
|
import {
|
||||||
@@ -66,23 +62,9 @@ import {
|
|||||||
featuresConfig,
|
featuresConfig,
|
||||||
throttleConfig,
|
throttleConfig,
|
||||||
geminiConfig,
|
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)
|
// Logger (Structured Logging with Pino)
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
@@ -177,8 +159,6 @@ import {
|
|||||||
|
|
||||||
// Optional Modules (controlled by env variables)
|
// Optional Modules (controlled by env variables)
|
||||||
GeminiModule,
|
GeminiModule,
|
||||||
SkriptaiModule,
|
|
||||||
StorageModule,
|
|
||||||
HealthModule,
|
HealthModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
});
|
});
|
||||||
// Only update if translation exists (key is different from result)
|
// Only update if translation exists (key is different from result)
|
||||||
if (translatedMessage !== `errors.${message}`) {
|
if (translatedMessage !== `errors.${message}`) {
|
||||||
message = translatedMessage;
|
message = translatedMessage as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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),
|
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
||||||
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 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
|
||||||
JWT_SECRET: z.string().min(32),
|
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'),
|
JWT_REFRESH_EXPIRATION: z.string().default('7d'),
|
||||||
|
|
||||||
// Redis
|
// 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());
|
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||||
|
|
||||||
// Security Headers
|
// Security Headers
|
||||||
app.use(helmet());
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
}));
|
||||||
|
|
||||||
// Graceful Shutdown (Prisma & Docker)
|
// Graceful Shutdown (Prisma & Docker)
|
||||||
app.enableShutdownHooks();
|
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 { Module, Global } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { GeminiService } from './gemini.service';
|
import { GeminiService } from './gemini.service';
|
||||||
import { ContextManagerService } from './context-manager.service';
|
|
||||||
import { MapReduceService } from './map-reduce.service';
|
|
||||||
import { geminiConfig } from './gemini.config';
|
import { geminiConfig } from './gemini.config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,16 +8,11 @@ import { geminiConfig } from './gemini.config';
|
|||||||
*
|
*
|
||||||
* Optional module for AI-powered features using Google Gemini API.
|
* Optional module for AI-powered features using Google Gemini API.
|
||||||
* Enable by setting ENABLE_GEMINI=true in your .env file.
|
* 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()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule.forFeature(geminiConfig)],
|
imports: [ConfigModule.forFeature(geminiConfig)],
|
||||||
providers: [GeminiService, ContextManagerService, MapReduceService],
|
providers: [GeminiService],
|
||||||
exports: [GeminiService, ContextManagerService, MapReduceService],
|
exports: [GeminiService],
|
||||||
})
|
})
|
||||||
export class GeminiModule {}
|
export class GeminiModule {}
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
import { ZodSchema, ZodError } from 'zod';
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Types & Interfaces
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface GeminiGenerateOptions {
|
export interface GeminiGenerateOptions {
|
||||||
model?: string;
|
model?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
tools?: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeminiChatMessage {
|
export interface GeminiChatMessage {
|
||||||
@@ -20,72 +14,30 @@ export interface GeminiChatMessage {
|
|||||||
content: string;
|
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
|
* Gemini AI Service
|
||||||
*/
|
|
||||||
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
|
|
||||||
*
|
*
|
||||||
* 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.
|
* 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
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // Simple text generation
|
* // Simple text generation
|
||||||
* const response = await geminiService.generateText('Write a poem about coding');
|
* const response = await geminiService.generateText('Write a poem about coding');
|
||||||
*
|
*
|
||||||
* // JSON generation with Zod validation
|
* // With options
|
||||||
* import { z } from 'zod';
|
* const response = await geminiService.generateText('Translate to Turkish', {
|
||||||
* const schema = z.object({ title: z.string(), score: z.number() });
|
* temperature: 0.7,
|
||||||
* const result = await geminiService.generateJSON(
|
* systemPrompt: 'You are a professional translator',
|
||||||
* 'Analyze this script', '{ title, score }',
|
* });
|
||||||
* { zodSchema: schema }
|
*
|
||||||
* );
|
* // 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()
|
@Injectable()
|
||||||
@@ -134,10 +86,6 @@ export class GeminiService implements OnModuleInit {
|
|||||||
return this.isEnabled && this.client !== null;
|
return this.isEnabled && this.client !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Text Generation
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate text content from a prompt
|
* Generate text content from a prompt
|
||||||
*
|
*
|
||||||
@@ -149,10 +97,11 @@ export class GeminiService implements OnModuleInit {
|
|||||||
prompt: string,
|
prompt: string,
|
||||||
options: GeminiGenerateOptions = {},
|
options: GeminiGenerateOptions = {},
|
||||||
): Promise<{ text: string; usage?: any }> {
|
): 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 model = options.model || this.defaultModel;
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contents: any[] = [];
|
const contents: any[] = [];
|
||||||
@@ -180,31 +129,19 @@ export class GeminiService implements OnModuleInit {
|
|||||||
config: {
|
config: {
|
||||||
temperature: options.temperature,
|
temperature: options.temperature,
|
||||||
maxOutputTokens: options.maxTokens,
|
maxOutputTokens: options.maxTokens,
|
||||||
tools: options.tools,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
|
||||||
this.logUsage('generateText', model, response.usageMetadata, durationMs);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: (response.text || '').trim(),
|
text: (response.text || '').trim(),
|
||||||
usage: response.usageMetadata,
|
usage: response.usageMetadata,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const durationMs = Date.now() - startTime;
|
this.logger.error('Gemini generation failed', error);
|
||||||
this.logger.error(
|
throw error;
|
||||||
`Gemini generation failed after ${durationMs}ms`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
throw this.classifyError(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Chat
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Have a multi-turn chat conversation
|
* Have a multi-turn chat conversation
|
||||||
*
|
*
|
||||||
@@ -216,10 +153,11 @@ export class GeminiService implements OnModuleInit {
|
|||||||
messages: GeminiChatMessage[],
|
messages: GeminiChatMessage[],
|
||||||
options: GeminiGenerateOptions = {},
|
options: GeminiGenerateOptions = {},
|
||||||
): Promise<{ text: string; usage?: any }> {
|
): 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 model = options.model || this.defaultModel;
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contents = messages.map((msg) => ({
|
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 {
|
return {
|
||||||
text: (response.text || '').trim(),
|
text: (response.text || '').trim(),
|
||||||
usage: response.usageMetadata,
|
usage: response.usageMetadata,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Gemini chat failed', 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.
|
* Generate structured JSON output
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
* @param prompt - The prompt describing what JSON to generate
|
* @param prompt - The prompt describing what JSON to generate
|
||||||
* @param schema - JSON schema description for the expected output (human readable)
|
* @param schema - JSON schema description for the expected output
|
||||||
* @param options - Optional configuration including zodSchema and maxRetries
|
* @param options - Optional configuration for the generation
|
||||||
* @returns Parsed and optionally validated JSON object
|
* @returns Parsed JSON object
|
||||||
*/
|
*/
|
||||||
async generateJSON<T = any>(
|
async generateJSON<T = any>(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
schema: string,
|
schema: string,
|
||||||
options: GeminiJSONOptions<T> = {},
|
options: GeminiGenerateOptions = {},
|
||||||
): Promise<{ data: T; usage?: any }> {
|
): Promise<{ data: T; usage?: any }> {
|
||||||
this.ensureAvailable();
|
|
||||||
|
|
||||||
const maxRetries = options.maxRetries ?? 3;
|
|
||||||
const model = options.model || this.defaultModel;
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
let lastUsage: any = undefined;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build the full prompt
|
|
||||||
const fullPrompt = `${prompt}
|
const fullPrompt = `${prompt}
|
||||||
|
|
||||||
Output the result as valid JSON that matches this schema:
|
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.`;
|
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) {
|
try {
|
||||||
contents.push({
|
// Try to extract JSON from the response
|
||||||
role: 'user',
|
let jsonStr = response.text;
|
||||||
parts: [{ text: options.systemPrompt }],
|
|
||||||
});
|
// Remove potential markdown code blocks
|
||||||
contents.push({
|
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
role: 'model',
|
if (jsonMatch) {
|
||||||
parts: [
|
jsonStr = jsonMatch[1].trim();
|
||||||
{ text: 'Understood. I will follow these instructions.' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contents.push({
|
|
||||||
role: 'user',
|
|
||||||
parts: [{ text: fullPrompt }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure responseMimeType for native JSON (only when no tools — tools don't support it)
|
|
||||||
const config: any = {
|
|
||||||
temperature: options.temperature,
|
|
||||||
maxOutputTokens: options.maxTokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.tools || options.tools.length === 0) {
|
|
||||||
config.responseMimeType = 'application/json';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.tools) {
|
|
||||||
config.tools = options.tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.client!.models.generateContent({
|
|
||||||
model,
|
|
||||||
contents,
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
|
||||||
lastUsage = response.usageMetadata;
|
|
||||||
this.logUsage(
|
|
||||||
`generateJSON (attempt ${attempt}/${maxRetries})`,
|
|
||||||
model,
|
|
||||||
response.usageMetadata,
|
|
||||||
durationMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rawText = (response.text || '').trim();
|
|
||||||
|
|
||||||
// Try to extract and parse JSON
|
|
||||||
const jsonStr = this.extractJSON(rawText);
|
|
||||||
const data = JSON.parse(jsonStr) as T;
|
const data = JSON.parse(jsonStr) as T;
|
||||||
|
return { data, usage: response.usage };
|
||||||
// Validate with Zod schema if provided
|
|
||||||
if (options.zodSchema) {
|
|
||||||
const validated = options.zodSchema.parse(data);
|
|
||||||
return { data: validated as T, usage: lastUsage };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data, usage: lastUsage };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error as Error;
|
this.logger.error('Failed to parse JSON response', error);
|
||||||
|
throw new Error('Failed to parse AI response as JSON');
|
||||||
const isParseError =
|
|
||||||
error instanceof SyntaxError ||
|
|
||||||
(error instanceof ZodError) ||
|
|
||||||
(error instanceof Error &&
|
|
||||||
error.message.includes('Failed to extract JSON'));
|
|
||||||
|
|
||||||
const isRetryable = isParseError || this.isRetryableError(error);
|
|
||||||
|
|
||||||
if (isRetryable && attempt < maxRetries) {
|
|
||||||
const backoffMs = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
|
|
||||||
this.logger.warn(
|
|
||||||
`JSON generation attempt ${attempt}/${maxRetries} failed (${error instanceof Error ? error.message : 'unknown'}). Retrying in ${backoffMs}ms...`,
|
|
||||||
);
|
|
||||||
await this.sleep(backoffMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log failure details
|
|
||||||
if (error instanceof ZodError) {
|
|
||||||
this.logger.error(
|
|
||||||
`Zod validation failed after ${attempt} attempts: ${error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All retries exhausted
|
|
||||||
throw new GeminiException(
|
|
||||||
`Failed to generate valid JSON after ${maxRetries} attempts: ${lastError?.message}`,
|
|
||||||
GeminiErrorType.JSON_PARSE_FAILED,
|
|
||||||
lastError,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Image Generation
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an image using Google Imagen (Nano Banana)
|
|
||||||
*
|
|
||||||
* @param prompt - Image description
|
|
||||||
* @returns Base64 encoded image data URI
|
|
||||||
*/
|
|
||||||
async generateImage(prompt: string): Promise<string> {
|
|
||||||
this.ensureAvailable();
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
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