fix(danisman): 'Kaydettim' deyip kaydetmeme — sunucu tarafı denetim + prompt kuralı (iş 4; QA H4) [KARAR]

- sohbet-akisi.ts: cevapta kayıt beyanı (kaydettim / profiline ekledim / not aldım…) var ama o turda
  başarılı profil_guncelle ('kaydedildi') yoksa cevabın sonuna düzeltme notu eklenir. Kayıt kararı
  modelin sözüne bırakılmaz; gerçek araç sonucuyla karşılaştırılır. Hata dönen çağrı sayılmaz.
- Kredi: bu cevabın kredisi değişmez (iade yok, ek model çağrısı yok). Not 'profiline kaydedeyim mi'
  ibaresini taşır; adayın 'evet'i mevcut bedelsiz onay yolundan geçer — sistemin hatasını düzeltmek
  adaya kredi harcatmaz.
- Prompt: KAYIT DÜRÜSTLÜĞÜ kuralı (araç 'kaydedildi' dönmediyse 'kaydettim' deme).
- scripts/eval/akis-denetim.ts: sahte yerel model ucuyla parasız mekanik test (6 senaryo: aracsız
  'kaydettim', araçlı, araç hatası, vaat regex notu, temiz, araç kotası) — 6/6 geçti.
  Gerçek modelle bu yol henüz tetiklenmedi; model davranışı eval:sohbet ile ölçülür.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-05 08:37:33 +03:00
parent ebc4aa4205
commit f4a5e23c51
5 changed files with 167 additions and 5 deletions

View File

@@ -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<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.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);
});

View File

@@ -64,7 +64,7 @@ async function main() {
const { listeOzetiCikar } = await import("@/lib/ai/rapor"); const { listeOzetiCikar } = await import("@/lib/ai/rapor");
const { sohbetModeli, saglayiciAdi, anahtarVar } = await import("@/lib/ai/client"); const { sohbetModeli, saglayiciAdi, anahtarVar } = await import("@/lib/ai/client");
const { JEV_DUZELTME_NOTU } = await import("@/lib/ai/jev"); 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 { havuzOlustur, dilimHedefiHesapla } = await import("@/lib/rapor-havuzu");
const { PUAN_TURLERI } = await import("@/types/yokatlas"); const { PUAN_TURLERI } = await import("@/types/yokatlas");
const { SON_YIL } = await import("@/lib/veri-yillari"); const { SON_YIL } = await import("@/lib/veri-yillari");
@@ -222,7 +222,9 @@ async function main() {
const hamMetin: string = tamMetin ?? yarimMetin ?? ""; const hamMetin: string = tamMetin ?? yarimMetin ?? "";
const canliJevNotu = hamMetin.includes(JEV_DUZELTME_NOTU); 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 m = metin.toLocaleLowerCase("tr-TR");
const cagrilan = araclar.map((a) => a.ad); const cagrilan = araclar.map((a) => a.ad);
const kontroller: Kontrol[] = []; const kontroller: Kontrol[] = [];
@@ -247,7 +249,7 @@ async function main() {
// 3) "kaydettim" deyip kaydetmeme (H4): başarılı profil_guncelle çıktısı yoksa // 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; 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)) { 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 // 4) vaat regex'i
@@ -317,6 +319,7 @@ async function main() {
adim, adim,
cevap: metin, cevap: metin,
canliJevNotu, canliJevNotu,
canliKayitNotu,
canliDenetim: canliDenetim as CanliDenetim | null, canliDenetim: canliDenetim as CanliDenetim | null,
kayitliProfil, kayitliProfil,
jev: jev?.durum === "ok" ? jev.p : jev ? { atlandi: jev.neden } : null, jev: jev?.durum === "ok" ? jev.p : jev ? { atlandi: jev.neden } : null,
@@ -378,6 +381,8 @@ async function main() {
...oran(sonuclar), ...oran(sonuclar),
atlananSoru: isler.length - sonuclar.length, atlananSoru: isler.length - sonuclar.length,
canliJevNotuEklenen: sonuclar.filter((x) => x.canliJevNotu).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, 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, 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)), enCokAracCagrisi: Math.max(0, ...sonuclar.map((x) => x.araclar.length)),

View File

@@ -75,3 +75,26 @@ export function vaatDiliBul(metin: string): string[] {
} }
return bulunan; 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 =
/(?<!\p{L})(?:kaydettim|kaydedildi|kaydını yaptım|profiline (?:ekledim|yazdım|işledim)|profilini güncelledim|profilin güncellendi|not (?:aldım|ettim))(?!\p{L})/u;
export function kayitBeyaniVar(metin: string): boolean {
return KAYIT_BEYANI.test(tr(metin));
}
/**
* Cevap kayıt bildiriyor ama o turda başarılı profil_guncelle yoksa cevabın
* sonuna eklenir. "Profiline kaydedeyim mi" ibaresi bilerek var: adayın buna
* vereceği "evet" bedelsiz onay yolundan geçer (sohbet-gecmisi.onayMesajiMi) —
* sistemin hatasını düzeltmek adaya kredi harcatmaz. Metin, model "daha önce
* kaydettim" dediğinde de doğrudur (bu cevapta yeni kayıt yok).
*/
export const KAYIT_DUZELTME_NOTU =
"Düzeltme: Bu cevapta profiline yeni bir kayıt YAPILMADI; yukarıda \"kaydettim\" dediysem henüz kaydedemedim. Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz.)";

View File

@@ -166,6 +166,12 @@ KURALLAR:
Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.` Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.`
: `profil_guncelle ile kaydet ve cevabının sonunda tek cümleyle söyle: : `profil_guncelle ile kaydet ve cevabının sonunda tek cümleyle söyle:
"Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`} "Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`}
KAYIT DÜRÜSTLÜĞÜ: kayıt YALNIZ profil_guncelle aracıyla olur. "Kaydettim",
"profiline ekledim", "not aldım" gibi bir cümleyi ancak BU cevapta
profil_guncelle'yi çağırdıysan ve sonuç "kaydedildi" döndüyse yaz. Aracı
çağırmadıysan ya da hata döndüyse "kaydettim" DEME; "kaydedemedim" de ve
nedenini söyle (ör. en az 50 km girilebilir, il tanınmadı). İki tercih
birden söylendiyse her biri için ayrı profil_guncelle çağır.
Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma: Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma:
sağlık/engel, din, aile durumu, gelir, cinsiyet, siyasi görüş. Aday sağlık/engel, din, aile durumu, gelir, cinsiyet, siyasi görüş. Aday
bunları kendiliğinden yazsa bile profile YAZMA; yalnız cevabında dikkate al. bunları kendiliğinden yazsa bile profile YAZMA; yalnız cevabında dikkate al.

View File

@@ -24,7 +24,7 @@ import {
} from "ai"; } from "ai";
import { aracSeti, type AracBaglami } from "./araclar"; import { aracSeti, type AracBaglami } from "./araclar";
import { sohbetDilModeli } from "./client"; import { sohbetDilModeli } from "./client";
import { vaatDiliBul } from "./cikti-denetimi"; import { KAYIT_DUZELTME_NOTU, kayitBeyaniVar, vaatDiliBul } from "./cikti-denetimi";
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev"; import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */ /** Bir cevapta en fazla model adımı (araç turları + son cevap). */
@@ -49,6 +49,8 @@ export type CiktiDenetimi = {
vaatNotu: boolean; vaatNotu: boolean;
/** Sınır aşıldığı için çalıştırılmayan araç çağrısı sayısı. */ /** Sınır aşıldığı için çalıştırılmayan araç çağrısı sayısı. */
reddedilenArac: number; reddedilenArac: number;
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
kayitNotu: boolean;
}; };
export function danismanAkisi(opts: { export function danismanAkisi(opts: {
@@ -76,6 +78,7 @@ export function danismanAkisi(opts: {
let adimdakiArac = 0; let adimdakiArac = 0;
let toplamArac = 0; let toplamArac = 0;
let reddedilenArac = 0; let reddedilenArac = 0;
let basariliKayit = 0;
try { try {
const result = streamText({ const result = streamText({
model: sohbetDilModeli(), model: sohbetDilModeli(),
@@ -91,6 +94,12 @@ export function danismanAkisi(opts: {
reddedilenArac++; reddedilenArac++;
return `Araç çağrısı sınırı aşıldı (bir adımda en fazla ${ADIM_BASINA_ARAC}, bir cevapta en fazla ${CEVAP_BASINA_ARAC}). Bu çağrı ÇALIŞTIRILMADI. Yeni araç çağırma; elindeki sonuçlarla cevap ver, eksik kalan kısmı adaya açıkça söyle ve sorusunu daraltmasını iste. Çalışmayan çağrı için sayı uydurma.`; return `Araç çağrısı sınırı aşıldı (bir adımda en fazla ${ADIM_BASINA_ARAC}, bir cevapta en fazla ${CEVAP_BASINA_ARAC}). Bu çağrı ÇALIŞTIRILMADI. Yeni araç çağırma; elindeki sonuçlarla cevap ver, eksik kalan kısmı adaya açıkça söyle ve sorusunu daraltmasını iste. Çalışmayan çağrı için sayı uydurma.`;
}, },
(ad, cikti) => {
// 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 // En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
// vermek zorunda). // vermek zorunda).
@@ -152,8 +161,22 @@ export function danismanAkisi(opts: {
writer.write({ type: "text-delta", id, delta: JEV_DUZELTME_NOTU }); writer.write({ type: "text-delta", id, delta: JEV_DUZELTME_NOTU });
writer.write({ type: "text-end", id }); 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ı"); 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); await opts.onTamam(metin);
tamamlandi = true; tamamlandi = true;
writer.write({ type: "finish" }); writer.write({ type: "finish" });