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:
456
src/lib/ai/araclar.ts
Normal file
456
src/lib/ai/araclar.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
|
||||
*
|
||||
* İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
|
||||
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
|
||||
* (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
|
||||
* üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
|
||||
*
|
||||
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
|
||||
*/
|
||||
import type OpenAI from "openai";
|
||||
import {
|
||||
dilimAraligi,
|
||||
EFEKTIF_SIRA,
|
||||
getDb,
|
||||
programGetir,
|
||||
programSatiri,
|
||||
SELECT_COLS,
|
||||
type ProgramHamSatiri,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
kosulMetinleri,
|
||||
ogretimUyesiToplami,
|
||||
ozellikOzeti,
|
||||
programOzellikGetir,
|
||||
programOzellikleriGetir,
|
||||
type ProgramOzellik,
|
||||
} from "@/lib/program-ozellik";
|
||||
import { getAllUniversiteler, getUniversiteBySlug } from "@/lib/katalog";
|
||||
import { kadinYuzdesi, ogrenciBasinaUye, YOK_ISTATISTIK_DONEM } from "@/lib/yok-istatistik";
|
||||
import { URAP_DONEM, URAP_TOPLAM } from "@/lib/urap-siralama";
|
||||
import { turkishSlugify } from "@/lib/slug";
|
||||
import { DILIM_ETIKET } from "@/lib/risk";
|
||||
import { YILLAR } from "@/lib/veri-yillari";
|
||||
import { PUAN_TURLERI, type Program, type PuanTuruKey } from "@/types/yokatlas";
|
||||
import {
|
||||
PROFIL_ALAN_ACIKLAMA,
|
||||
PROFIL_ALANLARI,
|
||||
PROFIL_SECENEKLERI,
|
||||
profilSatirlari,
|
||||
type DanismanProfili,
|
||||
type ProfilAlani,
|
||||
} from "@/lib/danisman-profil";
|
||||
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
|
||||
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
|
||||
|
||||
export type AracBaglami = {
|
||||
userId: string;
|
||||
sira: number;
|
||||
tur: PuanTuruKey;
|
||||
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
|
||||
profil: DanismanProfili;
|
||||
};
|
||||
|
||||
export type AracTanimi = OpenAI.Chat.Completions.ChatCompletionTool;
|
||||
|
||||
const ARAMA_LIMIT = 8;
|
||||
|
||||
export const ARACLAR: AracTanimi[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "program_ara",
|
||||
description:
|
||||
"Adayın puan türündeki programları veriden arar (YÖK Atlas, son yerleştirme). Bölüm adı, üniversite, il, öğretim dili, burs ve yıllık ücret üst sınırına göre süzer; adayın sırasına en yakın programları döner. Sayı uydurmak yerine bunu çağır.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
arama: { type: "string", description: "Bölüm adı parçası, ör. 'bilgisayar mühendisliği', 'hukuk'" },
|
||||
universite: { type: "string", description: "Üniversite adı parçası" },
|
||||
il: { type: "string", description: "İl adı, ör. 'İzmir'" },
|
||||
dil: { type: "string", enum: ["Türkçe", "İngilizce"], description: "Öğretim dili" },
|
||||
burs: {
|
||||
type: "string",
|
||||
enum: ["Burslu", "%50 İndirimli", "%25 İndirimli", "Ücretli"],
|
||||
description: "Vakıf burs oranı",
|
||||
},
|
||||
ucret_ust: { type: "integer", description: "Yıllık ücret üst sınırı (TL); devlet programları her zaman geçer" },
|
||||
dilim: {
|
||||
type: "string",
|
||||
enum: ["hayal", "dengeli", "guvenli"],
|
||||
description: "Yalnız bu dilimdekiler: hayal = adayın sırasının üstünde kapananlar, dengeli = sırasının 1,4 katına kadar, guvenli = daha altı",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "program_detay",
|
||||
description:
|
||||
"Tek programın ayrıntısı: yıllara göre taban sırası ve kontenjan, ücret/burs, dil, öğretim türü, akreditasyon, kadro, tercih barajı ve kılavuzdaki yerleşme koşulları (sağlık şartı, mülakat vb.).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
program_id: { type: "string", description: "program_ara sonucundaki id (kılavuz kodu)" },
|
||||
},
|
||||
required: ["program_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "universite_profili",
|
||||
description:
|
||||
"Üniversitenin künyesi: il, devlet/vakıf, program sayısı, URAP araştırma sırası, örgün öğrenci sayısı, hoca başına öğrenci ve kadın/erkek öğrenci oranı (üniversite geneli, bölüm bazında veri yok).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
universite: { type: "string", description: "Üniversite adı, ör. 'Boğaziçi', 'ODTÜ', 'Ege Üniversitesi'" },
|
||||
},
|
||||
required: ["universite"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "profil_guncelle",
|
||||
description:
|
||||
"Adayın sohbette söylediği bir tercihi profile kaydeder ya da siler (deger null). YALNIZ şu alanlar: " +
|
||||
PROFIL_ALANLARI.map((a) => `${a} (${PROFIL_ALAN_ACIKLAMA[a]})`).join("; ") +
|
||||
". Sağlık, engel, din, aile, gelir, cinsiyet gibi bilgileri ASLA kaydetme. Kaydettikten sonra cevabında tek cümleyle 'Şunu kaydettim: …' de.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
alan: { type: "string", enum: PROFIL_ALANLARI },
|
||||
deger: {
|
||||
type: ["string", "null"],
|
||||
description:
|
||||
"evIl: il adı; maxKm: sayı (km); ucretUst: sayı (TL, yıllık); " +
|
||||
`burs: ${Object.keys(PROFIL_SECENEKLERI.burs).join("|")}; ` +
|
||||
`dil: ${Object.keys(PROFIL_SECENEKLERI.dil).join("|")}; ` +
|
||||
`ogretimTuru: ${Object.keys(PROFIL_SECENEKLERI.ogretimTuru).join("|")}; ` +
|
||||
`kampus: ${Object.keys(PROFIL_SECENEKLERI.kampus).join("|")}; null = alanı sil`,
|
||||
},
|
||||
},
|
||||
required: ["alan", "deger"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ── Yardımcılar ──────────────────────────────────────────────────────────
|
||||
|
||||
function dilimAdi(sira: number, efektif: number | null): string {
|
||||
if (efektif == null) return "Veri yetersiz";
|
||||
if (efektif < sira) return DILIM_ETIKET.hayal;
|
||||
if (efektif <= dilimAraligi(sira, "dengeli").ust!) return DILIM_ETIKET.dengeli;
|
||||
return DILIM_ETIKET.garanti;
|
||||
}
|
||||
|
||||
function programOzeti(p: Program, sira: number, o?: ProgramOzellik) {
|
||||
return {
|
||||
id: p.id,
|
||||
program: p.isim,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
tur: p.unitur,
|
||||
tabanSira: p.efektifSira,
|
||||
dilim: dilimAdi(sira, p.efektifSira),
|
||||
kontenjan: p.kontenjanSon,
|
||||
ozellik: o ? ozellikOzeti(o) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** SQLite LIKE Türkçe büyük/küçük harfi bilmez: hem yazıldığı gibi hem başlık düzeni denenir. */
|
||||
function likeVaryantlari(q: string): string[] {
|
||||
const t = q.trim();
|
||||
const baslik = t
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.split(" ")
|
||||
.map((w) => (w ? w[0].toLocaleUpperCase("tr-TR") + w.slice(1) : w))
|
||||
.join(" ");
|
||||
return Array.from(new Set([t, baslik, t.toLocaleUpperCase("tr-TR")]));
|
||||
}
|
||||
|
||||
function likeKosulu(kolon: string, q: string): { sql: string; args: string[] } {
|
||||
const v = likeVaryantlari(q);
|
||||
return {
|
||||
sql: `(${v.map(() => `${kolon} LIKE ?`).join(" OR ")})`,
|
||||
args: v.map((x) => `%${x}%`),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Araç gövdeleri ───────────────────────────────────────────────────────
|
||||
|
||||
type ProgramAraArg = {
|
||||
arama?: string;
|
||||
universite?: string;
|
||||
il?: string;
|
||||
dil?: string;
|
||||
burs?: string;
|
||||
ucret_ust?: number;
|
||||
dilim?: "hayal" | "dengeli" | "guvenli";
|
||||
};
|
||||
|
||||
function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): ProgramAraSonuc {
|
||||
const db = getDb();
|
||||
const profil: DanismanProfili = profilUygula ? b.profil : {};
|
||||
const kosullar: string[] = ["tur = ?", "onlisans = ?", `${EFEKTIF_SIRA} IS NOT NULL`];
|
||||
const args: (string | number)[] = [PUAN_TURLERI[b.tur], b.tur === "tyt" ? 1 : 0];
|
||||
const uygulanan: string[] = [];
|
||||
|
||||
if (a.arama) {
|
||||
const k = likeKosulu("isim", a.arama);
|
||||
kosullar.push(k.sql);
|
||||
args.push(...k.args);
|
||||
uygulanan.push(`bölüm ~ "${a.arama}"`);
|
||||
}
|
||||
if (a.universite) {
|
||||
const k = likeKosulu("universite", a.universite);
|
||||
kosullar.push(k.sql);
|
||||
args.push(...k.args);
|
||||
uygulanan.push(`üniversite ~ "${a.universite}"`);
|
||||
}
|
||||
// İl: parametre > profildeki km sınırı
|
||||
if (a.il) {
|
||||
const il = ilAdiNormalize(a.il);
|
||||
if (!il) {
|
||||
return { toplamEslesen: 0, gosterilen: 0, uygulananFiltreler: [], sonuclar: [], not: `İl tanınmadı: ${a.il}` };
|
||||
}
|
||||
kosullar.push("il = ?");
|
||||
args.push(il);
|
||||
uygulanan.push(`il = ${il}`);
|
||||
} else if (profil.evIl && profil.maxKm) {
|
||||
const iller = yakinIller(profil.evIl, profil.maxKm);
|
||||
if (iller.length) {
|
||||
kosullar.push(`il IN (${iller.map(() => "?").join(",")})`);
|
||||
args.push(...iller);
|
||||
uygulanan.push(`profil: ${profil.evIl} iline en fazla ${profil.maxKm} km (${iller.length} il)`);
|
||||
}
|
||||
}
|
||||
// Özellik süzgeçleri alt sorguda (program_ozellik.id çakışmasın)
|
||||
const ozKosul: string[] = [];
|
||||
const ozArgs: (string | number)[] = [];
|
||||
const dil = a.dil ?? (profil.dil === "ingilizce" ? "İngilizce" : profil.dil === "turkce" ? "Türkçe" : undefined);
|
||||
if (dil) {
|
||||
ozKosul.push("ogrenim_dili LIKE ?");
|
||||
ozArgs.push(`${dil}%`);
|
||||
uygulanan.push(`dil = ${dil}${a.dil ? "" : " (profil)"}`);
|
||||
}
|
||||
if (a.burs) {
|
||||
ozKosul.push("burs_orani = ?");
|
||||
ozArgs.push(a.burs);
|
||||
uygulanan.push(`burs = ${a.burs}`);
|
||||
} else if (profil.burs === "yalniz-burslu") {
|
||||
ozKosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
|
||||
uygulanan.push("profil: vakıfta yalnız burslu");
|
||||
}
|
||||
const ucretUst = a.ucret_ust ?? profil.ucretUst;
|
||||
if (ucretUst) {
|
||||
ozKosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
|
||||
ozArgs.push(ucretUst);
|
||||
uygulanan.push(`yıllık ücret ≤ ${ucretUst.toLocaleString("tr-TR")} TL${a.ucret_ust ? "" : " (profil)"}`);
|
||||
}
|
||||
if (profil.ogretimTuru === "yalniz-orgun") {
|
||||
ozKosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
|
||||
uygulanan.push("profil: yalnız örgün");
|
||||
}
|
||||
if (ozKosul.length) {
|
||||
kosullar.push(`id IN (SELECT id FROM program_ozellik WHERE ${ozKosul.join(" AND ")})`);
|
||||
args.push(...ozArgs);
|
||||
}
|
||||
if (a.dilim) {
|
||||
const anahtar = a.dilim === "guvenli" ? "garanti" : a.dilim;
|
||||
const { alt, ust } = dilimAraligi(b.sira, anahtar);
|
||||
kosullar.push(ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`);
|
||||
args.push(alt);
|
||||
if (ust != null) args.push(ust);
|
||||
uygulanan.push(`dilim = ${DILIM_ETIKET[anahtar]}`);
|
||||
}
|
||||
|
||||
const satirlar = db
|
||||
.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")}
|
||||
ORDER BY ABS(${EFEKTIF_SIRA} - ?) ASC LIMIT ?`,
|
||||
)
|
||||
.all(...args, b.sira, ARAMA_LIMIT) as ProgramHamSatiri[];
|
||||
const programlar = satirlar.map(programSatiri);
|
||||
const toplam = (
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${kosullar.join(" AND ")}`).get(...args) as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
// Profil süzgeçleri sonucu sıfırladıysa profilsiz tekrar dene ve bunu söyle:
|
||||
// model "hiç yok" demek yerine "profilin dışında şunlar var" diyebilsin.
|
||||
if (toplam === 0 && profilUygula && uygulanan.some((u) => u.includes("profil"))) {
|
||||
const gevsek = programAra(b, a, false);
|
||||
return {
|
||||
...gevsek,
|
||||
not: `Profil süzgeçleriyle (${uygulanan.filter((u) => u.includes("profil")).join("; ")}) eşleşen program YOK; aşağıdakiler profil süzgeci olmadan bulundu — adaya bunu açıkça söyle. ${gevsek.not}`,
|
||||
};
|
||||
}
|
||||
const ozellikler = programOzellikleriGetir(programlar.map((p) => p.id));
|
||||
return {
|
||||
toplamEslesen: toplam,
|
||||
gosterilen: programlar.length,
|
||||
uygulananFiltreler: uygulanan,
|
||||
sonuclar: programlar.map((p) => programOzeti(p, b.sira, ozellikler[p.id])),
|
||||
not: "tabanSira son yerleştirme yılının taban başarı sırası; her yıl değişir.",
|
||||
};
|
||||
}
|
||||
|
||||
type ProgramAraSonuc = {
|
||||
toplamEslesen: number;
|
||||
gosterilen: number;
|
||||
uygulananFiltreler: string[];
|
||||
sonuclar: ReturnType<typeof programOzeti>[];
|
||||
not: string;
|
||||
};
|
||||
|
||||
function programDetay(b: AracBaglami, a: { program_id?: string }) {
|
||||
const id = String(a.program_id ?? "").trim();
|
||||
const p = id ? programGetir(id) : null;
|
||||
if (!p) return { hata: "Program bulunamadı; önce program_ara ile id al." };
|
||||
const o = programOzellikGetir(id);
|
||||
const kosullar = o ? kosulMetinleri(o.kosulKodlari).slice(0, 8) : [];
|
||||
const evKm = b.profil.evIl && p.il ? illerArasiKm(b.profil.evIl, p.il) : null;
|
||||
return {
|
||||
...programOzeti(p, b.sira, o ?? undefined),
|
||||
fakulte: p.fakulte,
|
||||
sure: p.sure,
|
||||
tabanSiraYillara: Object.fromEntries(YILLAR.map((y, i) => [y, p.siraGecmisi[i]])),
|
||||
puanSon: p.puanSon,
|
||||
yerlesenSon: p.yerlesenSon,
|
||||
ucretYillik: o?.ucret ?? null,
|
||||
bursOrani: o?.bursOrani ?? null,
|
||||
ogretimDili: o?.ogrenimDili ?? null,
|
||||
ogretimTuru: o?.ogrenimTuru ?? null,
|
||||
akreditasyon: o?.akreditasyon ?? null,
|
||||
kadro: o
|
||||
? { prof: o.prof, docent: o.doc, drOgrUyesi: o.dou, arsGor: o.argor, ogretimUyesiToplam: ogretimUyesiToplami(o) }
|
||||
: null,
|
||||
tercihBaraji: o?.minBasariSirasi ?? null,
|
||||
ilce: o?.ilce ?? null,
|
||||
evdenKm: evKm,
|
||||
kosullar: kosullar.map((k) => (k.metin.length > 240 ? `${k.metin.slice(0, 237)}…` : k.metin)),
|
||||
};
|
||||
}
|
||||
|
||||
function universiteProfili(_b: AracBaglami, a: { universite?: string }) {
|
||||
const q = String(a.universite ?? "").trim();
|
||||
if (!q) return { hata: "Üniversite adı gerekli." };
|
||||
const slugQ = turkishSlugify(q);
|
||||
const hepsi = getAllUniversiteler();
|
||||
const kisaltma: Record<string, string> = {
|
||||
odtu: "orta-dogu-teknik",
|
||||
itu: "istanbul-teknik",
|
||||
ytu: "yildiz-teknik",
|
||||
ktu: "karadeniz-teknik",
|
||||
dtu: "deu",
|
||||
};
|
||||
const arananSlug = kisaltma[slugQ] ?? slugQ;
|
||||
const aday =
|
||||
hepsi.find((u) => u.slug === arananSlug || u.slug === `${arananSlug}-universitesi`) ??
|
||||
hepsi.find((u) => u.slug.startsWith(arananSlug)) ??
|
||||
hepsi.find((u) => u.slug.includes(arananSlug));
|
||||
if (!aday) return { hata: `Üniversite bulunamadı: ${q}` };
|
||||
const u = getUniversiteBySlug(aday.slug);
|
||||
if (!u) return { hata: `Üniversite bulunamadı: ${q}` };
|
||||
const i = u.istatistik;
|
||||
const kadin = i ? kadinYuzdesi(i) : null;
|
||||
return {
|
||||
ad: u.ad,
|
||||
il: u.il,
|
||||
tur: u.unitur,
|
||||
programSayisi: u.programSayisi,
|
||||
enDusukTabanSira: u.stats.minSira,
|
||||
toplamKontenjan: u.stats.toplamKontenjan,
|
||||
urap: u.urap ? { sira: u.urap.sira, toplam: URAP_TOPLAM, donem: URAP_DONEM, olctugu: "araştırma performansı; eğitim kalitesi ya da iş bulma değil" } : null,
|
||||
istatistik: i
|
||||
? {
|
||||
donem: YOK_ISTATISTIK_DONEM,
|
||||
orgunOgrenci: i.ogrenci,
|
||||
ogretimUyesi: i.ogretimUyesi,
|
||||
hocaBasinaOgrenci: ogrenciBasinaUye(i),
|
||||
kadinYuzdesi: kadin,
|
||||
erkekYuzdesi: kadin == null ? null : 100 - kadin,
|
||||
not: "Kadın/erkek oranı örgün ön lisans + lisans, ÜNİVERSİTE GENELİ; bölüm bazında veri yok. Bilgi olarak söyle, filtre yapma.",
|
||||
}
|
||||
: null,
|
||||
sayfa: `/universite/${u.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sayiParse(v: unknown): number | null {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v !== "string") return null;
|
||||
const t = v.toLocaleLowerCase("tr-TR").replace(/\s/g, "");
|
||||
const m = t.match(/^([\d.,]+)(bin|k|milyon|m)?/);
|
||||
if (!m) return null;
|
||||
let n = Number.parseFloat(m[1].replace(/\./g, "").replace(",", "."));
|
||||
if (!Number.isFinite(n)) return null;
|
||||
if (m[2] === "bin" || m[2] === "k") n *= 1000;
|
||||
if (m[2] === "milyon" || m[2] === "m") n *= 1_000_000;
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
async function profilGuncelle(b: AracBaglami, a: { alan?: string; deger?: unknown }) {
|
||||
const alan = a.alan as ProfilAlani;
|
||||
if (!PROFIL_ALANLARI.includes(alan)) {
|
||||
return { hata: `Bilinmeyen alan: ${String(a.alan)}. Yalnız ${PROFIL_ALANLARI.join(", ")} kaydedilebilir.` };
|
||||
}
|
||||
let deger: unknown = a.deger;
|
||||
if (deger !== null && deger !== undefined) {
|
||||
if (alan === "maxKm" || alan === "ucretUst") {
|
||||
deger = sayiParse(deger);
|
||||
if (deger == null) return { hata: "Sayı okunamadı." };
|
||||
} else if (alan === "evIl") {
|
||||
const il = ilAdiNormalize(String(deger));
|
||||
if (!il) return { hata: `İl tanınmadı: ${String(deger)}` };
|
||||
deger = il;
|
||||
} else {
|
||||
deger = String(deger).trim();
|
||||
}
|
||||
}
|
||||
try {
|
||||
const profil = await profilAlanYaz(b.userId, alan, deger ?? null);
|
||||
b.profil = profil;
|
||||
const satirlar = profilSatirlari(profil);
|
||||
return {
|
||||
kaydedildi: deger == null ? `${alan} silindi` : satirlar.find((s) => s.alan === alan)?.metin,
|
||||
profil: satirlar.map((s) => s.metin),
|
||||
};
|
||||
} catch {
|
||||
return { hata: `Değer kabul edilmedi (${alan}); aralık dışı ya da geçersiz seçenek.` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Route'un araç döngüsüne verdiği çalıştırıcı. Bilinmeyen araç hata döner. */
|
||||
export function aracCalistirici(b: AracBaglami) {
|
||||
return async (ad: string, arg: unknown): Promise<unknown> => {
|
||||
const a = (arg && typeof arg === "object" ? arg : {}) as Record<string, unknown>;
|
||||
switch (ad) {
|
||||
case "program_ara":
|
||||
return programAra(b, a as ProgramAraArg);
|
||||
case "program_detay":
|
||||
return programDetay(b, a);
|
||||
case "universite_profili":
|
||||
return universiteProfili(b, a);
|
||||
case "profil_guncelle":
|
||||
return profilGuncelle(b, a);
|
||||
default:
|
||||
return { hata: `Bilinmeyen araç: ${ad}` };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { profilGetir };
|
||||
@@ -1,3 +1,4 @@
|
||||
import type OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
|
||||
|
||||
@@ -166,3 +167,109 @@ export async function* sohbetAkisi(opts: {
|
||||
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
|
||||
if (sinyal.aborted) throw sinyal.reason;
|
||||
}
|
||||
|
||||
// ── Araçlı sohbet ─────────────────────────────────────────────────────────
|
||||
|
||||
type AracCagrisiBirikim = { id: string; name: string; arguments: string };
|
||||
|
||||
const ARAC_TUR_SINIRI = 4;
|
||||
|
||||
/**
|
||||
* Araç çağrılı sohbet akışı. Model araç isterse (stream'de tool_calls
|
||||
* parçaları birikir) araçlar çalıştırılır, sonuçlar `tool` mesajı olarak
|
||||
* eklenir ve akış yeniden başlar; en fazla ARAC_TUR_SINIRI tur, son turda
|
||||
* araç kapalı (cevap vermek zorunda). Metin parçaları geldikçe yield edilir.
|
||||
*
|
||||
* Sağlayıcı araçları desteklemiyorsa (ilk çağrıda 400/404) düz sohbete düşer.
|
||||
*/
|
||||
export async function* sohbetAkisiAraclarla(opts: {
|
||||
system: string;
|
||||
mesajlar: SohbetMesaji[];
|
||||
maxTokens: number;
|
||||
araclar: OpenAI.Chat.Completions.ChatCompletionTool[];
|
||||
calistir: (ad: string, argumanlar: unknown) => Promise<unknown>;
|
||||
/** Her araç çağrısında (günlük/analitik için) */
|
||||
onArac?: (ad: string) => void;
|
||||
}): AsyncGenerator<string> {
|
||||
const sinyal = AbortSignal.timeout(120_000);
|
||||
const gecmis: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: opts.system },
|
||||
...opts.mesajlar,
|
||||
];
|
||||
|
||||
for (let tur = 0; tur < ARAC_TUR_SINIRI; tur++) {
|
||||
const sonTur = tur === ARAC_TUR_SINIRI - 1;
|
||||
let akis;
|
||||
try {
|
||||
akis = await geciciHatadaTekrarla(() =>
|
||||
getOpenRouter().chat.completions.create(
|
||||
{
|
||||
model: sohbetModeli(),
|
||||
max_tokens: opts.maxTokens,
|
||||
reasoning_effort: "none",
|
||||
stream: true,
|
||||
messages: gecmis,
|
||||
tools: opts.araclar,
|
||||
tool_choice: sonTur ? "none" : "auto",
|
||||
},
|
||||
{ signal: sinyal },
|
||||
),
|
||||
);
|
||||
} catch (hata) {
|
||||
const kod = (hata as { status?: number })?.status;
|
||||
if (tur === 0 && (kod === 400 || kod === 404)) {
|
||||
console.warn("[cagri] sağlayıcı araç çağrısını kabul etmedi, düz sohbete düşüldü:", kod);
|
||||
yield* sohbetAkisi({ system: opts.system, mesajlar: opts.mesajlar, maxTokens: opts.maxTokens });
|
||||
return;
|
||||
}
|
||||
throw hata;
|
||||
}
|
||||
|
||||
let metin = "";
|
||||
const cagrilar = new Map<number, AracCagrisiBirikim>();
|
||||
for await (const parca of akis) {
|
||||
const delta = parca.choices?.[0]?.delta;
|
||||
if (!delta) continue;
|
||||
if (delta.content) {
|
||||
metin += delta.content;
|
||||
yield delta.content;
|
||||
}
|
||||
for (const tc of delta.tool_calls ?? []) {
|
||||
const mevcut = cagrilar.get(tc.index) ?? { id: "", name: "", arguments: "" };
|
||||
if (tc.id) mevcut.id = tc.id;
|
||||
if (tc.function?.name) mevcut.name += tc.function.name;
|
||||
if (tc.function?.arguments) mevcut.arguments += tc.function.arguments;
|
||||
cagrilar.set(tc.index, mevcut);
|
||||
}
|
||||
}
|
||||
if (sinyal.aborted) throw sinyal.reason;
|
||||
if (cagrilar.size === 0) return;
|
||||
|
||||
const liste = [...cagrilar.values()].filter((c) => c.name);
|
||||
const toolCalls = liste.map((c) => ({
|
||||
id: c.id || `call_${Math.random().toString(36).slice(2, 10)}`,
|
||||
type: "function" as const,
|
||||
function: { name: c.name, arguments: c.arguments || "{}" },
|
||||
}));
|
||||
gecmis.push({ role: "assistant", content: metin || null, tool_calls: toolCalls });
|
||||
for (const [i, c] of liste.entries()) {
|
||||
opts.onArac?.(c.name);
|
||||
let arg: unknown = {};
|
||||
try {
|
||||
arg = c.arguments ? JSON.parse(c.arguments) : {};
|
||||
} catch {
|
||||
arg = {};
|
||||
}
|
||||
let sonuc: unknown;
|
||||
try {
|
||||
sonuc = await opts.calistir(c.name, arg);
|
||||
} catch (hata) {
|
||||
console.error("[cagri] araç hatası:", c.name, hata);
|
||||
sonuc = { hata: "Araç çalışmadı; veriye ulaşılamadı, uydurma." };
|
||||
}
|
||||
gecmis.push({ role: "tool", tool_call_id: toolCalls[i].id, content: JSON.stringify(sonuc) });
|
||||
}
|
||||
// Araç turundan sonra araya boşluk: metin + tool ile bitmiş cümle yapışmasın
|
||||
if (metin && !metin.endsWith("\n")) yield "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,12 @@ export type OlayAdi =
|
||||
// kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı
|
||||
// tıklama, Vazgeç); tamamlanan kapıda atılmaz.
|
||||
| "kapi_acildi"
|
||||
| "kapi_kapatildi";
|
||||
| "kapi_kapatildi"
|
||||
// Danışman profili: yalnız ALAN ADI gider, değer gitmez (KVKK: analitiğe
|
||||
// profil değeri sızmaz). kaydedildi = model aracı çağırıp alanı yazdı
|
||||
// (istemci cevap sonrası profili çekip farkı görür); silindi = çipteki çarpı.
|
||||
| "profil_alani_kaydedildi"
|
||||
| "profil_alani_silindi";
|
||||
|
||||
export type Ozellikler = Record<string, string | number>;
|
||||
|
||||
|
||||
@@ -231,3 +231,15 @@ export const savedLists = sqliteTable("saved_lists", {
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
// Danışman profili: sohbetten `profil_guncelle` aracıyla çıkarılan, KAPALI
|
||||
// LİSTELİ tercih alanları (bkz. src/lib/danisman-profil.ts). Serbest metin
|
||||
// yok; öğrenci /listem'de görür ve alan alan siler. Kullanıcı başına tek satır.
|
||||
export const danismanProfil = sqliteTable("danisman_profil", {
|
||||
userId: text("user_id")
|
||||
.primaryKey()
|
||||
.references(() => user.id),
|
||||
// DanismanProfili (yalnız tanımlı anahtarlar; okurken yeniden doğrulanır)
|
||||
alanlar: text("alanlar", { mode: "json" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
114
src/lib/danisman-profil.ts
Normal file
114
src/lib/danisman-profil.ts
Normal 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
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;
|
||||
}
|
||||
Reference in New Issue
Block a user