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:
@@ -14,11 +14,10 @@ import { kullanicininRaporu, sohbetGecmisiGetir } from "@/features/rapor/rapor-q
|
||||
import { barajTablosu, type AracBaglami } from "@/lib/ai/araclar";
|
||||
import { danismanAkisi } from "@/lib/ai/sohbet-akisi";
|
||||
import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt";
|
||||
import { modelPenceresi, onayMesajiMi } from "@/lib/ai/sohbet-gecmisi";
|
||||
import { kayitliMesajModelMetni, modelPenceresi, onayMesajiMi, revizyonNiyetiVar } from "@/lib/ai/sohbet-gecmisi";
|
||||
import {
|
||||
anketCevabiDogrula,
|
||||
anketCevabiMetni,
|
||||
anketModelMetni,
|
||||
type RevizyonOzeti,
|
||||
type SohbetMeta,
|
||||
} from "@/lib/ai/sohbet-meta";
|
||||
@@ -268,14 +267,7 @@ export async function POST(request: NextRequest) {
|
||||
// Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere
|
||||
// modele bozuk sıra göndermesin. Kayıtlı asistan metni soru formunu
|
||||
// içermez; model cevapların neye verildiğini formun dökümünden görür.
|
||||
const mesajlar = modelPenceresi(
|
||||
gecmis.map((m) =>
|
||||
m.role === "assistant" && m.meta?.anket
|
||||
? { role: m.role, content: `${m.content}\n\n${anketModelMetni(m.meta.anket)}` }
|
||||
: { role: m.role, content: m.content },
|
||||
),
|
||||
mesaj,
|
||||
);
|
||||
const mesajlar = modelPenceresi(gecmis.map(kayitliMesajModelMetni), mesaj);
|
||||
|
||||
const userId = session.user.id;
|
||||
// Bu turda araçların bıraktığı yapısal ek (soru formu, revizyon sonucu)
|
||||
@@ -299,6 +291,13 @@ export async function POST(request: NextRequest) {
|
||||
// Cevaptaki /rehber/<slug> adresleri gerçek yazı listesiyle doğrulanır
|
||||
// (prod'da cache'li, lib/rehber.ts)
|
||||
rehberler: getRehberAramaKayitlari(),
|
||||
// Liste değişikliği isteğinde ilk adım araçsız bitemez (QA B1). VARSAYILAN
|
||||
// KAPALI: tool_choice "required" OpenRouter'da isteği Baidu yerine
|
||||
// OpenInference'a yönlendirdi; cevap 80-112 sn sürdü ve onlarca paralel
|
||||
// araç çağrısı geldi (8 Eki 2026 eval, scripts/eval/revizyon-arac.ts).
|
||||
// Açmak Bilal'in kararı (SOHBET_REVIZYON_ARAC_ZORUNLU=1); paketsizde hiç
|
||||
// zorlanmaz — liste_revize zaten paket ister, model sözle yönlendirir.
|
||||
ilkAdimAracZorunlu: process.env.SOHBET_REVIZYON_ARAC_ZORUNLU === "1" && paketli && revizyonNiyetiVar(mesaj),
|
||||
onAracSonucu: (ad, cikti) => {
|
||||
const c = cikti as Record<string, unknown>;
|
||||
if (ad === "soru_sor" && c.anket) cevapMeta.anket = c.anket as SohbetMeta["anket"];
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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("; ")}.` : ""}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user