From 5a04db7e4ba63c51dabaf631c30be6541678b2d9 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Fri, 25 Sep 2026 01:23:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(liste):=20dan=C4=B1=C5=9Fman=20profili=20h?= =?UTF-8?q?avuzu=20s=C3=BCzer=20(=C3=B6nce=20profil=20gev=C5=9Fetilir)=20+?= =?UTF-8?q?=20KVKK=20metin=20d=C3=BCzeltmeleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db.ts OzellikSuzgeci (dil, yalnız örgün, yalnız burslu, ücret üst sınırı) program_ozellik alt sorgusuyla; tablo yoksa süzgeç yok. - rapor-havuzu: profilSuzgeci (ev ili + km → il listesi; sihirbazda il seçiliyse km uygulanmaz); havuz 24'ün altına düşerse gevşetme sırası profil → il → kategori. rapor.ts prompt'a 'danışmana söylediği tercihler' ve profil gevşetme notu; rapor-actions profili okuyup geçirir. - /meraklisina şeması (huni-semasi SEMA + aria-label + figcaption) ve 4. bölüm metni yeni adım ve gevşetme sırasıyla eşitlendi (AGENTS.md kuralı). - /api/soru: profil kaydı öncesi 'kaydedeyim mi?' onayı (PROFIL_ONAY_SOR). - Gizlilik metni kodla eşitlendi: OpenRouter→DeepSeek (Anthropic değil), profil satırı, amaç+hukuki sebep, saklama süreleri, md. 11 hakları, 18 yaş altı notu; unvan/adres ve aktarım dayanağı TODO. Giriş kartı: koşullar 'kabul', gizlilik 'okudum'. Sohbet girişine kişisel bilgi uyarısı. Co-Authored-By: Claude Fable 5.1 --- src/app/api/soru/route.ts | 18 +- src/app/gizlilik/page.tsx | 238 ++++++++++++++---- .../kullanici/components/giris-karti.tsx | 14 +- .../pazarlama/components/huni-semasi.tsx | 13 +- .../components/meraklisina-icerik.tsx | 11 +- .../rapor/components/sohbet-client.tsx | 5 + src/features/rapor/rapor-actions.ts | 7 +- src/lib/ai/rapor.ts | 13 +- src/lib/danisman-profil.ts | 5 + src/lib/db.ts | 50 +++- src/lib/rapor-havuzu.ts | 97 ++++++- 11 files changed, 385 insertions(+), 86 deletions(-) diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index 66ba2ac..ac805b7 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -21,6 +21,13 @@ import { PUAN_TURLERI } from "@/types/yokatlas"; import { ACIK_SATIR } from "@/lib/rapor-maske"; const MAX_GECMIS = 12; +/** + * Profil kaydı öncesi "kaydedeyim mi?" onayı (KVKK Üretken YZ Rehberi: + * kişiselleştirme ayrı işleme faaliyeti; onaysız otomatik kayıt avukata + * sorulacak — docs/arastirma/2026-09-25-kisisellestirme/hukuk-ve-rakipler.md + * C.3/10). Bedeli: "evet" cevabı bir kredi. Kapatılırsa model doğrudan kaydeder. + */ +const PROFIL_ONAY_SOR = true; const MAX_MESAJ_UZUNLUK = 2000; function sistemPromptu(opts: { @@ -63,9 +70,14 @@ KURALLAR: aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır. - PROFİL: aday bir tercih söylerse (yaşadığı il, evden en fazla kaç km, vakıfta yıllık ücret sınırı, burs beklentisi, öğretim dili, uzaktan/açık - kabul mü, kampüs büyüklüğü) profil_guncelle ile kaydet ve cevabının - sonunda tek cümleyle söyle: "Şunu kaydettim: …". Emin değilsen önce sor, - tahminle kaydetme. Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma: + kabul mü, kampüs büyüklüğü) ${PROFIL_ONAY_SOR + ? `HEMEN KAYDETME: cevabının sonunda tek cümleyle sor: "Bunu profiline + kaydedeyim mi? (Listen ve aramalar buna göre süzülür; istediğin zaman + silebilirsin.)" Aday evet/kaydet derse o zaman profil_guncelle ile kaydet + ve "Kaydettim: …" de. Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.` + : `profil_guncelle ile kaydet ve cevabının sonunda tek cümleyle söyle: + "Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`} + Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma: sağlık/engel, din, aile durumu, gelir, cinsiyet, siyasi görüş. Aday bunları kendiliğinden yazsa bile profile YAZMA; yalnız cevabında dikkate al. - Kadın/erkek öğrenci oranı üniversite genelidir (bölüm bazında veri yok); diff --git a/src/app/gizlilik/page.tsx b/src/app/gizlilik/page.tsx index bcbad35..462ad38 100644 --- a/src/app/gizlilik/page.tsx +++ b/src/app/gizlilik/page.tsx @@ -12,8 +12,16 @@ export const metadata: Metadata = sayfaMetadata({ path: "/gizlilik", }); -// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir. +// NOT: 25 Eyl 2026 taslağı — v2 dalı. Yayına almadan önce hukuki gözden +// geçirme gerekir (docs/arastirma/2026-09-25-kisisellestirme/hukuk-ve-rakipler.md +// C.2). Kodun gerçekte yaptığıyla birebir örtüşmesi zorunludur: sağlayıcı, +// alanlar ya da süreler değişirse bu metin aynı commit'te güncellenir. +// TODO(bilal): veri sorumlusunun ticari unvanı ve adresi (Tebliğ md. 5/1-a). +// TODO(avukat): yurt dışı aktarımın dayanağı (standart sözleşme / Türkiye'de +// model) kesinleşince "Hizmet sağlayıcılar" bölümüne yazılacak; saklama +// süreleri onaylanacak. export default function GizlilikPage() { + const baslik = "font-heading text-lg font-bold text-slate-900"; return ( <>
@@ -22,7 +30,7 @@ export default function GizlilikPage() { Gizlilik ve Kişisel Verilerin Korunması

- Son güncelleme: 2 Ağustos 2026 + Son güncelleme: 25 Eylül 2026

@@ -33,66 +41,187 @@ export default function GizlilikPage() { strength={4} className="mt-8 space-y-6 text-sm leading-7 text-slate-700" > -
-

- Hangi verileri işliyoruz? -

-

- KolayTercih; e-posta adresini (hesap oluşturma ve giriş için), YKS - başarı sıralamanı ve sihirbazda yaptığın seçimleri (tercih listeni - üretmek için), danışman sohbetine yazdığın mesajları (sorularını - yanıtlamak için) ve ödeme yaptığında sipariş kayıtlarını işler. - Kart bilgilerin bize hiç ulaşmaz; ödeme iyzico altyapısında - gerçekleşir. -

+
+

Kısa özet

+
    +
  • + Senden yalnız e-posta, başarı sıralaman, sihirbaz seçimlerin, + danışmana yazdıkların ve danışmana söyleyip kaydettiğin + tercihler alınır. Ad, kimlik numarası, adres, cinsiyet, sağlık + ya da gelir bilgisi istenmez. +
  • +
  • + Bunlar yalnız tercih listeni üretmek ve sorularını yanıtlamak + için kullanılır; satılmaz, reklam için kullanılmaz. +
  • +
  • + Danışmana yazdıkların yanıt üretilmesi için yurt dışındaki + yapay zekâ sağlayıcısına gider; adın ve e-postan gitmez. +
  • +
  • + Kaydedilen tercihlerini listem sayfasında görür, tek tıkla + silersin. Hesabını sildirmek için yazman yeterli. +
  • +
  • + 18 yaşından küçüksen bu metni velinle birlikte oku. +
  • +
+
-

- Ne için kullanıyoruz? -

+

Veri sorumlusu

- Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi - bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş - bağlantısı, ödeme bilgilendirmesi) göndermek için kullanılır. - Verilerini üçüncü taraflara satmayız, reklam amacıyla paylaşmayız. -

-
-
-

- Hizmet sağlayıcılar ve yurt dışına aktarım -

-

- Hizmeti çalıştırabilmek için birkaç dış sağlayıcıdan yararlanıyoruz - ve verinin bir kısmı yalnızca ilgili iş için bu sağlayıcılara - iletilir. Giriş bağlantısı ve bilgilendirme e-postaları Resend - üzerinden gönderilir; e-posta adresin bu amaçla Avrupa - Birliği’ndeki (İrlanda) sunucularda işlenir. Ödemeler, - Türkiye’de yerleşik iyzico altyapısında gerçekleşir. Danışman - sohbetine yazdığın mesajlar, yanıt üretilebilmesi için - Anthropic’in yapay zekâ servisine (ABD) iletilir; bu mesajlar - sağlayıcının yapay zekâ modellerini eğitmek için kullanılmaz. - KVKK açısından bunlardan e-posta ve yapay zekâ sağlayıcısına yapılan - iletim yurt dışına aktarım sayılır ve hizmetin sunulabilmesi için - zorunludur. -

-
-
-

- Saklama ve silme -

-

- Hesabını sildirmek veya verilerinin bir kopyasını istemek için{" "} + Bu sitedeki kişisel verilerin veri sorumlusu KolayTercih’tir + [şirket unvanı ve adresi eklenecek]. Sorularını ve başvurularını{" "} iletişim sayfasındaki {" "} - adresten bize yazabilirsin; talepler 30 gün içinde - sonuçlandırılır. + e-posta adresine iletebilirsin.

+
-

- Çerezler ve analitik -

+

Hangi verileri, ne için, hangi dayanakla işliyoruz?

+
    +
  • + E-posta adresin — hesap oluşturmak, giriş + bağlantısı ve ödeme bilgilendirmesi göndermek için. + Dayanak: seninle kurulan hizmet sözleşmesinin kurulması ve + ifası. +
  • +
  • + YKS başarı sıralaman ve sihirbaz seçimlerin{" "} + (ilgi alanı, il, devlet/vakıf, öncelikler) — 24’lük tercih + listeni üretmek için. Dayanak: sözleşmenin ifası. +
  • +
  • + Danışman sohbetine yazdığın mesajlar — + sorularını listen bağlamında yanıtlamak için. Dayanak: + sözleşmenin ifası. Sohbete ad, telefon, kimlik numarası, + sağlık ya da aile bilgisi yazma; danışmanın bunlara ihtiyacı + yok ve bunları saklamak istemiyoruz. +
  • +
  • + Danışman profilin — sohbette söylediğin ve{" "} + “kaydedeyim mi?” sorusuna evet dediğin + tercihler: yaşadığın il, evden en fazla kaç km, vakıfta yıllık + ücret üst sınırı, burs beklentin, öğretim dili, uzaktan/açık + öğretim kabulün, kampüs büyüklüğü tercihin. Bu alanlar sabit + bir listedir; serbest metin kaydedilmez, sağlık, din, gelir ya + da cinsiyet gibi bilgiler bu profile hiçbir zaman yazılmaz. + Profil yalnız senin listeni ve danışman aramalarını süzmek için + kullanılır; reklam, pazarlama ya da e-posta için kullanılmaz. + Profil boşken de hizmet tam çalışır. Listem sayfasındaki + “Kaydettiklerim” çiplerinden her alanı görür ve tek + tıkla silersin. Dayanak: senin talimatınla sözleşmenin ifası. +
  • +
  • + Sipariş kayıtların (paket alırsan) — fatura + ve muhasebe yükümlülüğü için. Kart bilgilerin bize hiç ulaşmaz; + ödeme iyzico altyapısında gerçekleşir. Dayanak: hukuki + yükümlülük. +
  • +
  • + Kullanım ölçümü — hangi sayfaların + görüntülendiği ve hangi adımların tamamlandığı (aşağıda + “Çerezler ve analitik”). Dayanak: siteyi + geliştirmeye yönelik meşru menfaatimiz. +
  • +
+

+ Verilerini üçüncü taraflara satmayız, reklam amacıyla + paylaşmayız. Program ve üniversite bilgileri (taban sıralamaları, + ücretler, öğrenci sayıları, kadın/erkek oranı) kişisel veri + değildir; YÖK Atlas ve YÖK İstatistik’ten alınır ve + sayfalarda kaynağıyla gösterilir. +

+
+ +
+

Hizmet sağlayıcılar ve yurt dışına aktarım

+

+ Hizmeti çalıştırabilmek için birkaç dış sağlayıcıdan yararlanıyoruz + ve verinin yalnız ilgili iş için gereken kısmı bu sağlayıcılara + iletilir: +

+
    +
  • + Resend (İrlanda) — giriş bağlantısı ve + bilgilendirme e-postaları; e-posta adresin bu amaçla Avrupa + Birliği’ndeki sunucularda işlenir. +
  • +
  • + iyzico (Türkiye) — ödeme. +
  • +
  • + Yapay zekâ sağlayıcısı — danışman sohbetine + yazdığın mesajlar, listendeki program bilgileri ve kaydettiğin + tercihler, yanıt üretilebilmesi için OpenRouter (ABD) aracılığıyla + kullandığımız dil modelinin sağlayıcısına (bugün DeepSeek) + iletilir. Adın ve e-postan modele gönderilmez. Tercih listeni + üreten yapay zekâ da aynı yoldan çalışır; ona sıralaman, + seçimlerin ve aday program havuzu gider. +
  • +
+

+ KVKK açısından e-posta ve yapay zekâ sağlayıcısına yapılan iletim + yurt dışına aktarımdır ve hizmetin sunulabilmesi için gereklidir. + Sağlayıcı değişirse bu bölüm aynı gün güncellenir. +

+
+ +
+

Saklama ve silme

+
    +
  • + Sohbet mesajların ve danışman profilin: hesabın açık kaldığı + sürece; her ikisini de listem sayfasından kendin silebilirsin. + Tercih döneminin bitiminden 12 ay sonra sohbet mesajları ve + profil silinir. +
  • +
  • + Hesabını sildirme ya da verilerinin kopyasını isteme talebin + 30 gün içinde sonuçlandırılır; işlenme sebebi ortadan kalkan + veri en geç 3 ay içinde silinir. +
  • +
  • + Sipariş ve fatura kayıtları vergi mevzuatı gereği 10 yıl + saklanır. +
  • +
+
+ +
+

Hakların

+

+ KVKK’nın 11. maddesi gereği bize başvurarak şunları + yapabilirsin: verilerinin işlenip işlenmediğini öğrenmek, + işlendiyse bilgi istemek, amacına uygun kullanılıp + kullanılmadığını öğrenmek, aktarıldığı üçüncü kişileri bilmek, + eksik ya da yanlış işlenmişse düzeltilmesini istemek, + silinmesini ya da yok edilmesini istemek, bu işlemlerin + aktarıldığı üçüncü kişilere bildirilmesini istemek, yalnızca + otomatik sistemlerle analiz edilmesi sonucu aleyhine bir sonuç + çıkmasına itiraz etmek ve kanuna aykırı işleme yüzünden zarara + uğradıysan tazminat istemek. Başvurunu{" "} + + iletişim sayfasındaki + {" "} + e-posta adresine yaz; en geç 30 gün içinde yanıtlarız. +

+
+ +
+

18 yaşından küçüksen

+

+ Bu metni velinle birlikte oku. Paket satın alınacaksa ödemeyi + velin yapmalı. Verilerin hiçbir şekilde pazarlama için + kullanılmaz. +

+
+ +
+

Çerezler ve analitik

Yalnızca oturumunu açık tutmak için zorunlu çerezler kullanılır; reklam veya izleme çerezi kullanılmaz. Siteyi geliştirebilmek için @@ -105,7 +234,8 @@ export default function GizlilikPage() { yalnızca sorun giderme ile destek amacıyla kullanılır. Başarı sıralaman analitiğe ham hâliyle gönderilmez; yalnızca “50-100 bin” gibi geniş bir - aralık olarak kaydedilir. + aralık olarak kaydedilir. Danışman profilindeki değerler + analitiğe gitmez; yalnız hangi alanın kaydedildiği sayılır.

diff --git a/src/features/kullanici/components/giris-karti.tsx b/src/features/kullanici/components/giris-karti.tsx index 7da1add..c1a1b49 100644 --- a/src/features/kullanici/components/giris-karti.tsx +++ b/src/features/kullanici/components/giris-karti.tsx @@ -77,15 +77,17 @@ export async function GirisKarti({

- Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "} - - gizlilik metnindeki - {" "} - gibi işlenmesini ve{" "} + {/* Aydınlatma "okudum" (bilgilendirme), koşullar "kabul": KVKK İlke + Kararı 2026/347 aydınlatma ile rızayı ayırır (hukuk-ve-rakipler.md C.2/8). */} + Giriş yaparak{" "} kullanım koşullarını {" "} - kabul etmiş olursun. KolayTercih yerleşme garantisi vermez. + kabul etmiş ve verilerinin nasıl işlendiğini anlatan{" "} + + gizlilik metnini + {" "} + okumuş olursun. KolayTercih yerleşme garantisi vermez.

); diff --git a/src/features/pazarlama/components/huni-semasi.tsx b/src/features/pazarlama/components/huni-semasi.tsx index 4286fbb..ee1f190 100644 --- a/src/features/pazarlama/components/huni-semasi.tsx +++ b/src/features/pazarlama/components/huni-semasi.tsx @@ -16,9 +16,10 @@ flowchart TD T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi
alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, yalnızca ulaşabildiklerin listelenir") A --> B("2. adım · İl seçimi
programı olan tüm iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır") B --> C("3. adım · Devlet / Vakıf
sayılar hem alanı hem ili hesaba katar") - C --> H("Aday havuzu
elde kalan gerçek programlar — yalnızca en son tercih kılavuzunda yer alanlar
kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez") + C --> P("Danışmana söylediklerin
öğretim dili, uzaktan/örgün, burs, yıllık ücret sınırı, evden uzaklık — sohbette söyleyip kaydettiklerin varsa havuzu bunlar da süzer") + P --> H("Aday havuzu
elde kalan gerçek programlar — yalnızca en son tercih kılavuzunda yer alanlar
kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez") H --> S{"Havuzda 24'ten az program mı kaldı?"} - S -- "evet" --> G("Filtreler adım adım gevşetilir
önce il, sonra alan — listede açıkça belirtilir") + S -- "evet" --> G("Filtreler adım adım gevşetilir
önce danışmana söylediklerin, sonra il, sonra alan — listede açıkça belirtilir") G --> H H --> U("Havuz yine de dar kalırsa
liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir") S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer
havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar") @@ -34,7 +35,7 @@ flowchart TD classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46 - class H,Y havuz + class H,Y,P havuz class G,U uyari class L1 hayal class L2 dengeli @@ -44,12 +45,14 @@ export function HuniSemasi() { return ( Havuza yalnızca en son tercih kılavuzunda yer alan programlar girer; kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez. Her - adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "} + adım havuzu biraz daha daraltır; danışmana söyleyip kaydettiğin + tercihler de (dil, ücret, uzaklık gibi) havuzu süzer. Yapay zekâ + listeyi bu havuzun{" "} dışına asla çıkmadan {" "} diff --git a/src/features/pazarlama/components/meraklisina-icerik.tsx b/src/features/pazarlama/components/meraklisina-icerik.tsx index 1d62768..947ccac 100644 --- a/src/features/pazarlama/components/meraklisina-icerik.tsx +++ b/src/features/pazarlama/components/meraklisina-icerik.tsx @@ -114,15 +114,20 @@ export function MeraklisinaIcerik() { göre yeniden hesaplanarak listelenir; üçüncü adımdaki devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar. Seçtiğin il ve devlet/vakıf tercihi, sonuç sayfasındaki ücretsiz - tabloya da uygulanır. Adımlar bitince elde kalan gerçek + tabloya da uygulanır. Danışmanla konuşurken söylediğin ve + “Kaydettiklerim” çiplerinde duran tercihler (öğretim + dili, uzaktan ya da örgün, burs, yıllık ücret sınırı, evden + uzaklık) varsa havuzu onlar da süzer; bir çipi silersen bir + sonraki liste onsuz kurulur. Adımlar bitince elde kalan gerçek programlar bir aday havuzu{" "} olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz verilir. Havuza yalnızca{" "} en son tercih kılavuzunda yer alan{" "} programlar girer: kapanmış ya da kodu değiştiği için eski kaydı kalmış bir program sana önerilmez. Aşağıdaki şema akışın tamamını - gösterir — havuz 24'ün altına düşerse filtrelerin nasıl - gevşetildiği, yine de yetmezse listenin eldeki programlarla, sana + gösterir — havuz 24'ün altına düşerse filtrelerin hangi + sırayla gevşetildiği (önce danışmana söylediklerin, sonra il, + sonra alan), yine de yetmezse listenin eldeki programlarla, sana açıkça belirtilerek nasıl kurulduğu da dahil.

diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 6002573..2da7a06 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -663,6 +663,11 @@ export function SohbetClient({ + {/* KVKK: sohbete kişisel bilgi yazılmasın (hukuk-ve-rakipler.md C.5/22) */} +

+ Ad, telefon, kimlik numarası ya da sağlık bilgisi yazma; danışmanın + bunlara ihtiyacı yok. +

)} diff --git a/src/features/rapor/rapor-actions.ts b/src/features/rapor/rapor-actions.ts index cad49f7..ebc3185 100644 --- a/src/features/rapor/rapor-actions.ts +++ b/src/features/rapor/rapor-actions.ts @@ -6,6 +6,7 @@ import { uretimKilidiBirak, } from "@/lib/uretim-kilidi"; import { getSession, getCurrentUser } from "@/lib/session"; +import { profilGetir } from "@/lib/danisman-profil-db"; import { appDb, schema } from "@/lib/appdb"; import { kullanicininRaporu } from "@/features/rapor/rapor-queries"; import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas"; @@ -214,7 +215,9 @@ async function listeOlusturKilitli( let sonuc: RaporSonuc; try { - sonuc = await raporUret(params); + // Danışmana söylenen tercihler (varsa) havuzu süzer; okunamazsa profilsiz üret + const profil = await profilGetir(user.id).catch(() => undefined); + sonuc = await raporUret(params, undefined, undefined, profil); } catch (err) { // Üretim başarısız → krediyi iade et. Satır paketlide de yazılır (delta 0): // yukarıdaki DUPLICATE dalı "önceki deneme başarısız mıydı" sorusunu tam @@ -347,10 +350,12 @@ export async function listeRevize(input: { 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 diff --git a/src/lib/ai/rapor.ts b/src/lib/ai/rapor.ts index 13b6adf..1b86e2d 100644 --- a/src/lib/ai/rapor.ts +++ b/src/lib/ai/rapor.ts @@ -1,3 +1,4 @@ +import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil"; import "server-only"; import { z } from "zod"; import type { @@ -231,8 +232,10 @@ export async function raporUret( params: RaporParams, revizyonNotu?: string, oncekiListeOzeti?: string, + profil?: DanismanProfili, ): Promise { - const { havuz, gevsetildi } = havuzOlustur(params); + const { havuz, gevsetildi } = havuzOlustur(params, profil); + const profilSatir = profil ? profilSatirlari(profil) : []; if (havuz.length < MIN_LISTE) { throw new RaporUretimHatasi( "Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.", @@ -252,6 +255,11 @@ export async function raporUret( (params.universiteTipi && params.universiteTipi !== "farketmez"); const gevsetmeNotu: string[] = []; + if (gevsetildi.includes("profil")) { + gevsetmeNotu.push( + "Adayın danışmana söylediği tercihlerle (aşağıda) yeterli program yoktu; havuz bu tercihler uygulanmadan kuruldu. Uyanları öne al ve uyarılarda bunu tek cümleyle söyle.", + ); + } if (gevsetildi.includes("il")) { gevsetmeNotu.push( `Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi ${toplamHedef}'e tamamlamak için diğerlerini de kullan.`, @@ -280,6 +288,9 @@ export async function raporUret( })}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.` : null, params.notlar ? `Adayın ek notları: ${params.notlar}` : null, + profilSatir.length > 0 + ? `ADAYIN DANIŞMANA SÖYLEDİĞİ TERCİHLER (havuz buna göre süzüldü; gerekçelerde uyanı belirt):\n${profilSatir.map((s) => `- ${s.metin}`).join("\n")}` + : null, gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null, revizyonNotu ? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}` diff --git a/src/lib/danisman-profil.ts b/src/lib/danisman-profil.ts index 13ecc18..3bd5b46 100644 --- a/src/lib/danisman-profil.ts +++ b/src/lib/danisman-profil.ts @@ -8,6 +8,11 @@ * Sorulmayanlar: cinsiyet, sağlık/engel, gelir. Ücret üst sınırı sorulur, gelir * sorulmaz. Konum il düzeyindedir (adres/koordinat değil). * + * KULLANIM SINIRI (karar, 25 Eyl 2026): profil yalnız liste üretimi + * (rapor-havuzu) ve danışman aramalarında kullanılır; pazarlama, e-posta, + * segmentleme ya da analitik değerinde KULLANILMAZ — kullanılırsa açık rıza + * konusu olur (hukuk-ve-rakipler.md C.3/13). Profil boşken hizmet tam çalışır. + * * Saf modül: client, server ve scripts aynı dosyayı okur. */ import { z } from "zod"; diff --git a/src/lib/db.ts b/src/lib/db.ts index 0d3b1be..f173092 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -75,6 +75,48 @@ export function programSatiri(row: ProgramHamSatiri): Program { * sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü * demektir (hayal), büyükse daha güvenli demektir (garanti). */ +/** + * Danışman profilinden gelen program özelliği süzgeci (program_ozellik + * tablosu; bkz. lib/program-ozellik.ts). Tablo yoksa süzgeç uygulanmaz. + * Devlet programlarında ücret/burs NULL'dur ve her zaman geçer. + */ +export type OzellikSuzgeci = { + dil?: "Türkçe" | "İngilizce"; + yalnizOrgun?: boolean; + yalnizBurslu?: boolean; + ucretUst?: number; +}; + +let ozellikTablosuVar: boolean | null = null; + +function ozellikFiltreSql(o?: OzellikSuzgeci): { sql: string; args: (string | number)[] } { + if (!o) return { sql: "", args: [] }; + const kosul: string[] = []; + const args: (string | number)[] = []; + if (o.dil) { + kosul.push("ogrenim_dili LIKE ?"); + args.push(`${o.dil}%`); + } + if (o.yalnizOrgun) kosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')"); + if (o.yalnizBurslu) kosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')"); + if (o.ucretUst) { + kosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)"); + args.push(o.ucretUst); + } + if (kosul.length === 0) return { sql: "", args: [] }; + if (ozellikTablosuVar == null) { + ozellikTablosuVar = + getDb() + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'") + .get() !== undefined; + } + if (!ozellikTablosuVar) return { sql: "", args: [] }; + return { + sql: `AND id IN (SELECT id FROM program_ozellik WHERE ${kosul.join(" AND ")})`, + args, + }; +} + function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } { if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] }; if (grup === "vakif") @@ -112,6 +154,7 @@ export function dilimAra( il?: string; iller?: string[]; uniturGrubu?: UniturGrubu; + ozellik?: OzellikSuzgeci; limit?: number; offset?: number; } = {} @@ -132,16 +175,17 @@ export function dilimAra( ? `AND il IN (${illerListe.map(() => "?").join(",")})` : ""; const unitur = uniturFiltreSql(opts.uniturGrubu); + const ozellik = ozellikFiltreSql(opts.ozellik); const { alt, ust } = dilimAraligi(sira, dilim); if (ust != null && ust < alt) return { programlar: [], toplam: 0 }; const siraKosulu = ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`; const siraArgs = ust != null ? [alt, ust] : [alt]; - const args = [tur, onlisans, ...illerListe, ...unitur.args, ...siraArgs]; + const args = [tur, onlisans, ...illerListe, ...unitur.args, ...ozellik.args, ...siraArgs]; const base = `FROM programs - WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} + WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} ${ozellik.sql} AND ${EFEKTIF_SIRA} IS NOT NULL AND ${siraKosulu}`; // hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa @@ -168,6 +212,7 @@ export function searchByRank( il?: string; iller?: string[]; uniturGrubu?: UniturGrubu; + ozellik?: OzellikSuzgeci; limitPerBucket?: number; } = {} ): RankResults { @@ -175,6 +220,7 @@ export function searchByRank( il: opts.il, iller: opts.iller, uniturGrubu: opts.uniturGrubu, + ozellik: opts.ozellik, limit: opts.limitPerBucket ?? 12, }; const hayal = dilimAra(sira, turKey, "hayal", ortak); diff --git a/src/lib/rapor-havuzu.ts b/src/lib/rapor-havuzu.ts index 7efe92f..296fc44 100644 --- a/src/lib/rapor-havuzu.ts +++ b/src/lib/rapor-havuzu.ts @@ -1,6 +1,8 @@ // server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz // node (tsx) altında import ediyor; server-only paketi orada fırlar. -import { searchByRank } from "./db"; +import { searchByRank, type OzellikSuzgeci } from "./db"; +import type { DanismanProfili } from "./danisman-profil"; +import { yakinIller } from "./mesafe"; import { PUAN_TURLERI, type Program, @@ -16,10 +18,55 @@ export interface AdayProgram extends Program { dilim: Dilim; } +export type Gevsetilen = "profil" | "kategori" | "il"; + export interface HavuzSonuc { havuz: AdayProgram[]; - // Havuz 24'e ulaşmak için hangi filtreler gevşetildi - gevsetildi: ("kategori" | "il")[]; + // Havuz 24'e ulaşmak için hangi filtreler gevşetildi (sırayla) + gevsetildi: Gevsetilen[]; +} + +/** + * Danışman profilinden sert süzgeç: dil, öğretim türü, burs, ücret üst sınırı + * (program_ozellik) ve ev iline km sınırı (il listesi). Sihirbazda il + * seçilmişse km sınırı uygulanmaz — sihirbaz seçimi daha yeni ve açık. + */ +export function profilSuzgeci( + profil: DanismanProfili | undefined, + sihirbazIlVar: boolean, +): { ozellik?: OzellikSuzgeci; iller?: string[]; ozet: string[] } { + if (!profil) return { ozet: [] }; + const ozellik: OzellikSuzgeci = {}; + const ozet: string[] = []; + if (profil.dil === "ingilizce" || profil.dil === "turkce") { + ozellik.dil = profil.dil === "ingilizce" ? "İngilizce" : "Türkçe"; + ozet.push(`öğretim dili ${ozellik.dil}`); + } + if (profil.ogretimTuru === "yalniz-orgun") { + ozellik.yalnizOrgun = true; + ozet.push("yalnız örgün öğretim"); + } + if (profil.burs === "yalniz-burslu") { + ozellik.yalnizBurslu = true; + ozet.push("vakıfta yalnız burslu"); + } + if (profil.ucretUst) { + ozellik.ucretUst = profil.ucretUst; + ozet.push(`yıllık ücret en fazla ${profil.ucretUst.toLocaleString("tr-TR")} TL`); + } + let iller: string[] | undefined; + if (!sihirbazIlVar && profil.evIl && profil.maxKm) { + const yakin = yakinIller(profil.evIl, profil.maxKm); + if (yakin.length > 0) { + iller = yakin; + ozet.push(`${profil.evIl} iline en fazla ${profil.maxKm} km`); + } + } + return { + ozellik: Object.keys(ozellik).length ? ozellik : undefined, + iller, + ozet, + }; } const HEDEF = 24; @@ -89,12 +136,16 @@ function dilimEkle(r: { } /** - * Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz. - * Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz - * 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste - * her zaman kurulabilsin. + * Deterministik aday havuzu: yapay zekâ bu havuzdan SEÇER, asla uydurmaz. + * Sihirbaz seçimlerine (kategori/il/üniversite tipi) ve varsa danışman + * profiline (dil, öğretim türü, burs, ücret, km) göre filtreler; havuz 24'ün + * altına düşerse kademeli gevşetir (önce profil, sonra il, sonra kategori) ki + * liste her zaman kurulabilsin. /meraklisina şeması bu sırayı anlatır. */ -export function havuzOlustur(params: RaporParams): HavuzSonuc { +export function havuzOlustur( + params: RaporParams, + profil?: DanismanProfili, +): HavuzSonuc { const uniturGrubu = params.universiteTipi && params.universiteTipi !== "farketmez" ? params.universiteTipi @@ -110,6 +161,30 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc { ? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler, p.fakulte)) : havuz; + // 0. Profil süzgeci (danışmana söylenenler) — yalnız yeterli havuz bırakırsa. + // Bırakmazsa profil gevşetilir ve aşağıdaki sihirbaz akışı aynen sürer; + // liste yine kurulur, gevşetme yapay zekâya ve adaya söylenir. + const suzgec = profilSuzgeci(profil, iller.length > 0); + const profilGevsetildi: Gevsetilen[] = []; + if (suzgec.ozellik || suzgec.iller) { + const profilliHam = dilimEkle( + searchByRank(params.sira, params.tur, { + iller: iller.length > 0 ? iller : suzgec.iller, + uniturGrubu, + ozellik: suzgec.ozellik, + limitPerBucket: LIMIT, + }), + ); + const profilliFiltreli = kategoriFiltre(profilliHam); + if (profilliFiltreli.length >= HEDEF) { + return { + havuz: dilimBasinaKirp(profilliFiltreli, PROMPT_LIMIT_DILIM), + gevsetildi: [], + }; + } + profilGevsetildi.push("profil"); + } + // 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS) const tamHam = dilimEkle( searchByRank(params.sira, params.tur, { @@ -122,7 +197,7 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc { if (tamFiltreli.length >= HEDEF) { return { havuz: dilimBasinaKirp(tamFiltreli, PROMPT_LIMIT_DILIM), - gevsetildi: [], + gevsetildi: profilGevsetildi, }; } @@ -130,12 +205,12 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc { if (kategoriler.length > 0 && tamHam.length >= HEDEF) { return { havuz: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM), - gevsetildi: ["kategori"], + gevsetildi: [...profilGevsetildi, "kategori"], }; } // 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön - const gevsetildi: ("kategori" | "il")[] = []; + const gevsetildi: Gevsetilen[] = [...profilGevsetildi]; if (iller.length > 0) gevsetildi.push("il"); const ilsizHam = dilimEkle( searchByRank(params.sira, params.tur, {