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

115
src/lib/ai/sohbet-akisi.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Danışman cevabının UI mesaj akışı (AI SDK). api/soru/route.ts bunu
* createUIMessageStreamResponse ile döner; istemci useChat ile okur.
*
* Araç çağrıları akışta ayrı parça olarak görünür ("Programlara
* bakıyorum…"); kayda yalnız METİN gider (chat_messages.content düz metin).
* Kayıt ve kredi iadesi çağıranın işidir: cevap tamamlanınca `onTamam`,
* yarıda kalınca `onYarim` çağrılır (ikisi de biriken metinle).
*
* Saf modül: DB'ye ve oturuma dokunmaz, Next dışından da çalışır.
*/
import {
createUIMessageStream,
isStepCount,
streamText,
toUIMessageStream,
type ModelMessage,
type UIMessage,
type UIMessageChunk,
} from "ai";
import { aracSeti, type AracBaglami } from "./araclar";
import { sohbetDilModeli } from "./client";
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
const ARAC_TUR_SINIRI = 4;
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
export function danismanAkisi(opts: {
system: string;
mesajlar: ModelMessage[];
aracBaglami: AracBaglami;
/** Cevap tamamlandı (çıktı denetimi notu dahil). Fırlatırsa akış hata verir. */
onTamam: (metin: string) => Promise<void>;
/** Cevap tamamlanamadı; `metin` o ana kadar üretilen kısım (boş olabilir). */
onYarim: (metin: string) => Promise<void>;
}): ReadableStream<UIMessageChunk> {
return createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
let tamamlandi = false;
let metin = "";
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
let akisHatasi: string | null = null;
try {
const result = streamText({
model: sohbetDilModeli(),
instructions: opts.system,
messages: opts.mesajlar,
tools: aracSeti(opts.aracBaglami, (ad) => console.info("[soru] araç:", ad)),
// En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
// vermek zorunda).
stopWhen: isStepCount(ARAC_TUR_SINIRI),
prepareStep: ({ stepNumber }) =>
stepNumber >= ARAC_TUR_SINIRI - 1 ? { toolChoice: "none" } : undefined,
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(120_000),
providerOptions: { openaiCompatible: { reasoningEffort: "none" } },
onError: ({ error }) => {
// Hata nesnesinin tamamı istek gövdesini ve cevap başlıklarını
// taşır; loga yalnız durum kodu ve mesaj gider.
const e = error as { statusCode?: number; message?: string };
console.error("[soru] model hatası:", e?.statusCode ?? "", e?.message ?? String(error));
},
});
// Bitiş parçasını biz yazarız: araya çıktı denetimi notu girebilir.
const okuyucu = toUIMessageStream({
stream: result.stream,
sendFinish: false,
onError: () => HATA_METNI,
}).getReader();
for (;;) {
const { done, value: parca } = await okuyucu.read();
if (done) break;
if (parca.type === "text-start" && metin && !metin.endsWith("\n")) {
// Araç turundan sonra yeni metin parçası: kayıtta yapışmasın
metin += "\n\n";
} else if (parca.type === "text-delta") {
metin += parca.delta;
} else if (parca.type === "error") {
akisHatasi = parca.errorText;
}
writer.write(parca);
}
if (akisHatasi != null) throw new Error(akisHatasi);
if (!metin.trim()) throw new Error("Boş cevap");
// Çıktı denetimi (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 id = crypto.randomUUID();
metin += `\n\n${JEV_DUZELTME_NOTU}`;
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: JEV_DUZELTME_NOTU });
writer.write({ type: "text-end", id });
}
}
await opts.onTamam(metin);
tamamlandi = true;
writer.write({ type: "finish" });
} catch (err) {
if (!tamamlandi) await opts.onYarim(metin).catch(() => {});
console.error("[soru] stream hatası:", err);
// Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa
// (boş cevap, kayıt hatası) istemci hatayı buradan görür.
if (akisHatasi == null) throw err;
}
},
onError: () => HATA_METNI,
});
}