diff --git a/scripts/eval/baglanti-denetim.ts b/scripts/eval/baglanti-denetim.ts index 0891570..43d10bd 100644 --- a/scripts/eval/baglanti-denetim.ts +++ b/scripts/eval/baglanti-denetim.ts @@ -5,8 +5,9 @@ * * lib/ai/baglanti-denetimi.ts gerçek content/rehber listesiyle sınanır: * kayıtlı slug dokunulmaz, yakın slug yeniden yazılır (başlık kayıttan), - * uydurma slug düz metne iner; akış tamponunun parça parça çıktısı tek - * seferlik sonuçla birebir aynı olmalı. Beklenti tutmazsa çıkış kodu 1. + * uydurma slug düz metne iner; iç içe `[[x](y)](y)` bağlantısı (her adres) + * `[x](y)` olur; akış tamponunun parça parça çıktısı tek seferlik sonuçla + * birebir aynı olmalı. Beklenti tutmazsa çıkış kodu 1. */ import { BaglantiTamponu, rehberBaglantilariniDuzelt, type RehberKaydi } from "@/lib/ai/baglanti-denetimi"; import { getRehberAramaKayitlari } from "@/lib/rehber"; @@ -71,6 +72,19 @@ function senaryolar(rehberler: readonly RehberKaydi[]): Senaryo[] { bekle: `Oku: [${obp.baslik}](/rehber/${obp.slug}).`, duzeltme: 1, }, + { + // QA 8 Eki 2026: paketsiz cevapta modelin ürettiği biçim + ad: "ic-ice-paket-baglantisi", + metin: "Tercih Dönemi Paketi (299 TL, [[/paket](/paket)](/paket)) ile açılır; [[a](/x)](/y) kalır.", + bekle: "Tercih Dönemi Paketi (299 TL, [/paket](/paket)) ile açılır; [[a](/x)](/y) kalır.", + duzeltme: 0, + }, + { + ad: "ic-ice-rehber-baglantisi-sonra-eslesir", + metin: "Oku: [[Yatay geçiş](/rehber/yatay-gecis-rehberi)](/rehber/yatay-gecis-rehberi) ve [[[OBP](/rehber/obp-nedir)](/rehber/obp-nedir)](/rehber/obp-nedir).", + bekle: `Oku: [${yatay.baslik}](/rehber/${yatay.slug}) ve [${obp.baslik}](/rehber/${obp.slug}).`, + duzeltme: 2, + }, ]; } diff --git a/src/lib/ai/baglanti-denetimi.ts b/src/lib/ai/baglanti-denetimi.ts index 0099ae9..a41b0ad 100644 --- a/src/lib/ai/baglanti-denetimi.ts +++ b/src/lib/ai/baglanti-denetimi.ts @@ -19,6 +19,14 @@ * * `/universite/…` ve `/bolum/…` bu turda doğrulanmaz (backlog). * + * İç içe bağlantı (8 Eki 2026, QA): model bazen `[[/paket](/paket)](/paket)` + * gibi aynı adresi iki kez saran bozuk markdown yazıyor; ekranda köşeli + * parantezler ve "Paket (Paket)" kartı görünüyordu. Adres ne olursa olsun + * (`/rehber/` ile sınırlı değil) `[[x](y)](y)` → `[x](y)` düzleştirilir; bu + * düzeltme rehber eşlemesinden ÖNCE yapılır ki içteki rehber adresi de + * doğrulansın. Tampon bunun için bir bağlantı adayının `)`'sinden sonra + * `](` gelip gelmediğine bakar (bir karakterlik bekleme). + * * Saf modül: DB/ağ/Next yok, `server-only` import etmez; akış modülü gibi * betiklerden de koşar. */ @@ -59,6 +67,25 @@ const BAGLANTI = new RegExp( "g", ); +/** + * Aynı adresi iki kez saran iç içe markdown bağlantısı: `[[x](y)](y)`. + * Etiket köşeli parantez, adres yuvarlak parantez içermez; adres iki + * katmanda aynıysa eşleşir (farklı adresler düzleştirilmez — hangisi + * doğru bilinemez). + */ +const IC_ICE_BAGLANTI = /\[\[([^[\]]*)\]\(([^()]*)\)\]\(\2\)/g; + +/** `[[x](y)](y)` → `[x](y)`; daha derin sarmalar için kalmayana dek yinelenir. */ +export function icIceBaglantiyiDuzlestir(metin: string): string { + let onceki: string; + let simdiki = metin; + do { + onceki = simdiki; + simdiki = onceki.replace(IC_ICE_BAGLANTI, "[$1]($2)"); + } while (simdiki !== onceki); + return simdiki; +} + function parcalar(slug: string): Set { return new Set(slug.split("-").filter(Boolean)); } @@ -95,8 +122,10 @@ export function enYakinRehber(slug: string, rehberler: readonly RehberKaydi[]): } /** - * Metindeki `/rehber/` adreslerini (markdown bağlantısı, ters tırnak ve - * çıplak adres) gerçek kayıtlarla eşler. Kayıtlı slug dokunulmaz; yakın + * Önce iç içe bağlantılar düzleştirilir (`[[x](y)](y)` → `[x](y)`, her adres; + * `duzeltmeler`e yazılmaz — rehber kaydı değil biçim hatası), sonra + * metindeki `/rehber/` adresleri (markdown bağlantısı, ters tırnak ve + * çıplak adres) gerçek kayıtlarla eşlenir. 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). @@ -107,7 +136,7 @@ export function rehberBaglantilariniDuzelt( ): { 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 yeni = icIceBaglantiyiDuzlestir(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); @@ -203,9 +232,20 @@ export class BaglantiTamponu { 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); + let parantez = m.indexOf(")", kapanis + 2); if (parantez === -1) return m.length - (kapanis + 2) > ADRES_UST ? "degil" : "bekle"; - return parantez + 1; + // Kapanan bağlantıyı bir dış bağlantı sarıyor olabilir (`[[x](y)](y)`, + // icIceBaglantiyiDuzlestir): `)`'den sonra `](` geliyorsa dış adresin + // kapanışına dek tutulur; sonraki karakter henüz gelmediyse beklenir. + for (;;) { + if (parantez + 1 >= m.length) return "bekle"; + if (m[parantez + 1] !== "]") return parantez + 1; + if (parantez + 2 >= m.length) return "bekle"; + if (m[parantez + 2] !== "(") return parantez + 1; + const dis = m.indexOf(")", parantez + 3); + if (dis === -1) return m.length - (parantez + 3) > ADRES_UST ? parantez + 1 : "bekle"; + parantez = dis; + } } if (m[0] === "`") { const kapanis = m.indexOf("`", 1);