From da8d303ea453cd5221e0afb152ad5439f35dccee Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 5 Oct 2026 08:49:41 +0300
Subject: [PATCH] =?UTF-8?q?fix(liste):=20ba=C5=9Fka=20programa=20ait=20ger?=
=?UTF-8?q?ek=C3=A7e=20(il/=C3=BCniversite=20=C3=A7eli=C5=9Fkisi)=20=C5=9F?=
=?UTF-8?q?ablona=20d=C3=BC=C5=9Fs=C3=BCn=20[KARAR]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../pazarlama/components/huni-semasi.tsx | 4 +-
.../components/meraklisina-icerik.tsx | 7 +-
src/lib/ai/liste-denetim.ts | 127 ++++++++++++++++++
src/lib/ai/rapor.ts | 20 +++
4 files changed, 155 insertions(+), 3 deletions(-)
diff --git a/src/features/pazarlama/components/huni-semasi.tsx b/src/features/pazarlama/components/huni-semasi.tsx
index fef00c5..c7f0f52 100644
--- a/src/features/pazarlama/components/huni-semasi.tsx
+++ b/src/features/pazarlama/components/huni-semasi.tsx
@@ -22,7 +22,7 @@ flowchart TD
S -- "evet" --> G("Filtreler adım adım gevşetilir
önce danışmana söylediklerin, sonra alan, sonra il — listede açıkça belirtilir
seçimine uyan programlar havuzda kalır, boşluk sıralamana en yakın diğer programlarla dolar")
G --> H
H --> U("Havuz yine de dar kalırsa
liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir")
- S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer
havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
+ S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer
havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar
seçimi ve gerekçeleri kodla denetlenir — eksik kalan satır havuzdan tamamlanır, vaat içeren ya da başka programı anlatan gerekçenin yerine standart açıklama yazılır")
U --> Y
Y --> R("Risk notu ve trend özeti
gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
R --> L("En fazla 24 tercihlik liste")
@@ -45,7 +45,7 @@ export function HuniSemasi() {
return (
Havuza yalnızca en son tercih kılavuzunda yer alan programlar girer;
diff --git a/src/features/pazarlama/components/meraklisina-icerik.tsx b/src/features/pazarlama/components/meraklisina-icerik.tsx
index 32fc4b5..4cdcf19 100644
--- a/src/features/pazarlama/components/meraklisina-icerik.tsx
+++ b/src/features/pazarlama/components/meraklisina-icerik.tsx
@@ -137,7 +137,12 @@ export function MeraklisinaIcerik() {
açıkça belirtilerek nasıl kurulduğu da dahil. Bir filtre
gevşediğinde seçimine uyan programlar havuzdan çıkmaz: önce onlar
alınır, kalan boşluk sıralamana en yakın diğer programlarla
- doldurulur.
+ doldurulur. Yapay zekânın seçtiği liste sana gelmeden önce kodla
+ denetlenir: havuzda olmayan ya da iki kez yazılan program atılır,
+ eksik kalan satır havuzdan tamamlanır; yerleşme vaadi içeren ya da
+ başka bir programı anlatan gerekçenin yerine standart bir açıklama
+ yazılır. Yapay zekâ zamanında yanıt vermezse liste aynı havuzdan
+ kurallarla kurulur ve bu, listenin başında sana söylenir.
diff --git a/src/lib/ai/liste-denetim.ts b/src/lib/ai/liste-denetim.ts
index 8b6adab..26a07e6 100644
--- a/src/lib/ai/liste-denetim.ts
+++ b/src/lib/ai/liste-denetim.ts
@@ -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 = {
+ 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 = {
+ 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;
+ ilRe: RegExp;
+ /** katlanmış ayırt edici üniversite adı ("hacettepe", "gebze teknik") */
+ uniler: Set;
+ 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();
+ 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();
+ 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) => [...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(`(? {
+ 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ığı ─────────────────────────
/**
diff --git a/src/lib/ai/rapor.ts b/src/lib/ai/rapor.ts
index d11c52a..b7cc15a 100644
--- a/src/lib/ai/rapor.ts
+++ b/src/lib/ai/rapor.ts
@@ -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");
}