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

View File

@@ -4,7 +4,7 @@ import { startTransition, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
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 { cn } from "@/lib/utils";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
@@ -42,6 +42,10 @@ import {
import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
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 {
id: string;
@@ -123,8 +127,11 @@ export function SohbetClient({
ozet,
ilkYorum,
bildikleri,
initialProfil,
}: {
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 */
initialMesajlar?: Mesaj[];
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
@@ -146,8 +153,57 @@ export function SohbetClient({
const [kredi, setKredi] = useState(baslangicKredi);
const [temizleAcik, setTemizleAcik] = 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();
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ı
// (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın.
useEffect(() => {
@@ -236,6 +292,8 @@ export function SohbetClient({
// Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
// Danışman bu cevapta profil_guncelle çağırmış olabilir
void profiliCek(new Set(profil.map((s) => s.alan)));
} catch {
// 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
@@ -358,6 +416,35 @@ export function SohbetClient({
</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}>
<DialogContent>
<DialogHeader>