feat(sohbet): rehber bağlantıları akışta gerçek slug'larla doğrulanıyor

Model araç çağırmadan ezberden /rehber/<slug> yazabiliyordu; istemci her
adresi "Rehber yazısı" hapı yapıp 404'e gönderiyordu. Yeni saf modül
lib/ai/baglanti-denetimi.ts: kayıtlı slug dokunulmaz, yakın slug (Dice ≥ 0,5
ve açık ara önde) kayıttaki başlıkla yeniden yazılır, uydurma slug düz metne
iner / çıplak adres silinir. BaglantiTamponu akış sırasında çalışır (cevap
bittikten sonra ekrandaki hap geri alınamaz); parça parça çıktı tek seferlik
sonuçla birebir aynı. danismanAkisi `rehberler` seçeneği alır (verilmezse
pasif); route gerçek content/rehber listesini verir; CiktiDenetimi'ne
baglantiDuzeltme eklendi. Prompt BAĞLANTI maddesi rehber_ara'yı teşvik eder.

Aynı dosyalardaki revizyon-hak metinleri (prompt revizyonBolumu, araç
açıklaması/HAK dalı, route'taki kalanHak/kalanRevizyon) de bu commit'te:
sınırın kaldırılması bir sonraki commit'te tamamlanır.

Test: scripts/eval/baglanti-denetim.ts (44/44) ve akis-denetim.ts'e
rehber-uydurma / rehber-gercek senaryoları (9/9).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-07 23:16:34 +03:00
parent 4d30dd1305
commit 15f4c4f4e1
7 changed files with 439 additions and 30 deletions

View File

@@ -22,8 +22,8 @@ import {
type RevizyonOzeti,
type SohbetMeta,
} from "@/lib/ai/sohbet-meta";
import { MAX_REVIZYON } from "@/lib/credits";
import { profilGetir } from "@/lib/danisman-profil-db";
import { getRehberAramaKayitlari } from "@/lib/rehber";
import { listeOzetiCikar } from "@/lib/ai/rapor";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { RaporParams } from "@/features/rapor/types/rapor";
@@ -196,7 +196,7 @@ export async function POST(request: NextRequest) {
uyarilar: raporSonuc.uyarilar,
kapsam: raporSonuc.kapsam ? { toplam: raporSonuc.kapsam.toplam, uyarlandi: raporSonuc.kapsam.uyarlandi } : undefined,
},
revizyon: { kalanHak: Math.max(0, MAX_REVIZYON - rapor.revisionCount), paketli },
revizyon: { paketli },
});
// Araçların bağlamı: aday sırası/puan türü (arama süzgeci), profil
// (profil_guncelle yazınca aynı tur içinde tazelenir) ve sihirbaz seçimleri
@@ -207,7 +207,6 @@ export async function POST(request: NextRequest) {
tur: raporParams.tur,
hasPaket: paketli,
mesajId: kullaniciMesajId,
kalanRevizyon: Math.max(0, MAX_REVIZYON - rapor.revisionCount),
profil,
oncelikler: raporParams.oncelikler ?? [],
ozelDurum: raporParams.ozelDurum ?? [],
@@ -297,6 +296,9 @@ export async function POST(request: NextRequest) {
system,
mesajlar,
aracBaglami,
// Cevaptaki /rehber/<slug> adresleri gerçek yazı listesiyle doğrulanır
// (prod'da cache'li, lib/rehber.ts)
rehberler: getRehberAramaKayitlari(),
onAracSonucu: (ad, cikti) => {
const c = cikti as Record<string, unknown>;
if (ad === "soru_sor" && c.anket) cevapMeta.anket = c.anket as SohbetMeta["anket"];

View File

@@ -100,8 +100,6 @@ export type AracBaglami = {
hasPaket?: boolean;
/** Adayın bu turdaki mesajının kimliği: liste_revize'nin idempotency anahtarı. */
mesajId?: string;
/** Kalan revizyon hakkı (prompt'a ve araç hatasına gider). */
kalanRevizyon?: number;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
/** Sihirbaz öncelik çipleri (uyum puanı / öne çıkan için) */
@@ -469,7 +467,7 @@ export const ARACLAR: AracTanimi[] = [
function: {
name: "liste_revize",
description:
"Adayın 24'lük listesini verilen YAPISAL kurallarla yeniden kurar (paketli adayda, en fazla 2 hak). Kural kodla uygulanır: il/alan/tip farkı aday havuzunu değiştirir, 'ilkTercihler' listenin başını o ölçütle doldurur, 'cikar' havuzdan düşer, 'ekle' program kimliklerini listeye alır, 'iskelet' dilim dağılımını değiştirir. En az bir kural ver; adayın isteği belirsizse ÖNCE soru_sor. Çalışması 20-60 sn sürer; sonuç ok ise liste ekranda yenilenir, sen ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi yaz. 'not' alanına adayın isteğini bir cümleyle özetle.",
"Adayın 24'lük listesini verilen YAPISAL kurallarla yeniden kurar (paketli adayda; sınır yok). Kural kodla uygulanır: il/alan/tip farkı aday havuzunu değiştirir, 'ilkTercihler' listenin başını o ölçütle doldurur, 'cikar' havuzdan düşer, 'ekle' program kimliklerini listeye alır, 'iskelet' dilim dağılımını değiştirir. En az bir kural ver; adayın isteği belirsizse ÖNCE soru_sor. Çalışması 20-60 sn sürer; sonuç ok ise liste ekranda yenilenir, sen ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi yaz. 'not' alanına adayın isteğini bir cümleyle özetle.",
parameters: {
type: "object",
properties: {
@@ -1759,15 +1757,12 @@ async function listeRevize(b: AracBaglami, a: Record<string, unknown>): Promise<
if (!sonuc.ok) {
return {
hata: sonuc.neden,
not:
sonuc.kod === "HAK"
? "Adaya revizyon hakkının dolduğunu söyle; sohbetten listesi hakkında soru sormaya devam edebilir."
: "Adaya listenin yeniden kurulamadığını ve nedenini söyle; 'yeniden kurdum' deme.",
not: "Adaya listenin yeniden kurulamadığını ve nedenini söyle; 'yeniden kurdum' deme.",
};
}
return {
...sonuc.ozet,
not: "Liste ekranda yenilendi. Adaya ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi numaralı yaz; kalan revizyon hakkını belirt. Sayı uydurma; yukarıdaki alanları kullan.",
not: "Liste ekranda yenilendi. Adaya ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi numaralı yaz. Sayı uydurma; yukarıdaki alanları kullan.",
};
}

View File

@@ -0,0 +1,221 @@
/**
* Danışman cevabındaki rehber bağlantılarının doğrulanması (7 Eki 2026).
*
* Model araç çağırmadan ezberden `[Yatay geçiş](/rehber/yatay-gecis-rehberi)`
* yazabiliyor; istemci her `/rehber/<slug>` adresini "Rehber yazısı" hapı
* yapıyor ve hap 404'e gidiyordu. Prompt "adres uydurma" diyor ama asıl
* savunma koddur: metindeki her `/rehber/<slug>` gerçek yazı listesiyle
* eşlenir. Slug kayıtlıysa dokunulmaz; değilse en yakın gerçek slug'a
* (başlığı kayıttan) yeniden yazılır; yakın aday yoksa bağlantı düz metne
* iner, çıplak adres silinir.
*
* Cevap `text-delta` parçalarıyla anında ekrana gittiği için düzeltme cevap
* bittikten sonra yapılamaz (yanlış hap ekrandan geri alınamaz). Bu yüzden
* `BaglantiTamponu` akış sırasında çalışır: bir bağlantı adayı başlayınca
* metni tutar, aday kapanınca doğrulayıp bırakır. Bağlantılar kısadır,
* gecikme hissedilmez. Tamponun parça parça çıktısı tek seferlik
* `rehberBaglantilariniDuzelt` sonucuyla birebir aynıdır
* (scripts/eval/baglanti-denetim.ts bunu sınar).
*
* `/universite/…` ve `/bolum/…` bu turda doğrulanmaz (backlog).
*
* Saf modül: DB/ağ/Next yok, `server-only` import etmez; akış modülü gibi
* betiklerden de koşar.
*/
export type RehberKaydi = { slug: string; baslik: string };
export type BaglantiDuzeltme = {
/** Modelin yazdığı (kayıtlı olmayan) slug. */
slug: string;
/** "yakin": gerçek bir yazıya yeniden yazıldı; "duz": bağlantı düz metne indi / adres silindi. */
sonuc: "yakin" | "duz";
yeniSlug?: string;
};
/** Yakın eşleşme için en düşük Dice benzerliği. */
const YAKIN_ESIK = 0.5;
/** En iyi adayın ikinciden en az bu kadar önde olması gerekir (belirsiz eşleşme yeniden yazılmaz). */
const ACIK_ARA_FARK = 0.15;
/** `[` sonrası bu kadar karakter içinde `](` gelmezse sıradan köşeli parantezdir. */
const ETIKET_UST = 160;
/** `](` sonrası bu kadar karakter içinde `)` gelmezse bağlantı değildir. */
const ADRES_UST = 200;
/** Ters tırnak bu kadar karakter içinde kapanmazsa bağlantı adayı değildir. */
const TIRNAK_UST = 160;
const SITE_KOKU = "(?:https?:\\/\\/(?:www\\.)?kolaytercih\\.com)?";
const SLUG = "[a-z0-9-]+";
/**
* Üç biçim, soldan sağa tek geçişte: markdown bağlantısı, ters tırnak içi
* adres, çıplak adres. Markdown etiketi `]` içeremez; etiket ve adres
* uzunluk sınırları tamponun bekleme sınırlarıyla aynıdır ki parça parça
* çıktı tek seferlik sonuçla aynı kalsın.
*/
const BAGLANTI = new RegExp(
`\\[([^\\]]{0,${ETIKET_UST}})\\]\\(\\s*${SITE_KOKU}\\/rehber\\/(${SLUG})\\s*\\)` +
`|\`\\s*\\/rehber\\/(${SLUG})\\s*\`` +
`|\\/rehber\\/(${SLUG})`,
"g",
);
function parcalar(slug: string): Set<string> {
return new Set(slug.split("-").filter(Boolean));
}
/** Sørensen–Dice: iki slug'ın `-` ile ayrılmış parça kümeleri üzerinden. */
function dice(a: Set<string>, b: Set<string>): number {
if (!a.size || !b.size) return 0;
let ortak = 0;
for (const p of a) if (b.has(p)) ortak++;
return (2 * ortak) / (a.size + b.size);
}
/**
* Kayıtlı olmayan slug'a en yakın gerçek kayıt: benzerlik eşiği geçilmeli
* ve tek aday açık ara önde olmalı; aksi hâlde null (yeniden yazma yapılmaz,
* bağlantı düz metne iner).
*/
export function enYakinRehber(slug: string, rehberler: readonly RehberKaydi[]): RehberKaydi | null {
const hedef = parcalar(slug);
let birinci: { kayit: RehberKaydi; puan: number } | null = null;
let ikinciPuan = 0;
for (const kayit of rehberler) {
const puan = dice(hedef, parcalar(kayit.slug));
if (!birinci || puan > birinci.puan) {
ikinciPuan = birinci?.puan ?? 0;
birinci = { kayit, puan };
} else if (puan > ikinciPuan) {
ikinciPuan = puan;
}
}
if (!birinci || birinci.puan < YAKIN_ESIK) return null;
if (birinci.puan - ikinciPuan < ACIK_ARA_FARK) return null;
return birinci.kayit;
}
/**
* Metindeki `/rehber/<slug>` adreslerini (markdown bağlantısı, ters tırnak ve
* çıplak adres) gerçek kayıtlarla eşler. Kayıtlı slug dokunulmaz; yakın
* kayıt varsa `[başlık](/rehber/yeni-slug)` olur (başlık kayıttan); yoksa
* markdown bağlantısı etiketine iner, ters tırnak içi ve çıplak adres
* silinir ("bkz." gibi öncesindeki bağlaç kalır).
*/
export function rehberBaglantilariniDuzelt(
metin: string,
rehberler: readonly RehberKaydi[],
): { metin: string; duzeltmeler: BaglantiDuzeltme[] } {
const duzeltmeler: BaglantiDuzeltme[] = [];
const kayitli = new Set(rehberler.map((r) => r.slug));
const yeni = metin.replace(BAGLANTI, (eslesme, etiket: string | undefined, mdSlug?: string, tirnakSlug?: string, ciplakSlug?: string) => {
const slug = mdSlug ?? tirnakSlug ?? ciplakSlug ?? "";
if (kayitli.has(slug)) return eslesme;
const yakin = enYakinRehber(slug, rehberler);
if (yakin) {
duzeltmeler.push({ slug, sonuc: "yakin", yeniSlug: yakin.slug });
return `[${yakin.baslik}](/rehber/${yakin.slug})`;
}
duzeltmeler.push({ slug, sonuc: "duz" });
return mdSlug != null ? (etiket ?? "") : "";
});
return { metin: yeni, duzeltmeler };
}
const SLUG_KARAKTERI = /[a-z0-9-]/;
const ADRES_ONEKI = "/rehber/";
/** Metnin sonundaki, `/rehber/` önekinin gerçek öneki olan kuyruk uzunluğu ("/reh" → 4). */
function kuyrukOnEki(metin: string): number {
const ust = Math.min(metin.length, ADRES_ONEKI.length - 1);
for (let n = ust; n > 0; n--) {
if (metin.endsWith(ADRES_ONEKI.slice(0, n))) return n;
}
return 0;
}
/**
* Akış tamponu. `besle(delta)` güvenle bırakılabilen metni döner (bağlantı
* adayı dışındaki metin olduğu gibi, kapanan aday doğrulanmış hâliyle);
* `bitir()` kalanı doğrulayıp boşaltır. Tutma noktaları: `[`, ters tırnak,
* `/rehber/` (ve metin sonunda yarım kalmış "/reh" gibi öneki).
*/
export class BaglantiTamponu {
readonly duzeltmeler: BaglantiDuzeltme[] = [];
private bekleyen = "";
constructor(private readonly rehberler: readonly RehberKaydi[]) {}
besle(delta: string): string {
this.bekleyen += delta;
let cikti = "";
for (;;) {
const t = this.tetik();
if (t === -1) {
const kuyruk = kuyrukOnEki(this.bekleyen);
const kes = this.bekleyen.length - kuyruk;
cikti += this.bekleyen.slice(0, kes);
this.bekleyen = this.bekleyen.slice(kes);
return cikti;
}
cikti += this.bekleyen.slice(0, t);
this.bekleyen = this.bekleyen.slice(t);
const son = this.adayinSonu();
if (son === "bekle") return cikti;
if (son === "degil") {
// Sıradan köşeli parantez / ters tırnak / "/rehber/" artığı: ilk
// karakter bırakılır, kalan yeniden taranır.
cikti += this.bekleyen[0];
this.bekleyen = this.bekleyen.slice(1);
continue;
}
cikti += this.duzelt(this.bekleyen.slice(0, son));
this.bekleyen = this.bekleyen.slice(son);
}
}
bitir(): string {
const kalan = this.bekleyen;
this.bekleyen = "";
return kalan ? this.duzelt(kalan) : "";
}
private duzelt(parca: string): string {
const s = rehberBaglantilariniDuzelt(parca, this.rehberler);
this.duzeltmeler.push(...s.duzeltmeler);
return s.metin;
}
/** Bekleyen metinde en erken tutma noktası; yoksa -1. */
private tetik(): number {
const adaylar = [this.bekleyen.indexOf("["), this.bekleyen.indexOf("`"), this.bekleyen.indexOf(ADRES_ONEKI)].filter((i) => i >= 0);
return adaylar.length ? Math.min(...adaylar) : -1;
}
/**
* Bekleyen metin bir tutma noktasıyla başlar. Aday kapandıysa bitiş
* indeksi (hariç); daha veri gerekiyorsa "bekle"; aday olmadığı
* anlaşıldıysa "degil".
*/
private adayinSonu(): number | "bekle" | "degil" {
const m = this.bekleyen;
if (m[0] === "[") {
const kapanis = m.indexOf("]", 1);
if (kapanis === -1 || kapanis > ETIKET_UST + 1) return m.length > ETIKET_UST + 1 ? "degil" : "bekle";
if (kapanis + 1 >= m.length) return "bekle";
if (m[kapanis + 1] !== "(") return "degil";
const parantez = m.indexOf(")", kapanis + 2);
if (parantez === -1) return m.length - (kapanis + 2) > ADRES_UST ? "degil" : "bekle";
return parantez + 1;
}
if (m[0] === "`") {
const kapanis = m.indexOf("`", 1);
if (kapanis === -1) return m.length > TIRNAK_UST + 1 ? "degil" : "bekle";
return kapanis + 1;
}
// "/rehber/" ile başlıyor: slug slug-dışı bir karakterle kapanır
let i = ADRES_ONEKI.length;
while (i < m.length && SLUG_KARAKTERI.test(m[i])) i++;
if (i >= m.length) return "bekle";
return i === ADRES_ONEKI.length ? "degil" : i;
}
}

View File

@@ -72,13 +72,11 @@ programlarında başarı sırası barajı yoktur.`;
* netleştirir (soru_sor formu), sonra liste_revize ile YAPISAL kural verir.
* Serbest metin kural değildir: "ilk 5 İstanbul olsun" → ilkTercihler.
*/
function revizyonBolumu(r: { kalanHak: number; paketli: boolean }, params: RaporParams): string {
function revizyonBolumu(r: { paketli: boolean }, params: RaporParams): string {
const mevcut = kuralOzeti(params.revizyon);
const durum = !r.paketli
? `Adayın paketi YOK: liste_revize çağırma; listeyi değiştirmek isterse tam liste ve revizyonun Tercih Dönemi Paketi'yle (/paket) açıldığını sempatik biçimde söyle.`
: r.kalanHak <= 0
? `Adayın revizyon hakkı DOLDU (${r.kalanHak} kaldı): liste_revize çağırma; değişiklik isterse hakkın dolduğunu söyle, listesi hakkında soru cevaplamaya devam et.`
: `Adayın kalan revizyon hakkı: ${r.kalanHak}. Her başarılı liste_revize bir hak düşer; bu yüzden isteği tek seferde doğru kur.`;
: `Revizyon sınırsız; her kurulum 20-60 sn sürdüğü için isteği yine tek seferde doğru kur.`;
return `LİSTE REVİZYONU (sohbetten): aday listesinde değişiklik istediğinde ("ilk 5
İstanbul olsun", "Ankara da olsun", "vakıf olmasın", "X üniversitesini çıkar",
"daha güvenli olsun", "şu programı ekle") şu sırayla çalış:
@@ -98,8 +96,8 @@ function revizyonBolumu(r: { kalanHak: number; paketli: boolean }, params: Rapor
kısa cümleyle bitir. Cevap "Form cevaplarım:" diye gelince liste_revize
çağır; tekrar sorma.
3. liste_revize ok dönünce: liste ekranda zaten yenilendi. Ne değiştiğini 2-3
cümleyle söyle, ilk 5 tercihi numaralı yaz (aracın verdiği adlarla), kalan
hakkı belirt. Dilim sırası "ilk N" kuralında bilerek bozulur; aday sorarsa
cümleyle söyle, ilk 5 tercihi numaralı yaz (aracın verdiği adlarla).
Dilim sırası "ilk N" kuralında bilerek bozulur; aday sorarsa
bunu söyle. Araç hata dönerse "yeniden kurdum" DEME; nedenini söyle.
4. REVİZYON DÜRÜSTLÜĞÜ: "listeni yeniden kurdum / güncelledim / revize ettim"
cümlesini ancak BU cevapta liste_revize çağırdıysan ve ok döndüyse yaz.
@@ -123,10 +121,10 @@ export function sistemPromptu(opts: {
*/
rapor?: { genelDegerlendirme?: string; uyarilar?: string[]; kapsam?: { toplam: number; uyarlandi: boolean } };
/**
* Sohbetten liste revizyonu (7 Eki 2026): kalan hak ve paket durumu.
* Sohbetten liste revizyonu (7 Eki 2026): paket durumu (sayı sınırı yok).
* Verilmezse revizyon bölümü yazılmaz (eval betikleri eski davranışı korur).
*/
revizyon?: { kalanHak: number; paketli: boolean };
revizyon?: { paketli: boolean };
}): string {
const { params } = opts;
const secimVar =
@@ -249,7 +247,9 @@ KURALLAR:
[Yazının başlığı](/rehber/slug), [Üniversite adı](/universite/slug),
[Bölüm adı](/bolum/slug); adresi düz metin ya da ters tırnak içinde yazma. Adres ve
başlık araç sonucundaki "sayfa" / "baslik" / "ad" alanından gelir; adres
uydurma. Ekranda bu bağlantı ikonlu bir hap olarak görünür.
uydurma. Rehber adresi yalnız bu cevapta rehber_ara / rehber_oku sonucunda
gördüğün "sayfa" alanından gelir; görmediğin bir yazıya bağlantı verme, önce
rehber_ara çağır. Ekranda bu bağlantı ikonlu bir hap olarak görünür.
- PROFİL: aday bir tercih söylerse (yaşadığı il, evden en fazla kaç km,
vakıfta yıllık ücret sınırı, burs beklentisi, öğretim dili, uzaktan/açık
kabul mü, kampüs büyüklüğü, özel kontenjan durumu) ${PROFIL_ONAY_SOR

View File

@@ -23,6 +23,7 @@ import {
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,
@@ -69,6 +70,8 @@ export type CiktiDenetimi = {
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;
};
export function danismanAkisi(opts: {
@@ -88,6 +91,13 @@ export function danismanAkisi(opts: {
* 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 }) => {
@@ -109,6 +119,10 @@ export function danismanAkisi(opts: {
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 {
const result = streamText({
model: sohbetDilModeli(),
@@ -183,11 +197,30 @@ export function danismanAkisi(opts: {
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" && metin && !metin.endsWith("\n")) {
// Araç turundan sonra yeni metin parçası: kayıtta yapışmasın
metin += "\n\n";
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") {
metin += parca.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.slug}→${d.yeniSlug}` : `${d.slug}→(düz)`);
tampon = null;
}
} else if (parca.type === "error") {
akisHatasi = parca.errorText;
}
@@ -244,11 +277,15 @@ export function danismanAkisi(opts: {
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, revizeNotu, semaSizintisi });
// Slug kişisel veri değildir; hangi uydurma adresin neye döndüğü loglanır
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));