Danışman sohbeti AI SDK'ya taşındı; dört yeni araç

- Sunucu: elle yazılmış araç döngüsü yerine streamText + UI mesaj akışı
  (src/lib/ai/sohbet-akisi.ts). Kredi düşme/iade, bedelsiz profil onayı,
  mesaj kaydı ve Jev çıktı denetimi aynı davranışla korunur.
- İstemci: sohbet-client useChat kullanır; araç çalışırken balonun altında
  durum satırı görünür. Kayıtlı geçmiş düz metin kalır.
- Sağlayıcı aynı tek kapı (AI_BASE_URL / AI_API_KEY), openai-compatible.
- Yeni araçlar: bolum_ozeti, il_ozeti, rehber_ara, tercih_takvimi.
- Kalkan davranış: sağlayıcı araç çağrısını reddedince düz sohbete düşme.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-05 00:36:27 +03:00
parent 6564ec8948
commit b935158d3c
8 changed files with 802 additions and 368 deletions

View File

@@ -8,15 +8,11 @@ import {
grantCredits,
krediBittiIsaretle,
} from "@/lib/credits";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { sohbetAkisiAraclarla } from "@/lib/ai/cagri";
import { ARACLAR, aracCalistirici, type AracBaglami } from "@/lib/ai/araclar";
import {
cevapIhlalOlasiligi,
JEV_DUZELTME_NOTU,
JEV_IHLAL_ESIGI,
} from "@/lib/ai/jev";
import type { AracBaglami } from "@/lib/ai/araclar";
import { danismanAkisi } from "@/lib/ai/sohbet-akisi";
import { profilGetir } from "@/lib/danisman-profil-db";
import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil";
import { listeOzetiCikar } from "@/lib/ai/rapor";
@@ -27,6 +23,12 @@ import { PUAN_TURLERI } from "@/types/yokatlas";
import { ACIK_SATIR } from "@/lib/rapor-maske";
const MAX_GECMIS = 12;
/** Akış cevabının başlıkları; X-Kredi-Dusuldu'yu istemci bakiye için okur. */
const akisBasliklari = (bedelsiz: boolean) => ({
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
"X-Kredi-Dusuldu": bedelsiz ? "0" : "1",
});
/**
* Profil kaydı öncesi "kaydedeyim mi?" onayı (KVKK Üretken YZ Rehberi:
* kişiselleştirme ayrı işleme faaliyeti; onaysız otomatik kayıt avukata
@@ -86,9 +88,11 @@ KURALLAR:
(Bu yasak YERLEŞME tahmini içindir; bolum_istihdam'ın TÜİK istihdam
yüzdesi gibi kaynaklı istatistikleri kaynağıyla aynen aktarabilirsin.)
- Bilmediğin güncel veriyi uydurma: taban sırası, kontenjan, ücret, burs,
dil, akreditasyon, koşul, net, mezun istihdamı ya da üniversite künyesi
gerekince ARAÇLARI çağır (program_ara, program_detay, program_netleri,
program_karsilastir, bolum_istihdam, universite_profili); araç veremiyorsa
dil, akreditasyon, koşul, net, mezun istihdamı, bölüm ya da şehir özeti,
tercih takvimi, rehber yazısı ya da üniversite künyesi gerekince ARAÇLARI
çağır (program_ara, program_detay, program_netleri, program_karsilastir,
bolum_istihdam, bolum_ozeti, il_ozeti, rehber_ara, tercih_takvimi,
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
@@ -302,18 +306,21 @@ export async function POST(request: NextRequest) {
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
const mockEncoder = new TextEncoder();
const mockUserId = session.user.id;
const cevap =
`(Dev mock — Yapay Zeka anahtarı tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` +
`Gerçek danışman burada listen ve seçimlerin bağlamında yanıt verecek. ` +
`${SORUMLULUK_REDDI}`;
const mockReadable = new ReadableStream({
async start(controller) {
const mockStream = createUIMessageStream({
execute: async ({ writer }) => {
const id = crypto.randomUUID();
writer.write({ type: "start" });
writer.write({ type: "text-start", id });
for (const parca of cevap.split(/(?<= )/)) {
controller.enqueue(mockEncoder.encode(parca));
writer.write({ type: "text-delta", id, delta: parca });
await new Promise((r) => setTimeout(r, 25));
}
writer.write({ type: "text-end", id });
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId: mockUserId,
@@ -321,15 +328,12 @@ export async function POST(request: NextRequest) {
content: cevap,
createdAt: new Date(),
});
controller.close();
writer.write({ type: "finish" });
},
});
return new Response(mockReadable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
return createUIMessageStreamResponse({
stream: mockStream,
headers: akisBasliklari(bedelsiz),
});
}
@@ -360,86 +364,44 @@ export async function POST(request: NextRequest) {
{ role: "user" as const, content: mesaj },
];
const encoder = new TextEncoder();
const userId = session.user.id;
const cevabiKaydet = (metin: string) =>
appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId,
role: "assistant",
content: metin,
createdAt: new Date(),
});
const readable = new ReadableStream({
async start(controller) {
let tamamlandi = false;
let metin = "";
try {
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
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));
}
// Çıktı denetimi (lib/ai/jev.ts): cevap garanti/olasılık dili
// içeriyorsa sonuna düzeltme notu eklenir. Akış zaten gittiği için
// cevap geri alınamaz; not hem ekrana hem kayda yazılır. Denetim
// yapılamazsa (anahtar yok, zaman aşımı) cevap olduğu gibi kalır.
const ihlalP = await cevapIhlalOlasiligi(metin);
if (ihlalP != null) {
console.info("[soru] jev ihlal olasılığı:", ihlalP.toFixed(2));
if (ihlalP >= JEV_IHLAL_ESIGI) {
const ek = `\n\n${JEV_DUZELTME_NOTU}`;
metin += ek;
controller.enqueue(encoder.encode(ek));
}
}
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
// Cevap AI SDK'nın UI mesaj akışıyla gider (lib/ai/sohbet-akisi.ts;
// istemci: useChat). Kayıt ve kredi iadesi burada kalır.
const stream = danismanAkisi({
system,
mesajlar,
aracBaglami,
onTamam: async (metin) => {
await cevabiKaydet(metin);
},
// Cevap tamamlanmadı: kredi iade edilir. Yarıda kalan metin varsa yine
// de kaydedilir ki kullanıcı ekranda gördüğü kısmı sayfa yenilenince
// kaybetmesin.
onYarim: async (metin) => {
if (metin) await cevabiKaydet(metin).catch(() => {});
if (!bedelsiz) {
await grantCredits({
userId,
role: "assistant",
content: metin,
createdAt: new Date(),
});
tamamlandi = true;
controller.close();
} catch (err) {
if (!tamamlandi) {
// Cevap tamamlanmadı: kredi iade edilir. Yarıda kalan metin varsa
// yine de kaydedilir ki kullanıcı ekranda gördüğü kısmı sayfa
// yenilenince kaybetmesin.
if (metin) {
try {
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId,
role: "assistant",
content: metin,
createdAt: new Date(),
});
} catch {}
}
if (!bedelsiz) {
await grantCredits({
userId,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
}).catch(() => {});
}
}
console.error("[soru] stream hatası:", err);
controller.error(err);
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
}).catch(() => {});
}
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
// İstemci bakiyeyi buna göre düşürür (onay mesajı bedelsiz)
"X-Kredi-Dusuldu": bedelsiz ? "0" : "1",
},
return createUIMessageStreamResponse({
stream,
headers: akisBasliklari(bedelsiz),
});
}