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

View File

@@ -11,16 +11,25 @@
import type OpenAI from "openai";
import { jsonSchema, tool, type ToolSet } from "ai";
import {
arsivProgramAra,
ayrikListe,
dilimAraligi,
EFEKTIF_SIRA,
getDb,
katliEslesenler,
KONTENJAN_TURU_ONEK,
kontenjanTuruYili,
kontenjanTurleriGetir,
kontenjanTurleriGetirToplu,
netlerGetir,
programGecmisiGetir,
programGetir,
programSatiri,
SELECT_COLS,
type ProgramHamSatiri,
} from "@/lib/db";
import {
CINSIYET_KOSULU_METNI,
kosulMetinleri,
ogretimUyesiToplami,
ozellikOzeti,
@@ -34,12 +43,26 @@ import {
getBolumBySlug,
getIlBySlug,
getUniversiteBySlug,
ilgiliBolumler,
} from "@/lib/katalog";
import { KATEGORILER, kategoriEslesir } from "@/lib/kategoriler";
import {
KONTENJAN_TURU_ADI,
OZEL_DURUMLAR,
OZEL_DURUM_KONTENJAN_ADI,
OZEL_DURUM_KONTENJAN_ALANI,
ozelDurumDogrula,
type OzelDurum,
} from "@/lib/ozel-durum";
import { uyumBaglamiVar, uyumPuanla, type UyumBaglami } from "@/lib/uyum-puani";
import { OGRENCI_BASINA_UYE_MEDYAN } from "@/lib/yok-istatistik";
import { URAP_SIRA, urapSirasiBul } from "@/lib/urap-siralama";
import { getRehber, getRehberAramaKayitlari } from "@/lib/rehber";
import { SEZON_TAKVIMI, sezonAsamasi } from "@/lib/sezon";
import { kadinYuzdesi, ogrenciBasinaUye, YOK_ISTATISTIK_DONEM } from "@/lib/yok-istatistik";
import { URAP_DONEM, URAP_TOPLAM } from "@/lib/urap-siralama";
import { bolumSayfaSlug, turkishSlugify } from "@/lib/slug";
import type { KontenjanTurleri } from "@/types/yokatlas";
import { bolumSayfaSlug, turkishSlugify, uniSayfaSlug } from "@/lib/slug";
import {
BOLUM_ISTIHDAM,
BOLUM_ISTIHDAM_KAYNAK,
@@ -49,7 +72,7 @@ import {
import { DILIM_ETIKET } from "@/lib/risk";
import { trendOzetiMetni } from "@/lib/trend";
import { TUR_SAYFALARI } from "@/lib/unitur";
import { ONCEKI_YIL, SON_YIL, TERCIH_YILI, YILLAR } from "@/lib/veri-yillari";
import { ONCEKI_YIL, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { ozelKontenjanSatiriMi } from "@/lib/rapor-havuzu";
import { PUAN_TURLERI, type Program, type ProgramNetSatiri, type PuanTuruKey } from "@/types/yokatlas";
import {
@@ -69,13 +92,32 @@ export type AracBaglami = {
tur: PuanTuruKey;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
/** Sihirbaz öncelik çipleri (uyum puanı / öne çıkan için) */
oncelikler?: readonly string[];
/** Sihirbaz özel kontenjan durumu (profildekiyle birleşir: adayDurumu) */
ozelDurum?: readonly OzelDurum[];
/** Sihirbazda il seçildi mi (ev iline uzaklık puanlanmaz) */
sihirbazIlVar?: boolean;
};
/** Araç bağlamından uyum puanı bağlamı (profil tazelendikçe yeniden kurulur). */
function uyumBaglamiKur(b: AracBaglami): UyumBaglami {
return {
sira: b.sira,
oncelikler: b.oncelikler ?? [],
ozelDurum: ozelDurumDogrula([...(b.ozelDurum ?? []), ...(b.profil.ozelDurum ?? [])]),
profil: b.profil,
sihirbazIlVar: b.sihirbazIlVar ?? false,
};
}
export type AracTanimi = OpenAI.Chat.Completions.ChatCompletionTool;
const ARAMA_LIMIT = 8;
const ARAMA_LIMIT = 10;
const KARSILASTIRMA_UST = 4;
const NET_YIL_SAYISI = 2;
const SIRALAMA_UST = 30;
const FAKULTE_UST = 20;
const IL_DAGILIMI_UST = 8;
export const ARACLAR: AracTanimi[] = [
{
@@ -83,11 +125,24 @@ export const ARACLAR: AracTanimi[] = [
function: {
name: "program_ara",
description:
`Adayın puan türündeki programları veriden arar (YÖK Atlas, ${SON_YIL} yerleştirmesi). Bölüm adı, üniversite, il, devlet/vakıf, öğretim dili ve türü, burs ve yıllık ücret üst sınırına göre süzer; varsayılan olarak adayın sırasına en yakın programları döner. Her sonuçta taban sırası, dilim ve varsa başarı sırası barajı (baraj) gelir; aday barajın dışındaysa dilim yerine "Tercih edilemez (baraj dışı)" yazar. 'X yazabilir miyim', 'tabanı kaç', 'hangi programlar var' sorularında sayı uydurmak yerine bunu çağır.`,
`Adayın puan türündeki programları veriden arar (YÖK Atlas, ${SON_YIL} yerleştirmesi). Bölüm adı, alan (kategori), fakülte, üniversite, il, devlet/vakıf, öğretim dili ve türü, burs, yıllık ücret üst sınırı, akreditasyon ve özel kontenjan türüne göre süzer; varsayılan olarak adayın sırasına en yakın ${ARAMA_LIMIT} programı döner, devamı için sayfa=2, 3… Her sonuçta taban sırası, dilim, varsa başarı sırası barajı (baraj; aday dışındaysa dilim yerine "Tercih edilemez (baraj dışı)"), adayın özel durumuna uyan kontenjan türü ve koddan hesaplanan "öne çıkan" veriler gelir. 'X yazabilir miyim', 'tabanı kaç', 'hangi programlar var', 'sağlık alanında neler var' sorularında sayı uydurmak yerine bunu çağır.`,
parameters: {
type: "object",
properties: {
arama: { type: "string", description: "Bölüm adı parçası, ör. 'bilgisayar mühendisliği', 'hukuk'" },
kategori: {
type: "string",
enum: [...KATEGORILER],
description: "Alan (sihirbazdaki ilgi alanı); 'sağlık alanında', 'mühendislik dışında ne var' gibi sorularda. Bölüm adı bilinmiyorsa arama yerine bunu kullan.",
},
fakulte: { type: "string", description: "Fakülte/yüksekokul adı parçası, ör. 'tıp fakültesi', 'meslek yüksekokulu'" },
kontenjan_turu: {
type: "string",
enum: [...OZEL_DURUMLAR],
description: "Yalnız bu özel kontenjan türü olan programlar: okul-birincisi, sehit-gazi-yakini, depremzede, kadin-34 (34 yaş üstü kadın). Adayın özel durumu varsa ve 'bana ayrılmış kontenjanı olan' diye sorarsa kullan.",
},
akreditasyonlu: { type: "boolean", description: "true = yalnız program akreditasyonu olanlar (MÜDEK, TEPDAD, FEDEK …)" },
sayfa: { type: "integer", minimum: 1, description: `Sonuç sayfası (her sayfa ${ARAMA_LIMIT} program); 'daha fazla göster' için bir artır` },
universite: { type: "string", description: "Üniversite adı parçası ya da yaygın kısaltması, ör. 'Ege', 'ODTÜ', 'İTÜ', 'DEÜ'" },
il: { type: "string", description: "İl adı, ör. 'İzmir', 'Urfa'; KKTC için şehir ('Lefkoşa', 'Girne', 'Gazimağusa') ya da tümü için 'KKTC'" },
tur: { type: "string", enum: ["devlet", "vakif"], description: "Yalnız devlet ya da yalnız vakıf üniversiteleri" },
@@ -115,9 +170,9 @@ export const ARACLAR: AracTanimi[] = [
},
sirala: {
type: "string",
enum: ["yakinlik", "taban", "ucret"],
enum: ["yakinlik", "taban", "ucret", "kadro"],
description:
"yakinlik (varsayılan) = adayın sırasına en yakın; taban = en iyi taban sırası önce ('en iyi/en yüksek' sorularında); ucret = en ucuz önce (devlet ve tam burslu 0 sayılır)",
"yakinlik (varsayılan) = adayın sırasına en yakın; taban = en iyi taban sırası önce ('en iyi/en yüksek' sorularında); ucret = en ucuz önce (devlet ve tam burslu 0 sayılır); kadro = en çok profesör+doçent önce ('hocası güçlü', 'akademik kadro' sorularında)",
},
},
additionalProperties: false,
@@ -129,7 +184,7 @@ export const ARACLAR: AracTanimi[] = [
function: {
name: "program_detay",
description:
"Tek programın ayrıntısı: yıllara göre taban sırası ve kontenjan, ücret/burs, dil, öğretim türü, akreditasyon, kadro, başarı sırası barajı (baraj; aday dışındaysa barajDurumu 'tercih edilemez' der) ve kılavuzdaki yerleşme koşulları (sağlık şartı, mülakat vb.).",
"Tek programın ayrıntısı: yıllara göre taban sırası, puan, kontenjan ve yerleşen (gecmis), kontenjan türleri (genel, okul birincisi, şehit-gazi yakını, depremzede, 34 yaş üstü kadın — kontenjan ve yerleşen; bu türlerin ayrı taban sırası elimizde YOK), ücret/burs, dil, öğretim türü, program ve üniversite akreditasyonu, kadro, başarı sırası barajı (baraj; aday dışındaysa barajDurumu 'tercih edilemez' der), yalnız kız/erkek öğrenci koşulu, kılavuzdaki yerleşme koşulları (sağlık şartı, mülakat vb.), bölümün TÜİK mezun istihdamı ve üniversitenin URAP sırası. 'Kontenjan geçen yıl arttı mı', 'kaç kişi yerleşmiş', 'bana ayrılmış kontenjan var mı' sorularında bunu çağır.",
parameters: {
type: "object",
properties: {
@@ -145,7 +200,7 @@ export const ARACLAR: AracTanimi[] = [
function: {
name: "program_netleri",
description:
"Programa yerleşen SON kişinin ders bazında netleri (TYT/AYT/YDT), taban puanı ve OBP'si; son iki yıl. 'Kaç net lazım' sorularında bunu çağır, net uydurma.",
"Programa yerleşen SON kişinin ders bazında netleri (TYT/AYT/YDT), taban puanı, OBP'si ve OBP katsayısı (0,12 standart; 0,18 = meslek lisesi alan içi ek puanla yerleşmiş); verideki tüm yıllar. 'Kaç net lazım' sorularında bunu çağır, net uydurma.",
parameters: {
type: "object",
properties: {
@@ -204,7 +259,7 @@ export const ARACLAR: AracTanimi[] = [
function: {
name: "bolum_ozeti",
description:
"Bir bölümün Türkiye geneli özeti: kaç program var, kaçı devlet/vakıf, kaç ilde, en iyi taban sırası ve bölüm sayfasının adresi. 'Bu bölüm kaç üniversitede var' gibi genel sorularda bunu çağır; tek tek program için program_ara kullan.",
"Bir bölümün Türkiye geneli özeti: kaç program var, kaçı devlet/vakıf, kaç ilde, hangi illerde çok (ilDagilimi), alanları (kategoriler), en iyi taban sırası ve puan aralığı, ilgili bölümler, TÜİK mezun istihdamı ve bölüm sayfasının adresi. 'Bu bölüm kaç üniversitede var', 'en çok hangi şehirde', 'benzer bölümler' sorularında bunu çağır; tek tek program için program_ara kullan.",
parameters: {
type: "object",
properties: {
@@ -261,7 +316,7 @@ export const ARACLAR: AracTanimi[] = [
function: {
name: "universite_profili",
description:
"Üniversitenin künyesi: il, devlet/vakıf, program sayısı, URAP araştırma sırası, örgün öğrenci sayısı, hoca başına öğrenci ve kadın/erkek öğrenci oranı (üniversite geneli, bölüm bazında veri yok).",
"Üniversitenin künyesi: il, devlet/vakıf, program sayısı, fakülteler (program sayılarıyla), kampüs ilçeleri, URAP araştırma sırası, üniversite akreditasyonu (YÖKAK), örgün öğrenci sayısı, öğretim üyesi ve toplam akademisyen sayısı, hoca başına öğrenci (medyanla kıyas) ve kadın/erkek öğrenci oranı (üniversite geneli, bölüm bazında veri yok). 'Hangi fakülteleri var', 'kampüs nerede', 'büyük mü' sorularında bunu çağır.",
parameters: {
type: "object",
properties: {
@@ -272,6 +327,56 @@ export const ARACLAR: AracTanimi[] = [
},
},
},
{
type: "function",
function: {
name: "universite_siralamasi",
description: `URAP Türkiye genel sıralaması (${URAP_DONEM}, ${URAP_TOPLAM} üniversite): sıra, üniversite, il, tür ve sayfa adresi; ile ya da türe göre süzülebilir. 'En iyi üniversiteler', 'İzmir'in en iyi üniversitesi', 'X kaçıncı sırada' sorularında bunu çağır. URAP araştırma performansını ölçer — eğitim kalitesi ya da iş bulma değil; cevabında bunu söyle.`,
parameters: {
type: "object",
properties: {
il: { type: "string", description: "Yalnız bu ildeki üniversiteler, ör. 'İzmir'" },
tur: { type: "string", enum: ["devlet", "vakif"], description: "Yalnız devlet ya da yalnız vakıf" },
ust: { type: "integer", minimum: 1, maximum: SIRALAMA_UST, description: `Kaç satır (varsayılan 10, en çok ${SIRALAMA_UST})` },
sayfa: { type: "integer", minimum: 1, description: "Sonuç sayfası; devamı için bir artır" },
},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "kapanan_program_ara",
description: `Güncel kılavuzda OLMAYAN (kapanan, birleşen ya da kodu değişen) programları arşivde arar: son kılavuz yılı, o yılın taban sırası/puanı/kontenjanı. 'X üniversitesinin Y bölümü neden listede/katalogda yok', 'bu bölüm kapandı mı' sorularında önce program_ara ile güncel kılavuza bak, bulamazsan bunu çağır. Arşivdeki program tercih EDİLEMEZ; bunu açıkça söyle.`,
parameters: {
type: "object",
properties: {
arama: { type: "string", description: "Bölüm adı parçası" },
universite: { type: "string", description: "Üniversite adı parçası ya da kısaltması" },
il: { type: "string", description: "İl adı" },
},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "rehber_oku",
description:
"Belirli bir rehber yazısının sorulan konuyla ilgili paragraflarını okur (rehber_ara'dan dönen adres/slug ile). Aday 'o yazıda ne diyor', 'ayrıntısı ne', 'oranı kaç' diye sorduğunda ya da rehber_ara'nın özeti yetmediğinde çağır. Kural, oran, tutar ve tarihleri YALNIZ okuduğun paragraflarda yazdığı kadarıyla aktar.",
parameters: {
type: "object",
properties: {
slug: { type: "string", description: "Yazının adresi ya da slug'ı, ör. '/rehber/obp-nedir-nasil-hesaplanir' ya da 'obp-nedir-nasil-hesaplanir'" },
konu: { type: "string", description: "Yazıda aranacak konu, 1-3 anahtar sözcük (verilmezse yazının özü döner)" },
},
required: ["slug"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
@@ -279,7 +384,7 @@ export const ARACLAR: AracTanimi[] = [
description:
"Adayın sohbette söylediği bir tercihi profile kaydeder ya da siler (deger null). YALNIZ şu alanlar: " +
PROFIL_ALANLARI.map((a) => `${a} (${PROFIL_ALAN_ACIKLAMA[a]})`).join("; ") +
". Sağlık, engel, din, aile, gelir, cinsiyet gibi bilgileri ASLA kaydetme. Kaydettikten sonra cevabında tek cümleyle 'Şunu kaydettim: …' de.",
". Sağlık, engel, din, aile, gelir, cinsiyet gibi bilgileri ASLA kaydetme (ozelDurum bir cinsiyet alanı DEĞİLDİR: ÖSYM'nin ayırdığı kontenjan hakkının beyanıdır — 'okul birincisiyim', 'depremzedeyim', 'şehit yakınıyım', '34 yaş üstü kadınım' kaydedilebilir). Kaydettikten sonra cevabında tek cümleyle 'Şunu kaydettim: …' de.",
parameters: {
type: "object",
properties: {
@@ -291,7 +396,8 @@ export const ARACLAR: AracTanimi[] = [
`burs: ${Object.keys(PROFIL_SECENEKLERI.burs).join("|")}; ` +
`dil: ${Object.keys(PROFIL_SECENEKLERI.dil).join("|")}; ` +
`ogretimTuru: ${Object.keys(PROFIL_SECENEKLERI.ogretimTuru).join("|")}; ` +
`kampus: ${Object.keys(PROFIL_SECENEKLERI.kampus).join("|")}; null = alanı sil`,
`kampus: ${Object.keys(PROFIL_SECENEKLERI.kampus).join("|")}; ` +
`ozelDurum: ${OZEL_DURUMLAR.join("|")} (birden çoğu virgülle, tümü yazılır — eklerken eskileri de yaz); null = alanı sil`,
},
},
required: ["alan", "deger"],
@@ -319,9 +425,31 @@ function dilimAdi(sira: number, efektif: number | null, baraj?: number | null):
return DILIM_ETIKET.garanti;
}
function programOzeti(p: Program, sira: number, o?: ProgramOzellik) {
/** Adayın özel durumuna uyan kontenjan türleri (yalnız >0 olanlar), öğrenci diliyle. */
function uyanKontenjanlar(
k: KontenjanTurleri | undefined,
durumlar: readonly OzelDurum[],
): Record<string, string> | undefined {
if (!k || durumlar.length === 0) return undefined;
const sonuc: Record<string, string> = {};
for (const d of durumlar) {
const dilim = k[OZEL_DURUM_KONTENJAN_ALANI[d]];
if ((dilim.kontenjan ?? 0) > 0) {
sonuc[OZEL_DURUM_KONTENJAN_ADI[d]] = `${k.yil}: ${dilim.kontenjan} kontenjan, ${dilim.yerlesen ?? "—"} yerleşen (ayrı taban sırası elimizde yok)`;
}
}
return Object.keys(sonuc).length ? sonuc : undefined;
}
function programOzeti(
p: Program,
sira: number,
o?: ProgramOzellik,
ek?: { kontenjanTurleri?: KontenjanTurleri; ozelDurum?: readonly OzelDurum[]; oneCikan?: string[] },
) {
// Baraj yalnız veride varsa yazılır; boş = barajsız ya da veride eksik (uydurulmaz)
const baraj = o?.minBasariSirasi ?? null;
const adayinKontenjani = uyanKontenjanlar(ek?.kontenjanTurleri, ek?.ozelDurum ?? []);
return {
id: p.id,
program: p.isim,
@@ -340,9 +468,27 @@ function programOzeti(p: Program, sira: number, o?: ProgramOzellik) {
: {}),
kontenjan: p.kontenjanSon,
ozellik: o ? ozellikOzeti(o) : [],
...(adayinKontenjani ? { adayinOzelKontenjani: adayinKontenjani } : {}),
...(ek?.oneCikan?.length ? { oneCikan: ek.oneCikan } : {}),
};
}
/** Sonuç satırlarına uyum puanının "öne çıkan" cümlelerini ekler (ölçüt yoksa boş Map). */
function oneCikanlar(b: AracBaglami, programlar: readonly Program[]): Map<string, string[]> {
const baglam = uyumBaglamiKur(b);
const sonuc = new Map<string, string[]>();
if (!uyumBaglamiVar(baglam)) return sonuc;
for (const [id, u] of uyumPuanla(programlar, baglam)) {
if (u.oneCikan.length) sonuc.set(id, u.oneCikan);
}
return sonuc;
}
/** Adayın etkin özel durumu (sihirbaz ∪ profil). */
function adayOzelDurumu(b: AracBaglami): OzelDurum[] {
return ozelDurumDogrula([...(b.ozelDurum ?? []), ...(b.profil.ozelDurum ?? [])]);
}
/** Arama metninin katlanmış hâli en az bu kadar harf olmalı; daha kısası her kayıtla eşleşir. */
const ARAMA_ALT_HARF = 3;
@@ -434,28 +580,6 @@ function aramaIliCoz(ham: string): { il: string } | { kktc: true } | null {
return adaylar.length === 1 ? { il: adaylar[0].il } : null;
}
const ayrikDegerler = new Map<string, { ham: string; katli: string }[]>();
/**
* Kolonun, katlanmış hâli `katliSorgu`yu içeren değerleri. SQLite LIKE Türkçe
* harf katlamayı bilmez ("isletme" İşletme'yi, "bogazici" Boğaziçi'ni bulmaz);
* ayrık değerler (birkaç bin) bir kez okunur, eşleşme JS'te yapılır.
*/
function ayrikListe(kolon: "isim" | "universite"): { ham: string; katli: string }[] {
let liste = ayrikDegerler.get(kolon);
if (!liste) {
liste = (getDb().prepare(`SELECT DISTINCT ${kolon} AS v FROM programs`).all() as { v: string | null }[])
.filter((x): x is { v: string } => x.v != null)
.map((x) => ({ ham: x.v, katli: turkishSlugify(x.v) }));
ayrikDegerler.set(kolon, liste);
}
return liste;
}
function katliEslesenler(kolon: "isim" | "universite", katliSorgu: string): string[] {
return ayrikListe(kolon).filter((x) => x.katli.includes(katliSorgu)).map((x) => x.ham);
}
/** Özel kontenjan satırlarının (KKTC Uyruklu, M.T.O.K.) ayrık program adları. */
function ozelKontenjanAdlari(): string[] {
return ayrikListe("isim").map((x) => x.ham).filter(ozelKontenjanSatiriMi);
@@ -515,6 +639,8 @@ function inKosulu(kolon: string, degerler: string[]): string {
type ProgramAraArg = {
arama?: string;
kategori?: string;
fakulte?: string;
universite?: string;
il?: string;
tur?: "devlet" | "vakif";
@@ -524,12 +650,18 @@ type ProgramAraArg = {
ucret_ust?: number;
dilim?: "hayal" | "dengeli" | "guvenli";
ozel_kontenjan?: boolean;
sirala?: "yakinlik" | "taban" | "ucret";
kontenjan_turu?: OzelDurum;
akreditasyonlu?: boolean;
sayfa?: number;
sirala?: "yakinlik" | "taban" | "ucret" | "kadro";
};
/** Ücret sıralaması: devlet (ücret yok) ve tam burslu 0 sayılır. */
const UCRET_SIRA_SQL = `COALESCE((SELECT CASE WHEN o.burs_orani = 'Burslu' THEN 0 ELSE o.ucret END
FROM program_ozellik o WHERE o.id = programs.id), 0)`;
/** Kadro sıralaması: profesör + doçent (boşlar 0). */
const KADRO_SIRA_SQL = `COALESCE((SELECT COALESCE(o.prof,0) + COALESCE(o.doc,0)
FROM program_ozellik o WHERE o.id = programs.id), 0)`;
function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): ProgramAraSonuc {
const db = getDb();
@@ -547,6 +679,23 @@ function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): Prog
args.push(...adlar);
uygulanan.push(`bölüm ~ "${a.arama}"`);
}
// Alan ve fakülte DB kolonu değil (kategori isim+fakülteden türer, fakülte
// Türkçe katlama ister): JS'te süzülür; o zaman SQL limitsiz koşar.
const kategori = a.kategori && (KATEGORILER as readonly string[]).includes(a.kategori) ? a.kategori : undefined;
const fakulteKatli = a.fakulte ? turkishSlugify(a.fakulte) : "";
if (a.fakulte && fakulteKatli.length < ARAMA_ALT_HARF) {
return bos(`Fakülte araması için en az ${ARAMA_ALT_HARF} harf yaz: ${a.fakulte}`);
}
if (kategori) uygulanan.push(`alan = ${kategori}`);
if (fakulteKatli) uygulanan.push(`fakülte ~ "${a.fakulte}"`);
const jsSuzgec = Boolean(kategori || fakulteKatli);
if (a.kontenjan_turu) {
const yil = kontenjanTuruYili();
if (yil == null) return bos("Kontenjan türü verisi bu veritabanında yok.");
const onek = KONTENJAN_TURU_ONEK[OZEL_DURUM_KONTENJAN_ALANI[a.kontenjan_turu]];
kosullar.push(`${onek}${yil} > 0`);
uygulanan.push(`${OZEL_DURUM_KONTENJAN_ADI[a.kontenjan_turu]} olanlar (${yil} kırılımı)`);
}
if (a.universite) {
const katli = uniKatli(a.universite);
if (katli.length < ARAMA_ALT_HARF) return bos(`Üniversite araması için en az ${ARAMA_ALT_HARF} harf yaz: ${a.universite}`);
@@ -623,6 +772,10 @@ function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): Prog
ozKosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
uygulanan.push("profil: yalnız örgün");
}
if (a.akreditasyonlu) {
ozKosul.push("(akreditasyon IS NOT NULL AND akreditasyon <> '')");
uygulanan.push("yalnız akreditasyonlu programlar");
}
if (ozKosul.length) {
kosullar.push(`id IN (SELECT id FROM program_ozellik WHERE ${ozKosul.join(" AND ")})`);
args.push(...ozArgs);
@@ -646,21 +799,44 @@ function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): Prog
? `${EFEKTIF_SIRA} ASC, ${yakinlik}`
: a.sirala === "ucret"
? `${UCRET_SIRA_SQL} ASC, ${yakinlik}`
: yakinlik;
: a.sirala === "kadro"
? `${KADRO_SIRA_SQL} DESC, ${yakinlik}`
: yakinlik;
if (a.sirala === "taban") uygulanan.push("sıralama: en iyi taban sırası önce");
if (a.sirala === "ucret") uygulanan.push("sıralama: en ucuz önce");
const satirlar = db
.prepare(
`SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")}
ORDER BY ${siralama} LIMIT ?`,
if (a.sirala === "kadro") uygulanan.push("sıralama: en kalabalık kadro (prof.+doç.) önce");
const sayfa = Math.max(1, Math.floor(a.sayfa ?? 1));
const offset = (sayfa - 1) * ARAMA_LIMIT;
let programlar: Program[];
let toplam: number;
if (jsSuzgec) {
// Limitsiz SQL + JS süzgeci (alan / fakülte), sonra sayfala
const tumu = (
db
.prepare(`SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")} ORDER BY ${siralama}`)
.all(...args, b.sira) as ProgramHamSatiri[]
)
.all(...args, b.sira, ARAMA_LIMIT) as ProgramHamSatiri[];
const programlar = satirlar.map(programSatiri);
const toplam = (
db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${kosullar.join(" AND ")}`).get(...args) as {
n: number;
}
).n;
.map(programSatiri)
.filter((p) => !kategori || kategoriEslesir(p.isim, [kategori], p.fakulte))
.filter((p) => !fakulteKatli || turkishSlugify(p.fakulte ?? "").includes(fakulteKatli));
toplam = tumu.length;
programlar = tumu.slice(offset, offset + ARAMA_LIMIT);
} else {
programlar = (
db
.prepare(
`SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")}
ORDER BY ${siralama} LIMIT ? OFFSET ?`,
)
.all(...args, b.sira, ARAMA_LIMIT, offset) as ProgramHamSatiri[]
).map(programSatiri);
toplam = (
db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${kosullar.join(" AND ")}`).get(...args) as {
n: number;
}
).n;
}
if (sayfa > 1) uygulanan.push(`sayfa ${sayfa}`);
// Profil süzgeçleri sonucu sıfırladıysa profilsiz tekrar dene ve bunu söyle:
// model "hiç yok" demek yerine "profilin dışında şunlar var" diyebilsin.
if (toplam === 0 && profilUygula && uygulanan.some((u) => u.includes("profil"))) {
@@ -670,18 +846,34 @@ function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): Prog
not: `Profil süzgeçleriyle (${uygulanan.filter((u) => u.includes("profil")).join("; ")}) eşleşen program YOK; aşağıdakiler profil süzgeci olmadan bulundu — adaya bunu açıkça söyle. ${gevsek.not}`,
};
}
const ozellikler = programOzellikleriGetir(programlar.map((p) => p.id));
const sonuclar = programlar.map((p) => programOzeti(p, b.sira, ozellikler[p.id]));
const ids = programlar.map((p) => p.id);
const ozellikler = programOzellikleriGetir(ids);
const ozelDurum = adayOzelDurumu(b);
const kontenjanlar = ozelDurum.length ? kontenjanTurleriGetirToplu(ids) : {};
const oneCikan = oneCikanlar(b, programlar);
const sonuclar = programlar.map((p) =>
programOzeti(p, b.sira, ozellikler[p.id], {
kontenjanTurleri: kontenjanlar[p.id],
ozelDurum,
oneCikan: oneCikan.get(p.id),
}),
);
const barajDisiSayisi = sonuclar.filter((x) => "barajDurumu" in x).length;
const kalan = Math.max(0, toplam - offset - programlar.length);
return {
toplamEslesen: toplam,
gosterilen: programlar.length,
sayfa,
uygulananFiltreler: uygulanan,
sonuclar,
not:
`tabanSira ${SON_YIL} yerleştirmesinin taban başarı sırasıdır (tabanYili ${ONCEKI_YIL} ise o program ${SON_YIL}'da dolmadı); her yıl değişir.` +
(kalan ? ` ${kalan} sonuç daha var: devamı için aynı aramayı sayfa=${sayfa + 1} ile çağır.` : "") +
(barajDisiSayisi
? ` ${barajDisiSayisi} sonuç BARAJ DIŞI: aday bu programları tercih edemez; öneri olarak sunma, "Hayal olarak yaz" deme, barajı açıkça söyle.`
: "") +
(ozelDurum.length
? " adayinOzelKontenjani alanı: adayın durumuna ayrılmış kontenjan; o kontenjanın taban sırası elimizde YOK, 'kontenjanın var, girersin' deme."
: ""),
};
}
@@ -689,6 +881,7 @@ function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): Prog
type ProgramAraSonuc = {
toplamEslesen: number;
gosterilen: number;
sayfa?: number;
uygulananFiltreler: string[];
sonuclar: ReturnType<typeof programOzeti>[];
not: string;
@@ -701,25 +894,52 @@ function programDetay(b: AracBaglami, a: { program_id?: string }) {
const o = programOzellikGetir(id);
const kosullar = o ? kosulMetinleri(o.kosulKodlari).slice(0, 8) : [];
const evKm = b.profil.evIl && p.il ? illerArasiKm(b.profil.evIl, p.il) : null;
const kontenjanTurleri = kontenjanTurleriGetir(id);
const duzey: IstihdamDuzeyi = b.tur === "tyt" ? "onlisans" : "lisans";
const istihdam = BOLUM_ISTIHDAM[duzey][bolumSayfaSlug(p.isim)] ?? null;
const uniSlug = uniSayfaSlug(p.universite);
const urap = urapSirasiBul(uniSlug);
return {
...programOzeti(p, b.sira, o ?? undefined),
...programOzeti(p, b.sira, o ?? undefined, {
kontenjanTurleri: kontenjanTurleri ?? undefined,
ozelDurum: adayOzelDurumu(b),
oneCikan: oneCikanlar(b, [p]).get(p.id),
}),
fakulte: p.fakulte,
sure: p.sure,
tabanSiraYillara: Object.fromEntries(YILLAR.map((y, i) => [y, p.siraGecmisi[i]])),
// Yıl → sıra/puan/kontenjan/yerleşen (yeniden eskiye); boş yıl: dolmadı ya da yoktu
gecmis: Object.fromEntries(
programGecmisiGetir(id).map((g) => [g.yil, { sira: g.sira, puan: g.puan, kontenjan: g.kontenjan, yerlesen: g.yerlesen }]),
),
trend: trendOzetiMetni(p.siraGecmisi),
puanSon: p.puanSon,
yerlesenSon: p.yerlesenSon,
kontenjanTurleri: kontenjanTurleri
? {
yil: kontenjanTurleri.yil,
...Object.fromEntries(
(Object.keys(KONTENJAN_TURU_ADI) as (keyof typeof KONTENJAN_TURU_ADI)[])
.filter((t) => (kontenjanTurleri[t].kontenjan ?? 0) > 0)
.map((t) => [KONTENJAN_TURU_ADI[t], { kontenjan: kontenjanTurleri[t].kontenjan, yerlesen: kontenjanTurleri[t].yerlesen }]),
),
not: "Özel kontenjanlar yalnız o durumdaki adaylara açıktır; her birinin ayrı taban sırası elimizde YOK.",
}
: null,
ucretYillik: o?.ucret ?? null,
bursOrani: o?.bursOrani ?? null,
ogretimDili: o?.ogrenimDili ?? null,
ogretimTuru: o?.ogrenimTuru ?? null,
akreditasyon: o?.akreditasyon ?? null,
uniAkreditasyon: o?.uniAkreditasyon ?? null,
kadro: o
? { prof: o.prof, docent: o.doc, drOgrUyesi: o.dou, arsGor: o.argor, ogretimUyesiToplam: ogretimUyesiToplami(o) }
: null,
cinsiyetKosulu: o?.cinsiyetKosulu ? CINSIYET_KOSULU_METNI[o.cinsiyetKosulu] : null,
ilce: o?.ilce ?? null,
evdenKm: evKm,
kosullar: kosullar.map((k) => (k.metin.length > 240 ? `${k.metin.slice(0, 237)}…` : k.metin)),
istihdam: istihdam
? { kayitliIstihdamYuzde: istihdam.istihdam, ilkIsBulmaAy: istihdam.isBulmaAy, kazancGrubu: istihdam.kazanc, kaynak: BOLUM_ISTIHDAM_KAYNAK, not: "Ülke geneli, bölüm bazında; üniversite kırılımı yok." }
: null,
urap: urap ? { sira: urap.sira, toplam: URAP_TOPLAM, donem: URAP_DONEM } : null,
};
}
@@ -746,7 +966,7 @@ function programNetleri(_b: AracBaglami, a: { program_id?: string }) {
const id = String(a.program_id ?? "").trim();
const p = id ? programGetir(id) : null;
if (!p) return { hata: "Program bulunamadı; önce program_ara ile id al." };
const satirlar = netlerGetir(id).slice(0, NET_YIL_SAYISI);
const satirlar = netlerGetir(id);
if (!satirlar.length) {
return { program: p.isim, universite: p.universite, hata: "Bu program için net verisi yok; uydurma." };
}
@@ -757,13 +977,14 @@ function programNetleri(_b: AracBaglami, a: { program_id?: string }) {
yil: s.yil,
tabanPuan: s.tabanPuan,
obp: s.obp,
obpKatsayisi: s.katsayi,
netler: Object.fromEntries(
(Object.keys(NET_ETIKET) as (keyof ProgramNetSatiri)[])
.filter((k) => s[k] != null)
.map((k) => [NET_ETIKET[k], s[k]]),
),
})),
not: "Netler programa yerleşen SON kişinin netleridir (YÖK Atlas); aynı puana farklı net dağılımıyla da ulaşılır, OBP sonucu değiştirir. Hedef değil, yaklaşık düzey olarak aktar.",
not: "Netler programa yerleşen SON kişinin netleridir (YÖK Atlas); aynı puana farklı net dağılımıyla da ulaşılır, OBP sonucu değiştirir. Hedef değil, yaklaşık düzey olarak aktar. obpKatsayisi 0,12 standarttır; 0,18 ise o yıl son yerleşen meslek lisesi alan içi ek puanıyla (0,06) yerleşmiştir — düz lise mezunu için netler daha yüksek olmalıydı, bunu söyle.",
};
}
@@ -832,16 +1053,32 @@ function bolumOzeti(_b: AracBaglami, a: { bolum?: string }) {
const bolum = aday ? getBolumBySlug(aday.slug) : null;
if (!bolum) return { hata: `Bölüm bulunamadı: ${q}` };
const s = bolum.stats;
const programlar = [...bolum.lisansProgramlari, ...bolum.onlisansProgramlari];
const ilSayac = new Map<string, number>();
for (const p of programlar) if (p.il) ilSayac.set(p.il, (ilSayac.get(p.il) ?? 0) + 1);
const ilDagilimi = [...ilSayac]
.sort((x, y) => y[1] - x[1])
.slice(0, IL_DAGILIMI_UST)
.map(([il, n]) => `${il}: ${n}`);
const duzey: IstihdamDuzeyi = bolum.lisans ? "lisans" : "onlisans";
const istihdam = BOLUM_ISTIHDAM[duzey][bolum.slug] ?? null;
return {
ad: bolum.ad,
duzey: [bolum.lisans ? "lisans" : null, bolum.onlisans ? "ön lisans" : null].filter(Boolean),
kategoriler: bolum.kategoriler,
programSayisi: s.toplam,
devletProgram: s.devlet,
vakifProgram: s.vakif,
ilSayisi: s.ilSayisi,
ilDagilimi,
enIyiTabanSira: s.minSira,
tabanPuanAraligi: s.minPuan != null && s.maxPuan != null ? { enDusuk: s.minPuan, enYuksek: s.maxPuan } : null,
ilgiliBolumler: ilgiliBolumler(bolum.slug, 5).map((x) => ({ ad: x.ad, sayfa: `/bolum/${x.slug}` })),
istihdam: istihdam
? { kayitliIstihdamYuzde: istihdam.istihdam, ilkIsBulmaAy: istihdam.isBulmaAy, kazancGrubu: istihdam.kazanc, kaynak: BOLUM_ISTIHDAM_KAYNAK }
: null,
sayfa: `/bolum/${bolum.slug}`,
not: "Sayılar tüm puan türleri ve Türkiye geneli içindir; adayın sırasına göre program listesi için program_ara kullan.",
not: "Sayılar tüm puan türleri ve Türkiye geneli içindir; adayın sırasına göre program listesi için program_ara kullan. ilDagilimi en çok programı olan iller (program sayısı).",
};
}
@@ -885,7 +1122,10 @@ const HTML_VARLIK: Record<string, string> = { "&amp;": "&", "&lt;": "<", "&gt;":
* "0,75" yazdı). Alıntı kendi rehberimizin metnidir; şema parçalarında şemanın
* sözel açıklaması (aria) kullanılır. Okunamazsa boş döner — araç düşmez.
*/
function rehberAlintisi(slug: string, kelimeler: string[]): string {
/** rehber_oku ile doğrudan okunan yazının alıntı üst sınırı (karakter); rehber_ara'dakinden geniş. */
const REHBER_OKUMA_UST = 2400;
function rehberAlintisi(slug: string, kelimeler: string[], ustSinir = REHBER_ALINTI_UST): string {
try {
const yazi = getRehber(slug);
if (!yazi) return "";
@@ -906,7 +1146,7 @@ function rehberAlintisi(slug: string, kelimeler: string[]): string {
let uzunluk = puanli[0]?.metin.length ?? 0;
for (const x of [...puanli].sort((a, b) => b.puan - a.puan || a.i - b.i)) {
if (secilen.has(x.i) || x.puan === 0) continue;
if (uzunluk + x.metin.length > REHBER_ALINTI_UST) continue;
if (uzunluk + x.metin.length > ustSinir) continue;
secilen.add(x.i);
uzunluk += x.metin.length;
}
@@ -994,11 +1234,27 @@ function universiteProfili(_b: AracBaglami, a: { universite?: string }) {
if (!u) return { hata: `Üniversite bulunamadı: ${q}` };
const i = u.istatistik;
const kadin = i ? kadinYuzdesi(i) : null;
// Üniversite akreditasyonu ve kampüs ilçeleri program_ozellik'ten: en sık değer / ayrık liste
const tumProgramlar = u.fakulteler.flatMap((f) => f.programlar);
const ozellikler = Object.values(programOzellikleriGetir(tumProgramlar.map((p) => p.id)));
const enSik = (degerler: (string | null)[]) => {
const sayac = new Map<string, number>();
for (const d of degerler) if (d) sayac.set(d, (sayac.get(d) ?? 0) + 1);
return [...sayac].sort((x, y) => y[1] - x[1])[0]?.[0] ?? null;
};
const ilceler = [...new Set(ozellikler.map((o) => o.ilce).filter((x): x is string => Boolean(x)))].sort();
return {
ad: u.ad,
il: u.il,
tur: u.unitur,
programSayisi: u.programSayisi,
fakulteSayisi: u.fakulteler.length,
fakulteler: [...u.fakulteler]
.sort((x, y) => y.programlar.length - x.programlar.length)
.slice(0, FAKULTE_UST)
.map((f) => `${f.fakulte} (${f.programlar.length} program)`),
kampusIlceleri: ilceler,
uniAkreditasyon: enSik(ozellikler.map((o) => o.uniAkreditasyon)),
enDusukTabanSira: u.stats.minSira,
toplamKontenjan: u.stats.toplamKontenjan,
urap: u.urap ? { sira: u.urap.sira, toplam: URAP_TOPLAM, donem: URAP_DONEM, olctugu: "araştırma performansı; eğitim kalitesi ya da iş bulma değil" } : null,
@@ -1007,16 +1263,109 @@ function universiteProfili(_b: AracBaglami, a: { universite?: string }) {
donem: YOK_ISTATISTIK_DONEM,
orgunOgrenci: i.ogrenci,
ogretimUyesi: i.ogretimUyesi,
akademisyenToplam: i.akademisyen,
hocaBasinaOgrenci: ogrenciBasinaUye(i),
hocaBasinaOgrenciMedyan: OGRENCI_BASINA_UYE_MEDYAN,
kadinYuzdesi: kadin,
erkekYuzdesi: kadin == null ? null : 100 - kadin,
not: "Kadın/erkek oranı örgün ön lisans + lisans, ÜNİVERSİTE GENELİ; bölüm bazında veri yok. Bilgi olarak söyle, filtre yapma.",
not: "Kadın/erkek oranı örgün ön lisans + lisans, ÜNİVERSİTE GENELİ; bölüm bazında veri yok. Bilgi olarak söyle, filtre yapma. hocaBasinaOgrenci medyanın altındaysa hoca başına daha az öğrenci düşüyor demektir.",
}
: null,
sayfa: `/universite/${u.slug}`,
};
}
function universiteSiralamasi(_b: AracBaglami, a: { il?: string; tur?: "devlet" | "vakif"; ust?: number; sayfa?: number }) {
const ust = Math.min(SIRALAMA_UST, Math.max(1, Math.floor(a.ust ?? 10)));
const sayfa = Math.max(1, Math.floor(a.sayfa ?? 1));
let ilAdi: string | null = null;
if (a.il) {
const cozum = aramaIliCoz(a.il);
if (!cozum || "kktc" in cozum) return { hata: `İl tanınmadı ya da URAP'ta yok: ${a.il}` };
ilAdi = cozum.il;
}
const uniler = new Map(getAllUniversiteler().map((u) => [u.slug, u]));
const liste = Object.entries(URAP_SIRA)
.map(([slug, u]) => ({ slug, sira: u.sira, uni: uniler.get(slug) }))
.filter((x) => x.uni)
.filter((x) => !ilAdi || x.uni!.il === ilAdi)
.filter((x) => !a.tur || (TUR_SAYFALARI[a.tur].uniturler as readonly string[]).includes(x.uni!.unitur ?? ""))
.sort((x, y) => x.sira - y.sira);
const dilim = liste.slice((sayfa - 1) * ust, sayfa * ust);
return {
donem: URAP_DONEM,
toplam: liste.length,
gosterilen: dilim.length,
sayfa,
siralama: dilim.map((x) => ({
sira: x.sira,
universite: x.uni!.ad,
il: x.uni!.il,
tur: x.uni!.unitur,
sayfa: `/universite/${x.slug}`,
})),
not: `URAP Türkiye genel sıralaması (${URAP_TOPLAM} üniversite) ARAŞTIRMA performansını ölçer — yayın ve atıf; eğitim kalitesi, iş bulma ya da öğrenci memnuniyeti değildir. Cevabında bunu söyle. "siralama" satırlarını (sıra + üniversite, numaralı liste) cevabına YAZ — "işte liste:" deyip boş bırakma. Listede olmayan üniversite (vakıf/KKTC'nin çoğu) kötü demek değildir, sıralanmamıştır.`,
};
}
function kapananProgramAra(_b: AracBaglami, a: { arama?: string; universite?: string; il?: string }) {
if (!a.arama && !a.universite && !a.il) return { hata: "En az bir ölçüt gerekli: arama, universite ya da il." };
let il: string | undefined;
if (a.il) {
const cozum = aramaIliCoz(a.il);
if (!cozum || "kktc" in cozum) return { hata: `İl tanınmadı: ${a.il}` };
il = cozum.il;
}
const universite = a.universite ? uniKatli(a.universite) : undefined;
if (a.arama && turkishSlugify(a.arama).length < ARAMA_ALT_HARF) return { hata: `En az ${ARAMA_ALT_HARF} harf yaz.` };
if (universite && universite.length < ARAMA_ALT_HARF) return { hata: `Üniversite için en az ${ARAMA_ALT_HARF} harf yaz.` };
// db.arsivProgramAra üniversiteyi katlanmış dizeyle arar; kısaltma burada açıldı
const { toplam, programlar } = arsivProgramAra({
arama: a.arama,
universite: universite ? universite.replace(/-/g, " ") : undefined,
il,
limit: ARAMA_LIMIT,
});
if (toplam === 0) return { toplam: 0, programlar: [], not: "Arşivde de böyle bir program yok; güncel kılavuz için program_ara'yı dene. Program adı ya da kod uydurma." };
return {
toplam,
gosterilen: programlar.length,
programlar: programlar.map((p) => ({
program: p.isim,
universite: p.universite,
il: p.il,
tur: p.unitur,
puanTuru: p.tur,
sonKilavuzYili: p.sonKilavuzYili,
sonYerlestirme: p.sonYil ? { yil: p.sonYil, tabanSira: p.sonSira, tabanPuan: p.sonPuan, kontenjan: p.sonKontenjan, yerlesen: p.sonYerlesen } : null,
...(p.eskiKod ? { eskiKod: p.eskiKod } : {}),
})),
not: `Bu programlar ${TERCIH_YILI} tercih kılavuzunda YOK: kapanmış, başka programla birleşmiş ya da kodu değişmiş olabilir; tercih edilemezler. Aynı bölümün güncel programı varsa program_ara ile bul. Kapanma sebebini uydurma (veride yok).`,
};
}
function rehberOku(_b: AracBaglami, a: { slug?: string; konu?: string }) {
const slug = String(a.slug ?? "")
.trim()
.replace(/^https?:\/\/[^/]+/, "")
.replace(/^\/?rehber\//, "")
.replace(/[/?#].*$/, "");
if (!slug) return { hata: "Yazının adresi ya da slug'ı gerekli (rehber_ara sonucundaki sayfa alanı)." };
const yazi = getRehber(slug);
if (!yazi) return { hata: `Böyle bir rehber yazısı yok: ${slug}. Önce rehber_ara ile adresi bul.` };
const kelimeler = turkishSlugify(String(a.konu ?? ""))
.split("-")
.filter((k) => k.length > 2);
const alinti = rehberAlintisi(slug, kelimeler, REHBER_OKUMA_UST);
return {
baslik: yazi.baslik,
ozet: yazi.aciklama,
sayfa: `/rehber/${slug}`,
alinti: alinti || yazi.aciklama,
not: "Kural, oran, katsayı, tutar ve tarihleri YALNIZ alıntıda yazdığı kadarıyla aktar; alıntıda yoksa 'yazının tamamında' de ve adresini ver.",
};
}
function sayiParse(v: unknown): number | null {
if (typeof v === "number") return v;
if (typeof v !== "string") return null;
@@ -1045,6 +1394,16 @@ async function profilGuncelle(b: AracBaglami, a: { alan?: string; deger?: unknow
const il = trIlCoz(String(deger));
if (!il) return { hata: `İl tanınmadı: ${String(deger)}` };
deger = il;
} else if (alan === "ozelDurum") {
// "okul-birincisi, depremzede" → allowlist dizisi; bilinmeyen değer hata
const parcalar = (Array.isArray(deger) ? deger : String(deger).split(","))
.map((x) => turkishSlugify(String(x)).replace(/^sehit-?gazi-?yakini$/, "sehit-gazi-yakini"))
.filter(Boolean);
const gecerli = ozelDurumDogrula(parcalar);
if (gecerli.length !== parcalar.length) {
return { hata: `Bilinmeyen özel durum; yalnız şunlar: ${OZEL_DURUMLAR.join(", ")}` };
}
deger = gecerli;
} else {
deger = String(deger).trim();
}
@@ -1181,6 +1540,12 @@ export function aracCalistirici(b: AracBaglami) {
return tercihTakvimi();
case "universite_profili":
return universiteProfili(b, a);
case "universite_siralamasi":
return universiteSiralamasi(b, a);
case "kapanan_program_ara":
return kapananProgramAra(b, a);
case "rehber_oku":
return rehberOku(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
default:

View File

@@ -11,6 +11,7 @@ import type { RaporParams } from "@/features/rapor/types/rapor";
import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler";
import { PUAN_TURLERI } from "@/types/yokatlas";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { adayDurumu, ozelDurumOzeti } from "@/lib/ozel-durum";
import { ONCEKI_YIL, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
/**
@@ -70,12 +71,19 @@ export function sistemPromptu(opts: {
profil?: DanismanProfili;
/** Verideki başarı sırası barajları (araclar.barajTablosu); verilmezse baraj bölümü yazılmaz. */
barajlar?: { sira: number; bolumler: string }[];
/**
* Adayın raporunun üst metni (yapay zekânın listeyle birlikte yazdığı genel
* değerlendirme ve uyarılar) ve kapsam notu. Kilitli kullanıcıda genel
* değerlendirme verilmez (liste gizli); uyarılar herkese.
*/
rapor?: { genelDegerlendirme?: string; uyarilar?: string[]; kapsam?: { toplam: number; uyarlandi: boolean } };
}): string {
const { params } = opts;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.ozelDurum?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
const parcalar = [
@@ -94,6 +102,12 @@ KURALLAR:
sıralaması adayın belirgin altında, Az riskli (sarı) = adayın sıralaması
civarında, Riskli (kırmızı) = son yerleştirmede adayın sıralamasının
üzerinde kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
- Satırlarda "neden:" listeyi kuran yapay zekânın o satır için yazdığı
gerekçe, "öne çıkan:" ise KODUN hesapladığı gerçek veriler (TÜİK istihdamı,
URAP sırası, kadro, özel kontenjan, kampüs, uzaklık). "Bu program neden
listemde?" sorusunda ÖNCE bu ikisini aktar; sayı gerekirse program_detay
ile destekle. Gerekçe boşsa "liste kurulurken gerekçe yazılmamış" de,
uydurma.
- VERİ YILI: listedeki ve araçlardaki taban sıraları ile kontenjanlar
${SON_YIL} yerleştirmesine aittir (${SON_YIL}'da dolmayan az sayıda programda
${ONCEKI_YIL}; araç bunu "tabanYili" alanında söyler). Yıl söylemen
@@ -123,13 +137,26 @@ KURALLAR:
"Bu durumda listende Güvenli etiketli tercih azalır / kalmaz."
(Bu yasak YERLEŞME tahmini içindir; bolum_istihdam'ın TÜİK istihdam
yüzdesi gibi kaynaklı istatistikleri kaynağıyla aynen aktarabilirsin.)
- Bilmediğin güncel veriyi uydurma: taban sırası, kontenjan, ücret, burs,
dil, akreditasyon, koşul, net, mezun istihdamı, bölüm ya da şehir özeti,
tercih takvimi, rehber yazısı ya da üniversite künyesi gerekince ARAÇLARI
çağır (program_ara, program_detay, program_netleri, program_karsilastir,
bolum_istihdam, bolum_ozeti, il_ozeti, rehber_ara, tercih_takvimi,
universite_profili); araç veremiyorsa
- ÖZEL KONTENJAN (okul birincisi, şehit-gazi yakını, depremzede, 34 yaş üstü
kadın): aday böyle bir durum söylerse "yararlanırsın / bu kontenjandan
girersin" DEME. ÖSYM bu kontenjanlar için ayrı taban sırası yayımlar ve o
sıra elimizde YOK; yararlanma koşulunu ÖSYM kılavuzuna bırak, genel taban
sırasıyla kıyasla. Programda böyle bir kontenjan olup olmadığını araç
(program_detay, program_ara) söyler; sayı uydurma.
- Bilmediğin güncel veriyi uydurma: taban sırası, kontenjan (yıllara göre
ve türlere göre), yerleşen, ücret, burs, dil, akreditasyon, koşul, net,
mezun istihdamı, bölüm ya da şehir özeti, üniversite sıralaması, kapanan
program, tercih takvimi, rehber yazısı ya da üniversite künyesi gerekince
ARAÇLARI çağır (program_ara, program_detay, program_netleri,
program_karsilastir, bolum_istihdam, bolum_ozeti, il_ozeti, rehber_ara,
rehber_oku, tercih_takvimi, universite_profili, universite_siralamasi,
kapanan_program_ara); araç veremiyorsa
"elimde veri yok" de ve YÖK Atlas'a bakmasını söyle.
"X üniversitesinin Y bölümü neden yok / kapandı mı" sorusunda önce
program_ara, bulamazsan kapanan_program_ara; arşivdeki program tercih
edilemez. "En iyi üniversite" sorusunda universite_siralamasi (URAP
araştırma sırası — eğitim kalitesi değil, bunu söyle). Rehber yazısının
ayrıntısı sorulunca rehber_oku.
SAYI KURALI: bir taban sırası, kontenjan, ücret, net, kira, burs tutarı ya
da oran yazacaksan o sayı yukarıdaki listede ya da BU cevapta çağırdığın
aracın sonucunda görünüyor olmalı. "Yaklaşık", "civarı", "genelde",
@@ -150,6 +177,9 @@ KURALLAR:
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır
(sınır: bir adımda 4, bir cevapta 8 çağrı; fazlası çalıştırılmaz).
Araç çağırmadan ÖNCE yazdığın metin ("İşte en iyi 10 üniversite:")
listeyi yazmış sayılmaz; araç sonucu geldikten SONRA satırları (sıra +
ad, numaralı liste) cevabına mutlaka yaz. "İşte …:" deyip boş bırakma.
Listedeki her satırın başında programın kimliği var ("id: …"):
program_detay, program_netleri ve program_karsilastir'ı listedeki
programlar için DOĞRUDAN bu kimlikle çağır, önce program_ara çağırma.
@@ -160,18 +190,27 @@ KURALLAR:
olmayan bir soru sorduysa ("hangi üniversitelerde var", "… okutan yerler",
"en iyi / en ucuz …") cevabı yalnız listeden verme, program_ara çağır.
Kimlikler araç içindir: adaya kimlik numarası, araç adı ya da parametre
yazma ("program_ara'ya baktım" değil, "verilere baktım").
yazma ("program_ara'ya baktım" değil, "verilere baktım"; "program_detay ile
bakabilirim" değil, "ayrıntısına bakabilirim"; "(ID: 103310131)" gibi kimlik
yazma). Liste hakkında bir sayım yapacaksan ("kaç satırda özel kontenjan
var" gibi) yukarıdaki satırları tek tek say, tahmin etme.
- BAĞLANTI: site içi bir sayfaya yönlendirirken markdown bağlantısı yaz —
[Yazının başlığı](/rehber/slug), [Üniversite adı](/universite/slug),
[Bölüm adı](/bolum/slug); adresi düz metin ya da ters tırnak içinde yazma. Adres ve
başlık araç sonucundaki "sayfa" / "baslik" / "ad" alanından gelir; adres
uydurma. Ekranda bu bağlantı ikonlu bir hap olarak görünür.
- PROFİL: aday bir tercih söylerse (yaşadığı il, evden en fazla kaç km,
vakıfta yıllık ücret sınırı, burs beklentisi, öğretim dili, uzaktan/açık
kabul mü, kampüs büyüklüğü) ${PROFIL_ONAY_SOR
kabul mü, kampüs büyüklüğü, özel kontenjan durumu) ${PROFIL_ONAY_SOR
? `HEMEN KAYDETME: cevabının sonunda tek cümleyle, tam bu ibareyle sor:
"Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz; listen ve
aramalar buna göre süzülür, istediğin zaman silebilirsin.)" Aday
evet/kaydet derse o zaman profil_guncelle ile kaydet ve "Kaydettim: …" de.
Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.
Bu soruyu YALNIZ aday bu mesajında yukarıdaki yedi tercihten birini
söylediyse sor; ilgi alanı, bölüm merakı, duygu, aile durumu ya da genel
bir soru için SORMA ve cevabın başına koyma.`
Bu soruyu YALNIZ aday bu mesajında yukarıdaki yedi tercihten birini ya da
özel kontenjan durumunu (okul birincisi, şehit-gazi yakını, depremzede,
34 yaş üstü kadın kontenjanı) söylediyse sor; ilgi alanı, bölüm merakı,
duygu, aile durumu ya da genel bir soru için SORMA ve cevabın başına koyma.`
: `profil_guncelle ile kaydet ve cevabının sonunda tek cümleyle söyle:
"Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`}
KAYIT DÜRÜSTLÜĞÜ: kayıt YALNIZ profil_guncelle aracıyla olur. "Kaydettim",
@@ -232,6 +271,40 @@ KURALLAR:
// TYT sırası lisans barajlarıyla kıyaslanmaz; ön lisansta baraj yok
const baraj = params.tur === "tyt" ? null : barajBolumu(params.sira, opts.barajlar);
if (baraj) parcalar.splice(2, 0, baraj);
// Raporun üst metni: adayın listesini kuran yapay zekânın genel
// değerlendirmesi ve uyarıları — danışman "listem nasıl kuruldu?" sorusunu
// kendi raporundan cevaplar. Kilitli kullanıcıda genel değerlendirme gizli.
const raporUst: string[] = [];
if (opts.rapor?.genelDegerlendirme && !opts.kilitli) {
raporUst.push(`Genel değerlendirme: ${opts.rapor.genelDegerlendirme}`);
}
if (opts.rapor?.uyarilar?.length) {
raporUst.push(`Uyarılar: ${opts.rapor.uyarilar.map((u) => `(${u})`).join(" ")}`);
}
if (opts.rapor?.kapsam?.uyarlandi) {
raporUst.push(
`Kapsam: liste ${opts.rapor.kapsam.toplam} tercihtir ve dağılım havuza uyarlandı (ideal 5/13/6 tutmadı); "neden 24 değil / neden güvenli az" sorusunda bunu ve uyarılardaki gevşetme notunu aktar.`,
);
}
if (raporUst.length) {
parcalar.push(`LİSTENİN ÜST METNİ (liste kurulurken yazıldı; aday bunu ekranda görüyor):\n${raporUst.join("\n")}`);
}
const ozelDurum = adayDurumu(params, opts.profil);
if (ozelDurum.length) {
parcalar.push(
`ADAYIN ÖZEL KONTENJAN DURUMU: ${ozelDurumOzeti(ozelDurum)}. ÖSYM bu durumlar için
programlarda ayrı (genelde 1-2 kişilik) kontenjan ayırır; araç sonuçlarında
"adayinOzelKontenjani" / "kontenjanTurleri" alanı o programda böyle bir
kontenjan olup olmadığını söyler. Bu kontenjanların AYRI taban sırası elimizde
YOK; genel taban sırasıyla kıyaslama yapılır. Vaat yasağı burada da geçerli:
"kontenjanın var, rahat girersin / şansın yüksek" DEME; "bu programda sana
ayrılmış N kişilik kontenjan var; taban sırası bu kontenjan için ayrı
yayımlanır, elimde yok" de. Cinsiyet ya da sağlık sorma; 34 yaş üstü kadın
kontenjanı bir kontenjan hakkıdır, cinsiyet bilgisi olarak kullanma. Bu durum
ZATEN kayıtlı (sihirbazdan ya da profilden): yeniden kaydetmeyi teklif etme,
bunun için onay sorusu sorma.`,
);
}
if (opts.kilitli) {
parcalar.push(
`ÖNEMLİ: Adayın paketi yok; listenin yalnızca ilk ${ACIK_SATIR} tercihi açık.
@@ -255,6 +328,7 @@ tercih soruları üzerinden yardımcı olmaya devam et.`,
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
ozelDurum: params.ozelDurum ?? [],
})}`,
);
}

View File

@@ -24,6 +24,7 @@ import {
havuzuKompaktJson,
turLabel,
dilimHedefiHesapla,
idealHedef,
IDEAL_HEDEF,
type DilimHedef,
type Dilim,
@@ -110,7 +111,11 @@ KURALLAR (kesin):
- SADECE havuzdaki id'leri kullan; havuzda olmayan program ASLA önerme.
Aynı id'yi birden fazla kez kullanma.
- Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini
ve bölüm çeşitliliğini gözet.
ve bölüm çeşitliliğini gözet. Havuz satırında "one_cikan" varsa bunlar
adayın önceliklerine göre KODLA hesaplanmış gerçek verilerdir (TÜİK
istihdamı, URAP sırası, kadro, özel kontenjan, kampüs, uzaklık): o
programları öne al ve gerekçede bunlardan EN FAZLA BİRİNİ, yazıldığı
gibi kullanabilirsin.
- gerekce: TEK kısa cümle, en fazla 12 kelime, Türkçe. YALNIZCA o satırdaki
programı anlat: alanının ya da ilinin adayın seçimine uyumu, bölümün ya da
üniversitenin öne çıkan yanı. Başka bir programın ilini ya da üniversitesini
@@ -145,11 +150,11 @@ const MIN_LISTE = 3;
const DILIMLER: Dilim[] = ["hayal", "dengeli", "garanti"];
function kapsamUret(hedef: DilimHedef): RaporKapsam {
function kapsamUret(hedef: DilimHedef, ideal: DilimHedef = IDEAL_HEDEF): RaporKapsam {
return {
toplam: hedef.hayal + hedef.dengeli + hedef.garanti,
dagilim: { ...hedef },
uyarlandi: DILIMLER.some((d) => hedef[d] !== IDEAL_HEDEF[d]),
uyarlandi: DILIMLER.some((d) => hedef[d] !== ideal[d]),
};
}
@@ -359,7 +364,7 @@ function mockRapor(
revizyonNotu?: string,
): RaporSonuc {
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
const hedef = dilimHedefiHesapla(havuz);
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const efektif = (p: AdayProgram) => p.efektifSira ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
@@ -382,6 +387,7 @@ function mockRapor(
// Risk ve trend mock'ta da gerçek veriden: canlı davranışla birebir aynı
riskNotu: riskNotuUret(p, params.sira),
trendOzeti: trendOzetiUret(p),
...(p.oneCikan?.length ? { oneCikan: p.oneCikan } : {}),
}));
const programlar: RaporSonuc["programlar"] = {};
@@ -401,7 +407,7 @@ function mockRapor(
"(Dev mock) Güvenli dilimindeki son tercihleri asla silme.",
],
programlar,
kapsam: kapsamUret(hedef),
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
devMock: true,
};
}
@@ -486,14 +492,16 @@ export async function raporUretAyrintili(
// Dilim hedefleri havuzun gerçek dağılımına uyarlanır: sabit 5/13/6 bazı
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
// üretim her seferinde doğrulamada patlıyordu.
const hedef = dilimHedefiHesapla(havuz);
// üretim her seferinde doğrulamada patlıyordu. İskelet "Kolay yerleşme"
// seçiliyse 4/12/8 (idealHedef).
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.ozelDurum?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
const gevsetmeNotu: string[] = [];
@@ -526,6 +534,7 @@ export async function raporUretAyrintili(
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
ozelDurum: params.ozelDurum ?? [],
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
: null,
params.notlar
@@ -560,7 +569,9 @@ export async function raporUretAyrintili(
kategoriEslesir(p.isim, seciliKategoriler, p.fakulte);
const ilUyar = (p: AdayProgram) =>
seciliIller.length > 0 && p.il != null && seciliIller.includes(p.il);
const uyum = (p: AdayProgram) => (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0);
// Alan 2, il 1, uyum puanı 0-1: tamamlama ve kırpma önce seçime uyana, eşitte
// uyum puanı yüksek olana gider
const uyum = (p: AdayProgram) => (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
// Onarım modelin seçiminin en az yarısını koruyabiliyorsa ikinci çağrı
// yapılmaz; altındaysa liste çoğunlukla kodla dolardı, model bir kez daha denenir.
@@ -671,14 +682,18 @@ export async function raporUretAyrintili(
const sablonGerekceMetni = (p: AdayProgram): string => {
const alan = alanUyar(p);
const il = ilUyar(p);
if (alan && il) return "Seçtiğin alan ve ille eşleşiyor.";
// Öne çıkan veri varsa şablon da onu söyler (koddan, sayı meşru)
const ek = p.oneCikan?.length ? ` Öne çıkan: ${p.oneCikan[0]}.` : "";
if (alan && il) return `Seçtiğin alan ve ille eşleşiyor.${ek}`;
if (alan) {
return seciliIller.length > 0
? "Seçtiğin alanla eşleşiyor; seçtiğin illerin dışında."
: "Seçtiğin alanla eşleşiyor.";
return (
(seciliIller.length > 0
? "Seçtiğin alanla eşleşiyor; seçtiğin illerin dışında."
: "Seçtiğin alanla eşleşiyor.") + ek
);
}
if (il) return "Seçtiğin ilde; seçtiğin alanların dışında, sıralamana yakın bir program.";
return "Sıralamana yakın olduğu için listene eklendi.";
if (il) return `Seçtiğin ilde; seçtiğin alanların dışında, sıralamana yakın bir program.${ek}`;
return `Sıralamana yakın olduğu için listene eklendi.${ek}`;
};
const sablonGerekce: RaporTani["sablonGerekce"] = [];
const gerekceDenetle = (s: SeciliSatir, p: AdayProgram): string => {
@@ -708,6 +723,8 @@ export async function raporUretAyrintili(
p.yerlesenSon,
params.sira,
...metindekiSayilar(p.isim, p.universite),
// Koddan gelen "öne çıkan" cümlelerdeki sayılar meşrudur (TÜİK %, URAP sırası, km…)
...metindekiSayilar(...(p.oneCikan ?? [])),
...profilSayilari,
].filter((n): n is number => typeof n === "number"),
),
@@ -804,6 +821,7 @@ export async function raporUretAyrintili(
gerekce: gerekceDenetle(satir, p),
riskNotu: riskNotuUret(p, params.sira),
trendOzeti: trendOzetiUret(p),
...(p.oneCikan?.length ? { oneCikan: p.oneCikan } : {}),
};
});
return {
@@ -812,7 +830,7 @@ export async function raporUretAyrintili(
genelDegerlendirme: genel,
uyarilar,
programlar,
kapsam: kapsamUret(hedef),
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
},
tani: {
kaynak,
@@ -837,7 +855,7 @@ export async function raporUretAyrintili(
export function listeOzetiCikar(
rapor: RaporSonuc,
adaySira?: number,
opts: { kimlik?: boolean } = {},
opts: { kimlik?: boolean; gerekce?: boolean } = {},
): string {
return rapor.tercihler
.map((t) => {
@@ -853,7 +871,16 @@ export function listeOzetiCikar(
// sohbet cevaplarına "garanti dilimi" sızıyor. Dilim ve risk aynı kelime
// olabildiği için ("Güvenli") alanlar adlandırılır.
const kimlik = opts.kimlik ? `id: ${t.programId} | ` : "";
return `${t.sira}. [${kimlik}dilim: ${DILIM_ETIKET[t.dilim] ?? t.dilim} | risk: ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"} — ${p?.universite ?? "?"}${taban}`;
// Sohbet: satırın gerekçesi ve öne çıkan verileri de girer (danışman
// "neden listemde?" sorusuna kendi raporunun gerekçesiyle cevap verir).
// Öne çıkanların TAMAMI (≤3, uyum-puani.ts ONE_CIKAN_UST) girer: ikiye
// kırpılınca model "24'ün 23'ünde okul birincisi kontenjanı var" diye
// eksik saydı (6 Eki 2026 QA).
const gerekce =
opts.gerekce && t.gerekce ? ` | neden: ${t.gerekce.replace(/\s+/g, " ").trim()}` : "";
const oneCikan =
opts.gerekce && t.oneCikan?.length ? ` | öne çıkan: ${t.oneCikan.join(" · ")}` : "";
return `${t.sira}. [${kimlik}dilim: ${DILIM_ETIKET[t.dilim] ?? t.dilim} | risk: ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"} — ${p?.universite ?? "?"}${taban}${gerekce}${oneCikan}`;
})
.join("\n");
}

View File

@@ -30,7 +30,9 @@ export type OlayAdi =
// tıklaması (kapı açılsa da atılıyordu), sonrası = gerçek ekleme
// ({yol: "dogrudan" | "kapi"}); seriler karşılaştırılamaz.
| "program_eklendi"
| "netler_goruntulendi"
// Program ayrıntı çekmecesi (eski adı netler_goruntulendi; 6 Eki 2026
// çekmece netlerden tüm ayrıntıya genişledi, seri yeni adla başlar)
| "program_ayrinti_goruntulendi"
| "danisman_sorusu"
| "katalog_arama"
| "odeme_baslatildi"

View File

@@ -88,7 +88,12 @@ async function girisLinkiniIlet(email: string, url: string) {
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
// src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify
// URL'ine gittiği için ham link saklanır. Prod'da asla çalışmaz.
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
const g = globalThis as { __sonMagicLink?: string; __devAnindaGiris?: boolean };
g.__sonMagicLink = url;
// Dev paneli "anında giriş"te e-posta HİÇ gönderilmez: panel linki kendisi
// açar. Dev env'inde Resend anahtarı varken example.com alıcısına gerçek
// gönderim başarısız olup girişi sessizce düşürüyordu (6 Eki 2026 QA).
if (g.__devAnindaGiris) return;
}
const mailLinki = onaySayfasiUrl(url);
if (epostaKonsolModu()) {

View File

@@ -44,12 +44,17 @@ export async function profilAlanYaz(
if (!dogrulama.success) {
throw new Error(`Geçersiz değer: ${alan}`);
}
// Dizi değer (ozelDurum) JSON metni olarak bağlanır ve json() ile açılır;
// düz bağlanırsa SQLite onu tek bir dize olarak saklar.
const yeni = Array.isArray(dogrulama.data)
? sql`json(${JSON.stringify(dogrulama.data)})`
: sql`${dogrulama.data}`;
await appDb
.insert(schema.danismanProfil)
.values({ userId, alanlar: { [alan]: dogrulama.data }, updatedAt: now })
.onConflictDoUpdate({
target: schema.danismanProfil.userId,
set: { alanlar: sql`json_set(${kolon}, ${yol}, ${dogrulama.data})`, updatedAt: now },
set: { alanlar: sql`json_set(${kolon}, ${yol}, ${yeni})`, updatedAt: now },
});
}
return profilGetir(userId);

View File

@@ -6,7 +6,9 @@
* (docs/arastirma/2026-09-25-kisisellestirme/README.md §8).
*
* Sorulmayanlar: cinsiyet, sağlık/engel, gelir. Ücret üst sınırı sorulur, gelir
* sorulmaz. Konum il düzeyindedir (adres/koordinat değil).
* sorulmaz. Konum il düzeyindedir (adres/koordinat değil). Özel kontenjan
* durumu (okul birincisi, şehit-gazi yakını, depremzede, 34 yaş üstü kadın)
* ÖSYM kontenjan hakkının beyanıdır, cinsiyet alanı değildir (6 Eki 2026).
*
* KULLANIM SINIRI (karar, 25 Eyl 2026): profil yalnız liste üretimi
* (rapor-havuzu) ve danışman aramalarında kullanılır; pazarlama, e-posta,
@@ -16,6 +18,7 @@
* Saf modül: client, server ve scripts aynı dosyayı okur.
*/
import { z } from "zod";
import { OZEL_DURUMLAR, OZEL_DURUM_KONTENJAN_ADI, ozelDurumDogrula, type OzelDurum } from "./ozel-durum";
export const PROFIL_SECENEKLERI = {
burs: {
@@ -51,6 +54,8 @@ export const PROFIL_SEMASI = z
dil: z.enum(["turkce", "ingilizce", "farketmez"]).optional(),
ogretimTuru: z.enum(["yalniz-orgun", "uzaktan-olur"]).optional(),
kampus: z.enum(["sakin", "kalabalik", "farketmez"]).optional(),
/** Özel kontenjan durumu (lib/ozel-durum.ts); sihirbazdaki alanla birleşir. */
ozelDurum: z.array(z.enum(OZEL_DURUMLAR)).max(OZEL_DURUMLAR.length).optional(),
})
.strict();
@@ -68,6 +73,7 @@ export const PROFIL_ALAN_ACIKLAMA: Record<ProfilAlani, string> = {
dil: "Öğretim dili tercihi",
ogretimTuru: "Uzaktan/açık öğretim kabul mü",
kampus: "Kampüs büyüklüğü tercihi",
ozelDurum: "Özel kontenjan durumu (okul-birincisi, sehit-gazi-yakini, depremzede, kadin-34; virgülle birden çok)",
};
/** Bilinmeyen anahtarları atarak profili güvenli okur; bozuk değer alanı düşürür. */
@@ -100,6 +106,10 @@ export function profilAlaniMetni(alan: ProfilAlani, deger: unknown): string {
return deger === "yalniz-orgun" ? "Yalnız örgün öğretim" : "Uzaktan/açık öğretim de olur";
case "kampus":
return `Kampüs: ${PROFIL_SECENEKLERI.kampus[deger as keyof typeof PROFIL_SECENEKLERI.kampus]}`;
case "ozelDurum":
return `Özel kontenjan: ${ozelDurumDogrula(deger)
.map((d: OzelDurum) => OZEL_DURUM_KONTENJAN_ADI[d].replace(/ kontenjanı$/, ""))
.join(", ")}`;
}
}

View File

@@ -3,11 +3,17 @@
import path from "node:path";
import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
import { EFEKTIF_SIRA_SQL, SON_YIL, YILLAR } from "./veri-yillari";
import { EFEKTIF_SIRA_SQL, ILK_YIL, SON_YIL, YILLAR } from "./veri-yillari";
import { turkishSlugify } from "./slug";
import {
PUAN_TURLERI,
type ArsivProgram,
type DilimKey,
type KontenjanDilimi,
type KontenjanTurleri,
type KontenjanTuruAnahtari,
type Program,
type ProgramGecmisSatiri,
type ProgramNetSatiri,
type PuanTuruKey,
type RankResults,
@@ -300,7 +306,7 @@ export function havuzAdaylari(
export function netlerGetir(programId: string): ProgramNetSatiri[] {
return getDb()
.prepare(
`SELECT yil, puanTuru, tabanPuan, obp,
`SELECT yil, puanTuru, tabanPuan, obp, katsayi,
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
@@ -318,6 +324,273 @@ export function programGetir(id: string): Program | null {
return row ? programSatiri(row) : null;
}
// ── Yıl bazlı geçmiş, kontenjan türleri, arşiv ───────────────────────────
// Bu alanlar `Program` tipine girmez: liste satırları client'a serileşiyor,
// her satırı 4×6 kolonla şişirmek istemiyoruz. Program ayrıntı çekmecesi
// (/api/program-ayrinti), danışman araçları ve uyum puanı buradan okur.
let programKolonlari: Set<string> | null = null;
/** `programs` tablosunun kolon adları (bir kez okunur). */
function programKolonlariGetir(): Set<string> {
if (!programKolonlari) {
programKolonlari = new Set(
(getDb().prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map(
(c) => c.name
)
);
}
return programKolonlari;
}
/**
* Programın yıl bazlı geçmişi, YENİDEN ESKİYE. Veri penceresi `YILLAR`;
* kolonu olmayan yıl atlanır. Bilinmeyen id'de boş dizi.
*/
export function programGecmisiGetir(id: string): ProgramGecmisSatiri[] {
const kolonlar = programKolonlariGetir();
const yillar = YILLAR.filter((y) => kolonlar.has(`sira${y}`));
if (yillar.length === 0) return [];
const secim = yillar
.map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`)
.join(", ");
const row = getDb()
.prepare(`SELECT ${secim} FROM programs WHERE id = ?`)
.get(id) as Record<string, number | null> | undefined;
if (!row) return [];
return [...yillar].reverse().map((yil) => ({
yil,
sira: row[`s${yil}`] ?? null,
puan: row[`p${yil}`] ?? null,
kontenjan: row[`k${yil}`] ?? null,
yerlesen: row[`y${yil}`] ?? null,
}));
}
/** Kontenjan türü kolonu öneki → KontenjanTurleri alanı. */
const KONTENJAN_TURU_KOLON = {
gk: "genel",
obk: "okulBirincisi",
sgy: "sehitGaziYakini",
dprm: "depremzede",
y34k: "kadin34",
} as const;
/** KontenjanTurleri alanı → kolon öneki (SQL süzgeçleri `${onek}${kontenjanTuruYili()}`). */
export const KONTENJAN_TURU_ONEK: Record<KontenjanTuruAnahtari, string> = {
genel: "gk",
okulBirincisi: "obk",
sehitGaziYakini: "sgy",
depremzede: "dprm",
kadin34: "y34k",
};
let kontenjanTuruYiliOnbellek: number | null | undefined;
/**
* Kontenjan türü kırılımının bulunduğu en yeni yıl (`gk<yil>` kolonu olan).
* Kırılım bir sonraki kılavuzla açıklandığı için SON_YIL'dan bir geride
* olabilir; hiç yoksa null (eski DB).
*/
export function kontenjanTuruYili(): number | null {
if (kontenjanTuruYiliOnbellek !== undefined) return kontenjanTuruYiliOnbellek;
const kolonlar = programKolonlariGetir();
let bulunan: number | null = null;
for (let y = SON_YIL; y >= ILK_YIL; y--) {
if (kolonlar.has(`gk${y}`) && kolonlar.has(`y34k${y}`)) {
bulunan = y;
break;
}
}
kontenjanTuruYiliOnbellek = bulunan;
return bulunan;
}
function kontenjanTurleriSatiri(
yil: number,
row: Record<string, number | null>
): KontenjanTurleri {
const dilim = (onek: string): KontenjanDilimi => ({
kontenjan: row[`${onek}${yil}`] ?? null,
yerlesen: row[`${onek}Y${yil}`] ?? null,
});
return {
yil,
genel: dilim("gk"),
okulBirincisi: dilim("obk"),
sehitGaziYakini: dilim("sgy"),
depremzede: dilim("dprm"),
kadin34: dilim("y34k"),
};
}
function kontenjanTuruSecimi(yil: number): string {
return Object.keys(KONTENJAN_TURU_KOLON)
.map((onek) => `${onek}${yil}, ${onek}Y${yil}`)
.join(", ");
}
/** Tek programın kontenjan türleri; kırılım kolonu ya da program yoksa null. */
export function kontenjanTurleriGetir(id: string): KontenjanTurleri | null {
const yil = kontenjanTuruYili();
if (yil == null) return null;
const row = getDb()
.prepare(`SELECT ${kontenjanTuruSecimi(yil)} FROM programs WHERE id = ?`)
.get(id) as Record<string, number | null> | undefined;
return row ? kontenjanTurleriSatiri(yil, row) : null;
}
const IN_PARCA = 500;
/** Çok programın kontenjan türleri (uyum puanı, arama sonuçları); id → satır. */
export function kontenjanTurleriGetirToplu(
ids: readonly string[]
): Record<string, KontenjanTurleri> {
const sonuc: Record<string, KontenjanTurleri> = {};
const yil = kontenjanTuruYili();
if (yil == null || ids.length === 0) return sonuc;
const db = getDb();
for (let i = 0; i < ids.length; i += IN_PARCA) {
const parca = ids.slice(i, i + IN_PARCA);
const satirlar = db
.prepare(
`SELECT id, ${kontenjanTuruSecimi(yil)} FROM programs WHERE id IN (${parca.map(() => "?").join(",")})`
)
.all(...parca) as (Record<string, number | null> & { id: string })[];
for (const r of satirlar) sonuc[r.id] = kontenjanTurleriSatiri(yil, r);
}
return sonuc;
}
// Katlanmış (Türkçe harf duyarsız) ad eşleştirme: SQLite LIKE "isletme"yi
// İşletme'yle, "bogazici"yi Boğaziçi'yle eşlemez. Kolonun ayrık değerleri
// (birkaç bin) tablo başına bir kez okunur, eşleşme JS'te yapılır. Danışman
// araçları (program_ara) ve arşiv araması aynı yardımcıyı kullanır.
type AyrikDeger = { ham: string; katli: string };
const ayrikDegerler = new Map<string, AyrikDeger[]>();
export function ayrikListe(
kolon: "isim" | "universite",
tablo: "programs" | "programs_arsiv" = "programs"
): AyrikDeger[] {
const anahtar = `${tablo}.${kolon}`;
let liste = ayrikDegerler.get(anahtar);
if (!liste) {
liste = (getDb().prepare(`SELECT DISTINCT ${kolon} AS v FROM ${tablo}`).all() as { v: string | null }[])
.filter((x): x is { v: string } => x.v != null)
.map((x) => ({ ham: x.v, katli: turkishSlugify(x.v) }));
ayrikDegerler.set(anahtar, liste);
}
return liste;
}
/** Kolonun, katlanmış hâli `katliSorgu`yu içeren ham değerleri. */
export function katliEslesenler(
kolon: "isim" | "universite",
katliSorgu: string,
tablo: "programs" | "programs_arsiv" = "programs"
): string[] {
return ayrikListe(kolon, tablo)
.filter((x) => x.katli.includes(katliSorgu))
.map((x) => x.ham);
}
let arsivTablosuVar: boolean | null = null;
function arsivTablosuVarMi(): boolean {
if (arsivTablosuVar == null) {
arsivTablosuVar =
getDb()
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='programs_arsiv'")
.get() !== undefined;
}
return arsivTablosuVar;
}
const ARSIV_SELECT = `id, isim, universite, unitur, il, fakulte, tur, sure, son_kilavuz_yili AS sonKilavuzYili, eski_kod AS eskiKod,
${YILLAR.map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`).join(", ")}`;
function arsivSatiri(row: Record<string, unknown>): ArsivProgram {
// Dolu son yerleştirme yılı: sırası ya da kontenjanı olan en yeni yıl
let sonYil: number | null = null;
for (const y of [...YILLAR].reverse()) {
if (row[`s${y}`] != null || row[`k${y}`] != null) {
sonYil = y;
break;
}
}
const deger = (onek: string) =>
sonYil == null ? null : ((row[`${onek}${sonYil}`] as number | null) ?? null);
return {
id: String(row.id),
isim: String(row.isim),
universite: String(row.universite),
unitur: (row.unitur as string | null) ?? null,
il: (row.il as string | null) ?? null,
fakulte: (row.fakulte as string | null) ?? null,
tur: String(row.tur),
sure: (row.sure as number | null) ?? null,
sonKilavuzYili: (row.sonKilavuzYili as number | null) ?? null,
sonYil,
sonSira: deger("s"),
sonPuan: deger("p"),
sonKontenjan: deger("k"),
sonYerlesen: deger("y"),
eskiKod: (row.eskiKod as string | null) ?? null,
};
}
function inKosulu(kolon: string, degerler: string[]): string {
return degerler.length ? `${kolon} IN (${degerler.map(() => "?").join(",")})` : "0 = 1";
}
/**
* Arşivdeki (güncel kılavuzda olmayan) programları arar: bölüm adı parçası,
* üniversite adı parçası (katlanmış), il (DB biçimi). En yeni son kılavuz yılı
* önce. Tablo yoksa boş.
*/
export function arsivProgramAra(
a: { arama?: string; universite?: string; il?: string; limit?: number }
): { toplam: number; programlar: ArsivProgram[] } {
if (!arsivTablosuVarMi()) return { toplam: 0, programlar: [] };
const kosullar: string[] = [];
const args: (string | number)[] = [];
if (a.arama) {
const adlar = katliEslesenler("isim", turkishSlugify(a.arama), "programs_arsiv");
kosullar.push(inKosulu("isim", adlar));
args.push(...adlar);
}
if (a.universite) {
const adlar = katliEslesenler("universite", turkishSlugify(a.universite), "programs_arsiv");
kosullar.push(inKosulu("universite", adlar));
args.push(...adlar);
}
if (a.il) {
kosullar.push("il = ?");
args.push(a.il);
}
if (kosullar.length === 0) return { toplam: 0, programlar: [] };
const where = `WHERE ${kosullar.join(" AND ")}`;
const db = getDb();
const toplam = (db.prepare(`SELECT COUNT(*) AS n FROM programs_arsiv ${where}`).get(...args) as { n: number }).n;
const satirlar = db
.prepare(
`SELECT ${ARSIV_SELECT} FROM programs_arsiv ${where}
ORDER BY son_kilavuz_yili DESC, universite, isim LIMIT ?`
)
.all(...args, a.limit ?? 10) as Record<string, unknown>[];
return { toplam, programlar: satirlar.map(arsivSatiri) };
}
/** Arşivdeki tek program (id `kod` ya da `kod~yıl` biçiminde olabilir). */
export function arsivProgramGetir(id: string): ArsivProgram | null {
if (!arsivTablosuVarMi()) return null;
const row = getDb()
.prepare(`SELECT ${ARSIV_SELECT} FROM programs_arsiv WHERE id = ? OR id LIKE ? ORDER BY son_kilavuz_yili DESC LIMIT 1`)
.get(id, `${id}~%`) as Record<string, unknown> | undefined;
return row ? arsivSatiri(row) : null;
}
/**
* Kayıtlı manuel liste satırlarının güncel efektif sırası (son yıl, yoksa bir
* önceki). Kayıtlar efektif sırayı ekleme anının snapshot'ı olarak taşır; yeni

View File

@@ -57,12 +57,18 @@ export async function devAnindaGiris(
if (!/^\S+@\S+\.\S+$/.test(temiz)) {
return { ok: false, error: "Geçerli bir e-posta gir." };
}
const g = globalThis as { __sonMagicLink?: string };
const g = globalThis as { __sonMagicLink?: string; __devAnindaGiris?: boolean };
g.__sonMagicLink = undefined;
await auth.api.signInMagicLink({
body: { email: temiz, callbackURL: "/" },
headers: await headers(),
});
// Bayrak: auth.ts linki yakalar ve e-posta göndermez (bkz. girisLinkiniIlet)
g.__devAnindaGiris = true;
try {
await auth.api.signInMagicLink({
body: { email: temiz, callbackURL: "/" },
headers: await headers(),
});
} finally {
g.__devAnindaGiris = false;
}
if (!g.__sonMagicLink) {
return { ok: false, error: "Magic link üretilemedi (auth.ts yakalayamadı)." };
}

View File

@@ -12,6 +12,7 @@ import { uniAdiNormalize } from "./harita";
import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug";
import { urapSirasiBul, type UrapSira } from "./urap-siralama";
import { uniIstatistikBul, type UniIstatistik } from "./yok-istatistik";
import { tabanOzetiHesapla, type TabanOzeti } from "./taban-ozeti";
/**
* Programatik SEO sayfalarının veri katmanı (/bolum, /universite, hub'lar).
@@ -23,6 +24,51 @@ import { uniIstatistikBul, type UniIstatistik } from "./yok-istatistik";
* TRIM/fonksiyon kullanmak index'i öldürür).
*/
// ---------- Taban yelpazesi dışı satırlar ----------
/** YÖK Atlas'ta KKTC kampüslerinin `ilce` değerleri (il Türkiye'deki ana kampüs gelir). */
const KKTC_ILCELER = [
"GÜZELYURT",
"GAZİMAĞUSA",
"LEFKOŞA",
"GİRNE",
"LEFKE",
"İSKELE",
"KALKANLI",
];
/**
* Özet kartlarının yelpazesine girmeyen program kimlikleri: örgün olmayan
* (açık / uzaktan öğretim) ve KKTC kampüsü satırları — `program_ozellik`
* tablosundan, süreç başına bir kez (bkz. lib/taban-ozeti.ts
* `orgunTurkiyeMi`). Tablo yoksa (eski DB anlık görüntüsü) boş küme: ad ve
* il kuralı tek başına çalışır, sayfa kırılmaz.
*/
function yelpazeDisiKimlikler(): ReadonlySet<string> {
if (!globalKatalog.yelpazeDisi) {
const db = getDb();
const tabloVar = db
.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'",
)
.get();
const kimlikler = new Set<string>();
if (tabloVar) {
const yer = KKTC_ILCELER.map(() => "?").join(",");
const rows = db
.prepare(
`SELECT id FROM program_ozellik
WHERE (ogrenim_turu IS NOT NULL AND ogrenim_turu <> 'Örgün Öğretim')
OR ilce IN (${yer})`,
)
.all(...KKTC_ILCELER) as { id: string }[];
for (const r of rows) kimlikler.add(r.id);
}
globalKatalog.yelpazeDisi = kimlikler;
}
return globalKatalog.yelpazeDisi;
}
// ---------- Bölüm tarafı ----------
export type BolumOzet = {
@@ -53,6 +99,8 @@ export type BolumDetay = BolumOzet & {
lisansProgramlari: Program[];
onlisansProgramlari: Program[];
stats: BolumStats;
/** Özet kartları + "Yıllara göre taban" çekmecesi (lib/taban-ozeti.ts) */
taban: TabanOzeti;
};
type BolumKayit = { slug: string; ad: string; hamIsimler: string[] };
@@ -66,6 +114,8 @@ const globalKatalog = globalThis as unknown as {
ilMap?: Map<string, IlOzet>;
/** slug → (il → o ildeki program sayısı); getAllUniversiteler doldurur */
uniIlAdetleri?: Map<string, Map<string, number>>;
/** Yelpaze dışı program kimlikleri (örgün olmayan + KKTC kampüsü) */
yelpazeDisi?: ReadonlySet<string>;
};
function bolumMapGetir(): Map<string, BolumKayit> {
@@ -186,6 +236,7 @@ export function getBolumBySlug(slug: string): BolumDetay | null {
...ozet,
lisansProgramlari,
onlisansProgramlari,
taban: tabanOzetiHesapla(programlar, "bolum", yelpazeDisiKimlikler()),
stats: {
toplam: programlar.length,
devlet,
@@ -238,6 +289,8 @@ export type UniDetay = UniOzet & {
/** YÖK İstatistik örgün öğrenci / öğretim üyesi sayısı; KKTC ve yurt dışında null. */
istatistik: UniIstatistik | null;
fakulteler: { fakulte: string; programlar: Program[] }[];
/** Özet kartları + "Yıllara göre taban" çekmecesi (lib/taban-ozeti.ts) */
taban: TabanOzeti;
stats: {
minSira: number | null;
toplamKontenjan: number;
@@ -493,6 +546,7 @@ export function getUniversiteBySlug(slug: string): UniDetay | null {
fakulteler: [...gruplar.entries()]
.map(([fakulte, programlar]) => ({ fakulte, programlar }))
.sort((a, b) => a.fakulte.localeCompare(b.fakulte, "tr-TR")),
taban: tabanOzetiHesapla(programlar, "universite", yelpazeDisiKimlikler()),
stats: {
minSira,
toplamKontenjan,

96
src/lib/ozel-durum.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Özel kontenjan durumu — ÖSYM'nin ayrı kontenjan ayırdığı aday grupları.
* Sihirbazın 3. adımında (isteğe bağlı çipler) ve danışman profilinde
* (`profil_guncelle`) toplanır; `adayDurumu` ikisini birleştirir.
*
* Kurallar (Bilal, 6 Eki 2026):
* - Cinsiyet SORULMAZ. "34 yaş üstü kadın kontenjanı" bir cinsiyet alanı
* değil, ÖSYM kılavuzundaki kontenjan hakkının beyanıdır; /gizlilik bunu
* böyle yazar.
* - Özel durum havuzu DARALTMAZ: o kontenjanı olan programlar uyum puanında
* öne alınır, listede ve danışmanda söylenir. ÖSYM her kontenjan türü için
* ayrı taban sırası yayımlar; o sıra elimizde YOK — ürün bunu uydurmaz.
* - Değer analitiğe gitmez (profil değerleri analitiğe gitmez kuralı).
*
* Saf modül: client, server ve scripts aynı dosyayı okur.
*/
import type { KontenjanTuruAnahtari } from "@/types/yokatlas";
export const OZEL_DURUMLAR = [
"okul-birincisi",
"sehit-gazi-yakini",
"depremzede",
"kadin-34",
] as const;
export type OzelDurum = (typeof OZEL_DURUMLAR)[number];
/** Sihirbaz çipi ve profil çipi metni (birinci tekil, öğrenci dili). */
export const OZEL_DURUM_ETIKET: Record<OzelDurum, string> = {
"okul-birincisi": "Okul birincisiyim",
"sehit-gazi-yakini": "Şehit / gazi yakınıyım",
depremzede: "Depremzedeyim",
"kadin-34": "34 yaş üstü kadınım",
};
/** Kontenjanın adı (üçüncü şahıs; satır ve danışman metinleri). */
export const OZEL_DURUM_KONTENJAN_ADI: Record<OzelDurum, string> = {
"okul-birincisi": "okul birincisi kontenjanı",
"sehit-gazi-yakini": "şehit-gazi yakını kontenjanı",
depremzede: "depremzede kontenjanı",
"kadin-34": "34 yaş üstü kadın kontenjanı",
};
/** Durum → `KontenjanTurleri` alanı (db.ts kırılımı). */
export const OZEL_DURUM_KONTENJAN_ALANI: Record<OzelDurum, KontenjanTuruAnahtari> = {
"okul-birincisi": "okulBirincisi",
"sehit-gazi-yakini": "sehitGaziYakini",
depremzede: "depremzede",
"kadin-34": "kadin34",
};
/**
* ÖSYM koşulu 31/33: program yalnız kız ya da yalnız erkek öğrenci alır.
* Yalnız BİLGİ (cinsiyet sorulmaz, süzgeç yapılmaz); program-ozellik.ts
* türetir, çekmece ve danışman buradan okur (saf modül, db yok).
*/
export const CINSIYET_KOSULU_METNI: Record<"kiz" | "erkek", string> = {
kiz: "yalnız kız öğrenci alır",
erkek: "yalnız erkek öğrenci alır",
};
/** Kontenjan türü alanı → öğrenciye görünen ad (çekmece, danışman). */
export const KONTENJAN_TURU_ADI: Record<KontenjanTuruAnahtari, string> = {
genel: "Genel kontenjan",
okulBirincisi: "Okul birincisi",
sehitGaziYakini: "Şehit / gazi yakını",
depremzede: "Depremzede",
kadin34: "34 yaş üstü kadın",
};
/** Bilinmeyen değerleri atar, tekrarları birleştirir; sıra sabit listeye göre. */
export function ozelDurumDogrula(girdi: unknown): OzelDurum[] {
if (!Array.isArray(girdi)) return [];
const kume = new Set(
girdi.filter((x): x is OzelDurum => typeof x === "string" && (OZEL_DURUMLAR as readonly string[]).includes(x)),
);
return OZEL_DURUMLAR.filter((d) => kume.has(d));
}
/**
* Adayın etkin özel durumu: sihirbaz seçimleri ∪ danışman profili. İkisi de
* boşsa boş dizi — uyum puanı bu boyutu atlar.
*/
export function adayDurumu(
params: { ozelDurum?: readonly string[] } | null | undefined,
profil?: { ozelDurum?: readonly string[] } | null,
): OzelDurum[] {
return ozelDurumDogrula([...(params?.ozelDurum ?? []), ...(profil?.ozelDurum ?? [])]);
}
/** "okul birincisi, depremzede" — özet satırları için. */
export function ozelDurumOzeti(durumlar: readonly OzelDurum[]): string {
return durumlar
.map((d) => OZEL_DURUM_KONTENJAN_ADI[d].replace(/ kontenjanı$/, ""))
.join(", ");
}

View File

@@ -3,6 +3,9 @@
// (ücret, burs, dil, öğretim türü, akreditasyon, kadro, baraj, ilçe, koşullar).
// Sıra/puan/kontenjan gibi yıllık alanlar burada DEĞİL (bkz. db.ts).
import { getDb } from "./db";
import { CINSIYET_KOSULU_METNI } from "./ozel-durum";
export { CINSIYET_KOSULU_METNI };
export type ProgramOzellik = {
id: string;
@@ -28,10 +31,22 @@ export type ProgramOzellik = {
ilce: string | null;
/** Yerleşme koşulu kodları (kosul tablosundan metne çevrilir) */
kosulKodlari: string[];
/**
* Program yalnız kız (koşul 31) ya da yalnız erkek (koşul 33) öğrenci
* alıyorsa. YALNIZ BİLGİ: ürün adayın cinsiyetini sormaz (KVKK kararı,
* 25 Eyl ve 6 Eki 2026), bu yüzden süzgeç yapılmaz; satırda ve danışman
* cevabında koşul olarak söylenir.
*/
cinsiyetKosulu: "kiz" | "erkek" | null;
/** Kılavuz yılı */
yil: number;
};
/** ÖSYM kılavuzu koşul kodları: 31 "yalnız kız öğrenci", 33 "yalnız erkek öğrenci". */
const KOSUL_YALNIZ_KIZ = "31";
const KOSUL_YALNIZ_ERKEK = "33";
type HamSatir = {
id: string;
ucret: number | null;
@@ -54,6 +69,7 @@ const OZELLIK_COLS = `id, ucret, burs_orani, ogrenim_dili, ogrenim_turu, akredit
uni_akreditasyon, prof, doc, dou, argor, min_basari_sirasi, ilce, kosul_kodlari, yil`;
function satir(r: HamSatir): ProgramOzellik {
const kosulKodlari = r.kosul_kodlari ? r.kosul_kodlari.split(",") : [];
return {
id: r.id,
ucret: r.ucret,
@@ -68,7 +84,12 @@ function satir(r: HamSatir): ProgramOzellik {
argor: r.argor,
minBasariSirasi: r.min_basari_sirasi,
ilce: r.ilce,
kosulKodlari: r.kosul_kodlari ? r.kosul_kodlari.split(",") : [],
kosulKodlari,
cinsiyetKosulu: kosulKodlari.includes(KOSUL_YALNIZ_KIZ)
? "kiz"
: kosulKodlari.includes(KOSUL_YALNIZ_ERKEK)
? "erkek"
: null,
yil: r.yil,
};
}
@@ -133,5 +154,6 @@ export function ozellikOzeti(o: ProgramOzellik): string[] {
if (o.minBasariSirasi != null) {
parcalar.push(`baraj ${o.minBasariSirasi.toLocaleString("tr-TR")}. sıra`);
}
if (o.cinsiyetKosulu) parcalar.push(CINSIYET_KOSULU_METNI[o.cinsiyetKosulu]);
return parcalar;
}

View File

@@ -10,12 +10,19 @@ import {
} 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";
@@ -75,6 +82,15 @@ 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"];
@@ -112,12 +128,15 @@ function eksigiDagit(pay: DilimHedef, mevcut: DilimHedef, toplam: number) {
* 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 {
export function dilimHedefiHesapla(
havuz: AdayProgram[],
ideal: DilimHedef = IDEAL_HEDEF,
): 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),
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;
@@ -166,16 +185,49 @@ function dilimBasinaKirp(havuz: AdayProgram[], n: number): AdayProgram[] {
function oncelikliBirlestir(...katmanlar: AdayProgram[][]): AdayProgram[] {
const gorulen = new Set<string>();
const sonuc: AdayProgram[] = [];
for (const katman of katmanlar) {
katmanlar.forEach((katman, i) => {
for (const p of katman) {
if (gorulen.has(p.id)) continue;
gorulen.add(p.id);
sonuc.push(p);
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\.)\)/;
@@ -242,6 +294,10 @@ export function havuzOlustur(
? 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.
@@ -264,7 +320,7 @@ export function havuzOlustur(
profilliFiltreli = kategoriFiltre(profilliHam);
if (profilliFiltreli.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(profilliFiltreli, PROMPT_LIMIT_DILIM),
havuz: dilimBasinaKirp(uyumlaSirala(profilliFiltreli, baglam), PROMPT_LIMIT_DILIM),
gevsetildi: [],
};
}
@@ -277,7 +333,7 @@ export function havuzOlustur(
const tamFiltreli = kategoriFiltre(tamHam);
const kirp = (...katmanlar: AdayProgram[][]) =>
dilimBasinaKirp(
oncelikliBirlestir(profilliFiltreli, ...katmanlar),
uyumlaSirala(oncelikliBirlestir(profilliFiltreli, ...katmanlar), baglam),
PROMPT_LIMIT_DILIM,
);
if (tamFiltreli.length >= HEDEF) {
@@ -338,6 +394,9 @@ export function havuzuKompaktJson(havuz: AdayProgram[]): string {
),
[`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 } : {}),
})),
);
}

View File

@@ -1,7 +1,9 @@
// Paketsiz kullanıcıya giden raporun sunucu tarafı maskesi.
// YÖK Atlas'ın ham program ve taban sıralaması verileri ücretsiz kalır.
// Yalnızca Yapay Zeka'nın ürettiği kişisel gerekçe/risk/trend yorumu maskelenir;
// böylece istemciye ücretli analiz metni hiç inmez.
// Yalnızca kişisel analiz maskelenir: Yapay Zeka'nın gerekçe/risk/trend yorumu ve
// adayın önceliklerine göre koddan hesaplanan "öne çıkan" veriler (uyum-puani.ts;
// 6 Eki 2026 QA: kilitli satırlarda sayfa verisiyle iniyordu). Böylece istemciye
// ücretli analiz hiç inmez.
import "server-only";
import type {
@@ -34,6 +36,7 @@ export function raporMaskele(rapor: RaporSonuc): MaskeliRapor {
gerekce: SAHTE_GEREKCE,
riskNotu: SAHTE_RISK,
trendOzeti: SAHTE_TREND,
oneCikan: undefined,
},
);

258
src/lib/taban-ozeti.ts Normal file
View File

@@ -0,0 +1,258 @@
// Bölüm ve üniversite sayfalarının özet kartlarını besleyen taban özeti —
// saf hesap, DB'ye dokunmaz; katalog.ts yüklediği program listesini buraya
// verir. Öğrencinin sorusu "kaç program var" değil, "girmesi ne kadar zor,
// yıllar içinde nereye gidiyor, boş kalıyor mu" (Bilal, 6 Eki 2026); kartlar
// bu üç soruya cevap verir. Sayım kuralı docs/arastirma/2026-09-24-
// ogrenci-senaryolari/30-urun-basit-veri.md karar 2 ile aynı: KKTC uyruklu
// satırları sayılmaz; açıköğretim ve uzaktan öğretim de yelpazeye girmez
// (tabanları 1 milyonun üstünde, örgün adayı yanıltır). Doluluk ise bütün
// satırlar üzerinden hesaplanır — kontenjan kontenjandır.
import type { Program } from "@/types/yokatlas";
import { YILLAR } from "./veri-yillari";
/** Yelpazenin bir ucu: hangi program, hangi üniversite, kaçıncı sıra. */
export type TabanUcu = { isim: string; universite: string; sira: number };
/** Orta taban ve en geniş kapı hangi küme üzerinden: devlet örgün ya da hepsi. */
export type TabanKumesi = "devlet" | "tum";
export type TabanDuzeyi = "lisans" | "onlisans";
export type Doluluk = {
kontenjan: number;
/** Σ min(yerleşen, kontenjan): ek yerleştirme taşmaları (yerleşen > kontenjan) sayılmaz */
dolan: number;
bosKoltuk: number;
dolmayanProgram: number;
programSayisi: number;
/** 0–100, yuvarlanmış */
yuzde: number;
};
export type TabanOzeti = {
duzey: TabanDuzeyi;
/** Efektif sıraya göre en seçici program (örgün Türkiye satırları içinde, tüm türler) */
enSecici: TabanUcu | null;
/** Efektif sıraya göre en geniş kapı — `kume` içinde */
enGenis: TabanUcu | null;
kume: TabanKumesi;
/** YILLAR ile hizalı yıl yıl orta taban; o yıl hiç veri yoksa null */
ortaTaban: (number | null)[];
/** Son dolu yılda medyana giren program sayısı */
ortaTabanAdet: number;
doluluk: Doluluk | null;
};
/**
* Örgün, Türkiye'deki satır mı: KKTC uyruklu / açıköğretim / uzaktan değil ve
* kampüs Türkiye'de. Ad ve il her durumu yakalamaz: Atatürk AÖF'ün
* "Grafik Sanatlar"ı adında "Açıköğretim" taşımaz, ODTÜ/ASBÜ/İTÜ'nün KKTC
* kampüs satırları `unitur` DEVLET ve `il` ANKARA/İSTANBUL gelir (yalnız
* `program_ozellik.ilce` Güzelyurt/Lefkoşa der). Bu yüzden `haric` kümesi
* (katalog.ts `program_ozellik`'ten kurar: örgün olmayan + KKTC ilçeli
* kimlikler) ayrıca verilir; tablo yoksa ad + il kuralı tek başına kalır.
*/
export function orgunTurkiyeMi(
p: Pick<Program, "id" | "isim" | "il">,
haric?: ReadonlySet<string>,
): boolean {
return orgunMu(p) && p.il !== "KIBRIS" && !(haric?.has(p.id) ?? false);
}
/**
* Yalnız ad kuralı: KKTC uyruklu kontenjanı, açıköğretim ve uzaktan satırı
* değil. KKTC üniversitesinin kendi sayfasında yelpaze bununla kurulur
* (il/kampüs dışlaması olmadan — bkz. tabanOzetiHesapla).
*/
export function orgunMu(p: Pick<Program, "isim">): boolean {
const ad = p.isim;
return (
!ad.includes("KKTC Uyruklu") &&
!ad.includes("Açıköğretim") &&
!ad.includes("Uzaktan")
);
}
/**
* "En geniş kapı" adayı: son yıl kontenjanı en az bu kadar olan program.
* 1–4 kişilik satırlar (kapanan programın son öğrencisi, tek kişilik özel
* kontenjan) 1,3 milyonluk "taban" üretir; öğrenci için kapı değildir.
* Medyan ve en seçici bu eşiğe bakmaz (medyan uç değere dayanıklı; tek
* kişilik tam burslu tıp satırı gerçekten en seçicidir).
*/
export const EN_GENIS_MIN_KONTENJAN = 5;
/**
* Devlet kümesinin satırı: devlet üniversitesinin ücretsiz programı. UOLP
* ortak programları ve "(Ücretli)/(%25 İndirimli)/(Burslu)" etiketli
* satırlar devlet çatısı altında paralı yollardır (İÜ Hukuk UOLP 82.987 gibi
* "devlette en geniş" diye okunur, değildir); "tüm" kümesinde kalırlar.
*/
export function devletUcretsizMi(
p: Pick<Program, "isim" | "unitur" | "universite">,
): boolean {
if (p.unitur !== "DEVLET") return false;
// "İçişleri Bakanlığı ve MSB adına SBÜ'de eğitim alacaklar": bakanlık adına,
// hizmet yükümlülüklü özel satır — herkese açık devlet kapısı değil.
if (p.universite.includes(" ADINA ")) return false;
const ad = p.isim;
return (
!ad.includes("UOLP") &&
!ad.includes("Ücretli") &&
!ad.includes("İndirimli") &&
!ad.includes("Burslu")
);
}
function enGenisAdayiMi(p: Program): boolean {
return p.kontenjanSon == null || p.kontenjanSon >= EN_GENIS_MIN_KONTENJAN;
}
export function medyan(degerler: number[]): number | null {
if (degerler.length === 0) return null;
const sirali = [...degerler].sort((a, b) => a - b);
const orta = Math.floor(sirali.length / 2);
return sirali.length % 2 === 1
? sirali[orta]
: Math.round((sirali[orta - 1] + sirali[orta]) / 2);
}
function uc(p: Program, sira: number): TabanUcu {
return { isim: p.isim, universite: p.universite, sira };
}
/**
* `kapsam`: "bolum" — devlet örgün programlar yeterliyse (en az 3) orta taban
* ve en geniş kapı devlet içinde hesaplanır (öğrencinin sorusu "devlette kaça
* kapanıyor"), yoksa tüm örgün; "universite" — tür ayrımı anlamsız, hepsi.
* Düzey: lisans programı varsa lisans, yoksa önlisans (TYT) — ikisinin
* sıralaması aynı ölçekte değil, karıştırılmaz.
*/
export function tabanOzetiHesapla(
programlar: readonly Program[],
kapsam: "bolum" | "universite",
haric?: ReadonlySet<string>,
): TabanOzeti {
const lisansVar = programlar.some((p) => p.tur !== "TYT");
const duzey: TabanDuzeyi = lisansVar ? "lisans" : "onlisans";
const duzeydekiler = programlar.filter((p) => (p.tur !== "TYT") === lisansVar);
let aday = duzeydekiler.filter((p) => orgunTurkiyeMi(p, haric));
// KKTC üniversitesinin kendi sayfası: bütün satırları il=KIBRIS olduğu için
// Türkiye yelpazesinden düşüyor ve kartlar "henüz taban oluşmadı" diyordu
// (6 Eki 2026 QA, DAÜ'nün 60 programında 2026 sırası varken). Kurumun kendi
// sayfasında kıyas evreni kurumun kendisidir: il ve kampüs dışlaması kalkar,
// ad kuralı (KKTC Uyruklu / Açıköğretim / Uzaktan) kalır. Bölüm sayfası
// etkilenmez — orada KKTC satırları Türkiye yelpazesine girmemeye devam eder.
if (kapsam === "universite" && aday.length === 0) {
aday = duzeydekiler.filter(orgunMu);
}
let kume: TabanKumesi = "tum";
let kumeListesi = aday;
if (kapsam === "bolum") {
const devlet = aday.filter(devletUcretsizMi);
if (devlet.filter((p) => p.efektifSira != null).length >= 3) {
kume = "devlet";
kumeListesi = devlet;
}
}
let enSecici: TabanUcu | null = null;
for (const p of aday) {
if (p.efektifSira != null && (!enSecici || p.efektifSira < enSecici.sira)) {
enSecici = uc(p, p.efektifSira);
}
}
let enGenis: TabanUcu | null = null;
for (const p of kumeListesi) {
if (!enGenisAdayiMi(p)) continue;
if (p.efektifSira != null && (!enGenis || p.efektifSira > enGenis.sira)) {
enGenis = uc(p, p.efektifSira);
}
}
// Yıl yıl orta taban (medyan) — kart altındaki piksel seri ve yön cümlesi.
const yillar = YILLAR.map((_, i) => {
const kumeSiralari: number[] = [];
for (const p of kumeListesi) {
const s = p.siraGecmisi[i];
if (s != null) kumeSiralari.push(s);
}
return { orta: medyan(kumeSiralari), adet: kumeSiralari.length };
});
const ortaTaban = yillar.map((y) => y.orta);
const sonDolu = [...yillar].reverse().find((y) => y.orta != null);
let kontenjan = 0;
let dolan = 0;
let bosKoltuk = 0;
let dolmayanProgram = 0;
let programSayisi = 0;
for (const p of programlar) {
const k = p.kontenjanSon;
const y = p.yerlesenSon;
if (k == null || k <= 0 || y == null) continue;
programSayisi += 1;
kontenjan += k;
dolan += Math.min(y, k);
if (y < k) {
dolmayanProgram += 1;
bosKoltuk += k - y;
}
}
const doluluk: Doluluk | null =
kontenjan > 0
? {
kontenjan,
dolan,
bosKoltuk,
dolmayanProgram,
programSayisi,
// Boş koltuk varken "%100" yazmaz: 5.056'da 5 boş %99,9 → 99 (6 Eki 2026 QA)
yuzde:
dolan >= kontenjan
? 100
: Math.min(99, Math.round((dolan / kontenjan) * 100)),
}
: null;
return {
duzey,
enSecici,
enGenis,
kume,
ortaTaban,
ortaTabanAdet: sonDolu?.adet ?? 0,
doluluk,
};
}
/**
* Orta tabanın ilk ve son dolu yılını kıyaslayan tek kelimelik yön —
* trend.ts ile aynı ±%10 eşiği. Küçülen sıra = sıkılaştı (girmesi
* zorlaştı), büyüyen sıra = gevşedi. Geleceğe dönük yargı kurmaz.
*/
export function ortaTabanYonu(
ortaTaban: readonly (number | null)[],
): { ilkYil: number; ilk: number; sonYil: number; son: number; yon: "sikilasti" | "gevsedi" | "yerinde" } | null {
const dolu: [number, number][] = [];
ortaTaban.forEach((v, i) => {
if (v != null) dolu.push([YILLAR[i], v]);
});
if (dolu.length < 2) return null;
const [ilkYil, ilk] = dolu[0];
const [sonYil, son] = dolu[dolu.length - 1];
const oran = (son - ilk) / ilk;
const yon = oran <= -0.1 ? "sikilasti" : oran >= 0.1 ? "gevsedi" : "yerinde";
return { ilkYil, ilk, sonYil, son, yon };
}
export const YON_ETIKET: Record<
NonNullable<ReturnType<typeof ortaTabanYonu>>["yon"],
string
> = {
sikilasti: "sıkılaştı",
gevsedi: "gevşedi",
yerinde: "yerinde",
};

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`,
};
}