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:
@@ -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.
|
||||
|
||||
1
drizzle/0006_little_luckman.sql
Normal file
1
drizzle/0006_little_luckman.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `chat_messages` ADD `meta` text;
|
||||
1018
drizzle/meta/0006_snapshot.json
Normal file
1018
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
51
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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ı.
|
||||
|
||||
329
src/components/ui/questionnaire.tsx
Normal file
329
src/components/ui/questionnaire.tsx
Normal 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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
{/* 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
|
||||
asChild
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => sohbetiOdakla("Listemde şunu değiştir: ")}
|
||||
className={IKINCIL_BUTON}
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
@@ -371,11 +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 ? (
|
||||
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
@@ -386,11 +447,14 @@ export function ListemGovde({
|
||||
bildikleri={danismaninBildikleri}
|
||||
genis={sohbetGenis}
|
||||
onGenisDegistir={sohbetGenisligiAyarla}
|
||||
revizyonHakki={kalanHak}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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 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}
|
||||
@@ -399,14 +463,18 @@ export function ListemGovde({
|
||||
uyarilar={uyarilar}
|
||||
ilkYorum={ilkYorum}
|
||||
bildikleri={danismaninBildikleri}
|
||||
className="h-[480px]"
|
||||
// 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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
144
src/features/rapor/components/sohbet-anketi.tsx
Normal file
144
src/features/rapor/components/sohbet-anketi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
164
src/features/rapor/liste-revizyon.ts
Normal file
164
src/features/rapor/liste-revizyon.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
192
src/lib/ai/sohbet-meta.ts
Normal 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")}]`;
|
||||
}
|
||||
@@ -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)],
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
220
src/lib/revizyon-kurali.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user