Files
kolaytercih/src/lib/ai/sohbet-akisi.ts
bilalgursen cee4bf38af 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>
2026-10-09 02:10:59 +03:00

425 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Danışman cevabının UI mesaj akışı (AI SDK). api/soru/route.ts bunu
* createUIMessageStreamResponse ile döner; istemci useChat ile okur.
*
* 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).
*
* İstemci bağlantıyı koparırsa üretim SÜRER ve cevap kaydedilir (kredi
* harcanmış kalır): isteğin iptal sinyali modele bilerek bağlanmaz, yoksa
* cevabı okuyup bağlantıyı kesen kullanıcı her seferinde iade alırdı.
*
* Saf modül: DB'ye ve oturuma dokunmaz, Next dışından da çalışır.
*/
import {
createUIMessageStream,
isStepCount,
streamText,
toUIMessageStream,
type ModelMessage,
type UIMessage,
type UIMessageChunk,
} from "ai";
import { ARACLAR, aracSeti, type AracBaglami } from "./araclar";
import { BaglantiTamponu, type RehberKaydi } from "./baglanti-denetimi";
import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
import {
KAYIT_DUZELTME_NOTU,
REVIZE_DUZELTME_NOTU,
REVIZE_DUZELTME_NOTU_IADELI,
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). */
const ARAC_TUR_SINIRI = 4;
/**
* Araç çağrısı üst sınırları. Adım sayısı sınırı çağrı sayısını sınırlamıyordu:
* model tek adımda onlarca paralel çağrı yapabildi (5 Eki 2026 eval, s057:
* 33 çağrı, 34 bin token, 17,8 sn). Aynı koşuda meşru en yüksek 5 çağrıydı.
* Fazlası çalıştırılmaz; modele açık mesaj döner, cevap elindekiyle yazılır.
* Kredi: sınır cevabı düşürmez, iade tetiklemez.
*/
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] : []));
/**
* 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;
/** Regex'in yakaladığı garanti/olasılık ifadeleri (boş = temiz). */
vaatRegex: string[];
/** Cevabın sonuna düzeltme notu eklendi mi. */
vaatNotu: boolean;
/** Sınır aşıldığı için çalıştırılmayan araç çağrısı sayısı. */
reddedilenArac: number;
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
kayitNotu: boolean;
/** "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;
/** Akış sırasında düzeltilen rehber bağlantısı sayısı (baglanti-denetimi.ts). */
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[];
aracBaglami: AracBaglami;
/** Cevap tamamlandı (çıktı denetimi notu dahil). Fırlatırsa akış hata verir. */
onTamam: (metin: string) => Promise<void>;
/** Cevap tamamlanamadı; `metin` o ana kadar üretilen kısım (boş olabilir). */
onYarim: (metin: string) => Promise<void>;
/** Sağlayıcı bu sürede bitirmezse cevap yarım sayılır (varsayılan 120 sn). */
zamanAsimiMs?: number;
/** Çıktı denetiminin sonucu (gözlem/eval için; cevabı etkilemez). */
onDenetim?: (d: CiktiDenetimi) => void;
/**
* 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;
/**
* Gerçek rehber yazıları (slug + başlık). Verilirse cevaptaki
* `/rehber/<slug>` adresleri akış sırasında bu listeyle doğrulanır
* (baglanti-denetimi.ts); verilmezse tampon pasiftir (betikler/evaller
* eski davranışı korur).
*/
rehberler?: readonly RehberKaydi[];
}): ReadableStream<UIMessageChunk> {
return createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
let tamamlandi = false;
let metin = "";
// 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. 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;
// Rehber bağlantısı doğrulama: her metin parçası için ayrı tampon
let tampon: BaglantiTamponu | null = null;
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),
() => {
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 (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" && nesne && "kaydedildi" in nesne) basariliKayit++;
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
// vermek zorunda).
stopWhen: isStepCount(ARAC_TUR_SINIRI),
prepareStep: ({ stepNumber }) => {
adimdakiArac = 0;
// 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" };
return undefined;
},
maxOutputTokens: 1024,
abortSignal: zamanAsimi,
providerOptions: {
openaiCompatible: { reasoningEffort: "none" },
// Sağlayıcı adı altındaki bilinmeyen alanlar istek gövdesine aynen geçer
...(kaliciDislananSaglayicilar().length > 0
? { openrouter: { provider: { ignore: kaliciDislananSaglayicilar() } } }
: {}),
},
onError: ({ error }) => {
// Hata nesnesinin tamamı istek gövdesini ve cevap başlıklarını
// taşır; loga yalnız durum kodu ve mesaj gider.
const e = error as { statusCode?: number; message?: string };
console.error("[soru] model hatası:", e?.statusCode ?? "", e?.message ?? String(error));
},
});
// 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();
for (;;) {
const { done, value: parca } = await okuyucu.read();
if (done) break;
// Kesilme istemciye "abort" değil hata olarak gider (aşağıda fırlatılır)
if (parca.type === "abort") continue;
if (parca.type === "text-start") {
if (metin && !metin.endsWith("\n")) {
// Araç turundan sonra yeni metin parçası: kayıtta yapışmasın
metin += "\n\n";
}
if (opts.rehberler) tampon = new BaglantiTamponu(opts.rehberler);
} else if (parca.type === "text-delta") {
// Tampon bir bağlantı adayı görünce metni tutar, aday kapanınca
// doğrulanmış hâlini bırakır; ekran ve kayıt aynı metni alır.
const cikti = tampon ? tampon.besle(parca.delta) : parca.delta;
metin += cikti;
if (cikti) writer.write({ ...parca, delta: cikti });
continue;
} else if (parca.type === "text-end") {
const kalan = tampon?.bitir() ?? "";
if (kalan) {
metin += kalan;
writer.write({ type: "text-delta", id: parca.id, delta: kalan });
}
if (tampon) {
baglantiDuzeltme += tampon.duzeltmeler.length;
for (const d of tampon.duzeltmeler) duzeltilenSluglar.push(d.yeniSlug ? `→${d.yeniSlug}` : "→(düz)");
tampon = null;
}
} else if (parca.type === "error") {
akisHatasi = parca.errorText;
}
writer.write(parca);
}
if (akisHatasi != null) throw new Error(akisHatasi);
// Yarım metin "tamamlandı" diye kaydedilmesin: onYarim krediyi iade eder
if (zamanAsimi.aborted) throw new Error("Zaman aşımı: sağlayıcı cevabı bitirmedi");
if (!metin.trim()) throw new Error("Boş cevap");
// Çıktı denetimi: cevap garanti/olasılık dili içeriyorsa sonuna
// düzeltme notu eklenir. İki bağımsız denetimden biri yeter: Jev
// (jev.ts; anahtar yoksa ya da zaman aşımında atlanır) ve anahtarsız
// regex (cikti-denetimi.ts; her cevapta koşar). Akış zaten gittiği
// için cümle ekrandan geri alınamaz; not hem ekrana hem kayda yazılır.
// Ek model çağrısı yok; kredi etkilenmez.
const vaatRegex = vaatDiliBul(metin);
const jevP = await cevapIhlalOlasiligi(metin);
const vaatNotu = vaatRegex.length > 0 || (jevP != null && jevP >= JEV_IHLAL_ESIGI);
console.info("[soru] çıktı denetimi: jev", jevP == null ? "atlandı" : jevP.toFixed(2), "regex", vaatRegex.length, vaatNotu ? "→ not eklendi" : "");
if (vaatNotu) {
const id = crypto.randomUUID();
metin += `\n\n${JEV_DUZELTME_NOTU}`;
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: JEV_DUZELTME_NOTU });
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 });
}
// Revizyon beyanı denetimi: "yeniden kurdum" ama bu turda başarılı
// 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) {
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${not}`;
writer.write({ type: "text-start", id });
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.
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ı");
// Yalnız sayaç + kayıtlı (gerçek) slug loglanır; modelin yazdığı ham slug
// kullanıcı metninden gelebileceği için log'a geçmez (güvenlik-uyum, 7 Eki 2026).
if (baglantiDuzeltme) console.info("[soru] rehber bağlantısı düzeltildi:", baglantiDuzeltme, duzeltilenSluglar.join(", "));
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, revizeNotu, semaSizintisi, baglantiDuzeltme });
await opts.onTamam(metin);
tamamlandi = true;
writer.write({ type: "finish" });
} catch (err) {
// Tamponda tutulan bağlantı adayı yarım kayda doğrulanmış hâliyle girsin
if (tampon) metin += tampon.bitir();
if (!tamamlandi) await opts.onYarim(metin).catch(() => {});
// Tam hata nesnesi istek gövdesini ve cevap başlıklarını taşır; yalnız mesaj
console.error("[soru] stream hatası:", err instanceof Error ? err.message : String(err));
// 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,
});
}