feat(sohbet): liste revizyonu sohbete taşındı — soru formu ve yapısal kural
Eski revizyon kutusu kaldırıldı: serbest metin modele gidiyor, sıra ve havuz koddan belirlendiği için "ilk 5 X ilinden olsun" gibi isteklerin karşılığı yoktu. Danışman artık belirsiz isteği soru formuyla (soru_sor → shadcn Questionnaire, proje diline çevrildi) netleştirir, sonra liste_revize ile yapısal kural verir: il/alan/tip farkı, "ilk N" ölçütü, çıkar/ekle, iskelet (lib/revizyon-kurali.ts). Kurallar havuz, seçim ve sırada koddan uygulanır; "ilk N" ölçütünün ili/alanı süzgece kendiliğinden eklenir. - chat_messages.meta sütunu (göç 0006): form, form cevabı ve revizyon sonucu kayıttan geri kurulur; form cevabı kredi harcamaz. - Akış korumaları: form açılınca başka araç yok, revizyonda zaman aşımı uzar, "yeniden kurdum" beyanı araçsızsa düzeltme notu. - /listem "Revize et" sohbeti odaklar; başarılı revizyonda liste yenilenir. - /dev/sohbet senaryosu, DESIGN.md §4.20 notu ve §4.33. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,14 @@ import {
|
||||
} from "ai";
|
||||
import { ARACLAR, aracSeti, type AracBaglami } from "./araclar";
|
||||
import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
|
||||
import { KAYIT_DUZELTME_NOTU, kayitBeyaniVar, semaSizintisiVar, vaatDiliBul } from "./cikti-denetimi";
|
||||
import {
|
||||
KAYIT_DUZELTME_NOTU,
|
||||
REVIZE_DUZELTME_NOTU,
|
||||
kayitBeyaniVar,
|
||||
revizeBeyaniVar,
|
||||
semaSizintisiVar,
|
||||
vaatDiliBul,
|
||||
} from "./cikti-denetimi";
|
||||
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
|
||||
|
||||
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
|
||||
@@ -40,6 +47,12 @@ const ADIM_BASINA_ARAC = 4;
|
||||
const CEVAP_BASINA_ARAC = 8;
|
||||
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
|
||||
const ARAC_ADLARI = ARACLAR.flatMap((a) => (a.type === "function" ? [a.function.name] : []));
|
||||
/**
|
||||
* liste_revize liste üretimini çalıştırır (iki model denemesine kadar, ~20-60
|
||||
* sn); sohbetin 120 sn'lik toplam süresi ona yetmeyebilir. Araç başlayınca
|
||||
* zaman aşımı bu kadar ileri alınır (features/rapor/liste-revizyon.ts).
|
||||
*/
|
||||
const REVIZYON_EK_SURE_MS = 150_000;
|
||||
|
||||
export type CiktiDenetimi = {
|
||||
/** Jev'in ihlal olasılığı; denetim yapılamadıysa null. */
|
||||
@@ -52,6 +65,8 @@ export type CiktiDenetimi = {
|
||||
reddedilenArac: number;
|
||||
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
|
||||
kayitNotu: boolean;
|
||||
/** "Yeniden kurdum" dendi ama başarılı liste_revize yoktu; düzeltme notu eklendi. */
|
||||
revizeNotu: boolean;
|
||||
/** Cevapta araç adları / şema anahtarları var (yalnız gözlem). */
|
||||
semaSizintisi: boolean;
|
||||
};
|
||||
@@ -68,6 +83,11 @@ export function danismanAkisi(opts: {
|
||||
zamanAsimiMs?: number;
|
||||
/** Çıktı denetiminin sonucu (gözlem/eval için; cevabı etkilemez). */
|
||||
onDenetim?: (d: CiktiDenetimi) => void;
|
||||
/**
|
||||
* Her başarılı araç sonucu (ad, çıktı). Route bununla soru formunu ve
|
||||
* revizyon sonucunu kayda (chat_messages.meta) yazar.
|
||||
*/
|
||||
onAracSonucu?: (ad: string, cikti: unknown) => void;
|
||||
}): ReadableStream<UIMessageChunk> {
|
||||
return createUIMessageStream<UIMessage>({
|
||||
execute: async ({ writer }) => {
|
||||
@@ -76,12 +96,19 @@ export function danismanAkisi(opts: {
|
||||
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
|
||||
let akisHatasi: string | null = null;
|
||||
// Sağlayıcı takılırsa SDK akışı "abort" parçasıyla sessizce bitirir
|
||||
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır.
|
||||
const zamanAsimi = AbortSignal.timeout(opts.zamanAsimiMs ?? 120_000);
|
||||
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır. Süre
|
||||
// uzatılabilir olsun diye AbortSignal.timeout yerine zamanlayıcı.
|
||||
const kesici = new AbortController();
|
||||
let sure = opts.zamanAsimiMs ?? 120_000;
|
||||
let zamanlayici = setTimeout(() => kesici.abort(), sure);
|
||||
const zamanAsimi = kesici.signal;
|
||||
let adimdakiArac = 0;
|
||||
let toplamArac = 0;
|
||||
let reddedilenArac = 0;
|
||||
let basariliKayit = 0;
|
||||
let basariliRevizyon = 0;
|
||||
// soru_sor çağrıldı: form adayda; model aynı cevapta başka araç çağırmasın
|
||||
let formAcildi = false;
|
||||
try {
|
||||
const result = streamText({
|
||||
model: sohbetDilModeli(),
|
||||
@@ -89,19 +116,36 @@ export function danismanAkisi(opts: {
|
||||
messages: opts.mesajlar,
|
||||
tools: aracSeti(
|
||||
opts.aracBaglami,
|
||||
(ad) => console.info("[soru] araç:", ad),
|
||||
() => {
|
||||
(ad) => {
|
||||
console.info("[soru] araç:", ad);
|
||||
if (ad === "liste_revize") {
|
||||
clearTimeout(zamanlayici);
|
||||
sure += REVIZYON_EK_SURE_MS;
|
||||
zamanlayici = setTimeout(() => kesici.abort(), sure);
|
||||
}
|
||||
},
|
||||
(ad) => {
|
||||
adimdakiArac++;
|
||||
toplamArac++;
|
||||
if (formAcildi) {
|
||||
reddedilenArac++;
|
||||
return "Soru formu adayda; cevap gelene kadar başka araç çağrılmaz. Bu çağrı ÇALIŞTIRILMADI. Tek kısa cümleyle bitir.";
|
||||
}
|
||||
if (ad === "liste_revize" && basariliRevizyon > 0) {
|
||||
reddedilenArac++;
|
||||
return "Liste bu cevapta zaten yeniden kuruldu; ikinci revizyon ÇALIŞTIRILMADI. Sonucu adaya anlat.";
|
||||
}
|
||||
if (adimdakiArac <= ADIM_BASINA_ARAC && toplamArac <= CEVAP_BASINA_ARAC) return null;
|
||||
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.`;
|
||||
},
|
||||
(ad, cikti) => {
|
||||
const nesne = cikti != null && typeof cikti === "object" ? (cikti as Record<string, unknown>) : null;
|
||||
// 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++;
|
||||
}
|
||||
if (ad === "profil_guncelle" && nesne && "kaydedildi" in nesne) basariliKayit++;
|
||||
if (ad === "liste_revize" && nesne?.ok === true) basariliRevizyon++;
|
||||
if (ad === "soru_sor" && nesne?.soruldu === true) formAcildi = true;
|
||||
if (nesne && !("hata" in nesne)) opts.onAracSonucu?.(ad, cikti);
|
||||
},
|
||||
),
|
||||
// En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
|
||||
@@ -109,7 +153,8 @@ export function danismanAkisi(opts: {
|
||||
stopWhen: isStepCount(ARAC_TUR_SINIRI),
|
||||
prepareStep: ({ stepNumber }) => {
|
||||
adimdakiArac = 0;
|
||||
return stepNumber >= ARAC_TUR_SINIRI - 1 ? { toolChoice: "none" } : undefined;
|
||||
// Form açıldıysa model cevabı bitirmek zorunda (araç kapalı)
|
||||
return stepNumber >= ARAC_TUR_SINIRI - 1 || formAcildi ? { toolChoice: "none" } : undefined;
|
||||
},
|
||||
maxOutputTokens: 1024,
|
||||
abortSignal: zamanAsimi,
|
||||
@@ -184,11 +229,22 @@ export function danismanAkisi(opts: {
|
||||
writer.write({ type: "text-delta", id, delta: KAYIT_DUZELTME_NOTU });
|
||||
writer.write({ type: "text-end", id });
|
||||
}
|
||||
// Revizyon beyanı denetimi: "yeniden kurdum" ama bu turda başarılı
|
||||
// liste_revize yok → düzeltme notu (kayıt denetimiyle aynı mantık).
|
||||
const revizeNotu = basariliRevizyon === 0 && revizeBeyaniVar(metin);
|
||||
if (revizeNotu) {
|
||||
console.info("[soru] revizyon beyanı var, başarılı liste_revize yok → not eklendi");
|
||||
const id = crypto.randomUUID();
|
||||
metin += `\n\n${REVIZE_DUZELTME_NOTU}`;
|
||||
writer.write({ type: "text-start", id });
|
||||
writer.write({ type: "text-delta", id, delta: REVIZE_DUZELTME_NOTU });
|
||||
writer.write({ type: "text-end", id });
|
||||
}
|
||||
// İç şema sızıntısı: yalnız gözlem (cevap geri alınamaz). Metin loglanmaz.
|
||||
const semaSizintisi = semaSizintisiVar(metin, ARAC_ADLARI);
|
||||
if (semaSizintisi) console.warn("[soru] olası iç şema sızıntısı: cevapta araç adları / şema anahtarları var");
|
||||
if (reddedilenArac) console.info("[soru] araç sınırı:", reddedilenArac, "çağrı çalıştırılmadı");
|
||||
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, semaSizintisi });
|
||||
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, revizeNotu, semaSizintisi });
|
||||
await opts.onTamam(metin);
|
||||
tamamlandi = true;
|
||||
writer.write({ type: "finish" });
|
||||
@@ -199,6 +255,8 @@ export function danismanAkisi(opts: {
|
||||
// Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa
|
||||
// (boş cevap, kayıt hatası) istemci hatayı buradan görür.
|
||||
if (akisHatasi == null) throw err;
|
||||
} finally {
|
||||
clearTimeout(zamanlayici);
|
||||
}
|
||||
},
|
||||
onError: () => HATA_METNI,
|
||||
|
||||
Reference in New Issue
Block a user