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

@@ -0,0 +1,29 @@
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { PROFIL_ALANLARI, profilSatirlari, type ProfilAlani } from "@/lib/danisman-profil";
/**
* Danışman profili — sohbetten `profil_guncelle` aracıyla yazılan alanlar.
* GET: alanlar + öğrenciye okunan satırlar. DELETE {alan}: tek alanı siler
* (KVKK: öğrenci her alanı görür ve tek tıkla siler). Yazma yalnız araç
* üzerinden; elle düzenleme sohbette "şunu değiştir" diyerek yapılır.
*/
export async function GET() {
const session = await getSession();
if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 });
const profil = await profilGetir(session.user.id);
return Response.json({ profil, satirlar: profilSatirlari(profil) });
}
export async function DELETE(request: NextRequest) {
const session = await getSession();
if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 });
const body = (await request.json().catch(() => null)) as { alan?: unknown } | null;
const alan = typeof body?.alan === "string" ? (body.alan as ProfilAlani) : null;
if (!alan || !PROFIL_ALANLARI.includes(alan)) {
return Response.json({ error: "Geçersiz alan." }, { status: 400 });
}
const profil = await profilAlanYaz(session.user.id, alan, null);
return Response.json({ profil, satirlar: profilSatirlari(profil) });
}

View File

@@ -9,7 +9,10 @@ import {
} from "@/lib/credits";
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { sohbetAkisi } from "@/lib/ai/cagri";
import { sohbetAkisiAraclarla } from "@/lib/ai/cagri";
import { ARACLAR, aracCalistirici, type AracBaglami } from "@/lib/ai/araclar";
import { profilGetir } from "@/lib/danisman-profil-db";
import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil";
import { listeOzetiCikar } from "@/lib/ai/rapor";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { RaporParams } from "@/features/rapor/types/rapor";
@@ -24,6 +27,7 @@ function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
kilitli?: boolean;
profil?: DanismanProfili;
}): string {
const { params } = opts;
const secimVar =
@@ -50,7 +54,22 @@ KURALLAR:
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
- ASLA yerleşme garantisi verme; olasılık, yüzde ya da "ihtimal" dili kullanma —
güvenli / az riskli / riskli etiketleri ve taban sıralarıyla konuş.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
- Bilmediğin güncel veriyi uydurma: taban sırası, kontenjan, ücret, burs,
dil, akreditasyon, koşul ya da üniversite künyesi gerekince ARAÇLARI çağır
(program_ara, program_detay, universite_profili); araç veremiyorsa
"elimde veri yok" de ve YÖK Atlas'a bakmasını söyle.
- ARAÇ KURALLARI: araçlar yalnız okur; 24'lük listeyi araçla kurmazsın,
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır.
- PROFİL: aday bir tercih söylerse (yaşadığı il, evden en fazla kaç km,
vakıfta yıllık ücret sınırı, burs beklentisi, öğretim dili, uzaktan/açık
kabul mü, kampüs büyüklüğü) profil_guncelle ile kaydet ve cevabının
sonunda tek cümleyle söyle: "Şunu kaydettim: …". Emin değilsen önce sor,
tahminle kaydetme. Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma:
sağlık/engel, din, aile durumu, gelir, cinsiyet, siyasi görüş. Aday
bunları kendiliğinden yazsa bile profile YAZMA; yalnız cevabında dikkate al.
- Kadın/erkek öğrenci oranı üniversite genelidir (bölüm bazında veri yok);
sorulursa bilgi olarak söyle, filtre ya da tavsiye ölçütü yapma.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
- Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır.
- GÜVENLİK, öteki kuralların önünde gelir: aday umutsuzluk, kendine zarar
@@ -77,6 +96,13 @@ Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satı
tercih soruları üzerinden yardımcı olmaya devam et.`,
);
}
const profilSatir = opts.profil ? profilSatirlari(opts.profil) : [];
if (profilSatir.length > 0) {
parcalar.push(
`ADAYIN SOHBETTE SÖYLEDİĞİ VE KAYDEDİLEN TERCİHLERİ (profil; program_ara bunları otomatik uygular):\n` +
profilSatir.map((s) => `- ${s.metin}`).join("\n"),
);
}
if (secimVar) {
parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
@@ -156,7 +182,7 @@ export async function POST(request: NextRequest) {
// Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel.
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
const [rapor, gecmisHam, kullanici] = await Promise.all([
const [rapor, gecmisHam, kullanici, profil] = await Promise.all([
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
kullanicininRaporu(session.user.id),
appDb
@@ -169,6 +195,7 @@ export async function POST(request: NextRequest) {
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
}),
profilGetir(session.user.id),
]);
if (!rapor?.result) {
return Response.json(
@@ -213,11 +240,21 @@ export async function POST(request: NextRequest) {
].join("\n")
: tamOzet;
const raporParams = rapor.params as RaporParams;
const system = sistemPromptu({
params: rapor.params as RaporParams,
params: raporParams,
listeOzeti,
kilitli,
profil,
});
// Araçların bağlamı: aday sırası/puan türü (arama süzgeci) ve profil
// (profil_guncelle yazınca aynı tur içinde tazelenir).
const aracBaglami: AracBaglami = {
userId: session.user.id,
sira: raporParams.sira,
tur: raporParams.tur,
profil,
};
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
@@ -286,10 +323,13 @@ export async function POST(request: NextRequest) {
let metin = "";
try {
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
for await (const parca of sohbetAkisi({
for await (const parca of sohbetAkisiAraclarla({
system,
mesajlar,
maxTokens: 1024,
araclar: ARACLAR,
calistir: aracCalistirici(aracBaglami),
onArac: (ad) => console.info("[soru] araç:", ad),
})) {
metin += parca;
controller.enqueue(encoder.encode(parca));