feat: update app.db and BACKLOG.md with new decisions and adjustments
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:
bilalgursen
2026-10-06 17:17:49 +03:00
parent c0696e2cbc
commit a3cb058a3b
74 changed files with 10334 additions and 445 deletions

300
src/lib/uyum-puani.ts Normal file
View 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`,
};
}