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)
|
# YÖK Atlas ham API çekimleri (scripts/refresh.ts; ~yıllık, büyük, yeniden üretilebilir)
|
||||||
data/ham/
|
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
|
## 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 | İş |
|
| 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-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-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 |
|
| `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):
|
Ö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,
|
"when": 1790031029618,
|
||||||
"tag": "0004_shocking_caretaker",
|
"tag": "0004_shocking_caretaker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790287503094,
|
||||||
|
"tag": "0005_uneven_omega_red",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"lint": "eslint && pnpm tasarim:denetim",
|
"lint": "eslint && pnpm tasarim:denetim",
|
||||||
"ingest": "tsx scripts/ingest.ts",
|
"ingest": "tsx scripts/ingest.ts",
|
||||||
"refresh": "tsx scripts/refresh.ts",
|
"refresh": "tsx scripts/refresh.ts",
|
||||||
|
"ozellik": "tsx scripts/program-ozellik.ts",
|
||||||
"detay": "tsx scripts/detay.ts",
|
"detay": "tsx scripts/detay.ts",
|
||||||
"yokatlas:goc": "tsx scripts/yokatlas-goc.ts",
|
"yokatlas:goc": "tsx scripts/yokatlas-goc.ts",
|
||||||
"gecmis-doldur": "tsx scripts/gecmis-doldur.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 { appDb, schema } from "@/lib/appdb";
|
||||||
import {
|
import {
|
||||||
spendCreditForMessage,
|
spendCreditForMessage,
|
||||||
|
bedelsizMesajKaydet,
|
||||||
grantCredits,
|
grantCredits,
|
||||||
krediBittiIsaretle,
|
krediBittiIsaretle,
|
||||||
} from "@/lib/credits";
|
} from "@/lib/credits";
|
||||||
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
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 { listeOzetiCikar } from "@/lib/ai/rapor";
|
||||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||||
import type { RaporParams } 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";
|
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
||||||
|
|
||||||
const MAX_GECMIS = 12;
|
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;
|
const MAX_MESAJ_UZUNLUK = 2000;
|
||||||
|
|
||||||
function sistemPromptu(opts: {
|
function sistemPromptu(opts: {
|
||||||
params: RaporParams;
|
params: RaporParams;
|
||||||
listeOzeti: string;
|
listeOzeti: string;
|
||||||
kilitli?: boolean;
|
kilitli?: boolean;
|
||||||
|
profil?: DanismanProfili;
|
||||||
}): string {
|
}): string {
|
||||||
const { params } = opts;
|
const { params } = opts;
|
||||||
const secimVar =
|
const secimVar =
|
||||||
@@ -50,7 +78,28 @@ KURALLAR:
|
|||||||
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
|
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 —
|
- 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ş.
|
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.
|
- 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.
|
- 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
|
- 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.`,
|
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) {
|
if (secimVar) {
|
||||||
parcalar.push(
|
parcalar.push(
|
||||||
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
|
`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.
|
// Üç 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;
|
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
|
||||||
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
|
// 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)
|
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
|
||||||
kullanicininRaporu(session.user.id),
|
kullanicininRaporu(session.user.id),
|
||||||
appDb
|
appDb
|
||||||
@@ -169,6 +225,7 @@ export async function POST(request: NextRequest) {
|
|||||||
where: eq(schema.user.id, session.user.id),
|
where: eq(schema.user.id, session.user.id),
|
||||||
columns: { hasPaket: true },
|
columns: { hasPaket: true },
|
||||||
}),
|
}),
|
||||||
|
profilGetir(session.user.id),
|
||||||
]);
|
]);
|
||||||
if (!rapor?.result) {
|
if (!rapor?.result) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -178,12 +235,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const gecmis = gecmisHam.reverse();
|
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
|
// Kredi düşümü stream'den önce — yetersizse hiç başlama
|
||||||
const harcama = await spendCreditForMessage({
|
const harcama = bedelsiz
|
||||||
userId: session.user.id,
|
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
|
||||||
content: mesaj,
|
: await spendCreditForMessage({
|
||||||
clientMessageId,
|
userId: session.user.id,
|
||||||
});
|
content: mesaj,
|
||||||
|
clientMessageId,
|
||||||
|
});
|
||||||
if (!harcama.ok) {
|
if (!harcama.ok) {
|
||||||
if (harcama.error === "INSUFFICIENT") {
|
if (harcama.error === "INSUFFICIENT") {
|
||||||
await krediBittiIsaretle(session.user.id);
|
await krediBittiIsaretle(session.user.id);
|
||||||
@@ -213,11 +276,21 @@ export async function POST(request: NextRequest) {
|
|||||||
].join("\n")
|
].join("\n")
|
||||||
: tamOzet;
|
: tamOzet;
|
||||||
|
|
||||||
|
const raporParams = rapor.params as RaporParams;
|
||||||
const system = sistemPromptu({
|
const system = sistemPromptu({
|
||||||
params: rapor.params as RaporParams,
|
params: raporParams,
|
||||||
listeOzeti,
|
listeOzeti,
|
||||||
kilitli,
|
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)
|
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
|
||||||
if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
|
if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
|
||||||
@@ -253,12 +326,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!anahtarVar()) {
|
if (!anahtarVar()) {
|
||||||
await grantCredits({
|
if (!bedelsiz) {
|
||||||
userId: session.user.id,
|
await grantCredits({
|
||||||
delta: 1,
|
userId: session.user.id,
|
||||||
reason: "refund",
|
delta: 1,
|
||||||
refId: kullaniciMesajId,
|
reason: "refund",
|
||||||
});
|
refId: kullaniciMesajId,
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error("[soru] Yapay Zeka anahtarı tanımlı değil");
|
console.error("[soru] Yapay Zeka anahtarı tanımlı değil");
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
@@ -286,10 +361,13 @@ export async function POST(request: NextRequest) {
|
|||||||
let metin = "";
|
let metin = "";
|
||||||
try {
|
try {
|
||||||
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
|
// 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,
|
system,
|
||||||
mesajlar,
|
mesajlar,
|
||||||
maxTokens: 1024,
|
maxTokens: 1024,
|
||||||
|
araclar: ARACLAR,
|
||||||
|
calistir: aracCalistirici(aracBaglami),
|
||||||
|
onArac: (ad) => console.info("[soru] araç:", ad),
|
||||||
})) {
|
})) {
|
||||||
metin += parca;
|
metin += parca;
|
||||||
controller.enqueue(encoder.encode(parca));
|
controller.enqueue(encoder.encode(parca));
|
||||||
@@ -319,12 +397,14 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
await grantCredits({
|
if (!bedelsiz) {
|
||||||
userId,
|
await grantCredits({
|
||||||
delta: 1,
|
userId,
|
||||||
reason: "refund",
|
delta: 1,
|
||||||
refId: kullaniciMesajId,
|
reason: "refund",
|
||||||
}).catch(() => {});
|
refId: kullaniciMesajId,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.error("[soru] stream hatası:", err);
|
console.error("[soru] stream hatası:", err);
|
||||||
controller.error(err);
|
controller.error(err);
|
||||||
@@ -337,6 +417,8 @@ export async function POST(request: NextRequest) {
|
|||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"Cache-Control": "no-store",
|
"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",
|
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() {
|
export default function GizlilikPage() {
|
||||||
|
const baslik = "font-heading text-lg font-bold text-slate-900";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
<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ı
|
Gizlilik ve Kişisel Verilerin Korunması
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
Son güncelleme: 2 Ağustos 2026
|
Son güncelleme: 25 Eylül 2026
|
||||||
</p>
|
</p>
|
||||||
</Parallax>
|
</Parallax>
|
||||||
|
|
||||||
@@ -33,66 +41,187 @@ export default function GizlilikPage() {
|
|||||||
strength={4}
|
strength={4}
|
||||||
className="mt-8 space-y-6 text-sm leading-7 text-slate-700"
|
className="mt-8 space-y-6 text-sm leading-7 text-slate-700"
|
||||||
>
|
>
|
||||||
<section>
|
<section className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
<h2 className={baslik}>Kısa özet</h2>
|
||||||
Hangi verileri işliyoruz?
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
</h2>
|
<li>
|
||||||
<p className="mt-2">
|
Senden yalnız e-posta, başarı sıralaman, sihirbaz seçimlerin,
|
||||||
KolayTercih; e-posta adresini (hesap oluşturma ve giriş için), YKS
|
danışmana yazdıkların ve danışmana söyleyip kaydettiğin
|
||||||
başarı sıralamanı ve sihirbazda yaptığın seçimleri (tercih listeni
|
tercihler alınır. Ad, kimlik numarası, adres, cinsiyet, sağlık
|
||||||
üretmek için), danışman sohbetine yazdığın mesajları (sorularını
|
ya da gelir bilgisi istenmez.
|
||||||
yanıtlamak için) ve ödeme yaptığında sipariş kayıtlarını işler.
|
</li>
|
||||||
Kart bilgilerin bize hiç ulaşmaz; ödeme iyzico altyapısında
|
<li>
|
||||||
gerçekleşir.
|
Bunlar yalnız tercih listeni üretmek ve sorularını yanıtlamak
|
||||||
</p>
|
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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
<h2 className={baslik}>Veri sorumlusu</h2>
|
||||||
Ne için kullanıyoruz?
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi
|
Bu sitedeki kişisel verilerin veri sorumlusu KolayTercih’tir
|
||||||
bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş
|
[şirket unvanı ve adresi eklenecek]. Sorularını ve başvurularını{" "}
|
||||||
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{" "}
|
|
||||||
<Link href="/iletisim" className="text-primary underline">
|
<Link href="/iletisim" className="text-primary underline">
|
||||||
iletişim sayfasındaki
|
iletişim sayfasındaki
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
adresten bize yazabilirsin; talepler 30 gün içinde
|
e-posta adresine iletebilirsin.
|
||||||
sonuçlandırılır.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
<h2 className={baslik}>Hangi verileri, ne için, hangi dayanakla işliyoruz?</h2>
|
||||||
Çerezler ve analitik
|
<ul className="mt-2 list-disc space-y-2 pl-5">
|
||||||
</h2>
|
<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">
|
<p className="mt-2">
|
||||||
Yalnızca oturumunu açık tutmak için zorunlu çerezler kullanılır;
|
Yalnızca oturumunu açık tutmak için zorunlu çerezler kullanılır;
|
||||||
reklam veya izleme çerezi kullanılmaz. Siteyi geliştirebilmek için
|
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ı
|
yalnızca sorun giderme ile destek amacıyla kullanılır. Başarı
|
||||||
sıralaman analitiğe ham hâliyle
|
sıralaman analitiğe ham hâliyle
|
||||||
gönderilmez; yalnızca “50-100 bin” gibi geniş bir
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</Parallax>
|
</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 { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
|
||||||
import { HeaderGate } from "@/components/header-gate";
|
import { HeaderGate } from "@/components/header-gate";
|
||||||
import { DevPanelMount } from "@/components/dev/dev-panel-mount";
|
import { DevPanelMount } from "@/components/dev/dev-panel-mount";
|
||||||
|
import { ConsoleAsr } from "@/components/console-asr";
|
||||||
import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo";
|
import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -117,6 +118,8 @@ export default function RootLayout({
|
|||||||
{/* İlk "+" için sıra + sihirbaz kapısı */}
|
{/* İlk "+" için sıra + sihirbaz kapısı */}
|
||||||
<TercihProfiliKapisiLazy />
|
<TercihProfiliKapisiLazy />
|
||||||
<Toaster position="top-center" />
|
<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 */}
|
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
|
||||||
{process.env.NODE_ENV !== "production" ? <DevPanelMount /> : null}
|
{process.env.NODE_ENV !== "production" ? <DevPanelMount /> : null}
|
||||||
<JsonLd data={organizationJsonLd()} />
|
<JsonLd data={organizationJsonLd()} />
|
||||||
|
|||||||
@@ -2,31 +2,136 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
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 = [
|
let basildi = false;
|
||||||
"%cبِسْمِ اللَّهِ الرَّحْمٰنِ الرَّحِيمِ",
|
|
||||||
"",
|
// 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.",
|
"Rahmân ve Rahîm olan Allah’ın adıyla.",
|
||||||
"",
|
"",
|
||||||
"Asra yemîn olsun ki,",
|
"1-2. Andolsun zamana ki, insan gerçekten ziyan içindedir.",
|
||||||
"İnsan gerçekten ziyândadır.",
|
"3. Ancak, iman edip de sâlih ameller işleyenler, birbirlerine hakkı tavsiye edenler, birbirlerine sabrı tavsiye edenler başka (Onlar ziyanda değillerdir).",
|
||||||
"Ancak iman edip sâlih ameller yapanlar, birbirlerine hakkı tavsiye",
|
];
|
||||||
"edenler ve birbirlerine sabretmeyi öğütleyenler müstesnâ!",
|
|
||||||
].join("\n");
|
// 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() {
|
export function ConsoleAsr() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPlayed) return;
|
if (basildi) return;
|
||||||
hasPlayed = true;
|
basildi = true;
|
||||||
|
|
||||||
window.setTimeout(() => {
|
// Hidrasyon ve ilk etkileşimle yarışmasın: tarayıcı boşa çıkınca bas.
|
||||||
console.log(consoleMessage, "font-size:16px");
|
// Safari'de requestIdleCallback yok, orada kısa bir gecikme yeter.
|
||||||
}, 250);
|
const bas = () => console.log(...mesaj());
|
||||||
|
if (typeof window.requestIdleCallback === "function") {
|
||||||
|
window.requestIdleCallback(bas, { timeout: 3000 });
|
||||||
|
} else {
|
||||||
|
window.setTimeout(bas, 1000);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Amiri_Quran } from "next/font/google";
|
||||||
|
import { PixelMark } from "@/components/pixel-decor";
|
||||||
import { getAllBolumler } from "@/lib/katalog";
|
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 —
|
* Site footer'ı: yasal metin + SEO iç link kolonları. Server component —
|
||||||
@@ -125,6 +136,32 @@ export function SiteFooter() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 border-t border-slate-200/70 pt-6 text-center text-sm leading-relaxed text-slate-500">
|
<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>
|
<p>
|
||||||
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
|
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
|
||||||
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.
|
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.
|
||||||
|
|||||||
@@ -77,15 +77,17 @@ export async function GirisKarti({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
|
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
|
||||||
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
|
{/* Aydınlatma "okudum" (bilgilendirme), koşullar "kabul": KVKK İlke
|
||||||
<Link href="/gizlilik" className="underline hover:text-slate-700">
|
Kararı 2026/347 aydınlatma ile rızayı ayırır (hukuk-ve-rakipler.md C.2/8). */}
|
||||||
gizlilik metnindeki
|
Giriş yaparak{" "}
|
||||||
</Link>{" "}
|
|
||||||
gibi işlenmesini ve{" "}
|
|
||||||
<Link href="/kosullar" className="underline hover:text-slate-700">
|
<Link href="/kosullar" className="underline hover:text-slate-700">
|
||||||
kullanım koşullarını
|
kullanım koşullarını
|
||||||
</Link>{" "}
|
</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>
|
</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")
|
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")
|
A --> B("2. adım · İl seçimi<br/>programı olan tüm iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
|
||||||
B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
|
B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
|
||||||
C --> H("Aday havuzu<br/>elde kalan gerçek programlar — 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ı?"}
|
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
|
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")
|
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")
|
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 hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||||
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||||
class H,Y havuz
|
class H,Y,P havuz
|
||||||
class G,U uyari
|
class G,U uyari
|
||||||
class L1 hayal
|
class L1 hayal
|
||||||
class L2 dengeli
|
class L2 dengeli
|
||||||
@@ -44,12 +45,14 @@ export function HuniSemasi() {
|
|||||||
return (
|
return (
|
||||||
<ElCizimiSema
|
<ElCizimiSema
|
||||||
tanim={SEMA}
|
tanim={SEMA}
|
||||||
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; 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={
|
figcaption={
|
||||||
<>
|
<>
|
||||||
Havuza yalnızca en son tercih kılavuzunda yer alan programlar girer;
|
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
|
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">
|
<span className="font-medium text-slate-700">
|
||||||
dışına asla çıkmadan
|
dışına asla çıkmadan
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
|
|||||||
@@ -114,15 +114,20 @@ export function MeraklisinaIcerik() {
|
|||||||
göre yeniden hesaplanarak listelenir; üçüncü adımdaki
|
göre yeniden hesaplanarak listelenir; üçüncü adımdaki
|
||||||
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
|
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
|
||||||
Seçtiğin il ve devlet/vakıf tercihi, sonuç sayfasındaki ücretsiz
|
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>{" "}
|
programlar bir <strong>aday havuzu</strong>{" "}
|
||||||
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
||||||
verilir. Havuza yalnızca{" "}
|
verilir. Havuza yalnızca{" "}
|
||||||
<strong>en son tercih kılavuzunda yer alan</strong>{" "}
|
<strong>en son tercih kılavuzunda yer alan</strong>{" "}
|
||||||
programlar girer: kapanmış ya da kodu değiştiği için eski kaydı
|
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ı
|
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
|
gösterir — havuz 24'ün altına düşerse filtrelerin hangi
|
||||||
gevşetildiği, yine de yetmezse listenin eldeki programlarla, sana
|
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.
|
açıkça belirtilerek nasıl kurulduğu da dahil.
|
||||||
</p>
|
</p>
|
||||||
<HuniSemasi />
|
<HuniSemasi />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
type DanismanOzeti,
|
type DanismanOzeti,
|
||||||
type IlkTercihYorumu,
|
type IlkTercihYorumu,
|
||||||
type Mesaj,
|
type Mesaj,
|
||||||
|
type ProfilSatiri,
|
||||||
} from "./sohbet-client";
|
} from "./sohbet-client";
|
||||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,7 @@ export function ListemGovde({
|
|||||||
kredi,
|
kredi,
|
||||||
raporKredi,
|
raporKredi,
|
||||||
sohbetGecmisi,
|
sohbetGecmisi,
|
||||||
|
danismanProfili,
|
||||||
ozet,
|
ozet,
|
||||||
ilkYorum,
|
ilkYorum,
|
||||||
kalanHak,
|
kalanHak,
|
||||||
@@ -76,6 +78,8 @@ export function ListemGovde({
|
|||||||
* kapıdan geçtiği için aynı bedeli gösterir. */
|
* kapıdan geçtiği için aynı bedeli gösterir. */
|
||||||
raporKredi: number;
|
raporKredi: number;
|
||||||
sohbetGecmisi: Mesaj[];
|
sohbetGecmisi: Mesaj[];
|
||||||
|
/** Danışmanın kaydettiği tercihler (sunucudan; çipler bununla açılır) */
|
||||||
|
danismanProfili: ProfilSatiri[];
|
||||||
ozet?: DanismanOzeti;
|
ozet?: DanismanOzeti;
|
||||||
/** Sohbet boşken açılışta gösterilen 1. tercih yorumu */
|
/** Sohbet boşken açılışta gösterilen 1. tercih yorumu */
|
||||||
ilkYorum?: IlkTercihYorumu;
|
ilkYorum?: IlkTercihYorumu;
|
||||||
@@ -124,6 +128,12 @@ export function ListemGovde({
|
|||||||
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi"
|
? "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`,
|
: `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) {
|
if (params.kategoriler?.length) {
|
||||||
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
|
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -318,6 +328,7 @@ export function ListemGovde({
|
|||||||
<SohbetClient
|
<SohbetClient
|
||||||
baslangicKredi={kredi}
|
baslangicKredi={kredi}
|
||||||
initialMesajlar={sohbetGecmisi}
|
initialMesajlar={sohbetGecmisi}
|
||||||
|
initialProfil={danismanProfili}
|
||||||
ozet={ozet}
|
ozet={ozet}
|
||||||
ilkYorum={ilkYorum}
|
ilkYorum={ilkYorum}
|
||||||
bildikleri={danismaninBildikleri}
|
bildikleri={danismaninBildikleri}
|
||||||
@@ -329,6 +340,7 @@ export function ListemGovde({
|
|||||||
<SohbetClient
|
<SohbetClient
|
||||||
baslangicKredi={kredi}
|
baslangicKredi={kredi}
|
||||||
initialMesajlar={sohbetGecmisi}
|
initialMesajlar={sohbetGecmisi}
|
||||||
|
initialProfil={danismanProfili}
|
||||||
ozet={ozet}
|
ozet={ozet}
|
||||||
ilkYorum={ilkYorum}
|
ilkYorum={ilkYorum}
|
||||||
bildikleri={danismaninBildikleri}
|
bildikleri={danismaninBildikleri}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
cerezdenTercihProfili,
|
cerezdenTercihProfili,
|
||||||
} from "@/features/sihirbaz/sihirbaz-sabitler";
|
} from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import { kullanicininRaporu, sohbetGecmisiGetir } from "../rapor-queries";
|
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 { URUNLER, MAX_REVIZYON, RAPOR_KREDI } from "@/lib/credits";
|
||||||
import type {
|
import type {
|
||||||
MaskeliRapor,
|
MaskeliRapor,
|
||||||
@@ -53,10 +55,11 @@ export async function ListemIcerik({
|
|||||||
? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}`
|
? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}`
|
||||||
: "/listem";
|
: "/listem";
|
||||||
const session = await verifySession(geriYol);
|
const session = await verifySession(geriYol);
|
||||||
const [user, satir, sohbetGecmisi] = await Promise.all([
|
const [user, satir, sohbetGecmisi, danismanProfili] = await Promise.all([
|
||||||
getCurrentUser(),
|
getCurrentUser(),
|
||||||
kullanicininRaporu(session.user.id),
|
kullanicininRaporu(session.user.id),
|
||||||
sohbetGecmisiGetir(session.user.id),
|
sohbetGecmisiGetir(session.user.id),
|
||||||
|
profilGetir(session.user.id),
|
||||||
]);
|
]);
|
||||||
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
||||||
|
|
||||||
@@ -171,6 +174,7 @@ export async function ListemIcerik({
|
|||||||
user.hasPaket ? 0 : RAPOR_KREDI
|
user.hasPaket ? 0 : RAPOR_KREDI
|
||||||
}
|
}
|
||||||
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
||||||
|
danismanProfili={profilSatirlari(danismanProfili)}
|
||||||
ozet={danismanOzeti}
|
ozet={danismanOzeti}
|
||||||
ilkYorum={ilkYorum}
|
ilkYorum={ilkYorum}
|
||||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
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 { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||||||
@@ -42,6 +42,10 @@ import {
|
|||||||
import { DotsLoader } from "@/components/ui/loader";
|
import { DotsLoader } from "@/components/ui/loader";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { olay } from "@/lib/analitik";
|
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 {
|
export interface Mesaj {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -123,8 +127,11 @@ export function SohbetClient({
|
|||||||
ozet,
|
ozet,
|
||||||
ilkYorum,
|
ilkYorum,
|
||||||
bildikleri,
|
bildikleri,
|
||||||
|
initialProfil,
|
||||||
}: {
|
}: {
|
||||||
baslangicKredi: number;
|
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 */
|
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
||||||
initialMesajlar?: Mesaj[];
|
initialMesajlar?: Mesaj[];
|
||||||
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
|
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
|
||||||
@@ -146,8 +153,57 @@ export function SohbetClient({
|
|||||||
const [kredi, setKredi] = useState(baslangicKredi);
|
const [kredi, setKredi] = useState(baslangicKredi);
|
||||||
const [temizleAcik, setTemizleAcik] = useState(false);
|
const [temizleAcik, setTemizleAcik] = useState(false);
|
||||||
const [temizleniyor, setTemizleniyor] = 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();
|
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ı
|
// 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.
|
// (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -213,7 +269,8 @@ export function SohbetClient({
|
|||||||
return;
|
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)
|
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
|
||||||
olay("danisman_sorusu", {
|
olay("danisman_sorusu", {
|
||||||
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
|
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
|
// Header'daki kredi pill'i layout'ta server-render edilir ve client
|
||||||
// harcamasını görmez; bakiye orada da güncellensin.
|
// harcamasını görmez; bakiye orada da güncellensin.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
// Danışman bu cevapta profil_guncelle çağırmış olabilir
|
||||||
|
void profiliCek(new Set(profil.map((s) => s.alan)));
|
||||||
} catch {
|
} catch {
|
||||||
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
|
// 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
|
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
|
||||||
@@ -358,6 +417,35 @@ export function SohbetClient({
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -576,6 +664,11 @@ export function SohbetClient({
|
|||||||
</PromptInputAction>
|
</PromptInputAction>
|
||||||
</PromptInputActions>
|
</PromptInputActions>
|
||||||
</PromptInput>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
uretimKilidiBirak,
|
uretimKilidiBirak,
|
||||||
} from "@/lib/uretim-kilidi";
|
} from "@/lib/uretim-kilidi";
|
||||||
import { getSession, getCurrentUser } from "@/lib/session";
|
import { getSession, getCurrentUser } from "@/lib/session";
|
||||||
|
import { profilGetir } from "@/lib/danisman-profil-db";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
||||||
@@ -214,7 +215,9 @@ async function listeOlusturKilitli(
|
|||||||
|
|
||||||
let sonuc: RaporSonuc;
|
let sonuc: RaporSonuc;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
// Üretim başarısız → krediyi iade et. Satır paketlide de yazılır (delta 0):
|
// Ü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
|
// 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;
|
let sonuc: RaporSonuc;
|
||||||
try {
|
try {
|
||||||
|
const profil = await profilGetir(user.id).catch(() => undefined);
|
||||||
sonuc = await raporUret(
|
sonuc = await raporUret(
|
||||||
params,
|
params,
|
||||||
feedback.slice(0, 1000),
|
feedback.slice(0, 1000),
|
||||||
listeOzetiCikar(onceki, params.sira),
|
listeOzetiCikar(onceki, params.sira),
|
||||||
|
profil,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// İade satırı burada da yazılır (paketlide delta 0) — DUPLICATE dalındaki
|
// İ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 { z } from "zod";
|
||||||
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
|
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.
|
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
|
||||||
if (sinyal.aborted) throw sinyal.reason;
|
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";
|
import OpenAI from "openai";
|
||||||
|
|
||||||
// Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı,
|
// Tüm AI akışı OpenAI uyumlu TEK kapıdan gider. Varsayılan OpenRouter
|
||||||
// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı).
|
// (7 Ağu 2026 kararı); 26 Eyl 2026 kararıyla model Türkiye'de barındırılacak:
|
||||||
// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir.
|
// 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_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 {
|
export function raporModeli(): string {
|
||||||
return (
|
return (
|
||||||
@@ -23,20 +43,20 @@ export function sohbetModeli(): string {
|
|||||||
|
|
||||||
/** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
|
/** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
|
||||||
export function anahtarVar(): boolean {
|
export function anahtarVar(): boolean {
|
||||||
return Boolean(process.env.OPENROUTER_API_KEY);
|
return Boolean(apiKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
|
const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
|
||||||
|
|
||||||
export function getOpenRouter(): OpenAI {
|
export function getOpenRouter(): OpenAI {
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
const key = apiKey();
|
||||||
if (!apiKey) {
|
if (!key) {
|
||||||
throw new Error("AI_KEY_MISSING");
|
throw new Error("AI_KEY_MISSING");
|
||||||
}
|
}
|
||||||
if (!globalForAi.__openrouter) {
|
if (!globalForAi.__openrouter) {
|
||||||
globalForAi.__openrouter = new OpenAI({
|
globalForAi.__openrouter = new OpenAI({
|
||||||
baseURL: "https://openrouter.ai/api/v1",
|
baseURL: baseUrl(),
|
||||||
apiKey,
|
apiKey: key,
|
||||||
// Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla
|
// Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla
|
||||||
maxRetries: 0,
|
maxRetries: 0,
|
||||||
// OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel)
|
// 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 "server-only";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
import type {
|
||||||
@@ -231,8 +232,10 @@ export async function raporUret(
|
|||||||
params: RaporParams,
|
params: RaporParams,
|
||||||
revizyonNotu?: string,
|
revizyonNotu?: string,
|
||||||
oncekiListeOzeti?: string,
|
oncekiListeOzeti?: string,
|
||||||
|
profil?: DanismanProfili,
|
||||||
): Promise<RaporSonuc> {
|
): Promise<RaporSonuc> {
|
||||||
const { havuz, gevsetildi } = havuzOlustur(params);
|
const { havuz, gevsetildi } = havuzOlustur(params, profil);
|
||||||
|
const profilSatir = profil ? profilSatirlari(profil) : [];
|
||||||
if (havuz.length < MIN_LISTE) {
|
if (havuz.length < MIN_LISTE) {
|
||||||
throw new RaporUretimHatasi(
|
throw new RaporUretimHatasi(
|
||||||
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
|
"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");
|
(params.universiteTipi && params.universiteTipi !== "farketmez");
|
||||||
|
|
||||||
const gevsetmeNotu: string[] = [];
|
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")) {
|
if (gevsetildi.includes("il")) {
|
||||||
gevsetmeNotu.push(
|
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.`,
|
`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.`
|
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
|
||||||
: null,
|
: null,
|
||||||
params.notlar ? `Adayın ek notları: ${params.notlar}` : 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,
|
gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null,
|
||||||
revizyonNotu
|
revizyonNotu
|
||||||
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
|
? `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ı
|
// kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı
|
||||||
// tıklama, Vazgeç); tamamlanan kapıda atılmaz.
|
// tıklama, Vazgeç); tamamlanan kapıda atılmaz.
|
||||||
| "kapi_acildi"
|
| "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>;
|
export type Ozellikler = Record<string, string | number>;
|
||||||
|
|
||||||
|
|||||||
@@ -231,3 +231,15 @@ export const savedLists = sqliteTable("saved_lists", {
|
|||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||||
updatedAt: integer("updated_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. */
|
/** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */
|
||||||
class ZatenPaketli extends Error {}
|
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ü
|
* 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).
|
* 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[] } {
|
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
|
||||||
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
|
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
|
||||||
if (grup === "vakif")
|
if (grup === "vakif")
|
||||||
@@ -112,6 +154,7 @@ export function dilimAra(
|
|||||||
il?: string;
|
il?: string;
|
||||||
iller?: string[];
|
iller?: string[];
|
||||||
uniturGrubu?: UniturGrubu;
|
uniturGrubu?: UniturGrubu;
|
||||||
|
ozellik?: OzellikSuzgeci;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
} = {}
|
} = {}
|
||||||
@@ -132,16 +175,17 @@ export function dilimAra(
|
|||||||
? `AND il IN (${illerListe.map(() => "?").join(",")})`
|
? `AND il IN (${illerListe.map(() => "?").join(",")})`
|
||||||
: "";
|
: "";
|
||||||
const unitur = uniturFiltreSql(opts.uniturGrubu);
|
const unitur = uniturFiltreSql(opts.uniturGrubu);
|
||||||
|
const ozellik = ozellikFiltreSql(opts.ozellik);
|
||||||
|
|
||||||
const { alt, ust } = dilimAraligi(sira, dilim);
|
const { alt, ust } = dilimAraligi(sira, dilim);
|
||||||
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
|
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
|
||||||
const siraKosulu =
|
const siraKosulu =
|
||||||
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
|
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
|
||||||
const siraArgs = ust != null ? [alt, ust] : [alt];
|
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
|
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 ${EFEKTIF_SIRA} IS NOT NULL
|
||||||
AND ${siraKosulu}`;
|
AND ${siraKosulu}`;
|
||||||
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
|
// 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;
|
il?: string;
|
||||||
iller?: string[];
|
iller?: string[];
|
||||||
uniturGrubu?: UniturGrubu;
|
uniturGrubu?: UniturGrubu;
|
||||||
|
ozellik?: OzellikSuzgeci;
|
||||||
limitPerBucket?: number;
|
limitPerBucket?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): RankResults {
|
): RankResults {
|
||||||
@@ -175,6 +220,7 @@ export function searchByRank(
|
|||||||
il: opts.il,
|
il: opts.il,
|
||||||
iller: opts.iller,
|
iller: opts.iller,
|
||||||
uniturGrubu: opts.uniturGrubu,
|
uniturGrubu: opts.uniturGrubu,
|
||||||
|
ozellik: opts.ozellik,
|
||||||
limit: opts.limitPerBucket ?? 12,
|
limit: opts.limitPerBucket ?? 12,
|
||||||
};
|
};
|
||||||
const hayal = dilimAra(sira, turKey, "hayal", ortak);
|
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
|
// 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.
|
// 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 {
|
import {
|
||||||
PUAN_TURLERI,
|
PUAN_TURLERI,
|
||||||
type Program,
|
type Program,
|
||||||
@@ -16,10 +18,55 @@ export interface AdayProgram extends Program {
|
|||||||
dilim: Dilim;
|
dilim: Dilim;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Gevsetilen = "profil" | "kategori" | "il";
|
||||||
|
|
||||||
export interface HavuzSonuc {
|
export interface HavuzSonuc {
|
||||||
havuz: AdayProgram[];
|
havuz: AdayProgram[];
|
||||||
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi
|
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi (sırayla)
|
||||||
gevsetildi: ("kategori" | "il")[];
|
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;
|
const HEDEF = 24;
|
||||||
@@ -89,12 +136,16 @@ function dilimEkle(r: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz.
|
* Deterministik aday havuzu: yapay zekâ bu havuzdan SEÇER, asla uydurmaz.
|
||||||
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz
|
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) ve varsa danışman
|
||||||
* 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste
|
* profiline (dil, öğretim türü, burs, ücret, km) göre filtreler; havuz 24'ün
|
||||||
* her zaman kurulabilsin.
|
* 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 =
|
const uniturGrubu =
|
||||||
params.universiteTipi && params.universiteTipi !== "farketmez"
|
params.universiteTipi && params.universiteTipi !== "farketmez"
|
||||||
? params.universiteTipi
|
? params.universiteTipi
|
||||||
@@ -110,6 +161,30 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
|||||||
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler, p.fakulte))
|
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler, p.fakulte))
|
||||||
: havuz;
|
: 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)
|
// 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS)
|
||||||
const tamHam = dilimEkle(
|
const tamHam = dilimEkle(
|
||||||
searchByRank(params.sira, params.tur, {
|
searchByRank(params.sira, params.tur, {
|
||||||
@@ -122,7 +197,7 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
|||||||
if (tamFiltreli.length >= HEDEF) {
|
if (tamFiltreli.length >= HEDEF) {
|
||||||
return {
|
return {
|
||||||
havuz: dilimBasinaKirp(tamFiltreli, PROMPT_LIMIT_DILIM),
|
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) {
|
if (kategoriler.length > 0 && tamHam.length >= HEDEF) {
|
||||||
return {
|
return {
|
||||||
havuz: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM),
|
havuz: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM),
|
||||||
gevsetildi: ["kategori"],
|
gevsetildi: [...profilGevsetildi, "kategori"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
|
// 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");
|
if (iller.length > 0) gevsetildi.push("il");
|
||||||
const ilsizHam = dilimEkle(
|
const ilsizHam = dilimEkle(
|
||||||
searchByRank(params.sira, params.tur, {
|
searchByRank(params.sira, params.tur, {
|
||||||
|
|||||||
Reference in New Issue
Block a user