Compare commits

4 Commits

Author SHA1 Message Date
bilalgursen
37baeb9172 fix: drizzle/ ve db-goc.mjs'i docker build context'ine al
All checks were successful
Deploy / deploy (push) Successful in 9m37s
.dockerignore drizzle/'ı ve scripts/* ile db-goc.mjs'i dışlıyordu;
runner katmanındaki COPY'ler 'not found' ile build'i kırıyordu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:13:45 +03:00
bilalgursen
45775c8c3f feat: prod app.db şemasını açılışta drizzle migration'larıyla eşitle
Some checks failed
Deploy / deploy (push) Failing after 13s
Volume'deki app.db yalnızca ilk kurulumda seed'den kopyalanıyordu; sonraki
deploy'larda şemaya eklenen kolonlar (son örnek: kredi_bitti_at,
kredi_hatirlatma_gonderildi_at) prod'a hiç ulaşmıyor ve 'no such column'
hatalarıyla kırılıyordu.

- drizzle-kit generate ile baseline migration üretildi (drizzle/0000_baslangic)
- scripts/db-goc.mjs: entrypoint'te server'dan önce koşan migration runner;
  drizzle migrator protokolünü birebir taklit eder (__drizzle_migrations,
  sha256, created_at=journal.when) ama yalnızca @libsql/client'a dayanır —
  drizzle-orm standalone imajda bundle'landığından paket olarak çözülemiyor
- Benimseme: migration kaydı olmayan mevcut volume'lerde baseline additive
  uzlaştırılır (IF NOT EXISTS + snapshot'tan eksik kolon ALTER'ları) ve
  damgalanır; sonraki migration'lar normal akışla uygulanır
- sema-guvence.ts kaldırıldı: elle DDL aynalama yerini migration'lara bıraktı
- Yeni akış: şema değişikliğinde pnpm db:generate ile migration commit'lenir

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:05:42 +03:00
bilalgursen
18d5b42136 AI katmanını OpenRouter tek kapısına geçir ve rapor üretimini hızlandır
All checks were successful
Deploy / deploy (push) Successful in 9m30s
- Tüm AI akışı OpenRouter üzerinden (openai SDK, varsayılan model
  deepseek/deepseek-v4-flash, OPENROUTER_MODEL ile değiştirilebilir);
  Anthropic/Gemini/lokal sağlayıcı seçimi ve SDK'ları kaldırıldı.
- Rapor: aday havuzu dilim başına 20 (60 program, ~8k token prompt);
  AI şeması sabit uzunluklu üç liste (hayal 5 / dengeli 13 / garanti 6 —
  dağılım şema düzeyinde zorlanır), sıra + dilim + riskNotu + trendOzeti
  gerçek sıra geçmişinden deterministik üretilir; 184 sn → ~12 sn.
- Sohbet: akış ortası zaman aşımı artık sessizce yutulmaz (kesik cevap
  tam cevap gibi kaydedilmiyordu, kredi iadesi çalışmıyordu); istemci
  hata anında durumu sunucudan senkronlar, boş balon bırakmaz.
- aiHataMesaji, openai SDK'nın .name'i "Error" kalan abort/timeout
  hatalarını da yakalar; meraklisina şeması yeni akışla senkronlandı.
- CI: OPENROUTER_API_KEY / OPENROUTER_MODEL secret'ları; eski AI
  secret'ları kaldırıldı. next.config serverExternalPackages sadeleşti.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 00:37:54 +03:00
bilalgursen
4c618781f6 Docker build'ini optimize et: OOM kökü çözümü + bellek sınırlı builder
All checks were successful
Deploy / deploy (push) Successful in 7m33s
Prod VPS'te next build sırasında makineyi kilitleyen OOM'un çözümü:

- serverExternalPackages'a @google/genai, @anthropic-ai/sdk, resend eklendi;
  Turbopack artık genai'nin protobufjs/google-auth-library/ws zincirini
  derlemiyor (c2aba25 sonrası OOM'un tetiği buydu).
- Docker build'inde (BUILD_KAYNAK_KISITLI=1) bellek kısıtları: Turbopack 1.5G
  hedefi, statik üretim tek worker × 4 sayfa, prerender source map kapalı.
- Dockerfile: runner'a tam node_modules kopyası kaldırıldı (imaj ~1.9G→966M),
  pnpm store + .next/cache BuildKit cache mount'ları, açık COPY listesi,
  V8 heap sınırı.
- scripts/standalone-linkleri-onar.sh: Turbopack ESM external'ların kök
  node_modules symlink'ini nft trace'ine yazmıyor; eksik linkler build sonrası
  tamamlanmazsa runtime MODULE_NOT_FOUND ile düşer.
- ci.yaml: build bellek sınırlı buildx docker-container builder'da (2200m,
  max-parallelism 1; BuildKit --memory bayrağını yok sayıyor), deploy
  up -d --no-build ile ayrıldı, temizlik if: always() ile her koşuda.
- compose: app'e mem_limit 1g + oom_score_adj -600.
- Kullanılmayan simple-icons ve @react-pdf/renderer kaldırıldı; npm artığı
  package-lock.json silindi.

Doğrulama: imaj 2 CPU/2GiB VM'de sıfırdan build edildi, konteynerde ana
sayfa/universite 200 ve üç ESM external import OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 17:20:17 +03:00
27 changed files with 1747 additions and 13497 deletions

View File

@@ -1,13 +1,27 @@
node_modules node_modules
.next .next
.git .git
.gitea
.agents .agents
.claude .claude
.mcp.json .mcp.json
# Yalnızca kök dizindeki .md dosyaları (path ayracını geçmez);
# content/rehber/*.md build için gerekli, etkilenmez.
*.md *.md
.env* .env*
!.env.example !.env.example
drizzle/ docs/
# scripts/ build'de kullanılmaz; istisnalar: entrypoint + standalone link
# onarımı + açılış migration runner'ı (drizzle/ da onun için imaja girer)
scripts/*
!scripts/docker-entrypoint.sh
!scripts/standalone-linkleri-onar.sh
!scripts/db-goc.mjs
package-lock.json
skills-lock.json
components.json
eslint.config.mjs
drizzle.config.ts
data/ data/
!data/app.db !data/app.db
!data/yokatlas.db !data/yokatlas.db

View File

@@ -26,12 +26,54 @@ jobs:
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }} IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }} IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }} IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
AI_SAGLAYICI=${{ secrets.AI_SAGLAYICI }} OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}
EOF EOF
- name: Build & deploy # BuildKit'te "docker build --memory" YOK SAYILIR (moby/buildkit#1362);
# gerçek sınır ancak buildkitd'yi kendi cgroup'u olan bir container'da
# (buildx docker-container driver) koşturarak konur. Böylece build OOM
# olursa yalnızca builder container'ı ölür — uygulama + Gitea ayakta kalır
# ve adım 35-90 dk sessiz kilitlenme yerine hızlı, görünür hata verir.
#
# Sınırlar container İLK oluşturulduğunda donar; değerleri değiştirmek için
# önce: docker buildx rm sinirli && docker rm -f buildx_buildkit_sinirli0
- name: Sınırlı builder hazırla
run: | run: |
docker compose -p kolaytercih up -d --build --remove-orphans cat > buildkitd.toml <<'EOF'
docker image prune -f [worker.oci]
enabled = true
max-parallelism = 1
EOF
# Act container'ı her koşuda sıfırdan geldiği için client kaydı hep
# yeniden oluşturulur; host'taki buildx_buildkit_sinirli0 container'ı
# (ve build cache'i) varsa bootstrap'ta aynen yeniden kullanılır.
docker buildx create --name sinirli \
--driver docker-container \
--driver-opt memory=2200m \
--driver-opt memory-swap=4200m \
--driver-opt cpu-quota=150000 \
--buildkitd-config ./buildkitd.toml \
2>/dev/null || true
docker buildx inspect sinirli --bootstrap >/dev/null
- name: Build (sınırlı builder)
run: |
docker compose -p kolaytercih build --builder sinirli
# docker-container driver'da imajın daemon'a --load ile düştüğünü doğrula;
# düşmediyse aynı cache'ten yeniden build edip elle yükle.
docker image inspect kolaytercih-app >/dev/null 2>&1 || \
docker buildx build --builder sinirli --load -t kolaytercih-app .
- name: Deploy
run: docker compose -p kolaytercih up -d --no-build --remove-orphans
- name: Temizlik (başarısız build'de de çalışır)
if: always()
run: |
docker image prune -f || true
# sinirli builder'ın cache'ini 4 GB'ta tut (flag adı buildx sürümüne göre değişti)
docker buildx prune --builder sinirli -f --max-used-space 4gb 2>/dev/null || \
docker buildx prune --builder sinirli -f --keep-storage 4gb || true
# eski varsayılan-builder cache artıkları
docker builder prune -f --filter "until=72h" || true

View File

@@ -1,6 +1,8 @@
FROM node:20-alpine AS base # syntax=docker/dockerfile:1
# pnpm kurulumu FROM node:20-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
# ── Bağımlılık katmanı ── # ── Bağımlılık katmanı ──
@@ -9,17 +11,36 @@ WORKDIR /app
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok) # better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile # pnpm store cache mount'ta yaşar: lockfile değişse bile inen paketler yeniden inmez
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# ── Derleme katmanı ── # ── Derleme katmanı ──
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . # Build girdileri açık listelenir: docs/, scripts/, CI dosyası değişiklikleri
# bu katmanı invalide edemez. next build'in okuduğu her şey burada:
# content/ (rehber.ts), data/ (db.ts prerender), public/ (opengraph-image).
COPY package.json next.config.ts tsconfig.json postcss.config.mjs ./
COPY scripts/standalone-linkleri-onar.sh ./scripts/
COPY public ./public
COPY data ./data
COPY content ./content
COPY src ./src
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli # V8 heap sınırı ana süreç + statik üretim worker'larına miras kalır.
RUN mkdir -p data && pnpm build # Turbopack'ın Rust tarafı bu sınırın dışında; onun hedefi next.config.ts'te
# turbopackMemoryLimit ile verilir (BUILD_KAYNAK_KISITLI bloğu).
ENV NODE_OPTIONS="--max-old-space-size=1536"
ENV BUILD_KAYNAK_KISITLI=1
RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \
pnpm build
# Turbopack, ESM external'ların (@google/genai vb.) kök node_modules symlink'ini
# nft trace'ine yazmıyor; eksik linkler tamamlanmazsa runtime MODULE_NOT_FOUND
# ile düşer. Ayrıntı script'in başındaki açıklamada.
RUN sh scripts/standalone-linkleri-onar.sh .next/standalone
# ── Çalıştırma katmanı ── # ── Çalıştırma katmanı ──
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
@@ -31,14 +52,21 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
# Bağımlılıklar # standalone çıktı, file-tracing ile seçilmiş kendi minimal node_modules'unu
COPY --from=deps /app/node_modules ./node_modules # içerir (serverExternalPackages dahil) — tam node_modules kopyalanmaz.
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Trace, data/yokatlas.db'yi de standalone'a koyuyor; runtime'da /app/data
# volume'ü bunu gölgelediği için imajdan atılır (~22M).
RUN rm -rf /app/data
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar # Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
COPY data/ ./seed/ COPY data/ ./seed/
# Migration'lar: entrypoint açılışta db-goc.mjs ile volume'deki app.db'ye uygular
COPY drizzle ./drizzle
COPY scripts/db-goc.mjs ./scripts/db-goc.mjs
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
# SQLite veri dizini # SQLite veri dizini

Binary file not shown.

View File

@@ -3,6 +3,11 @@ services:
build: . build: .
container_name: kolaytercih container_name: kolaytercih
restart: unless-stopped restart: unless-stopped
# Host'u korur: uygulama bu sınırın üstüne çıkamaz. OOM anında kernel'ın
# önce build süreçlerini (skor 0) öldürmesi için negatif oom_score_adj.
mem_limit: 1g
mem_reservation: 256m
oom_score_adj: -600
volumes: volumes:
- app-data:/app/data - app-data:/app/data
env_file: env_file:

131
drizzle/0000_baslangic.sql Normal file
View File

@@ -0,0 +1,131 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY NOT NULL,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `chat_messages` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`role` text NOT NULL,
`content` text NOT NULL,
`client_message_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `chat_messages_client_message_id_unique` ON `chat_messages` (`client_message_id`);--> statement-breakpoint
CREATE INDEX `chat_user_created` ON `chat_messages` (`user_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `credit_ledger` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`delta` integer NOT NULL,
`reason` text NOT NULL,
`ref_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `ledger_reason_ref` ON `credit_ledger` (`reason`,`ref_id`);--> statement-breakpoint
CREATE INDEX `ledger_user` ON `credit_ledger` (`user_id`);--> statement-breakpoint
CREATE TABLE `orders` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`product` text NOT NULL,
`amount_kurus` integer NOT NULL,
`credits` integer NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`iyzico_token` text,
`iyzico_payment_id` text,
`created_at` integer NOT NULL,
`paid_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `orders_user` ON `orders` (`user_id`);--> statement-breakpoint
CREATE TABLE `reports` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`params` text NOT NULL,
`result` text,
`revision_count` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `reports_user_id_unique` ON `reports` (`user_id`);--> statement-breakpoint
CREATE TABLE `saved_lists` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`items` text NOT NULL,
`profil` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `saved_lists_user_id_unique` ON `saved_lists` (`user_id`);--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`token` text NOT NULL,
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE TABLE `tadimlik_havuzu` (
`id` text PRIMARY KEY NOT NULL,
`kova_slug` text NOT NULL,
`tur` text NOT NULL,
`kategori_slug` text NOT NULL,
`program_id` text NOT NULL,
`dilim` text NOT NULL,
`gerekce` text NOT NULL,
`risk_notu` text NOT NULL,
`trend_ozeti` text NOT NULL,
`program` text NOT NULL,
`ornek_sira` integer NOT NULL,
`uretim_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tadimlik_anahtar` ON `tadimlik_havuzu` (`kova_slug`,`tur`,`kategori_slug`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`credit_balance` integer DEFAULT 0 NOT NULL,
`has_paket` integer DEFAULT false NOT NULL,
`kredi_bitti_at` integer,
`kredi_hatirlatma_gonderildi_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer,
`updated_at` integer
);

View File

@@ -0,0 +1,898 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a802fded-01d5-4d17-8b6f-fca5dfc2920d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chat_messages": {
"name": "chat_messages",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_message_id": {
"name": "client_message_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"chat_messages_client_message_id_unique": {
"name": "chat_messages_client_message_id_unique",
"columns": [
"client_message_id"
],
"isUnique": true
},
"chat_user_created": {
"name": "chat_user_created",
"columns": [
"user_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {
"chat_messages_user_id_user_id_fk": {
"name": "chat_messages_user_id_user_id_fk",
"tableFrom": "chat_messages",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"credit_ledger": {
"name": "credit_ledger",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"delta": {
"name": "delta",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ref_id": {
"name": "ref_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_reason_ref": {
"name": "ledger_reason_ref",
"columns": [
"reason",
"ref_id"
],
"isUnique": true
},
"ledger_user": {
"name": "ledger_user",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"credit_ledger_user_id_user_id_fk": {
"name": "credit_ledger_user_id_user_id_fk",
"tableFrom": "credit_ledger",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"orders": {
"name": "orders",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"product": {
"name": "product",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_kurus": {
"name": "amount_kurus",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credits": {
"name": "credits",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"iyzico_token": {
"name": "iyzico_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"iyzico_payment_id": {
"name": "iyzico_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"orders_user": {
"name": "orders_user",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"orders_user_id_user_id_fk": {
"name": "orders_user_id_user_id_fk",
"tableFrom": "orders",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reports": {
"name": "reports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"params": {
"name": "params",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revision_count": {
"name": "revision_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"reports_user_id_unique": {
"name": "reports_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"reports_user_id_user_id_fk": {
"name": "reports_user_id_user_id_fk",
"tableFrom": "reports",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"saved_lists": {
"name": "saved_lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"items": {
"name": "items",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profil": {
"name": "profil",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"saved_lists_user_id_unique": {
"name": "saved_lists_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"saved_lists_user_id_user_id_fk": {
"name": "saved_lists_user_id_user_id_fk",
"tableFrom": "saved_lists",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tadimlik_havuzu": {
"name": "tadimlik_havuzu",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kova_slug": {
"name": "kova_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tur": {
"name": "tur",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kategori_slug": {
"name": "kategori_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"program_id": {
"name": "program_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dilim": {
"name": "dilim",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"gerekce": {
"name": "gerekce",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"risk_notu": {
"name": "risk_notu",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"trend_ozeti": {
"name": "trend_ozeti",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"program": {
"name": "program",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ornek_sira": {
"name": "ornek_sira",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"uretim_at": {
"name": "uretim_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tadimlik_anahtar": {
"name": "tadimlik_anahtar",
"columns": [
"kova_slug",
"tur",
"kategori_slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"credit_balance": {
"name": "credit_balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"has_paket": {
"name": "has_paket",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"kredi_bitti_at": {
"name": "kredi_bitti_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kredi_hatirlatma_gonderildi_at": {
"name": "kredi_hatirlatma_gonderildi_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1786053613118,
"tag": "0000_baslangic",
"breakpoints": true
}
]
}

View File

@@ -1,11 +1,43 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
// Dockerfile builder katmanı BUILD_KAYNAK_KISITLI=1 verir: imaj build'i prod
// VPS'te çalışan uygulamayla aynı makinede koştuğu için bellek/paralellik kısılır.
// Lokal build ve dev bu kısıtlardan etkilenmez.
const kaynakKisitli = process.env.BUILD_KAYNAK_KISITLI === "1";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// PPR: statik kabuk anında servis edilir, dinamik parçalar (UserNav vb.) // PPR: statik kabuk anında servis edilir, dinamik parçalar (UserNav vb.)
// Suspense sınırlarının içinde akar. Bkz. docs/cacheComponents.md. // Suspense sınırlarının içinde akar. Bkz. docs/cacheComponents.md.
cacheComponents: true, cacheComponents: true,
output: "standalone", output: "standalone",
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"], // Server-only paketler bundle dışında tutulur: Turbopack bunları derlemez
// (build belleği ciddi düşer), runtime'da native require ile yüklenir ve
// standalone trace'i imaja kopyalar. (Tarihçe: @google/genai'nin sürüklediği
// bağımlılık zinciri VPS'te build OOM'unu tetiklemişti; AI katmanı artık
// yalnızca openai paketiyle OpenRouter'a gidiyor.)
serverExternalPackages: [
"better-sqlite3",
"iyzipay",
"@libsql/client",
"openai",
"resend",
],
...(kaynakKisitli
? {
// cacheComponents açıkken prerender fazında source map üretimi varsayılan
// açık; 1900+ statik sayfada ciddi bellek maliyeti var.
enablePrerenderSourceMaps: false,
experimental: {
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
// hedefi byte cinsinden ayrıca verilir (1.5 GB).
turbopackMemoryLimit: 1_610_612_736,
// Statik üretim: varsayılan 4 worker × 8 eşzamanlı sayfa yerine tek
// worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir.
cpus: 1,
staticGenerationMaxConcurrency: 4,
},
}
: {}),
async redirects() { async redirects() {
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme). // Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
return [ return [

12500
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,15 +12,14 @@
"detay": "tsx scripts/detay.ts", "detay": "tsx scripts/detay.ts",
"logolar": "tsx scripts/logo-indir.ts", "logolar": "tsx scripts/logo-indir.ts",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "node scripts/db-goc.mjs",
"tadimlik": "tsx scripts/tadimlik-uret.ts" "tadimlik": "tsx scripts/tadimlik-uret.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.112.4",
"@cds/city": "^1.1.0", "@cds/city": "^1.1.0",
"@google/genai": "^2.15.0",
"@libsql/client": "^0.17.4", "@libsql/client": "^0.17.4",
"@number-flow/react": "^0.6.2", "@number-flow/react": "^0.6.2",
"@react-pdf/renderer": "^4.5.1",
"better-auth": "^1.6.23", "better-auth": "^1.6.23",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -34,6 +33,7 @@
"motion": "^12.42.2", "motion": "^12.42.2",
"next": "16.2.10", "next": "16.2.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^7.4.0",
"radix-ui": "^1.6.3", "radix-ui": "^1.6.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -43,7 +43,6 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"resend": "^6.18.0", "resend": "^6.18.0",
"shiki": "^4.3.1", "shiki": "^4.3.1",
"simple-icons": "^16.27.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"turkey-map-react": "^2.0.5", "turkey-map-react": "^2.0.5",

752
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

157
scripts/db-goc.mjs Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* app.db şemasını drizzle/ altındaki migration'larla eşitler — konteyner
* açılışında (docker-entrypoint.sh) node server.js'den ÖNCE koşar, lokalde
* `pnpm db:migrate` ile çalıştırılır. Idempotenttir.
*
* Neden drizzle-orm'un kendi migrate()'i değil: standalone çıktıda drizzle-orm
* Turbopack'çe bundle'landığı için /app/node_modules'ta paket olarak yok;
* @libsql/client ise serverExternalPackages'ta olduğundan her zaman çözülür.
* Bu script drizzle migrator protokolünü birebir taklit eder (__drizzle_migrations
* tablosu, sha256 hash, created_at = journal.when) — ileride resmi migrate()'e
* geçiş kayıtları bozmaz.
*
* Benimseme (adoption): migration akışından önce doldurulmuş prod volume'lerinde
* __drizzle_migrations tablosu yoktur ama şema (kısmen) vardır. Bu durumda
* baseline (0000) bir kez additive uygulanır — CREATE'ler IF NOT EXISTS'e
* çevrilir, eksik kolonlar snapshot'tan ALTER TABLE ile eklenir — ve baseline
* uygulanmış olarak damgalanır. Sonraki migration'lar (0001+) normal akışla gelir.
*/
import { createClient } from "@libsql/client";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import process from "node:process";
const GOC_KLASORU = process.env.DRIZZLE_DIR ?? join(process.cwd(), "drizzle");
const DB_URL = process.env.APP_DB_URL ?? "file:./data/app.db";
const GOC_TABLOSU_DDL = `CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
)`;
/** Journal'daki her girdiyi { when, tag, hash, ifadeler[] } olarak yükler. */
function gocleriOku() {
const journal = JSON.parse(
readFileSync(join(GOC_KLASORU, "meta", "_journal.json"), "utf8"),
);
return journal.entries.map((girdi) => {
const sql = readFileSync(join(GOC_KLASORU, `${girdi.tag}.sql`), "utf8");
return {
when: girdi.when,
tag: girdi.tag,
hash: createHash("sha256").update(sql).digest("hex"),
ifadeler: sql
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter(Boolean),
};
});
}
async function tabloVar(db, ad) {
const r = await db.execute({
sql: "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
args: [ad],
});
return r.rows.length > 0;
}
/** Snapshot'taki default değerini SQL literaline çevirir (string zaten tırnaklı gelir). */
function sqlVarsayilan(deger) {
return typeof deger === "string" ? deger : String(deger);
}
/**
* Benimseme adımı: baseline'ı additive uygular.
* 1) CREATE TABLE/INDEX ifadeleri IF NOT EXISTS ile koşulur (eksik tablolar gelir).
* 2) Mevcut tablolarda eksik kolonlar baseline snapshot'ından ALTER TABLE ile eklenir.
*/
async function baselineIleUzlastir(db, baseline) {
for (const ifade of baseline.ifadeler) {
const korunmus = ifade
.replace(/^CREATE TABLE `/, "CREATE TABLE IF NOT EXISTS `")
.replace(/^CREATE INDEX `/, "CREATE INDEX IF NOT EXISTS `")
.replace(/^CREATE UNIQUE INDEX `/, "CREATE UNIQUE INDEX IF NOT EXISTS `");
await db.execute(korunmus);
}
const snapshot = JSON.parse(
readFileSync(join(GOC_KLASORU, "meta", "0000_snapshot.json"), "utf8"),
);
for (const tablo of Object.values(snapshot.tables)) {
const mevcut = await db.execute(`PRAGMA table_info(\`${tablo.name}\`)`);
const mevcutKolonlar = new Set(mevcut.rows.map((r) => r.name));
for (const kolon of Object.values(tablo.columns)) {
if (mevcutKolonlar.has(kolon.name)) continue;
let ddl = `ALTER TABLE \`${tablo.name}\` ADD COLUMN \`${kolon.name}\` ${kolon.type}`;
if (kolon.default !== undefined) {
ddl += ` DEFAULT ${sqlVarsayilan(kolon.default)}`;
if (kolon.notNull) ddl += " NOT NULL";
} else if (kolon.notNull) {
// Dolu tabloya default'suz NOT NULL kolon eklenemez (SQLite kısıtı);
// nullable ekleyip uyarıyoruz — uygulama zaten değeri hep yazıyor.
console.warn(
`[db-goc] uyarı: ${tablo.name}.${kolon.name} NOT NULL ama default'suz — nullable eklendi`,
);
}
await db.execute(ddl);
console.log(`[db-goc] kolon eklendi: ${tablo.name}.${kolon.name}`);
}
}
}
async function main() {
const gocler = gocleriOku();
const db = createClient({
url: DB_URL,
authToken: process.env.APP_DB_AUTH_TOKEN,
});
try {
// Benimseme: şema var ama migration kaydı yok → baseline'ı uzlaştır + damgala.
const kayitVar = await tabloVar(db, "__drizzle_migrations");
if (!kayitVar && (await tabloVar(db, "user"))) {
console.log("[db-goc] migration kaydı yok, mevcut şema benimseniyor...");
await baselineIleUzlastir(db, gocler[0]);
await db.execute(GOC_TABLOSU_DDL);
await db.execute({
sql: 'INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)',
args: [gocler[0].hash, gocler[0].when],
});
console.log(`[db-goc] baseline damgalandı: ${gocler[0].tag}`);
}
// Normal akış: son uygulanan migration'dan sonrakileri sırayla uygula.
await db.execute(GOC_TABLOSU_DDL);
const son = await db.execute(
'SELECT created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1',
);
const sonZaman = son.rows.length ? Number(son.rows[0].created_at) : 0;
for (const goc of gocler) {
if (goc.when <= sonZaman) continue;
await db.batch(
[
...goc.ifadeler,
{
sql: 'INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)',
args: [goc.hash, goc.when],
},
],
"write",
);
console.log(`[db-goc] uygulandı: ${goc.tag}`);
}
console.log("[db-goc] şema güncel");
} finally {
db.close();
}
}
main().catch((hata) => {
console.error("[db-goc] migration başarısız:", hata);
process.exit(1);
});

View File

@@ -9,9 +9,14 @@ for ek in wal shm; do
fi fi
done done
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) şema tohumu kopyalanır # app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) tohum kopyalanır
# (tohum tadimlik_havuzu içeriği de taşır, salt şema değil)
if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then
cp /app/seed/app.db /app/data/ cp /app/seed/app.db /app/data/
fi fi
# Volume'deki app.db şeması güncel şemayla eşitlenir: drizzle/ altındaki
# migration'lar uygulanır (idempotent; ilk koşuda mevcut şema benimsenir).
node /app/scripts/db-goc.mjs
exec node server.js exec node server.js

View File

@@ -0,0 +1,41 @@
#!/bin/sh
# Next standalone çıktısında eksik kök node_modules linklerini tamamlar.
#
# Neden: Turbopack, serverExternalPackages'taki ESM paketleri (openai,
# resend) çalışma anında bare import ile yükler ama nft
# trace'ine yalnızca .pnpm gerçek yollarını yazar — kök symlink standalone'a
# kopyalanmaz ve import MODULE_NOT_FOUND ile düşer. (CJS external'larda —
# better-sqlite3, iyzipay — symlink trace'e giriyor, sorun yalnızca ESM'de.)
# Bu script .pnpm altındaki her paket için eksik kök girdisini symlink'ler;
# mevcut girdilere dokunmaz. Yeni bir external eklendiğinde de kendiliğinden
# kapsar, paket adı/versiyon sabitlemez.
set -eu
KOK="${1:-.next/standalone}/node_modules"
[ -d "$KOK/.pnpm" ] || { echo "standalone .pnpm yok, atlandi: $KOK"; exit 0; }
cd "$KOK"
for d in .pnpm/*/node_modules/*; do
[ -e "$d" ] || continue
base=${d##*/}
case "$base" in
.*) continue ;;
@*)
for sub in "$d"/*; do
[ -d "$sub" ] || continue
hedef="$base/${sub##*/}"
if [ ! -e "$hedef" ]; then
mkdir -p "$base"
ln -s "../$sub" "$hedef"
echo "link: $hedef"
fi
done
;;
*)
if [ ! -e "$base" ]; then
ln -s "$d" "$base"
echo "link: $base"
fi
;;
esac
done

View File

@@ -10,8 +10,8 @@
* pnpm tadimlik --bekleme=13000 # LLM çağrıları arası bekleme (ms) * pnpm tadimlik --bekleme=13000 # LLM çağrıları arası bekleme (ms)
* *
* AI anahtarı yoksa (dev) mock metin üretir — akış uçtan uca test edilebilir. * AI anahtarı yoksa (dev) mock metin üretir — akış uçtan uca test edilebilir.
* Gemini ücretsiz katmanı dakikada ~5 istek kabul eder; gerçek üretimde * OpenRouter'da katı bir dakikalık limit yok; --bekleme varsayılanı (500 ms)
* --bekleme=13000 gibi bir tempo kullan. * genelde yeterli, 429 görürsen tempoyu artır.
* Anonim trafik LLM'e asla istek atmaz; tek maliyet bu script'in koşusudur. * Anonim trafik LLM'e asla istek atmaz; tek maliyet bu script'in koşusudur.
*/ */
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -128,8 +128,6 @@ async function metinUret(
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
// Not: Gemini'de thinking bütçesi de bu sınırdan düşer; dar tutulursa
// çıktı kırpılıp şema doğrulaması boşa düşüyor.
return yapilandirilmisUret({ return yapilandirilmisUret({
system: SISTEM, system: SISTEM,
kullanici, kullanici,

View File

@@ -15,10 +15,12 @@ import { ListemGovde } from "./listem-govde";
import { ListemBosCta } from "./listem-bos-cta"; import { ListemBosCta } from "./listem-bos-cta";
import { ListeUretici } from "./liste-uretici"; import { ListeUretici } from "./liste-uretici";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort // listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA // sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
// dolmalı ki kredi iadesi her zaman çalışabilsin. // SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
export const maxDuration = 120; // bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin
// istek süresi limiti de >= 200 sn olmalı.
export const maxDuration = 300;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Listem", title: "Listem",

View File

@@ -121,6 +121,7 @@ export function SohbetClient({
{ id: asistanId, role: "assistant", content: "" }, { id: asistanId, role: "assistant", content: "" },
]); ]);
let birikmis = "";
try { try {
const res = await fetch("/api/soru", { const res = await fetch("/api/soru", {
method: "POST", method: "POST",
@@ -151,7 +152,6 @@ export function SohbetClient({
}); });
const reader = res.body!.getReader(); const reader = res.body!.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let birikmis = "";
for (;;) { for (;;) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
@@ -165,7 +165,26 @@ export function SohbetClient({
// harcamasını görmez; bakiye orada da güncellensin. // harcamasını görmez; bakiye orada da güncellensin.
router.refresh(); router.refresh();
} catch { } catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin."); // Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
// bu senkronla temizlenir). Senkron da düşerse boş balonu elle kaldır.
try {
const res = await fetch("/api/soru");
const data = res.ok ? await res.json() : null;
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi);
} catch {}
if (!birikmis) {
setMesajlar((m) => m.filter((x) => x.id !== asistanId));
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(mesaj);
}
router.refresh();
toast.error(
birikmis
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
);
} finally { } finally {
setBekliyor(false); setBekliyor(false);
} }

View File

@@ -18,8 +18,9 @@ flowchart TD
H --> S{"Havuzda 24'ten az program mı kaldı?"} H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir") S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
G --> H G --> H
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe + risk notu ekler") S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
Y --> L("24 tercihlik liste") Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
R --> L("24 tercihlik liste")
L --> L1("üstte 46 hayal") L --> L1("üstte 46 hayal")
L --> L2("ortada 1214 dengeli") L --> L2("ortada 1214 dengeli")
L --> L3("sonda 57 garanti") L --> L3("sonda 57 garanti")
@@ -39,7 +40,7 @@ export function HuniSemasi() {
return ( return (
<ElCizimiSema <ElCizimiSema
tanim={SEMA} tanim={SEMA}
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçer." ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
figcaption={ figcaption={
<> <>
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "} Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}

View File

@@ -43,8 +43,15 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
function aiHataMesaji(err: unknown): string { function aiHataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message; if (err instanceof RaporUretimHatasi) return err.message;
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi // AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) — ada göre yakala. // hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) ve openai/anthropic
if (err instanceof Error && /abort|timeout/i.test(err.name)) { // bu sınıflarda .name'i "Error" bırakıyor — ada ek olarak sınıf adına ve
// mesaja da bak ("Request was aborted." / "Request timed out.").
if (
err instanceof Error &&
/abort|timeout|timed out/i.test(
`${err.name} ${err.constructor.name} ${err.message}`,
)
) {
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; harcanan kredin otomatik iade edildi. Tekrar dener misin?"; return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; harcanan kredin otomatik iade edildi. Tekrar dener misin?";
} }
if (err instanceof Error && err.message === "AI_KEY_MISSING") { if (err instanceof Error && err.message === "AI_KEY_MISSING") {

View File

@@ -6,10 +6,8 @@
export async function register() { export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return; if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.NEXT_PHASE === "phase-production-build") return; if (process.env.NEXT_PHASE === "phase-production-build") return;
// Migration akışı olmadığından sonradan eklenen tablolar açılışta güvenceye // Şema, konteyner açılışında scripts/db-goc.mjs'in uyguladığı drizzle
// alınır (additive DDL, idempotent) — mevcut prod volume'ü seed'den kopyalanmaz. // migration'larıyla güncellenir (bkz. docker-entrypoint.sh); burada DDL koşmaz.
const { semayiGuvenceyeAl } = await import("./lib/appdb/sema-guvence");
await semayiGuvenceyeAl();
const { krediHatirlatmaBaslat } = await import("./lib/kredi-hatirlatma"); const { krediHatirlatmaBaslat } = await import("./lib/kredi-hatirlatma");
krediHatirlatmaBaslat(); krediHatirlatmaBaslat();
} }

View File

@@ -1,43 +1,40 @@
import { z } from "zod"; import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod"; import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
import { ThinkingLevel } from "@google/genai";
import {
getAnthropic,
getGemini,
raporModeli,
saglayici,
sohbetModeli,
} from "./client";
/** /**
* Sağlayıcıdan bağımsız AI çağrı katmanı. Anthropic ve Gemini'nin SDK'ları * AI çağrı katmanı: tüm istekler OpenRouter'ın OpenAI-uyumlu API'sine gider
* yapılandırılmış çıktı ve stream için farklı şekiller kullanıyor; rapor ve * (tek kapı — kullanıcı kararı, 7 Ağu 2026). Rapor ve sohbet kodu SDK/istek
* sohbet kodu bu farkı görmesin diye tek arayüz burada toplanıyor. * ayrıntısını görmesin diye tek arayüz burada toplanıyor.
*/ */
/** /**
* Gemini JSON Schema'nın yalnızca bir alt kümesini destekliyor; zod'un ürettiği * zod şemasını JSON Schema'ya çevirir: z.toJSONSchema'nın eklediği $schema
* $schema ve additionalProperties alanları isteği reddettiriyor, ayıklanıyor. * alanı ayıklanır, her object düğümüne additionalProperties: false eklenir —
* OpenAI-uyumlu strict json_schema doğrulayıcıları bunu şart koşuyor.
*/ */
function geminiSemasi(sema: z.ZodType): unknown { function jsonSemasi(sema: z.ZodType): Record<string, unknown> {
const temizle = (dugum: unknown): unknown => { const donustur = (dugum: unknown): unknown => {
if (Array.isArray(dugum)) return dugum.map(temizle); if (Array.isArray(dugum)) return dugum.map(donustur);
if (dugum && typeof dugum === "object") { if (dugum && typeof dugum === "object") {
const cikti: Record<string, unknown> = {}; const cikti: Record<string, unknown> = {};
for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) { for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) {
if (k === "$schema" || k === "additionalProperties") continue; if (k === "$schema") continue;
cikti[k] = temizle(v); cikti[k] = donustur(v);
}
if (cikti.type === "object" && cikti.additionalProperties === undefined) {
cikti.additionalProperties = false;
} }
return cikti; return cikti;
} }
return dugum; return dugum;
}; };
return temizle(z.toJSONSchema(sema)); return donustur(z.toJSONSchema(sema)) as Record<string, unknown>;
} }
/** /**
* Gemini ücretsiz katmanı yoğunlukta 503/429 döndürüyor; bu geçici hatalarda * OpenRouter yoğunlukta/limitte 429, üst sağlayıcı arızasında 5xx dönebiliyor;
* artan bekleme ile yeniden dene. Kalıcı hatalar (400, 403) olduğu gibi fırlar. * bu geçici hatalarda artan bekleme ile yeniden dene. Kalıcı hatalar
* (400, 401, 403) olduğu gibi fırlar.
*/ */
const GECICI_KODLAR = new Set([429, 500, 502, 503, 504]); const GECICI_KODLAR = new Set([429, 500, 502, 503, 504]);
@@ -65,9 +62,10 @@ async function geciciHatadaTekrarla<T>(
* Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın * Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın
* (server action) platform limitinden önce dolmalı ki hata iade kodunun * (server action) platform limitinden önce dolmalı ki hata iade kodunun
* çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse * çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse
* harcanan kredi iade edilemiyor. * harcanan kredi iade edilemiyor. Flash sınıfı model tipik ~10-20 sn'de
* biter; 60 sn üst sağlayıcı yavaşlamalarına pay bırakır.
*/ */
const URETIM_ZAMAN_ASIMI_MS = 50_000; const URETIM_ZAMAN_ASIMI_MS = 60_000;
/** /**
* Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner * Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner
@@ -82,22 +80,32 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
// Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da // Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da
// anında düşer, toplam süre sınırın üstüne çıkamaz. // anında düşer, toplam süre sınırın üstüne çıkamaz.
const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS); const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS);
if (saglayici() === "gemini") {
const yanit = await geciciHatadaTekrarla(() => const yanit = await geciciHatadaTekrarla(() =>
getGemini().models.generateContent({ getOpenRouter().chat.completions.create(
{
model: raporModeli(), model: raporModeli(),
contents: opts.kullanici, max_tokens: opts.maxTokens,
config: { // Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya
abortSignal: sinyal, // uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit
systemInstruction: opts.system, // modellerde düşünme bütçe/süre yemesin.
maxOutputTokens: opts.maxTokens, reasoning_effort: "none",
responseMimeType: "application/json", messages: [
responseJsonSchema: geminiSemasi(opts.sema), { role: "system", content: opts.system },
thinkingConfig: { thinkingLevel: ThinkingLevel.HIGH }, { role: "user", content: opts.kullanici },
],
response_format: {
type: "json_schema",
json_schema: {
name: "yanit",
strict: true,
schema: jsonSemasi(opts.sema),
}, },
}), },
},
{ signal: sinyal },
),
); );
const metin = yanit.text; const metin = yanit.choices[0]?.message?.content;
if (!metin) return null; if (!metin) return null;
try { try {
const parsed = opts.sema.safeParse(JSON.parse(metin)); const parsed = opts.sema.safeParse(JSON.parse(metin));
@@ -107,67 +115,43 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
} }
} }
const yanit = await getAnthropic().messages.parse(
{
model: raporModeli(),
max_tokens: opts.maxTokens,
thinking: { type: "adaptive" },
system: opts.system,
messages: [{ role: "user", content: opts.kullanici }],
output_config: { format: zodOutputFormat(opts.sema) },
},
{ signal: sinyal },
);
return yanit.parsed_output ?? null;
}
export type SohbetMesaji = { role: "user" | "assistant"; content: string }; export type SohbetMesaji = { role: "user" | "assistant"; content: string };
/** /**
* Sohbet yanıtını parça parça üretir. Sağlayıcıya bakmaksızın düz metin * Sohbet yanıtını parça parça üretir; düz metin parçaları yield eder, çağıran
* parçaları yield eder; çağıran taraf hem stream'ler hem biriktirip kaydeder. * taraf hem stream'ler hem biriktirip kaydeder. Akış ortasında zaman aşımı
* olursa hata fırlar (route'taki kısmi kaydet + iade yolu devreye girer).
*/ */
export async function* sohbetAkisi(opts: { export async function* sohbetAkisi(opts: {
system: string; system: string;
mesajlar: SohbetMesaji[]; mesajlar: SohbetMesaji[];
maxTokens: number; maxTokens: number;
}): AsyncGenerator<string> { }): AsyncGenerator<string> {
if (saglayici() === "gemini") { // Asılı kalan üst sağlayıcıya karşı sigorta
const sinyal = AbortSignal.timeout(120_000);
const akis = await geciciHatadaTekrarla(() => const akis = await geciciHatadaTekrarla(() =>
getGemini().models.generateContentStream({ getOpenRouter().chat.completions.create(
model: sohbetModeli(), {
contents: opts.mesajlar.map((m) => ({
// Gemini "assistant" yerine "model" rolünü kullanır
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
config: {
systemInstruction: opts.system,
maxOutputTokens: opts.maxTokens,
// Sohbet kısa ve hızlı olmalı; uzun düşünme adayı bekletiyor
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
},
}),
);
for await (const parca of akis) {
const metin = parca.text;
if (metin) yield metin;
}
return;
}
const akis = getAnthropic().messages.stream({
model: sohbetModeli(), model: sohbetModeli(),
max_tokens: opts.maxTokens, max_tokens: opts.maxTokens,
system: opts.system, // Sohbet 1024 token'a sığmalı; düşünme hem bütçeyi hem adayı bekletiyor
messages: opts.mesajlar, reasoning_effort: "none",
}); stream: true,
for await (const olay of akis) { messages: [
if ( { role: "system", content: opts.system },
olay.type === "content_block_delta" && ...opts.mesajlar,
olay.delta.type === "text_delta" ],
) { },
yield olay.delta.text; { signal: sinyal },
} ),
);
for await (const parca of akis) {
// Yalnızca delta.content akar; varsa delta.reasoning kullanıcıya sızmaz
const metin = parca.choices[0]?.delta?.content;
if (metin) yield metin;
} }
// openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor
// (core/streaming: isAbortError -> return); kesik cevabın tam cevap
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
if (sinyal.aborted) throw sinyal.reason;
} }

View File

@@ -1,64 +1,52 @@
import Anthropic from "@anthropic-ai/sdk"; import OpenAI from "openai";
import { GoogleGenAI } from "@google/genai";
export type Saglayici = "anthropic" | "gemini"; // Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı,
// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı).
/** AI_SAGLAYICI env'i ile seçilir; tanımsızsa hangi anahtar varsa o kullanılır. */ // Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir.
export function saglayici(): Saglayici { const VARSAYILAN_MODEL = "deepseek/deepseek-v4-flash";
const secim = process.env.AI_SAGLAYICI?.trim().toLowerCase();
if (secim === "gemini" || secim === "anthropic") return secim;
return process.env.GEMINI_API_KEY ? "gemini" : "anthropic";
}
/** Seçili sağlayıcının anahtarı tanımlı mı (mock moduna düşülecek mi)? */
export function anahtarVar(): boolean {
return saglayici() === "gemini"
? Boolean(process.env.GEMINI_API_KEY)
: Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
}
// Model seçimi (bilinçli):
// Anthropic — rapor tek seferlik ve yüksek değerli -> Opus 4.8;
// sohbet kredi başına marj hassas -> Haiku 4.5 (kullanıcı kararı, 22 Tem 2026).
// Gemini — ücretsiz katmanda yalnızca Flash ailesi açık (Pro'da kota 0),
// bu yüzden rapor ve sohbet aynı modeli kullanır; ayrım thinking seviyesinde.
const MODELLER: Record<Saglayici, { rapor: string; sohbet: string }> = {
anthropic: { rapor: "claude-opus-4-8", sohbet: "claude-haiku-4-5" },
gemini: { rapor: "gemini-3.6-flash", sohbet: "gemini-3.6-flash" },
};
export function raporModeli(): string { export function raporModeli(): string {
return MODELLER[saglayici()].rapor; return (
process.env.OPENROUTER_RAPOR_MODEL?.trim() ||
process.env.OPENROUTER_MODEL?.trim() ||
VARSAYILAN_MODEL
);
} }
export function sohbetModeli(): string { export function sohbetModeli(): string {
return MODELLER[saglayici()].sohbet; return (
process.env.OPENROUTER_SOHBET_MODEL?.trim() ||
process.env.OPENROUTER_MODEL?.trim() ||
VARSAYILAN_MODEL
);
} }
const globalForAi = globalThis as unknown as { /** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
__anthropic?: Anthropic; export function anahtarVar(): boolean {
__gemini?: GoogleGenAI; return Boolean(process.env.OPENROUTER_API_KEY);
};
export function getAnthropic(): Anthropic {
if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
throw new Error("AI_KEY_MISSING");
}
if (!globalForAi.__anthropic) {
globalForAi.__anthropic = new Anthropic();
}
return globalForAi.__anthropic;
} }
export function getGemini(): GoogleGenAI { const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
const apiKey = process.env.GEMINI_API_KEY;
export function getOpenRouter(): OpenAI {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new Error("AI_KEY_MISSING"); throw new Error("AI_KEY_MISSING");
} }
if (!globalForAi.__gemini) { if (!globalForAi.__openrouter) {
globalForAi.__gemini = new GoogleGenAI({ apiKey }); globalForAi.__openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey,
// Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla
maxRetries: 0,
// OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel)
defaultHeaders: {
"HTTP-Referer": "https://kolaytercih.com",
"X-Title": "KolayTercih",
},
});
} }
return globalForAi.__gemini; return globalForAi.__openrouter;
} }
export const SORUMLULUK_REDDI = export const SORUMLULUK_REDDI =

View File

@@ -26,6 +26,24 @@ const RaporSchema = z.object({
uyarilar: z.array(z.string()).min(2).max(3), uyarilar: z.array(z.string()).min(2).max(3),
}); });
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; 5/13/6'lık
// dilim iskeleti ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu
// ayrı listeler istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile)
// zorlansın — reasoning kapalıyken model serbest dağılımı tutturamıyor
// (ölçüldü: 13 hayal / 0 garanti dönmüştü). AI yalnızca seçer + gerekce yazar.
const AiTercihSchema = z.object({
programId: z.string(),
gerekce: z.string(),
});
const AiRaporSchema = z.object({
hayal: z.array(AiTercihSchema).length(5),
dengeli: z.array(AiTercihSchema).length(13),
garanti: z.array(AiTercihSchema).length(6),
genelDegerlendirme: z.string(),
uyarilar: z.array(z.string()).min(2).max(3),
});
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel // uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & { export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
uyarilar?: string[]; uyarilar?: string[];
@@ -46,30 +64,77 @@ export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
}; };
const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen
GERÇEK YÖK Atlas aday havuzundan, adayın sıralamasına göre 24 tercihlik dengeli GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi
bir liste kurmak. doldurmak: hayal (TAM 5 program), dengeli (TAM 13), garanti (TAM 6).
KURALLAR (kesin): KURALLAR (kesin):
- SADECE havuzdaki programId'leri kullan. Havuzda olmayan program ASLA önerme. - SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme.
- Tam 24 tercih üret; sira alanı 1'den 24'e sıralı olsun. Aynı programId'yi birden fazla kez kullanma.
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti. - Havuzdaki her programın "dilim" alanı var: hayal listesine yalnızca dilim
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli değeri "hayal" olan programları koy; dengeli ve garanti için de aynısı.
(küçük taban sıralaması = daha zor bölüm üstte). - Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini
- METİN DİYETİ: aday 24 satır okuyacak; her alan TEK kısa cümle, dolgu söz yok. ve bölüm çeşitliliğini gözet.
- gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle, - METİN DİYETİ: aday 24 satır okuyacak; gerekce TEK kısa cümle (en fazla ~15
en fazla ~15 kelime, Türkçe). kelime, Türkçe): bu programın bu aday için neden mantıklı olduğu. Dolgu söz
- riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti", yok; ASLA "garanti", "kesin", "yüzde yüz" deme.
"kesin", "yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
- trendOzeti: son 4 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den
beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma. - Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
- genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları - genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları
buraya yazma. buraya yazma.
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.`; - uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.
(Tercih sırası, risk notu ve trend özeti koddan hesaplanır; sen yazmazsın.)`;
export class RaporUretimHatasi extends Error {} export class RaporUretimHatasi extends Error {}
// --- Deterministik satır metinleri: risk ve trend gerçek YÖK Atlas sıra
// geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod
// kesin rakam verir ("45B düzeyinden 38B düzeyine indi").
/** 45000 -> "45B", 1_200_000 -> "1,2M" (şemadaki öğrenci dili). */
function siraKisa(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toLocaleString("tr-TR", { maximumFractionDigits: 1 })}M`;
}
if (n >= 1000) return `${Math.round(n / 1000)}B`;
return String(n);
}
function trendOzetiUret(p: AdayProgram): string {
const ham: Array<[number, number | null | undefined]> = [
[2021, p.sira2021],
[2022, p.sira2022],
[2023, p.sira2023],
[2024, p.sira2024],
[2025, p.sira2025],
];
const seri = ham.filter((cift): cift is [number, number] => cift[1] != null);
if (seri.length < 2) {
return "Karşılaştırma için yeterli geçmiş yıl verisi yok; trendi temkinli oku.";
}
const [ilkYil, ilkSira] = seri[0];
const [sonYil, sonSira] = seri[seri.length - 1];
const oran = (sonSira - ilkSira) / ilkSira;
if (oran <= -0.1) {
return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; bölüm zorlaşıyor.`;
}
if (oran >= 0.1) {
return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; yerleşmek görece kolaylaşıyor.`;
}
return `${ilkYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`;
}
function riskNotuUret(p: AdayProgram, adaySira: number): string {
switch (riskHesapla(p.sira2025 ?? p.sira2024, adaySira)) {
case "guvenli":
return "Taban sıralaması senin sıranın belirgin altında; yüksek ihtimalle yerleşirsin.";
case "az-riskli":
return "Taban sıralaması sırana yakın; orta ihtimalle yerleşilebilir.";
case "riskli":
return "Taban sıralaması senin sıranın üstünde kapandı; yerleşme ihtimali düşük.";
default:
return "Güncel taban sıralaması verisi yok; ihtimal öngörülemez, temkinli yaklaş.";
}
}
/** Havuz programını render için gereken bilgiye indirger. */ /** Havuz programını render için gereken bilgiye indirger. */
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] { function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025]; const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
@@ -119,14 +184,9 @@ function mockRapor(
sira: i + 1, sira: i + 1,
dilim: p.dilim, dilim: p.dilim,
gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`, gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`,
riskNotu: // Risk ve trend mock'ta da gerçek veriden: canlı davranışla birebir aynı
p.dilim === "hayal" riskNotu: riskNotuUret(p, params.sira),
? "(Dev mock) Taban sıralaması senin üstünde kapandı; düşük ihtimal ama listenin başında yeri var." trendOzeti: trendOzetiUret(p),
: p.dilim === "dengeli"
? "(Dev mock) Taban sıralaması senin civarında; orta-yüksek ihtimalle yerleşilebilir."
: "(Dev mock) Taban sıralaması senin belirgin altında; yüksek ihtimalle yerleşirsin.",
trendOzeti:
"(Dev mock) Son 4 yılda taban sıralaması görece stabil seyretti.",
})); }));
const programlar: RaporSonuc["programlar"] = {}; const programlar: RaporSonuc["programlar"] = {};
@@ -205,9 +265,9 @@ export async function raporUret(
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
const gecerliIdler = new Set(havuz.map((p) => p.id)); const havuzMap = new Map(havuz.map((p) => [p.id, p]));
let sonuc: z.infer<typeof RaporSchema> | null = null; let sonuc: z.infer<typeof AiRaporSchema> | null = null;
let sonHata = ""; let sonHata = "";
for (let deneme = 0; deneme < 2; deneme++) { for (let deneme = 0; deneme < 2; deneme++) {
const parsed = await yapilandirilmisUret({ const parsed = await yapilandirilmisUret({
@@ -216,26 +276,40 @@ export async function raporUret(
deneme === 0 deneme === 0
? kullaniciMesaji ? kullaniciMesaji
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`, : `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
sema: RaporSchema, sema: AiRaporSchema,
maxTokens: 16000, // Küçük şema (24 satır × gerekce + genel + uyarılar) ~1-1.5k token;
// reasoning kapalıyken 4000 rahat bir tavan, kaçak uzun üretimi de keser.
maxTokens: 4000,
}); });
if (!parsed) { if (!parsed) {
sonHata = ıktı şemaya uymadı."; // Dilim uzunlukları (5/13/6) şemada sabit: sayı tutmazsa da buraya düşer
sonHata = ıktı şemaya uymadı (hayal 5, dengeli 13, garanti 6 olmalı).";
continue; continue;
} }
// Halüsinasyon koruması: her programId havuzda olmalı + tam 24 öğe // Halüsinasyon koruması: id'ler havuzda, benzersiz ve doğru dilimde olmalı
const bilinmeyen = parsed.tercihler.filter( const gruplar = [
(t) => !gecerliIdler.has(t.programId), ["hayal", parsed.hayal],
["dengeli", parsed.dengeli],
["garanti", parsed.garanti],
] as const;
const idler = gruplar.flatMap(([, liste]) => liste.map((t) => t.programId));
const bilinmeyen = idler.filter((id) => !havuzMap.has(id));
if (bilinmeyen.length > 0) {
sonHata = `Havuzda olmayan programId kullanıldı: ${bilinmeyen.slice(0, 3).join(", ")}.`;
continue;
}
if (new Set(idler).size !== idler.length) {
sonHata = "Aynı programId birden fazla kez kullanıldı; her program tek kez seçilmeli.";
continue;
}
const yanlisDilim = gruplar.flatMap(([dilim, liste]) =>
liste
.filter((t) => havuzMap.get(t.programId)!.dilim !== dilim)
.map((t) => `${t.programId} (${dilim} listesinde ama havuzdaki dilimi farklı)`),
); );
if (parsed.tercihler.length !== 24 || bilinmeyen.length > 0) { if (yanlisDilim.length > 0) {
sonHata = sonHata = `Programlar yanlış dilim listesine konmuş: ${yanlisDilim.slice(0, 3).join(", ")}. Havuzdaki "dilim" alanına birebir uy.`;
parsed.tercihler.length !== 24
? `Liste ${parsed.tercihler.length} öğe içeriyor, tam 24 olmalı.`
: `Havuzda olmayan programId kullanıldı: ${bilinmeyen
.map((t) => t.programId)
.slice(0, 3)
.join(", ")}.`;
continue; continue;
} }
sonuc = parsed; sonuc = parsed;
@@ -252,7 +326,39 @@ export async function raporUret(
for (const p of havuz) { for (const p of havuz) {
programlar[p.id] = programBilgisi(p); programlar[p.id] = programBilgisi(p);
} }
return { ...sonuc, programlar }; // Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
// 1-24 koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
const efektif = (id: string) => {
const p = havuzMap.get(id)!;
return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER;
};
const tercihler = (
[
["hayal", sonuc.hayal],
["dengeli", sonuc.dengeli],
["garanti", sonuc.garanti],
] as const
).flatMap(([dilim, liste]) =>
[...liste].sort((a, b) => efektif(a.programId) - efektif(b.programId))
.map((t) => ({ dilim, ...t })),
).map((t, i) => {
const p = havuzMap.get(t.programId)!;
return {
programId: t.programId,
sira: i + 1,
dilim: t.dilim,
gerekce: t.gerekce,
riskNotu: riskNotuUret(p, params.sira),
trendOzeti: trendOzetiUret(p),
};
});
return {
tercihler,
genelDegerlendirme: sonuc.genelDegerlendirme,
uyarilar: sonuc.uyarilar,
programlar,
};
} }
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string { export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {

View File

@@ -156,7 +156,8 @@ export const reports = sqliteTable("reports", {
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları. // Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback). // Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback).
// Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir. // Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir.
// NOT: Yeni tablo/kolon eklerken sema-guvence.ts'teki DDL de güncellenmeli. // NOT: Yeni tablo/kolon eklerken `pnpm db:generate` ile migration üretilmeli;
// prod şemasıılışta yalnızca drizzle/ altındaki migration'larla güncellenir.
export const tadimlikHavuzu = sqliteTable( export const tadimlikHavuzu = sqliteTable(
"tadimlik_havuzu", "tadimlik_havuzu",
{ {

View File

@@ -1,45 +0,0 @@
import { sql } from "drizzle-orm";
import { appDb } from "./index";
/**
* Prod volume'ündeki app.db'ye sonradan eklenen tabloları güvenceye alır.
*
* Projede drizzle migration akışı yok: şema lokalde `pnpm db:push` ile
* uygulanıp data/app.db seed olarak taşınıyor; ama docker-entrypoint.sh
* app.db'yi yalnızca volume BOŞKEN kopyalar — mevcut prod volume'üne yeni
* tablolar başka türlü gelmez. Burası sunucu açılışında (instrumentation)
* bir kez koşar; yalnızca additive DDL içerir, idempotenttir.
*
* NOT: schema.ts'e tablo/kolon eklendiğinde buradaki DDL de güncellenmeli.
*/
export async function semayiGuvenceyeAl(): Promise<void> {
await appDb.run(sql`
CREATE TABLE IF NOT EXISTS tadimlik_havuzu (
id TEXT PRIMARY KEY,
kova_slug TEXT NOT NULL,
tur TEXT NOT NULL,
kategori_slug TEXT NOT NULL,
program_id TEXT NOT NULL,
dilim TEXT NOT NULL,
gerekce TEXT NOT NULL,
risk_notu TEXT NOT NULL,
trend_ozeti TEXT NOT NULL,
program TEXT NOT NULL,
ornek_sira INTEGER NOT NULL,
uretim_at INTEGER NOT NULL
)
`);
await appDb.run(
sql`CREATE UNIQUE INDEX IF NOT EXISTS tadimlik_anahtar ON tadimlik_havuzu (kova_slug, tur, kategori_slug)`,
);
await appDb.run(sql`
CREATE TABLE IF NOT EXISTS saved_lists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE REFERENCES user(id),
items TEXT NOT NULL,
profil TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}

View File

@@ -34,6 +34,17 @@ export interface HavuzSonuc {
const HEDEF = 24; const HEDEF = 24;
const LIMIT = 60; const LIMIT = 60;
// Prompt'a giden nihai havuz: dilim başına aday sayısı. Arama LIMIT ile geniş
// yapılır (filtre/gevşetme zenginliği için), filtre sonrası buraya kırpılır —
// 20×3=60 program ≈ 8k token; 180 program 24k token tutuyor ve lokal/ücretsiz
// modellerde prefill + isabet maliyeti yüksek (ölçüm: 6 Ağu 2026).
const PROMPT_LIMIT_DILIM = 20;
/** Dilim sırasını koruyarak her dilimden en fazla n aday bırakır. */
function dilimBasinaKirp(havuz: AdayProgram[], n: number): AdayProgram[] {
const sayac: Record<Dilim, number> = { hayal: 0, dengeli: 0, garanti: 0 };
return havuz.filter((p) => ++sayac[p.dilim] <= n);
}
function dilimEkle(r: { function dilimEkle(r: {
hayal: Program[]; hayal: Program[];
@@ -79,12 +90,18 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
); );
const tamFiltreli = kategoriFiltre(tamHam); const tamFiltreli = kategoriFiltre(tamHam);
if (tamFiltreli.length >= HEDEF) { if (tamFiltreli.length >= HEDEF) {
return { havuz: tamFiltreli, gevsetildi: [] }; return {
havuz: dilimBasinaKirp(tamFiltreli, PROMPT_LIMIT_DILIM),
gevsetildi: [],
};
} }
// 2. Kategori gevşet: aynı illerdeki kategori-dışı programlarla doldur // 2. Kategori gevşet: aynı illerdeki kategori-dışı programlarla doldur
if (kategoriler.length > 0 && tamHam.length >= HEDEF) { if (kategoriler.length > 0 && tamHam.length >= HEDEF) {
return { havuz: tamHam, gevsetildi: ["kategori"] }; return {
havuz: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM),
gevsetildi: ["kategori"],
};
} }
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön // 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
@@ -98,12 +115,15 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
); );
const ilsizFiltreli = kategoriFiltre(ilsizHam); const ilsizFiltreli = kategoriFiltre(ilsizHam);
if (ilsizFiltreli.length >= HEDEF) { if (ilsizFiltreli.length >= HEDEF) {
return { havuz: ilsizFiltreli, gevsetildi }; return {
havuz: dilimBasinaKirp(ilsizFiltreli, PROMPT_LIMIT_DILIM),
gevsetildi,
};
} }
// 4. Hem il hem kategori gevşet: elde ne varsa // 4. Hem il hem kategori gevşet: elde ne varsa
if (kategoriler.length > 0) gevsetildi.push("kategori"); if (kategoriler.length > 0) gevsetildi.push("kategori");
return { havuz: ilsizHam, gevsetildi }; return { havuz: dilimBasinaKirp(ilsizHam, PROMPT_LIMIT_DILIM), gevsetildi };
} }
/** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */ /** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */