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

@@ -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));