diff --git a/src/lib/ai/liste-denetim.ts b/src/lib/ai/liste-denetim.ts new file mode 100644 index 0000000..8b6adab --- /dev/null +++ b/src/lib/ai/liste-denetim.ts @@ -0,0 +1,146 @@ +/** + * Liste metinlerinin (gerekçe, genel değerlendirme, uyarılar) DETERMİNİSTİK + * çıktı denetimi. Model çağrısı yok, hakem yok: saf fonksiyonlar. Prompt + * yasağı tek başına yetmiyor (ölçüm, 5 Eki 2026: 37 listenin 14'ünün üst + * metninde, 888 gerekçenin 19'unda vaat dili); rapor.ts her metni buradan + * geçirir, ihlalli metin kullanıcıya gitmez ve koddan üretilen şablona düşer. + * + * Bilinçli olarak DAR: yanlış alarmın bedeli bir metnin şablona düşmesi, + * kaçırmanın bedeli kullanıcıya vaat gitmesi. Reddeden kalıplar ("garanti + * verilemez", "kesin değildir") ihlal değildir; "Güvenli" dilim etiketi ihlal + * değildir. + * + * server-only guard'ı yok: scripts/eval altından düz tsx ile de okunur. + */ + +const tr = (s: string) => s.toLocaleLowerCase("tr-TR"); + +// ───────────────────────── dilim adı: garanti → güvenli ───────────────────────── + +// Üçüncü dilimin iç anahtarı `garanti`, kullanıcıya görünen adı "Güvenli" +// (DILIM_ETIKET, lib/risk.ts). Model metinde dilimi iç anahtarıyla anarsa +// ("garanti dilimde", "garanti listen") bu bir vaat değil yanlış etikettir: +// atmak yerine görünen ada çevrilir. Yalnız dilim/liste adı konumunda. +const DILIM_ADI_GARANTI = + /(? (sozcuk[0] === "G" ? "Güvenli" : "güvenli")); +} + +// ───────────────────────── yasak ifade ───────────────────────── + +// Eşleşmenin HEMEN ardından (aynı cümlecikte, araya noktalama girmeden) gelen +// olumsuzlama: "garanti verilemez", "garantisi yoktur", "kesin değildir". +const REDDEDEN = + /^[^.,;:!?\n]{0,35}?(? boolean }; + +const KALIPLAR: Kalip[] = [ + { ad: "garanti", re: /garanti\p{L}*/gu }, + // "kesinleştir", "kesin kayıt" (ÖSYM terimi) hariç + { ad: "kesin", re: /(? ONCELIK_ADI.test(ardi) }, + { + ad: "kesin yerleşme", + re: /(?): string[] { + const eksik: string[] = []; + for (const e of metin.matchAll(/\d+(?:[.,]\d+)*/g)) { + const t = e[0]; + const son = (e.index ?? 0) + t.length; + const ardi = tr(metin.slice(son, son + 6)); + // "45.312" binlik; "21,7" / "3.5" ondalık → model ondalık üretmemeli + const binlik = /^\d{1,3}(\.\d{3})+$/.test(t); + if (!binlik && !/^\d+$/.test(t)) { + eksik.push(t); + continue; + } + const n = Number(t.replace(/\./g, "")); + if (/^\s?(?:bin|b|k)(?!\p{L})/u.test(ardi)) { + if (![...izinli].some((v) => Math.round(v / 1000) === n || Math.floor(v / 1000) === n)) eksik.push(`${t} bin`); + continue; + } + if (n <= 31 || (n >= 2015 && n <= 2030) || izinli.has(n)) continue; + eksik.push(t); + } + return eksik; +} + +/** Program adında/üniversite adında geçen sayılar ("%50 İndirimli", "7 Aralık") gerekçede meşrudur. */ +export function metindekiSayilar(...metinler: (string | null | undefined)[]): number[] { + const cikti: number[] = []; + // "50.000" binlik ayraçlı tek sayıdır + for (const m of metinler) for (const e of (m ?? "").matchAll(/\d{1,3}(?:\.\d{3})+|\d+/g)) cikti.push(Number(e[0].replace(/\./g, ""))); + return cikti; +} diff --git a/src/lib/ai/rapor.ts b/src/lib/ai/rapor.ts index f2487bf..d11c52a 100644 --- a/src/lib/ai/rapor.ts +++ b/src/lib/ai/rapor.ts @@ -8,6 +8,12 @@ import type { } from "@/features/rapor/types/rapor"; import { anahtarVar } from "./client"; import { yapilandirilmisUret, type CagriGozlemi } from "./cagri"; +import { + dayanaksizSayi, + dilimAdiniDuzelt, + metindekiSayilar, + yasakIfadeler, +} from "./liste-denetim"; import { kategoriEslesir } from "../kategoriler"; import { havuzOlustur, @@ -237,6 +243,13 @@ function secimiOnar( const GEREKCE_UST_SINIR = 200; +// "Seçtiğin alanla eşleşiyor / alanına uygun": alan uyumu koddan bilinir +// (kategoriEslesir). Alan gevşediğinde havuza seçili alanın DIŞINDAN program +// girer; model bu satırlara da "seçtiğin alanla uyumlu" yazabiliyor (ölçüm: +// a07, öğretmenlik seçen adaya Türk Dili ve Edebiyatı satırında). +const ALAN_UYUMU_IDDIASI = + /(?:seçtiğin(?:iz)?|ilgi)\s+alan\p{L}*\s+(?:\p{L}+\s+)?(?:uyumlu|uygun|eşleş\p{L}*|örtüş\p{L}*)|alan(?:ın|ınız)(?:la|a|ıza|ızla)\s+(?:birebir\s+|doğrudan\s+|tam\s+)?(?:uyumlu|uygun|eşleş\p{L}*|örtüş\p{L}*)/iu; + // İlk üretimde model zaman aşımına uğrarsa liste tamamen koddan kurulur // ("ne olursa olsun liste kurulur", kullanıcı kararı 7 Ağu 2026). Bu yolda // üretim BAŞARILI sayılır: rapor-actions krediyi iade etmez (paketsiz @@ -618,8 +631,10 @@ export async function raporUretAyrintili( } const nihai = enIyi; - // --- Şablon metinler: kodla tamamlanan satırın gerekçesi ve boş kalan üst - // metin koddan basılır (ek model çağrısı yok). + // --- Metin denetimi (deterministik; ek model çağrısı yok). İhlalli metin + // kullanıcıya gitmez, koddan üretilen şablona düşer. + // Adayın kendi söylediği sayılar ("300 km", "%50 burslu") metinde meşrudur + const profilSayilari = metindekiSayilar(...profilSatir.map((s) => s.metin)); const sablonGerekceMetni = (p: AdayProgram): string => { const alan = alanUyar(p); const il = ilUyar(p); @@ -639,10 +654,30 @@ export async function raporUretAyrintili( return sablonGerekceMetni(p); }; if (s.gerekce == null) return sablon("kodla tamamlandı"); - let m = s.gerekce.replace(/\s+/g, " ").trim(); + let m = dilimAdiniDuzelt(s.gerekce.replace(/\s+/g, " ").trim()); if (m.length > GEREKCE_UST_SINIR) m = m.split(/(?<=[.!?])\s+/)[0]; if (m.length < 8) return sablon("boş"); if (m.length > GEREKCE_UST_SINIR) return sablon("uzun"); + const yasak = yasakIfadeler(m); + if (yasak.length > 0) return sablon(`yasak ifade — ${yasak.join(" | ")}`); + if (seciliKategoriler.length > 0 && !alanUyar(p) && ALAN_UYUMU_IDDIASI.test(m)) { + return sablon("alan uyumu iddiası, program seçili alanda değil"); + } + const sayi = dayanaksizSayi( + m, + new Set( + [ + ...p.siraGecmisi, + p.efektifSira, + p.kontenjanSon, + p.yerlesenSon, + params.sira, + ...metindekiSayilar(p.isim, p.universite), + ...profilSayilari, + ].filter((n): n is number => typeof n === "number"), + ), + ); + if (sayi.length > 0) return sablon(`dayanaksız sayı — ${sayi.join(", ")}`); // Model bu programı başka dilimin listesine koymuştu; gerekçe o dilimi // anlatıyorsa (ör. güvenli diye yazıp hayale taşındı) metin yanıltır. if (s.kaynak === "tasindi" && /hayal|dengeli|güvenli|risk/i.test(m)) { @@ -651,6 +686,21 @@ export async function raporUretAyrintili( return m; }; + const ustSayilar = new Set([ + params.sira, + toplamHedef, + hedef.hayal, + hedef.dengeli, + hedef.garanti, + ...profilSayilari, + ]); + const ustMetinIhlali = (m: string): string | null => { + const yasak = yasakIfadeler(m); + if (yasak.length > 0) return `yasak ifade — ${yasak.join(" | ")}`; + const sayi = dayanaksizSayi(m, ustSayilar); + return sayi.length > 0 ? `dayanaksız sayı — ${sayi.join(", ")}` : null; + }; + const dagilimMetni = DILIMLER.filter((d) => hedef[d] > 0) .map((d) => `${hedef[d]} ${DILIM_ETIKET[d].toLocaleLowerCase("tr-TR")}`) .join(", "); @@ -659,21 +709,26 @@ export async function raporUretAyrintili( ? `Listen ${toplamHedef} tercihten oluşuyor: ${dagilimMetni}. Yapay zekâ bu sefer zamanında yanıt vermediği için liste, sıralamana ve seçimlerine göre aday havuzundan otomatik kuruldu; satır açıklamaları standart metindir.` : `Listen ${toplamHedef} tercihten oluşuyor: ${dagilimMetni}. Dilimler, programların son taban sıralaması senin sıranla kıyaslanarak belirlendi.`; let genelSablon: string | null = null; - let genel = nihai.genel.replace(/\s+/g, " ").trim(); + let genel = dilimAdiniDuzelt(nihai.genel.replace(/\s+/g, " ").trim()); if (kaynak === "deterministik") { genelSablon = "model zaman aşımı"; } else if (!genel) { genelSablon = "boş"; + } else { + genelSablon = ustMetinIhlali(genel); } if (genelSablon) genel = sablonGenel; const uyariAtilan: string[] = []; const uyarilar: string[] = []; for (const ham of nihai.uyarilar) { - const m = ham.replace(/\s+/g, " ").trim(); - if (m) uyarilar.push(m); + const m = dilimAdiniDuzelt(ham.replace(/\s+/g, " ").trim()); + if (!m) continue; + const ihlal = ustMetinIhlali(m); + if (ihlal) uyariAtilan.push(ihlal); + else uyarilar.push(m); } - // Model en az 2 uyarı yazar; azı geldiyse (ya da liste koddan kurulduysa) eksik koddan tamamlanır. + // Model en az 2 uyarı yazar; denetimden 2'den azı geçtiyse eksik koddan tamamlanır. const sablonUyarilar: string[] = [ ...(gevsetildi.includes("profil") ? ["Danışmana söylediğin tercihlerle yeterli program bulunamadığı için liste bu tercihler uygulanmadan kuruldu."]