fix(ai): tadımlık trendi deterministik + gerekçe dil filtresi

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>
This commit is contained in:
bilalgursen
2026-09-21 09:22:40 +03:00
parent cae2aaab4d
commit 79bc7fa8be
3 changed files with 88 additions and 36 deletions

View File

@@ -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 {

View File

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

51
src/lib/trend.ts Normal file
View File

@@ -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.`;
}