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

@@ -25,9 +25,12 @@
"tasarim:denetim": "tsx scripts/tasarim-denetim.ts"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^3.0.62",
"@ai-sdk/react": "^4.0.130",
"@cds/city": "^1.1.0",
"@libsql/client": "^0.17.4",
"@number-flow/react": "^0.6.2",
"ai": "^7.0.127",
"better-auth": "^1.6.23",
"better-sqlite3": "^12.11.1",
"class-variance-authority": "^0.7.1",

145
pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@ai-sdk/openai-compatible':
specifier: ^3.0.62
version: 3.0.62(zod@4.4.3)
'@ai-sdk/react':
specifier: ^4.0.130
version: 4.0.130(react@19.2.4)(zod@4.4.3)
'@cds/city':
specifier: ^1.1.0
version: 1.1.0
@@ -17,6 +23,9 @@ importers:
'@number-flow/react':
specifier: ^0.6.2
version: 0.6.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ai:
specifier: ^7.0.127
version: 7.0.127(zod@4.4.3)
better-auth:
specifier: ^1.6.23
version: 1.6.23(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.4)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.29.4))(next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -150,6 +159,40 @@ importers:
packages:
'@ai-sdk/gateway@4.0.103':
resolution: {integrity: sha512-nnGTUHzPRQ14jhv9CL4OQrzcRBIFTBMIPKGIi9Z9u/4GtwSRxa/nxUX6np2Iwp5phOzO0a0njj/j9Jolf5AEaA==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/mcp@2.0.66':
resolution: {integrity: sha512-wFAL5rL/CQljgrhrtpzvrmLJ1tY7Q3o9+SQR+02ZYff3jzXOpj3vfCdi6PjJ1jS3jDd8rkmE9QFlfOFKztI3pg==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai-compatible@3.0.62':
resolution: {integrity: sha512-IAz1xv8zot4KLGa8kN7UfAA6H5+ARzAgAKLsoKxHPBzZuOc2E3ohaELg0x/JR9HVTbMbiftb0dPyCb8cT2KBRg==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@5.0.53':
resolution: {integrity: sha512-VVe6UDd0y0/B4TfauuKKzcppqvhTmSluwwUVT4WG2/8h7gFbyFm7PM4UGM+ZXe3/RI6bv+Vl1cw/7aiBoQqdxg==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@4.0.21':
resolution: {integrity: sha512-UpbC9C1oht8dhfKPbVXSLRZS3DI8uk8n5v2uMjBPNIYGsC2kL045ywq7D8Hh9KgnH9rP5W/GxQdYtWScRouqlA==}
engines: {node: '>=22'}
'@ai-sdk/react@4.0.130':
resolution: {integrity: sha512-6nUGT2mWIPUK/s2nUJH0Hiu90Oqawwo2INAHekOi4hJBb8zhFJS0yMP0aRn/+UO9VmJT/v58d8rWSmGE3B6ApA==}
engines: {node: '>=22'}
peerDependencies:
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -2425,6 +2468,13 @@ packages:
'@upsetjs/venn.js@2.0.0':
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@workflow/serde@4.1.0':
resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -2443,6 +2493,12 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ai@7.0.127:
resolution: {integrity: sha512-JNsNPk4ZvRGEJuqdVo2lYFkm7uAuf3p/RwKFWn1Wh/+IK3U1RyFWw2GXoFnhzqNfxEuj7XJB4WpIUhl1sHToWg==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -5346,6 +5402,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
swr@2.5.1:
resolution: {integrity: sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
systeminformation@5.33.0:
resolution: {integrity: sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA==}
engines: {node: '>=10.0.0'}
@@ -5369,6 +5430,10 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -5484,6 +5549,10 @@ packages:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
undici@7.30.0:
resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==}
engines: {node: '>=20.18.1'}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -5558,6 +5627,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
use-sync-external-store@1.7.0:
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -5680,6 +5754,52 @@ packages:
snapshots:
'@ai-sdk/gateway@4.0.103(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 4.0.21
'@ai-sdk/provider-utils': 5.0.53(zod@4.4.3)
'@vercel/oidc': 3.2.0
zod: 4.4.3
'@ai-sdk/mcp@2.0.66(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 4.0.21
'@ai-sdk/provider-utils': 5.0.53(zod@4.4.3)
cross-spawn: 7.0.6
pkce-challenge: 5.0.1
zod: 4.4.3
'@ai-sdk/openai-compatible@3.0.62(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 4.0.21
'@ai-sdk/provider-utils': 5.0.53(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/provider-utils@5.0.53(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 4.0.21
'@standard-schema/spec': 1.1.0
'@workflow/serde': 4.1.0
eventsource-parser: 3.1.0
undici: 7.30.0
zod: 4.4.3
'@ai-sdk/provider@4.0.21':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@4.0.130(react@19.2.4)(zod@4.4.3)':
dependencies:
'@ai-sdk/mcp': 2.0.66(zod@4.4.3)
'@ai-sdk/provider': 4.0.21
'@ai-sdk/provider-utils': 5.0.53(zod@4.4.3)
ai: 7.0.127(zod@4.4.3)
react: 19.2.4
swr: 2.5.1(react@19.2.4)
throttleit: 2.1.0
transitivePeerDependencies:
- zod
'@alloc/quick-lru@5.2.0': {}
'@antfu/install-pkg@1.1.0':
@@ -7805,6 +7925,10 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
'@vercel/oidc@3.2.0': {}
'@workflow/serde@4.1.0': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -7818,6 +7942,13 @@ snapshots:
agent-base@7.1.4: {}
ai@7.0.127(zod@4.4.3):
dependencies:
'@ai-sdk/gateway': 4.0.103(zod@4.4.3)
'@ai-sdk/provider': 4.0.21
'@ai-sdk/provider-utils': 5.0.53(zod@4.4.3)
zod: 4.4.3
ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
@@ -11267,6 +11398,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
swr@2.5.1(react@19.2.4):
dependencies:
dequal: 2.0.3
react: 19.2.4
use-sync-external-store: 1.7.0(react@19.2.4)
systeminformation@5.33.0: {}
tailwind-merge@3.6.0: {}
@@ -11290,6 +11427,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
throttleit@2.1.0: {}
tiny-invariant@1.3.3: {}
tinyexec@1.3.0: {}
@@ -11421,6 +11560,8 @@ snapshots:
undici@7.28.0: {}
undici@7.30.0: {}
unicorn-magic@0.3.0: {}
unified@11.0.5:
@@ -11523,6 +11664,10 @@ snapshots:
dependencies:
react: 19.2.4
use-sync-external-store@1.7.0(react@19.2.4):
dependencies:
react: 19.2.4
util-deprecate@1.0.2: {}
uuid@14.0.1: {}

View File

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

View File

@@ -1,6 +1,8 @@
"use client";
import { startTransition, useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Image from "next/image";
@@ -53,6 +55,92 @@ export interface Mesaj {
content: string;
}
/** Kayıtlı (düz metin) mesajı useChat'in parçalı mesajına çevirir. */
function uiMesaj(m: Mesaj): UIMessage {
return { id: m.id, role: m.role, parts: [{ type: "text", text: m.content }] };
}
/** Mesajın metni: araç turları arasındaki metin parçaları boş satırla birleşir. */
function mesajMetni(m: UIMessage): string {
return m.parts
.flatMap((p) => (p.type === "text" && p.text.trim() ? [p.text.trim()] : []))
.join("\n\n");
}
/** Danışman veriye bakarken görünen satır (araç adı → öğrenci dili). */
const ARAC_ETIKETI: Record<string, string> = {
program_ara: "Programlara bakıyorum",
program_detay: "Programın ayrıntısına bakıyorum",
program_netleri: "Netlere bakıyorum",
program_karsilastir: "Programları karşılaştırıyorum",
bolum_istihdam: "Mezunların iş durumuna bakıyorum",
bolum_ozeti: "Bölüme bakıyorum",
il_ozeti: "Şehirdeki üniversitelere bakıyorum",
rehber_ara: "Rehber yazılarına bakıyorum",
tercih_takvimi: "Takvime bakıyorum",
universite_profili: "Üniversiteye bakıyorum",
profil_guncelle: "Tercihini kaydediyorum",
};
/** Şu an çalışan (sonucu henüz gelmemiş) araçların etiketleri. */
function calisanAraclar(m: UIMessage): string[] {
const etiketler = m.parts.flatMap((p) => {
if (!p.type.startsWith("tool-") || !("state" in p)) return [];
if (p.state === "output-available" || p.state === "output-error") return [];
return [ARAC_ETIKETI[p.type.slice("tool-".length)] ?? "Veriye bakıyorum"];
});
return [...new Set(etiketler)];
}
type Red = { status: number; error?: string };
/**
* /api/soru ile konuşan useChat taşıyıcısı. Sunucu geçmişi kendi kaydından
* okur; istemci yalnız yeni soruyu ve kimliğini yollar (kimlik = kredi
* düşümünün tekrarsızlık anahtarı). Akış başlamadan reddedilen istek
* (402/409/503) `redAl` ile bir kez okunur; useChat hatayı yalnız metin
* olarak verdiği için durum kodu burada tutulur.
*/
function sohbetKanaliKur(onBasladi: (krediDustu: boolean) => void) {
let red: Red | null = null;
const transport = new DefaultChatTransport<UIMessage>({
api: "/api/soru",
prepareSendMessagesRequest: ({ messages }) => {
const son = messages[messages.length - 1];
return {
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id },
};
},
fetch: async (girdiUrl, init) => {
red = null;
const res = await fetch(girdiUrl, init);
if (!res.ok) {
const data = (await res
.clone()
.json()
.catch(() => ({}))) as { error?: string };
red = { status: res.status, error: data.error };
return res;
}
// Profil onayı ("kaydedeyim mi?" → "evet") sunucuda bedelsizdir
onBasladi(res.headers.get("X-Kredi-Dusuldu") !== "0");
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
olay("danisman_sorusu", {
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
});
return res;
},
});
return {
transport,
redAl(): Red | null {
const r = red;
red = null;
return r;
},
};
}
export interface DanismanOzeti {
metin: string;
kilitli?: boolean;
@@ -109,6 +197,17 @@ export function DanismanAvatar({ canli = false }: { canli?: boolean }) {
);
}
/** Cevap metni gelene kadar balonun yerinde duran iskelet. */
function CevapIskeleti() {
return (
<div className="space-y-2 rounded-lg bg-slate-100 px-4 py-3">
<Skeleton className="h-3.5 w-full bg-slate-200" />
<Skeleton className="h-3.5 w-4/5 bg-slate-200" />
<Skeleton className="h-3.5 w-3/5 bg-slate-200" />
</div>
);
}
const ORNEK_SORULAR = [
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
@@ -146,9 +245,7 @@ export function SohbetClient({
* "Danışmanın bildikleri" ipucunda listelenir. Boşsa ipucu çizilmez. */
bildikleri?: string[];
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
const [kredi, setKredi] = useState(baslangicKredi);
const [temizleAcik, setTemizleAcik] = useState(false);
@@ -159,6 +256,91 @@ export function SohbetClient({
const [profil, setProfil] = useState<ProfilSatiri[]>(initialProfil ?? []);
const router = useRouter();
// useChat geri çağrıları ilk render'da yakalanır; güncel değerler ref'ten okunur.
const profilRef = useRef(profil);
useEffect(() => {
profilRef.current = profil;
}, [profil]);
/** Son gönderilen soru; istek reddedilirse giriş kutusuna geri konur. */
const sonSoruRef = useRef<{ id: string; metin: string } | null>(null);
// Kanal bir kez kurulur; akış başlayınca bakiye hemen düşer.
const [kanal] = useState(() =>
sohbetKanaliKur((krediDustu) => {
if (krediDustu) setKredi((k) => k - 1);
}),
);
const { transport } = kanal;
const {
messages: mesajlar,
sendMessage,
setMessages: setMesajlar,
status,
} = useChat({
messages: (initialMesajlar ?? []).map(uiMesaj),
transport,
// Kullanıcı mesajının kimliği sunucuya clientMessageId olarak gider
generateId: () => crypto.randomUUID(),
onFinish: ({ isError, isAbort, isDisconnect }) => {
if (isError || isAbort || isDisconnect) return;
// Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
// Danışman bu cevapta profil_guncelle çağırmış olabilir
void profiliCek(new Set(profilRef.current.map((s) => s.alan)));
},
onError: () => {
void hatadanSonraToparla();
},
});
const bekliyor = status === "submitted" || status === "streaming";
/**
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
* ve giriş kutusuna koy. Akış yarıda koptuysa tahmin etme: sunucu krediyi
* iade edip yarım cevabı kaydetmiş olabilir — kayıtlı geçmişi ve bakiyeyi
* sunucudan çek.
*/
async function hatadanSonraToparla() {
const reddedilen = kanal.redAl();
const sonSoru = sonSoruRef.current;
if (reddedilen) {
if (sonSoru) {
setMesajlar((m) => m.filter((x) => x.id !== sonSoru.id));
// Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(sonSoru.metin);
}
if (reddedilen.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
} else {
toast.error(reddedilen.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
let cevapKaydedildi = false;
try {
const res = await fetch("/api/soru");
const data = res.ok
? ((await res.json()) as { mesajlar?: Mesaj[]; kredi?: number })
: null;
if (data?.mesajlar) {
setMesajlar(data.mesajlar.map(uiMesaj));
cevapKaydedildi = data.mesajlar.at(-1)?.role === "assistant";
}
if (typeof data?.kredi === "number") setKredi(data.kredi);
} catch {}
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
if (!cevapKaydedildi && sonSoru) setGirdi(sonSoru.metin);
router.refresh();
toast.error(
cevapKaydedildi
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
);
}
async function profiliCek(oncekiAlanlar: Set<ProfilAlani>) {
try {
const res = await fetch("/api/soru/profil");
@@ -221,14 +403,14 @@ export function SohbetClient({
ttlMs: 0,
})
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (data?.mesajlar) setMesajlar(data.mesajlar.map(uiMesaj));
if (typeof data?.kredi === "number") setKredi(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
}, [initialMesajlar]);
}, [initialMesajlar, setMesajlar]);
async function gonder(metin: string) {
function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
@@ -236,89 +418,10 @@ export function SohbetClient({
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
let birikmis = "";
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
// Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(mesaj);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
// Profil onayı ("kaydedeyim mi?" → "evet") sunucuda bedelsizdir
if (res.headers.get("X-Kredi-Dusuldu") !== "0") setKredi((k) => k - 1);
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
olay("danisman_sorusu", {
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
// Chunk başına gelen güncelleme acil değil: transition'a alınca
// kullanıcı stream sürerken input'a takılmadan yazabilir
startTransition(() =>
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
),
);
}
// Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
// Danışman bu cevapta profil_guncelle çağırmış olabilir
void profiliCek(new Set(profil.map((s) => s.alan)));
} catch {
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
// bu senkronla temizlenir). Senkron da düşerse boş balonu elle kaldır.
try {
const res = await fetch("/api/soru");
const data = res.ok ? await res.json() : null;
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi);
} catch {}
if (!birikmis) {
setMesajlar((m) => m.filter((x) => x.id !== asistanId));
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(mesaj);
}
router.refresh();
toast.error(
birikmis
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
);
} finally {
setBekliyor(false);
}
const id = crypto.randomUUID();
sonSoruRef.current = { id, metin: mesaj };
// Hata ve bitiş useChat'in onError / onFinish geri çağrılarında ele alınır
void sendMessage({ id, role: "user", parts: [{ type: "text", text: mesaj }] });
}
/**
@@ -572,37 +675,61 @@ export function SohbetClient({
)}
</div>
) : (
mesajlar.map((m, i) =>
m.role === "user" ? (
<Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white">
{m.content}
</MessageContent>
</Message>
) : (
<Message key={m.id} className="justify-start">
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız animasyonuna geçer. */}
<DanismanAvatar
canli={bekliyor && i === mesajlar.length - 1}
/>
{m.content ? (
<MessageContent
markdown
className="max-w-[85%] bg-slate-100 text-slate-800 prose-sm"
>
{m.content}
</MessageContent>
) : (
<div className="w-full max-w-[85%] space-y-2 rounded-lg bg-slate-100 px-4 py-3">
<Skeleton className="h-3.5 w-full bg-slate-200" />
<Skeleton className="h-3.5 w-4/5 bg-slate-200" />
<Skeleton className="h-3.5 w-3/5 bg-slate-200" />
<>
{mesajlar.map((m, i) => {
const metin = mesajMetni(m);
if (m.role === "user") {
return (
<Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white">
{metin}
</MessageContent>
</Message>
);
}
const canli = bekliyor && i === mesajlar.length - 1;
// Danışman veriye bakarken ne yaptığını söyler; sonuç
// gelince satır kalkar, cevap metni akar.
const araclar = canli ? calisanAraclar(m) : [];
return (
<Message key={m.id} className="justify-start">
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız animasyonuna geçer. */}
<DanismanAvatar canli={canli} />
<div className="flex w-full max-w-[85%] flex-col gap-2">
{metin ? (
<MessageContent
markdown
className="bg-slate-100 text-slate-800 prose-sm"
>
{metin}
</MessageContent>
) : (
<CevapIskeleti />
)}
{araclar.length > 0 ? (
<p
role="status"
className="px-1 text-xs font-medium text-slate-500"
>
{araclar.join(" · ")}…
</p>
) : null}
</div>
)}
</Message>
);
})}
{/* Soru gitti, cevabın ilk parçası henüz gelmedi */}
{status === "submitted" &&
mesajlar[mesajlar.length - 1]?.role === "user" ? (
<Message className="justify-start">
<DanismanAvatar canli />
<div className="w-full max-w-[85%]">
<CevapIskeleti />
</div>
</Message>
),
)
) : null}
</>
)}
<ChatContainerScrollAnchor />
</ChatContainerContent>

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