import { searchByRank, type Program, type PuanTuruKey, type UniturGrubu, PUAN_TURLERI, } from "./db"; import { kategoriEslesir } from "./kategoriler"; export type Dilim = "hayal" | "dengeli" | "garanti"; export interface AdayProgram extends Program { dilim: Dilim; } export interface RaporParams { sira: number; tur: PuanTuruKey; // Sihirbaz seçimleri (yeni akış) kategoriler?: string[]; iller?: string[]; universiteTipi?: UniturGrubu | "farketmez"; oncelikler?: string[]; // Eski akış alanları (geriye uyum — eski kayıtlı raporlar) il?: string; notlar?: string; } export interface HavuzSonuc { havuz: AdayProgram[]; // Havuz 24'e ulaşmak için hangi filtreler gevşetildi gevsetildi: ("kategori" | "il")[]; } const HEDEF = 24; const LIMIT = 60; export type DilimHedef = Record; // Listenin ideal iskeleti: 5 hayal / 13 dengeli / 6 garanti = 24 tercih. export const IDEAL_HEDEF: DilimHedef = { hayal: 5, dengeli: 13, garanti: 6 }; /** * Havuzun gerçek dilim dağılımına uyarlanmış liste hedefi. İdeal 5/13/6'dan * başlar; bir dilimde yeterli aday yoksa eksik, kapasitesi kalan dilimlere * dağıtılır (öncelik: dengeli → garanti → hayal — omurga önce, sigorta sonra, * en riskli dilim en son). Havuz 24'ün altındaysa hedef havuzun tamamıdır. * Deterministiktir: aynı havuz her zaman aynı hedefi üretir. */ export function dilimHedefiHesapla(havuz: AdayProgram[]): DilimHedef { const mevcut: DilimHedef = { hayal: 0, dengeli: 0, garanti: 0 }; for (const p of havuz) mevcut[p.dilim]++; const toplamHedef = Math.min(HEDEF, havuz.length); const hedef: DilimHedef = { hayal: Math.min(IDEAL_HEDEF.hayal, mevcut.hayal), dengeli: Math.min(IDEAL_HEDEF.dengeli, mevcut.dengeli), garanti: Math.min(IDEAL_HEDEF.garanti, mevcut.garanti), }; const oncelik: Dilim[] = ["dengeli", "garanti", "hayal"]; let eksik = toplamHedef - hedef.hayal - hedef.dengeli - hedef.garanti; while (eksik > 0) { let dagitildi = false; for (const d of oncelik) { if (eksik <= 0) break; if (mevcut[d] > hedef[d]) { hedef[d]++; eksik--; dagitildi = true; } } if (!dagitildi) break; } return hedef; } // 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 = { hayal: 0, dengeli: 0, garanti: 0 }; return havuz.filter((p) => ++sayac[p.dilim] <= n); } function dilimEkle(r: { hayal: Program[]; dengeli: Program[]; garanti: Program[]; }): AdayProgram[] { return [ ...r.hayal.map((p) => ({ ...p, dilim: "hayal" as const })), ...r.dengeli.map((p) => ({ ...p, dilim: "dengeli" as const })), ...r.garanti.map((p) => ({ ...p, dilim: "garanti" as const })), ]; } /** * Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz. * Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz * 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste * her zaman kurulabilsin. */ export function havuzOlustur(params: RaporParams): HavuzSonuc { const uniturGrubu = params.universiteTipi && params.universiteTipi !== "farketmez" ? params.universiteTipi : undefined; const kategoriler = params.kategoriler ?? []; const iller = [ ...(params.il ? [params.il] : []), ...(params.iller ?? []), ].filter((v, i, a) => v && a.indexOf(v) === i); const kategoriFiltre = (havuz: AdayProgram[]) => kategoriler.length > 0 ? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler)) : havuz; // 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS) const tamHam = dilimEkle( searchByRank(params.sira, params.tur, { iller: iller.length > 0 ? iller : undefined, uniturGrubu, limitPerBucket: LIMIT, }), ); const tamFiltreli = kategoriFiltre(tamHam); if (tamFiltreli.length >= HEDEF) { 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: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM), gevsetildi: ["kategori"], }; } // 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön const gevsetildi: ("kategori" | "il")[] = []; if (iller.length > 0) gevsetildi.push("il"); const ilsizHam = dilimEkle( searchByRank(params.sira, params.tur, { uniturGrubu, limitPerBucket: LIMIT, }), ); const ilsizFiltreli = kategoriFiltre(ilsizHam); if (ilsizFiltreli.length >= HEDEF) { 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: dilimBasinaKirp(ilsizHam, PROMPT_LIMIT_DILIM), gevsetildi }; } /** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */ export function havuzuKompaktJson(havuz: AdayProgram[]): string { return JSON.stringify( havuz.map((p) => ({ id: p.id, isim: p.isim, uni: p.universite, unitur: p.unitur, il: p.il, dilim: p.dilim, sira: { y2025: p.sira2025, y2024: p.sira2024, y2023: p.sira2023, y2022: p.sira2022, }, kontenjan2025: p.kontenjan2025, yerlesen2025: p.yerlesen2025, })), ); } export function turLabel(tur: PuanTuruKey): string { return PUAN_TURLERI[tur]; }