v2 #10

Merged
bilal merged 34 commits from v2 into main 2026-10-11 01:38:50 +03:00
16 changed files with 206 additions and 9 deletions
Showing only changes of commit e20a45c766 - Show all commits

View File

@@ -0,0 +1,79 @@
/**
* Program varyant etiketinin MEKANİK testi — parasız, anahtarsız, salt okur.
*
* pnpm tsx scripts/eval/varyant-denetim.ts
*
* react-server koşulu gerekmez: db.ts ve program-varyant.ts saf/sqlite.
* Doğrulananlar: (1) varyantEtiketi birim kuralları, (2) Başkent Tıp'ın 6
* satırının etiketleri birbirinden farklı ve hiçbiri null, (3) dilimAra'nın
* döndürdüğü her satırda `varyant` tanımlı (SELECT_COLS tüm yolu kapsıyor),
* (4) devlet/Türkçe/örgün programda null. data/yokatlas.db'ye yazılmaz.
* Beklenti tutmazsa çıkış kodu 1.
*/
import { dilimAra, programGetir } from "../../src/lib/db";
import { varyantEtiketi, type VaryantGirdisi } from "../../src/lib/program-varyant";
let hata = 0;
function bekle(ad: string, kosul: boolean, detay = ""): void {
console.log(`${kosul ? "✔" : "✘"} ${ad}${detay ? ` — ${detay}` : ""}`);
if (!kosul) hata++;
}
// 1. Birim: etiket kuralları
const birim: [VaryantGirdisi, string | null][] = [
[{ bursOrani: "Burslu", ogrenimDili: "İngilizce", ogrenimTuru: "Örgün Öğretim" }, "Burslu · İngilizce"],
[{ bursOrani: null, ogrenimDili: "Türkçe", ogrenimTuru: "Örgün Öğretim" }, null],
[{ bursOrani: "Ücretli", ogrenimDili: "Türkçe", ogrenimTuru: "İkinci Öğretim" }, "Ücretli · İkinci Öğretim"],
[{ bursOrani: "%50 İndirimli", ogrenimDili: null, ogrenimTuru: null }, "%50 İndirimli"],
// DB'de burs_orani boş string de geliyor (devlet); null gibi davranır
[{ bursOrani: "", ogrenimDili: "Türkçe", ogrenimTuru: "Uzaktan Öğretim" }, "Uzaktan Öğretim"],
];
for (const [girdi, beklenen] of birim) {
const sonuc = varyantEtiketi(girdi);
bekle(
`birim ${JSON.stringify(girdi)}`,
sonuc === beklenen,
`beklenen ${JSON.stringify(beklenen)}, gelen ${JSON.stringify(sonuc)}`,
);
}
// 2. Başkent Tıp ×6: isim hepsinde "Tıp", varyant hepsinde farklı
const BASKENT_TIP = ["200610657", "200610666", "200611488", "200690357", "200690358", "200690359"];
const baskent = BASKENT_TIP.map((id) => ({ id, p: programGetir(id) }));
for (const { id, p } of baskent) {
bekle(
`programGetir(${id}) varyant dolu`,
p != null && p.varyant != null,
p ? `${p.universite} / ${p.isim} → ${p.varyant}` : "bulunamadı",
);
}
const etiketler = baskent.map(({ p }) => p?.varyant ?? null);
bekle(
"Başkent Tıp 6 varyant birbirinden farklı",
new Set(etiketler).size === BASKENT_TIP.length,
etiketler.join(" | "),
);
// 3. dilimAra yolu: her satırda varyant tanımlı (string ya da null)
const { programlar } = dilimAra(100000, "say", "dengeli", { limit: 50 });
const tanimsiz = programlar.filter(
(p) => !("varyant" in p) || (p.varyant !== null && typeof p.varyant !== "string"),
);
bekle(
`dilimAra 50 satırda varyant tanımlı (${programlar.length} satır)`,
programlar.length > 0 && tanimsiz.length === 0,
tanimsiz.map((p) => p.id).join(","),
);
const etiketli = programlar.filter((p) => p.varyant).length;
console.log(` (bilgi) dilimAra örneğinde etiketli satır: ${etiketli}/${programlar.length}`);
// 4. Devlet / Türkçe / örgün → null (Marmara Hukuk)
const marmara = programGetir("107210332");
bekle(
"107210332 Marmara Hukuk varyant null",
marmara != null && marmara.varyant === null,
marmara ? `${marmara.universite} / ${marmara.isim} → ${JSON.stringify(marmara.varyant)}` : "bulunamadı",
);
console.log(hata === 0 ? "\nVaryant denetimi: TEMİZ" : `\nVaryant denetimi: ${hata} hata`);
process.exit(hata === 0 ? 0 : 1);

View File

@@ -22,6 +22,7 @@ import {
SATIR_BUTON_BASLIK,
SATIR_BUTON_HUCRE,
SATIR_BUTON_KABI,
SATIR_CIP,
SATIR_GRUP,
SATIR_IKINCIL_LINK,
SATIR_META,
@@ -242,6 +243,10 @@ export function BolumProgramListesi({
)}
<span className={SATIR_META}>
<TurCipi unitur={p.unitur} />
{/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
{p.varyant ? (
<span className={SATIR_CIP}>{p.varyant}</span>
) : null}
{p.il ? (
<Link
href={ilSayfaHref(p.il)}

View File

@@ -107,6 +107,10 @@ export function UniversiteProgramTablosu({
<span className={SATIR_CIP}>
{TUR_ETIKET[p.tur] ?? p.tur}
</span>
{/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
{p.varyant ? (
<span className={SATIR_CIP}>{p.varyant}</span>
) : null}
{p.sure ? (
<span className="shrink-0">{p.sure} yıl</span>
) : null}

View File

@@ -452,6 +452,10 @@ function CekmeceSatiri({
>
{trBaslikDuzeni(tercih.universite)}
</Link>
{/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder */}
{tercih.varyant ? (
<span className="shrink-0 text-slate-500">· {tercih.varyant}</span>
) : null}
</span>
{tercih.risk || tercih.efektifSira != null ? (
<span className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-500">

View File

@@ -28,6 +28,7 @@ type ProgramOzet = Pick<
| "unitur"
| "tur"
| "efektifSira"
| "varyant"
>;
const PROFILSIZ_EKLEME_TOAST_ID = "profilsiz-ekleme";

View File

@@ -55,6 +55,7 @@ import {
SATIR_BUTON_HUCRE,
SATIR_BUTON_KABI,
SATIR_ALT_GRUP,
SATIR_CIP,
SATIR_IKINCIL_LINK,
SATIR_META,
SATIR_MOBIL_SAYI,
@@ -373,6 +374,10 @@ function ProgramSatiri({
unitur={p.unitur}
onClick={(e) => e.stopPropagation()}
/>
{/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
{p.varyant ? (
<span className={SATIR_CIP}>{p.varyant}</span>
) : null}
{p.il ? (
<Link
href={ilSayfaHref(p.il)}

View File

@@ -29,6 +29,7 @@ export type BekleyenProgram = {
| "unitur"
| "tur"
| "efektifSira"
| "varyant"
>;
kaynak: ManuelKaynak;
adaySira?: number;
@@ -271,11 +272,16 @@ export function programEkleIste(opts: {
}
const profil = profilsiz ? null : (durum.profil ?? tercihProfiliOku());
const tercih = programdanManuelTercih(
program,
adaySira ?? profil?.sira ?? null,
kaynak,
);
// programdanManuelTercih (liste-store) varyantı bilmez; etiket burada
// kopyalanır ki çekmece aynı adlı satırları ayırt edebilsin.
const tercih = {
...programdanManuelTercih(
program,
adaySira ?? profil?.sira ?? null,
kaynak,
),
varyant: program.varyant ?? null,
};
const ok = ekle(tercih);
if (!ok) {
return {

View File

@@ -30,6 +30,8 @@ export type ManuelTercih = {
/** Ekleme / profil anındaki aday sıralamasına göre risk */
risk: RiskSeviyesi | null;
kaynak?: ManuelKaynak;
/** Burs/dil/öğretim türü etiketi (Program.varyant); eski kayıtlarda yok */
varyant?: string | null;
};
/** Güvenilmeyen JSON'u (localStorage veya sunucu kaydı) listeye süzer. */
@@ -66,6 +68,7 @@ export function dogrula(ham: unknown): ManuelTercih[] {
: null,
risk,
kaynak,
varyant: typeof kayit.varyant === "string" ? kayit.varyant : null,
});
if (sonuc.length >= MANUEL_LISTE_MAX) break;
}

View File

@@ -25,6 +25,7 @@ export function programOzetle(p: Program): ProgramListeSatiri {
efektifSira: p.efektifSira,
kontenjanSon: p.kontenjanSon,
yerlesenSon: p.yerlesenSon,
varyant: p.varyant,
};
}

View File

@@ -41,6 +41,7 @@ import {
SATIR_BUTON_HUCRE,
SATIR_DUGME_BOYU,
SATIR_ALT_GRUP,
SATIR_CIP,
SATIR_IKINCIL_LINK,
SATIR_META,
SATIR_MOBIL_SAYI,
@@ -267,6 +268,10 @@ export function RaporListesi({
unitur={p?.unitur}
onClick={(e) => e.stopPropagation()}
/>
{/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
{p?.varyant ? (
<span className={SATIR_CIP}>{p.varyant}</span>
) : null}
{p?.il ? (
<Link
href={ilSayfaHref(p.il)}

View File

@@ -2,6 +2,8 @@ import "server-only";
import { desc, eq, sql } from "drizzle-orm";
import { appDb, schema } from "@/lib/appdb";
import { anahtarVar } from "@/lib/ai/client";
import { programOzellikleriGetir } from "@/lib/program-ozellik";
import { varyantEtiketi } from "@/lib/program-varyant";
import type { RaporSonuc } from "./types/rapor";
// Rapor feature'ının sorgu kapısı: kayıtlı rapor, sohbet geçmişi ve girişsiz
@@ -37,9 +39,23 @@ export async function kullanicininRaporu(userId: string) {
where: eq(schema.reports.userId, userId),
});
if (satir?.result && bayatMockMu(satir.result as RaporSonuc)) return null;
if (satir?.result) varyantlariEkle(satir.result as RaporSonuc);
return satir ?? null;
}
// Kayıtlı raporun program satırlarına burs/dil/öğretim türü etiketi (okuma
// anında, tek sorgu): rapor JSON'u üretim anında bunu taşımıyor, eski
// kayıtlar da bu yoldan kazanır. yokatlas.db salt okunur; app.db'ye yazılmaz.
function varyantlariEkle(sonuc: RaporSonuc): void {
const idler = Object.keys(sonuc.programlar ?? {});
if (idler.length === 0) return;
const ozellikler = programOzellikleriGetir(idler);
for (const id of idler) {
const o = ozellikler[id];
sonuc.programlar[id].varyant = o ? varyantEtiketi(o) : null;
}
}
/**
* Kullanıcının danışman sohbet geçmişi: en YENİ 100 mesaj, eskiden yeniye.
* created_at saniye çözünürlüklü; aynı saniyedeki soru-cevap sırası rowid ile

View File

@@ -58,6 +58,9 @@ export type RaporSonuc = {
efektifSira?: number | null;
siraGecmisi?: (number | null)[];
ilkYil?: number;
/** Burs/dil/öğretim türü etiketi; DB'ye yazılmaz, okuma anında
* program_ozellik'ten eklenir (rapor-queries kullanicininRaporu) */
varyant?: string | null;
}
>;
};

View File

@@ -5,6 +5,7 @@ import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
import { EFEKTIF_SIRA_SQL, ILK_YIL, SON_YIL, YILLAR } from "./veri-yillari";
import { turkishSlugify } from "./slug";
import { varyantEtiketi } from "./program-varyant";
import {
PUAN_TURLERI,
type ArsivProgram,
@@ -37,20 +38,33 @@ export function getDb(): Database.Database {
// Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB
// dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır
// `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir).
// Burs/dil/öğretim türü `program_ozellik`'ten ilişkili alt sorguyla gelir
// (id PRIMARY KEY). Bütün SELECT_COLS kullanımları takma adsız `FROM programs`
// olduğu için `programs.id` çözümlenir; `programs_arsiv` (ARSIV_SELECT) bu
// kolonları almaz (arşivde özellik yok). Tablo yokluğu koruması gerekmez:
// data/yokatlas.db depoda ve Docker seed'inde tabloyla gelir.
export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")},
puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon`;
puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon,
(SELECT burs_orani FROM program_ozellik o WHERE o.id = programs.id) AS bursOrani,
(SELECT ogrenim_dili FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimDili,
(SELECT ogrenim_turu FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimTuru`;
// Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer.
// İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts.
export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL;
/** SELECT_COLS ile okunan ham satır: sıra kolonları `s<yıl>` takma adıyla gelir. */
/** SELECT_COLS ile okunan ham satır: sıra kolonları `s<yıl>` takma adıyla,
* varyant ise üç ham özellik alanı olarak gelir. */
export type ProgramHamSatiri = Omit<
Program,
"siraGecmisi" | "efektifSira" | "siraSon"
"siraGecmisi" | "efektifSira" | "siraSon" | "varyant"
> &
Record<`s${number}`, number | null>;
Record<`s${number}`, number | null> & {
bursOrani: string | null;
ogrenimDili: string | null;
ogrenimTuru: string | null;
};
/** Ham DB satırını yılsız `Program`'a çevirir. */
export function programSatiri(row: ProgramHamSatiri): Program {
@@ -72,6 +86,7 @@ export function programSatiri(row: ProgramHamSatiri): Program {
puanSon: row.puanSon,
kontenjanSon: row.kontenjanSon,
yerlesenSon: row.yerlesenSon,
varyant: varyantEtiketi(row),
};
}

View File

@@ -0,0 +1,40 @@
// Program varyant etiketi — saf, DB'siz. `programs.isim` burs/dil/öğretim
// türünü taşımaz (Başkent Tıp 6 satırda da "Tıp"); ayırt edici alanlar
// `program_ozellik.burs_orani / ogrenim_dili / ogrenim_turu`. Bu modül o üç
// alanı satır altında gösterilecek tek etikete çevirir; db.ts `programSatiri`
// ve rapor-queries (kayıtlı rapor) aynı fonksiyonu kullanır.
export type VaryantGirdisi = {
/** "Burslu" | "%50 İndirimli" | "%25 İndirimli" | "Ücretli"; devlette null/boş */
bursOrani: string | null;
/** "Türkçe" | "İngilizce" | "İngilizce (%30)" | "Almanca" … */
ogrenimDili: string | null;
/** "Örgün Öğretim" | "Uzaktan Öğretim" | "Açık Öğretim" | "UOLP" … */
ogrenimTuru: string | null;
};
const VARSAYILAN_DIL = "Türkçe";
const VARSAYILAN_OGRETIM = "Örgün Öğretim";
const AYRAC = " · ";
function dolu(deger: string | null | undefined): string | null {
const metin = deger?.trim();
return metin ? metin : null;
}
/**
* Satır altı etiketi: "Burslu · İngilizce", "%50 İndirimli", "İkinci Öğretim",
* "Ücretli · Uzaktan Öğretim". Sıra: burs (varsa, DB'deki hâliyle), dil
* (yalnız Türkçe değilse), öğretim türü (yalnız örgün değilse). Üçü de
* varsayılansa (devlet / Türkçe / örgün) null — satırda hiçbir şey çizilmez.
*/
export function varyantEtiketi(g: VaryantGirdisi): string | null {
const parcalar: string[] = [];
const burs = dolu(g.bursOrani);
if (burs) parcalar.push(burs);
const dil = dolu(g.ogrenimDili);
if (dil && dil !== VARSAYILAN_DIL) parcalar.push(dil);
const ogretim = dolu(g.ogrenimTuru);
if (ogretim && ogretim !== VARSAYILAN_OGRETIM) parcalar.push(ogretim);
return parcalar.length > 0 ? parcalar.join(AYRAC) : null;
}

View File

@@ -433,6 +433,9 @@ export function havuzuKompaktJson(havuz: AdayProgram[]): string {
unitur: p.unitur,
il: p.il,
dilim: p.dilim,
// Burs/dil/öğretim türü: model aynı adlı satırları (Başkent Tıp
// burslu / %50 / ücretli) ayırt etsin, gerekçede "burslu" diyebilsin
...(p.varyant ? { varyant: p.varyant } : {}),
// Anahtarlar yıl taşır (y<yıl>, kontenjan<yıl>): model rakamın hangi
// yıla ait olduğunu anahtardan okur.
sira: Object.fromEntries(

View File

@@ -21,6 +21,13 @@ export type Program = {
puanSon: number | null;
kontenjanSon: number | null;
yerlesenSon: number | null;
/**
* Burs / dil / öğretim türü etiketi ("Burslu · İngilizce", "%50 İndirimli",
* "Uzaktan Öğretim"); `program_ozellik`'ten `varyantEtiketi` ile üretilir
* (lib/program-varyant). Devlet/Türkçe/örgün programda null. `isim` aynı
* olan satırları (Başkent Tıp ×6) listede ayırt eden tek alan.
*/
varyant: string | null;
};
export const PUAN_TURLERI = {