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
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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 4–6 hayal")
|
||||
L --> L2("ortada 12–14 dengeli")
|
||||
L --> L3("sonda 5–7 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{" "}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user