diff --git a/.claude/settings.json b/.claude/settings.json index d5eafa3..107d899 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,7 @@ { "enabledPlugins": { - "superpowers@superpowers-marketplace": true, - "prd-development@pm-skills": true + "superpowers@superpowers-marketplace": false, + "prd-development@pm-skills": false }, "extraKnownMarketplaces": { "superpowers-marketplace": { diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md deleted file mode 100644 index 9470cfc..0000000 --- a/.claude/skills/grill-me/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: grill-me -description: A relentless interview to sharpen a plan or design. -disable-model-invocation: true ---- - -Run a `/grilling` session. diff --git a/.claude/skills/grilling/SKILL.md b/.claude/skills/grilling/SKILL.md deleted file mode 100644 index 95bd01e..0000000 --- a/.claude/skills/grilling/SKILL.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: grilling -description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases. ---- - -Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it. - -Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled — the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round. - -Each question should be formatted like so: - -``` -❓ **Q1** - ****: - -➡️ -``` - -Each round the user answers reshapes the tree — settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one. - -Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it — don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report — ask the rest of the frontier now. The _decisions_ are the user's — put each to them and wait. - -The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding. diff --git a/Dockerfile b/Dockerfile index 275fbde..66a56ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,10 +69,11 @@ COPY drizzle ./drizzle COPY scripts/db-goc.mjs ./scripts/db-goc.mjs COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh -# SQLite veri dizini +# SQLite veri dizini + Next'in runtime'da yazdığı image/fetch cache dizini +# (.next'in geri kalanı root'ta kalır; salt-okunur olması sorun değil) RUN chmod +x /app/docker-entrypoint.sh && \ - mkdir -p /app/data && \ - chown -R nextjs:nodejs /app/data /app/seed + mkdir -p /app/data /app/.next/cache && \ + chown -R nextjs:nodejs /app/data /app/seed /app/.next/cache USER nextjs diff --git a/data/app.db b/data/app.db index 8401bcc..64ac060 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/src/app/giris/dogrula/page.tsx b/src/app/giris/dogrula/page.tsx new file mode 100644 index 0000000..9be3acc --- /dev/null +++ b/src/app/giris/dogrula/page.tsx @@ -0,0 +1,102 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { ArrowRight } from "lucide-react"; +import { PixelCorner } from "@/components/pixel-decor"; +import { Button } from "@/components/ui/button"; + +export const metadata: Metadata = { + title: "Girişini onayla", + robots: { index: false, follow: false }, +}; + +/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */ +function guvenliYol(deger: string | undefined, varsayilan: string): string { + return deger && deger.startsWith("/") && !deger.startsWith("//") + ? deger + : varsayilan; +} + +/** + * Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu + * (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail + * güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce + * açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya + * gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları + * sayfayı GET'lese bile form'u göndermez, token yanmaz. + */ +export default async function DogrulaPage({ + searchParams, +}: { + searchParams: Promise<{ + token?: string; + callbackURL?: string; + errorCallbackURL?: string; + newUserCallbackURL?: string; + }>; +}) { + const { token, callbackURL, errorCallbackURL, newUserCallbackURL } = + await searchParams; + if (!token) redirect("/giris"); + + const callback = guvenliYol(callbackURL, "/"); + const hataCallback = guvenliYol( + errorCallbackURL, + `/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`, + ); + const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback); + + return ( +
+
+
+
+

Girişini onayla

+

+ Bu bağlantı senin için üretildi ve{" "} + tek kullanımlık. Aşağıdaki + butona tıkladığında KolayTercih hesabına giriş yapacaksın. +

+
+ + + + + +
+

+ Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin — butona + tıklanmadığı sürece hesabında hiçbir şey değişmez. +

+
+
+ + + + +
+
+
+
+ ); +} diff --git a/src/app/giris/giris-form.tsx b/src/app/giris/giris-form.tsx index 46d0a3e..2a14fe6 100644 --- a/src/app/giris/giris-form.tsx +++ b/src/app/giris/giris-form.tsx @@ -74,6 +74,10 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) { const { error } = await authClient.signIn.magicLink({ email, callbackURL, + // Geçersiz/süresi dolmuş token'da kullanıcı ham ?error=INVALID_TOKEN + // parametresiyle baş başa kalmasın: giriş sayfası bu değeri dostça bir + // mesaja çevirir, callback korunur (bkz. src/app/giris/page.tsx). + errorCallbackURL: `/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callbackURL)}`, }); setBusy(null); if (error) { @@ -93,7 +97,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {

{email} adresine bir giriş bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et — - bağlantı 5 dakika geçerli. + bağlantı 15 dakika geçerli.

diff --git a/src/app/giris/page.tsx b/src/app/giris/page.tsx index e62dd7d..db2bfe3 100644 --- a/src/app/giris/page.tsx +++ b/src/app/giris/page.tsx @@ -14,11 +14,15 @@ export const metadata: Metadata = { export default async function GirisPage({ searchParams, }: { - searchParams: Promise<{ callback?: string }>; + searchParams: Promise<{ callback?: string; hata?: string }>; }) { - const { callback } = await searchParams; + const { callback, hata } = await searchParams; const callbackURL = callback && callback.startsWith("/") ? callback : "/"; + // Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür + // (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine + // dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz. + const baglantiHatasi = hata === "gecersiz-baglanti"; const session = await getSession(); if (session) redirect(callbackURL); @@ -33,6 +37,20 @@ export default async function GirisPage({
+ {baglantiHatasi && ( +
+

+ Giriş bağlantısı geçersiz ya da süresi dolmuş +

+

+ Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan + yeni bir bağlantı isteyebilirsin. +

+
+ )}

{funnel ? ( diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx index 681ade9..a49a1a9 100644 --- a/src/app/listem/page.tsx +++ b/src/app/listem/page.tsx @@ -118,8 +118,8 @@ export default async function ListemPage({ className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800" > - Paketin aktif — 24 tercihinin tamamı, gerekçeler, riskler ve PDF - artık açık. + Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve + PDF artık açık.

) : null} {/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında diff --git a/src/app/meraklisina/huni-semasi.tsx b/src/app/meraklisina/huni-semasi.tsx index 2c70cf9..8571752 100644 --- a/src/app/meraklisina/huni-semasi.tsx +++ b/src/app/meraklisina/huni-semasi.tsx @@ -18,12 +18,14 @@ flowchart TD H --> S{"Havuzda 24'ten az program mı kaldı?"} S -- "evet" --> G("Filtreler adım adım gevşetilir
önce il, sonra alan — listede açıkça belirtilir") G --> H - S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer
havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar") + H --> U("Havuz yine de dar kalırsa
liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir") + S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer
havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar") + U --> Y Y --> R("Risk notu ve trend özeti
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") + R --> L("En fazla 24 tercihlik liste") + L --> L1("üstte hayal — hedef 5, havuz elverdiğince") + L --> L2("ortada dengeli — hedef 13, havuz elverdiğince") + L --> L3("sonda garanti — hedef 6, havuz elverdiğince") classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e @@ -31,7 +33,7 @@ flowchart TD classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46 class H,Y havuz - class G uyari + class G,U uyari class L1 hayal class L2 dengeli class L3 garanti`; @@ -40,14 +42,15 @@ export function HuniSemasi() { return ( Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "} dışına asla çıkmadan {" "} - kurar. + kurar — havuz dar kalırsa liste eldeki programlarla kurulur ve bunu + listende açıkça görürsün. } /> diff --git a/src/app/meraklisina/page.tsx b/src/app/meraklisina/page.tsx index 3aa121b..445ac0d 100644 --- a/src/app/meraklisina/page.tsx +++ b/src/app/meraklisina/page.tsx @@ -150,7 +150,9 @@ export default function MeraklisinaPage() { aday havuzu{" "} olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz verilir. Aşağıdaki şema akışın tamamını gösterir — havuz - 24'ün altına düşerse filtrelerin nasıl gevşetildiği de dahil. + 24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de + yetmezse listenin eldeki programlarla, sana açıkça belirtilerek + nasıl kurulduğu da dahil.

diff --git a/src/app/rapor/yazdir/page.tsx b/src/app/rapor/yazdir/page.tsx index 2820901..0c026e8 100644 --- a/src/app/rapor/yazdir/page.tsx +++ b/src/app/rapor/yazdir/page.tsx @@ -47,13 +47,21 @@ export default async function YazdirPage() {

- 24 Tercihlik Kişisel Liste ve Risk Raporu + {rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu

Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan türü: {PUAN_TURLERI[params.tur]} {params.il ? ` · İl: ${params.il}` : ""} · {tarih}

+ {rapor.kapsam?.uyarlandi ? ( +

+ Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; + liste {rapor.kapsam.dagilim.hayal} hayal ·{" "} + {rapor.kapsam.dagilim.dengeli} dengeli ·{" "} + {rapor.kapsam.dagilim.garanti} garanti olarak kuruldu. +

+ ) : null}
diff --git a/src/components/rapor-listesi.tsx b/src/components/rapor-listesi.tsx index 6356a20..9486fb8 100644 --- a/src/components/rapor-listesi.tsx +++ b/src/components/rapor-listesi.tsx @@ -333,9 +333,18 @@ export function RaporListesi({

) : null} + {rapor.kapsam?.uyarlandi ? ( +

+ Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "} + {rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "} + dengeli · {rapor.kapsam.dagilim.garanti} garanti olarak kuruldu. Daha + fazla seçenek için alan veya il filtreni genişletebilirsin. +

+ ) : null} + {tekIl ? (

- Listenin 24 tercihi de tek şehirde ( + Listenin {rapor.tercihler.length} tercihi de tek şehirde ( {tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için birkaç farklı şehir eklemeyi düşünebilirsin.

diff --git a/src/lib/ai/cagri.ts b/src/lib/ai/cagri.ts index 6fb0eff..8c6c866 100644 --- a/src/lib/ai/cagri.ts +++ b/src/lib/ai/cagri.ts @@ -75,6 +75,14 @@ export async function yapilandirilmisUret(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 | null> { // Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da @@ -98,13 +106,16 @@ export async function yapilandirilmisUret(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 diff --git a/src/lib/ai/rapor.ts b/src/lib/ai/rapor.ts index 2b10134..cb15fe7 100644 --- a/src/lib/ai/rapor.ts +++ b/src/lib/ai/rapor.ts @@ -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; + uyarlandi: boolean; +}; + // uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel export type RaporSonuc = Omit, "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, "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; + +/** + * 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, + havuzMap: Map, +): Record { + const gruplar: Record = { + hayal: [], + dengeli: [], + garanti: [], + }; + const gorulen = new Set(); + 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 { 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 | null = null; + let secim: Record | null = null; + let sonSecim: Record | 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 = { + 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), }; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 14eca56..7ebd96d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,20 +6,37 @@ import { grantCredits, DENEME_KREDISI } from "./credits"; import { magicLinkEpostasi } from "./eposta"; import { epostaGonder } from "./eposta-gonder"; +/** + * Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki + * GET'lendiği anda token'ı tüketen tek kullanımlık bir uçtur; mail güvenlik + * tarayıcıları (SafeLinks vb.) ve tarayıcı ön-yüklemeleri linki kullanıcıdan + * önce "ziyaret edip" token'ı yakıyordu. Maildeki link bu yüzden token'ı + * tüketmeyen /giris/dogrula sayfasına gider; verify ancak oradaki butonla + * tetiklenir. + */ +function onaySayfasiUrl(verifyUrl: string): string { + const kaynak = new URL(verifyUrl); + const hedef = new URL("/giris/dogrula", kaynak.origin); + hedef.search = kaynak.search; + return hedef.toString(); +} + async function sendMagicLinkEmail(email: string, url: string) { if (process.env.NODE_ENV !== "production") { // Dev paneli "anında giriş" için son üretilen linki yakalar (bkz. - // src/lib/dev/actions.ts) — prod'da asla çalışmaz. + // src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify + // URL'ine gittiği için ham link saklanır. Prod'da asla çalışmaz. (globalThis as { __sonMagicLink?: string }).__sonMagicLink = url; } + const mailLinki = onaySayfasiUrl(url); if (!process.env.RESEND_API_KEY) { // Dev fallback: Resend anahtarı yoksa linki terminale yaz - console.log(`\n[giris] Magic link for ${email}:\n${url}\n`); + console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`); return; } // Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı // "gönderildi" ekranını görür ama mail hiç çıkmaz. - await epostaGonder(email, magicLinkEpostasi(url)); + await epostaGonder(email, magicLinkEpostasi(mailLinki)); } export const auth = betterAuth({ @@ -54,6 +71,10 @@ export const auth = betterAuth({ }, plugins: [ magicLink({ + // Varsayılan 5 dk, mail gecikmesi + onay adımı için 15 dk'ya çıkarıldı. + // Süreyi değiştirirken e-posta metnini (src/lib/eposta.ts) ve giriş + // ekranındaki "gönderildi" mesajını (giris-form.tsx) da güncelle. + expiresIn: 900, sendMagicLink: async ({ email, url }) => { await sendMagicLinkEmail(email, url); }, diff --git a/src/lib/eposta.ts b/src/lib/eposta.ts index a1aef35..3805927 100644 --- a/src/lib/eposta.ts +++ b/src/lib/eposta.ts @@ -126,7 +126,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {

Aşağıdaki butona tıklayarak KolayTercih hesabına giriş yapabilirsin. - Bağlantı 5 dakika geçerli ve + Bağlantı 15 dakika geçerli ve yalnızca bir kez kullanılabilir.

${buton(guvenliUrl, "Giriş yap")} @@ -142,7 +142,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik { return { subject: "Merhaba, giriş bağlantını gönderdik", html: kabuk({ - onizleme: "Giriş bağlantın 5 dakika geçerli.", + onizleme: "Giriş bağlantın 15 dakika geçerli.", govde, baslikEtiketi: "Merhaba, giriş bağlantını gönderdik", }), @@ -152,7 +152,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik { "Hesabına giriş yapmak için bu adresi tarayıcında aç:", url, "", - "Bağlantı 5 dakika geçerli ve yalnızca bir kez kullanılabilir.", + "Bağlantı 15 dakika geçerli ve yalnızca bir kez kullanılabilir.", "Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.", ].join("\n"), }; diff --git a/src/lib/rapor-havuzu.ts b/src/lib/rapor-havuzu.ts index a4a01cb..269410f 100644 --- a/src/lib/rapor-havuzu.ts +++ b/src/lib/rapor-havuzu.ts @@ -34,6 +34,46 @@ export interface HavuzSonuc { const HEDEF = 24; const LIMIT = 60; + +export type DilimHedef = Record; + +// 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 diff --git a/src/lib/rapor-maske.ts b/src/lib/rapor-maske.ts index 4746b0d..1521b7f 100644 --- a/src/lib/rapor-maske.ts +++ b/src/lib/rapor-maske.ts @@ -48,5 +48,8 @@ export function raporMaskele(rapor: RaporSonuc): MaskeliRapor { kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1), // Program adı, üniversite ve 2021–2025 taban sıralamaları ham veridir. programlar: rapor.programlar, + // Kapsam (kaç program bulundu / dağılım uyarlandı mı) dürüstlük bilgisi: + // paketsiz kullanıcı da görür, maskelenmez. + kapsam: rapor.kapsam, }; }