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

@@ -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 };

View File

@@ -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";
}
}

View File

@@ -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
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,
});
}