Compare commits

...

7 Commits

Author SHA1 Message Date
bilalgursen
24f53ddbce chore: update memory limits in Dockerfile and next.config.ts
All checks were successful
Deploy / deploy (push) Successful in 7m21s
- Reduced NODE_OPTIONS max-old-space-size from 1536 to 1024 MB in Dockerfile.
- Adjusted turbopackMemoryLimit in next.config.ts from 1.5 GB to 1 GB to prevent OOM-kills during build on VPS with limited memory.
- Added a search input for filtering Turkish provinces in the SihirbazAdimlar component, improving user experience with real-time search functionality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:33:13 +03:00
bilalgursen
e0ff9dec0a refactor: /sonuc tablosundaki "Filtreler:" çip alanını kaldır
Filtreleme davranışı (URL'deki il+tip paramlarının tabloya ve
sayfalamaya uygulanması) aynen korunur; yalnızca tablo üstündeki
kaldırılabilir çip satırı gösterilmez.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:26:10 +03:00
bilalgursen
14f9b622b0 feat: tadımlık havuzuna üniversite tipi boyutu
Tadımlık satırı batch üretildiği için sihirbazdaki devlet/vakıf seçimini
bilmiyor, vakıf seçen kullanıcıya devlet örneği gösteriyordu. Havuz
anahtarı kova×tür×kategori×tip'e genişledi (migration 0001; mevcut
satırlar tip='genel'). Üretim scripti tip varyantlarını uniturGrubu
filtresiyle seçer; tadimlikSec tip verildiğinde kategori→genel fallback'i
korur ama tip fallback'i YAPMAZ — uygun satır yoksa bileşen gizlenir,
yanlış tipte örnek asla gösterilmez. Client profildeki tipi de gönderir ve
SSR "genel" satırı tipe uymuyorsa fetch bitene kadar gizler.

Not: prod app.db volume'de — `pnpm tadimlik` prod DB'ye karşı ayrıca
koşulmalı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:12:25 +03:00
bilalgursen
0c6c9da56c feat: /sonuc ham tablosuna sihirbaz il + devlet/vakıf filtresi
Vakıf seçen kullanıcı sonuç tablosunda devlet üniversiteleri görüyordu:
tablo sihirbaz seçimlerini hiç kullanmıyordu. sonucHref artık profildeki
seçimlerden il (tekrarlı) ve tip paramlarını URL'e yazar; tablo SSR'da bu
paramlarla filtrelenir, "daha fazla göster" sayfalaması aynı filtreleri
taşır. Sekmelerin üstünde kaldırılabilir filtre çipleri görünür — çip
kaldırmak yalnızca URL'i günceller, localStorage profiline dokunmaz.

Kategori tabloya bilinçli uygulanmaz (DB'de kategori kolonu yok, JS
filtresi offset/toplam semantiğini bozar); kategori AI listesi ve
tadımlıkta uygulanır.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:12:08 +03:00
bilalgursen
33a260017a data: yokatlas.db unitur backfill (NULL=0)
scripts/unitur-onar.ts koşusu: 2 kurum adı normalize edildi, 849 satır
üniversiteden kopya, 1.142 satır KKTC ili kuralı, 90 satır manuel eşleme,
47 satırda il dolduruldu. Dağılım: DEVLET 12.750 / VAKIF 9.303 / KKTC 1.229
/ VAKIF MYO 261 / YURTDISI 67 — NULL kalmadı, WAL checkpoint alındı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:11:54 +03:00
bilalgursen
1e810e1782 feat: unitur NULL backfill scripti + refresh ingest onarımı
YÖK Atlas API'sinin universiteTuru döndürmediği ~2.081 satır (Nişantaşı,
Beykent gibi vakıflar + KKTC kampüsleri) devlet/vakıf filtrelerinden ve
sihirbaz sayaçlarından sessizce düşüyordu. unitur-onar üç aşamada doldurur:
aynı üniversitenin dolu satırından kopya → KKTC ili kuralı → manuel eşleme
(Cerrahpaşa, MSB/İçişleri SBÜ, Alanya, Manas, Yesevi, Az. Pedagoji).

"ÜNİVERSİTESİ- CERRAHPAŞA" tarzı tire etrafı boşluklu duplikat adlar da
normalize edilir; refresh.ts hem insert yolunda adı normalize eder hem her
tazeleme sonunda onarımı idempotent olarak koşar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:11:53 +03:00
bilalgursen
34c8f90e26 feat: alan eşlemesine fakülte sinyali + il adımında tüm iller
Sihirbazın alan çipleri artık YÖK Atlas'ın kendi verisiyle süzülüyor:
kategoriBul program adı kurallarını fakülte kurallarıyla kesiştiriyor
(Siber Güvenlik Müh. artık Hukuk değil, Siyaset Bilimi artık Fen değil).
Aşırı geniş isim regexleri sıkılaştırıldı (bilim/güvenlik/programcı/çevre
+ gastronomi'nin içindeki "astronomi" tuzağı) — sayısalda Hukuk & Siyasal,
sözelde Mühendislik çipi tamamen kalktı.

İl adımındaki .slice(0,15) kırpması kaldırıldı: penceresinde programı olan
TÜM iller listelenir; KKTC/yurtdışı kampüsler ayrı çip grubunda. Haritanın
15-limit yüzünden yanlış "program yok" grilemesi kendiliğinden düzeldi.

Öncelik çiplerine şeffaflık notu eklendi (listeyi daraltmaz, AI'ya yön
verir). /meraklisina şeması ve anlatımı aynı davranışla senkronlandı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:11:36 +03:00
29 changed files with 1586 additions and 146 deletions

View File

@@ -33,7 +33,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
# V8 heap sınırı ana süreç + statik üretim worker'larına miras kalır. # V8 heap sınırı ana süreç + statik üretim worker'larına miras kalır.
# Turbopack'ın Rust tarafı bu sınırın dışında; onun hedefi next.config.ts'te # Turbopack'ın Rust tarafı bu sınırın dışında; onun hedefi next.config.ts'te
# turbopackMemoryLimit ile verilir (BUILD_KAYNAK_KISITLI bloğu). # turbopackMemoryLimit ile verilir (BUILD_KAYNAK_KISITLI bloğu).
ENV NODE_OPTIONS="--max-old-space-size=1536" ENV NODE_OPTIONS="--max-old-space-size=1024"
ENV BUILD_KAYNAK_KISITLI=1 ENV BUILD_KAYNAK_KISITLI=1
RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \ RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \
pnpm build pnpm build

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,3 @@
DROP INDEX `tadimlik_anahtar`;--> statement-breakpoint
ALTER TABLE `tadimlik_havuzu` ADD `tip` text DEFAULT 'genel' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `tadimlik_anahtar` ON `tadimlik_havuzu` (`kova_slug`,`tur`,`kategori_slug`,`tip`);

View File

@@ -0,0 +1,907 @@
{
"version": "6",
"dialect": "sqlite",
"id": "46e3e804-4222-4e4c-9151-dd71510cded5",
"prevId": "a802fded-01d5-4d17-8b6f-fca5dfc2920d",
"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": {}
},
"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_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
}
},
"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": {}
},
"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": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1786053613118, "when": 1786053613118,
"tag": "0000_baslangic", "tag": "0000_baslangic",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1786223252523,
"tag": "0001_steady_donald_blake",
"breakpoints": true
} }
] ]
} }

View File

@@ -30,8 +30,10 @@ const nextConfig: NextConfig = {
...(kaynakKisitli ...(kaynakKisitli
? { ? {
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek // Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
// hedefi byte cinsinden ayrıca verilir (1.5 GB). // hedefi byte cinsinden ayrıca verilir (1 GB). 1.5 GB hedef + Node
turbopackMemoryLimit: 1_610_612_736, // heap'i, ~1.5 GB boş belleği olan VPS'te derleme fazında OOM-kill
// üretiyordu (lokalde --memory=1536m ile reprodüke edildi).
turbopackMemoryLimit: 1_073_741_824,
// Statik üretim: varsayılan 4 worker × 8 eşzamanlı sayfa yerine tek // Statik üretim: varsayılan 4 worker × 8 eşzamanlı sayfa yerine tek
// worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir. // worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir.
cpus: 1, cpus: 1,

View File

@@ -12,6 +12,7 @@
*/ */
import path from "node:path"; import path from "node:path";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { uniAdiNormalize, uniturOnar } from "./unitur-onar";
const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search"; const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const PAGE_SIZE = 500; const PAGE_SIZE = 500;
@@ -169,7 +170,9 @@ async function main() {
insert.run( insert.run(
id, id,
r.birimAdi, r.birimAdi,
r.universiteAdi, // "ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu varyantlar
// duplikat kurum adı üretmesin
uniAdiNormalize(r.universiteAdi),
r.universiteTuru ?? null, r.universiteTuru ?? null,
r.ilAdi ?? null, r.ilAdi ?? null,
r.fymkAdi ?? null, r.fymkAdi ?? null,
@@ -205,6 +208,11 @@ async function main() {
console.log( console.log(
`DB toplam: ${stats.toplam} program, 2025 sıralaması olan: ${stats.sirali}` `DB toplam: ${stats.toplam} program, 2025 sıralaması olan: ${stats.sirali}`
); );
// API'nin universiteTuru döndürmediği kayıtlar devlet/vakıf filtrelerinden
// düşmesin: her tazelemeden sonra unitur onarımı koşulur (idempotent).
db.transaction(() => uniturOnar(db))();
db.close(); db.close();
} }

View File

@@ -1,11 +1,13 @@
/** /**
* Tadımlık havuzu batch üretimi: kova × puan türü × (kategori + "genel") * Tadımlık havuzu batch üretimi: kova × puan türü × (kategori + "genel") ×
* kombinasyonları için 1'er örnek tercih satırı (gerekce/riskNotu/trendOzeti) * (üniversite tipi: genel/devlet/vakıf) kombinasyonları için 1'er örnek
* üretip app.db'deki tadimlik_havuzu tablosuna yazar. * tercih satırı (gerekce/riskNotu/trendOzeti) üretip app.db'deki
* tadimlik_havuzu tablosuna yazar.
* *
* pnpm tadimlik # boş anahtarları doldurur (idempotent) * pnpm tadimlik # boş anahtarları doldurur (idempotent)
* pnpm tadimlik --force # mevcut satırları da yeniden üretir * pnpm tadimlik --force # mevcut satırları da yeniden üretir
* pnpm tadimlik --kova=10-50k --tur=say --kategori=muhendislik-teknoloji * pnpm tadimlik --kova=10-50k --tur=say --kategori=muhendislik-teknoloji
* pnpm tadimlik --tip=vakif # yalnızca vakıf varyantları
* pnpm tadimlik --mock # anahtar olsa da mock metin (dev/test) * pnpm tadimlik --mock # anahtar olsa da mock metin (dev/test)
* pnpm tadimlik --bekleme=13000 # LLM çağrıları arası bekleme (ms) * pnpm tadimlik --bekleme=13000 # LLM çağrıları arası bekleme (ms)
* *
@@ -24,10 +26,16 @@ import {
type DilimKey, type DilimKey,
type Program, type Program,
type PuanTuruKey, type PuanTuruKey,
type UniturGrubu,
} from "../src/types/yokatlas"; } from "../src/types/yokatlas";
import { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler"; import { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler";
import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu"; import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu";
import { GENEL_KATEGORI, KOVALAR, kategoriSlugBul } from "../src/lib/tadimlik-havuzu"; import {
GENEL_KATEGORI,
KOVALAR,
kategoriSlugBul,
type TadimlikTip,
} from "../src/lib/tadimlik-havuzu";
import { anahtarVar } from "../src/lib/ai/client"; import { anahtarVar } from "../src/lib/ai/client";
import { yapilandirilmisUret } from "../src/lib/ai/cagri"; import { yapilandirilmisUret } from "../src/lib/ai/cagri";
@@ -95,18 +103,22 @@ function programBilgisi(p: Program) {
/** /**
* Deterministik program seçimi: kovanın temsili sırası için "dengeli" dilimden * Deterministik program seçimi: kovanın temsili sırası için "dengeli" dilimden
* (boşsa "garanti") kategoriye uyan, 2025'te en çok yerleşen alan program. * (boşsa "garanti") kategoriye ve üniversite tipine uyan, 2025'te en çok
* Tanınır/dolu programlar tadımlıkta daha ikna edici. * yerleşen alan program. Tanınır/dolu programlar tadımlıkta daha ikna edici.
*/ */
function programSec( function programSec(
temsiliSira: number, temsiliSira: number,
tur: PuanTuruKey, tur: PuanTuruKey,
kategori: string | null, kategori: string | null,
uniturGrubu?: UniturGrubu,
): { program: Program; dilim: DilimKey } | null { ): { program: Program; dilim: DilimKey } | null {
for (const dilim of ["dengeli", "garanti"] as const) { for (const dilim of ["dengeli", "garanti"] as const) {
const { programlar } = dilimAra(temsiliSira, tur, dilim, { limit: 60 }); const { programlar } = dilimAra(temsiliSira, tur, dilim, {
limit: 60,
uniturGrubu,
});
const uygunlar = kategori const uygunlar = kategori
? programlar.filter((p) => kategoriEslesir(p.isim, [kategori])) ? programlar.filter((p) => kategoriEslesir(p.isim, [kategori], p.fakulte))
: programlar; : programlar;
if (uygunlar.length === 0) continue; if (uygunlar.length === 0) continue;
const secilen = [...uygunlar].sort( const secilen = [...uygunlar].sort(
@@ -152,6 +164,7 @@ async function main() {
const kovaFiltre = bayrak("kova"); const kovaFiltre = bayrak("kova");
const turFiltre = bayrak("tur"); const turFiltre = bayrak("tur");
const kategoriFiltre = bayrak("kategori"); const kategoriFiltre = bayrak("kategori");
const tipFiltre = bayrak("tip");
const turlar = (Object.keys(PUAN_TURLERI) as PuanTuruKey[]).filter( const turlar = (Object.keys(PUAN_TURLERI) as PuanTuruKey[]).filter(
(t) => !turFiltre || t === turFiltre, (t) => !turFiltre || t === turFiltre,
@@ -161,6 +174,9 @@ async function main() {
...KATEGORILER.map((ad) => ({ ad: ad as string, slug: kategoriSlugBul(ad) })), ...KATEGORILER.map((ad) => ({ ad: ad as string, slug: kategoriSlugBul(ad) })),
].filter((k) => !kategoriFiltre || k.slug === kategoriFiltre); ].filter((k) => !kategoriFiltre || k.slug === kategoriFiltre);
const kovalar = KOVALAR.filter((k) => !kovaFiltre || k.slug === kovaFiltre); const kovalar = KOVALAR.filter((k) => !kovaFiltre || k.slug === kovaFiltre);
const tipler = (["genel", "devlet", "vakif"] as TadimlikTip[]).filter(
(t) => !tipFiltre || t === tipFiltre,
);
const mevcutlar = new Set( const mevcutlar = new Set(
( (
@@ -169,9 +185,10 @@ async function main() {
kovaSlug: schema.tadimlikHavuzu.kovaSlug, kovaSlug: schema.tadimlikHavuzu.kovaSlug,
tur: schema.tadimlikHavuzu.tur, tur: schema.tadimlikHavuzu.tur,
kategoriSlug: schema.tadimlikHavuzu.kategoriSlug, kategoriSlug: schema.tadimlikHavuzu.kategoriSlug,
tip: schema.tadimlikHavuzu.tip,
}) })
.from(schema.tadimlikHavuzu) .from(schema.tadimlikHavuzu)
).map((s) => `${s.kovaSlug}|${s.tur}|${s.kategoriSlug}`), ).map((s) => `${s.kovaSlug}|${s.tur}|${s.kategoriSlug}|${s.tip}`),
); );
if (mockAktif()) { if (mockAktif()) {
@@ -185,55 +202,46 @@ async function main() {
for (const kova of kovalar) { for (const kova of kovalar) {
for (const tur of turlar) { for (const tur of turlar) {
for (const kategori of kategoriler) { for (const kategori of kategoriler) {
const anahtar = `${kova.slug}|${tur}|${kategori.slug}`; for (const tip of tipler) {
if (!force && mevcutlar.has(anahtar)) { const anahtar = `${kova.slug}|${tur}|${kategori.slug}|${tip}`;
atlananMevcut++; if (!force && mevcutlar.has(anahtar)) {
continue; atlananMevcut++;
} continue;
}
const secim = programSec(kova.temsiliSira, tur, kategori.ad); const secim = programSec(
if (!secim) { kova.temsiliSira,
atlananBos++;
continue;
}
const metin = await metinUret(
secim.program,
secim.dilim,
kova.temsiliSira,
tur,
kategori.ad,
);
if (!metin) {
console.warn(`Şemaya uymayan çıktı, atlandı: ${anahtar}`);
atlananBos++;
continue;
}
const now = new Date();
await appDb
.insert(schema.tadimlikHavuzu)
.values({
id: randomUUID(),
kovaSlug: kova.slug,
tur, tur,
kategoriSlug: kategori.slug, kategori.ad,
programId: secim.program.id, tip === "genel" ? undefined : tip,
dilim: secim.dilim, );
gerekce: metin.gerekce, if (!secim) {
riskNotu: metin.riskNotu, atlananBos++;
trendOzeti: metin.trendOzeti, continue;
program: programBilgisi(secim.program), }
ornekSira: kova.temsiliSira,
uretimAt: now, const metin = await metinUret(
}) secim.program,
.onConflictDoUpdate({ secim.dilim,
target: [ kova.temsiliSira,
schema.tadimlikHavuzu.kovaSlug, tur,
schema.tadimlikHavuzu.tur, kategori.ad,
schema.tadimlikHavuzu.kategoriSlug, );
], if (!metin) {
set: { console.warn(`Şemaya uymayan çıktı, atlandı: ${anahtar}`);
atlananBos++;
continue;
}
const now = new Date();
await appDb
.insert(schema.tadimlikHavuzu)
.values({
id: randomUUID(),
kovaSlug: kova.slug,
tur,
kategoriSlug: kategori.slug,
tip,
programId: secim.program.id, programId: secim.program.id,
dilim: secim.dilim, dilim: secim.dilim,
gerekce: metin.gerekce, gerekce: metin.gerekce,
@@ -242,15 +250,35 @@ async function main() {
program: programBilgisi(secim.program), program: programBilgisi(secim.program),
ornekSira: kova.temsiliSira, ornekSira: kova.temsiliSira,
uretimAt: now, uretimAt: now,
}, })
}); .onConflictDoUpdate({
target: [
schema.tadimlikHavuzu.kovaSlug,
schema.tadimlikHavuzu.tur,
schema.tadimlikHavuzu.kategoriSlug,
schema.tadimlikHavuzu.tip,
],
set: {
programId: secim.program.id,
dilim: secim.dilim,
gerekce: metin.gerekce,
riskNotu: metin.riskNotu,
trendOzeti: metin.trendOzeti,
program: programBilgisi(secim.program),
ornekSira: kova.temsiliSira,
uretimAt: now,
},
});
uretilen++; uretilen++;
console.log(`✓ ${anahtar} → ${secim.program.isim} (${secim.dilim})`); console.log(
// Gerçek LLM'de rate limit dostu ardışık tempo `✓ ${anahtar} → ${secim.program.isim} (${secim.dilim})`,
if (!mockAktif()) { );
const bekleme = Number.parseInt(bayrak("bekleme") ?? "500", 10); // Gerçek LLM'de rate limit dostu ardışık tempo
await new Promise((r) => setTimeout(r, bekleme)); if (!mockAktif()) {
const bekleme = Number.parseInt(bayrak("bekleme") ?? "500", 10);
await new Promise((r) => setTimeout(r, bekleme));
}
} }
} }
} }

158
scripts/unitur-onar.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* unitur (üniversite tipi) NULL onarımı + üniversite adı normalizasyonu.
*
* YÖK Atlas API'si bazı kayıtlarda universiteTuru döndürmüyor; DB'de ~2.081
* satır unitur=NULL kalıyor ve bunlar hem devlet/vakıf filtrelerinden hem
* sihirbaz sayaçlarından sessizce düşüyor. Onarım üç aşamalı:
*
* A) Aynı üniversitenin dolu satırından kopyala (Nişantaşı, Beykent…)
* B) Kalanlarda il KKTC yerleşimiyse unitur='KKTC'
* C) Küçük manuel eşleme (Cerrahpaşa, MSB/İçişleri, Alanya, Manas…)
*
* Ayrıca "İSTANBUL ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu
* duplikat adlar normalize edilir; refresh.ts insert yolunda da aynı
* normalizasyon uygulanır ki duplikat bir daha oluşmasın.
*
* İdempotenttir; yalnızca NULL satırlara dokunur.
*
* Kullanım: npx tsx scripts/unitur-onar.ts (dev sunucu KAPALIYKEN —
* uygulama DB'yi readonly açar ama WAL checkpoint için tek yazar olmalı)
*/
import path from "node:path";
import Database from "better-sqlite3";
/** Tire etrafı boşluk + çift boşluk temizliği (üniversite adı için). */
export function uniAdiNormalize(ad: string): string {
return ad.replace(/\s*-\s*/g, "-").replace(/\s{2,}/g, " ").trim();
}
const KKTC_ILLERI = [
"GİRNE",
"LEFKOŞA",
"GAZİMAĞUSA",
"LEFKE",
"GÜZELYURT",
"KIBRIS",
];
// Hiçbir satırında unitur olmayan ve il'den de çıkarılamayan kurumlar.
const MANUEL_UNITUR: Record<string, string> = {
"İSTANBUL ÜNİVERSİTESİ-CERRAHPAŞA": "DEVLET",
"İÇİŞLERİ BAKANLIĞI VE MİLLİ SAVUNMA BAKANLIĞI ADINA SAĞLIK BİLİMLERİ ÜNİVERSİTESİNDE EĞİTİM ALACAKLAR":
"DEVLET",
"ALANYA ÜNİVERSİTESİ": "VAKIF",
"KIRGIZİSTAN-TÜRKİYE MANAS ÜNİVERSİTESİ": "YURTDISI KAMU",
"HOCA AHMET YESEVİ ULUSLARARASI TÜRK-KAZAK ÜNİVERSİTESİ": "YURTDISI KAMU",
"AZERBAYCAN DEVLET PEDAGOJİ ÜNİVERSİTESİ": "YURTDISI KAMU",
};
// İl'i hiç olmayan kampüsler için ad → il eşlemesi (yalnızca kesin olanlar).
const MANUEL_IL: Record<string, string> = {
"İSTANBUL ÜNİVERSİTESİ-CERRAHPAŞA": "İSTANBUL",
};
export function uniturOnar(db: Database.Database): void {
// 0) Üniversite adı normalizasyonu (Cerrahpaşa duplikatını birleştirir)
const adlar = db
.prepare("SELECT DISTINCT universite FROM programs")
.all() as { universite: string }[];
const adDuzelt = db.prepare(
"UPDATE programs SET universite = ? WHERE universite = ?"
);
let duzeltilen = 0;
for (const { universite } of adlar) {
const temiz = uniAdiNormalize(universite);
if (temiz !== universite) {
adDuzelt.run(temiz, universite);
duzeltilen++;
}
}
if (duzeltilen > 0) console.log(`Ad normalizasyonu: ${duzeltilen} kurum`);
// A) Aynı üniversitenin dolu satırından kopyala. Birden fazla FARKLI dolu
// değer varsa sessizce seçim yapma — kaynak veride tutarsızlık demektir.
const celiskiler = db
.prepare(
`SELECT universite, COUNT(DISTINCT unitur) AS n FROM programs
WHERE unitur IS NOT NULL GROUP BY universite HAVING n > 1`
)
.all() as { universite: string; n: number }[];
if (celiskiler.length > 0) {
throw new Error(
`Aynı üniversitede birden fazla unitur: ${celiskiler
.map((c) => c.universite)
.join(", ")} — önce kaynak veriyi kontrol et.`
);
}
const aKopya = db
.prepare(
`UPDATE programs SET unitur = (
SELECT p2.unitur FROM programs p2
WHERE p2.universite = programs.universite AND p2.unitur IS NOT NULL
LIMIT 1
)
WHERE unitur IS NULL AND EXISTS (
SELECT 1 FROM programs p3
WHERE p3.universite = programs.universite AND p3.unitur IS NOT NULL
)`
)
.run();
console.log(`Aşama A (üniversiteden kopya): ${aKopya.changes} satır`);
// B) Kalan NULL'larda il KKTC yerleşimiyse
const bKktc = db
.prepare(
`UPDATE programs SET unitur = 'KKTC'
WHERE unitur IS NULL AND il IN (${KKTC_ILLERI.map(() => "?").join(",")})`
)
.run(...KKTC_ILLERI);
console.log(`Aşama B (KKTC ili): ${bKktc.changes} satır`);
// C) Manuel eşlemeler
const cUnitur = db.prepare(
"UPDATE programs SET unitur = ? WHERE unitur IS NULL AND universite = ?"
);
let cToplam = 0;
for (const [ad, tur] of Object.entries(MANUEL_UNITUR)) {
cToplam += cUnitur.run(tur, ad).changes;
}
console.log(`Aşama C (manuel): ${cToplam} satır`);
const ilDuzelt = db.prepare(
"UPDATE programs SET il = ? WHERE il IS NULL AND universite = ?"
);
let ilToplam = 0;
for (const [ad, il] of Object.entries(MANUEL_IL)) {
ilToplam += ilDuzelt.run(il, ad).changes;
}
if (ilToplam > 0) console.log(`İl doldurma (manuel): ${ilToplam} satır`);
// Rapor: kalan NULL'lar
const kalan = db
.prepare(
`SELECT universite, COUNT(*) AS adet FROM programs
WHERE unitur IS NULL GROUP BY universite ORDER BY adet DESC`
)
.all() as { universite: string; adet: number }[];
if (kalan.length === 0) {
console.log("Kalan NULL unitur: 0 ✓");
} else {
console.log(`Kalan NULL unitur (${kalan.length} kurum):`);
for (const k of kalan) console.log(` ${k.universite}: ${k.adet}`);
}
}
// Doğrudan koşulduğunda (npx tsx scripts/unitur-onar.ts)
if (process.argv[1]?.endsWith("unitur-onar.ts")) {
const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
db.pragma("journal_mode = WAL");
db.transaction(() => uniturOnar(db))();
db.pragma("wal_checkpoint(TRUNCATE)");
const dagilim = db
.prepare(
"SELECT COALESCE(unitur,'(NULL)') AS t, COUNT(*) AS n FROM programs GROUP BY unitur ORDER BY n DESC"
)
.all() as { t: string; n: number }[];
console.log("Dağılım:", dagilim.map((d) => `${d.t}=${d.n}`).join(" "));
db.close();
}

View File

@@ -36,9 +36,19 @@ export function GET(request: NextRequest) {
return NextResponse.json({ error: "Geçersiz offset." }, { status: 400 }); return NextResponse.json({ error: "Geçersiz offset." }, { status: 400 });
} }
// /sonuc tablosunun filtreleri sayfalamada da korunur (bkz. sonucHref)
const iller = [
...new Set(searchParams.getAll("il").filter((i) => i.trim().length > 0)),
].slice(0, 5);
const tipParam = searchParams.get("tip");
const uniturGrubu =
tipParam === "devlet" || tipParam === "vakif" ? tipParam : undefined;
const { programlar, toplam } = dilimAra(sira, tur, dilimParam as DilimKey, { const { programlar, toplam } = dilimAra(sira, tur, dilimParam as DilimKey, {
limit: SAYFA_BOYU, limit: SAYFA_BOYU,
offset, offset,
iller,
uniturGrubu,
}); });
// Tablo satırının kullanmadığı alanlar payload'a girmesin // Tablo satırının kullanmadığı alanlar payload'a girmesin
return NextResponse.json({ programlar: programlar.map(programOzetle), toplam }); return NextResponse.json({ programlar: programlar.map(programOzetle), toplam });

View File

@@ -29,7 +29,18 @@ export async function GET(istek: Request) {
? kategoriParam ? kategoriParam
: undefined; : undefined;
const tadimlik = await tadimlikSec(sira, tur as PuanTuruKey, kategoriSlug); // Sihirbazdaki devlet/vakıf tercihi: uygun tipte satır yoksa null döner,
// yanlış tipte örnek asla servis edilmez.
const tipParam = url.searchParams.get("tip");
const tip =
tipParam === "devlet" || tipParam === "vakif" ? tipParam : "genel";
const tadimlik = await tadimlikSec(
sira,
tur as PuanTuruKey,
kategoriSlug,
tip,
);
return NextResponse.json( return NextResponse.json(
{ tadimlik }, { tadimlik },
// Satırlar kova granülaritesinde statik; 1 saat cache yeterli. // Satırlar kova granülaritesinde statik; 1 saat cache yeterli.

View File

@@ -28,6 +28,8 @@ export default function SonucPage({
tur?: string; tur?: string;
sihirbaz?: string; sihirbaz?: string;
hazir?: string; hazir?: string;
il?: string | string[];
tip?: string;
}>; }>;
}) { }) {
return ( return (
@@ -51,6 +53,22 @@ export default function SonucPage({
params.tur && params.tur in PUAN_TURLERI params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey) ? (params.tur as PuanTuruKey)
: "say"; : "say";
// Sihirbaz seçimlerinden gelen tablo filtreleri (bkz. sonucHref):
// il tekrarlı param, tip devlet|vakif allowlist'li
const iller = [
...new Set(
(Array.isArray(params.il)
? params.il
: params.il
? [params.il]
: []
).filter((i) => i.trim().length > 0),
),
].slice(0, 5);
const uniturGrubu =
params.tip === "devlet" || params.tip === "vakif"
? params.tip
: undefined;
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return ( return (
@@ -71,8 +89,14 @@ export default function SonucPage({
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında, {/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında,
sayfanın ana gövdesi; puan türü navbar'daki sıralama sayfanın ana gövdesi; puan türü navbar'daki sıralama
rozetinden değiştirilir */} rozetinden değiştirilir. Sihirbaz seçimleri (il, devlet/
<SonucProgramTablosu sira={sira} tur={turKey} /> vakıf) URL paramlarıyla tabloya uygulanır. */}
<SonucProgramTablosu
sira={sira}
tur={turKey}
iller={iller}
uniturGrubu={uniturGrubu}
/>
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek. {/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */} Havuzda içerik yoksa bileşen hiç render edilmez. */}

View File

@@ -94,21 +94,28 @@ export function ProgramTablosu({
sonuclar, sonuclar,
adaySira, adaySira,
tur, tur,
filtreIller,
filtreTip,
}: { }: {
sonuclar: RankResultsListe; sonuclar: RankResultsListe;
adaySira: number; adaySira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
filtreIller?: string[];
filtreTip?: "devlet" | "vakif";
}) { }) {
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() => const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
varsayilanDilim(sonuclar), varsayilanDilim(sonuclar),
); );
// Sihirbaz seçimlerinden URL'e yazılan filtreler (bkz. sonucHref) —
// sayfalama isteklerinde ve veri anahtarında kullanılır.
const iller = useMemo(() => filtreIller ?? [], [filtreIller]);
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür değişince // "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür/filtre
// (puan türü seçici aynı bileşeni yeniden render eder, remount etmez) // değişince (puan türü seçici aynı bileşeni yeniden render eder, remount
// render sırasında sıfırlanır ki eski türün satırları yenisine karışmasın. // etmez) render sırasında sıfırlanır ki eski kümenin satırları karışmasın.
const [ekstra, setEkstra] = useState(BOS_EKSTRA); const [ekstra, setEkstra] = useState(BOS_EKSTRA);
const [yuklenen, setYuklenen] = useState<DilimKey | null>(null); const [yuklenen, setYuklenen] = useState<DilimKey | null>(null);
const veriAnahtari = `${adaySira}-${tur}`; const veriAnahtari = `${adaySira}-${tur}-${iller.join(",")}-${filtreTip ?? ""}`;
const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari); const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari);
if (veriAnahtari !== oncekiAnahtar) { if (veriAnahtari !== oncekiAnahtar) {
setOncekiAnahtar(veriAnahtari); setOncekiAnahtar(veriAnahtari);
@@ -129,9 +136,15 @@ export function ProgramTablosu({
async function dahaFazlaYukle(dilim: DilimKey, offset: number) { async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim); setYuklenen(dilim);
try { try {
const res = await fetch( const q = new URLSearchParams({
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`, sira: String(adaySira),
); tur,
dilim,
offset: String(offset),
});
for (const il of iller) q.append("il", il);
if (filtreTip) q.set("tip", filtreTip);
const res = await fetch(`/api/programlar?${q}`);
if (!res.ok) return; if (!res.ok) return;
const veri = (await res.json()) as { programlar: ProgramListeSatiri[] }; const veri = (await res.json()) as { programlar: ProgramListeSatiri[] };
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir // Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir

View File

@@ -1,19 +1,31 @@
import { siraylaProgramAra } from "../liste-queries"; import { siraylaProgramAra } from "../liste-queries";
import { ProgramTablosu } from "./program-tablosu"; import { ProgramTablosu } from "./program-tablosu";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import type { PuanTuruKey } from "@/types/yokatlas"; import type { PuanTuruKey, UniturGrubu } from "@/types/yokatlas";
// /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa // /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa
// yalnızca kompoze eder. // yalnızca kompoze eder.
export function SonucProgramTablosu({ export function SonucProgramTablosu({
sira, sira,
tur, tur,
iller,
uniturGrubu,
}: { }: {
sira: number; sira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
iller?: string[];
uniturGrubu?: UniturGrubu;
}) { }) {
const sonuclar = siraylaProgramAra(sira, tur); const sonuclar = siraylaProgramAra(sira, tur, { iller, uniturGrubu });
return <ProgramTablosu sonuclar={sonuclar} adaySira={sira} tur={tur} />; return (
<ProgramTablosu
sonuclar={sonuclar}
adaySira={sira}
tur={tur}
filtreIller={iller}
filtreTip={uniturGrubu}
/>
);
} }
export function SonucProgramTablosuSkeleton() { export function SonucProgramTablosuSkeleton() {

View File

@@ -5,6 +5,7 @@ import type {
ProgramListeSatiri, ProgramListeSatiri,
PuanTuruKey, PuanTuruKey,
RankResultsListe, RankResultsListe,
UniturGrubu,
} from "@/types/yokatlas"; } from "@/types/yokatlas";
// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin. // Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin.
@@ -29,13 +30,22 @@ export function programOzetle(p: Program): ProgramListeSatiri {
}; };
} }
// /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar, // /sonuc'un ham tablosu: sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır. // hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır. Sihirbaz
// seçimlerinden il + üniversite tipi URL üzerinden buraya taşınır ve SQL'de
// filtrelenir; kategori bilinçli olarak uygulanmaz (DB'de kategori kolonu
// yok, JS filtresi offset/toplam semantiğini bozar — kategori AI listesi ve
// tadımlıkta uygulanır).
export function siraylaProgramAra( export function siraylaProgramAra(
sira: number, sira: number,
tur: PuanTuruKey, tur: PuanTuruKey,
opts: { iller?: string[]; uniturGrubu?: UniturGrubu } = {},
): RankResultsListe { ): RankResultsListe {
const ham = searchByRank(sira, tur, { limitPerBucket: 20 }); const ham = searchByRank(sira, tur, {
limitPerBucket: 20,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
});
return { return {
hayal: ham.hayal.map(programOzetle), hayal: ham.hayal.map(programOzetle),
dengeli: ham.dengeli.map(programOzetle), dengeli: ham.dengeli.map(programOzetle),

View File

@@ -11,8 +11,8 @@ config:
look: handDrawn look: handDrawn
--- ---
flowchart TD flowchart TD
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi<br/>yalnızca ulaşabildiğin alanlar listelenir") T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi<br/>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<br/>iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır") A --> B("2. adım · İl seçimi<br/>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<br/>sayılar hem alanı hem ili hesaba katar") B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
C --> H("Aday havuzu<br/>elde kalan gerçek programlar") C --> H("Aday havuzu<br/>elde kalan gerçek programlar")
H --> S{"Havuzda 24'ten az program mı kaldı?"} H --> S{"Havuzda 24'ten az program mı kaldı?"}
@@ -42,7 +42,7 @@ export function HuniSemasi() {
return ( return (
<ElCizimiSema <ElCizimiSema
tanim={SEMA} tanim={SEMA}
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır." ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, il adımında programı olan tüm iller listelenir; havuz 24'ün altına düşerse filtreler gevşetilir, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
figcaption={ figcaption={
<> <>
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "} Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}

View File

@@ -107,11 +107,14 @@ export function MeraklisinaIcerik() {
&ldquo;Yapay Zeka listemi kur&rdquo; sihirbazındaki üç adım birbirinden &ldquo;Yapay Zeka listemi kur&rdquo; sihirbazındaki üç adım birbirinden
bağımsız değildir. İlk adımda yalnızca{" "} bağımsız değildir. İlk adımda yalnızca{" "}
<strong>sıralamanla ulaşabildiğin</strong>{" "} <strong>sıralamanla ulaşabildiğin</strong>{" "}
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki alanlar listelenir — bir bölümün hangi alana girdiği, adına ve
program sayısına göre yeniden hesaplanır; üçüncü adımdaki bağlı olduğu fakülteye bakılarak belirlenir. İkinci adımda
programı olan tüm iller, seçtiğin alanlardaki program sayısına
göre yeniden hesaplanarak listelenir; üçüncü adımdaki
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar. devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
Adımlar bitince elde kalan gerçek programlar bir{" "} Seçtiğin il ve devlet/vakıf tercihi, sonuç sayfasındaki ücretsiz
<strong>aday havuzu</strong>{" "} tabloya da uygulanır. Adımlar bitince elde kalan gerçek
programlar bir <strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği, yine de 24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği, yine de

View File

@@ -27,10 +27,17 @@ import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug"; import { turkishSlugify } from "@/lib/slug";
import { PROFIL_DEGISTI_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler"; import { PROFIL_DEGISTI_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil"; import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu"; import type { TadimlikSatir, TadimlikTip } from "@/lib/tadimlik-havuzu";
const GENEL = "genel"; const GENEL = "genel";
/** Program tipi ile sihirbaz tercihi eşleşiyor mu (uniturFiltreSql semantiği). */
function tipeUyar(program: TadimlikSatir["program"], tip: TadimlikTip): boolean {
if (tip === "genel") return true;
if (tip === "devlet") return program.unitur === "DEVLET";
return program.unitur != null && program.unitur !== "DEVLET";
}
export function TadimlikSatiri({ export function TadimlikSatiri({
sira, sira,
tur, tur,
@@ -42,29 +49,46 @@ export function TadimlikSatiri({
ilk: TadimlikSatir | null; ilk: TadimlikSatir | null;
}) { }) {
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk); const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
const yuklenenSlug = useRef(GENEL); const yuklenenAnahtar = useRef(`${GENEL}|${GENEL}`);
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil // Sihirbaz kategorisi ve devlet/vakıf tercihi yalnızca localStorage'da
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir. // yaşar; mount'ta ve profil değiştikçe okunur, seçime özel satır sunucudan
// çekilir. Tip tercihi varsa yanlış tipte örnek bir an bile gösterilmez.
useEffect(() => { useEffect(() => {
const guncelle = () => { const guncelle = () => {
const profil = tercihProfiliOku(); const profil = tercihProfiliOku();
const kategori = profil?.secimler.kategoriler[0]; const kategori = profil?.secimler.kategoriler[0];
const slug = kategori ? turkishSlugify(kategori) : GENEL; const slug = kategori ? turkishSlugify(kategori) : GENEL;
if (slug === yuklenenSlug.current) return; const tipTercihi = profil?.secimler.universiteTipi;
yuklenenSlug.current = slug; const tip: TadimlikTip =
if (slug === GENEL) { tipTercihi === "devlet" || tipTercihi === "vakif"
? tipTercihi
: GENEL;
const anahtar = `${slug}|${tip}`;
if (anahtar === yuklenenAnahtar.current) return;
yuklenenAnahtar.current = anahtar;
if (slug === GENEL && tip === GENEL) {
setSatir(ilk); setSatir(ilk);
return; return;
} }
fetch( // SSR "genel" satırı seçilen tipe uymuyorsa fetch bitene kadar gizle
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`, setSatir((mevcut) =>
) mevcut && !tipeUyar(mevcut.program, tip) ? null : mevcut,
);
const q = new URLSearchParams({ sira: String(sira), tur });
if (slug !== GENEL) q.set("kategori", slug);
if (tip !== GENEL) q.set("tip", tip);
fetch(`/api/tadimlik?${q}`)
.then((yanit) => (yanit.ok ? yanit.json() : null)) .then((yanit) => (yanit.ok ? yanit.json() : null))
.then((veri: { tadimlik: TadimlikSatir | null } | null) => { .then((veri: { tadimlik: TadimlikSatir | null } | null) => {
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma // Yarış koruması: bu arada başka seçim yüklendiyse dokunma
if (yuklenenSlug.current !== slug) return; if (yuklenenAnahtar.current !== anahtar) return;
if (veri?.tadimlik) setSatir(veri.tadimlik); if (veri?.tadimlik) {
setSatir(veri.tadimlik);
} else if (tip !== GENEL) {
// Uygun tipte satır yok: yanlış tip göstermektense gizle
setSatir(null);
}
}) })
.catch(() => {}); .catch(() => {});
}; };

View File

@@ -8,7 +8,7 @@
// üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir. // üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir.
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, X } from "lucide-react"; import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
import { useReducedMotion } from "motion/react"; import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -27,11 +27,14 @@ export function Cip({
secili, secili,
onClick, onClick,
children, children,
kucuk = false,
}: { }: {
secili: boolean; secili: boolean;
onClick: () => void; onClick: () => void;
children: React.ReactNode; children: React.ReactNode;
kucuk?: boolean;
}) { }) {
const boyut = kucuk ? "px-2.5 py-1 text-xs" : "px-3.5 py-2 text-sm";
return ( return (
<button <button
type="button" type="button"
@@ -39,8 +42,8 @@ export function Cip({
aria-pressed={secili} aria-pressed={secili}
className={ className={
secili secili
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200" ? `cursor-pointer rounded-full border border-primary bg-primary font-medium text-white transition-colors duration-200 ${boyut}`
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50" : `cursor-pointer rounded-full border border-slate-200 bg-white text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 ${boyut}`
} }
> >
{children} {children}
@@ -93,6 +96,36 @@ export function SihirbazAdimlar({
() => [...canliFacetler.iller].sort((a, b) => b.adet - a.adet), () => [...canliFacetler.iller].sort((a, b) => b.adet - a.adet),
[canliFacetler.iller], [canliFacetler.iller],
); );
// Türkiye illeri ana çip grubu; KKTC/yurtdışı kampüsler haritada olmadığı
// için ayrı alt grupta listelenir.
const turkiyeIlleri = useMemo(
() => siraliIller.filter((i) => HARITA_ILLERI.has(i.il)),
[siraliIller],
);
const disIller = useMemo(
() => siraliIller.filter((i) => !HARITA_ILLERI.has(i.il)),
[siraliIller],
);
// İl araması: Türkçe büyük/küçük harf ve şapkalı harf farklarına duyarsız
// eşleşme için iki taraf da normalizeIlAdi'dan geçirilir.
const [ilArama, setIlArama] = useState("");
const ilAramaNorm = normalizeIlAdi(ilArama.trim());
const gorunenTurkiyeIlleri = useMemo(
() =>
ilAramaNorm
? turkiyeIlleri.filter((i) =>
normalizeIlAdi(i.il).includes(ilAramaNorm),
)
: turkiyeIlleri,
[turkiyeIlleri, ilAramaNorm],
);
const gorunenDisIller = useMemo(
() =>
ilAramaNorm
? disIller.filter((i) => normalizeIlAdi(i.il).includes(ilAramaNorm))
: disIller,
[disIller, ilAramaNorm],
);
const haritaIlleri = useMemo( const haritaIlleri = useMemo(
() => canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il)), () => canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il)),
[canliFacetler.iller], [canliFacetler.iller],
@@ -256,26 +289,85 @@ export function SihirbazAdimlar({
En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez. En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.
</p> </p>
<div className="mt-4 flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-2 transition-colors focus-within:border-slate-300">
<Search className="size-4 shrink-0 text-slate-400" aria-hidden />
<input
type="text"
value={ilArama}
onChange={(e) => setIlArama(e.target.value)}
placeholder="İl ara..."
aria-label="İl ara"
autoComplete="off"
className="min-w-0 flex-1 bg-transparent text-sm text-slate-950 outline-none placeholder:text-slate-400"
/>
{ilArama ? (
<button
type="button"
onClick={() => setIlArama("")}
aria-label="Aramayı temizle"
className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<X className="size-3.5" aria-hidden />
</button>
) : null}
</div>
{/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde {/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde
güvenilir dokunma hedefi değil); iller seçili kategorilere göre güvenilir dokunma hedefi değil); iller seçili kategorilere göre
süzülmüş, program sayısına göre sıralı listelenir. */} süzülmüş, program sayısına göre sıralı listelenir. Penceresinde
programı olan TÜM iller görünür (kırpma yok); arama yalnızca
görünümü daraltır, seçimleri etkilemez. */}
<div <div
className={`mt-4 flex flex-wrap gap-2 ${ className={`mt-3 flex max-h-56 flex-wrap gap-1.5 overflow-y-auto sm:max-h-none sm:overflow-visible ${
facetYukleniyor ? "animate-pulse opacity-60" : "" facetYukleniyor ? "animate-pulse opacity-60" : ""
}`} }`}
> >
{siraliIller.map((i) => ( {gorunenTurkiyeIlleri.map((i) => (
<Cip <Cip
key={i.il} key={i.il}
kucuk
secili={iller.includes(i.il)} secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)} onClick={() => listeDegistir(iller, setIller, i.il, 5)}
> >
{trBaslikDuzeni(i.il)} {trBaslikDuzeni(i.il)}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span> <span className="ml-1 text-[10px] opacity-70">{i.adet}</span>
</Cip> </Cip>
))} ))}
</div> </div>
{ilAramaNorm &&
gorunenTurkiyeIlleri.length === 0 &&
gorunenDisIller.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">
&ldquo;{ilArama.trim()}&rdquo; ile eşleşen il yok.
</p>
) : null}
{gorunenDisIller.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
KKTC & Yurt Dışı
</p>
<div
className={`flex flex-wrap gap-1.5 ${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
{gorunenDisIller.map((i) => (
<Cip
key={i.il}
kucuk
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{trBaslikDuzeni(i.il)}
<span className="ml-1 text-[10px] opacity-70">{i.adet}</span>
</Cip>
))}
</div>
</div>
) : null}
{iller.length > 0 ? ( {iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400"> <span className="text-xs font-semibold text-slate-400">
@@ -359,6 +451,10 @@ export function SihirbazAdimlar({
</Cip> </Cip>
))} ))}
</div> </div>
<p className="mt-2 text-xs text-slate-500">
Öncelikler yapay zekânın gerekçelerine ve program seçimine yön
verir; listeyi daraltmaz.
</p>
</div> </div>
</div> </div>
</TransitionPanel> </TransitionPanel>

View File

@@ -126,16 +126,29 @@ export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null {
return { sira, tur: p.tur, secimler }; return { sira, tur: p.tur, secimler };
} }
/** Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu kullanır. */ /**
* Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu
* kullanır. Profilde sihirbaz seçimleri varsa il + üniversite tipi de URL'e
* yazılır: /sonuc'un ham tablosu SSR'da bu paramlarla filtrelenir (tek
* doğruluk kaynağı URL; localStorage profili yalnızca URL'i üretir).
*/
export function sonucHref( export function sonucHref(
profil: Pick<TercihProfili, "sira" | "tur">, profil: Pick<TercihProfili, "sira" | "tur"> & {
secimler?: SihirbazSecimleri;
},
ekstra?: Record<string, string>, ekstra?: Record<string, string>,
): string { ): string {
const q = new URLSearchParams({ const q = new URLSearchParams({
sira: String(profil.sira), sira: String(profil.sira),
tur: profil.tur, tur: profil.tur,
...ekstra,
}); });
if (profil.secimler) {
for (const il of profil.secimler.iller) q.append("il", il);
if (profil.secimler.universiteTipi !== "farketmez") {
q.set("tip", profil.secimler.universiteTipi);
}
}
for (const [k, v] of Object.entries(ekstra ?? {})) q.set(k, v);
return `/sonuc?${q}`; return `/sonuc?${q}`;
} }

View File

@@ -154,8 +154,9 @@ export const reports = sqliteTable("reports", {
}); });
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları. // Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback). // Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
// Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir. // ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez;
// bu tablo salt-okunur servis edilir.
// NOT: Yeni tablo/kolon eklerken `pnpm db:generate` ile migration üretilmeli; // NOT: Yeni tablo/kolon eklerken `pnpm db:generate` ile migration üretilmeli;
// prod şeması açılışta yalnızca drizzle/ altındaki migration'larla güncellenir. // prod şeması açılışta yalnızca drizzle/ altındaki migration'larla güncellenir.
export const tadimlikHavuzu = sqliteTable( export const tadimlikHavuzu = sqliteTable(
@@ -165,6 +166,11 @@ export const tadimlikHavuzu = sqliteTable(
kovaSlug: text("kova_slug").notNull(), kovaSlug: text("kova_slug").notNull(),
tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).notNull(), tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).notNull(),
kategoriSlug: text("kategori_slug").notNull(), kategoriSlug: text("kategori_slug").notNull(),
// Sihirbazdaki devlet/vakıf tercihine uyan varyant; "genel" = tip
// filtresi olmayan satır (tipsiz kullanıcı bunu görür).
tip: text("tip", { enum: ["genel", "devlet", "vakif"] })
.notNull()
.default("genel"),
programId: text("program_id").notNull(), programId: text("program_id").notNull(),
dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(), dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(),
gerekce: text("gerekce").notNull(), gerekce: text("gerekce").notNull(),
@@ -178,7 +184,7 @@ export const tadimlikHavuzu = sqliteTable(
uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(), uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(),
}, },
(t) => [ (t) => [
uniqueIndex("tadimlik_anahtar").on(t.kovaSlug, t.tur, t.kategoriSlug), uniqueIndex("tadimlik_anahtar").on(t.kovaSlug, t.tur, t.kategoriSlug, t.tip),
], ],
); );

View File

@@ -196,31 +196,38 @@ export function rankWindowFacets(
const satirlar = db const satirlar = db
.prepare( .prepare(
`SELECT isim, il, unitur FROM programs `SELECT isim, fakulte, il, unitur FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} >= ?` AND ${EFEKTIF_SIRA} >= ?`
) )
.all(tur, onlisans, altSinir) as { .all(tur, onlisans, altSinir) as {
isim: string; isim: string;
fakulte: string | null;
il: string | null; il: string | null;
unitur: string | null; unitur: string | null;
}[]; }[];
// isim → kategori eşlemesi pahalı (12 regex); tekrar eden isimler için önbellek // isim+fakülte → kategori eşlemesi pahalı (regexler); tekrar edenler için önbellek
const kategoriCache = new Map<string, string[]>(); const kategoriCache = new Map<string, string[]>();
const kategorileriGetir = (isim: string): string[] => { const kategorileriGetir = (
let k = kategoriCache.get(isim); isim: string,
fakulte: string | null
): string[] => {
const anahtar = `${isim}|${fakulte ?? ""}`;
let k = kategoriCache.get(anahtar);
if (!k) { if (!k) {
k = kategoriBul(isim); k = kategoriBul(isim, fakulte);
kategoriCache.set(isim, k); kategoriCache.set(anahtar, k);
} }
return k; return k;
}; };
const seciliKategoriler = secim.kategoriler ?? []; const seciliKategoriler = secim.kategoriler ?? [];
const kategoriUyar = (isim: string) => const kategoriUyar = (isim: string, fakulte: string | null) =>
seciliKategoriler.length === 0 || seciliKategoriler.length === 0 ||
kategorileriGetir(isim).some((k) => seciliKategoriler.includes(k)); kategorileriGetir(isim, fakulte).some((k) =>
seciliKategoriler.includes(k)
);
const seciliIller = new Set(secim.iller ?? []); const seciliIller = new Set(secim.iller ?? []);
const ilUyar = (il: string | null) => const ilUyar = (il: string | null) =>
seciliIller.size === 0 || (il != null && seciliIller.has(il)); seciliIller.size === 0 || (il != null && seciliIller.has(il));
@@ -230,12 +237,12 @@ export function rankWindowFacets(
let devlet = 0; let devlet = 0;
let vakif = 0; let vakif = 0;
for (const { isim, il, unitur } of satirlar) { for (const { isim, fakulte, il, unitur } of satirlar) {
// 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz) // 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz)
for (const k of kategorileriGetir(isim)) { for (const k of kategorileriGetir(isim, fakulte)) {
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1); kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1);
} }
if (!kategoriUyar(isim)) continue; if (!kategoriUyar(isim, fakulte)) continue;
// 2. adım: iller seçili kategorilerle süzülür // 2. adım: iller seçili kategorilerle süzülür
if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1); if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1);
if (!ilUyar(il)) continue; if (!ilUyar(il)) continue;
@@ -251,10 +258,11 @@ export function rankWindowFacets(
.filter((k) => k.adet >= 3) // gürültüyü ele .filter((k) => k.adet >= 3) // gürültüyü ele
.sort((a, b) => b.adet - a.adet); .sort((a, b) => b.adet - a.adet);
// Penceredeki programı olan TÜM iller döner (81 il + KKTC/yurtdışı);
// sıralama/gruplama UI'da yapılır. Kırpma yok: kullanıcı her ili görebilmeli.
const iller = [...ilSay.entries()] const iller = [...ilSay.entries()]
.map(([il, adet]) => ({ il, adet })) .map(([il, adet]) => ({ il, adet }))
.sort((a, b) => b.adet - a.adet) .sort((a, b) => b.adet - a.adet);
.slice(0, 15);
const uniturler: { grup: UniturGrubu; adet: number }[] = []; const uniturler: { grup: UniturGrubu; adet: number }[] = [];
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet }); if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });

View File

@@ -26,7 +26,7 @@ const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
{ {
kategori: "Mühendislik & Teknoloji", kategori: "Mühendislik & Teknoloji",
kural: kural:
/mühendis|yazılım|bilgisayar|bilişim|elektrik|elektronik|mekatronik|makine|makina|otomotiv|inşaat|endüstri|metalurji|malzeme|maden|jeoloji|jeofizik|petrol|enerji|robot|yapay zeka|yapay zekâ|siber|ağ teknolojileri|kontrol|otomasyon|uçak|havacılık|uzay|gemi|deniz ulaştırma|teknoloji|teknolojisi|programcı|elektrik-elektronik|nükleer|tekstil müh/, /mühendis|yazılım|bilgisayar|bilişim|elektrik|elektronik|mekatronik|makine|makina|otomotiv|inşaat|endüstri|metalurji|malzeme|maden|jeoloji|jeofizik|petrol|enerji|robot|yapay zeka|yapay zekâ|siber|ağ teknolojileri|kontrol|otomasyon|uçak|havacılık|uzay|gemi|deniz ulaştırma|teknoloji|teknolojisi|bilgisayar programcı|elektrik-elektronik|nükleer|tekstil müh/,
}, },
{ {
kategori: "Sağlık & Tıp", kategori: "Sağlık & Tıp",
@@ -35,13 +35,15 @@ const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
}, },
{ {
kategori: "Hukuk & Siyasal", kategori: "Hukuk & Siyasal",
// "güvenlik" bilinçli olarak dar: jenerik hali Siber/Sosyal/Bilgi
// Güvenliği'ni yanlışlıkla bu kategoriye alıyordu.
kural: kural:
/hukuk|adalet|siyaset|kamu yönetim|uluslararası ilişki|siyasal|güvenlik|ceza infaz|milletlerarası|diplomasi/, /hukuk|adalet|siyaset|kamu yönetim|uluslararası ilişki|siyasal|özel güvenlik|ceza infaz|milletlerarası|diplomasi/,
}, },
{ {
kategori: "İktisat & İşletme", kategori: "İktisat & İşletme",
kural: kural:
/işletme|iktisat|ekonomi|maliye|muhasebe|finans|bankacılık|sigortacılık|dış ticaret|lojistik|pazarlama|yönetim bilişim|insan kaynak|çalışma ekonomisi|uluslararası ticaret|bankacılık ve sigortacılık|halkla ilişkiler|reklamcılık|emlak|menkul|gümrük|büro yönetim|yönetici asistan/, /işletme|iktisat|iktisadi|ekonomi|maliye|muhasebe|finans|bankacılık|sigortacılık|sosyal güvenlik|dış ticaret|elektronik ticaret|lojistik|pazarlama|yönetim bilişim|havacılık yönetimi|enerji yönetimi|bilgi yönetimi|insan kaynak|çalışma ekonomisi|uluslararası ticaret|bankacılık ve sigortacılık|halkla ilişkiler|reklamcılık|emlak|menkul|gümrük|büro yönetim|yönetici asistan/,
}, },
{ {
kategori: "Eğitim & Öğretmenlik", kategori: "Eğitim & Öğretmenlik",
@@ -55,13 +57,15 @@ const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
}, },
{ {
kategori: "Fen & Temel Bilimler", kategori: "Fen & Temel Bilimler",
// Jenerik "bilim" yok: "Siyaset Bilimi" / "İletişim Bilimleri" /
// "Dilbilimi" gibi programları yanlışlıkla Fen yapıyordu.
kural: kural:
/matematik|fizik|kimya|biyoloji|istatistik|moleküler|genetik|biyoteknoloji|astronomi|aktüerya|fen bilim|bilim/, /matematik|fizik|kimya|biyoloji|istatistik|moleküler|genetik|biyoteknoloji|\bastronomi|aktüerya|fen bilim|veri bilim|adli bilim/,
}, },
{ {
kategori: "Sosyal & Beşeri Bilimler", kategori: "Sosyal & Beşeri Bilimler",
kural: kural:
/sosyoloji|tarih|coğrafya|felsefe|edebiyat|dil ve edebiyat|filoloji|arkeoloji|antropoloji|ilahiyat|din kültürü|dilbilim|sosyal hizmet|mütercim|çeviribilim|tercümanlık|türk dili|dünya dilleri|sanat tarihi|halkbilim|dinî/, /sosyoloji|tarih|coğrafya|felsefe|edebiyat|dil ve edebiyat|filoloji|arkeoloji|antropoloji|ilahiyat|din kültürü|dilbilim|sosyal hizmet|sosyal bilim|islam|mütercim|çeviribilim|tercümanlık|türk dili|dünya dilleri|sanat tarihi|halkbilim|dinî/,
}, },
{ {
kategori: "Sanat, Medya & İletişim", kategori: "Sanat, Medya & İletişim",
@@ -79,24 +83,108 @@ const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
}, },
{ {
kategori: "Tarım & Doğa", kategori: "Tarım & Doğa",
// "çevre" bağlamlı: jenerik hali "İç Mimarlık ve Çevre Tasarımı"nı
// yanlışlıkla Tarım'a alıyordu.
kural: kural:
/ziraat|tarım|tarla|bahçe|bitki|hayvan|su ürünleri|gıda|orman|çevre|balıkçılık|peyzaj|tohum|toprak|zootekni/, /ziraat|tarım|tarla|bahçe|bitki|hayvan|su ürünleri|gıda|orman|çevre müh|çevre sağlığı|çevre koruma|çevre temizli|çevresel|balıkçılık|peyzaj|tohum|toprak|zootekni/,
}, },
]; ];
/** Verilen program adının girdiği tüm kategorileri döner (0..n). */ // YÖK Atlas'ın kendi alan sinyali: programın bağlı olduğu fakülte adı.
export function kategoriBul(isim: string): Kategori[] { // Lisansta güçlü ("Mühendislik Fakültesi", "Hukuk Fakültesi"); önlisansta
const lower = isim.toLocaleLowerCase("tr-TR"); // jenerik ("... Meslek Yüksekokulu") olduğundan hiçbir kurala eşleşmez ve
// isim kuralları tek başına devrede kalır. Bir fakülte birden çok kategoriye
// eşlenebilir (ör. Fen-Edebiyat → Fen + Sosyal); program bazında ayrım
// isim∩fakülte kesişimiyle yapılır (bkz. kategoriBul).
const FAKULTE_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
{
kategori: "Mühendislik & Teknoloji",
kural: /mühendis|teknoloji|bilişim|havacılık ve uzay|denizcilik|uygulamalı bilimler/,
},
{
kategori: "Sağlık & Tıp",
kural: /tıp|sağlık|diş|eczacılık|hemşirelik|veteriner|spor bilim/,
},
{
kategori: "Hukuk & Siyasal",
kural: /hukuk|siyasal|iktisadi|idari|güvenlik/,
},
{
kategori: "İktisat & İşletme",
kural: /iktisadi|işletme|yönetim|ticaret|bankacılık|uygulamalı bilimler|finans/,
},
{ kategori: "Eğitim & Öğretmenlik", kural: /eğitim/ },
{
kategori: "Mimarlık & Tasarım",
kural: /mimarlık|tasarım|güzel sanatlar/,
},
{
kategori: "Fen & Temel Bilimler",
kural: /\bfen\b|fen-edebiyat|doğa bilimleri|temel bilimler/,
},
{
kategori: "Sosyal & Beşeri Bilimler",
kural:
/edebiyat|fen-edebiyat|insan ve toplum|sosyal bilim|ilahiyat|islami|beşeri/,
},
{
kategori: "Sanat, Medya & İletişim",
kural: /iletişim|güzel sanatlar|sanat|konservatuvar|müzik/,
},
{
kategori: "Psikoloji & Danışmanlık",
kural: /insan ve toplum|edebiyat|fen-edebiyat|sosyal bilim|eğitim/,
},
{ kategori: "Turizm & Gastronomi", kural: /turizm|gastronomi/ },
{
kategori: "Tarım & Doğa",
kural: /ziraat|orman|su ürünleri|tarım|doğa bilimleri|veteriner/,
},
];
function eslesenKategoriler(
kurallar: { kategori: Kategori; kural: RegExp }[],
metin: string,
): Kategori[] {
const lower = metin.toLocaleLowerCase("tr-TR");
const sonuc: Kategori[] = []; const sonuc: Kategori[] = [];
for (const { kategori, kural } of KATEGORI_KURALLARI) { for (const { kategori, kural } of kurallar) {
if (kural.test(lower)) sonuc.push(kategori); if (kural.test(lower)) sonuc.push(kategori);
} }
return sonuc; return sonuc;
} }
/**
* Verilen programın girdiği tüm kategorileri döner (0..n).
*
* Fakülte verilirse isim∩fakülte kesişimi alınır: "Siber Güvenlik
* Mühendisliği" isimden {Mühendislik, Hukuk} çıkarken Mühendislik
* Fakültesi'nde olduğu için Hukuk elenir. Kesişim boşsa ya da fakülte
* hiçbir kurala eşleşmiyorsa (jenerik MYO adları) isim kuralları geçerlidir;
* fakültesiz çağrı bugüne kadarki davranışla birebir aynıdır.
*/
export function kategoriBul(
isim: string,
fakulte?: string | null,
): Kategori[] {
const isimKats = eslesenKategoriler(KATEGORI_KURALLARI, isim);
if (fakulte && isimKats.length > 0) {
const fakulteKats = eslesenKategoriler(FAKULTE_KURALLARI, fakulte);
if (fakulteKats.length > 0) {
const kesisim = isimKats.filter((k) => fakulteKats.includes(k));
if (kesisim.length > 0) return kesisim;
}
}
return isimKats;
}
/** Bir programın seçili kategorilerden en az biriyle eşleşip eşleşmediği. */ /** Bir programın seçili kategorilerden en az biriyle eşleşip eşleşmediği. */
export function kategoriEslesir(isim: string, secililer: string[]): boolean { export function kategoriEslesir(
isim: string,
secililer: string[],
fakulte?: string | null,
): boolean {
if (secililer.length === 0) return true; if (secililer.length === 0) return true;
const kats = kategoriBul(isim); const kats = kategoriBul(isim, fakulte);
return kats.some((k) => secililer.includes(k)); return kats.some((k) => secililer.includes(k));
} }

View File

@@ -106,7 +106,7 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
const kategoriFiltre = (havuz: AdayProgram[]) => const kategoriFiltre = (havuz: AdayProgram[]) =>
kategoriler.length > 0 kategoriler.length > 0
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler)) ? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler, p.fakulte))
: havuz; : havuz;
// 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS) // 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS)

View File

@@ -46,15 +46,21 @@ export interface TadimlikSatir {
program: RaporSonuc["programlar"][string]; program: RaporSonuc["programlar"][string];
} }
export type TadimlikTip = "genel" | "devlet" | "vakif";
/** /**
* Kova × tür × kategori anahtarıyla tadımlık satırı seçer. Kategoriye özel * Kova × tür × kategori × tip anahtarıyla tadımlık satırı seçer. Kategoriye
* satır yoksa "genel"e düşer; o da yoksa null — çağıran taraf bileşeni hiç * özel satır yoksa "genel" kategoriye düşer; o da yoksa null — çağıran taraf
* render etmez (boş/generic tadımlık gösterilmez, PRD kuralı). * bileşeni hiç render etmez (boş/generic tadımlık gösterilmez, PRD kuralı).
*
* Tip fallback'i YOKTUR: kullanıcı devlet/vakıf seçtiyse yanlış tipte örnek
* asla gösterilmez — uygun satır yoksa tadımlık gizlenir.
*/ */
export async function tadimlikSec( export async function tadimlikSec(
sira: number, sira: number,
tur: PuanTuruKey, tur: PuanTuruKey,
kategoriSlug?: string, kategoriSlug?: string,
tip: TadimlikTip = "genel",
): Promise<TadimlikSatir | null> { ): Promise<TadimlikSatir | null> {
const kova = kovaBul(sira); const kova = kovaBul(sira);
const sluglar = [ const sluglar = [
@@ -66,6 +72,7 @@ export async function tadimlikSec(
eq(schema.tadimlikHavuzu.kovaSlug, kova.slug), eq(schema.tadimlikHavuzu.kovaSlug, kova.slug),
eq(schema.tadimlikHavuzu.tur, tur), eq(schema.tadimlikHavuzu.tur, tur),
eq(schema.tadimlikHavuzu.kategoriSlug, slug), eq(schema.tadimlikHavuzu.kategoriSlug, slug),
eq(schema.tadimlikHavuzu.tip, tip),
), ),
}); });
if (satir) { if (satir) {

View File

@@ -29,6 +29,8 @@ export function tercihDegerlendir(
const olumlu: string[] = []; const olumlu: string[] = [];
const celiskiler: string[] = []; const celiskiler: string[] = [];
// Manuel liste satırında fakülte taşınmıyor (ProgramListeSatiri bilinçli
// omit ediyor); eşleşme yalnızca isim kurallarıyla yapılır.
if (kategoriEslesir(program.isim, profil.secimler.kategoriler)) { if (kategoriEslesir(program.isim, profil.secimler.kategoriler)) {
olumlu.push("İlgi alanlarınla uyumlu"); olumlu.push("İlgi alanlarınla uyumlu");
} else { } else {