From 15f4c4f4e1d346c1d03bec85fdc317795742429f Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Wed, 7 Oct 2026 23:16:34 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(sohbet):=20rehber=20ba=C4=9Flant=C4=B1?= =?UTF-8?q?lar=C4=B1=20ak=C4=B1=C5=9Fta=20ger=C3=A7ek=20slug'larla=20do?= =?UTF-8?q?=C4=9Frulan=C4=B1yor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model araç çağırmadan ezberden /rehber/ 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 --- scripts/eval/akis-denetim.ts | 37 +++++- scripts/eval/baglanti-denetim.ts | 127 ++++++++++++++++++ src/app/api/soru/route.ts | 8 +- src/lib/ai/araclar.ts | 11 +- src/lib/ai/baglanti-denetimi.ts | 221 +++++++++++++++++++++++++++++++ src/lib/ai/danisman-prompt.ts | 18 +-- src/lib/ai/sohbet-akisi.ts | 47 ++++++- 7 files changed, 439 insertions(+), 30 deletions(-) create mode 100644 scripts/eval/baglanti-denetim.ts create mode 100644 src/lib/ai/baglanti-denetimi.ts diff --git a/scripts/eval/akis-denetim.ts b/scripts/eval/akis-denetim.ts index 2da1069..edc51b8 100644 --- a/scripts/eval/akis-denetim.ts +++ b/scripts/eval/akis-denetim.ts @@ -7,16 +7,24 @@ * araç çağrılarını aynen döner. Gerçek danismanAkisi + gerçek araçlar koşar * (profil_guncelle data/app.db'nin geçici kopyasına yazar). Doğrulananlar: * vaat regex'i notu (Jev anahtarı yokken), "kaydettim" deyip kaydetmeme notu, - * araç çağrısı kotası. Model davranışını ÖLÇMEZ; onu eval:sohbet ölçer. + * araç çağrısı kotası, rehber bağlantısı doğrulama (uydurma slug akışta + * gerçek slug'a döner, kayıt da düzeltilmiş metni alır). Model davranışını + * ÖLÇMEZ; onu eval:sohbet ölçer. * Beklenti tutmazsa çıkış kodu 1. */ import { createServer } from "node:http"; import { copyFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; -type Beklenen = { vaatNotu: boolean; kayitNotu: boolean; reddedilenArac: number }; -type Senaryo = { ad: string; beklenen: Beklenen; adimlar: ({ metin: string } | { araclar: { name: string; arguments: string }[] })[] }; -const B = (vaatNotu: boolean, kayitNotu: boolean, reddedilenArac = 0): Beklenen => ({ vaatNotu, kayitNotu, reddedilenArac }); +type Beklenen = { vaatNotu: boolean; kayitNotu: boolean; reddedilenArac: number; baglantiDuzeltme: number }; +type Senaryo = { + ad: string; + beklenen: Beklenen; + adimlar: ({ metin: string } | { araclar: { name: string; arguments: string }[] })[]; + /** Kaydedilen metinde olması / olmaması gerekenler (rehber bağlantısı senaryoları). */ + metinde?: { var?: string[]; yok?: string[] }; +}; +const B = (vaatNotu: boolean, kayitNotu: boolean, reddedilenArac = 0, baglantiDuzeltme = 0): Beklenen => ({ vaatNotu, kayitNotu, reddedilenArac, baglantiDuzeltme }); const SENARYOLAR: Senaryo[] = [ { ad: "kaydettim-aracsiz", beklenen: B(false, true), adimlar: [{ metin: "Kaydettim: Vakıfta yıllık ücret üst sınırı 400.000 TL. Aramalar buna göre süzülecek." }] }, { ad: "kaydettim-aracli", beklenen: B(false, false), adimlar: [{ araclar: [{ name: "profil_guncelle", arguments: JSON.stringify({ alan: "ucretUst", deger: 400000 }) }] }, { metin: "Kaydettim: yıllık ücret en fazla 400.000 TL." }] }, @@ -25,6 +33,19 @@ const SENARYOLAR: Senaryo[] = [ { ad: "vaat", beklenen: B(true, false), adimlar: [{ metin: "Bu programa yerleşme olasılığın düşük ama yine de yazabilirsin." }] }, { ad: "temiz", beklenen: B(false, false), adimlar: [{ metin: "Bu program son yerleştirmede 178.278. sırada kapandı; etiketi Riskli. Garanti verilemez." }] }, { ad: "kota", beklenen: B(false, false, 2), adimlar: [{ araclar: Array.from({ length: 6 }, (_, i) => ({ name: "il_ozeti", arguments: JSON.stringify({ il: ["İzmir", "Ankara", "Bursa", "Konya", "Adana", "Samsun"][i] }) })) }, { metin: "İllere baktım." }] }, + // Model araç çağırmadan ezberden iki yanlış rehber adresi yazar; ikisi de akışta gerçek slug'a döner + { + ad: "rehber-uydurma", + beklenen: B(false, false, 0, 2), + adimlar: [{ metin: "Şu yazılara bak: [OBP](/rehber/obp-nedir) ve [Yatay geçiş](/rehber/yatay-gecis-rehberi). İkisi de sana göre." }], + metinde: { var: ["/rehber/obp-nedir-nasil-hesaplanir", "/rehber/yatay-gecis-cap-yandal"], yok: ["/rehber/obp-nedir)", "/rehber/yatay-gecis-rehberi"] }, + }, + { + ad: "rehber-gercek", + beklenen: B(false, false, 0, 0), + adimlar: [{ metin: "Bak: [Devlet mi vakıf mı](/rehber/devlet-mi-vakif-mi) yazısı bunu anlatıyor." }], + metinde: { var: ["/rehber/devlet-mi-vakif-mi"] }, + }, ]; let aktif: Senaryo = SENARYOLAR[0]; let adimNo = 0; @@ -61,6 +82,9 @@ async function main() { const Database = (await import("better-sqlite3")).default; const sqlite = new Database(db); const { danismanAkisi } = await import("@/lib/ai/sohbet-akisi"); + // Gerçek content/rehber listesi: bağlantı doğrulama tamponu aktif + const { getRehberAramaKayitlari } = await import("@/lib/rehber"); + const rehberler = getRehberAramaKayitlari(); const info = console.info; console.info = () => {}; let hata = 0; @@ -76,6 +100,7 @@ async function main() { system: "test", mesajlar: [{ role: "user", content: "test" }], aracBaglami: { userId, sira: 100000, tur: "say", profil: {} }, + rehberler, onTamam: async (m) => { tam = m; }, @@ -91,7 +116,9 @@ async function main() { if (v.type === "tool-output-available") araclar.push(JSON.stringify(v.output).slice(0, 70)); } const d = denetim as Beklenen | null; - const tuttu = d != null && d.vaatNotu === s.beklenen.vaatNotu && d.kayitNotu === s.beklenen.kayitNotu && d.reddedilenArac === s.beklenen.reddedilenArac; + const kayit = String(tam); + const metinTuttu = (s.metinde?.var ?? []).every((m) => kayit.includes(m)) && (s.metinde?.yok ?? []).every((m) => !kayit.includes(m)); + const tuttu = d != null && d.vaatNotu === s.beklenen.vaatNotu && d.kayitNotu === s.beklenen.kayitNotu && d.reddedilenArac === s.beklenen.reddedilenArac && d.baglantiDuzeltme === s.beklenen.baglantiDuzeltme && metinTuttu; if (!tuttu) hata++; info(`\n${tuttu ? "geçti" : "KALDI"} [${s.ad}]\n denetim: ${JSON.stringify(denetim)}\n araç çıktıları: ${araclar.length} → ${araclar.join(" | ")}\n son metin: ${String(tam).replace(/\n+/g, " ⏎ ")}`); } diff --git a/scripts/eval/baglanti-denetim.ts b/scripts/eval/baglanti-denetim.ts new file mode 100644 index 0000000..0891570 --- /dev/null +++ b/scripts/eval/baglanti-denetim.ts @@ -0,0 +1,127 @@ +/** + * Rehber bağlantısı doğrulamasının MEKANİK testi — parasız, anahtarsız, DB'siz. + * + * pnpm tsx --conditions=react-server scripts/eval/baglanti-denetim.ts + * + * 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. + */ +import { BaglantiTamponu, rehberBaglantilariniDuzelt, type RehberKaydi } from "@/lib/ai/baglanti-denetimi"; +import { getRehberAramaKayitlari } from "@/lib/rehber"; + +type Senaryo = { + ad: string; + metin: string; + /** Beklenen tam çıktı (string) ya da çıktıyı denetleyen fonksiyon. */ + bekle: string | ((cikti: string) => boolean); + duzeltme: number; +}; + +function senaryolar(rehberler: readonly RehberKaydi[]): Senaryo[] { + const kayit = (slug: string) => { + const r = rehberler.find((x) => x.slug === slug); + if (!r) throw new Error(`content/rehber içinde yok: ${slug}`); + return r; + }; + const yatay = kayit("yatay-gecis-cap-yandal"); + const obp = kayit("obp-nedir-nasil-hesaplanir"); + const gercek = kayit("devlet-mi-vakif-mi"); + return [ + { + ad: "gercek-slug-aynen", + metin: `Şu yazıya bak: [${gercek.baslik}](/rehber/${gercek.slug}) — orada ayrıntı var.`, + bekle: `Şu yazıya bak: [${gercek.baslik}](/rehber/${gercek.slug}) — orada ayrıntı var.`, + duzeltme: 0, + }, + { + ad: "yakin-eslesme-markdown", + metin: "Detay için [Yatay geçiş](/rehber/yatay-gecis-rehberi) yazısına bak.", + bekle: `Detay için [${yatay.baslik}](/rehber/${yatay.slug}) yazısına bak.`, + duzeltme: 1, + }, + { + ad: "uydurma-duz-metin", + metin: "Bunu [Tamamen uydurma yazı](/rehber/tamamen-uydurma-seyler) anlatıyor.", + bekle: "Bunu Tamamen uydurma yazı anlatıyor.", + duzeltme: 1, + }, + { + ad: "ters-tirnak-ve-ciplak", + metin: "Bkz. `/rehber/obp-nedir` ve ayrıca /rehber/yatay-gecis-rehberi. Uydurma: /rehber/tamamen-uydurma-seyler bitti.", + bekle: `Bkz. [${obp.baslik}](/rehber/${obp.slug}) ve ayrıca [${yatay.baslik}](/rehber/${yatay.slug}). Uydurma: bitti.`, + duzeltme: 3, + }, + { + ad: "koseli-parantez-kayipsiz", + metin: "[2024] verisi ve [not] şöyle; `kod` de olduğu gibi kalır.", + bekle: "[2024] verisi ve [not] şöyle; `kod` de olduğu gibi kalır.", + duzeltme: 0, + }, + { + ad: "baska-yol-dokunulmaz", + metin: "[Ankara Üniversitesi](/universite/ankara-universitesi) ve [Tıp](/bolum/tip) kalır.", + bekle: "[Ankara Üniversitesi](/universite/ankara-universitesi) ve [Tıp](/bolum/tip) kalır.", + duzeltme: 0, + }, + { + ad: "site-koku-ile-markdown", + metin: "Oku: [OBP](https://kolaytercih.com/rehber/obp-nedir).", + bekle: `Oku: [${obp.baslik}](/rehber/${obp.slug}).`, + duzeltme: 1, + }, + ]; +} + +/** Metni verilen parça sayısına rastgele olmayan, sabit noktalardan böler. */ +function parcala(metin: string, adet: number): string[] { + const boy = Math.max(1, Math.ceil(metin.length / adet)); + const parcalar: string[] = []; + for (let i = 0; i < metin.length; i += boy) parcalar.push(metin.slice(i, i + boy)); + return parcalar; +} + +function main() { + const rehberler = getRehberAramaKayitlari(); + if (rehberler.length < 10) throw new Error(`rehber listesi şüpheli derecede kısa: ${rehberler.length}`); + let hata = 0; + const kontrol = (ad: string, tuttu: boolean, detay: string) => { + if (!tuttu) hata++; + console.log(`${tuttu ? "geçti" : "KALDI"} [${ad}]${tuttu ? "" : `\n ${detay}`}`); + }; + for (const s of senaryolar(rehberler)) { + const tek = rehberBaglantilariniDuzelt(s.metin, rehberler); + const beklendi = typeof s.bekle === "string" ? tek.metin === s.bekle : s.bekle(tek.metin); + kontrol(s.ad, beklendi && tek.duzeltmeler.length === s.duzeltme, `çıktı: ${tek.metin}\n düzeltme: ${JSON.stringify(tek.duzeltmeler)}`); + // Tampon: metin 1, 3, 4, 7 ve karakter karakter parçalanınca da aynı sonuç + for (const adet of [1, 3, 4, 7, s.metin.length]) { + const tampon = new BaglantiTamponu(rehberler); + let akis = ""; + for (const p of parcala(s.metin, adet)) akis += tampon.besle(p); + akis += tampon.bitir(); + kontrol( + `${s.ad} · tampon ${adet} parça`, + akis === tek.metin && tampon.duzeltmeler.length === tek.duzeltmeler.length, + `tampon: ${akis}\n tek seferlik: ${tek.metin}`, + ); + } + } + // Tampon bir bağlantı adayını kapanmadan bırakmamalı (yanlış hap ekrana gitmesin) + { + const tampon = new BaglantiTamponu(rehberler); + const ilk = tampon.besle("Bak: [Yatay geçiş](/rehber/yatay-gecis"); + const son = tampon.besle("-rehberi) iyi.") + tampon.bitir(); + kontrol("tampon-adayi-tutuyor", ilk === "Bak: " && !ilk.includes("/rehber/") && son.includes("/rehber/yatay-gecis-cap-yandal"), `ilk: ${ilk}\n son: ${son}`); + } + { + const tampon = new BaglantiTamponu(rehberler); + const ilk = tampon.besle("Ayrıntı /reh"); + const son = tampon.besle("ber/obp-nedir yazısında.") + tampon.bitir(); + kontrol("tampon-yarim-onek", ilk === "Ayrıntı " && son.includes("/rehber/obp-nedir-nasil-hesaplanir"), `ilk: ${ilk}\n son: ${son}`); + } + const toplam = senaryolar(rehberler).length * 6 + 2; + console.log(`\n${toplam - hata}/${toplam} senaryo geçti (mekanik test; gerçek model davranışını ölçmez)`); + process.exit(hata ? 1 : 0); +} +main(); diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index 3c1d018..70806e6 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -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/ 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; if (ad === "soru_sor" && c.anket) cevapMeta.anket = c.anket as SohbetMeta["anket"]; diff --git a/src/lib/ai/araclar.ts b/src/lib/ai/araclar.ts index 9f66127..f84c28f 100644 --- a/src/lib/ai/araclar.ts +++ b/src/lib/ai/araclar.ts @@ -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): 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.", }; } diff --git a/src/lib/ai/baglanti-denetimi.ts b/src/lib/ai/baglanti-denetimi.ts new file mode 100644 index 0000000..0099ae9 --- /dev/null +++ b/src/lib/ai/baglanti-denetimi.ts @@ -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/` adresini "Rehber yazısı" hapı + * yapıyor ve hap 404'e gidiyordu. Prompt "adres uydurma" diyor ama asıl + * savunma koddur: metindeki her `/rehber/` 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 { + 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, b: Set): 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/` 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; + } +} diff --git a/src/lib/ai/danisman-prompt.ts b/src/lib/ai/danisman-prompt.ts index 2855fe7..73b521b 100644 --- a/src/lib/ai/danisman-prompt.ts +++ b/src/lib/ai/danisman-prompt.ts @@ -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 diff --git a/src/lib/ai/sohbet-akisi.ts b/src/lib/ai/sohbet-akisi.ts index 08f7991..294765a 100644 --- a/src/lib/ai/sohbet-akisi.ts +++ b/src/lib/ai/sohbet-akisi.ts @@ -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/` 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 }) => { @@ -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)); From e3d79f582e632c0925bd05f683273743f8b22ba1 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Wed, 7 Oct 2026 23:16:34 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(revizyon):=20revizyon=20say=C4=B1s?= =?UTF-8?q?=C4=B1=20s=C4=B1n=C4=B1r=C4=B1=20kald=C4=B1r=C4=B1ld=C4=B1=20?= =?UTF-8?q?=E2=80=94=20tek=20ko=C5=9Ful=20paket=20+=20sohbet=20kredisi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX_REVIZYON silindi; liste-revizyon.ts'teki HAK kapısı ve RevizyonSonucu'nun "HAK" kodu kalktı, ozetCikar kalanHak döndürmüyor (sohbet-meta tipi de). reports.revision_count sayaç olarak kalır (göç yok). /listem: "Revize et" düğmesi paketlide hep görünür, "kalan hak" ifadeleri kalktı; sohbet-client prop'u 0/1 bayrak olarak besleniyor (entegrasyonda revizyonAcik olacak). Pazarlama metni: "Sınırsız liste revizyonu (danışmanla sohbetten)" (ana sayfa ve paket kartı). Paketsiz-kredili adayda paket şartı korunur. Co-Authored-By: Claude Fable 5.1 --- src/app/page.tsx | 4 +-- .../odeme/components/paket-satinal.tsx | 2 +- .../rapor/components/listem-govde.tsx | 31 +++++++++---------- .../rapor/components/listem-icerik.tsx | 3 +- .../rapor/components/sohbet-senaryo-verisi.ts | 4 +-- src/features/rapor/liste-revizyon.ts | 26 +++++----------- src/lib/ai/sohbet-meta.ts | 1 - src/lib/credits.ts | 3 -- 8 files changed, 27 insertions(+), 47 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 99d0104..ce82edb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -28,7 +28,7 @@ import { SectionEyebrow, } from "@/components/pixel-decor"; import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi"; -import { MAX_REVIZYON, URUNLER } from "@/lib/credits"; +import { URUNLER } from "@/lib/credits"; import { PROGRAM_SAYISI_CUMLE, PROGRAM_SAYISI_METNI, @@ -244,7 +244,7 @@ const paketOzellikleri = [ text: `Her tercih için yerleşme riski ve trend analizi (son ${VERI_YIL_SAYISI} yıl)`, }, { icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" }, - { icon: RefreshCcw, text: `${MAX_REVIZYON} liste revizyon hakkı` }, + { icon: RefreshCcw, text: "Sınırsız liste revizyonu (danışmanla sohbetten)" }, { icon: MessageCircleQuestion, text: `${URUNLER.paket.credits} yapay zekâ danışman sorusu`, diff --git a/src/features/odeme/components/paket-satinal.tsx b/src/features/odeme/components/paket-satinal.tsx index b8e0f49..2011579 100644 --- a/src/features/odeme/components/paket-satinal.tsx +++ b/src/features/odeme/components/paket-satinal.tsx @@ -34,7 +34,7 @@ const OZELLIKLER = [ text: `Her tercih için yerleşme riski ve trend analizi (son ${VERI_YIL_SAYISI} yıl)`, }, { icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" }, - { icon: RefreshCcw, text: "2 liste revizyon hakkı" }, + { icon: RefreshCcw, text: "Sınırsız liste revizyonu (danışmanla sohbetten)" }, { icon: MessageCircleQuestion, text: `${URUNLER.paket.credits} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`, diff --git a/src/features/rapor/components/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index 0aa55a6..3943711 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -75,7 +75,6 @@ export function ListemGovde({ danismanProfili, ozet, ilkYorum, - kalanHak, paketFiyati, paketKredi, }: { @@ -99,7 +98,6 @@ export function ListemGovde({ ozet?: DanismanOzeti; /** Sohbet boşken açılışta gösterilen 1. tercih yorumu */ ilkYorum?: IlkTercihYorumu; - kalanHak: number; paketFiyati: string; paketKredi: number; }) { @@ -216,12 +214,12 @@ export function ListemGovde({ } if (hasPaket) { satirlar.push( - `Listende değişiklik isteyebilirsin: danışman gerekirse kısa bir formla sorar, sonra listeyi yeniden kurar (kalan hak: ${kalanHak})`, + `Listende değişiklik isteyebilirsin: danışman gerekirse kısa bir formla sorar, sonra listeyi yeniden kurar`, ); } satirlar.push("Bu sohbetteki son mesajların"); return satirlar; - }, [adaySira, params, hasPaket, acikSatir, kalanHak]); + }, [adaySira, params, hasPaket, acikSatir]); // Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez). // Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları @@ -331,18 +329,16 @@ export function ListemGovde({ {/* Revizyon sohbette yaşar (7 Eki 2026): düğme danışman kutusunu odaklar, aday ne değişsin istediğini yazar; danışman gerekirse soru formuyla netleştirip listeyi - yeniden kurar. Hak bitince düğme kalkar. */} - {kalanHak > 0 ? ( - - ) : null} + yeniden kurar. Sayı sınırı yok (7 Eki 2026). */} +