// 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 { uyumBaglami, uyumBaglamiVar, uyumPuanla, type UyumBaglami } from "./uyum-puani"; import { SON_YIL, YILLAR } from "./veri-yillari"; export type Dilim = "hayal" | "dengeli" | "garanti"; export interface AdayProgram extends Program { dilim: Dilim; /** Uyum puanı 0-1 (lib/uyum-puani.ts); ölçüt yoksa yazılmaz. */ uyum?: number; /** Koddan üretilen "öne çıkan" veri cümleleri (≤3); gerekçede meşru sayı kaynağı. */ oneCikan?: string[]; /** Gevşetme katmanı (0 = adayın seçimine tam uyan); sıralamada birincil anahtar. */ katman?: number; } 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 }; // "Kolay yerleşme" önceliği seçiliyse güvenli dilim payı artar (6 Eki 2026): // 4 hayal / 12 dengeli / 8 güvenli. /meraklisina şeması bu iki iskeleti anlatır. export const IDEAL_HEDEF_KOLAY_YERLESME: DilimHedef = { hayal: 4, dengeli: 12, garanti: 8 }; export const KOLAY_YERLESME_ONCELIGI = "Kolay yerleşme"; /** Adayın önceliklerine göre liste iskeleti (tek kaynak; rapor, mock ve eval aynı yoldan). */ export function idealHedef(oncelikler?: readonly string[]): DilimHedef { return oncelikler?.includes(KOLAY_YERLESME_ONCELIGI) ? IDEAL_HEDEF_KOLAY_YERLESME : IDEAL_HEDEF; } 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[], ideal: DilimHedef = IDEAL_HEDEF, ): DilimHedef { const mevcut = dilimSay(havuz); const hedef: DilimHedef = { hayal: Math.min(ideal.hayal, mevcut.hayal), dengeli: Math.min(ideal.dengeli, mevcut.dengeli), garanti: Math.min(ideal.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), ); } /** Revizyonun "ekle" kimlikleri: dilim evreninde bulunanlar, katman 0 ile. */ function ekstraSec(evren: AdayProgram[], idler?: readonly string[]): AdayProgram[] { if (!idler?.length) return []; const iste = new Set(idler); return evren.filter((p) => iste.has(p.id)); } /** * 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[] = []; katmanlar.forEach((katman, i) => { for (const p of katman) { if (gorulen.has(p.id)) continue; gorulen.add(p.id); sonuc.push({ ...p, katman: i }); } }); return sonuc; } // Uyum puanının sıradaki ağırlığı; kalan sıraya yakınlık. İkisi de 0-1. const UYUM_AGIRLIGI = 0.6; /** * Havuzu kırpmadan önce dilim içinde sıralar: önce gevşetme katmanı (adayın * seçimine uyanlar öne — bu kural uyum puanından güçlü), sonra * 0,6 × uyum + 0,4 × sıraya yakınlık (yakınlık dilim içinde 0-1'e açılır). * Puanı ve "öne çıkan" cümleleri satıra yazar. Ölçüt yoksa havuza dokunmaz: * sıra ve eval `beklenen` değerleri değişmez. Deterministik (kararlı sort). */ function uyumlaSirala(havuz: AdayProgram[], baglam: UyumBaglami): AdayProgram[] { if (!uyumBaglamiVar(baglam) || havuz.length === 0) return havuz; const puanlar = uyumPuanla(havuz, baglam); const sira = baglam.sira; const enUzak: Record = { hayal: 0, dengeli: 0, garanti: 0 }; for (const p of havuz) { const d = Math.abs((p.efektifSira ?? sira) - sira); if (d > enUzak[p.dilim]) enUzak[p.dilim] = d; } const anahtar = (p: AdayProgram) => { const uyum = puanlar.get(p.id)?.puan ?? 0.5; const d = Math.abs((p.efektifSira ?? sira) - sira); const yakinlik = enUzak[p.dilim] > 0 ? 1 - d / enUzak[p.dilim] : 1; return UYUM_AGIRLIGI * uyum + (1 - UYUM_AGIRLIGI) * yakinlik; }; return havuz .map((p) => { const u = puanlar.get(p.id); return u ? { ...p, uyum: u.puan, oneCikan: u.oneCikan } : p; }) .sort((a, b) => (a.katman ?? 0) - (b.katman ?? 0) || anahtar(b) - anahtar(a)); } // Program adının sonundaki kontenjan eki; YÖK Atlas bu kontenjanları ayrı // program satırı olarak verir. const OZEL_KONTENJAN = /\((KKTC Uyruklu|M\.T\.O\.K\.)\)/; /** * Yalnızca belirli adaylara ayrılmış kontenjan satırı mı: "(KKTC Uyruklu)" * KKTC vatandaşlarına, "(M.T.O.K.)" mesleki ve teknik ortaöğretim kurumu * mezunlarına ayrılır. Sihirbaz adayın uyruğunu ya da lise türünü sormaz; bu * satırlar herkese önerilemez, aday havuzuna girmez. Kural tek yerde: aynı * ayrımı yapacak her yer (ör. danışman araçları) bunu kullanmalı. */ export function ozelKontenjanSatiriMi(isim: string): boolean { return OZEL_KONTENJAN.test(isim); } 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 kategori, sonra il; * o da yetmezse il ve kategori birlikte) ki liste her zaman kurulabilsin. * /meraklisina şeması bu sırayı anlatır; sırayı değiştiren şemayı da değiştirir. * * 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. * * Adayın yazamayacağı programlar hiçbir basamakta havuza girmez: başarı * sırası barajının gerisinde kaldığı bölümler ve özel kontenjan satırları. */ /** * Sohbet revizyonunun havuza dokunan kısmı (lib/ai/rapor.ts kurar): * `cikar` eşleşenler hiçbir katmana girmez; `oncelikli` eşleşenler kırpmadan * önce dilim içinde öne alınır ("ilk 5 İstanbul olsun" için İstanbul havuzda * kalsın); `ekstraIdler` adayın açıkça istediği programlar — sihirbaz * süzgecine takılsa bile (baraj ve özel kontenjan kuralı yine geçer) havuza * kendi dilimiyle eklenir. */ export type HavuzSecenekleri = { cikar?: (p: AdayProgram) => boolean; oncelikli?: (p: AdayProgram) => boolean; ekstraIdler?: readonly string[]; }; export function havuzOlustur( params: RaporParams, profil?: DanismanProfili, secenekler: HavuzSecenekleri = {}, ): 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; // Uyum puanı bağlamı (öncelikler, özel durum, kampüs, ev ili) — havuzu // daraltmaz, kırpmadan önce sıralar (uyumlaSirala) const baglam = uyumBaglami(params, profil); // 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); // Dilimin tamamı; adayın barajını geçemediği programlar (SQL) ve özel // kontenjan satırları (KKTC uyruklu, M.T.O.K.) hariç const adaylar = (ozellik?: OzellikSuzgeci) => dilimEkle( havuzAdaylari(params.sira, params.tur, { uniturGrubu, ozellik }), ).filter( (p) => !ozelKontenjanSatiriMi(p.isim) && !(secenekler.cikar?.(p) ?? false), ); // 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS). Süzgeçsiz // dilim evreni (ilsizHam) revizyonun "ekle" kimliklerinin de kaynağıdır. const ilsizHam = adaylar(); const ekstra = ekstraSec(ilsizHam, secenekler.ekstraIdler); // Revizyon önceliği gevşetme katmanından sonra, uyum puanından önce gelir: // kararlı sıralama, eşleşenler dilim içinde öne (kırpma öndekileri tutar). const oncelikle = (havuz: AdayProgram[]) => secenekler.oncelikli ? [...havuz].sort( (a, b) => (a.katman ?? 0) - (b.katman ?? 0) || Number(secenekler.oncelikli!(b)) - Number(secenekler.oncelikli!(a)), ) : havuz; // Ekstralar katman 0 ile başa; ötekilerin gevşetme katmanı korunur const ekstraIdKumesi = new Set(ekstra.map((p) => p.id)); const kirpVe = (havuz: AdayProgram[]) => dilimBasinaKirp( oncelikle( uyumlaSirala( [ ...ekstra.map((p) => ({ ...p, katman: 0 })), ...havuz.filter((p) => !ekstraIdKumesi.has(p.id)), ], baglam, ), ), PROMPT_LIMIT_DILIM, ); 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( adaylar(suzgec.ozellik), iller.length > 0 ? iller : suzgec.iller, ); profilliFiltreli = kategoriFiltre(profilliHam); if (profilliFiltreli.length >= HEDEF) { return { havuz: kirpVe(profilliFiltreli), gevsetildi: [], }; } profilGevsetildi.push("profil"); } const tamHam = ilFiltre(ilsizHam, iller); const tamFiltreli = kategoriFiltre(tamHam); const kirp = (...katmanlar: AdayProgram[][]) => kirpVe(oncelikliBirlestir(profilliFiltreli, ...katmanlar)); 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, // Burs/dil/öğretim türü: model aynı adlı satırları (Başkent Tıp // burslu / %50 / ücretli) ayırt etsin, gerekçede "burslu" diyebilsin ...(p.varyant ? { varyant: p.varyant } : {}), // 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, // Koddan hesaplanan "öne çıkan" veriler (uyum-puani.ts); model gerekçede // bunlardan birini yazabilir, başka sayı yazamaz (ai/rapor.ts denetimi) ...(p.oneCikan && p.oneCikan.length > 0 ? { one_cikan: p.oneCikan } : {}), })), ); } export function turLabel(tur: PuanTuruKey): string { return PUAN_TURLERI[tur]; }