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