fix(danisman): liste revizyonu koda bağlandı — model artık revizyon başlatamaz (QA B1)

Revizyon sohbet modelinin gönüllü liste_revize araç seçimine bağlıydı
(8 Eki eval: 8 istekte 5 çağrı, 3 sahte beyan; tool_choice required
OpenRouter'da 80-112 sn). Şimdi:

- Niyet kodda: revizyonNiyetiVar (regex) + paketli → lib/ai/revizyon-niyeti.ts
  yapılandırılmış çıkarım (yapilandirilmisUret, json_schema; eski liste_revize
  parametre şeması ve revizyonKuraliKur aynen yeniden kullanılır, kopya yok).
  Model niyetModeli() (OPENROUTER_NIYET_MODEL, yoksa rapor modeli), 20 sn,
  OpenInference bu çağrıda atlanır (11-12,5 sn ölçüldü).
- Dallanma kodda: kural geçerli → listeRevizeKoddan (paket/kilit/kredi kapıları
  aynı); kural eksik → soru formu koddan (modelin formu kod doğrular, yoksa
  varsayılan form); niyet soru/diger → normal sohbet; çıkarım başarısız →
  koddan "okuyamadım" cevabı + kredi iadesi (normal sohbete düşünce model
  uydurma adlarla "kurdum" yazıyordu).
- Cevap da kodda: revizyon/form turunda model hiç çağrılmaz; sayı ve program
  adları sunucu sonucundan şablonla (sabitCevap). Model sonuç önündeyken
  dağılımı 8'de 2 yanlış yazdı. Ekrana eski araç parçaları (tool-liste_revize /
  tool-soru_sor) aynen gider; istemci ve kayıt değişmez. Başarısız revizyon
  artık ekranda "kuramadım" hapı (output-error).
- liste_revize modelin araç listesinden çıktı; prompt revizyon bölümü
  "kurdum deme, net istek iste ya da soru_sor" diye sadeleşti.
- Güvenlik ağı: beyan regex'i kaldı; beyan var + revizyon yok → not + mesajın
  sohbet kredisi iade (grantCredits refund, refId=mesajId, idempotent), not
  bunu söyler. liste-revizyon iade refId'si "<mesajId>:revizyon" oldu:
  (reason, refId) tekilliği sohbet iadesiyle çakışıyordu.
- Geçmiş penceresi: "[Araç kaydı: …]" etiketi modelce kopyalanıyordu;
  yerine doğal tek cümle.
- Kaldırıldı: ilkAdimAracZorunlu / SOHBET_REVIZYON_ARAC_ZORUNLU.

Ölçüm (deepseek-v4-flash @ openrouter, pnpm eval:revizyon): koddan revizyon
8/8, kural doğru 8/8, sahte beyan 0, 10/10; revizyon turu 1 küçük çağrı
(ort. 1-3 sn, 0,00005 $) + liste üretimi; sohbet çağrısı yok.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-09 02:10:59 +03:00
parent 128c175327
commit cee4bf38af
10 changed files with 632 additions and 217 deletions

View File

@@ -4,6 +4,16 @@
*
* Araç çağrıları akışta ayrı parça olarak görünür ("Programlara
* bakıyorum…"); kayda yalnız METİN gider (chat_messages.content düz metin).
*
* Koddan işlem (9 Eki 2026): liste revizyonu ve belirsiz istekteki soru
* formu modelin kararına bırakılmaz. Route, adayın mesajındaki niyeti
* kodla + yapılandırılmış çıkarımla okur (revizyon-niyeti.ts) ve `islem`
* verir; akış işlemi çalıştırır, ekrana eski araç parçasıyla aynı biçimde
* yazar (tool-liste_revize / tool-soru_sor; istemci ve kayıt değişmez) ve
* cevabı KODDAN şablonla yazar — bu turda model hiç çağrılmaz (9 Eki eval:
* sonuç önünde dururken model dağılım sayılarını 8 cevabın 2'sinde yanlış
* yazdı; sayı ve ad şablondan basılır, ilke: deterministik olan LLM'e
* bırakılmaz). `liste_revize` modelin araç listesinde yok.
* Kayıt ve kredi iadesi çağıranın işidir: cevap tamamlanınca `onTamam`,
* yarıda kalınca `onYarim` çağrılır (ikisi de biriken metinle).
*
@@ -28,6 +38,7 @@ import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
import {
KAYIT_DUZELTME_NOTU,
REVIZE_DUZELTME_NOTU,
REVIZE_DUZELTME_NOTU_IADELI,
kayitBeyaniVar,
revizeBeyaniVar,
semaSizintisiVar,
@@ -49,12 +60,27 @@ 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).
* Koddan liste revizyonu liste üretimini çalıştırır (iki model denemesine
* kadar, ~20-60 sn); sohbetin 120 sn'lik toplam süresi ona yetmeyebilir.
* İşlem başlayınca zaman aşımı bu kadar ileri alınır (features/rapor/liste-revizyon.ts).
*/
const REVIZYON_EK_SURE_MS = 150_000;
/**
* Modele gitmeden önce KODDAN çalıştırılan işlem. Ekranda ve kayıtta eski
* aracın parçası olarak görünür (`tool-<ad>`; arac-pilleri.tsx etiketleri,
* sohbet-client.tsx liste yenileme ve form çizimi aynen çalışır).
*/
export type KoddanIslem = {
ad: "liste_revize" | "soru_sor";
/** Ekrana giden girdi (ör. çıkarılan kural); modele gitmez. */
girdi: unknown;
/** İşlemin gövdesi; dönen nesne ekrana araç sonucu olarak gider ve cevap şablonuna girer. */
calistir: () => Promise<unknown>;
/** Revizyon cevabının şablon girdisi: kural özeti (lib/revizyon-kurali kuralOzeti) ve "ilk N" kuralı var mı. */
anlatim?: { ozet: string[]; ilkN: boolean };
};
export type CiktiDenetimi = {
/** Jev'in ihlal olasılığı; denetim yapılamadıysa null. */
jevP: number | null;
@@ -66,7 +92,7 @@ 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. */
/** "Yeniden kurdum" dendi ama bu turda başarılı (koddan) revizyon yoktu; düzeltme notu eklendi. */
revizeNotu: boolean;
/** Cevapta araç adları / şema anahtarları var (yalnız gözlem). */
semaSizintisi: boolean;
@@ -74,6 +100,46 @@ export type CiktiDenetimi = {
baglantiDuzeltme: number;
};
/** Kısaltma olarak kalacak büyük harfli sözcükler (başlık yazımına çevrilmez). */
const KISALTMA = new Set(["KKTC", "TED", "ODTÜ", "İTÜ", "YTÜ", "TOBB", "MEF", "İZTECH", "ABD", "AB"]);
/**
* Veritabanı biçimindeki BÜYÜK HARFLİ sözcükleri başlık yazımına çevirir;
* küçük harfli metne dokunmaz: "Bilgisayar Mühendisliği — EGE ÜNİVERSİTESİ
* (İZMİR)" → "… — Ege Üniversitesi (İzmir)", "iller: KOCAELİ eklendi" →
* "iller: Kocaeli eklendi".
*/
function baslikYazimi(metin: string): string {
return metin.replace(/\p{Lu}[\p{Lu}'’.]+/gu, (soz) => {
if (KISALTMA.has(soz)) return soz;
const kucuk = soz.toLocaleLowerCase("tr-TR");
return kucuk.charAt(0).toLocaleUpperCase("tr-TR") + kucuk.slice(1);
});
}
/**
* Koddan işlemin cevabı — model çağrılmaz. Soru formu: sorular ekranda
* (model "ne istediğini yaz" diye formu baltalıyordu — 9 Eki eval r09).
* Başarısız revizyon: neden zaten adaya yönelik metin. Başarılı revizyon:
* sayı ve adlar sunucu sonucundan şablonla (model aynı sonuç önündeyken
* dağılımı 8'de 2 yanlış yazdı, adları eksik/sıra dışı aktardı).
*/
export function sabitCevap(islem: Pick<KoddanIslem, "ad" | "anlatim">, cikti: unknown): string {
const nesne = cikti != null && typeof cikti === "object" ? (cikti as Record<string, unknown>) : {};
if (islem.ad === "soru_sor") {
return nesne.soruldu === true
? "Birkaç kısa soru hazırladım; cevaplarsan listeni ona göre yeniden kurarım."
: "Soru formunu açamadım; ne değişsin istediğini bir cümleyle yazarsan listeni ona göre yeniden kurarım.";
}
if (nesne.ok !== true) return `Listeni yeniden kuramadım: ${String(nesne.hata ?? "bilinmeyen bir sorun oluştu.")}`;
const d = (nesne.dagilim ?? {}) as Record<string, number>;
const ilk = Array.isArray(nesne.ilkTercihler) ? (nesne.ilkTercihler as string[]) : [];
const ozet = islem.anlatim?.ozet.length ? ` — ${islem.anlatim.ozet.map(baslikYazimi).join("; ")}` : "";
const satirlar = ilk.map((x, i) => `${i + 1}. ${baslikYazimi(x)}`).join("\n");
const ilkN = islem.anlatim?.ilkN ? ' "İlk N" kuralında listenin başı o ölçütle dolar, dilim sırası bilerek bozulur.' : "";
return `Listeni yeniden kurdum${ozet}. ${nesne.toplam} tercih: ${d.hayal ?? 0} hayal / ${d.dengeli ?? 0} dengeli / ${d.garanti ?? 0} güvenli.\n\nİlk ${ilk.length}:\n${satirlar}\n\nEkrandaki liste yenilendi.${ilkN} Bir programı sormak ya da başka bir şey değiştirmek istersen yaz.`;
}
export function danismanAkisi(opts: {
system: string;
mesajlar: ModelMessage[];
@@ -87,8 +153,21 @@ export function danismanAkisi(opts: {
/** Çı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.
* Model "yeniden kurdum" dedi ama bu turda başarılı revizyon yok: düzeltme
* notundan önce çağrılır; `true` dönerse not "kredin iade edildi" der.
* Route burada mesajın sohbet kredisini iade eder (idempotent; bedelsiz
* mesajda false döner). Verilmezse iadesiz not.
*/
onRevizeBeyani?: () => Promise<boolean>;
/**
* Koddan işlem (revizyon ya da soru formu). Verilirse model bu turda HİÇ
* çağrılmaz: işlem çalışır, sonucu ekrana araç parçası olarak gider, cevap
* şablondan yazılır (sabitCevap), kayıt ve kredi akışı aynen (onTamam).
*/
islem?: KoddanIslem;
/**
* Her başarılı araç sonucu (ad, çıktı) — koddan işlem dâhil. Route bununla
* soru formunu ve revizyon sonucunu kayda (chat_messages.meta) yazar.
*/
onAracSonucu?: (ad: string, cikti: unknown) => void;
/**
@@ -98,18 +177,6 @@ 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 }) => {
@@ -136,31 +203,66 @@ export function danismanAkisi(opts: {
let baglantiDuzeltme = 0;
const duzeltilenSluglar: string[] = [];
try {
// Koddan işlem: ekrana araç parçası olarak, cevap şablondan; model bu
// turda çağrılmaz. Başarısız revizyon da ekrana gider ("Listeni
// yeniden kuramadım" hapı); gövde fırlatırsa aynı yol.
writer.write({ type: "start" });
if (opts.islem) {
const { ad, girdi } = opts.islem;
const toolCallId = `kod-${crypto.randomUUID()}`;
console.info("[soru] koddan işlem:", ad);
writer.write({ type: "tool-input-start", toolCallId, toolName: ad });
writer.write({ type: "tool-input-available", toolCallId, toolName: ad, input: girdi });
if (ad === "liste_revize") {
clearTimeout(zamanlayici);
sure += REVIZYON_EK_SURE_MS;
zamanlayici = setTimeout(() => kesici.abort(), sure);
}
let cikti: unknown;
try {
cikti = await opts.islem.calistir();
} catch (hata) {
console.error("[soru] koddan işlem hatası:", ad, hata instanceof Error ? hata.message : String(hata));
cikti = { hata: ad === "liste_revize" ? "Liste yeniden kurulurken bir sorun oluştu." : "Soru formu açılamadı." };
}
const nesne = cikti != null && typeof cikti === "object" ? (cikti as Record<string, unknown>) : null;
if (ad === "liste_revize" && nesne?.ok === true) basariliRevizyon++;
if (ad === "soru_sor" && nesne?.soruldu === true) formAcildi = true;
if (nesne && "hata" in nesne) {
writer.write({ type: "tool-output-error", toolCallId, errorText: String(nesne.hata) });
} else {
writer.write({ type: "tool-output-available", toolCallId, output: cikti });
if (nesne) opts.onAracSonucu?.(ad, cikti);
}
// Cevap koddan (model çağrısı yok → gecikme, maliyet ve "yeniden
// kurdum" riski sıfır; sayılar sunucu sonucundan). Kredi: mesajın
// kredisi düşmüş kalır (form cevabı bedelsiz).
const sabit = sabitCevap(opts.islem, cikti);
const id = crypto.randomUUID();
metin = sabit;
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: sabit });
writer.write({ type: "text-end", id });
opts.onDenetim?.({ jevP: null, vaatRegex: [], vaatNotu: false, reddedilenArac: 0, kayitNotu: false, revizeNotu: false, semaSizintisi: false, baglantiDuzeltme: 0 });
await opts.onTamam(metin);
tamamlandi = true;
writer.write({ type: "finish" });
return;
}
const result = streamText({
model: sohbetDilModeli(),
instructions: opts.system,
messages: opts.mesajlar,
tools: aracSeti(
opts.aracBaglami,
(ad) => {
console.info("[soru] araç:", ad);
if (ad === "liste_revize") {
clearTimeout(zamanlayici);
sure += REVIZYON_EK_SURE_MS;
zamanlayici = setTimeout(() => kesici.abort(), sure);
}
},
(ad) => {
(ad) => console.info("[soru] araç:", 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.`;
@@ -169,7 +271,6 @@ export function danismanAkisi(opts: {
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" && 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);
},
@@ -179,10 +280,9 @@ export function danismanAkisi(opts: {
stopWhen: isStepCount(ARAC_TUR_SINIRI),
prepareStep: ({ stepNumber }) => {
adimdakiArac = 0;
// Form açıldıysa model cevabı bitirmek zorunda (araç kapalı)
// Form açıldıysa (modelin soru_sor'u ya da koddan form) model
// cevabı bitirmek zorunda (araç kapalı)
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,
@@ -204,6 +304,8 @@ export function danismanAkisi(opts: {
// Bitiş parçasını biz yazarız: araya çıktı denetimi notu girebilir.
const okuyucu = toUIMessageStream({
stream: result.stream,
// Başlangıç parçası yukarıda yazıldı (koddan işlem ondan sonra gelir)
sendStart: false,
sendFinish: false,
onError: () => HATA_METNI,
}).getReader();
@@ -278,14 +380,19 @@ export function danismanAkisi(opts: {
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).
// koddan revizyon yok → düzeltme notu (kayıt denetimiyle aynı mantık).
// Revizyonu artık model başlatamadığı için bu yalnız güvenlik ağıdır;
// tetiklenirse mesajın kredisi iade edilir (route, onRevizeBeyani) ve
// not bunu söyler. Kredi: iade idempotent, çift iade yok.
const revizeNotu = basariliRevizyon === 0 && revizeBeyaniVar(metin);
if (revizeNotu) {
console.info("[soru] revizyon beyanı var, başarılı liste_revize yok → not eklendi");
const iade = (await opts.onRevizeBeyani?.().catch(() => false)) ?? false;
console.info("[soru] revizyon beyanı var, bu turda revizyon yok → not eklendi", iade ? "(kredi iade edildi)" : "");
const not = iade ? REVIZE_DUZELTME_NOTU_IADELI : REVIZE_DUZELTME_NOTU;
const id = crypto.randomUUID();
metin += `\n\n${REVIZE_DUZELTME_NOTU}`;
metin += `\n\n${not}`;
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: REVIZE_DUZELTME_NOTU });
writer.write({ type: "text-delta", id, delta: not });
writer.write({ type: "text-end", id });
}
// İç şema sızıntısı: yalnız gözlem (cevap geri alınamaz). Metin loglanmaz.