feat(sohbet): liste revizyonu sohbete taşındı — soru formu ve yapısal kural
Eski revizyon kutusu kaldırıldı: serbest metin modele gidiyor, sıra ve havuz koddan belirlendiği için "ilk 5 X ilinden olsun" gibi isteklerin karşılığı yoktu. Danışman artık belirsiz isteği soru formuyla (soru_sor → shadcn Questionnaire, proje diline çevrildi) netleştirir, sonra liste_revize ile yapısal kural verir: il/alan/tip farkı, "ilk N" ölçütü, çıkar/ekle, iskelet (lib/revizyon-kurali.ts). Kurallar havuz, seçim ve sırada koddan uygulanır; "ilk N" ölçütünün ili/alanı süzgece kendiliğinden eklenir. - chat_messages.meta sütunu (göç 0006): form, form cevabı ve revizyon sonucu kayıttan geri kurulur; form cevabı kredi harcamaz. - Akış korumaları: form açılınca başka araç yok, revizyonda zaman aşımı uzar, "yeniden kurdum" beyanı araçsızsa düzeltme notu. - /listem "Revize et" sohbeti odaklar; başarılı revizyonda liste yenilenir. - /dev/sohbet senaryosu, DESIGN.md §4.20 notu ve §4.33. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,14 @@ 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 {
|
||||
anketCevabiDogrula,
|
||||
anketCevabiMetni,
|
||||
anketModelMetni,
|
||||
type RevizyonOzeti,
|
||||
type SohbetMeta,
|
||||
} from "@/lib/ai/sohbet-meta";
|
||||
import { MAX_REVIZYON } from "@/lib/credits";
|
||||
import { profilGetir } from "@/lib/danisman-profil-db";
|
||||
import { listeOzetiCikar } from "@/lib/ai/rapor";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
@@ -75,11 +83,20 @@ export async function POST(request: NextRequest) {
|
||||
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 body = hamBody as {
|
||||
message?: unknown;
|
||||
clientMessageId?: unknown;
|
||||
/** Danışmanın soru formuna cevap (sohbet-anketi.tsx); metin istemcide de üretilir ama sunucu kendi yazar. */
|
||||
anketCevabi?: { anketId?: unknown; cevaplar?: unknown };
|
||||
};
|
||||
let mesaj = typeof body.message === "string" ? body.message.trim() : "";
|
||||
const clientMessageId =
|
||||
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
|
||||
if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
|
||||
const anketCevabiHam =
|
||||
body.anketCevabi && typeof body.anketCevabi === "object" && typeof body.anketCevabi.anketId === "string"
|
||||
? { anketId: body.anketCevabi.anketId, cevaplar: body.anketCevabi.cevaplar }
|
||||
: null;
|
||||
if ((!mesaj && !anketCevabiHam) || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
|
||||
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -101,6 +118,7 @@ export async function POST(request: NextRequest) {
|
||||
}),
|
||||
profilGetir(session.user.id),
|
||||
]);
|
||||
const paketli = Boolean(kullanici?.hasPaket);
|
||||
if (!rapor?.result) {
|
||||
return Response.json(
|
||||
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
|
||||
@@ -114,11 +132,27 @@ export async function POST(request: NextRequest) {
|
||||
// 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);
|
||||
// Soru formunun cevabı da bedelsiz (7 Eki 2026): yalnız EN SON danışman
|
||||
// mesajının formu için ve o formun kimliğiyle. Cevap metni sunucuda formdan
|
||||
// üretilir; istemcinin yazdığı metin kullanılmaz.
|
||||
const sonAnket = sonMesaj?.role === "assistant" ? sonMesaj.meta?.anket : undefined;
|
||||
let kullaniciMeta: SohbetMeta | undefined;
|
||||
if (anketCevabiHam) {
|
||||
if (!sonAnket || sonAnket.id !== anketCevabiHam.anketId) {
|
||||
return Response.json({ error: "Bu form artık geçerli değil; isteğini yazarak söyleyebilirsin." }, { status: 409 });
|
||||
}
|
||||
const cevaplar = anketCevabiDogrula(sonAnket, anketCevabiHam.cevaplar);
|
||||
if (!cevaplar || Object.keys(cevaplar).length === 0) {
|
||||
return Response.json({ error: "Formda bir cevap seçilmedi." }, { status: 400 });
|
||||
}
|
||||
mesaj = anketCevabiMetni(sonAnket, cevaplar);
|
||||
kullaniciMeta = { anketCevabi: { anketId: sonAnket.id, cevaplar } };
|
||||
}
|
||||
const bedelsiz = Boolean(anketCevabiHam) || 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 bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId, meta: kullaniciMeta })
|
||||
: await spendCreditForMessage({
|
||||
userId: session.user.id,
|
||||
content: mesaj,
|
||||
@@ -141,7 +175,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 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 kilitli = !paketli;
|
||||
const raporSonuc = rapor.result as RaporSonuc;
|
||||
const tamOzet = listeOzetiCikar(
|
||||
raporSonuc,
|
||||
@@ -162,6 +196,7 @@ export async function POST(request: NextRequest) {
|
||||
uyarilar: raporSonuc.uyarilar,
|
||||
kapsam: raporSonuc.kapsam ? { toplam: raporSonuc.kapsam.toplam, uyarlandi: raporSonuc.kapsam.uyarlandi } : undefined,
|
||||
},
|
||||
revizyon: { kalanHak: Math.max(0, MAX_REVIZYON - rapor.revisionCount), paketli },
|
||||
});
|
||||
// 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
|
||||
@@ -170,6 +205,9 @@ export async function POST(request: NextRequest) {
|
||||
userId: session.user.id,
|
||||
sira: raporParams.sira,
|
||||
tur: raporParams.tur,
|
||||
hasPaket: paketli,
|
||||
mesajId: kullaniciMesajId,
|
||||
kalanRevizyon: Math.max(0, MAX_REVIZYON - rapor.revisionCount),
|
||||
profil,
|
||||
oncelikler: raporParams.oncelikler ?? [],
|
||||
ozelDurum: raporParams.ozelDurum ?? [],
|
||||
@@ -229,16 +267,27 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere
|
||||
// modele bozuk sıra göndermesin
|
||||
const mesajlar = modelPenceresi(gecmis, mesaj);
|
||||
// modele bozuk sıra göndermesin. Kayıtlı asistan metni soru formunu
|
||||
// içermez; model cevapların neye verildiğini formun dökümünden görür.
|
||||
const mesajlar = modelPenceresi(
|
||||
gecmis.map((m) =>
|
||||
m.role === "assistant" && m.meta?.anket
|
||||
? { role: m.role, content: `${m.content}\n\n${anketModelMetni(m.meta.anket)}` }
|
||||
: { role: m.role, content: m.content },
|
||||
),
|
||||
mesaj,
|
||||
);
|
||||
|
||||
const userId = session.user.id;
|
||||
// Bu turda araçların bıraktığı yapısal ek (soru formu, revizyon sonucu)
|
||||
const cevapMeta: SohbetMeta = {};
|
||||
const cevabiKaydet = (metin: string) =>
|
||||
appDb.insert(schema.chatMessages).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
role: "assistant",
|
||||
content: metin,
|
||||
meta: Object.keys(cevapMeta).length ? cevapMeta : null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
@@ -248,6 +297,15 @@ export async function POST(request: NextRequest) {
|
||||
system,
|
||||
mesajlar,
|
||||
aracBaglami,
|
||||
onAracSonucu: (ad, cikti) => {
|
||||
const c = cikti as Record<string, unknown>;
|
||||
if (ad === "soru_sor" && c.anket) cevapMeta.anket = c.anket as SohbetMeta["anket"];
|
||||
if (ad === "liste_revize" && c.ok === true) {
|
||||
const { not: _not, ...ozet } = c as Record<string, unknown>;
|
||||
void _not;
|
||||
cevapMeta.revizyon = ozet as RevizyonOzeti;
|
||||
}
|
||||
},
|
||||
onTamam: async (metin) => {
|
||||
await cevabiKaydet(metin);
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ListemIcerikSkeleton,
|
||||
} from "@/features/rapor/components/listem-icerik";
|
||||
|
||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||
// Liste üretimi (listeOlustur) 2 AI denemesi yapabiliyor (deneme başına 60 sn
|
||||
// abort sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||
// SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
|
||||
// bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin
|
||||
// istek süresi limiti de >= 200 sn olmalı.
|
||||
|
||||
Reference in New Issue
Block a user