diff --git a/package.json b/package.json index 5dea669..4a60195 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3888f7..7de8720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index 8c890f9..de1623b 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -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), }); } + diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 367f4b7..fffe67c 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -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 = { + 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({ + 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 ( +
+ + + +
+ ); +} + 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(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(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) { 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({ )} ) : ( - mesajlar.map((m, i) => - m.role === "user" ? ( - - - {m.content} - - - ) : ( - - {/* Cevap beklenirken/stream sürerken (son mesaj) avatar - nabız animasyonuna geçer. */} - - {m.content ? ( - - {m.content} - - ) : ( -
- - - + <> + {mesajlar.map((m, i) => { + const metin = mesajMetni(m); + if (m.role === "user") { + return ( + + + {metin} + + + ); + } + 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 ( + + {/* Cevap beklenirken/stream sürerken (son mesaj) avatar + nabız animasyonuna geçer. */} + +
+ {metin ? ( + + {metin} + + ) : ( + + )} + {araclar.length > 0 ? ( +

+ {araclar.join(" · ")}… +

+ ) : null}
- )} +
+ ); + })} + {/* Soru gitti, cevabın ilk parçası henüz gelmedi */} + {status === "submitted" && + mesajlar[mesajlar.length - 1]?.role === "user" ? ( + + +
+ +
- ), - ) + ) : null} + )} diff --git a/src/lib/ai/araclar.ts b/src/lib/ai/araclar.ts index dad7adf..7b76b46 100644 --- a/src/lib/ai/araclar.ts +++ b/src/lib/ai/araclar.ts @@ -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 }; diff --git a/src/lib/ai/cagri.ts b/src/lib/ai/cagri.ts index 0ba0bbd..f7a7c62 100644 --- a/src/lib/ai/cagri.ts +++ b/src/lib/ai/cagri.ts @@ -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(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 { - // 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; - /** Her araç çağrısında (günlük/analitik için) */ - onArac?: (ad: string) => void; -}): AsyncGenerator { - 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(); - 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"; - } -} diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index bb2b835..edc57f8 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -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."; diff --git a/src/lib/ai/sohbet-akisi.ts b/src/lib/ai/sohbet-akisi.ts new file mode 100644 index 0000000..b391315 --- /dev/null +++ b/src/lib/ai/sohbet-akisi.ts @@ -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; + /** Cevap tamamlanamadı; `metin` o ana kadar üretilen kısım (boş olabilir). */ + onYarim: (metin: string) => Promise; +}): ReadableStream { + return createUIMessageStream({ + 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, + }); +}