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:
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
192
src/lib/ai/sohbet-meta.ts
Normal 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")}]`;
|
||||
}
|
||||
Reference in New Issue
Block a user