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

@@ -31,6 +31,7 @@ import {
type DanismanOzeti,
type IlkTercihYorumu,
type Mesaj,
type ProfilSatiri,
} from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import {
@@ -55,6 +56,7 @@ export function ListemGovde({
kredi,
raporKredi,
sohbetGecmisi,
danismanProfili,
ozet,
ilkYorum,
kalanHak,
@@ -76,6 +78,8 @@ export function ListemGovde({
* kapıdan geçtiği için aynı bedeli gösterir. */
raporKredi: number;
sohbetGecmisi: Mesaj[];
/** Danışmanın kaydettiği tercihler (sunucudan; çipler bununla açılır) */
danismanProfili: ProfilSatiri[];
ozet?: DanismanOzeti;
/** Sohbet boşken açılışta gösterilen 1. tercih yorumu */
ilkYorum?: IlkTercihYorumu;
@@ -124,6 +128,12 @@ export function ListemGovde({
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi"
: `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`,
];
satirlar.push(
"Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs)",
);
satirlar.push(
"Sorunca veriden okur: taban sıraları, kontenjan, ücret/burs, dil, akreditasyon, koşullar, üniversite künyesi",
);
if (params.kategoriler?.length) {
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
}
@@ -318,6 +328,7 @@ export function ListemGovde({
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
@@ -329,6 +340,7 @@ export function ListemGovde({
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}

View File

@@ -7,6 +7,8 @@ import {
cerezdenTercihProfili,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import { kullanicininRaporu, sohbetGecmisiGetir } from "../rapor-queries";
import { profilGetir } from "@/lib/danisman-profil-db";
import { profilSatirlari } from "@/lib/danisman-profil";
import { URUNLER, MAX_REVIZYON, RAPOR_KREDI } from "@/lib/credits";
import type {
MaskeliRapor,
@@ -53,10 +55,11 @@ export async function ListemIcerik({
? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}`
: "/listem";
const session = await verifySession(geriYol);
const [user, satir, sohbetGecmisi] = await Promise.all([
const [user, satir, sohbetGecmisi, danismanProfili] = await Promise.all([
getCurrentUser(),
kullanicininRaporu(session.user.id),
sohbetGecmisiGetir(session.user.id),
profilGetir(session.user.id),
]);
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
@@ -171,6 +174,7 @@ export async function ListemIcerik({
user.hasPaket ? 0 : RAPOR_KREDI
}
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
danismanProfili={profilSatirlari(danismanProfili)}
ozet={danismanOzeti}
ilkYorum={ilkYorum}
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}

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>