cae2aaa yalnız riskNotu'yu deterministik yapmıştı; aynı satırın gerekce ve
trendOzeti alanları DB'deki eski LLM metni olarak servis ediliyordu (lokal
havuzda 89 satır "Son 4 yıl…", 13+ satır "yerleşme olasılığı/ihtimali").
Oradaki "olasılık dili sızamaz" yorumu yanlıştı; düzeltildi.
- yeni src/lib/trend.ts: trendOzetiMetni() saf fonksiyonu — rapor.ts'teki
trendOzetiUret gövdesi buraya taşındı, rapor ve tadımlık aynı kaynaktan
okur. Yıl aralığı serideki dolu yıllardan gelir ("2021-2025 arasında…")
- trend cümlesinden tahmin yargısı çıktı: "bölüm zorlaşıyor" → "taban
sıkılaştı", "yerleşmek görece kolaylaşıyor" → "taban gevşedi". ±%10
eşiği, ilk–son yıl mantığı, 5/13/6 ve LLM şeması/prompt'u aynı
- tadimlikSec(): DB'deki trendOzeti servis edilmez; program.siraGecmisi'nden
okuma anında üretilir (alan yoksa "yeterli geçmiş yıl verisi yok")
- tadimlikSec(): gerekce olasılık/ihtimal/şans/garanti/kesin/yüzde/
"yerleşirsin"/"son 4 yıl" içeriyorsa satır yok sayılır → "genel"
kategoriye düşer, o da takılırsa null (tadımlık gizlenir). Eşleşme
Türkçe küçük harfle yapılır (İ/ı)
DB satırları, şema ve script'ler değişmedi.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
import "server-only";
|
||
import { z } from "zod";
|
||
import type {
|
||
RaporKapsam,
|
||
RaporParams,
|
||
RaporSonuc,
|
||
} from "@/features/rapor/types/rapor";
|
||
import { anahtarVar } from "./client";
|
||
import { yapilandirilmisUret } from "./cagri";
|
||
import {
|
||
havuzOlustur,
|
||
havuzuKompaktJson,
|
||
turLabel,
|
||
dilimHedefiHesapla,
|
||
IDEAL_HEDEF,
|
||
type DilimHedef,
|
||
type Dilim,
|
||
type AdayProgram,
|
||
} from "../rapor-havuzu";
|
||
import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||
import {
|
||
RISK_ETIKET,
|
||
dilimdenRisk,
|
||
riskHesapla,
|
||
riskNotuMetni,
|
||
} from "../risk";
|
||
import { trendOzetiMetni } from "../trend";
|
||
|
||
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
|
||
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
|
||
// ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu ayrı listeler
|
||
// istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile) zorlansın —
|
||
// reasoning kapalıyken model serbest dağılımı tutturamıyor (ölçüldü: 13 hayal
|
||
// / 0 garanti dönmüştü). Uzunluklar havuza uyarlanmış hedeften gelir (havuzda
|
||
// dilim başına yeterli program olmayabiliyor; sabit 5/13/6 o durumda aritmetik
|
||
// olarak imkânsızdı ve üretim her seferinde patlıyordu). AI yalnızca seçer +
|
||
// gerekce yazar.
|
||
const AiTercihSchema = z.object({
|
||
programId: z.string(),
|
||
gerekce: z.string(),
|
||
});
|
||
/** Sağlayıcıya giden tel şeması: dilim uzunlukları hedefe sabitlenir. */
|
||
function aiSemaWire(hedef: DilimHedef) {
|
||
return z.object({
|
||
hayal: z.array(AiTercihSchema).length(hedef.hayal),
|
||
dengeli: z.array(AiTercihSchema).length(hedef.dengeli),
|
||
garanti: z.array(AiTercihSchema).length(hedef.garanti),
|
||
genelDegerlendirme: z.string(),
|
||
uyarilar: z.array(z.string()).min(2).max(3),
|
||
});
|
||
}
|
||
// Lokal parse şeması: uzunluk kısıtsız. Sağlayıcı tel şemasındaki kısıtları
|
||
// yok sayarsa çıktı reddedilmez; normalize + kurtarma katmanı düzeltir.
|
||
const AiRaporGevsek = z.object({
|
||
hayal: z.array(AiTercihSchema),
|
||
dengeli: z.array(AiTercihSchema),
|
||
garanti: z.array(AiTercihSchema),
|
||
genelDegerlendirme: z.string(),
|
||
uyarilar: z.array(z.string()),
|
||
});
|
||
|
||
function sistemMetni(hedef: DilimHedef): string {
|
||
const adet = (d: Dilim) =>
|
||
hedef[d] === 0 ? "BOŞ liste [] döndür" : `TAM ${hedef[d]} program`;
|
||
const toplam = hedef.hayal + hedef.dengeli + hedef.garanti;
|
||
return `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen
|
||
GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi
|
||
doldurmak: hayal (${adet("hayal")}), dengeli (${adet("dengeli")}), garanti (${adet("garanti")}).
|
||
|
||
KURALLAR (kesin):
|
||
- SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme.
|
||
Aynı programId'yi birden fazla kez kullanma.
|
||
- Havuzdaki her programın "dilim" alanı var: hayal listesine yalnızca dilim
|
||
değeri "hayal" olan programları koy; dengeli ve garanti için de aynısı.
|
||
- Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini
|
||
ve bölüm çeşitliliğini gözet.
|
||
- METİN DİYETİ: aday ${toplam} satır okuyacak; gerekce TEK kısa cümle (en fazla ~15
|
||
kelime, Türkçe): bu programın bu aday için neden mantıklı olduğu. Dolgu söz
|
||
yok; ASLA "garanti", "kesin", "yüzde yüz" deme.
|
||
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
||
- genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları
|
||
buraya yazma.
|
||
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.
|
||
(Tercih sırası, risk notu ve trend özeti koddan hesaplanır; sen yazmazsın.)`;
|
||
}
|
||
|
||
export class RaporUretimHatasi extends Error {}
|
||
|
||
// Liste kurmak için gereken mutlak taban. Havuz bunun altındaysa hata ekranı
|
||
// meşrudur; üstündeyse "ne olursa olsun liste kurulur" (kullanıcı kararı,
|
||
// 7 Ağu 2026) — eksik dilimler uyarlanır ve UI'da dürüstçe belirtilir.
|
||
// Not: ACIK_SATIR=3 maskesiyle tam 3 satırlık liste fiilen açık iner;
|
||
// kabul edilen kenar durumu.
|
||
const MIN_LISTE = 3;
|
||
|
||
const DILIMLER: Dilim[] = ["hayal", "dengeli", "garanti"];
|
||
|
||
function kapsamUret(hedef: DilimHedef): RaporKapsam {
|
||
return {
|
||
toplam: hedef.hayal + hedef.dengeli + hedef.garanti,
|
||
dagilim: { ...hedef },
|
||
uyarlandi: DILIMLER.some((d) => hedef[d] !== IDEAL_HEDEF[d]),
|
||
};
|
||
}
|
||
|
||
type AiSecim = z.infer<typeof AiTercihSchema>;
|
||
|
||
/**
|
||
* AI çıktısını havuza göre normalize eder: havuzda olmayan id'ler ve
|
||
* tekrarlar atılır, her program havuzdaki GERÇEK dilimine yeniden atanır
|
||
* (modelin yanlış listeye koyması artık ret sebebi değil, düzeltilir).
|
||
*/
|
||
function normalizeEt(
|
||
parsed: z.infer<typeof AiRaporGevsek>,
|
||
havuzMap: Map<string, AdayProgram>,
|
||
): Record<Dilim, AiSecim[]> {
|
||
const gruplar: Record<Dilim, AiSecim[]> = {
|
||
hayal: [],
|
||
dengeli: [],
|
||
garanti: [],
|
||
};
|
||
const gorulen = new Set<string>();
|
||
for (const t of [...parsed.hayal, ...parsed.dengeli, ...parsed.garanti]) {
|
||
const p = havuzMap.get(t.programId);
|
||
if (!p || gorulen.has(t.programId)) continue;
|
||
gorulen.add(t.programId);
|
||
gruplar[p.dilim].push(t);
|
||
}
|
||
return gruplar;
|
||
}
|
||
|
||
// --- Deterministik satır metinleri: risk ve trend gerçek YÖK Atlas sıra
|
||
// geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod
|
||
// kesin rakam verir ("45B düzeyinden 38B düzeyine indi").
|
||
|
||
function trendOzetiUret(p: AdayProgram): string {
|
||
// Cümle tadımlıkla ortak saf fonksiyondan gelir (lib/trend.ts)
|
||
return trendOzetiMetni([
|
||
p.sira2021,
|
||
p.sira2022,
|
||
p.sira2023,
|
||
p.sira2024,
|
||
p.sira2025,
|
||
]);
|
||
}
|
||
|
||
function riskNotuUret(p: AdayProgram, adaySira: number): string {
|
||
return riskNotuMetni(riskHesapla(p.sira2025 ?? p.sira2024, adaySira));
|
||
}
|
||
|
||
/** Havuz programını render için gereken bilgiye indirger. */
|
||
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
|
||
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
|
||
return {
|
||
isim: p.isim,
|
||
universite: p.universite,
|
||
il: p.il,
|
||
unitur: p.unitur,
|
||
efektifSira: p.sira2025 ?? p.sira2024,
|
||
siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
|
||
};
|
||
}
|
||
|
||
// --- Dev mock: API key yokken (yalnızca development) gerçek havuzdan sahte
|
||
// ama tutarlı bir rapor üret. Akış (kredi, blur, risk renkleri, revizyon)
|
||
// gerçek Yapay Zeka olmadan uçtan uca test edilebilsin diye. Prod'da asla çalışmaz.
|
||
|
||
function mockAktif(): boolean {
|
||
return process.env.NODE_ENV !== "production" && !anahtarVar();
|
||
}
|
||
|
||
function mockRapor(
|
||
havuz: AdayProgram[],
|
||
params: RaporParams,
|
||
revizyonNotu?: string,
|
||
): RaporSonuc {
|
||
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
|
||
const hedef = dilimHedefiHesapla(havuz);
|
||
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
|
||
const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
|
||
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
|
||
havuz
|
||
.filter((p) => p.dilim === dilim)
|
||
.sort((a, b) => efektif(a) - efektif(b))
|
||
.slice(0, adet);
|
||
|
||
const secilen = [
|
||
...dilimden("hayal", hedef.hayal),
|
||
...dilimden("dengeli", hedef.dengeli),
|
||
...dilimden("garanti", hedef.garanti),
|
||
];
|
||
|
||
const tercihler = secilen.map((p, i) => ({
|
||
programId: p.id,
|
||
sira: i + 1,
|
||
dilim: p.dilim,
|
||
gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`,
|
||
// Risk ve trend mock'ta da gerçek veriden: canlı davranışla birebir aynı
|
||
riskNotu: riskNotuUret(p, params.sira),
|
||
trendOzeti: trendOzetiUret(p),
|
||
}));
|
||
|
||
const programlar: RaporSonuc["programlar"] = {};
|
||
for (const p of secilen) {
|
||
programlar[p.id] = programBilgisi(p);
|
||
}
|
||
|
||
return {
|
||
tercihler,
|
||
genelDegerlendirme: `(Dev mock — Yapay Zeka anahtarı tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString(
|
||
"tr-TR",
|
||
)}. sıra için hayal/dengeli/garanti dağılımlı ${toplamHedef} tercihlik örnek liste kuruldu.${
|
||
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
|
||
}`,
|
||
uyarilar: [
|
||
"(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.",
|
||
"(Dev mock) Garanti dilimindeki son tercihleri asla silme.",
|
||
],
|
||
programlar,
|
||
kapsam: kapsamUret(hedef),
|
||
devMock: true,
|
||
};
|
||
}
|
||
|
||
export async function raporUret(
|
||
params: RaporParams,
|
||
revizyonNotu?: string,
|
||
oncekiListeOzeti?: string,
|
||
): Promise<RaporSonuc> {
|
||
const { havuz, gevsetildi } = havuzOlustur(params);
|
||
if (havuz.length < MIN_LISTE) {
|
||
throw new RaporUretimHatasi(
|
||
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
|
||
);
|
||
}
|
||
|
||
// 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);
|
||
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.universiteTipi && params.universiteTipi !== "farketmez");
|
||
|
||
const gevsetmeNotu: string[] = [];
|
||
if (gevsetildi.includes("il")) {
|
||
gevsetmeNotu.push(
|
||
`Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi ${toplamHedef}'e tamamlamak için diğerlerini de kullan.`,
|
||
);
|
||
}
|
||
if (gevsetildi.includes("kategori")) {
|
||
gevsetmeNotu.push(
|
||
"Adayın seçtiği ilgi alanlarında yeterli program yoktu; havuza yakın alanlardan program eklendi. Seçili alanlara öncelik ver.",
|
||
);
|
||
}
|
||
|
||
// Dev'de API key yoksa gerçek Yapay Zeka yerine mock rapor (üretim gecikmesi taklidi)
|
||
if (mockAktif()) {
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
return mockRapor(havuz, params, revizyonNotu);
|
||
}
|
||
|
||
const havuzJson = havuzuKompaktJson(havuz);
|
||
const kullaniciMesaji = [
|
||
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
|
||
secimVar ? `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
|
||
kategoriler: params.kategoriler ?? [],
|
||
iller: params.iller ?? [],
|
||
universiteTipi: params.universiteTipi ?? "farketmez",
|
||
oncelikler: params.oncelikler ?? [],
|
||
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
|
||
: null,
|
||
params.notlar ? `Adayın ek notları: ${params.notlar}` : null,
|
||
gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null,
|
||
revizyonNotu
|
||
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
|
||
: null,
|
||
`ADAY HAVUZU (yalnızca buradan seç): ${havuzJson}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
|
||
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
|
||
const efektif = (id: string) => {
|
||
const p = havuzMap.get(id)!;
|
||
return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER;
|
||
};
|
||
|
||
let secim: Record<Dilim, AiSecim[]> | null = null;
|
||
let sonSecim: Record<Dilim, AiSecim[]> | null = null;
|
||
let genel = "";
|
||
let uyarilar: string[] = [];
|
||
let sonHata = "";
|
||
for (let deneme = 0; deneme < 2; deneme++) {
|
||
const parsed = await yapilandirilmisUret({
|
||
system: sistemMetni(hedef),
|
||
kullanici:
|
||
deneme === 0
|
||
? kullaniciMesaji
|
||
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
|
||
sema: AiRaporGevsek,
|
||
wireSema: aiSemaWire(hedef),
|
||
// Küçük şema (24 satır × gerekce + genel + uyarılar) ~1-1.5k token;
|
||
// reasoning kapalıyken 4000 rahat bir tavan, kaçak uzun üretimi de keser.
|
||
maxTokens: 4000,
|
||
});
|
||
|
||
if (!parsed) {
|
||
sonHata = `Çıktı şemaya uymadı (hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti} olmalı).`;
|
||
continue;
|
||
}
|
||
// Halüsinasyon koruması normalize içinde: havuz-dışı id ve tekrarlar
|
||
// atılır, dilimler havuzdan yeniden atanır. Ret yalnızca sayı tutmayınca.
|
||
const norm = normalizeEt(parsed, havuzMap);
|
||
sonSecim = norm;
|
||
genel = parsed.genelDegerlendirme;
|
||
uyarilar = parsed.uyarilar.slice(0, 3);
|
||
if (DILIMLER.every((d) => norm[d].length === hedef[d])) {
|
||
secim = norm;
|
||
break;
|
||
}
|
||
sonHata = `Dilim sayıları tutmadı (beklenen: hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti}; havuz-dışı ve tekrar eden id'ler sayılmaz). Havuzdaki "dilim" alanına ve adetlere birebir uy.`;
|
||
}
|
||
|
||
if (!secim) {
|
||
// AI hiç kullanılabilir program seçemedi (parse iki kez null ya da tüm
|
||
// id'ler havuz-dışı): gerçek üretim hatası, hata ekranı meşru.
|
||
if (!sonSecim) {
|
||
throw new RaporUretimHatasi(
|
||
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
|
||
);
|
||
}
|
||
const son = sonSecim;
|
||
const eldeki = son.hayal.length + son.dengeli.length + son.garanti.length;
|
||
if (eldeki === 0) {
|
||
throw new RaporUretimHatasi(
|
||
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
|
||
);
|
||
}
|
||
// Kurtarma ("ne olursa olsun liste kurulur"): fazla dilim en iyi taban
|
||
// sıralamalılar kalacak şekilde kırpılır, eksik dilim havuzun seçilmemiş
|
||
// programlarından deterministik tamamlanır. hedef[d] ≤ havuzdaki sayı
|
||
// olduğu için tamamlama her zaman yeterli aday bulur.
|
||
console.warn(
|
||
`[rapor] kurtarma: AI ${eldeki} kullanılabilir program döndü, hedef ${toplamHedef} (hayal ${son.hayal.length}/${hedef.hayal}, dengeli ${son.dengeli.length}/${hedef.dengeli}, garanti ${son.garanti.length}/${hedef.garanti})`,
|
||
);
|
||
const kullanilan = new Set(
|
||
DILIMLER.flatMap((d) => son[d].map((t) => t.programId)),
|
||
);
|
||
const kurtarilan: Record<Dilim, AiSecim[]> = {
|
||
hayal: [],
|
||
dengeli: [],
|
||
garanti: [],
|
||
};
|
||
for (const d of DILIMLER) {
|
||
const liste = [...son[d]]
|
||
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
|
||
.slice(0, hedef[d]);
|
||
if (liste.length < hedef[d]) {
|
||
const adaylar = havuz
|
||
.filter((p) => p.dilim === d && !kullanilan.has(p.id))
|
||
.sort((a, b) => efektif(a.id) - efektif(b.id));
|
||
for (const p of adaylar) {
|
||
if (liste.length >= hedef[d]) break;
|
||
kullanilan.add(p.id);
|
||
liste.push({
|
||
programId: p.id,
|
||
gerekce:
|
||
"Sıralamana ve seçimlerine uygun olduğu için listene eklendi.",
|
||
});
|
||
}
|
||
}
|
||
kurtarilan[d] = liste;
|
||
}
|
||
secim = kurtarilan;
|
||
}
|
||
|
||
const programlar: RaporSonuc["programlar"] = {};
|
||
for (const p of havuz) {
|
||
programlar[p.id] = programBilgisi(p);
|
||
}
|
||
// Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
|
||
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
|
||
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
|
||
const nihai = secim;
|
||
const tercihler = DILIMLER.flatMap((dilim) =>
|
||
[...nihai[dilim]]
|
||
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
|
||
.map((t) => ({ dilim, ...t })),
|
||
).map((t, i) => {
|
||
const p = havuzMap.get(t.programId)!;
|
||
return {
|
||
programId: t.programId,
|
||
sira: i + 1,
|
||
dilim: t.dilim,
|
||
gerekce: t.gerekce,
|
||
riskNotu: riskNotuUret(p, params.sira),
|
||
trendOzeti: trendOzetiUret(p),
|
||
};
|
||
});
|
||
return {
|
||
tercihler,
|
||
genelDegerlendirme:
|
||
genel.trim() ||
|
||
`Listen ${toplamHedef} tercihten oluşuyor; hayal, dengeli ve garanti dilimleri sıralamana göre dengelendi.`,
|
||
uyarilar: uyarilar.length > 0 ? uyarilar : undefined,
|
||
programlar,
|
||
kapsam: kapsamUret(hedef),
|
||
};
|
||
}
|
||
|
||
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
|
||
return rapor.tercihler
|
||
.map((t) => {
|
||
const p = rapor.programlar[t.programId];
|
||
const risk =
|
||
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
||
dilimdenRisk(t.dilim);
|
||
const taban =
|
||
p?.efektifSira != null
|
||
? ` (taban sırası ~${p.efektifSira.toLocaleString("tr-TR")})`
|
||
: "";
|
||
return `${t.sira}. [${t.dilim} | ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"} — ${p?.universite ?? "?"}${taban}`;
|
||
})
|
||
.join("\n");
|
||
}
|
||
|
||
export type { AdayProgram };
|