- app/sonuc/actions.ts → features/rapor/rapor-actions.ts birebir taşındı (kredi harcama/iade idempotency yolları bayt bayt aynı, yalnızca import yolları değişti); liste-uretici/revizyon-kutusu action'ı doğrudan import ediyor - lib/rapor-kaydi.ts rapor-queries.ts'e emildi: kullanicininRaporu aynı tek okuma kapısı; listem sayfasındaki inline drizzle sorgusu sohbetGecmisiGetir oldu; tadimlikSec re-export'u eklendi - listem-govde, sohbet-client, revizyon-kutusu, liste-uretici, listem-bos-cta, rapor-listesi, tadimlik-satiri, yazdir-butonu features/rapor/components/'a - Yeni async ListemIcerik (verifySession + maskeleme + bosDurum dalı içeride; çift await searchParams kalktı) ve RaporYazdir (3 redirect + yazdırılabilir belge); skeleton'lar eski loading.tsx geometrileriyle aynı dosyada - /listem ve /rapor/yazdir senkron: searchParams.then() + sayfada Suspense; maxDuration=300 ve metadata sayfada kaldı; iki loading.tsx silindi - Davranış değişikliği yok; /api/soru el değmedi Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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",
|
||
},
|
||
});
|
||
}
|