/** * Uyum puanı — adayın söylediklerinin (öncelik çipleri, özel kontenjan durumu, * danışman profilindeki kampüs ve ev ili) havuzdaki her programa kodla * verdiği 0-1 arası puan ve "öne çıkan" veri cümleleri. Yapay zekâ puanlamaz; * havuz bu puana göre sıralanır (rapor-havuzu.ts), gerekçeler bu cümlelerden * beslenir (ai/rapor.ts), danışman araçları aynı cümleleri döner (araclar.ts). * * İlkeler (25 Eyl araştırması §4, 6 Eki 2026 kararı): * - Sert filtre DEĞİL: havuzu daraltmaz, dilimi (hayal/dengeli/güvenli) * değiştirmez; yalnız sırayı yönlendirir. * - Verisi olmayan program cezalandırılmaz: boyut 0,5 (nötr) sayılır ve o * boyut için "öne çıkan" yazılmaz. * - Her boyut yılı belli bir kaynağa bağlıdır (güncellik kuralı): TÜİK 2025, * URAP 2025-2026, YÖK İstatistik 2025-2026, YÖK Atlas kılavuz yılı. * - "Kolay yerleşme" puan boyutu değildir: liste iskeletini değiştirir * (rapor-havuzu.ts `idealHedef`). * - Deterministik: aynı girdi her zaman aynı puan. Ölçüt yoksa hiç çağrılmaz * (`uyumBaglamiVar` false) — eval `beklenen` değerleri değişmez. * * server-only guard'ı bilinçli yok: scripts/eval ve tadimlik-uret düz tsx * altında rapor-havuzu üzerinden import eder. katalog.ts (server-only) * import EDİLMEZ; üniversite slug'ı lib/slug.ts `uniSayfaSlug` ile türer. */ import { kontenjanTurleriGetirToplu } from "./db"; import type { DanismanProfili } from "./danisman-profil"; import { BOLUM_ISTIHDAM, BOLUM_ISTIHDAM_YIL, ISTIHDAM_ORTALAMA, type IstihdamDuzeyi, type KazancGrubu, } from "./bolum-istihdam"; import { illerArasiKm } from "./mesafe"; import { OZEL_DURUM_KONTENJAN_ADI, OZEL_DURUM_KONTENJAN_ALANI, adayDurumu, type OzelDurum, } from "./ozel-durum"; import { programOzellikleriGetir, type ProgramOzellik } from "./program-ozellik"; import { bolumSayfaSlug, uniSayfaSlug } from "./slug"; import { URAP_DONEM, URAP_TOPLAM, urapSirasiBul } from "./urap-siralama"; import { YOK_ISTATISTIK, YOK_ISTATISTIK_DONEM, uniIstatistikBul } from "./yok-istatistik"; import type { RaporParams } from "@/features/rapor/types/rapor"; import type { KontenjanTurleri, Program } from "@/types/yokatlas"; export type UyumBaglami = { /** Adayın başarı sırası (yakınlık hesabı rapor-havuzu'nda). */ sira: number; /** Sihirbaz öncelik çipleri (ONCELIKLER adları). */ oncelikler: readonly string[]; /** Etkin özel kontenjan durumu (sihirbaz ∪ danışman profili). */ ozelDurum: readonly OzelDurum[]; profil?: DanismanProfili; /** Sihirbazda il seçildiyse ev iline uzaklık puanlanmaz (il seçimi daha açık). */ sihirbazIlVar: boolean; }; export type UyumSonucu = { /** 0-1; boyutların ağırlıklı ortalaması. */ puan: number; /** En fazla 3 kısa cümle, öğrenci dili, sayı + kaynak. Gerekçede meşru sayı kaynağı. */ oneCikan: string[]; }; /** Puan boyutu olan öncelikler (Kolay yerleşme iskeleti değiştirir, puanlamaz). */ const PUANLANAN_ONCELIKLER = new Set([ "İş bulma kolaylığı", "Yüksek maaş potansiyeli", "Akademik kariyer", "Prestijli üniversite", ]); const ONE_CIKAN_UST = 3; /** Bir boyutun "öne çıkan" cümle yazabilmesi için alt sınır (ortalama üstü). */ const ONE_CIKAN_ESIK = 0.6; const NOTR = 0.5; const KAZANC_PUANI: Record = { "Çok düşük": 0, Düşük: 0.25, Orta: 0.5, Yüksek: 0.75, "Çok yüksek": 1, }; /** Ağırlıklar: seçili öncelik 1; özel durum 1; profil boyutları 0,5. */ const AGIRLIK = { oncelik: 1, ozelDurum: 1, kampus: 0.5, mesafe: 0.5 } as const; /** İstihdam %'si ortalamanın ±20 puanı içinde 0-1'e açılır. */ const ISTIHDAM_GENISLIK = 20; /** Uzaklık: 0 km → 1, bu km ve ötesi → 0. */ const MESAFE_UST_KM = 1000; const sayi = (n: number) => n.toLocaleString("tr-TR"); const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); /** YÖK İstatistik örgün öğrenci sayısının medyanı — "sakin / kalabalık" kıyas çizgisi. */ const OGRENCI_MEDYANI = (() => { const sayilar = Object.values(YOK_ISTATISTIK) .map((i) => i.ogrenci) .filter((n) => n > 0) .sort((a, b) => a - b); return sayilar.length ? sayilar[Math.floor(sayilar.length / 2)] : 0; })(); export function uyumBaglami(params: RaporParams, profil?: DanismanProfili): UyumBaglami { return { sira: params.sira, oncelikler: params.oncelikler ?? [], ozelDurum: adayDurumu(params, profil), profil, sihirbazIlVar: (params.iller?.length ?? 0) > 0 || Boolean(params.il), }; } /** En az bir puan boyutu var mı; yoksa havuz sıralanmaz, satırlara "öne çıkan" yazılmaz. */ export function uyumBaglamiVar(b: UyumBaglami): boolean { if (b.oncelikler.some((o) => PUANLANAN_ONCELIKLER.has(o))) return true; if (b.ozelDurum.length > 0) return true; if (b.profil?.kampus === "sakin" || b.profil?.kampus === "kalabalik") return true; return mesafePuanlanir(b); } function mesafePuanlanir(b: UyumBaglami): boolean { return Boolean(b.profil?.evIl) && !b.profil?.maxKm && !b.sihirbazIlVar; } type Boyut = { agirlik: number; puan: number; oneCikan?: string }; /** * Havuzdaki programların uyum puanı ve öne çıkan cümleleri. Veri tek seferde * toplu okunur (program_ozellik, kontenjan türleri); URAP / YÖK İstatistik / * TÜİK bellekte. Ölçüt yoksa boş Map (çağıran `uyumBaglamiVar` ile atlar). */ export function uyumPuanla( programlar: readonly Program[], b: UyumBaglami, ): Map { const sonuc = new Map(); if (!uyumBaglamiVar(b) || programlar.length === 0) return sonuc; const ids = programlar.map((p) => p.id); const akademik = b.oncelikler.includes("Akademik kariyer"); const ozellikler = akademik ? programOzellikleriGetir(ids) : {}; const kontenjanlar = b.ozelDurum.length > 0 ? kontenjanTurleriGetirToplu(ids) : {}; // Kadro havuz içinde normalize edilir: en kalabalık kadro 1 const kadroUst = akademik ? Math.max(0, ...Object.values(ozellikler).map((o) => kadro(o) ?? 0)) : 0; for (const p of programlar) { const boyutlar: Boyut[] = []; const duzey: IstihdamDuzeyi = p.tur === "TYT" ? "onlisans" : "lisans"; const istihdam = BOLUM_ISTIHDAM[duzey][bolumSayfaSlug(p.isim)]; const uniSlug = uniSayfaSlug(p.universite); const urap = urapSirasiBul(uniSlug); const istatistik = uniIstatistikBul(uniSlug); for (const oncelik of b.oncelikler) { if (!PUANLANAN_ONCELIKLER.has(oncelik)) continue; boyutlar.push(oncelikBoyutu(oncelik, { istihdam, duzey, urap, ozellik: ozellikler[p.id], kadroUst })); } if (b.ozelDurum.length > 0) { boyutlar.push(ozelDurumBoyutu(b.ozelDurum, kontenjanlar[p.id])); } if (b.profil?.kampus === "sakin" || b.profil?.kampus === "kalabalik") { boyutlar.push(kampusBoyutu(b.profil.kampus, istatistik?.ogrenci ?? null)); } if (mesafePuanlanir(b) && b.profil?.evIl) { boyutlar.push(mesafeBoyutu(b.profil.evIl, p.il)); } const toplamAgirlik = boyutlar.reduce((t, x) => t + x.agirlik, 0); const puan = toplamAgirlik ? boyutlar.reduce((t, x) => t + x.agirlik * x.puan, 0) / toplamAgirlik : NOTR; const oneCikan = boyutlar .filter((x) => x.oneCikan && x.puan >= ONE_CIKAN_ESIK) .sort((x, y) => y.agirlik * y.puan - x.agirlik * x.puan) .slice(0, ONE_CIKAN_UST) .map((x) => x.oneCikan!); sonuc.set(p.id, { puan, oneCikan }); } return sonuc; } function kadro(o: ProgramOzellik | undefined): number | null { if (!o) return null; const parcalar = [o.prof, o.doc].filter((x): x is number => x != null); return parcalar.length ? parcalar.reduce((a, c) => a + c, 0) : null; } function oncelikBoyutu( oncelik: string, v: { istihdam: { istihdam: number | null; isBulmaAy: number | null; kazanc: KazancGrubu | null } | undefined; duzey: IstihdamDuzeyi; urap: { sira: number } | null; ozellik: ProgramOzellik | undefined; kadroUst: number; }, ): Boyut { const notr: Boyut = { agirlik: AGIRLIK.oncelik, puan: NOTR }; switch (oncelik) { case "İş bulma kolaylığı": { const oran = v.istihdam?.istihdam ?? null; const ort = ISTIHDAM_ORTALAMA[v.duzey].istihdam; if (oran == null || ort == null) return notr; return { agirlik: AGIRLIK.oncelik, puan: clamp01(0.5 + (oran - ort) / (2 * ISTIHDAM_GENISLIK)), oneCikan: `mezun istihdamı %${oran.toLocaleString("tr-TR", { maximumFractionDigits: 1 })} (TÜİK ${BOLUM_ISTIHDAM_YIL})`, }; } case "Yüksek maaş potansiyeli": { const grup = v.istihdam?.kazanc ?? null; if (!grup) return notr; return { agirlik: AGIRLIK.oncelik, puan: KAZANC_PUANI[grup], oneCikan: `kazanç grubu ${grup.toLocaleLowerCase("tr-TR")} (TÜİK ${BOLUM_ISTIHDAM_YIL})`, }; } case "Akademik kariyer": { const k = kadro(v.ozellik); const kadroPuan = k != null && v.kadroUst > 0 ? k / v.kadroUst : null; const urapPuan = v.urap ? urapPuani(v.urap.sira) : null; const parcalar = [kadroPuan, urapPuan].filter((x): x is number => x != null); if (parcalar.length === 0) return notr; const puan = parcalar.reduce((a, c) => a + c, 0) / parcalar.length; return { agirlik: AGIRLIK.oncelik, puan, oneCikan: k != null && kadroPuan != null && kadroPuan >= ONE_CIKAN_ESIK ? `${sayi(k)} profesör ve doçent (YÖK Atlas)` : v.urap ? `URAP araştırma sırası ${v.urap.sira}/${URAP_TOPLAM} (${URAP_DONEM})` : undefined, }; } case "Prestijli üniversite": { if (!v.urap) return notr; return { agirlik: AGIRLIK.oncelik, puan: urapPuani(v.urap.sira), oneCikan: `URAP araştırma sırası ${v.urap.sira}/${URAP_TOPLAM} (${URAP_DONEM})`, }; } default: return notr; } } /** URAP 1. sıra → 1, son sıra → 0. */ function urapPuani(sira: number): number { return clamp01(1 - (sira - 1) / Math.max(1, URAP_TOPLAM - 1)); } /** * Adayın durumuna uyan en az bir kontenjan türü varsa 1; kırılım verisi var ama * o kontenjan yoksa 0,3; kırılım verisi hiç yoksa nötr (0,5 — cezalandırılmaz). */ function ozelDurumBoyutu(durumlar: readonly OzelDurum[], k: KontenjanTurleri | undefined): Boyut { if (!k || k.genel.kontenjan == null) return { agirlik: AGIRLIK.ozelDurum, puan: NOTR }; const uyanlar = durumlar.filter((d) => (k[OZEL_DURUM_KONTENJAN_ALANI[d]].kontenjan ?? 0) > 0); if (uyanlar.length === 0) return { agirlik: AGIRLIK.ozelDurum, puan: 0.3 }; const d = uyanlar[0]; const dilim = k[OZEL_DURUM_KONTENJAN_ALANI[d]]; return { agirlik: AGIRLIK.ozelDurum, puan: 1, oneCikan: `${OZEL_DURUM_KONTENJAN_ADI[d]} var (${k.yil}: ${sayi(dilim.kontenjan ?? 0)} kontenjan, ${dilim.yerlesen != null ? sayi(dilim.yerlesen) : "—"} yerleşen)`, }; } /** Sakin: medyanın altı iyi; kalabalık: üstü iyi. Üniversite geneli öğrenci sayısı. */ function kampusBoyutu(tercih: "sakin" | "kalabalik", ogrenci: number | null): Boyut { if (ogrenci == null || OGRENCI_MEDYANI === 0) return { agirlik: AGIRLIK.kampus, puan: NOTR }; // medyanda 0,5; medyanın 1/3'ünde ~0,83; 3 katında ~0,17 (log ölçek) const oran = Math.log(ogrenci / OGRENCI_MEDYANI) / Math.log(3); const kucukIyi = clamp01(0.5 - oran / 2); const puan = tercih === "sakin" ? kucukIyi : 1 - kucukIyi; return { agirlik: AGIRLIK.kampus, puan, oneCikan: `${tercih === "sakin" ? "sakin" : "kalabalık"} kampüs (${sayi(ogrenci)} örgün öğrenci, YÖK İstatistik ${YOK_ISTATISTIK_DONEM})`, }; } function mesafeBoyutu(evIl: string, il: string | null): Boyut { const km = il ? illerArasiKm(evIl, il) : null; if (km == null) return { agirlik: AGIRLIK.mesafe, puan: NOTR }; return { agirlik: AGIRLIK.mesafe, puan: clamp01(1 - km / MESAFE_UST_KM), oneCikan: km === 0 ? "yaşadığın ilde" : `evine ${sayi(Math.round(km))} km`, }; }