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.
# 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).
ENV NODE_OPTIONS="--max-old-space-size=1536"
ENV NODE_OPTIONS="--max-old-space-size=1024"
ENV BUILD_KAYNAK_KISITLI=1
RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \
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,
"tag": "0000_baslangic",
"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
? {
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
// hedefi byte cinsinden ayrıca verilir (1.5 GB).
turbopackMemoryLimit: 1_610_612_736,
// hedefi byte cinsinden ayrıca verilir (1 GB). 1.5 GB hedef + Node
// 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
// worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir.
cpus: 1,

View File

@@ -12,6 +12,7 @@
*/
import path from "node:path";
import Database from "better-sqlite3";
import { uniAdiNormalize, uniturOnar } from "./unitur-onar";
const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const PAGE_SIZE = 500;
@@ -169,7 +170,9 @@ async function main() {
insert.run(
id,
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.ilAdi ?? null,
r.fymkAdi ?? null,
@@ -205,6 +208,11 @@ async function main() {
console.log(
`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();
}

View File

@@ -1,11 +1,13 @@
/**
* Tadımlık havuzu batch üretimi: kova × puan türü × (kategori + "genel")
* kombinasyonları için 1'er örnek tercih satırı (gerekce/riskNotu/trendOzeti)
* üretip app.db'deki tadimlik_havuzu tablosuna yazar.
* Tadımlık havuzu batch üretimi: kova × puan türü × (kategori + "genel") ×
* (üniversite tipi: genel/devlet/vakıf) kombinasyonları için 1'er örnek
* tercih satırı (gerekce/riskNotu/trendOzeti) üretip app.db'deki
* tadimlik_havuzu tablosuna yazar.
*
* pnpm tadimlik # boş anahtarları doldurur (idempotent)
* pnpm tadimlik --force # mevcut satırları da yeniden üretir
* 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 --bekleme=13000 # LLM çağrıları arası bekleme (ms)
*
@@ -24,10 +26,16 @@ import {
type DilimKey,
type Program,
type PuanTuruKey,
type UniturGrubu,
} from "../src/types/yokatlas";
import { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler";
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 { 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
* (boşsa "garanti") kategoriye uyan, 2025'te en çok yerleşen alan program.
* Tanınır/dolu programlar tadımlıkta daha ikna edici.
* (boşsa "garanti") kategoriye ve üniversite tipine uyan, 2025'te en çok
* yerleşen alan program. Tanınır/dolu programlar tadımlıkta daha ikna edici.
*/
function programSec(
temsiliSira: number,
tur: PuanTuruKey,
kategori: string | null,
uniturGrubu?: UniturGrubu,
): { program: Program; dilim: DilimKey } | null {
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
? programlar.filter((p) => kategoriEslesir(p.isim, [kategori]))
? programlar.filter((p) => kategoriEslesir(p.isim, [kategori], p.fakulte))
: programlar;
if (uygunlar.length === 0) continue;
const secilen = [...uygunlar].sort(
@@ -152,6 +164,7 @@ async function main() {
const kovaFiltre = bayrak("kova");
const turFiltre = bayrak("tur");
const kategoriFiltre = bayrak("kategori");
const tipFiltre = bayrak("tip");
const turlar = (Object.keys(PUAN_TURLERI) as PuanTuruKey[]).filter(
(t) => !turFiltre || t === turFiltre,
@@ -161,6 +174,9 @@ async function main() {
...KATEGORILER.map((ad) => ({ ad: ad as string, slug: kategoriSlugBul(ad) })),
].filter((k) => !kategoriFiltre || k.slug === kategoriFiltre);
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(
(
@@ -169,9 +185,10 @@ async function main() {
kovaSlug: schema.tadimlikHavuzu.kovaSlug,
tur: schema.tadimlikHavuzu.tur,
kategoriSlug: schema.tadimlikHavuzu.kategoriSlug,
tip: schema.tadimlikHavuzu.tip,
})
.from(schema.tadimlikHavuzu)
).map((s) => `${s.kovaSlug}|${s.tur}|${s.kategoriSlug}`),
).map((s) => `${s.kovaSlug}|${s.tur}|${s.kategoriSlug}|${s.tip}`),
);
if (mockAktif()) {
@@ -185,55 +202,46 @@ async function main() {
for (const kova of kovalar) {
for (const tur of turlar) {
for (const kategori of kategoriler) {
const anahtar = `${kova.slug}|${tur}|${kategori.slug}`;
if (!force && mevcutlar.has(anahtar)) {
atlananMevcut++;
continue;
}
for (const tip of tipler) {
const anahtar = `${kova.slug}|${tur}|${kategori.slug}|${tip}`;
if (!force && mevcutlar.has(anahtar)) {
atlananMevcut++;
continue;
}
const secim = programSec(kova.temsiliSira, tur, kategori.ad);
if (!secim) {
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,
const secim = programSec(
kova.temsiliSira,
tur,
kategoriSlug: kategori.slug,
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,
})
.onConflictDoUpdate({
target: [
schema.tadimlikHavuzu.kovaSlug,
schema.tadimlikHavuzu.tur,
schema.tadimlikHavuzu.kategoriSlug,
],
set: {
kategori.ad,
tip === "genel" ? undefined : tip,
);
if (!secim) {
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,
kategoriSlug: kategori.slug,
tip,
programId: secim.program.id,
dilim: secim.dilim,
gerekce: metin.gerekce,
@@ -242,15 +250,35 @@ async function main() {
program: programBilgisi(secim.program),
ornekSira: kova.temsiliSira,
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++;
console.log(`✓ ${anahtar} → ${secim.program.isim} (${secim.dilim})`);
// Gerçek LLM'de rate limit dostu ardışık tempo
if (!mockAktif()) {
const bekleme = Number.parseInt(bayrak("bekleme") ?? "500", 10);
await new Promise((r) => setTimeout(r, bekleme));
uretilen++;
console.log(
`✓ ${anahtar} → ${secim.program.isim} (${secim.dilim})`,
);
// Gerçek LLM'de rate limit dostu ardışık tempo
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 });
}
// /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, {
limit: SAYFA_BOYU,
offset,
iller,
uniturGrubu,
});
// Tablo satırının kullanmadığı alanlar payload'a girmesin
return NextResponse.json({ programlar: programlar.map(programOzetle), toplam });

View File

@@ -29,7 +29,18 @@ export async function GET(istek: Request) {
? kategoriParam
: 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(
{ tadimlik },
// Satırlar kova granülaritesinde statik; 1 saat cache yeterli.

View File

@@ -28,6 +28,8 @@ export default function SonucPage({
tur?: string;
sihirbaz?: string;
hazir?: string;
il?: string | string[];
tip?: string;
}>;
}) {
return (
@@ -51,6 +53,22 @@ export default function SonucPage({
params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey)
: "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) {
return (
@@ -71,8 +89,14 @@ export default function SonucPage({
{/* 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
rozetinden değiştirilir */}
<SonucProgramTablosu sira={sira} tur={turKey} />
rozetinden değiştirilir. Sihirbaz seçimleri (il, devlet/
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.
Havuzda içerik yoksa bileşen hiç render edilmez. */}

View File

@@ -94,21 +94,28 @@ export function ProgramTablosu({
sonuclar,
adaySira,
tur,
filtreIller,
filtreTip,
}: {
sonuclar: RankResultsListe;
adaySira: number;
tur: PuanTuruKey;
filtreIller?: string[];
filtreTip?: "devlet" | "vakif";
}) {
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
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
// (puan türü seçici aynı bileşeni yeniden render eder, remount etmez)
// render sırasında sıfırlanır ki eski türün satırları yenisine karışmasın.
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür/filtre
// değişince (puan türü seçici aynı bileşeni yeniden render eder, remount
// 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 [yuklenen, setYuklenen] = useState<DilimKey | null>(null);
const veriAnahtari = `${adaySira}-${tur}`;
const veriAnahtari = `${adaySira}-${tur}-${iller.join(",")}-${filtreTip ?? ""}`;
const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari);
if (veriAnahtari !== oncekiAnahtar) {
setOncekiAnahtar(veriAnahtari);
@@ -129,9 +136,15 @@ export function ProgramTablosu({
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim);
try {
const res = await fetch(
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
);
const q = new URLSearchParams({
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;
const veri = (await res.json()) as { programlar: ProgramListeSatiri[] };
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir

View File

@@ -1,19 +1,31 @@
import { siraylaProgramAra } from "../liste-queries";
import { ProgramTablosu } from "./program-tablosu";
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
// yalnızca kompoze eder.
export function SonucProgramTablosu({
sira,
tur,
iller,
uniturGrubu,
}: {
sira: number;
tur: PuanTuruKey;
iller?: string[];
uniturGrubu?: UniturGrubu;
}) {
const sonuclar = siraylaProgramAra(sira, tur);
return <ProgramTablosu sonuclar={sonuclar} adaySira={sira} tur={tur} />;
const sonuclar = siraylaProgramAra(sira, tur, { iller, uniturGrubu });
return (
<ProgramTablosu
sonuclar={sonuclar}
adaySira={sira}
tur={tur}
filtreIller={iller}
filtreTip={uniturGrubu}
/>
);
}
export function SonucProgramTablosuSkeleton() {

View File

@@ -5,6 +5,7 @@ import type {
ProgramListeSatiri,
PuanTuruKey,
RankResultsListe,
UniturGrubu,
} from "@/types/yokatlas";
// 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,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
// /sonuc'un ham tablosu: sıralamayla ulaşılabilen programlar,
// 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(
sira: number,
tur: PuanTuruKey,
opts: { iller?: string[]; uniturGrubu?: UniturGrubu } = {},
): RankResultsListe {
const ham = searchByRank(sira, tur, { limitPerBucket: 20 });
const ham = searchByRank(sira, tur, {
limitPerBucket: 20,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
});
return {
hayal: ham.hayal.map(programOzetle),
dengeli: ham.dengeli.map(programOzetle),

View File

@@ -11,8 +11,8 @@ config:
look: handDrawn
---
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")
A --> B("2. adım · İl seçimi<br/>iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
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/>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")
C --> H("Aday havuzu<br/>elde kalan gerçek programlar")
H --> S{"Havuzda 24'ten az program mı kaldı?"}
@@ -42,7 +42,7 @@ export function HuniSemasi() {
return (
<ElCizimiSema
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={
<>
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
bağımsız değildir. İlk adımda yalnızca{" "}
<strong>sıralamanla ulaşabildiğin</strong>{" "}
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
program sayısına göre yeniden hesaplanır; üçüncü adımdaki
alanlar listelenir — bir bölümün hangi alana girdiği, adına ve
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.
Adımlar bitince elde kalan gerçek programlar bir{" "}
<strong>aday havuzu</strong>{" "}
Seçtiğin il ve devlet/vakıf tercihi, sonuç sayfasındaki ücretsiz
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
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

View File

@@ -27,10 +27,17 @@ import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import { PROFIL_DEGISTI_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
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";
/** 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({
sira,
tur,
@@ -42,29 +49,46 @@ export function TadimlikSatiri({
ilk: TadimlikSatir | null;
}) {
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
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
// Sihirbaz kategorisi ve devlet/vakıf tercihi yalnızca localStorage'da
// 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(() => {
const guncelle = () => {
const profil = tercihProfiliOku();
const kategori = profil?.secimler.kategoriler[0];
const slug = kategori ? turkishSlugify(kategori) : GENEL;
if (slug === yuklenenSlug.current) return;
yuklenenSlug.current = slug;
if (slug === GENEL) {
const tipTercihi = profil?.secimler.universiteTipi;
const tip: TadimlikTip =
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);
return;
}
fetch(
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
)
// SSR "genel" satırı seçilen tipe uymuyorsa fetch bitene kadar gizle
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((veri: { tadimlik: TadimlikSatir | null } | null) => {
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
if (yuklenenSlug.current !== slug) return;
if (veri?.tadimlik) setSatir(veri.tadimlik);
// Yarış koruması: bu arada başka seçim yüklendiyse dokunma
if (yuklenenAnahtar.current !== anahtar) return;
if (veri?.tadimlik) {
setSatir(veri.tadimlik);
} else if (tip !== GENEL) {
// Uygun tipte satır yok: yanlış tip göstermektense gizle
setSatir(null);
}
})
.catch(() => {});
};

View File

@@ -8,7 +8,7 @@
// üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir.
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 useMeasure from "react-use-measure";
import { toast } from "sonner";
@@ -27,11 +27,14 @@ export function Cip({
secili,
onClick,
children,
kucuk = false,
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
kucuk?: boolean;
}) {
const boyut = kucuk ? "px-2.5 py-1 text-xs" : "px-3.5 py-2 text-sm";
return (
<button
type="button"
@@ -39,8 +42,8 @@ export function Cip({
aria-pressed={secili}
className={
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-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-primary bg-primary font-medium text-white transition-colors duration-200 ${boyut}`
: `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}
@@ -93,6 +96,36 @@ export function SihirbazAdimlar({
() => [...canliFacetler.iller].sort((a, b) => b.adet - a.adet),
[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(
() => canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il)),
[canliFacetler.iller],
@@ -256,26 +289,85 @@ export function SihirbazAdimlar({
En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.
</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
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
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" : ""
}`}
>
{siraliIller.map((i) => (
{gorunenTurkiyeIlleri.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.5 text-xs opacity-70">{i.adet}</span>
<span className="ml-1 text-[10px] opacity-70">{i.adet}</span>
</Cip>
))}
</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 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400">
@@ -359,6 +451,10 @@ export function SihirbazAdimlar({
</Cip>
))}
</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>
</TransitionPanel>

View File

@@ -126,16 +126,29 @@ export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null {
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(
profil: Pick<TercihProfili, "sira" | "tur">,
profil: Pick<TercihProfili, "sira" | "tur"> & {
secimler?: SihirbazSecimleri;
},
ekstra?: Record<string, string>,
): string {
const q = new URLSearchParams({
sira: String(profil.sira),
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}`;
}

View File

@@ -154,8 +154,9 @@ export const reports = sqliteTable("reports", {
});
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback).
// Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir.
// Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
// ("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;
// prod şeması açılışta yalnızca drizzle/ altındaki migration'larla güncellenir.
export const tadimlikHavuzu = sqliteTable(
@@ -165,6 +166,11 @@ export const tadimlikHavuzu = sqliteTable(
kovaSlug: text("kova_slug").notNull(),
tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).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(),
dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(),
gerekce: text("gerekce").notNull(),
@@ -178,7 +184,7 @@ export const tadimlikHavuzu = sqliteTable(
uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(),
},
(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
.prepare(
`SELECT isim, il, unitur FROM programs
`SELECT isim, fakulte, il, unitur FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} >= ?`
)
.all(tur, onlisans, altSinir) as {
isim: string;
fakulte: string | null;
il: 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 kategorileriGetir = (isim: string): string[] => {
let k = kategoriCache.get(isim);
const kategorileriGetir = (
isim: string,
fakulte: string | null
): string[] => {
const anahtar = `${isim}|${fakulte ?? ""}`;
let k = kategoriCache.get(anahtar);
if (!k) {
k = kategoriBul(isim);
kategoriCache.set(isim, k);
k = kategoriBul(isim, fakulte);
kategoriCache.set(anahtar, k);
}
return k;
};
const seciliKategoriler = secim.kategoriler ?? [];
const kategoriUyar = (isim: string) =>
const kategoriUyar = (isim: string, fakulte: string | null) =>
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 ilUyar = (il: string | null) =>
seciliIller.size === 0 || (il != null && seciliIller.has(il));
@@ -230,12 +237,12 @@ export function rankWindowFacets(
let devlet = 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)
for (const k of kategorileriGetir(isim)) {
for (const k of kategorileriGetir(isim, fakulte)) {
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
if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1);
if (!ilUyar(il)) continue;
@@ -251,10 +258,11 @@ export function rankWindowFacets(
.filter((k) => k.adet >= 3) // gürültüyü ele
.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()]
.map(([il, adet]) => ({ il, adet }))
.sort((a, b) => b.adet - a.adet)
.slice(0, 15);
.sort((a, b) => b.adet - a.adet);
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
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",
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",
@@ -35,13 +35,15 @@ const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
},
{
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:
/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",
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",
@@ -55,13 +57,15 @@ const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
},
{
kategori: "Fen & Temel Bilimler",
// Jenerik "bilim" yok: "Siyaset Bilimi" / "İletişim Bilimleri" /
// "Dilbilimi" gibi programları yanlışlıkla Fen yapıyordu.
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",
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",
@@ -79,24 +83,108 @@ const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
},
{
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:
/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). */
export function kategoriBul(isim: string): Kategori[] {
const lower = isim.toLocaleLowerCase("tr-TR");
// YÖK Atlas'ın kendi alan sinyali: programın bağlı olduğu fakülte adı.
// Lisansta güçlü ("Mühendislik Fakültesi", "Hukuk Fakültesi"); önlisansta
// 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[] = [];
for (const { kategori, kural } of KATEGORI_KURALLARI) {
for (const { kategori, kural } of kurallar) {
if (kural.test(lower)) sonuc.push(kategori);
}
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. */
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;
const kats = kategoriBul(isim);
const kats = kategoriBul(isim, fakulte);
return kats.some((k) => secililer.includes(k));
}

View File

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

View File

@@ -46,15 +46,21 @@ export interface TadimlikSatir {
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
* satır yoksa "genel"e düşer; o da yoksa null — çağıran taraf bileşeni hiç
* render etmez (boş/generic tadımlık gösterilmez, PRD kuralı).
* Kova × tür × kategori × tip anahtarıyla tadımlık satırı seçer. Kategoriye
* özel satır yoksa "genel" kategoriye düşer; o da yoksa null — çağıran taraf
* 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(
sira: number,
tur: PuanTuruKey,
kategoriSlug?: string,
tip: TadimlikTip = "genel",
): Promise<TadimlikSatir | null> {
const kova = kovaBul(sira);
const sluglar = [
@@ -66,6 +72,7 @@ export async function tadimlikSec(
eq(schema.tadimlikHavuzu.kovaSlug, kova.slug),
eq(schema.tadimlikHavuzu.tur, tur),
eq(schema.tadimlikHavuzu.kategoriSlug, slug),
eq(schema.tadimlikHavuzu.tip, tip),
),
});
if (satir) {

View File

@@ -29,6 +29,8 @@ export function tercihDegerlendir(
const olumlu: 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)) {
olumlu.push("İlgi alanlarınla uyumlu");
} else {