Files
kolaytercih/scripts/eval/revizyon-denetim.ts
bilalgursen 91e285900e fix(havuz): "ilk N" ölçütü yalnız ilk N satırı belirler; öncelikliye kırpmada N+pay kota
"İlk 5 İstanbul" isteğinde öncelikliler dilim içinde sınırsız öne alınınca
havuz 60/60 İstanbul oluyor, 24 satırın hepsi İstanbul'a kayıyordu. Kırpma
artık öncelikliye yalnız adet+2 yer ayırır (dilimlere sırayla), kalan kota
sihirbaz süzgecine uyan adaylara gider. revizyon-denetim ilk5-istanbul
senaryosu kalan 19'un çoğunluğunu sihirbaz illerinden bekliyor; 5/5 geçti.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 23:27:13 +03:00

224 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, { minItems?: number }> } } };
};
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<TelListe, { id: string; oncelikli?: boolean }[]>;
const ozellik = istek.response_format.json_schema.schema.properties;
const cikti: Record<string, unknown> = {
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<ReturnType<typeof import("@/lib/ai/rapor").raporUretAyrintili>>;
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<RaporParams["revizyon"]>, 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}`];
}
/** İlk N'den sonraki satırların çoğunluğu sihirbaz illerinden mi ("ilk N" listenin kalanını kaydırmaz). */
function kalanSihirbazdan(r: Rapor, adet: number, iller: string[]): string[] {
const kalan = r.rapor.tercihler.slice(adet);
const sihirbaz = kalan.filter((t) => iller.map(tr).includes(tr(r.rapor.programlar[t.programId]?.il))).length;
return sihirbaz * 2 > kalan.length ? [] : [`kalan ${kalan.length} satırın yalnız ${sihirbaz}'i sihirbaz illerinden (${iller.join("/")})`];
}
const SENARYOLAR: Senaryo[] = [
{
// Bilal'in 7 Eki denemesi. İstanbul her dilimde 20+ programla havuzu tek
// başına doldurabilirdi (eskiden 60/60 İstanbul → 24/24 İstanbul); şimdi
// kırpma öncelikliye yalnız N + pay yer ayırır (rapor-havuzu.ts
// oncelikliyeKotaAyir), kalan havuz Ankara/İzmir'den kurulur. Sahte uç
// İstanbul'u atlar, kod 5'ini takaslar; kalan 19'un çoğunluğu sihirbaz
// illerinden olmalı ("ilk N yalnız ilk N'yi belirler" ürün kararı).
ad: "ilk5-istanbul",
params: taban({ ilkTercihler: { adet: 5, iller: ["İSTANBUL"] }, not: "ilk 5 İstanbul" }),
dogrula: (r) => [...ilkNIl(r, 5, "İSTANBUL"), ...eklenenEsit(r, 5), ...kalanSihirbazdan(r, 5, ["ANKARA", "İZMİR"])],
},
{
// 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<void>((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);
});