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

@@ -17,7 +17,7 @@ import {
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { cn } from "@/lib/utils";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
@@ -58,6 +58,14 @@ import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
import { olay } from "@/lib/analitik";
import type { ProfilAlani } from "@/lib/danisman-profil";
import {
anketCevabiMetni,
type Anket,
type AnketCevaplari,
type RevizyonOzeti,
type SohbetMeta,
} from "@/lib/ai/sohbet-meta";
import { SohbetAnketi, type AnketDurumu } from "./sohbet-anketi";
/** /api/soru/profil'in döndüğü, öğrenciye okunan profil satırı. */
export type ProfilSatiri = { alan: ProfilAlani; metin: string };
@@ -66,11 +74,72 @@ export interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
/** Yapısal ek: soru formu / form cevabı / revizyon sonucu (lib/ai/sohbet-meta.ts) */
meta?: SohbetMeta | null;
}
/** Kayıtlı (düz metin) mesajı useChat'in parçalı mesajına çevirir. */
/** Kayıttan gelen araç parçalarının kimlik öneki: canlı akışın parçalarından ayırt edilir. */
const KAYIT_PARCA_ONEKI = "kayit-";
/**
* Kayıtlı mesajı useChat'in parçalı mesajına çevirir. Kayıt düz metindir;
* soru formu ve revizyon sonucu `meta`dan araç parçası olarak geri kurulur ki
* canlı akışla aynı yoldan çizilsin (form, "Listeni yeniden kurdum" hapı).
*/
function uiMesaj(m: Mesaj): UIMessage {
return { id: m.id, role: m.role, parts: [{ type: "text", text: m.content }] };
const parts: UIMessage["parts"] = [{ type: "text", text: m.content }];
if (m.role === "assistant" && m.meta?.anket) {
parts.push({
type: "tool-soru_sor",
toolCallId: `${KAYIT_PARCA_ONEKI}anket-${m.meta.anket.id}`,
state: "output-available",
input: {},
output: { soruldu: true, anket: m.meta.anket },
} as UIMessage["parts"][number]);
}
if (m.role === "assistant" && m.meta?.revizyon?.ok) {
parts.push({
type: "tool-liste_revize",
toolCallId: `${KAYIT_PARCA_ONEKI}revizyon-${m.id}`,
state: "output-available",
input: {},
output: m.meta.revizyon,
} as UIMessage["parts"][number]);
}
return { id: m.id, role: m.role, parts };
}
/** Mesajdaki soru formu (soru_sor aracının çıktısı); yoksa null. */
function mesajinAnketi(m: UIMessage): Anket | null {
for (const p of m.parts) {
if (p.type !== "tool-soru_sor" || !("state" in p) || p.state !== "output-available") continue;
const cikti = p.output as { anket?: Anket } | undefined;
if (cikti?.anket?.sorular?.length) return cikti.anket;
}
return null;
}
/** Mesajdaki başarılı liste revizyonları: parça kimliği → özet. */
function mesajinRevizyonlari(m: UIMessage): { id: string; ozet: RevizyonOzeti }[] {
const sonuc: { id: string; ozet: RevizyonOzeti }[] = [];
for (const p of m.parts) {
if (p.type !== "tool-liste_revize" || !("state" in p) || p.state !== "output-available") continue;
const cikti = p.output as RevizyonOzeti | undefined;
if (cikti?.ok) sonuc.push({ id: p.toolCallId, ozet: cikti });
}
return sonuc;
}
/** Formun cevabı olan kullanıcı mesajı mı (anketCevabiMetni ile aynı başlık). */
const FORM_CEVABI_BASI = "Form cevaplarım:";
/**
* Sayfadaki "Revize et" düğmesi (listem-govde.tsx) sohbeti bu olayla odaklar:
* kutuya başlangıç metni yazılır, panel görünür yere kaydırılır, imleç kutuya gelir.
*/
export const SOHBET_ODAK_OLAYI = "kolaytercih:sohbet-odak";
export function sohbetiOdakla(metin?: string) {
window.dispatchEvent(new CustomEvent(SOHBET_ODAK_OLAYI, { detail: { metin } }));
}
/** Mesajın metni: araç turları arasındaki metin parçaları boş satırla birleşir. */
@@ -111,6 +180,20 @@ export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.
*/
const USTE_UZAKLIK_REM = 7;
const ALT_BLUR_REM = 7;
/**
* Dar hâlin boyu (Bilal, 7 Eki 2026: "yüksekliğini alttan kısalım, progressive
* blur üzerine geliyor, sığmıyor"). Panelin altı, sayfa en üstteyken bile alt
* bulanık şeridin yaklaşık 1rem üstünde biter. Ekrandan düşülen pay üç
* parçadır: panelin sayfa en üstteykenki üst boşluğu + 7rem şerit + 1rem nefes.
* Üst boşluk ölçüldü (7 Eki 2026, canlı site): sezon bandı + site başlığının
* altı telefonda 171, 768 px'te 187, 1280 px'te 137 px; üstüne `main` py-12 ve
* ızgara mt-8 (80 px) gelir → 251 / 267 / 217 px. Eski tek değer (18rem) sezon
* bandını hesaba katmıyordu: giriş kutusu masaüstünde ~40 px, telefonda ~90 px
* şeridin altında kalıyordu. Sezon bandının metni uzayıp satır atlarsa bu
* sayılar yeniden ölçülür. En az boy çok kısa ekran içindir.
*/
export const SOHBET_DAR_BOY =
"h-[calc(100dvh-23.75rem)] min-h-[340px] sm:h-[calc(100dvh-24.75rem)] lg:h-[calc(100dvh-21.5rem)]";
const GENIS_EN_AZ_PX = 380;
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
const GENIS_YUKSEKLIK = "";
@@ -128,10 +211,11 @@ function sohbetKanaliKur(onBasladi: (krediDustu: boolean) => void) {
let red: Red | null = null;
const transport = new DefaultChatTransport<UIMessage>({
api: "/api/soru",
prepareSendMessagesRequest: ({ messages }) => {
// `body`: sendMessage'a verilen ek gövde (soru formunun yapısal cevabı)
prepareSendMessagesRequest: ({ messages, body }) => {
const son = messages[messages.length - 1];
return {
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id },
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id, ...(body ?? {}) },
};
},
fetch: async (girdiUrl, init) => {
@@ -245,6 +329,8 @@ const ORNEK_SORULAR = [
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
];
/** Revizyon hakkı olan (paketli) adaya listeyi sohbetten değiştirme örneği önde gelir. */
const REVIZYON_ORNEGI = "Listemde değişiklik istiyorum: ilk 5 tercihim İstanbul'dan olsun";
/**
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
@@ -263,6 +349,7 @@ export function SohbetClient({
genis: genisProp,
onGenisDegistir,
genisYukseklik = GENIS_YUKSEKLIK,
revizyonHakki = 0,
}: {
baslangicKredi: number;
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
@@ -294,6 +381,8 @@ export function SohbetClient({
/** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır
* (üst 7rem sticky hizası, alt 7rem progressive blur payı). */
genisYukseklik?: string;
/** Kalan liste revizyonu hakkı (paketli); 0 ise revizyon örneği gösterilmez. */
revizyonHakki?: number;
}) {
const [girdi, setGirdi] = useState("");
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
@@ -406,6 +495,41 @@ export function SohbetClient({
? [...mesajlar, BEKLENEN_CEVAP]
: mesajlar;
// Canlı akışta liste_revize başarıyla bitince liste sayfada yenilenir
// (sunucu bileşeni raporu yeniden okur). Kayıttan gelen parçalar (önek)
// zaten ekrandaki listeye ait; onlar için yenileme yok.
const islenenRevizyonlar = useRef(new Set<string>());
useEffect(() => {
for (const m of mesajlar) {
if (m.role !== "assistant") continue;
for (const r of mesajinRevizyonlari(m)) {
if (r.id.startsWith(KAYIT_PARCA_ONEKI) || islenenRevizyonlar.current.has(r.id)) continue;
islenenRevizyonlar.current.add(r.id);
router.refresh();
toast.success("Listen yeniden kuruldu.");
olay("liste_revize", { sayfa: "listem", yol: "sohbet" });
}
}
}, [mesajlar, router]);
// Sayfadaki "Revize et" düğmesi sohbeti odaklar (sohbetiOdakla)
useEffect(() => {
function odakla(e: Event) {
const metin = (e as CustomEvent<{ metin?: string }>).detail?.metin;
if (typeof metin === "string") setGirdi(metin);
const el = kapRef.current;
el?.scrollIntoView({ behavior: hareketAz ? "auto" : "smooth", block: "nearest" });
// Kaydırma başlasın, sonra odak (odak kaydırmayı yarıda keser)
window.setTimeout(() => {
const alan = el?.querySelector("textarea");
alan?.focus();
alan?.setSelectionRange(alan.value.length, alan.value.length);
}, 50);
}
window.addEventListener(SOHBET_ODAK_OLAYI, odakla);
return () => window.removeEventListener(SOHBET_ODAK_OLAYI, odakla);
}, [hareketAz]);
/**
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
* ve giriş kutusuna koy. Akış yarıda koptuysa tahmin etme: sunucu krediyi
@@ -536,6 +660,30 @@ export function SohbetClient({
void sendMessage({ id, role: "user", parts: [{ type: "text", text: mesaj }] });
}
/**
* Soru formunun cevabı: balonda okunur metin, gövdede yapısal cevap. Kredi
* harcamaz (sunucu formu son danışman mesajıyla eşleştirip bedelsiz sayar),
* bu yüzden kredi kapısından geçmez.
*/
function anketiGonder(anket: Anket, cevaplar: AnketCevaplari) {
if (bekliyor) return;
const metin = anketCevabiMetni(anket, cevaplar);
const id = crypto.randomUUID();
sonSoruRef.current = null;
void sendMessage(
{ id, role: "user", parts: [{ type: "text", text: metin }] },
{ body: { anketCevabi: { anketId: anket.id, cevaplar } } },
);
}
/** Formun durumu: yalnız son danışman mesajındaki form açıktır. */
function anketDurumu(i: number): AnketDurumu {
const sonraki = gorunenMesajlar[i + 1];
if (!sonraki) return bekliyor ? "gonderiliyor" : "acik";
if (sonraki.role === "user" && mesajMetni(sonraki).startsWith(FORM_CEVABI_BASI)) return "cevaplandi";
return "kapandi";
}
/**
* Sohbet geçmişini hem sunucudan hem ekrandan siler. Krediler iade
* edilmez; danışman bir sonraki soruda sıfır geçmişle başlar (rapor özeti
@@ -569,10 +717,7 @@ export function SohbetClient({
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
// 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
// sohbeti sıkıştırıyordu, 10-14rem denemeleri fazla yüksek geldi;
// panel görünür ama sayfaya baskın değil.
className ?? "h-[calc(100dvh-18rem)] min-h-[380px]",
className ?? SOHBET_DAR_BOY,
)}
>
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
@@ -796,7 +941,7 @@ export function SohbetClient({
!ozet && "justify-center",
)}
>
{ORNEK_SORULAR.map((s) => (
{[...(revizyonHakki > 0 ? [REVIZYON_ORNEGI] : []), ...ORNEK_SORULAR].map((s) => (
<PromptSuggestion
key={s}
size="sm"
@@ -842,6 +987,8 @@ export function SohbetClient({
// Danışman veriye bakarken ne yaptığını hap olarak söyler
// (arac-pilleri.tsx); haplar cevap bitince de kalır.
const piller = aracPilleri(m);
// Soru formu balonun altında (sohbet-anketi.tsx)
const anket = mesajinAnketi(m);
return (
<Message
key={cevapAnahtari(gorunenMesajlar, i)}
@@ -868,6 +1015,13 @@ export function SohbetClient({
) : (
<CevapIskeleti />
)}
{anket ? (
<SohbetAnketi
anket={anket}
durum={anketDurumu(i)}
onGonder={(cevaplar) => anketiGonder(anket, cevaplar)}
/>
) : null}
</div>
</Message>
);
@@ -917,7 +1071,11 @@ export function SohbetClient({
className="rounded-2xl border-slate-200"
>
<PromptInputTextarea
placeholder="Sorunu yaz… (1 kredi)"
placeholder={
revizyonHakki > 0
? "Sorunu yaz ya da listende ne değişsin söyle… (1 kredi)"
: "Sorunu yaz… (1 kredi)"
}
maxLength={2000}
/>
<PromptInputActions className="justify-end pt-1">