AI katmanını OpenRouter tek kapısına geçir ve rapor üretimini hızlandır
All checks were successful
Deploy / deploy (push) Successful in 9m30s

- Tüm AI akışı OpenRouter üzerinden (openai SDK, varsayılan model
  deepseek/deepseek-v4-flash, OPENROUTER_MODEL ile değiştirilebilir);
  Anthropic/Gemini/lokal sağlayıcı seçimi ve SDK'ları kaldırıldı.
- Rapor: aday havuzu dilim başına 20 (60 program, ~8k token prompt);
  AI şeması sabit uzunluklu üç liste (hayal 5 / dengeli 13 / garanti 6 —
  dağılım şema düzeyinde zorlanır), sıra + dilim + riskNotu + trendOzeti
  gerçek sıra geçmişinden deterministik üretilir; 184 sn → ~12 sn.
- Sohbet: akış ortası zaman aşımı artık sessizce yutulmaz (kesik cevap
  tam cevap gibi kaydedilmiyordu, kredi iadesi çalışmıyordu); istemci
  hata anında durumu sunucudan senkronlar, boş balon bırakmaz.
- aiHataMesaji, openai SDK'nın .name'i "Error" kalan abort/timeout
  hatalarını da yakalar; meraklisina şeması yeni akışla senkronlandı.
- CI: OPENROUTER_API_KEY / OPENROUTER_MODEL secret'ları; eski AI
  secret'ları kaldırıldı. next.config serverExternalPackages sadeleşti.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-07 00:37:54 +03:00
parent 4c618781f6
commit 18d5b42136
15 changed files with 366 additions and 540 deletions

View File

@@ -26,6 +26,24 @@ const RaporSchema = z.object({
uyarilar: z.array(z.string()).min(2).max(3),
});
// 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; 5/13/6'lık
// 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ü). AI yalnızca seçer + gerekce yazar.
const AiTercihSchema = z.object({
programId: z.string(),
gerekce: z.string(),
});
const AiRaporSchema = z.object({
hayal: z.array(AiTercihSchema).length(5),
dengeli: z.array(AiTercihSchema).length(13),
garanti: z.array(AiTercihSchema).length(6),
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<z.infer<typeof RaporSchema>, "uyarilar"> & {
uyarilar?: string[];
@@ -46,30 +64,77 @@ export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
};
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.
GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi
doldurmak: hayal (TAM 5 program), dengeli (TAM 13), garanti (TAM 6).
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.
- 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 24 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.`;
- 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 {}
// --- 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").
/** 45000 -> "45B", 1_200_000 -> "1,2M" (şemadaki öğrenci dili). */
function siraKisa(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toLocaleString("tr-TR", { maximumFractionDigits: 1 })}M`;
}
if (n >= 1000) return `${Math.round(n / 1000)}B`;
return String(n);
}
function trendOzetiUret(p: AdayProgram): string {
const ham: Array<[number, number | null | undefined]> = [
[2021, p.sira2021],
[2022, p.sira2022],
[2023, p.sira2023],
[2024, p.sira2024],
[2025, p.sira2025],
];
const seri = ham.filter((cift): cift is [number, number] => cift[1] != null);
if (seri.length < 2) {
return "Karşılaştırma için yeterli geçmiş yıl verisi yok; trendi temkinli oku.";
}
const [ilkYil, ilkSira] = seri[0];
const [sonYil, sonSira] = seri[seri.length - 1];
const oran = (sonSira - ilkSira) / ilkSira;
if (oran <= -0.1) {
return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; bölüm zorlaşıyor.`;
}
if (oran >= 0.1) {
return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; yerleşmek görece kolaylaşıyor.`;
}
return `${ilkYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`;
}
function riskNotuUret(p: AdayProgram, adaySira: number): string {
switch (riskHesapla(p.sira2025 ?? p.sira2024, adaySira)) {
case "guvenli":
return "Taban sıralaması senin sıranın belirgin altında; yüksek ihtimalle yerleşirsin.";
case "az-riskli":
return "Taban sıralaması sırana yakın; orta ihtimalle yerleşilebilir.";
case "riskli":
return "Taban sıralaması senin sıranın üstünde kapandı; yerleşme ihtimali düşük.";
default:
return "Güncel taban sıralaması verisi yok; ihtimal öngörülemez, temkinli yaklaş.";
}
}
/** 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];
@@ -119,14 +184,9 @@ function mockRapor(
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.",
// 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"] = {};
@@ -205,9 +265,9 @@ export async function raporUret(
.filter(Boolean)
.join("\n\n");
const gecerliIdler = new Set(havuz.map((p) => p.id));
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
let sonuc: z.infer<typeof RaporSchema> | null = null;
let sonuc: z.infer<typeof AiRaporSchema> | null = null;
let sonHata = "";
for (let deneme = 0; deneme < 2; deneme++) {
const parsed = await yapilandirilmisUret({
@@ -216,26 +276,40 @@ export async function raporUret(
deneme === 0
? kullaniciMesaji
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
sema: RaporSchema,
maxTokens: 16000,
sema: AiRaporSchema,
// 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ı.";
// Dilim uzunlukları (5/13/6) şemada sabit: sayı tutmazsa da buraya düşer
sonHata = "Çıktı şemaya uymadı (hayal 5, dengeli 13, garanti 6 olmalı).";
continue;
}
// Halüsinasyon koruması: her programId havuzda olmalı + tam 24 öğe
const bilinmeyen = parsed.tercihler.filter(
(t) => !gecerliIdler.has(t.programId),
// Halüsinasyon koruması: id'ler havuzda, benzersiz ve doğru dilimde olmalı
const gruplar = [
["hayal", parsed.hayal],
["dengeli", parsed.dengeli],
["garanti", parsed.garanti],
] as const;
const idler = gruplar.flatMap(([, liste]) => liste.map((t) => t.programId));
const bilinmeyen = idler.filter((id) => !havuzMap.has(id));
if (bilinmeyen.length > 0) {
sonHata = `Havuzda olmayan programId kullanıldı: ${bilinmeyen.slice(0, 3).join(", ")}.`;
continue;
}
if (new Set(idler).size !== idler.length) {
sonHata = "Aynı programId birden fazla kez kullanıldı; her program tek kez seçilmeli.";
continue;
}
const yanlisDilim = gruplar.flatMap(([dilim, liste]) =>
liste
.filter((t) => havuzMap.get(t.programId)!.dilim !== dilim)
.map((t) => `${t.programId} (${dilim} listesinde ama havuzdaki dilimi farklı)`),
);
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(", ")}.`;
if (yanlisDilim.length > 0) {
sonHata = `Programlar yanlış dilim listesine konmuş: ${yanlisDilim.slice(0, 3).join(", ")}. Havuzdaki "dilim" alanına birebir uy.`;
continue;
}
sonuc = parsed;
@@ -252,7 +326,39 @@ export async function raporUret(
for (const p of havuz) {
programlar[p.id] = programBilgisi(p);
}
return { ...sonuc, programlar };
// 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
// 1-24 koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
const efektif = (id: string) => {
const p = havuzMap.get(id)!;
return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER;
};
const tercihler = (
[
["hayal", sonuc.hayal],
["dengeli", sonuc.dengeli],
["garanti", sonuc.garanti],
] as const
).flatMap(([dilim, liste]) =>
[...liste].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: sonuc.genelDegerlendirme,
uyarilar: sonuc.uyarilar,
programlar,
};
}
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {