diff --git a/.gitignore b/.gitignore index 7f59ca4..2dc4bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ __pycache__/ # YÖK Atlas ham API çekimleri (scripts/refresh.ts; ~yıllık, büyük, yeniden üretilebilir) data/ham/ + +# YÖK Atlas API ham dökümleri (refresh.ts, program-ozellik.ts) +/data/ham/ diff --git a/data/yokatlas.db b/data/yokatlas.db index 3926e72..2642738 100644 Binary files a/data/yokatlas.db and b/data/yokatlas.db differ diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index cc67a8f..86b9659 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -125,7 +125,7 @@ turuncu **tek tondur: `orange-500`** — CTA butonunun turuncusuyla birebir ayn ## 3. Tipografi -Dört yazı tipi, her birinin tek bir işi var: +Dört arayüz yazı tipi (+ yalnız Kur'an metni için bir Arapça yazı tipi), her birinin tek bir işi var: | Sınıf | Yazı tipi | İş | |---|---|---| @@ -133,6 +133,7 @@ Dört yazı tipi, her birinin tek bir işi var: | `font-sans` (varsayılan) | Clarity City 400–800 | Gövde, buton, form, tablo | | `font-bricolage` | Bricolage Grotesque (opsz ekseni) | Vurgu: hero'da italik mavi span, logo yazısı, üst çubuktaki sayfa adı (§4.14), küçük etiketler/çipler | | `font-mono` | Geist Mono | Yalnızca kod/markdown blokları. Arayüzde kullanılmaz | +| `amiriQuran.className` (`site-footer.tsx`) | Amiri Quran | Yalnızca Kur'an metni: footer'daki âyet (`lang="ar" dir="rtl"`). Arayüz metninde kullanılmaz; önceden yüklenmez, Arapça alt kümesi âyet çizilirken iner. Aşağıdaki "serif yasak" kuralı Latin arayüz metni içindir | Ölçek (kopyala, uydurma): diff --git a/drizzle/0005_uneven_omega_red.sql b/drizzle/0005_uneven_omega_red.sql new file mode 100644 index 0000000..ca15953 --- /dev/null +++ b/drizzle/0005_uneven_omega_red.sql @@ -0,0 +1,6 @@ +CREATE TABLE `danisman_profil` ( + `user_id` text PRIMARY KEY NOT NULL, + `alanlar` text NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..f0f67b8 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1011 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "09682d29-2bdc-4235-b931-9057baf5657a", + "prevId": "79c73a04-96a6-4849-adf6-63a502c314d0", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_message_id": { + "name": "client_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_client_message_id_unique": { + "name": "chat_messages_client_message_id_unique", + "columns": [ + "client_message_id" + ], + "isUnique": true + }, + "chat_user_created": { + "name": "chat_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credit_ledger": { + "name": "credit_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delta": { + "name": "delta", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ledger_reason_ref": { + "name": "ledger_reason_ref", + "columns": [ + "reason", + "ref_id" + ], + "isUnique": true + }, + "ledger_user": { + "name": "ledger_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "danisman_profil": { + "name": "danisman_profil", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alanlar": { + "name": "alanlar", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "danisman_profil_user_id_user_id_fk": { + "name": "danisman_profil_user_id_user_id_fk", + "tableFrom": "danisman_profil", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_kurus": { + "name": "amount_kurus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "iyzico_token": { + "name": "iyzico_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iyzico_form_content": { + "name": "iyzico_form_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iyzico_payment_id": { + "name": "iyzico_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "orders_user": { + "name": "orders_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "orders_iyzico_token": { + "name": "orders_iyzico_token", + "columns": [ + "iyzico_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "orders_user_id_user_id_fk": { + "name": "orders_user_id_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_locks": { + "name": "report_locks", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "report_locks_user_id_user_id_fk": { + "name": "report_locks_user_id_user_id_fk", + "tableFrom": "report_locks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reports": { + "name": "reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "params": { + "name": "params", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_count": { + "name": "revision_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reports_user_id_unique": { + "name": "reports_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reports_user_id_user_id_fk": { + "name": "reports_user_id_user_id_fk", + "tableFrom": "reports", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_lists": { + "name": "saved_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items": { + "name": "items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profil": { + "name": "profil", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "saved_lists_user_id_unique": { + "name": "saved_lists_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "saved_lists_user_id_user_id_fk": { + "name": "saved_lists_user_id_user_id_fk", + "tableFrom": "saved_lists", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tadimlik_havuzu": { + "name": "tadimlik_havuzu", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kova_slug": { + "name": "kova_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tur": { + "name": "tur", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kategori_slug": { + "name": "kategori_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tip": { + "name": "tip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'genel'" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dilim": { + "name": "dilim", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gerekce": { + "name": "gerekce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "risk_notu": { + "name": "risk_notu", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trend_ozeti": { + "name": "trend_ozeti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "program": { + "name": "program", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ornek_sira": { + "name": "ornek_sira", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uretim_at": { + "name": "uretim_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tadimlik_anahtar": { + "name": "tadimlik_anahtar", + "columns": [ + "kova_slug", + "tur", + "kategori_slug", + "tip" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_paket": { + "name": "has_paket", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "kredi_bitti_at": { + "name": "kredi_bitti_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kredi_hatirlatma_gonderildi_at": { + "name": "kredi_hatirlatma_gonderildi_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 838c808..d07b295 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1790031029618, "tag": "0004_shocking_caretaker", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1790287503094, + "tag": "0005_uneven_omega_red", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 8f8865a..5dea669 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "eslint && pnpm tasarim:denetim", "ingest": "tsx scripts/ingest.ts", "refresh": "tsx scripts/refresh.ts", + "ozellik": "tsx scripts/program-ozellik.ts", "detay": "tsx scripts/detay.ts", "yokatlas:goc": "tsx scripts/yokatlas-goc.ts", "gecmis-doldur": "tsx scripts/gecmis-doldur.ts", diff --git a/scripts/program-ozellik.ts b/scripts/program-ozellik.ts new file mode 100644 index 0000000..13fbae8 --- /dev/null +++ b/scripts/program-ozellik.ts @@ -0,0 +1,282 @@ +/** + * YÖK Atlas API'sindeki program ÖZELLİKLERİNİ `program_ozellik` tablosuna yazar: + * yıllık ücret, burs oranı, öğretim dili, öğretim türü, program ve üniversite + * akreditasyonu, kadro (prof/doç/dr. öğr. üyesi/arş. gör.), tercih baraj + * sırası, kampüs ilçesi ve yerleşme koşulu kodları. Koşul metinleri koda göre + * `kosul` tablosunda tek kopya durur. + * + * refresh.ts'ten AYRI betik: bu alanların yıl anlamı yok (kılavuzun o anki + * hâli), yıl kilidi gerekmez; ama --yil ve --db yine zorunlu ve tablo satırı + * hangi yılın kılavuzundan geldiğini `yil` kolonunda taşır. Yalnız `programs` + * tablosunda olan id'lere yazılır; eşleşmeyen kayıt sayısı raporlanır. + * + * Kaynağa nazik: sayfa başı 500 kayıt, istekler arası 0,5 sn, tek bağlantı. + * Çekilen ham kayıtlar data/ham/ozellik--.jsonl.gz'e yazılır; + * `--ham ` verilirse API'ye gidilmez, o dosya okunur. + * + * Kullanım: + * pnpm ozellik --yil 2026 --db /tmp/deneme.db + * pnpm ozellik --yil 2026 --db data/yokatlas.db --ham data/ham/ozellik-2026-2026-09-25.jsonl.gz + */ +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; +import zlib from "node:zlib"; +import { + argOku, + calistir, + dbAc, + dbArgumani, + dur, + metaYaz, + yilArgumani, + type Db, +} from "./yokatlas-goc"; + +const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search"; +const PAGE_SIZE = 500; +const DELAY_MS = 500; + +type HamKayit = Record; + +type ApiPage = { + content: HamKayit[]; + totalPages: number; + totalElements: number; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function fetchPage(page: number): Promise { + const res = await fetch(API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filters: { + puanTuru: null, + universiteId: [], + birimGrupId: [], + ilKodu: [], + birimTuruId: null, + universiteTuru: null, + bursOraniId: null, + ogrenimTuruId: null, + kilavuzKodu: null, + minBasariSirasi: null, + maxBasariSirasi: null, + }, + page, + size: PAGE_SIZE, + sortBy: "kilavuzKodu", + direction: "ASC", + }), + }); + if (!res.ok) throw new Error(`API ${res.status} (sayfa ${page})`); + return (await res.json()) as ApiPage; +} + +/** Pozitif tam sayı; 0 / boş / sayı değil → null (API "veri yok"u 0 ile verir). */ +function intOrNull(v: unknown): number | null { + const n = typeof v === "string" ? Number.parseFloat(v) : (v as number); + return typeof n === "number" && Number.isFinite(n) && n > 0 + ? Math.round(n) + : null; +} + +function metinOrNull(v: unknown): string | null { + if (typeof v !== "string") return null; + const t = v.trim(); + return t ? t : null; +} + +// ── Şema ───────────────────────────────────────────────────────────────── + +export function ozellikTablolariniGarantiEt(db: Db): void { + db.exec(` + CREATE TABLE IF NOT EXISTS program_ozellik ( + id TEXT PRIMARY KEY REFERENCES programs(id), + ucret INTEGER, + burs_orani TEXT, + ogrenim_dili TEXT, + ogrenim_turu TEXT, + akreditasyon TEXT, + uni_akreditasyon TEXT, + prof INTEGER, + doc INTEGER, + dou INTEGER, + argor INTEGER, + min_basari_sirasi INTEGER, + ilce TEXT, + kosul_kodlari TEXT, + yil INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS kosul ( + kod TEXT PRIMARY KEY, + metin TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_program_ozellik_dili ON program_ozellik (ogrenim_dili); + CREATE INDEX IF NOT EXISTS idx_program_ozellik_turu ON program_ozellik (ogrenim_turu); + `); +} + +// ── Ham dosya ──────────────────────────────────────────────────────────── + +async function hamOku(dosya: string, yil: number): Promise { + if (!fs.existsSync(dosya)) dur(`Ham dosya yok: ${dosya}`); + const rl = readline.createInterface({ + input: fs.createReadStream(dosya).pipe(zlib.createGunzip()), + }); + const kayitlar: HamKayit[] = []; + let metaVar = false; + for await (const satir of rl) { + if (!satir) continue; + const obj = JSON.parse(satir) as HamKayit; + if (obj._meta) { + const meta = obj._meta as { yil: number }; + if (meta.yil !== yil) dur(`Ham dosya ${meta.yil} yılı için, --yil ${yil} verildi.`); + metaVar = true; + continue; + } + kayitlar.push(obj); + } + if (!metaVar) dur(`Ham dosyada _meta satırı yok: ${dosya}`); + return kayitlar; +} + +async function apidenCek(yil: number): Promise { + const ilk = await fetchPage(0); + const toplamSayfa = ilk.totalPages; + const kayitlar = [...ilk.content]; + console.log(`API: ${ilk.totalElements} kayıt, ${toplamSayfa} sayfa`); + for (let p = 1; p < toplamSayfa; p++) { + await sleep(DELAY_MS); + const sayfa = await fetchPage(p); + kayitlar.push(...sayfa.content); + if (p % 10 === 0) console.log(` sayfa ${p}/${toplamSayfa}`); + } + const benzersiz = new Set(kayitlar.map((r) => String(r.kilavuzKodu))).size; + if (benzersiz !== ilk.totalElements) { + dur(`Benzersiz kod ${benzersiz} ≠ totalElements ${ilk.totalElements} (sayfalama kaydı)`); + } + const dizin = path.join(process.cwd(), "data", "ham"); + fs.mkdirSync(dizin, { recursive: true }); + const tarih = new Date().toISOString().slice(0, 10); + const dosya = path.join(dizin, `ozellik-${yil}-${tarih}.jsonl.gz`); + const gz = zlib.createGzip(); + const out = fs.createWriteStream(dosya); + gz.pipe(out); + gz.write(JSON.stringify({ _meta: { yil, totalElements: ilk.totalElements, tarih, kaynak: "program-ozellik" } }) + "\n"); + for (const r of kayitlar) gz.write(JSON.stringify(r) + "\n"); + await new Promise((res, rej) => { + out.on("finish", () => res()); + out.on("error", rej); + gz.end(); + }); + console.log(`Ham dosya: ${dosya}`); + return kayitlar; +} + +// ── Yazım ──────────────────────────────────────────────────────────────── + +function kosulKodlari(r: HamKayit): { kodlar: string[]; metinler: Map } { + const metinler = new Map(); + const liste = Array.isArray(r.kosulList) ? (r.kosulList as Record[]) : []; + for (const k of liste) { + for (const [kod, metin] of Object.entries(k)) { + if (typeof metin === "string" && metin.trim()) metinler.set(kod, metin.trim()); + } + } + // `kosul` alanı "155,18,21" biçiminde; listedeki kodlarla birleşir. + const hamKodlar = typeof r.kosul === "string" ? r.kosul.split(",") : []; + const kodlar = Array.from( + new Set([...hamKodlar.map((k) => k.trim()).filter(Boolean), ...metinler.keys()]) + ).sort((a, b) => Number(a) - Number(b)); + return { kodlar, metinler }; +} + +calistir(async () => { + const yil = yilArgumani(); + const dbYolu = dbArgumani(); + const hamArg = argOku("ham"); + + const kayitlar = hamArg + ? await hamOku(path.resolve(process.cwd(), hamArg), yil) + : await apidenCek(yil); + + const db = dbAc(dbYolu); + ozellikTablolariniGarantiEt(db); + + const mevcut = new Set( + (db.prepare("SELECT id FROM programs").all() as { id: string }[]).map((r) => r.id) + ); + + const yaz = db.prepare(` + INSERT INTO program_ozellik (id, ucret, burs_orani, ogrenim_dili, ogrenim_turu, + akreditasyon, uni_akreditasyon, prof, doc, dou, argor, min_basari_sirasi, + ilce, kosul_kodlari, yil) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ucret = excluded.ucret, burs_orani = excluded.burs_orani, + ogrenim_dili = excluded.ogrenim_dili, ogrenim_turu = excluded.ogrenim_turu, + akreditasyon = excluded.akreditasyon, uni_akreditasyon = excluded.uni_akreditasyon, + prof = excluded.prof, doc = excluded.doc, dou = excluded.dou, argor = excluded.argor, + min_basari_sirasi = excluded.min_basari_sirasi, ilce = excluded.ilce, + kosul_kodlari = excluded.kosul_kodlari, yil = excluded.yil + `); + const kosulYaz = db.prepare( + "INSERT INTO kosul (kod, metin) VALUES (?, ?) ON CONFLICT(kod) DO UPDATE SET metin = excluded.metin" + ); + + let yazilan = 0; + let eslesmeyen = 0; + const tumKosullar = new Map(); + db.transaction(() => { + for (const r of kayitlar) { + const id = String(r.kilavuzKodu ?? ""); + if (!mevcut.has(id)) { + eslesmeyen++; + continue; + } + const { kodlar, metinler } = kosulKodlari(r); + for (const [k, m] of metinler) tumKosullar.set(k, m); + yaz.run( + id, + intOrNull(r.ucret), + metinOrNull(r.bursOraniAdi), + metinOrNull(r.ogrenimDiliAdi), + metinOrNull(r.ogrenimTuruAdi), + metinOrNull(r.akreditasyon), + metinOrNull(r.uniAkreditasyon), + intOrNull(r.prof), + intOrNull(r.doc), + intOrNull(r.dou), + intOrNull(r.arGor), + intOrNull(r.minBasariSirasi), + metinOrNull(r.ilceAdi), + kodlar.length ? kodlar.join(",") : null, + yil + ); + yazilan++; + } + for (const [k, m] of tumKosullar) kosulYaz.run(k, m); + metaYaz(db, "program_ozellik_yil", yil); + metaYaz(db, "program_ozellik_tarih", new Date().toISOString().slice(0, 10)); + })(); + + const ozet = db + .prepare( + `SELECT COUNT(*) AS n, + SUM(ucret IS NOT NULL) AS ucret, SUM(burs_orani IS NOT NULL) AS burs, + SUM(ogrenim_dili IS NOT NULL) AS dil, SUM(akreditasyon IS NOT NULL) AS akr, + SUM(prof IS NOT NULL) AS prof, SUM(min_basari_sirasi IS NOT NULL) AS baraj, + SUM(kosul_kodlari IS NOT NULL) AS kosul + FROM program_ozellik` + ) + .get() as Record; + db.pragma("wal_checkpoint(TRUNCATE)"); + db.close(); + console.log( + `Yazıldı ${yazilan}, programs'ta olmayan ${eslesmeyen}, koşul metni ${tumKosullar.size}.\n` + + `Doluluk: ${JSON.stringify(ozet)}` + ); +}); diff --git a/src/app/api/soru/profil/route.ts b/src/app/api/soru/profil/route.ts new file mode 100644 index 0000000..a9cb5da --- /dev/null +++ b/src/app/api/soru/profil/route.ts @@ -0,0 +1,29 @@ +import type { NextRequest } from "next/server"; +import { getSession } from "@/lib/session"; +import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db"; +import { PROFIL_ALANLARI, profilSatirlari, type ProfilAlani } from "@/lib/danisman-profil"; + +/** + * Danışman profili — sohbetten `profil_guncelle` aracıyla yazılan alanlar. + * GET: alanlar + öğrenciye okunan satırlar. DELETE {alan}: tek alanı siler + * (KVKK: öğrenci her alanı görür ve tek tıkla siler). Yazma yalnız araç + * üzerinden; elle düzenleme sohbette "şunu değiştir" diyerek yapılır. + */ +export async function GET() { + const session = await getSession(); + if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 }); + const profil = await profilGetir(session.user.id); + return Response.json({ profil, satirlar: profilSatirlari(profil) }); +} + +export async function DELETE(request: NextRequest) { + const session = await getSession(); + if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 }); + const body = (await request.json().catch(() => null)) as { alan?: unknown } | null; + const alan = typeof body?.alan === "string" ? (body.alan as ProfilAlani) : null; + if (!alan || !PROFIL_ALANLARI.includes(alan)) { + return Response.json({ error: "Geçersiz alan." }, { status: 400 }); + } + const profil = await profilAlanYaz(session.user.id, alan, null); + return Response.json({ profil, satirlar: profilSatirlari(profil) }); +} diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index b791501..6c39354 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -4,12 +4,16 @@ import { getSession } from "@/lib/session"; import { appDb, schema } from "@/lib/appdb"; import { spendCreditForMessage, + bedelsizMesajKaydet, grantCredits, krediBittiIsaretle, } from "@/lib/credits"; import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client"; import { kullanicininRaporu } from "@/features/rapor/rapor-queries"; -import { sohbetAkisi } from "@/lib/ai/cagri"; +import { sohbetAkisiAraclarla } from "@/lib/ai/cagri"; +import { ARACLAR, aracCalistirici, type AracBaglami } from "@/lib/ai/araclar"; +import { profilGetir } from "@/lib/danisman-profil-db"; +import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil"; import { listeOzetiCikar } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; import type { RaporParams } from "@/features/rapor/types/rapor"; @@ -18,12 +22,36 @@ 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; + +/** Danışmanın onay sorusunda geçen sabit ibare; istemci de bunu görmez, yalnız sunucu arar. */ +const ONAY_SORUSU = "profiline kaydedeyim mi"; + +/** + * Onay mesajı mı: son danışman mesajı "kaydedeyim mi?" diye sormuş VE aday + * kısa bir evet/hayır yazmış. Bu mesaj kredi harcamaz (Bilal, 26 Eyl 2026). + * Kısa tutulur ki "evet ama İzmir'de hukuk kaç sırayla alıyor" gibi gerçek + * sorular bedava geçmesin. + */ +function onayMesajiMi(mesaj: string, sonAsistan: string | undefined): boolean { + if (!sonAsistan || !sonAsistan.toLocaleLowerCase("tr-TR").includes(ONAY_SORUSU)) return false; + const m = mesaj.trim().toLocaleLowerCase("tr-TR"); + if (m.length > 40) return false; + return /^(evet|olur|tamam|kaydet|kaydedebilirsin|kaydedelim|hayır|hayir|kaydetme|gerek yok|istemiyorum|şimdilik hayır)\b/.test(m); +} const MAX_MESAJ_UZUNLUK = 2000; function sistemPromptu(opts: { params: RaporParams; listeOzeti: string; kilitli?: boolean; + profil?: DanismanProfili; }): string { const { params } = opts; const secimVar = @@ -50,7 +78,28 @@ KURALLAR: kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan. - ASLA yerleşme garantisi verme; olasılık, yüzde ya da "ihtimal" dili kullanma — güvenli / az riskli / riskli etiketleri ve taban sıralarıyla konuş. -- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle. +- Bilmediğin güncel veriyi uydurma: taban sırası, kontenjan, ücret, burs, + dil, akreditasyon, koşul ya da üniversite künyesi gerekince ARAÇLARI çağır + (program_ara, program_detay, universite_profili); araç veremiyorsa + "elimde veri yok" de ve YÖK Atlas'a bakmasını söyle. +- 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 + 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_ONAY_SOR + ? `HEMEN KAYDETME: cevabının sonunda tek cümleyle, tam bu ibareyle sor: + "Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz; 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); + sorulursa bilgi olarak söyle, filtre ya da tavsiye ölçütü yapma. - Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat. - Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır. - GÜVENLİK, öteki kuralların önünde gelir: aday umutsuzluk, kendine zarar @@ -77,6 +126,13 @@ Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satı tercih soruları üzerinden yardımcı olmaya devam et.`, ); } + const profilSatir = opts.profil ? profilSatirlari(opts.profil) : []; + if (profilSatir.length > 0) { + parcalar.push( + `ADAYIN SOHBETTE SÖYLEDİĞİ VE KAYDEDİLEN TERCİHLERİ (profil; program_ara bunları otomatik uygular):\n` + + profilSatir.map((s) => `- ${s.metin}`).join("\n"), + ); + } if (secimVar) { parcalar.push( `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({ @@ -156,7 +212,7 @@ export async function POST(request: NextRequest) { // Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel. // Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın; // hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli. - const [rapor, gecmisHam, kullanici] = await Promise.all([ + const [rapor, gecmisHam, kullanici, profil] = await Promise.all([ // Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz) kullanicininRaporu(session.user.id), appDb @@ -169,6 +225,7 @@ export async function POST(request: NextRequest) { where: eq(schema.user.id, session.user.id), columns: { hasPaket: true }, }), + profilGetir(session.user.id), ]); if (!rapor?.result) { return Response.json( @@ -178,12 +235,18 @@ export async function POST(request: NextRequest) { } const gecmis = gecmisHam.reverse(); + // Profil onayı ("kaydedeyim mi?" → "evet") kredi harcamaz; iade de yok. + const sonAsistan = [...gecmis].reverse().find((m) => m.role === "assistant")?.content; + const bedelsiz = onayMesajiMi(mesaj, sonAsistan); + // Kredi düşümü stream'den önce — yetersizse hiç başlama - const harcama = await spendCreditForMessage({ - userId: session.user.id, - content: mesaj, - clientMessageId, - }); + const harcama = bedelsiz + ? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId }) + : await spendCreditForMessage({ + userId: session.user.id, + content: mesaj, + clientMessageId, + }); if (!harcama.ok) { if (harcama.error === "INSUFFICIENT") { await krediBittiIsaretle(session.user.id); @@ -213,11 +276,21 @@ export async function POST(request: NextRequest) { ].join("\n") : tamOzet; + const raporParams = rapor.params as RaporParams; const system = sistemPromptu({ - params: rapor.params as RaporParams, + params: raporParams, listeOzeti, kilitli, + profil, }); + // Araçların bağlamı: aday sırası/puan türü (arama süzgeci) ve profil + // (profil_guncelle yazınca aynı tur içinde tazelenir). + const aracBaglami: AracBaglami = { + userId: session.user.id, + sira: raporParams.sira, + tur: raporParams.tur, + profil, + }; // Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla) if (process.env.NODE_ENV !== "production" && !anahtarVar()) { @@ -253,12 +326,14 @@ export async function POST(request: NextRequest) { } if (!anahtarVar()) { - await grantCredits({ - userId: session.user.id, - delta: 1, - reason: "refund", - refId: kullaniciMesajId, - }); + if (!bedelsiz) { + await grantCredits({ + userId: session.user.id, + delta: 1, + reason: "refund", + refId: kullaniciMesajId, + }); + } console.error("[soru] Yapay Zeka anahtarı tanımlı değil"); return Response.json( { @@ -286,10 +361,13 @@ export async function POST(request: NextRequest) { let metin = ""; try { // Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır - for await (const parca of sohbetAkisi({ + for await (const parca of sohbetAkisiAraclarla({ system, mesajlar, maxTokens: 1024, + araclar: ARACLAR, + calistir: aracCalistirici(aracBaglami), + onArac: (ad) => console.info("[soru] araç:", ad), })) { metin += parca; controller.enqueue(encoder.encode(parca)); @@ -319,12 +397,14 @@ export async function POST(request: NextRequest) { }); } catch {} } - await grantCredits({ - userId, - delta: 1, - reason: "refund", - refId: kullaniciMesajId, - }).catch(() => {}); + if (!bedelsiz) { + await grantCredits({ + userId, + delta: 1, + reason: "refund", + refId: kullaniciMesajId, + }).catch(() => {}); + } } console.error("[soru] stream hatası:", err); controller.error(err); @@ -337,6 +417,8 @@ export async function POST(request: NextRequest) { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", + // İstemci bakiyeyi buna göre düşürür (onay mesajı bedelsiz) + "X-Kredi-Dusuldu": bedelsiz ? "0" : "1", }, }); } 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/app/layout.tsx b/src/app/layout.tsx index 3eba033..72762f4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,7 @@ import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy"; import { HeaderGate } from "@/components/header-gate"; import { DevPanelMount } from "@/components/dev/dev-panel-mount"; +import { ConsoleAsr } from "@/components/console-asr"; import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo"; import "./globals.css"; @@ -117,6 +118,8 @@ export default function RootLayout({ {/* İlk "+" için sıra + sihirbaz kapısı */} + {/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */} + {/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */} {process.env.NODE_ENV !== "production" ? : null} diff --git a/src/components/console-asr.tsx b/src/components/console-asr.tsx index f20b684..acf4f07 100644 --- a/src/components/console-asr.tsx +++ b/src/components/console-asr.tsx @@ -2,31 +2,136 @@ import { useEffect } from "react"; -let hasPlayed = false; +/** + * Konsol sürprizi: DevTools'u açan meraklıya sitenin piksel dilinde bir kum + * saati, Asr Sûresi (Arapça + meal) ve tasavvuftan bir vakit sözü. "Asr" + * zaman demek; kum saati de onu anlatır. Görünür arayüzde iz bırakmaz; kök + * layout'ta bir kez çalışır, sayfa geçişlerinde tekrar basmaz. + * + * DevTools açık ya da koyu temada olabilir: düz metne renk verilmez + * (konsolun kendi rengi kalır), yalnız iki zeminde de okunan marka renkleri + * kullanılır. + * + * Arapça metin Diyanet'in Kur'an-ı Kerim mushafından, meal Diyanet İşleri + * mealinden harfi harfine alındı (kuran.diyanet.gov.tr, âyet gruplaması + * aynen). Elle düzeltme yapılmaz; değişecekse kaynaktan yeniden kopyalanır. + */ -const consoleMessage = [ - "%cبِسْمِ اللَّهِ الرَّحْمٰنِ الرَّحِيمِ", - "", - "وَالْعَصْرِۙ اِنَّ الْاِنْسَانَ لَف۪ي خُسْرٍۙ", - "اِلَّا الَّذ۪ينَ اٰمَنُوا وَعَمِلُوا الصَّالِحَاتِ", - "وَتَوَاصَوْا بِالْحَقِّ وَتَوَاصَوْا بِالصَّبْرِ", - "", +let basildi = false; + +// Logodaki üç kare (public/kolay-tercih-mark.svg) +const MAVI = "#3b82f6"; +const SEFTALI = "#fdc7a7"; +const TURUNCU = "#ff5a00"; +// slate-400: açık ve koyu konsolda okunan silik metin +const SILIK = "#94a3b8"; + +// Hero'daki dot-matrix ızgarasıyla kum saati: # çerçeve, g boş cam, +// o yığılmış kum, p akan kum. Üst hazne boşalıyor, alt hazne doluyor. +const KUM_SAATI = [ + "###########", + " #ggggggg# ", + " #ooooooo# ", + " #ooooo# ", + " #ooo# ", + " #p# ", + " #p# ", + " #gpg# ", + " #ggpgg# ", + " #ggooogg# ", + " #ooooooo# ", + "###########", +]; + +const PIKSEL_RENK: Record = { + "#": MAVI, + g: "rgba(59,130,246,0.16)", + o: TURUNCU, + p: SEFTALI, +}; + +// Piksel = arka planı boyanmış iki boşluk; satır aralığı ve sağ boşluk +// hero'daki gibi kareler arasında eşit nefes bırakır. +const HUCRE = "font-size:8px;line-height:14px;margin-right:4px;border-radius:2px"; + +// Kum saatinin sağında, boğaz hizasında duran satırlar +const YAN_YAZI: Record = { + 5: ["KolayTercih", `font-size:13px;font-weight:700;color:${MAVI}`], + 6: ["Asr Sûresi", `font-size:12px;color:${SILIK}`], +}; + +const BESMELE = "بِسْمِ اللّٰهِ الرَّحْمٰنِ الرَّح۪يمِ"; + +const ASR = [ + "وَالْعَصْرِۙ", + "اِنَّ الْاِنْسَانَ لَفٖي خُسْرٍۙ", + "اِلَّا الَّذٖينَ اٰمَنُوا وَعَمِلُوا الصَّالِحَاتِ وَتَوَاصَوْا بِالْحَقِّ وَتَوَاصَوْا بِالصَّبْرِ", +]; + +const MEAL = [ "Rahmân ve Rahîm olan Allah’ın adıyla.", "", - "Asra yemîn olsun ki,", - "İnsan gerçekten ziyândadır.", - "Ancak iman edip sâlih ameller yapanlar, birbirlerine hakkı tavsiye", - "edenler ve birbirlerine sabretmeyi öğütleyenler müstesnâ!", -].join("\n"); + "1-2. Andolsun zamana ki, insan gerçekten ziyan içindedir.", + "3. Ancak, iman edip de sâlih ameller işleyenler, birbirlerine hakkı tavsiye edenler, birbirlerine sabrı tavsiye edenler başka (Onlar ziyanda değillerdir).", +]; + +// Konsol sayfanın web fontlarını görmez; sistemde ne varsa o seçilir. +const ARAPCA_YAZI_TIPI = + "'Amiri Quran','Amiri','Scheherazade New','Noto Naskh Arabic','Geeza Pro','Traditional Arabic',serif"; + +function mesaj(): string[] { + let bicim = ""; + const stiller: string[] = []; + const ekle = (metin: string, stil: string) => { + bicim += `%c${metin}`; + stiller.push(stil); + }; + + KUM_SAATI.forEach((satir, i) => { + for (const k of satir) { + const renk = PIKSEL_RENK[k]; + ekle(" ", renk ? `${HUCRE};background:${renk}` : HUCRE); + } + const yan = YAN_YAZI[i]; + if (yan) ekle(` ${yan[0]}`, `line-height:14px;${yan[1]}`); + bicim += "\n"; + }); + + ekle( + `\n${BESMELE}\n${ASR.join("\n")}\n`, + `font-size:22px;line-height:2;font-family:${ARAPCA_YAZI_TIPI}`, + ); + ekle(`\n${MEAL.join("\n")}\n`, "font-size:13px;line-height:1.7"); + ekle("Asr Sûresi (103) · Diyanet İşleri meali\n\n", `font-size:11px;color:${SILIK}`); + ekle( + "«Vakit bir kılıçtır; sen onu kesmezsen o seni keser.»\n", + `font-size:13px;font-weight:600;line-height:1.8;color:${TURUNCU}`, + ); + ekle( + "İmam Şâfiî’nin sûfîlerden öğrendiğini söylediği söz\n\n", + `font-size:11px;color:${SILIK}`, + ); + ekle( + `Meraklısına: ${window.location.origin}/meraklisina`, + `font-size:11px;color:${SILIK}`, + ); + + return [bicim, ...stiller]; +} export function ConsoleAsr() { useEffect(() => { - if (hasPlayed) return; - hasPlayed = true; + if (basildi) return; + basildi = true; - window.setTimeout(() => { - console.log(consoleMessage, "font-size:16px"); - }, 250); + // Hidrasyon ve ilk etkileşimle yarışmasın: tarayıcı boşa çıkınca bas. + // Safari'de requestIdleCallback yok, orada kısa bir gecikme yeter. + const bas = () => console.log(...mesaj()); + if (typeof window.requestIdleCallback === "function") { + window.requestIdleCallback(bas, { timeout: 3000 }); + } else { + window.setTimeout(bas, 1000); + } }, []); return null; diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 81b4264..689975e 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -1,6 +1,17 @@ import Image from "next/image"; import Link from "next/link"; +import { Amiri_Quran } from "next/font/google"; +import { PixelMark } from "@/components/pixel-decor"; import { getAllBolumler } from "@/lib/katalog"; +import { cn } from "@/lib/utils"; + +// Yalnız footer'daki âyet için (DESIGN.md §3). Önceden yüklenmez: Arapça +// alt kümesi (~45 KB) âyet çizilirken bir kez iner, sonra önbellekten gelir. +const amiriQuran = Amiri_Quran({ + weight: "400", + subsets: ["arabic"], + preload: false, +}); /** * Site footer'ı: yasal metin + SEO iç link kolonları. Server component — @@ -125,6 +136,32 @@ export function SiteFooter() {
+ {/* Hz. Mûsâ'nın göreve çıkarken ettiği dua (Tâhâ 25–26); sınav + öncesi okunan dua da budur. "İşimi kolaylaştır" sitenin adıyla + aynı dilekten. Arapça Diyanet mushafından, meal Diyanet İşleri + mealinden harfi harfine; dua olarak okunduğu gibi baştaki + "kâle" (dedi ki) alınmadı. */} +
+
+

+ رَبِّ اشْرَحْ لٖي صَدْرٖيۙ وَيَسِّرْ لٖٓي اَمْرٖيۙ +

+

+ “Rabbim! Gönlüme ferahlık ver. İşimi bana kolaylaştır.” +

+
+
+ + Tâhâ Sûresi 25–26 · Diyanet İşleri meali +
+

© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir; KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir. 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/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index f2a6076..e37eb48 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -31,6 +31,7 @@ import { type DanismanOzeti, type IlkTercihYorumu, type Mesaj, + type ProfilSatiri, } from "./sohbet-client"; import { RevizyonKutusu } from "./revizyon-kutusu"; import { @@ -55,6 +56,7 @@ export function ListemGovde({ kredi, raporKredi, sohbetGecmisi, + danismanProfili, ozet, ilkYorum, kalanHak, @@ -76,6 +78,8 @@ export function ListemGovde({ * kapıdan geçtiği için aynı bedeli gösterir. */ raporKredi: number; sohbetGecmisi: Mesaj[]; + /** Danışmanın kaydettiği tercihler (sunucudan; çipler bununla açılır) */ + danismanProfili: ProfilSatiri[]; ozet?: DanismanOzeti; /** Sohbet boşken açılışta gösterilen 1. tercih yorumu */ ilkYorum?: IlkTercihYorumu; @@ -124,6 +128,12 @@ export function ListemGovde({ ? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi" : `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`, ]; + satirlar.push( + "Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs)", + ); + satirlar.push( + "Sorunca veriden okur: taban sıraları, kontenjan, ücret/burs, dil, akreditasyon, koşullar, üniversite künyesi", + ); if (params.kategoriler?.length) { satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`); } @@ -318,6 +328,7 @@ export function ListemGovde({ (initialProfil ?? []); const router = useRouter(); + async function profiliCek(oncekiAlanlar: Set) { + try { + const res = await fetch("/api/soru/profil"); + if (!res.ok) return; + const data = (await res.json()) as { satirlar?: ProfilSatiri[] }; + const satirlar = data.satirlar ?? []; + setProfil(satirlar); + for (const s of satirlar) { + // Analitiğe yalnız alan adı gider, değer gitmez (KVKK) + if (!oncekiAlanlar.has(s.alan)) olay("profil_alani_kaydedildi", { alan: s.alan }); + } + } catch {} + } + + useEffect(() => { + // Listem sayfası profili sunucudan indirir; /sonuc popup'ı gibi geç mount + // olan yerler çeker (ttlMs 0: yalnız in-flight dedup, DELETE sonrası + // bayat kalmasın). + if (initialProfil != null) return; + onbellekliJsonGetir<{ satirlar?: ProfilSatiri[] }>("/api/soru/profil", { + ttlMs: 0, + }) + .then((data) => setProfil(data?.satirlar ?? [])) + .catch(() => {}); + }, [initialProfil]); + + async function profilAlaniSil(alan: ProfilAlani) { + const onceki = profil; + setProfil((p) => p.filter((s) => s.alan !== alan)); + try { + const res = await fetch("/api/soru/profil", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alan }), + }); + if (!res.ok) throw new Error("silinemedi"); + const data = (await res.json()) as { satirlar?: ProfilSatiri[] }; + setProfil(data.satirlar ?? []); + olay("profil_alani_silindi", { alan }); + } catch { + setProfil(onceki); + toast.error("Silinemedi, tekrar dener misin?"); + } + } + // Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı // (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın. useEffect(() => { @@ -213,7 +269,8 @@ export function SohbetClient({ return; } - setKredi((k) => k - 1); + // Profil onayı ("kaydedeyim mi?" → "evet") sunucuda bedelsizdir + if (res.headers.get("X-Kredi-Dusuldu") !== "0") setKredi((k) => k - 1); // Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx) olay("danisman_sorusu", { sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc", @@ -236,6 +293,8 @@ export function SohbetClient({ // Header'daki kredi pill'i layout'ta server-render edilir ve client // harcamasını görmez; bakiye orada da güncellensin. router.refresh(); + // Danışman bu cevapta profil_guncelle çağırmış olabilir + void profiliCek(new Set(profil.map((s) => s.alan))); } catch { // Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir; // tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da @@ -358,6 +417,35 @@ export function SohbetClient({ + {profil.length > 0 ? ( +
+ + Kaydettiklerim + + {profil.map((s) => ( + // Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil. + + {s.metin} + + + ))} +
+ ) : null} + @@ -576,6 +664,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/araclar.ts b/src/lib/ai/araclar.ts new file mode 100644 index 0000000..6720a86 --- /dev/null +++ b/src/lib/ai/araclar.ts @@ -0,0 +1,456 @@ +/** + * 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; + * `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ı). + * + * Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token). + */ +import type OpenAI from "openai"; +import { + dilimAraligi, + EFEKTIF_SIRA, + getDb, + programGetir, + programSatiri, + SELECT_COLS, + type ProgramHamSatiri, +} from "@/lib/db"; +import { + kosulMetinleri, + ogretimUyesiToplami, + ozellikOzeti, + programOzellikGetir, + programOzellikleriGetir, + type ProgramOzellik, +} from "@/lib/program-ozellik"; +import { getAllUniversiteler, getUniversiteBySlug } from "@/lib/katalog"; +import { kadinYuzdesi, ogrenciBasinaUye, YOK_ISTATISTIK_DONEM } from "@/lib/yok-istatistik"; +import { URAP_DONEM, URAP_TOPLAM } from "@/lib/urap-siralama"; +import { turkishSlugify } from "@/lib/slug"; +import { DILIM_ETIKET } from "@/lib/risk"; +import { YILLAR } from "@/lib/veri-yillari"; +import { PUAN_TURLERI, type Program, type PuanTuruKey } from "@/types/yokatlas"; +import { + PROFIL_ALAN_ACIKLAMA, + PROFIL_ALANLARI, + PROFIL_SECENEKLERI, + profilSatirlari, + type DanismanProfili, + type ProfilAlani, +} from "@/lib/danisman-profil"; +import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db"; +import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe"; + +export type AracBaglami = { + userId: string; + sira: number; + tur: PuanTuruKey; + /** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */ + profil: DanismanProfili; +}; + +export type AracTanimi = OpenAI.Chat.Completions.ChatCompletionTool; + +const ARAMA_LIMIT = 8; + +export const ARACLAR: AracTanimi[] = [ + { + type: "function", + function: { + name: "program_ara", + description: + "Adayın puan türündeki programları veriden arar (YÖK Atlas, son yerleştirme). Bölüm adı, üniversite, il, öğretim dili, burs ve yıllık ücret üst sınırına göre süzer; adayın sırasına en yakın programları döner. Sayı uydurmak yerine bunu çağır.", + parameters: { + type: "object", + properties: { + arama: { type: "string", description: "Bölüm adı parçası, ör. 'bilgisayar mühendisliği', 'hukuk'" }, + universite: { type: "string", description: "Üniversite adı parçası" }, + il: { type: "string", description: "İl adı, ör. 'İzmir'" }, + dil: { type: "string", enum: ["Türkçe", "İngilizce"], description: "Öğretim dili" }, + burs: { + type: "string", + enum: ["Burslu", "%50 İndirimli", "%25 İndirimli", "Ücretli"], + description: "Vakıf burs oranı", + }, + ucret_ust: { type: "integer", description: "Yıllık ücret üst sınırı (TL); devlet programları her zaman geçer" }, + dilim: { + type: "string", + enum: ["hayal", "dengeli", "guvenli"], + description: "Yalnız bu dilimdekiler: hayal = adayın sırasının üstünde kapananlar, dengeli = sırasının 1,4 katına kadar, guvenli = daha altı", + }, + }, + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "program_detay", + description: + "Tek programın ayrıntısı: yıllara göre taban sırası ve kontenjan, ücret/burs, dil, öğretim türü, akreditasyon, kadro, tercih barajı ve kılavuzdaki yerleşme koşulları (sağlık şartı, mülakat vb.).", + parameters: { + type: "object", + properties: { + program_id: { type: "string", description: "program_ara sonucundaki id (kılavuz kodu)" }, + }, + required: ["program_id"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "universite_profili", + description: + "Üniversitenin künyesi: il, devlet/vakıf, program sayısı, URAP araştırma sırası, örgün öğrenci sayısı, hoca başına öğrenci ve kadın/erkek öğrenci oranı (üniversite geneli, bölüm bazında veri yok).", + parameters: { + type: "object", + properties: { + universite: { type: "string", description: "Üniversite adı, ör. 'Boğaziçi', 'ODTÜ', 'Ege Üniversitesi'" }, + }, + required: ["universite"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "profil_guncelle", + description: + "Adayın sohbette söylediği bir tercihi profile kaydeder ya da siler (deger null). YALNIZ şu alanlar: " + + PROFIL_ALANLARI.map((a) => `${a} (${PROFIL_ALAN_ACIKLAMA[a]})`).join("; ") + + ". Sağlık, engel, din, aile, gelir, cinsiyet gibi bilgileri ASLA kaydetme. Kaydettikten sonra cevabında tek cümleyle 'Şunu kaydettim: …' de.", + parameters: { + type: "object", + properties: { + alan: { type: "string", enum: PROFIL_ALANLARI }, + deger: { + type: ["string", "null"], + description: + "evIl: il adı; maxKm: sayı (km); ucretUst: sayı (TL, yıllık); " + + `burs: ${Object.keys(PROFIL_SECENEKLERI.burs).join("|")}; ` + + `dil: ${Object.keys(PROFIL_SECENEKLERI.dil).join("|")}; ` + + `ogretimTuru: ${Object.keys(PROFIL_SECENEKLERI.ogretimTuru).join("|")}; ` + + `kampus: ${Object.keys(PROFIL_SECENEKLERI.kampus).join("|")}; null = alanı sil`, + }, + }, + required: ["alan", "deger"], + additionalProperties: false, + }, + }, + }, +]; + +// ── Yardımcılar ────────────────────────────────────────────────────────── + +function dilimAdi(sira: number, efektif: number | null): string { + if (efektif == null) return "Veri yetersiz"; + if (efektif < sira) return DILIM_ETIKET.hayal; + if (efektif <= dilimAraligi(sira, "dengeli").ust!) return DILIM_ETIKET.dengeli; + return DILIM_ETIKET.garanti; +} + +function programOzeti(p: Program, sira: number, o?: ProgramOzellik) { + return { + id: p.id, + program: p.isim, + universite: p.universite, + il: p.il, + tur: p.unitur, + tabanSira: p.efektifSira, + dilim: dilimAdi(sira, p.efektifSira), + kontenjan: p.kontenjanSon, + ozellik: o ? ozellikOzeti(o) : [], + }; +} + +/** SQLite LIKE Türkçe büyük/küçük harfi bilmez: hem yazıldığı gibi hem başlık düzeni denenir. */ +function likeVaryantlari(q: string): string[] { + const t = q.trim(); + const baslik = t + .toLocaleLowerCase("tr-TR") + .split(" ") + .map((w) => (w ? w[0].toLocaleUpperCase("tr-TR") + w.slice(1) : w)) + .join(" "); + return Array.from(new Set([t, baslik, t.toLocaleUpperCase("tr-TR")])); +} + +function likeKosulu(kolon: string, q: string): { sql: string; args: string[] } { + const v = likeVaryantlari(q); + return { + sql: `(${v.map(() => `${kolon} LIKE ?`).join(" OR ")})`, + args: v.map((x) => `%${x}%`), + }; +} + +// ── Araç gövdeleri ─────────────────────────────────────────────────────── + +type ProgramAraArg = { + arama?: string; + universite?: string; + il?: string; + dil?: string; + burs?: string; + ucret_ust?: number; + dilim?: "hayal" | "dengeli" | "guvenli"; +}; + +function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): ProgramAraSonuc { + const db = getDb(); + const profil: DanismanProfili = profilUygula ? b.profil : {}; + const kosullar: string[] = ["tur = ?", "onlisans = ?", `${EFEKTIF_SIRA} IS NOT NULL`]; + const args: (string | number)[] = [PUAN_TURLERI[b.tur], b.tur === "tyt" ? 1 : 0]; + const uygulanan: string[] = []; + + if (a.arama) { + const k = likeKosulu("isim", a.arama); + kosullar.push(k.sql); + args.push(...k.args); + uygulanan.push(`bölüm ~ "${a.arama}"`); + } + if (a.universite) { + const k = likeKosulu("universite", a.universite); + kosullar.push(k.sql); + args.push(...k.args); + uygulanan.push(`üniversite ~ "${a.universite}"`); + } + // İl: parametre > profildeki km sınırı + if (a.il) { + const il = ilAdiNormalize(a.il); + if (!il) { + return { toplamEslesen: 0, gosterilen: 0, uygulananFiltreler: [], sonuclar: [], not: `İl tanınmadı: ${a.il}` }; + } + kosullar.push("il = ?"); + args.push(il); + uygulanan.push(`il = ${il}`); + } else if (profil.evIl && profil.maxKm) { + const iller = yakinIller(profil.evIl, profil.maxKm); + if (iller.length) { + kosullar.push(`il IN (${iller.map(() => "?").join(",")})`); + args.push(...iller); + uygulanan.push(`profil: ${profil.evIl} iline en fazla ${profil.maxKm} km (${iller.length} il)`); + } + } + // Özellik süzgeçleri alt sorguda (program_ozellik.id çakışmasın) + const ozKosul: string[] = []; + const ozArgs: (string | number)[] = []; + const dil = a.dil ?? (profil.dil === "ingilizce" ? "İngilizce" : profil.dil === "turkce" ? "Türkçe" : undefined); + if (dil) { + ozKosul.push("ogrenim_dili LIKE ?"); + ozArgs.push(`${dil}%`); + uygulanan.push(`dil = ${dil}${a.dil ? "" : " (profil)"}`); + } + if (a.burs) { + ozKosul.push("burs_orani = ?"); + ozArgs.push(a.burs); + uygulanan.push(`burs = ${a.burs}`); + } else if (profil.burs === "yalniz-burslu") { + ozKosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')"); + uygulanan.push("profil: vakıfta yalnız burslu"); + } + const ucretUst = a.ucret_ust ?? profil.ucretUst; + if (ucretUst) { + ozKosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)"); + ozArgs.push(ucretUst); + uygulanan.push(`yıllık ücret ≤ ${ucretUst.toLocaleString("tr-TR")} TL${a.ucret_ust ? "" : " (profil)"}`); + } + if (profil.ogretimTuru === "yalniz-orgun") { + ozKosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')"); + uygulanan.push("profil: yalnız örgün"); + } + if (ozKosul.length) { + kosullar.push(`id IN (SELECT id FROM program_ozellik WHERE ${ozKosul.join(" AND ")})`); + args.push(...ozArgs); + } + if (a.dilim) { + const anahtar = a.dilim === "guvenli" ? "garanti" : a.dilim; + const { alt, ust } = dilimAraligi(b.sira, anahtar); + kosullar.push(ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`); + args.push(alt); + if (ust != null) args.push(ust); + uygulanan.push(`dilim = ${DILIM_ETIKET[anahtar]}`); + } + + const satirlar = db + .prepare( + `SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")} + ORDER BY ABS(${EFEKTIF_SIRA} - ?) ASC LIMIT ?`, + ) + .all(...args, b.sira, ARAMA_LIMIT) as ProgramHamSatiri[]; + const programlar = satirlar.map(programSatiri); + const toplam = ( + db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${kosullar.join(" AND ")}`).get(...args) as { + n: number; + } + ).n; + // Profil süzgeçleri sonucu sıfırladıysa profilsiz tekrar dene ve bunu söyle: + // model "hiç yok" demek yerine "profilin dışında şunlar var" diyebilsin. + if (toplam === 0 && profilUygula && uygulanan.some((u) => u.includes("profil"))) { + const gevsek = programAra(b, a, false); + return { + ...gevsek, + not: `Profil süzgeçleriyle (${uygulanan.filter((u) => u.includes("profil")).join("; ")}) eşleşen program YOK; aşağıdakiler profil süzgeci olmadan bulundu — adaya bunu açıkça söyle. ${gevsek.not}`, + }; + } + const ozellikler = programOzellikleriGetir(programlar.map((p) => p.id)); + return { + toplamEslesen: toplam, + gosterilen: programlar.length, + uygulananFiltreler: uygulanan, + sonuclar: programlar.map((p) => programOzeti(p, b.sira, ozellikler[p.id])), + not: "tabanSira son yerleştirme yılının taban başarı sırası; her yıl değişir.", + }; +} + +type ProgramAraSonuc = { + toplamEslesen: number; + gosterilen: number; + uygulananFiltreler: string[]; + sonuclar: ReturnType[]; + not: string; +}; + +function programDetay(b: AracBaglami, a: { program_id?: string }) { + const id = String(a.program_id ?? "").trim(); + const p = id ? programGetir(id) : null; + if (!p) return { hata: "Program bulunamadı; önce program_ara ile id al." }; + const o = programOzellikGetir(id); + const kosullar = o ? kosulMetinleri(o.kosulKodlari).slice(0, 8) : []; + const evKm = b.profil.evIl && p.il ? illerArasiKm(b.profil.evIl, p.il) : null; + return { + ...programOzeti(p, b.sira, o ?? undefined), + fakulte: p.fakulte, + sure: p.sure, + tabanSiraYillara: Object.fromEntries(YILLAR.map((y, i) => [y, p.siraGecmisi[i]])), + puanSon: p.puanSon, + yerlesenSon: p.yerlesenSon, + ucretYillik: o?.ucret ?? null, + bursOrani: o?.bursOrani ?? null, + ogretimDili: o?.ogrenimDili ?? null, + ogretimTuru: o?.ogrenimTuru ?? null, + akreditasyon: o?.akreditasyon ?? null, + kadro: o + ? { prof: o.prof, docent: o.doc, drOgrUyesi: o.dou, arsGor: o.argor, ogretimUyesiToplam: ogretimUyesiToplami(o) } + : null, + tercihBaraji: o?.minBasariSirasi ?? null, + ilce: o?.ilce ?? null, + evdenKm: evKm, + kosullar: kosullar.map((k) => (k.metin.length > 240 ? `${k.metin.slice(0, 237)}…` : k.metin)), + }; +} + +function universiteProfili(_b: AracBaglami, a: { universite?: string }) { + const q = String(a.universite ?? "").trim(); + if (!q) return { hata: "Üniversite adı gerekli." }; + const slugQ = turkishSlugify(q); + const hepsi = getAllUniversiteler(); + const kisaltma: Record = { + odtu: "orta-dogu-teknik", + itu: "istanbul-teknik", + ytu: "yildiz-teknik", + ktu: "karadeniz-teknik", + dtu: "deu", + }; + const arananSlug = kisaltma[slugQ] ?? slugQ; + const aday = + hepsi.find((u) => u.slug === arananSlug || u.slug === `${arananSlug}-universitesi`) ?? + hepsi.find((u) => u.slug.startsWith(arananSlug)) ?? + hepsi.find((u) => u.slug.includes(arananSlug)); + if (!aday) return { hata: `Üniversite bulunamadı: ${q}` }; + const u = getUniversiteBySlug(aday.slug); + if (!u) return { hata: `Üniversite bulunamadı: ${q}` }; + const i = u.istatistik; + const kadin = i ? kadinYuzdesi(i) : null; + return { + ad: u.ad, + il: u.il, + tur: u.unitur, + programSayisi: u.programSayisi, + enDusukTabanSira: u.stats.minSira, + toplamKontenjan: u.stats.toplamKontenjan, + urap: u.urap ? { sira: u.urap.sira, toplam: URAP_TOPLAM, donem: URAP_DONEM, olctugu: "araştırma performansı; eğitim kalitesi ya da iş bulma değil" } : null, + istatistik: i + ? { + donem: YOK_ISTATISTIK_DONEM, + orgunOgrenci: i.ogrenci, + ogretimUyesi: i.ogretimUyesi, + hocaBasinaOgrenci: ogrenciBasinaUye(i), + kadinYuzdesi: kadin, + erkekYuzdesi: kadin == null ? null : 100 - kadin, + not: "Kadın/erkek oranı örgün ön lisans + lisans, ÜNİVERSİTE GENELİ; bölüm bazında veri yok. Bilgi olarak söyle, filtre yapma.", + } + : null, + sayfa: `/universite/${u.slug}`, + }; +} + +function sayiParse(v: unknown): number | null { + if (typeof v === "number") return v; + if (typeof v !== "string") return null; + const t = v.toLocaleLowerCase("tr-TR").replace(/\s/g, ""); + const m = t.match(/^([\d.,]+)(bin|k|milyon|m)?/); + if (!m) return null; + let n = Number.parseFloat(m[1].replace(/\./g, "").replace(",", ".")); + if (!Number.isFinite(n)) return null; + if (m[2] === "bin" || m[2] === "k") n *= 1000; + if (m[2] === "milyon" || m[2] === "m") n *= 1_000_000; + return Math.round(n); +} + +async function profilGuncelle(b: AracBaglami, a: { alan?: string; deger?: unknown }) { + const alan = a.alan as ProfilAlani; + if (!PROFIL_ALANLARI.includes(alan)) { + return { hata: `Bilinmeyen alan: ${String(a.alan)}. Yalnız ${PROFIL_ALANLARI.join(", ")} kaydedilebilir.` }; + } + let deger: unknown = a.deger; + if (deger !== null && deger !== undefined) { + if (alan === "maxKm" || alan === "ucretUst") { + deger = sayiParse(deger); + if (deger == null) return { hata: "Sayı okunamadı." }; + } else if (alan === "evIl") { + const il = ilAdiNormalize(String(deger)); + if (!il) return { hata: `İl tanınmadı: ${String(deger)}` }; + deger = il; + } else { + deger = String(deger).trim(); + } + } + try { + const profil = await profilAlanYaz(b.userId, alan, deger ?? null); + b.profil = profil; + const satirlar = profilSatirlari(profil); + return { + kaydedildi: deger == null ? `${alan} silindi` : satirlar.find((s) => s.alan === alan)?.metin, + profil: satirlar.map((s) => s.metin), + }; + } catch { + return { hata: `Değer kabul edilmedi (${alan}); aralık dışı ya da geçersiz seçenek.` }; + } +} + +/** Route'un araç döngüsüne verdiği çalıştırıcı. Bilinmeyen araç hata döner. */ +export function aracCalistirici(b: AracBaglami) { + return async (ad: string, arg: unknown): Promise => { + const a = (arg && typeof arg === "object" ? arg : {}) as Record; + switch (ad) { + case "program_ara": + return programAra(b, a as ProgramAraArg); + case "program_detay": + return programDetay(b, a); + case "universite_profili": + return universiteProfili(b, a); + case "profil_guncelle": + return profilGuncelle(b, a); + default: + return { hata: `Bilinmeyen araç: ${ad}` }; + } + }; +} + +export { profilGetir }; diff --git a/src/lib/ai/cagri.ts b/src/lib/ai/cagri.ts index 8c6c866..a2367ad 100644 --- a/src/lib/ai/cagri.ts +++ b/src/lib/ai/cagri.ts @@ -1,3 +1,4 @@ +import type OpenAI from "openai"; import { z } from "zod"; import { getOpenRouter, raporModeli, sohbetModeli } from "./client"; @@ -166,3 +167,109 @@ export async function* sohbetAkisi(opts: { // sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat. if (sinyal.aborted) throw sinyal.reason; } + +// ── Araçlı sohbet ───────────────────────────────────────────────────────── + +type AracCagrisiBirikim = { id: string; name: string; arguments: string }; + +const ARAC_TUR_SINIRI = 4; + +/** + * Araç çağrılı sohbet akışı. Model araç isterse (stream'de tool_calls + * parçaları birikir) araçlar çalıştırılır, sonuçlar `tool` mesajı olarak + * eklenir ve akış yeniden başlar; en fazla ARAC_TUR_SINIRI tur, son turda + * araç kapalı (cevap vermek zorunda). Metin parçaları geldikçe yield edilir. + * + * Sağlayıcı araçları desteklemiyorsa (ilk çağrıda 400/404) düz sohbete düşer. + */ +export async function* sohbetAkisiAraclarla(opts: { + system: string; + mesajlar: SohbetMesaji[]; + maxTokens: number; + araclar: OpenAI.Chat.Completions.ChatCompletionTool[]; + calistir: (ad: string, argumanlar: unknown) => Promise; + /** Her araç çağrısında (günlük/analitik için) */ + onArac?: (ad: string) => void; +}): AsyncGenerator { + const sinyal = AbortSignal.timeout(120_000); + const gecmis: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { role: "system", content: opts.system }, + ...opts.mesajlar, + ]; + + for (let tur = 0; tur < ARAC_TUR_SINIRI; tur++) { + const sonTur = tur === ARAC_TUR_SINIRI - 1; + let akis; + try { + akis = await geciciHatadaTekrarla(() => + getOpenRouter().chat.completions.create( + { + model: sohbetModeli(), + max_tokens: opts.maxTokens, + reasoning_effort: "none", + stream: true, + messages: gecmis, + tools: opts.araclar, + tool_choice: sonTur ? "none" : "auto", + }, + { signal: sinyal }, + ), + ); + } catch (hata) { + const kod = (hata as { status?: number })?.status; + if (tur === 0 && (kod === 400 || kod === 404)) { + console.warn("[cagri] sağlayıcı araç çağrısını kabul etmedi, düz sohbete düşüldü:", kod); + yield* sohbetAkisi({ system: opts.system, mesajlar: opts.mesajlar, maxTokens: opts.maxTokens }); + return; + } + throw hata; + } + + let metin = ""; + const cagrilar = new Map(); + for await (const parca of akis) { + const delta = parca.choices?.[0]?.delta; + if (!delta) continue; + if (delta.content) { + metin += delta.content; + yield delta.content; + } + for (const tc of delta.tool_calls ?? []) { + const mevcut = cagrilar.get(tc.index) ?? { id: "", name: "", arguments: "" }; + if (tc.id) mevcut.id = tc.id; + if (tc.function?.name) mevcut.name += tc.function.name; + if (tc.function?.arguments) mevcut.arguments += tc.function.arguments; + cagrilar.set(tc.index, mevcut); + } + } + if (sinyal.aborted) throw sinyal.reason; + if (cagrilar.size === 0) return; + + const liste = [...cagrilar.values()].filter((c) => c.name); + const toolCalls = liste.map((c) => ({ + id: c.id || `call_${Math.random().toString(36).slice(2, 10)}`, + type: "function" as const, + function: { name: c.name, arguments: c.arguments || "{}" }, + })); + gecmis.push({ role: "assistant", content: metin || null, tool_calls: toolCalls }); + for (const [i, c] of liste.entries()) { + opts.onArac?.(c.name); + let arg: unknown = {}; + try { + arg = c.arguments ? JSON.parse(c.arguments) : {}; + } catch { + arg = {}; + } + let sonuc: unknown; + try { + sonuc = await opts.calistir(c.name, arg); + } catch (hata) { + console.error("[cagri] araç hatası:", c.name, hata); + sonuc = { hata: "Araç çalışmadı; veriye ulaşılamadı, uydurma." }; + } + gecmis.push({ role: "tool", tool_call_id: toolCalls[i].id, content: JSON.stringify(sonuc) }); + } + // Araç turundan sonra araya boşluk: metin + tool ile bitmiş cümle yapışmasın + if (metin && !metin.endsWith("\n")) yield "\n\n"; + } +} diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index 953ff9d..bb2b835 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -1,9 +1,29 @@ import OpenAI from "openai"; -// Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı, -// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı). -// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir. +// Tüm AI akışı OpenAI uyumlu TEK kapıdan gider. Varsayılan OpenRouter +// (7 Ağu 2026 kararı); 26 Eyl 2026 kararıyla model Türkiye'de barındırılacak: +// sağlayıcı değişince kod değil .env değişir — AI_BASE_URL + AI_API_KEY +// (OpenRouter için eski OPENROUTER_* adları da çalışır). Sağlayıcı değişirse +// /gizlilik "Hizmet sağlayıcılar" bölümü aynı commit'te güncellenir. const VARSAYILAN_MODEL = "deepseek/deepseek-v4-flash"; +const VARSAYILAN_BASE_URL = "https://openrouter.ai/api/v1"; + +function baseUrl(): string { + return process.env.AI_BASE_URL?.trim() || VARSAYILAN_BASE_URL; +} + +function apiKey(): string | undefined { + return process.env.AI_API_KEY?.trim() || process.env.OPENROUTER_API_KEY?.trim(); +} + +/** OpenRouter dışı bir sağlayıcı tanımlı mı (gizlilik metni ve log için). */ +export function saglayiciAdi(): string { + try { + return new URL(baseUrl()).hostname; + } catch { + return "bilinmiyor"; + } +} export function raporModeli(): string { return ( @@ -23,20 +43,20 @@ export function sohbetModeli(): string { /** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */ export function anahtarVar(): boolean { - return Boolean(process.env.OPENROUTER_API_KEY); + return Boolean(apiKey()); } const globalForAi = globalThis as unknown as { __openrouter?: OpenAI }; export function getOpenRouter(): OpenAI { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + const key = apiKey(); + if (!key) { throw new Error("AI_KEY_MISSING"); } if (!globalForAi.__openrouter) { globalForAi.__openrouter = new OpenAI({ - baseURL: "https://openrouter.ai/api/v1", - apiKey, + baseURL: baseUrl(), + apiKey: key, // Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla maxRetries: 0, // OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel) 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/analitik.ts b/src/lib/analitik.ts index 147da7c..f1ff2e9 100644 --- a/src/lib/analitik.ts +++ b/src/lib/analitik.ts @@ -50,7 +50,12 @@ export type OlayAdi = // kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı // tıklama, Vazgeç); tamamlanan kapıda atılmaz. | "kapi_acildi" - | "kapi_kapatildi"; + | "kapi_kapatildi" + // Danışman profili: yalnız ALAN ADI gider, değer gitmez (KVKK: analitiğe + // profil değeri sızmaz). kaydedildi = model aracı çağırıp alanı yazdı + // (istemci cevap sonrası profili çekip farkı görür); silindi = çipteki çarpı. + | "profil_alani_kaydedildi" + | "profil_alani_silindi"; export type Ozellikler = Record; diff --git a/src/lib/appdb/schema.ts b/src/lib/appdb/schema.ts index 19042bd..9dc160f 100644 --- a/src/lib/appdb/schema.ts +++ b/src/lib/appdb/schema.ts @@ -231,3 +231,15 @@ export const savedLists = sqliteTable("saved_lists", { createdAt: integer("created_at", { mode: "timestamp" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), }); + +// Danışman profili: sohbetten `profil_guncelle` aracıyla çıkarılan, KAPALI +// LİSTELİ tercih alanları (bkz. src/lib/danisman-profil.ts). Serbest metin +// yok; öğrenci /listem'de görür ve alan alan siler. Kullanıcı başına tek satır. +export const danismanProfil = sqliteTable("danisman_profil", { + userId: text("user_id") + .primaryKey() + .references(() => user.id), + // DanismanProfili (yalnız tanımlı anahtarlar; okurken yeniden doğrulanır) + alanlar: text("alanlar", { mode: "json" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), +}); diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 2683b5c..465d64b 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -137,6 +137,33 @@ 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. + */ +export async function bedelsizMesajKaydet(opts: { + userId: string; + content: string; + clientMessageId: string; +}): Promise { + const messageId = newId(); + try { + await appDb.insert(chatMessages).values({ + id: messageId, + userId: opts.userId, + role: "user", + content: opts.content, + clientMessageId: opts.clientMessageId, + createdAt: new Date(), + }); + return { ok: true, messageId }; + } catch (err) { + if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" }; + throw err; + } +} + /** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */ class ZatenPaketli extends Error {} diff --git a/src/lib/danisman-profil-db.ts b/src/lib/danisman-profil-db.ts new file mode 100644 index 0000000..e92703f --- /dev/null +++ b/src/lib/danisman-profil-db.ts @@ -0,0 +1,56 @@ +import "server-only"; +import { eq } from "drizzle-orm"; +import { appDb, schema } from "@/lib/appdb"; +import { + PROFIL_SEMASI, + profilTemizle, + type DanismanProfili, + type ProfilAlani, +} from "@/lib/danisman-profil"; + +/** Kullanıcının danışman profili; satır yoksa boş nesne. Bozuk alanlar düşer. */ +export async function profilGetir(userId: string): Promise { + const satir = await appDb.query.danismanProfil.findFirst({ + where: eq(schema.danismanProfil.userId, userId), + columns: { alanlar: true }, + }); + return profilTemizle(satir?.alanlar); +} + +/** + * Tek alanı yazar (deger null → alan silinir). Değer şemadan geçmezse hata + * fırlatır; çağıran taraf (araç) bunu modele "kaydedilmedi" diye döner. + */ +export async function profilAlanYaz( + userId: string, + alan: ProfilAlani, + deger: unknown, +): Promise { + const mevcut = await profilGetir(userId); + const yeni: Record = { ...mevcut }; + if (deger === null || deger === undefined) { + delete yeni[alan]; + } else { + const dogrulama = PROFIL_SEMASI.shape[alan].safeParse(deger); + if (!dogrulama.success) { + throw new Error(`Geçersiz değer: ${alan}`); + } + yeni[alan] = dogrulama.data; + } + const alanlar = PROFIL_SEMASI.parse(yeni); + const now = new Date(); + await appDb + .insert(schema.danismanProfil) + .values({ userId, alanlar, updatedAt: now }) + .onConflictDoUpdate({ + target: schema.danismanProfil.userId, + set: { alanlar, updatedAt: now }, + }); + return alanlar; +} + +export async function profilSil(userId: string): Promise { + await appDb + .delete(schema.danismanProfil) + .where(eq(schema.danismanProfil.userId, userId)); +} diff --git a/src/lib/danisman-profil.ts b/src/lib/danisman-profil.ts new file mode 100644 index 0000000..3bd5b46 --- /dev/null +++ b/src/lib/danisman-profil.ts @@ -0,0 +1,119 @@ +/** + * Danışman profili — sohbetten çıkarılan öğrenci tercihleri. KAPALI LİSTE: + * yalnız burada tanımlı alanlar yazılabilir, serbest metin alanı yoktur. + * Neden: model sohbetten din, sağlık, aile gibi özel nitelikli veriyi profile + * taşıyamasın (KVKK md. 6); öğrenci her alanı görür ve tek tıkla siler + * (docs/arastirma/2026-09-25-kisisellestirme/README.md §8). + * + * 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"; + +export const PROFIL_SECENEKLERI = { + burs: { + "yalniz-burslu": "yalnız burslu", + yuzde50: "%50 burslu da olur", + "ucretli-olur": "ücretli de olur", + }, + dil: { + turkce: "Türkçe", + ingilizce: "İngilizce", + farketmez: "fark etmez", + }, + ogretimTuru: { + "yalniz-orgun": "yalnız örgün", + "uzaktan-olur": "uzaktan/açık da olur", + }, + kampus: { + sakin: "sakin, küçük", + kalabalik: "kalabalık, büyük", + farketmez: "fark etmez", + }, +} as const; + +export const PROFIL_SEMASI = z + .object({ + /** Yaşadığı il — DB'deki il adı biçimi (büyük harf, Türkçe). */ + evIl: z.string().min(2).max(40).optional(), + /** Evden en fazla kaç km (il merkezleri arası kuş uçuşu). */ + maxKm: z.number().int().min(50).max(1500).optional(), + /** Vakıfta yıllık ücret üst sınırı (TL). */ + ucretUst: z.number().int().min(50_000).max(5_000_000).optional(), + burs: z.enum(["yalniz-burslu", "yuzde50", "ucretli-olur"]).optional(), + dil: z.enum(["turkce", "ingilizce", "farketmez"]).optional(), + ogretimTuru: z.enum(["yalniz-orgun", "uzaktan-olur"]).optional(), + kampus: z.enum(["sakin", "kalabalik", "farketmez"]).optional(), + }) + .strict(); + +export type DanismanProfili = z.infer; +export type ProfilAlani = keyof DanismanProfili; + +export const PROFIL_ALANLARI = Object.keys(PROFIL_SEMASI.shape) as ProfilAlani[]; + +/** Alanın öğrenciye görünen kısa açıklaması (çip ve araç şeması aynı metni kullanır). */ +export const PROFIL_ALAN_ACIKLAMA: Record = { + evIl: "Yaşadığı il (evden uzaklık için)", + maxKm: "Evden en fazla kaç km uzakta okumak istediği", + ucretUst: "Vakıfta yıllık ücret üst sınırı (TL)", + burs: "Vakıfta burs beklentisi", + dil: "Öğretim dili tercihi", + ogretimTuru: "Uzaktan/açık öğretim kabul mü", + kampus: "Kampüs büyüklüğü tercihi", +}; + +/** Bilinmeyen anahtarları atarak profili güvenli okur; bozuk değer alanı düşürür. */ +export function profilTemizle(ham: unknown): DanismanProfili { + if (!ham || typeof ham !== "object") return {}; + const sonuc: Record = {}; + for (const alan of PROFIL_ALANLARI) { + const deger = (ham as Record)[alan]; + if (deger === undefined || deger === null) continue; + const tek = PROFIL_SEMASI.shape[alan].safeParse(deger); + if (tek.success) sonuc[alan] = tek.data; + } + return sonuc as DanismanProfili; +} + +/** Tek alanı öğrencinin okuyacağı cümleye çevirir: "Yıllık ücret en fazla 300.000 TL". */ +export function profilAlaniMetni(alan: ProfilAlani, deger: unknown): string { + switch (alan) { + case "evIl": + return `Yaşadığı il: ${ilBaslik(String(deger))}`; + case "maxKm": + return `Evden en fazla ${Number(deger).toLocaleString("tr-TR")} km`; + case "ucretUst": + return `Yıllık ücret en fazla ${Number(deger).toLocaleString("tr-TR")} TL`; + case "burs": + return `Vakıfta ${PROFIL_SECENEKLERI.burs[deger as keyof typeof PROFIL_SECENEKLERI.burs]}`; + case "dil": + return `Öğretim dili: ${PROFIL_SECENEKLERI.dil[deger as keyof typeof PROFIL_SECENEKLERI.dil]}`; + case "ogretimTuru": + return deger === "yalniz-orgun" ? "Yalnız örgün öğretim" : "Uzaktan/açık öğretim de olur"; + case "kampus": + return `Kampüs: ${PROFIL_SECENEKLERI.kampus[deger as keyof typeof PROFIL_SECENEKLERI.kampus]}`; + } +} + +/** Profilin tamamı, dolu alanlar sırayla. */ +export function profilSatirlari(p: DanismanProfili): { alan: ProfilAlani; metin: string }[] { + return PROFIL_ALANLARI.flatMap((alan) => + p[alan] === undefined ? [] : [{ alan, metin: profilAlaniMetni(alan, p[alan]) }] + ); +} + +function ilBaslik(il: string): string { + return il + .toLocaleLowerCase("tr-TR") + .split(" ") + .map((k) => k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1)) + .join(" "); +} 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/mesafe.ts b/src/lib/mesafe.ts new file mode 100644 index 0000000..faf9620 --- /dev/null +++ b/src/lib/mesafe.ts @@ -0,0 +1,42 @@ +// İl merkezleri arası kuş uçuşu mesafe (km). Profildeki "evden en fazla N km" +// filtresi il düzeyinde çalışır: adres/koordinat tutulmaz (KVKK, §8). +import { IL_MERKEZ, type LatLng } from "./uni-koordinatlar"; + +const DUNYA_YARICAP_KM = 6371; + +export function kmMesafe(a: LatLng, b: LatLng): number { + const rad = (d: number) => (d * Math.PI) / 180; + const dLat = rad(b[0] - a[0]); + const dLng = rad(b[1] - a[1]); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(rad(a[0])) * Math.cos(rad(b[0])) * Math.sin(dLng / 2) ** 2; + return Math.round(2 * DUNYA_YARICAP_KM * Math.asin(Math.sqrt(h))); +} + +/** DB il adı biçimine (büyük harf, Türkçe) çevirir; bilinmiyorsa null. */ +export function ilAdiNormalize(ham: string): string | null { + const aday = ham.trim().toLocaleUpperCase("tr-TR"); + if (aday in IL_MERKEZ) return aday; + // "Afyon" → AFYONKARAHİSAR gibi öneki eşleşen tek il varsa onu al + const adaylar = Object.keys(IL_MERKEZ).filter((il) => il.startsWith(aday)); + return adaylar.length === 1 ? adaylar[0] : null; +} + +/** Ev iline en fazla `km` uzaktaki iller (evin kendisi dahil), yakından uzağa. */ +export function yakinIller(evIl: string, km: number): string[] { + const ev = IL_MERKEZ[evIl]; + if (!ev) return []; + return Object.entries(IL_MERKEZ) + .map(([il, k]) => ({ il, km: kmMesafe(ev, k) })) + .filter((x) => x.km <= km) + .sort((a, b) => a.km - b.km) + .map((x) => x.il); +} + +/** İki il arası km; biri bilinmiyorsa null. */ +export function illerArasiKm(a: string, b: string): number | null { + const ka = IL_MERKEZ[a]; + const kb = IL_MERKEZ[b]; + return ka && kb ? kmMesafe(ka, kb) : null; +} diff --git a/src/lib/program-ozellik.ts b/src/lib/program-ozellik.ts new file mode 100644 index 0000000..05ee622 --- /dev/null +++ b/src/lib/program-ozellik.ts @@ -0,0 +1,137 @@ +// Program özellikleri: YÖK Atlas API'sinden scripts/program-ozellik.ts ile +// `program_ozellik` ve `kosul` tablolarına yazılan, yıl anlamı olmayan alanlar +// (ücret, burs, dil, öğretim türü, akreditasyon, kadro, baraj, ilçe, koşullar). +// Sıra/puan/kontenjan gibi yıllık alanlar burada DEĞİL (bkz. db.ts). +import { getDb } from "./db"; + +export type ProgramOzellik = { + id: string; + /** Vakıf/KKTC yıllık öğrenim ücreti (TL, KDV dahil, kılavuz yılı); devlette null */ + ucret: number | null; + /** "Burslu" | "%50 İndirimli" | "%25 İndirimli" | "Ücretli"; devlette null */ + bursOrani: string | null; + /** "Türkçe" | "İngilizce" | "İngilizce (%30)" | "Almanca" … */ + ogrenimDili: string | null; + /** "Örgün Öğretim" | "Uzaktan Öğretim" | "Açık Öğretim" | "UOLP" */ + ogrenimTuru: string | null; + /** Program akreditasyonu (MÜDEK, TEPDAD, FEDEK …) */ + akreditasyon: string | null; + /** Üniversite akreditasyonu (YÖKAK …) */ + uniAkreditasyon: string | null; + prof: number | null; + doc: number | null; + dou: number | null; + argor: number | null; + /** Tercih edebilmek için gereken en düşük başarı sırası (baraj) */ + minBasariSirasi: number | null; + /** Kampüs ilçesi (büyük harf, API'den) */ + ilce: string | null; + /** Yerleşme koşulu kodları (kosul tablosundan metne çevrilir) */ + kosulKodlari: string[]; + /** Kılavuz yılı */ + yil: number; +}; + +type HamSatir = { + id: string; + ucret: number | null; + burs_orani: string | null; + ogrenim_dili: string | null; + ogrenim_turu: string | null; + akreditasyon: string | null; + uni_akreditasyon: string | null; + prof: number | null; + doc: number | null; + dou: number | null; + argor: number | null; + min_basari_sirasi: number | null; + ilce: string | null; + kosul_kodlari: string | null; + yil: number; +}; + +const OZELLIK_COLS = `id, ucret, burs_orani, ogrenim_dili, ogrenim_turu, akreditasyon, + uni_akreditasyon, prof, doc, dou, argor, min_basari_sirasi, ilce, kosul_kodlari, yil`; + +function satir(r: HamSatir): ProgramOzellik { + return { + id: r.id, + ucret: r.ucret, + bursOrani: r.burs_orani, + ogrenimDili: r.ogrenim_dili, + ogrenimTuru: r.ogrenim_turu, + akreditasyon: r.akreditasyon, + uniAkreditasyon: r.uni_akreditasyon, + prof: r.prof, + doc: r.doc, + dou: r.dou, + argor: r.argor, + minBasariSirasi: r.min_basari_sirasi, + ilce: r.ilce, + kosulKodlari: r.kosul_kodlari ? r.kosul_kodlari.split(",") : [], + yil: r.yil, + }; +} + +/** Tablo yoksa (eski DB) her okuma null/boş döner; sayfa kırılmaz. */ +function tabloVar(): boolean { + return ( + getDb() + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'") + .get() !== undefined + ); +} + +export function programOzellikGetir(id: string): ProgramOzellik | null { + if (!tabloVar()) return null; + const r = getDb() + .prepare(`SELECT ${OZELLIK_COLS} FROM program_ozellik WHERE id = ?`) + .get(id) as HamSatir | undefined; + return r ? satir(r) : null; +} + +export function programOzellikleriGetir( + ids: readonly string[] +): Record { + const sonuc: Record = {}; + if (ids.length === 0 || !tabloVar()) return sonuc; + const satirlar = getDb() + .prepare( + `SELECT ${OZELLIK_COLS} FROM program_ozellik WHERE id IN (${ids.map(() => "?").join(",")})` + ) + .all(...ids) as HamSatir[]; + for (const r of satirlar) sonuc[r.id] = satir(r); + return sonuc; +} + +/** Koşul kodlarının kılavuz metinleri, kod sırasıyla. Bilinmeyen kod atlanır. */ +export function kosulMetinleri(kodlar: readonly string[]): { kod: string; metin: string }[] { + if (kodlar.length === 0 || !tabloVar()) return []; + const satirlar = getDb() + .prepare(`SELECT kod, metin FROM kosul WHERE kod IN (${kodlar.map(() => "?").join(",")})`) + .all(...kodlar) as { kod: string; metin: string }[]; + const harita = new Map(satirlar.map((s) => [s.kod, s.metin])); + return kodlar.flatMap((k) => (harita.has(k) ? [{ kod: k, metin: harita.get(k)! }] : [])); +} + +/** Kadro toplamı (prof + doç + dr. öğr. üyesi); hiçbiri yoksa null. */ +export function ogretimUyesiToplami(o: ProgramOzellik): number | null { + const parcalar = [o.prof, o.doc, o.dou].filter((x): x is number => x != null); + return parcalar.length ? parcalar.reduce((a, b) => a + b, 0) : null; +} + +/** Öğrenciye okunan kısa özet: "İngilizce · Burslu · MÜDEK akreditasyonlu". */ +export function ozellikOzeti(o: ProgramOzellik): string[] { + const parcalar: string[] = []; + if (o.ogrenimDili && o.ogrenimDili !== "Türkçe") parcalar.push(o.ogrenimDili); + if (o.ogrenimTuru && o.ogrenimTuru !== "Örgün Öğretim") parcalar.push(o.ogrenimTuru); + if (o.bursOrani) parcalar.push(o.bursOrani); + if (o.ucret != null && o.bursOrani !== "Burslu") { + parcalar.push(`yıllık ${o.ucret.toLocaleString("tr-TR")} TL`); + } + if (o.akreditasyon) parcalar.push(`${o.akreditasyon} akreditasyonlu`); + if (o.minBasariSirasi != null) { + parcalar.push(`baraj ${o.minBasariSirasi.toLocaleString("tr-TR")}. sıra`); + } + return parcalar; +} 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, {