test(revizyon): mekanik ilk-N denetimi (sahte model önceliği atlar)
pnpm tsx --conditions=react-server scripts/eval/revizyon-denetim.ts Sahte OpenAI ucu json_schema adetlerini ve havuz JSON'unu okuyup her listeyi kasten "oncelikli" olmayan id'lerle doldurur. Beş senaryo: ilk5-istanbul (Bilal'in denemesi; havuz kırpması İstanbul'u 60/60 doldurduğu için takas gerekmez), ilk5-takas (Yalova, 5 takas), ilk3-vakif (Kayseri, 3 takas), havuz-yetersiz (Ardahan, hata yok), mock-yolu (anahtarsız dev mock). Model davranışını ölçmez; onu eval:sohbet ölçer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
214
scripts/eval/revizyon-denetim.ts
Normal file
214
scripts/eval/revizyon-denetim.ts
Normal file
@@ -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<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}`];
|
||||
}
|
||||
|
||||
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<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);
|
||||
});
|
||||
Reference in New Issue
Block a user