Files
kolaytercih/src/lib/db.ts
bilalgursen 5a04db7e4b feat(liste): danışman profili havuzu süzer (önce profil gevşetilir) + KVKK metin düzeltmeleri
- db.ts OzellikSuzgeci (dil, yalnız örgün, yalnız burslu, ücret üst sınırı)
  program_ozellik alt sorgusuyla; tablo yoksa süzgeç yok.
- rapor-havuzu: profilSuzgeci (ev ili + km → il listesi; sihirbazda il
  seçiliyse km uygulanmaz); havuz 24'ün altına düşerse gevşetme sırası
  profil → il → kategori. rapor.ts prompt'a 'danışmana söylediği tercihler'
  ve profil gevşetme notu; rapor-actions profili okuyup geçirir.
- /meraklisina şeması (huni-semasi SEMA + aria-label + figcaption) ve 4.
  bölüm metni yeni adım ve gevşetme sırasıyla eşitlendi (AGENTS.md kuralı).
- /api/soru: profil kaydı öncesi 'kaydedeyim mi?' onayı (PROFIL_ONAY_SOR).
- Gizlilik metni kodla eşitlendi: OpenRouter→DeepSeek (Anthropic değil),
  profil satırı, amaç+hukuki sebep, saklama süreleri, md. 11 hakları,
  18 yaş altı notu; unvan/adres ve aktarım dayanağı TODO. Giriş kartı:
  koşullar 'kabul', gizlilik 'okudum'. Sohbet girişine kişisel bilgi uyarısı.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 01:23:14 +03:00

383 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
import path from "node:path";
import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
import { EFEKTIF_SIRA_SQL, SON_YIL, YILLAR } from "./veri-yillari";
import {
PUAN_TURLERI,
type DilimKey,
type Program,
type ProgramNetSatiri,
type PuanTuruKey,
type RankResults,
type SihirbazFacetleri,
type UniturGrubu,
} from "@/types/yokatlas";
// Dev'de hot-reload başına yeni bağlantı açılmasın diye global cache
const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database };
export function getDb(): Database.Database {
if (!globalForDb.yokatlasDb) {
globalForDb.yokatlasDb = new Database(
path.join(process.cwd(), "data", "yokatlas.db"),
{ readonly: true, fileMustExist: true }
);
}
return globalForDb.yokatlasDb;
}
// Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB
// dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır
// `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir).
export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")},
puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon`;
// Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer.
// İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts.
export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL;
/** SELECT_COLS ile okunan ham satır: sıra kolonları `s<yıl>` takma adıyla gelir. */
export type ProgramHamSatiri = Omit<
Program,
"siraGecmisi" | "efektifSira" | "siraSon"
> &
Record<`s${number}`, number | null>;
/** Ham DB satırını yılsız `Program`'a çevirir. */
export function programSatiri(row: ProgramHamSatiri): Program {
const siraGecmisi = YILLAR.map((yil) => row[`s${yil}`] ?? null);
const siraSon = siraGecmisi[siraGecmisi.length - 1] ?? null;
return {
id: row.id,
isim: row.isim,
universite: row.universite,
unitur: row.unitur,
il: row.il,
fakulte: row.fakulte,
tur: row.tur,
sure: row.sure,
siraGecmisi,
// EFEKTIF_SIRA ile aynı kural: son yıl, yoksa bir önceki yıl
efektifSira: siraSon ?? siraGecmisi[siraGecmisi.length - 2] ?? null,
siraSon,
puanSon: row.puanSon,
kontenjanSon: row.kontenjanSon,
yerlesenSon: row.yerlesenSon,
};
}
/**
* Kullanıcının başarı sıralamasına göre programları üç dilime ayırır.
* Sıralamada küçük sayı daha iyidir; program taban sıralaması kullanıcının
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
* demektir (hayal), büyükse daha güvenli demektir (garanti).
*/
/**
* Danışman profilinden gelen program özelliği süzgeci (program_ozellik
* tablosu; bkz. lib/program-ozellik.ts). Tablo yoksa süzgeç uygulanmaz.
* Devlet programlarında ücret/burs NULL'dur ve her zaman geçer.
*/
export type OzellikSuzgeci = {
dil?: "Türkçe" | "İngilizce";
yalnizOrgun?: boolean;
yalnizBurslu?: boolean;
ucretUst?: number;
};
let ozellikTablosuVar: boolean | null = null;
function ozellikFiltreSql(o?: OzellikSuzgeci): { sql: string; args: (string | number)[] } {
if (!o) return { sql: "", args: [] };
const kosul: string[] = [];
const args: (string | number)[] = [];
if (o.dil) {
kosul.push("ogrenim_dili LIKE ?");
args.push(`${o.dil}%`);
}
if (o.yalnizOrgun) kosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
if (o.yalnizBurslu) kosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
if (o.ucretUst) {
kosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
args.push(o.ucretUst);
}
if (kosul.length === 0) return { sql: "", args: [] };
if (ozellikTablosuVar == null) {
ozellikTablosuVar =
getDb()
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'")
.get() !== undefined;
}
if (!ozellikTablosuVar) return { sql: "", args: [] };
return {
sql: `AND id IN (SELECT id FROM program_ozellik WHERE ${kosul.join(" AND ")})`,
args,
};
}
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
if (grup === "vakif")
return { sql: "AND unitur IS NOT NULL AND unitur <> ?", args: ["DEVLET"] };
return { sql: "", args: [] };
}
/**
* Dilim sınırları. Pencereler çarpan bazlı DEĞİL, açık uçlu: hayal adayın
* üstündeki her şeyi, garanti 1.4× eşiğinin altındaki her şeyi kapsar.
* "Yakından uzağa" sıralama + limit/offset sayesinde uçtaki gerçekçi olmayan
* programlar ancak sayfalandıkça görünür; eski ×0.5/×3 pencereleri küçük
* sıralamalarda (ör. 87) hiç sonuç bırakmıyordu.
*/
export function dilimAraligi(
sira: number,
dilim: DilimKey
): { alt: number; ust: number | null } {
const dengeliUst = Math.round(sira * 1.4);
if (dilim === "hayal") return { alt: 1, ust: sira - 1 };
if (dilim === "dengeli") return { alt: sira, ust: dengeliUst };
return { alt: dengeliUst + 1, ust: null };
}
/**
* Tek dilimi "yakından uzağa" sıralı ve sayfalı getirir: hayal adayın
* sıralamasından yukarı (taban DESC), dengeli/garanti aşağı (taban ASC).
* toplam, limit'ten bağımsız olarak dilimdeki tüm eşleşme sayısıdır.
*/
export function dilimAra(
sira: number,
turKey: PuanTuruKey,
dilim: DilimKey,
opts: {
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
ozellik?: OzellikSuzgeci;
limit?: number;
offset?: number;
} = {}
): { programlar: Program[]; toplam: number } {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const limit = opts.limit ?? 12;
const offset = opts.offset ?? 0;
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
const illerListe = [
...(opts.il ? [opts.il] : []),
...(opts.iller ?? []),
].filter((v, i, a) => v && a.indexOf(v) === i);
const ilFiltre =
illerListe.length > 0
? `AND il IN (${illerListe.map(() => "?").join(",")})`
: "";
const unitur = uniturFiltreSql(opts.uniturGrubu);
const ozellik = ozellikFiltreSql(opts.ozellik);
const { alt, ust } = dilimAraligi(sira, dilim);
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
const siraKosulu =
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
const siraArgs = ust != null ? [alt, ust] : [alt];
const args = [tur, onlisans, ...illerListe, ...unitur.args, ...ozellik.args, ...siraArgs];
const base = `FROM programs
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} ${ozellik.sql}
AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${siraKosulu}`;
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
const yon = dilim === "hayal" ? "DESC" : "ASC";
const satirlar = db
.prepare(
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?`
)
.all(...args, limit, offset) as ProgramHamSatiri[];
const toplam = (
db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as {
adet: number;
}
).adet;
return { programlar: satirlar.map(programSatiri), toplam };
}
export function searchByRank(
sira: number,
turKey: PuanTuruKey,
opts: {
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
ozellik?: OzellikSuzgeci;
limitPerBucket?: number;
} = {}
): RankResults {
const ortak = {
il: opts.il,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
ozellik: opts.ozellik,
limit: opts.limitPerBucket ?? 12,
};
const hayal = dilimAra(sira, turKey, "hayal", ortak);
const dengeli = dilimAra(sira, turKey, "dengeli", ortak);
const garanti = dilimAra(sira, turKey, "garanti", ortak);
return {
hayal: hayal.programlar,
dengeli: dengeli.programlar,
garanti: garanti.programlar,
toplam: {
hayal: hayal.toplam,
dengeli: dengeli.toplam,
garanti: garanti.toplam,
},
};
}
/**
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
*/
export function netlerGetir(programId: string): ProgramNetSatiri[] {
return getDb()
.prepare(
`SELECT yil, puanTuru, tabanPuan, obp,
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
aytDinNet, aytFelNet, ydtYdilNet
FROM netler WHERE program_id = ? ORDER BY yil DESC`
)
.all(programId) as ProgramNetSatiri[];
}
/** Güncel kılavuzdaki tek program; arşivlenmiş ya da bilinmeyen id'de null. */
export function programGetir(id: string): Program | null {
const row = getDb()
.prepare(`SELECT ${SELECT_COLS} FROM programs WHERE id = ?`)
.get(id) as ProgramHamSatiri | undefined;
return row ? programSatiri(row) : null;
}
/**
* Kayıtlı manuel liste satırlarının güncel efektif sırası (son yıl, yoksa bir
* önceki). Kayıtlar efektif sırayı ekleme anının snapshot'ı olarak taşır; yeni
* yerleştirme yılı yüklenince bununla tazelenir. Güncel kılavuzda olmayan
* (arşivlenmiş) id'ler sonuçta yer almaz — çağıran taraf eski değeri korur.
*/
export function efektifSiralarGetir(
ids: readonly string[]
): Record<string, number | null> {
if (ids.length === 0) return {};
const satirlar = getDb()
.prepare(
`SELECT id, ${EFEKTIF_SIRA} AS efektif FROM programs
WHERE id IN (${ids.map(() => "?").join(",")})`
)
.all(...ids) as { id: string; efektif: number | null }[];
const sonuc: Record<string, number | null> = {};
for (const s of satirlar) sonuc[s.id] = s.efektif;
return sonuc;
}
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için
// gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık
// sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı
// görmez; 5B sıradaki görür.
//
// Kademeli (cascade) filtre: adımlar birbirini süzer. Kategoriler yalnızca
// pencereden; iller seçili kategorilerden; üniversite tipi kategori+il'den
// hesaplanır. Böylece "Mühendislik + İzmir" seçen aday 3. adımda yalnızca
// İzmir'deki mühendislik programlarının devlet/vakıf dağılımını görür.
export function rankWindowFacets(
sira: number,
turKey: PuanTuruKey,
secim: { kategoriler?: string[]; iller?: string[] } = {}
): SihirbazFacetleri {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const altSinir = Math.round(sira * 0.5);
const satirlar = db
.prepare(
`SELECT isim, fakulte, il, unitur FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} >= ?`
)
.all(tur, onlisans, altSinir) as {
isim: string;
fakulte: string | null;
il: string | null;
unitur: string | null;
}[];
// isim+fakülte → kategori eşlemesi pahalı (regexler); tekrar edenler için önbellek
const kategoriCache = new Map<string, string[]>();
const kategorileriGetir = (
isim: string,
fakulte: string | null
): string[] => {
const anahtar = `${isim}|${fakulte ?? ""}`;
let k = kategoriCache.get(anahtar);
if (!k) {
k = kategoriBul(isim, fakulte);
kategoriCache.set(anahtar, k);
}
return k;
};
const seciliKategoriler = secim.kategoriler ?? [];
const kategoriUyar = (isim: string, fakulte: string | null) =>
seciliKategoriler.length === 0 ||
kategorileriGetir(isim, fakulte).some((k) =>
seciliKategoriler.includes(k)
);
const seciliIller = new Set(secim.iller ?? []);
const ilUyar = (il: string | null) =>
seciliIller.size === 0 || (il != null && seciliIller.has(il));
const kategoriSay = new Map<string, number>();
const ilSay = new Map<string, number>();
let devlet = 0;
let vakif = 0;
for (const { isim, fakulte, il, unitur } of satirlar) {
// 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz)
for (const k of kategorileriGetir(isim, fakulte)) {
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1);
}
if (!kategoriUyar(isim, fakulte)) continue;
// 2. adım: iller seçili kategorilerle süzülür
if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1);
if (!ilUyar(il)) continue;
// 3. adım: üniversite tipi kategori + il ile süzülür
if (unitur === "DEVLET") devlet += 1;
else if (unitur) vakif += 1;
}
const kategoriler = KATEGORILER.map((ad) => ({
ad,
adet: kategoriSay.get(ad) ?? 0,
}))
.filter((k) => k.adet >= 3) // gürültüyü ele
.sort((a, b) => b.adet - a.adet);
// Penceredeki programı olan TÜM iller döner (81 il + KKTC/yurtdışı);
// sıralama/gruplama UI'da yapılır. Kırpma yok: kullanıcı her ili görebilmeli.
const iller = [...ilSay.entries()]
.map(([il, adet]) => ({ il, adet }))
.sort((a, b) => b.adet - a.adet);
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });
if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif });
return { kategoriler, iller, uniturler, toplam: satirlar.length };
}