All checks were successful
Deploy / deploy (push) Successful in 7m45s
- Updated the app.db file to reflect recent changes in the application state. - Modified BACKLOG.md to include new entries regarding the removal of certain wizard steps, adjustments to priority chips, and the introduction of special quota conditions for candidates. - Added details on the decision to link priority chips to data sources and clarified the implications for user experience and data handling.
276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
import { eq, desc, sql } from "drizzle-orm";
|
||
import type { NextRequest } from "next/server";
|
||
import { getSession } from "@/lib/session";
|
||
import { appDb, schema } from "@/lib/appdb";
|
||
import {
|
||
spendCreditForMessage,
|
||
bedelsizMesajKaydet,
|
||
grantCredits,
|
||
krediBittiIsaretle,
|
||
} from "@/lib/credits";
|
||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||
import { kullanicininRaporu, sohbetGecmisiGetir } from "@/features/rapor/rapor-queries";
|
||
import { barajTablosu, type AracBaglami } from "@/lib/ai/araclar";
|
||
import { danismanAkisi } from "@/lib/ai/sohbet-akisi";
|
||
import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt";
|
||
import { modelPenceresi, onayMesajiMi } from "@/lib/ai/sohbet-gecmisi";
|
||
import { profilGetir } from "@/lib/danisman-profil-db";
|
||
import { listeOzetiCikar } from "@/lib/ai/rapor";
|
||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||
import type { RaporParams } from "@/features/rapor/types/rapor";
|
||
|
||
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",
|
||
});
|
||
const MAX_MESAJ_UZUNLUK = 2000;
|
||
|
||
// 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([
|
||
sohbetGecmisiGetir(session.user.id),
|
||
appDb.query.user.findFirst({
|
||
where: eq(schema.user.id, session.user.id),
|
||
columns: { creditBalance: true },
|
||
}),
|
||
]);
|
||
return Response.json({
|
||
mesajlar,
|
||
kredi: kullanici?.creditBalance ?? 0,
|
||
});
|
||
}
|
||
|
||
// Kullanıcının sohbet geçmişini siler. Kredi iadesi YOK: harcanan krediler
|
||
// ledger'da kalır, yalnızca mesajlar temizlenir (bir sonraki soruda danışman
|
||
// sıfırdan başlar; liste/özet bağlamı prompt'tan geldiği için korunur).
|
||
export async function DELETE() {
|
||
const session = await getSession();
|
||
if (!session) {
|
||
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
|
||
}
|
||
await appDb
|
||
.delete(schema.chatMessages)
|
||
.where(eq(schema.chatMessages.userId, session.user.id));
|
||
return Response.json({ ok: true });
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
// Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı
|
||
// kalır (önce 401, sonra 400).
|
||
const [session, hamBody] = await Promise.all([
|
||
getSession(),
|
||
request.json().catch(() => null),
|
||
]);
|
||
if (!session) {
|
||
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
|
||
}
|
||
if (hamBody === null || typeof hamBody !== "object") {
|
||
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
|
||
}
|
||
const body = hamBody as { message?: unknown; clientMessageId?: unknown };
|
||
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 });
|
||
}
|
||
|
||
// Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel.
|
||
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
|
||
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
|
||
const [rapor, gecmisHam, kullanici, profil] = await Promise.all([
|
||
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
|
||
kullanicininRaporu(session.user.id),
|
||
appDb
|
||
.select()
|
||
.from(schema.chatMessages)
|
||
.where(eq(schema.chatMessages.userId, session.user.id))
|
||
.orderBy(desc(schema.chatMessages.createdAt), sql`rowid DESC`)
|
||
.limit(MAX_GECMIS),
|
||
appDb.query.user.findFirst({
|
||
where: eq(schema.user.id, session.user.id),
|
||
columns: { hasPaket: true },
|
||
}),
|
||
profilGetir(session.user.id),
|
||
]);
|
||
if (!rapor?.result) {
|
||
return Response.json(
|
||
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
|
||
{ status: 409 },
|
||
);
|
||
}
|
||
const gecmis = gecmisHam.reverse();
|
||
|
||
// Profil onayı ("kaydedeyim mi?" → "evet") kredi harcamaz; iade de yok.
|
||
// Yalnız geçmişteki EN SON mesaj danışmanın sorusuysa: aynı soruya art arda
|
||
// gönderilen "evet"ler bedelsiz model çağrısı açmasın.
|
||
const sonMesaj = gecmis.at(-1);
|
||
const sonAsistan = sonMesaj?.role === "assistant" ? sonMesaj.content : undefined;
|
||
const bedelsiz = onayMesajiMi(mesaj, sonAsistan);
|
||
|
||
// Kredi düşümü stream'den önce — yetersizse hiç başlama
|
||
const harcama = bedelsiz
|
||
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
|
||
: 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 kilitli = !kullanici?.hasPaket;
|
||
const raporSonuc = rapor.result as RaporSonuc;
|
||
const tamOzet = listeOzetiCikar(
|
||
raporSonuc,
|
||
(rapor.params as RaporParams).sira,
|
||
{ kimlik: true, gerekce: true },
|
||
);
|
||
const listeOzeti = sohbetListeOzeti(tamOzet, kilitli);
|
||
|
||
const raporParams = rapor.params as RaporParams;
|
||
const system = sistemPromptu({
|
||
params: raporParams,
|
||
listeOzeti,
|
||
kilitli,
|
||
profil,
|
||
barajlar: barajTablosu(),
|
||
rapor: {
|
||
genelDegerlendirme: raporSonuc.genelDegerlendirme,
|
||
uyarilar: raporSonuc.uyarilar,
|
||
kapsam: raporSonuc.kapsam ? { toplam: raporSonuc.kapsam.toplam, uyarlandi: raporSonuc.kapsam.uyarlandi } : undefined,
|
||
},
|
||
});
|
||
// Araçların bağlamı: aday sırası/puan türü (arama süzgeci), profil
|
||
// (profil_guncelle yazınca aynı tur içinde tazelenir) ve sihirbaz seçimleri
|
||
// (uyum puanı "öne çıkan" cümleleri ve özel kontenjan eşlemesi için).
|
||
const aracBaglami: AracBaglami = {
|
||
userId: session.user.id,
|
||
sira: raporParams.sira,
|
||
tur: raporParams.tur,
|
||
profil,
|
||
oncelikler: raporParams.oncelikler ?? [],
|
||
ozelDurum: raporParams.ozelDurum ?? [],
|
||
sihirbazIlVar: (raporParams.iller?.length ?? 0) > 0 || Boolean(raporParams.il),
|
||
};
|
||
|
||
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
|
||
if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
|
||
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 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(/(?<= )/)) {
|
||
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,
|
||
role: "assistant",
|
||
content: cevap,
|
||
createdAt: new Date(),
|
||
});
|
||
writer.write({ type: "finish" });
|
||
},
|
||
});
|
||
return createUIMessageStreamResponse({
|
||
stream: mockStream,
|
||
headers: akisBasliklari(bedelsiz),
|
||
});
|
||
}
|
||
|
||
if (!anahtarVar()) {
|
||
if (!bedelsiz) {
|
||
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 },
|
||
);
|
||
}
|
||
|
||
// Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere
|
||
// modele bozuk sıra göndermesin
|
||
const mesajlar = modelPenceresi(gecmis, mesaj);
|
||
|
||
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(),
|
||
});
|
||
|
||
// 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,
|
||
delta: 1,
|
||
reason: "refund",
|
||
refId: kullaniciMesajId,
|
||
}).catch(() => {});
|
||
}
|
||
},
|
||
});
|
||
|
||
return createUIMessageStreamResponse({
|
||
stream,
|
||
headers: akisBasliklari(bedelsiz),
|
||
});
|
||
}
|
||
|