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:
@@ -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}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user