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

@@ -15,10 +15,12 @@ import { ListemGovde } from "./listem-govde";
import { ListemBosCta } from "./listem-bos-cta";
import { ListeUretici } from "./liste-uretici";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
// dolmalı ki kredi iadesi her zaman çalışabilsin.
export const maxDuration = 120;
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
// SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
// bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin
// istek süresi limiti de >= 200 sn olmalı.
export const maxDuration = 300;
export const metadata: Metadata = {
title: "Listem",

View File

@@ -121,6 +121,7 @@ export function SohbetClient({
{ id: asistanId, role: "assistant", content: "" },
]);
let birikmis = "";
try {
const res = await fetch("/api/soru", {
method: "POST",
@@ -151,7 +152,6 @@ export function SohbetClient({
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
@@ -165,7 +165,26 @@ export function SohbetClient({
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
// bu senkronla temizlenir). Senkron da düşerse boş balonu elle kaldır.
try {
const res = await fetch("/api/soru");
const data = res.ok ? await res.json() : null;
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi);
} catch {}
if (!birikmis) {
setMesajlar((m) => m.filter((x) => x.id !== asistanId));
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(mesaj);
}
router.refresh();
toast.error(
birikmis
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
);
} finally {
setBekliyor(false);
}

View File

@@ -18,8 +18,9 @@ flowchart TD
H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
G --> H
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe + risk notu ekler")
Y --> L("24 tercihlik liste")
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
R --> L("24 tercihlik liste")
L --> L1("üstte 46 hayal")
L --> L2("ortada 1214 dengeli")
L --> L3("sonda 57 garanti")
@@ -39,7 +40,7 @@ export function HuniSemasi() {
return (
<ElCizimiSema
tanim={SEMA}
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçer."
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
figcaption={
<>
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}

View File

@@ -43,8 +43,15 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
function aiHataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message;
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) — ada göre yakala.
if (err instanceof Error && /abort|timeout/i.test(err.name)) {
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) ve openai/anthropic
// bu sınıflarda .name'i "Error" bırakıyor — ada ek olarak sınıf adına ve
// mesaja da bak ("Request was aborted." / "Request timed out.").
if (
err instanceof Error &&
/abort|timeout|timed out/i.test(
`${err.name} ${err.constructor.name} ${err.message}`,
)
) {
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; harcanan kredin otomatik iade edildi. Tekrar dener misin?";
}
if (err instanceof Error && err.message === "AI_KEY_MISSING") {

View File

@@ -1,43 +1,40 @@
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { ThinkingLevel } from "@google/genai";
import {
getAnthropic,
getGemini,
raporModeli,
saglayici,
sohbetModeli,
} from "./client";
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
/**
* Sağlayıcıdan bağımsız AI çağrı katmanı. Anthropic ve Gemini'nin SDK'ları
* yapılandırılmış çıktı ve stream için farklı şekiller kullanıyor; rapor ve
* sohbet kodu bu farkı görmesin diye tek arayüz burada toplanıyor.
* AI çağrı katmanı: tüm istekler OpenRouter'ın OpenAI-uyumlu API'sine gider
* (tek kapı — kullanıcı kararı, 7 Ağu 2026). Rapor ve sohbet kodu SDK/istek
* ayrıntısını görmesin diye tek arayüz burada toplanıyor.
*/
/**
* Gemini JSON Schema'nın yalnızca bir alt kümesini destekliyor; zod'un ürettiği
* $schema ve additionalProperties alanları isteği reddettiriyor, ayıklanıyor.
* zod şemasını JSON Schema'ya çevirir: z.toJSONSchema'nın eklediği $schema
* alanı ayıklanır, her object düğümüne additionalProperties: false eklenir —
* OpenAI-uyumlu strict json_schema doğrulayıcıları bunu şart koşuyor.
*/
function geminiSemasi(sema: z.ZodType): unknown {
const temizle = (dugum: unknown): unknown => {
if (Array.isArray(dugum)) return dugum.map(temizle);
function jsonSemasi(sema: z.ZodType): Record<string, unknown> {
const donustur = (dugum: unknown): unknown => {
if (Array.isArray(dugum)) return dugum.map(donustur);
if (dugum && typeof dugum === "object") {
const cikti: Record<string, unknown> = {};
for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) {
if (k === "$schema" || k === "additionalProperties") continue;
cikti[k] = temizle(v);
if (k === "$schema") continue;
cikti[k] = donustur(v);
}
if (cikti.type === "object" && cikti.additionalProperties === undefined) {
cikti.additionalProperties = false;
}
return cikti;
}
return dugum;
};
return temizle(z.toJSONSchema(sema));
return donustur(z.toJSONSchema(sema)) as Record<string, unknown>;
}
/**
* Gemini ücretsiz katmanı yoğunlukta 503/429 döndürüyor; bu geçici hatalarda
* artan bekleme ile yeniden dene. Kalıcı hatalar (400, 403) olduğu gibi fırlar.
* OpenRouter yoğunlukta/limitte 429, üst sağlayıcı arızasında 5xx dönebiliyor;
* bu geçici hatalarda artan bekleme ile yeniden dene. Kalıcı hatalar
* (400, 401, 403) olduğu gibi fırlar.
*/
const GECICI_KODLAR = new Set([429, 500, 502, 503, 504]);
@@ -65,9 +62,10 @@ async function geciciHatadaTekrarla<T>(
* Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın
* (server action) platform limitinden önce dolmalı ki hata iade kodunun
* çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse
* harcanan kredi iade edilemiyor.
* harcanan kredi iade edilemiyor. Flash sınıfı model tipik ~10-20 sn'de
* biter; 60 sn üst sağlayıcı yavaşlamalarına pay bırakır.
*/
const URETIM_ZAMAN_ASIMI_MS = 50_000;
const URETIM_ZAMAN_ASIMI_MS = 60_000;
/**
* Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner
@@ -82,92 +80,78 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
// Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da
// anında düşer, toplam süre sınırın üstüne çıkamaz.
const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS);
if (saglayici() === "gemini") {
const yanit = await geciciHatadaTekrarla(() =>
getGemini().models.generateContent({
const yanit = await geciciHatadaTekrarla(() =>
getOpenRouter().chat.completions.create(
{
model: raporModeli(),
contents: opts.kullanici,
config: {
abortSignal: sinyal,
systemInstruction: opts.system,
maxOutputTokens: opts.maxTokens,
responseMimeType: "application/json",
responseJsonSchema: geminiSemasi(opts.sema),
thinkingConfig: { thinkingLevel: ThinkingLevel.HIGH },
max_tokens: opts.maxTokens,
// Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya
// uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit
// modellerde düşünme bütçe/süre yemesin.
reasoning_effort: "none",
messages: [
{ role: "system", content: opts.system },
{ role: "user", content: opts.kullanici },
],
response_format: {
type: "json_schema",
json_schema: {
name: "yanit",
strict: true,
schema: jsonSemasi(opts.sema),
},
},
}),
);
const metin = yanit.text;
if (!metin) return null;
try {
const parsed = opts.sema.safeParse(JSON.parse(metin));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
const yanit = await getAnthropic().messages.parse(
{
model: raporModeli(),
max_tokens: opts.maxTokens,
thinking: { type: "adaptive" },
system: opts.system,
messages: [{ role: "user", content: opts.kullanici }],
output_config: { format: zodOutputFormat(opts.sema) },
},
{ signal: sinyal },
},
{ signal: sinyal },
),
);
return yanit.parsed_output ?? null;
const metin = yanit.choices[0]?.message?.content;
if (!metin) return null;
try {
const parsed = opts.sema.safeParse(JSON.parse(metin));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
export type SohbetMesaji = { role: "user" | "assistant"; content: string };
/**
* Sohbet yanıtını parça parça üretir. Sağlayıcıya bakmaksızın düz metin
* parçaları yield eder; çağıran taraf hem stream'ler hem biriktirip kaydeder.
* Sohbet yanıtını parça parça üretir; düz metin parçaları yield eder, çağıran
* taraf hem stream'ler hem biriktirip kaydeder. Akış ortasında zaman aşımı
* olursa hata fırlar (route'taki kısmi kaydet + iade yolu devreye girer).
*/
export async function* sohbetAkisi(opts: {
system: string;
mesajlar: SohbetMesaji[];
maxTokens: number;
}): AsyncGenerator<string> {
if (saglayici() === "gemini") {
const akis = await geciciHatadaTekrarla(() =>
getGemini().models.generateContentStream({
// Asılı kalan üst sağlayıcıya karşı sigorta
const sinyal = AbortSignal.timeout(120_000);
const akis = await geciciHatadaTekrarla(() =>
getOpenRouter().chat.completions.create(
{
model: sohbetModeli(),
contents: opts.mesajlar.map((m) => ({
// Gemini "assistant" yerine "model" rolünü kullanır
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
config: {
systemInstruction: opts.system,
maxOutputTokens: opts.maxTokens,
// Sohbet kısa ve hızlı olmalı; uzun düşünme adayı bekletiyor
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
},
}),
);
for await (const parca of akis) {
const metin = parca.text;
if (metin) yield metin;
}
return;
}
const akis = getAnthropic().messages.stream({
model: sohbetModeli(),
max_tokens: opts.maxTokens,
system: opts.system,
messages: opts.mesajlar,
});
for await (const olay of akis) {
if (
olay.type === "content_block_delta" &&
olay.delta.type === "text_delta"
) {
yield olay.delta.text;
}
max_tokens: opts.maxTokens,
// Sohbet 1024 token'a sığmalı; düşünme hem bütçeyi hem adayı bekletiyor
reasoning_effort: "none",
stream: true,
messages: [
{ role: "system", content: opts.system },
...opts.mesajlar,
],
},
{ signal: sinyal },
),
);
for await (const parca of akis) {
// Yalnızca delta.content akar; varsa delta.reasoning kullanıcıya sızmaz
const metin = parca.choices[0]?.delta?.content;
if (metin) yield metin;
}
// openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor
// (core/streaming: isAbortError -> return); kesik cevabın tam cevap
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
if (sinyal.aborted) throw sinyal.reason;
}

View File

@@ -1,64 +1,52 @@
import Anthropic from "@anthropic-ai/sdk";
import { GoogleGenAI } from "@google/genai";
import OpenAI from "openai";
export type Saglayici = "anthropic" | "gemini";
/** AI_SAGLAYICI env'i ile seçilir; tanımsızsa hangi anahtar varsa o kullanılır. */
export function saglayici(): Saglayici {
const secim = process.env.AI_SAGLAYICI?.trim().toLowerCase();
if (secim === "gemini" || secim === "anthropic") return secim;
return process.env.GEMINI_API_KEY ? "gemini" : "anthropic";
}
/** Seçili sağlayıcının anahtarı tanımlı mı (mock moduna düşülecek mi)? */
export function anahtarVar(): boolean {
return saglayici() === "gemini"
? Boolean(process.env.GEMINI_API_KEY)
: Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
}
// Model seçimi (bilinçli):
// Anthropic — rapor tek seferlik ve yüksek değerli -> Opus 4.8;
// sohbet kredi başına marj hassas -> Haiku 4.5 (kullanıcı kararı, 22 Tem 2026).
// Gemini — ücretsiz katmanda yalnızca Flash ailesi açık (Pro'da kota 0),
// bu yüzden rapor ve sohbet aynı modeli kullanır; ayrım thinking seviyesinde.
const MODELLER: Record<Saglayici, { rapor: string; sohbet: string }> = {
anthropic: { rapor: "claude-opus-4-8", sohbet: "claude-haiku-4-5" },
gemini: { rapor: "gemini-3.6-flash", sohbet: "gemini-3.6-flash" },
};
// Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı,
// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı).
// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir.
const VARSAYILAN_MODEL = "deepseek/deepseek-v4-flash";
export function raporModeli(): string {
return MODELLER[saglayici()].rapor;
return (
process.env.OPENROUTER_RAPOR_MODEL?.trim() ||
process.env.OPENROUTER_MODEL?.trim() ||
VARSAYILAN_MODEL
);
}
export function sohbetModeli(): string {
return MODELLER[saglayici()].sohbet;
return (
process.env.OPENROUTER_SOHBET_MODEL?.trim() ||
process.env.OPENROUTER_MODEL?.trim() ||
VARSAYILAN_MODEL
);
}
const globalForAi = globalThis as unknown as {
__anthropic?: Anthropic;
__gemini?: GoogleGenAI;
};
export function getAnthropic(): Anthropic {
if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
throw new Error("AI_KEY_MISSING");
}
if (!globalForAi.__anthropic) {
globalForAi.__anthropic = new Anthropic();
}
return globalForAi.__anthropic;
/** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
export function anahtarVar(): boolean {
return Boolean(process.env.OPENROUTER_API_KEY);
}
export function getGemini(): GoogleGenAI {
const apiKey = process.env.GEMINI_API_KEY;
const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
export function getOpenRouter(): OpenAI {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("AI_KEY_MISSING");
}
if (!globalForAi.__gemini) {
globalForAi.__gemini = new GoogleGenAI({ apiKey });
if (!globalForAi.__openrouter) {
globalForAi.__openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey,
// Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla
maxRetries: 0,
// OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel)
defaultHeaders: {
"HTTP-Referer": "https://kolaytercih.com",
"X-Title": "KolayTercih",
},
});
}
return globalForAi.__gemini;
return globalForAi.__openrouter;
}
export const SORUMLULUK_REDDI =

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 {

View File

@@ -34,6 +34,17 @@ export interface HavuzSonuc {
const HEDEF = 24;
const LIMIT = 60;
// Prompt'a giden nihai havuz: dilim başına aday sayısı. Arama LIMIT ile geniş
// yapılır (filtre/gevşetme zenginliği için), filtre sonrası buraya kırpılır —
// 20×3=60 program ≈ 8k token; 180 program 24k token tutuyor ve lokal/ücretsiz
// modellerde prefill + isabet maliyeti yüksek (ölçüm: 6 Ağu 2026).
const PROMPT_LIMIT_DILIM = 20;
/** Dilim sırasını koruyarak her dilimden en fazla n aday bırakır. */
function dilimBasinaKirp(havuz: AdayProgram[], n: number): AdayProgram[] {
const sayac: Record<Dilim, number> = { hayal: 0, dengeli: 0, garanti: 0 };
return havuz.filter((p) => ++sayac[p.dilim] <= n);
}
function dilimEkle(r: {
hayal: Program[];
@@ -79,12 +90,18 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
);
const tamFiltreli = kategoriFiltre(tamHam);
if (tamFiltreli.length >= HEDEF) {
return { havuz: tamFiltreli, gevsetildi: [] };
return {
havuz: dilimBasinaKirp(tamFiltreli, PROMPT_LIMIT_DILIM),
gevsetildi: [],
};
}
// 2. Kategori gevşet: aynı illerdeki kategori-dışı programlarla doldur
if (kategoriler.length > 0 && tamHam.length >= HEDEF) {
return { havuz: tamHam, gevsetildi: ["kategori"] };
return {
havuz: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM),
gevsetildi: ["kategori"],
};
}
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
@@ -98,12 +115,15 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
);
const ilsizFiltreli = kategoriFiltre(ilsizHam);
if (ilsizFiltreli.length >= HEDEF) {
return { havuz: ilsizFiltreli, gevsetildi };
return {
havuz: dilimBasinaKirp(ilsizFiltreli, PROMPT_LIMIT_DILIM),
gevsetildi,
};
}
// 4. Hem il hem kategori gevşet: elde ne varsa
if (kategoriler.length > 0) gevsetildi.push("kategori");
return { havuz: ilsizHam, gevsetildi };
return { havuz: dilimBasinaKirp(ilsizHam, PROMPT_LIMIT_DILIM), gevsetildi };
}
/** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */