import { eq, desc, asc } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { getSession } from "@/lib/session"; import { appDb, schema } from "@/lib/appdb"; import { spendCreditForMessage, grantCredits, krediBittiIsaretle, } from "@/lib/credits"; import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client"; import { kullanicininRaporu } from "@/features/rapor/rapor-queries"; import { sohbetAkisi } from "@/lib/ai/cagri"; import { listeOzetiCikar } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; import type { RaporParams } from "@/features/rapor/types/rapor"; import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler"; import { PUAN_TURLERI } from "@/types/yokatlas"; import { ACIK_SATIR } from "@/lib/rapor-maske"; const MAX_GECMIS = 12; const MAX_MESAJ_UZUNLUK = 2000; function sistemPromptu(opts: { params: RaporParams; listeOzeti: string; kilitli?: boolean; }): string { const { params } = opts; const secimVar = (params.kategoriler?.length ?? 0) > 0 || (params.iller?.length ?? 0) > 0 || (params.oncelikler?.length ?? 0) > 0 || (params.universiteTipi && params.universiteTipi !== "farketmez"); const parcalar = [ `Sen KolayTercih'in YKS tercih danışmanısın. Türkçe, samimi ama profesyonel konuş. Aday az önce sana verilen 24'lük tercih listesini oluşturdu. Görevin: adayın bu liste ve seçimleri BAĞLAMINDA sorularını (bölüm kıyaslama, şehir, risk, kontenjan, iş imkânları, tercih sırası) net ve dürüst yanıtlamak. KURALLAR: - Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki programlara referans verebilirsin. - Listedeki her satırda dilim ve risk etiketi var: Güvenli (yeşil) = taban sıralaması adayın belirgin altında, Az riskli (sarı) = adayın sıralaması civarında, Riskli (kırmızı) = geçen yıl adayın sıralamasının üzerinde kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan. - ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan. - Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle. - Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat. - Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır. - Tercih dışı konularda (ödev, kod, genel sohbet) kibarca reddet ve tercih konusuna dön. - Kesin konuşmaktan kaçın: "${SORUMLULUK_REDDI}"`, `ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`, `ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`, ]; if (opts.kilitli) { parcalar.push( `ÖNEMLİ: Adayın paketi yok; listenin yalnızca ilk ${ACIK_SATIR} tercihi açık. Sana da yalnızca bu satırlar verildi — kilitli tercihlerin içeriğini BİLMİYORSUN, asla tahmin edip söyleme. Kilitli satırlar sorulursa sempatik biçimde tam listenin Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satırlar ve genel tercih soruları üzerinden yardımcı olmaya devam et.`, ); } if (secimVar) { parcalar.push( `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({ kategoriler: params.kategoriler ?? [], iller: params.iller ?? [], universiteTipi: params.universiteTipi ?? "farketmez", oncelikler: params.oncelikler ?? [], })}`, ); } return parcalar.join("\n\n"); } // Widget açıldığında geçmişi ve kredi bakiyesini döner export async function GET() { const session = await getSession(); if (!session) { return Response.json({ error: "Oturum gerekli." }, { status: 401 }); } const [mesajlar, kullanici] = await Promise.all([ appDb .select({ id: schema.chatMessages.id, role: schema.chatMessages.role, content: schema.chatMessages.content, }) .from(schema.chatMessages) .where(eq(schema.chatMessages.userId, session.user.id)) .orderBy(asc(schema.chatMessages.createdAt)) .limit(100), appDb.query.user.findFirst({ where: eq(schema.user.id, session.user.id), columns: { creditBalance: true }, }), ]); return Response.json({ mesajlar, kredi: kullanici?.creditBalance ?? 0, }); } export async function POST(request: NextRequest) { const session = await getSession(); if (!session) { return Response.json({ error: "Oturum gerekli." }, { status: 401 }); } let body: { message?: unknown; clientMessageId?: unknown }; try { body = await request.json(); } catch { return Response.json({ error: "Geçersiz istek." }, { status: 400 }); } const mesaj = typeof body.message === "string" ? body.message.trim() : ""; const clientMessageId = typeof body.clientMessageId === "string" ? body.clientMessageId : ""; if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) { return Response.json({ error: "Geçersiz mesaj." }, { status: 400 }); } // Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz) const rapor = await kullanicininRaporu(session.user.id); if (!rapor?.result) { return Response.json( { error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" }, { status: 409 }, ); } // Geçmişi krediyi düşmeden ÖNCE oku ki kullanıcı mesajı dahil olmasın const gecmis = ( await appDb .select() .from(schema.chatMessages) .where(eq(schema.chatMessages.userId, session.user.id)) .orderBy(desc(schema.chatMessages.createdAt)) .limit(MAX_GECMIS) ).reverse(); // Kredi düşümü stream'den önce — yetersizse hiç başlama const harcama = await spendCreditForMessage({ userId: session.user.id, content: mesaj, clientMessageId, }); if (!harcama.ok) { if (harcama.error === "INSUFFICIENT") { await krediBittiIsaretle(session.user.id); return Response.json( { error: "Kredin bitti.", code: "INSUFFICIENT" }, { status: 402 }, ); } return Response.json( { error: "Bu mesaj zaten işlendi.", code: "DUPLICATE" }, { status: 409 }, ); } const kullaniciMesajId = harcama.messageId!; // Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi // halde danışman kilitli listeyi sözle sızdırabilir. const kullanici = await appDb.query.user.findFirst({ where: eq(schema.user.id, session.user.id), columns: { hasPaket: true }, }); const kilitli = !kullanici?.hasPaket; const tamOzet = listeOzetiCikar( rapor.result as RaporSonuc, (rapor.params as RaporParams).sira, ); const listeOzeti = kilitli ? [ ...tamOzet.split("\n").slice(0, ACIK_SATIR), `… kalan ${24 - ACIK_SATIR} tercih kilitli.`, ].join("\n") : tamOzet; const system = sistemPromptu({ params: rapor.params as RaporParams, listeOzeti, kilitli, }); // 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) { for (const parca of cevap.split(/(?<= )/)) { controller.enqueue(mockEncoder.encode(parca)); await new Promise((r) => setTimeout(r, 25)); } await appDb.insert(schema.chatMessages).values({ id: crypto.randomUUID(), userId: mockUserId, role: "assistant", content: cevap, createdAt: new Date(), }); controller.close(); }, }); return new Response(mockReadable, { headers: { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", }, }); } if (!anahtarVar()) { await grantCredits({ userId: session.user.id, delta: 1, reason: "refund", refId: kullaniciMesajId, }); console.error("[soru] Yapay Zeka anahtarı tanımlı değil"); return Response.json( { error: "Danışman şu anda yanıt veremiyor; kredin iade edildi. Birazdan tekrar dener misin?", }, { status: 503 }, ); } const mesajlar = [ ...gecmis.map((m) => ({ role: m.role as "user" | "assistant", content: m.content, })), { role: "user" as const, content: mesaj }, ]; const encoder = new TextEncoder(); const userId = session.user.id; 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 sohbetAkisi({ system, mesajlar, maxTokens: 1024, })) { metin += parca; controller.enqueue(encoder.encode(parca)); } await appDb.insert(schema.chatMessages).values({ id: crypto.randomUUID(), 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 {} } await grantCredits({ userId, delta: 1, reason: "refund", refId: kullaniciMesajId, }).catch(() => {}); } console.error("[soru] stream hatası:", err); controller.error(err); } }, }); return new Response(readable, { headers: { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", }, }); }