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:
bilalgursen
2026-10-07 17:12:53 +03:00
parent 3c995402c6
commit 4d30dd1305
29 changed files with 3392 additions and 414 deletions

View File

@@ -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);
},

View File

@@ -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ı.