Kod her zaman önce alanı, sonra ili gevşetiyordu (il korunur, aynı ildeki alan dışı programlarla dolar); fonksiyon yorumu, /meraklisina şeması, 4. bölüm anlatımı ve aria-label ise "önce il, sonra alan" diyordu. KARARLAR.md'de sıra için karar yok; davranış DEĞİŞMEDİ, yalnız anlatım koda eşitlendi (AGENTS.md şema senkronu). Hangi sıranın doğru olması gerektiği raporda [BİLAL] sorusu. Kanıt: p01 (Eskişehir), p06 (İzmir), a15 (Kilis) — il korunur, alan gevşer; 40 profilde bu commit öncesi/sonrası havuz birebir aynı. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
// 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;
|
||
}
|
||
|
||
// 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ı.
|
||
*/
|
||
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);
|
||
// 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));
|
||
|
||
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: dilimBasinaKirp(profilliFiltreli, PROMPT_LIMIT_DILIM),
|
||
gevsetildi: [],
|
||
};
|
||
}
|
||
profilGevsetildi.push("profil");
|
||
}
|
||
|
||
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS)
|
||
const ilsizHam = adaylar();
|
||
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];
|
||
}
|