Files
kolaytercih/scripts/tadimlik-uret.ts
bilalgursen 18d5b42136
All checks were successful
Deploy / deploy (push) Successful in 9m30s
AI katmanını OpenRouter tek kapısına geçir ve rapor üretimini hızlandır
- 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

262 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tadımlık havuzu batch üretimi: kova × puan türü × (kategori + "genel")
* kombinasyonları için 1'er örnek tercih satırı (gerekce/riskNotu/trendOzeti)
* üretip app.db'deki tadimlik_havuzu tablosuna yazar.
*
* pnpm tadimlik # boş anahtarları doldurur (idempotent)
* pnpm tadimlik --force # mevcut satırları da yeniden üretir
* pnpm tadimlik --kova=10-50k --tur=say --kategori=muhendislik-teknoloji
* pnpm tadimlik --mock # anahtar olsa da mock metin (dev/test)
* pnpm tadimlik --bekleme=13000 # LLM çağrıları arası bekleme (ms)
*
* AI anahtarı yoksa (dev) mock metin üretir — akış uçtan uca test edilebilir.
* 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";
import { existsSync, readFileSync } from "node:fs";
import { z } from "zod";
import { appDb, schema } from "../src/lib/appdb";
import { dilimAra, type DilimKey, type Program, type PuanTuruKey, PUAN_TURLERI } from "../src/lib/db";
import { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler";
import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu";
import { GENEL_KATEGORI, KOVALAR, kategoriSlugBul } from "../src/lib/tadimlik-havuzu";
import { anahtarVar } from "../src/lib/ai/client";
import { yapilandirilmisUret } from "../src/lib/ai/cagri";
// Next dışında koşuyoruz; .env dosyalarını elle yükle (mevcut env kazanır).
function envYukle(dosya: string): void {
if (!existsSync(dosya)) return;
for (const satir of readFileSync(dosya, "utf8").split("\n")) {
const m = satir.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (m && process.env[m[1]] === undefined) {
process.env[m[1]] = m[2].replace(/^"(.*)"$/, "$1").trim();
}
}
}
envYukle(".env.local");
envYukle(".env");
const TadimlikSchema = z.object({
gerekce: z.string(),
riskNotu: z.string(),
trendOzeti: z.string(),
});
// rapor.ts'teki SISTEM kurallarının tek satırlık versiyonu.
const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Sana GERÇEK
YÖK Atlas verisiyle TEK bir program ve temsili bir aday tanımı verilecek; bu
program için tercih listesi satırı metinleri üreteceksin.
KURALLAR (kesin):
- METİN DİYETİ: 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").
- Adayın sıralaması yaklaşıktır; kesin sıra telaffuz etme, banda göre konuş.`;
function mockAktif(): boolean {
if (process.argv.includes("--mock")) return true;
return process.env.NODE_ENV !== "production" && !anahtarVar();
}
function mockMetin(p: Program, temsiliSira: number, kategori: string | null) {
return {
gerekce: `(Dev mock) ${p.isim}, ${kategori ? `${kategori} ilgisiyle ve ` : ""}~${temsiliSira.toLocaleString("tr-TR")} bandındaki sıralamanla uyumlu bir aday.`,
riskNotu:
"(Dev mock) Taban sıralaması senin civarında; orta-yüksek ihtimalle yerleşilebilir.",
trendOzeti:
"(Dev mock) Son 4 yılda taban sıralaması görece stabil seyretti.",
};
}
/** rapor.ts programBilgisi ile aynı snapshot şekli. */
function programBilgisi(p: Program) {
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
return {
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
efektifSira: p.sira2025 ?? p.sira2024,
siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
};
}
/**
* Deterministik program seçimi: kovanın temsili sırası için "dengeli" dilimden
* (boşsa "garanti") kategoriye uyan, 2025'te en çok yerleşen alan program.
* Tanınır/dolu programlar tadımlıkta daha ikna edici.
*/
function programSec(
temsiliSira: number,
tur: PuanTuruKey,
kategori: string | null,
): { program: Program; dilim: DilimKey } | null {
for (const dilim of ["dengeli", "garanti"] as const) {
const { programlar } = dilimAra(temsiliSira, tur, dilim, { limit: 60 });
const uygunlar = kategori
? programlar.filter((p) => kategoriEslesir(p.isim, [kategori]))
: programlar;
if (uygunlar.length === 0) continue;
const secilen = [...uygunlar].sort(
(a, b) => (b.yerlesen2025 ?? -1) - (a.yerlesen2025 ?? -1),
)[0];
return { program: secilen, dilim };
}
return null;
}
async function metinUret(
p: Program,
dilim: DilimKey,
temsiliSira: number,
tur: PuanTuruKey,
kategori: string | null,
): Promise<z.infer<typeof TadimlikSchema> | null> {
if (mockAktif()) return mockMetin(p, temsiliSira, kategori);
const kullanici = [
`Temsili aday: başarı sıralaması ~${temsiliSira.toLocaleString("tr-TR")} bandında, puan türü ${turLabel(tur)}.`,
kategori ? `Adayın ilgi alanı: ${kategori}.` : null,
`PROGRAM (yalnızca bunun için metin üret): ${havuzuKompaktJson([{ ...p, dilim }])}`,
]
.filter(Boolean)
.join("\n\n");
return yapilandirilmisUret({
system: SISTEM,
kullanici,
sema: TadimlikSchema,
maxTokens: 4000,
});
}
function bayrak(ad: string): string | undefined {
const arg = process.argv.find((a) => a.startsWith(`--${ad}=`));
return arg?.slice(ad.length + 3);
}
async function main() {
const force = process.argv.includes("--force");
const kovaFiltre = bayrak("kova");
const turFiltre = bayrak("tur");
const kategoriFiltre = bayrak("kategori");
const turlar = (Object.keys(PUAN_TURLERI) as PuanTuruKey[]).filter(
(t) => !turFiltre || t === turFiltre,
);
const kategoriler: { ad: string | null; slug: string }[] = [
{ ad: null, slug: GENEL_KATEGORI },
...KATEGORILER.map((ad) => ({ ad: ad as string, slug: kategoriSlugBul(ad) })),
].filter((k) => !kategoriFiltre || k.slug === kategoriFiltre);
const kovalar = KOVALAR.filter((k) => !kovaFiltre || k.slug === kovaFiltre);
const mevcutlar = new Set(
(
await appDb
.select({
kovaSlug: schema.tadimlikHavuzu.kovaSlug,
tur: schema.tadimlikHavuzu.tur,
kategoriSlug: schema.tadimlikHavuzu.kategoriSlug,
})
.from(schema.tadimlikHavuzu)
).map((s) => `${s.kovaSlug}|${s.tur}|${s.kategoriSlug}`),
);
if (mockAktif()) {
console.log("AI anahtarı yok — mock metinlerle üretiliyor (dev).");
}
let uretilen = 0;
let atlananMevcut = 0;
let atlananBos = 0;
for (const kova of kovalar) {
for (const tur of turlar) {
for (const kategori of kategoriler) {
const anahtar = `${kova.slug}|${tur}|${kategori.slug}`;
if (!force && mevcutlar.has(anahtar)) {
atlananMevcut++;
continue;
}
const secim = programSec(kova.temsiliSira, tur, kategori.ad);
if (!secim) {
atlananBos++;
continue;
}
const metin = await metinUret(
secim.program,
secim.dilim,
kova.temsiliSira,
tur,
kategori.ad,
);
if (!metin) {
console.warn(`Şemaya uymayan çıktı, atlandı: ${anahtar}`);
atlananBos++;
continue;
}
const now = new Date();
await appDb
.insert(schema.tadimlikHavuzu)
.values({
id: randomUUID(),
kovaSlug: kova.slug,
tur,
kategoriSlug: kategori.slug,
programId: secim.program.id,
dilim: secim.dilim,
gerekce: metin.gerekce,
riskNotu: metin.riskNotu,
trendOzeti: metin.trendOzeti,
program: programBilgisi(secim.program),
ornekSira: kova.temsiliSira,
uretimAt: now,
})
.onConflictDoUpdate({
target: [
schema.tadimlikHavuzu.kovaSlug,
schema.tadimlikHavuzu.tur,
schema.tadimlikHavuzu.kategoriSlug,
],
set: {
programId: secim.program.id,
dilim: secim.dilim,
gerekce: metin.gerekce,
riskNotu: metin.riskNotu,
trendOzeti: metin.trendOzeti,
program: programBilgisi(secim.program),
ornekSira: kova.temsiliSira,
uretimAt: now,
},
});
uretilen++;
console.log(`${anahtar}${secim.program.isim} (${secim.dilim})`);
// Gerçek LLM'de rate limit dostu ardışık tempo
if (!mockAktif()) {
const bekleme = Number.parseInt(bayrak("bekleme") ?? "500", 10);
await new Promise((r) => setTimeout(r, bekleme));
}
}
}
}
console.log(
`Bitti: ${uretilen} üretildi, ${atlananMevcut} mevcut atlandı, ${atlananBos} içeriksiz/şemasız atlandı.`,
);
}
main().catch((hata) => {
console.error(hata);
process.exit(1);
});