import { z } from "zod"; import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod"; import { getAnthropic, RAPOR_MODEL } from "./client"; import { havuzOlustur, havuzuKompaktJson, turLabel, type RaporParams, type AdayProgram, } from "../rapor-havuzu"; import { secimOzeti } from "../sihirbaz"; import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk"; const TercihSchema = z.object({ programId: z.string(), sira: z.number().int(), dilim: z.enum(["hayal", "dengeli", "garanti"]), gerekce: z.string(), riskNotu: z.string(), trendOzeti: z.string(), }); const RaporSchema = z.object({ tercihler: z.array(TercihSchema), genelDegerlendirme: z.string(), uyarilar: z.array(z.string()).min(2).max(3), }); // uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel export type RaporSonuc = Omit, "uyarilar"> & { uyarilar?: string[]; // Render için havuzdan zenginleştirilmiş program bilgisi // efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok // siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku programlar: Record< string, { isim: string; universite: string; il: string | null; unitur: string | null; efektifSira?: number | null; siraGecmisi?: (number | null)[]; } >; }; const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen GERÇEK YÖK Atlas aday havuzundan, adayın sıralamasına göre 24 tercihlik dengeli bir liste kurmak. KURALLAR (kesin): - SADECE havuzdaki programId'leri kullan. Havuzda olmayan program ASLA önerme. - Tam 24 tercih üret; sira alanı 1'den 24'e sıralı olsun. - Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti. Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli (küçük taban sıralaması = daha zor bölüm üstte). - METİN DİYETİ: aday 24 satır okuyacak; her alan TEK kısa cümle, dolgu söz yok. - gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle, en fazla ~15 kelime, Türkçe). - riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti", "kesin", "yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan. - trendOzeti: son 4 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor"). - Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat. - 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.`; export class RaporUretimHatasi extends Error {} /** 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 AI olmadan uçtan uca test edilebilsin diye. Prod'da asla çalışmaz. function mockAktif(): boolean { return ( process.env.NODE_ENV !== "production" && !process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN ); } function mockRapor( havuz: AdayProgram[], params: RaporParams, revizyonNotu?: string, ): RaporSonuc { 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); // 5 hayal + 13 dengeli + 6 garanti; eksik dilim varsa havuzdan tamamla const secilen = [ ...dilimden("hayal", 5), ...dilimden("dengeli", 13), ...dilimden("garanti", 6), ]; for (const p of havuz) { if (secilen.length >= 24) break; if (!secilen.includes(p)) secilen.push(p); } const tercihler = secilen.slice(0, 24).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.`, riskNotu: p.dilim === "hayal" ? "(Dev mock) Taban sıralaması senin üstünde kapandı; düşük ihtimal ama listenin başında yeri var." : p.dilim === "dengeli" ? "(Dev mock) Taban sıralaması senin civarında; orta-yüksek ihtimalle yerleşilebilir." : "(Dev mock) Taban sıralaması senin belirgin altında; yüksek ihtimalle yerleşirsin.", trendOzeti: "(Dev mock) Son 4 yılda taban sıralaması görece stabil seyretti.", })); const programlar: RaporSonuc["programlar"] = {}; for (const p of secilen.slice(0, 24)) { programlar[p.id] = programBilgisi(p); } return { tercihler, genelDegerlendirme: `(Dev mock — ANTHROPIC_API_KEY tanımlı değil, bu rapor gerçek AI çıktısı değildir.) ${params.sira.toLocaleString( "tr-TR", )}. sıra için hayal/dengeli/garanti dağılımlı 24 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 3 tercihi asla silme.", ], programlar, }; } export async function raporUret( params: RaporParams, revizyonNotu?: string, oncekiListeOzeti?: string, ): Promise { const { havuz, gevsetildi } = havuzOlustur(params); if (havuz.length < 24) { throw new RaporUretimHatasi( "Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.", ); } 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 24'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 AI yerine mock rapor (üretim gecikmesi taklidi) if (mockAktif()) { await new Promise((r) => setTimeout(r, 1500)); return mockRapor(havuz, params, revizyonNotu); } const client = getAnthropic(); 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 gecerliIdler = new Set(havuz.map((p) => p.id)); let sonuc: z.infer | null = null; let sonHata = ""; for (let deneme = 0; deneme < 2; deneme++) { const response = await client.messages.parse({ model: RAPOR_MODEL, max_tokens: 16000, thinking: { type: "adaptive" }, system: SISTEM, messages: [ { role: "user", content: deneme === 0 ? kullaniciMesaji : `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`, }, ], output_config: { format: zodOutputFormat(RaporSchema) }, }); const parsed = response.parsed_output; if (!parsed) { sonHata = "Çıktı şemaya uymadı."; continue; } // Halüsinasyon koruması: her programId havuzda olmalı + tam 24 öğe const bilinmeyen = parsed.tercihler.filter( (t) => !gecerliIdler.has(t.programId), ); if (parsed.tercihler.length !== 24 || bilinmeyen.length > 0) { sonHata = parsed.tercihler.length !== 24 ? `Liste ${parsed.tercihler.length} öğe içeriyor, tam 24 olmalı.` : `Havuzda olmayan programId kullanıldı: ${bilinmeyen .map((t) => t.programId) .slice(0, 3) .join(", ")}.`; continue; } sonuc = parsed; break; } if (!sonuc) { throw new RaporUretimHatasi( "Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.", ); } const programlar: RaporSonuc["programlar"] = {}; for (const p of havuz) { programlar[p.id] = programBilgisi(p); } return { ...sonuc, programlar }; } 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 };