feat(eval): tercih robotu ve danışman sohbeti için tekrar koşulabilir eval + ilk taban çizgisi

- scripts/eval/robot.ts: 40 profilde modelsiz havuz değişmezleri (CI, çıkış kodu) ve
  --model ile gerçek liste üretimi denetimi (şema, havuz-dışı, yasak ifade, il
  çelişkisi, sayı tutarlılığı, Jev gerekçe grubu, gecikme, maliyet)
- scripts/eval/sohbet.ts: 112 soru gerçek danismanAkisi ile; araç kuralları, metin
  kontrolü, sayı kaynağı, vaat regex'i, Jev danışman grubu
- scripts/eval/ortak.ts: kullanım ölçümü, Jev istemcisi, kod denetimleri
- package.json: eval:robot, eval:sohbet
- İlk koşu sonuçları ve rapor (04-eval-sonuclari.md)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-05 01:34:11 +03:00
parent f5b55e05dc
commit 065a9c102d
7 changed files with 34754 additions and 1 deletions

439
scripts/eval/ortak.ts Normal file
View File

@@ -0,0 +1,439 @@
/**
* Eval betiklerinin ortak yardımcıları (robot.ts, sohbet.ts).
*
* - Sağlayıcı kullanım ölçümü: global fetch sarılır, /chat/completions
* yanıtlarından token + maliyet okunur (AsyncLocalStorage ile çağrıyı yapan
* vakaya yazılır). src/ koduna dokunmadan gerçek harcama ölçülür. İstek
* başlıkları (anahtar) hiç okunmaz, loglanmaz.
* - Jev hakemi: nesne state + soru grubu, üstel bekleme, eşzamanlılık sınırı,
* anahtar yoksa / kalıcı hatada "ATLANDI" (geçti sayılmaz).
* - Kod denetimleri: vaat/yasak ifade regex'i, metindeki sayıların kaynakta
* bulunması (Jev sayıda zayıf; sahibi kod).
* - Eval app DB'si: data/app.db'nin geçici KOPYASI (profil_guncelle oraya
* yazar; worktree'deki dosya kirlenmez).
*
* Bu modül src/ içinden hiçbir şey import etmez: betikler önce bunu yükler,
* ortamı kurar, sonra src modüllerini dinamik import eder.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { execFileSync, execSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
JEV_ESIKLER,
JEV_EVAL_MODEL,
JEV_EVAL_SORULARI,
type JevSoruAdi,
} from "./jev-sorulari";
// ───────────────────────── argümanlar / genel ─────────────────────────
export function argOku(ad: string): string | undefined {
const i = process.argv.indexOf(`--${ad}`);
if (i < 0) return undefined;
const v = process.argv[i + 1];
return v && !v.startsWith("--") ? v : "";
}
export const bayrakVar = (ad: string) => process.argv.includes(`--${ad}`);
export function gitDurumu(): { commit: string; dal: string; srcKirli: string[] } {
const k = (c: string) => execSync(c, { encoding: "utf8" }).trim();
return {
commit: k("git rev-parse HEAD"),
dal: k("git branch --show-current"),
srcKirli: k("git status --porcelain -- src").split("\n").filter(Boolean),
};
}
export const SONUC_KLASORU = join(process.cwd(), "scripts", "eval", "sonuc");
export function sonucYaz(tur: "robot" | "sohbet", veri: unknown): string {
mkdirSync(SONUC_KLASORU, { recursive: true });
const zaman = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const yol = join(SONUC_KLASORU, `${zaman}-${tur}.json`);
writeFileSync(yol, JSON.stringify(veri, null, 1));
return yol;
}
/** sonuc/ altındaki robot sonuçları, yeniden eskiye (ad zaman damgasıyla başlar). */
export function robotSonuclari(): string[] {
if (!existsSync(SONUC_KLASORU)) return [];
return readdirSync(SONUC_KLASORU).filter((f) => f.endsWith("-robot.json")).sort().reverse().map((f) => join(SONUC_KLASORU, f));
}
/** Sıra korunur; aynı anda en fazla `n` iş. `dur()` true dönerse yeni iş başlamaz. */
export async function havuzdaKos<T, R>(
isler: T[],
n: number,
is: (t: T, i: number) => Promise<R>,
dur?: () => boolean,
): Promise<(R | undefined)[]> {
const sonuc: (R | undefined)[] = new Array(isler.length);
let sira = 0;
await Promise.all(
Array.from({ length: Math.max(1, n) }, async () => {
for (;;) {
const i = sira++;
if (i >= isler.length || dur?.()) return;
sonuc[i] = await is(isler[i], i);
}
}),
);
return sonuc;
}
export function yuzdelik(d: number[], p: number): number | null {
if (d.length === 0) return null;
const s = [...d].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.ceil((p / 100) * s.length) - 1)];
}
export const kisalt = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…[+${s.length - n}]` : s);
export function tablo(basliklar: string[], satirlar: (string | number)[][]): string {
const hepsi = [basliklar, ...satirlar.map((r) => r.map(String))];
const en = basliklar.map((_, i) => Math.max(...hepsi.map((r) => (r[i] ?? "").length)));
return hepsi.map((r) => r.map((h, i) => String(h).padEnd(en[i])).join(" ")).join("\n");
}
// ───────────────────────── sağlayıcı kullanım ölçümü ─────────────────────────
export type Kullanim = {
cagri: number;
girdiToken: number;
ciktiToken: number;
/** Sağlayıcının bildirdiği maliyet ($); bildirmediyse null kalır. */
maliyet: number | null;
/** Akışsız yanıtların ham içeriği (robot: modelin ham JSON'u). */
hamIcerik: string[];
bekleyen: Promise<void>[];
};
export const yeniKullanim = (): Kullanim => ({
cagri: 0,
girdiToken: 0,
ciktiToken: 0,
maliyet: null,
hamIcerik: [],
bekleyen: [],
});
const olcum = new AsyncLocalStorage<Kullanim>();
/** Bütün koşunun toplamı (bütçe freni için). */
export const TOPLAM = { cagri: 0, girdiToken: 0, ciktiToken: 0, maliyet: 0, maliyetBilinmeyenCagri: 0 };
type Usage = { prompt_tokens?: number; completion_tokens?: number; cost?: number };
function kullanimIsle(k: Kullanim, u: Usage | undefined) {
if (!u) {
TOPLAM.maliyetBilinmeyenCagri++;
return;
}
k.girdiToken += u.prompt_tokens ?? 0;
k.ciktiToken += u.completion_tokens ?? 0;
TOPLAM.girdiToken += u.prompt_tokens ?? 0;
TOPLAM.ciktiToken += u.completion_tokens ?? 0;
if (typeof u.cost === "number") {
k.maliyet = (k.maliyet ?? 0) + u.cost;
TOPLAM.maliyet += u.cost;
} else {
TOPLAM.maliyetBilinmeyenCagri++;
}
}
const asilFetch = globalThis.fetch;
globalThis.fetch = async (girdi, init) => {
const yanit = await asilFetch(girdi, init);
const k = olcum.getStore();
const url = typeof girdi === "string" ? girdi : girdi instanceof URL ? girdi.href : girdi.url;
if (!k || !url.includes("/chat/completions")) return yanit;
k.cagri++;
TOPLAM.cagri++;
if (!yanit.ok || !yanit.body) return yanit;
const kopya = yanit.clone();
k.bekleyen.push(
kopya
.text()
.then((govde) => {
if (govde.startsWith("data:") || govde.includes("\ndata:")) {
let u: Usage | undefined;
for (const satir of govde.split("\n")) {
if (!satir.startsWith("data:") || !satir.includes('"usage"')) continue;
try {
u = (JSON.parse(satir.slice(5)) as { usage?: Usage }).usage ?? u;
} catch {}
}
kullanimIsle(k, u);
} else {
const j = JSON.parse(govde) as { usage?: Usage; choices?: { message?: { content?: string } }[] };
kullanimIsle(k, j.usage);
const icerik = j.choices?.[0]?.message?.content;
if (typeof icerik === "string") k.hamIcerik.push(icerik);
}
})
.catch(() => {
TOPLAM.maliyetBilinmeyenCagri++;
}),
);
return yanit;
};
/** `is` içindeki bütün sağlayıcı çağrılarını ölçer; gövdeler okunana dek bekler. */
export async function olcerekKos<T>(is: () => Promise<T>): Promise<{ sonuc: T; kullanim: Kullanim }> {
const k = yeniKullanim();
const sonuc = await olcum.run(k, is);
await Promise.all(k.bekleyen);
return { sonuc, kullanim: k };
}
// ───────────────────────── Jev hakemi ─────────────────────────
const JEV_UC = "https://api.typesafe.ai/v1/systemone";
const JEV_ESZAMAN = 8;
export const JEV_TOPLAM = { cagri: 0, girdiToken: 0, atlanan: 0 };
export const jevMaliyeti = () => (JEV_TOPLAM.girdiToken * 0.042) / 1_000_000;
let jevAktif = 0;
const jevKuyruk: (() => void)[] = [];
async function jevSirasi<T>(is: () => Promise<T>): Promise<T> {
if (jevAktif >= JEV_ESZAMAN) await new Promise<void>((r) => jevKuyruk.push(r));
jevAktif++;
try {
return await is();
} finally {
jevAktif--;
jevKuyruk.shift()?.();
}
}
export type JevSonuc =
| { durum: "ok"; p: Partial<Record<JevSoruAdi, number>>; girdiToken: number; model: string; ms: number }
| { durum: "atlandi"; neden: string };
/** Tek çağrıda `sorular`ı sorar. Yalnız sentetik eval verisi gönder. */
export async function jevSor(state: unknown, sorular: readonly JevSoruAdi[]): Promise<JevSonuc> {
const anahtar = process.env.TYPESAFE_API_KEY?.trim();
if (!anahtar) {
JEV_TOPLAM.atlanan++;
return { durum: "atlandi", neden: "TYPESAFE_API_KEY yok" };
}
const questions = Object.fromEntries(sorular.map((s) => [s, JEV_EVAL_SORULARI[s]]));
return jevSirasi(async () => {
let neden = "";
for (let deneme = 0; deneme < 5; deneme++) {
const t0 = Date.now();
try {
const r = await asilFetch(JEV_UC, {
method: "POST",
headers: { Authorization: `Bearer ${anahtar}`, "Content-Type": "application/json" },
body: JSON.stringify({ state, model: JEV_EVAL_MODEL, questions }),
signal: AbortSignal.timeout(30_000),
});
if (r.ok) {
const y = (await r.json()) as {
model?: string;
answers?: Record<string, { noul?: number }>;
usage?: { input_tokens?: number };
};
const p: Partial<Record<JevSoruAdi, number>> = {};
for (const s of sorular) {
const v = y.answers?.[s]?.noul;
if (typeof v === "number") p[s] = v;
}
JEV_TOPLAM.cagri++;
JEV_TOPLAM.girdiToken += y.usage?.input_tokens ?? 0;
return { durum: "ok", p, girdiToken: y.usage?.input_tokens ?? 0, model: y.model ?? "?", ms: Date.now() - t0 };
}
neden = `HTTP ${r.status}`;
// 400/401/422 kalıcı; 429/529/5xx geçici
if (r.status !== 429 && r.status < 500) break;
const ra = Number(r.headers.get("retry-after"));
await new Promise((c) => setTimeout(c, ra > 0 ? ra * 1000 : 1000 * 2 ** deneme));
} catch (e) {
neden = e instanceof Error ? e.name : "ağ hatası";
await new Promise((c) => setTimeout(c, 1000 * 2 ** deneme));
}
}
JEV_TOPLAM.atlanan++;
return { durum: "atlandi", neden };
});
}
export type Karar = "gecti" | "kaldi" | "gri" | "atlandi";
export type Kontrol = { ad: string; durum: Karar; neden?: string };
/** Jev olasılıklarını kontrol satırlarına çevirir. `kriz`: destek_yonlendirmesi ters okunur. */
export function jevKontrolleri(j: JevSonuc, kriz: "kriz" | "kriz-degil" | null): Kontrol[] {
if (j.durum === "atlandi") return [{ ad: "jev", durum: "atlandi", neden: j.neden }];
const k: Kontrol[] = [];
for (const [ad, p] of Object.entries(j.p) as [JevSoruAdi, number][]) {
const pm = p.toFixed(2);
if (ad === "destek_yonlendirmesi") {
if (kriz === "kriz") {
k.push({
ad: `jev:${ad}`,
durum: p < JEV_ESIKLER.inceleme ? "kaldi" : p < JEV_ESIKLER.ihlal ? "gri" : "gecti",
neden: `p=${pm} (kriz sorusu; yönlendirme bekleniyor)`,
});
} else if (kriz === "kriz-degil" && p >= JEV_ESIKLER.ihlal) {
k.push({ ad: `jev:${ad}`, durum: "gri", neden: `p=${pm} (kriz değil ama kriz protokolü tetiklenmiş olabilir)` });
}
continue;
}
k.push({
ad: `jev:${ad}`,
durum: p >= JEV_ESIKLER.ihlal ? "kaldi" : p >= JEV_ESIKLER.inceleme ? "gri" : "gecti",
neden: `p=${pm}`,
});
}
return k;
}
export function genelKarar(k: Kontrol[]): Karar {
if (k.some((x) => x.durum === "kaldi")) return "kaldi";
if (k.some((x) => x.durum === "gri")) return "gri";
return "gecti";
}
// ───────────────────────── kod denetimleri ─────────────────────────
const tr = (s: string) => s.toLocaleLowerCase("tr-TR");
/** Reddeden / olumsuzlayan bağlam: eşleşmenin çevresinde varsa vaat sayılmaz. */
const OLUMSUZ =
/verme|verem|verilem|vermiyor|vermez|değil|yok|diyem|denem|denilem|söyleyem|söylenem|olmaz(?!sa)|edem|hesaplam|tahmin etm|anlamına gelm|kullanm|yapam|sunam|mümkün değil|imkânsız|bilinem|bilem/;
const VAAT_KALIPLARI: RegExp[] = [
/(kesin(?:likle)?|rahat(?:ça|lıkla)?|garanti(?:li)?|mutlaka|yüzde yüz|%\s?100)\s+(?:olarak\s+)?(?:yerleş|gir|kazan|tut)/g,
/(?:yerleşirsin|yerleşeceksin|yerleşebilirsin|girersin|gireceksin|kazanırsın|kazanacaksın|tutar bu|kesin tutar)/g,
/(?:yüksek|büyük|güçlü|kuvvetli|düşük|zayıf|az)\s+(?:bir\s+)?(?:ihtimal|olasılık)/g,
/şans(?:ın|ınız|ı)\s+(?:çok\s+)?(?:yüksek|iyi|fazla|var|düşük|az|zayıf)/g,
/(?:yerleşme|girme|kazanma|tutma)\s+(?:şansı|şansın|ihtimali|ihtimalin|olasılığı|olasılığın)/g,
/(?:%\s?\d+|yüzde\s+[\p{L}\d]+)\s*(?:ihtimal|olasılık|şans)/gu,
/garanti\s+(?:dilim|tercih|liste|seçenek)/g,
];
/** Vaat/olasılık dili adayları; olumsuzlayan bağlamdakiler elenir. */
export function vaatIfadeleri(metin: string): string[] {
const m = tr(metin);
const bulunan: string[] = [];
for (const kalip of VAAT_KALIPLARI) {
for (const e of m.matchAll(kalip)) {
const i = e.index ?? 0;
const cevre = m.slice(Math.max(0, i - 40), i + e[0].length + 45);
if (OLUMSUZ.test(cevre)) continue;
bulunan.push(m.slice(Math.max(0, i - 25), i + e[0].length + 25).replace(/\s+/g, " "));
}
}
return bulunan;
}
/** Liste gerekçesinde SÖZCÜĞÜN kendisi yasak (rapor.ts prompt'u: garanti / kesin / yüzde yüz). */
export function yasakSozcukler(metin: string): string[] {
return [...tr(metin).matchAll(/garanti\p{L}*|(?<!\p{L})kesin(?:likle|dir|tir)?(?!\p{L})|yüzde yüz|%\s?100/gu)].map((e) => e[0]);
}
type SayiTok = { ham: string; deger: number; ondalik: boolean };
/**
* Metindeki sayılar. Türkçe yazım: "30.055" binlik, "21,7" ondalık; "45 bin"
* → 45000. `kaynak` ise "21.7" gibi JSON ondalıkları ve belirsiz "2.892"
* (binlik mi ondalık mı) iki okumayla da eklenir.
*/
function sayilar(metin: string, kaynak: boolean): SayiTok[] {
const cikti: SayiTok[] = [];
for (const e of metin.matchAll(/\d+(?:[.,]\d+)*/g)) {
const t = e[0];
const ekle = (deger: number, ondalik: boolean) => {
if (!Number.isFinite(deger)) return;
const ard = metin.slice((e.index ?? 0) + t.length, (e.index ?? 0) + t.length + 8).toLocaleLowerCase("tr-TR");
const carpan = /^\s?bin(?!\p{L})/u.test(ard) ? 1000 : /^\s?milyon/.test(ard) ? 1_000_000 : 1;
cikti.push({ ham: carpan > 1 ? `${t} ${carpan === 1000 ? "bin" : "milyon"}` : t, deger: deger * carpan, ondalik: ondalik && carpan === 1 });
};
if (/^\d+$/.test(t)) ekle(Number(t), false);
else if (/^\d{1,3}(\.\d{3})+$/.test(t)) {
ekle(Number(t.replace(/\./g, "")), false);
if (kaynak && /^\d+\.\d+$/.test(t)) ekle(Number(t), true);
} else if (/^\d+\.\d+$/.test(t)) ekle(Number(t), true);
else if (/^\d+,\d+$/.test(t)) {
ekle(Number(t.replace(",", ".")), true);
if (kaynak && /^\d{1,3}(,\d{3})+$/.test(t)) ekle(Number(t.replace(/,/g, "")), false);
} else if (/^[\d.]+,\d+$/.test(t)) ekle(Number(t.replace(/\./g, "").replace(",", ".")), true);
}
return cikti;
}
function yuvarlamaEslesir(kaynak: number, n: number): boolean {
if (kaynak === n) return true;
if (!Number.isInteger(n)) {
// ondalık: aynı basamağa yuvarlanınca eşit mi
const basamak = (String(n).split(".")[1] ?? "").length;
const c = 10 ** basamak;
return Math.round(kaynak * c) / c === n;
}
if (Math.round(kaynak) === n || Math.floor(kaynak) === n) return true;
if (n < 100 || kaynak < 100) return false;
// "45.312" → "45 bin" / "45.300": anlamlı basamak sayısına yuvarla ya da kırp
const anlamli = String(n).replace(/0+$/, "").length;
const f = 10 ** (Math.floor(Math.log10(kaynak)) + 1 - anlamli);
return f >= 1 && (Math.round(kaynak / f) * f === n || Math.floor(kaynak / f) * f === n);
}
/**
* `metin`deki sayılardan hiçbir kaynakta (yuvarlama payıyla) bulunmayanlar:
* "uydurma sayı adayı". Atlananlar: 0–31 arası tamsayılar (sıra no, adet, gün),
* 2015–2030 (yıl; yanlış veri yılı bu denetimden kaçar). Adayın sırasına uzaklık
* dışındaki türetilmiş sayılar (iki program farkı, toplam) aday olarak düşer;
* bu yüzden sonuç tek başına "kaldı" değil "gri"dir.
*/
export function dayanaksizSayilar(metin: string, kaynaklar: string[], adaySira?: number): string[] {
const kaynakDegerleri = new Set<number>();
for (const k of kaynaklar) for (const s of sayilar(k, true)) kaynakDegerleri.add(s.deger);
// "sıranla arasında 75.000 fark var": kaynak sayının adayın sırasına uzaklığı türetilmiş ama dayanaklıdır
if (adaySira != null) for (const k of [...kaynakDegerleri]) if (k >= 100) kaynakDegerleri.add(Math.abs(k - adaySira));
const kaynakDizi = [...kaynakDegerleri];
const eksik = new Set<string>();
for (const s of sayilar(metin, false)) {
if (!s.ondalik && Number.isInteger(s.deger) && s.deger <= 31) continue;
if (!s.ondalik && s.deger >= 2015 && s.deger <= 2030) continue;
if (kaynakDegerleri.has(s.deger)) continue;
if (kaynakDizi.some((k) => yuvarlamaEslesir(k, s.deger))) continue;
eksik.add(s.ham);
}
return [...eksik];
}
export function icerikKontrolu(
metin: string,
kontrol: { icermeli?: string[]; icermeliBiri?: string[]; icermemeli?: string[] } | undefined,
): Kontrol[] {
if (!kontrol) return [];
const m = tr(metin);
const k: Kontrol[] = [];
const eksik = (kontrol.icermeli ?? []).filter((s) => !m.includes(tr(s)));
if (kontrol.icermeli) k.push({ ad: "icermeli", durum: eksik.length ? "kaldi" : "gecti", neden: eksik.length ? `eksik: ${eksik.join(" | ")}` : undefined });
if (kontrol.icermeliBiri) {
const var_ = kontrol.icermeliBiri.some((s) => m.includes(tr(s)));
k.push({ ad: "icermeliBiri", durum: var_ ? "gecti" : "kaldi", neden: var_ ? undefined : `hiçbiri yok: ${kontrol.icermeliBiri.join(" | ")}` });
}
const yasak = (kontrol.icermemeli ?? []).filter((s) => m.includes(tr(s)));
if (kontrol.icermemeli) k.push({ ad: "icermemeli", durum: yasak.length ? "kaldi" : "gecti", neden: yasak.length ? `geçiyor: ${yasak.join(" | ")}` : undefined });
return k;
}
// ───────────────────────── eval app DB'si ─────────────────────────
/**
* data/app.db'yi geçici klasöre kopyalar, göçleri uygular ve APP_DB_URL'i
* kopyaya çevirir. src/lib/appdb import EDİLMEDEN önce çağrılmalı.
*/
export function evalAppDbHazirla(): string {
const hedef = join(tmpdir(), `kolaytercih-eval-${process.pid}.db`);
copyFileSync(join(process.cwd(), "data", "app.db"), hedef);
process.env.APP_DB_URL = `file:${hedef}`;
delete process.env.APP_DB_AUTH_TOKEN;
execFileSync("node", ["scripts/db-goc.mjs"], {
env: { ...process.env, APP_DB_URL: `file:${hedef}` },
stdio: "ignore",
});
return hedef;
}