Files
kolaytercih/src/lib/rapor-havuzu.ts
bilalgursen 5c583ac8b4 feat(veri): Program.varyant — burs/dil/öğretim türü etiketi tek kaynaktan (program_ozellik)
programs.isim burs/dil varyantını taşımıyor (Başkent Tıp 6 satırda da
"Tıp"); ayırt edici alanlar program_ozellik.burs_orani / ogrenim_dili /
ogrenim_turu. SELECT_COLS üç ilişkili alt sorgu kazanır, programSatiri
varyantEtiketi ile "Burslu · İngilizce" gibi tek etiket üretir; devlet /
Türkçe / örgün programda null. Havuz JSON'una da girer: model aynı adlı
satırları ayırt edebilsin.

Tablo yokluğu koruması bilinçli yok: data/yokatlas.db depoda ve Docker
seed'inde (Dockerfile) program_ozellik tablosuyla geliyor. programs_arsiv
sorguları dokunulmadı (arşivde özellik yok).

Test: pnpm tsx scripts/eval/varyant-denetim.ts (salt okur, çıkış 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:12:03 +03:00

459 lines
18 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.
// 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<Dilim, number>;
// 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<Dilim, number> = { 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<string>();
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<Dilim, number> = { 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<yıl>, kontenjan<yıl>): 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];
}