/** * 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-`; 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; /** 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, cikti: unknown): string { const nesne = cikti != null && typeof cikti === "object" ? (cikti as Record) : {}; 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; 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; /** Cevap tamamlanamadı; `metin` o ana kadar üretilen kısım (boş olabilir). */ onYarim: (metin: string) => Promise; /** 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; /** * 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/` 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 { return createUIMessageStream({ 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) : 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) : 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, }); }