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:
@@ -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
145
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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