import "server-only"; import { z } from "zod"; import type { RaporKapsam, RaporParams, RaporSonuc, } from "@/features/rapor/types/rapor"; import { anahtarVar } from "./client"; import { yapilandirilmisUret } from "./cagri"; import { havuzOlustur, havuzuKompaktJson, turLabel, dilimHedefiHesapla, IDEAL_HEDEF, type DilimHedef, type Dilim, type AdayProgram, } from "../rapor-havuzu"; import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler"; import { RISK_ETIKET, dilimdenRisk, riskHesapla, riskNotuMetni, } from "../risk"; import { trendOzetiMetni } from "../trend"; // 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 // ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu ayrı listeler // istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile) zorlansın — // reasoning kapalıyken model serbest dağılımı tutturamıyor (ölçüldü: 13 hayal // / 0 garanti dönmüştü). Uzunluklar havuza uyarlanmış hedeften gelir (havuzda // dilim başına yeterli program olmayabiliyor; sabit 5/13/6 o durumda aritmetik // olarak imkânsızdı ve üretim her seferinde patlıyordu). AI yalnızca seçer + // gerekce yazar. const AiTercihSchema = z.object({ programId: z.string(), gerekce: z.string(), }); /** Sağlayıcıya giden tel şeması: dilim uzunlukları hedefe sabitlenir. */ function aiSemaWire(hedef: DilimHedef) { return z.object({ hayal: z.array(AiTercihSchema).length(hedef.hayal), dengeli: z.array(AiTercihSchema).length(hedef.dengeli), garanti: z.array(AiTercihSchema).length(hedef.garanti), genelDegerlendirme: z.string(), uyarilar: z.array(z.string()).min(2).max(3), }); } // Lokal parse şeması: uzunluk kısıtsız. Sağlayıcı tel şemasındaki kısıtları // yok sayarsa çıktı reddedilmez; normalize + kurtarma katmanı düzeltir. const AiRaporGevsek = z.object({ hayal: z.array(AiTercihSchema), dengeli: z.array(AiTercihSchema), garanti: z.array(AiTercihSchema), genelDegerlendirme: z.string(), uyarilar: z.array(z.string()), }); function sistemMetni(hedef: DilimHedef): string { const adet = (d: Dilim) => hedef[d] === 0 ? "BOŞ liste [] döndür" : `TAM ${hedef[d]} program`; const toplam = hedef.hayal + hedef.dengeli + hedef.garanti; return `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi doldurmak: hayal (${adet("hayal")}), dengeli (${adet("dengeli")}), garanti (${adet("garanti")}). KURALLAR (kesin): - SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme. Aynı programId'yi birden fazla kez kullanma. - Havuzdaki her programın "dilim" alanı var: hayal listesine yalnızca dilim değeri "hayal" olan programları koy; dengeli ve garanti için de aynısı. - Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini ve bölüm çeşitliliğini gözet. - METİN DİYETİ: aday ${toplam} satır okuyacak; gerekce TEK kısa cümle (en fazla ~15 kelime, Türkçe): bu programın bu aday için neden mantıklı olduğu. Dolgu söz yok; ASLA "garanti", "kesin", "yüzde yüz" deme. - Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma. - genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları buraya yazma. - uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle. (Tercih sırası, risk notu ve trend özeti koddan hesaplanır; sen yazmazsın.)`; } export class RaporUretimHatasi extends Error {} // Liste kurmak için gereken mutlak taban. Havuz bunun altındaysa hata ekranı // meşrudur; üstündeyse "ne olursa olsun liste kurulur" (kullanıcı kararı, // 7 Ağu 2026) — eksik dilimler uyarlanır ve UI'da dürüstçe belirtilir. // Not: ACIK_SATIR=3 maskesiyle tam 3 satırlık liste fiilen açık iner; // kabul edilen kenar durumu. const MIN_LISTE = 3; const DILIMLER: Dilim[] = ["hayal", "dengeli", "garanti"]; function kapsamUret(hedef: DilimHedef): RaporKapsam { return { toplam: hedef.hayal + hedef.dengeli + hedef.garanti, dagilim: { ...hedef }, uyarlandi: DILIMLER.some((d) => hedef[d] !== IDEAL_HEDEF[d]), }; } type AiSecim = z.infer; /** * AI çıktısını havuza göre normalize eder: havuzda olmayan id'ler ve * tekrarlar atılır, her program havuzdaki GERÇEK dilimine yeniden atanır * (modelin yanlış listeye koyması artık ret sebebi değil, düzeltilir). */ function normalizeEt( parsed: z.infer, havuzMap: Map, ): Record { const gruplar: Record = { hayal: [], dengeli: [], garanti: [], }; const gorulen = new Set(); for (const t of [...parsed.hayal, ...parsed.dengeli, ...parsed.garanti]) { const p = havuzMap.get(t.programId); if (!p || gorulen.has(t.programId)) continue; gorulen.add(t.programId); gruplar[p.dilim].push(t); } return gruplar; } // --- Deterministik satır metinleri: risk ve trend gerçek YÖK Atlas sıra // geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod // kesin rakam verir ("45B düzeyinden 38B düzeyine indi"). function trendOzetiUret(p: AdayProgram): string { // Cümle tadımlıkla ortak saf fonksiyondan gelir (lib/trend.ts) return trendOzetiMetni([ p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025, ]); } function riskNotuUret(p: AdayProgram, adaySira: number): string { return riskNotuMetni(riskHesapla(p.sira2025 ?? p.sira2024, adaySira)); } /** Havuz programını render için gereken bilgiye indirger. */ function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] { const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025]; return { isim: p.isim, universite: p.universite, il: p.il, unitur: p.unitur, efektifSira: p.sira2025 ?? p.sira2024, siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined, }; } // --- Dev mock: API key yokken (yalnızca development) gerçek havuzdan sahte // ama tutarlı bir rapor üret. Akış (kredi, blur, risk renkleri, revizyon) // gerçek Yapay Zeka olmadan uçtan uca test edilebilsin diye. Prod'da asla çalışmaz. function mockAktif(): boolean { return process.env.NODE_ENV !== "production" && !anahtarVar(); } function mockRapor( havuz: AdayProgram[], params: RaporParams, revizyonNotu?: string, ): RaporSonuc { // Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder const hedef = dilimHedefiHesapla(havuz); const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti; const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0; const dilimden = (dilim: AdayProgram["dilim"], adet: number) => havuz .filter((p) => p.dilim === dilim) .sort((a, b) => efektif(a) - efektif(b)) .slice(0, adet); const secilen = [ ...dilimden("hayal", hedef.hayal), ...dilimden("dengeli", hedef.dengeli), ...dilimden("garanti", hedef.garanti), ]; const tercihler = secilen.map((p, i) => ({ programId: p.id, sira: i + 1, dilim: p.dilim, gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`, // Risk ve trend mock'ta da gerçek veriden: canlı davranışla birebir aynı riskNotu: riskNotuUret(p, params.sira), trendOzeti: trendOzetiUret(p), })); const programlar: RaporSonuc["programlar"] = {}; for (const p of secilen) { programlar[p.id] = programBilgisi(p); } return { tercihler, genelDegerlendirme: `(Dev mock — Yapay Zeka anahtarı tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString( "tr-TR", )}. sıra için hayal/dengeli/garanti dağılımlı ${toplamHedef} tercihlik örnek liste kuruldu.${ revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : "" }`, uyarilar: [ "(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.", "(Dev mock) Garanti dilimindeki son tercihleri asla silme.", ], programlar, kapsam: kapsamUret(hedef), devMock: true, }; } export async function raporUret( params: RaporParams, revizyonNotu?: string, oncekiListeOzeti?: string, ): Promise { const { havuz, gevsetildi } = havuzOlustur(params); if (havuz.length < MIN_LISTE) { throw new RaporUretimHatasi( "Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.", ); } // Dilim hedefleri havuzun gerçek dağılımına uyarlanır: sabit 5/13/6 bazı // sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve // üretim her seferinde doğrulamada patlıyordu. const hedef = dilimHedefiHesapla(havuz); 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.universiteTipi && params.universiteTipi !== "farketmez"); const gevsetmeNotu: string[] = []; if (gevsetildi.includes("il")) { gevsetmeNotu.push( `Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi ${toplamHedef}'e tamamlamak için diğerlerini de kullan.`, ); } if (gevsetildi.includes("kategori")) { gevsetmeNotu.push( "Adayın seçtiği ilgi alanlarında yeterli program yoktu; havuza yakın alanlardan program eklendi. Seçili alanlara öncelik ver.", ); } // Dev'de API key yoksa gerçek Yapay Zeka yerine mock rapor (üretim gecikmesi taklidi) if (mockAktif()) { await new Promise((r) => setTimeout(r, 1500)); return mockRapor(havuz, params, revizyonNotu); } const havuzJson = havuzuKompaktJson(havuz); 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 ?? [], })}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.` : null, params.notlar ? `Adayın ek notları: ${params.notlar}` : null, gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null, revizyonNotu ? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}` : null, `ADAY HAVUZU (yalnızca buradan seç): ${havuzJson}`, ] .filter(Boolean) .join("\n\n"); const havuzMap = new Map(havuz.map((p) => [p.id, p])); const efektif = (id: string) => { const p = havuzMap.get(id)!; return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER; }; let secim: Record | null = null; let sonSecim: Record | null = null; let genel = ""; let uyarilar: string[] = []; let sonHata = ""; for (let deneme = 0; deneme < 2; deneme++) { const parsed = await yapilandirilmisUret({ system: sistemMetni(hedef), kullanici: deneme === 0 ? kullaniciMesaji : `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`, sema: AiRaporGevsek, wireSema: aiSemaWire(hedef), // Küçük şema (24 satır × gerekce + genel + uyarılar) ~1-1.5k token; // reasoning kapalıyken 4000 rahat bir tavan, kaçak uzun üretimi de keser. maxTokens: 4000, }); if (!parsed) { sonHata = `Çıktı şemaya uymadı (hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti} olmalı).`; continue; } // Halüsinasyon koruması normalize içinde: havuz-dışı id ve tekrarlar // atılır, dilimler havuzdan yeniden atanır. Ret yalnızca sayı tutmayınca. const norm = normalizeEt(parsed, havuzMap); sonSecim = norm; genel = parsed.genelDegerlendirme; uyarilar = parsed.uyarilar.slice(0, 3); if (DILIMLER.every((d) => norm[d].length === hedef[d])) { secim = norm; break; } sonHata = `Dilim sayıları tutmadı (beklenen: hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti}; havuz-dışı ve tekrar eden id'ler sayılmaz). Havuzdaki "dilim" alanına ve adetlere birebir uy.`; } if (!secim) { // AI hiç kullanılabilir program seçemedi (parse iki kez null ya da tüm // id'ler havuz-dışı): gerçek üretim hatası, hata ekranı meşru. if (!sonSecim) { throw new RaporUretimHatasi( "Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.", ); } const son = sonSecim; const eldeki = son.hayal.length + son.dengeli.length + son.garanti.length; if (eldeki === 0) { throw new RaporUretimHatasi( "Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.", ); } // Kurtarma ("ne olursa olsun liste kurulur"): fazla dilim en iyi taban // sıralamalılar kalacak şekilde kırpılır, eksik dilim havuzun seçilmemiş // programlarından deterministik tamamlanır. hedef[d] ≤ havuzdaki sayı // olduğu için tamamlama her zaman yeterli aday bulur. console.warn( `[rapor] kurtarma: AI ${eldeki} kullanılabilir program döndü, hedef ${toplamHedef} (hayal ${son.hayal.length}/${hedef.hayal}, dengeli ${son.dengeli.length}/${hedef.dengeli}, garanti ${son.garanti.length}/${hedef.garanti})`, ); const kullanilan = new Set( DILIMLER.flatMap((d) => son[d].map((t) => t.programId)), ); const kurtarilan: Record = { hayal: [], dengeli: [], garanti: [], }; for (const d of DILIMLER) { const liste = [...son[d]] .sort((a, b) => efektif(a.programId) - efektif(b.programId)) .slice(0, hedef[d]); if (liste.length < hedef[d]) { const adaylar = havuz .filter((p) => p.dilim === d && !kullanilan.has(p.id)) .sort((a, b) => efektif(a.id) - efektif(b.id)); for (const p of adaylar) { if (liste.length >= hedef[d]) break; kullanilan.add(p.id); liste.push({ programId: p.id, gerekce: "Sıralamana ve seçimlerine uygun olduğu için listene eklendi.", }); } } kurtarilan[d] = liste; } secim = kurtarilan; } const programlar: RaporSonuc["programlar"] = {}; for (const p of havuz) { programlar[p.id] = programBilgisi(p); } // 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 nihai = secim; const tercihler = DILIMLER.flatMap((dilim) => [...nihai[dilim]] .sort((a, b) => efektif(a.programId) - efektif(b.programId)) .map((t) => ({ dilim, ...t })), ).map((t, i) => { const p = havuzMap.get(t.programId)!; return { programId: t.programId, sira: i + 1, dilim: t.dilim, gerekce: t.gerekce, riskNotu: riskNotuUret(p, params.sira), trendOzeti: trendOzetiUret(p), }; }); return { tercihler, genelDegerlendirme: genel.trim() || `Listen ${toplamHedef} tercihten oluşuyor; hayal, dengeli ve garanti dilimleri sıralamana göre dengelendi.`, uyarilar: uyarilar.length > 0 ? uyarilar : undefined, programlar, kapsam: kapsamUret(hedef), }; } export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string { return rapor.tercihler .map((t) => { const p = rapor.programlar[t.programId]; const risk = (adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ?? dilimdenRisk(t.dilim); const taban = p?.efektifSira != null ? ` (taban sırası ~${p.efektifSira.toLocaleString("tr-TR")})` : ""; return `${t.sira}. [${t.dilim} | ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"} — ${p?.universite ?? "?"}${taban}`; }) .join("\n"); } export type { AdayProgram };