Merge branch 'ozellik/tasavvuf-easter-egg' (v2 dahil) into main
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
BIN
data/yokatlas.db
BIN
data/yokatlas.db
Binary file not shown.
@@ -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):
|
||||
|
||||
|
||||
6
drizzle/0005_uneven_omega_red.sql
Normal file
6
drizzle/0005_uneven_omega_red.sql
Normal 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
|
||||
);
|
||||
1011
drizzle/meta/0005_snapshot.json
Normal file
1011
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
282
scripts/program-ozellik.ts
Normal 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)}`
|
||||
);
|
||||
});
|
||||
29
src/app/api/soru/profil/route.ts
Normal file
29
src/app/api/soru/profil/route.ts
Normal 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) });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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’ndeki (İrlanda) sunucularda işlenir. Ödemeler,
|
||||
Türkiye’de yerleşik iyzico altyapısında gerçekleşir. Danışman
|
||||
sohbetine yazdığın mesajlar, yanıt üretilebilmesi için
|
||||
Anthropic’in yapay zekâ servisine (ABD) iletilir; bu mesajlar
|
||||
sağlayıcının yapay zekâ modellerini eğitmek için kullanılmaz.
|
||||
KVKK açısından bunlardan e-posta ve yapay zekâ sağlayıcısına yapılan
|
||||
iletim yurt dışına aktarım sayılır ve hizmetin sunulabilmesi için
|
||||
zorunludur.
|
||||
</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’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’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>“kaydedeyim mi?”</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
|
||||
“Kaydettiklerim” ç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
|
||||
“Çerezler ve analitik”). 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’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’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’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 “50-100 bin” gibi geniş bir
|
||||
aralık olarak kaydedilir.
|
||||
aralık olarak kaydedilir. Danışman profilindeki değerler
|
||||
analitiğe gitmez; yalnız hangi alanın kaydedildiği sayılır.
|
||||
</p>
|
||||
</section>
|
||||
</Parallax>
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>{" "}
|
||||
|
||||
@@ -114,15 +114,20 @@ export function MeraklisinaIcerik() {
|
||||
göre yeniden hesaplanarak listelenir; üçüncü adımdaki
|
||||
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
|
||||
Seçtiğin il ve devlet/vakıf tercihi, sonuç sayfasındaki ücretsiz
|
||||
tabloya da uygulanır. Adımlar bitince elde kalan gerçek
|
||||
tabloya da uygulanır. Danışmanla konuşurken söylediğin ve
|
||||
“Kaydettiklerim” çiplerinde duran tercihler (öğretim
|
||||
dili, uzaktan ya da örgün, burs, yıllık ücret sınırı, evden
|
||||
uzaklık) varsa havuzu onlar da süzer; bir çipi silersen bir
|
||||
sonraki liste onsuz kurulur. Adımlar bitince elde kalan gerçek
|
||||
programlar bir <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'ün altına düşerse filtrelerin nasıl
|
||||
gevşetildiği, yine de yetmezse listenin eldeki programlarla, sana
|
||||
gösterir — havuz 24'ün altına düşerse filtrelerin hangi
|
||||
sırayla gevşetildiği (önce danışmana söylediklerin, sonra il,
|
||||
sonra alan), yine de yetmezse listenin eldeki programlarla, sana
|
||||
açıkça belirtilerek nasıl kurulduğu da dahil.
|
||||
</p>
|
||||
<HuniSemasi />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
456
src/lib/ai/araclar.ts
Normal 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 };
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}`
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
56
src/lib/danisman-profil-db.ts
Normal file
56
src/lib/danisman-profil-db.ts
Normal 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
119
src/lib/danisman-profil.ts
Normal 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(" ");
|
||||
}
|
||||
@@ -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
42
src/lib/mesafe.ts
Normal 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
137
src/lib/program-ozellik.ts
Normal 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;
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user