+ {/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */}
+
diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx
index 03cff3b..0ef0b9c 100644
--- a/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx
+++ b/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx
@@ -5,6 +5,7 @@
// dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır.
import dynamic from "next/dynamic";
+import { AdimGostergesiIskeleti } from "@/components/ui/adim-gostergesi";
import { Skeleton } from "@/components/ui/skeleton";
function SihirbazAdimlarSkeleton() {
@@ -14,11 +15,8 @@ function SihirbazAdimlarSkeleton() {
aria-live="polite"
className="flex min-h-64 flex-col gap-5"
>
-
);
diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
index 2402443..b3cf895 100644
--- a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
+++ b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
@@ -11,9 +11,11 @@ import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel";
+import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
+import { AdimGostergesi } from "@/components/ui/adim-gostergesi";
import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "./il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita";
@@ -23,47 +25,8 @@ import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler";
import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum";
-
-export function Cip({
- secili,
- onClick,
- children,
- kucuk = false,
- ton = "notr",
- className = "",
-}: {
- secili: boolean;
- onClick: () => void;
- children: React.ReactNode;
- kucuk?: boolean;
- /**
- * bilgi: özel kontenjan durumu çipi (DESIGN.md §4.21) — pasif hâli bilgi
- * mavisi tonunda (§2 `primary` rolü), seçili hâli nötr çiple aynı.
- */
- ton?: "notr" | "bilgi";
- className?: string;
-}) {
- // Şişko ve sade (DESIGN.md §1.1): çip 44 px dokunma hedefi; il
- // çipleri (kucuk) çok sayıda olduğu için 40 px. Hover §2.1 tek turuncu.
- const boyut = kucuk ? "h-10 px-3.5 text-sm" : "h-11 px-4 text-sm";
- const temel = `inline-flex cursor-pointer items-center rounded-full border font-semibold transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] ${boyut} ${className}`;
- const pasif =
- ton === "bilgi"
- ? "border-primary/30 bg-primary/10 text-primary hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"
- : "border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500";
- return (
-
- );
-}
+// Seçim çipi tek kaynaktan (DESIGN.md §4.24): seçilince tik büyür, yazı kayar.
+import { CipGrubu, SecimCipi as Cip } from "@/components/ui/cip-grubu";
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
@@ -252,83 +215,26 @@ export function SihirbazAdimlar({
return (
@@ -533,7 +442,7 @@ export function SihirbazAdimlar({
{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda
yedi çip 44 px'le pencereyi 812 px'in dışına itiyordu. */}
-
+
{ONCELIKLER.map((o) => (
))}
-
+
Listeyi daraltmaz; programların sırasını ve gerekçelerini
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
@@ -559,7 +468,7 @@ export function SihirbazAdimlar({
Özel kontenjan durumun{" "}
(isteğe bağlı)
-
+
{OZEL_DURUMLAR.map((d) => (
))}
-
+
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
kontenjanı olan programlar listende öne çıkar ve danışman bunu
@@ -603,7 +512,8 @@ export function SihirbazAdimlar({
type="button"
onClick={() => adimaGit(adim + 1)}
disabled={!adimGecerli}
- className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
+ variant="cta"
+ className="h-11 px-5"
>
Devam
@@ -622,7 +532,8 @@ export function SihirbazAdimlar({
onTamamla({ kategoriler, iller, universiteTipi, oncelikler, ozelDurum });
}}
disabled={kategoriler.length === 0}
- className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
+ variant="cta"
+ className="h-11 px-5"
>
{sonButonEtiketi}
diff --git a/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx b/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx
index 70627d5..9ebfece 100644
--- a/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx
+++ b/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx
@@ -1,5 +1,6 @@
"use client";
+import { logoTepkisi } from "@/lib/logo-tepkisi";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -182,6 +183,8 @@ export function SihirbazBolumu({
*/
async function ilerle(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
+ // Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
+ logoTepkisi("sira");
await modalCikisiniOynat();
setElleAcik(false);
if (!girisliMi) {
diff --git a/src/lib/adim-gecisi.ts b/src/lib/adim-gecisi.ts
new file mode 100644
index 0000000..ac05ac0
--- /dev/null
+++ b/src/lib/adim-gecisi.ts
@@ -0,0 +1,66 @@
+// Adım adım ilerleyen panellerin ortak geçişi (DESIGN.md §6): sihirbazın
+// alan → il → öncelik adımları (sihirbaz-adimlar.tsx, TransitionPanel) ile
+// danışmanın soru formundaki sorular (sohbet-anketi.tsx) aynı yoldan gelir
+// gider — yeni adım ileri giderken sağdan, geri dönerken soldan kayar; boy
+// ölçülen yüksekliğe yayla uzar. Sabitler yalnız burada; iki yerde de bu iki
+// fonksiyon çağrılır, kopyası yazılmaz. Yay sekmesiz (stiffness 300 /
+// damping 30), opaklık 0,2 s. "Hareketi azalt"ta kayma ve boy yayı yok,
+// yalnız 0,12 s'lik çapraz solma.
+
+import type { Transition, Variants } from "motion/react";
+import { YAY } from "@/lib/hareket";
+
+/**
+ * enter / center / exit varyantları; `custom` geçiş yönüdür (1 ileri, -1
+ * geri). Giren panel akışta (`relative`) durur, çıkan panel mutlak konuma
+ * alınır ki ikisi aynı anda üst üste kayabilsin. `yukseklik` ölçülen boy
+ * (ilk ölçümden önce "auto").
+ */
+export function adimGecisiVaryantlari({
+ hareketiAzalt,
+ yukseklik,
+}: {
+ hareketiAzalt: boolean;
+ yukseklik: number | "auto";
+}): Variants {
+ return {
+ enter: (gecisYonu: number) => ({
+ x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
+ opacity: 0,
+ height: yukseklik,
+ position: "relative",
+ }),
+ center: {
+ zIndex: 1,
+ x: 0,
+ opacity: 1,
+ height: yukseklik,
+ position: "relative",
+ },
+ exit: (gecisYonu: number) => ({
+ zIndex: 0,
+ x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
+ opacity: 0,
+ position: "absolute",
+ top: 0,
+ left: 0,
+ width: "100%",
+ }),
+ };
+}
+
+/** Geçişin zamanlaması; azaltılmış harekette x/height açıkça sıfırlanır ki
+ * varsayılan yaya düşüp oynamasın. */
+export function adimGecisi(hareketiAzalt: boolean): Transition {
+ return hareketiAzalt
+ ? {
+ opacity: { duration: 0.12 },
+ x: { duration: 0 },
+ height: { duration: 0 },
+ }
+ : {
+ x: YAY.adim,
+ height: YAY.adim,
+ opacity: { duration: 0.2 },
+ };
+}
diff --git a/src/lib/ai/araclar.ts b/src/lib/ai/araclar.ts
index ee0f0d7..f9915a3 100644
--- a/src/lib/ai/araclar.ts
+++ b/src/lib/ai/araclar.ts
@@ -1,10 +1,16 @@
/**
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
*
- * İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
+ * Üç tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
- * (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
- * üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
+ * (src/lib/danisman-profil.ts); `soru_sor` belirsiz revizyon isteğini form
+ * olarak sorar (lib/ai/sohbet-meta.ts). Liste revizyonunun kendisi (9 Eki
+ * 2026'dan beri) modelin aracı DEĞİLDİR: adayın mesajındaki niyet kodla +
+ * yapılandırılmış çıkarımla okunur (revizyon-niyeti.ts) ve `listeRevizeKoddan`
+ * YAPISAL kuralla listeyi yeniden kurdurur (features/rapor/liste-revizyon.ts);
+ * model yalnız sonucu anlatır. Model listeyi yine kendisi kurmaz:
+ * üretim deterministik havuzda kalır (havuz-dışına-çıkamama kuralı); kural
+ * havuzu, iskeleti ve sırayı koddan değiştirir (lib/revizyon-kurali.ts).
*
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
*/
@@ -85,11 +91,18 @@ import {
} from "@/lib/danisman-profil";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
+import { anketDogrula, type Anket, type RevizyonOzeti } from "./sohbet-meta";
+import type { RevizyonKurali, ListeFarki } from "@/features/rapor/types/rapor";
+import type { RevizyonSonucu } from "@/features/rapor/liste-revizyon";
export type AracBaglami = {
userId: string;
sira: number;
tur: PuanTuruKey;
+ /** Paketli mi (liste revizyonu paket ister); verilmezse "paket yok" sayılır. */
+ hasPaket?: boolean;
+ /** Adayın bu turdaki mesajının kimliği: koddan revizyonun idempotency anahtarı. */
+ mesajId?: string;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
/** Sihirbaz öncelik çipleri (uyum puanı / öne çıkan için) */
@@ -405,8 +418,133 @@ export const ARACLAR: AracTanimi[] = [
},
},
},
+ {
+ type: "function",
+ function: {
+ name: "soru_sor",
+ description:
+ "Adaya ekranda tek tek gösterilen kısa bir SORU FORMU açar (en çok 3 soru, soru başına 2-6 seçenek). Liste revizyonu isteği belirsizken kullan: hangi il, kaç tercih, ekle mi yalnız mı, hangi dilim. Her soruya net, kısa seçenekler yaz; gerekirse serbest metin alanı ekle (serbest). Form gösterildikten sonra BAŞKA ARAÇ ÇAĞIRMA ve cevabını tek kısa cümleyle bitir; adayın cevabı sonraki mesajda 'Form cevaplarım' olarak gelir; sunucu o cevaptan kuralı çıkarıp listeyi kodla yeniden kurar, sen sonucu anlatırsın. Soru ve seçenek metinleri Türkçe karakterlerle yazılır (ş, ı, ğ, ö, ü, ç, İ: 'Vakıf', 'kalsın', 'İstanbul'); ASCII yazım ('Vakif', 'kalsin', 'Istanbul') yanlıştır.",
+ parameters: {
+ type: "object",
+ properties: {
+ baslik: { type: "string", description: "Formun kısa başlığı (isteğe bağlı), ör. 'Listeni nasıl değiştirelim?'" },
+ sorular: {
+ type: "array",
+ description: "1-3 soru; sırayla gösterilir.",
+ items: {
+ type: "object",
+ properties: {
+ ad: { type: "string", description: "Cevap anahtarı: küçük harf, rakam, tire (ör. 'il', 'adet')." },
+ soru: { type: "string", description: "Adaya gösterilen soru metni." },
+ aciklama: { type: "string", description: "Sorunun altında küçük açıklama (isteğe bağlı)." },
+ secenekler: {
+ type: "array",
+ description: "2-6 seçenek.",
+ items: {
+ type: "object",
+ properties: {
+ deger: { type: "string", description: "Cevap değeri (kısa, ör. 'istanbul', '5')." },
+ etiket: { type: "string", description: "Adaya görünen metin." },
+ aciklama: { type: "string", description: "Seçeneğin altında küçük açıklama (isteğe bağlı)." },
+ },
+ required: ["deger", "etiket"],
+ additionalProperties: false,
+ },
+ },
+ coklu: { type: "boolean", description: "true: birden çok seçenek işaretlenebilir." },
+ serbest: { type: "string", description: "Verilirse serbest metin alanı eklenir; değer yer tutucudur (ör. 'Başka bir il yaz…')." },
+ zorunlu: { type: "boolean", description: "false: boş geçilebilir (varsayılan true)." },
+ },
+ required: ["ad", "soru", "secenekler"],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ["sorular"],
+ additionalProperties: false,
+ },
+ },
+ },
];
+/**
+ * Liste revizyonu kuralının JSON şeması (OpenAI tool `parameters` biçimi).
+ * 7-9 Eki 2026 arası `liste_revize` adlı bir sohbet aracıydı; model aracı
+ * çağırmadan "listeni yeniden kurdum" yazabildiği için (QA B1) araç sohbet
+ * modelinin listesinden ÇIKARILDI. Şema şimdi yapılandırılmış niyet
+ * çıkarımının tel şemasında yeniden kullanılır (revizyon-niyeti.ts);
+ * revizyonu kod başlatır, model yalnız sonucu anlatır.
+ */
+export const LISTE_REVIZE_PARAMETRELERI = {
+ type: "object",
+ properties: {
+ iller: {
+ type: "object",
+ description: "Şehir tercihi farkı. yalniz: listede sadece bu iller; ekle: mevcut illere eklenir; cikar: mevcut illerden düşer. İl adı Türkçe (İstanbul, Ankara…).",
+ properties: {
+ yalniz: { type: "array", items: { type: "string" } },
+ ekle: { type: "array", items: { type: "string" } },
+ cikar: { type: "array", items: { type: "string" } },
+ },
+ additionalProperties: false,
+ },
+ kategoriler: {
+ type: "object",
+ description: `İlgi alanı farkı; adlar tam olarak şunlardan: ${KATEGORILER.join(" | ")}.`,
+ properties: {
+ yalniz: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
+ ekle: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
+ cikar: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
+ },
+ additionalProperties: false,
+ },
+ universiteTipi: { type: "string", enum: ["devlet", "vakif", "farketmez"], description: "Yalnız devlet / yalnız vakıf / fark etmez." },
+ iskelet: {
+ type: "string",
+ enum: ["standart", "guvenli-agirlikli", "hayal-agirlikli"],
+ description: "Dilim dağılımı: standart 5 hayal/13 dengeli/6 güvenli; guvenli-agirlikli 4/12/8 ('daha güvenli olsun'); hayal-agirlikli 7/12/5 ('daha iddialı olsun').",
+ },
+ ilkTercihler: {
+ type: "object",
+ description: "'İlk N tercih şu ölçüte uysun' (ör. ilk 5 İstanbul). En az bir ölçüt ver.",
+ properties: {
+ adet: { type: "integer", minimum: 1, maximum: 24 },
+ iller: { type: "array", items: { type: "string" } },
+ kategoriler: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
+ universiteTipi: {
+ type: "string",
+ enum: ["devlet", "vakif"],
+ description: "Yalnız ilk N tercihe özgü tip kısıtı ('ilk 5 devlet olsun, gerisi fark etmez'). 'Vakıf olmasın / sadece devlet' gibi genel istek buraya değil üst düzey universiteTipi'ne yazılır.",
+ },
+ },
+ required: ["adet"],
+ additionalProperties: false,
+ },
+ cikar: {
+ type: "object",
+ description: "Listeye girmeyecekler: üniversite adı parçası, bölüm adı parçası ya da program kimliği (listedeki 'id').",
+ properties: {
+ universiteler: { type: "array", items: { type: "string" } },
+ bolumler: { type: "array", items: { type: "string" } },
+ programIdler: { type: "array", items: { type: "string" } },
+ },
+ additionalProperties: false,
+ },
+ ekle: {
+ type: "object",
+ description: "Listeye mutlaka girecek program kimlikleri (program_ara sonucundaki id; uydurma).",
+ properties: { programIdler: { type: "array", items: { type: "string" } } },
+ required: ["programIdler"],
+ additionalProperties: false,
+ },
+ not: { type: "string", description: "Adayın isteğinin bir cümlelik özeti (liste üretimine gider)." },
+ },
+ additionalProperties: false,
+} as const;
+
+/** soru_sor aracının argüman şeması; niyet çıkarımı formu da bu şemayla ister (revizyon-niyeti.ts). */
+export const SORU_SOR_PARAMETRELERI = (ARACLAR.find((a) => a.type === "function" && a.function.name === "soru_sor") as Extract).function.parameters as Record;
+
// ── Yardımcılar ──────────────────────────────────────────────────────────
/** Adayın sırası programın başarı sırası barajını geçemiyor mu (baraj boşsa: hayır). */
@@ -1480,6 +1618,228 @@ function alanHatasi(ad: string, s: AlanSemasi, v: unknown): string | null {
return null;
}
+// ── Sohbetten liste revizyonu ────────────────────────────────────────────
+
+/** İl adını başlığa uygun yazım: "İSTANBUL" → "İstanbul", "AFYONKARAHİSAR" → "Afyonkarahisar". */
+function ilBaslikYazimi(il: string): string {
+ const k = il.toLocaleLowerCase("tr-TR");
+ return k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1);
+}
+
+/**
+ * Metindeki ASCII'ye düşmüş il adlarını düzeltir: "Istanbul" → "İstanbul",
+ * "Eskisehir" → "Eskişehir", "Izmir/Ankara" → "İzmir/Ankara". Model araç
+ * argümanlarında (soru_sor formu) Türkçe harfleri sık düşürüyor (QA B4,
+ * 8 Eki 2026). Yalnız büyük harfle başlayan ve bir il adıyla tam
+ * (katlanmış) eşleşen sözcükler değişir; "ordu", "van" gibi küçük harfli
+ * sözcükler ve öneki eşleşen kısaltmalar ("Afyon") dokunulmaz. Sözlük
+ * dışındaki sözcükler ("Vakif", "kalsin") prompt kuralına bırakılır.
+ */
+export function ilAdlariniDuzelt(metin: string): string {
+ return metin.replace(/\p{Lu}\p{L}+/gu, (soz) => {
+ const il = ilAdiNormalize(soz);
+ if (!il || turkishSlugify(il) !== turkishSlugify(soz)) return soz;
+ const dogru = ilBaslikYazimi(il);
+ return soz === dogru || soz === il ? soz : dogru;
+ });
+}
+
+function anketIlAdlariniDuzelt(anket: Omit): Omit {
+ return {
+ ...(anket.baslik ? { baslik: ilAdlariniDuzelt(anket.baslik) } : {}),
+ sorular: anket.sorular.map((s) => ({
+ ...s,
+ soru: ilAdlariniDuzelt(s.soru),
+ ...(s.aciklama ? { aciklama: ilAdlariniDuzelt(s.aciklama) } : {}),
+ ...(s.serbest ? { serbest: ilAdlariniDuzelt(s.serbest) } : {}),
+ secenekler: s.secenekler.map((o) => ({
+ ...o,
+ etiket: ilAdlariniDuzelt(o.etiket),
+ ...(o.aciklama ? { aciklama: ilAdlariniDuzelt(o.aciklama) } : {}),
+ })),
+ })),
+ };
+}
+
+/** `soru_sor`: formu denetler, kimlik verir; metin ve kayıt çağıranın (route) işi. */
+function soruSor(a: Record): { soruldu: true; anket: Anket; not: string } | { hata: string } {
+ const d = anketDogrula(a);
+ if (!d.ok) return { hata: `Form geçersiz — ${d.hata}. Düzeltip yeniden çağır.` };
+ return soruFormuSonucu(d.anket);
+}
+const SORU_FORMU_NOTU =
+ "Form adaya gösterildi. Şimdi BAŞKA ARAÇ ÇAĞIRMA, formu metin olarak tekrar yazma; tek kısa cümleyle bitir (ör. 'Birkaç kısa soru, sonra listeni yeniden kurayım.'). Cevaplar sonraki mesajda 'Form cevaplarım' olarak gelir.";
+
+type Normalizasyon = { ok: true; deger: string[] } | { ok: false; hata: string };
+
+function illeriNormalize(ham: unknown, alan: string): Normalizasyon {
+ if (ham == null) return { ok: true, deger: [] };
+ if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
+ const sonuc: string[] = [];
+ for (const x of ham) {
+ if (typeof x !== "string") return { ok: false, hata: `${alan}: il adı metin olmalı` };
+ const il = ilAdiNormalize(x);
+ if (!il) return { ok: false, hata: `${alan}: "${x.slice(0, 30)}" il olarak tanınmadı; Türkçe il adı yaz (ör. İstanbul, İzmir)` };
+ if (!sonuc.includes(il)) sonuc.push(il);
+ }
+ return { ok: true, deger: sonuc };
+}
+
+function kategorileriNormalize(ham: unknown, alan: string): Normalizasyon {
+ if (ham == null) return { ok: true, deger: [] };
+ if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
+ const sonuc: string[] = [];
+ for (const x of ham) {
+ if (typeof x !== "string" || !(KATEGORILER as readonly string[]).includes(x)) {
+ return { ok: false, hata: `${alan}: "${String(x).slice(0, 30)}" geçerli bir ilgi alanı değil; şunlardan biri olmalı: ${KATEGORILER.join(", ")}` };
+ }
+ if (!sonuc.includes(x)) sonuc.push(x);
+ }
+ return { ok: true, deger: sonuc };
+}
+
+function farkNormalize(
+ ham: unknown,
+ alan: string,
+ normalize: (x: unknown, alan: string) => Normalizasyon,
+): { ok: true; fark?: ListeFarki } | { ok: false; hata: string } {
+ if (ham == null) return { ok: true };
+ if (typeof ham !== "object" || Array.isArray(ham)) return { ok: false, hata: `${alan}: nesne bekleniyor` };
+ const h = ham as Record;
+ const fark: ListeFarki = {};
+ for (const anahtar of ["yalniz", "ekle", "cikar"] as const) {
+ if (h[anahtar] == null) continue;
+ const n = normalize(h[anahtar], `${alan}.${anahtar}`);
+ if (!n.ok) return n;
+ if (n.deger.length) fark[anahtar] = n.deger;
+ }
+ if (fark.yalniz && fark.yalniz.length > 5) return { ok: false, hata: `${alan}.yalniz: en fazla 5` };
+ return { ok: true, fark: Object.keys(fark).length ? fark : undefined };
+}
+
+function metinListesi(ham: unknown, alan: string, ust = 10): Normalizasyon {
+ if (ham == null) return { ok: true, deger: [] };
+ if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
+ const sonuc = ham
+ .filter((x): x is string => typeof x === "string")
+ .map((x) => x.replace(/\s+/g, " ").trim().slice(0, 80))
+ .filter(Boolean);
+ return { ok: true, deger: [...new Set(sonuc)].slice(0, ust) };
+}
+
+/**
+ * Yapısal revizyon argümanını (LISTE_REVIZE_PARAMETRELERI biçiminde)
+ * RevizyonKurali'na çevirir; il/alan adları doğrulanır, bilinmeyen il ya da
+ * alan hata döner. Niyet çıkarımı (revizyon-niyeti.ts) kullanır.
+ */
+export function revizyonKuraliKur(a: Record): { ok: true; kural: RevizyonKurali } | { ok: false; hata: string } {
+ const kural: RevizyonKurali = {};
+ const iller = farkNormalize(a.iller, "iller", illeriNormalize);
+ if (!iller.ok) return iller;
+ if (iller.fark) kural.iller = iller.fark;
+ const kategoriler = farkNormalize(a.kategoriler, "kategoriler", kategorileriNormalize);
+ if (!kategoriler.ok) return kategoriler;
+ if (kategoriler.fark) kural.kategoriler = kategoriler.fark;
+ if (a.universiteTipi != null) kural.universiteTipi = a.universiteTipi as RevizyonKurali["universiteTipi"];
+ if (a.iskelet != null) kural.iskelet = a.iskelet as RevizyonKurali["iskelet"];
+ if (a.ilkTercihler != null) {
+ const o = a.ilkTercihler as Record;
+ const adet = Math.round(Number(o.adet));
+ const il = illeriNormalize(o.iller, "ilkTercihler.iller");
+ if (!il.ok) return il;
+ const kat = kategorileriNormalize(o.kategoriler, "ilkTercihler.kategoriler");
+ if (!kat.ok) return kat;
+ const tip = o.universiteTipi as "devlet" | "vakif" | undefined;
+ if (!il.deger.length && !kat.deger.length && !tip) {
+ return { ok: false, hata: "ilkTercihler: en az bir ölçüt gerekli (iller, kategoriler ya da universiteTipi)" };
+ }
+ kural.ilkTercihler = {
+ adet: Math.min(24, Math.max(1, adet || 1)),
+ ...(il.deger.length ? { iller: il.deger } : {}),
+ ...(kat.deger.length ? { kategoriler: kat.deger } : {}),
+ ...(tip ? { universiteTipi: tip } : {}),
+ };
+ }
+ if (a.cikar != null) {
+ const c = a.cikar as Record;
+ const uni = metinListesi(c.universiteler, "cikar.universiteler");
+ const bol = metinListesi(c.bolumler, "cikar.bolumler");
+ const idler = metinListesi(c.programIdler, "cikar.programIdler", 24);
+ if (!uni.ok) return uni;
+ if (!bol.ok) return bol;
+ if (!idler.ok) return idler;
+ if (uni.deger.length || bol.deger.length || idler.deger.length) {
+ kural.cikar = {
+ ...(uni.deger.length ? { universiteler: uni.deger } : {}),
+ ...(bol.deger.length ? { bolumler: bol.deger } : {}),
+ ...(idler.deger.length ? { programIdler: idler.deger } : {}),
+ };
+ }
+ }
+ if (a.ekle != null) {
+ const idler = metinListesi((a.ekle as Record).programIdler, "ekle.programIdler", 10);
+ if (!idler.ok) return idler;
+ if (idler.deger.length) kural.ekle = { programIdler: idler.deger };
+ }
+ if (typeof a.not === "string" && a.not.trim()) kural.not = a.not.replace(/\s+/g, " ").trim().slice(0, 300);
+ const { not: _not, ...kalan } = kural;
+ void _not;
+ if (Object.keys(kalan).length === 0) {
+ return { ok: false, hata: "en az bir kural gerekli (iller, kategoriler, universiteTipi, iskelet, ilkTercihler, cikar ya da ekle); yalnız 'not' yetmez." };
+ }
+ return { ok: true, kural };
+}
+
+/**
+ * Liste revizyonunu KODDAN çalıştırır (features/rapor/liste-revizyon.ts —
+ * dinamik import: eval betikleri bu modülü Next dışında yükler, server-only
+ * bağımlılık orada eager yüklenmesin). Çağıran: api/soru/route.ts, adayın
+ * mesajı revizyon niyeti taşıyıp kural çıkarıldığında (revizyon-niyeti.ts).
+ * Dönen nesne sohbet modeline "sunucu işlemi sonucu" olarak verilir ve
+ * ekrana `tool-liste_revize` parçası olarak gider (sohbet-akisi.ts); biçim
+ * eski aracın sonucuyla aynı. Paket/kilit/kredi kapıları çekirdekte.
+ */
+export async function listeRevizeKoddan(b: AracBaglami, kural: RevizyonKurali): Promise {
+ if (!b.hasPaket) {
+ return { hata: "Liste revizyonu Tercih Dönemi Paketi'ne dâhil; aday paketsiz. Nazikçe söyle: tam liste ve revizyon /paket ile açılır; açık satırlar üzerinden yardımcı olmaya devam et." };
+ }
+ if (!b.mesajId) return { hata: "Revizyon bu bağlamda çalıştırılamıyor." };
+ const { listeRevizeCekirdek } = await import("@/features/rapor/liste-revizyon");
+ const sonuc = await listeRevizeCekirdek({
+ userId: b.userId,
+ requestId: b.mesajId,
+ kural,
+ });
+ return listeRevizeSonucuBicimle(sonuc);
+}
+
+export type ListeRevizeSonucu =
+ | (Extract & { not: string })
+ | { hata: string; not?: string };
+
+/** Çekirdek sonucunu modele giden biçime çevirir (eval betikleri de kullanır). */
+export function listeRevizeSonucuBicimle(sonuc: RevizyonSonucu): ListeRevizeSonucu {
+ if (!sonuc.ok) {
+ return {
+ hata: sonuc.neden,
+ 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. Sayı uydurma; yukarıdaki alanları kullan.",
+ };
+}
+
+/** Koddan açılan soru formunun modele/ekrana giden sonucu (soru_sor aracının sonucuyla aynı biçim). */
+export function soruFormuSonucu(anket: Omit): { soruldu: true; anket: Anket; not: string } {
+ return {
+ soruldu: true,
+ anket: { id: crypto.randomUUID(), ...anketIlAdlariniDuzelt(anket) },
+ not: SORU_FORMU_NOTU,
+ };
+}
+
/**
* Argümanı aracın ARACLAR'daki şemasına göre denetler: bilinmeyen alan,
* yanlış tip, geçersiz seçenek, alt sınır, eksik zorunlu alan. Hata varsa
@@ -1548,6 +1908,8 @@ export function aracCalistirici(b: AracBaglami) {
return rehberOku(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
+ case "soru_sor":
+ return soruSor(a);
default:
return { hata: `Bilinmeyen araç: ${ad}` };
}
diff --git a/src/lib/ai/baglanti-denetimi.ts b/src/lib/ai/baglanti-denetimi.ts
new file mode 100644
index 0000000..a41b0ad
--- /dev/null
+++ b/src/lib/ai/baglanti-denetimi.ts
@@ -0,0 +1,261 @@
+/**
+ * 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).
+ *
+ * İç 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.
+ */
+
+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",
+);
+
+/**
+ * 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));
+}
+
+/** 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;
+}
+
+/**
+ * Ö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).
+ */
+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 = 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);
+ 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";
+ let parantez = m.indexOf(")", kapanis + 2);
+ if (parantez === -1) return m.length - (kapanis + 2) > ADRES_UST ? "degil" : "bekle";
+ // 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);
+ 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/cagri.ts b/src/lib/ai/cagri.ts
index 59f9e8b..d7e37dc 100644
--- a/src/lib/ai/cagri.ts
+++ b/src/lib/ai/cagri.ts
@@ -81,6 +81,20 @@ const URETIM_ZAMAN_ASIMI_MS = 60_000;
* Kalanlar arasında yönlendirme OpenRouter'da kalır.
*/
const DISLAMA_SURESI_MS = 6 * 60 * 60_000;
+
+/**
+ * Düşünme (reasoning) ayarı. Varsayılan "none": görev küçük, hibrit model
+ * düşünmeye bütçe yemesin. Bazı modeller/uç noktalar "none"ı 400 ile
+ * reddediyor ("Reasoning is mandatory", ölçüm 10 Eki 2026: glm-5.3-flash,
+ * gpt-5-nano); model değişiminde AI_REASONING_EFFORT ile "minimal"/"low"
+ * verilir, "yok" alanı hiç göndermez.
+ */
+function dusunmeAyari(): { reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" } {
+ const v = process.env.AI_REASONING_EFFORT?.trim();
+ if (v === "yok") return {};
+ if (v === "minimal" || v === "low" || v === "medium" || v === "high") return { reasoning_effort: v };
+ return { reasoning_effort: "none" };
+}
const globalForCagri = globalThis as unknown as {
__aiDislananSaglayicilar?: Map