diff --git a/scripts/eval/revizyon-denetim.ts b/scripts/eval/revizyon-denetim.ts new file mode 100644 index 0000000..de5f0d7 --- /dev/null +++ b/scripts/eval/revizyon-denetim.ts @@ -0,0 +1,214 @@ +/** + * Revizyonun "ilk N" garantisinin MEKANİK testi — parasız, anahtarsız. + * + * pnpm tsx --conditions=react-server scripts/eval/revizyon-denetim.ts + * + * Yerelde sahte bir OpenAI-uyumlu uç açılır; "model" isteğin json_schema + * adetlerini ve kullanıcı mesajındaki ADAY HAVUZU'nu okuyup her listeyi + * KASTEN `oncelikli` olmayan id'lerle tam adet doldurur (7 Eki 2026 kök + * nedeni: model öncelikliyi atlayınca onarım tamamlayacak boşluk bulamıyor, + * liste değişmemiş görünüyordu). Gerçek raporUretAyrintili + gerçek havuz + * (data/yokatlas.db, salt okunur) koşar; app.db gerekmez. Doğrulanan: ölçüte + * uyan satırların kodla takas edilip başa alınması, dilim adetlerinin + * korunması, havuz yetersizken hata fırlamaması ve dev mock yolunun da kuralı + * uygulaması. Model davranışını ÖLÇMEZ; onu eval:sohbet ölçer. Beklenti + * tutmazsa çıkış kodu 1. + */ +import { createServer } from "node:http"; +import type { RaporParams } from "@/features/rapor/types/rapor"; + +const tr = (s: string | null | undefined) => (s ?? "").toLocaleLowerCase("tr-TR"); +const HAVUZ_ISARETI = "ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): "; +type TelListe = "hayal" | "dengeli" | "guvenli"; +const LISTELER: TelListe[] = ["hayal", "dengeli", "guvenli"]; + +/** Sahte ucun son isteğinde kasten atlanan öncelikli satır sayısı (bilgi amaçlı). */ +let atlananOncelikli = 0; +const sunucu = createServer((req, res) => { + let govde = ""; + req.on("data", (c) => (govde += c)); + req.on("end", () => { + const istek = JSON.parse(govde) as { + messages: { role: string; content: string }[]; + response_format: { json_schema: { schema: { properties: Record } } }; + }; + const kullanici = istek.messages.find((m) => m.role === "user")?.content ?? ""; + const i = kullanici.indexOf(HAVUZ_ISARETI); + const havuz = JSON.parse(kullanici.slice(i + HAVUZ_ISARETI.length)) as Record; + const ozellik = istek.response_format.json_schema.schema.properties; + const cikti: Record = { + genelDegerlendirme: "Liste sıralamana ve seçimlerine göre kuruldu.", + uyarilar: ["Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.", "Her programın koşullarını kılavuzdan kontrol et."], + }; + atlananOncelikli = 0; + for (const l of LISTELER) { + const adet = ozellik[l]?.minItems ?? 0; + // Kasten: önce öncelikli OLMAYANLAR; yetmezse öncelikliler (havuz tamamen ölçütten oluşuyorsa) + const siradan = havuz[l].filter((p) => !p.oncelikli); + const oncelikli = havuz[l].filter((p) => p.oncelikli); + const secim = [...siradan, ...oncelikli].slice(0, adet); + atlananOncelikli += oncelikli.length - secim.filter((p) => p.oncelikli).length; + cikti[l] = secim.map((p) => `${p.id}|Sıralamana yakın, alanına uygun bir program.`); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: "x", + object: "chat.completion", + created: 1, + model: "sahte", + choices: [{ index: 0, message: { role: "assistant", content: JSON.stringify(cikti) }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }), + ); + }); +}); + +type Rapor = Awaited>; +type Senaryo = { + ad: string; + params: RaporParams; + /** AI_API_KEY silinir → dev mock yolu (tani null). */ + mock?: boolean; + dogrula: (r: Rapor) => string[]; +}; + +/** + * İl adları DB biçimindedir ("ANKARA"): sihirbaz ve liste_revize (ilAdiNormalize) + * bu biçimi yazar, havuz il süzgeci tam eşleşme yapar; "Ankara" yazılsa süzgeç + * boş kalır ve havuz "il gevşet" yoluyla tüm ülkeye açılır (test tuzağı). + */ +const taban = (revizyon: NonNullable, iller = ["ANKARA", "İZMİR"]): RaporParams => ({ + sira: 100000, + tur: "say", + iller, + kategoriler: [], + revizyon, +}); + +/** Her senaryoda ortak: 24 satır, dilim adetleri kapsam.dagilim ile aynı, sira 1..n. */ +function iskeletDogrula({ rapor }: Rapor): string[] { + const h: string[] = []; + if (rapor.tercihler.length !== 24) h.push(`satır sayısı ${rapor.tercihler.length} ≠ 24`); + const sayac = { hayal: 0, dengeli: 0, garanti: 0 }; + for (const t of rapor.tercihler) sayac[t.dilim]++; + for (const d of ["hayal", "dengeli", "garanti"] as const) { + if (sayac[d] !== rapor.kapsam?.dagilim[d]) h.push(`${d} ${sayac[d]} ≠ dagilim ${rapor.kapsam?.dagilim[d]}`); + } + rapor.tercihler.forEach((t, i) => { + if (t.sira !== i + 1) h.push(`sira ${t.sira} ≠ ${i + 1}`); + }); + return h; +} + +function ilkNIl(r: Rapor, adet: number, il: string): string[] { + const h = iskeletDogrula(r); + r.rapor.tercihler.slice(0, adet).forEach((t, i) => { + const p = r.rapor.programlar[t.programId]; + if (tr(p?.il) !== tr(il)) h.push(`${i + 1}. satır ${p?.il ?? "?"} (${il} bekleniyor)`); + }); + return h; +} + +function eklenenEsit(r: Rapor, n: number): string[] { + return r.tani?.ilkNEklenen === n ? [] : [`tani.ilkNEklenen ${r.tani?.ilkNEklenen} ≠ ${n}`]; +} + +const SENARYOLAR: Senaryo[] = [ + { + // Bilal'in 7 Eki denemesi. Not: İstanbul her dilimde 20+ programla havuz + // kırpmasının öncelikli kotasını tek başına doldurur → havuz 60/60 İstanbul + // olur, sahte uç öncelikli olmayan satır bulamaz, takas gerekmez + // (ilkNEklenen 0). Garantinin takas yolu "ilk5-takas" senaryosunda ölçülür. + ad: "ilk5-istanbul", + params: taban({ ilkTercihler: { adet: 5, iller: ["İSTANBUL"] }, not: "ilk 5 İstanbul" }), + dogrula: (r) => ilkNIl(r, 5, "İSTANBUL"), + }, + { + // Takas yolu: Yalova 100.000 say'da havuza 0/3/8 (hayal/dengeli/güvenli) + // öncelikli satır sokar, her dilimde sıradan satır hedeften fazla kalır → + // sahte uç 24'ü önceliklisiz doldurur, kod 5 satırı takasla sokar. + ad: "ilk5-takas", + params: taban({ ilkTercihler: { adet: 5, iller: ["YALOVA"] }, not: "ilk 5 Yalova" }), + dogrula: (r) => [...ilkNIl(r, 5, "YALOVA"), ...eklenenEsit(r, 5)], + }, + { + // Kayseri 100.000 say: 41 programlık havuzda 8 vakıf (1/0/7), sıradan her + // dilimde hedeften fazla → sahte uç hep devlet seçer, kod 3 vakıf takaslar. + ad: "ilk3-vakif", + params: taban({ ilkTercihler: { adet: 3, universiteTipi: "vakif" }, not: "ilk 3 vakıf" }, ["KAYSERİ"]), + dogrula: (r) => { + const h = iskeletDogrula(r); + r.rapor.tercihler.slice(0, 3).forEach((t, i) => { + const p = r.rapor.programlar[t.programId]; + if (p?.unitur === "DEVLET") h.push(`${i + 1}. satır devlet (vakıf bekleniyor)`); + }); + return [...h, ...eklenenEsit(r, 3)]; + }, + }, + { + // Ardahan 100.000 say'da havuza yalnız 2 program sokar: eldeki kadarı başta, hata yok + ad: "havuz-yetersiz", + params: taban({ ilkTercihler: { adet: 10, iller: ["ARDAHAN"] }, not: "ilk 10 Ardahan" }), + dogrula: (r) => { + const h = iskeletDogrula(r); + const n = r.tani?.ilkNEklenen ?? -1; + if (n < 0 || n > 10) h.push(`tani.ilkNEklenen ${n} 0..10 dışında`); + const ardahan = r.rapor.tercihler.filter((t) => tr(r.rapor.programlar[t.programId]?.il) === "ardahan").length; + const bastaki = r.rapor.tercihler.findIndex((t) => tr(r.rapor.programlar[t.programId]?.il) !== "ardahan"); + if (bastaki !== -1 && bastaki !== Math.min(ardahan, 10)) h.push(`Ardahan satırları başta toplu değil (${ardahan} satır, ilk uymayan ${bastaki + 1}.)`); + return h; + }, + }, + { + // Dev mock yolu (AI_API_KEY yok): mock dilim içinde öncelikliyi önce alır ve + // sonunda ilkTercihleriOneAl uygular; Yalova params'ı ile ilk 5 Yalova olmalı. + ad: "mock-yolu", + mock: true, + params: taban({ ilkTercihler: { adet: 5, iller: ["YALOVA"] }, not: "ilk 5 Yalova" }), + dogrula: (r) => { + const h = ilkNIl(r, 5, "YALOVA"); + if (r.tani !== null) h.push("mock yolunda tani null olmalı"); + if (!r.rapor.devMock) h.push("devMock işareti yok"); + return h; + }, + }, +]; + +async function main() { + await new Promise((r) => sunucu.listen(0, "127.0.0.1", r)); + const port = (sunucu.address() as { port: number }).port; + process.env.AI_BASE_URL = `http://127.0.0.1:${port}/v1`; + process.env.AI_API_KEY = "sahte-anahtar"; + delete process.env.OPENROUTER_API_KEY; + delete process.env.TYPESAFE_API_KEY; // Jev'e gitme + const { raporUretAyrintili } = await import("@/lib/ai/rapor"); + const info = console.info; + console.info = () => {}; + let hata = 0; + for (const s of SENARYOLAR) { + if (s.mock) delete process.env.AI_API_KEY; // mockAktif(): anahtar yok + NODE_ENV ≠ production + let hatalar: string[]; + let ozet = ""; + try { + const r = await raporUretAyrintili(s.params, s.params.revizyon?.not, undefined); + hatalar = s.dogrula(r); + const ilk5 = r.rapor.tercihler.slice(0, 5).map((t) => { + const p = r.rapor.programlar[t.programId]; + return `${p?.il ?? "?"}/${p?.unitur === "DEVLET" ? "D" : "V"}`; + }); + ozet = `satır ${r.rapor.tercihler.length}, ilkNEklenen ${r.tani?.ilkNEklenen ?? "—"}, sahte uç atladı ${s.mock ? "—" : atlananOncelikli}, ilk 5: ${ilk5.join(" ")}`; + } catch (e) { + hatalar = [`hata fırladı: ${(e as Error).message}`]; + } + if (hatalar.length) hata++; + info(`\n${hatalar.length ? "KALDI" : "geçti"} [${s.ad}]\n ${ozet}${hatalar.map((h) => `\n ✘ ${h}`).join("")}`); + } + sunucu.close(); + info(`\n${SENARYOLAR.length - hata}/${SENARYOLAR.length} senaryo geçti (gerçek modelle koşulmadı; mekanik test)`); + process.exit(hata ? 1 : 0); +} +main().catch((e) => { + console.error(e); + process.exit(1); +});