// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz // node (tsx) altında import ediyor; server-only paketi orada fırlar. import { havuzAdaylari, type OzellikSuzgeci } from "./db"; import type { DanismanProfili } from "./danisman-profil"; import { yakinIller } from "./mesafe"; import { PUAN_TURLERI, type Program, type PuanTuruKey, } from "@/types/yokatlas"; import type { RaporParams } from "@/features/rapor/types/rapor"; import { kategoriEslesir } from "./kategoriler"; import { SON_YIL, YILLAR } from "./veri-yillari"; export type Dilim = "hayal" | "dengeli" | "garanti"; export interface AdayProgram extends Program { dilim: Dilim; } export type Gevsetilen = "profil" | "kategori" | "il"; export interface HavuzSonuc { havuz: AdayProgram[]; // Havuz 24'e ulaşmak için hangi filtreler gevşetildi (sırayla) gevsetildi: Gevsetilen[]; } /** * Danışman profilinden sert süzgeç: dil, öğretim türü, burs, ücret üst sınırı * (program_ozellik) ve ev iline km sınırı (il listesi). Sihirbazda il * seçilmişse km sınırı uygulanmaz — sihirbaz seçimi daha yeni ve açık. */ export function profilSuzgeci( profil: DanismanProfili | undefined, sihirbazIlVar: boolean, ): { ozellik?: OzellikSuzgeci; iller?: string[]; ozet: string[] } { if (!profil) return { ozet: [] }; const ozellik: OzellikSuzgeci = {}; const ozet: string[] = []; if (profil.dil === "ingilizce" || profil.dil === "turkce") { ozellik.dil = profil.dil === "ingilizce" ? "İngilizce" : "Türkçe"; ozet.push(`öğretim dili ${ozellik.dil}`); } if (profil.ogretimTuru === "yalniz-orgun") { ozellik.yalnizOrgun = true; ozet.push("yalnız örgün öğretim"); } if (profil.burs === "yalniz-burslu") { ozellik.yalnizBurslu = true; ozet.push("vakıfta yalnız burslu"); } if (profil.ucretUst) { ozellik.ucretUst = profil.ucretUst; ozet.push(`yıllık ücret en fazla ${profil.ucretUst.toLocaleString("tr-TR")} TL`); } let iller: string[] | undefined; if (!sihirbazIlVar && profil.evIl && profil.maxKm) { const yakin = yakinIller(profil.evIl, profil.maxKm); if (yakin.length > 0) { iller = yakin; ozet.push(`${profil.evIl} iline en fazla ${profil.maxKm} km`); } } return { ozellik: Object.keys(ozellik).length ? ozellik : undefined, iller, ozet, }; } const HEDEF = 24; 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 }; const DILIM_ONCELIGI: Dilim[] = ["dengeli", "garanti", "hayal"]; function dilimSay(havuz: AdayProgram[]): DilimHedef { const mevcut: DilimHedef = { hayal: 0, dengeli: 0, garanti: 0 }; for (const p of havuz) mevcut[p.dilim]++; return mevcut; } /** * `pay` toplamı `toplam`ın altındaysa eksiği, adayı kalan dilimlere birer * birer dağıtır (öncelik: dengeli → garanti → hayal — omurga önce, sigorta * sonra, en riskli dilim en son). `pay`'i yerinde günceller. */ function eksigiDagit(pay: DilimHedef, mevcut: DilimHedef, toplam: number) { let eksik = toplam - pay.hayal - pay.dengeli - pay.garanti; while (eksik > 0) { let dagitildi = false; for (const d of DILIM_ONCELIGI) { if (eksik <= 0) break; if (mevcut[d] > pay[d]) { pay[d]++; eksik--; dagitildi = true; } } if (!dagitildi) break; } } /** * 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 = dilimSay(havuz); 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), }; eksigiDagit(hedef, mevcut, Math.min(HEDEF, havuz.length)); return hedef; } // Prompt'a giden nihai havuz: dilim başına aday sayısı. Arama dilimin tamamını // alır (filtre/gevşetme gerçek sayıya baksın diye), 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; const DILIM_SIRASI: Record = { hayal: 0, dengeli: 1, garanti: 2 }; /** * Her dilimden en fazla n aday bırakır; dizideki sıra önceliktir (öndeki * kalır). Havuz tek dilime yığılmışsa (ör. çok iyi derece: neredeyse hepsi * güvenli) n'e kırpmak listeyi 24'ün altına düşürürdü; o durumda eksik, adayı * kalan dilimlerden tamamlanır — yeterli program varken liste 24'ten kısa * kalmaz. Sonuç dilim sırasına ve dilim içinde yakından uzağa dizilir. */ function dilimBasinaKirp(havuz: AdayProgram[], n: number): AdayProgram[] { const mevcut = dilimSay(havuz); const pay: DilimHedef = { hayal: Math.min(n, mevcut.hayal), dengeli: Math.min(n, mevcut.dengeli), garanti: Math.min(n, mevcut.garanti), }; eksigiDagit(pay, mevcut, Math.min(HEDEF, havuz.length)); const sayac: DilimHedef = { hayal: 0, dengeli: 0, garanti: 0 }; // hayal: sınıra en yakın (en büyük taban) önce; diğerleri en küçük taban önce const yakinlik = (p: AdayProgram) => p.dilim === "hayal" ? -(p.efektifSira ?? 0) : (p.efektifSira ?? 0); return havuz .filter((p) => ++sayac[p.dilim] <= pay[p.dilim]) .sort( (a, b) => DILIM_SIRASI[a.dilim] - DILIM_SIRASI[b.dilim] || yakinlik(a) - yakinlik(b), ); } /** * Katmanları öncelik sırasıyla, tekrarsız birleştirir. Gevşetmede kullanılır: * gevşeyen filtreye UYAN programlar önce gelir ve kırpmada korunur, kalan * boşluk sonraki katmanlardan (sıraya en yakın diğer programlar) dolar. */ function oncelikliBirlestir(...katmanlar: AdayProgram[][]): AdayProgram[] { const gorulen = new Set(); const sonuc: AdayProgram[] = []; for (const katman of katmanlar) { for (const p of katman) { if (gorulen.has(p.id)) continue; gorulen.add(p.id); sonuc.push(p); } } return sonuc; } 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: yapay zekâ bu havuzdan SEÇER, asla uydurmaz. * Sihirbaz seçimlerine (kategori/il/üniversite tipi) ve varsa danışman * profiline (dil, öğretim türü, burs, ücret, km) göre filtreler; havuz 24'ün * altına düşerse kademeli gevşetir (önce profil, sonra il, sonra kategori) ki * liste her zaman kurulabilsin. /meraklisina şeması bu sırayı anlatır. * * Süzgeçlerin hepsi dilimin TAMAMINA uygulanır, kırpma en sonda yapılır: * "24'ten az" kararı o alanda/ilde gerçekten kaç program olduğuna bakar. * Bir filtre gevşediğinde ona uyan programlar havuzdan düşmez: önce onlar * alınır, boşluk sıraya en yakın diğer programlarla dolar. */ export function havuzOlustur( params: RaporParams, profil?: DanismanProfili, ): 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, p.fakulte)) : havuz; const ilFiltre = (havuz: AdayProgram[], liste: string[] | undefined) => liste && liste.length > 0 ? havuz.filter((p) => p.il != null && liste.includes(p.il)) : havuz; // 0. Profil süzgeci (danışmana söylenenler) — yalnız yeterli havuz bırakırsa. // Bırakmazsa profil gevşetilir ve aşağıdaki sihirbaz akışı aynen sürer; // liste yine kurulur, gevşetme yapay zekâya ve adaya söylenir. const suzgec = profilSuzgeci(profil, iller.length > 0); const profilGevsetildi: Gevsetilen[] = []; // Profil gevşerse profile uyan (24'ten az) programlar yine en önde tutulur let profilliFiltreli: AdayProgram[] = []; if (suzgec.ozellik || suzgec.iller) { const profilliHam = ilFiltre( dilimEkle( havuzAdaylari(params.sira, params.tur, { uniturGrubu, ozellik: suzgec.ozellik, }), ), iller.length > 0 ? iller : suzgec.iller, ); profilliFiltreli = kategoriFiltre(profilliHam); if (profilliFiltreli.length >= HEDEF) { return { havuz: dilimBasinaKirp(profilliFiltreli, PROMPT_LIMIT_DILIM), gevsetildi: [], }; } profilGevsetildi.push("profil"); } // 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS) const ilsizHam = dilimEkle( havuzAdaylari(params.sira, params.tur, { uniturGrubu }), ); const tamHam = ilFiltre(ilsizHam, iller); const tamFiltreli = kategoriFiltre(tamHam); const kirp = (...katmanlar: AdayProgram[][]) => dilimBasinaKirp( oncelikliBirlestir(profilliFiltreli, ...katmanlar), PROMPT_LIMIT_DILIM, ); if (tamFiltreli.length >= HEDEF) { return { havuz: kirp(tamFiltreli), gevsetildi: profilGevsetildi }; } // 2. Kategori gevşet: seçili alandakiler kalır, boşluk aynı illerdeki // kategori-dışı programlarla dolar if (kategoriler.length > 0 && tamHam.length >= HEDEF) { return { havuz: kirp(tamFiltreli, tamHam), gevsetildi: [...profilGevsetildi, "kategori"], }; } // 3. İl gevşet: seçili ildekiler kalır, boşluk aynı kategorideki başka // illerin programlarıyla dolar const gevsetildi: Gevsetilen[] = [...profilGevsetildi]; if (iller.length > 0) gevsetildi.push("il"); const ilsizFiltreli = kategoriFiltre(ilsizHam); if (ilsizFiltreli.length >= HEDEF) { return { havuz: kirp(tamFiltreli, ilsizFiltreli), gevsetildi }; } // 4. Hem il hem kategori gevşet: önce iki seçime de uyanlar, sonra alana // uyanlar, sonra ile uyanlar, en son elde ne varsa if (kategoriler.length > 0) gevsetildi.push("kategori"); return { havuz: kirp(tamFiltreli, ilsizFiltreli, tamHam, ilsizHam), gevsetildi, }; } // Prompt'a veri penceresinin tamamı değil son 4 yılı gider (yeniden eskiye): // pencere uzadıkça satır başına token sayısı sabit kalır. const PROMPT_YIL_SAYISI = 4; const PROMPT_YIL_INDEKSLERI = YILLAR.map((_, i) => i) .slice(-PROMPT_YIL_SAYISI) .reverse(); /** 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, // Anahtarlar yıl taşır (y, kontenjan): model rakamın hangi // yıla ait olduğunu anahtardan okur. sira: Object.fromEntries( PROMPT_YIL_INDEKSLERI.map((i) => [ `y${YILLAR[i]}`, p.siraGecmisi[i] ?? null, ]), ), [`kontenjan${SON_YIL}`]: p.kontenjanSon, [`yerlesen${SON_YIL}`]: p.yerlesenSon, })), ); } export function turLabel(tur: PuanTuruKey): string { return PUAN_TURLERI[tur]; }