Merge branch 'ozellik/tasavvuf-easter-egg' (v2 dahil) into main
Some checks failed
Deploy / deploy (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-04 13:35:05 +03:00
34 changed files with 3070 additions and 136 deletions

3
.gitignore vendored
View File

@@ -49,3 +49,6 @@ __pycache__/
# YÖK Atlas ham API çekimleri (scripts/refresh.ts; ~yıllık, büyük, yeniden üretilebilir)
data/ham/
# YÖK Atlas API ham dökümleri (refresh.ts, program-ozellik.ts)
/data/ham/

Binary file not shown.

View File

@@ -125,7 +125,7 @@ turuncu **tek tondur: `orange-500`** — CTA butonunun turuncusuyla birebir ayn
## 3. Tipografi
Dört yazı tipi, her birinin tek bir işi var:
Dört arayüz yazı tipi (+ yalnız Kur'an metni için bir Arapça yazı tipi), her birinin tek bir işi var:
| Sınıf | Yazı tipi | İş |
|---|---|---|
@@ -133,6 +133,7 @@ Dört yazı tipi, her birinin tek bir işi var:
| `font-sans` (varsayılan) | Clarity City 400–800 | Gövde, buton, form, tablo |
| `font-bricolage` | Bricolage Grotesque (opsz ekseni) | Vurgu: hero'da italik mavi span, logo yazısı, üst çubuktaki sayfa adı (§4.14), küçük etiketler/çipler |
| `font-mono` | Geist Mono | Yalnızca kod/markdown blokları. Arayüzde kullanılmaz |
| `amiriQuran.className` (`site-footer.tsx`) | Amiri Quran | Yalnızca Kur'an metni: footer'daki âyet (`lang="ar" dir="rtl"`). Arayüz metninde kullanılmaz; önceden yüklenmez, Arapça alt kümesi âyet çizilirken iner. Aşağıdaki "serif yasak" kuralı Latin arayüz metni içindir |
Ölçek (kopyala, uydurma):

View File

@@ -0,0 +1,6 @@
CREATE TABLE `danisman_profil` (
`user_id` text PRIMARY KEY NOT NULL,
`alanlar` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1790031029618,
"tag": "0004_shocking_caretaker",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1790287503094,
"tag": "0005_uneven_omega_red",
"breakpoints": true
}
]
}

View File

@@ -10,6 +10,7 @@
"lint": "eslint && pnpm tasarim:denetim",
"ingest": "tsx scripts/ingest.ts",
"refresh": "tsx scripts/refresh.ts",
"ozellik": "tsx scripts/program-ozellik.ts",
"detay": "tsx scripts/detay.ts",
"yokatlas:goc": "tsx scripts/yokatlas-goc.ts",
"gecmis-doldur": "tsx scripts/gecmis-doldur.ts",

282
scripts/program-ozellik.ts Normal file
View File

@@ -0,0 +1,282 @@
/**
* YÖK Atlas API'sindeki program ÖZELLİKLERİNİ `program_ozellik` tablosuna yazar:
* yıllık ücret, burs oranı, öğretim dili, öğretim türü, program ve üniversite
* akreditasyonu, kadro (prof/doç/dr. öğr. üyesi/arş. gör.), tercih baraj
* sırası, kampüs ilçesi ve yerleşme koşulu kodları. Koşul metinleri koda göre
* `kosul` tablosunda tek kopya durur.
*
* refresh.ts'ten AYRI betik: bu alanların yıl anlamı yok (kılavuzun o anki
* hâli), yıl kilidi gerekmez; ama --yil ve --db yine zorunlu ve tablo satırı
* hangi yılın kılavuzundan geldiğini `yil` kolonunda taşır. Yalnız `programs`
* tablosunda olan id'lere yazılır; eşleşmeyen kayıt sayısı raporlanır.
*
* Kaynağa nazik: sayfa başı 500 kayıt, istekler arası 0,5 sn, tek bağlantı.
* Çekilen ham kayıtlar data/ham/ozellik-<yil>-<tarih>.jsonl.gz'e yazılır;
* `--ham <dosya>` verilirse API'ye gidilmez, o dosya okunur.
*
* Kullanım:
* pnpm ozellik --yil 2026 --db /tmp/deneme.db
* pnpm ozellik --yil 2026 --db data/yokatlas.db --ham data/ham/ozellik-2026-2026-09-25.jsonl.gz
*/
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import zlib from "node:zlib";
import {
argOku,
calistir,
dbAc,
dbArgumani,
dur,
metaYaz,
yilArgumani,
type Db,
} from "./yokatlas-goc";
const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const PAGE_SIZE = 500;
const DELAY_MS = 500;
type HamKayit = Record<string, unknown>;
type ApiPage = {
content: HamKayit[];
totalPages: number;
totalElements: number;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fetchPage(page: number): Promise<ApiPage> {
const res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters: {
puanTuru: null,
universiteId: [],
birimGrupId: [],
ilKodu: [],
birimTuruId: null,
universiteTuru: null,
bursOraniId: null,
ogrenimTuruId: null,
kilavuzKodu: null,
minBasariSirasi: null,
maxBasariSirasi: null,
},
page,
size: PAGE_SIZE,
sortBy: "kilavuzKodu",
direction: "ASC",
}),
});
if (!res.ok) throw new Error(`API ${res.status} (sayfa ${page})`);
return (await res.json()) as ApiPage;
}
/** Pozitif tam sayı; 0 / boş / sayı değil → null (API "veri yok"u 0 ile verir). */
function intOrNull(v: unknown): number | null {
const n = typeof v === "string" ? Number.parseFloat(v) : (v as number);
return typeof n === "number" && Number.isFinite(n) && n > 0
? Math.round(n)
: null;
}
function metinOrNull(v: unknown): string | null {
if (typeof v !== "string") return null;
const t = v.trim();
return t ? t : null;
}
// ── Şema ─────────────────────────────────────────────────────────────────
export function ozellikTablolariniGarantiEt(db: Db): void {
db.exec(`
CREATE TABLE IF NOT EXISTS program_ozellik (
id TEXT PRIMARY KEY REFERENCES programs(id),
ucret INTEGER,
burs_orani TEXT,
ogrenim_dili TEXT,
ogrenim_turu TEXT,
akreditasyon TEXT,
uni_akreditasyon TEXT,
prof INTEGER,
doc INTEGER,
dou INTEGER,
argor INTEGER,
min_basari_sirasi INTEGER,
ilce TEXT,
kosul_kodlari TEXT,
yil INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kosul (
kod TEXT PRIMARY KEY,
metin TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_program_ozellik_dili ON program_ozellik (ogrenim_dili);
CREATE INDEX IF NOT EXISTS idx_program_ozellik_turu ON program_ozellik (ogrenim_turu);
`);
}
// ── Ham dosya ────────────────────────────────────────────────────────────
async function hamOku(dosya: string, yil: number): Promise<HamKayit[]> {
if (!fs.existsSync(dosya)) dur(`Ham dosya yok: ${dosya}`);
const rl = readline.createInterface({
input: fs.createReadStream(dosya).pipe(zlib.createGunzip()),
});
const kayitlar: HamKayit[] = [];
let metaVar = false;
for await (const satir of rl) {
if (!satir) continue;
const obj = JSON.parse(satir) as HamKayit;
if (obj._meta) {
const meta = obj._meta as { yil: number };
if (meta.yil !== yil) dur(`Ham dosya ${meta.yil} yılı için, --yil ${yil} verildi.`);
metaVar = true;
continue;
}
kayitlar.push(obj);
}
if (!metaVar) dur(`Ham dosyada _meta satırı yok: ${dosya}`);
return kayitlar;
}
async function apidenCek(yil: number): Promise<HamKayit[]> {
const ilk = await fetchPage(0);
const toplamSayfa = ilk.totalPages;
const kayitlar = [...ilk.content];
console.log(`API: ${ilk.totalElements} kayıt, ${toplamSayfa} sayfa`);
for (let p = 1; p < toplamSayfa; p++) {
await sleep(DELAY_MS);
const sayfa = await fetchPage(p);
kayitlar.push(...sayfa.content);
if (p % 10 === 0) console.log(` sayfa ${p}/${toplamSayfa}`);
}
const benzersiz = new Set(kayitlar.map((r) => String(r.kilavuzKodu))).size;
if (benzersiz !== ilk.totalElements) {
dur(`Benzersiz kod ${benzersiz} ≠ totalElements ${ilk.totalElements} (sayfalama kaydı)`);
}
const dizin = path.join(process.cwd(), "data", "ham");
fs.mkdirSync(dizin, { recursive: true });
const tarih = new Date().toISOString().slice(0, 10);
const dosya = path.join(dizin, `ozellik-${yil}-${tarih}.jsonl.gz`);
const gz = zlib.createGzip();
const out = fs.createWriteStream(dosya);
gz.pipe(out);
gz.write(JSON.stringify({ _meta: { yil, totalElements: ilk.totalElements, tarih, kaynak: "program-ozellik" } }) + "\n");
for (const r of kayitlar) gz.write(JSON.stringify(r) + "\n");
await new Promise<void>((res, rej) => {
out.on("finish", () => res());
out.on("error", rej);
gz.end();
});
console.log(`Ham dosya: ${dosya}`);
return kayitlar;
}
// ── Yazım ────────────────────────────────────────────────────────────────
function kosulKodlari(r: HamKayit): { kodlar: string[]; metinler: Map<string, string> } {
const metinler = new Map<string, string>();
const liste = Array.isArray(r.kosulList) ? (r.kosulList as Record<string, string>[]) : [];
for (const k of liste) {
for (const [kod, metin] of Object.entries(k)) {
if (typeof metin === "string" && metin.trim()) metinler.set(kod, metin.trim());
}
}
// `kosul` alanı "155,18,21" biçiminde; listedeki kodlarla birleşir.
const hamKodlar = typeof r.kosul === "string" ? r.kosul.split(",") : [];
const kodlar = Array.from(
new Set([...hamKodlar.map((k) => k.trim()).filter(Boolean), ...metinler.keys()])
).sort((a, b) => Number(a) - Number(b));
return { kodlar, metinler };
}
calistir(async () => {
const yil = yilArgumani();
const dbYolu = dbArgumani();
const hamArg = argOku("ham");
const kayitlar = hamArg
? await hamOku(path.resolve(process.cwd(), hamArg), yil)
: await apidenCek(yil);
const db = dbAc(dbYolu);
ozellikTablolariniGarantiEt(db);
const mevcut = new Set(
(db.prepare("SELECT id FROM programs").all() as { id: string }[]).map((r) => r.id)
);
const yaz = db.prepare(`
INSERT INTO program_ozellik (id, ucret, burs_orani, ogrenim_dili, ogrenim_turu,
akreditasyon, uni_akreditasyon, prof, doc, dou, argor, min_basari_sirasi,
ilce, kosul_kodlari, yil)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
ucret = excluded.ucret, burs_orani = excluded.burs_orani,
ogrenim_dili = excluded.ogrenim_dili, ogrenim_turu = excluded.ogrenim_turu,
akreditasyon = excluded.akreditasyon, uni_akreditasyon = excluded.uni_akreditasyon,
prof = excluded.prof, doc = excluded.doc, dou = excluded.dou, argor = excluded.argor,
min_basari_sirasi = excluded.min_basari_sirasi, ilce = excluded.ilce,
kosul_kodlari = excluded.kosul_kodlari, yil = excluded.yil
`);
const kosulYaz = db.prepare(
"INSERT INTO kosul (kod, metin) VALUES (?, ?) ON CONFLICT(kod) DO UPDATE SET metin = excluded.metin"
);
let yazilan = 0;
let eslesmeyen = 0;
const tumKosullar = new Map<string, string>();
db.transaction(() => {
for (const r of kayitlar) {
const id = String(r.kilavuzKodu ?? "");
if (!mevcut.has(id)) {
eslesmeyen++;
continue;
}
const { kodlar, metinler } = kosulKodlari(r);
for (const [k, m] of metinler) tumKosullar.set(k, m);
yaz.run(
id,
intOrNull(r.ucret),
metinOrNull(r.bursOraniAdi),
metinOrNull(r.ogrenimDiliAdi),
metinOrNull(r.ogrenimTuruAdi),
metinOrNull(r.akreditasyon),
metinOrNull(r.uniAkreditasyon),
intOrNull(r.prof),
intOrNull(r.doc),
intOrNull(r.dou),
intOrNull(r.arGor),
intOrNull(r.minBasariSirasi),
metinOrNull(r.ilceAdi),
kodlar.length ? kodlar.join(",") : null,
yil
);
yazilan++;
}
for (const [k, m] of tumKosullar) kosulYaz.run(k, m);
metaYaz(db, "program_ozellik_yil", yil);
metaYaz(db, "program_ozellik_tarih", new Date().toISOString().slice(0, 10));
})();
const ozet = db
.prepare(
`SELECT COUNT(*) AS n,
SUM(ucret IS NOT NULL) AS ucret, SUM(burs_orani IS NOT NULL) AS burs,
SUM(ogrenim_dili IS NOT NULL) AS dil, SUM(akreditasyon IS NOT NULL) AS akr,
SUM(prof IS NOT NULL) AS prof, SUM(min_basari_sirasi IS NOT NULL) AS baraj,
SUM(kosul_kodlari IS NOT NULL) AS kosul
FROM program_ozellik`
)
.get() as Record<string, number>;
db.pragma("wal_checkpoint(TRUNCATE)");
db.close();
console.log(
`Yazıldı ${yazilan}, programs'ta olmayan ${eslesmeyen}, koşul metni ${tumKosullar.size}.\n` +
`Doluluk: ${JSON.stringify(ozet)}`
);
});

View File

@@ -0,0 +1,29 @@
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { PROFIL_ALANLARI, profilSatirlari, type ProfilAlani } from "@/lib/danisman-profil";
/**
* Danışman profili — sohbetten `profil_guncelle` aracıyla yazılan alanlar.
* GET: alanlar + öğrenciye okunan satırlar. DELETE {alan}: tek alanı siler
* (KVKK: öğrenci her alanı görür ve tek tıkla siler). Yazma yalnız araç
* üzerinden; elle düzenleme sohbette "şunu değiştir" diyerek yapılır.
*/
export async function GET() {
const session = await getSession();
if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 });
const profil = await profilGetir(session.user.id);
return Response.json({ profil, satirlar: profilSatirlari(profil) });
}
export async function DELETE(request: NextRequest) {
const session = await getSession();
if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 });
const body = (await request.json().catch(() => null)) as { alan?: unknown } | null;
const alan = typeof body?.alan === "string" ? (body.alan as ProfilAlani) : null;
if (!alan || !PROFIL_ALANLARI.includes(alan)) {
return Response.json({ error: "Geçersiz alan." }, { status: 400 });
}
const profil = await profilAlanYaz(session.user.id, alan, null);
return Response.json({ profil, satirlar: profilSatirlari(profil) });
}

View File

@@ -4,12 +4,16 @@ import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import {
spendCreditForMessage,
bedelsizMesajKaydet,
grantCredits,
krediBittiIsaretle,
} from "@/lib/credits";
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { sohbetAkisi } from "@/lib/ai/cagri";
import { sohbetAkisiAraclarla } from "@/lib/ai/cagri";
import { ARACLAR, aracCalistirici, type AracBaglami } from "@/lib/ai/araclar";
import { profilGetir } from "@/lib/danisman-profil-db";
import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil";
import { listeOzetiCikar } from "@/lib/ai/rapor";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { RaporParams } from "@/features/rapor/types/rapor";
@@ -18,12 +22,36 @@ import { PUAN_TURLERI } from "@/types/yokatlas";
import { ACIK_SATIR } from "@/lib/rapor-maske";
const MAX_GECMIS = 12;
/**
* Profil kaydı öncesi "kaydedeyim mi?" onayı (KVKK Üretken YZ Rehberi:
* kişiselleştirme ayrı işleme faaliyeti; onaysız otomatik kayıt avukata
* sorulacak — docs/arastirma/2026-09-25-kisisellestirme/hukuk-ve-rakipler.md
* C.3/10). Bedeli: "evet" cevabı bir kredi. Kapatılırsa model doğrudan kaydeder.
*/
const PROFIL_ONAY_SOR = true;
/** Danışmanın onay sorusunda geçen sabit ibare; istemci de bunu görmez, yalnız sunucu arar. */
const ONAY_SORUSU = "profiline kaydedeyim mi";
/**
* Onay mesajı mı: son danışman mesajı "kaydedeyim mi?" diye sormuş VE aday
* kısa bir evet/hayır yazmış. Bu mesaj kredi harcamaz (Bilal, 26 Eyl 2026).
* Kısa tutulur ki "evet ama İzmir'de hukuk kaç sırayla alıyor" gibi gerçek
* sorular bedava geçmesin.
*/
function onayMesajiMi(mesaj: string, sonAsistan: string | undefined): boolean {
if (!sonAsistan || !sonAsistan.toLocaleLowerCase("tr-TR").includes(ONAY_SORUSU)) return false;
const m = mesaj.trim().toLocaleLowerCase("tr-TR");
if (m.length > 40) return false;
return /^(evet|olur|tamam|kaydet|kaydedebilirsin|kaydedelim|hayır|hayir|kaydetme|gerek yok|istemiyorum|şimdilik hayır)\b/.test(m);
}
const MAX_MESAJ_UZUNLUK = 2000;
function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
kilitli?: boolean;
profil?: DanismanProfili;
}): string {
const { params } = opts;
const secimVar =
@@ -50,7 +78,28 @@ KURALLAR:
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
- ASLA yerleşme garantisi verme; olasılık, yüzde ya da "ihtimal" dili kullanma —
güvenli / az riskli / riskli etiketleri ve taban sıralarıyla konuş.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
- Bilmediğin güncel veriyi uydurma: taban sırası, kontenjan, ücret, burs,
dil, akreditasyon, koşul ya da üniversite künyesi gerekince ARAÇLARI çağır
(program_ara, program_detay, universite_profili); araç veremiyorsa
"elimde veri yok" de ve YÖK Atlas'a bakmasını söyle.
- ARAÇ KURALLARI: araçlar yalnız okur; 24'lük listeyi araçla kurmazsın,
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır.
- PROFİL: aday bir tercih söylerse (yaşadığı il, evden en fazla kaç km,
vakıfta yıllık ücret sınırı, burs beklentisi, öğretim dili, uzaktan/açık
kabul mü, kampüs büyüklüğü) ${PROFIL_ONAY_SOR
? `HEMEN KAYDETME: cevabının sonunda tek cümleyle, tam bu ibareyle sor:
"Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz; listen ve
aramalar buna göre süzülür, istediğin zaman silebilirsin.)" Aday
evet/kaydet derse o zaman profil_guncelle ile kaydet ve "Kaydettim: …" de.
Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.`
: `profil_guncelle ile kaydet ve cevabının sonunda tek cümleyle söyle:
"Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`}
Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma:
sağlık/engel, din, aile durumu, gelir, cinsiyet, siyasi görüş. Aday
bunları kendiliğinden yazsa bile profile YAZMA; yalnız cevabında dikkate al.
- Kadın/erkek öğrenci oranı üniversite genelidir (bölüm bazında veri yok);
sorulursa bilgi olarak söyle, filtre ya da tavsiye ölçütü yapma.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
- Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır.
- GÜVENLİK, öteki kuralların önünde gelir: aday umutsuzluk, kendine zarar
@@ -77,6 +126,13 @@ Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satı
tercih soruları üzerinden yardımcı olmaya devam et.`,
);
}
const profilSatir = opts.profil ? profilSatirlari(opts.profil) : [];
if (profilSatir.length > 0) {
parcalar.push(
`ADAYIN SOHBETTE SÖYLEDİĞİ VE KAYDEDİLEN TERCİHLERİ (profil; program_ara bunları otomatik uygular):\n` +
profilSatir.map((s) => `- ${s.metin}`).join("\n"),
);
}
if (secimVar) {
parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
@@ -156,7 +212,7 @@ export async function POST(request: NextRequest) {
// Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel.
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
const [rapor, gecmisHam, kullanici] = await Promise.all([
const [rapor, gecmisHam, kullanici, profil] = await Promise.all([
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
kullanicininRaporu(session.user.id),
appDb
@@ -169,6 +225,7 @@ export async function POST(request: NextRequest) {
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
}),
profilGetir(session.user.id),
]);
if (!rapor?.result) {
return Response.json(
@@ -178,12 +235,18 @@ export async function POST(request: NextRequest) {
}
const gecmis = gecmisHam.reverse();
// Profil onayı ("kaydedeyim mi?" → "evet") kredi harcamaz; iade de yok.
const sonAsistan = [...gecmis].reverse().find((m) => m.role === "assistant")?.content;
const bedelsiz = onayMesajiMi(mesaj, sonAsistan);
// Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = await spendCreditForMessage({
userId: session.user.id,
content: mesaj,
clientMessageId,
});
const harcama = bedelsiz
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
: await spendCreditForMessage({
userId: session.user.id,
content: mesaj,
clientMessageId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(session.user.id);
@@ -213,11 +276,21 @@ export async function POST(request: NextRequest) {
].join("\n")
: tamOzet;
const raporParams = rapor.params as RaporParams;
const system = sistemPromptu({
params: rapor.params as RaporParams,
params: raporParams,
listeOzeti,
kilitli,
profil,
});
// Araçların bağlamı: aday sırası/puan türü (arama süzgeci) ve profil
// (profil_guncelle yazınca aynı tur içinde tazelenir).
const aracBaglami: AracBaglami = {
userId: session.user.id,
sira: raporParams.sira,
tur: raporParams.tur,
profil,
};
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
@@ -253,12 +326,14 @@ export async function POST(request: NextRequest) {
}
if (!anahtarVar()) {
await grantCredits({
userId: session.user.id,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
});
if (!bedelsiz) {
await grantCredits({
userId: session.user.id,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
});
}
console.error("[soru] Yapay Zeka anahtarı tanımlı değil");
return Response.json(
{
@@ -286,10 +361,13 @@ export async function POST(request: NextRequest) {
let metin = "";
try {
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
for await (const parca of sohbetAkisi({
for await (const parca of sohbetAkisiAraclarla({
system,
mesajlar,
maxTokens: 1024,
araclar: ARACLAR,
calistir: aracCalistirici(aracBaglami),
onArac: (ad) => console.info("[soru] araç:", ad),
})) {
metin += parca;
controller.enqueue(encoder.encode(parca));
@@ -319,12 +397,14 @@ export async function POST(request: NextRequest) {
});
} catch {}
}
await grantCredits({
userId,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
}).catch(() => {});
if (!bedelsiz) {
await grantCredits({
userId,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
}).catch(() => {});
}
}
console.error("[soru] stream hatası:", err);
controller.error(err);
@@ -337,6 +417,8 @@ export async function POST(request: NextRequest) {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
// İstemci bakiyeyi buna göre düşürür (onay mesajı bedelsiz)
"X-Kredi-Dusuldu": bedelsiz ? "0" : "1",
},
});
}

View File

@@ -12,8 +12,16 @@ export const metadata: Metadata = sayfaMetadata({
path: "/gizlilik",
});
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
// NOT: 25 Eyl 2026 taslağı — v2 dalı. Yayına almadan önce hukuki gözden
// geçirme gerekir (docs/arastirma/2026-09-25-kisisellestirme/hukuk-ve-rakipler.md
// C.2). Kodun gerçekte yaptığıyla birebir örtüşmesi zorunludur: sağlayıcı,
// alanlar ya da süreler değişirse bu metin aynı commit'te güncellenir.
// TODO(bilal): veri sorumlusunun ticari unvanı ve adresi (Tebliğ md. 5/1-a).
// TODO(avukat): yurt dışı aktarımın dayanağı (standart sözleşme / Türkiye'de
// model) kesinleşince "Hizmet sağlayıcılar" bölümüne yazılacak; saklama
// süreleri onaylanacak.
export default function GizlilikPage() {
const baslik = "font-heading text-lg font-bold text-slate-900";
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
@@ -22,7 +30,7 @@ export default function GizlilikPage() {
Gizlilik ve Kişisel Verilerin Korunması
</h1>
<p className="mt-2 text-sm text-slate-500">
Son güncelleme: 2 Ağustos 2026
Son güncelleme: 25 Eylül 2026
</p>
</Parallax>
@@ -33,66 +41,187 @@ export default function GizlilikPage() {
strength={4}
className="mt-8 space-y-6 text-sm leading-7 text-slate-700"
>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hangi verileri işliyoruz?
</h2>
<p className="mt-2">
KolayTercih; e-posta adresini (hesap oluşturma ve giriş için), YKS
başarı sıralamanı ve sihirbazda yaptığın seçimleri (tercih listeni
üretmek için), danışman sohbetine yazdığın mesajları (sorularını
yanıtlamak için) ve ödeme yaptığında sipariş kayıtlarını işler.
Kart bilgilerin bize hiç ulaşmaz; ödeme iyzico altyapısında
gerçekleşir.
</p>
<section className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
<h2 className={baslik}>Kısa özet</h2>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Senden yalnız e-posta, başarı sıralaman, sihirbaz seçimlerin,
danışmana yazdıkların ve danışmana söyleyip kaydettiğin
tercihler alınır. Ad, kimlik numarası, adres, cinsiyet, sağlık
ya da gelir bilgisi istenmez.
</li>
<li>
Bunlar yalnız tercih listeni üretmek ve sorularını yanıtlamak
için kullanılır; satılmaz, reklam için kullanılmaz.
</li>
<li>
Danışmana yazdıkların yanıt üretilmesi için yurt dışındaki
yapay zekâ sağlayıcısına gider; adın ve e-postan gitmez.
</li>
<li>
Kaydedilen tercihlerini listem sayfasında görür, tek tıkla
silersin. Hesabını sildirmek için yazman yeterli.
</li>
<li>
18 yaşından küçüksen bu metni velinle birlikte oku.
</li>
</ul>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ne için kullanıyoruz?
</h2>
<h2 className={baslik}>Veri sorumlusu</h2>
<p className="mt-2">
Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi
bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş
bağlantısı, ödeme bilgilendirmesi) göndermek için kullanılır.
Verilerini üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hizmet sağlayıcılar ve yurt dışına aktarım
</h2>
<p className="mt-2">
Hizmeti çalıştırabilmek için birkaç dış sağlayıcıdan yararlanıyoruz
ve verinin bir kısmı yalnızca ilgili iş için bu sağlayıcılara
iletilir. Giriş bağlantısı ve bilgilendirme e-postaları Resend
üzerinden gönderilir; e-posta adresin bu amaçla Avrupa
Birliği&rsquo;ndeki (İrlanda) sunucularda işlenir. Ödemeler,
Türkiye&rsquo;de yerleşik iyzico altyapısında gerçekleşir. Danışman
sohbetine yazdığın mesajlar, yanıt üretilebilmesi için
Anthropic&rsquo;in yapay zekâ servisine (ABD) iletilir; bu mesajlar
sağlayıcının yapay zekâ modellerini eğitmek için kullanılmaz.
KVKK açısından bunlardan e-posta ve yapay zekâ sağlayıcısına yapılan
iletim yurt dışına aktarım sayılır ve hizmetin sunulabilmesi için
zorunludur.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Saklama ve silme
</h2>
<p className="mt-2">
Hesabını sildirmek veya verilerinin bir kopyasını istemek için{" "}
Bu sitedeki kişisel verilerin veri sorumlusu KolayTercih&rsquo;tir
[şirket unvanı ve adresi eklenecek]. Sorularını ve başvurularını{" "}
<Link href="/iletisim" className="text-primary underline">
iletişim sayfasındaki
</Link>{" "}
adresten bize yazabilirsin; talepler 30 gün içinde
sonuçlandırılır.
e-posta adresine iletebilirsin.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Çerezler ve analitik
</h2>
<h2 className={baslik}>Hangi verileri, ne için, hangi dayanakla işliyoruz?</h2>
<ul className="mt-2 list-disc space-y-2 pl-5">
<li>
<strong>E-posta adresin</strong> — hesap oluşturmak, giriş
bağlantısı ve ödeme bilgilendirmesi göndermek için.
Dayanak: seninle kurulan hizmet sözleşmesinin kurulması ve
ifası.
</li>
<li>
<strong>YKS başarı sıralaman ve sihirbaz seçimlerin</strong>{" "}
(ilgi alanı, il, devlet/vakıf, öncelikler) — 24&rsquo;lük tercih
listeni üretmek için. Dayanak: sözleşmenin ifası.
</li>
<li>
<strong>Danışman sohbetine yazdığın mesajlar</strong> —
sorularını listen bağlamında yanıtlamak için. Dayanak:
sözleşmenin ifası. Sohbete ad, telefon, kimlik numarası,
sağlık ya da aile bilgisi yazma; danışmanın bunlara ihtiyacı
yok ve bunları saklamak istemiyoruz.
</li>
<li>
<strong>Danışman profilin</strong> — sohbette söylediğin ve{" "}
<em>&ldquo;kaydedeyim mi?&rdquo;</em> sorusuna evet dediğin
tercihler: yaşadığın il, evden en fazla kaç km, vakıfta yıllık
ücret üst sınırı, burs beklentin, öğretim dili, uzaktan/açık
öğretim kabulün, kampüs büyüklüğü tercihin. Bu alanlar sabit
bir listedir; serbest metin kaydedilmez, sağlık, din, gelir ya
da cinsiyet gibi bilgiler bu profile hiçbir zaman yazılmaz.
Profil yalnız senin listeni ve danışman aramalarını süzmek için
kullanılır; reklam, pazarlama ya da e-posta için kullanılmaz.
Profil boşken de hizmet tam çalışır. Listem sayfasındaki
&ldquo;Kaydettiklerim&rdquo; çiplerinden her alanı görür ve tek
tıkla silersin. Dayanak: senin talimatınla sözleşmenin ifası.
</li>
<li>
<strong>Sipariş kayıtların</strong> (paket alırsan) — fatura
ve muhasebe yükümlülüğü için. Kart bilgilerin bize hiç ulaşmaz;
ödeme iyzico altyapısında gerçekleşir. Dayanak: hukuki
yükümlülük.
</li>
<li>
<strong>Kullanım ölçümü</strong> — hangi sayfaların
görüntülendiği ve hangi adımların tamamlandığı (aşağıda
&ldquo;Çerezler ve analitik&rdquo;). Dayanak: siteyi
geliştirmeye yönelik meşru menfaatimiz.
</li>
</ul>
<p className="mt-2">
Verilerini üçüncü taraflara satmayız, reklam amacıyla
paylaşmayız. Program ve üniversite bilgileri (taban sıralamaları,
ücretler, öğrenci sayıları, kadın/erkek oranı) kişisel veri
değildir; YÖK Atlas ve YÖK İstatistik&rsquo;ten alınır ve
sayfalarda kaynağıyla gösterilir.
</p>
</section>
<section>
<h2 className={baslik}>Hizmet sağlayıcılar ve yurt dışına aktarım</h2>
<p className="mt-2">
Hizmeti çalıştırabilmek için birkaç dış sağlayıcıdan yararlanıyoruz
ve verinin yalnız ilgili iş için gereken kısmı bu sağlayıcılara
iletilir:
</p>
<ul className="mt-2 list-disc space-y-2 pl-5">
<li>
<strong>Resend (İrlanda)</strong> — giriş bağlantısı ve
bilgilendirme e-postaları; e-posta adresin bu amaçla Avrupa
Birliği&rsquo;ndeki sunucularda işlenir.
</li>
<li>
<strong>iyzico (Türkiye)</strong> — ödeme.
</li>
<li>
<strong>Yapay zekâ sağlayıcısı</strong> — danışman sohbetine
yazdığın mesajlar, listendeki program bilgileri ve kaydettiğin
tercihler, yanıt üretilebilmesi için OpenRouter (ABD) aracılığıyla
kullandığımız dil modelinin sağlayıcısına (bugün DeepSeek)
iletilir. Adın ve e-postan modele gönderilmez. Tercih listeni
üreten yapay zekâ da aynı yoldan çalışır; ona sıralaman,
seçimlerin ve aday program havuzu gider.
</li>
</ul>
<p className="mt-2">
KVKK açısından e-posta ve yapay zekâ sağlayıcısına yapılan iletim
yurt dışına aktarımdır ve hizmetin sunulabilmesi için gereklidir.
Sağlayıcı değişirse bu bölüm aynı gün güncellenir.
</p>
</section>
<section>
<h2 className={baslik}>Saklama ve silme</h2>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Sohbet mesajların ve danışman profilin: hesabın açık kaldığı
sürece; her ikisini de listem sayfasından kendin silebilirsin.
Tercih döneminin bitiminden 12 ay sonra sohbet mesajları ve
profil silinir.
</li>
<li>
Hesabını sildirme ya da verilerinin kopyasını isteme talebin
30 gün içinde sonuçlandırılır; işlenme sebebi ortadan kalkan
veri en geç 3 ay içinde silinir.
</li>
<li>
Sipariş ve fatura kayıtları vergi mevzuatı gereği 10 yıl
saklanır.
</li>
</ul>
</section>
<section>
<h2 className={baslik}>Hakların</h2>
<p className="mt-2">
KVKK&rsquo;nın 11. maddesi gereği bize başvurarak şunları
yapabilirsin: verilerinin işlenip işlenmediğini öğrenmek,
işlendiyse bilgi istemek, amacına uygun kullanılıp
kullanılmadığını öğrenmek, aktarıldığı üçüncü kişileri bilmek,
eksik ya da yanlış işlenmişse düzeltilmesini istemek,
silinmesini ya da yok edilmesini istemek, bu işlemlerin
aktarıldığı üçüncü kişilere bildirilmesini istemek, yalnızca
otomatik sistemlerle analiz edilmesi sonucu aleyhine bir sonuç
çıkmasına itiraz etmek ve kanuna aykırı işleme yüzünden zarara
uğradıysan tazminat istemek. Başvurunu{" "}
<Link href="/iletisim" className="text-primary underline">
iletişim sayfasındaki
</Link>{" "}
e-posta adresine yaz; en geç 30 gün içinde yanıtlarız.
</p>
</section>
<section>
<h2 className={baslik}>18 yaşından küçüksen</h2>
<p className="mt-2">
Bu metni velinle birlikte oku. Paket satın alınacaksa ödemeyi
velin yapmalı. Verilerin hiçbir şekilde pazarlama için
kullanılmaz.
</p>
</section>
<section>
<h2 className={baslik}>Çerezler ve analitik</h2>
<p className="mt-2">
Yalnızca oturumunu açık tutmak için zorunlu çerezler kullanılır;
reklam veya izleme çerezi kullanılmaz. Siteyi geliştirebilmek için
@@ -105,7 +234,8 @@ export default function GizlilikPage() {
yalnızca sorun giderme ile destek amacıyla kullanılır. Başarı
sıralaman analitiğe ham hâliyle
gönderilmez; yalnızca &ldquo;50-100 bin&rdquo; gibi geniş bir
aralık olarak kaydedilir.
aralık olarak kaydedilir. Danışman profilindeki değerler
analitiğe gitmez; yalnız hangi alanın kaydedildiği sayılır.
</p>
</section>
</Parallax>

View File

@@ -10,6 +10,7 @@ import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate";
import { DevPanelMount } from "@/components/dev/dev-panel-mount";
import { ConsoleAsr } from "@/components/console-asr";
import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo";
import "./globals.css";
@@ -117,6 +118,8 @@ export default function RootLayout({
{/* İlk "+" için sıra + sihirbaz kapısı */}
<TercihProfiliKapisiLazy />
<Toaster position="top-center" />
{/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */}
<ConsoleAsr />
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
{process.env.NODE_ENV !== "production" ? <DevPanelMount /> : null}
<JsonLd data={organizationJsonLd()} />

View File

@@ -2,31 +2,136 @@
import { useEffect } from "react";
let hasPlayed = false;
/**
* Konsol sürprizi: DevTools'u açan meraklıya sitenin piksel dilinde bir kum
* saati, Asr Sûresi (Arapça + meal) ve tasavvuftan bir vakit sözü. "Asr"
* zaman demek; kum saati de onu anlatır. Görünür arayüzde iz bırakmaz; kök
* layout'ta bir kez çalışır, sayfa geçişlerinde tekrar basmaz.
*
* DevTools açık ya da koyu temada olabilir: düz metne renk verilmez
* (konsolun kendi rengi kalır), yalnız iki zeminde de okunan marka renkleri
* kullanılır.
*
* Arapça metin Diyanet'in Kur'an-ı Kerim mushafından, meal Diyanet İşleri
* mealinden harfi harfine alındı (kuran.diyanet.gov.tr, âyet gruplaması
* aynen). Elle düzeltme yapılmaz; değişecekse kaynaktan yeniden kopyalanır.
*/
const consoleMessage = [
"%cبِسْمِ اللَّهِ الرَّحْمٰنِ الرَّحِيمِ",
"",
"وَالْعَصْرِۙ اِنَّ الْاِنْسَانَ لَف۪ي خُسْرٍۙ",
"اِلَّا الَّذ۪ينَ اٰمَنُوا وَعَمِلُوا الصَّالِحَاتِ",
"وَتَوَاصَوْا بِالْحَقِّ وَتَوَاصَوْا بِالصَّبْرِ",
"",
let basildi = false;
// Logodaki üç kare (public/kolay-tercih-mark.svg)
const MAVI = "#3b82f6";
const SEFTALI = "#fdc7a7";
const TURUNCU = "#ff5a00";
// slate-400: açık ve koyu konsolda okunan silik metin
const SILIK = "#94a3b8";
// Hero'daki dot-matrix ızgarasıyla kum saati: # çerçeve, g boş cam,
// o yığılmış kum, p akan kum. Üst hazne boşalıyor, alt hazne doluyor.
const KUM_SAATI = [
"###########",
" #ggggggg# ",
" #ooooooo# ",
" #ooooo# ",
" #ooo# ",
" #p# ",
" #p# ",
" #gpg# ",
" #ggpgg# ",
" #ggooogg# ",
" #ooooooo# ",
"###########",
];
const PIKSEL_RENK: Record<string, string> = {
"#": MAVI,
g: "rgba(59,130,246,0.16)",
o: TURUNCU,
p: SEFTALI,
};
// Piksel = arka planı boyanmış iki boşluk; satır aralığı ve sağ boşluk
// hero'daki gibi kareler arasında eşit nefes bırakır.
const HUCRE = "font-size:8px;line-height:14px;margin-right:4px;border-radius:2px";
// Kum saatinin sağında, boğaz hizasında duran satırlar
const YAN_YAZI: Record<number, [string, string]> = {
5: ["KolayTercih", `font-size:13px;font-weight:700;color:${MAVI}`],
6: ["Asr Sûresi", `font-size:12px;color:${SILIK}`],
};
const BESMELE = "بِسْمِ اللّٰهِ الرَّحْمٰنِ الرَّح۪يمِ";
const ASR = [
"وَالْعَصْرِۙ",
"اِنَّ الْاِنْسَانَ لَفٖي خُسْرٍۙ",
"اِلَّا الَّذٖينَ اٰمَنُوا وَعَمِلُوا الصَّالِحَاتِ وَتَوَاصَوْا بِالْحَقِّ وَتَوَاصَوْا بِالصَّبْرِ",
];
const MEAL = [
"Rahmân ve Rahîm olan Allah’ın adıyla.",
"",
"Asra yemîn olsun ki,",
"İnsan gerçekten ziyândadır.",
"Ancak iman edip sâlih ameller yapanlar, birbirlerine hakkı tavsiye",
"edenler ve birbirlerine sabretmeyi öğütleyenler müstesnâ!",
].join("\n");
"1-2. Andolsun zamana ki, insan gerçekten ziyan içindedir.",
"3. Ancak, iman edip de sâlih ameller işleyenler, birbirlerine hakkı tavsiye edenler, birbirlerine sabrı tavsiye edenler başka (Onlar ziyanda değillerdir).",
];
// Konsol sayfanın web fontlarını görmez; sistemde ne varsa o seçilir.
const ARAPCA_YAZI_TIPI =
"'Amiri Quran','Amiri','Scheherazade New','Noto Naskh Arabic','Geeza Pro','Traditional Arabic',serif";
function mesaj(): string[] {
let bicim = "";
const stiller: string[] = [];
const ekle = (metin: string, stil: string) => {
bicim += `%c${metin}`;
stiller.push(stil);
};
KUM_SAATI.forEach((satir, i) => {
for (const k of satir) {
const renk = PIKSEL_RENK[k];
ekle(" ", renk ? `${HUCRE};background:${renk}` : HUCRE);
}
const yan = YAN_YAZI[i];
if (yan) ekle(` ${yan[0]}`, `line-height:14px;${yan[1]}`);
bicim += "\n";
});
ekle(
`\n${BESMELE}\n${ASR.join("\n")}\n`,
`font-size:22px;line-height:2;font-family:${ARAPCA_YAZI_TIPI}`,
);
ekle(`\n${MEAL.join("\n")}\n`, "font-size:13px;line-height:1.7");
ekle("Asr Sûresi (103) · Diyanet İşleri meali\n\n", `font-size:11px;color:${SILIK}`);
ekle(
"«Vakit bir kılıçtır; sen onu kesmezsen o seni keser.»\n",
`font-size:13px;font-weight:600;line-height:1.8;color:${TURUNCU}`,
);
ekle(
"İmam Şâfiî’nin sûfîlerden öğrendiğini söylediği söz\n\n",
`font-size:11px;color:${SILIK}`,
);
ekle(
`Meraklısına: ${window.location.origin}/meraklisina`,
`font-size:11px;color:${SILIK}`,
);
return [bicim, ...stiller];
}
export function ConsoleAsr() {
useEffect(() => {
if (hasPlayed) return;
hasPlayed = true;
if (basildi) return;
basildi = true;
window.setTimeout(() => {
console.log(consoleMessage, "font-size:16px");
}, 250);
// Hidrasyon ve ilk etkileşimle yarışmasın: tarayıcı boşa çıkınca bas.
// Safari'de requestIdleCallback yok, orada kısa bir gecikme yeter.
const bas = () => console.log(...mesaj());
if (typeof window.requestIdleCallback === "function") {
window.requestIdleCallback(bas, { timeout: 3000 });
} else {
window.setTimeout(bas, 1000);
}
}, []);
return null;

View File

@@ -1,6 +1,17 @@
import Image from "next/image";
import Link from "next/link";
import { Amiri_Quran } from "next/font/google";
import { PixelMark } from "@/components/pixel-decor";
import { getAllBolumler } from "@/lib/katalog";
import { cn } from "@/lib/utils";
// Yalnız footer'daki âyet için (DESIGN.md §3). Önceden yüklenmez: Arapça
// alt kümesi (~45 KB) âyet çizilirken bir kez iner, sonra önbellekten gelir.
const amiriQuran = Amiri_Quran({
weight: "400",
subsets: ["arabic"],
preload: false,
});
/**
* Site footer'ı: yasal metin + SEO iç link kolonları. Server component —
@@ -125,6 +136,32 @@ export function SiteFooter() {
</div>
<div className="mt-10 border-t border-slate-200/70 pt-6 text-center text-sm leading-relaxed text-slate-500">
{/* Hz. Mûsâ'nın göreve çıkarken ettiği dua (Tâhâ 25–26); sınav
öncesi okunan dua da budur. "İşimi kolaylaştır" sitenin adıyla
aynı dilekten. Arapça Diyanet mushafından, meal Diyanet İşleri
mealinden harfi harfine; dua olarak okunduğu gibi baştaki
"kâle" (dedi ki) alınmadı. */}
<figure className="mx-auto mb-8 max-w-md">
<blockquote>
<p
lang="ar"
dir="rtl"
className={cn(
amiriQuran.className,
"text-2xl leading-loose text-slate-900",
)}
>
رَبِّ اشْرَحْ لٖي صَدْرٖيۙ وَيَسِّرْ لٖٓي اَمْرٖيۙ
</p>
<p className="mt-1 text-slate-600">
“Rabbim! Gönlüme ferahlık ver. İşimi bana kolaylaştır.”
</p>
</blockquote>
<figcaption className="mt-2 inline-flex items-center gap-1.5 text-xs text-slate-400">
<PixelMark className="text-primary" />
Tâhâ Sûresi 25–26 · Diyanet İşleri meali
</figcaption>
</figure>
<p>
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.

View File

@@ -77,15 +77,17 @@ export async function GirisKarti({
</div>
</div>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
<Link href="/gizlilik" className="underline hover:text-slate-700">
gizlilik metnindeki
</Link>{" "}
gibi işlenmesini ve{" "}
{/* Aydınlatma "okudum" (bilgilendirme), koşullar "kabul": KVKK İlke
Kararı 2026/347 aydınlatma ile rızayı ayırır (hukuk-ve-rakipler.md C.2/8). */}
Giriş yaparak{" "}
<Link href="/kosullar" className="underline hover:text-slate-700">
kullanım koşullarını
</Link>{" "}
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
kabul etmiş ve verilerinin nasıl işlendiğini anlatan{" "}
<Link href="/gizlilik" className="underline hover:text-slate-700">
gizlilik metnini
</Link>{" "}
okumuş olursun. KolayTercih yerleşme garantisi vermez.
</p>
</>
);

View File

@@ -16,9 +16,10 @@ flowchart TD
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 — yalnızca en son tercih kılavuzunda yer alanlar<br/>kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez")
C --> P("Danışmana söylediklerin<br/>öğretim dili, uzaktan/örgün, burs, yıllık ücret sınırı, evden uzaklık — sohbette söyleyip kaydettiklerin varsa havuzu bunlar da süzer")
P --> H("Aday havuzu<br/>elde kalan gerçek programlar — yalnızca en son tercih kılavuzunda yer alanlar<br/>kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez")
H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce danışmana söylediklerin, sonra il, sonra alan — listede açıkça belirtilir")
G --> H
H --> U("Havuz yine de dar kalırsa<br/>liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir")
S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
@@ -34,7 +35,7 @@ flowchart TD
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
class H,Y havuz
class H,Y,P havuz
class G,U uyari
class L1 hayal
class L2 dengeli
@@ -44,12 +45,14 @@ export function HuniSemasi() {
return (
<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; havuza yalnızca en son tercih kılavuzunda yer alan programlar girer, kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez; 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 güvenli 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, danışmana söyleyip kaydettiğin tercihler (öğretim dili, uzaktan/örgün, burs, yıllık ücret sınırı, evden uzaklık) varsa onlarla da aday havuzuna daraltılır; havuza yalnızca en son tercih kılavuzunda yer alan programlar girer, kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez; 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 önce danışmana söylediklerin, sonra il, sonra alan sırasıyla 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 güvenli 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={
<>
Havuza yalnızca en son tercih kılavuzunda yer alan programlar girer;
kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez. Her
adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
adım havuzu biraz daha daraltır; danışmana söyleyip kaydettiğin
tercihler de (dil, ücret, uzaklık gibi) havuzu süzer. Yapay zekâ
listeyi bu havuzun{" "}
<span className="font-medium text-slate-700">
dışına asla çıkmadan
</span>{" "}

View File

@@ -114,15 +114,20 @@ export function MeraklisinaIcerik() {
göre yeniden hesaplanarak listelenir; üçüncü adımdaki
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
Seçtiğin il ve devlet/vakıf tercihi, sonuç sayfasındaki ücretsiz
tabloya da uygulanır. Adımlar bitince elde kalan gerçek
tabloya da uygulanır. Danışmanla konuşurken söylediğin ve
&ldquo;Kaydettiklerim&rdquo; çiplerinde duran tercihler (öğretim
dili, uzaktan ya da örgün, burs, yıllık ücret sınırı, evden
uzaklık) varsa havuzu onlar da süzer; bir çipi silersen bir
sonraki liste onsuz kurulur. Adımlar bitince elde kalan gerçek
programlar bir <strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Havuza yalnızca{" "}
<strong>en son tercih kılavuzunda yer alan</strong>{" "}
programlar girer: kapanmış ya da kodu değiştiği için eski kaydı
kalmış bir program sana önerilmez. Aşağıdaki şema akışın tamamını
gösterir — havuz 24&apos;ün altına düşerse filtrelerin nasıl
gevşetildiği, yine de yetmezse listenin eldeki programlarla, sana
gösterir — havuz 24&apos;ün altına düşerse filtrelerin hangi
sırayla gevşetildiği (önce danışmana söylediklerin, sonra il,
sonra alan), yine de yetmezse listenin eldeki programlarla, sana
açıkça belirtilerek nasıl kurulduğu da dahil.
</p>
<HuniSemasi />

View File

@@ -31,6 +31,7 @@ import {
type DanismanOzeti,
type IlkTercihYorumu,
type Mesaj,
type ProfilSatiri,
} from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import {
@@ -55,6 +56,7 @@ export function ListemGovde({
kredi,
raporKredi,
sohbetGecmisi,
danismanProfili,
ozet,
ilkYorum,
kalanHak,
@@ -76,6 +78,8 @@ export function ListemGovde({
* kapıdan geçtiği için aynı bedeli gösterir. */
raporKredi: number;
sohbetGecmisi: Mesaj[];
/** Danışmanın kaydettiği tercihler (sunucudan; çipler bununla açılır) */
danismanProfili: ProfilSatiri[];
ozet?: DanismanOzeti;
/** Sohbet boşken açılışta gösterilen 1. tercih yorumu */
ilkYorum?: IlkTercihYorumu;
@@ -124,6 +128,12 @@ export function ListemGovde({
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi"
: `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`,
];
satirlar.push(
"Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs)",
);
satirlar.push(
"Sorunca veriden okur: taban sıraları, kontenjan, ücret/burs, dil, akreditasyon, koşullar, üniversite künyesi",
);
if (params.kategoriler?.length) {
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
}
@@ -318,6 +328,7 @@ export function ListemGovde({
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
@@ -329,6 +340,7 @@ export function ListemGovde({
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}

View File

@@ -7,6 +7,8 @@ import {
cerezdenTercihProfili,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import { kullanicininRaporu, sohbetGecmisiGetir } from "../rapor-queries";
import { profilGetir } from "@/lib/danisman-profil-db";
import { profilSatirlari } from "@/lib/danisman-profil";
import { URUNLER, MAX_REVIZYON, RAPOR_KREDI } from "@/lib/credits";
import type {
MaskeliRapor,
@@ -53,10 +55,11 @@ export async function ListemIcerik({
? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}`
: "/listem";
const session = await verifySession(geriYol);
const [user, satir, sohbetGecmisi] = await Promise.all([
const [user, satir, sohbetGecmisi, danismanProfili] = await Promise.all([
getCurrentUser(),
kullanicininRaporu(session.user.id),
sohbetGecmisiGetir(session.user.id),
profilGetir(session.user.id),
]);
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
@@ -171,6 +174,7 @@ export async function ListemIcerik({
user.hasPaket ? 0 : RAPOR_KREDI
}
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
danismanProfili={profilSatirlari(danismanProfili)}
ozet={danismanOzeti}
ilkYorum={ilkYorum}
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}

View File

@@ -4,7 +4,7 @@ import { startTransition, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2 } from "lucide-react";
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
@@ -42,6 +42,10 @@ import {
import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
import { olay } from "@/lib/analitik";
import type { ProfilAlani } from "@/lib/danisman-profil";
/** /api/soru/profil'in döndüğü, öğrenciye okunan profil satırı. */
export type ProfilSatiri = { alan: ProfilAlani; metin: string };
export interface Mesaj {
id: string;
@@ -123,8 +127,11 @@ export function SohbetClient({
ozet,
ilkYorum,
bildikleri,
initialProfil,
}: {
baslangicKredi: number;
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
initialProfil?: ProfilSatiri[];
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
initialMesajlar?: Mesaj[];
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
@@ -146,8 +153,57 @@ export function SohbetClient({
const [kredi, setKredi] = useState(baslangicKredi);
const [temizleAcik, setTemizleAcik] = useState(false);
const [temizleniyor, setTemizleniyor] = useState(false);
// Danışmanın sohbetten çıkarıp kaydettiği tercihler (kapalı listeli profil).
// Başlığın altında çip olarak durur; çarpı tek alanı siler. Cevap sonrası
// yeniden çekilir: model profil_guncelle çağırdıysa yeni çip burada belirir.
const [profil, setProfil] = useState<ProfilSatiri[]>(initialProfil ?? []);
const router = useRouter();
async function profiliCek(oncekiAlanlar: Set<ProfilAlani>) {
try {
const res = await fetch("/api/soru/profil");
if (!res.ok) return;
const data = (await res.json()) as { satirlar?: ProfilSatiri[] };
const satirlar = data.satirlar ?? [];
setProfil(satirlar);
for (const s of satirlar) {
// Analitiğe yalnız alan adı gider, değer gitmez (KVKK)
if (!oncekiAlanlar.has(s.alan)) olay("profil_alani_kaydedildi", { alan: s.alan });
}
} catch {}
}
useEffect(() => {
// Listem sayfası profili sunucudan indirir; /sonuc popup'ı gibi geç mount
// olan yerler çeker (ttlMs 0: yalnız in-flight dedup, DELETE sonrası
// bayat kalmasın).
if (initialProfil != null) return;
onbellekliJsonGetir<{ satirlar?: ProfilSatiri[] }>("/api/soru/profil", {
ttlMs: 0,
})
.then((data) => setProfil(data?.satirlar ?? []))
.catch(() => {});
}, [initialProfil]);
async function profilAlaniSil(alan: ProfilAlani) {
const onceki = profil;
setProfil((p) => p.filter((s) => s.alan !== alan));
try {
const res = await fetch("/api/soru/profil", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ alan }),
});
if (!res.ok) throw new Error("silinemedi");
const data = (await res.json()) as { satirlar?: ProfilSatiri[] };
setProfil(data.satirlar ?? []);
olay("profil_alani_silindi", { alan });
} catch {
setProfil(onceki);
toast.error("Silinemedi, tekrar dener misin?");
}
}
// Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı
// (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın.
useEffect(() => {
@@ -213,7 +269,8 @@ export function SohbetClient({
return;
}
setKredi((k) => k - 1);
// Profil onayı ("kaydedeyim mi?" → "evet") sunucuda bedelsizdir
if (res.headers.get("X-Kredi-Dusuldu") !== "0") setKredi((k) => k - 1);
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
olay("danisman_sorusu", {
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
@@ -236,6 +293,8 @@ export function SohbetClient({
// Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
// Danışman bu cevapta profil_guncelle çağırmış olabilir
void profiliCek(new Set(profil.map((s) => s.alan)));
} catch {
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
@@ -358,6 +417,35 @@ export function SohbetClient({
</div>
</div>
{profil.length > 0 ? (
<div
role="group"
aria-label="Danışmanın kaydettiği tercihlerin"
className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-2.5"
>
<span className="text-xs font-semibold text-slate-500">
Kaydettiklerim
</span>
{profil.map((s) => (
// Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil.
<span
key={s.alan}
className="inline-flex h-8 items-center gap-1 rounded-full border border-slate-200 bg-white pr-1 pl-3 text-xs font-semibold text-slate-700"
>
{s.metin}
<button
type="button"
onClick={() => profilAlaniSil(s.alan)}
aria-label={`Sil: ${s.metin}`}
className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-slate-500 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-orange-500 active:scale-[0.96]"
>
<X className="size-3.5" aria-hidden />
</button>
</span>
))}
</div>
) : null}
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
<DialogContent>
<DialogHeader>
@@ -576,6 +664,11 @@ export function SohbetClient({
</PromptInputAction>
</PromptInputActions>
</PromptInput>
{/* KVKK: sohbete kişisel bilgi yazılmasın (hukuk-ve-rakipler.md C.5/22) */}
<p className="mt-2 px-1 text-xs leading-5 text-slate-500">
Ad, telefon, kimlik numarası ya da sağlık bilgisi yazma; danışmanın
bunlara ihtiyacı yok.
</p>
</>
)}
</div>

View File

@@ -6,6 +6,7 @@ import {
uretimKilidiBirak,
} from "@/lib/uretim-kilidi";
import { getSession, getCurrentUser } from "@/lib/session";
import { profilGetir } from "@/lib/danisman-profil-db";
import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
@@ -214,7 +215,9 @@ async function listeOlusturKilitli(
let sonuc: RaporSonuc;
try {
sonuc = await raporUret(params);
// Danışmana söylenen tercihler (varsa) havuzu süzer; okunamazsa profilsiz üret
const profil = await profilGetir(user.id).catch(() => undefined);
sonuc = await raporUret(params, undefined, undefined, profil);
} catch (err) {
// Üretim başarısız → krediyi iade et. Satır paketlide de yazılır (delta 0):
// yukarıdaki DUPLICATE dalı "önceki deneme başarısız mıydı" sorusunu tam
@@ -347,10 +350,12 @@ export async function listeRevize(input: {
let sonuc: RaporSonuc;
try {
const profil = await profilGetir(user.id).catch(() => undefined);
sonuc = await raporUret(
params,
feedback.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
profil,
);
} catch (err) {
// İade satırı burada da yazılır (paketlide delta 0) — DUPLICATE dalındaki

456
src/lib/ai/araclar.ts Normal file
View File

@@ -0,0 +1,456 @@
/**
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
*
* İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
* (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
* üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
*
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
*/
import type OpenAI from "openai";
import {
dilimAraligi,
EFEKTIF_SIRA,
getDb,
programGetir,
programSatiri,
SELECT_COLS,
type ProgramHamSatiri,
} from "@/lib/db";
import {
kosulMetinleri,
ogretimUyesiToplami,
ozellikOzeti,
programOzellikGetir,
programOzellikleriGetir,
type ProgramOzellik,
} from "@/lib/program-ozellik";
import { getAllUniversiteler, getUniversiteBySlug } from "@/lib/katalog";
import { kadinYuzdesi, ogrenciBasinaUye, YOK_ISTATISTIK_DONEM } from "@/lib/yok-istatistik";
import { URAP_DONEM, URAP_TOPLAM } from "@/lib/urap-siralama";
import { turkishSlugify } from "@/lib/slug";
import { DILIM_ETIKET } from "@/lib/risk";
import { YILLAR } from "@/lib/veri-yillari";
import { PUAN_TURLERI, type Program, type PuanTuruKey } from "@/types/yokatlas";
import {
PROFIL_ALAN_ACIKLAMA,
PROFIL_ALANLARI,
PROFIL_SECENEKLERI,
profilSatirlari,
type DanismanProfili,
type ProfilAlani,
} from "@/lib/danisman-profil";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
export type AracBaglami = {
userId: string;
sira: number;
tur: PuanTuruKey;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
};
export type AracTanimi = OpenAI.Chat.Completions.ChatCompletionTool;
const ARAMA_LIMIT = 8;
export const ARACLAR: AracTanimi[] = [
{
type: "function",
function: {
name: "program_ara",
description:
"Adayın puan türündeki programları veriden arar (YÖK Atlas, son yerleştirme). Bölüm adı, üniversite, il, öğretim dili, burs ve yıllık ücret üst sınırına göre süzer; adayın sırasına en yakın programları döner. Sayı uydurmak yerine bunu çağır.",
parameters: {
type: "object",
properties: {
arama: { type: "string", description: "Bölüm adı parçası, ör. 'bilgisayar mühendisliği', 'hukuk'" },
universite: { type: "string", description: "Üniversite adı parçası" },
il: { type: "string", description: "İl adı, ör. 'İzmir'" },
dil: { type: "string", enum: ["Türkçe", "İngilizce"], description: "Öğretim dili" },
burs: {
type: "string",
enum: ["Burslu", "%50 İndirimli", "%25 İndirimli", "Ücretli"],
description: "Vakıf burs oranı",
},
ucret_ust: { type: "integer", description: "Yıllık ücret üst sınırı (TL); devlet programları her zaman geçer" },
dilim: {
type: "string",
enum: ["hayal", "dengeli", "guvenli"],
description: "Yalnız bu dilimdekiler: hayal = adayın sırasının üstünde kapananlar, dengeli = sırasının 1,4 katına kadar, guvenli = daha altı",
},
},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "program_detay",
description:
"Tek programın ayrıntısı: yıllara göre taban sırası ve kontenjan, ücret/burs, dil, öğretim türü, akreditasyon, kadro, tercih barajı ve kılavuzdaki yerleşme koşulları (sağlık şartı, mülakat vb.).",
parameters: {
type: "object",
properties: {
program_id: { type: "string", description: "program_ara sonucundaki id (kılavuz kodu)" },
},
required: ["program_id"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "universite_profili",
description:
"Üniversitenin künyesi: il, devlet/vakıf, program sayısı, URAP araştırma sırası, örgün öğrenci sayısı, hoca başına öğrenci ve kadın/erkek öğrenci oranı (üniversite geneli, bölüm bazında veri yok).",
parameters: {
type: "object",
properties: {
universite: { type: "string", description: "Üniversite adı, ör. 'Boğaziçi', 'ODTÜ', 'Ege Üniversitesi'" },
},
required: ["universite"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "profil_guncelle",
description:
"Adayın sohbette söylediği bir tercihi profile kaydeder ya da siler (deger null). YALNIZ şu alanlar: " +
PROFIL_ALANLARI.map((a) => `${a} (${PROFIL_ALAN_ACIKLAMA[a]})`).join("; ") +
". Sağlık, engel, din, aile, gelir, cinsiyet gibi bilgileri ASLA kaydetme. Kaydettikten sonra cevabında tek cümleyle 'Şunu kaydettim: …' de.",
parameters: {
type: "object",
properties: {
alan: { type: "string", enum: PROFIL_ALANLARI },
deger: {
type: ["string", "null"],
description:
"evIl: il adı; maxKm: sayı (km); ucretUst: sayı (TL, yıllık); " +
`burs: ${Object.keys(PROFIL_SECENEKLERI.burs).join("|")}; ` +
`dil: ${Object.keys(PROFIL_SECENEKLERI.dil).join("|")}; ` +
`ogretimTuru: ${Object.keys(PROFIL_SECENEKLERI.ogretimTuru).join("|")}; ` +
`kampus: ${Object.keys(PROFIL_SECENEKLERI.kampus).join("|")}; null = alanı sil`,
},
},
required: ["alan", "deger"],
additionalProperties: false,
},
},
},
];
// ── Yardımcılar ──────────────────────────────────────────────────────────
function dilimAdi(sira: number, efektif: number | null): string {
if (efektif == null) return "Veri yetersiz";
if (efektif < sira) return DILIM_ETIKET.hayal;
if (efektif <= dilimAraligi(sira, "dengeli").ust!) return DILIM_ETIKET.dengeli;
return DILIM_ETIKET.garanti;
}
function programOzeti(p: Program, sira: number, o?: ProgramOzellik) {
return {
id: p.id,
program: p.isim,
universite: p.universite,
il: p.il,
tur: p.unitur,
tabanSira: p.efektifSira,
dilim: dilimAdi(sira, p.efektifSira),
kontenjan: p.kontenjanSon,
ozellik: o ? ozellikOzeti(o) : [],
};
}
/** SQLite LIKE Türkçe büyük/küçük harfi bilmez: hem yazıldığı gibi hem başlık düzeni denenir. */
function likeVaryantlari(q: string): string[] {
const t = q.trim();
const baslik = t
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((w) => (w ? w[0].toLocaleUpperCase("tr-TR") + w.slice(1) : w))
.join(" ");
return Array.from(new Set([t, baslik, t.toLocaleUpperCase("tr-TR")]));
}
function likeKosulu(kolon: string, q: string): { sql: string; args: string[] } {
const v = likeVaryantlari(q);
return {
sql: `(${v.map(() => `${kolon} LIKE ?`).join(" OR ")})`,
args: v.map((x) => `%${x}%`),
};
}
// ── Araç gövdeleri ───────────────────────────────────────────────────────
type ProgramAraArg = {
arama?: string;
universite?: string;
il?: string;
dil?: string;
burs?: string;
ucret_ust?: number;
dilim?: "hayal" | "dengeli" | "guvenli";
};
function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): ProgramAraSonuc {
const db = getDb();
const profil: DanismanProfili = profilUygula ? b.profil : {};
const kosullar: string[] = ["tur = ?", "onlisans = ?", `${EFEKTIF_SIRA} IS NOT NULL`];
const args: (string | number)[] = [PUAN_TURLERI[b.tur], b.tur === "tyt" ? 1 : 0];
const uygulanan: string[] = [];
if (a.arama) {
const k = likeKosulu("isim", a.arama);
kosullar.push(k.sql);
args.push(...k.args);
uygulanan.push(`bölüm ~ "${a.arama}"`);
}
if (a.universite) {
const k = likeKosulu("universite", a.universite);
kosullar.push(k.sql);
args.push(...k.args);
uygulanan.push(`üniversite ~ "${a.universite}"`);
}
// İl: parametre > profildeki km sınırı
if (a.il) {
const il = ilAdiNormalize(a.il);
if (!il) {
return { toplamEslesen: 0, gosterilen: 0, uygulananFiltreler: [], sonuclar: [], not: `İl tanınmadı: ${a.il}` };
}
kosullar.push("il = ?");
args.push(il);
uygulanan.push(`il = ${il}`);
} else if (profil.evIl && profil.maxKm) {
const iller = yakinIller(profil.evIl, profil.maxKm);
if (iller.length) {
kosullar.push(`il IN (${iller.map(() => "?").join(",")})`);
args.push(...iller);
uygulanan.push(`profil: ${profil.evIl} iline en fazla ${profil.maxKm} km (${iller.length} il)`);
}
}
// Özellik süzgeçleri alt sorguda (program_ozellik.id çakışmasın)
const ozKosul: string[] = [];
const ozArgs: (string | number)[] = [];
const dil = a.dil ?? (profil.dil === "ingilizce" ? "İngilizce" : profil.dil === "turkce" ? "Türkçe" : undefined);
if (dil) {
ozKosul.push("ogrenim_dili LIKE ?");
ozArgs.push(`${dil}%`);
uygulanan.push(`dil = ${dil}${a.dil ? "" : " (profil)"}`);
}
if (a.burs) {
ozKosul.push("burs_orani = ?");
ozArgs.push(a.burs);
uygulanan.push(`burs = ${a.burs}`);
} else if (profil.burs === "yalniz-burslu") {
ozKosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
uygulanan.push("profil: vakıfta yalnız burslu");
}
const ucretUst = a.ucret_ust ?? profil.ucretUst;
if (ucretUst) {
ozKosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
ozArgs.push(ucretUst);
uygulanan.push(`yıllık ücret ≤ ${ucretUst.toLocaleString("tr-TR")} TL${a.ucret_ust ? "" : " (profil)"}`);
}
if (profil.ogretimTuru === "yalniz-orgun") {
ozKosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
uygulanan.push("profil: yalnız örgün");
}
if (ozKosul.length) {
kosullar.push(`id IN (SELECT id FROM program_ozellik WHERE ${ozKosul.join(" AND ")})`);
args.push(...ozArgs);
}
if (a.dilim) {
const anahtar = a.dilim === "guvenli" ? "garanti" : a.dilim;
const { alt, ust } = dilimAraligi(b.sira, anahtar);
kosullar.push(ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`);
args.push(alt);
if (ust != null) args.push(ust);
uygulanan.push(`dilim = ${DILIM_ETIKET[anahtar]}`);
}
const satirlar = db
.prepare(
`SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")}
ORDER BY ABS(${EFEKTIF_SIRA} - ?) ASC LIMIT ?`,
)
.all(...args, b.sira, ARAMA_LIMIT) as ProgramHamSatiri[];
const programlar = satirlar.map(programSatiri);
const toplam = (
db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${kosullar.join(" AND ")}`).get(...args) as {
n: number;
}
).n;
// Profil süzgeçleri sonucu sıfırladıysa profilsiz tekrar dene ve bunu söyle:
// model "hiç yok" demek yerine "profilin dışında şunlar var" diyebilsin.
if (toplam === 0 && profilUygula && uygulanan.some((u) => u.includes("profil"))) {
const gevsek = programAra(b, a, false);
return {
...gevsek,
not: `Profil süzgeçleriyle (${uygulanan.filter((u) => u.includes("profil")).join("; ")}) eşleşen program YOK; aşağıdakiler profil süzgeci olmadan bulundu — adaya bunu açıkça söyle. ${gevsek.not}`,
};
}
const ozellikler = programOzellikleriGetir(programlar.map((p) => p.id));
return {
toplamEslesen: toplam,
gosterilen: programlar.length,
uygulananFiltreler: uygulanan,
sonuclar: programlar.map((p) => programOzeti(p, b.sira, ozellikler[p.id])),
not: "tabanSira son yerleştirme yılının taban başarı sırası; her yıl değişir.",
};
}
type ProgramAraSonuc = {
toplamEslesen: number;
gosterilen: number;
uygulananFiltreler: string[];
sonuclar: ReturnType<typeof programOzeti>[];
not: string;
};
function programDetay(b: AracBaglami, a: { program_id?: string }) {
const id = String(a.program_id ?? "").trim();
const p = id ? programGetir(id) : null;
if (!p) return { hata: "Program bulunamadı; önce program_ara ile id al." };
const o = programOzellikGetir(id);
const kosullar = o ? kosulMetinleri(o.kosulKodlari).slice(0, 8) : [];
const evKm = b.profil.evIl && p.il ? illerArasiKm(b.profil.evIl, p.il) : null;
return {
...programOzeti(p, b.sira, o ?? undefined),
fakulte: p.fakulte,
sure: p.sure,
tabanSiraYillara: Object.fromEntries(YILLAR.map((y, i) => [y, p.siraGecmisi[i]])),
puanSon: p.puanSon,
yerlesenSon: p.yerlesenSon,
ucretYillik: o?.ucret ?? null,
bursOrani: o?.bursOrani ?? null,
ogretimDili: o?.ogrenimDili ?? null,
ogretimTuru: o?.ogrenimTuru ?? null,
akreditasyon: o?.akreditasyon ?? null,
kadro: o
? { prof: o.prof, docent: o.doc, drOgrUyesi: o.dou, arsGor: o.argor, ogretimUyesiToplam: ogretimUyesiToplami(o) }
: null,
tercihBaraji: o?.minBasariSirasi ?? null,
ilce: o?.ilce ?? null,
evdenKm: evKm,
kosullar: kosullar.map((k) => (k.metin.length > 240 ? `${k.metin.slice(0, 237)}…` : k.metin)),
};
}
function universiteProfili(_b: AracBaglami, a: { universite?: string }) {
const q = String(a.universite ?? "").trim();
if (!q) return { hata: "Üniversite adı gerekli." };
const slugQ = turkishSlugify(q);
const hepsi = getAllUniversiteler();
const kisaltma: Record<string, string> = {
odtu: "orta-dogu-teknik",
itu: "istanbul-teknik",
ytu: "yildiz-teknik",
ktu: "karadeniz-teknik",
dtu: "deu",
};
const arananSlug = kisaltma[slugQ] ?? slugQ;
const aday =
hepsi.find((u) => u.slug === arananSlug || u.slug === `${arananSlug}-universitesi`) ??
hepsi.find((u) => u.slug.startsWith(arananSlug)) ??
hepsi.find((u) => u.slug.includes(arananSlug));
if (!aday) return { hata: `Üniversite bulunamadı: ${q}` };
const u = getUniversiteBySlug(aday.slug);
if (!u) return { hata: `Üniversite bulunamadı: ${q}` };
const i = u.istatistik;
const kadin = i ? kadinYuzdesi(i) : null;
return {
ad: u.ad,
il: u.il,
tur: u.unitur,
programSayisi: u.programSayisi,
enDusukTabanSira: u.stats.minSira,
toplamKontenjan: u.stats.toplamKontenjan,
urap: u.urap ? { sira: u.urap.sira, toplam: URAP_TOPLAM, donem: URAP_DONEM, olctugu: "araştırma performansı; eğitim kalitesi ya da iş bulma değil" } : null,
istatistik: i
? {
donem: YOK_ISTATISTIK_DONEM,
orgunOgrenci: i.ogrenci,
ogretimUyesi: i.ogretimUyesi,
hocaBasinaOgrenci: ogrenciBasinaUye(i),
kadinYuzdesi: kadin,
erkekYuzdesi: kadin == null ? null : 100 - kadin,
not: "Kadın/erkek oranı örgün ön lisans + lisans, ÜNİVERSİTE GENELİ; bölüm bazında veri yok. Bilgi olarak söyle, filtre yapma.",
}
: null,
sayfa: `/universite/${u.slug}`,
};
}
function sayiParse(v: unknown): number | null {
if (typeof v === "number") return v;
if (typeof v !== "string") return null;
const t = v.toLocaleLowerCase("tr-TR").replace(/\s/g, "");
const m = t.match(/^([\d.,]+)(bin|k|milyon|m)?/);
if (!m) return null;
let n = Number.parseFloat(m[1].replace(/\./g, "").replace(",", "."));
if (!Number.isFinite(n)) return null;
if (m[2] === "bin" || m[2] === "k") n *= 1000;
if (m[2] === "milyon" || m[2] === "m") n *= 1_000_000;
return Math.round(n);
}
async function profilGuncelle(b: AracBaglami, a: { alan?: string; deger?: unknown }) {
const alan = a.alan as ProfilAlani;
if (!PROFIL_ALANLARI.includes(alan)) {
return { hata: `Bilinmeyen alan: ${String(a.alan)}. Yalnız ${PROFIL_ALANLARI.join(", ")} kaydedilebilir.` };
}
let deger: unknown = a.deger;
if (deger !== null && deger !== undefined) {
if (alan === "maxKm" || alan === "ucretUst") {
deger = sayiParse(deger);
if (deger == null) return { hata: "Sayı okunamadı." };
} else if (alan === "evIl") {
const il = ilAdiNormalize(String(deger));
if (!il) return { hata: `İl tanınmadı: ${String(deger)}` };
deger = il;
} else {
deger = String(deger).trim();
}
}
try {
const profil = await profilAlanYaz(b.userId, alan, deger ?? null);
b.profil = profil;
const satirlar = profilSatirlari(profil);
return {
kaydedildi: deger == null ? `${alan} silindi` : satirlar.find((s) => s.alan === alan)?.metin,
profil: satirlar.map((s) => s.metin),
};
} catch {
return { hata: `Değer kabul edilmedi (${alan}); aralık dışı ya da geçersiz seçenek.` };
}
}
/** Route'un araç döngüsüne verdiği çalıştırıcı. Bilinmeyen araç hata döner. */
export function aracCalistirici(b: AracBaglami) {
return async (ad: string, arg: unknown): Promise<unknown> => {
const a = (arg && typeof arg === "object" ? arg : {}) as Record<string, unknown>;
switch (ad) {
case "program_ara":
return programAra(b, a as ProgramAraArg);
case "program_detay":
return programDetay(b, a);
case "universite_profili":
return universiteProfili(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
default:
return { hata: `Bilinmeyen araç: ${ad}` };
}
};
}
export { profilGetir };

View File

@@ -1,3 +1,4 @@
import type OpenAI from "openai";
import { z } from "zod";
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
@@ -166,3 +167,109 @@ export async function* sohbetAkisi(opts: {
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
if (sinyal.aborted) throw sinyal.reason;
}
// ── Araçlı sohbet ─────────────────────────────────────────────────────────
type AracCagrisiBirikim = { id: string; name: string; arguments: string };
const ARAC_TUR_SINIRI = 4;
/**
* Araç çağrılı sohbet akışı. Model araç isterse (stream'de tool_calls
* parçaları birikir) araçlar çalıştırılır, sonuçlar `tool` mesajı olarak
* eklenir ve akış yeniden başlar; en fazla ARAC_TUR_SINIRI tur, son turda
* araç kapalı (cevap vermek zorunda). Metin parçaları geldikçe yield edilir.
*
* Sağlayıcı araçları desteklemiyorsa (ilk çağrıda 400/404) düz sohbete düşer.
*/
export async function* sohbetAkisiAraclarla(opts: {
system: string;
mesajlar: SohbetMesaji[];
maxTokens: number;
araclar: OpenAI.Chat.Completions.ChatCompletionTool[];
calistir: (ad: string, argumanlar: unknown) => Promise<unknown>;
/** Her araç çağrısında (günlük/analitik için) */
onArac?: (ad: string) => void;
}): AsyncGenerator<string> {
const sinyal = AbortSignal.timeout(120_000);
const gecmis: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: opts.system },
...opts.mesajlar,
];
for (let tur = 0; tur < ARAC_TUR_SINIRI; tur++) {
const sonTur = tur === ARAC_TUR_SINIRI - 1;
let akis;
try {
akis = await geciciHatadaTekrarla(() =>
getOpenRouter().chat.completions.create(
{
model: sohbetModeli(),
max_tokens: opts.maxTokens,
reasoning_effort: "none",
stream: true,
messages: gecmis,
tools: opts.araclar,
tool_choice: sonTur ? "none" : "auto",
},
{ signal: sinyal },
),
);
} catch (hata) {
const kod = (hata as { status?: number })?.status;
if (tur === 0 && (kod === 400 || kod === 404)) {
console.warn("[cagri] sağlayıcı araç çağrısını kabul etmedi, düz sohbete düşüldü:", kod);
yield* sohbetAkisi({ system: opts.system, mesajlar: opts.mesajlar, maxTokens: opts.maxTokens });
return;
}
throw hata;
}
let metin = "";
const cagrilar = new Map<number, AracCagrisiBirikim>();
for await (const parca of akis) {
const delta = parca.choices?.[0]?.delta;
if (!delta) continue;
if (delta.content) {
metin += delta.content;
yield delta.content;
}
for (const tc of delta.tool_calls ?? []) {
const mevcut = cagrilar.get(tc.index) ?? { id: "", name: "", arguments: "" };
if (tc.id) mevcut.id = tc.id;
if (tc.function?.name) mevcut.name += tc.function.name;
if (tc.function?.arguments) mevcut.arguments += tc.function.arguments;
cagrilar.set(tc.index, mevcut);
}
}
if (sinyal.aborted) throw sinyal.reason;
if (cagrilar.size === 0) return;
const liste = [...cagrilar.values()].filter((c) => c.name);
const toolCalls = liste.map((c) => ({
id: c.id || `call_${Math.random().toString(36).slice(2, 10)}`,
type: "function" as const,
function: { name: c.name, arguments: c.arguments || "{}" },
}));
gecmis.push({ role: "assistant", content: metin || null, tool_calls: toolCalls });
for (const [i, c] of liste.entries()) {
opts.onArac?.(c.name);
let arg: unknown = {};
try {
arg = c.arguments ? JSON.parse(c.arguments) : {};
} catch {
arg = {};
}
let sonuc: unknown;
try {
sonuc = await opts.calistir(c.name, arg);
} catch (hata) {
console.error("[cagri] araç hatası:", c.name, hata);
sonuc = { hata: "Araç çalışmadı; veriye ulaşılamadı, uydurma." };
}
gecmis.push({ role: "tool", tool_call_id: toolCalls[i].id, content: JSON.stringify(sonuc) });
}
// Araç turundan sonra araya boşluk: metin + tool ile bitmiş cümle yapışmasın
if (metin && !metin.endsWith("\n")) yield "\n\n";
}
}

View File

@@ -1,9 +1,29 @@
import OpenAI from "openai";
// Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı,
// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı).
// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir.
// Tüm AI akışı OpenAI uyumlu TEK kapıdan gider. Varsayılan OpenRouter
// (7 Ağu 2026 kararı); 26 Eyl 2026 kararıyla model Türkiye'de barındırılacak:
// sağlayıcı değişince kod değil .env değişir — AI_BASE_URL + AI_API_KEY
// (OpenRouter için eski OPENROUTER_* adları da çalışır). Sağlayıcı değişirse
// /gizlilik "Hizmet sağlayıcılar" bölümü aynı commit'te güncellenir.
const VARSAYILAN_MODEL = "deepseek/deepseek-v4-flash";
const VARSAYILAN_BASE_URL = "https://openrouter.ai/api/v1";
function baseUrl(): string {
return process.env.AI_BASE_URL?.trim() || VARSAYILAN_BASE_URL;
}
function apiKey(): string | undefined {
return process.env.AI_API_KEY?.trim() || process.env.OPENROUTER_API_KEY?.trim();
}
/** OpenRouter dışı bir sağlayıcı tanımlı mı (gizlilik metni ve log için). */
export function saglayiciAdi(): string {
try {
return new URL(baseUrl()).hostname;
} catch {
return "bilinmiyor";
}
}
export function raporModeli(): string {
return (
@@ -23,20 +43,20 @@ export function sohbetModeli(): string {
/** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
export function anahtarVar(): boolean {
return Boolean(process.env.OPENROUTER_API_KEY);
return Boolean(apiKey());
}
const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
export function getOpenRouter(): OpenAI {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
const key = apiKey();
if (!key) {
throw new Error("AI_KEY_MISSING");
}
if (!globalForAi.__openrouter) {
globalForAi.__openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey,
baseURL: baseUrl(),
apiKey: key,
// Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla
maxRetries: 0,
// OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel)

View File

@@ -1,3 +1,4 @@
import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil";
import "server-only";
import { z } from "zod";
import type {
@@ -231,8 +232,10 @@ export async function raporUret(
params: RaporParams,
revizyonNotu?: string,
oncekiListeOzeti?: string,
profil?: DanismanProfili,
): Promise<RaporSonuc> {
const { havuz, gevsetildi } = havuzOlustur(params);
const { havuz, gevsetildi } = havuzOlustur(params, profil);
const profilSatir = profil ? profilSatirlari(profil) : [];
if (havuz.length < MIN_LISTE) {
throw new RaporUretimHatasi(
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
@@ -252,6 +255,11 @@ export async function raporUret(
(params.universiteTipi && params.universiteTipi !== "farketmez");
const gevsetmeNotu: string[] = [];
if (gevsetildi.includes("profil")) {
gevsetmeNotu.push(
"Adayın danışmana söylediği tercihlerle (aşağıda) yeterli program yoktu; havuz bu tercihler uygulanmadan kuruldu. Uyanları öne al ve uyarılarda bunu tek cümleyle söyle.",
);
}
if (gevsetildi.includes("il")) {
gevsetmeNotu.push(
`Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi ${toplamHedef}'e tamamlamak için diğerlerini de kullan.`,
@@ -280,6 +288,9 @@ export async function raporUret(
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
: null,
params.notlar ? `Adayın ek notları: ${params.notlar}` : null,
profilSatir.length > 0
? `ADAYIN DANIŞMANA SÖYLEDİĞİ TERCİHLER (havuz buna göre süzüldü; gerekçelerde uyanı belirt):\n${profilSatir.map((s) => `- ${s.metin}`).join("\n")}`
: null,
gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null,
revizyonNotu
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`

View File

@@ -50,7 +50,12 @@ export type OlayAdi =
// kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı
// tıklama, Vazgeç); tamamlanan kapıda atılmaz.
| "kapi_acildi"
| "kapi_kapatildi";
| "kapi_kapatildi"
// Danışman profili: yalnız ALAN ADI gider, değer gitmez (KVKK: analitiğe
// profil değeri sızmaz). kaydedildi = model aracı çağırıp alanı yazdı
// (istemci cevap sonrası profili çekip farkı görür); silindi = çipteki çarpı.
| "profil_alani_kaydedildi"
| "profil_alani_silindi";
export type Ozellikler = Record<string, string | number>;

View File

@@ -231,3 +231,15 @@ export const savedLists = sqliteTable("saved_lists", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
// Danışman profili: sohbetten `profil_guncelle` aracıyla çıkarılan, KAPALI
// LİSTELİ tercih alanları (bkz. src/lib/danisman-profil.ts). Serbest metin
// yok; öğrenci /listem'de görür ve alan alan siler. Kullanıcı başına tek satır.
export const danismanProfil = sqliteTable("danisman_profil", {
userId: text("user_id")
.primaryKey()
.references(() => user.id),
// DanismanProfili (yalnız tanımlı anahtarlar; okurken yeniden doğrulanır)
alanlar: text("alanlar", { mode: "json" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

View File

@@ -137,6 +137,33 @@ export async function spendCreditForMessage(opts: {
}
}
/**
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — yalnız profil onayı ("kaydedeyim
* mi?" → "evet") için (Bilal kararı, 26 Eyl 2026: onay ücretsiz). Ledger'a
* satır yazılmaz; clientMessageId çakışırsa DUPLICATE.
*/
export async function bedelsizMesajKaydet(opts: {
userId: string;
content: string;
clientMessageId: string;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
await appDb.insert(chatMessages).values({
id: messageId,
userId: opts.userId,
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
createdAt: new Date(),
});
return { ok: true, messageId };
} catch (err) {
if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
/** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */
class ZatenPaketli extends Error {}

View File

@@ -0,0 +1,56 @@
import "server-only";
import { eq } from "drizzle-orm";
import { appDb, schema } from "@/lib/appdb";
import {
PROFIL_SEMASI,
profilTemizle,
type DanismanProfili,
type ProfilAlani,
} from "@/lib/danisman-profil";
/** Kullanıcının danışman profili; satır yoksa boş nesne. Bozuk alanlar düşer. */
export async function profilGetir(userId: string): Promise<DanismanProfili> {
const satir = await appDb.query.danismanProfil.findFirst({
where: eq(schema.danismanProfil.userId, userId),
columns: { alanlar: true },
});
return profilTemizle(satir?.alanlar);
}
/**
* Tek alanı yazar (deger null → alan silinir). Değer şemadan geçmezse hata
* fırlatır; çağıran taraf (araç) bunu modele "kaydedilmedi" diye döner.
*/
export async function profilAlanYaz(
userId: string,
alan: ProfilAlani,
deger: unknown,
): Promise<DanismanProfili> {
const mevcut = await profilGetir(userId);
const yeni: Record<string, unknown> = { ...mevcut };
if (deger === null || deger === undefined) {
delete yeni[alan];
} else {
const dogrulama = PROFIL_SEMASI.shape[alan].safeParse(deger);
if (!dogrulama.success) {
throw new Error(`Geçersiz değer: ${alan}`);
}
yeni[alan] = dogrulama.data;
}
const alanlar = PROFIL_SEMASI.parse(yeni);
const now = new Date();
await appDb
.insert(schema.danismanProfil)
.values({ userId, alanlar, updatedAt: now })
.onConflictDoUpdate({
target: schema.danismanProfil.userId,
set: { alanlar, updatedAt: now },
});
return alanlar;
}
export async function profilSil(userId: string): Promise<void> {
await appDb
.delete(schema.danismanProfil)
.where(eq(schema.danismanProfil.userId, userId));
}

119
src/lib/danisman-profil.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* Danışman profili — sohbetten çıkarılan öğrenci tercihleri. KAPALI LİSTE:
* yalnız burada tanımlı alanlar yazılabilir, serbest metin alanı yoktur.
* Neden: model sohbetten din, sağlık, aile gibi özel nitelikli veriyi profile
* taşıyamasın (KVKK md. 6); öğrenci her alanı görür ve tek tıkla siler
* (docs/arastirma/2026-09-25-kisisellestirme/README.md §8).
*
* Sorulmayanlar: cinsiyet, sağlık/engel, gelir. Ücret üst sınırı sorulur, gelir
* sorulmaz. Konum il düzeyindedir (adres/koordinat değil).
*
* KULLANIM SINIRI (karar, 25 Eyl 2026): profil yalnız liste üretimi
* (rapor-havuzu) ve danışman aramalarında kullanılır; pazarlama, e-posta,
* segmentleme ya da analitik değerinde KULLANILMAZ — kullanılırsa açık rıza
* konusu olur (hukuk-ve-rakipler.md C.3/13). Profil boşken hizmet tam çalışır.
*
* Saf modül: client, server ve scripts aynı dosyayı okur.
*/
import { z } from "zod";
export const PROFIL_SECENEKLERI = {
burs: {
"yalniz-burslu": "yalnız burslu",
yuzde50: "%50 burslu da olur",
"ucretli-olur": "ücretli de olur",
},
dil: {
turkce: "Türkçe",
ingilizce: "İngilizce",
farketmez: "fark etmez",
},
ogretimTuru: {
"yalniz-orgun": "yalnız örgün",
"uzaktan-olur": "uzaktan/açık da olur",
},
kampus: {
sakin: "sakin, küçük",
kalabalik: "kalabalık, büyük",
farketmez: "fark etmez",
},
} as const;
export const PROFIL_SEMASI = z
.object({
/** Yaşadığı il — DB'deki il adı biçimi (büyük harf, Türkçe). */
evIl: z.string().min(2).max(40).optional(),
/** Evden en fazla kaç km (il merkezleri arası kuş uçuşu). */
maxKm: z.number().int().min(50).max(1500).optional(),
/** Vakıfta yıllık ücret üst sınırı (TL). */
ucretUst: z.number().int().min(50_000).max(5_000_000).optional(),
burs: z.enum(["yalniz-burslu", "yuzde50", "ucretli-olur"]).optional(),
dil: z.enum(["turkce", "ingilizce", "farketmez"]).optional(),
ogretimTuru: z.enum(["yalniz-orgun", "uzaktan-olur"]).optional(),
kampus: z.enum(["sakin", "kalabalik", "farketmez"]).optional(),
})
.strict();
export type DanismanProfili = z.infer<typeof PROFIL_SEMASI>;
export type ProfilAlani = keyof DanismanProfili;
export const PROFIL_ALANLARI = Object.keys(PROFIL_SEMASI.shape) as ProfilAlani[];
/** Alanın öğrenciye görünen kısa açıklaması (çip ve araç şeması aynı metni kullanır). */
export const PROFIL_ALAN_ACIKLAMA: Record<ProfilAlani, string> = {
evIl: "Yaşadığı il (evden uzaklık için)",
maxKm: "Evden en fazla kaç km uzakta okumak istediği",
ucretUst: "Vakıfta yıllık ücret üst sınırı (TL)",
burs: "Vakıfta burs beklentisi",
dil: "Öğretim dili tercihi",
ogretimTuru: "Uzaktan/açık öğretim kabul mü",
kampus: "Kampüs büyüklüğü tercihi",
};
/** Bilinmeyen anahtarları atarak profili güvenli okur; bozuk değer alanı düşürür. */
export function profilTemizle(ham: unknown): DanismanProfili {
if (!ham || typeof ham !== "object") return {};
const sonuc: Record<string, unknown> = {};
for (const alan of PROFIL_ALANLARI) {
const deger = (ham as Record<string, unknown>)[alan];
if (deger === undefined || deger === null) continue;
const tek = PROFIL_SEMASI.shape[alan].safeParse(deger);
if (tek.success) sonuc[alan] = tek.data;
}
return sonuc as DanismanProfili;
}
/** Tek alanı öğrencinin okuyacağı cümleye çevirir: "Yıllık ücret en fazla 300.000 TL". */
export function profilAlaniMetni(alan: ProfilAlani, deger: unknown): string {
switch (alan) {
case "evIl":
return `Yaşadığı il: ${ilBaslik(String(deger))}`;
case "maxKm":
return `Evden en fazla ${Number(deger).toLocaleString("tr-TR")} km`;
case "ucretUst":
return `Yıllık ücret en fazla ${Number(deger).toLocaleString("tr-TR")} TL`;
case "burs":
return `Vakıfta ${PROFIL_SECENEKLERI.burs[deger as keyof typeof PROFIL_SECENEKLERI.burs]}`;
case "dil":
return `Öğretim dili: ${PROFIL_SECENEKLERI.dil[deger as keyof typeof PROFIL_SECENEKLERI.dil]}`;
case "ogretimTuru":
return deger === "yalniz-orgun" ? "Yalnız örgün öğretim" : "Uzaktan/açık öğretim de olur";
case "kampus":
return `Kampüs: ${PROFIL_SECENEKLERI.kampus[deger as keyof typeof PROFIL_SECENEKLERI.kampus]}`;
}
}
/** Profilin tamamı, dolu alanlar sırayla. */
export function profilSatirlari(p: DanismanProfili): { alan: ProfilAlani; metin: string }[] {
return PROFIL_ALANLARI.flatMap((alan) =>
p[alan] === undefined ? [] : [{ alan, metin: profilAlaniMetni(alan, p[alan]) }]
);
}
function ilBaslik(il: string): string {
return il
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((k) => k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1))
.join(" ");
}

View File

@@ -75,6 +75,48 @@ export function programSatiri(row: ProgramHamSatiri): Program {
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
* demektir (hayal), büyükse daha güvenli demektir (garanti).
*/
/**
* Danışman profilinden gelen program özelliği süzgeci (program_ozellik
* tablosu; bkz. lib/program-ozellik.ts). Tablo yoksa süzgeç uygulanmaz.
* Devlet programlarında ücret/burs NULL'dur ve her zaman geçer.
*/
export type OzellikSuzgeci = {
dil?: "Türkçe" | "İngilizce";
yalnizOrgun?: boolean;
yalnizBurslu?: boolean;
ucretUst?: number;
};
let ozellikTablosuVar: boolean | null = null;
function ozellikFiltreSql(o?: OzellikSuzgeci): { sql: string; args: (string | number)[] } {
if (!o) return { sql: "", args: [] };
const kosul: string[] = [];
const args: (string | number)[] = [];
if (o.dil) {
kosul.push("ogrenim_dili LIKE ?");
args.push(`${o.dil}%`);
}
if (o.yalnizOrgun) kosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
if (o.yalnizBurslu) kosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
if (o.ucretUst) {
kosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
args.push(o.ucretUst);
}
if (kosul.length === 0) return { sql: "", args: [] };
if (ozellikTablosuVar == null) {
ozellikTablosuVar =
getDb()
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'")
.get() !== undefined;
}
if (!ozellikTablosuVar) return { sql: "", args: [] };
return {
sql: `AND id IN (SELECT id FROM program_ozellik WHERE ${kosul.join(" AND ")})`,
args,
};
}
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
if (grup === "vakif")
@@ -112,6 +154,7 @@ export function dilimAra(
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
ozellik?: OzellikSuzgeci;
limit?: number;
offset?: number;
} = {}
@@ -132,16 +175,17 @@ export function dilimAra(
? `AND il IN (${illerListe.map(() => "?").join(",")})`
: "";
const unitur = uniturFiltreSql(opts.uniturGrubu);
const ozellik = ozellikFiltreSql(opts.ozellik);
const { alt, ust } = dilimAraligi(sira, dilim);
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
const siraKosulu =
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
const siraArgs = ust != null ? [alt, ust] : [alt];
const args = [tur, onlisans, ...illerListe, ...unitur.args, ...siraArgs];
const args = [tur, onlisans, ...illerListe, ...unitur.args, ...ozellik.args, ...siraArgs];
const base = `FROM programs
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql}
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} ${ozellik.sql}
AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${siraKosulu}`;
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
@@ -168,6 +212,7 @@ export function searchByRank(
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
ozellik?: OzellikSuzgeci;
limitPerBucket?: number;
} = {}
): RankResults {
@@ -175,6 +220,7 @@ export function searchByRank(
il: opts.il,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
ozellik: opts.ozellik,
limit: opts.limitPerBucket ?? 12,
};
const hayal = dilimAra(sira, turKey, "hayal", ortak);

42
src/lib/mesafe.ts Normal file
View File

@@ -0,0 +1,42 @@
// İl merkezleri arası kuş uçuşu mesafe (km). Profildeki "evden en fazla N km"
// filtresi il düzeyinde çalışır: adres/koordinat tutulmaz (KVKK, §8).
import { IL_MERKEZ, type LatLng } from "./uni-koordinatlar";
const DUNYA_YARICAP_KM = 6371;
export function kmMesafe(a: LatLng, b: LatLng): number {
const rad = (d: number) => (d * Math.PI) / 180;
const dLat = rad(b[0] - a[0]);
const dLng = rad(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(rad(a[0])) * Math.cos(rad(b[0])) * Math.sin(dLng / 2) ** 2;
return Math.round(2 * DUNYA_YARICAP_KM * Math.asin(Math.sqrt(h)));
}
/** DB il adı biçimine (büyük harf, Türkçe) çevirir; bilinmiyorsa null. */
export function ilAdiNormalize(ham: string): string | null {
const aday = ham.trim().toLocaleUpperCase("tr-TR");
if (aday in IL_MERKEZ) return aday;
// "Afyon" → AFYONKARAHİSAR gibi öneki eşleşen tek il varsa onu al
const adaylar = Object.keys(IL_MERKEZ).filter((il) => il.startsWith(aday));
return adaylar.length === 1 ? adaylar[0] : null;
}
/** Ev iline en fazla `km` uzaktaki iller (evin kendisi dahil), yakından uzağa. */
export function yakinIller(evIl: string, km: number): string[] {
const ev = IL_MERKEZ[evIl];
if (!ev) return [];
return Object.entries(IL_MERKEZ)
.map(([il, k]) => ({ il, km: kmMesafe(ev, k) }))
.filter((x) => x.km <= km)
.sort((a, b) => a.km - b.km)
.map((x) => x.il);
}
/** İki il arası km; biri bilinmiyorsa null. */
export function illerArasiKm(a: string, b: string): number | null {
const ka = IL_MERKEZ[a];
const kb = IL_MERKEZ[b];
return ka && kb ? kmMesafe(ka, kb) : null;
}

137
src/lib/program-ozellik.ts Normal file
View File

@@ -0,0 +1,137 @@
// Program özellikleri: YÖK Atlas API'sinden scripts/program-ozellik.ts ile
// `program_ozellik` ve `kosul` tablolarına yazılan, yıl anlamı olmayan alanlar
// (ücret, burs, dil, öğretim türü, akreditasyon, kadro, baraj, ilçe, koşullar).
// Sıra/puan/kontenjan gibi yıllık alanlar burada DEĞİL (bkz. db.ts).
import { getDb } from "./db";
export type ProgramOzellik = {
id: string;
/** Vakıf/KKTC yıllık öğrenim ücreti (TL, KDV dahil, kılavuz yılı); devlette null */
ucret: number | null;
/** "Burslu" | "%50 İndirimli" | "%25 İndirimli" | "Ücretli"; devlette null */
bursOrani: string | null;
/** "Türkçe" | "İngilizce" | "İngilizce (%30)" | "Almanca" … */
ogrenimDili: string | null;
/** "Örgün Öğretim" | "Uzaktan Öğretim" | "Açık Öğretim" | "UOLP" */
ogrenimTuru: string | null;
/** Program akreditasyonu (MÜDEK, TEPDAD, FEDEK …) */
akreditasyon: string | null;
/** Üniversite akreditasyonu (YÖKAK …) */
uniAkreditasyon: string | null;
prof: number | null;
doc: number | null;
dou: number | null;
argor: number | null;
/** Tercih edebilmek için gereken en düşük başarı sırası (baraj) */
minBasariSirasi: number | null;
/** Kampüs ilçesi (büyük harf, API'den) */
ilce: string | null;
/** Yerleşme koşulu kodları (kosul tablosundan metne çevrilir) */
kosulKodlari: string[];
/** Kılavuz yılı */
yil: number;
};
type HamSatir = {
id: string;
ucret: number | null;
burs_orani: string | null;
ogrenim_dili: string | null;
ogrenim_turu: string | null;
akreditasyon: string | null;
uni_akreditasyon: string | null;
prof: number | null;
doc: number | null;
dou: number | null;
argor: number | null;
min_basari_sirasi: number | null;
ilce: string | null;
kosul_kodlari: string | null;
yil: number;
};
const OZELLIK_COLS = `id, ucret, burs_orani, ogrenim_dili, ogrenim_turu, akreditasyon,
uni_akreditasyon, prof, doc, dou, argor, min_basari_sirasi, ilce, kosul_kodlari, yil`;
function satir(r: HamSatir): ProgramOzellik {
return {
id: r.id,
ucret: r.ucret,
bursOrani: r.burs_orani,
ogrenimDili: r.ogrenim_dili,
ogrenimTuru: r.ogrenim_turu,
akreditasyon: r.akreditasyon,
uniAkreditasyon: r.uni_akreditasyon,
prof: r.prof,
doc: r.doc,
dou: r.dou,
argor: r.argor,
minBasariSirasi: r.min_basari_sirasi,
ilce: r.ilce,
kosulKodlari: r.kosul_kodlari ? r.kosul_kodlari.split(",") : [],
yil: r.yil,
};
}
/** Tablo yoksa (eski DB) her okuma null/boş döner; sayfa kırılmaz. */
function tabloVar(): boolean {
return (
getDb()
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'")
.get() !== undefined
);
}
export function programOzellikGetir(id: string): ProgramOzellik | null {
if (!tabloVar()) return null;
const r = getDb()
.prepare(`SELECT ${OZELLIK_COLS} FROM program_ozellik WHERE id = ?`)
.get(id) as HamSatir | undefined;
return r ? satir(r) : null;
}
export function programOzellikleriGetir(
ids: readonly string[]
): Record<string, ProgramOzellik> {
const sonuc: Record<string, ProgramOzellik> = {};
if (ids.length === 0 || !tabloVar()) return sonuc;
const satirlar = getDb()
.prepare(
`SELECT ${OZELLIK_COLS} FROM program_ozellik WHERE id IN (${ids.map(() => "?").join(",")})`
)
.all(...ids) as HamSatir[];
for (const r of satirlar) sonuc[r.id] = satir(r);
return sonuc;
}
/** Koşul kodlarının kılavuz metinleri, kod sırasıyla. Bilinmeyen kod atlanır. */
export function kosulMetinleri(kodlar: readonly string[]): { kod: string; metin: string }[] {
if (kodlar.length === 0 || !tabloVar()) return [];
const satirlar = getDb()
.prepare(`SELECT kod, metin FROM kosul WHERE kod IN (${kodlar.map(() => "?").join(",")})`)
.all(...kodlar) as { kod: string; metin: string }[];
const harita = new Map(satirlar.map((s) => [s.kod, s.metin]));
return kodlar.flatMap((k) => (harita.has(k) ? [{ kod: k, metin: harita.get(k)! }] : []));
}
/** Kadro toplamı (prof + doç + dr. öğr. üyesi); hiçbiri yoksa null. */
export function ogretimUyesiToplami(o: ProgramOzellik): number | null {
const parcalar = [o.prof, o.doc, o.dou].filter((x): x is number => x != null);
return parcalar.length ? parcalar.reduce((a, b) => a + b, 0) : null;
}
/** Öğrenciye okunan kısa özet: "İngilizce · Burslu · MÜDEK akreditasyonlu". */
export function ozellikOzeti(o: ProgramOzellik): string[] {
const parcalar: string[] = [];
if (o.ogrenimDili && o.ogrenimDili !== "Türkçe") parcalar.push(o.ogrenimDili);
if (o.ogrenimTuru && o.ogrenimTuru !== "Örgün Öğretim") parcalar.push(o.ogrenimTuru);
if (o.bursOrani) parcalar.push(o.bursOrani);
if (o.ucret != null && o.bursOrani !== "Burslu") {
parcalar.push(`yıllık ${o.ucret.toLocaleString("tr-TR")} TL`);
}
if (o.akreditasyon) parcalar.push(`${o.akreditasyon} akreditasyonlu`);
if (o.minBasariSirasi != null) {
parcalar.push(`baraj ${o.minBasariSirasi.toLocaleString("tr-TR")}. sıra`);
}
return parcalar;
}

View File

@@ -1,6 +1,8 @@
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
import { searchByRank } from "./db";
import { searchByRank, type OzellikSuzgeci } from "./db";
import type { DanismanProfili } from "./danisman-profil";
import { yakinIller } from "./mesafe";
import {
PUAN_TURLERI,
type Program,
@@ -16,10 +18,55 @@ export interface AdayProgram extends Program {
dilim: Dilim;
}
export type Gevsetilen = "profil" | "kategori" | "il";
export interface HavuzSonuc {
havuz: AdayProgram[];
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi
gevsetildi: ("kategori" | "il")[];
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi (sırayla)
gevsetildi: Gevsetilen[];
}
/**
* Danışman profilinden sert süzgeç: dil, öğretim türü, burs, ücret üst sınırı
* (program_ozellik) ve ev iline km sınırı (il listesi). Sihirbazda il
* seçilmişse km sınırı uygulanmaz — sihirbaz seçimi daha yeni ve açık.
*/
export function profilSuzgeci(
profil: DanismanProfili | undefined,
sihirbazIlVar: boolean,
): { ozellik?: OzellikSuzgeci; iller?: string[]; ozet: string[] } {
if (!profil) return { ozet: [] };
const ozellik: OzellikSuzgeci = {};
const ozet: string[] = [];
if (profil.dil === "ingilizce" || profil.dil === "turkce") {
ozellik.dil = profil.dil === "ingilizce" ? "İngilizce" : "Türkçe";
ozet.push(`öğretim dili ${ozellik.dil}`);
}
if (profil.ogretimTuru === "yalniz-orgun") {
ozellik.yalnizOrgun = true;
ozet.push("yalnız örgün öğretim");
}
if (profil.burs === "yalniz-burslu") {
ozellik.yalnizBurslu = true;
ozet.push("vakıfta yalnız burslu");
}
if (profil.ucretUst) {
ozellik.ucretUst = profil.ucretUst;
ozet.push(`yıllık ücret en fazla ${profil.ucretUst.toLocaleString("tr-TR")} TL`);
}
let iller: string[] | undefined;
if (!sihirbazIlVar && profil.evIl && profil.maxKm) {
const yakin = yakinIller(profil.evIl, profil.maxKm);
if (yakin.length > 0) {
iller = yakin;
ozet.push(`${profil.evIl} iline en fazla ${profil.maxKm} km`);
}
}
return {
ozellik: Object.keys(ozellik).length ? ozellik : undefined,
iller,
ozet,
};
}
const HEDEF = 24;
@@ -89,12 +136,16 @@ function dilimEkle(r: {
}
/**
* Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz.
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz
* 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste
* her zaman kurulabilsin.
* Deterministik aday havuzu: yapay zekâ bu havuzdan SEÇER, asla uydurmaz.
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) ve varsa danışman
* profiline (dil, öğretim türü, burs, ücret, km) göre filtreler; havuz 24'ün
* altına düşerse kademeli gevşetir (önce profil, sonra il, sonra kategori) ki
* liste her zaman kurulabilsin. /meraklisina şeması bu sırayı anlatır.
*/
export function havuzOlustur(params: RaporParams): HavuzSonuc {
export function havuzOlustur(
params: RaporParams,
profil?: DanismanProfili,
): HavuzSonuc {
const uniturGrubu =
params.universiteTipi && params.universiteTipi !== "farketmez"
? params.universiteTipi
@@ -110,6 +161,30 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler, p.fakulte))
: havuz;
// 0. Profil süzgeci (danışmana söylenenler) — yalnız yeterli havuz bırakırsa.
// Bırakmazsa profil gevşetilir ve aşağıdaki sihirbaz akışı aynen sürer;
// liste yine kurulur, gevşetme yapay zekâya ve adaya söylenir.
const suzgec = profilSuzgeci(profil, iller.length > 0);
const profilGevsetildi: Gevsetilen[] = [];
if (suzgec.ozellik || suzgec.iller) {
const profilliHam = dilimEkle(
searchByRank(params.sira, params.tur, {
iller: iller.length > 0 ? iller : suzgec.iller,
uniturGrubu,
ozellik: suzgec.ozellik,
limitPerBucket: LIMIT,
}),
);
const profilliFiltreli = kategoriFiltre(profilliHam);
if (profilliFiltreli.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(profilliFiltreli, PROMPT_LIMIT_DILIM),
gevsetildi: [],
};
}
profilGevsetildi.push("profil");
}
// 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS)
const tamHam = dilimEkle(
searchByRank(params.sira, params.tur, {
@@ -122,7 +197,7 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
if (tamFiltreli.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(tamFiltreli, PROMPT_LIMIT_DILIM),
gevsetildi: [],
gevsetildi: profilGevsetildi,
};
}
@@ -130,12 +205,12 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
if (kategoriler.length > 0 && tamHam.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM),
gevsetildi: ["kategori"],
gevsetildi: [...profilGevsetildi, "kategori"],
};
}
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
const gevsetildi: ("kategori" | "il")[] = [];
const gevsetildi: Gevsetilen[] = [...profilGevsetildi];
if (iller.length > 0) gevsetildi.push("il");
const ilsizHam = dilimEkle(
searchByRank(params.sira, params.tur, {