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:
@@ -9,6 +9,7 @@
|
||||
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
|
||||
*/
|
||||
import type OpenAI from "openai";
|
||||
import { jsonSchema, tool, type ToolSet } from "ai";
|
||||
import {
|
||||
dilimAraligi,
|
||||
EFEKTIF_SIRA,
|
||||
@@ -27,7 +28,15 @@ import {
|
||||
programOzellikleriGetir,
|
||||
type ProgramOzellik,
|
||||
} from "@/lib/program-ozellik";
|
||||
import { getAllUniversiteler, getUniversiteBySlug } from "@/lib/katalog";
|
||||
import {
|
||||
getAllBolumler,
|
||||
getAllUniversiteler,
|
||||
getBolumBySlug,
|
||||
getIlBySlug,
|
||||
getUniversiteBySlug,
|
||||
} from "@/lib/katalog";
|
||||
import { getRehberAramaKayitlari } from "@/lib/rehber";
|
||||
import { SEZON_TAKVIMI, sezonAsamasi } from "@/lib/sezon";
|
||||
import { kadinYuzdesi, ogrenciBasinaUye, YOK_ISTATISTIK_DONEM } from "@/lib/yok-istatistik";
|
||||
import { URAP_DONEM, URAP_TOPLAM } from "@/lib/urap-siralama";
|
||||
import { bolumSayfaSlug, turkishSlugify } from "@/lib/slug";
|
||||
@@ -40,7 +49,7 @@ import {
|
||||
import { DILIM_ETIKET } from "@/lib/risk";
|
||||
import { trendOzetiMetni } from "@/lib/trend";
|
||||
import { TUR_SAYFALARI } from "@/lib/unitur";
|
||||
import { YILLAR } from "@/lib/veri-yillari";
|
||||
import { TERCIH_YILI, YILLAR } from "@/lib/veri-yillari";
|
||||
import { PUAN_TURLERI, type Program, type ProgramNetSatiri, type PuanTuruKey } from "@/types/yokatlas";
|
||||
import {
|
||||
PROFIL_ALAN_ACIKLAMA,
|
||||
@@ -184,6 +193,63 @@ export const ARACLAR: AracTanimi[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "bolum_ozeti",
|
||||
description:
|
||||
"Bir bölümün Türkiye geneli özeti: kaç program var, kaçı devlet/vakıf, kaç ilde, en iyi taban sırası ve bölüm sayfasının adresi. 'Bu bölüm kaç üniversitede var' gibi genel sorularda bunu çağır; tek tek program için program_ara kullan.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bolum: { type: "string", description: "Bölüm adı, ör. 'Psikoloji'" },
|
||||
},
|
||||
required: ["bolum"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "il_ozeti",
|
||||
description:
|
||||
"Bir ildeki üniversiteler: adları, devlet/vakıf, program sayıları ve şehir sayfasının adresi. 'İzmir'de hangi üniversiteler var' sorularında bunu çağır.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
il: { type: "string", description: "İl adı, ör. 'İzmir'" },
|
||||
},
|
||||
required: ["il"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "rehber_ara",
|
||||
description:
|
||||
"KolayTercih rehber yazılarında arar (tercih nasıl yapılır, burs/kredi, ek tercih, yatay geçiş vb.). Başlık, özet ve yazının adresini döner. Süreç ve kural sorularında bunu çağır ve cevabında ilgili yazının adresini ver.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sorgu: { type: "string", description: "Aranacak konu, ör. 'ek tercih', 'KYK burs'" },
|
||||
},
|
||||
required: ["sorgu"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "tercih_takvimi",
|
||||
description:
|
||||
"Tercih döneminin tarihleri ve şu anki aşaması (başlamadı / sürüyor / bitti / takvim açıklanmadı). Tarih sorularında bunu çağır; tarih uydurma.",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -549,6 +615,110 @@ function bolumIstihdam(b: AracBaglami, a: { bolumler?: unknown }) {
|
||||
};
|
||||
}
|
||||
|
||||
function bolumOzeti(_b: AracBaglami, a: { bolum?: string }) {
|
||||
const q = String(a.bolum ?? "").trim();
|
||||
if (!q) return { hata: "Bölüm adı gerekli." };
|
||||
const slug = bolumSayfaSlug(q);
|
||||
const hepsi = getAllBolumler();
|
||||
const aday =
|
||||
hepsi.find((x) => x.slug === slug) ??
|
||||
hepsi.find((x) => x.slug.startsWith(slug)) ??
|
||||
hepsi.find((x) => x.slug.includes(slug));
|
||||
const bolum = aday ? getBolumBySlug(aday.slug) : null;
|
||||
if (!bolum) return { hata: `Bölüm bulunamadı: ${q}` };
|
||||
const s = bolum.stats;
|
||||
return {
|
||||
ad: bolum.ad,
|
||||
duzey: [bolum.lisans ? "lisans" : null, bolum.onlisans ? "ön lisans" : null].filter(Boolean),
|
||||
programSayisi: s.toplam,
|
||||
devletProgram: s.devlet,
|
||||
vakifProgram: s.vakif,
|
||||
ilSayisi: s.ilSayisi,
|
||||
enIyiTabanSira: s.minSira,
|
||||
sayfa: `/bolum/${bolum.slug}`,
|
||||
not: "Sayılar tüm puan türleri ve Türkiye geneli içindir; adayın sırasına göre program listesi için program_ara kullan.",
|
||||
};
|
||||
}
|
||||
|
||||
const IL_UNI_UST = 30;
|
||||
|
||||
function ilOzeti(_b: AracBaglami, a: { il?: string }) {
|
||||
const q = String(a.il ?? "").trim();
|
||||
if (!q) return { hata: "İl adı gerekli." };
|
||||
const il = getIlBySlug(turkishSlugify(q));
|
||||
if (!il) return { hata: `İl bulunamadı: ${q}` };
|
||||
return {
|
||||
il: il.ad,
|
||||
programSayisi: il.programSayisi,
|
||||
universiteSayisi: il.uniler.length,
|
||||
universiteler: il.uniler.slice(0, IL_UNI_UST).map((u) => ({
|
||||
ad: u.ad,
|
||||
tur: u.unitur,
|
||||
programSayisi: u.programSayisi,
|
||||
})),
|
||||
sayfa: `/universiteler/${il.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
const REHBER_SONUC_UST = 4;
|
||||
|
||||
function rehberAra(_b: AracBaglami, a: { sorgu?: string }) {
|
||||
const kelimeler = turkishSlugify(String(a.sorgu ?? ""))
|
||||
.split("-")
|
||||
.filter((k) => k.length > 2);
|
||||
if (!kelimeler.length) return { hata: "Aranacak konu gerekli." };
|
||||
// Başlıktaki eşleşme özetten değerli; hiç eşleşmeyen yazı dönmez.
|
||||
const sonuclar = getRehberAramaKayitlari()
|
||||
.map((r) => {
|
||||
const baslik = turkishSlugify(r.baslik);
|
||||
const ozet = turkishSlugify(r.aciklama);
|
||||
const puan = kelimeler.reduce(
|
||||
(t, k) => t + (baslik.includes(k) ? 3 : 0) + (ozet.includes(k) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
return { r, puan };
|
||||
})
|
||||
.filter((x) => x.puan > 0)
|
||||
.sort((x, y) => y.puan - x.puan)
|
||||
.slice(0, REHBER_SONUC_UST)
|
||||
.map(({ r }) => ({ baslik: r.baslik, ozet: r.aciklama, sayfa: `/rehber/${r.slug}` }));
|
||||
if (!sonuclar.length) return { sonuclar: [], not: "Bu konuda rehber yazısı bulunamadı; kural uydurma." };
|
||||
return {
|
||||
sonuclar,
|
||||
not: "Yalnız başlık ve özet döner; ayrıntı için adaya yazının adresini ver.",
|
||||
};
|
||||
}
|
||||
|
||||
function tercihTakvimi() {
|
||||
const tarih = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const takvim = SEZON_TAKVIMI[TERCIH_YILI] ?? null;
|
||||
const asama = sezonAsamasi(Date.now());
|
||||
const onceki = SEZON_TAKVIMI[TERCIH_YILI - 1] ?? null;
|
||||
return {
|
||||
tercihYili: TERCIH_YILI,
|
||||
asama:
|
||||
asama === "takvim-belirsiz"
|
||||
? "ÖSYM bu yılın tercih takvimini henüz açıklamadı"
|
||||
: asama === "baslamadi"
|
||||
? "tercih dönemi başlamadı"
|
||||
: asama === "devam-ediyor"
|
||||
? "tercih dönemi sürüyor"
|
||||
: "tercih dönemi bitti",
|
||||
baslangic: takvim ? tarih(takvim.baslangic) : null,
|
||||
bitis: takvim ? tarih(takvim.bitis) : null,
|
||||
gecenYil: onceki
|
||||
? { yil: TERCIH_YILI - 1, baslangic: tarih(onceki.baslangic), bitis: tarih(onceki.bitis) }
|
||||
: null,
|
||||
not: "Takvim açıklanmadıysa tarih UYDURMA; geçen yılın tarihlerini yalnız 'geçen yıl şöyleydi' diye söyle ve ÖSYM duyurusunu takip etmesini öner.",
|
||||
};
|
||||
}
|
||||
|
||||
function universiteProfili(_b: AracBaglami, a: { universite?: string }) {
|
||||
const q = String(a.universite ?? "").trim();
|
||||
if (!q) return { hata: "Üniversite adı gerekli." };
|
||||
@@ -653,6 +823,14 @@ export function aracCalistirici(b: AracBaglami) {
|
||||
return programKarsilastir(b, a);
|
||||
case "bolum_istihdam":
|
||||
return bolumIstihdam(b, a);
|
||||
case "bolum_ozeti":
|
||||
return bolumOzeti(b, a);
|
||||
case "il_ozeti":
|
||||
return ilOzeti(b, a);
|
||||
case "rehber_ara":
|
||||
return rehberAra(b, a);
|
||||
case "tercih_takvimi":
|
||||
return tercihTakvimi();
|
||||
case "universite_profili":
|
||||
return universiteProfili(b, a);
|
||||
case "profil_guncelle":
|
||||
@@ -663,4 +841,38 @@ export function aracCalistirici(b: AracBaglami) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AI SDK araç seti (api/soru/route.ts → streamText). Tanımlar ve gövdeler
|
||||
* yukarıdaki ARACLAR + aracCalistirici ile AYNI; burada yalnız biçim çevrilir.
|
||||
* Araç gövdesi fırlatırsa model "veri yok, uydurma" hatasını görür — cevap
|
||||
* düşmez.
|
||||
*/
|
||||
export function aracSeti(b: AracBaglami, onArac?: (ad: string) => void): ToolSet {
|
||||
const calistir = aracCalistirici(b);
|
||||
return Object.fromEntries(
|
||||
ARACLAR.flatMap((a) => {
|
||||
if (a.type !== "function") return [];
|
||||
const { name, description, parameters } = a.function;
|
||||
return [
|
||||
[
|
||||
name,
|
||||
tool({
|
||||
description,
|
||||
inputSchema: jsonSchema(parameters ?? { type: "object", properties: {} }),
|
||||
execute: async (girdi: unknown) => {
|
||||
onArac?.(name);
|
||||
try {
|
||||
return await calistir(name, girdi);
|
||||
} catch (hata) {
|
||||
console.error("[araclar] araç hatası:", name, hata);
|
||||
return { hata: "Araç çalışmadı; veriye ulaşılamadı, uydurma." };
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { profilGetir };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
|
||||
import { getOpenRouter, raporModeli } from "./client";
|
||||
|
||||
/**
|
||||
* AI çağrı katmanı: tüm istekler OpenRouter'ın OpenAI-uyumlu API'sine gider
|
||||
@@ -126,155 +126,3 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SohbetMesaji = { role: "user" | "assistant"; content: string };
|
||||
|
||||
/**
|
||||
* Sohbet yanıtını parça parça üretir; düz metin parçaları yield eder, çağıran
|
||||
* taraf hem stream'ler hem biriktirip kaydeder. Akış ortasında zaman aşımı
|
||||
* olursa hata fırlar (route'taki kısmi kaydet + iade yolu devreye girer).
|
||||
*/
|
||||
export async function* sohbetAkisi(opts: {
|
||||
system: string;
|
||||
mesajlar: SohbetMesaji[];
|
||||
maxTokens: number;
|
||||
}): AsyncGenerator<string> {
|
||||
// Asılı kalan üst sağlayıcıya karşı sigorta
|
||||
const sinyal = AbortSignal.timeout(120_000);
|
||||
const akis = await geciciHatadaTekrarla(() =>
|
||||
getOpenRouter().chat.completions.create(
|
||||
{
|
||||
model: sohbetModeli(),
|
||||
max_tokens: opts.maxTokens,
|
||||
// Sohbet 1024 token'a sığmalı; düşünme hem bütçeyi hem adayı bekletiyor
|
||||
reasoning_effort: "none",
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: "system", content: opts.system },
|
||||
...opts.mesajlar,
|
||||
],
|
||||
},
|
||||
{ signal: sinyal },
|
||||
),
|
||||
);
|
||||
for await (const parca of akis) {
|
||||
// Yalnızca delta.content akar; varsa delta.reasoning kullanıcıya sızmaz
|
||||
const metin = parca.choices?.[0]?.delta?.content;
|
||||
if (metin) yield metin;
|
||||
}
|
||||
// openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor
|
||||
// (core/streaming: isAbortError -> return); kesik cevabın tam cevap
|
||||
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
|
||||
if (sinyal.aborted) throw sinyal.reason;
|
||||
}
|
||||
|
||||
// ── Araçlı sohbet ─────────────────────────────────────────────────────────
|
||||
|
||||
type AracCagrisiBirikim = { id: string; name: string; arguments: string };
|
||||
|
||||
const ARAC_TUR_SINIRI = 4;
|
||||
|
||||
/**
|
||||
* Araç çağrılı sohbet akışı. Model araç isterse (stream'de tool_calls
|
||||
* parçaları birikir) araçlar çalıştırılır, sonuçlar `tool` mesajı olarak
|
||||
* eklenir ve akış yeniden başlar; en fazla ARAC_TUR_SINIRI tur, son turda
|
||||
* araç kapalı (cevap vermek zorunda). Metin parçaları geldikçe yield edilir.
|
||||
*
|
||||
* Sağlayıcı araçları desteklemiyorsa (ilk çağrıda 400/404) düz sohbete düşer.
|
||||
*/
|
||||
export async function* sohbetAkisiAraclarla(opts: {
|
||||
system: string;
|
||||
mesajlar: SohbetMesaji[];
|
||||
maxTokens: number;
|
||||
araclar: OpenAI.Chat.Completions.ChatCompletionTool[];
|
||||
calistir: (ad: string, argumanlar: unknown) => Promise<unknown>;
|
||||
/** Her araç çağrısında (günlük/analitik için) */
|
||||
onArac?: (ad: string) => void;
|
||||
}): AsyncGenerator<string> {
|
||||
const sinyal = AbortSignal.timeout(120_000);
|
||||
const gecmis: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: opts.system },
|
||||
...opts.mesajlar,
|
||||
];
|
||||
|
||||
for (let tur = 0; tur < ARAC_TUR_SINIRI; tur++) {
|
||||
const sonTur = tur === ARAC_TUR_SINIRI - 1;
|
||||
let akis;
|
||||
try {
|
||||
akis = await geciciHatadaTekrarla(() =>
|
||||
getOpenRouter().chat.completions.create(
|
||||
{
|
||||
model: sohbetModeli(),
|
||||
max_tokens: opts.maxTokens,
|
||||
reasoning_effort: "none",
|
||||
stream: true,
|
||||
messages: gecmis,
|
||||
tools: opts.araclar,
|
||||
tool_choice: sonTur ? "none" : "auto",
|
||||
},
|
||||
{ signal: sinyal },
|
||||
),
|
||||
);
|
||||
} catch (hata) {
|
||||
const kod = (hata as { status?: number })?.status;
|
||||
if (tur === 0 && (kod === 400 || kod === 404)) {
|
||||
console.warn("[cagri] sağlayıcı araç çağrısını kabul etmedi, düz sohbete düşüldü:", kod);
|
||||
yield* sohbetAkisi({ system: opts.system, mesajlar: opts.mesajlar, maxTokens: opts.maxTokens });
|
||||
return;
|
||||
}
|
||||
throw hata;
|
||||
}
|
||||
|
||||
let metin = "";
|
||||
const cagrilar = new Map<number, AracCagrisiBirikim>();
|
||||
for await (const parca of akis) {
|
||||
const delta = parca.choices?.[0]?.delta;
|
||||
if (!delta) continue;
|
||||
if (delta.content) {
|
||||
metin += delta.content;
|
||||
yield delta.content;
|
||||
}
|
||||
for (const tc of delta.tool_calls ?? []) {
|
||||
const mevcut = cagrilar.get(tc.index) ?? { id: "", name: "", arguments: "" };
|
||||
if (tc.id) mevcut.id = tc.id;
|
||||
if (tc.function?.name) mevcut.name += tc.function.name;
|
||||
if (tc.function?.arguments) mevcut.arguments += tc.function.arguments;
|
||||
cagrilar.set(tc.index, mevcut);
|
||||
}
|
||||
}
|
||||
if (sinyal.aborted) throw sinyal.reason;
|
||||
if (cagrilar.size === 0) return;
|
||||
|
||||
const liste = [...cagrilar.values()].filter((c) => c.name);
|
||||
const toolCalls = liste.map((c) => ({
|
||||
id: c.id || `call_${Math.random().toString(36).slice(2, 10)}`,
|
||||
type: "function" as const,
|
||||
function: { name: c.name, arguments: c.arguments || "{}" },
|
||||
}));
|
||||
gecmis.push({ role: "assistant", content: metin || null, tool_calls: toolCalls });
|
||||
for (const [i, c] of liste.entries()) {
|
||||
opts.onArac?.(c.name);
|
||||
let arg: unknown = {};
|
||||
let argBozuk = false;
|
||||
try {
|
||||
arg = c.arguments ? JSON.parse(c.arguments) : {};
|
||||
} catch {
|
||||
argBozuk = true;
|
||||
}
|
||||
let sonuc: unknown;
|
||||
try {
|
||||
// Bozuk argümanla çalıştırma: boş argüman süzgeçsiz sonuç döndürür,
|
||||
// model onu sorunun cevabı sanar. Hatayı söyle, yeniden denesin.
|
||||
sonuc = argBozuk
|
||||
? { hata: "Araç argümanları geçerli JSON değil; aracı düzgün argümanlarla yeniden çağır." }
|
||||
: await opts.calistir(c.name, arg);
|
||||
} catch (hata) {
|
||||
console.error("[cagri] araç hatası:", c.name, hata);
|
||||
sonuc = { hata: "Araç çalışmadı; veriye ulaşılamadı, uydurma." };
|
||||
}
|
||||
gecmis.push({ role: "tool", tool_call_id: toolCalls[i].id, content: JSON.stringify(sonuc) });
|
||||
}
|
||||
// Araç turundan sonra araya boşluk: metin + tool ile bitmiş cümle yapışmasın
|
||||
if (metin && !metin.endsWith("\n")) yield "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import OpenAI from "openai";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
|
||||
// Tüm AI akışı OpenAI uyumlu TEK kapıdan gider. Varsayılan OpenRouter
|
||||
// (7 Ağu 2026 kararı); 26 Eyl 2026 kararıyla model Türkiye'de barındırılacak:
|
||||
@@ -69,5 +70,26 @@ export function getOpenRouter(): OpenAI {
|
||||
return globalForAi.__openrouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Danışman sohbetinin dil modeli (AI SDK). Aynı tek kapı: AI_BASE_URL +
|
||||
* AI_API_KEY (ya da eski OPENROUTER_* adları). Rapor/liste üretimi openai
|
||||
* paketiyle (getOpenRouter) kalır; yalnız sohbet AI SDK'dan geçer.
|
||||
*/
|
||||
export function sohbetDilModeli() {
|
||||
const key = apiKey();
|
||||
if (!key) {
|
||||
throw new Error("AI_KEY_MISSING");
|
||||
}
|
||||
return createOpenAICompatible({
|
||||
name: "openrouter",
|
||||
baseURL: baseUrl(),
|
||||
apiKey: key,
|
||||
headers: {
|
||||
"HTTP-Referer": "https://kolaytercih.com",
|
||||
"X-Title": "KolayTercih",
|
||||
},
|
||||
}).chatModel(sohbetModeli());
|
||||
}
|
||||
|
||||
export const SORUMLULUK_REDDI =
|
||||
"KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine dayanır ancak yerleşme garantisi verilmez. Nihai tercih sorumluluğu adaya aittir.";
|
||||
|
||||
115
src/lib/ai/sohbet-akisi.ts
Normal file
115
src/lib/ai/sohbet-akisi.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user