feat(sohbet): liste revizyonu sohbete taşındı — soru formu ve yapısal kural

Eski revizyon kutusu kaldırıldı: serbest metin modele gidiyor, sıra ve havuz
koddan belirlendiği için "ilk 5 X ilinden olsun" gibi isteklerin karşılığı
yoktu. Danışman artık belirsiz isteği soru formuyla (soru_sor → shadcn
Questionnaire, proje diline çevrildi) netleştirir, sonra liste_revize ile
yapısal kural verir: il/alan/tip farkı, "ilk N" ölçütü, çıkar/ekle,
iskelet (lib/revizyon-kurali.ts). Kurallar havuz, seçim ve sırada koddan
uygulanır; "ilk N" ölçütünün ili/alanı süzgece kendiliğinden eklenir.

- chat_messages.meta sütunu (göç 0006): form, form cevabı ve revizyon sonucu
  kayıttan geri kurulur; form cevabı kredi harcamaz.
- Akış korumaları: form açılınca başka araç yok, revizyonda zaman aşımı uzar,
  "yeniden kurdum" beyanı araçsızsa düzeltme notu.
- /listem "Revize et" sohbeti odaklar; başarılı revizyonda liste yenilenir.
- /dev/sohbet senaryosu, DESIGN.md §4.20 notu ve §4.33.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-07 17:12:53 +03:00
parent 3c995402c6
commit 4d30dd1305
29 changed files with 3392 additions and 414 deletions

View File

@@ -245,10 +245,10 @@ ve kimlik hapları (§4.12) bu kuralın dışındadır. Rozet **sayfa** bölüm
### 4.3 Modal kapatma çarpısı — `DialogContent` varsayılanı, `src/components/ui/dialog.tsx`
`absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]` + `<XIcon className="size-4" aria-hidden />` + `sr-only "Kapat"`.
Özel yerleşim gerekiyorsa `showCloseButton={false}` ver ve **aynı sınıf setini** kopyala.
Alt sayfada (`Drawer`) aynı sınıf seti `DrawerClose` üstünde kullanılır (§4.10).
Alt sayfada (`AltSayfa`) aynı sınıf seti `AltSayfaKapat` üstünde kullanılır (§4.10).
### 4.4 Kart ve kutu
- Landing/öne çıkan kart: `<Card className="relative h-full overflow-hidden border-slate-200/70 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">` + sağ üstte `<PixelCorner className="absolute right-4 top-4" />`. (Kodda hâlâ `hover:border-primary/30 hover:shadow-primary/5`; §2.1 geçiş notu.)
- Landing/öne çıkan kart = **sahne kartı** (§4.31): `flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm transition-[translate,border-color,box-shadow] duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5`; üstte piksel sahne, altında başlık + metin. Eski hâli (ikon kutusu + başlık + metin + köşede `PixelCorner`) 7 Eki 2026'da kaldırıldı (Bilal: "sıkıcı kart tasarımları"); yeni yerde yazılmaz. `PixelCorner` giriş/doğrulama kartlarının köşe süsü olarak kalır.
- Sade kutu (form, panel, liste satırı): `rounded-2xl border border-slate-200 bg-card` (+ `p-5`/`p-6`). Küçük iç kutu: `rounded-xl border border-slate-200 bg-slate-50`. Not kutusu: `rounded-lg`.
- İkon kutusu: `flex size-11 items-center justify-center rounded-xl bg-primary/10` içinde `size-5 text-primary` ikon. Kart içinde sayının yanında küçük hâli: `size-8 … sm:size-10` + `size-4 sm:size-5` ikon (§4.8).
- Gölge: `shadow-sm` varsayılan; hover'da `shadow-lg shadow-orange-500/5`. Başka gölge yok.
@@ -318,16 +318,19 @@ yanındaki 2×2).
(KKTC uyruklu, açıköğretim ve uzaktan satırları yelpazeye girmez; doluluk bütün satırlar.
KKTC üniversitesinin kendi sayfasında kıyas evreni kurumun kendisidir: il/kampüs dışlaması kalkar,
ad kuralı kalır — yoksa DAÜ gibi kurumlarda kartlar "henüz taban oluşmadı" der, 6 Eki 2026 QA).
Doluluk yüzdesi boş koltuk varken 100'e yuvarlanmaz, en çok 99 yazar.
- **Bölüm** (`grid grid-cols-2 gap-4 lg:grid-cols-4`, haritası yok): En seçici (sıra +
üniversite adı linkli) · En geniş kapı (ücretsiz devlet örgün programı ≥3 ise devlet
kümesinde, açıklama "Devlet · Bingöl Üni. · 2026 taban sırası"; etiket "Devlette en geniş"
131 px'te iki satıra kırıldığı için kısa tutulur) · Orta taban (devlet programlarını sıraya
dizince ortadakinin sırası; açıklamada "Devlet · 2021'de 105 bin · gevşedi") · Boş koltuk.
Doluluk yüzdesi boş kontenjan varken 100'e yuvarlanmaz, en çok 99 yazar.
- **Bölüm** (`grid grid-cols-2 gap-4 lg:grid-cols-3`, telefonda tek kalan üçüncü kart
`col-span-2`; haritası yok): En yüksek taban (sıra + üniversite adı linkli) · En düşük taban
(ücretsiz devlet örgün programı ≥3 ise devlet kümesinde, açıklama "Devlet · Bingöl Üni. ·
2026 taban sırası"; etiket "Devlette en düşük" 131 px'te iki satıra kırıldığı için kısa
tutulur) · Boş kontenjan. Orta taban kartı 6 Eki 2026'da kaldırıldı (Bilal: fazla geldi);
hesabı `taban-ozeti.ts`'te duruyor. Etiketlerde "en seçici" ve "en geniş kapı" denmez
("En yüksek / En düşük taban" öğrencinin kendi dili); kullanıcıya dönük metinde "koltuk"
değil **kontenjan** yazılır.
Kartın içinde piksel çizgi/seri **yok** (Bilal, 6 Eki 2026: "çok boğdu"); yön kelimesi yeter.
- **Üniversite** (harita yanındaki 2×2): En seçici bölüm (sıra + bölüm adı linkli) · Orta
taban (lisans programlarının ortası) · Doluluk (`%97`) · Türkiye sırası (URAP; listede
olmayan kurumda Kontenjan).
- **Üniversite** (harita yanındaki 2×2): En yüksek taban (sıra + bölüm adı linkli) · En düşük
taban (aynı biçim) · Doluluk (`%97`) · Türkiye sırası (URAP; listede olmayan kurumda
Kontenjan).
- Sayının yanındaki ad (`KartBaglantisi`, `taban-karti.tsx`) karşı sayfaya gider: bölümde
üniversiteye, üniversitede bölüme; stil kaynak linkiyle aynı (`font-medium text-primary
hover:text-orange-500 hover:underline`). Üniversite adı kısaltılır ("Koç Üni.", "… MYO").
@@ -353,10 +356,26 @@ yanındaki 2×2).
en üstte. Bugün her sayfada tek satır var (üniversite: "Akademik göstergeler"; bölüm: "Mezun
istihdamı").
### 4.10 Alt sayfa (Drawer) içeriği
Ayrıntı her zaman **alttan açılan sayfada** (`Drawer handleOnly`, tutamaçlı); ekranın ortasında
### 4.10 Alt sayfa — `AltSayfa`, `src/components/ui/alt-sayfa.tsx`
**Kabuk tek kaynak** (Bilal, 7 Eki 2026: "bottom drawer yerine bottom sheet"): eski vaul çekmecesi
kaldırıldı; yerinde Arc UI "Bottom sheet"ten taşınan `AltSayfa` var. Sayfa parmağı birebir izler:
tutamaçtan, başlık alanından (`AltSayfaSurukle`) ya da içerik en üstteyken gövdeden aşağı
çekince kapanır; yukarı çekince lastik gibi direnir; bırakınca hızını taşıyarak durağına oturur;
kapanırken yakalanıp geri açılabilir. Perde zamanlayıcıyla değil sayfanın konumuyla koyulaşır.
Parçalar: `AltSayfa` (kök) · `AltSayfaTetik` · `AltSayfaIcerik` (perde + yüzey + tutamaç) ·
`AltSayfaGovde` (**kaydırılan alan mutlaka budur**; sayfa onun kaydırmasıyla el sıkışır) ·
`AltSayfaSurukle` (kaydırmayan başlık alanı) · `AltSayfaBaslik` / `AltSayfaAciklama` ·
`AltSayfaKapat`. Yüzey sınıfı bileşenin içindedir (`rounded-t-3xl border border-b-0
border-slate-200 bg-card shadow-2xl sm:max-w-2xl`, tavan `92dvh`); kullanım yerinde kap sınıfı
**yazılmaz**. Boy içeriğe göredir. `duraklar` (ekran yüksekliğinin oranı) yalnız uzun, kaydırılan
içerikte verilir: program ayrıntısı `[0.6, 0.92]` ile %60'ta açılır, yukarı çekince ya da
kaydırınca tam boya çıkar (§4.19). İçinde kendi sürüklemesi olan öğe (sıralama tutamağı)
`data-alt-sayfa-yoksay` alır. Bildirimdeki düğmeye basmak sayfayı kapatmaz. Hareketi azalt:
kayma yok, yalnız opaklık.
Ayrıntı her zaman **alttan açılan sayfada** (`AltSayfa`, tutamaçlı); ekranın ortasında
açılan modal değil. Çekmece kabı ve çarpı `cta-sira-form.tsx`'teki çekmeceyle birebir:
`DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"`.
`<AltSayfaIcerik>` + §4.3 çarpısı + `<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">`.
İçerik kuralları (Apple ayrıntı sayfası):
- **Her ekranda ortalı:** başlık `px-10 text-center font-heading text-2xl font-bold`, altında
@@ -377,8 +396,8 @@ açılan modal değil. Çekmece kabı ve çarpı `cta-sira-form.tsx`'teki çekme
tek istisnası); alt bandı telefonda alt alta dizilir ki listeye en az ~280 px kalsın.
- **Rozet yok, ek liste yok** (Bilal, 24 Eyl 2026: "buralarda badge kullanma"): çekmece ve
sihirbaz modallarının başlığı üstünde `SectionEyebrow` durmaz; başlık + tek cümle bağlam yeter.
"Saniyede gör" çekmecesi (`cta-sira-form.tsx`) bu düzene çevrildi: `DrawerTitle` ortalı,
`DrawerDescription` bağlam, altında form; "neler alacaksın" çizgili ikon listesi kaldırıldı
"Saniyede gör" çekmecesi (`cta-sira-form.tsx`) bu düzene çevrildi: `AltSayfaBaslik` ortalı,
`AltSayfaAciklama` bağlam, altında form; "neler alacaksın" çizgili ikon listesi kaldırıldı
(bağlam cümlesi zaten söylüyor). Sihirbaz adımlarında çekmece başlığı `sr-only`, görünür
başlık adımın kendisinde. Denetim: `modal-rozet` (hata).
- **Arama motoru uyarısı:** çekmece içeriği sayfa açılırken HTML'de yoktur (kapalıyken
@@ -493,13 +512,30 @@ tablosu bu sabitleri kullanır, sınıfı elle yazmaz.
- Tür çipi devlet/vakıf için de aynı slate çip; vakıf violet değildir (§2.2).
- Tablo başlığı `h-12 text-sm font-semibold text-slate-500`; hücre dolgusu `py-4 pl-4 sm:pl-5`.
### 4.16 Bildirim (toast) — `src/components/ui/sonner.tsx` + `globals.css`
Tema **sitenin temasını izler** (`useTheme().resolvedTheme`, §2.3), sistemin değil: sistem koyu
temadayken sonner açıklamayı beyaza yakın griye çevirip beyaz zeminde okunmaz ediyordu (Bilal,
24 Eyl 2026: "toast açıklaması okunmuyor"). Kutu `rounded-2xl border-slate-200`; başlık `font-semibold text-slate-900`; açıklama
### 4.16 Bildirim yığını (toast) — `src/components/ui/bildirim.tsx`
Bilal (7 Eki 2026): "toastlarımız için Toast stack kullanalım." `sonner` kaldırıldı; yerinde Arc UI
"Toast stack"ten taşınan `BildirimYigini` (layout'ta bir kez) ve aynı çağrı biçimli `toast` var
(`toast.success(başlık, { description, action, id, duration })` — yalnız import yolu değişti).
Bildirimler **üst ortada** birikir (altta çekmecenin çağrı bandı ve telefonda klavye var): yenisi
yukarıdan iner, eskiler arkasına saklanıp altından 12 px şerit gösterir, en çok üçü görünür;
üzerine gelince ya da dokununca yığın listeye açılır ve süre durur; sağa kaydırınca kapanır.
Aynı `id` yeni bildirim açmaz, olanı yerinde günceller (metin dönüşür). Renkler palet token'ı
olduğundan tema kendiliğinden doğru (eski sorun: sonner sistem temasına bakıp açıklamayı okunmaz
ediyordu — Bilal, 24 Eyl 2026). Tür ikonu: başarı `emerald-600`, bilgi `primary`, uyarı
`amber-600`, hata `red-600`, sürüyor dönen `Loader2 text-primary`; tür adı ekran okuyucuya yazıyla söylenir. Kutu `rounded-2xl border-slate-200`; başlık `font-semibold text-slate-900`; açıklama
`text-slate-600`; aksiyon düğmesi üst çubuktaki koyu hap (`h-9 rounded-full bg-slate-900`,
yazı `slate-50`, hover `slate-700`). Metin §10.4: ne oldu + ne yapılacak; kilometre taşı toast'ları
(`program-ekle-butonu.tsx`) tek cümle açıklama + tek aksiyon taşır.
**İç düzen — tek hiza (Arc'ın düzeni; Bilal, 7 Eki 2026: "Arc'taki gibi hizalı görünüm
olmuyor").** İkon, başlığın ilk satırı, aksiyon hapı ve çarpı aynı 36 px'lik şeridin ortasına
oturur: içerik `flex items-start gap-3 py-1.5 pr-1.5 pl-4`; ikon kutusu `h-9 w-5` (ikon `size-5`);
metin `py-2 text-sm leading-5`; aksiyon hapı ve çarpı `h-9` / `size-9`, sağda `gap-1` tek grup.
Tek satırlık bildirim 48 px'tir ve her şey kutunun tam ortasındadır; çok satırlıda ikon ve
çarpı ilk satırla hizalı kalır. **Aksiyonun yeri içeriğe göre:** açıklama yoksa hap başlığın
yanında, sağda ("Çıkarıldı · Geri al" tek satır); açıklama varsa metnin altında, metinle aynı
sol hizada (`mt-2.5`) — yanında durursa Türkçe açıklamayı üç-dört satıra sıkıştırır. Eksi kenar
boşluğuyla hizalama yapılmaz (eski hâlde çarpı ilk satırdan 4 px aşağıdaydı); yeni bir öğe
eklenirse o da 36 px'lik şeride oturtulur.
### 4.17 Şehir çipleri — `SehirCipleri`, `src/features/katalog/components/sehir-cipleri.tsx`
Şehre göre üniversite gezinmesi (`/universiteler` üstü ve `/universiteler/[il]` altı).
@@ -577,7 +613,7 @@ telefonda tam genişlik), "Şehre göre" çipleri (sayfa içi `#sehir-…` çapa
Bilal (24 Eyl 2026): "bu kısımlar design md'ye uygun yap, belirt ki bir daha bu hata olmasın".
Panel ortada açılan `Dialog` ve `text-xs` hücreli bir tabloydu; iki kural birden çiğnenmişti.
**Kural:** program satırından açılan her ayrıntı (netler, akademik göstergeler,
kontenjan geçmişi) §4.10 **alt sayfadır** — `Drawer handleOnly`, kap ve çarpı `cta-sira-form.tsx`
kontenjan geçmişi) §4.10 **alt sayfadır** — `AltSayfa`, kap ve çarpı `cta-sira-form.tsx`
çekmecesiyle birebir, başlık `px-10 text-center font-heading text-2xl font-bold`, altında tek
cümle bağlam `text-sm text-slate-500`. İçindeki tablo §4.15'tir: başlık `SATIR_BASLIK`, hücre
`text-sm` (gövde `text-xs`'e inmez, §3), sayılar `font-sans tabular-nums`, **en yeni yıl** koyu
@@ -634,7 +670,21 @@ genişletilebilir olmalı; UI için prompt-kit kullan."
`animate(izgara, { "--sohbet-kolon": "<px>" })` ile ızgara genişliğinin %60'ına (en çok 760 px)
büyür; liste ve harita daralan sol sütunda kendini yeniden akıtır. Telefonda (lg altı) yalnız boy
uzar. `Minimize2` geri alır; sohbet durumu (useChat, kaydırma) korunur. Hareketi azalt: geçişsiz.
Perde, sabit konum, ayrı `Dialog`/`Drawer` **yok** — sohbet yeniden kurulmaz, sayfa zıplamaz.
Perde, sabit konum, ayrı `Dialog`/`AltSayfa` **yok** — sohbet yeniden kurulmaz, sayfa zıplamaz.
- **Dar hâlin boyu — sayfa en üstteyken de bulanık şeridin üstünde biter** (Bilal, 7 Eki 2026:
"yüksekliğini alttan kısalım, progressive blur üzerine geliyor, sığmıyor"). Sınıf tek yerde:
`SOHBET_DAR_BOY` (`sohbet-client.tsx`) = `h-[calc(100dvh-23.75rem)] min-h-[340px]
sm:h-[calc(100dvh-24.75rem)] lg:h-[calc(100dvh-21.5rem)]`. Düşülen pay = panelin sayfa en
üstteykenki üst boşluğu (sezon bandı + site başlığı + `main` py-12 + ızgara mt-8; **ölçüldü**:
telefonda 251, 768 px'te 267, 1280 px'te 217 px) + 7rem şerit + 1rem nefes. Eski `18rem`
sezon bandını saymıyordu. Paketsizdeki 480 px'lik panel aynı sayıları `max-h` olarak taşır.
Sezon bandı ya da başlık boyu değişirse bu üç sayı yeniden ölçülür (§9.2).
- **Elle boyutlandırma ve telefonda sıra** (Bilal, 7 Eki 2026): geniş ekranda liste ile sohbet
arasındaki boşlukta `BolmeTutamaci` durur (§4.25); sürükleyince aynı `--sohbet-kolon` değişkeni
değişir (sohbet 320–820 px, liste en az 420 px), çift tık varsayılana döner. "Genişlet" düğmesi
elle verilen genişlikten devam eder; "Daralt" varsayılana döner. **Telefonda (lg altı) danışman
en üsttedir**: sütun kabı `max-lg:contents`, sohbet `max-lg:order-first`, paketsizdeki paket
kartı `max-lg:order-last` — harita ve liste sohbetin altında akar.
- **Bağlantı hapları** (`sohbet-baglanti.tsx`, Bilal 6 Eki 2026: "chat link verdiğinde kod gibi
veriyor"): danışman cevabındaki site içi adres (`/rehber/…`, `/universite/…`, `/bolum/…`,
`/universiteler/<il>`, `/paket`, `/listem`) prompt-kit `Source` kalıbında **küçük satır içi hap**
@@ -686,6 +736,16 @@ genişletilebilir olmalı; UI için prompt-kit kullan."
zaman yazılıdır (§2.2). Sohbet ilerlese de kalır. Yeni bir liste uyarısı gerekince
`listeUyarilari`'na eklenir, sayfaya yeni kutu açılmaz.
- **Liste revizyonu sohbette** (Bilal, 7 Eki 2026: "revize sistemi tamamen chate taşınsın";
geri bildirim: eski kutudan "ilk 5 X ilinden olsun" denince liste değişmiyordu — not modele
serbest metin gidiyor, sıra ve havuz koddan belirleniyordu). `/listem`deki revizyon kutusu
kaldırıldı; başlıktaki "Revize et" düğmesi sohbet kutusunu odaklar (`sohbetiOdakla`). Danışman
belirsiz isteği **soru formuyla** netleştirir (`soru_sor` → §4.33 `SohbetAnketi`, balonun
altında sade kutu), sonra `liste_revize` aracıyla **yapısal kural** verir (il/alan/tip farkı,
"ilk N" ölçütü, çıkar/ekle, iskelet; `lib/revizyon-kurali.ts`); liste koddan yeniden kurulur,
sayfa `router.refresh()` ile yenilenir, hap "Listeni yeniden kurdum" olarak kalır. Form cevabı
kredi harcamaz. Kayıtlı geçmişte form ve revizyon sonucu `chat_messages.meta`dan geri kurulur.
### 4.21 Sayı rulosu — `SayiRulo`, `src/components/ui/sayi-rulo.tsx`
Ekranda **değişen** sayı (üst çubuk rozeti ve kredi hapı, "Listende 5/24", çekmece sayacı,
danışman kredi rozeti, "40 / 312 program", üst şeridin sezon geri sayımı, giriş formunun
@@ -704,7 +764,160 @@ Tek seçimli hap grubunda seçili vurgu yeniden boyanmaz, tek yüzey olarak seç
**kayar** (`layoutId`). `ayrik`: kenarlıklı ayrı haplar + mavi dolu vurgu (puan türü çipleri —
hero ve CTA formu). `kapsul`: tek kapsülde koyu hap, yazı `text-slate-50` (tema seçici). Yay §6;
hareketi azalt'ta vurgu yerinde belirir. Kaynak Arc UI "Chip group" (6 Eki 2026). Çok seçimli
çipler (sihirbaz alan/il) bu desene girmez — orada her çip kendi başına yanar.
çipler (sihirbaz alan/il) bu desene girmez — onlar §4.24 `SecimCipi`'dir (seçilince tik büyür).
### 4.23 Kaydırma eylemi — `KaydirmaGrubu` + `KaydirmaSatiri`, `src/components/ui/kaydirma-eylemi.tsx`
Bilal (7 Eki 2026): "Arc'taki swipe action'ı bizdeki listeye getir." "Kendi Listem" satırı **sola
kaydırılınca** altından "Çıkar" eylemi açılır (80 px, `bg-red-500 text-white`, ikon + yazı);
sonuna kadar çekilince eylem satırın tamamını kaplar ve bırakınca uygulanır, dokunmatikte eşikte
kısa titreşim verir. Aynı anda tek satır açık kalır; başka yere dokunmak ya da Escape kapatır.
Çıkarma §10.5 gibi geri alınabilir bildirimle biter. **Eylem, satırda görünen bir düğmeyle de
yapılabilmelidir** (listede `CircleMinus`): klavye ve ekran okuyucu yolu odur, kaydırılan kopya
`aria-hidden`. Dikey sıralama tutamağı ayrı kalır. Tonlar: `tehlike` (kırmızı; etiket sonucu
söyler), `vurgu` (mavi), `notr` (slate). Kaynak Arc UI "Swipe actions"; "Diğer eylemler" menüsü
alınmadı. Yeni bir listede kaydırma gerekirse bu bileşen kullanılır, elle `drag="x"` yazılmaz.
### 4.24 Seçim çipi ve çip grubu — `SecimCipi` + `CipGrubu`, `src/components/ui/cip-grubu.tsx`
Bilal (7 Eki 2026): "chip group'u pills seçimi olan yerlerde kullanalım." Çok seçimli hap
seçimlerinin tek kaynağı (sihirbazın alan, şehir, üniversite tipi, öncelik ve özel kontenjan
çipleri). Sınıf §4.17 çipi (44 px; `kucuk` 40 px; seçili `border-primary bg-primary text-white`;
`ton="bilgi"` pasifte mavi ton). Seçilince başta **tik kendini çizerek büyür**, yazı yana kayar,
çipin kenarı yayla izler, komşular akışla yer açar — seçili hâl renk + tik'tir (§2.2). `CipGrubu`
gruba ad verir (`etiket`) ve ok tuşlarıyla çipten çipe geçirir. Kaynak Arc UI "Chip group";
komşuları layout projeksiyonuyla kaydırma alınmadı (kayan alt sayfa ve ölçülen sihirbaz paneli
içinde erken ölçüm satırları kaydırıyordu), "+N daha" katlaması da yok. Tek seçimde kayan vurgu
§4.22.
### 4.25 Bölme tutamacı — `BolmeTutamaci`, `src/components/ui/bolme-tutamaci.tsx`
Bilal (7 Eki 2026): "Resizable panels ile danışmanın bulunduğu sayfada ölçeklendirme
yapılabilsin." Yan yana iki alanın arasındaki 24 px'lik boşlukta duran ayırıcı: dinlenirken silik
bir tutamaç (`h-10 w-1.5 bg-slate-300`), üzerine gelince / odakta / sürüklerken turuncu ve uzun
(§2.1), yanında ince turuncu çizgi. İşaretçiyi birebir izler, sınırın ötesinde lastik gibi direnir
ve bırakınca sınıra döner. `role="separator"`: ok tuşları 16 px (Shift 64 px), Home / End sınırlar,
çift tık varsayılan. Genişliği çağıran tutar (`oku` / `yaz` / `sinirlar`); `/listem`'de ızgaranın
`--sohbet-kolon` değişkeni (§4.20). Yalnız `lg` ve üstünde görünür. Kaynak Arc UI "Resizable
panels"; sabit boylu kap ve bölmeyi tamamen kapatma alınmadı (sayfa kayıyor, sohbet yapışkan).
### 4.26 Yazı dönüşümü — `YaziDonusumu`, `src/components/ui/yazi-donusumu.tsx`
Kısa bir ifade yerinde bir sonrakine dönüşür: ortak harfler yeni yerine süzülür, yeniler
netleşerek gelir, gidenler bulanıp söner, genişlik yayla izler. **Şu an sayfada kullanılmıyor:**
ana sayfa başlığındaki dönen vurgu ("gerçek veriyle" → "risk hesabıyla" → "gerekçesiyle")
7 Eki 2026'da geri alındı (Bilal: "diğer ana sayfa bileşenleri eski hâline gelsin"); başlık yine
sabit "gerçek veriyle" yazar. Bileşen dosyası duruyor. Kaynak Arc UI "Text morph". Yeni kullanım: yalnız **birkaç harfi değişen kısa
durum yazısı**; paragraf ya da uzunluğu çok değişen metin dönüştürülmez (Bilal'in 24 Eyl 2026'da
kaldırdığı "yazılıp silinen satır" geri gelmez).
### 4.27 Kart destesi — `KartDestesi`, `src/components/ui/kart-destesi.tsx`
Kartlar tek tek sorulur: üstteki kart sağa/sola sürüklenip fırlatılır (parmağın hızını taşır),
arkadakiler o uzaklaştıkça kalkar; aynı karar düğmelerle ve ok tuşlarıyla verilir, "Geri al" kartı
çıktığı yerden getirir. Kart `rounded-3xl border border-slate-200 bg-card`, boyu `--kart-boyu`
(19rem); düğmeler 44 px hap — sol ikincil (hover §2.1), orta sessiz, sağ **mavi dolu** (turuncu
yalnız funnel CTA'sı). Sürüklerken beliren damgalar hap; onay damgası mavi tonda (yeşil risk
dilindedir, burada kullanılmaz). Kaynak Arc UI "Card stack".
- **"Bu program listene girer mi?" demosu** (`deste-demosu.tsx`, veri `landing-verisi.ts`
`ornekProgramlar`): örnek sıralamaya (Sayısal 85.000) göre **gerçek** yedi devlet programı.
Kartta künye şeridi (il · Devlet), ad, üniversite, iri taban sıralaması (§1.1) ve
`RiskEtiketi`. Uydurma sayı yok; veri okunamazsa çizilmez. Bitince tek turuncu CTA forma
götürür. **Ana sayfadan çıkarıldı (7 Eki 2026)** — Bilal: "bunu oyunlaştırmada ilerde
kullanacağız". Dosyalar duruyor; şu an hiçbir sayfada çizilmiyor.
### 4.28 Veri akışı — `VeriAkisi`, `src/features/pazarlama/components/veri-akisi.tsx`
Ana sayfa "Nasıl çalışır" bölümünde, üç adımın (§4.32) altında: ortada KolayTercih işareti,
çevresinde altı girdi; her girdiden merkeze **düz çizgi** iner, üzerinde piksel kare akar. **Solda
öğrencinin üç seçimi** (turuncu ikon ve kare), **sağda YÖK Atlas'ın üç verisi** (mavi ikon ve
kare); ayrımı etiket ve yan taşır, renk eşlik eder (§2.2). Akış CSS'tir (`kt-akis`, globals.css),
yalnız ekrandayken oynar; hareketi azalt'ta kareler durur. Esin React Bits "Center Flow"; kod
bizim — parlama ve bulanıklık yok, akış içeri doğru. Yeni bir girdi eklenirse sihirbaz/havuz
kurallarıyla aynı olmalı (`/meraklisina` şemasıyla çelişmez).
- **Çizgi düz, karolar beyaz** (Bilal, 7 Eki 2026: "aradaki yerler solid çizgi olsun, etrafındaki
kartların bg'si ve görünümü daha iyi olabilir"). Çizgi `var(--color-slate-300)`, 1,5 px
(`vectorEffect="non-scaling-stroke"`), karonun merkezinden işaretin merkezine çekilir ve
karoların **altında** kalır; kesik/noktalı çizgi yazılmaz. Girdi karosu
`size-14 rounded-2xl border border-slate-200 bg-card` + `size-6` ikon; ortadaki karo
`size-24 rounded-3xl border border-slate-200 bg-card`. Renkli soluk zemin (`bg-orange-50/60`,
`bg-primary/10`) kaldırıldı: karo dolu olmalı ki çizgi içinden görünmesin. Gölge yok (§6).
- **Kartsız, zeminsiz; bilgiler iki yanında, onlar da kartsız** (Bilal, 7 Eki 2026: "kartın
dışında, arka plansız, sağında solunda da alttaki bilgiler olacak şekilde"; aynı gün: "hero'daki
gibi ferah"). Geniş ekranda (lg+) üç sütun (`lg:grid-cols-[minmax(0,1fr)_22rem_minmax(0,1fr)]`,
xl'de orta 24rem, dikeyde ortalı): **solda dürüstlük notu** — `SectionEyebrow` + `h3`
(`font-heading text-2xl font-bold text-balance`, ikinci yarısı hero başlığındaki vurgu:
`font-bricolage font-bold tracking-tighter text-primary italic`) + `text-base` paragraf, sola
yaslı, `max-w-xs`; **ortada animasyon**; **sağda üç sayı** alt alta, sağa yaslı (YÖK Atlas
girdilerinin yanında — sayılar o verinin sayılarıdır). Sayı §1.1: `font-heading text-4xl
font-bold leading-none tracking-tight text-primary sm:text-5xl`, birim ("yıl") §3'teki gibi
`font-sans text-base font-semibold text-slate-400`, etiket `text-sm font-semibold
text-slate-500`. Çerçeve, zemin, ayırıcı çizgi ve sayının yanında piksel süs **yok**.
lg altında tek sütun, her şey ortalı: animasyon → sayılar (sm'de üçü yan yana) → dürüstlük notu.
- **İşaret site başlığındaki logonun animasyonunu oynar** (`AnimasyonluMark`, varsayılan "rota":
kareler sırayla birbirinin yerine süzülür). Başlıkta üzerine gelince, burada ekrandayken
sürekli (`oynat`); ekrandan çıkınca kareler yerine oturur. Nabız + tüm karoyu döndüren eski
hâl (`kt-akis-donus`) yanlıştı ve kaldırıldı (Bilal, 7 Eki 2026). Hareketi azalt'ta durağan.
### 4.29 Program duvarı — `ProgramDuvari`, `src/features/pazarlama/components/program-duvari.tsx`
**Şu an sayfada kullanılmıyor:** ana sayfa "Sorun" bölümünden 7 Eki 2026'da çıkarıldı (Bilal:
"diğer ana sayfa bileşenleri eski hâline gelsin"); bölüm yine yalnız üç kartla açılır. Dosya
duruyor. Tarif:
"Binlerce ihtimal"in resmi: aramadaki mini pencere dilinde
(`rounded-2xl`, üç noktalı `h-9` çubuk, §4.20) bir tuvalde piksel kareler satır satır akar —
her kare bir program; aralarında **tam 24 turuncu kare** sırayla belirir ve yerinde kalır (senin
listen). İmleç yaklaştığı satırları hızlandırır. Izgara hero'nun ölçüsü (hücre 10, nokta 7.4,
köşe 2), mavi doku opaklıkları aynı; renk CSS'ten okunur, tema değişince yeniden çizilir. Tek
`<canvas>`, ekrandayken ~30 fps; hareketi azalt'ta durağan. Anlamı altındaki cümle söyler. Esin
React Bits "Squares Terminal"; koyu CRT ekranı, parlama ve tüp eğimi **alınmadı** (koyu yüzey
yalnız kapanışta, §1.2; parlama gölgesi yok, §2.2).
### 4.30 Süzülen haplar — `SuzulenHaplar`, `src/features/pazarlama/components/suzulen-haplar.tsx`
Ana sayfa "Katalog ücretsiz" bölümü: en çok aranan bölüm ve şehir sayfalarına giden haplar
(§4.12 kimlik hapı, `GraduationCap` / `MapPin`, hover §2.1; her üçüncü hap bir kademe iri).
Bölüm ekrana girince haplar yukarıdan hafif eğik düşüp oturur (tek, hafif sekme — düşen nesne,
§6); imleç gezdikçe her hap kendi derinliğinde en çok 18 px süzülür, arkadaki piksel kareler
ters yöne kayar. Haplar akışla dizilir (375 px'te üst üste binmez) ve SSR'da görünür linktir;
yalnız katalogda sayfası olan adlar çizilir. Dokunmatikte süzülme yok; hareketi azalt'ta yalnız
belirir. Esin React Bits "Parallax Pills"; kod bizim.
### 4.31 Piksel sahne ve sahne kartı — `PixelSahne`, `src/components/pixel-decor.tsx`
Bilal (7 Eki 2026): "landing'de en beğendiğim yer hero'daki Türkiye kısmı; buradaki gibi ferah ve
marka kimliğimizi yansıtacak şekilde sıkıcı kart tasarımlarını da güncelle." Hero haritasının
dili (hücre 10, nokta 7.4, köşe 2, altı opaklık, imleçle turuncuya ısınma, nabız atan turuncu
vurgu) küçük resimlere taşınır. Sahne, harita gibi satır satır bir ızgaradan çizilir: `#` doku
pikseli, `-` silik piksel, `o` turuncu vurgu.
- **Sahne süs değildir, kartın söylediğini çizer.** Ana sayfa "Sorun" kartları: "24 tercih,
binlerce ihtimal" → yoğun piksel alanı, içinde **tam 24** turuncu kare; "Geçen yılın puanı
yetmiyor" → her yıl başka boyda altı çubuk, yalnız sonuncusunun tepesi turuncu; "Danışman lüks
haline geldi" → basamak basamak tırmanan merdiven, tepesi turuncu. Ne çizeceğini bilmediğin
karta sahne koyma; rastgele doku sahne değildir. Sayı söyleyen sahnede (24) kare sayısı tutar.
- **Sahne kartı** (§4.4 sınıfı): üstte sahne yuvası `border-b border-slate-100 bg-slate-50 px-6
py-8` (hero'yla aynı zemin), altında `p-6` içinde `h3` (`font-heading text-xl font-bold
text-balance`) + `mt-2 text-base leading-relaxed text-slate-600`. Kartta ikon kutusu ve köşe
pikseli yok — sahne ikisinin yerini tutar. Izgaralar 24×8; üç kart aynı boyda sahne taşır.
- **Zemin değildir.** §10.8'deki "piksel zemin yalnız CTA yüzeylerinde" kuralı sürer: sahne
yazının arkasına serilmez, kendi yuvasında durur ve üstüne metin gelmez. Çekmece, modal ve
katalog kartlarına sahne konmaz (§4.8: özet kartının içinde piksel seri "çok boğdu").
- `ton="dolgun"` aynı dokunun koyu hâlidir (opaklık 0,64–1); okunması gereken şekiller (rakam)
için. Sahne `aria-hidden`'dır; anlamı başlık taşır.
### 4.32 Adım rakamları — `PixelRakam`, `src/components/pixel-decor.tsx`
Ana sayfa "Nasıl çalışır"ın üç adımı **kartsızdır** (Bilal, 7 Eki 2026, §4.31'deki istek): adım
numarası hero'nun piksel diliyle yazılmış iri rakamdır (5×7 ızgara, `h-24`, `ton="dolgun"`),
altında `h3` (`mt-6 font-heading text-xl font-bold text-balance`) ve `mt-2 max-w-sm text-base
leading-relaxed text-slate-600`; her şey ortalı. `md`'den itibaren rakamlar düz bir çizgiyle
birbirine bağlanır (`h-0.5 rounded-full bg-slate-200`, rakamın ortasında; veri akışındaki düz
çizgiyle aynı dil). Telefonda adımlar alt alta, çizgisiz. Yapı `ol > li`; rakam `aria-hidden`,
başlığın başında `sr-only` "1. adım:". Turuncu numara rozeti, ikon ve kart çerçevesi kaldırıldı.
Yeni bir rakam gerekirse ızgarası `RAKAMLAR`'a eklenir (bugün 1–3).
### 4.33 Soru formu — `questionnaire.tsx` (shadcn Questionnaire) + `SohbetAnketi`, `sohbet-anketi.tsx`
shadcn'in `questionnaire` kayıt öğesi (ilkel `@shadcn/react/questionnaire`) sitenin diline çevrildi:
`muted / input / foreground` token'ları yok. Sorular **tek tek** gösterilir; sağ üstte ilerleme
`1 / 3` (`text-xs font-semibold text-slate-500 tabular-nums`). Seçenek satırı §4.4 sade kutu +
§4.17 çip dili: `min-h-11 rounded-2xl border border-slate-200 bg-card px-3.5 py-2.5 text-sm
font-semibold text-slate-700`, hover tek turuncu, seçili `border-primary bg-primary text-white` ve
başındaki kutu tik olur (seçili hâl renk + tik, §2.2); sağda klavye harfi küçük kutuda. Serbest cevap
alanı §4.6 hapı (`h-11 rounded-full`). Düğmeler sitenin `Button`'u (`sm`): Geri (outline), Atla
(ghost; yalnız zorunlu olmayan soruda), İleri / "Cevapları gönder" (default). Hata `text-sm
font-medium text-red-600`. Kapanmış form tek satır: "Formu cevapladın." (yeşil tik) ya da "Form
kapandı; isteğini yazarak söyleyebilirsin." Yeni bir anket/soru akışı gerekince bu bileşen kullanılır;
radio/checkbox'lı yeni form tasarlanmaz.
## 5. Yerleşim
@@ -764,11 +977,15 @@ sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerek
## 6. Derinlik, katman ve hareket
Derinlik gölgeyle değil **piksel katmanı ve hareket farkıyla** verilir: başlık bloğu gövdeden hafif hızlı akar (`Parallax strength={16}` başlık, `7` içerik, `26` ayraç). Kartlar hover'da 4 px yükselir. Modal arka planı `bg-black/10` + hafif blur. Yığın: içerik < sabit bar < çekmece/modal (`z-50`).
Derinlik gölgeyle değil **piksel katmanı ve hareket farkıyla** verilir: başlık bloğu gövdeden hafif hızlı akar (`Parallax strength={16}` başlık, `7` içerik, `26` ayraç). Sahne kartları (§4.31) hover'da 4 px yükselir. Modal arka planı `bg-black/10` + hafif blur. Yığın: içerik < sabit bar < çekmece/modal (`z-50`).
Hareket kuralları (Apple, `motion/react`):
- Varsayılan yay: `{ type: "spring", bounce: 0, duration: 0.35 }` — sekmesiz, sakin. Sekme
yalnızca kullanıcı bir şeyi fırlattıysa (sürükle-bırak).
- Arc UI'dan taşınan bileşenlerin yayları, eğrileri ve lastik direnci tek dosyada:
`src/lib/hareket.ts` (`YAY.akici` panel/yığın, `YAY.donusum` biçim, `YAY.birakma` parmak
bıraktıktan sonraki tek hafif sekme). Yeni bir Arc bileşeni yay uydurmaz, buradan alır;
sınıflar Arc'ın CSS modülünden değil bu belgeden gelir.
- Giriş ve çıkış **aynı yoldan** (aşağıdan geldiyse aşağı gider).
- Basınca anında tepki: `active:scale-[0.97]` (buton), `[0.99]` (geniş satır), `[0.96]` (çarpı).
- Hover'da ok `translate-x-0.5` kayar; kart yükselmesi yalnız landing kartında.
@@ -805,7 +1022,7 @@ Hareket kuralları (Apple, `motion/react`):
tasarlanır. Ortalı ↔ sola yaslı geçişi `lg`'dedir (§5.2).
- Dokunma hedefi en az 44 px (`h-11`, `size-11`); satır içi küçük buton `h-9` yalnızca masaüstü tablo aksiyonlarında.
- Başlık ölçeği bir kademe düşer (`text-3xl` → `sm:text-5xl` alt sayfa h1), gövde 16 px altına inmez.
- Modal genişliği `max-w-[calc(100%-2rem)]`; ayrıntı her zaman çekmecede (`Drawer`), alttan açılır.
- Modal genişliği `max-w-[calc(100%-2rem)]`; ayrıntı her zaman alt sayfada (`AltSayfa`, §4.10), alttan açılır.
- Çekmece içeriği 375×812'de kaydırmasız sığar (§4.10).
- `prefers-reduced-motion`: her animasyonun sade karşılığı var (`motion-reduce:animate-none`, `.kt-unblur-sade`, `useReducedMotion`).
@@ -866,7 +1083,7 @@ Bir ekranı dönüştüren ajan o ekrandaki uyarıları sıfırlar ya da neden k
### 9.5 Bitirmeden
`pnpm tasarim:denetim` sıfır hata (uyarılar raporda listelenir) + 375 px'te tek bakış (taşma,
üst üste binme, dokunma hedefi, çekmece tek ekran) + §9.2 ölçümleri.
- Yeni ayrıntı paneli mi? `Dialog` değil `Drawer` (§4.10, §4.19). İçinde tablo mu? §4.15
- Yeni ayrıntı paneli mi? `Dialog` değil `AltSayfa` (§4.10, §4.19). İçinde tablo mu? §4.15
sınıfları, hücre `text-sm`, `text-xs` yok. Buton mu? Gölge yok, hover tek turuncu (§2.1, §6).
## 10. Samimi ton, ilerleme ve kutlama (öğrenci ekranları)
@@ -913,7 +1130,7 @@ Araştırma ve kaynaklar: `docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md`
### 10.6 Anlamsal yapı
- Sayfa başına tek `h1`; bölümler `h2`. Başlıklarda `text-balance`.
- Odak almayan durum değişikliği ekran okuyucuya duyurulur: `role="status"` (liste sayacı,
sihirbaz adımı, bekleme). Bildirimler (sonner) bunu kendisi yapar.
sihirbaz adımı, bekleme). Bildirim yığını (§4.16) bunu kendisi yapar.
- Açılır satır: `aria-expanded` + `aria-controls` (APG disclosure).
- Site geneli "İçeriğe geç" linki `layout.tsx`'te; içerik kabı `#icerik`.
- Çekmece kaydırma alanı `overscroll-contain`.
@@ -988,6 +1205,7 @@ karar.
Aynı sayfadaki iki band farklı `seed` alır.
**Piksel zemin yalnız CTA ve başlık yüzeylerinde — `PikselZemin`, `src/components/piksel-zemin.tsx`**
(Ana sayfa kartlarındaki piksel **sahne** zemin değildir; kendi yuvasında duran resimdir, §4.31.)
(Bilal, 24 Eyl 2026: önce "her CTA benzeri kısmın arkasında olmalı", sonra "/sonuc'taki kartın
arkasına da piksel koymuşsun; pikseller CTA'larda olacak sadece"). CTA yüzeyi = çağrı yüzeyi:
mavi `CtaBandi`, koyu kapanış kartı `PikselCtaYuzeyi`, arama penceresindeki koyu kart.

View File

@@ -0,0 +1 @@
ALTER TABLE `chat_messages` ADD `meta` text;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1790287503094,
"tag": "0005_uneven_omega_red",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1791372024282,
"tag": "0006_little_luckman",
"breakpoints": true
}
]
}

View File

@@ -32,6 +32,7 @@
"@cds/city": "^1.1.0",
"@libsql/client": "^0.17.4",
"@shadcn/helpers": "^0.2.0",
"@shadcn/react": "0.3.1",
"ai": "^7.0.127",
"better-auth": "^1.6.23",
"better-sqlite3": "^12.11.1",
@@ -56,12 +57,10 @@
"resend": "^6.18.0",
"server-only": "^0.0.1",
"shiki": "^4.3.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"turkey-map-react": "^2.0.5",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6",
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {

51
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@shadcn/helpers':
specifier: ^0.2.0
version: 0.2.0(ai@7.0.127(zod@4.4.3))
'@shadcn/react':
specifier: 0.3.1
version: 0.3.1(@types/react@19.2.17)(react@19.2.4)
ai:
specifier: ^7.0.127
version: 7.0.127(zod@4.4.3)
@@ -95,9 +98,6 @@ importers:
shiki:
specifier: ^4.3.1
version: 4.3.1
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -110,9 +110,6 @@ importers:
use-stick-to-bottom:
specifier: ^1.1.6
version: 1.1.6(react@19.2.4)
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -2011,6 +2008,17 @@ packages:
ai:
optional: true
'@shadcn/react@0.3.1':
resolution: {integrity: sha512-2gOR0HDMtWeRsCZfNDaU0YDFdgH3zsDQ6lz67Fv/y/qjY9y+R8kJqAn6q56phqv7/zHi0wqURjntrNO9zL7vnQ==}
peerDependencies:
'@types/react': '>=19'
react: '>=19'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
engines: {node: '>=20'}
@@ -5263,12 +5271,6 @@ packages:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -5654,12 +5656,6 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vaul@1.1.2:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
verror@1.10.0:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}
@@ -7452,6 +7448,11 @@ snapshots:
optionalDependencies:
ai: 7.0.127(zod@4.4.3)
'@shadcn/react@0.3.1(@types/react@19.2.17)(react@19.2.4)':
optionalDependencies:
'@types/react': 19.2.17
react: 19.2.4
'@shikijs/core@4.3.1':
dependencies:
'@shikijs/primitive': 4.3.1
@@ -11230,11 +11231,6 @@ snapshots:
ip-address: 10.2.0
smart-buffer: 4.2.0
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -11671,15 +11667,6 @@ snapshots:
vary@1.1.2: {}
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@radix-ui/react-dialog': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
verror@1.10.0:
dependencies:
assert-plus: 1.0.0

View File

@@ -15,6 +15,14 @@ import { barajTablosu, type AracBaglami } from "@/lib/ai/araclar";
import { danismanAkisi } from "@/lib/ai/sohbet-akisi";
import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt";
import { modelPenceresi, onayMesajiMi } from "@/lib/ai/sohbet-gecmisi";
import {
anketCevabiDogrula,
anketCevabiMetni,
anketModelMetni,
type RevizyonOzeti,
type SohbetMeta,
} from "@/lib/ai/sohbet-meta";
import { MAX_REVIZYON } from "@/lib/credits";
import { profilGetir } from "@/lib/danisman-profil-db";
import { listeOzetiCikar } from "@/lib/ai/rapor";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
@@ -75,11 +83,20 @@ export async function POST(request: NextRequest) {
if (hamBody === null || typeof hamBody !== "object") {
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
}
const body = hamBody as { message?: unknown; clientMessageId?: unknown };
const mesaj = typeof body.message === "string" ? body.message.trim() : "";
const body = hamBody as {
message?: unknown;
clientMessageId?: unknown;
/** Danışmanın soru formuna cevap (sohbet-anketi.tsx); metin istemcide de üretilir ama sunucu kendi yazar. */
anketCevabi?: { anketId?: unknown; cevaplar?: unknown };
};
let mesaj = typeof body.message === "string" ? body.message.trim() : "";
const clientMessageId =
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
const anketCevabiHam =
body.anketCevabi && typeof body.anketCevabi === "object" && typeof body.anketCevabi.anketId === "string"
? { anketId: body.anketCevabi.anketId, cevaplar: body.anketCevabi.cevaplar }
: null;
if ((!mesaj && !anketCevabiHam) || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
}
@@ -101,6 +118,7 @@ export async function POST(request: NextRequest) {
}),
profilGetir(session.user.id),
]);
const paketli = Boolean(kullanici?.hasPaket);
if (!rapor?.result) {
return Response.json(
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
@@ -114,11 +132,27 @@ export async function POST(request: NextRequest) {
// gönderilen "evet"ler bedelsiz model çağrısı açmasın.
const sonMesaj = gecmis.at(-1);
const sonAsistan = sonMesaj?.role === "assistant" ? sonMesaj.content : undefined;
const bedelsiz = onayMesajiMi(mesaj, sonAsistan);
// Soru formunun cevabı da bedelsiz (7 Eki 2026): yalnız EN SON danışman
// mesajının formu için ve o formun kimliğiyle. Cevap metni sunucuda formdan
// üretilir; istemcinin yazdığı metin kullanılmaz.
const sonAnket = sonMesaj?.role === "assistant" ? sonMesaj.meta?.anket : undefined;
let kullaniciMeta: SohbetMeta | undefined;
if (anketCevabiHam) {
if (!sonAnket || sonAnket.id !== anketCevabiHam.anketId) {
return Response.json({ error: "Bu form artık geçerli değil; isteğini yazarak söyleyebilirsin." }, { status: 409 });
}
const cevaplar = anketCevabiDogrula(sonAnket, anketCevabiHam.cevaplar);
if (!cevaplar || Object.keys(cevaplar).length === 0) {
return Response.json({ error: "Formda bir cevap seçilmedi." }, { status: 400 });
}
mesaj = anketCevabiMetni(sonAnket, cevaplar);
kullaniciMeta = { anketCevabi: { anketId: sonAnket.id, cevaplar } };
}
const bedelsiz = Boolean(anketCevabiHam) || onayMesajiMi(mesaj, sonAsistan);
// Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = bedelsiz
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId, meta: kullaniciMeta })
: await spendCreditForMessage({
userId: session.user.id,
content: mesaj,
@@ -141,7 +175,7 @@ export async function POST(request: NextRequest) {
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
// halde danışman kilitli listeyi sözle sızdırabilir.
const kilitli = !kullanici?.hasPaket;
const kilitli = !paketli;
const raporSonuc = rapor.result as RaporSonuc;
const tamOzet = listeOzetiCikar(
raporSonuc,
@@ -162,6 +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 },
});
// 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
@@ -170,6 +205,9 @@ export async function POST(request: NextRequest) {
userId: session.user.id,
sira: raporParams.sira,
tur: raporParams.tur,
hasPaket: paketli,
mesajId: kullaniciMesajId,
kalanRevizyon: Math.max(0, MAX_REVIZYON - rapor.revisionCount),
profil,
oncelikler: raporParams.oncelikler ?? [],
ozelDurum: raporParams.ozelDurum ?? [],
@@ -229,16 +267,27 @@ export async function POST(request: NextRequest) {
}
// Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere
// modele bozuk sıra göndermesin
const mesajlar = modelPenceresi(gecmis, mesaj);
// modele bozuk sıra göndermesin. Kayıtlı asistan metni soru formunu
// içermez; model cevapların neye verildiğini formun dökümünden görür.
const mesajlar = modelPenceresi(
gecmis.map((m) =>
m.role === "assistant" && m.meta?.anket
? { role: m.role, content: `${m.content}\n\n${anketModelMetni(m.meta.anket)}` }
: { role: m.role, content: m.content },
),
mesaj,
);
const userId = session.user.id;
// Bu turda araçların bıraktığı yapısal ek (soru formu, revizyon sonucu)
const cevapMeta: SohbetMeta = {};
const cevabiKaydet = (metin: string) =>
appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId,
role: "assistant",
content: metin,
meta: Object.keys(cevapMeta).length ? cevapMeta : null,
createdAt: new Date(),
});
@@ -248,6 +297,15 @@ export async function POST(request: NextRequest) {
system,
mesajlar,
aracBaglami,
onAracSonucu: (ad, cikti) => {
const c = cikti as Record<string, unknown>;
if (ad === "soru_sor" && c.anket) cevapMeta.anket = c.anket as SohbetMeta["anket"];
if (ad === "liste_revize" && c.ok === true) {
const { not: _not, ...ozet } = c as Record<string, unknown>;
void _not;
cevapMeta.revizyon = ozet as RevizyonOzeti;
}
},
onTamam: async (metin) => {
await cevabiKaydet(metin);
},

View File

@@ -5,8 +5,8 @@ import {
ListemIcerikSkeleton,
} from "@/features/rapor/components/listem-icerik";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
// Liste üretimi (listeOlustur) 2 AI denemesi yapabiliyor (deneme başına 60 sn
// abort sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
// SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
// bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin
// istek süresi limiti de >= 200 sn olmalı.

View File

@@ -0,0 +1,329 @@
"use client";
// shadcn `questionnaire` (https://ui.shadcn.com/docs/react/questionnaire,
// radix-nova kayıt öğesi; ilkel: @shadcn/react/questionnaire), sitenin diline
// çevrildi (DESIGN.md §4.6, §4.17, §4.24): `border-input / bg-muted /
// text-muted-foreground` token'ları yerine slate ölçeği ve `bg-card`; seçili
// seçenek sihirbaz çipi gibi `border-primary bg-primary text-white`; hover
// tek turuncu (§2.1); dokunma hedefi 44 px; köşe `rounded-2xl` (§4.4 sade
// kutu); düğmeler sitenin `Button`'u. Yapı ve davranış (tek tek soru,
// ilerleme, doğrulama, klavye kısayolu, atlama) ilkelden gelir. Danışman
// sohbetindeki soru formu bunu kullanır (features/rapor/components/sohbet-anketi.tsx).
import * as React from "react";
import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire";
import { Check } from "lucide-react";
import { buttonVariants, type Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Questionnaire({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Root>) {
return (
<QuestionnairePrimitive.Root
data-slot="questionnaire"
className={cn("flex w-full min-w-0 flex-col gap-4", className)}
{...props}
/>
);
}
function QuestionnaireProgress({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) {
return (
<QuestionnairePrimitive.Progress
data-slot="questionnaire-progress"
className={cn(
"min-h-[1lh] w-fit min-w-[14ch] text-xs font-semibold text-slate-500 tabular-nums",
className,
)}
{...props}
/>
);
}
function QuestionnaireItem({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
return (
<QuestionnairePrimitive.Item
data-slot="questionnaire-item"
className={cn("flex min-w-0 flex-col gap-3 border-0 p-0 outline-none", className)}
{...props}
/>
);
}
function QuestionnaireTitle({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
return (
<QuestionnairePrimitive.Title
data-slot="questionnaire-title"
className={cn(
"font-heading text-base leading-snug font-bold text-pretty text-slate-900 [&:not(:has(~[data-slot=questionnaire-description]))]:mb-1",
className,
)}
{...props}
/>
);
}
function QuestionnaireDescription({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) {
return (
<QuestionnairePrimitive.Description
data-slot="questionnaire-description"
className={cn("text-sm text-pretty leading-5 text-slate-600", className)}
{...props}
/>
);
}
function QuestionnaireChoices({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) {
return (
<QuestionnairePrimitive.Choices
data-slot="questionnaire-choices"
className={cn("group/questionnaire-choices grid min-w-0 gap-2", className)}
{...props}
/>
);
}
/**
* Seçenek satırı: 44 px sade kutu; seçilince sihirbaz çipi gibi mavi dolar ve
* başındaki işaret tik olur — seçili hâl renk + tik'tir (§2.2). Klavye
* kısayolu (harf) sağda küçük bir kutuda durur.
*/
function QuestionnaireChoice({
children,
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
return (
<QuestionnairePrimitive.Choice
data-slot="questionnaire-choice"
className={cn(
"group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-2xl border border-slate-200 bg-card px-3.5 py-2.5 text-start text-sm font-semibold text-slate-700 transition-[background-color,border-color,color,transform] duration-150 outline-none select-none",
"hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.99]",
"has-[>input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-ring/50",
"data-checked:border-primary data-checked:bg-primary data-checked:text-white data-checked:hover:border-primary data-checked:hover:bg-primary data-checked:hover:text-white",
"data-invalid:border-red-500",
"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<QuestionnairePrimitive.ChoiceInput
data-slot="questionnaire-choice-input"
className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span
aria-hidden="true"
data-slot="questionnaire-choice-indicator"
className="pointer-events-none relative mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-slate-300 bg-card group-data-[type=radio]/questionnaire-choice:rounded-full group-data-checked/questionnaire-choice:border-primary-foreground group-data-checked/questionnaire-choice:bg-primary-foreground group-data-checked/questionnaire-choice:text-primary"
>
<Check
data-slot="questionnaire-choice-indicator-check"
className="hidden size-3 group-data-checked/questionnaire-choice:block"
strokeWidth={3}
/>
</span>
<QuestionnairePrimitive.ChoiceLabel
data-slot="questionnaire-choice-label"
className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"
>
{children}
</QuestionnairePrimitive.ChoiceLabel>
<QuestionnairePrimitive.ChoiceShortcut
data-slot="questionnaire-choice-shortcut"
className="pointer-events-none ms-auto mt-0.5 hidden size-5 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-card font-mono text-[0.625rem] leading-none font-medium text-slate-500 group-data-checked/questionnaire-choice:border-white/40 group-data-checked/questionnaire-choice:bg-white/15 group-data-checked/questionnaire-choice:text-white group-data-[shortcut]/questionnaire-choice:inline-flex"
/>
</QuestionnairePrimitive.Choice>
);
}
function QuestionnaireChoiceDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="questionnaire-choice-description"
className={cn(
"text-xs font-normal text-slate-500 group-data-checked/questionnaire-choice:text-white/80",
className,
)}
{...props}
/>
);
}
/** Serbest cevap alanı: §4.6 metin alanı hapı (44 px, bu kutuda bir kademe kısa). */
function QuestionnaireInput({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
return (
<div
data-slot="questionnaire-input-wrapper"
className="group/questionnaire-input relative w-full min-w-0"
>
<QuestionnairePrimitive.Input
data-slot="questionnaire-input"
className={cn(
"h-11 w-full min-w-0 rounded-full border border-slate-200 bg-card px-4 text-base text-slate-900 transition-[border-color,box-shadow] outline-none placeholder:text-slate-400 hover:border-slate-300 focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-50 aria-invalid:border-red-500 aria-invalid:ring-3 aria-invalid:ring-red-500/20",
className,
)}
{...props}
/>
</div>
);
}
function QuestionnaireError({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
return (
<QuestionnairePrimitive.Error
data-slot="questionnaire-error"
className={cn("text-sm font-medium text-red-600", className)}
{...props}
/>
);
}
function QuestionnaireActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="questionnaire-actions"
className={cn(
"grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2",
className,
)}
{...props}
/>
);
}
type DugmeProps = Pick<React.ComponentProps<typeof Button>, "size" | "variant">;
function QuestionnairePrevious({
children,
className,
size = "default",
variant = "outline",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> & DugmeProps) {
return (
<QuestionnairePrimitive.Previous
data-slot="questionnaire-previous"
className={cn(
buttonVariants({ size, variant }),
"col-start-1 row-start-1 cursor-pointer justify-self-start",
className,
)}
{...props}
>
{children ?? "Geri"}
</QuestionnairePrimitive.Previous>
);
}
function QuestionnaireSkip({
children,
className,
size = "default",
variant = "ghost",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> & DugmeProps) {
return (
<QuestionnairePrimitive.Skip
data-slot="questionnaire-skip"
className={cn(
buttonVariants({ size, variant }),
"col-start-2 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "Atla"}
</QuestionnairePrimitive.Skip>
);
}
function QuestionnaireNext({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Next> & DugmeProps) {
return (
<QuestionnairePrimitive.Next
data-slot="questionnaire-next"
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "İleri"}
</QuestionnairePrimitive.Next>
);
}
function QuestionnaireSubmit({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> & DugmeProps) {
return (
<QuestionnairePrimitive.Submit
data-slot="questionnaire-submit"
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "Gönder"}
</QuestionnairePrimitive.Submit>
);
}
export {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
};

View File

@@ -41,8 +41,12 @@ const ARAC_ETIKETI: Record<string, { surer: string; bitti: string }> = {
universite_siralamasi: { surer: "Üniversite sıralamasına bakıyorum", bitti: "Üniversite sıralamasına baktım" },
kapanan_program_ara: { surer: "Kapanan programlara bakıyorum", bitti: "Kapanan programlara baktım" },
profil_guncelle: { surer: "Tercihini kaydediyorum", bitti: "Tercihini kaydettim" },
liste_revize: { surer: "Listeni yeniden kuruyorum", bitti: "Listeni yeniden kurdum" },
};
/** Hap olarak çizilmeyen araçlar: soru formu balonun içinde form olarak görünür (sohbet-anketi.tsx). */
const HAPSIZ_ARACLAR = new Set(["soru_sor"]);
const VARSAYILAN_ETIKET = { surer: "Veriye bakıyorum", bitti: "Veriye baktım" };
/** Hapın metni; hata renkle değil sözle söylenir (§2.2). */
@@ -50,7 +54,9 @@ export function aracPiliMetni(p: AracPili): string {
const e = ARAC_ETIKETI[p.arac] ?? VARSAYILAN_ETIKET;
const sayi = p.sayi > 1 ? ` ×${p.sayi}` : "";
if (p.durum === "calisiyor") return `${e.surer}${sayi}…`;
if (p.durum === "hata") return `${e.bitti}${sayi} (veri gelmedi)`;
if (p.durum === "hata") {
return p.arac === "liste_revize" ? `Listeni yeniden kuramadım${sayi}` : `${e.bitti}${sayi} (veri gelmedi)`;
}
return `${e.bitti}${sayi}`;
}
@@ -65,6 +71,7 @@ export function aracPilleri(m: UIMessage): AracPili[] {
for (const p of m.parts) {
if (!p.type.startsWith("tool-") || !("state" in p)) continue;
const arac = p.type.slice("tool-".length);
if (HAPSIZ_ARACLAR.has(arac)) continue;
let t = toplam.get(arac);
if (!t) {
t = { calisiyor: 0, hata: 0, sayi: 0 };

View File

@@ -19,6 +19,7 @@ import {
SlidersHorizontal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { BolmeTutamaci } from "@/components/ui/bolme-tutamaci";
import { RaporListesi } from "./rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { tercihFarki } from "@/features/rapor/tercih-farki";
@@ -31,12 +32,12 @@ import type { RaporParams, RaporSonuc } from "@/features/rapor/types/rapor";
import {
SohbetClient,
SOHBET_GENISLEME_GECISI,
sohbetiOdakla,
type DanismanOzeti,
type IlkTercihYorumu,
type Mesaj,
type ProfilSatiri,
} from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import {
profilDuzenlemeyiAc,
useTercihProfili,
@@ -46,6 +47,17 @@ import { ozelDurumOzeti } from "@/lib/ozel-durum";
import { PUAN_TURLERI } from "@/types/yokatlas";
import { DILIM_ETIKET } from "@/lib/risk";
// Sohbet sütununun sürüklenebildiği aralık (DESIGN.md §4.25): sohbet 320 px'in
// altına, liste 420 px'in altına inmez; sohbet 820 px'i geçmez.
const SOHBET_EN_AZ = 320;
const SOHBET_EN_COK = 820;
const LISTE_EN_AZ = 420;
/** Izgaranın sütun aralığı (gap-6); tutamaç bu boşlukta durur. */
const SUTUN_ARALIGI = 24;
/** Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440. */
const kolonTabani = () =>
window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
// Başlık satırındaki ikincil düğmeler: 44 px, hover tek turuncu (§4.1, §2.1)
const IKINCIL_BUTON =
"h-11 cursor-pointer px-4 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]";
@@ -101,22 +113,20 @@ export function ListemGovde({
// yeniden akar. Telefonda (lg altı) ızgara tek sütun, yalnız boy değişir.
const [sohbetGenis, setSohbetGenis] = useState(false);
const izgaraRef = useRef<HTMLDivElement>(null);
const sohbetSutunuRef = useRef<HTMLDivElement>(null);
const hareketAz = useReducedMotion();
function sohbetGenisligiAyarla(genis: boolean) {
setSohbetGenis(genis);
/** Sohbet sütununu yayla hedef genişliğe götürür; `null` sınıftaki varsayılana döner. */
function kolonaGit(hedefPx: number | null) {
const el = izgaraRef.current;
if (!el || !window.matchMedia("(min-width: 1024px)").matches) return;
// Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440
const taban = window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
const hedef = genis
? Math.max(taban, Math.round(Math.min(el.clientWidth * 0.6, 760)))
: taban;
const taban = kolonTabani();
const hedef = hedefPx ?? taban;
const simdiki = parseFloat(el.style.getPropertyValue("--sohbet-kolon")) || taban;
const bitir = () => {
// Dar hâlde satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur
if (genis) el.style.setProperty("--sohbet-kolon", `${hedef}px`);
else el.style.removeProperty("--sohbet-kolon");
// Varsayılanda satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur
if (hedefPx == null) el.style.removeProperty("--sohbet-kolon");
else el.style.setProperty("--sohbet-kolon", `${hedef}px`);
};
if (hareketAz) {
bitir();
@@ -130,6 +140,17 @@ export function ListemGovde({
).then(bitir);
}
function sohbetGenisligiAyarla(genis: boolean) {
setSohbetGenis(genis);
const el = izgaraRef.current;
if (!el) return;
kolonaGit(
genis
? Math.max(kolonTabani(), Math.round(Math.min(el.clientWidth * 0.6, 760)))
: null,
);
}
const pinVerisi = useMemo(
() =>
pinleriTuret(
@@ -193,9 +214,14 @@ export function ListemGovde({
if (params.ozelDurum?.length) {
satirlar.push(`Özel kontenjan durumun: ${ozelDurumOzeti(params.ozelDurum)}`);
}
if (hasPaket) {
satirlar.push(
`Listende değişiklik isteyebilirsin: danışman gerekirse kısa bir formla sorar, sonra listeyi yeniden kurar (kalan hak: ${kalanHak})`,
);
}
satirlar.push("Bu sohbetteki son mesajların");
return satirlar;
}, [adaySira, params, hasPaket, acikSatir]);
}, [adaySira, params, hasPaket, acikSatir, kalanHak]);
// 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ı
@@ -212,9 +238,12 @@ export function ListemGovde({
);
return (
// Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = SOHBET_KOLON_TABANI);
// "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla), liste
// ve harita daralan 1fr sütunda kendini yeniden akıtır. DESIGN.md §4.20.
// Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = kolonTabani);
// "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla),
// aradaki tutamaç elle sürükletir (§4.25); liste ve harita daralan 1fr
// sütunda kendini yeniden akıtır. DESIGN.md §4.20.
// Telefonda (lg altı) tek sütun: danışman en üstte, paywall kartı en altta
// (Bilal, 7 Eki 2026) — sütun kabı `contents` olur, sıra `order` ile kurulur.
<div
ref={izgaraRef}
className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:[--sohbet-kolon:380px] lg:grid-cols-[minmax(0,1fr)_var(--sohbet-kolon)] xl:[--sohbet-kolon:440px]"
@@ -299,16 +328,21 @@ export function ListemGovde({
)}
{hasPaket ? (
<>
<Button
asChild
variant="outline"
className={IKINCIL_BUTON}
>
<Link href="#revizyon">
{/* 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 ? (
<Button
type="button"
variant="outline"
onClick={() => sohbetiOdakla("Listemde şunu değiştir: ")}
className={IKINCIL_BUTON}
>
<PencilLine className="size-4" aria-hidden />
Revize et
</Link>
</Button>
</Button>
) : null}
<Button
asChild
variant="outline"
@@ -371,26 +405,38 @@ export function ListemGovde({
/>
</section>
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
</div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
<div
ref={sohbetSutunuRef}
className="relative max-lg:contents lg:sticky lg:top-28"
>
{/* Liste ⇄ sohbet bölmesi: sütun aralığında durur, yalnız geniş ekranda */}
<BolmeTutamaci
etiket="Danışman sohbeti"
kontrol="sohbet"
yon={-1}
oku={() =>
sohbetSutunuRef.current?.getBoundingClientRect().width ?? kolonTabani()
}
yaz={(px) =>
izgaraRef.current?.style.setProperty("--sohbet-kolon", `${px}px`)
}
sinirlar={() => {
const en = izgaraRef.current?.clientWidth ?? 1024;
return {
enAz: SOHBET_EN_AZ,
enCok: Math.max(
SOHBET_EN_AZ,
Math.min(en - SUTUN_ARALIGI - LISTE_EN_AZ, SOHBET_EN_COK),
),
};
}}
onSifirla={() => kolonaGit(null)}
className="absolute inset-y-0 -left-6 hidden lg:block"
/>
{hasPaket ? (
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
) : (
<div className="flex flex-col gap-6">
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
@@ -399,14 +445,36 @@ export function ListemGovde({
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
className="h-[480px]"
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
revizyonHakki={kalanHak}
/>
</div>
) : (
<div className="flex flex-col gap-6 max-lg:contents">
{/* Sohbet paket kartının üstünde: #sohbet çapası doğrudan
danışmana düşsün. */}
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
// Paketsizde 480 px; kısa ekranda paketlideki boyla aynı tavana
// iner (SOHBET_DAR_BOY, sohbet-client.tsx), alt bulanık şeridin
// altına girmez.
className="h-[480px] max-h-[calc(100dvh-23.75rem)] min-h-[340px] sm:max-h-[calc(100dvh-24.75rem)] lg:max-h-[calc(100dvh-21.5rem)]"
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
</div>
<section
ref={kilitKartRef}
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm"
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm max-lg:order-last"
>
<div className="border-b border-slate-100 p-6">
<h3 className="font-heading text-xl font-bold tracking-tight">

View File

@@ -1,100 +0,0 @@
"use client";
// Paketlinin geri bildirime dayalı liste revizyonu (/listem üzerinde).
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın.
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { listeRevize } from "@/features/rapor/rapor-actions";
import { olay } from "@/lib/analitik";
export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
const router = useRouter();
// Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry
// çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
const requestIdRef = useRef<string | null>(null);
if (kalanHak <= 0) {
return (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-card p-5"
>
<p className="text-sm text-slate-500">
Revizyon hakların doldu. Danışman sohbetinden listen hakkında soru
sormaya devam edebilirsin.
</p>
</section>
);
}
async function revizeEt() {
setPending(true);
try {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
const sonuc = await listeRevize({
feedback: feedback.trim(),
requestId: requestIdRef.current,
});
requestIdRef.current = null;
if (sonuc.ok) {
olay("liste_revize", { sayfa: "listem" });
toast.success("Listen yeniden kuruldu.");
setFeedback("");
router.refresh();
} else {
toast.error(sonuc.error);
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
} finally {
setPending(false);
}
}
return (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-card p-5"
>
<h2 className="font-heading text-base font-bold">
Listede değişiklik mi istiyorsun?
</h2>
<p className="mb-3 mt-1 text-sm text-slate-600">
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
(3 kredi).
</p>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
maxLength={1000}
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, güvenli dilimi genişlet…"
disabled={pending}
className="w-full rounded-lg border border-slate-200 bg-card px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<div className="mt-3 flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={pending || feedback.trim().length < 5}
onClick={revizeEt}
className="cursor-pointer"
>
<RefreshCcw className="size-4" aria-hidden />
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button>
<span className="text-xs text-slate-500">
Kalan hak: <span className="font-semibold">{kalanHak}</span>
</span>
</div>
</section>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
// Danışmanın soru formu (DESIGN.md §4.20, §4.33). `soru_sor` aracı
// (lib/ai/araclar.ts) liste revizyonu isteği belirsizken 1-3 kısa soruyu bu
// formla sorar; sorular tek tek gösterilir (shadcn Questionnaire,
// components/ui/questionnaire.tsx). Gönderilince cevaplar hem adayın balonuna
// okunur metin olarak (lib/ai/sohbet-meta.ts anketCevabiMetni) hem sunucuya
// yapısal olarak gider; bu mesaj kredi harcamaz. Form yalnız sohbetin SON
// danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey
// yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan
// aynı hâlde döner).
import { useId } from "react";
import { Check, MessageSquareText } from "lucide-react";
import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
export type AnketDurumu = "acik" | "gonderiliyor" | "cevaplandi" | "kapandi";
export function SohbetAnketi({
anket,
durum,
onGonder,
}: {
anket: Anket;
durum: AnketDurumu;
onGonder: (cevaplar: AnketCevaplari) => void;
}) {
const kimlik = useId();
if (durum === "cevaplandi" || durum === "kapandi") {
return (
<p className="flex items-center gap-1.5 px-0.5 text-xs font-medium text-slate-500">
{durum === "cevaplandi" ? (
<Check className="size-3.5 text-emerald-600" aria-hidden />
) : (
<MessageSquareText className="size-3.5" aria-hidden />
)}
{durum === "cevaplandi" ? "Formu cevapladın." : "Form kapandı; isteğini yazarak söyleyebilirsin."}
</p>
);
}
const items = anket.sorular.map((s) => ({
name: s.ad,
required: s.zorunlu !== false,
choices: s.secenekler.map((o) => ({ value: o.deger })),
}));
function gonder(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (durum === "gonderiliyor") return;
const veri = new FormData(e.currentTarget);
const cevaplar: AnketCevaplari = {};
for (const s of anket.sorular) {
const hepsi = veri
.getAll(s.ad)
.map((v) => String(v).trim())
.filter(Boolean);
if (hepsi.length === 0) continue;
cevaplar[s.ad] = s.coklu ? [...new Set(hepsi)] : hepsi[hepsi.length - 1];
}
if (Object.keys(cevaplar).length === 0) return;
onGonder(cevaplar);
}
return (
// Sade kutu (§4.4); balonun altında, aynı sütunda durur
<Questionnaire
aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined}
aria-label={anket.baslik ? undefined : "Danışmanın sorusu"}
className="rounded-2xl border border-slate-200 bg-card p-4"
defaultItem={anket.sorular[0]?.ad}
items={items}
shortcuts="letters"
onSubmit={gonder}
>
<div className="flex items-center justify-between gap-3">
<p id={`${kimlik}-baslik`} className="text-xs font-semibold text-slate-500">
{anket.baslik ?? "Danışmanın sorusu"}
</p>
<QuestionnaireProgress
render={(props, state) => (
<div {...props}>
{state.current} / {state.total}
</div>
)}
/>
</div>
{anket.sorular.map((s) => (
<QuestionnaireItem
key={s.ad}
name={s.ad}
multiple={s.coklu === true}
required={s.zorunlu !== false}
disabled={durum === "gonderiliyor"}
>
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
<QuestionnaireChoices>
{s.secenekler.map((o) => (
<QuestionnaireChoice key={o.deger} value={o.deger}>
<span>{o.etiket}</span>
{o.aciklama ? (
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription>
) : null}
</QuestionnaireChoice>
))}
{s.serbest ? (
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} />
) : null}
</QuestionnaireChoices>
<QuestionnaireError>
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
</QuestionnaireError>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious size="sm" />
<QuestionnaireSkip size="sm" />
<QuestionnaireNext size="sm" />
<QuestionnaireSubmit size="sm" disabled={durum === "gonderiliyor"}>
{durum === "gonderiliyor" ? "Gönderiliyor…" : "Cevapları gönder"}
</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}

View File

@@ -17,7 +17,7 @@ import {
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { cn } from "@/lib/utils";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
@@ -58,6 +58,14 @@ import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
import { olay } from "@/lib/analitik";
import type { ProfilAlani } from "@/lib/danisman-profil";
import {
anketCevabiMetni,
type Anket,
type AnketCevaplari,
type RevizyonOzeti,
type SohbetMeta,
} from "@/lib/ai/sohbet-meta";
import { SohbetAnketi, type AnketDurumu } from "./sohbet-anketi";
/** /api/soru/profil'in döndüğü, öğrenciye okunan profil satırı. */
export type ProfilSatiri = { alan: ProfilAlani; metin: string };
@@ -66,11 +74,72 @@ export interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
/** Yapısal ek: soru formu / form cevabı / revizyon sonucu (lib/ai/sohbet-meta.ts) */
meta?: SohbetMeta | null;
}
/** Kayıtlı (düz metin) mesajı useChat'in parçalı mesajına çevirir. */
/** Kayıttan gelen araç parçalarının kimlik öneki: canlı akışın parçalarından ayırt edilir. */
const KAYIT_PARCA_ONEKI = "kayit-";
/**
* Kayıtlı mesajı useChat'in parçalı mesajına çevirir. Kayıt düz metindir;
* soru formu ve revizyon sonucu `meta`dan araç parçası olarak geri kurulur ki
* canlı akışla aynı yoldan çizilsin (form, "Listeni yeniden kurdum" hapı).
*/
function uiMesaj(m: Mesaj): UIMessage {
return { id: m.id, role: m.role, parts: [{ type: "text", text: m.content }] };
const parts: UIMessage["parts"] = [{ type: "text", text: m.content }];
if (m.role === "assistant" && m.meta?.anket) {
parts.push({
type: "tool-soru_sor",
toolCallId: `${KAYIT_PARCA_ONEKI}anket-${m.meta.anket.id}`,
state: "output-available",
input: {},
output: { soruldu: true, anket: m.meta.anket },
} as UIMessage["parts"][number]);
}
if (m.role === "assistant" && m.meta?.revizyon?.ok) {
parts.push({
type: "tool-liste_revize",
toolCallId: `${KAYIT_PARCA_ONEKI}revizyon-${m.id}`,
state: "output-available",
input: {},
output: m.meta.revizyon,
} as UIMessage["parts"][number]);
}
return { id: m.id, role: m.role, parts };
}
/** Mesajdaki soru formu (soru_sor aracının çıktısı); yoksa null. */
function mesajinAnketi(m: UIMessage): Anket | null {
for (const p of m.parts) {
if (p.type !== "tool-soru_sor" || !("state" in p) || p.state !== "output-available") continue;
const cikti = p.output as { anket?: Anket } | undefined;
if (cikti?.anket?.sorular?.length) return cikti.anket;
}
return null;
}
/** Mesajdaki başarılı liste revizyonları: parça kimliği → özet. */
function mesajinRevizyonlari(m: UIMessage): { id: string; ozet: RevizyonOzeti }[] {
const sonuc: { id: string; ozet: RevizyonOzeti }[] = [];
for (const p of m.parts) {
if (p.type !== "tool-liste_revize" || !("state" in p) || p.state !== "output-available") continue;
const cikti = p.output as RevizyonOzeti | undefined;
if (cikti?.ok) sonuc.push({ id: p.toolCallId, ozet: cikti });
}
return sonuc;
}
/** Formun cevabı olan kullanıcı mesajı mı (anketCevabiMetni ile aynı başlık). */
const FORM_CEVABI_BASI = "Form cevaplarım:";
/**
* Sayfadaki "Revize et" düğmesi (listem-govde.tsx) sohbeti bu olayla odaklar:
* kutuya başlangıç metni yazılır, panel görünür yere kaydırılır, imleç kutuya gelir.
*/
export const SOHBET_ODAK_OLAYI = "kolaytercih:sohbet-odak";
export function sohbetiOdakla(metin?: string) {
window.dispatchEvent(new CustomEvent(SOHBET_ODAK_OLAYI, { detail: { metin } }));
}
/** Mesajın metni: araç turları arasındaki metin parçaları boş satırla birleşir. */
@@ -111,6 +180,20 @@ export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.
*/
const USTE_UZAKLIK_REM = 7;
const ALT_BLUR_REM = 7;
/**
* Dar hâlin boyu (Bilal, 7 Eki 2026: "yüksekliğini alttan kısalım, progressive
* blur üzerine geliyor, sığmıyor"). Panelin altı, sayfa en üstteyken bile alt
* bulanık şeridin yaklaşık 1rem üstünde biter. Ekrandan düşülen pay üç
* parçadır: panelin sayfa en üstteykenki üst boşluğu + 7rem şerit + 1rem nefes.
* Üst boşluk ölçüldü (7 Eki 2026, canlı site): sezon bandı + site başlığının
* altı telefonda 171, 768 px'te 187, 1280 px'te 137 px; üstüne `main` py-12 ve
* ızgara mt-8 (80 px) gelir → 251 / 267 / 217 px. Eski tek değer (18rem) sezon
* bandını hesaba katmıyordu: giriş kutusu masaüstünde ~40 px, telefonda ~90 px
* şeridin altında kalıyordu. Sezon bandının metni uzayıp satır atlarsa bu
* sayılar yeniden ölçülür. En az boy çok kısa ekran içindir.
*/
export const SOHBET_DAR_BOY =
"h-[calc(100dvh-23.75rem)] min-h-[340px] sm:h-[calc(100dvh-24.75rem)] lg:h-[calc(100dvh-21.5rem)]";
const GENIS_EN_AZ_PX = 380;
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
const GENIS_YUKSEKLIK = "";
@@ -128,10 +211,11 @@ function sohbetKanaliKur(onBasladi: (krediDustu: boolean) => void) {
let red: Red | null = null;
const transport = new DefaultChatTransport<UIMessage>({
api: "/api/soru",
prepareSendMessagesRequest: ({ messages }) => {
// `body`: sendMessage'a verilen ek gövde (soru formunun yapısal cevabı)
prepareSendMessagesRequest: ({ messages, body }) => {
const son = messages[messages.length - 1];
return {
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id },
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id, ...(body ?? {}) },
};
},
fetch: async (girdiUrl, init) => {
@@ -245,6 +329,8 @@ const ORNEK_SORULAR = [
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
];
/** Revizyon hakkı olan (paketli) adaya listeyi sohbetten değiştirme örneği önde gelir. */
const REVIZYON_ORNEGI = "Listemde değişiklik istiyorum: ilk 5 tercihim İstanbul'dan olsun";
/**
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
@@ -263,6 +349,7 @@ export function SohbetClient({
genis: genisProp,
onGenisDegistir,
genisYukseklik = GENIS_YUKSEKLIK,
revizyonHakki = 0,
}: {
baslangicKredi: number;
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
@@ -294,6 +381,8 @@ export function SohbetClient({
/** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır
* (üst 7rem sticky hizası, alt 7rem progressive blur payı). */
genisYukseklik?: string;
/** Kalan liste revizyonu hakkı (paketli); 0 ise revizyon örneği gösterilmez. */
revizyonHakki?: number;
}) {
const [girdi, setGirdi] = useState("");
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
@@ -406,6 +495,41 @@ export function SohbetClient({
? [...mesajlar, BEKLENEN_CEVAP]
: mesajlar;
// Canlı akışta liste_revize başarıyla bitince liste sayfada yenilenir
// (sunucu bileşeni raporu yeniden okur). Kayıttan gelen parçalar (önek)
// zaten ekrandaki listeye ait; onlar için yenileme yok.
const islenenRevizyonlar = useRef(new Set<string>());
useEffect(() => {
for (const m of mesajlar) {
if (m.role !== "assistant") continue;
for (const r of mesajinRevizyonlari(m)) {
if (r.id.startsWith(KAYIT_PARCA_ONEKI) || islenenRevizyonlar.current.has(r.id)) continue;
islenenRevizyonlar.current.add(r.id);
router.refresh();
toast.success("Listen yeniden kuruldu.");
olay("liste_revize", { sayfa: "listem", yol: "sohbet" });
}
}
}, [mesajlar, router]);
// Sayfadaki "Revize et" düğmesi sohbeti odaklar (sohbetiOdakla)
useEffect(() => {
function odakla(e: Event) {
const metin = (e as CustomEvent<{ metin?: string }>).detail?.metin;
if (typeof metin === "string") setGirdi(metin);
const el = kapRef.current;
el?.scrollIntoView({ behavior: hareketAz ? "auto" : "smooth", block: "nearest" });
// Kaydırma başlasın, sonra odak (odak kaydırmayı yarıda keser)
window.setTimeout(() => {
const alan = el?.querySelector("textarea");
alan?.focus();
alan?.setSelectionRange(alan.value.length, alan.value.length);
}, 50);
}
window.addEventListener(SOHBET_ODAK_OLAYI, odakla);
return () => window.removeEventListener(SOHBET_ODAK_OLAYI, odakla);
}, [hareketAz]);
/**
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
* ve giriş kutusuna koy. Akış yarıda koptuysa tahmin etme: sunucu krediyi
@@ -536,6 +660,30 @@ export function SohbetClient({
void sendMessage({ id, role: "user", parts: [{ type: "text", text: mesaj }] });
}
/**
* Soru formunun cevabı: balonda okunur metin, gövdede yapısal cevap. Kredi
* harcamaz (sunucu formu son danışman mesajıyla eşleştirip bedelsiz sayar),
* bu yüzden kredi kapısından geçmez.
*/
function anketiGonder(anket: Anket, cevaplar: AnketCevaplari) {
if (bekliyor) return;
const metin = anketCevabiMetni(anket, cevaplar);
const id = crypto.randomUUID();
sonSoruRef.current = null;
void sendMessage(
{ id, role: "user", parts: [{ type: "text", text: metin }] },
{ body: { anketCevabi: { anketId: anket.id, cevaplar } } },
);
}
/** Formun durumu: yalnız son danışman mesajındaki form açıktır. */
function anketDurumu(i: number): AnketDurumu {
const sonraki = gorunenMesajlar[i + 1];
if (!sonraki) return bekliyor ? "gonderiliyor" : "acik";
if (sonraki.role === "user" && mesajMetni(sonraki).startsWith(FORM_CEVABI_BASI)) return "cevaplandi";
return "kapandi";
}
/**
* Sohbet geçmişini hem sunucudan hem ekrandan siler. Krediler iade
* edilmez; danışman bir sonraki soruda sıfır geçmişle başlar (rapor özeti
@@ -569,10 +717,7 @@ export function SohbetClient({
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
// 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
// sohbeti sıkıştırıyordu, 10-14rem denemeleri fazla yüksek geldi;
// panel görünür ama sayfaya baskın değil.
className ?? "h-[calc(100dvh-18rem)] min-h-[380px]",
className ?? SOHBET_DAR_BOY,
)}
>
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
@@ -796,7 +941,7 @@ export function SohbetClient({
!ozet && "justify-center",
)}
>
{ORNEK_SORULAR.map((s) => (
{[...(revizyonHakki > 0 ? [REVIZYON_ORNEGI] : []), ...ORNEK_SORULAR].map((s) => (
<PromptSuggestion
key={s}
size="sm"
@@ -842,6 +987,8 @@ export function SohbetClient({
// Danışman veriye bakarken ne yaptığını hap olarak söyler
// (arac-pilleri.tsx); haplar cevap bitince de kalır.
const piller = aracPilleri(m);
// Soru formu balonun altında (sohbet-anketi.tsx)
const anket = mesajinAnketi(m);
return (
<Message
key={cevapAnahtari(gorunenMesajlar, i)}
@@ -868,6 +1015,13 @@ export function SohbetClient({
) : (
<CevapIskeleti />
)}
{anket ? (
<SohbetAnketi
anket={anket}
durum={anketDurumu(i)}
onGonder={(cevaplar) => anketiGonder(anket, cevaplar)}
/>
) : null}
</div>
</Message>
);
@@ -917,7 +1071,11 @@ export function SohbetClient({
className="rounded-2xl border-slate-200"
>
<PromptInputTextarea
placeholder="Sorunu yaz… (1 kredi)"
placeholder={
revizyonHakki > 0
? "Sorunu yaz ya da listende ne değişsin söyle… (1 kredi)"
: "Sorunu yaz… (1 kredi)"
}
maxLength={2000}
/>
<PromptInputActions className="justify-end pt-1">

View File

@@ -19,6 +19,79 @@ export type Senaryo = {
const ARAC_BEKLEME = 1400;
export const SENARYOLAR: Senaryo[] = [
{
ad: "Soru formu + revizyon",
aciklama:
"Belirsiz revizyon isteği: danışman soru formu açar (soru_sor), cevap gelince listeyi yeniden kurar (liste_revize).",
kredi: 12,
transport: () =>
createChat()
.user("Listemde daha fazla İstanbul olsun")
.sleep(600)
.assistant(({ writer }) => {
writer
.tool("soru_sor", {
input: { sorular: [] },
})
.sleep(700)
.output({
soruldu: true,
anket: {
id: "demo-anket",
baslik: "Listeni nasıl değiştirelim?",
sorular: [
{
ad: "adet",
soru: "İlk kaç tercihin İstanbul'dan olsun?",
secenekler: [
{ deger: "3", etiket: "İlk 3" },
{ deger: "5", etiket: "İlk 5" },
{ deger: "10", etiket: "İlk 10" },
],
},
{
ad: "kapsam",
soru: "İstanbul dışındaki iller listede kalsın mı?",
aciklama: "Kalırsa liste yine 24 tercih; yalnız başı İstanbul olur.",
secenekler: [
{ deger: "kalsin", etiket: "Kalsın", aciklama: "Ankara ve İzmir de listede olsun" },
{ deger: "yalniz", etiket: "Yalnız İstanbul" },
],
},
],
},
});
writer.text("Birkaç kısa soru, sonra listeni yeniden kurayım.");
})
.user("Form cevaplarım:\n- İlk kaç tercihin İstanbul'dan olsun? → İlk 5\n- İstanbul dışındaki iller listede kalsın mı? → Kalsın")
.sleep(500)
.assistant(({ writer }) => {
writer
.tool("liste_revize", {
input: { ilkTercihler: { adet: 5, iller: ["İstanbul"] }, iller: { ekle: ["İstanbul"] } },
})
.sleep(2500)
.output({
ok: true,
toplam: 24,
dagilim: { hayal: 5, dengeli: 13, garanti: 6 },
kalanHak: 1,
ilkTercihler: [
"Bilgisayar Mühendisliği — İstanbul Teknik Üniversitesi (İSTANBUL)",
"Bilgisayar Mühendisliği — Boğaziçi Üniversitesi (İSTANBUL)",
"Yazılım Mühendisliği — Yıldız Teknik Üniversitesi (İSTANBUL)",
"Bilgisayar Mühendisliği — Marmara Üniversitesi (İSTANBUL)",
"Bilgisayar Mühendisliği — İstanbul Üniversitesi-Cerrahpaşa (İSTANBUL)",
],
});
writer.text(
"Listeni yeniden kurdum: ilk 5 tercihin artık İstanbul'dan, kalan 19 tercih Ankara ve İzmir'le devam ediyor. Dağılım yine 5 hayal / 13 dengeli / 6 güvenli.\n\n" +
"1. Bilgisayar Mühendisliği — İTÜ\n2. Bilgisayar Mühendisliği — Boğaziçi\n3. Yazılım Mühendisliği — YTÜ\n4. Bilgisayar Mühendisliği — Marmara\n5. Bilgisayar Mühendisliği — İÜ-Cerrahpaşa\n\n" +
"Kalan revizyon hakkın: 1.",
);
})
.transport({ delayMs: 20 }),
},
{
ad: "Tek araç",
aciklama: "Bir arama, sonra kısa cevap.",

View File

@@ -0,0 +1,164 @@
import "server-only";
import { and, eq, sql } from "drizzle-orm";
import { appDb, schema } from "@/lib/appdb";
import {
grantCredits,
krediBittiIsaretle,
MAX_REVIZYON,
RAPOR_KREDI,
spendCredits,
} from "@/lib/credits";
import { uretimKilidiAl, uretimKilidiBirak } from "@/lib/uretim-kilidi";
import { profilGetir } from "@/lib/danisman-profil-db";
import { listeOzetiCikar, raporUret, RaporUretimHatasi } from "@/lib/ai/rapor";
import { kurallariBirlestir } from "@/lib/revizyon-kurali";
import type { RevizyonOzeti } from "@/lib/ai/sohbet-meta";
import { kullanicininRaporu } from "./rapor-queries";
import type { RaporParams, RaporSonuc, RevizyonKurali } from "./types/rapor";
/**
* Sohbetten liste revizyonu (7 Eki 2026). Danışmanın `liste_revize` aracı
* (lib/ai/araclar.ts) burayı çağırır; eski /listem revizyon kutusu ve
* `listeRevize` sunucu aksiyonu kaldırıldı — tek yol budur.
*
* Koruma kapıları eski aksiyonla aynı: paket şartı, MAX_REVIZYON sayacı,
* kullanıcı başına üretim kilidi, `requestId` (= adayın sohbet mesajının
* kimliği) ile idempotent defter kaydı, başarısız üretimde iade. Bedel
* paketliye 0 (liste ve revizyon pakete dâhil); satır yine yazılır ki aynı
* mesaj ikinci kez revizyon tetiklemesin.
*/
export type RevizyonSonucu =
| { ok: true; ozet: Extract<RevizyonOzeti, { ok: true }>; rapor: RaporSonuc }
| { ok: false; neden: string; kod: "PAKET" | "HAK" | "RAPOR" | "SURUYOR" | "KREDI" | "HATA" };
function hataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message;
if (
err instanceof Error &&
/abort|timeout|timed out/i.test(`${err.name} ${err.constructor.name} ${err.message}`)
) {
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; revizyon hakkın kullanılmadı.";
}
console.error("[revizyon] üretim hatası:", err instanceof Error ? err.message : String(err));
return "Liste yeniden kurulurken bir sorun oluştu; revizyon hakkın kullanılmadı.";
}
function ozetCikar(rapor: RaporSonuc, revisionCount: number): Extract<RevizyonOzeti, { ok: true }> {
const say = (d: string) => rapor.tercihler.filter((t) => t.dilim === d).length;
return {
ok: true,
toplam: rapor.tercihler.length,
dagilim: { hayal: say("hayal"), dengeli: say("dengeli"), garanti: say("garanti") },
kalanHak: Math.max(0, MAX_REVIZYON - revisionCount),
ilkTercihler: rapor.tercihler.slice(0, 5).map((t) => {
const p = rapor.programlar[t.programId];
return `${p?.isim ?? "?"} — ${p?.universite ?? "?"}${p?.il ? ` (${p.il})` : ""}`;
}),
};
}
export async function listeRevizeCekirdek(input: {
userId: string;
/** Idempotency anahtarı: adayın sohbet mesajının kimliği. */
requestId: string;
kural: RevizyonKurali;
}): Promise<RevizyonSonucu> {
const user = await appDb.query.user.findFirst({
where: eq(schema.user.id, input.userId),
columns: { hasPaket: true, creditBalance: true },
});
if (!user) return { ok: false, kod: "HATA", neden: "Kullanıcı bulunamadı." };
if (!user.hasPaket) {
return {
ok: false,
kod: "PAKET",
neden: "Liste revizyonu Tercih Dönemi Paketi'ne dâhil; aday paketsiz.",
};
}
const rapor = await kullanicininRaporu(input.userId);
if (!rapor?.result) {
return { ok: false, kod: "RAPOR", neden: "Adayın kayıtlı listesi yok; önce liste oluşturulmalı." };
}
if (rapor.revisionCount >= MAX_REVIZYON) {
return {
ok: false,
kod: "HAK",
neden: `Revizyon hakkı doldu (en fazla ${MAX_REVIZYON}).`,
};
}
const kilit = await uretimKilidiAl(input.userId, input.requestId);
if (!kilit.ok) {
return { ok: false, kod: "SURUYOR", neden: "Liste şu anda zaten yeniden kuruluyor." };
}
try {
const eskiParams = rapor.params as RaporParams;
const onceki = rapor.result as RaporSonuc;
const params: RaporParams = {
...eskiParams,
revizyon: kurallariBirlestir(eskiParams.revizyon, input.kural),
};
const maliyet = user.hasPaket ? 0 : RAPOR_KREDI;
const harcama = await spendCredits({
userId: input.userId,
amount: maliyet,
reason: "report_revision",
refId: input.requestId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(input.userId);
return { ok: false, kod: "KREDI", neden: `Revizyon için ${RAPOR_KREDI} kredi gerekli.` };
}
// DUPLICATE: aynı mesaj ikinci kez (ağ tekrarı). İade varsa önceki deneme
// başarısızdı; yoksa revizyon zaten yapıldı → güncel listeyi döndür.
const iade = await appDb.query.creditLedger.findFirst({
where: and(
eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId),
),
});
if (iade) return { ok: false, kod: "HATA", neden: "Önceki deneme başarısız olmuştu; yeni bir mesajla tekrar iste." };
const guncel = await kullanicininRaporu(input.userId);
if (guncel?.result) {
const sonuc = guncel.result as RaporSonuc;
return { ok: true, ozet: ozetCikar(sonuc, guncel.revisionCount), rapor: sonuc };
}
return { ok: false, kod: "SURUYOR", neden: "Liste şu anda zaten yeniden kuruluyor." };
}
let sonuc: RaporSonuc;
try {
const profil = await profilGetir(input.userId).catch(() => undefined);
sonuc = await raporUret(
params,
input.kural.not?.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
profil,
);
} catch (err) {
await grantCredits({
userId: input.userId,
delta: maliyet,
reason: "refund",
refId: input.requestId,
}).catch(() => {});
return { ok: false, kod: "HATA", neden: hataMesaji(err) };
}
await appDb
.update(schema.reports)
.set({
params,
result: sonuc,
revisionCount: sql`${schema.reports.revisionCount} + 1`,
updatedAt: new Date(),
})
.where(eq(schema.reports.id, rapor.id));
return { ok: true, ozet: ozetCikar(sonuc, rapor.revisionCount + 1), rapor: sonuc };
} finally {
await uretimKilidiBirak(input.userId, input.requestId);
}
}

View File

@@ -1,6 +1,6 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
uretimKilidiAl,
uretimKilidiBirak,
@@ -15,13 +15,8 @@ import {
grantCredits,
krediBittiIsaretle,
RAPOR_KREDI,
MAX_REVIZYON,
} from "@/lib/credits";
import {
raporUret,
listeOzetiCikar,
RaporUretimHatasi,
} from "@/lib/ai/rapor";
import { raporUret, RaporUretimHatasi } from "@/lib/ai/rapor";
import type {
RaporParams,
RaporSonuc,
@@ -31,6 +26,8 @@ import { raporMaskele } from "@/lib/rapor-maske";
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
// Liste revizyonu burada değil: sohbetteki liste_revize aracı
// (features/rapor/liste-revizyon.ts) tek yoldur (7 Eki 2026).
export type ListeSonuc =
| {
ok: true;
@@ -259,133 +256,3 @@ async function listeOlusturKilitli(
hasPaket: user.hasPaket,
};
}
export async function listeRevize(input: {
feedback: string;
requestId: string;
}): Promise<ListeSonuc> {
const session = await getSession();
if (!session) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
const user = await getCurrentUser();
if (!user) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
if (!user.hasPaket) return { ok: false, code: "PAKET", error: "Paket gerekli." };
const feedback = (input.feedback ?? "").trim();
if (feedback.length < 5) {
return {
ok: false,
code: "HATA",
error: "Ne değişsin istediğini kısaca yazmalısın.",
};
}
if (!input.requestId) {
return { ok: false, code: "HATA", error: "Geçersiz istek." };
}
const rapor = await kullanicininRaporu(user.id);
if (!rapor?.result) {
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
}
if (rapor.revisionCount >= MAX_REVIZYON) {
return {
ok: false,
code: "HATA",
error: `Revizyon hakkın doldu (en fazla ${MAX_REVIZYON}).`,
};
}
const params = rapor.params as RaporParams;
const onceki = rapor.result as RaporSonuc;
// Revizyon pakete dâhil (bkz. listeOlusturKilitli'deki aynı not). Yukarıdaki
// hasPaket kapısı yüzünden burada maliyet her zaman 0; ifade yine de koşullu
// yazılıyor ki kapı ileride gevşerse paketsiz kullanıcıdan ücret düşmeye
// devam etsin. Revizyon hakkını MAX_REVIZYON sayacı sınırlıyor, kredi değil.
const maliyet = user.hasPaket ? 0 : RAPOR_KREDI;
const harcama = await spendCredits({
userId: user.id,
amount: maliyet,
reason: "report_revision",
refId: input.requestId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`,
};
}
// DUPLICATE: bu requestId zaten işlendi. İade varsa önceki deneme
// başarısızdı → taze bir denemeye yönlendir. Yoksa revizyon tamamlanmış
// ama cevap istemciye ulaşmamıştır → güncel raporu döndür.
const iade = await appDb.query.creditLedger.findFirst({
where: and(
eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId),
),
});
if (iade) {
return {
ok: false,
code: "HATA",
error:
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
};
}
const guncel = await kullanicininRaporu(user.id);
if (guncel?.result) {
return {
ok: true,
rapor: guncel.result as RaporSonuc,
params: guncel.params as RaporParams,
revisionCount: guncel.revisionCount,
kredi: user.creditBalance,
hasPaket: true,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
}
let sonuc: RaporSonuc;
try {
const profil = await profilGetir(user.id).catch(() => undefined);
sonuc = await raporUret(
params,
feedback.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
profil,
);
} catch (err) {
// İade satırı burada da yazılır (paketlide delta 0) — DUPLICATE dalındaki
// "önceki deneme başarısızdı" çıkarımı buna dayanıyor.
await grantCredits({
userId: user.id,
delta: maliyet,
reason: "refund",
refId: input.requestId,
}).catch(() => {});
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
}
// Sayaç yalnızca başarılı üretimde artar
await appDb
.update(schema.reports)
.set({
result: sonuc,
revisionCount: sql`${schema.reports.revisionCount} + 1`,
updatedAt: new Date(),
})
.where(eq(schema.reports.id, rapor.id));
return {
ok: true,
rapor: sonuc,
params,
revisionCount: rapor.revisionCount + 1,
kredi: user.creditBalance - maliyet,
hasPaket: true,
};
}

View File

@@ -51,6 +51,7 @@ export async function sohbetGecmisiGetir(userId: string) {
id: schema.chatMessages.id,
role: schema.chatMessages.role,
content: schema.chatMessages.content,
meta: schema.chatMessages.meta,
})
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, userId))

View File

@@ -81,4 +81,48 @@ export interface RaporParams {
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
il?: string;
notlar?: string;
/**
* Sohbetten yapılan liste revizyonlarının yapısal kuralları (7 Eki 2026).
* Sihirbaz seçimlerinin ÜSTÜNE uygulanır; sihirbaz seçimleri değişmediği
* için "Tercihlerin değişmiş" farkına girmez (tercih-farki.ts yalnız adlı
* alanlara bakar). Her yeni revizyonda üst anahtar bazında birleştirilir.
*/
revizyon?: RevizyonKurali;
}
/** "yalnız / ekle / çıkar" üçlüsü: sihirbaz listesine göre fark. */
export type ListeFarki = {
yalniz?: string[];
ekle?: string[];
cikar?: string[];
};
/**
* Sohbetten gelen revizyon kuralları (lib/ai/araclar.ts `liste_revize`).
* Serbest metin yerine yapısal: havuz süzgeci, iskelet ve sıra koddan
* uygulanır; model yalnız seçer ve gerekçe yazar. Eksik alan "dokunma" demektir.
*/
export type RevizyonKurali = {
iller?: ListeFarki;
kategoriler?: ListeFarki;
universiteTipi?: UniturGrubu | "farketmez";
/** Dilim iskeleti: standart 5/13/6, güvenli ağırlıklı 4/12/8, hayal ağırlıklı 7/12/5. */
iskelet?: "standart" | "guvenli-agirlikli" | "hayal-agirlikli";
/** "İlk N tercih şu ölçüte uysun": listenin başı bu ölçütle dolar. */
ilkTercihler?: {
adet: number;
iller?: string[];
kategoriler?: string[];
universiteTipi?: UniturGrubu;
};
/** Listeden ve havuzdan dışlanacaklar (ad eşleşmesi büyük-küçük harfsiz). */
cikar?: {
universiteler?: string[];
bolumler?: string[];
programIdler?: string[];
};
/** Listeye mutlaka girecek program kimlikleri (havuzda yoksa dilimine eklenir). */
ekle?: { programIdler: string[] };
/** Adayın isteğinin kısa özeti; modele revizyon notu olarak gider. */
not?: string;
};

View File

@@ -1,10 +1,14 @@
/**
* 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` + `liste_revize` sohbetten liste
* revizyonu (7 Eki 2026): danışman belirsiz isteği form olarak sorar
* (lib/ai/sohbet-meta.ts), netleşince YAPISAL kuralla listeyi yeniden kurdurur
* (features/rapor/liste-revizyon.ts). 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 +89,19 @@ import {
} from "@/lib/danisman-profil";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
import { anketDogrula, type Anket } from "./sohbet-meta";
import type { RevizyonKurali, ListeFarki } from "@/features/rapor/types/rapor";
export type AracBaglami = {
userId: string;
sira: number;
tur: PuanTuruKey;
/** Paketli mi (liste_revize paket ister); verilmezse araç "paket yok" sayar. */
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) */
@@ -405,6 +417,123 @@ 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, o zaman liste_revize çağı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,
},
},
},
{
type: "function",
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.",
parameters: {
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"] },
},
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,
},
},
},
];
// ── Yardımcılar ──────────────────────────────────────────────────────────
@@ -1480,6 +1609,168 @@ function alanHatasi(ad: string, s: AlanSemasi, v: unknown): string | null {
return null;
}
// ── Sohbetten liste revizyonu ────────────────────────────────────────────
/** `soru_sor`: formu denetler, kimlik verir; metin ve kayıt çağıranın (route) işi. */
function soruSor(a: Record<string, unknown>): { 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 {
soruldu: true,
anket: { id: crypto.randomUUID(), ...d.anket },
not: "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<string, unknown>;
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) };
}
/** Modelin argümanını RevizyonKurali'na çevirir; il/alan adları doğrulanır. */
function revizyonKuraliKur(a: Record<string, unknown>): { 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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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. İstek belirsizse soru_sor ile sor." };
}
return { ok: true, kural };
}
/**
* `liste_revize`: kuralı doğrular, listeyi yeniden kurdurur
* (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).
*/
async function listeRevize(b: AracBaglami, a: Record<string, unknown>): Promise<unknown> {
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 kuralSonucu = revizyonKuraliKur(a);
if (!kuralSonucu.ok) return { hata: `Geçersiz kural — ${kuralSonucu.hata}. Düzeltip yeniden çağır.` };
const { listeRevizeCekirdek } = await import("@/features/rapor/liste-revizyon");
const sonuc = await listeRevizeCekirdek({
userId: b.userId,
requestId: b.mesajId,
kural: kuralSonucu.kural,
});
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.",
};
}
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.",
};
}
/**
* 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 +1839,10 @@ export function aracCalistirici(b: AracBaglami) {
return rehberOku(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
case "soru_sor":
return soruSor(a);
case "liste_revize":
return listeRevize(b, a);
default:
return { hata: `Bilinmeyen araç: ${ad}` };
}

View File

@@ -107,6 +107,25 @@ export function kayitBeyaniVar(metin: string): boolean {
export const KAYIT_DUZELTME_NOTU =
"Düzeltme: Bu cevapta profiline yeni bir kayıt YAPILMADI; yukarıda \"kaydettim\" dediysem henüz kaydedemedim. Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz.)";
/**
* Cevap "listeni yeniden kurdum / revize ettim" türü bir beyan içeriyor mu.
* Revizyon yalnız liste_revize aracıyla olur (7 Eki 2026); model aracı
* çağırmadan "güncelledim" diyebilir. Olumsuzlar ve sorular eşleşmez.
*/
const REVIZE_BEYANI =
/(?<!\p{L})(?:listeni (?:yeniden kurdum|güncelledim|revize ettim|değiştirdim|yeniledim)|liste(?:n)? (?:yeniden kuruldu|güncellendi|revize edildi|yenilendi)|revizyonu(?:nu)? (?:yaptım|tamamladım|uyguladım)|yeniden kurdum)(?!\p{L})/u;
const REVIZE_REDDI =
/(?<!\p{L})(?:kuramadım|kurulamadı|güncelleyemedim|güncellenemedi|revize edemedim|değiştiremedim|yenileyemedim|kurmadım|değiştirmedim)(?!\p{L})/u;
export function revizeBeyaniVar(metin: string): boolean {
const m = tr(metin);
return REVIZE_BEYANI.test(m) && !REVIZE_REDDI.test(m);
}
/** Cevap revizyon bildiriyor ama o turda başarılı liste_revize yoksa sonuna eklenir. */
export const REVIZE_DUZELTME_NOTU =
"Düzeltme: Bu cevapta listen YENİDEN KURULMADI; yukarıda \"yeniden kurdum\" dediysem henüz yapmadım. Ne değişsin istediğini bir cümleyle yazarsan listeni ona göre yeniden kurarım.";
/**
* İç şema sızıntısı belirtisi: cevapta araçların iç adlarından en az üçü ya
* da JSON şema anahtarları geçiyor (5 Eki 2026 eval s077: 11 aracın adı ve

View File

@@ -13,6 +13,8 @@ import { PUAN_TURLERI } from "@/types/yokatlas";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { adayDurumu, ozelDurumOzeti } from "@/lib/ozel-durum";
import { ONCEKI_YIL, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { kuralOzeti } from "@/lib/revizyon-kurali";
import { KATEGORILER } from "@/lib/kategoriler";
/**
* Profil kaydı öncesi "kaydedeyim mi?" onayı (KVKK Üretken YZ Rehberi:
@@ -64,6 +66,49 @@ programda baraj yok ya da veride yok — baraj UYDURMA. Ön lisans (TYT)
programlarında başarı sırası barajı yoktur.`;
}
/**
* Sohbetten liste revizyonu kuralları (7 Eki 2026). Eski "revize et" kutusu
* kaldırıldı; aday listesinde değişiklik istediğinde danışman önce isteği
* 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 {
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.`;
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ış:
1. İstek NET ve tam ise (il adı belli, kaç tercih belli, ekle mi yalnız mı
belli) doğrudan liste_revize çağır. Karşılıklar: "ilk N tercih X ilinden"
→ ilkTercihler {adet: N, iller: [X]} (X sihirbaz illerinde yoksa iller.ekle
ile de ekle); "sadece X" → iller.yalniz; "X de olsun" → iller.ekle; "X
olmasın" → iller.cikar; "sadece devlet / vakıf olmasın" → universiteTipi;
"daha güvenli / garantili olsun" → iskelet guvenli-agirlikli; "daha iddialı"
→ iskelet hayal-agirlikli; "X üniversitesi / Y bölümü olmasın" → cikar; "şu
programı ekle" → önce program_ara ile kimliği bul, sonra ekle.programIdler.
İlgi alanı adları tam olarak şunlardan biri olmalı: ${KATEGORILER.join(", ")}.
2. İstek BELİRSİZ ise (il söylenmemiş, "ilk kaç" belli değil, ekle mi yalnız mı
belli değil, "daha iyi olsun" gibi) ÖNCE soru_sor ile 1-3 kısa soru sor;
seçenekleri adayın durumuna göre yaz (ör. sihirbaz illeri + "Başka il").
Form açıldıktan sonra başka araç çağırma, formu metinle tekrarlama, tek
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
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.
Sihirbaz seçimlerini (sıra, alan) değiştirmek bu aracın işi değil: aday
sırasını ya da alanını değiştirmek isterse "Tercihlerimi düzenle"
düğmesine yönlendir.
${durum}${mevcut.length ? `\nUYGULANMIŞ REVİZYON KURALLARI (önceki revizyonlardan, hâlâ geçerli): ${mevcut.join("; ")}.` : ""}`;
}
export function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
@@ -77,6 +122,11 @@ export function sistemPromptu(opts: {
* değerlendirme verilmez (liste gizli); uyarılar herkese.
*/
rapor?: { genelDegerlendirme?: string; uyarilar?: string[]; kapsam?: { toplam: number; uyarlandi: boolean } };
/**
* Sohbetten liste revizyonu (7 Eki 2026): kalan hak ve paket durumu.
* Verilmezse revizyon bölümü yazılmaz (eval betikleri eski davranışı korur).
*/
revizyon?: { kalanHak: number; paketli: boolean };
}): string {
const { params } = opts;
const secimVar =
@@ -173,8 +223,9 @@ KURALLAR:
Kendi genel bilginle kural, oran, tutar, katsayı ya da tarih YAZMA. Yazı
bulunmazsa "rehberimizde bu konuda yazı yok" de ve ÖSYM kılavuzuna ya da
ilgili kuruma yönlendir.
- ARAÇ KURALLARI: araçlar yalnız okur; 24'lük listeyi araçla kurmazsın,
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
- ARAÇ KURALLARI: sorgu araçları yalnız okur; 24'lük listeyi kendin
KURMAZSIN, satır ekleyip çıkarmazsın — listeyi değiştiren tek yol
liste_revize aracıdır (aşağıda). Araç sonucundaki sayıları olduğu gibi
aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır
(sınır: bir adımda 4, bir cevapta 8 çağrı; fazlası çalıştırılmaz).
Araç çağırmadan ÖNCE yazdığın metin ("İşte en iyi 10 üniversite:")
@@ -271,6 +322,7 @@ KURALLAR:
// TYT sırası lisans barajlarıyla kıyaslanmaz; ön lisansta baraj yok
const baraj = params.tur === "tyt" ? null : barajBolumu(params.sira, opts.barajlar);
if (baraj) parcalar.splice(2, 0, baraj);
if (opts.revizyon) parcalar.push(revizyonBolumu(opts.revizyon, params));
// Raporun üst metni: adayın listesini kuran yapay zekânın genel
// değerlendirmesi ve uyarıları — danışman "listem nasıl kuruldu?" sorusunu
// kendi raporundan cevaplar. Kilitli kullanıcıda genel değerlendirme gizli.

View File

@@ -24,7 +24,6 @@ import {
havuzuKompaktJson,
turLabel,
dilimHedefiHesapla,
idealHedef,
IDEAL_HEDEF,
type DilimHedef,
type Dilim,
@@ -40,6 +39,14 @@ import {
} from "../risk";
import { trendOzetiMetni } from "../trend";
import { ILK_YIL } from "../veri-yillari";
import {
cikarSuzgeci,
efektifParams,
ilkTercihleriOneAl,
kuralOzeti,
oncelikSuzgeci,
revizyonIskeleti,
} from "../revizyon-kurali";
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
@@ -364,7 +371,7 @@ function mockRapor(
revizyonNotu?: string,
): RaporSonuc {
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const efektif = (p: AdayProgram) => p.efektifSira ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
@@ -407,13 +414,17 @@ function mockRapor(
"(Dev mock) Güvenli dilimindeki son tercihleri asla silme.",
],
programlar,
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
devMock: true,
};
}
/** Modele giden havuz: dilim başına üç dizi; satırda `dilim` alanı yok (dizinin adı dilimdir). */
function havuzuDilimliJson(havuz: AdayProgram[]): string {
/** Modele giden havuz: dilim başına üç dizi; satırda `dilim` alanı yok (dizinin adı dilimdir).
* `oncelikli`: revizyonun "ilk N" ölçütüne uyan satırlar işaretlenir (yalnız kural varken). */
function havuzuDilimliJson(
havuz: AdayProgram[],
oncelikli?: ((p: AdayProgram) => boolean) | null,
): string {
const satirlar = JSON.parse(havuzuKompaktJson(havuz)) as ({
id: string;
dilim: Dilim;
@@ -423,7 +434,11 @@ function havuzuDilimliJson(havuz: AdayProgram[]): string {
dengeli: [],
guvenli: [],
};
for (const { dilim, ...kalan } of satirlar) gruplar[TEL_ADI[dilim]].push(kalan);
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
for (const { dilim, ...kalan } of satirlar) {
const p = oncelikli ? havuzMap.get(kalan.id as string) : undefined;
gruplar[TEL_ADI[dilim]].push(p && oncelikli!(p) ? { ...kalan, oncelikli: true } : kalan);
}
return JSON.stringify(gruplar);
}
@@ -482,7 +497,17 @@ export async function raporUretAyrintili(
oncekiListeOzeti?: string,
profil?: DanismanProfili,
): Promise<{ rapor: RaporSonuc; tani: RaporTani | null }> {
const { havuz, gevsetildi } = havuzOlustur(params, profil);
// Sohbet revizyonu (revizyon-kurali.ts): il/alan/tip farkı sihirbaz seçiminin
// üstüne biner, "çıkar" havuza girmez, "ilk N" ölçütü ve "ekle" kimlikleri
// kırpmada öne alınır. Kayıtlı params değişmez; yalnız üretim bu kopyayı görür.
const kural = params.revizyon;
const havuzParams = efektifParams(params);
const oncelikli = oncelikSuzgeci(kural);
const { havuz, gevsetildi } = havuzOlustur(havuzParams, profil, {
cikar: cikarSuzgeci(kural) ?? undefined,
oncelikli: oncelikli ?? undefined,
ekstraIdler: kural?.ekle?.programIdler,
});
const profilSatir = profil ? profilSatirlari(profil) : [];
if (havuz.length < MIN_LISTE) {
throw new RaporUretimHatasi(
@@ -494,15 +519,17 @@ export async function raporUretAyrintili(
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
// üretim her seferinde doğrulamada patlıyordu. İskelet "Kolay yerleşme"
// seçiliyse 4/12/8 (idealHedef).
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.ozelDurum?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
(havuzParams.kategoriler?.length ?? 0) > 0 ||
(havuzParams.iller?.length ?? 0) > 0 ||
(havuzParams.oncelikler?.length ?? 0) > 0 ||
(havuzParams.ozelDurum?.length ?? 0) > 0 ||
(havuzParams.universiteTipi && havuzParams.universiteTipi !== "farketmez");
const kuralSatirlari = kuralOzeti(kural);
const ilkN = kural?.ilkTercihler;
const gevsetmeNotu: string[] = [];
if (gevsetildi.includes("profil")) {
@@ -530,13 +557,20 @@ export async function raporUretAyrintili(
const kullaniciMesaji = [
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
secimVar ? `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
kategoriler: params.kategoriler ?? [],
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
ozelDurum: params.ozelDurum ?? [],
kategoriler: havuzParams.kategoriler ?? [],
iller: havuzParams.iller ?? [],
universiteTipi: havuzParams.universiteTipi ?? "farketmez",
oncelikler: havuzParams.oncelikler ?? [],
ozelDurum: havuzParams.ozelDurum ?? [],
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
: null,
kuralSatirlari.length > 0
? `ADAYIN SOHBETTE İSTEDİĞİ REVİZYON KURALLARI (koddan uygulandı; havuz buna göre kuruldu):\n${kuralSatirlari.map((k) => `- ${k}`).join("\n")}${
ilkN && oncelikli
? `\nHavuzda "oncelikli": true olan satırlar "ilk ${ilkN.adet}" ölçütüne uyar: hayal ve dengeli listelerinde bunlardan toplam EN AZ ${ilkN.adet} tane seç; kod bunları listenin başına alacak.`
: ""
}`
: null,
params.notlar
? `Adayın ek notları: <aday_notu>${kullaniciMetni(params.notlar)}</aday_notu>`
: null,
@@ -547,7 +581,7 @@ export async function raporUretAyrintili(
revizyonNotu
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: <revizyon_istegi>${kullaniciMetni(revizyonNotu)}</revizyon_istegi>\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
: null,
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz)}`,
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz, oncelikli)}`,
]
.filter(Boolean)
.join("\n\n");
@@ -558,11 +592,12 @@ export async function raporUretAyrintili(
return p.efektifSira ?? Number.MAX_SAFE_INTEGER;
};
// Adayın seçimine uyum (tamamlama önceliği ve şablon gerekçe için)
const seciliKategoriler = params.kategoriler ?? [];
// Adayın seçimine uyum (tamamlama önceliği ve şablon gerekçe için);
// revizyonla değişen il/alan da buradan okunur
const seciliKategoriler = havuzParams.kategoriler ?? [];
const seciliIller = [
...(params.il ? [params.il] : []),
...(params.iller ?? []),
...(havuzParams.il ? [havuzParams.il] : []),
...(havuzParams.iller ?? []),
];
const alanUyar = (p: AdayProgram) =>
seciliKategoriler.length > 0 &&
@@ -571,7 +606,10 @@ export async function raporUretAyrintili(
seciliIller.length > 0 && p.il != null && seciliIller.includes(p.il);
// Alan 2, il 1, uyum puanı 0-1: tamamlama ve kırpma önce seçime uyana, eşitte
// uyum puanı yüksek olana gider
const uyum = (p: AdayProgram) => (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
// Revizyonun "ilk N" ölçütüne uyan program her şeyin önünde (+4): kırpma ve
// tamamlama adayın açık isteğini korur
const uyum = (p: AdayProgram) =>
(oncelikli?.(p) ? 4 : 0) + (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
// Onarım modelin seçiminin en az yarısını koruyabiliyorsa ikinci çağrı
// yapılmaz; altındaysa liste çoğunlukla kodla dolardı, model bir kez daha denenir.
@@ -808,7 +846,7 @@ export async function raporUretAyrintili(
// Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
const tercihler = DILIMLER.flatMap((dilim) =>
const dilimSirali = DILIMLER.flatMap((dilim) =>
[...nihai.secim[dilim]]
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
.map((t) => ({ dilim, satir: t })),
@@ -824,13 +862,15 @@ export async function raporUretAyrintili(
...(p.oneCikan?.length ? { oneCikan: p.oneCikan } : {}),
};
});
// Revizyonun "ilk N" kuralı: ölçüte uyanlar başa (revizyon-kurali.ts)
const { tercihler } = ilkTercihleriOneAl(dilimSirali, kural, (id) => havuzMap.get(id));
return {
rapor: {
tercihler,
genelDegerlendirme: genel,
uyarilar,
programlar,
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
},
tani: {
kaynak,

View File

@@ -24,7 +24,14 @@ import {
} from "ai";
import { ARACLAR, aracSeti, type AracBaglami } from "./araclar";
import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
import { KAYIT_DUZELTME_NOTU, kayitBeyaniVar, semaSizintisiVar, vaatDiliBul } from "./cikti-denetimi";
import {
KAYIT_DUZELTME_NOTU,
REVIZE_DUZELTME_NOTU,
kayitBeyaniVar,
revizeBeyaniVar,
semaSizintisiVar,
vaatDiliBul,
} from "./cikti-denetimi";
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
@@ -40,6 +47,12 @@ const ADIM_BASINA_ARAC = 4;
const CEVAP_BASINA_ARAC = 8;
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
const ARAC_ADLARI = ARACLAR.flatMap((a) => (a.type === "function" ? [a.function.name] : []));
/**
* liste_revize liste üretimini çalıştırır (iki model denemesine kadar, ~20-60
* sn); sohbetin 120 sn'lik toplam süresi ona yetmeyebilir. Araç başlayınca
* zaman aşımı bu kadar ileri alınır (features/rapor/liste-revizyon.ts).
*/
const REVIZYON_EK_SURE_MS = 150_000;
export type CiktiDenetimi = {
/** Jev'in ihlal olasılığı; denetim yapılamadıysa null. */
@@ -52,6 +65,8 @@ export type CiktiDenetimi = {
reddedilenArac: number;
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
kayitNotu: boolean;
/** "Yeniden kurdum" dendi ama başarılı liste_revize yoktu; düzeltme notu eklendi. */
revizeNotu: boolean;
/** Cevapta araç adları / şema anahtarları var (yalnız gözlem). */
semaSizintisi: boolean;
};
@@ -68,6 +83,11 @@ export function danismanAkisi(opts: {
zamanAsimiMs?: number;
/** Çıktı denetiminin sonucu (gözlem/eval için; cevabı etkilemez). */
onDenetim?: (d: CiktiDenetimi) => void;
/**
* Her başarılı araç sonucu (ad, çıktı). Route bununla soru formunu ve
* revizyon sonucunu kayda (chat_messages.meta) yazar.
*/
onAracSonucu?: (ad: string, cikti: unknown) => void;
}): ReadableStream<UIMessageChunk> {
return createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
@@ -76,12 +96,19 @@ export function danismanAkisi(opts: {
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
let akisHatasi: string | null = null;
// Sağlayıcı takılırsa SDK akışı "abort" parçasıyla sessizce bitirir
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır.
const zamanAsimi = AbortSignal.timeout(opts.zamanAsimiMs ?? 120_000);
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır. Süre
// uzatılabilir olsun diye AbortSignal.timeout yerine zamanlayıcı.
const kesici = new AbortController();
let sure = opts.zamanAsimiMs ?? 120_000;
let zamanlayici = setTimeout(() => kesici.abort(), sure);
const zamanAsimi = kesici.signal;
let adimdakiArac = 0;
let toplamArac = 0;
let reddedilenArac = 0;
let basariliKayit = 0;
let basariliRevizyon = 0;
// soru_sor çağrıldı: form adayda; model aynı cevapta başka araç çağırmasın
let formAcildi = false;
try {
const result = streamText({
model: sohbetDilModeli(),
@@ -89,19 +116,36 @@ export function danismanAkisi(opts: {
messages: opts.mesajlar,
tools: aracSeti(
opts.aracBaglami,
(ad) => console.info("[soru] araç:", ad),
() => {
(ad) => {
console.info("[soru] araç:", ad);
if (ad === "liste_revize") {
clearTimeout(zamanlayici);
sure += REVIZYON_EK_SURE_MS;
zamanlayici = setTimeout(() => kesici.abort(), sure);
}
},
(ad) => {
adimdakiArac++;
toplamArac++;
if (formAcildi) {
reddedilenArac++;
return "Soru formu adayda; cevap gelene kadar başka araç çağrılmaz. Bu çağrı ÇALIŞTIRILMADI. Tek kısa cümleyle bitir.";
}
if (ad === "liste_revize" && basariliRevizyon > 0) {
reddedilenArac++;
return "Liste bu cevapta zaten yeniden kuruldu; ikinci revizyon ÇALIŞTIRILMADI. Sonucu adaya anlat.";
}
if (adimdakiArac <= ADIM_BASINA_ARAC && toplamArac <= CEVAP_BASINA_ARAC) return null;
reddedilenArac++;
return `Araç çağrısı sınırı aşıldı (bir adımda en fazla ${ADIM_BASINA_ARAC}, bir cevapta en fazla ${CEVAP_BASINA_ARAC}). Bu çağrı ÇALIŞTIRILMADI. Yeni araç çağırma; elindeki sonuçlarla cevap ver, eksik kalan kısmı adaya açıkça söyle ve sorusunu daraltmasını iste. Çalışmayan çağrı için sayı uydurma.`;
},
(ad, cikti) => {
const nesne = cikti != null && typeof cikti === "object" ? (cikti as Record<string, unknown>) : null;
// Başarılı kayıt = profil_guncelle "kaydedildi" döndü (hata dönerse sayılmaz)
if (ad === "profil_guncelle" && cikti != null && typeof cikti === "object" && "kaydedildi" in cikti) {
basariliKayit++;
}
if (ad === "profil_guncelle" && nesne && "kaydedildi" in nesne) basariliKayit++;
if (ad === "liste_revize" && nesne?.ok === true) basariliRevizyon++;
if (ad === "soru_sor" && nesne?.soruldu === true) formAcildi = true;
if (nesne && !("hata" in nesne)) opts.onAracSonucu?.(ad, cikti);
},
),
// En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
@@ -109,7 +153,8 @@ export function danismanAkisi(opts: {
stopWhen: isStepCount(ARAC_TUR_SINIRI),
prepareStep: ({ stepNumber }) => {
adimdakiArac = 0;
return stepNumber >= ARAC_TUR_SINIRI - 1 ? { toolChoice: "none" } : undefined;
// Form açıldıysa model cevabı bitirmek zorunda (araç kapalı)
return stepNumber >= ARAC_TUR_SINIRI - 1 || formAcildi ? { toolChoice: "none" } : undefined;
},
maxOutputTokens: 1024,
abortSignal: zamanAsimi,
@@ -184,11 +229,22 @@ export function danismanAkisi(opts: {
writer.write({ type: "text-delta", id, delta: KAYIT_DUZELTME_NOTU });
writer.write({ type: "text-end", id });
}
// Revizyon beyanı denetimi: "yeniden kurdum" ama bu turda başarılı
// liste_revize yok → düzeltme notu (kayıt denetimiyle aynı mantık).
const revizeNotu = basariliRevizyon === 0 && revizeBeyaniVar(metin);
if (revizeNotu) {
console.info("[soru] revizyon beyanı var, başarılı liste_revize yok → not eklendi");
const id = crypto.randomUUID();
metin += `\n\n${REVIZE_DUZELTME_NOTU}`;
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: REVIZE_DUZELTME_NOTU });
writer.write({ type: "text-end", id });
}
// İç şema sızıntısı: yalnız gözlem (cevap geri alınamaz). Metin loglanmaz.
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, semaSizintisi });
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, revizeNotu, semaSizintisi });
await opts.onTamam(metin);
tamamlandi = true;
writer.write({ type: "finish" });
@@ -199,6 +255,8 @@ export function danismanAkisi(opts: {
// Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa
// (boş cevap, kayıt hatası) istemci hatayı buradan görür.
if (akisHatasi == null) throw err;
} finally {
clearTimeout(zamanlayici);
}
},
onError: () => HATA_METNI,

192
src/lib/ai/sohbet-meta.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* Danışman sohbetinin yapısal mesaj ekleri (chat_messages.meta).
*
* Sohbet kaydı düz metindir; iki tur bunun dışına taşar ve burada tiplenir:
* - `anket`: danışmanın `soru_sor` aracıyla adaya gösterdiği soru formu
* (lib/ai/araclar.ts). Ekranda shadcn Questionnaire ile çizilir
* (features/rapor/components/sohbet-anketi.tsx); sayfa yenilenince de aynı
* form kayıttan geri gelir.
* - `anketCevabi`: adayın forma verdiği cevaplar (kullanıcı mesajı). Bu mesaj
* kredi harcamaz (api/soru/route.ts: formun cevabı, profil onayı gibi
* bedelsizdir).
* - `revizyon`: `liste_revize` aracının sonucu (liste yeniden kuruldu / kurulamadı).
*
* Saf modül: DB'ye ve Next'e dokunmaz; istemci ve sunucu birlikte okur.
*/
export type AnketSecenek = {
deger: string;
etiket: string;
aciklama?: string;
};
export type AnketSoru = {
/** Form alanı adı (cevap anahtarı); harf, rakam, tire. */
ad: string;
soru: string;
aciklama?: string;
secenekler: AnketSecenek[];
/** Birden çok seçenek işaretlenebilir. */
coklu?: boolean;
/** Serbest metin cevabı da alınır; değer yer tutucu metindir. */
serbest?: string;
/** Boş geçilemez (varsayılan: zorunlu). */
zorunlu?: boolean;
};
export type Anket = {
id: string;
baslik?: string;
sorular: AnketSoru[];
};
export type AnketCevaplari = Record<string, string | string[]>;
export type RevizyonOzeti =
| {
ok: true;
toplam: number;
dagilim: { hayal: number; dengeli: number; garanti: number };
kalanHak: number;
ilkTercihler: string[];
}
| { ok: false; neden: string };
export type SohbetMeta = {
anket?: Anket;
anketCevabi?: { anketId: string; cevaplar: AnketCevaplari };
revizyon?: RevizyonOzeti;
};
export const ANKET_EN_COK_SORU = 3;
export const ANKET_EN_COK_SECENEK = 6;
const AD_KALIBI = /^[a-z0-9-]{1,32}$/;
const METIN_UST = 160;
function kisa(s: unknown, ust = METIN_UST): string | null {
if (typeof s !== "string") return null;
const m = s.replace(/\s+/g, " ").trim();
return m ? m.slice(0, ust) : null;
}
/**
* Modelin `soru_sor` argümanını denetleyip temizler; hata varsa metin döner
* (model düzeltip yeniden çağırabilsin). Soru ve seçenek sayısı sınırlı: form
* sohbet balonunda tek tek gösterilir, uzun anket sohbeti boğar.
*/
export function anketDogrula(
girdi: unknown,
): { ok: true; anket: Omit<Anket, "id"> } | { ok: false; hata: string } {
if (typeof girdi !== "object" || girdi === null) return { ok: false, hata: "nesne bekleniyor" };
const g = girdi as Record<string, unknown>;
const baslik = kisa(g.baslik, 80) ?? undefined;
if (!Array.isArray(g.sorular) || g.sorular.length === 0) {
return { ok: false, hata: "sorular: en az bir soru gerekli" };
}
if (g.sorular.length > ANKET_EN_COK_SORU) {
return { ok: false, hata: `sorular: en fazla ${ANKET_EN_COK_SORU} soru` };
}
const sorular: AnketSoru[] = [];
const adlar = new Set<string>();
for (const [i, ham] of g.sorular.entries()) {
if (typeof ham !== "object" || ham === null) return { ok: false, hata: `sorular[${i}]: nesne bekleniyor` };
const s = ham as Record<string, unknown>;
const ad = typeof s.ad === "string" ? s.ad.trim().toLocaleLowerCase("en-US") : "";
if (!AD_KALIBI.test(ad)) return { ok: false, hata: `sorular[${i}].ad: yalnız küçük harf, rakam ve tire (en çok 32)` };
if (adlar.has(ad)) return { ok: false, hata: `sorular[${i}].ad: "${ad}" tekrar ediyor` };
adlar.add(ad);
const soru = kisa(s.soru);
if (!soru) return { ok: false, hata: `sorular[${i}].soru: boş` };
if (!Array.isArray(s.secenekler) || s.secenekler.length < 2) {
return { ok: false, hata: `sorular[${i}].secenekler: en az 2 seçenek` };
}
if (s.secenekler.length > ANKET_EN_COK_SECENEK) {
return { ok: false, hata: `sorular[${i}].secenekler: en fazla ${ANKET_EN_COK_SECENEK} seçenek` };
}
const secenekler: AnketSecenek[] = [];
const degerler = new Set<string>();
for (const [j, sh] of s.secenekler.entries()) {
if (typeof sh !== "object" || sh === null) return { ok: false, hata: `sorular[${i}].secenekler[${j}]: nesne bekleniyor` };
const o = sh as Record<string, unknown>;
const deger = kisa(o.deger, 64);
const etiket = kisa(o.etiket, 80);
if (!deger || !etiket) return { ok: false, hata: `sorular[${i}].secenekler[${j}]: deger ve etiket gerekli` };
if (degerler.has(deger)) return { ok: false, hata: `sorular[${i}].secenekler[${j}].deger: "${deger}" tekrar ediyor` };
degerler.add(deger);
const aciklama = kisa(o.aciklama, 120) ?? undefined;
secenekler.push({ deger, etiket, ...(aciklama ? { aciklama } : {}) });
}
const aciklama = kisa(s.aciklama) ?? undefined;
const serbest = kisa(s.serbest, 80) ?? undefined;
sorular.push({
ad,
soru,
...(aciklama ? { aciklama } : {}),
secenekler,
...(s.coklu === true ? { coklu: true } : {}),
...(serbest ? { serbest } : {}),
...(s.zorunlu === false ? { zorunlu: false } : {}),
});
}
return { ok: true, anket: { ...(baslik ? { baslik } : {}), sorular } };
}
/**
* İstemciden gelen cevapları forma göre süzer: bilinmeyen alan atılır, seçenek
* dışı değer yalnız `serbest` sorularda kabul edilir, boş cevap düşer.
*/
export function anketCevabiDogrula(anket: Anket, ham: unknown): AnketCevaplari | null {
if (typeof ham !== "object" || ham === null) return null;
const h = ham as Record<string, unknown>;
const cevaplar: AnketCevaplari = {};
for (const soru of anket.sorular) {
const v = h[soru.ad];
const secenek = new Set(soru.secenekler.map((s) => s.deger));
const temiz = (x: unknown): string | null => {
const m = kisa(x, 200);
if (!m) return null;
if (secenek.has(m)) return m;
return soru.serbest ? m : null;
};
if (soru.coklu) {
const liste = (Array.isArray(v) ? v : [v]).map(temiz).filter((x): x is string => x != null);
if (liste.length) cevaplar[soru.ad] = [...new Set(liste)];
} else {
const tek = temiz(Array.isArray(v) ? v[0] : v);
if (tek) cevaplar[soru.ad] = tek;
}
}
return cevaplar;
}
function secenekEtiketi(soru: AnketSoru, deger: string): string {
return soru.secenekler.find((s) => s.deger === deger)?.etiket ?? deger;
}
/**
* Cevapların okunur hâli: adayın balonunda ve modele giden mesajda aynı metin
* ("Hangi il? → İstanbul · Kaç tercih? → İlk 5").
*/
export function anketCevabiMetni(anket: Anket, cevaplar: AnketCevaplari): string {
const satirlar = anket.sorular.map((soru) => {
const v = cevaplar[soru.ad];
if (v == null || (Array.isArray(v) && v.length === 0)) return `${soru.soru} → (boş geçtim)`;
const etiket = Array.isArray(v)
? v.map((d) => secenekEtiketi(soru, d)).join(", ")
: secenekEtiketi(soru, v);
return `${soru.soru} → ${etiket}`;
});
return `Form cevaplarım:\n${satirlar.map((s) => `- ${s}`).join("\n")}`;
}
/**
* Formun modele giden kısa dökümü: kayıtlı asistan metni formu içermez; model
* cevapları neye verildiğini buradan görür (api/soru/route.ts model penceresi).
*/
export function anketModelMetni(anket: Anket): string {
const satirlar = anket.sorular.map(
(s) =>
`- ${s.ad}: "${s.soru}" (${s.secenekler.map((o) => `${o.deger} = ${o.etiket}`).join("; ")}${s.serbest ? "; serbest metin de olabilir" : ""})`,
);
return `[Adaya gösterilen soru formu${anket.baslik ? ` — ${anket.baslik}` : ""}:\n${satirlar.join("\n")}]`;
}

View File

@@ -1,3 +1,4 @@
import type { SohbetMeta } from "@/lib/ai/sohbet-meta";
import {
sqliteTable,
text,
@@ -145,6 +146,9 @@ export const chatMessages = sqliteTable(
role: text("role", { enum: ["user", "assistant"] }).notNull(),
content: text("content").notNull(),
clientMessageId: text("client_message_id").unique(),
// Yapısal ek (7 Eki 2026): soru formu, form cevabı, liste revizyon sonucu
// (lib/ai/sohbet-meta.ts). Düz metin mesajda null.
meta: text("meta", { mode: "json" }).$type<SohbetMeta>(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],

View File

@@ -1,5 +1,6 @@
import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import type { SohbetMeta } from "./ai/sohbet-meta";
const { user, creditLedger, chatMessages } = schema;
@@ -99,6 +100,7 @@ export async function spendCreditForMessage(opts: {
userId: string;
content: string;
clientMessageId: string;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
@@ -117,6 +119,7 @@ export async function spendCreditForMessage(opts: {
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
meta: opts.meta ?? null,
createdAt: new Date(),
});
await tx.insert(creditLedger).values({
@@ -138,14 +141,16 @@ export async function spendCreditForMessage(opts: {
}
/**
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — yalnız profil onayı ("kaydedeyim
* mi?" → "evet") için (Bilal kararı, 26 Eyl 2026: onay ücretsiz). Ledger'a
* satır yazılmaz; clientMessageId çakışırsa DUPLICATE.
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — profil onayı ("kaydedeyim mi?"
* → "evet"; Bilal kararı, 26 Eyl 2026: onay ücretsiz) ve danışmanın soru
* formuna verilen cevap (7 Eki 2026) için. Ledger'a satır yazılmaz;
* clientMessageId çakışırsa DUPLICATE.
*/
export async function bedelsizMesajKaydet(opts: {
userId: string;
content: string;
clientMessageId: string;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
@@ -155,6 +160,7 @@ export async function bedelsizMesajKaydet(opts: {
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
meta: opts.meta ?? null,
createdAt: new Date(),
});
return { ok: true, messageId };

View File

@@ -177,6 +177,13 @@ function dilimBasinaKirp(havuz: AdayProgram[], n: number): AdayProgram[] {
);
}
/** Revizyonun "ekle" kimlikleri: dilim evreninde bulunanlar, katman 0 ile. */
function ekstraSec(evren: AdayProgram[], idler?: readonly string[]): AdayProgram[] {
if (!idler?.length) return [];
const iste = new Set(idler);
return evren.filter((p) => iste.has(p.id));
}
/**
* Katmanları öncelik sırasıyla, tekrarsız birleştirir. Gevşetmede kullanılır:
* gevşeyen filtreye UYAN programlar önce gelir ve kırpmada korunur, kalan
@@ -271,9 +278,24 @@ function dilimEkle(r: {
* Adayın yazamayacağı programlar hiçbir basamakta havuza girmez: başarı
* sırası barajının gerisinde kaldığı bölümler ve özel kontenjan satırları.
*/
/**
* Sohbet revizyonunun havuza dokunan kısmı (lib/ai/rapor.ts kurar):
* `cikar` eşleşenler hiçbir katmana girmez; `oncelikli` eşleşenler kırpmadan
* önce dilim içinde öne alınır ("ilk 5 İstanbul olsun" için İstanbul havuzda
* kalsın); `ekstraIdler` adayın açıkça istediği programlar — sihirbaz
* süzgecine takılsa bile (baraj ve özel kontenjan kuralı yine geçer) havuza
* kendi dilimiyle eklenir.
*/
export type HavuzSecenekleri = {
cikar?: (p: AdayProgram) => boolean;
oncelikli?: (p: AdayProgram) => boolean;
ekstraIdler?: readonly string[];
};
export function havuzOlustur(
params: RaporParams,
profil?: DanismanProfili,
secenekler: HavuzSecenekleri = {},
): HavuzSonuc {
const uniturGrubu =
params.universiteTipi && params.universiteTipi !== "farketmez"
@@ -307,7 +329,39 @@ export function havuzOlustur(
const adaylar = (ozellik?: OzellikSuzgeci) =>
dilimEkle(
havuzAdaylari(params.sira, params.tur, { uniturGrubu, ozellik }),
).filter((p) => !ozelKontenjanSatiriMi(p.isim));
).filter(
(p) => !ozelKontenjanSatiriMi(p.isim) && !(secenekler.cikar?.(p) ?? false),
);
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS). Süzgeçsiz
// dilim evreni (ilsizHam) revizyonun "ekle" kimliklerinin de kaynağıdır.
const ilsizHam = adaylar();
const ekstra = ekstraSec(ilsizHam, secenekler.ekstraIdler);
// Revizyon önceliği gevşetme katmanından sonra, uyum puanından önce gelir:
// kararlı sıralama, eşleşenler dilim içinde öne (kırpma öndekileri tutar).
const oncelikle = (havuz: AdayProgram[]) =>
secenekler.oncelikli
? [...havuz].sort(
(a, b) =>
(a.katman ?? 0) - (b.katman ?? 0) ||
Number(secenekler.oncelikli!(b)) - Number(secenekler.oncelikli!(a)),
)
: havuz;
// Ekstralar katman 0 ile başa; ötekilerin gevşetme katmanı korunur
const ekstraIdKumesi = new Set(ekstra.map((p) => p.id));
const kirpVe = (havuz: AdayProgram[]) =>
dilimBasinaKirp(
oncelikle(
uyumlaSirala(
[
...ekstra.map((p) => ({ ...p, katman: 0 })),
...havuz.filter((p) => !ekstraIdKumesi.has(p.id)),
],
baglam,
),
),
PROMPT_LIMIT_DILIM,
);
const profilGevsetildi: Gevsetilen[] = [];
// Profil gevşerse profile uyan (24'ten az) programlar yine en önde tutulur
@@ -320,22 +374,17 @@ export function havuzOlustur(
profilliFiltreli = kategoriFiltre(profilliHam);
if (profilliFiltreli.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(uyumlaSirala(profilliFiltreli, baglam), PROMPT_LIMIT_DILIM),
havuz: kirpVe(profilliFiltreli),
gevsetildi: [],
};
}
profilGevsetildi.push("profil");
}
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS)
const ilsizHam = adaylar();
const tamHam = ilFiltre(ilsizHam, iller);
const tamFiltreli = kategoriFiltre(tamHam);
const kirp = (...katmanlar: AdayProgram[][]) =>
dilimBasinaKirp(
uyumlaSirala(oncelikliBirlestir(profilliFiltreli, ...katmanlar), baglam),
PROMPT_LIMIT_DILIM,
);
kirpVe(oncelikliBirlestir(profilliFiltreli, ...katmanlar));
if (tamFiltreli.length >= HEDEF) {
return { havuz: kirp(tamFiltreli), gevsetildi: profilGevsetildi };
}

220
src/lib/revizyon-kurali.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Sohbetten gelen liste revizyonu kurallarının (RevizyonKurali) saf
* uygulayıcıları. Eski revizyon yolu serbest metni modele "istek" olarak
* veriyordu; sıra ve havuz koddan belirlendiği için "ilk 5 İstanbul olsun"
* gibi isteklerin karşılığı yoktu (geri bildirim, 7 Eki 2026). Şimdi kural
* yapısaldır ve üç yerde koddan uygulanır:
* 1. Havuz: il / alan / üniversite tipi farkı sihirbaz seçiminin üstüne
* biner (`efektifParams`), "çıkar" havuza hiç girmez, "ilk N" ölçütü
* ve "ekle" kimlikleri kırpmada öne alınır (rapor-havuzu.ts).
* 2. Seçim: iskelet dilim hedefini değiştirir; tamamlama/kırpma ölçüte
* uyanı önceler (lib/ai/rapor.ts).
* 3. Sıra: liste kurulduktan sonra "ilk N" ölçütüne uyanlar başa alınır
* (`ilkTercihleriOneAl`).
*
* Saf modül: DB'ye ve Next'e dokunmaz; sunucu ve betikler birlikte kullanır.
*/
import type { RaporParams, RaporTercih, RevizyonKurali, ListeFarki } from "@/features/rapor/types/rapor";
import type { AdayProgram, DilimHedef } from "./rapor-havuzu";
import { IDEAL_HEDEF, IDEAL_HEDEF_KOLAY_YERLESME, idealHedef } from "./rapor-havuzu";
import { kategoriEslesir } from "./kategoriler";
import { uniturDevletMi } from "./unitur";
import type { UniturGrubu } from "@/types/yokatlas";
/** Hayal ağırlıklı iskelet: 7 hayal / 12 dengeli / 5 güvenli (toplam 24). */
export const IDEAL_HEDEF_HAYAL_AGIRLIKLI: DilimHedef = { hayal: 7, dengeli: 12, garanti: 5 };
const tr = (s: string) => s.toLocaleLowerCase("tr-TR").trim();
function farkUygula(taban: readonly string[], fark: ListeFarki | undefined): string[] {
if (!fark) return [...taban];
let liste = fark.yalniz ? [...fark.yalniz] : [...taban];
if (fark.ekle) liste = [...liste, ...fark.ekle];
if (fark.cikar) {
const cikar = new Set(fark.cikar.map(tr));
liste = liste.filter((x) => !cikar.has(tr(x)));
}
return [...new Set(liste)];
}
/**
* Havuzun kurulacağı etkin parametreler: sihirbaz seçimi + revizyon farkı.
* Kayıtlı `params` değişmez (fark ekranı sihirbaz seçimine bakar); yalnız
* üretim bu kopyayı görür.
*/
export function efektifParams(params: RaporParams): RaporParams {
const k = params.revizyon;
if (!k) return params;
const sonuc: RaporParams = { ...params };
if (k.iller) {
sonuc.iller = farkUygula(params.iller ?? (params.il ? [params.il] : []), k.iller);
if (k.iller.yalniz) delete sonuc.il;
}
if (k.kategoriler) sonuc.kategoriler = farkUygula(params.kategoriler ?? [], k.kategoriler);
if (k.universiteTipi) sonuc.universiteTipi = k.universiteTipi;
// "İlk N" ölçütü sihirbaz süzgecinin dışındaysa havuz o programları hiç
// görmezdi (7 Eki 2026 denemesi: sihirbazda Ankara/İzmir, istek "ilk 5
// İstanbul" → model iller.ekle vermedi, listede İstanbul çıkmadı). Ölçütün
// ili/alanı süzgece eklenir, tip çelişiyorsa süzgeç gevşer; model bunu
// hatırlamak zorunda kalmaz.
const o = k.ilkTercihler;
if (o) {
const iller = sonuc.iller ?? (sonuc.il ? [sonuc.il] : []);
if (o.iller?.length && iller.length > 0) {
const var_ = new Set(iller.map(tr));
const eksik = o.iller.filter((il) => !var_.has(tr(il)));
if (eksik.length) sonuc.iller = [...iller, ...eksik];
}
if (o.kategoriler?.length && (sonuc.kategoriler?.length ?? 0) > 0) {
const var_ = new Set(sonuc.kategoriler!.map(tr));
const eksik = o.kategoriler.filter((kat) => !var_.has(tr(kat)));
if (eksik.length) sonuc.kategoriler = [...sonuc.kategoriler!, ...eksik];
}
if (
o.universiteTipi &&
sonuc.universiteTipi &&
sonuc.universiteTipi !== "farketmez" &&
sonuc.universiteTipi !== o.universiteTipi
) {
sonuc.universiteTipi = "farketmez";
}
}
return sonuc;
}
/** Revizyonun iskelet seçimi; yoksa sihirbaz önceliğine göre varsayılan. */
export function revizyonIskeleti(params: RaporParams): DilimHedef {
switch (params.revizyon?.iskelet) {
case "guvenli-agirlikli":
return IDEAL_HEDEF_KOLAY_YERLESME;
case "hayal-agirlikli":
return IDEAL_HEDEF_HAYAL_AGIRLIKLI;
case "standart":
return IDEAL_HEDEF;
default:
return idealHedef(params.oncelikler);
}
}
type Olcut = NonNullable<RevizyonKurali["ilkTercihler"]>;
/** Program "ilk N" ölçütüne uyuyor mu (verilen her ölçüt birlikte sağlanmalı). */
export function olcuteUyar(
p: Pick<AdayProgram, "isim" | "il" | "unitur" | "fakulte">,
olcut: Pick<Olcut, "iller" | "kategoriler" | "universiteTipi">,
): boolean {
if (olcut.iller?.length) {
const iller = new Set(olcut.iller.map(tr));
if (!p.il || !iller.has(tr(p.il))) return false;
}
if (olcut.kategoriler?.length && !kategoriEslesir(p.isim, olcut.kategoriler, p.fakulte)) {
return false;
}
if (olcut.universiteTipi) {
const devlet = uniturDevletMi(p.unitur);
if ((olcut.universiteTipi === "devlet") !== devlet) return false;
}
return true;
}
function olcutBos(o: Pick<Olcut, "iller" | "kategoriler" | "universiteTipi"> | undefined): boolean {
return !o || (!o.iller?.length && !o.kategoriler?.length && !o.universiteTipi);
}
/** Havuz kırpmasında ve seçim tamamlamasında öncelenecek program süzgeci; ölçüt yoksa null. */
export function oncelikSuzgeci(k: RevizyonKurali | undefined): ((p: AdayProgram) => boolean) | null {
const o = k?.ilkTercihler;
if (!o || olcutBos(o)) return null;
return (p) => olcuteUyar(p, o);
}
/** "Çıkar" kuralı: üniversite / bölüm adı (büyük-küçük harfsiz içerir) ya da kimlik. */
export function cikarSuzgeci(k: RevizyonKurali | undefined): ((p: AdayProgram) => boolean) | null {
const c = k?.cikar;
if (!c || (!c.universiteler?.length && !c.bolumler?.length && !c.programIdler?.length)) return null;
const uniler = (c.universiteler ?? []).map(tr).filter(Boolean);
const bolumler = (c.bolumler ?? []).map(tr).filter(Boolean);
const idler = new Set(c.programIdler ?? []);
return (p) =>
idler.has(p.id) ||
uniler.some((u) => tr(p.universite).includes(u)) ||
bolumler.some((b) => tr(p.isim).includes(b));
}
/**
* Kurulmuş listede "ilk N" ölçütüne uyan ilk N satırı başa alır, kalanların
* sırası korunur; `sira` yeniden numaralanır. Dilim sırası (hayal → dengeli →
* güvenli) bu durumda bilerek bozulur: aday açıkça istedi, danışman da
* cevabında söyler. Ölçüte uyan satır sayısı N'den azsa eldeki kadarı öne alınır.
*/
export function ilkTercihleriOneAl<T extends Pick<RaporTercih, "programId" | "sira">>(
tercihler: T[],
k: RevizyonKurali | undefined,
program: (id: string) => Pick<AdayProgram, "isim" | "il" | "unitur" | "fakulte"> | undefined,
): { tercihler: T[]; oneAlinan: number } {
const o = k?.ilkTercihler;
if (!o || olcutBos(o) || o.adet < 1) return { tercihler, oneAlinan: 0 };
const on: T[] = [];
const kalan: T[] = [];
for (const t of tercihler) {
const p = program(t.programId);
if (on.length < o.adet && p && olcuteUyar(p, o)) on.push(t);
else kalan.push(t);
}
return {
tercihler: [...on, ...kalan].map((t, i) => ({ ...t, sira: i + 1 })),
oneAlinan: on.length,
};
}
/** Yeni revizyon eskisinin üstüne üst anahtar bazında biner; `not` yenilenir. */
export function kurallariBirlestir(
eski: RevizyonKurali | undefined,
yeni: RevizyonKurali,
): RevizyonKurali {
const sonuc: RevizyonKurali = { ...(eski ?? {}) };
delete sonuc.not;
for (const [anahtar, deger] of Object.entries(yeni)) {
if (deger === undefined || deger === null) continue;
(sonuc as Record<string, unknown>)[anahtar] = deger;
}
return sonuc;
}
const TIP_ETIKET: Record<UniturGrubu | "farketmez", string> = {
devlet: "devlet",
vakif: "vakıf",
farketmez: "fark etmez",
};
/** Kuralların modele ve adaya okunur dökümü (prompt + liste üst metni). */
export function kuralOzeti(k: RevizyonKurali | undefined): string[] {
if (!k) return [];
const s: string[] = [];
const fark = (ad: string, f: ListeFarki | undefined) => {
if (!f) return;
if (f.yalniz) s.push(`${ad}: yalnız ${f.yalniz.join(", ")}`);
if (f.ekle?.length) s.push(`${ad}: ${f.ekle.join(", ")} eklendi`);
if (f.cikar?.length) s.push(`${ad}: ${f.cikar.join(", ")} çıkarıldı`);
};
fark("iller", k.iller);
fark("ilgi alanları", k.kategoriler);
if (k.universiteTipi) s.push(`üniversite tipi: ${TIP_ETIKET[k.universiteTipi]}`);
if (k.iskelet && k.iskelet !== "standart") {
s.push(k.iskelet === "guvenli-agirlikli" ? "iskelet: güvenli ağırlıklı (4/12/8)" : "iskelet: hayal ağırlıklı (7/12/5)");
}
const o = k.ilkTercihler;
if (o && !olcutBos(o)) {
const olcut = [
o.iller?.length ? `il ${o.iller.join("/")}` : null,
o.kategoriler?.length ? `alan ${o.kategoriler.join("/")}` : null,
o.universiteTipi ? TIP_ETIKET[o.universiteTipi] : null,
].filter(Boolean);
s.push(`ilk ${o.adet} tercih: ${olcut.join(", ")}`);
}
if (k.cikar?.universiteler?.length) s.push(`çıkarılan üniversiteler: ${k.cikar.universiteler.join(", ")}`);
if (k.cikar?.bolumler?.length) s.push(`çıkarılan bölümler: ${k.cikar.bolumler.join(", ")}`);
if (k.cikar?.programIdler?.length) s.push(`çıkarılan ${k.cikar.programIdler.length} program`);
if (k.ekle?.programIdler?.length) s.push(`eklenen ${k.ekle.programIdler.length} program`);
return s;
}