Compare commits
4 Commits
e62c6f07df
...
claude/nif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37baeb9172 | ||
|
|
45775c8c3f | ||
|
|
18d5b42136 | ||
|
|
4c618781f6 |
@@ -1,13 +1,27 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitea
|
||||
.agents
|
||||
.claude
|
||||
.mcp.json
|
||||
# Yalnızca kök dizindeki .md dosyaları (path ayracını geçmez);
|
||||
# content/rehber/*.md build için gerekli, etkilenmez.
|
||||
*.md
|
||||
.env*
|
||||
!.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/app.db
|
||||
!data/yokatlas.db
|
||||
|
||||
@@ -26,12 +26,54 @@ jobs:
|
||||
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
||||
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
||||
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
||||
AI_SAGLAYICI=${{ secrets.AI_SAGLAYICI }}
|
||||
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}
|
||||
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
|
||||
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: |
|
||||
docker compose -p kolaytercih up -d --build --remove-orphans
|
||||
docker image prune -f
|
||||
cat > buildkitd.toml <<'EOF'
|
||||
[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
|
||||
|
||||
44
Dockerfile
44
Dockerfile
@@ -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
|
||||
|
||||
# ── Bağımlılık katmanı ──
|
||||
@@ -9,17 +11,36 @@ WORKDIR /app
|
||||
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
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ı ──
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
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
|
||||
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli
|
||||
RUN mkdir -p data && pnpm build
|
||||
# V8 heap sınırı ana süreç + statik üretim worker'larına miras kalır.
|
||||
# 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ı ──
|
||||
FROM node:20-alpine AS runner
|
||||
@@ -31,14 +52,21 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Bağımlılıklar
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# standalone çıktı, file-tracing ile seçilmiş kendi minimal node_modules'unu
|
||||
# içerir (serverExternalPackages dahil) — tam node_modules kopyalanmaz.
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
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
|
||||
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
|
||||
|
||||
# SQLite veri dizini
|
||||
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -3,6 +3,11 @@ services:
|
||||
build: .
|
||||
container_name: kolaytercih
|
||||
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:
|
||||
- app-data:/app/data
|
||||
env_file:
|
||||
|
||||
131
drizzle/0000_baslangic.sql
Normal file
131
drizzle/0000_baslangic.sql
Normal 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
|
||||
);
|
||||
898
drizzle/meta/0000_snapshot.json
Normal file
898
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1786053613118,
|
||||
"tag": "0000_baslangic",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,43 @@
|
||||
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 = {
|
||||
// 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.
|
||||
cacheComponents: true,
|
||||
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() {
|
||||
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
||||
return [
|
||||
|
||||
12500
package-lock.json
generated
12500
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,15 +12,14 @@
|
||||
"detay": "tsx scripts/detay.ts",
|
||||
"logolar": "tsx scripts/logo-indir.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "node scripts/db-goc.mjs",
|
||||
"tadimlik": "tsx scripts/tadimlik-uret.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.112.4",
|
||||
"@cds/city": "^1.1.0",
|
||||
"@google/genai": "^2.15.0",
|
||||
"@libsql/client": "^0.17.4",
|
||||
"@number-flow/react": "^0.6.2",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"better-auth": "^1.6.23",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -34,6 +33,7 @@
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^7.4.0",
|
||||
"radix-ui": "^1.6.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -43,7 +43,6 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.18.0",
|
||||
"shiki": "^4.3.1",
|
||||
"simple-icons": "^16.27.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"turkey-map-react": "^2.0.5",
|
||||
|
||||
752
pnpm-lock.yaml
generated
752
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
157
scripts/db-goc.mjs
Normal file
157
scripts/db-goc.mjs
Normal 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);
|
||||
});
|
||||
@@ -9,9 +9,14 @@ for ek in wal shm; do
|
||||
fi
|
||||
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
|
||||
cp /app/seed/app.db /app/data/
|
||||
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
|
||||
|
||||
41
scripts/standalone-linkleri-onar.sh
Normal file
41
scripts/standalone-linkleri-onar.sh
Normal 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
|
||||
@@ -10,8 +10,8 @@
|
||||
* pnpm tadimlik --bekleme=13000 # LLM çağrıları arası bekleme (ms)
|
||||
*
|
||||
* AI anahtarı yoksa (dev) mock metin üretir — akış uçtan uca test edilebilir.
|
||||
* Gemini ücretsiz katmanı dakikada ~5 istek kabul eder; gerçek üretimde
|
||||
* --bekleme=13000 gibi bir tempo kullan.
|
||||
* OpenRouter'da katı bir dakikalık limit yok; --bekleme varsayılanı (500 ms)
|
||||
* genelde yeterli, 429 görürsen tempoyu artır.
|
||||
* Anonim trafik LLM'e asla istek atmaz; tek maliyet bu script'in koşusudur.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -128,8 +128,6 @@ async function metinUret(
|
||||
.filter(Boolean)
|
||||
.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({
|
||||
system: SISTEM,
|
||||
kullanici,
|
||||
|
||||
@@ -15,10 +15,12 @@ import { ListemGovde } from "./listem-govde";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
import { ListeUretici } from "./liste-uretici";
|
||||
|
||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
|
||||
// dolmalı ki kredi iadesi her zaman çalışabilsin.
|
||||
export const maxDuration = 120;
|
||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||
// SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
|
||||
// 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 = {
|
||||
title: "Listem",
|
||||
|
||||
@@ -121,6 +121,7 @@ export function SohbetClient({
|
||||
{ id: asistanId, role: "assistant", content: "" },
|
||||
]);
|
||||
|
||||
let birikmis = "";
|
||||
try {
|
||||
const res = await fetch("/api/soru", {
|
||||
method: "POST",
|
||||
@@ -151,7 +152,6 @@ export function SohbetClient({
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let birikmis = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -165,7 +165,26 @@ export function SohbetClient({
|
||||
// harcamasını görmez; bakiye orada da güncellensin.
|
||||
router.refresh();
|
||||
} 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 {
|
||||
setBekliyor(false);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ flowchart TD
|
||||
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")
|
||||
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")
|
||||
Y --> L("24 tercihlik liste")
|
||||
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 --> 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 4–6 hayal")
|
||||
L --> L2("ortada 12–14 dengeli")
|
||||
L --> L3("sonda 5–7 garanti")
|
||||
@@ -39,7 +40,7 @@ export function HuniSemasi() {
|
||||
return (
|
||||
<ElCizimiSema
|
||||
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={
|
||||
<>
|
||||
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
|
||||
|
||||
@@ -43,8 +43,15 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
||||
function aiHataMesaji(err: unknown): string {
|
||||
if (err instanceof RaporUretimHatasi) return err.message;
|
||||
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
|
||||
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) — ada göre yakala.
|
||||
if (err instanceof Error && /abort|timeout/i.test(err.name)) {
|
||||
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) ve openai/anthropic
|
||||
// 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?";
|
||||
}
|
||||
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") return;
|
||||
// Migration akışı olmadığından sonradan eklenen tablolar açılışta güvenceye
|
||||
// alınır (additive DDL, idempotent) — mevcut prod volume'ü seed'den kopyalanmaz.
|
||||
const { semayiGuvenceyeAl } = await import("./lib/appdb/sema-guvence");
|
||||
await semayiGuvenceyeAl();
|
||||
// Şema, konteyner açılışında scripts/db-goc.mjs'in uyguladığı drizzle
|
||||
// migration'larıyla güncellenir (bkz. docker-entrypoint.sh); burada DDL koşmaz.
|
||||
const { krediHatirlatmaBaslat } = await import("./lib/kredi-hatirlatma");
|
||||
krediHatirlatmaBaslat();
|
||||
}
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
||||
import { ThinkingLevel } from "@google/genai";
|
||||
import {
|
||||
getAnthropic,
|
||||
getGemini,
|
||||
raporModeli,
|
||||
saglayici,
|
||||
sohbetModeli,
|
||||
} from "./client";
|
||||
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
|
||||
|
||||
/**
|
||||
* Sağlayıcıdan bağımsız AI çağrı katmanı. Anthropic ve Gemini'nin SDK'ları
|
||||
* yapılandırılmış çıktı ve stream için farklı şekiller kullanıyor; rapor ve
|
||||
* sohbet kodu bu farkı görmesin diye tek arayüz burada toplanıyor.
|
||||
* AI çağrı katmanı: tüm istekler OpenRouter'ın OpenAI-uyumlu API'sine gider
|
||||
* (tek kapı — kullanıcı kararı, 7 Ağu 2026). Rapor ve sohbet kodu SDK/istek
|
||||
* 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
|
||||
* $schema ve additionalProperties alanları isteği reddettiriyor, ayıklanıyor.
|
||||
* zod şemasını JSON Schema'ya çevirir: z.toJSONSchema'nın eklediği $schema
|
||||
* 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 {
|
||||
const temizle = (dugum: unknown): unknown => {
|
||||
if (Array.isArray(dugum)) return dugum.map(temizle);
|
||||
function jsonSemasi(sema: z.ZodType): Record<string, unknown> {
|
||||
const donustur = (dugum: unknown): unknown => {
|
||||
if (Array.isArray(dugum)) return dugum.map(donustur);
|
||||
if (dugum && typeof dugum === "object") {
|
||||
const cikti: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) {
|
||||
if (k === "$schema" || k === "additionalProperties") continue;
|
||||
cikti[k] = temizle(v);
|
||||
if (k === "$schema") continue;
|
||||
cikti[k] = donustur(v);
|
||||
}
|
||||
if (cikti.type === "object" && cikti.additionalProperties === undefined) {
|
||||
cikti.additionalProperties = false;
|
||||
}
|
||||
return cikti;
|
||||
}
|
||||
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
|
||||
* artan bekleme ile yeniden dene. Kalıcı hatalar (400, 403) olduğu gibi fırlar.
|
||||
* OpenRouter yoğunlukta/limitte 429, üst sağlayıcı arızasında 5xx dönebiliyor;
|
||||
* 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]);
|
||||
|
||||
@@ -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
|
||||
* (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
|
||||
* 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
|
||||
@@ -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
|
||||
// anında düşer, toplam süre sınırın üstüne çıkamaz.
|
||||
const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS);
|
||||
if (saglayici() === "gemini") {
|
||||
const yanit = await geciciHatadaTekrarla(() =>
|
||||
getGemini().models.generateContent({
|
||||
getOpenRouter().chat.completions.create(
|
||||
{
|
||||
model: raporModeli(),
|
||||
contents: opts.kullanici,
|
||||
config: {
|
||||
abortSignal: sinyal,
|
||||
systemInstruction: opts.system,
|
||||
maxOutputTokens: opts.maxTokens,
|
||||
responseMimeType: "application/json",
|
||||
responseJsonSchema: geminiSemasi(opts.sema),
|
||||
thinkingConfig: { thinkingLevel: ThinkingLevel.HIGH },
|
||||
max_tokens: opts.maxTokens,
|
||||
// Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya
|
||||
// uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit
|
||||
// modellerde düşünme bütçe/süre yemesin.
|
||||
reasoning_effort: "none",
|
||||
messages: [
|
||||
{ role: "system", content: opts.system },
|
||||
{ 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;
|
||||
try {
|
||||
const parsed = opts.sema.safeParse(JSON.parse(metin));
|
||||
@@ -105,69 +113,45 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
/**
|
||||
* Sohbet yanıtını parça parça üretir. Sağlayıcıya bakmaksızın düz metin
|
||||
* parçaları yield eder; çağıran taraf hem stream'ler hem biriktirip kaydeder.
|
||||
* Sohbet yanıtını parça parça üretir; düz metin parçaları yield eder, çağıran
|
||||
* 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: {
|
||||
system: string;
|
||||
mesajlar: SohbetMesaji[];
|
||||
maxTokens: number;
|
||||
}): 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(() =>
|
||||
getGemini().models.generateContentStream({
|
||||
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({
|
||||
getOpenRouter().chat.completions.create(
|
||||
{
|
||||
model: sohbetModeli(),
|
||||
max_tokens: opts.maxTokens,
|
||||
system: opts.system,
|
||||
messages: opts.mesajlar,
|
||||
});
|
||||
for await (const olay of akis) {
|
||||
if (
|
||||
olay.type === "content_block_delta" &&
|
||||
olay.delta.type === "text_delta"
|
||||
) {
|
||||
yield olay.delta.text;
|
||||
}
|
||||
// Sohbet 1024 token'a sığmalı; düşünme hem bütçeyi hem adayı bekletiyor
|
||||
reasoning_effort: "none",
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: "system", content: opts.system },
|
||||
...opts.mesajlar,
|
||||
],
|
||||
},
|
||||
{ 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;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,52 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import OpenAI from "openai";
|
||||
|
||||
export type Saglayici = "anthropic" | "gemini";
|
||||
|
||||
/** AI_SAGLAYICI env'i ile seçilir; tanımsızsa hangi anahtar varsa o kullanılır. */
|
||||
export function saglayici(): Saglayici {
|
||||
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" },
|
||||
};
|
||||
// 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ı).
|
||||
// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir.
|
||||
const VARSAYILAN_MODEL = "deepseek/deepseek-v4-flash";
|
||||
|
||||
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 {
|
||||
return MODELLER[saglayici()].sohbet;
|
||||
return (
|
||||
process.env.OPENROUTER_SOHBET_MODEL?.trim() ||
|
||||
process.env.OPENROUTER_MODEL?.trim() ||
|
||||
VARSAYILAN_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
const globalForAi = globalThis as unknown as {
|
||||
__anthropic?: Anthropic;
|
||||
__gemini?: GoogleGenAI;
|
||||
};
|
||||
|
||||
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;
|
||||
/** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
|
||||
export function anahtarVar(): boolean {
|
||||
return Boolean(process.env.OPENROUTER_API_KEY);
|
||||
}
|
||||
|
||||
export function getGemini(): GoogleGenAI {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
|
||||
|
||||
export function getOpenRouter(): OpenAI {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("AI_KEY_MISSING");
|
||||
}
|
||||
if (!globalForAi.__gemini) {
|
||||
globalForAi.__gemini = new GoogleGenAI({ apiKey });
|
||||
if (!globalForAi.__openrouter) {
|
||||
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 =
|
||||
|
||||
@@ -26,6 +26,24 @@ const RaporSchema = z.object({
|
||||
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
|
||||
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
|
||||
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
|
||||
GERÇEK YÖK Atlas aday havuzundan, adayın sıralamasına göre 24 tercihlik dengeli
|
||||
bir liste kurmak.
|
||||
GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi
|
||||
doldurmak: hayal (TAM 5 program), dengeli (TAM 13), garanti (TAM 6).
|
||||
|
||||
KURALLAR (kesin):
|
||||
- SADECE havuzdaki programId'leri kullan. Havuzda olmayan program ASLA önerme.
|
||||
- Tam 24 tercih üret; sira alanı 1'den 24'e sıralı olsun.
|
||||
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
|
||||
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
|
||||
(küçük taban sıralaması = daha zor bölüm üstte).
|
||||
- METİN DİYETİ: aday 24 satır okuyacak; her alan TEK kısa cümle, dolgu söz yok.
|
||||
- gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle,
|
||||
en fazla ~15 kelime, Türkçe).
|
||||
- riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti",
|
||||
"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.
|
||||
- SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme.
|
||||
Aynı programId'yi birden fazla kez kullanma.
|
||||
- Havuzdaki her programın "dilim" alanı var: hayal listesine yalnızca dilim
|
||||
değeri "hayal" olan programları koy; dengeli ve garanti için de aynısı.
|
||||
- Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini
|
||||
ve bölüm çeşitliliğini gözet.
|
||||
- METİN DİYETİ: aday 24 satır okuyacak; gerekce TEK kısa cümle (en fazla ~15
|
||||
kelime, Türkçe): bu programın bu aday için neden mantıklı olduğu. Dolgu söz
|
||||
yok; ASLA "garanti", "kesin", "yüzde yüz" deme.
|
||||
- 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ı
|
||||
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 {}
|
||||
|
||||
// --- 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. */
|
||||
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
|
||||
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
|
||||
@@ -119,14 +184,9 @@ function mockRapor(
|
||||
sira: i + 1,
|
||||
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.`,
|
||||
riskNotu:
|
||||
p.dilim === "hayal"
|
||||
? "(Dev mock) Taban sıralaması senin üstünde kapandı; düşük ihtimal ama listenin başında yeri var."
|
||||
: 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.",
|
||||
// Risk ve trend mock'ta da gerçek veriden: canlı davranışla birebir aynı
|
||||
riskNotu: riskNotuUret(p, params.sira),
|
||||
trendOzeti: trendOzetiUret(p),
|
||||
}));
|
||||
|
||||
const programlar: RaporSonuc["programlar"] = {};
|
||||
@@ -205,9 +265,9 @@ export async function raporUret(
|
||||
.filter(Boolean)
|
||||
.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 = "";
|
||||
for (let deneme = 0; deneme < 2; deneme++) {
|
||||
const parsed = await yapilandirilmisUret({
|
||||
@@ -216,26 +276,40 @@ export async function raporUret(
|
||||
deneme === 0
|
||||
? kullaniciMesaji
|
||||
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
|
||||
sema: RaporSchema,
|
||||
maxTokens: 16000,
|
||||
sema: AiRaporSchema,
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
// Halüsinasyon koruması: her programId havuzda olmalı + tam 24 öğe
|
||||
const bilinmeyen = parsed.tercihler.filter(
|
||||
(t) => !gecerliIdler.has(t.programId),
|
||||
// Halüsinasyon koruması: id'ler havuzda, benzersiz ve doğru dilimde olmalı
|
||||
const gruplar = [
|
||||
["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) {
|
||||
sonHata =
|
||||
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(", ")}.`;
|
||||
if (yanlisDilim.length > 0) {
|
||||
sonHata = `Programlar yanlış dilim listesine konmuş: ${yanlisDilim.slice(0, 3).join(", ")}. Havuzdaki "dilim" alanına birebir uy.`;
|
||||
continue;
|
||||
}
|
||||
sonuc = parsed;
|
||||
@@ -252,7 +326,39 @@ export async function raporUret(
|
||||
for (const p of havuz) {
|
||||
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 {
|
||||
|
||||
@@ -156,7 +156,8 @@ export const reports = sqliteTable("reports", {
|
||||
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
|
||||
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback).
|
||||
// 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ı açılışta yalnızca drizzle/ altındaki migration'larla güncellenir.
|
||||
export const tadimlikHavuzu = sqliteTable(
|
||||
"tadimlik_havuzu",
|
||||
{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
`);
|
||||
}
|
||||
@@ -34,6 +34,17 @@ export interface HavuzSonuc {
|
||||
|
||||
const HEDEF = 24;
|
||||
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: {
|
||||
hayal: Program[];
|
||||
@@ -79,12 +90,18 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||||
);
|
||||
const tamFiltreli = kategoriFiltre(tamHam);
|
||||
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
|
||||
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
|
||||
@@ -98,12 +115,15 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||||
);
|
||||
const ilsizFiltreli = kategoriFiltre(ilsizHam);
|
||||
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
|
||||
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). */
|
||||
|
||||
Reference in New Issue
Block a user