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

456
src/lib/ai/araclar.ts Normal file
View File

@@ -0,0 +1,456 @@
/**
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
*
* İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
* (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
* üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
*
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
*/
import type OpenAI from "openai";
import {
dilimAraligi,
EFEKTIF_SIRA,
getDb,
programGetir,
programSatiri,
SELECT_COLS,
type ProgramHamSatiri,
} from "@/lib/db";
import {
kosulMetinleri,
ogretimUyesiToplami,
ozellikOzeti,
programOzellikGetir,
programOzellikleriGetir,
type ProgramOzellik,
} from "@/lib/program-ozellik";
import { getAllUniversiteler, getUniversiteBySlug } from "@/lib/katalog";
import { kadinYuzdesi, ogrenciBasinaUye, YOK_ISTATISTIK_DONEM } from "@/lib/yok-istatistik";
import { URAP_DONEM, URAP_TOPLAM } from "@/lib/urap-siralama";
import { turkishSlugify } from "@/lib/slug";
import { DILIM_ETIKET } from "@/lib/risk";
import { YILLAR } from "@/lib/veri-yillari";
import { PUAN_TURLERI, type Program, type PuanTuruKey } from "@/types/yokatlas";
import {
PROFIL_ALAN_ACIKLAMA,
PROFIL_ALANLARI,
PROFIL_SECENEKLERI,
profilSatirlari,
type DanismanProfili,
type ProfilAlani,
} from "@/lib/danisman-profil";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
export type AracBaglami = {
userId: string;
sira: number;
tur: PuanTuruKey;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
};
export type AracTanimi = OpenAI.Chat.Completions.ChatCompletionTool;
const ARAMA_LIMIT = 8;
export const ARACLAR: AracTanimi[] = [
{
type: "function",
function: {
name: "program_ara",
description:
"Adayın puan türündeki programları veriden arar (YÖK Atlas, son yerleştirme). Bölüm adı, üniversite, il, öğretim dili, burs ve yıllık ücret üst sınırına göre süzer; adayın sırasına en yakın programları döner. Sayı uydurmak yerine bunu çağır.",
parameters: {
type: "object",
properties: {
arama: { type: "string", description: "Bölüm adı parçası, ör. 'bilgisayar mühendisliği', 'hukuk'" },
universite: { type: "string", description: "Üniversite adı parçası" },
il: { type: "string", description: "İl adı, ör. 'İzmir'" },
dil: { type: "string", enum: ["Türkçe", "İngilizce"], description: "Öğretim dili" },
burs: {
type: "string",
enum: ["Burslu", "%50 İndirimli", "%25 İndirimli", "Ücretli"],
description: "Vakıf burs oranı",
},
ucret_ust: { type: "integer", description: "Yıllık ücret üst sınırı (TL); devlet programları her zaman geçer" },
dilim: {
type: "string",
enum: ["hayal", "dengeli", "guvenli"],
description: "Yalnız bu dilimdekiler: hayal = adayın sırasının üstünde kapananlar, dengeli = sırasının 1,4 katına kadar, guvenli = daha altı",
},
},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "program_detay",
description:
"Tek programın ayrıntısı: yıllara göre taban sırası ve kontenjan, ücret/burs, dil, öğretim türü, akreditasyon, kadro, tercih barajı ve kılavuzdaki yerleşme koşulları (sağlık şartı, mülakat vb.).",
parameters: {
type: "object",
properties: {
program_id: { type: "string", description: "program_ara sonucundaki id (kılavuz kodu)" },
},
required: ["program_id"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "universite_profili",
description:
"Üniversitenin künyesi: il, devlet/vakıf, program sayısı, URAP araştırma sırası, örgün öğrenci sayısı, hoca başına öğrenci ve kadın/erkek öğrenci oranı (üniversite geneli, bölüm bazında veri yok).",
parameters: {
type: "object",
properties: {
universite: { type: "string", description: "Üniversite adı, ör. 'Boğaziçi', 'ODTÜ', 'Ege Üniversitesi'" },
},
required: ["universite"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "profil_guncelle",
description:
"Adayın sohbette söylediği bir tercihi profile kaydeder ya da siler (deger null). YALNIZ şu alanlar: " +
PROFIL_ALANLARI.map((a) => `${a} (${PROFIL_ALAN_ACIKLAMA[a]})`).join("; ") +
". Sağlık, engel, din, aile, gelir, cinsiyet gibi bilgileri ASLA kaydetme. Kaydettikten sonra cevabında tek cümleyle 'Şunu kaydettim: …' de.",
parameters: {
type: "object",
properties: {
alan: { type: "string", enum: PROFIL_ALANLARI },
deger: {
type: ["string", "null"],
description:
"evIl: il adı; maxKm: sayı (km); ucretUst: sayı (TL, yıllık); " +
`burs: ${Object.keys(PROFIL_SECENEKLERI.burs).join("|")}; ` +
`dil: ${Object.keys(PROFIL_SECENEKLERI.dil).join("|")}; ` +
`ogretimTuru: ${Object.keys(PROFIL_SECENEKLERI.ogretimTuru).join("|")}; ` +
`kampus: ${Object.keys(PROFIL_SECENEKLERI.kampus).join("|")}; null = alanı sil`,
},
},
required: ["alan", "deger"],
additionalProperties: false,
},
},
},
];
// ── Yardımcılar ──────────────────────────────────────────────────────────
function dilimAdi(sira: number, efektif: number | null): string {
if (efektif == null) return "Veri yetersiz";
if (efektif < sira) return DILIM_ETIKET.hayal;
if (efektif <= dilimAraligi(sira, "dengeli").ust!) return DILIM_ETIKET.dengeli;
return DILIM_ETIKET.garanti;
}
function programOzeti(p: Program, sira: number, o?: ProgramOzellik) {
return {
id: p.id,
program: p.isim,
universite: p.universite,
il: p.il,
tur: p.unitur,
tabanSira: p.efektifSira,
dilim: dilimAdi(sira, p.efektifSira),
kontenjan: p.kontenjanSon,
ozellik: o ? ozellikOzeti(o) : [],
};
}
/** SQLite LIKE Türkçe büyük/küçük harfi bilmez: hem yazıldığı gibi hem başlık düzeni denenir. */
function likeVaryantlari(q: string): string[] {
const t = q.trim();
const baslik = t
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((w) => (w ? w[0].toLocaleUpperCase("tr-TR") + w.slice(1) : w))
.join(" ");
return Array.from(new Set([t, baslik, t.toLocaleUpperCase("tr-TR")]));
}
function likeKosulu(kolon: string, q: string): { sql: string; args: string[] } {
const v = likeVaryantlari(q);
return {
sql: `(${v.map(() => `${kolon} LIKE ?`).join(" OR ")})`,
args: v.map((x) => `%${x}%`),
};
}
// ── Araç gövdeleri ───────────────────────────────────────────────────────
type ProgramAraArg = {
arama?: string;
universite?: string;
il?: string;
dil?: string;
burs?: string;
ucret_ust?: number;
dilim?: "hayal" | "dengeli" | "guvenli";
};
function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): ProgramAraSonuc {
const db = getDb();
const profil: DanismanProfili = profilUygula ? b.profil : {};
const kosullar: string[] = ["tur = ?", "onlisans = ?", `${EFEKTIF_SIRA} IS NOT NULL`];
const args: (string | number)[] = [PUAN_TURLERI[b.tur], b.tur === "tyt" ? 1 : 0];
const uygulanan: string[] = [];
if (a.arama) {
const k = likeKosulu("isim", a.arama);
kosullar.push(k.sql);
args.push(...k.args);
uygulanan.push(`bölüm ~ "${a.arama}"`);
}
if (a.universite) {
const k = likeKosulu("universite", a.universite);
kosullar.push(k.sql);
args.push(...k.args);
uygulanan.push(`üniversite ~ "${a.universite}"`);
}
// İl: parametre > profildeki km sınırı
if (a.il) {
const il = ilAdiNormalize(a.il);
if (!il) {
return { toplamEslesen: 0, gosterilen: 0, uygulananFiltreler: [], sonuclar: [], not: `İl tanınmadı: ${a.il}` };
}
kosullar.push("il = ?");
args.push(il);
uygulanan.push(`il = ${il}`);
} else if (profil.evIl && profil.maxKm) {
const iller = yakinIller(profil.evIl, profil.maxKm);
if (iller.length) {
kosullar.push(`il IN (${iller.map(() => "?").join(",")})`);
args.push(...iller);
uygulanan.push(`profil: ${profil.evIl} iline en fazla ${profil.maxKm} km (${iller.length} il)`);
}
}
// Özellik süzgeçleri alt sorguda (program_ozellik.id çakışmasın)
const ozKosul: string[] = [];
const ozArgs: (string | number)[] = [];
const dil = a.dil ?? (profil.dil === "ingilizce" ? "İngilizce" : profil.dil === "turkce" ? "Türkçe" : undefined);
if (dil) {
ozKosul.push("ogrenim_dili LIKE ?");
ozArgs.push(`${dil}%`);
uygulanan.push(`dil = ${dil}${a.dil ? "" : " (profil)"}`);
}
if (a.burs) {
ozKosul.push("burs_orani = ?");
ozArgs.push(a.burs);
uygulanan.push(`burs = ${a.burs}`);
} else if (profil.burs === "yalniz-burslu") {
ozKosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
uygulanan.push("profil: vakıfta yalnız burslu");
}
const ucretUst = a.ucret_ust ?? profil.ucretUst;
if (ucretUst) {
ozKosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
ozArgs.push(ucretUst);
uygulanan.push(`yıllık ücret ≤ ${ucretUst.toLocaleString("tr-TR")} TL${a.ucret_ust ? "" : " (profil)"}`);
}
if (profil.ogretimTuru === "yalniz-orgun") {
ozKosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
uygulanan.push("profil: yalnız örgün");
}
if (ozKosul.length) {
kosullar.push(`id IN (SELECT id FROM program_ozellik WHERE ${ozKosul.join(" AND ")})`);
args.push(...ozArgs);
}
if (a.dilim) {
const anahtar = a.dilim === "guvenli" ? "garanti" : a.dilim;
const { alt, ust } = dilimAraligi(b.sira, anahtar);
kosullar.push(ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`);
args.push(alt);
if (ust != null) args.push(ust);
uygulanan.push(`dilim = ${DILIM_ETIKET[anahtar]}`);
}
const satirlar = db
.prepare(
`SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")}
ORDER BY ABS(${EFEKTIF_SIRA} - ?) ASC LIMIT ?`,
)
.all(...args, b.sira, ARAMA_LIMIT) as ProgramHamSatiri[];
const programlar = satirlar.map(programSatiri);
const toplam = (
db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${kosullar.join(" AND ")}`).get(...args) as {
n: number;
}
).n;
// Profil süzgeçleri sonucu sıfırladıysa profilsiz tekrar dene ve bunu söyle:
// model "hiç yok" demek yerine "profilin dışında şunlar var" diyebilsin.
if (toplam === 0 && profilUygula && uygulanan.some((u) => u.includes("profil"))) {
const gevsek = programAra(b, a, false);
return {
...gevsek,
not: `Profil süzgeçleriyle (${uygulanan.filter((u) => u.includes("profil")).join("; ")}) eşleşen program YOK; aşağıdakiler profil süzgeci olmadan bulundu — adaya bunu açıkça söyle. ${gevsek.not}`,
};
}
const ozellikler = programOzellikleriGetir(programlar.map((p) => p.id));
return {
toplamEslesen: toplam,
gosterilen: programlar.length,
uygulananFiltreler: uygulanan,
sonuclar: programlar.map((p) => programOzeti(p, b.sira, ozellikler[p.id])),
not: "tabanSira son yerleştirme yılının taban başarı sırası; her yıl değişir.",
};
}
type ProgramAraSonuc = {
toplamEslesen: number;
gosterilen: number;
uygulananFiltreler: string[];
sonuclar: ReturnType<typeof programOzeti>[];
not: string;
};
function programDetay(b: AracBaglami, a: { program_id?: string }) {
const id = String(a.program_id ?? "").trim();
const p = id ? programGetir(id) : null;
if (!p) return { hata: "Program bulunamadı; önce program_ara ile id al." };
const o = programOzellikGetir(id);
const kosullar = o ? kosulMetinleri(o.kosulKodlari).slice(0, 8) : [];
const evKm = b.profil.evIl && p.il ? illerArasiKm(b.profil.evIl, p.il) : null;
return {
...programOzeti(p, b.sira, o ?? undefined),
fakulte: p.fakulte,
sure: p.sure,
tabanSiraYillara: Object.fromEntries(YILLAR.map((y, i) => [y, p.siraGecmisi[i]])),
puanSon: p.puanSon,
yerlesenSon: p.yerlesenSon,
ucretYillik: o?.ucret ?? null,
bursOrani: o?.bursOrani ?? null,
ogretimDili: o?.ogrenimDili ?? null,
ogretimTuru: o?.ogrenimTuru ?? null,
akreditasyon: o?.akreditasyon ?? null,
kadro: o
? { prof: o.prof, docent: o.doc, drOgrUyesi: o.dou, arsGor: o.argor, ogretimUyesiToplam: ogretimUyesiToplami(o) }
: null,
tercihBaraji: o?.minBasariSirasi ?? null,
ilce: o?.ilce ?? null,
evdenKm: evKm,
kosullar: kosullar.map((k) => (k.metin.length > 240 ? `${k.metin.slice(0, 237)}…` : k.metin)),
};
}
function universiteProfili(_b: AracBaglami, a: { universite?: string }) {
const q = String(a.universite ?? "").trim();
if (!q) return { hata: "Üniversite adı gerekli." };
const slugQ = turkishSlugify(q);
const hepsi = getAllUniversiteler();
const kisaltma: Record<string, string> = {
odtu: "orta-dogu-teknik",
itu: "istanbul-teknik",
ytu: "yildiz-teknik",
ktu: "karadeniz-teknik",
dtu: "deu",
};
const arananSlug = kisaltma[slugQ] ?? slugQ;
const aday =
hepsi.find((u) => u.slug === arananSlug || u.slug === `${arananSlug}-universitesi`) ??
hepsi.find((u) => u.slug.startsWith(arananSlug)) ??
hepsi.find((u) => u.slug.includes(arananSlug));
if (!aday) return { hata: `Üniversite bulunamadı: ${q}` };
const u = getUniversiteBySlug(aday.slug);
if (!u) return { hata: `Üniversite bulunamadı: ${q}` };
const i = u.istatistik;
const kadin = i ? kadinYuzdesi(i) : null;
return {
ad: u.ad,
il: u.il,
tur: u.unitur,
programSayisi: u.programSayisi,
enDusukTabanSira: u.stats.minSira,
toplamKontenjan: u.stats.toplamKontenjan,
urap: u.urap ? { sira: u.urap.sira, toplam: URAP_TOPLAM, donem: URAP_DONEM, olctugu: "araştırma performansı; eğitim kalitesi ya da iş bulma değil" } : null,
istatistik: i
? {
donem: YOK_ISTATISTIK_DONEM,
orgunOgrenci: i.ogrenci,
ogretimUyesi: i.ogretimUyesi,
hocaBasinaOgrenci: ogrenciBasinaUye(i),
kadinYuzdesi: kadin,
erkekYuzdesi: kadin == null ? null : 100 - kadin,
not: "Kadın/erkek oranı örgün ön lisans + lisans, ÜNİVERSİTE GENELİ; bölüm bazında veri yok. Bilgi olarak söyle, filtre yapma.",
}
: null,
sayfa: `/universite/${u.slug}`,
};
}
function sayiParse(v: unknown): number | null {
if (typeof v === "number") return v;
if (typeof v !== "string") return null;
const t = v.toLocaleLowerCase("tr-TR").replace(/\s/g, "");
const m = t.match(/^([\d.,]+)(bin|k|milyon|m)?/);
if (!m) return null;
let n = Number.parseFloat(m[1].replace(/\./g, "").replace(",", "."));
if (!Number.isFinite(n)) return null;
if (m[2] === "bin" || m[2] === "k") n *= 1000;
if (m[2] === "milyon" || m[2] === "m") n *= 1_000_000;
return Math.round(n);
}
async function profilGuncelle(b: AracBaglami, a: { alan?: string; deger?: unknown }) {
const alan = a.alan as ProfilAlani;
if (!PROFIL_ALANLARI.includes(alan)) {
return { hata: `Bilinmeyen alan: ${String(a.alan)}. Yalnız ${PROFIL_ALANLARI.join(", ")} kaydedilebilir.` };
}
let deger: unknown = a.deger;
if (deger !== null && deger !== undefined) {
if (alan === "maxKm" || alan === "ucretUst") {
deger = sayiParse(deger);
if (deger == null) return { hata: "Sayı okunamadı." };
} else if (alan === "evIl") {
const il = ilAdiNormalize(String(deger));
if (!il) return { hata: `İl tanınmadı: ${String(deger)}` };
deger = il;
} else {
deger = String(deger).trim();
}
}
try {
const profil = await profilAlanYaz(b.userId, alan, deger ?? null);
b.profil = profil;
const satirlar = profilSatirlari(profil);
return {
kaydedildi: deger == null ? `${alan} silindi` : satirlar.find((s) => s.alan === alan)?.metin,
profil: satirlar.map((s) => s.metin),
};
} catch {
return { hata: `Değer kabul edilmedi (${alan}); aralık dışı ya da geçersiz seçenek.` };
}
}
/** Route'un araç döngüsüne verdiği çalıştırıcı. Bilinmeyen araç hata döner. */
export function aracCalistirici(b: AracBaglami) {
return async (ad: string, arg: unknown): Promise<unknown> => {
const a = (arg && typeof arg === "object" ? arg : {}) as Record<string, unknown>;
switch (ad) {
case "program_ara":
return programAra(b, a as ProgramAraArg);
case "program_detay":
return programDetay(b, a);
case "universite_profili":
return universiteProfili(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
default:
return { hata: `Bilinmeyen araç: ${ad}` };
}
};
}
export { profilGetir };

View File

@@ -1,3 +1,4 @@
import type OpenAI from "openai";
import { z } from "zod";
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
@@ -166,3 +167,109 @@ export async function* sohbetAkisi(opts: {
// 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 = {};
try {
arg = c.arguments ? JSON.parse(c.arguments) : {};
} catch {
arg = {};
}
let sonuc: unknown;
try {
sonuc = 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";
}
}