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

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(" ");
}