fix(danisman): revizyon aracı atlanmasın — geçmiş araç kaydına indirgenir, beyan regex'i genişledi, soru formu Türkçe karakter (QA B1, B4)

Kök neden (B1): chat_messages yalnız metin tutar; başarılı revizyonun kaydı
("revize ediyorum… Liste yenilendi… 1-5") araçsız cevapla aynı görünüyordu ve
model sonraki turda bu kalıbı taklit edip liste_revize çağırmadan "yenilendi"
yazıyor, program adlarını eski cevaptan kopyalıyordu (eval: 8 revizyon
isteğinde 1 çağrı, 5 sahte beyan). Metnin altına "[araç çağrıldı]" işareti
eklemek işe yaramadı — model işareti de kopyaladı.

- sohbet-gecmisi.kayitliMesajModelMetni: modele giden pencerede başarılı
  revizyon turu kısa araç kaydına iner (meta.revizyon'dan), araçsız
  "yenilendi" diyen tur (düzeltme notu olmayan eski kayıtlar dâhil) uydurma
  listesiyle birlikte düşer; kayıt ve ekran değişmez. route.ts bunu kullanır.
- revizyonNiyetiVar + sohbet-akisi.ilkAdimAracZorunlu: niyet algılanınca ilk
  adımda tool_choice "required". VARSAYILAN KAPALI
  (SOHBET_REVIZYON_ARAC_ZORUNLU=1): ölçümde 7/8 çağrı, 0 sahte beyan verdi ama
  OpenRouter isteği Baidu yerine OpenInference'a yönlendirdi, gecikme 1-7 sn →
  80-112 sn, onlarca paralel araç çağrısı. Açmak Bilal'in kararı.
- danisman-prompt revizyon bölümü: 5 (geçmiş örnek değildir; önce aracı çağır)
  ve 6 (soru formunda Türkçe karakter).
- cikti-denetimi REVIZE_BEYANI: "değişiklikler yapıldı", "artık … tercihin …
  oluşuyor", "listeyi yeniden kurdum", "liste_revize çağrıldı" kalıpları.
- araclar.ts (B4): soru_sor açıklamasına Türkçe karakter kuralı; formdaki
  ASCII'ye düşmüş il adları koddan düzelir (ilAdlariniDuzelt: Istanbul →
  İstanbul, Eskisehir → Eskişehir; yalnız büyük harfle başlayan tam eşleşme).
- scripts/eval/revizyon-arac.ts (pnpm eval:revizyon): gerçek modelle 10
  senaryo, liste_revize çağrı oranı + sahte beyan; liste_revize çalıştırılmaz
  (mesajId yok). Sonuç dosyaları scripts/eval/sonuc/*-revizyon.json.

Kredi: değişmez. akis-denetim 9/9, revizyon-denetim 5/5, tsc ve lint temiz.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-09 00:16:35 +03:00
parent f955a76c1e
commit 128c175327
13 changed files with 1067 additions and 16 deletions

View File

@@ -420,7 +420,7 @@ export const ARACLAR: AracTanimi[] = [
function: {
name: "soru_sor",
description:
"Adaya ekranda tek tek gösterilen kısa bir SORU FORMU açar (en çok 3 soru, soru başına 2-6 seçenek). Liste revizyonu isteği belirsizken kullan: hangi il, kaç tercih, ekle mi yalnız mı, hangi dilim. Her soruya net, kısa seçenekler yaz; gerekirse serbest metin alanı ekle (serbest). Form gösterildikten sonra BAŞKA ARAÇ ÇAĞIRMA ve cevabını tek kısa cümleyle bitir; adayın cevabı sonraki mesajda 'Form cevaplarım' olarak gelir, o zaman liste_revize çağır.",
"Adaya ekranda tek tek gösterilen kısa bir SORU FORMU açar (en çok 3 soru, soru başına 2-6 seçenek). Liste revizyonu isteği belirsizken kullan: hangi il, kaç tercih, ekle mi yalnız mı, hangi dilim. Her soruya net, kısa seçenekler yaz; gerekirse serbest metin alanı ekle (serbest). Form gösterildikten sonra BAŞKA ARAÇ ÇAĞIRMA ve cevabını tek kısa cümleyle bitir; adayın cevabı sonraki mesajda 'Form cevaplarım' olarak gelir, o zaman liste_revize çağır. Soru ve seçenek metinleri Türkçe karakterlerle yazılır (ş, ı, ğ, ö, ü, ç, İ: 'Vakıf', 'kalsın', 'İstanbul'); ASCII yazım ('Vakif', 'kalsin', 'Istanbul') yanlıştır.",
parameters: {
type: "object",
properties: {
@@ -1609,13 +1609,54 @@ function alanHatasi(ad: string, s: AlanSemasi, v: unknown): string | null {
// ── Sohbetten liste revizyonu ────────────────────────────────────────────
/** İl adını başlığa uygun yazım: "İSTANBUL" → "İstanbul", "AFYONKARAHİSAR" → "Afyonkarahisar". */
function ilBaslikYazimi(il: string): string {
const k = il.toLocaleLowerCase("tr-TR");
return k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1);
}
/**
* Metindeki ASCII'ye düşmüş il adlarını düzeltir: "Istanbul" → "İstanbul",
* "Eskisehir" → "Eskişehir", "Izmir/Ankara" → "İzmir/Ankara". Model araç
* argümanlarında (soru_sor formu) Türkçe harfleri sık düşürüyor (QA B4,
* 8 Eki 2026). Yalnız büyük harfle başlayan ve bir il adıyla tam
* (katlanmış) eşleşen sözcükler değişir; "ordu", "van" gibi küçük harfli
* sözcükler ve öneki eşleşen kısaltmalar ("Afyon") dokunulmaz. Sözlük
* dışındaki sözcükler ("Vakif", "kalsin") prompt kuralına bırakılır.
*/
export function ilAdlariniDuzelt(metin: string): string {
return metin.replace(/\p{Lu}\p{L}+/gu, (soz) => {
const il = ilAdiNormalize(soz);
if (!il || turkishSlugify(il) !== turkishSlugify(soz)) return soz;
const dogru = ilBaslikYazimi(il);
return soz === dogru || soz === il ? soz : dogru;
});
}
function anketIlAdlariniDuzelt(anket: Omit<Anket, "id">): Omit<Anket, "id"> {
return {
...(anket.baslik ? { baslik: ilAdlariniDuzelt(anket.baslik) } : {}),
sorular: anket.sorular.map((s) => ({
...s,
soru: ilAdlariniDuzelt(s.soru),
...(s.aciklama ? { aciklama: ilAdlariniDuzelt(s.aciklama) } : {}),
...(s.serbest ? { serbest: ilAdlariniDuzelt(s.serbest) } : {}),
secenekler: s.secenekler.map((o) => ({
...o,
etiket: ilAdlariniDuzelt(o.etiket),
...(o.aciklama ? { aciklama: ilAdlariniDuzelt(o.aciklama) } : {}),
})),
})),
};
}
/** `soru_sor`: formu denetler, kimlik verir; metin ve kayıt çağıranın (route) işi. */
function soruSor(a: Record<string, unknown>): { soruldu: true; anket: Anket; not: string } | { hata: string } {
const d = anketDogrula(a);
if (!d.ok) return { hata: `Form geçersiz — ${d.hata}. Düzeltip yeniden çağır.` };
return {
soruldu: true,
anket: { id: crypto.randomUUID(), ...d.anket },
anket: { id: crypto.randomUUID(), ...anketIlAdlariniDuzelt(d.anket) },
not: "Form adaya gösterildi. Şimdi BAŞKA ARAÇ ÇAĞIRMA, formu metin olarak tekrar yazma; tek kısa cümleyle bitir (ör. 'Birkaç kısa soru, sonra listeni yeniden kurayım.'). Cevaplar sonraki mesajda 'Form cevaplarım' olarak gelir.",
};
}

View File

@@ -111,9 +111,11 @@ export const KAYIT_DUZELTME_NOTU =
* Cevap "listeni yeniden kurdum / revize ettim" türü bir beyan içeriyor mu.
* Revizyon yalnız liste_revize aracıyla olur (7 Eki 2026); model aracı
* çağırmadan "güncelledim" diyebilir. Olumsuzlar ve sorular eşleşmez.
* 8 Eki 2026 eval'inde araçsız "Şu değişiklikler yapıldı" / "artık 24
* tercihin tamamı devlet üniversitelerinden oluşuyor" kalıpları kaçtı; eklendi.
*/
const REVIZE_BEYANI =
/(?<!\p{L})(?:listeni (?:yeniden kurdum|güncelledim|revize ettim|değiştirdim|yeniledim)|liste(?:n)? (?:yeniden kuruldu|güncellendi|revize edildi|yenilendi)|revizyonu(?:nu)? (?:yaptım|tamamladım|uyguladım)|yeniden kurdum)(?!\p{L})/u;
/(?<!\p{L})(?:listeni (?:yeniden kurdum|güncelledim|revize ettim|değiştirdim|yeniledim)|liste(?:n|yi)? (?:yeniden kuruldu|güncellendi|revize edildi|yenilendi|yeniden kurdum)|revizyonu(?:nu)? (?:yaptım|tamamladım|uyguladım)|yeniden kurdum|liste_revize çağrıldı|değişiklik(?:ler)? yapıldı|(?:şu|bu) değişiklikler(?:i)? (?:yaptım|uyguladım)|artık (?:\S+ )?(?:tercihin|tercihlerin|listen) (?:\S+ )*(?:oluşuyor|kaldı|çıktı))(?!\p{L})/u;
const REVIZE_REDDI =
/(?<!\p{L})(?:kuramadım|kurulamadı|güncelleyemedim|güncellenemedi|revize edemedim|değiştiremedim|yenileyemedim|kurmadım|değiştirmedim)(?!\p{L})/u;

View File

@@ -104,6 +104,15 @@ function revizyonBolumu(r: { paketli: boolean }, params: RaporParams): string {
Sihirbaz seçimlerini (sıra, alan) değiştirmek bu aracın işi değil: aday
sırasını ya da alanını değiştirmek isterse "Tercihlerimi düzenle"
düğmesine yönlendir.
5. GEÇMİŞ ÖRNEK DEĞİLDİR: önceki cevaplarda araç çağrıları görünmez; "Liste
yenilendi" yazan eski bir cevap, aracı çağırmadan yazabileceğin anlamına
gelmez. Her yeni istek YENİ bir liste_revize çağrısı ister; program adı
ya da dilim sayısı ancak aracın BU cevaptaki sonucundan yazılır, eski
cevaplardan kopyalanmaz. "Revize ediyorum / yeniden kuruyorum" gibi bir
giriş cümlesi yazıp aracı çağırmadan bitirme: önce aracı çağır.
6. soru_sor formunun soru ve seçenek metinlerini Türkçe karakterlerle yaz
(ş, ı, ğ, ö, ü, ç, İ): "Vakıf", "kalsın", "başka bir şehir", "İstanbul";
"Vakif", "kalsin", "sehir", "Istanbul" gibi ASCII yazım YANLIŞTIR.
${durum}${mevcut.length ? `\nUYGULANMIŞ REVİZYON KURALLARI (önceki revizyonlardan, hâlâ geçerli): ${mevcut.join("; ")}.` : ""}`;
}

View File

@@ -98,6 +98,18 @@ export function danismanAkisi(opts: {
* eski davranışı korur).
*/
rehberler?: readonly RehberKaydi[];
/**
* İlk adımda model araç çağırmak ZORUNDA (toolChoice "required"; hangi araç
* olduğunu model seçer). Route bunu adayın mesajı liste değişikliği
* istediğinde (sohbet-gecmisi.revizyonNiyetiVar) ve aday paketliyken verir:
* model araçsız "listeni yeniden kurdum" yazamaz, ya liste_revize ya
* soru_sor çağırır (QA B1, 8 Eki 2026). Kredi: değişmez; ek adım yok,
* yalnız ilk adımın metin yerine araç olması zorunlu. Ölçüm (8 Eki 2026):
* liste_revize çağrı oranı 1/8 → 7/8, sahte beyan 5 → 0; ama OpenRouter
* "required" parametresini destekleyen sağlayıcıya (OpenInference) geçti,
* gecikme 1-7 sn → 80-112 sn. Bu yüzden route'ta env bayrağıyla, varsayılan kapalı.
*/
ilkAdimAracZorunlu?: boolean;
}): ReadableStream<UIMessageChunk> {
return createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
@@ -168,7 +180,10 @@ export function danismanAkisi(opts: {
prepareStep: ({ stepNumber }) => {
adimdakiArac = 0;
// Form açıldıysa model cevabı bitirmek zorunda (araç kapalı)
return stepNumber >= ARAC_TUR_SINIRI - 1 || formAcildi ? { toolChoice: "none" } : undefined;
if (stepNumber >= ARAC_TUR_SINIRI - 1 || formAcildi) return { toolChoice: "none" };
// Revizyon niyeti: ilk adım araçsız bitemez
if (stepNumber === 0 && opts.ilkAdimAracZorunlu) return { toolChoice: "required" };
return undefined;
},
maxOutputTokens: 1024,
abortSignal: zamanAsimi,

View File

@@ -4,8 +4,65 @@
* Saf modül: DB'ye ve oturuma dokunmaz, Next dışından da çalışır.
*/
import { revizeBeyaniVar } from "./cikti-denetimi";
import { anketModelMetni, type SohbetMeta } from "./sohbet-meta";
export type SohbetMesaji = { role: "user" | "assistant"; content: string };
/** chat_messages satırının modele gereken kısmı (api/soru/route.ts ve eval betikleri). */
export type KayitliMesaj = { role: "user" | "assistant"; content: string; meta?: SohbetMeta | null };
/**
* Kayıtlı mesajın modele giden metni. Kayıtlı asistan metni araç turlarını
* içermez; soru formu dökümü (anketModelMetni) buradan eklenir ki model
* cevapların neye verildiğini görsün.
*
* Revizyon turları (QA B1, 8 Eki 2026): başarılı revizyonun kayıtlı metni
* ("revize ediyorum… Liste yenilendi… 1. … 5. …") araçsız bir cevapla aynı
* görünüyordu; model sonraki turda bu kalıbı taklit edip aracı atlıyor, hatta
* program adlarını eski cevaptan kopyalıyordu (eval: 8 istekte 1 çağrı,
* 5 sahte beyan). Metnin altına "[araç çağrıldı]" işareti eklemek işe
* yaramadı: model işareti de kopyaladı. Bu yüzden taklit edilebilir metin
* pencereye hiç girmez — başarılı tur kısa bir araç kaydına indirgenir,
* araçsız "yenilendi" diyen tur (düzeltme notu olmayan eski kayıtlar dâhil)
* uydurma listesiyle birlikte düşer, yerine "liste değişmedi" kaydı girer.
* Yalnız modele giden pencere değişir; kayıt ve ekran aynı kalır.
*/
export function kayitliMesajModelMetni(m: KayitliMesaj): SohbetMesaji {
if (m.role !== "assistant") return { role: m.role, content: m.content };
let govde = m.content;
const r = m.meta?.revizyon;
if (r?.ok) {
govde = `[Araç kaydı: bu cevapta liste_revize çağrıldı ve liste yeniden kuruldu — ${r.toplam} tercih (${r.dagilim.hayal} hayal / ${r.dagilim.dengeli} dengeli / ${r.dagilim.garanti} güvenli); ilk 5: ${r.ilkTercihler.join("; ")}. Bu kayıt aracın sonucudur, örnek cevap değildir.]`;
} else if (revizeBeyaniVar(m.content)) {
govde = "[Araç kaydı: bu cevapta liste_revize ÇAĞRILMADI, liste değişmedi; adaya düzeltme notu gösterildi. Bu cevabın metni örnek değildir: revizyon yalnız liste_revize çağrısıyla olur.]";
}
return { role: m.role, content: m.meta?.anket ? `${govde}\n\n${anketModelMetni(m.meta.anket)}` : govde };
}
/**
* Adayın mesajı liste değişikliği istiyor mu (deterministik, anahtarsız).
* Eşleşirse sohbet akışı ilk adımda modeli araç çağırmaya zorlar
* (toolChoice "required": liste_revize ya da soru_sor; hangisi olduğuna
* model karar verir, ama araçsız "yenilendi" yazamaz). Yanlış pozitifin
* bedeli: model bir okuma aracı çağırır, bir adım fazla (kredi değişmez).
* Yanlış negatif eski davranışa düşer. Form cevabı her zaman revizyon
* niyetidir (soru_sor yalnız revizyon için açılır).
*/
export function revizyonNiyetiVar(mesaj: string): boolean {
if (mesaj.startsWith("Form cevaplarım:")) return true;
const m = mesaj.toLocaleLowerCase("tr-TR");
return REVIZYON_NIYETI.some((k) => k.test(m));
}
const REVIZYON_NIYETI: RegExp[] = [
// değişiklik fiilleri: güncelle(yelim), revize (et), yeniden kur, değiştir, düzenle, yenile
/(?<!\p{L})(?:güncelle|revize|yeniden kur|değiştir|düzenle|yenile)\p{L}*/u,
// "X de olsun", "vakıf olmasın", "daha güvenli olsun", "ilk 5 İstanbul olsun"
/(?<!\p{L})(?:olsun|olmasın|kalsın|kalmasın)(?!\p{L})/u,
// "listeye ekle", "X'i çıkar / kaldır"
/(?<!\p{L})(?:ekle|çıkar|kaldır)(?:yelim|elim|alım|ın|in|sene|sana)?(?!\p{L})/u,
];
/**
* Modele giden mesaj penceresi: kayıtlı geçmiş + yeni soru. Kayıt her zaman
* soru-cevap çifti değildir — hata/iade sonrası kullanıcı mesajı cevapsız