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:
@@ -41,8 +41,12 @@ const ARAC_ETIKETI: Record<string, { surer: string; bitti: string }> = {
|
||||
universite_siralamasi: { surer: "Üniversite sıralamasına bakıyorum", bitti: "Üniversite sıralamasına baktım" },
|
||||
kapanan_program_ara: { surer: "Kapanan programlara bakıyorum", bitti: "Kapanan programlara baktım" },
|
||||
profil_guncelle: { surer: "Tercihini kaydediyorum", bitti: "Tercihini kaydettim" },
|
||||
liste_revize: { surer: "Listeni yeniden kuruyorum", bitti: "Listeni yeniden kurdum" },
|
||||
};
|
||||
|
||||
/** Hap olarak çizilmeyen araçlar: soru formu balonun içinde form olarak görünür (sohbet-anketi.tsx). */
|
||||
const HAPSIZ_ARACLAR = new Set(["soru_sor"]);
|
||||
|
||||
const VARSAYILAN_ETIKET = { surer: "Veriye bakıyorum", bitti: "Veriye baktım" };
|
||||
|
||||
/** Hapın metni; hata renkle değil sözle söylenir (§2.2). */
|
||||
@@ -50,7 +54,9 @@ export function aracPiliMetni(p: AracPili): string {
|
||||
const e = ARAC_ETIKETI[p.arac] ?? VARSAYILAN_ETIKET;
|
||||
const sayi = p.sayi > 1 ? ` ×${p.sayi}` : "";
|
||||
if (p.durum === "calisiyor") return `${e.surer}${sayi}…`;
|
||||
if (p.durum === "hata") return `${e.bitti}${sayi} (veri gelmedi)`;
|
||||
if (p.durum === "hata") {
|
||||
return p.arac === "liste_revize" ? `Listeni yeniden kuramadım${sayi}` : `${e.bitti}${sayi} (veri gelmedi)`;
|
||||
}
|
||||
return `${e.bitti}${sayi}`;
|
||||
}
|
||||
|
||||
@@ -65,6 +71,7 @@ export function aracPilleri(m: UIMessage): AracPili[] {
|
||||
for (const p of m.parts) {
|
||||
if (!p.type.startsWith("tool-") || !("state" in p)) continue;
|
||||
const arac = p.type.slice("tool-".length);
|
||||
if (HAPSIZ_ARACLAR.has(arac)) continue;
|
||||
let t = toplam.get(arac);
|
||||
if (!t) {
|
||||
t = { calisiyor: 0, hata: 0, sayi: 0 };
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BolmeTutamaci } from "@/components/ui/bolme-tutamaci";
|
||||
import { RaporListesi } from "./rapor-listesi";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { tercihFarki } from "@/features/rapor/tercih-farki";
|
||||
@@ -31,12 +32,12 @@ import type { RaporParams, RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import {
|
||||
SohbetClient,
|
||||
SOHBET_GENISLEME_GECISI,
|
||||
sohbetiOdakla,
|
||||
type DanismanOzeti,
|
||||
type IlkTercihYorumu,
|
||||
type Mesaj,
|
||||
type ProfilSatiri,
|
||||
} from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
@@ -46,6 +47,17 @@ import { ozelDurumOzeti } from "@/lib/ozel-durum";
|
||||
import { PUAN_TURLERI } from "@/types/yokatlas";
|
||||
import { DILIM_ETIKET } from "@/lib/risk";
|
||||
|
||||
// Sohbet sütununun sürüklenebildiği aralık (DESIGN.md §4.25): sohbet 320 px'in
|
||||
// altına, liste 420 px'in altına inmez; sohbet 820 px'i geçmez.
|
||||
const SOHBET_EN_AZ = 320;
|
||||
const SOHBET_EN_COK = 820;
|
||||
const LISTE_EN_AZ = 420;
|
||||
/** Izgaranın sütun aralığı (gap-6); tutamaç bu boşlukta durur. */
|
||||
const SUTUN_ARALIGI = 24;
|
||||
/** Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440. */
|
||||
const kolonTabani = () =>
|
||||
window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
|
||||
|
||||
// Başlık satırındaki ikincil düğmeler: 44 px, hover tek turuncu (§4.1, §2.1)
|
||||
const IKINCIL_BUTON =
|
||||
"h-11 cursor-pointer px-4 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]";
|
||||
@@ -101,22 +113,20 @@ export function ListemGovde({
|
||||
// yeniden akar. Telefonda (lg altı) ızgara tek sütun, yalnız boy değişir.
|
||||
const [sohbetGenis, setSohbetGenis] = useState(false);
|
||||
const izgaraRef = useRef<HTMLDivElement>(null);
|
||||
const sohbetSutunuRef = useRef<HTMLDivElement>(null);
|
||||
const hareketAz = useReducedMotion();
|
||||
|
||||
function sohbetGenisligiAyarla(genis: boolean) {
|
||||
setSohbetGenis(genis);
|
||||
/** Sohbet sütununu yayla hedef genişliğe götürür; `null` sınıftaki varsayılana döner. */
|
||||
function kolonaGit(hedefPx: number | null) {
|
||||
const el = izgaraRef.current;
|
||||
if (!el || !window.matchMedia("(min-width: 1024px)").matches) return;
|
||||
// Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440
|
||||
const taban = window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
|
||||
const hedef = genis
|
||||
? Math.max(taban, Math.round(Math.min(el.clientWidth * 0.6, 760)))
|
||||
: taban;
|
||||
const taban = kolonTabani();
|
||||
const hedef = hedefPx ?? taban;
|
||||
const simdiki = parseFloat(el.style.getPropertyValue("--sohbet-kolon")) || taban;
|
||||
const bitir = () => {
|
||||
// Dar hâlde satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur
|
||||
if (genis) el.style.setProperty("--sohbet-kolon", `${hedef}px`);
|
||||
else el.style.removeProperty("--sohbet-kolon");
|
||||
// Varsayılanda satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur
|
||||
if (hedefPx == null) el.style.removeProperty("--sohbet-kolon");
|
||||
else el.style.setProperty("--sohbet-kolon", `${hedef}px`);
|
||||
};
|
||||
if (hareketAz) {
|
||||
bitir();
|
||||
@@ -130,6 +140,17 @@ export function ListemGovde({
|
||||
).then(bitir);
|
||||
}
|
||||
|
||||
function sohbetGenisligiAyarla(genis: boolean) {
|
||||
setSohbetGenis(genis);
|
||||
const el = izgaraRef.current;
|
||||
if (!el) return;
|
||||
kolonaGit(
|
||||
genis
|
||||
? Math.max(kolonTabani(), Math.round(Math.min(el.clientWidth * 0.6, 760)))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
const pinVerisi = useMemo(
|
||||
() =>
|
||||
pinleriTuret(
|
||||
@@ -193,9 +214,14 @@ export function ListemGovde({
|
||||
if (params.ozelDurum?.length) {
|
||||
satirlar.push(`Özel kontenjan durumun: ${ozelDurumOzeti(params.ozelDurum)}`);
|
||||
}
|
||||
if (hasPaket) {
|
||||
satirlar.push(
|
||||
`Listende değişiklik isteyebilirsin: danışman gerekirse kısa bir formla sorar, sonra listeyi yeniden kurar (kalan hak: ${kalanHak})`,
|
||||
);
|
||||
}
|
||||
satirlar.push("Bu sohbetteki son mesajların");
|
||||
return satirlar;
|
||||
}, [adaySira, params, hasPaket, acikSatir]);
|
||||
}, [adaySira, params, hasPaket, acikSatir, kalanHak]);
|
||||
|
||||
// Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez).
|
||||
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
|
||||
@@ -212,9 +238,12 @@ export function ListemGovde({
|
||||
);
|
||||
|
||||
return (
|
||||
// Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = SOHBET_KOLON_TABANI);
|
||||
// "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla), liste
|
||||
// ve harita daralan 1fr sütunda kendini yeniden akıtır. DESIGN.md §4.20.
|
||||
// Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = kolonTabani);
|
||||
// "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla),
|
||||
// aradaki tutamaç elle sürükletir (§4.25); liste ve harita daralan 1fr
|
||||
// sütunda kendini yeniden akıtır. DESIGN.md §4.20.
|
||||
// Telefonda (lg altı) tek sütun: danışman en üstte, paywall kartı en altta
|
||||
// (Bilal, 7 Eki 2026) — sütun kabı `contents` olur, sıra `order` ile kurulur.
|
||||
<div
|
||||
ref={izgaraRef}
|
||||
className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:[--sohbet-kolon:380px] lg:grid-cols-[minmax(0,1fr)_var(--sohbet-kolon)] xl:[--sohbet-kolon:440px]"
|
||||
@@ -299,16 +328,21 @@ export function ListemGovde({
|
||||
)}
|
||||
{hasPaket ? (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className={IKINCIL_BUTON}
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
{/* Revizyon sohbette yaşar (7 Eki 2026): düğme danışman
|
||||
kutusunu odaklar, aday ne değişsin istediğini yazar;
|
||||
danışman gerekirse soru formuyla netleştirip listeyi
|
||||
yeniden kurar. Hak bitince düğme kalkar. */}
|
||||
{kalanHak > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => sohbetiOdakla("Listemde şunu değiştir: ")}
|
||||
className={IKINCIL_BUTON}
|
||||
>
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
@@ -371,26 +405,38 @@ export function ListemGovde({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
|
||||
</div>
|
||||
|
||||
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||
<div
|
||||
ref={sohbetSutunuRef}
|
||||
className="relative max-lg:contents lg:sticky lg:top-28"
|
||||
>
|
||||
{/* Liste ⇄ sohbet bölmesi: sütun aralığında durur, yalnız geniş ekranda */}
|
||||
<BolmeTutamaci
|
||||
etiket="Danışman sohbeti"
|
||||
kontrol="sohbet"
|
||||
yon={-1}
|
||||
oku={() =>
|
||||
sohbetSutunuRef.current?.getBoundingClientRect().width ?? kolonTabani()
|
||||
}
|
||||
yaz={(px) =>
|
||||
izgaraRef.current?.style.setProperty("--sohbet-kolon", `${px}px`)
|
||||
}
|
||||
sinirlar={() => {
|
||||
const en = izgaraRef.current?.clientWidth ?? 1024;
|
||||
return {
|
||||
enAz: SOHBET_EN_AZ,
|
||||
enCok: Math.max(
|
||||
SOHBET_EN_AZ,
|
||||
Math.min(en - SUTUN_ARALIGI - LISTE_EN_AZ, SOHBET_EN_COK),
|
||||
),
|
||||
};
|
||||
}}
|
||||
onSifirla={() => kolonaGit(null)}
|
||||
className="absolute inset-y-0 -left-6 hidden lg:block"
|
||||
/>
|
||||
{hasPaket ? (
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
initialProfil={danismanProfili}
|
||||
ozet={ozet}
|
||||
uyarilar={uyarilar}
|
||||
ilkYorum={ilkYorum}
|
||||
bildikleri={danismaninBildikleri}
|
||||
genis={sohbetGenis}
|
||||
onGenisDegistir={sohbetGenisligiAyarla}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
|
||||
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
|
||||
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
@@ -399,14 +445,36 @@ export function ListemGovde({
|
||||
uyarilar={uyarilar}
|
||||
ilkYorum={ilkYorum}
|
||||
bildikleri={danismaninBildikleri}
|
||||
className="h-[480px]"
|
||||
genis={sohbetGenis}
|
||||
onGenisDegistir={sohbetGenisligiAyarla}
|
||||
revizyonHakki={kalanHak}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 max-lg:contents">
|
||||
{/* Sohbet paket kartının üstünde: #sohbet çapası doğrudan
|
||||
danışmana düşsün. */}
|
||||
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
initialProfil={danismanProfili}
|
||||
ozet={ozet}
|
||||
uyarilar={uyarilar}
|
||||
ilkYorum={ilkYorum}
|
||||
bildikleri={danismaninBildikleri}
|
||||
// Paketsizde 480 px; kısa ekranda paketlideki boyla aynı tavana
|
||||
// iner (SOHBET_DAR_BOY, sohbet-client.tsx), alt bulanık şeridin
|
||||
// altına girmez.
|
||||
className="h-[480px] max-h-[calc(100dvh-23.75rem)] min-h-[340px] sm:max-h-[calc(100dvh-24.75rem)] lg:max-h-[calc(100dvh-21.5rem)]"
|
||||
genis={sohbetGenis}
|
||||
onGenisDegistir={sohbetGenisligiAyarla}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
ref={kilitKartRef}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm"
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm max-lg:order-last"
|
||||
>
|
||||
<div className="border-b border-slate-100 p-6">
|
||||
<h3 className="font-heading text-xl font-bold tracking-tight">
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Paketlinin geri bildirime dayalı liste revizyonu (/listem üzerinde).
|
||||
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
|
||||
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın.
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listeRevize } from "@/features/rapor/rapor-actions";
|
||||
import { olay } from "@/lib/analitik";
|
||||
|
||||
export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
// Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry
|
||||
// çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı
|
||||
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
|
||||
if (kalanHak <= 0) {
|
||||
return (
|
||||
<section
|
||||
id="revizyon"
|
||||
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-card p-5"
|
||||
>
|
||||
<p className="text-sm text-slate-500">
|
||||
Revizyon hakların doldu. Danışman sohbetinden listen hakkında soru
|
||||
sormaya devam edebilirsin.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
async function revizeEt() {
|
||||
setPending(true);
|
||||
try {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
const sonuc = await listeRevize({
|
||||
feedback: feedback.trim(),
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.ok) {
|
||||
olay("liste_revize", { sayfa: "listem" });
|
||||
toast.success("Listen yeniden kuruldu.");
|
||||
setFeedback("");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id="revizyon"
|
||||
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-card p-5"
|
||||
>
|
||||
<h2 className="font-heading text-base font-bold">
|
||||
Listede değişiklik mi istiyorsun?
|
||||
</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-slate-600">
|
||||
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
|
||||
(3 kredi).
|
||||
</p>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, güvenli dilimi genişlet…"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg border border-slate-200 bg-card px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending || feedback.trim().length < 5}
|
||||
onClick={revizeEt}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Kalan hak: <span className="font-semibold">{kalanHak}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
144
src/features/rapor/components/sohbet-anketi.tsx
Normal file
144
src/features/rapor/components/sohbet-anketi.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
// Danışmanın soru formu (DESIGN.md §4.20, §4.33). `soru_sor` aracı
|
||||
// (lib/ai/araclar.ts) liste revizyonu isteği belirsizken 1-3 kısa soruyu bu
|
||||
// formla sorar; sorular tek tek gösterilir (shadcn Questionnaire,
|
||||
// components/ui/questionnaire.tsx). Gönderilince cevaplar hem adayın balonuna
|
||||
// okunur metin olarak (lib/ai/sohbet-meta.ts anketCevabiMetni) hem sunucuya
|
||||
// yapısal olarak gider; bu mesaj kredi harcamaz. Form yalnız sohbetin SON
|
||||
// danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey
|
||||
// yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan
|
||||
// aynı hâlde döner).
|
||||
|
||||
import { useId } from "react";
|
||||
import { Check, MessageSquareText } from "lucide-react";
|
||||
import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
|
||||
import {
|
||||
Questionnaire,
|
||||
QuestionnaireActions,
|
||||
QuestionnaireChoice,
|
||||
QuestionnaireChoiceDescription,
|
||||
QuestionnaireChoices,
|
||||
QuestionnaireDescription,
|
||||
QuestionnaireError,
|
||||
QuestionnaireInput,
|
||||
QuestionnaireItem,
|
||||
QuestionnaireNext,
|
||||
QuestionnairePrevious,
|
||||
QuestionnaireProgress,
|
||||
QuestionnaireSkip,
|
||||
QuestionnaireSubmit,
|
||||
QuestionnaireTitle,
|
||||
} from "@/components/ui/questionnaire";
|
||||
|
||||
export type AnketDurumu = "acik" | "gonderiliyor" | "cevaplandi" | "kapandi";
|
||||
|
||||
export function SohbetAnketi({
|
||||
anket,
|
||||
durum,
|
||||
onGonder,
|
||||
}: {
|
||||
anket: Anket;
|
||||
durum: AnketDurumu;
|
||||
onGonder: (cevaplar: AnketCevaplari) => void;
|
||||
}) {
|
||||
const kimlik = useId();
|
||||
|
||||
if (durum === "cevaplandi" || durum === "kapandi") {
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 px-0.5 text-xs font-medium text-slate-500">
|
||||
{durum === "cevaplandi" ? (
|
||||
<Check className="size-3.5 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<MessageSquareText className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{durum === "cevaplandi" ? "Formu cevapladın." : "Form kapandı; isteğini yazarak söyleyebilirsin."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const items = anket.sorular.map((s) => ({
|
||||
name: s.ad,
|
||||
required: s.zorunlu !== false,
|
||||
choices: s.secenekler.map((o) => ({ value: o.deger })),
|
||||
}));
|
||||
|
||||
function gonder(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (durum === "gonderiliyor") return;
|
||||
const veri = new FormData(e.currentTarget);
|
||||
const cevaplar: AnketCevaplari = {};
|
||||
for (const s of anket.sorular) {
|
||||
const hepsi = veri
|
||||
.getAll(s.ad)
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (hepsi.length === 0) continue;
|
||||
cevaplar[s.ad] = s.coklu ? [...new Set(hepsi)] : hepsi[hepsi.length - 1];
|
||||
}
|
||||
if (Object.keys(cevaplar).length === 0) return;
|
||||
onGonder(cevaplar);
|
||||
}
|
||||
|
||||
return (
|
||||
// Sade kutu (§4.4); balonun altında, aynı sütunda durur
|
||||
<Questionnaire
|
||||
aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined}
|
||||
aria-label={anket.baslik ? undefined : "Danışmanın sorusu"}
|
||||
className="rounded-2xl border border-slate-200 bg-card p-4"
|
||||
defaultItem={anket.sorular[0]?.ad}
|
||||
items={items}
|
||||
shortcuts="letters"
|
||||
onSubmit={gonder}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p id={`${kimlik}-baslik`} className="text-xs font-semibold text-slate-500">
|
||||
{anket.baslik ?? "Danışmanın sorusu"}
|
||||
</p>
|
||||
<QuestionnaireProgress
|
||||
render={(props, state) => (
|
||||
<div {...props}>
|
||||
{state.current} / {state.total}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{anket.sorular.map((s) => (
|
||||
<QuestionnaireItem
|
||||
key={s.ad}
|
||||
name={s.ad}
|
||||
multiple={s.coklu === true}
|
||||
required={s.zorunlu !== false}
|
||||
disabled={durum === "gonderiliyor"}
|
||||
>
|
||||
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
|
||||
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
|
||||
<QuestionnaireChoices>
|
||||
{s.secenekler.map((o) => (
|
||||
<QuestionnaireChoice key={o.deger} value={o.deger}>
|
||||
<span>{o.etiket}</span>
|
||||
{o.aciklama ? (
|
||||
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription>
|
||||
) : null}
|
||||
</QuestionnaireChoice>
|
||||
))}
|
||||
{s.serbest ? (
|
||||
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} />
|
||||
) : null}
|
||||
</QuestionnaireChoices>
|
||||
<QuestionnaireError>
|
||||
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
|
||||
</QuestionnaireError>
|
||||
</QuestionnaireItem>
|
||||
))}
|
||||
<QuestionnaireActions>
|
||||
<QuestionnairePrevious size="sm" />
|
||||
<QuestionnaireSkip size="sm" />
|
||||
<QuestionnaireNext size="sm" />
|
||||
<QuestionnaireSubmit size="sm" disabled={durum === "gonderiliyor"}>
|
||||
{durum === "gonderiliyor" ? "Gönderiliyor…" : "Cevapları gönder"}
|
||||
</QuestionnaireSubmit>
|
||||
</QuestionnaireActions>
|
||||
</Questionnaire>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -19,6 +19,79 @@ export type Senaryo = {
|
||||
const ARAC_BEKLEME = 1400;
|
||||
|
||||
export const SENARYOLAR: Senaryo[] = [
|
||||
{
|
||||
ad: "Soru formu + revizyon",
|
||||
aciklama:
|
||||
"Belirsiz revizyon isteği: danışman soru formu açar (soru_sor), cevap gelince listeyi yeniden kurar (liste_revize).",
|
||||
kredi: 12,
|
||||
transport: () =>
|
||||
createChat()
|
||||
.user("Listemde daha fazla İstanbul olsun")
|
||||
.sleep(600)
|
||||
.assistant(({ writer }) => {
|
||||
writer
|
||||
.tool("soru_sor", {
|
||||
input: { sorular: [] },
|
||||
})
|
||||
.sleep(700)
|
||||
.output({
|
||||
soruldu: true,
|
||||
anket: {
|
||||
id: "demo-anket",
|
||||
baslik: "Listeni nasıl değiştirelim?",
|
||||
sorular: [
|
||||
{
|
||||
ad: "adet",
|
||||
soru: "İlk kaç tercihin İstanbul'dan olsun?",
|
||||
secenekler: [
|
||||
{ deger: "3", etiket: "İlk 3" },
|
||||
{ deger: "5", etiket: "İlk 5" },
|
||||
{ deger: "10", etiket: "İlk 10" },
|
||||
],
|
||||
},
|
||||
{
|
||||
ad: "kapsam",
|
||||
soru: "İstanbul dışındaki iller listede kalsın mı?",
|
||||
aciklama: "Kalırsa liste yine 24 tercih; yalnız başı İstanbul olur.",
|
||||
secenekler: [
|
||||
{ deger: "kalsin", etiket: "Kalsın", aciklama: "Ankara ve İzmir de listede olsun" },
|
||||
{ deger: "yalniz", etiket: "Yalnız İstanbul" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
writer.text("Birkaç kısa soru, sonra listeni yeniden kurayım.");
|
||||
})
|
||||
.user("Form cevaplarım:\n- İlk kaç tercihin İstanbul'dan olsun? → İlk 5\n- İstanbul dışındaki iller listede kalsın mı? → Kalsın")
|
||||
.sleep(500)
|
||||
.assistant(({ writer }) => {
|
||||
writer
|
||||
.tool("liste_revize", {
|
||||
input: { ilkTercihler: { adet: 5, iller: ["İstanbul"] }, iller: { ekle: ["İstanbul"] } },
|
||||
})
|
||||
.sleep(2500)
|
||||
.output({
|
||||
ok: true,
|
||||
toplam: 24,
|
||||
dagilim: { hayal: 5, dengeli: 13, garanti: 6 },
|
||||
kalanHak: 1,
|
||||
ilkTercihler: [
|
||||
"Bilgisayar Mühendisliği — İstanbul Teknik Üniversitesi (İSTANBUL)",
|
||||
"Bilgisayar Mühendisliği — Boğaziçi Üniversitesi (İSTANBUL)",
|
||||
"Yazılım Mühendisliği — Yıldız Teknik Üniversitesi (İSTANBUL)",
|
||||
"Bilgisayar Mühendisliği — Marmara Üniversitesi (İSTANBUL)",
|
||||
"Bilgisayar Mühendisliği — İstanbul Üniversitesi-Cerrahpaşa (İSTANBUL)",
|
||||
],
|
||||
});
|
||||
writer.text(
|
||||
"Listeni yeniden kurdum: ilk 5 tercihin artık İstanbul'dan, kalan 19 tercih Ankara ve İzmir'le devam ediyor. Dağılım yine 5 hayal / 13 dengeli / 6 güvenli.\n\n" +
|
||||
"1. Bilgisayar Mühendisliği — İTÜ\n2. Bilgisayar Mühendisliği — Boğaziçi\n3. Yazılım Mühendisliği — YTÜ\n4. Bilgisayar Mühendisliği — Marmara\n5. Bilgisayar Mühendisliği — İÜ-Cerrahpaşa\n\n" +
|
||||
"Kalan revizyon hakkın: 1.",
|
||||
);
|
||||
})
|
||||
.transport({ delayMs: 20 }),
|
||||
},
|
||||
{
|
||||
ad: "Tek araç",
|
||||
aciklama: "Bir arama, sonra kısa cevap.",
|
||||
|
||||
164
src/features/rapor/liste-revizyon.ts
Normal file
164
src/features/rapor/liste-revizyon.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import "server-only";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import {
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
MAX_REVIZYON,
|
||||
RAPOR_KREDI,
|
||||
spendCredits,
|
||||
} from "@/lib/credits";
|
||||
import { uretimKilidiAl, uretimKilidiBirak } from "@/lib/uretim-kilidi";
|
||||
import { profilGetir } from "@/lib/danisman-profil-db";
|
||||
import { listeOzetiCikar, raporUret, RaporUretimHatasi } from "@/lib/ai/rapor";
|
||||
import { kurallariBirlestir } from "@/lib/revizyon-kurali";
|
||||
import type { RevizyonOzeti } from "@/lib/ai/sohbet-meta";
|
||||
import { kullanicininRaporu } from "./rapor-queries";
|
||||
import type { RaporParams, RaporSonuc, RevizyonKurali } from "./types/rapor";
|
||||
|
||||
/**
|
||||
* Sohbetten liste revizyonu (7 Eki 2026). Danışmanın `liste_revize` aracı
|
||||
* (lib/ai/araclar.ts) burayı çağırır; eski /listem revizyon kutusu ve
|
||||
* `listeRevize` sunucu aksiyonu kaldırıldı — tek yol budur.
|
||||
*
|
||||
* Koruma kapıları eski aksiyonla aynı: paket şartı, MAX_REVIZYON sayacı,
|
||||
* kullanıcı başına üretim kilidi, `requestId` (= adayın sohbet mesajının
|
||||
* kimliği) ile idempotent defter kaydı, başarısız üretimde iade. Bedel
|
||||
* paketliye 0 (liste ve revizyon pakete dâhil); satır yine yazılır ki aynı
|
||||
* mesaj ikinci kez revizyon tetiklemesin.
|
||||
*/
|
||||
export type RevizyonSonucu =
|
||||
| { ok: true; ozet: Extract<RevizyonOzeti, { ok: true }>; rapor: RaporSonuc }
|
||||
| { ok: false; neden: string; kod: "PAKET" | "HAK" | "RAPOR" | "SURUYOR" | "KREDI" | "HATA" };
|
||||
|
||||
function hataMesaji(err: unknown): string {
|
||||
if (err instanceof RaporUretimHatasi) return err.message;
|
||||
if (
|
||||
err instanceof Error &&
|
||||
/abort|timeout|timed out/i.test(`${err.name} ${err.constructor.name} ${err.message}`)
|
||||
) {
|
||||
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; revizyon hakkın kullanılmadı.";
|
||||
}
|
||||
console.error("[revizyon] üretim hatası:", err instanceof Error ? err.message : String(err));
|
||||
return "Liste yeniden kurulurken bir sorun oluştu; revizyon hakkın kullanılmadı.";
|
||||
}
|
||||
|
||||
function ozetCikar(rapor: RaporSonuc, revisionCount: number): Extract<RevizyonOzeti, { ok: true }> {
|
||||
const say = (d: string) => rapor.tercihler.filter((t) => t.dilim === d).length;
|
||||
return {
|
||||
ok: true,
|
||||
toplam: rapor.tercihler.length,
|
||||
dagilim: { hayal: say("hayal"), dengeli: say("dengeli"), garanti: say("garanti") },
|
||||
kalanHak: Math.max(0, MAX_REVIZYON - revisionCount),
|
||||
ilkTercihler: rapor.tercihler.slice(0, 5).map((t) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
return `${p?.isim ?? "?"} — ${p?.universite ?? "?"}${p?.il ? ` (${p.il})` : ""}`;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listeRevizeCekirdek(input: {
|
||||
userId: string;
|
||||
/** Idempotency anahtarı: adayın sohbet mesajının kimliği. */
|
||||
requestId: string;
|
||||
kural: RevizyonKurali;
|
||||
}): Promise<RevizyonSonucu> {
|
||||
const user = await appDb.query.user.findFirst({
|
||||
where: eq(schema.user.id, input.userId),
|
||||
columns: { hasPaket: true, creditBalance: true },
|
||||
});
|
||||
if (!user) return { ok: false, kod: "HATA", neden: "Kullanıcı bulunamadı." };
|
||||
if (!user.hasPaket) {
|
||||
return {
|
||||
ok: false,
|
||||
kod: "PAKET",
|
||||
neden: "Liste revizyonu Tercih Dönemi Paketi'ne dâhil; aday paketsiz.",
|
||||
};
|
||||
}
|
||||
const rapor = await kullanicininRaporu(input.userId);
|
||||
if (!rapor?.result) {
|
||||
return { ok: false, kod: "RAPOR", neden: "Adayın kayıtlı listesi yok; önce liste oluşturulmalı." };
|
||||
}
|
||||
if (rapor.revisionCount >= MAX_REVIZYON) {
|
||||
return {
|
||||
ok: false,
|
||||
kod: "HAK",
|
||||
neden: `Revizyon hakkı doldu (en fazla ${MAX_REVIZYON}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const kilit = await uretimKilidiAl(input.userId, input.requestId);
|
||||
if (!kilit.ok) {
|
||||
return { ok: false, kod: "SURUYOR", neden: "Liste şu anda zaten yeniden kuruluyor." };
|
||||
}
|
||||
try {
|
||||
const eskiParams = rapor.params as RaporParams;
|
||||
const onceki = rapor.result as RaporSonuc;
|
||||
const params: RaporParams = {
|
||||
...eskiParams,
|
||||
revizyon: kurallariBirlestir(eskiParams.revizyon, input.kural),
|
||||
};
|
||||
|
||||
const maliyet = user.hasPaket ? 0 : RAPOR_KREDI;
|
||||
const harcama = await spendCredits({
|
||||
userId: input.userId,
|
||||
amount: maliyet,
|
||||
reason: "report_revision",
|
||||
refId: input.requestId,
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(input.userId);
|
||||
return { ok: false, kod: "KREDI", neden: `Revizyon için ${RAPOR_KREDI} kredi gerekli.` };
|
||||
}
|
||||
// DUPLICATE: aynı mesaj ikinci kez (ağ tekrarı). İade varsa önceki deneme
|
||||
// başarısızdı; yoksa revizyon zaten yapıldı → güncel listeyi döndür.
|
||||
const iade = await appDb.query.creditLedger.findFirst({
|
||||
where: and(
|
||||
eq(schema.creditLedger.reason, "refund"),
|
||||
eq(schema.creditLedger.refId, input.requestId),
|
||||
),
|
||||
});
|
||||
if (iade) return { ok: false, kod: "HATA", neden: "Önceki deneme başarısız olmuştu; yeni bir mesajla tekrar iste." };
|
||||
const guncel = await kullanicininRaporu(input.userId);
|
||||
if (guncel?.result) {
|
||||
const sonuc = guncel.result as RaporSonuc;
|
||||
return { ok: true, ozet: ozetCikar(sonuc, guncel.revisionCount), rapor: sonuc };
|
||||
}
|
||||
return { ok: false, kod: "SURUYOR", neden: "Liste şu anda zaten yeniden kuruluyor." };
|
||||
}
|
||||
|
||||
let sonuc: RaporSonuc;
|
||||
try {
|
||||
const profil = await profilGetir(input.userId).catch(() => undefined);
|
||||
sonuc = await raporUret(
|
||||
params,
|
||||
input.kural.not?.slice(0, 1000),
|
||||
listeOzetiCikar(onceki, params.sira),
|
||||
profil,
|
||||
);
|
||||
} catch (err) {
|
||||
await grantCredits({
|
||||
userId: input.userId,
|
||||
delta: maliyet,
|
||||
reason: "refund",
|
||||
refId: input.requestId,
|
||||
}).catch(() => {});
|
||||
return { ok: false, kod: "HATA", neden: hataMesaji(err) };
|
||||
}
|
||||
|
||||
await appDb
|
||||
.update(schema.reports)
|
||||
.set({
|
||||
params,
|
||||
result: sonuc,
|
||||
revisionCount: sql`${schema.reports.revisionCount} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.reports.id, rapor.id));
|
||||
|
||||
return { ok: true, ozet: ozetCikar(sonuc, rapor.revisionCount + 1), rapor: sonuc };
|
||||
} finally {
|
||||
await uretimKilidiBirak(input.userId, input.requestId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
uretimKilidiAl,
|
||||
uretimKilidiBirak,
|
||||
@@ -15,13 +15,8 @@ import {
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
RAPOR_KREDI,
|
||||
MAX_REVIZYON,
|
||||
} from "@/lib/credits";
|
||||
import {
|
||||
raporUret,
|
||||
listeOzetiCikar,
|
||||
RaporUretimHatasi,
|
||||
} from "@/lib/ai/rapor";
|
||||
import { raporUret, RaporUretimHatasi } from "@/lib/ai/rapor";
|
||||
import type {
|
||||
RaporParams,
|
||||
RaporSonuc,
|
||||
@@ -31,6 +26,8 @@ import { raporMaskele } from "@/lib/rapor-maske";
|
||||
|
||||
|
||||
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
|
||||
// Liste revizyonu burada değil: sohbetteki liste_revize aracı
|
||||
// (features/rapor/liste-revizyon.ts) tek yoldur (7 Eki 2026).
|
||||
export type ListeSonuc =
|
||||
| {
|
||||
ok: true;
|
||||
@@ -259,133 +256,3 @@ async function listeOlusturKilitli(
|
||||
hasPaket: user.hasPaket,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listeRevize(input: {
|
||||
feedback: string;
|
||||
requestId: string;
|
||||
}): Promise<ListeSonuc> {
|
||||
const session = await getSession();
|
||||
if (!session) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||
if (!user.hasPaket) return { ok: false, code: "PAKET", error: "Paket gerekli." };
|
||||
|
||||
const feedback = (input.feedback ?? "").trim();
|
||||
if (feedback.length < 5) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error: "Ne değişsin istediğini kısaca yazmalısın.",
|
||||
};
|
||||
}
|
||||
if (!input.requestId) {
|
||||
return { ok: false, code: "HATA", error: "Geçersiz istek." };
|
||||
}
|
||||
|
||||
const rapor = await kullanicininRaporu(user.id);
|
||||
if (!rapor?.result) {
|
||||
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
|
||||
}
|
||||
if (rapor.revisionCount >= MAX_REVIZYON) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error: `Revizyon hakkın doldu (en fazla ${MAX_REVIZYON}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const params = rapor.params as RaporParams;
|
||||
const onceki = rapor.result as RaporSonuc;
|
||||
|
||||
// Revizyon pakete dâhil (bkz. listeOlusturKilitli'deki aynı not). Yukarıdaki
|
||||
// hasPaket kapısı yüzünden burada maliyet her zaman 0; ifade yine de koşullu
|
||||
// yazılıyor ki kapı ileride gevşerse paketsiz kullanıcıdan ücret düşmeye
|
||||
// devam etsin. Revizyon hakkını MAX_REVIZYON sayacı sınırlıyor, kredi değil.
|
||||
const maliyet = user.hasPaket ? 0 : RAPOR_KREDI;
|
||||
|
||||
const harcama = await spendCredits({
|
||||
userId: user.id,
|
||||
amount: maliyet,
|
||||
reason: "report_revision",
|
||||
refId: input.requestId,
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`,
|
||||
};
|
||||
}
|
||||
// DUPLICATE: bu requestId zaten işlendi. İade varsa önceki deneme
|
||||
// başarısızdı → taze bir denemeye yönlendir. Yoksa revizyon tamamlanmış
|
||||
// ama cevap istemciye ulaşmamıştır → güncel raporu döndür.
|
||||
const iade = await appDb.query.creditLedger.findFirst({
|
||||
where: and(
|
||||
eq(schema.creditLedger.reason, "refund"),
|
||||
eq(schema.creditLedger.refId, input.requestId),
|
||||
),
|
||||
});
|
||||
if (iade) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error:
|
||||
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
||||
};
|
||||
}
|
||||
const guncel = await kullanicininRaporu(user.id);
|
||||
if (guncel?.result) {
|
||||
return {
|
||||
ok: true,
|
||||
rapor: guncel.result as RaporSonuc,
|
||||
params: guncel.params as RaporParams,
|
||||
revisionCount: guncel.revisionCount,
|
||||
kredi: user.creditBalance,
|
||||
hasPaket: true,
|
||||
};
|
||||
}
|
||||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
||||
}
|
||||
|
||||
let sonuc: RaporSonuc;
|
||||
try {
|
||||
const profil = await profilGetir(user.id).catch(() => undefined);
|
||||
sonuc = await raporUret(
|
||||
params,
|
||||
feedback.slice(0, 1000),
|
||||
listeOzetiCikar(onceki, params.sira),
|
||||
profil,
|
||||
);
|
||||
} catch (err) {
|
||||
// İade satırı burada da yazılır (paketlide delta 0) — DUPLICATE dalındaki
|
||||
// "önceki deneme başarısızdı" çıkarımı buna dayanıyor.
|
||||
await grantCredits({
|
||||
userId: user.id,
|
||||
delta: maliyet,
|
||||
reason: "refund",
|
||||
refId: input.requestId,
|
||||
}).catch(() => {});
|
||||
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
|
||||
}
|
||||
|
||||
// Sayaç yalnızca başarılı üretimde artar
|
||||
await appDb
|
||||
.update(schema.reports)
|
||||
.set({
|
||||
result: sonuc,
|
||||
revisionCount: sql`${schema.reports.revisionCount} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.reports.id, rapor.id));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rapor: sonuc,
|
||||
params,
|
||||
revisionCount: rapor.revisionCount + 1,
|
||||
kredi: user.creditBalance - maliyet,
|
||||
hasPaket: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function sohbetGecmisiGetir(userId: string) {
|
||||
id: schema.chatMessages.id,
|
||||
role: schema.chatMessages.role,
|
||||
content: schema.chatMessages.content,
|
||||
meta: schema.chatMessages.meta,
|
||||
})
|
||||
.from(schema.chatMessages)
|
||||
.where(eq(schema.chatMessages.userId, userId))
|
||||
|
||||
@@ -81,4 +81,48 @@ export interface RaporParams {
|
||||
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
|
||||
il?: string;
|
||||
notlar?: string;
|
||||
/**
|
||||
* Sohbetten yapılan liste revizyonlarının yapısal kuralları (7 Eki 2026).
|
||||
* Sihirbaz seçimlerinin ÜSTÜNE uygulanır; sihirbaz seçimleri değişmediği
|
||||
* için "Tercihlerin değişmiş" farkına girmez (tercih-farki.ts yalnız adlı
|
||||
* alanlara bakar). Her yeni revizyonda üst anahtar bazında birleştirilir.
|
||||
*/
|
||||
revizyon?: RevizyonKurali;
|
||||
}
|
||||
|
||||
/** "yalnız / ekle / çıkar" üçlüsü: sihirbaz listesine göre fark. */
|
||||
export type ListeFarki = {
|
||||
yalniz?: string[];
|
||||
ekle?: string[];
|
||||
cikar?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sohbetten gelen revizyon kuralları (lib/ai/araclar.ts `liste_revize`).
|
||||
* Serbest metin yerine yapısal: havuz süzgeci, iskelet ve sıra koddan
|
||||
* uygulanır; model yalnız seçer ve gerekçe yazar. Eksik alan "dokunma" demektir.
|
||||
*/
|
||||
export type RevizyonKurali = {
|
||||
iller?: ListeFarki;
|
||||
kategoriler?: ListeFarki;
|
||||
universiteTipi?: UniturGrubu | "farketmez";
|
||||
/** Dilim iskeleti: standart 5/13/6, güvenli ağırlıklı 4/12/8, hayal ağırlıklı 7/12/5. */
|
||||
iskelet?: "standart" | "guvenli-agirlikli" | "hayal-agirlikli";
|
||||
/** "İlk N tercih şu ölçüte uysun": listenin başı bu ölçütle dolar. */
|
||||
ilkTercihler?: {
|
||||
adet: number;
|
||||
iller?: string[];
|
||||
kategoriler?: string[];
|
||||
universiteTipi?: UniturGrubu;
|
||||
};
|
||||
/** Listeden ve havuzdan dışlanacaklar (ad eşleşmesi büyük-küçük harfsiz). */
|
||||
cikar?: {
|
||||
universiteler?: string[];
|
||||
bolumler?: string[];
|
||||
programIdler?: string[];
|
||||
};
|
||||
/** Listeye mutlaka girecek program kimlikleri (havuzda yoksa dilimine eklenir). */
|
||||
ekle?: { programIdler: string[] };
|
||||
/** Adayın isteğinin kısa özeti; modele revizyon notu olarak gider. */
|
||||
not?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user