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")}]`;
}