feat(sohbet): liste revizyonu sohbete taşındı — soru formu ve yapısal kural

Eski revizyon kutusu kaldırıldı: serbest metin modele gidiyor, sıra ve havuz
koddan belirlendiği için "ilk 5 X ilinden olsun" gibi isteklerin karşılığı
yoktu. Danışman artık belirsiz isteği soru formuyla (soru_sor → shadcn
Questionnaire, proje diline çevrildi) netleştirir, sonra liste_revize ile
yapısal kural verir: il/alan/tip farkı, "ilk N" ölçütü, çıkar/ekle,
iskelet (lib/revizyon-kurali.ts). Kurallar havuz, seçim ve sırada koddan
uygulanır; "ilk N" ölçütünün ili/alanı süzgece kendiliğinden eklenir.

- chat_messages.meta sütunu (göç 0006): form, form cevabı ve revizyon sonucu
  kayıttan geri kurulur; form cevabı kredi harcamaz.
- Akış korumaları: form açılınca başka araç yok, revizyonda zaman aşımı uzar,
  "yeniden kurdum" beyanı araçsızsa düzeltme notu.
- /listem "Revize et" sohbeti odaklar; başarılı revizyonda liste yenilenir.
- /dev/sohbet senaryosu, DESIGN.md §4.20 notu ve §4.33.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-07 17:12:53 +03:00
parent 3c995402c6
commit 4d30dd1305
29 changed files with 3392 additions and 414 deletions

View File

@@ -15,6 +15,14 @@ import { barajTablosu, type AracBaglami } from "@/lib/ai/araclar";
import { danismanAkisi } from "@/lib/ai/sohbet-akisi";
import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt";
import { modelPenceresi, onayMesajiMi } from "@/lib/ai/sohbet-gecmisi";
import {
anketCevabiDogrula,
anketCevabiMetni,
anketModelMetni,
type RevizyonOzeti,
type SohbetMeta,
} from "@/lib/ai/sohbet-meta";
import { MAX_REVIZYON } from "@/lib/credits";
import { profilGetir } from "@/lib/danisman-profil-db";
import { listeOzetiCikar } from "@/lib/ai/rapor";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
@@ -75,11 +83,20 @@ export async function POST(request: NextRequest) {
if (hamBody === null || typeof hamBody !== "object") {
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
}
const body = hamBody as { message?: unknown; clientMessageId?: unknown };
const mesaj = typeof body.message === "string" ? body.message.trim() : "";
const body = hamBody as {
message?: unknown;
clientMessageId?: unknown;
/** Danışmanın soru formuna cevap (sohbet-anketi.tsx); metin istemcide de üretilir ama sunucu kendi yazar. */
anketCevabi?: { anketId?: unknown; cevaplar?: unknown };
};
let mesaj = typeof body.message === "string" ? body.message.trim() : "";
const clientMessageId =
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
const anketCevabiHam =
body.anketCevabi && typeof body.anketCevabi === "object" && typeof body.anketCevabi.anketId === "string"
? { anketId: body.anketCevabi.anketId, cevaplar: body.anketCevabi.cevaplar }
: null;
if ((!mesaj && !anketCevabiHam) || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
}
@@ -101,6 +118,7 @@ export async function POST(request: NextRequest) {
}),
profilGetir(session.user.id),
]);
const paketli = Boolean(kullanici?.hasPaket);
if (!rapor?.result) {
return Response.json(
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
@@ -114,11 +132,27 @@ export async function POST(request: NextRequest) {
// gönderilen "evet"ler bedelsiz model çağrısı açmasın.
const sonMesaj = gecmis.at(-1);
const sonAsistan = sonMesaj?.role === "assistant" ? sonMesaj.content : undefined;
const bedelsiz = onayMesajiMi(mesaj, sonAsistan);
// Soru formunun cevabı da bedelsiz (7 Eki 2026): yalnız EN SON danışman
// mesajının formu için ve o formun kimliğiyle. Cevap metni sunucuda formdan
// üretilir; istemcinin yazdığı metin kullanılmaz.
const sonAnket = sonMesaj?.role === "assistant" ? sonMesaj.meta?.anket : undefined;
let kullaniciMeta: SohbetMeta | undefined;
if (anketCevabiHam) {
if (!sonAnket || sonAnket.id !== anketCevabiHam.anketId) {
return Response.json({ error: "Bu form artık geçerli değil; isteğini yazarak söyleyebilirsin." }, { status: 409 });
}
const cevaplar = anketCevabiDogrula(sonAnket, anketCevabiHam.cevaplar);
if (!cevaplar || Object.keys(cevaplar).length === 0) {
return Response.json({ error: "Formda bir cevap seçilmedi." }, { status: 400 });
}
mesaj = anketCevabiMetni(sonAnket, cevaplar);
kullaniciMeta = { anketCevabi: { anketId: sonAnket.id, cevaplar } };
}
const bedelsiz = Boolean(anketCevabiHam) || onayMesajiMi(mesaj, sonAsistan);
// Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = bedelsiz
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId, meta: kullaniciMeta })
: await spendCreditForMessage({
userId: session.user.id,
content: mesaj,
@@ -141,7 +175,7 @@ export async function POST(request: NextRequest) {
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
// halde danışman kilitli listeyi sözle sızdırabilir.
const kilitli = !kullanici?.hasPaket;
const kilitli = !paketli;
const raporSonuc = rapor.result as RaporSonuc;
const tamOzet = listeOzetiCikar(
raporSonuc,
@@ -162,6 +196,7 @@ export async function POST(request: NextRequest) {
uyarilar: raporSonuc.uyarilar,
kapsam: raporSonuc.kapsam ? { toplam: raporSonuc.kapsam.toplam, uyarlandi: raporSonuc.kapsam.uyarlandi } : undefined,
},
revizyon: { kalanHak: Math.max(0, MAX_REVIZYON - rapor.revisionCount), paketli },
});
// Araçların bağlamı: aday sırası/puan türü (arama süzgeci), profil
// (profil_guncelle yazınca aynı tur içinde tazelenir) ve sihirbaz seçimleri
@@ -170,6 +205,9 @@ export async function POST(request: NextRequest) {
userId: session.user.id,
sira: raporParams.sira,
tur: raporParams.tur,
hasPaket: paketli,
mesajId: kullaniciMesajId,
kalanRevizyon: Math.max(0, MAX_REVIZYON - rapor.revisionCount),
profil,
oncelikler: raporParams.oncelikler ?? [],
ozelDurum: raporParams.ozelDurum ?? [],
@@ -229,16 +267,27 @@ export async function POST(request: NextRequest) {
}
// Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere
// modele bozuk sıra göndermesin
const mesajlar = modelPenceresi(gecmis, mesaj);
// modele bozuk sıra göndermesin. Kayıtlı asistan metni soru formunu
// içermez; model cevapların neye verildiğini formun dökümünden görür.
const mesajlar = modelPenceresi(
gecmis.map((m) =>
m.role === "assistant" && m.meta?.anket
? { role: m.role, content: `${m.content}\n\n${anketModelMetni(m.meta.anket)}` }
: { role: m.role, content: m.content },
),
mesaj,
);
const userId = session.user.id;
// Bu turda araçların bıraktığı yapısal ek (soru formu, revizyon sonucu)
const cevapMeta: SohbetMeta = {};
const cevabiKaydet = (metin: string) =>
appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId,
role: "assistant",
content: metin,
meta: Object.keys(cevapMeta).length ? cevapMeta : null,
createdAt: new Date(),
});
@@ -248,6 +297,15 @@ export async function POST(request: NextRequest) {
system,
mesajlar,
aracBaglami,
onAracSonucu: (ad, cikti) => {
const c = cikti as Record<string, unknown>;
if (ad === "soru_sor" && c.anket) cevapMeta.anket = c.anket as SohbetMeta["anket"];
if (ad === "liste_revize" && c.ok === true) {
const { not: _not, ...ozet } = c as Record<string, unknown>;
void _not;
cevapMeta.revizyon = ozet as RevizyonOzeti;
}
},
onTamam: async (metin) => {
await cevabiKaydet(metin);
},

View File

@@ -5,8 +5,8 @@ import {
ListemIcerikSkeleton,
} from "@/features/rapor/components/listem-icerik";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
// Liste üretimi (listeOlustur) 2 AI denemesi yapabiliyor (deneme başına 60 sn
// abort sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
// SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
// bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin
// istek süresi limiti de >= 200 sn olmalı.

View File

@@ -0,0 +1,329 @@
"use client";
// shadcn `questionnaire` (https://ui.shadcn.com/docs/react/questionnaire,
// radix-nova kayıt öğesi; ilkel: @shadcn/react/questionnaire), sitenin diline
// çevrildi (DESIGN.md §4.6, §4.17, §4.24): `border-input / bg-muted /
// text-muted-foreground` token'ları yerine slate ölçeği ve `bg-card`; seçili
// seçenek sihirbaz çipi gibi `border-primary bg-primary text-white`; hover
// tek turuncu (§2.1); dokunma hedefi 44 px; köşe `rounded-2xl` (§4.4 sade
// kutu); düğmeler sitenin `Button`'u. Yapı ve davranış (tek tek soru,
// ilerleme, doğrulama, klavye kısayolu, atlama) ilkelden gelir. Danışman
// sohbetindeki soru formu bunu kullanır (features/rapor/components/sohbet-anketi.tsx).
import * as React from "react";
import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire";
import { Check } from "lucide-react";
import { buttonVariants, type Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Questionnaire({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Root>) {
return (
<QuestionnairePrimitive.Root
data-slot="questionnaire"
className={cn("flex w-full min-w-0 flex-col gap-4", className)}
{...props}
/>
);
}
function QuestionnaireProgress({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) {
return (
<QuestionnairePrimitive.Progress
data-slot="questionnaire-progress"
className={cn(
"min-h-[1lh] w-fit min-w-[14ch] text-xs font-semibold text-slate-500 tabular-nums",
className,
)}
{...props}
/>
);
}
function QuestionnaireItem({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
return (
<QuestionnairePrimitive.Item
data-slot="questionnaire-item"
className={cn("flex min-w-0 flex-col gap-3 border-0 p-0 outline-none", className)}
{...props}
/>
);
}
function QuestionnaireTitle({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
return (
<QuestionnairePrimitive.Title
data-slot="questionnaire-title"
className={cn(
"font-heading text-base leading-snug font-bold text-pretty text-slate-900 [&:not(:has(~[data-slot=questionnaire-description]))]:mb-1",
className,
)}
{...props}
/>
);
}
function QuestionnaireDescription({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) {
return (
<QuestionnairePrimitive.Description
data-slot="questionnaire-description"
className={cn("text-sm text-pretty leading-5 text-slate-600", className)}
{...props}
/>
);
}
function QuestionnaireChoices({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) {
return (
<QuestionnairePrimitive.Choices
data-slot="questionnaire-choices"
className={cn("group/questionnaire-choices grid min-w-0 gap-2", className)}
{...props}
/>
);
}
/**
* Seçenek satırı: 44 px sade kutu; seçilince sihirbaz çipi gibi mavi dolar ve
* başındaki işaret tik olur — seçili hâl renk + tik'tir (§2.2). Klavye
* kısayolu (harf) sağda küçük bir kutuda durur.
*/
function QuestionnaireChoice({
children,
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
return (
<QuestionnairePrimitive.Choice
data-slot="questionnaire-choice"
className={cn(
"group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-2xl border border-slate-200 bg-card px-3.5 py-2.5 text-start text-sm font-semibold text-slate-700 transition-[background-color,border-color,color,transform] duration-150 outline-none select-none",
"hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.99]",
"has-[>input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-ring/50",
"data-checked:border-primary data-checked:bg-primary data-checked:text-white data-checked:hover:border-primary data-checked:hover:bg-primary data-checked:hover:text-white",
"data-invalid:border-red-500",
"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<QuestionnairePrimitive.ChoiceInput
data-slot="questionnaire-choice-input"
className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span
aria-hidden="true"
data-slot="questionnaire-choice-indicator"
className="pointer-events-none relative mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-slate-300 bg-card group-data-[type=radio]/questionnaire-choice:rounded-full group-data-checked/questionnaire-choice:border-primary-foreground group-data-checked/questionnaire-choice:bg-primary-foreground group-data-checked/questionnaire-choice:text-primary"
>
<Check
data-slot="questionnaire-choice-indicator-check"
className="hidden size-3 group-data-checked/questionnaire-choice:block"
strokeWidth={3}
/>
</span>
<QuestionnairePrimitive.ChoiceLabel
data-slot="questionnaire-choice-label"
className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"
>
{children}
</QuestionnairePrimitive.ChoiceLabel>
<QuestionnairePrimitive.ChoiceShortcut
data-slot="questionnaire-choice-shortcut"
className="pointer-events-none ms-auto mt-0.5 hidden size-5 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-card font-mono text-[0.625rem] leading-none font-medium text-slate-500 group-data-checked/questionnaire-choice:border-white/40 group-data-checked/questionnaire-choice:bg-white/15 group-data-checked/questionnaire-choice:text-white group-data-[shortcut]/questionnaire-choice:inline-flex"
/>
</QuestionnairePrimitive.Choice>
);
}
function QuestionnaireChoiceDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="questionnaire-choice-description"
className={cn(
"text-xs font-normal text-slate-500 group-data-checked/questionnaire-choice:text-white/80",
className,
)}
{...props}
/>
);
}
/** Serbest cevap alanı: §4.6 metin alanı hapı (44 px, bu kutuda bir kademe kısa). */
function QuestionnaireInput({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
return (
<div
data-slot="questionnaire-input-wrapper"
className="group/questionnaire-input relative w-full min-w-0"
>
<QuestionnairePrimitive.Input
data-slot="questionnaire-input"
className={cn(
"h-11 w-full min-w-0 rounded-full border border-slate-200 bg-card px-4 text-base text-slate-900 transition-[border-color,box-shadow] outline-none placeholder:text-slate-400 hover:border-slate-300 focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-50 aria-invalid:border-red-500 aria-invalid:ring-3 aria-invalid:ring-red-500/20",
className,
)}
{...props}
/>
</div>
);
}
function QuestionnaireError({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
return (
<QuestionnairePrimitive.Error
data-slot="questionnaire-error"
className={cn("text-sm font-medium text-red-600", className)}
{...props}
/>
);
}
function QuestionnaireActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="questionnaire-actions"
className={cn(
"grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2",
className,
)}
{...props}
/>
);
}
type DugmeProps = Pick<React.ComponentProps<typeof Button>, "size" | "variant">;
function QuestionnairePrevious({
children,
className,
size = "default",
variant = "outline",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> & DugmeProps) {
return (
<QuestionnairePrimitive.Previous
data-slot="questionnaire-previous"
className={cn(
buttonVariants({ size, variant }),
"col-start-1 row-start-1 cursor-pointer justify-self-start",
className,
)}
{...props}
>
{children ?? "Geri"}
</QuestionnairePrimitive.Previous>
);
}
function QuestionnaireSkip({
children,
className,
size = "default",
variant = "ghost",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> & DugmeProps) {
return (
<QuestionnairePrimitive.Skip
data-slot="questionnaire-skip"
className={cn(
buttonVariants({ size, variant }),
"col-start-2 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "Atla"}
</QuestionnairePrimitive.Skip>
);
}
function QuestionnaireNext({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Next> & DugmeProps) {
return (
<QuestionnairePrimitive.Next
data-slot="questionnaire-next"
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "İleri"}
</QuestionnairePrimitive.Next>
);
}
function QuestionnaireSubmit({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> & DugmeProps) {
return (
<QuestionnairePrimitive.Submit
data-slot="questionnaire-submit"
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "Gönder"}
</QuestionnairePrimitive.Submit>
);
}
export {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
};

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

View File

@@ -1,10 +1,14 @@
/**
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
*
* İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* Üç tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
* (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
* üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
* (src/lib/danisman-profil.ts); `soru_sor` + `liste_revize` sohbetten liste
* revizyonu (7 Eki 2026): danışman belirsiz isteği form olarak sorar
* (lib/ai/sohbet-meta.ts), netleşince YAPISAL kuralla listeyi yeniden kurdurur
* (features/rapor/liste-revizyon.ts). Model listeyi yine kendisi kurmaz:
* üretim deterministik havuzda kalır (havuz-dışına-çıkamama kuralı); kural
* havuzu, iskeleti ve sırayı koddan değiştirir (lib/revizyon-kurali.ts).
*
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
*/
@@ -85,11 +89,19 @@ import {
} from "@/lib/danisman-profil";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
import { anketDogrula, type Anket } from "./sohbet-meta";
import type { RevizyonKurali, ListeFarki } from "@/features/rapor/types/rapor";
export type AracBaglami = {
userId: string;
sira: number;
tur: PuanTuruKey;
/** Paketli mi (liste_revize paket ister); verilmezse araç "paket yok" sayar. */
hasPaket?: boolean;
/** Adayın bu turdaki mesajının kimliği: liste_revize'nin idempotency anahtarı. */
mesajId?: string;
/** Kalan revizyon hakkı (prompt'a ve araç hatasına gider). */
kalanRevizyon?: number;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
/** Sihirbaz öncelik çipleri (uyum puanı / öne çıkan için) */
@@ -405,6 +417,123 @@ export const ARACLAR: AracTanimi[] = [
},
},
},
{
type: "function",
function: {
name: "soru_sor",
description:
"Adaya ekranda tek tek gösterilen kısa bir SORU FORMU açar (en çok 3 soru, soru başına 2-6 seçenek). Liste revizyonu isteği belirsizken kullan: hangi il, kaç tercih, ekle mi yalnız mı, hangi dilim. Her soruya net, kısa seçenekler yaz; gerekirse serbest metin alanı ekle (serbest). Form gösterildikten sonra BAŞKA ARAÇ ÇAĞIRMA ve cevabını tek kısa cümleyle bitir; adayın cevabı sonraki mesajda 'Form cevaplarım' olarak gelir, o zaman liste_revize çağır.",
parameters: {
type: "object",
properties: {
baslik: { type: "string", description: "Formun kısa başlığı (isteğe bağlı), ör. 'Listeni nasıl değiştirelim?'" },
sorular: {
type: "array",
description: "1-3 soru; sırayla gösterilir.",
items: {
type: "object",
properties: {
ad: { type: "string", description: "Cevap anahtarı: küçük harf, rakam, tire (ör. 'il', 'adet')." },
soru: { type: "string", description: "Adaya gösterilen soru metni." },
aciklama: { type: "string", description: "Sorunun altında küçük açıklama (isteğe bağlı)." },
secenekler: {
type: "array",
description: "2-6 seçenek.",
items: {
type: "object",
properties: {
deger: { type: "string", description: "Cevap değeri (kısa, ör. 'istanbul', '5')." },
etiket: { type: "string", description: "Adaya görünen metin." },
aciklama: { type: "string", description: "Seçeneğin altında küçük açıklama (isteğe bağlı)." },
},
required: ["deger", "etiket"],
additionalProperties: false,
},
},
coklu: { type: "boolean", description: "true: birden çok seçenek işaretlenebilir." },
serbest: { type: "string", description: "Verilirse serbest metin alanı eklenir; değer yer tutucudur (ör. 'Başka bir il yaz…')." },
zorunlu: { type: "boolean", description: "false: boş geçilebilir (varsayılan true)." },
},
required: ["ad", "soru", "secenekler"],
additionalProperties: false,
},
},
},
required: ["sorular"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "liste_revize",
description:
"Adayın 24'lük listesini verilen YAPISAL kurallarla yeniden kurar (paketli adayda, en fazla 2 hak). Kural kodla uygulanır: il/alan/tip farkı aday havuzunu değiştirir, 'ilkTercihler' listenin başını o ölçütle doldurur, 'cikar' havuzdan düşer, 'ekle' program kimliklerini listeye alır, 'iskelet' dilim dağılımını değiştirir. En az bir kural ver; adayın isteği belirsizse ÖNCE soru_sor. Çalışması 20-60 sn sürer; sonuç ok ise liste ekranda yenilenir, sen ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi yaz. 'not' alanına adayın isteğini bir cümleyle özetle.",
parameters: {
type: "object",
properties: {
iller: {
type: "object",
description: "Şehir tercihi farkı. yalniz: listede sadece bu iller; ekle: mevcut illere eklenir; cikar: mevcut illerden düşer. İl adı Türkçe (İstanbul, Ankara…).",
properties: {
yalniz: { type: "array", items: { type: "string" } },
ekle: { type: "array", items: { type: "string" } },
cikar: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
kategoriler: {
type: "object",
description: `İlgi alanı farkı; adlar tam olarak şunlardan: ${KATEGORILER.join(" | ")}.`,
properties: {
yalniz: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
ekle: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
cikar: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
},
additionalProperties: false,
},
universiteTipi: { type: "string", enum: ["devlet", "vakif", "farketmez"], description: "Yalnız devlet / yalnız vakıf / fark etmez." },
iskelet: {
type: "string",
enum: ["standart", "guvenli-agirlikli", "hayal-agirlikli"],
description: "Dilim dağılımı: standart 5 hayal/13 dengeli/6 güvenli; guvenli-agirlikli 4/12/8 ('daha güvenli olsun'); hayal-agirlikli 7/12/5 ('daha iddialı olsun').",
},
ilkTercihler: {
type: "object",
description: "'İlk N tercih şu ölçüte uysun' (ör. ilk 5 İstanbul). En az bir ölçüt ver.",
properties: {
adet: { type: "integer", minimum: 1, maximum: 24 },
iller: { type: "array", items: { type: "string" } },
kategoriler: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
universiteTipi: { type: "string", enum: ["devlet", "vakif"] },
},
required: ["adet"],
additionalProperties: false,
},
cikar: {
type: "object",
description: "Listeye girmeyecekler: üniversite adı parçası, bölüm adı parçası ya da program kimliği (listedeki 'id').",
properties: {
universiteler: { type: "array", items: { type: "string" } },
bolumler: { type: "array", items: { type: "string" } },
programIdler: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
ekle: {
type: "object",
description: "Listeye mutlaka girecek program kimlikleri (program_ara sonucundaki id; uydurma).",
properties: { programIdler: { type: "array", items: { type: "string" } } },
required: ["programIdler"],
additionalProperties: false,
},
not: { type: "string", description: "Adayın isteğinin bir cümlelik özeti (liste üretimine gider)." },
},
additionalProperties: false,
},
},
},
];
// ── Yardımcılar ──────────────────────────────────────────────────────────
@@ -1480,6 +1609,168 @@ function alanHatasi(ad: string, s: AlanSemasi, v: unknown): string | null {
return null;
}
// ── Sohbetten liste revizyonu ────────────────────────────────────────────
/** `soru_sor`: formu denetler, kimlik verir; metin ve kayıt çağıranın (route) işi. */
function soruSor(a: Record<string, unknown>): { soruldu: true; anket: Anket; not: string } | { hata: string } {
const d = anketDogrula(a);
if (!d.ok) return { hata: `Form geçersiz — ${d.hata}. Düzeltip yeniden çağır.` };
return {
soruldu: true,
anket: { id: crypto.randomUUID(), ...d.anket },
not: "Form adaya gösterildi. Şimdi BAŞKA ARAÇ ÇAĞIRMA, formu metin olarak tekrar yazma; tek kısa cümleyle bitir (ör. 'Birkaç kısa soru, sonra listeni yeniden kurayım.'). Cevaplar sonraki mesajda 'Form cevaplarım' olarak gelir.",
};
}
type Normalizasyon = { ok: true; deger: string[] } | { ok: false; hata: string };
function illeriNormalize(ham: unknown, alan: string): Normalizasyon {
if (ham == null) return { ok: true, deger: [] };
if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
const sonuc: string[] = [];
for (const x of ham) {
if (typeof x !== "string") return { ok: false, hata: `${alan}: il adı metin olmalı` };
const il = ilAdiNormalize(x);
if (!il) return { ok: false, hata: `${alan}: "${x.slice(0, 30)}" il olarak tanınmadı; Türkçe il adı yaz (ör. İstanbul, İzmir)` };
if (!sonuc.includes(il)) sonuc.push(il);
}
return { ok: true, deger: sonuc };
}
function kategorileriNormalize(ham: unknown, alan: string): Normalizasyon {
if (ham == null) return { ok: true, deger: [] };
if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
const sonuc: string[] = [];
for (const x of ham) {
if (typeof x !== "string" || !(KATEGORILER as readonly string[]).includes(x)) {
return { ok: false, hata: `${alan}: "${String(x).slice(0, 30)}" geçerli bir ilgi alanı değil; şunlardan biri olmalı: ${KATEGORILER.join(", ")}` };
}
if (!sonuc.includes(x)) sonuc.push(x);
}
return { ok: true, deger: sonuc };
}
function farkNormalize(
ham: unknown,
alan: string,
normalize: (x: unknown, alan: string) => Normalizasyon,
): { ok: true; fark?: ListeFarki } | { ok: false; hata: string } {
if (ham == null) return { ok: true };
if (typeof ham !== "object" || Array.isArray(ham)) return { ok: false, hata: `${alan}: nesne bekleniyor` };
const h = ham as Record<string, unknown>;
const fark: ListeFarki = {};
for (const anahtar of ["yalniz", "ekle", "cikar"] as const) {
if (h[anahtar] == null) continue;
const n = normalize(h[anahtar], `${alan}.${anahtar}`);
if (!n.ok) return n;
if (n.deger.length) fark[anahtar] = n.deger;
}
if (fark.yalniz && fark.yalniz.length > 5) return { ok: false, hata: `${alan}.yalniz: en fazla 5` };
return { ok: true, fark: Object.keys(fark).length ? fark : undefined };
}
function metinListesi(ham: unknown, alan: string, ust = 10): Normalizasyon {
if (ham == null) return { ok: true, deger: [] };
if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
const sonuc = ham
.filter((x): x is string => typeof x === "string")
.map((x) => x.replace(/\s+/g, " ").trim().slice(0, 80))
.filter(Boolean);
return { ok: true, deger: [...new Set(sonuc)].slice(0, ust) };
}
/** Modelin argümanını RevizyonKurali'na çevirir; il/alan adları doğrulanır. */
function revizyonKuraliKur(a: Record<string, unknown>): { ok: true; kural: RevizyonKurali } | { ok: false; hata: string } {
const kural: RevizyonKurali = {};
const iller = farkNormalize(a.iller, "iller", illeriNormalize);
if (!iller.ok) return iller;
if (iller.fark) kural.iller = iller.fark;
const kategoriler = farkNormalize(a.kategoriler, "kategoriler", kategorileriNormalize);
if (!kategoriler.ok) return kategoriler;
if (kategoriler.fark) kural.kategoriler = kategoriler.fark;
if (a.universiteTipi != null) kural.universiteTipi = a.universiteTipi as RevizyonKurali["universiteTipi"];
if (a.iskelet != null) kural.iskelet = a.iskelet as RevizyonKurali["iskelet"];
if (a.ilkTercihler != null) {
const o = a.ilkTercihler as Record<string, unknown>;
const adet = Math.round(Number(o.adet));
const il = illeriNormalize(o.iller, "ilkTercihler.iller");
if (!il.ok) return il;
const kat = kategorileriNormalize(o.kategoriler, "ilkTercihler.kategoriler");
if (!kat.ok) return kat;
const tip = o.universiteTipi as "devlet" | "vakif" | undefined;
if (!il.deger.length && !kat.deger.length && !tip) {
return { ok: false, hata: "ilkTercihler: en az bir ölçüt gerekli (iller, kategoriler ya da universiteTipi)" };
}
kural.ilkTercihler = {
adet: Math.min(24, Math.max(1, adet || 1)),
...(il.deger.length ? { iller: il.deger } : {}),
...(kat.deger.length ? { kategoriler: kat.deger } : {}),
...(tip ? { universiteTipi: tip } : {}),
};
}
if (a.cikar != null) {
const c = a.cikar as Record<string, unknown>;
const uni = metinListesi(c.universiteler, "cikar.universiteler");
const bol = metinListesi(c.bolumler, "cikar.bolumler");
const idler = metinListesi(c.programIdler, "cikar.programIdler", 24);
if (!uni.ok) return uni;
if (!bol.ok) return bol;
if (!idler.ok) return idler;
if (uni.deger.length || bol.deger.length || idler.deger.length) {
kural.cikar = {
...(uni.deger.length ? { universiteler: uni.deger } : {}),
...(bol.deger.length ? { bolumler: bol.deger } : {}),
...(idler.deger.length ? { programIdler: idler.deger } : {}),
};
}
}
if (a.ekle != null) {
const idler = metinListesi((a.ekle as Record<string, unknown>).programIdler, "ekle.programIdler", 10);
if (!idler.ok) return idler;
if (idler.deger.length) kural.ekle = { programIdler: idler.deger };
}
if (typeof a.not === "string" && a.not.trim()) kural.not = a.not.replace(/\s+/g, " ").trim().slice(0, 300);
const { not: _not, ...kalan } = kural;
void _not;
if (Object.keys(kalan).length === 0) {
return { ok: false, hata: "en az bir kural gerekli (iller, kategoriler, universiteTipi, iskelet, ilkTercihler, cikar ya da ekle); yalnız 'not' yetmez. İstek belirsizse soru_sor ile sor." };
}
return { ok: true, kural };
}
/**
* `liste_revize`: kuralı doğrular, listeyi yeniden kurdurur
* (features/rapor/liste-revizyon.ts — dinamik import: eval betikleri bu
* modülü Next dışında yükler, server-only bağımlılık orada eager yüklenmesin).
*/
async function listeRevize(b: AracBaglami, a: Record<string, unknown>): Promise<unknown> {
if (!b.hasPaket) {
return { hata: "Liste revizyonu Tercih Dönemi Paketi'ne dâhil; aday paketsiz. Nazikçe söyle: tam liste ve revizyon /paket ile açılır; açık satırlar üzerinden yardımcı olmaya devam et." };
}
if (!b.mesajId) return { hata: "Revizyon bu bağlamda çalıştırılamıyor." };
const kuralSonucu = revizyonKuraliKur(a);
if (!kuralSonucu.ok) return { hata: `Geçersiz kural — ${kuralSonucu.hata}. Düzeltip yeniden çağır.` };
const { listeRevizeCekirdek } = await import("@/features/rapor/liste-revizyon");
const sonuc = await listeRevizeCekirdek({
userId: b.userId,
requestId: b.mesajId,
kural: kuralSonucu.kural,
});
if (!sonuc.ok) {
return {
hata: sonuc.neden,
not:
sonuc.kod === "HAK"
? "Adaya revizyon hakkının dolduğunu söyle; sohbetten listesi hakkında soru sormaya devam edebilir."
: "Adaya listenin yeniden kurulamadığını ve nedenini söyle; 'yeniden kurdum' deme.",
};
}
return {
...sonuc.ozet,
not: "Liste ekranda yenilendi. Adaya ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi numaralı yaz; kalan revizyon hakkını belirt. Sayı uydurma; yukarıdaki alanları kullan.",
};
}
/**
* Argümanı aracın ARACLAR'daki şemasına göre denetler: bilinmeyen alan,
* yanlış tip, geçersiz seçenek, alt sınır, eksik zorunlu alan. Hata varsa
@@ -1548,6 +1839,10 @@ export function aracCalistirici(b: AracBaglami) {
return rehberOku(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
case "soru_sor":
return soruSor(a);
case "liste_revize":
return listeRevize(b, a);
default:
return { hata: `Bilinmeyen araç: ${ad}` };
}

View File

@@ -107,6 +107,25 @@ export function kayitBeyaniVar(metin: string): boolean {
export const KAYIT_DUZELTME_NOTU =
"Düzeltme: Bu cevapta profiline yeni bir kayıt YAPILMADI; yukarıda \"kaydettim\" dediysem henüz kaydedemedim. Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz.)";
/**
* Cevap "listeni yeniden kurdum / revize ettim" türü bir beyan içeriyor mu.
* Revizyon yalnız liste_revize aracıyla olur (7 Eki 2026); model aracı
* çağırmadan "güncelledim" diyebilir. Olumsuzlar ve sorular eşleşmez.
*/
const REVIZE_BEYANI =
/(?<!\p{L})(?:listeni (?:yeniden kurdum|güncelledim|revize ettim|değiştirdim|yeniledim)|liste(?:n)? (?:yeniden kuruldu|güncellendi|revize edildi|yenilendi)|revizyonu(?:nu)? (?:yaptım|tamamladım|uyguladım)|yeniden kurdum)(?!\p{L})/u;
const REVIZE_REDDI =
/(?<!\p{L})(?:kuramadım|kurulamadı|güncelleyemedim|güncellenemedi|revize edemedim|değiştiremedim|yenileyemedim|kurmadım|değiştirmedim)(?!\p{L})/u;
export function revizeBeyaniVar(metin: string): boolean {
const m = tr(metin);
return REVIZE_BEYANI.test(m) && !REVIZE_REDDI.test(m);
}
/** Cevap revizyon bildiriyor ama o turda başarılı liste_revize yoksa sonuna eklenir. */
export const REVIZE_DUZELTME_NOTU =
"Düzeltme: Bu cevapta listen YENİDEN KURULMADI; yukarıda \"yeniden kurdum\" dediysem henüz yapmadım. Ne değişsin istediğini bir cümleyle yazarsan listeni ona göre yeniden kurarım.";
/**
* İç şema sızıntısı belirtisi: cevapta araçların iç adlarından en az üçü ya
* da JSON şema anahtarları geçiyor (5 Eki 2026 eval s077: 11 aracın adı ve

View File

@@ -13,6 +13,8 @@ import { PUAN_TURLERI } from "@/types/yokatlas";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { adayDurumu, ozelDurumOzeti } from "@/lib/ozel-durum";
import { ONCEKI_YIL, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { kuralOzeti } from "@/lib/revizyon-kurali";
import { KATEGORILER } from "@/lib/kategoriler";
/**
* Profil kaydı öncesi "kaydedeyim mi?" onayı (KVKK Üretken YZ Rehberi:
@@ -64,6 +66,49 @@ programda baraj yok ya da veride yok — baraj UYDURMA. Ön lisans (TYT)
programlarında başarı sırası barajı yoktur.`;
}
/**
* Sohbetten liste revizyonu kuralları (7 Eki 2026). Eski "revize et" kutusu
* kaldırıldı; aday listesinde değişiklik istediğinde danışman önce isteği
* netleştirir (soru_sor formu), sonra liste_revize ile YAPISAL kural verir.
* Serbest metin kural değildir: "ilk 5 İstanbul olsun" → ilkTercihler.
*/
function revizyonBolumu(r: { kalanHak: number; paketli: boolean }, params: RaporParams): string {
const mevcut = kuralOzeti(params.revizyon);
const durum = !r.paketli
? `Adayın paketi YOK: liste_revize çağırma; listeyi değiştirmek isterse tam liste ve revizyonun Tercih Dönemi Paketi'yle (/paket) açıldığını sempatik biçimde söyle.`
: r.kalanHak <= 0
? `Adayın revizyon hakkı DOLDU (${r.kalanHak} kaldı): liste_revize çağırma; değişiklik isterse hakkın dolduğunu söyle, listesi hakkında soru cevaplamaya devam et.`
: `Adayın kalan revizyon hakkı: ${r.kalanHak}. Her başarılı liste_revize bir hak düşer; bu yüzden isteği tek seferde doğru kur.`;
return `LİSTE REVİZYONU (sohbetten): aday listesinde değişiklik istediğinde ("ilk 5
İstanbul olsun", "Ankara da olsun", "vakıf olmasın", "X üniversitesini çıkar",
"daha güvenli olsun", "şu programı ekle") şu sırayla çalış:
1. İstek NET ve tam ise (il adı belli, kaç tercih belli, ekle mi yalnız mı
belli) doğrudan liste_revize çağır. Karşılıklar: "ilk N tercih X ilinden"
→ ilkTercihler {adet: N, iller: [X]} (X sihirbaz illerinde yoksa iller.ekle
ile de ekle); "sadece X" → iller.yalniz; "X de olsun" → iller.ekle; "X
olmasın" → iller.cikar; "sadece devlet / vakıf olmasın" → universiteTipi;
"daha güvenli / garantili olsun" → iskelet guvenli-agirlikli; "daha iddialı"
→ iskelet hayal-agirlikli; "X üniversitesi / Y bölümü olmasın" → cikar; "şu
programı ekle" → önce program_ara ile kimliği bul, sonra ekle.programIdler.
İlgi alanı adları tam olarak şunlardan biri olmalı: ${KATEGORILER.join(", ")}.
2. İstek BELİRSİZ ise (il söylenmemiş, "ilk kaç" belli değil, ekle mi yalnız mı
belli değil, "daha iyi olsun" gibi) ÖNCE soru_sor ile 1-3 kısa soru sor;
seçenekleri adayın durumuna göre yaz (ör. sihirbaz illeri + "Başka il").
Form açıldıktan sonra başka araç çağırma, formu metinle tekrarlama, tek
kısa cümleyle bitir. Cevap "Form cevaplarım:" diye gelince liste_revize
çağır; tekrar sorma.
3. liste_revize ok dönünce: liste ekranda zaten yenilendi. Ne değiştiğini 2-3
cümleyle söyle, ilk 5 tercihi numaralı yaz (aracın verdiği adlarla), kalan
hakkı belirt. Dilim sırası "ilk N" kuralında bilerek bozulur; aday sorarsa
bunu söyle. Araç hata dönerse "yeniden kurdum" DEME; nedenini söyle.
4. REVİZYON DÜRÜSTLÜĞÜ: "listeni yeniden kurdum / güncelledim / revize ettim"
cümlesini ancak BU cevapta liste_revize çağırdıysan ve ok döndüyse yaz.
Sihirbaz seçimlerini (sıra, alan) değiştirmek bu aracın işi değil: aday
sırasını ya da alanını değiştirmek isterse "Tercihlerimi düzenle"
düğmesine yönlendir.
${durum}${mevcut.length ? `\nUYGULANMIŞ REVİZYON KURALLARI (önceki revizyonlardan, hâlâ geçerli): ${mevcut.join("; ")}.` : ""}`;
}
export function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
@@ -77,6 +122,11 @@ export function sistemPromptu(opts: {
* değerlendirme verilmez (liste gizli); uyarılar herkese.
*/
rapor?: { genelDegerlendirme?: string; uyarilar?: string[]; kapsam?: { toplam: number; uyarlandi: boolean } };
/**
* Sohbetten liste revizyonu (7 Eki 2026): kalan hak ve paket durumu.
* Verilmezse revizyon bölümü yazılmaz (eval betikleri eski davranışı korur).
*/
revizyon?: { kalanHak: number; paketli: boolean };
}): string {
const { params } = opts;
const secimVar =
@@ -173,8 +223,9 @@ KURALLAR:
Kendi genel bilginle kural, oran, tutar, katsayı ya da tarih YAZMA. Yazı
bulunmazsa "rehberimizde bu konuda yazı yok" de ve ÖSYM kılavuzuna ya da
ilgili kuruma yönlendir.
- ARAÇ KURALLARI: araçlar yalnız okur; 24'lük listeyi araçla kurmazsın,
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
- ARAÇ KURALLARI: sorgu araçları yalnız okur; 24'lük listeyi kendin
KURMAZSIN, satır ekleyip çıkarmazsın — listeyi değiştiren tek yol
liste_revize aracıdır (aşağıda). Araç sonucundaki sayıları olduğu gibi
aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır
(sınır: bir adımda 4, bir cevapta 8 çağrı; fazlası çalıştırılmaz).
Araç çağırmadan ÖNCE yazdığın metin ("İşte en iyi 10 üniversite:")
@@ -271,6 +322,7 @@ KURALLAR:
// TYT sırası lisans barajlarıyla kıyaslanmaz; ön lisansta baraj yok
const baraj = params.tur === "tyt" ? null : barajBolumu(params.sira, opts.barajlar);
if (baraj) parcalar.splice(2, 0, baraj);
if (opts.revizyon) parcalar.push(revizyonBolumu(opts.revizyon, params));
// Raporun üst metni: adayın listesini kuran yapay zekânın genel
// değerlendirmesi ve uyarıları — danışman "listem nasıl kuruldu?" sorusunu
// kendi raporundan cevaplar. Kilitli kullanıcıda genel değerlendirme gizli.

View File

@@ -24,7 +24,6 @@ import {
havuzuKompaktJson,
turLabel,
dilimHedefiHesapla,
idealHedef,
IDEAL_HEDEF,
type DilimHedef,
type Dilim,
@@ -40,6 +39,14 @@ import {
} from "../risk";
import { trendOzetiMetni } from "../trend";
import { ILK_YIL } from "../veri-yillari";
import {
cikarSuzgeci,
efektifParams,
ilkTercihleriOneAl,
kuralOzeti,
oncelikSuzgeci,
revizyonIskeleti,
} from "../revizyon-kurali";
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
@@ -364,7 +371,7 @@ function mockRapor(
revizyonNotu?: string,
): RaporSonuc {
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const efektif = (p: AdayProgram) => p.efektifSira ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
@@ -407,13 +414,17 @@ function mockRapor(
"(Dev mock) Güvenli dilimindeki son tercihleri asla silme.",
],
programlar,
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
devMock: true,
};
}
/** Modele giden havuz: dilim başına üç dizi; satırda `dilim` alanı yok (dizinin adı dilimdir). */
function havuzuDilimliJson(havuz: AdayProgram[]): string {
/** Modele giden havuz: dilim başına üç dizi; satırda `dilim` alanı yok (dizinin adı dilimdir).
* `oncelikli`: revizyonun "ilk N" ölçütüne uyan satırlar işaretlenir (yalnız kural varken). */
function havuzuDilimliJson(
havuz: AdayProgram[],
oncelikli?: ((p: AdayProgram) => boolean) | null,
): string {
const satirlar = JSON.parse(havuzuKompaktJson(havuz)) as ({
id: string;
dilim: Dilim;
@@ -423,7 +434,11 @@ function havuzuDilimliJson(havuz: AdayProgram[]): string {
dengeli: [],
guvenli: [],
};
for (const { dilim, ...kalan } of satirlar) gruplar[TEL_ADI[dilim]].push(kalan);
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
for (const { dilim, ...kalan } of satirlar) {
const p = oncelikli ? havuzMap.get(kalan.id as string) : undefined;
gruplar[TEL_ADI[dilim]].push(p && oncelikli!(p) ? { ...kalan, oncelikli: true } : kalan);
}
return JSON.stringify(gruplar);
}
@@ -482,7 +497,17 @@ export async function raporUretAyrintili(
oncekiListeOzeti?: string,
profil?: DanismanProfili,
): Promise<{ rapor: RaporSonuc; tani: RaporTani | null }> {
const { havuz, gevsetildi } = havuzOlustur(params, profil);
// Sohbet revizyonu (revizyon-kurali.ts): il/alan/tip farkı sihirbaz seçiminin
// üstüne biner, "çıkar" havuza girmez, "ilk N" ölçütü ve "ekle" kimlikleri
// kırpmada öne alınır. Kayıtlı params değişmez; yalnız üretim bu kopyayı görür.
const kural = params.revizyon;
const havuzParams = efektifParams(params);
const oncelikli = oncelikSuzgeci(kural);
const { havuz, gevsetildi } = havuzOlustur(havuzParams, profil, {
cikar: cikarSuzgeci(kural) ?? undefined,
oncelikli: oncelikli ?? undefined,
ekstraIdler: kural?.ekle?.programIdler,
});
const profilSatir = profil ? profilSatirlari(profil) : [];
if (havuz.length < MIN_LISTE) {
throw new RaporUretimHatasi(
@@ -494,15 +519,17 @@ export async function raporUretAyrintili(
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
// üretim her seferinde doğrulamada patlıyordu. İskelet "Kolay yerleşme"
// seçiliyse 4/12/8 (idealHedef).
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.ozelDurum?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
(havuzParams.kategoriler?.length ?? 0) > 0 ||
(havuzParams.iller?.length ?? 0) > 0 ||
(havuzParams.oncelikler?.length ?? 0) > 0 ||
(havuzParams.ozelDurum?.length ?? 0) > 0 ||
(havuzParams.universiteTipi && havuzParams.universiteTipi !== "farketmez");
const kuralSatirlari = kuralOzeti(kural);
const ilkN = kural?.ilkTercihler;
const gevsetmeNotu: string[] = [];
if (gevsetildi.includes("profil")) {
@@ -530,13 +557,20 @@ export async function raporUretAyrintili(
const kullaniciMesaji = [
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
secimVar ? `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
kategoriler: params.kategoriler ?? [],
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
ozelDurum: params.ozelDurum ?? [],
kategoriler: havuzParams.kategoriler ?? [],
iller: havuzParams.iller ?? [],
universiteTipi: havuzParams.universiteTipi ?? "farketmez",
oncelikler: havuzParams.oncelikler ?? [],
ozelDurum: havuzParams.ozelDurum ?? [],
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
: null,
kuralSatirlari.length > 0
? `ADAYIN SOHBETTE İSTEDİĞİ REVİZYON KURALLARI (koddan uygulandı; havuz buna göre kuruldu):\n${kuralSatirlari.map((k) => `- ${k}`).join("\n")}${
ilkN && oncelikli
? `\nHavuzda "oncelikli": true olan satırlar "ilk ${ilkN.adet}" ölçütüne uyar: hayal ve dengeli listelerinde bunlardan toplam EN AZ ${ilkN.adet} tane seç; kod bunları listenin başına alacak.`
: ""
}`
: null,
params.notlar
? `Adayın ek notları: <aday_notu>${kullaniciMetni(params.notlar)}</aday_notu>`
: null,
@@ -547,7 +581,7 @@ export async function raporUretAyrintili(
revizyonNotu
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: <revizyon_istegi>${kullaniciMetni(revizyonNotu)}</revizyon_istegi>\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
: null,
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz)}`,
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz, oncelikli)}`,
]
.filter(Boolean)
.join("\n\n");
@@ -558,11 +592,12 @@ export async function raporUretAyrintili(
return p.efektifSira ?? Number.MAX_SAFE_INTEGER;
};
// Adayın seçimine uyum (tamamlama önceliği ve şablon gerekçe için)
const seciliKategoriler = params.kategoriler ?? [];
// Adayın seçimine uyum (tamamlama önceliği ve şablon gerekçe için);
// revizyonla değişen il/alan da buradan okunur
const seciliKategoriler = havuzParams.kategoriler ?? [];
const seciliIller = [
...(params.il ? [params.il] : []),
...(params.iller ?? []),
...(havuzParams.il ? [havuzParams.il] : []),
...(havuzParams.iller ?? []),
];
const alanUyar = (p: AdayProgram) =>
seciliKategoriler.length > 0 &&
@@ -571,7 +606,10 @@ export async function raporUretAyrintili(
seciliIller.length > 0 && p.il != null && seciliIller.includes(p.il);
// Alan 2, il 1, uyum puanı 0-1: tamamlama ve kırpma önce seçime uyana, eşitte
// uyum puanı yüksek olana gider
const uyum = (p: AdayProgram) => (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
// Revizyonun "ilk N" ölçütüne uyan program her şeyin önünde (+4): kırpma ve
// tamamlama adayın açık isteğini korur
const uyum = (p: AdayProgram) =>
(oncelikli?.(p) ? 4 : 0) + (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
// Onarım modelin seçiminin en az yarısını koruyabiliyorsa ikinci çağrı
// yapılmaz; altındaysa liste çoğunlukla kodla dolardı, model bir kez daha denenir.
@@ -808,7 +846,7 @@ export async function raporUretAyrintili(
// Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
const tercihler = DILIMLER.flatMap((dilim) =>
const dilimSirali = DILIMLER.flatMap((dilim) =>
[...nihai.secim[dilim]]
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
.map((t) => ({ dilim, satir: t })),
@@ -824,13 +862,15 @@ export async function raporUretAyrintili(
...(p.oneCikan?.length ? { oneCikan: p.oneCikan } : {}),
};
});
// Revizyonun "ilk N" kuralı: ölçüte uyanlar başa (revizyon-kurali.ts)
const { tercihler } = ilkTercihleriOneAl(dilimSirali, kural, (id) => havuzMap.get(id));
return {
rapor: {
tercihler,
genelDegerlendirme: genel,
uyarilar,
programlar,
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
},
tani: {
kaynak,

View File

@@ -24,7 +24,14 @@ import {
} from "ai";
import { ARACLAR, aracSeti, type AracBaglami } from "./araclar";
import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
import { KAYIT_DUZELTME_NOTU, kayitBeyaniVar, semaSizintisiVar, vaatDiliBul } from "./cikti-denetimi";
import {
KAYIT_DUZELTME_NOTU,
REVIZE_DUZELTME_NOTU,
kayitBeyaniVar,
revizeBeyaniVar,
semaSizintisiVar,
vaatDiliBul,
} from "./cikti-denetimi";
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
@@ -40,6 +47,12 @@ const ADIM_BASINA_ARAC = 4;
const CEVAP_BASINA_ARAC = 8;
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
const ARAC_ADLARI = ARACLAR.flatMap((a) => (a.type === "function" ? [a.function.name] : []));
/**
* liste_revize liste üretimini çalıştırır (iki model denemesine kadar, ~20-60
* sn); sohbetin 120 sn'lik toplam süresi ona yetmeyebilir. Araç başlayınca
* zaman aşımı bu kadar ileri alınır (features/rapor/liste-revizyon.ts).
*/
const REVIZYON_EK_SURE_MS = 150_000;
export type CiktiDenetimi = {
/** Jev'in ihlal olasılığı; denetim yapılamadıysa null. */
@@ -52,6 +65,8 @@ export type CiktiDenetimi = {
reddedilenArac: number;
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
kayitNotu: boolean;
/** "Yeniden kurdum" dendi ama başarılı liste_revize yoktu; düzeltme notu eklendi. */
revizeNotu: boolean;
/** Cevapta araç adları / şema anahtarları var (yalnız gözlem). */
semaSizintisi: boolean;
};
@@ -68,6 +83,11 @@ export function danismanAkisi(opts: {
zamanAsimiMs?: number;
/** Çıktı denetiminin sonucu (gözlem/eval için; cevabı etkilemez). */
onDenetim?: (d: CiktiDenetimi) => void;
/**
* Her başarılı araç sonucu (ad, çıktı). Route bununla soru formunu ve
* revizyon sonucunu kayda (chat_messages.meta) yazar.
*/
onAracSonucu?: (ad: string, cikti: unknown) => void;
}): ReadableStream<UIMessageChunk> {
return createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
@@ -76,12 +96,19 @@ export function danismanAkisi(opts: {
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
let akisHatasi: string | null = null;
// Sağlayıcı takılırsa SDK akışı "abort" parçasıyla sessizce bitirir
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır.
const zamanAsimi = AbortSignal.timeout(opts.zamanAsimiMs ?? 120_000);
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır. Süre
// uzatılabilir olsun diye AbortSignal.timeout yerine zamanlayıcı.
const kesici = new AbortController();
let sure = opts.zamanAsimiMs ?? 120_000;
let zamanlayici = setTimeout(() => kesici.abort(), sure);
const zamanAsimi = kesici.signal;
let adimdakiArac = 0;
let toplamArac = 0;
let reddedilenArac = 0;
let basariliKayit = 0;
let basariliRevizyon = 0;
// soru_sor çağrıldı: form adayda; model aynı cevapta başka araç çağırmasın
let formAcildi = false;
try {
const result = streamText({
model: sohbetDilModeli(),
@@ -89,19 +116,36 @@ export function danismanAkisi(opts: {
messages: opts.mesajlar,
tools: aracSeti(
opts.aracBaglami,
(ad) => console.info("[soru] araç:", ad),
() => {
(ad) => {
console.info("[soru] araç:", ad);
if (ad === "liste_revize") {
clearTimeout(zamanlayici);
sure += REVIZYON_EK_SURE_MS;
zamanlayici = setTimeout(() => kesici.abort(), sure);
}
},
(ad) => {
adimdakiArac++;
toplamArac++;
if (formAcildi) {
reddedilenArac++;
return "Soru formu adayda; cevap gelene kadar başka araç çağrılmaz. Bu çağrı ÇALIŞTIRILMADI. Tek kısa cümleyle bitir.";
}
if (ad === "liste_revize" && basariliRevizyon > 0) {
reddedilenArac++;
return "Liste bu cevapta zaten yeniden kuruldu; ikinci revizyon ÇALIŞTIRILMADI. Sonucu adaya anlat.";
}
if (adimdakiArac <= ADIM_BASINA_ARAC && toplamArac <= CEVAP_BASINA_ARAC) return null;
reddedilenArac++;
return `Araç çağrısı sınırı aşıldı (bir adımda en fazla ${ADIM_BASINA_ARAC}, bir cevapta en fazla ${CEVAP_BASINA_ARAC}). Bu çağrı ÇALIŞTIRILMADI. Yeni araç çağırma; elindeki sonuçlarla cevap ver, eksik kalan kısmı adaya açıkça söyle ve sorusunu daraltmasını iste. Çalışmayan çağrı için sayı uydurma.`;
},
(ad, cikti) => {
const nesne = cikti != null && typeof cikti === "object" ? (cikti as Record<string, unknown>) : null;
// Başarılı kayıt = profil_guncelle "kaydedildi" döndü (hata dönerse sayılmaz)
if (ad === "profil_guncelle" && cikti != null && typeof cikti === "object" && "kaydedildi" in cikti) {
basariliKayit++;
}
if (ad === "profil_guncelle" && nesne && "kaydedildi" in nesne) basariliKayit++;
if (ad === "liste_revize" && nesne?.ok === true) basariliRevizyon++;
if (ad === "soru_sor" && nesne?.soruldu === true) formAcildi = true;
if (nesne && !("hata" in nesne)) opts.onAracSonucu?.(ad, cikti);
},
),
// En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
@@ -109,7 +153,8 @@ export function danismanAkisi(opts: {
stopWhen: isStepCount(ARAC_TUR_SINIRI),
prepareStep: ({ stepNumber }) => {
adimdakiArac = 0;
return stepNumber >= ARAC_TUR_SINIRI - 1 ? { toolChoice: "none" } : undefined;
// Form açıldıysa model cevabı bitirmek zorunda (araç kapalı)
return stepNumber >= ARAC_TUR_SINIRI - 1 || formAcildi ? { toolChoice: "none" } : undefined;
},
maxOutputTokens: 1024,
abortSignal: zamanAsimi,
@@ -184,11 +229,22 @@ export function danismanAkisi(opts: {
writer.write({ type: "text-delta", id, delta: KAYIT_DUZELTME_NOTU });
writer.write({ type: "text-end", id });
}
// Revizyon beyanı denetimi: "yeniden kurdum" ama bu turda başarılı
// liste_revize yok → düzeltme notu (kayıt denetimiyle aynı mantık).
const revizeNotu = basariliRevizyon === 0 && revizeBeyaniVar(metin);
if (revizeNotu) {
console.info("[soru] revizyon beyanı var, başarılı liste_revize yok → not eklendi");
const id = crypto.randomUUID();
metin += `\n\n${REVIZE_DUZELTME_NOTU}`;
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: REVIZE_DUZELTME_NOTU });
writer.write({ type: "text-end", id });
}
// İç şema sızıntısı: yalnız gözlem (cevap geri alınamaz). Metin loglanmaz.
const semaSizintisi = semaSizintisiVar(metin, ARAC_ADLARI);
if (semaSizintisi) console.warn("[soru] olası iç şema sızıntısı: cevapta araç adları / şema anahtarları var");
if (reddedilenArac) console.info("[soru] araç sınırı:", reddedilenArac, "çağrı çalıştırılmadı");
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, semaSizintisi });
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, revizeNotu, semaSizintisi });
await opts.onTamam(metin);
tamamlandi = true;
writer.write({ type: "finish" });
@@ -199,6 +255,8 @@ export function danismanAkisi(opts: {
// Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa
// (boş cevap, kayıt hatası) istemci hatayı buradan görür.
if (akisHatasi == null) throw err;
} finally {
clearTimeout(zamanlayici);
}
},
onError: () => HATA_METNI,

192
src/lib/ai/sohbet-meta.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* Danışman sohbetinin yapısal mesaj ekleri (chat_messages.meta).
*
* Sohbet kaydı düz metindir; iki tur bunun dışına taşar ve burada tiplenir:
* - `anket`: danışmanın `soru_sor` aracıyla adaya gösterdiği soru formu
* (lib/ai/araclar.ts). Ekranda shadcn Questionnaire ile çizilir
* (features/rapor/components/sohbet-anketi.tsx); sayfa yenilenince de aynı
* form kayıttan geri gelir.
* - `anketCevabi`: adayın forma verdiği cevaplar (kullanıcı mesajı). Bu mesaj
* kredi harcamaz (api/soru/route.ts: formun cevabı, profil onayı gibi
* bedelsizdir).
* - `revizyon`: `liste_revize` aracının sonucu (liste yeniden kuruldu / kurulamadı).
*
* Saf modül: DB'ye ve Next'e dokunmaz; istemci ve sunucu birlikte okur.
*/
export type AnketSecenek = {
deger: string;
etiket: string;
aciklama?: string;
};
export type AnketSoru = {
/** Form alanı adı (cevap anahtarı); harf, rakam, tire. */
ad: string;
soru: string;
aciklama?: string;
secenekler: AnketSecenek[];
/** Birden çok seçenek işaretlenebilir. */
coklu?: boolean;
/** Serbest metin cevabı da alınır; değer yer tutucu metindir. */
serbest?: string;
/** Boş geçilemez (varsayılan: zorunlu). */
zorunlu?: boolean;
};
export type Anket = {
id: string;
baslik?: string;
sorular: AnketSoru[];
};
export type AnketCevaplari = Record<string, string | string[]>;
export type RevizyonOzeti =
| {
ok: true;
toplam: number;
dagilim: { hayal: number; dengeli: number; garanti: number };
kalanHak: number;
ilkTercihler: string[];
}
| { ok: false; neden: string };
export type SohbetMeta = {
anket?: Anket;
anketCevabi?: { anketId: string; cevaplar: AnketCevaplari };
revizyon?: RevizyonOzeti;
};
export const ANKET_EN_COK_SORU = 3;
export const ANKET_EN_COK_SECENEK = 6;
const AD_KALIBI = /^[a-z0-9-]{1,32}$/;
const METIN_UST = 160;
function kisa(s: unknown, ust = METIN_UST): string | null {
if (typeof s !== "string") return null;
const m = s.replace(/\s+/g, " ").trim();
return m ? m.slice(0, ust) : null;
}
/**
* Modelin `soru_sor` argümanını denetleyip temizler; hata varsa metin döner
* (model düzeltip yeniden çağırabilsin). Soru ve seçenek sayısı sınırlı: form
* sohbet balonunda tek tek gösterilir, uzun anket sohbeti boğar.
*/
export function anketDogrula(
girdi: unknown,
): { ok: true; anket: Omit<Anket, "id"> } | { ok: false; hata: string } {
if (typeof girdi !== "object" || girdi === null) return { ok: false, hata: "nesne bekleniyor" };
const g = girdi as Record<string, unknown>;
const baslik = kisa(g.baslik, 80) ?? undefined;
if (!Array.isArray(g.sorular) || g.sorular.length === 0) {
return { ok: false, hata: "sorular: en az bir soru gerekli" };
}
if (g.sorular.length > ANKET_EN_COK_SORU) {
return { ok: false, hata: `sorular: en fazla ${ANKET_EN_COK_SORU} soru` };
}
const sorular: AnketSoru[] = [];
const adlar = new Set<string>();
for (const [i, ham] of g.sorular.entries()) {
if (typeof ham !== "object" || ham === null) return { ok: false, hata: `sorular[${i}]: nesne bekleniyor` };
const s = ham as Record<string, unknown>;
const ad = typeof s.ad === "string" ? s.ad.trim().toLocaleLowerCase("en-US") : "";
if (!AD_KALIBI.test(ad)) return { ok: false, hata: `sorular[${i}].ad: yalnız küçük harf, rakam ve tire (en çok 32)` };
if (adlar.has(ad)) return { ok: false, hata: `sorular[${i}].ad: "${ad}" tekrar ediyor` };
adlar.add(ad);
const soru = kisa(s.soru);
if (!soru) return { ok: false, hata: `sorular[${i}].soru: boş` };
if (!Array.isArray(s.secenekler) || s.secenekler.length < 2) {
return { ok: false, hata: `sorular[${i}].secenekler: en az 2 seçenek` };
}
if (s.secenekler.length > ANKET_EN_COK_SECENEK) {
return { ok: false, hata: `sorular[${i}].secenekler: en fazla ${ANKET_EN_COK_SECENEK} seçenek` };
}
const secenekler: AnketSecenek[] = [];
const degerler = new Set<string>();
for (const [j, sh] of s.secenekler.entries()) {
if (typeof sh !== "object" || sh === null) return { ok: false, hata: `sorular[${i}].secenekler[${j}]: nesne bekleniyor` };
const o = sh as Record<string, unknown>;
const deger = kisa(o.deger, 64);
const etiket = kisa(o.etiket, 80);
if (!deger || !etiket) return { ok: false, hata: `sorular[${i}].secenekler[${j}]: deger ve etiket gerekli` };
if (degerler.has(deger)) return { ok: false, hata: `sorular[${i}].secenekler[${j}].deger: "${deger}" tekrar ediyor` };
degerler.add(deger);
const aciklama = kisa(o.aciklama, 120) ?? undefined;
secenekler.push({ deger, etiket, ...(aciklama ? { aciklama } : {}) });
}
const aciklama = kisa(s.aciklama) ?? undefined;
const serbest = kisa(s.serbest, 80) ?? undefined;
sorular.push({
ad,
soru,
...(aciklama ? { aciklama } : {}),
secenekler,
...(s.coklu === true ? { coklu: true } : {}),
...(serbest ? { serbest } : {}),
...(s.zorunlu === false ? { zorunlu: false } : {}),
});
}
return { ok: true, anket: { ...(baslik ? { baslik } : {}), sorular } };
}
/**
* İstemciden gelen cevapları forma göre süzer: bilinmeyen alan atılır, seçenek
* dışı değer yalnız `serbest` sorularda kabul edilir, boş cevap düşer.
*/
export function anketCevabiDogrula(anket: Anket, ham: unknown): AnketCevaplari | null {
if (typeof ham !== "object" || ham === null) return null;
const h = ham as Record<string, unknown>;
const cevaplar: AnketCevaplari = {};
for (const soru of anket.sorular) {
const v = h[soru.ad];
const secenek = new Set(soru.secenekler.map((s) => s.deger));
const temiz = (x: unknown): string | null => {
const m = kisa(x, 200);
if (!m) return null;
if (secenek.has(m)) return m;
return soru.serbest ? m : null;
};
if (soru.coklu) {
const liste = (Array.isArray(v) ? v : [v]).map(temiz).filter((x): x is string => x != null);
if (liste.length) cevaplar[soru.ad] = [...new Set(liste)];
} else {
const tek = temiz(Array.isArray(v) ? v[0] : v);
if (tek) cevaplar[soru.ad] = tek;
}
}
return cevaplar;
}
function secenekEtiketi(soru: AnketSoru, deger: string): string {
return soru.secenekler.find((s) => s.deger === deger)?.etiket ?? deger;
}
/**
* Cevapların okunur hâli: adayın balonunda ve modele giden mesajda aynı metin
* ("Hangi il? → İstanbul · Kaç tercih? → İlk 5").
*/
export function anketCevabiMetni(anket: Anket, cevaplar: AnketCevaplari): string {
const satirlar = anket.sorular.map((soru) => {
const v = cevaplar[soru.ad];
if (v == null || (Array.isArray(v) && v.length === 0)) return `${soru.soru} → (boş geçtim)`;
const etiket = Array.isArray(v)
? v.map((d) => secenekEtiketi(soru, d)).join(", ")
: secenekEtiketi(soru, v);
return `${soru.soru} → ${etiket}`;
});
return `Form cevaplarım:\n${satirlar.map((s) => `- ${s}`).join("\n")}`;
}
/**
* Formun modele giden kısa dökümü: kayıtlı asistan metni formu içermez; model
* cevapları neye verildiğini buradan görür (api/soru/route.ts model penceresi).
*/
export function anketModelMetni(anket: Anket): string {
const satirlar = anket.sorular.map(
(s) =>
`- ${s.ad}: "${s.soru}" (${s.secenekler.map((o) => `${o.deger} = ${o.etiket}`).join("; ")}${s.serbest ? "; serbest metin de olabilir" : ""})`,
);
return `[Adaya gösterilen soru formu${anket.baslik ? ` — ${anket.baslik}` : ""}:\n${satirlar.join("\n")}]`;
}

View File

@@ -1,3 +1,4 @@
import type { SohbetMeta } from "@/lib/ai/sohbet-meta";
import {
sqliteTable,
text,
@@ -145,6 +146,9 @@ export const chatMessages = sqliteTable(
role: text("role", { enum: ["user", "assistant"] }).notNull(),
content: text("content").notNull(),
clientMessageId: text("client_message_id").unique(),
// Yapısal ek (7 Eki 2026): soru formu, form cevabı, liste revizyon sonucu
// (lib/ai/sohbet-meta.ts). Düz metin mesajda null.
meta: text("meta", { mode: "json" }).$type<SohbetMeta>(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],

View File

@@ -1,5 +1,6 @@
import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import type { SohbetMeta } from "./ai/sohbet-meta";
const { user, creditLedger, chatMessages } = schema;
@@ -99,6 +100,7 @@ export async function spendCreditForMessage(opts: {
userId: string;
content: string;
clientMessageId: string;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
@@ -117,6 +119,7 @@ export async function spendCreditForMessage(opts: {
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
meta: opts.meta ?? null,
createdAt: new Date(),
});
await tx.insert(creditLedger).values({
@@ -138,14 +141,16 @@ export async function spendCreditForMessage(opts: {
}
/**
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — yalnız profil onayı ("kaydedeyim
* mi?" → "evet") için (Bilal kararı, 26 Eyl 2026: onay ücretsiz). Ledger'a
* satır yazılmaz; clientMessageId çakışırsa DUPLICATE.
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — profil onayı ("kaydedeyim mi?"
* → "evet"; Bilal kararı, 26 Eyl 2026: onay ücretsiz) ve danışmanın soru
* formuna verilen cevap (7 Eki 2026) için. Ledger'a satır yazılmaz;
* clientMessageId çakışırsa DUPLICATE.
*/
export async function bedelsizMesajKaydet(opts: {
userId: string;
content: string;
clientMessageId: string;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
@@ -155,6 +160,7 @@ export async function bedelsizMesajKaydet(opts: {
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
meta: opts.meta ?? null,
createdAt: new Date(),
});
return { ok: true, messageId };

View File

@@ -177,6 +177,13 @@ function dilimBasinaKirp(havuz: AdayProgram[], n: number): AdayProgram[] {
);
}
/** Revizyonun "ekle" kimlikleri: dilim evreninde bulunanlar, katman 0 ile. */
function ekstraSec(evren: AdayProgram[], idler?: readonly string[]): AdayProgram[] {
if (!idler?.length) return [];
const iste = new Set(idler);
return evren.filter((p) => iste.has(p.id));
}
/**
* Katmanları öncelik sırasıyla, tekrarsız birleştirir. Gevşetmede kullanılır:
* gevşeyen filtreye UYAN programlar önce gelir ve kırpmada korunur, kalan
@@ -271,9 +278,24 @@ function dilimEkle(r: {
* Adayın yazamayacağı programlar hiçbir basamakta havuza girmez: başarı
* sırası barajının gerisinde kaldığı bölümler ve özel kontenjan satırları.
*/
/**
* Sohbet revizyonunun havuza dokunan kısmı (lib/ai/rapor.ts kurar):
* `cikar` eşleşenler hiçbir katmana girmez; `oncelikli` eşleşenler kırpmadan
* önce dilim içinde öne alınır ("ilk 5 İstanbul olsun" için İstanbul havuzda
* kalsın); `ekstraIdler` adayın açıkça istediği programlar — sihirbaz
* süzgecine takılsa bile (baraj ve özel kontenjan kuralı yine geçer) havuza
* kendi dilimiyle eklenir.
*/
export type HavuzSecenekleri = {
cikar?: (p: AdayProgram) => boolean;
oncelikli?: (p: AdayProgram) => boolean;
ekstraIdler?: readonly string[];
};
export function havuzOlustur(
params: RaporParams,
profil?: DanismanProfili,
secenekler: HavuzSecenekleri = {},
): HavuzSonuc {
const uniturGrubu =
params.universiteTipi && params.universiteTipi !== "farketmez"
@@ -307,7 +329,39 @@ export function havuzOlustur(
const adaylar = (ozellik?: OzellikSuzgeci) =>
dilimEkle(
havuzAdaylari(params.sira, params.tur, { uniturGrubu, ozellik }),
).filter((p) => !ozelKontenjanSatiriMi(p.isim));
).filter(
(p) => !ozelKontenjanSatiriMi(p.isim) && !(secenekler.cikar?.(p) ?? false),
);
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS). Süzgeçsiz
// dilim evreni (ilsizHam) revizyonun "ekle" kimliklerinin de kaynağıdır.
const ilsizHam = adaylar();
const ekstra = ekstraSec(ilsizHam, secenekler.ekstraIdler);
// Revizyon önceliği gevşetme katmanından sonra, uyum puanından önce gelir:
// kararlı sıralama, eşleşenler dilim içinde öne (kırpma öndekileri tutar).
const oncelikle = (havuz: AdayProgram[]) =>
secenekler.oncelikli
? [...havuz].sort(
(a, b) =>
(a.katman ?? 0) - (b.katman ?? 0) ||
Number(secenekler.oncelikli!(b)) - Number(secenekler.oncelikli!(a)),
)
: havuz;
// Ekstralar katman 0 ile başa; ötekilerin gevşetme katmanı korunur
const ekstraIdKumesi = new Set(ekstra.map((p) => p.id));
const kirpVe = (havuz: AdayProgram[]) =>
dilimBasinaKirp(
oncelikle(
uyumlaSirala(
[
...ekstra.map((p) => ({ ...p, katman: 0 })),
...havuz.filter((p) => !ekstraIdKumesi.has(p.id)),
],
baglam,
),
),
PROMPT_LIMIT_DILIM,
);
const profilGevsetildi: Gevsetilen[] = [];
// Profil gevşerse profile uyan (24'ten az) programlar yine en önde tutulur
@@ -320,22 +374,17 @@ export function havuzOlustur(
profilliFiltreli = kategoriFiltre(profilliHam);
if (profilliFiltreli.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(uyumlaSirala(profilliFiltreli, baglam), PROMPT_LIMIT_DILIM),
havuz: kirpVe(profilliFiltreli),
gevsetildi: [],
};
}
profilGevsetildi.push("profil");
}
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS)
const ilsizHam = adaylar();
const tamHam = ilFiltre(ilsizHam, iller);
const tamFiltreli = kategoriFiltre(tamHam);
const kirp = (...katmanlar: AdayProgram[][]) =>
dilimBasinaKirp(
uyumlaSirala(oncelikliBirlestir(profilliFiltreli, ...katmanlar), baglam),
PROMPT_LIMIT_DILIM,
);
kirpVe(oncelikliBirlestir(profilliFiltreli, ...katmanlar));
if (tamFiltreli.length >= HEDEF) {
return { havuz: kirp(tamFiltreli), gevsetildi: profilGevsetildi };
}

220
src/lib/revizyon-kurali.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Sohbetten gelen liste revizyonu kurallarının (RevizyonKurali) saf
* uygulayıcıları. Eski revizyon yolu serbest metni modele "istek" olarak
* veriyordu; sıra ve havuz koddan belirlendiği için "ilk 5 İstanbul olsun"
* gibi isteklerin karşılığı yoktu (geri bildirim, 7 Eki 2026). Şimdi kural
* yapısaldır ve üç yerde koddan uygulanır:
* 1. Havuz: il / alan / üniversite tipi farkı sihirbaz seçiminin üstüne
* biner (`efektifParams`), "çıkar" havuza hiç girmez, "ilk N" ölçütü
* ve "ekle" kimlikleri kırpmada öne alınır (rapor-havuzu.ts).
* 2. Seçim: iskelet dilim hedefini değiştirir; tamamlama/kırpma ölçüte
* uyanı önceler (lib/ai/rapor.ts).
* 3. Sıra: liste kurulduktan sonra "ilk N" ölçütüne uyanlar başa alınır
* (`ilkTercihleriOneAl`).
*
* Saf modül: DB'ye ve Next'e dokunmaz; sunucu ve betikler birlikte kullanır.
*/
import type { RaporParams, RaporTercih, RevizyonKurali, ListeFarki } from "@/features/rapor/types/rapor";
import type { AdayProgram, DilimHedef } from "./rapor-havuzu";
import { IDEAL_HEDEF, IDEAL_HEDEF_KOLAY_YERLESME, idealHedef } from "./rapor-havuzu";
import { kategoriEslesir } from "./kategoriler";
import { uniturDevletMi } from "./unitur";
import type { UniturGrubu } from "@/types/yokatlas";
/** Hayal ağırlıklı iskelet: 7 hayal / 12 dengeli / 5 güvenli (toplam 24). */
export const IDEAL_HEDEF_HAYAL_AGIRLIKLI: DilimHedef = { hayal: 7, dengeli: 12, garanti: 5 };
const tr = (s: string) => s.toLocaleLowerCase("tr-TR").trim();
function farkUygula(taban: readonly string[], fark: ListeFarki | undefined): string[] {
if (!fark) return [...taban];
let liste = fark.yalniz ? [...fark.yalniz] : [...taban];
if (fark.ekle) liste = [...liste, ...fark.ekle];
if (fark.cikar) {
const cikar = new Set(fark.cikar.map(tr));
liste = liste.filter((x) => !cikar.has(tr(x)));
}
return [...new Set(liste)];
}
/**
* Havuzun kurulacağı etkin parametreler: sihirbaz seçimi + revizyon farkı.
* Kayıtlı `params` değişmez (fark ekranı sihirbaz seçimine bakar); yalnız
* üretim bu kopyayı görür.
*/
export function efektifParams(params: RaporParams): RaporParams {
const k = params.revizyon;
if (!k) return params;
const sonuc: RaporParams = { ...params };
if (k.iller) {
sonuc.iller = farkUygula(params.iller ?? (params.il ? [params.il] : []), k.iller);
if (k.iller.yalniz) delete sonuc.il;
}
if (k.kategoriler) sonuc.kategoriler = farkUygula(params.kategoriler ?? [], k.kategoriler);
if (k.universiteTipi) sonuc.universiteTipi = k.universiteTipi;
// "İlk N" ölçütü sihirbaz süzgecinin dışındaysa havuz o programları hiç
// görmezdi (7 Eki 2026 denemesi: sihirbazda Ankara/İzmir, istek "ilk 5
// İstanbul" → model iller.ekle vermedi, listede İstanbul çıkmadı). Ölçütün
// ili/alanı süzgece eklenir, tip çelişiyorsa süzgeç gevşer; model bunu
// hatırlamak zorunda kalmaz.
const o = k.ilkTercihler;
if (o) {
const iller = sonuc.iller ?? (sonuc.il ? [sonuc.il] : []);
if (o.iller?.length && iller.length > 0) {
const var_ = new Set(iller.map(tr));
const eksik = o.iller.filter((il) => !var_.has(tr(il)));
if (eksik.length) sonuc.iller = [...iller, ...eksik];
}
if (o.kategoriler?.length && (sonuc.kategoriler?.length ?? 0) > 0) {
const var_ = new Set(sonuc.kategoriler!.map(tr));
const eksik = o.kategoriler.filter((kat) => !var_.has(tr(kat)));
if (eksik.length) sonuc.kategoriler = [...sonuc.kategoriler!, ...eksik];
}
if (
o.universiteTipi &&
sonuc.universiteTipi &&
sonuc.universiteTipi !== "farketmez" &&
sonuc.universiteTipi !== o.universiteTipi
) {
sonuc.universiteTipi = "farketmez";
}
}
return sonuc;
}
/** Revizyonun iskelet seçimi; yoksa sihirbaz önceliğine göre varsayılan. */
export function revizyonIskeleti(params: RaporParams): DilimHedef {
switch (params.revizyon?.iskelet) {
case "guvenli-agirlikli":
return IDEAL_HEDEF_KOLAY_YERLESME;
case "hayal-agirlikli":
return IDEAL_HEDEF_HAYAL_AGIRLIKLI;
case "standart":
return IDEAL_HEDEF;
default:
return idealHedef(params.oncelikler);
}
}
type Olcut = NonNullable<RevizyonKurali["ilkTercihler"]>;
/** Program "ilk N" ölçütüne uyuyor mu (verilen her ölçüt birlikte sağlanmalı). */
export function olcuteUyar(
p: Pick<AdayProgram, "isim" | "il" | "unitur" | "fakulte">,
olcut: Pick<Olcut, "iller" | "kategoriler" | "universiteTipi">,
): boolean {
if (olcut.iller?.length) {
const iller = new Set(olcut.iller.map(tr));
if (!p.il || !iller.has(tr(p.il))) return false;
}
if (olcut.kategoriler?.length && !kategoriEslesir(p.isim, olcut.kategoriler, p.fakulte)) {
return false;
}
if (olcut.universiteTipi) {
const devlet = uniturDevletMi(p.unitur);
if ((olcut.universiteTipi === "devlet") !== devlet) return false;
}
return true;
}
function olcutBos(o: Pick<Olcut, "iller" | "kategoriler" | "universiteTipi"> | undefined): boolean {
return !o || (!o.iller?.length && !o.kategoriler?.length && !o.universiteTipi);
}
/** Havuz kırpmasında ve seçim tamamlamasında öncelenecek program süzgeci; ölçüt yoksa null. */
export function oncelikSuzgeci(k: RevizyonKurali | undefined): ((p: AdayProgram) => boolean) | null {
const o = k?.ilkTercihler;
if (!o || olcutBos(o)) return null;
return (p) => olcuteUyar(p, o);
}
/** "Çıkar" kuralı: üniversite / bölüm adı (büyük-küçük harfsiz içerir) ya da kimlik. */
export function cikarSuzgeci(k: RevizyonKurali | undefined): ((p: AdayProgram) => boolean) | null {
const c = k?.cikar;
if (!c || (!c.universiteler?.length && !c.bolumler?.length && !c.programIdler?.length)) return null;
const uniler = (c.universiteler ?? []).map(tr).filter(Boolean);
const bolumler = (c.bolumler ?? []).map(tr).filter(Boolean);
const idler = new Set(c.programIdler ?? []);
return (p) =>
idler.has(p.id) ||
uniler.some((u) => tr(p.universite).includes(u)) ||
bolumler.some((b) => tr(p.isim).includes(b));
}
/**
* Kurulmuş listede "ilk N" ölçütüne uyan ilk N satırı başa alır, kalanların
* sırası korunur; `sira` yeniden numaralanır. Dilim sırası (hayal → dengeli →
* güvenli) bu durumda bilerek bozulur: aday açıkça istedi, danışman da
* cevabında söyler. Ölçüte uyan satır sayısı N'den azsa eldeki kadarı öne alınır.
*/
export function ilkTercihleriOneAl<T extends Pick<RaporTercih, "programId" | "sira">>(
tercihler: T[],
k: RevizyonKurali | undefined,
program: (id: string) => Pick<AdayProgram, "isim" | "il" | "unitur" | "fakulte"> | undefined,
): { tercihler: T[]; oneAlinan: number } {
const o = k?.ilkTercihler;
if (!o || olcutBos(o) || o.adet < 1) return { tercihler, oneAlinan: 0 };
const on: T[] = [];
const kalan: T[] = [];
for (const t of tercihler) {
const p = program(t.programId);
if (on.length < o.adet && p && olcuteUyar(p, o)) on.push(t);
else kalan.push(t);
}
return {
tercihler: [...on, ...kalan].map((t, i) => ({ ...t, sira: i + 1 })),
oneAlinan: on.length,
};
}
/** Yeni revizyon eskisinin üstüne üst anahtar bazında biner; `not` yenilenir. */
export function kurallariBirlestir(
eski: RevizyonKurali | undefined,
yeni: RevizyonKurali,
): RevizyonKurali {
const sonuc: RevizyonKurali = { ...(eski ?? {}) };
delete sonuc.not;
for (const [anahtar, deger] of Object.entries(yeni)) {
if (deger === undefined || deger === null) continue;
(sonuc as Record<string, unknown>)[anahtar] = deger;
}
return sonuc;
}
const TIP_ETIKET: Record<UniturGrubu | "farketmez", string> = {
devlet: "devlet",
vakif: "vakıf",
farketmez: "fark etmez",
};
/** Kuralların modele ve adaya okunur dökümü (prompt + liste üst metni). */
export function kuralOzeti(k: RevizyonKurali | undefined): string[] {
if (!k) return [];
const s: string[] = [];
const fark = (ad: string, f: ListeFarki | undefined) => {
if (!f) return;
if (f.yalniz) s.push(`${ad}: yalnız ${f.yalniz.join(", ")}`);
if (f.ekle?.length) s.push(`${ad}: ${f.ekle.join(", ")} eklendi`);
if (f.cikar?.length) s.push(`${ad}: ${f.cikar.join(", ")} çıkarıldı`);
};
fark("iller", k.iller);
fark("ilgi alanları", k.kategoriler);
if (k.universiteTipi) s.push(`üniversite tipi: ${TIP_ETIKET[k.universiteTipi]}`);
if (k.iskelet && k.iskelet !== "standart") {
s.push(k.iskelet === "guvenli-agirlikli" ? "iskelet: güvenli ağırlıklı (4/12/8)" : "iskelet: hayal ağırlıklı (7/12/5)");
}
const o = k.ilkTercihler;
if (o && !olcutBos(o)) {
const olcut = [
o.iller?.length ? `il ${o.iller.join("/")}` : null,
o.kategoriler?.length ? `alan ${o.kategoriler.join("/")}` : null,
o.universiteTipi ? TIP_ETIKET[o.universiteTipi] : null,
].filter(Boolean);
s.push(`ilk ${o.adet} tercih: ${olcut.join(", ")}`);
}
if (k.cikar?.universiteler?.length) s.push(`çıkarılan üniversiteler: ${k.cikar.universiteler.join(", ")}`);
if (k.cikar?.bolumler?.length) s.push(`çıkarılan bölümler: ${k.cikar.bolumler.join(", ")}`);
if (k.cikar?.programIdler?.length) s.push(`çıkarılan ${k.cikar.programIdler.length} program`);
if (k.ekle?.programIdler?.length) s.push(`eklenen ${k.ekle.programIdler.length} program`);
return s;
}