fix(liste): başka programa ait gerekçe (il/üniversite çelişkisi) şablona düşsün [KARAR]

Kök neden 3 (04-eval-sonuclari): 888 gerekçenin 7'si başka programa aitti
(ör. Erzurum'daki programa "sanayi şehri Konya'da").

- liste-denetim.ts: yerCeliskisi — veri tabanındaki il ve üniversite
  adlarından sözlük. Bulunma ekiyle ("Konya'da") anılan il programın ili
  değilse, ya da anılan il ne programın ne adayın seçtiği/oturduğu ilse,
  ya da anılan üniversite satırınki değilse çelişki. "İzmir'e yakın"
  (adayın ili), üniversite adındaki il, "Kıbrıs'ta" (KKTC şehirleri),
  İTÜ/ODTÜ gibi kısaltmalar ve bölge/ilçe adları ("Ege", "Çankaya") ihlal
  değil.
- rapor.ts: çelişkili gerekçe koddan şablona düşer (ek model çağrısı yok).
- /meraklisina şeması ve 4. bölüm anlatımı (AGENTS.md senkronu): yapay
  zekânın seçimi ve gerekçeleri kodla denetlenir; eksik satır havuzdan
  tamamlanır, vaat içeren ya da başka programı anlatan gerekçenin yerine
  standart açıklama yazılır; yapay zekâ zamanında yanıt vermezse liste aynı
  havuzdan kurallarla kurulur. Havuz dışına çıkamama kuralı değişmedi. Bu
  metin önceki iki commit'in (3128b01, 8462894) davranışını da kapsar.

Kanıt: taban koşusunun 888 gerekçesi üzerinde çevrimdışı — 7 gerçek
çelişkinin 7'si yakalandı, yanlış alarm 0 (eval'in 2 yanlış alarmı dahil).
Canlı koşular: il çelişkisi 7 → 0 (iki koşuda 1 ve 0 gerekçe şablona düştü).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-05 08:49:41 +03:00
parent 10c405439d
commit da8d303ea4
4 changed files with 155 additions and 3 deletions

View File

@@ -106,6 +106,133 @@ export function yasakIfadeler(metin: string): string[] {
return bulunan;
}
// ───────────────────────── il / üniversite çelişkisi ─────────────────────────
/** Aksan katlama (uzunluk korunur): "Izmir", "Eskisehir" yazımları da yakalansın. */
const katla = (s: string) =>
tr(s).replace(/[ışğüöçâîû]/g, (c) => ({ ı: "i", ş: "s", ğ: "g", ü: "u", ö: "o", ç: "c", â: "a", î: "i", û: "u" })[c] ?? c);
const duzIfade = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const IL_KISA_AD: Record<string, string> = {
afyon: "AFYONKARAHİSAR",
maraş: "KAHRAMANMARAŞ",
urfa: "ŞANLIURFA",
antep: "GAZİANTEP",
izmit: "KOCAELİ",
adapazarı: "SAKARYA",
antakya: "HATAY",
};
// Üniversite adının ayırt edici kısmı bölge, ilçe ya da gündelik sözcükse
// ("Ege'de", "başkent Ankara", "Çankaya'da", "Alanya'ya yakın") çelişki aranmaz.
const UNI_ANAHTAR_DISI = new Set(
["ege", "marmara", "akdeniz", "trakya", "çukurova", "başkent", "kapadokya", "gazi", "kültür", "toros", "anadolu", "fırat", "dicle", "atılım", "doğuş", "ufuk", "bilgi", "ışık", "okan", "haliç", "maltepe", "üsküdar", "fenerbahçe", "galatasaray", "atatürk", "cumhuriyet", "yeni yüzyıl", "piri reis", "medeniyet", "sağlık bilimleri", "alanya", "mudanya", "tarsus", "pamukkale", "çankaya", "yaşar", "avrasya"].map(katla),
);
const UNI_KISALTMA: Record<string, string> = {
itü: "istanbul teknik",
odtü: "orta doğu teknik",
ytü: "yıldız teknik",
ktü: "karadeniz teknik",
gtü: "gebze teknik",
iyte: "izmir yüksek teknoloji",
deü: "dokuz eylül",
};
const KKTC_ILLERI = new Set(["LEFKOŞA", "GİRNE", "GAZİMAĞUSA", "GÜZELYURT", "LEFKE", "KIBRIS"]);
const UNI_KISALTMA_KATLI = new Map(Object.entries(UNI_KISALTMA).map(([k, v]) => [katla(k), katla(v)]));
export type YerSozlugu = {
/** katlanmış il adı (ve yaygın kısa ad) → veri tabanındaki il */
iller: Map<string, string>;
ilRe: RegExp;
/** katlanmış ayırt edici üniversite adı ("hacettepe", "gebze teknik") */
uniler: Set<string>;
uniRe: RegExp | null;
};
function uniAnahtari(universite: string): string {
return katla(universite)
.replace(/\s*\(.*?\)/g, "")
.replace(/\s+(?:universitesi|yuksek teknoloji enstitusu|meslek yuksekokulu)\b.*$/, (e) =>
e.includes("yuksek teknoloji") ? " yuksek teknoloji" : "",
)
.trim();
}
/** Bilinen il ve üniversite adlarından sözlük (veri tabanındaki tüm programlardan bir kez kurulur). */
export function yerSozluguKur(satirlar: { il: string | null; universite: string }[]): YerSozlugu {
const iller = new Map<string, string>();
for (const s of satirlar) {
if (!s.il) continue;
// "BAKÜ-AZERBAYCAN", "SARAYBOSNA - BOSNA - HERSEK" → ilk parça
const ad = katla(s.il.split(/\s*-\s*/)[0]).trim();
if (ad.length >= 3) iller.set(ad, s.il);
}
for (const [kisa, il] of Object.entries(IL_KISA_AD)) iller.set(katla(kisa), il);
const uniler = new Set<string>();
for (const s of satirlar) {
const a = uniAnahtari(s.universite);
if (a.length >= 5 && !iller.has(a) && !UNI_ANAHTAR_DISI.has(a)) uniler.add(a);
}
for (const [kisa, tam] of UNI_KISALTMA_KATLI) if (uniler.has(tam)) uniler.add(kisa);
const secenek = (k: Iterable<string>) => [...k].sort((a, b) => b.length - a.length).map(duzIfade).join("|");
return {
iller,
// Ek: 'da/'de/'ta/'te(ki) bulunma hâli ayrı yakalanır (programın yerini iddia eder)
ilRe: new RegExp(`(?<![\\p{L}])(${secenek(iller.keys())})(?:(['’]?)([dt][ae](?:ki)?))?(?![\\p{L}])`, "gu"),
uniler,
uniRe: uniler.size ? new RegExp(`(?<![\\p{L}])(${secenek(uniler)})(?![\\p{L}])`, "gu") : null,
};
}
/**
* Gerekçe, satırın programıyla çelişen bir il ya da üniversite adı anıyorsa
* neden metni döner (yoksa null). "Gerekçe başka programa ait" hatasının
* (ölçüm: 7/888 satır; ör. Erzurum'daki programa "Konya'da") kod denetimi.
*
* İl: (a) bulunma ekiyle ("Konya'da") anılan il programın ili değilse çelişki;
* (b) eksiz anılan il ne programın ili, ne adayın seçtiği/oturduğu il ise
* çelişki ("İzmir'e yakın" adayın kendi iliyse meşrudur).
* Üniversite: anılan üniversite satırınki değilse çelişki.
*/
export function yerCeliskisi(
gerekce: string,
program: { isim: string; universite: string; il: string | null },
sozluk: YerSozlugu,
adayIlleri: readonly string[] = [],
): string | null {
const k = katla(gerekce);
const uniK = katla(program.universite);
const isimK = katla(program.isim);
const buyukHarfle = (i: number) => {
const c = gerekce[i];
return c != null && c !== c.toLocaleLowerCase("tr-TR");
};
for (const e of k.matchAll(sozluk.ilRe)) {
const i = e.index ?? 0;
// Özel ad: özgün metinde büyük harfle başlamalı ("ordu", "aydın", "ağrı", "van" sözcükleri il değildir)
if (!buyukHarfle(i)) continue;
const il = sozluk.iller.get(e[1])!;
if (il === program.il) continue;
// "Kıbrıs'ta": KKTC şehirlerindeki programlar için aynı yer
if (il === "KIBRIS" && program.il != null && KKTC_ILLERI.has(program.il)) continue;
if (uniK.includes(e[1]) || isimK.includes(e[1])) continue;
const bulunmaEki = Boolean(e[3]);
if (bulunmaEki || !adayIlleri.includes(il)) return `il: gerekçe ${il} diyor, program ${program.il ?? "?"}`;
}
if (sozluk.uniRe) {
for (const e of k.matchAll(sozluk.uniRe)) {
if (!buyukHarfle(e.index ?? 0)) continue;
const anahtar = UNI_KISALTMA_KATLI.get(e[1]) ?? e[1];
if (!uniK.includes(anahtar)) return `üniversite: gerekçe "${e[1]}" diyor, program ${program.universite}`;
}
}
return null;
}
// ───────────────────────── sayı tutarlılığı ─────────────────────────
/**

View File

@@ -13,7 +13,11 @@ import {
dilimAdiniDuzelt,
metindekiSayilar,
yasakIfadeler,
yerCeliskisi,
yerSozluguKur,
type YerSozlugu,
} from "./liste-denetim";
import { getDb } from "../db";
import { kategoriEslesir } from "../kategoriler";
import {
havuzOlustur,
@@ -241,6 +245,18 @@ function secimiOnar(
return { secim, onarim };
}
// --- Metin denetimi sözlüğü: bilinen il ve üniversite adları (tek sorgu,
// süreç boyunca önbellekte).
let yerSozluguOnbellek: YerSozlugu | null = null;
function yerSozlugu(): YerSozlugu {
yerSozluguOnbellek ??= yerSozluguKur(
getDb()
.prepare("SELECT DISTINCT il, universite FROM programs")
.all() as { il: string | null; universite: string }[],
);
return yerSozluguOnbellek;
}
const GEREKCE_UST_SINIR = 200;
// "Seçtiğin alanla eşleşiyor / alanına uygun": alan uyumu koddan bilinir
@@ -633,6 +649,8 @@ export async function raporUretAyrintili(
// --- Metin denetimi (deterministik; ek model çağrısı yok). İhlalli metin
// kullanıcıya gitmez, koddan üretilen şablona düşer.
const sozluk = yerSozlugu();
const adayIlleri = [...seciliIller, ...(profil?.evIl ? [profil.evIl] : [])];
// 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 => {
@@ -660,6 +678,8 @@ export async function raporUretAyrintili(
if (m.length > GEREKCE_UST_SINIR) return sablon("uzun");
const yasak = yasakIfadeler(m);
if (yasak.length > 0) return sablon(`yasak ifade — ${yasak.join(" | ")}`);
const yer = yerCeliskisi(m, p, sozluk, adayIlleri);
if (yer) return sablon(`başka programa ait — ${yer}`);
if (seciliKategoriler.length > 0 && !alanUyar(p) && ALAN_UYUMU_IDDIASI.test(m)) {
return sablon("alan uyumu iddiası, program seçili alanda değil");
}