diff --git a/src/lib/ai/rapor.ts b/src/lib/ai/rapor.ts index 3141b23..416db7a 100644 --- a/src/lib/ai/rapor.ts +++ b/src/lib/ai/rapor.ts @@ -24,6 +24,7 @@ import { 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 @@ -132,37 +133,15 @@ function normalizeEt( // geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod // kesin rakam verir ("45B düzeyinden 38B düzeyine indi"). -/** 45000 -> "45B", 1_200_000 -> "1,2M" (şemadaki öğrenci dili). */ -function siraKisa(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toLocaleString("tr-TR", { maximumFractionDigits: 1 })}M`; - } - if (n >= 1000) return `${Math.round(n / 1000)}B`; - return String(n); -} - function trendOzetiUret(p: AdayProgram): string { - const ham: Array<[number, number | null | undefined]> = [ - [2021, p.sira2021], - [2022, p.sira2022], - [2023, p.sira2023], - [2024, p.sira2024], - [2025, p.sira2025], - ]; - const seri = ham.filter((cift): cift is [number, number] => cift[1] != null); - if (seri.length < 2) { - return "Karşılaştırma için yeterli geçmiş yıl verisi yok; trendi temkinli oku."; - } - const [ilkYil, ilkSira] = seri[0]; - const [sonYil, sonSira] = seri[seri.length - 1]; - const oran = (sonSira - ilkSira) / ilkSira; - if (oran <= -0.1) { - return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; bölüm zorlaşıyor.`; - } - if (oran >= 0.1) { - return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; yerleşmek görece kolaylaşıyor.`; - } - return `${ilkYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`; + // 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 { diff --git a/src/lib/tadimlik-havuzu.ts b/src/lib/tadimlik-havuzu.ts index e1df7ae..7b01b7b 100644 --- a/src/lib/tadimlik-havuzu.ts +++ b/src/lib/tadimlik-havuzu.ts @@ -9,6 +9,7 @@ import type { PuanTuruKey } from "@/types/yokatlas"; import type { Dilim } from "./rapor-havuzu"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; import { dilimdenRisk, riskHesapla, riskNotuMetni } from "./risk"; +import { trendOzetiMetni } from "./trend"; /** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */ export const GENEL_KATEGORI = "genel"; @@ -49,6 +50,22 @@ export interface TadimlikSatir { export type TadimlikTip = "genel" | "devlet" | "vakif"; +/** + * gerekce, satırın servis edilen TEK LLM metnidir (batch'te temsili sıraya + * göre yazıldı). Ürün yerleşme olasılığı hesaplamaz; bu yüzden olasılık / + * ihtimal / şans / garanti / kesin / yüzde / "yerleşirsin" diyen gerekçe + * servis edilmez. Eski batch'in "son 4 yıl" kalıbı da takılır (veri 5 yılı + * kapsıyor, bkz. veri-kapsami.ts). Kalıp küçük harfledir: metin önce Türkçe + * kurallarıyla küçültülür ("İhtimal" → "ihtimal"; düz /i bayrağı İ/ı'yı + * eşlemez). + */ +const YASAK_GEREKCE_DILI = + /olasılı|ihtimal|şans|garanti|kesin|yüzde|yerleşirsin|yerleşebil|son (4|dört) yıl/; + +export function gerekceServisEdilebilir(gerekce: string): boolean { + return !YASAK_GEREKCE_DILI.test(gerekce.toLocaleLowerCase("tr-TR")); +} + /** * Kova × tür × kategori × tip anahtarıyla tadımlık satırı seçer. Kategoriye * özel satır yoksa "genel" kategoriye düşer; o da yoksa null — çağıran taraf @@ -56,6 +73,9 @@ export type TadimlikTip = "genel" | "devlet" | "vakif"; * * Tip fallback'i YOKTUR: kullanıcı devlet/vakıf seçtiyse yanlış tipte örnek * asla gösterilmez — uygun satır yoksa tadımlık gizlenir. + * + * Gerekçesi dil filtresine takılan satır YOK sayılır: aynı sırayla "genel" + * kategoriye düşülür, o da takılırsa null (tadımlık gizlenir). */ export async function tadimlikSec( sira: number, @@ -76,20 +96,22 @@ export async function tadimlikSec( eq(schema.tadimlikHavuzu.tip, tip), ), }); - if (satir) { + if (satir && gerekceServisEdilebilir(satir.gerekce)) { const program = satir.program as RaporSonuc["programlar"][string]; return { programId: satir.programId, dilim: satir.dilim, + // Servis edilen tek LLM metni; yukarıdaki dil filtresinden geçti. gerekce: satir.gerekce, - // DB'deki riskNotu batch'te LLM'in temsili sıraya göre yazdığı - // cümledir; servis EDİLMEZ. Not, rozetle (tadimlik-satiri.tsx) aynı - // formülden adayın gerçek sırasına göre okuma anında üretilir — metin - // ile rozet çelişemez, olasılık dili sızamaz. + // DB'deki riskNotu ve trendOzeti batch'te LLM'in yazdığı cümlelerdir; + // ikisi de servis EDİLMEZ. Risk notu, rozetle (tadimlik-satiri.tsx) + // aynı formülden adayın gerçek sırasına göre; trend, rapordakiyle aynı + // saf fonksiyondan programın sıra geçmişinden okuma anında üretilir — + // metin rozetle çelişemez, "son 4 yıl" gibi bayat kalıp taşınmaz. riskNotu: riskNotuMetni( riskHesapla(program.efektifSira, sira) ?? dilimdenRisk(satir.dilim), ), - trendOzeti: satir.trendOzeti, + trendOzeti: trendOzetiMetni(program.siraGecmisi), program, }; } diff --git a/src/lib/trend.ts b/src/lib/trend.ts new file mode 100644 index 0000000..4e3c8bd --- /dev/null +++ b/src/lib/trend.ts @@ -0,0 +1,51 @@ +// Deterministik trend cümlesi: rapor satırları (lib/ai/rapor.ts) ve girişsiz +// tadımlık (lib/tadimlik-havuzu.ts) AYNI saf fonksiyondan okur. Cümle gerçek +// YÖK Atlas sıra geçmişinden kesin rakamla kurulur ("45B düzeyinden 38B +// düzeyine indi"); yıl aralığı sabit bir "son N yıl" kalıbından değil, +// serideki dolu yıllardan gelir. + +/** + * Sıra geçmişi dizisinin ilk elemanının yılı. Dizi her yerde 2021'den başlar + * (bkz. programBilgisi, raporSiraSerisi); yeni veri yılı sona eklenir, bu + * çapa değişmez. + */ +const ILK_VERI_YILI = 2021; + +/** 45000 -> "45B", 1_200_000 -> "1,2M" (şemadaki öğrenci dili). */ +function siraKisa(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toLocaleString("tr-TR", { maximumFractionDigits: 1 })}M`; + } + if (n >= 1000) return `${Math.round(n / 1000)}B`; + return String(n); +} + +/** + * [2021..] taban sıralaması dizisinden tek cümlelik trend özeti. + * + * Cümle yalnızca OLANI betimler: ilk ve son dolu yılın tabanını kıyaslar + * (±%10 eşiği). "Zorlaşıyor / kolaylaşıyor" gibi geleceğe dönük yargı + * kurmaz — iki uç yılın kıyası aradaki seyri ve gelecek yılı söylemez. + * Küçülen sıra = taban sıkılaştı, büyüyen sıra = taban gevşedi. + */ +export function trendOzetiMetni( + gecmis: readonly (number | null | undefined)[] | undefined, +): string { + const seri: Array<[number, number]> = []; + (gecmis ?? []).forEach((sira, i) => { + if (sira != null) seri.push([ILK_VERI_YILI + i, sira]); + }); + if (seri.length < 2) { + return "Karşılaştırma için yeterli geçmiş yıl verisi yok; trendi temkinli oku."; + } + const [ilkYil, ilkSira] = seri[0]; + const [sonYil, sonSira] = seri[seri.length - 1]; + const oran = (sonSira - ilkSira) / ilkSira; + if (oran <= -0.1) { + return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; taban sıkılaştı.`; + } + if (oran >= 0.1) { + return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; taban gevşedi.`; + } + return `${ilkYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`; +}