Files
kolaytercih/src/lib/rapor-havuzu.ts
bilalgursen 497b0247a0 fix(havuz): B5 — gevşeyen filtreye uyan programlar havuzda kalsın [KARAR]
İl gevşeyince havuz baştan, ilsiz kuruluyordu; seçili ildeki programlar
sıraya en yakın 20'ye giremezse listeden tamamen düşüyordu (Kayseri 1/60,
Trabzon 0/25) — prompt ise "seçili illere öncelik ver" diyordu. Artık
gevşetmenin her basamağında seçime uyan programlar önce alınır, boşluk
sıraya en yakın diğer programlarla dolar. Aynı kural alan ve danışman
profili gevşediğinde de geçerli (tek yardımcı: oncelikliBirlestir). Kırpma
sonrası havuz yine dilim sırasında, dilim içinde yakından uzağa dizilir.

Kanıt (havuzda seçili ildeki program, önce → sonra): p12 Kayseri 1 → 9,
p13 Trabzon 0 → 4, b06 Düzce 2 → 10, a09 Konya 2 → 3. Gevşetme basamağı ve
liste uzunluğu hiçbir profilde değişmedi.

/meraklisina şeması, 4. bölüm anlatımı ve aria-label aynı kuralla
güncellendi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 01:15:01 +03:00

329 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.
// 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 { SON_YIL, YILLAR } from "./veri-yillari";
export type Dilim = "hayal" | "dengeli" | "garanti";
export interface AdayProgram extends Program {
dilim: Dilim;
}
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 };
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[]): DilimHedef {
const mevcut = dilimSay(havuz);
const hedef: DilimHedef = {
hayal: Math.min(IDEAL_HEDEF.hayal, mevcut.hayal),
dengeli: Math.min(IDEAL_HEDEF.dengeli, mevcut.dengeli),
garanti: Math.min(IDEAL_HEDEF.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),
);
}
/**
* 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[] = [];
for (const katman of katmanlar) {
for (const p of katman) {
if (gorulen.has(p.id)) continue;
gorulen.add(p.id);
sonuc.push(p);
}
}
return sonuc;
}
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 il, sonra kategori) ki
* liste her zaman kurulabilsin. /meraklisina şeması bu sırayı anlatır.
*
* 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.
*/
export function havuzOlustur(
params: RaporParams,
profil?: DanismanProfili,
): 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;
// 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);
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(
dilimEkle(
havuzAdaylari(params.sira, params.tur, {
uniturGrubu,
ozellik: suzgec.ozellik,
}),
),
iller.length > 0 ? iller : suzgec.iller,
);
profilliFiltreli = kategoriFiltre(profilliHam);
if (profilliFiltreli.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(profilliFiltreli, PROMPT_LIMIT_DILIM),
gevsetildi: [],
};
}
profilGevsetildi.push("profil");
}
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS)
const ilsizHam = dilimEkle(
havuzAdaylari(params.sira, params.tur, { uniturGrubu }),
);
const tamHam = ilFiltre(ilsizHam, iller);
const tamFiltreli = kategoriFiltre(tamHam);
const kirp = (...katmanlar: AdayProgram[][]) =>
dilimBasinaKirp(
oncelikliBirlestir(profilliFiltreli, ...katmanlar),
PROMPT_LIMIT_DILIM,
);
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,
// 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,
})),
);
}
export function turLabel(tur: PuanTuruKey): string {
return PUAN_TURLERI[tur];
}