refactor: update Dockerfile and authentication flow for improved user experience
All checks were successful
Deploy / deploy (push) Successful in 15m6s

- Updated Dockerfile to create a cache directory for Next.js runtime, ensuring proper permissions and ownership.
- Enhanced the authentication flow by updating the magic link expiration time from 5 to 15 minutes, improving user experience and reducing login issues.
- Added user-friendly error handling for invalid or expired magic links, providing clear feedback on the login page.
- Adjusted email templates to reflect the new 15-minute validity period for magic links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-07 02:05:14 +03:00
parent 37baeb9172
commit be4b2c6000
19 changed files with 444 additions and 134 deletions

View File

@@ -75,6 +75,14 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
system: string;
kullanici: string;
sema: T;
// Sağlayıcıya giden json_schema bu şemadan üretilir (verilmezse sema).
// Lokal doğrulama HER ZAMAN sema ile yapılır: tel şeması sıkı uzunluk
// kısıtları taşıyabilir (grammar zorlaması), sağlayıcı bu kısıtları yok
// sayarsa çıktı yine gevşek şemayla kurtarılır. Not: strict json_schema'da
// minItems/maxItems bazı sağlayıcılarda 400 döndürebilir; mevcut sağlayıcı
// (deepseek) kabul ediyor — sağlayıcı değişiminde geri çekilme yolu
// wireSema'dan uzunluk kısıtlarını kaldırmaktır.
wireSema?: z.ZodType;
maxTokens: number;
}): Promise<z.infer<T> | null> {
// Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da
@@ -98,13 +106,16 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
json_schema: {
name: "yanit",
strict: true,
schema: jsonSemasi(opts.sema),
schema: jsonSemasi(opts.wireSema ?? opts.sema),
},
},
},
{ signal: sinyal },
),
);
// OpenRouter üst sağlayıcı arızasında HTTP 200 gövdesinde {"error": ...}
// dönebiliyor; choices o durumda yok — TypeError yerine null (retry yolu).
if (!Array.isArray(yanit.choices) || yanit.choices.length === 0) return null;
const metin = yanit.choices[0]?.message?.content;
if (!metin) return null;
try {
@@ -147,7 +158,7 @@ export async function* sohbetAkisi(opts: {
);
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;
const metin = parca.choices?.[0]?.delta?.content;
if (metin) yield metin;
}
// openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor

View File

@@ -5,6 +5,10 @@ import {
havuzOlustur,
havuzuKompaktJson,
turLabel,
dilimHedefiHesapla,
IDEAL_HEDEF,
type DilimHedef,
type Dilim,
type RaporParams,
type AdayProgram,
} from "../rapor-havuzu";
@@ -27,26 +31,53 @@ const RaporSchema = z.object({
});
// 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.
// 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(),
});
const AiRaporSchema = z.object({
hayal: z.array(AiTercihSchema).length(5),
dengeli: z.array(AiTercihSchema).length(13),
garanti: z.array(AiTercihSchema).length(6),
/** 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()).min(2).max(3),
uyarilar: z.array(z.string()),
});
/**
* Listenin havuza uyarlanma bilgisi. uyarlandi=true ise dağılım ideal
* 5/13/6'dan sapmıştır (havuz yetersizdi) ve UI dürüst bir not gösterir.
* Eski kayıtlı raporlarda yok → opsiyonel.
*/
export type RaporKapsam = {
toplam: number;
dagilim: Record<Dilim, number>;
uyarlandi: boolean;
};
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
uyarilar?: string[];
kapsam?: RaporKapsam;
// 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
@@ -63,9 +94,13 @@ 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
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 (TAM 5 program), dengeli (TAM 13), garanti (TAM 6).
doldurmak: hayal (${adet("hayal")}), dengeli (${adet("dengeli")}), garanti (${adet("garanti")}).
KURALLAR (kesin):
- SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme.
@@ -74,7 +109,7 @@ KURALLAR (kesin):
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
- 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.
@@ -82,9 +117,53 @@ KURALLAR (kesin):
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<typeof AiTercihSchema>;
/**
* 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<typeof AiRaporGevsek>,
havuzMap: Map<string, AdayProgram>,
): Record<Dilim, AiSecim[]> {
const gruplar: Record<Dilim, AiSecim[]> = {
hayal: [],
dengeli: [],
garanti: [],
};
const gorulen = new Set<string>();
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").
@@ -161,6 +240,9 @@ function mockRapor(
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
@@ -168,18 +250,13 @@ function mockRapor(
.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),
...dilimden("hayal", hedef.hayal),
...dilimden("dengeli", hedef.dengeli),
...dilimden("garanti", hedef.garanti),
];
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) => ({
const tercihler = secilen.map((p, i) => ({
programId: p.id,
sira: i + 1,
dilim: p.dilim,
@@ -190,7 +267,7 @@ function mockRapor(
}));
const programlar: RaporSonuc["programlar"] = {};
for (const p of secilen.slice(0, 24)) {
for (const p of secilen) {
programlar[p.id] = programBilgisi(p);
}
@@ -198,14 +275,15 @@ function mockRapor(
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ı 24 tercihlik örnek liste kuruldu.${
)}. 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 3 tercihi asla silme.",
"(Dev mock) Garanti dilimindeki son tercihleri asla silme.",
],
programlar,
kapsam: kapsamUret(hedef),
};
}
@@ -215,12 +293,18 @@ export async function raporUret(
oncekiListeOzeti?: string,
): Promise<RaporSonuc> {
const { havuz, gevsetildi } = havuzOlustur(params);
if (havuz.length < 24) {
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 ||
@@ -230,7 +314,7 @@ export async function raporUret(
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.",
`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")) {
@@ -266,60 +350,98 @@ export async function raporUret(
.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 sonuc: z.infer<typeof AiRaporSchema> | null = null;
let secim: Record<Dilim, AiSecim[]> | null = null;
let sonSecim: Record<Dilim, AiSecim[]> | null = null;
let genel = "";
let uyarilar: string[] = [];
let sonHata = "";
for (let deneme = 0; deneme < 2; deneme++) {
const parsed = await yapilandirilmisUret({
system: SISTEM,
system: sistemMetni(hedef),
kullanici:
deneme === 0
? kullaniciMesaji
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
sema: AiRaporSchema,
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) {
// Dilim uzunlukları (5/13/6) şemada sabit: sayı tutmazsa da buraya düşer
sonHata = "Çıktı şemaya uymadı (hayal 5, dengeli 13, garanti 6 olmalı).";
sonHata = `Çıktı şemaya uymadı (hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti} olmalı).`;
continue;
}
// 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;
// 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;
}
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 (yanlisDilim.length > 0) {
sonHata = `Programlar yanlış dilim listesine konmuş: ${yanlisDilim.slice(0, 3).join(", ")}. Havuzdaki "dilim" alanına birebir uy.`;
continue;
}
sonuc = parsed;
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 (!sonuc) {
throw new RaporUretimHatasi(
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
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<Dilim, AiSecim[]> = {
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"] = {};
@@ -328,19 +450,11 @@ export async function raporUret(
}
// 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))
// 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)!;
@@ -355,9 +469,12 @@ export async function raporUret(
});
return {
tercihler,
genelDegerlendirme: sonuc.genelDegerlendirme,
uyarilar: sonuc.uyarilar,
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),
};
}