diff --git a/scripts/eval/akis-denetim.ts b/scripts/eval/akis-denetim.ts new file mode 100644 index 0000000..008f675 --- /dev/null +++ b/scripts/eval/akis-denetim.ts @@ -0,0 +1,105 @@ +/** + * Danışman akışındaki sunucu denetimlerinin MEKANİK testi — parasız, anahtarsız. + * + * pnpm tsx --conditions=react-server scripts/eval/akis-denetim.ts + * + * Yerelde sahte bir OpenAI-uyumlu uç açılır; "model" senaryodaki metni ya da + * araç çağrılarını aynen döner. Gerçek danismanAkisi + gerçek araçlar koşar + * (profil_guncelle data/app.db'nin geçici kopyasına yazar). Doğrulananlar: + * vaat regex'i notu (Jev anahtarı yokken), "kaydettim" deyip kaydetmeme notu, + * araç çağrısı kotası. Model davranışını ÖLÇMEZ; onu eval:sohbet ölçer. + * Beklenti tutmazsa çıkış kodu 1. + */ +import { createServer } from "node:http"; +import { copyFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; + +type Beklenen = { vaatNotu: boolean; kayitNotu: boolean; reddedilenArac: number }; +type Senaryo = { ad: string; beklenen: Beklenen; adimlar: ({ metin: string } | { araclar: { name: string; arguments: string }[] })[] }; +const B = (vaatNotu: boolean, kayitNotu: boolean, reddedilenArac = 0): Beklenen => ({ vaatNotu, kayitNotu, reddedilenArac }); +const SENARYOLAR: Senaryo[] = [ + { ad: "kaydettim-aracsiz", beklenen: B(false, true), adimlar: [{ metin: "Kaydettim: Vakıfta yıllık ücret üst sınırı 400.000 TL. Aramalar buna göre süzülecek." }] }, + { ad: "kaydettim-aracli", beklenen: B(false, false), adimlar: [{ araclar: [{ name: "profil_guncelle", arguments: JSON.stringify({ alan: "ucretUst", deger: 400000 }) }] }, { metin: "Kaydettim: yıllık ücret en fazla 400.000 TL." }] }, + { ad: "kaydettim-arac-hata", beklenen: B(false, true), adimlar: [{ araclar: [{ name: "profil_guncelle", arguments: JSON.stringify({ alan: "maxKm", deger: 30 }) }] }, { metin: "Kaydettim: evden en fazla 30 km." }] }, + { ad: "vaat", beklenen: B(true, false), adimlar: [{ metin: "Bu programa yerleşme olasılığın düşük ama yine de yazabilirsin." }] }, + { ad: "temiz", beklenen: B(false, false), adimlar: [{ metin: "Bu program son yerleştirmede 178.278. sırada kapandı; etiketi Riskli. Garanti verilemez." }] }, + { ad: "kota", beklenen: B(false, false, 2), adimlar: [{ araclar: Array.from({ length: 6 }, (_, i) => ({ name: "il_ozeti", arguments: JSON.stringify({ il: ["İzmir", "Ankara", "Bursa", "Konya", "Adana", "Samsun"][i] }) })) }, { metin: "İllere baktım." }] }, +]; +let aktif: Senaryo = SENARYOLAR[0]; +let adimNo = 0; +const sunucu = createServer((req, res) => { + let govde = ""; + req.on("data", (c) => (govde += c)); + req.on("end", () => { + const adim = aktif.adimlar[Math.min(adimNo++, aktif.adimlar.length - 1)]; + res.writeHead(200, { "Content-Type": "text/event-stream" }); + const yaz = (delta: unknown, finish: string | null = null) => + res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", created: 1, model: "sahte", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`); + if ("metin" in adim) { + yaz({ role: "assistant", content: adim.metin }); + yaz({}, "stop"); + } else { + yaz({ role: "assistant", content: null, tool_calls: adim.araclar.map((a, i) => ({ index: i, id: `c${adimNo}_${i}`, type: "function", function: a })) }); + yaz({}, "tool_calls"); + } + res.write("data: [DONE]\n\n"); + res.end(); + }); +}); + +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.TYPESAFE_API_KEY; // Jev'e gitme: yalnız regex yolu + const db = `${process.env.TMPDIR ?? "/tmp"}/kolaytercih-akis-dene-${process.pid}.db`; + copyFileSync("data/app.db", db); + process.env.APP_DB_URL = `file:${db}`; + execFileSync("node", ["scripts/db-goc.mjs"], { env: { ...process.env }, stdio: "ignore" }); + const Database = (await import("better-sqlite3")).default; + const sqlite = new Database(db); + const { danismanAkisi } = await import("@/lib/ai/sohbet-akisi"); + const info = console.info; + console.info = () => {}; + let hata = 0; + for (const s of SENARYOLAR) { + aktif = s; + adimNo = 0; + const userId = `akis-dene-${s.ad}`; + sqlite.prepare("INSERT OR REPLACE INTO user (id, name, email, email_verified, credit_balance, has_paket, created_at, updated_at) VALUES (?, 'Dene', ?, 1, 0, 1, 1, 1)").run(userId, `${userId}@eval.invalid`); + let tam: string | null = null; + let denetim: Beklenen | null = null; + const araclar: string[] = []; + const okuyucu = danismanAkisi({ + system: "test", + mesajlar: [{ role: "user", content: "test" }], + aracBaglami: { userId, sira: 100000, tur: "say", profil: {} }, + onTamam: async (m) => { + tam = m; + }, + onYarim: async () => {}, + onDenetim: (d) => { + denetim = d; + }, + }).getReader(); + for (;;) { + const { done, value } = await okuyucu.read(); + if (done) break; + const v = value as { type: string; output?: unknown }; + if (v.type === "tool-output-available") araclar.push(JSON.stringify(v.output).slice(0, 70)); + } + const d = denetim as Beklenen | null; + const tuttu = d != null && d.vaatNotu === s.beklenen.vaatNotu && d.kayitNotu === s.beklenen.kayitNotu && d.reddedilenArac === s.beklenen.reddedilenArac; + if (!tuttu) hata++; + info(`\n${tuttu ? "geçti" : "KALDI"} [${s.ad}]\n denetim: ${JSON.stringify(denetim)}\n araç çıktıları: ${araclar.length} → ${araclar.join(" | ")}\n son metin: ${String(tam).replace(/\n+/g, " ⏎ ")}`); + } + sqlite.close(); + 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); +}); diff --git a/scripts/eval/sohbet.ts b/scripts/eval/sohbet.ts index 265fa04..656f7cb 100644 --- a/scripts/eval/sohbet.ts +++ b/scripts/eval/sohbet.ts @@ -64,7 +64,7 @@ async function main() { const { listeOzetiCikar } = await import("@/lib/ai/rapor"); const { sohbetModeli, saglayiciAdi, anahtarVar } = await import("@/lib/ai/client"); const { JEV_DUZELTME_NOTU } = await import("@/lib/ai/jev"); - const { vaatDiliBul } = await import("@/lib/ai/cikti-denetimi"); + const { vaatDiliBul, KAYIT_DUZELTME_NOTU } = await import("@/lib/ai/cikti-denetimi"); const { havuzOlustur, dilimHedefiHesapla } = await import("@/lib/rapor-havuzu"); const { PUAN_TURLERI } = await import("@/types/yokatlas"); const { SON_YIL } = await import("@/lib/veri-yillari"); @@ -222,7 +222,9 @@ async function main() { const hamMetin: string = tamMetin ?? yarimMetin ?? ""; const canliJevNotu = hamMetin.includes(JEV_DUZELTME_NOTU); - const metin = hamMetin.replace(JEV_DUZELTME_NOTU, "").trim(); + // Sunucunun eklediği notlar modelin cevabı değildir: ayrılır, ayrıca kaydedilir + const canliKayitNotu = hamMetin.includes(KAYIT_DUZELTME_NOTU); + const metin = hamMetin.replace(JEV_DUZELTME_NOTU, "").replace(KAYIT_DUZELTME_NOTU, "").trim(); const m = metin.toLocaleLowerCase("tr-TR"); const cagrilan = araclar.map((a) => a.ad); const kontroller: Kontrol[] = []; @@ -247,7 +249,7 @@ async function main() { // 3) "kaydettim" deyip kaydetmeme (H4): başarılı profil_guncelle çıktısı yoksa const basariliKayit = araclar.filter((a) => a.ad === "profil_guncelle" && a.cikti != null && typeof a.cikti === "object" && "kaydedildi" in (a.cikti as object)).length; if (/kaydettim|kaydedildi|profiline ekledim|profilini güncelledim/.test(m)) { - kontroller.push({ ad: "kaydettim-ama-yazmadi", durum: basariliKayit ? "gecti" : "kaldi", neden: basariliKayit ? undefined : "cevap kayıt bildiriyor, başarılı profil_guncelle çağrısı yok" }); + kontroller.push({ ad: "kaydettim-ama-yazmadi", durum: basariliKayit ? "gecti" : "kaldi", neden: basariliKayit ? undefined : `cevap kayıt bildiriyor, başarılı profil_guncelle çağrısı yok (sunucu düzeltme notu: ${canliKayitNotu ? "eklendi" : "EKLENMEDİ"})` }); } // 4) vaat regex'i @@ -317,6 +319,7 @@ async function main() { adim, cevap: metin, canliJevNotu, + canliKayitNotu, canliDenetim: canliDenetim as CanliDenetim | null, kayitliProfil, jev: jev?.durum === "ok" ? jev.p : jev ? { atlandi: jev.neden } : null, @@ -378,6 +381,8 @@ async function main() { ...oran(sonuclar), atlananSoru: isler.length - sonuclar.length, canliJevNotuEklenen: sonuclar.filter((x) => x.canliJevNotu).length, + canliKayitNotuEklenen: sonuclar.filter((x) => x.canliKayitNotu).length, + aracSiniriReddi: sonuclar.reduce((a, x) => a + (x.canliDenetim?.reddedilenArac ?? 0), 0), ortalamaAdim: sonuclar.length ? +(sonuclar.reduce((a, x) => a + x.adim, 0) / sonuclar.length).toFixed(2) : 0, ortalamaAracCagrisi: sonuclar.length ? +(sonuclar.reduce((a, x) => a + x.araclar.length, 0) / sonuclar.length).toFixed(2) : 0, enCokAracCagrisi: Math.max(0, ...sonuclar.map((x) => x.araclar.length)), diff --git a/src/lib/ai/cikti-denetimi.ts b/src/lib/ai/cikti-denetimi.ts index 329ea81..02514d4 100644 --- a/src/lib/ai/cikti-denetimi.ts +++ b/src/lib/ai/cikti-denetimi.ts @@ -75,3 +75,26 @@ export function vaatDiliBul(metin: string): string[] { } return bulunan; } + +/** + * Cevap "kaydettim" türü bir kayıt beyanı içeriyor mu. Kayıt yalnız + * profil_guncelle aracıyla olur; model aracı çağırmadan "Kaydettim" diyebiliyor + * (QA H4, 5 Eki 2026). Olumsuzlar ("kaydetmedim", "kaydedemedim") ve soru + * ("kaydedeyim mi?") eşleşmez. + */ +const KAYIT_BEYANI = + /(? { + // Başarılı kayıt = profil_guncelle "kaydedildi" döndü (hata dönerse sayılmaz) + if (ad === "profil_guncelle" && cikti != null && typeof cikti === "object" && "kaydedildi" in cikti) { + basariliKayit++; + } + }, ), // En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap // vermek zorunda). @@ -152,8 +161,22 @@ export function danismanAkisi(opts: { writer.write({ type: "text-delta", id, delta: JEV_DUZELTME_NOTU }); writer.write({ type: "text-end", id }); } + // Kayıt beyanı denetimi (QA H4): cevap "kaydettim" diyor ama bu turda + // başarılı profil_guncelle yok → düzeltme notu. Kayıt kararı modelin + // sözüne bırakılmaz; gerçek araç sonucuyla karşılaştırılır. Kredi: + // bu cevap için değişmez; notun sorduğu "kaydedeyim mi?"ye verilen + // "evet" bedelsizdir. + const kayitNotu = basariliKayit === 0 && kayitBeyaniVar(metin); + if (kayitNotu) { + console.info("[soru] kayıt beyanı var, başarılı profil_guncelle yok → not eklendi"); + const id = crypto.randomUUID(); + metin += `\n\n${KAYIT_DUZELTME_NOTU}`; + writer.write({ type: "text-start", id }); + writer.write({ type: "text-delta", id, delta: KAYIT_DUZELTME_NOTU }); + writer.write({ type: "text-end", id }); + } if (reddedilenArac) console.info("[soru] araç sınırı:", reddedilenArac, "çağrı çalıştırılmadı"); - opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac }); + opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu }); await opts.onTamam(metin); tamamlandi = true; writer.write({ type: "finish" });