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>
425 lines
22 KiB
TypeScript
425 lines
22 KiB
TypeScript
/**
|
||
* 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,
|
||
});
|
||
}
|