feat: update app.db and BACKLOG.md with new decisions and adjustments
All checks were successful
Deploy / deploy (push) Successful in 7m45s
All checks were successful
Deploy / deploy (push) Successful in 7m45s
- 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.
This commit is contained in:
300
src/lib/uyum-puani.ts
Normal file
300
src/lib/uyum-puani.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 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`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user