- {/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
- "Danışmana sor" butonu) doğrudan danışmana düşsün. */}
+
+
+ ) : (
+
+ {/* Sohbet paket kartının üstünde: #sohbet çapası doğrudan
+ danışmana düşsün. */}
+
+
+
diff --git a/src/features/rapor/components/revizyon-kutusu.tsx b/src/features/rapor/components/revizyon-kutusu.tsx
deleted file mode 100644
index 2f7050e..0000000
--- a/src/features/rapor/components/revizyon-kutusu.tsx
+++ /dev/null
@@ -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(null);
-
- if (kalanHak <= 0) {
- return (
-
-
- Revizyon hakların doldu. Danışman sohbetinden listen hakkında soru
- sormaya devam edebilirsin.
-
-
- );
- }
-
- 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 (
-
-
- Listede değişiklik mi istiyorsun?
-
-
- Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
- (3 kredi).
-
-
- );
-}
diff --git a/src/features/rapor/components/sohbet-anketi.tsx b/src/features/rapor/components/sohbet-anketi.tsx
new file mode 100644
index 0000000..e972acd
--- /dev/null
+++ b/src/features/rapor/components/sohbet-anketi.tsx
@@ -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 (
+
+ {durum === "cevaplandi" ? (
+
+ ) : (
+
+ )}
+ {durum === "cevaplandi" ? "Formu cevapladın." : "Form kapandı; isteğini yazarak söyleyebilirsin."}
+
+ );
+ }
+
+ 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) {
+ 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
+
+
+
+ {anket.baslik ?? "Danışmanın sorusu"}
+
+
(
+
+ {state.current} / {state.total}
+
+ )}
+ />
+
+ {anket.sorular.map((s) => (
+
+ {s.soru}
+ {s.aciklama ? {s.aciklama} : null}
+
+ {s.secenekler.map((o) => (
+
+ {o.etiket}
+ {o.aciklama ? (
+ {o.aciklama}
+ ) : null}
+
+ ))}
+ {s.serbest ? (
+
+ ) : null}
+
+
+ {s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
+
+
+ ))}
+
+
+
+
+
+ {durum === "gonderiliyor" ? "Gönderiliyor…" : "Cevapları gönder"}
+
+
+
+ );
+}
diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx
index 85b9f83..59a8846 100644
--- a/src/features/rapor/components/sohbet-client.tsx
+++ b/src/features/rapor/components/sohbet-client.tsx
@@ -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({
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());
+ 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) => (
)}
+ {anket ? (
+ anketiGonder(anket, cevaplar)}
+ />
+ ) : null}
);
@@ -917,7 +1071,11 @@ export function SohbetClient({
className="rounded-2xl border-slate-200"
>
0
+ ? "Sorunu yaz ya da listende ne değişsin söyle… (1 kredi)"
+ : "Sorunu yaz… (1 kredi)"
+ }
maxLength={2000}
/>
diff --git a/src/features/rapor/components/sohbet-senaryo-verisi.ts b/src/features/rapor/components/sohbet-senaryo-verisi.ts
index 07026d5..880c52e 100644
--- a/src/features/rapor/components/sohbet-senaryo-verisi.ts
+++ b/src/features/rapor/components/sohbet-senaryo-verisi.ts
@@ -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.",
diff --git a/src/features/rapor/liste-revizyon.ts b/src/features/rapor/liste-revizyon.ts
new file mode 100644
index 0000000..8676a3b
--- /dev/null
+++ b/src/features/rapor/liste-revizyon.ts
@@ -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; 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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/src/features/rapor/rapor-actions.ts b/src/features/rapor/rapor-actions.ts
index 1818ab9..fe2dad8 100644
--- a/src/features/rapor/rapor-actions.ts
+++ b/src/features/rapor/rapor-actions.ts
@@ -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 {
- 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,
- };
-}
diff --git a/src/features/rapor/rapor-queries.ts b/src/features/rapor/rapor-queries.ts
index e839076..c8dfc34 100644
--- a/src/features/rapor/rapor-queries.ts
+++ b/src/features/rapor/rapor-queries.ts
@@ -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))
diff --git a/src/features/rapor/types/rapor.ts b/src/features/rapor/types/rapor.ts
index eea85fc..80533f8 100644
--- a/src/features/rapor/types/rapor.ts
+++ b/src/features/rapor/types/rapor.ts
@@ -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;
+};
diff --git a/src/lib/ai/araclar.ts b/src/lib/ai/araclar.ts
index ee0f0d7..9f66127 100644
--- a/src/lib/ai/araclar.ts
+++ b/src/lib/ai/araclar.ts
@@ -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): { 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;
+ 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): { 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;
+ 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;
+ 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).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): Promise {
+ 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}` };
}
diff --git a/src/lib/ai/cikti-denetimi.ts b/src/lib/ai/cikti-denetimi.ts
index 93606e7..85891d4 100644
--- a/src/lib/ai/cikti-denetimi.ts
+++ b/src/lib/ai/cikti-denetimi.ts
@@ -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.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ı: ${kullaniciMetni(params.notlar)}`
: null,
@@ -547,7 +581,7 @@ export async function raporUretAyrintili(
revizyonNotu
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${kullaniciMetni(revizyonNotu)}\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,
diff --git a/src/lib/ai/sohbet-akisi.ts b/src/lib/ai/sohbet-akisi.ts
index 7a972d2..08f7991 100644
--- a/src/lib/ai/sohbet-akisi.ts
+++ b/src/lib/ai/sohbet-akisi.ts
@@ -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 {
return createUIMessageStream({
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) : 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,
diff --git a/src/lib/ai/sohbet-meta.ts b/src/lib/ai/sohbet-meta.ts
new file mode 100644
index 0000000..d442418
--- /dev/null
+++ b/src/lib/ai/sohbet-meta.ts
@@ -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;
+
+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 } | { ok: false; hata: string } {
+ if (typeof girdi !== "object" || girdi === null) return { ok: false, hata: "nesne bekleniyor" };
+ const g = girdi as Record;
+ 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();
+ 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;
+ 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();
+ 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;
+ 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;
+ 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")}]`;
+}
diff --git a/src/lib/appdb/schema.ts b/src/lib/appdb/schema.ts
index 9dc160f..23dcbde 100644
--- a/src/lib/appdb/schema.ts
+++ b/src/lib/appdb/schema.ts
@@ -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(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],
diff --git a/src/lib/credits.ts b/src/lib/credits.ts
index 465d64b..bc80d87 100644
--- a/src/lib/credits.ts
+++ b/src/lib/credits.ts
@@ -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 {
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 {
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 };
diff --git a/src/lib/rapor-havuzu.ts b/src/lib/rapor-havuzu.ts
index 26dd4e4..8e0126e 100644
--- a/src/lib/rapor-havuzu.ts
+++ b/src/lib/rapor-havuzu.ts
@@ -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 };
}
diff --git a/src/lib/revizyon-kurali.ts b/src/lib/revizyon-kurali.ts
new file mode 100644
index 0000000..5a9b2d0
--- /dev/null
+++ b/src/lib/revizyon-kurali.ts
@@ -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;
+
+/** Program "ilk N" ölçütüne uyuyor mu (verilen her ölçüt birlikte sağlanmalı). */
+export function olcuteUyar(
+ p: Pick,
+ olcut: Pick,
+): 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 | 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>(
+ tercihler: T[],
+ k: RevizyonKurali | undefined,
+ program: (id: string) => Pick | 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)[anahtar] = deger;
+ }
+ return sonuc;
+}
+
+const TIP_ETIKET: Record = {
+ 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;
+}