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

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