fix(liste): liste metinlerinde deterministik çıktı denetimi — vaat dili kullanıcıya gitmez [KARAR]

Kök neden 1 (04-eval-sonuclari): yasak yalnız prompt'taydı; 37 listenin
14'ünün üst metninde, 888 gerekçenin 19'unda vaat dili / "garanti" vardı.

- liste-denetim.ts (yeni, saf fonksiyonlar): yasakIfadeler — Türkçe
  çekimleri kapsayan dar kalıplar (garanti*, kesin, yüzde yüz, yerleşirsin,
  rahat/kolay yerleşme, yerleşme şansı/ihtimali/olasılığı, şansın yüksek,
  sırana rahat…). Reddeden kalıp ("garanti verilemez", "kesin değildir"),
  "Güvenli" dilim etiketi, "%100 burslu", "kesin kayıt" ihlal DEĞİL.
  dilimAdiniDuzelt — modelin dilim adı olarak yazdığı "garanti dilim/liste"
  görünen ada ("güvenli") çevrilir. dayanaksizSayi — metindeki rakam girdide
  yoksa ihlal (rakamlar koddan basılıyor).
- rapor.ts: her gerekçe, genel değerlendirme ve her uyarı denetimden geçer.
  İhlalli gerekçe → koddan şablon gerekçe; ihlalli genel değerlendirme →
  koddan (dağılım sayılarıyla) şablon; ihlalli uyarı atılır, 2'nin altına
  düşerse koddan uyarı eklenir. Seçili alanın dışındaki programa "seçtiğin
  alanla uyumlu" diyen gerekçe de şablona düşer. Ek model çağrısı YOK;
  kredi ve gecikme etkilenmez. Canlı akışa Jev eklenmedi (eval'de hakem).

Önce → sonra (eval regex'i, 40 profil): vaat dili içeren üst metin
14/37 → 0/40, gerekçe 19/888 → 0/960; hakem (Jev) ihlali 30 → 1 satır.
Bedel: 960 gerekçenin 4'ü, 40 genel değerlendirmenin 2'si, 2 uyarı şablona düştü.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-05 08:49:21 +03:00
parent 3128b01e26
commit 8462894137
2 changed files with 208 additions and 7 deletions

146
src/lib/ai/liste-denetim.ts Normal file
View File

@@ -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 =
/(?<!\p{L})garant[iİ](?=["'“”‘’]?\s+(?:dilim|liste|tercih|seçenek|grub|kısım|kısm|satır)\p{L}*)/giu;
/** "garanti dilim/liste/tercih…" → "güvenli …" (büyük harf korunur). */
export function dilimAdiniDuzelt(metin: string): string {
return metin.replace(DILIM_ADI_GARANTI, (sozcuk) => (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}?(?<!\p{L})(?:değil|yok(?:tur)?(?!\p{L})|ver(?:il)?e?m[ei]\p{L}*|etme[zm]\p{L}*|etmiyor\p{L}*|edeme\p{L}*|edileme\p{L}*|sayılma\p{L}*|olmaz(?!sa)|sunma[zm]\p{L}*|sağlama[zm]\p{L}*|taşıma[zm]\p{L}*|anlamına gelme\p{L}*|hesaplam\p{L}*|hesaplanm\p{L}*|söylene?me\p{L}*|denile?me\p{L}*)/u;
// Adayın sihirbazda seçtiği öncelik "Kolay yerleşme": model bunu ad olarak
// anabilir ("kolay yerleşme önceliğine uygun"); vaat cümlesi değildir.
const ONCELIK_ADI = /^\s+(?:önceli|iste[ğk]|hedef|kriter|beklenti|tercihin)/u;
type Kalip = { ad: string; re: RegExp; istisna?: (ardi: string) => boolean };
const KALIPLAR: Kalip[] = [
{ ad: "garanti", re: /garanti\p{L}*/gu },
// "kesinleştir", "kesin kayıt" (ÖSYM terimi) hariç
{ ad: "kesin", re: /(?<!\p{L})kesin(?:likle|dir|tir)?(?!\p{L})(?!\s+kayı[td])/gu },
// "%100 burslu / %100 İngilizce" program adıdır
{ ad: "yüzde yüz", re: /(?:yüzde\s+yüz|%\s?100)(?!\s*(?:burs|indirim|ingilizce|türkçe|almanca|fransızca|arapça))/gu },
{
ad: "yerleşirsin",
re: /(?<!\p{L})(?:yerleş(?:ir|ecek|ebilir|iyor)s[iıuü]n(?:[iı]z|[uü]z)?|yerleş(?:ebil)?eceğin(?:iz)?|kazan(?:ır|acak|abilir)s[ıi]n(?:ız)?|(?:bölüme|programa|buraya|üniversiteye|okula|fakülteye)\s+gir(?:er|ecek|ebilir)s[iı]n(?:iz)?)/gu,
},
{
ad: "rahat yerleşme",
re: /(?<!\p{L})(?:rahat(?:ça|lıkla)?|kolayca|kolaylıkla)\s+(?:yerleş|gir(?!iş)|kazan)\p{L}*/gu,
},
// "sıralamanı rahatça karşılayan", "rahatlıkla tercih edilebilir": zarf hâli her bağlamda güvence verir
{ ad: "rahatça", re: /(?<!\p{L})rahat(?:ça|lıkla)(?!\p{L})/gu },
// "sırana rahat", "rahat bir tercih/seçenek", "sıralamana rahat oturur" (ölçüm: hakemin kalan
// işaretlerinin çoğu). "rahat bir ortam/kampüs" yerleşmeyi değil yaşamı anlatır, ihlal değildir.
{ ad: "rahat tercih", re: /(?<!\p{L})rahat\s+(?:bir\s+)?(?:tercih|seçenek|seçim|alternatif|otur|karşıla|yet)\p{L}*/gu },
{ ad: "sırana rahat", re: /(?<!\p{L})sıra\p{L}*\s+(?:göre\s+)?rahat(?!\p{L})/gu },
{ ad: "kolay yerleşme", re: /(?<!\p{L})kolay\s+yerleş\p{L}*/gu, istisna: (ardi) => ONCELIK_ADI.test(ardi) },
{
ad: "kesin yerleşme",
re: /(?<!\p{L})(?:mutlaka|muhtemelen|büyük\s+ihtimalle|yüksek\s+ihtimalle|büyük\s+olasılıkla|yüksek\s+olasılıkla)\s+(?:yerleş|kazan)\p{L}*/gu,
},
// Ürün olasılık hesaplamaz: "yerleşme şansı/ihtimali/olasılığı" hiçbir yönde söylenmez
{
ad: "yerleşme olasılığı",
re: /(?<!\p{L})(?:yerleşme|yerleşebilme|girme|kazanma|tutma|kabul)\s+(?:şans|ihtimal|olasılı)\p{L}*/gu,
},
{
ad: "şans",
re: /(?<!\p{L})şans\p{L}*\s+(?:çok\s+|oldukça\s+|daha\s+|epey\s+)?(?:yüksek|iyi|fazla|var|düşük|az|zayıf|artar|artır|yükselt)\p{L}*/gu,
},
{
ad: "olasılık",
re: /(?<!\p{L})(?:ihtimal|olasılı)\p{L}*\s+(?:çok\s+|oldukça\s+|daha\s+)?(?:yüksek|düşük|büyük|az|zayıf|güçlü|artar|artır)\p{L}*/gu,
},
{
ad: "olasılık",
re: /(?<!\p{L})(?:yüksek|büyük|güçlü|kuvvetli|düşük|zayıf)\s+(?:bir\s+)?(?:ihtimal|olasılı)\p{L}*/gu,
},
{ ad: "yüzde olasılık", re: /(?:%\s?\d+|yüzde\s+[\p{L}\d]+)\s*(?:ihtimal|olasılı|şans)\p{L}*/gu },
];
/**
* Metindeki vaat/olasılık/yasak sözcük ihlalleri (boş dizi = temiz). Çağıran
* önce `dilimAdiniDuzelt` uygulamalıdır; burada kalan her "garanti" ihlaldir.
*/
export function yasakIfadeler(metin: string): string[] {
const m = tr(metin);
const bulunan: string[] = [];
for (const k of KALIPLAR) {
for (const e of m.matchAll(k.re)) {
const son = (e.index ?? 0) + e[0].length;
const ardi = m.slice(son, son + 60);
if (REDDEDEN.test(ardi)) continue;
if (k.istisna?.(ardi)) continue;
bulunan.push(`${k.ad}: "${e[0]}"`);
}
}
return bulunan;
}
// ───────────────────────── sayı tutarlılığı ─────────────────────────
/**
* Metindeki, `izinli` kümesinde karşılığı olmayan sayılar. Model rakam
* yazmamalı (sıra, kontenjan, trend koddan basılıyor); yazdıysa girdiyle
* birebir tutmalı. Atlananlar: 0–31 (adet, sıra no), 2015–2030 (yıl).
* "45 bin" / "45B" / "45K" → izinli bir değerin bine yuvarlanmışı olmalı.
*/
export function dayanaksizSayi(metin: string, izinli: ReadonlySet<number>): 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;
}

View File

@@ -8,6 +8,12 @@ import type {
} from "@/features/rapor/types/rapor"; } from "@/features/rapor/types/rapor";
import { anahtarVar } from "./client"; import { anahtarVar } from "./client";
import { yapilandirilmisUret, type CagriGozlemi } from "./cagri"; import { yapilandirilmisUret, type CagriGozlemi } from "./cagri";
import {
dayanaksizSayi,
dilimAdiniDuzelt,
metindekiSayilar,
yasakIfadeler,
} from "./liste-denetim";
import { kategoriEslesir } from "../kategoriler"; import { kategoriEslesir } from "../kategoriler";
import { import {
havuzOlustur, havuzOlustur,
@@ -237,6 +243,13 @@ function secimiOnar(
const GEREKCE_UST_SINIR = 200; 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 // İ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 // ("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 // üretim BAŞARILI sayılır: rapor-actions krediyi iade etmez (paketsiz
@@ -618,8 +631,10 @@ export async function raporUretAyrintili(
} }
const nihai = enIyi; const nihai = enIyi;
// --- Şablon metinler: kodla tamamlanan satırın gerekçesi ve boş kalan üst // --- Metin denetimi (deterministik; ek model çağrısı yok). İhlalli metin
// metin koddan basılır (ek model çağrısı yok). // 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 sablonGerekceMetni = (p: AdayProgram): string => {
const alan = alanUyar(p); const alan = alanUyar(p);
const il = ilUyar(p); const il = ilUyar(p);
@@ -639,10 +654,30 @@ export async function raporUretAyrintili(
return sablonGerekceMetni(p); return sablonGerekceMetni(p);
}; };
if (s.gerekce == null) return sablon("kodla tamamlandı"); 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 > GEREKCE_UST_SINIR) m = m.split(/(?<=[.!?])\s+/)[0];
if (m.length < 8) return sablon("boş"); if (m.length < 8) return sablon("boş");
if (m.length > GEREKCE_UST_SINIR) return sablon("uzun"); 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 // 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. // 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)) { if (s.kaynak === "tasindi" && /hayal|dengeli|güvenli|risk/i.test(m)) {
@@ -651,6 +686,21 @@ export async function raporUretAyrintili(
return m; 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) const dagilimMetni = DILIMLER.filter((d) => hedef[d] > 0)
.map((d) => `${hedef[d]} ${DILIM_ETIKET[d].toLocaleLowerCase("tr-TR")}`) .map((d) => `${hedef[d]} ${DILIM_ETIKET[d].toLocaleLowerCase("tr-TR")}`)
.join(", "); .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}. 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.`; : `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 genelSablon: string | null = null;
let genel = nihai.genel.replace(/\s+/g, " ").trim(); let genel = dilimAdiniDuzelt(nihai.genel.replace(/\s+/g, " ").trim());
if (kaynak === "deterministik") { if (kaynak === "deterministik") {
genelSablon = "model zaman aşımı"; genelSablon = "model zaman aşımı";
} else if (!genel) { } else if (!genel) {
genelSablon = "boş"; genelSablon = "boş";
} else {
genelSablon = ustMetinIhlali(genel);
} }
if (genelSablon) genel = sablonGenel; if (genelSablon) genel = sablonGenel;
const uyariAtilan: string[] = []; const uyariAtilan: string[] = [];
const uyarilar: string[] = []; const uyarilar: string[] = [];
for (const ham of nihai.uyarilar) { for (const ham of nihai.uyarilar) {
const m = ham.replace(/\s+/g, " ").trim(); const m = dilimAdiniDuzelt(ham.replace(/\s+/g, " ").trim());
if (m) uyarilar.push(m); 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[] = [ const sablonUyarilar: string[] = [
...(gevsetildi.includes("profil") ...(gevsetildi.includes("profil")
? ["Danışmana söylediğin tercihlerle yeterli program bulunamadığı için liste bu tercihler uygulanmadan kuruldu."] ? ["Danışmana söylediğin tercihlerle yeterli program bulunamadığı için liste bu tercihler uygulanmadan kuruldu."]