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>
194 lines
5.9 KiB
TypeScript
194 lines
5.9 KiB
TypeScript
import {
|
||
searchByRank,
|
||
type Program,
|
||
type PuanTuruKey,
|
||
type UniturGrubu,
|
||
PUAN_TURLERI,
|
||
} from "./db";
|
||
import { kategoriEslesir } from "./kategoriler";
|
||
|
||
export type Dilim = "hayal" | "dengeli" | "garanti";
|
||
|
||
export interface AdayProgram extends Program {
|
||
dilim: Dilim;
|
||
}
|
||
|
||
export interface RaporParams {
|
||
sira: number;
|
||
tur: PuanTuruKey;
|
||
// Sihirbaz seçimleri (yeni akış)
|
||
kategoriler?: string[];
|
||
iller?: string[];
|
||
universiteTipi?: UniturGrubu | "farketmez";
|
||
oncelikler?: string[];
|
||
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
|
||
il?: string;
|
||
notlar?: string;
|
||
}
|
||
|
||
export interface HavuzSonuc {
|
||
havuz: AdayProgram[];
|
||
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi
|
||
gevsetildi: ("kategori" | "il")[];
|
||
}
|
||
|
||
const HEDEF = 24;
|
||
const LIMIT = 60;
|
||
|
||
export type DilimHedef = Record<Dilim, number>;
|
||
|
||
// Listenin ideal iskeleti: 5 hayal / 13 dengeli / 6 garanti = 24 tercih.
|
||
export const IDEAL_HEDEF: DilimHedef = { hayal: 5, dengeli: 13, garanti: 6 };
|
||
|
||
/**
|
||
* Havuzun gerçek dilim dağılımına uyarlanmış liste hedefi. İdeal 5/13/6'dan
|
||
* başlar; bir dilimde yeterli aday yoksa eksik, kapasitesi kalan dilimlere
|
||
* dağıtılır (öncelik: dengeli → garanti → hayal — omurga önce, sigorta sonra,
|
||
* en riskli dilim en son). Havuz 24'ün altındaysa hedef havuzun tamamıdır.
|
||
* Deterministiktir: aynı havuz her zaman aynı hedefi üretir.
|
||
*/
|
||
export function dilimHedefiHesapla(havuz: AdayProgram[]): DilimHedef {
|
||
const mevcut: DilimHedef = { hayal: 0, dengeli: 0, garanti: 0 };
|
||
for (const p of havuz) mevcut[p.dilim]++;
|
||
|
||
const toplamHedef = Math.min(HEDEF, havuz.length);
|
||
const hedef: DilimHedef = {
|
||
hayal: Math.min(IDEAL_HEDEF.hayal, mevcut.hayal),
|
||
dengeli: Math.min(IDEAL_HEDEF.dengeli, mevcut.dengeli),
|
||
garanti: Math.min(IDEAL_HEDEF.garanti, mevcut.garanti),
|
||
};
|
||
|
||
const oncelik: Dilim[] = ["dengeli", "garanti", "hayal"];
|
||
let eksik = toplamHedef - hedef.hayal - hedef.dengeli - hedef.garanti;
|
||
while (eksik > 0) {
|
||
let dagitildi = false;
|
||
for (const d of oncelik) {
|
||
if (eksik <= 0) break;
|
||
if (mevcut[d] > hedef[d]) {
|
||
hedef[d]++;
|
||
eksik--;
|
||
dagitildi = true;
|
||
}
|
||
}
|
||
if (!dagitildi) break;
|
||
}
|
||
return hedef;
|
||
}
|
||
// 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[];
|
||
dengeli: Program[];
|
||
garanti: Program[];
|
||
}): AdayProgram[] {
|
||
return [
|
||
...r.hayal.map((p) => ({ ...p, dilim: "hayal" as const })),
|
||
...r.dengeli.map((p) => ({ ...p, dilim: "dengeli" as const })),
|
||
...r.garanti.map((p) => ({ ...p, dilim: "garanti" as const })),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz.
|
||
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz
|
||
* 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste
|
||
* her zaman kurulabilsin.
|
||
*/
|
||
export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||
const uniturGrubu =
|
||
params.universiteTipi && params.universiteTipi !== "farketmez"
|
||
? params.universiteTipi
|
||
: undefined;
|
||
const kategoriler = params.kategoriler ?? [];
|
||
const iller = [
|
||
...(params.il ? [params.il] : []),
|
||
...(params.iller ?? []),
|
||
].filter((v, i, a) => v && a.indexOf(v) === i);
|
||
|
||
const kategoriFiltre = (havuz: AdayProgram[]) =>
|
||
kategoriler.length > 0
|
||
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler))
|
||
: havuz;
|
||
|
||
// 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS)
|
||
const tamHam = dilimEkle(
|
||
searchByRank(params.sira, params.tur, {
|
||
iller: iller.length > 0 ? iller : undefined,
|
||
uniturGrubu,
|
||
limitPerBucket: LIMIT,
|
||
}),
|
||
);
|
||
const tamFiltreli = kategoriFiltre(tamHam);
|
||
if (tamFiltreli.length >= HEDEF) {
|
||
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: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM),
|
||
gevsetildi: ["kategori"],
|
||
};
|
||
}
|
||
|
||
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
|
||
const gevsetildi: ("kategori" | "il")[] = [];
|
||
if (iller.length > 0) gevsetildi.push("il");
|
||
const ilsizHam = dilimEkle(
|
||
searchByRank(params.sira, params.tur, {
|
||
uniturGrubu,
|
||
limitPerBucket: LIMIT,
|
||
}),
|
||
);
|
||
const ilsizFiltreli = kategoriFiltre(ilsizHam);
|
||
if (ilsizFiltreli.length >= HEDEF) {
|
||
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: dilimBasinaKirp(ilsizHam, PROMPT_LIMIT_DILIM), gevsetildi };
|
||
}
|
||
|
||
/** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */
|
||
export function havuzuKompaktJson(havuz: AdayProgram[]): string {
|
||
return JSON.stringify(
|
||
havuz.map((p) => ({
|
||
id: p.id,
|
||
isim: p.isim,
|
||
uni: p.universite,
|
||
unitur: p.unitur,
|
||
il: p.il,
|
||
dilim: p.dilim,
|
||
sira: {
|
||
y2025: p.sira2025,
|
||
y2024: p.sira2024,
|
||
y2023: p.sira2023,
|
||
y2022: p.sira2022,
|
||
},
|
||
kontenjan2025: p.kontenjan2025,
|
||
yerlesen2025: p.yerlesen2025,
|
||
})),
|
||
);
|
||
}
|
||
|
||
export function turLabel(tur: PuanTuruKey): string {
|
||
return PUAN_TURLERI[tur];
|
||
}
|