Files
kolaytercih/src/lib/uyum-puani.ts
bilalgursen a3cb058a3b
All checks were successful
Deploy / deploy (push) Successful in 7m45s
feat: update app.db and BACKLOG.md with new decisions and adjustments
- Updated the app.db file to reflect recent changes in the application state.
- Modified BACKLOG.md to include new entries regarding the removal of certain wizard steps, adjustments to priority chips, and the introduction of special quota conditions for candidates.
- Added details on the decision to link priority chips to data sources and clarified the implications for user experience and data handling.
2026-10-06 17:17:49 +03:00

301 lines
12 KiB
TypeScript
Raw 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.
/**
* 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<KazancGrubu, number> = {
"Ç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<string, UyumSonucu> {
const sonuc = new Map<string, UyumSonucu>();
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`,
};
}