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

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

View File

@@ -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">

View File

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

View 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>
);
}

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">

View File

@@ -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.",

View 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);
}
}

View File

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

View File

@@ -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))

View File

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