feat(danisman): araç kullanımı — program_ara/program_detay/universite_profili sorgu araçları + kapalı listeli profil (profil_guncelle)

- src/lib/ai/araclar.ts: OpenAI uyumlu 4 araç; sorgu araçları yalnız okur
  (yokatlas.db + program_ozellik + YÖK İstatistik + URAP), sayı uydurma yok.
  profil_guncelle yalnız 7 tanımlı alanı yazar (evIl, maxKm, ucretUst, burs,
  dil, ogretimTuru, kampus); sağlık/din/gelir/cinsiyet şemada yok (KVKK §8).
  Profil süzgeci sonucu sıfırlarsa profilsiz tekrar denenip modele söylenir.
- src/lib/ai/cagri.ts: sohbetAkisiAraclarla — stream'de tool_calls biriktirme,
  en fazla 4 tur, son tur araç kapalı; sağlayıcı reddederse düz sohbete düşer.
- src/lib/danisman-profil.ts (saf şema + metinler), danisman-profil-db.ts,
  drizzle 0005 danisman_profil tablosu, /api/soru/profil GET+DELETE.
- /api/soru: profil prompt'a girer, araç kuralları eklendi.
- Sohbet başlığı altında 'Kaydettiklerim' çipleri (tek tıkla sil); listem
  sayfası profili sunucudan indirir. Analitiğe yalnız alan adı gider.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-25 01:13:58 +03:00
parent c23910cc79
commit aa2051732d
15 changed files with 1996 additions and 8 deletions

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

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

View File

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

View File

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

View File

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

View File

@@ -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));
}

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

@@ -0,0 +1,114 @@
/**
* 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).
*
* 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(" ");
}

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

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