feat(veri): 2026 yükleme hattı — yıl kilitli refresh, geçmiş doldurma, arşiv, kalite kapısı (P-A)

- yokatlas-goc: idempotent şema göçü (yıl kolonları, son_kilavuz_yili, eski_kod,
  programs_arsiv, veri_meta) + betiklerin ortak yardımcıları
- refresh: --yil/--db zorunlu, YIL KİLİDİ (…1 = DB sira<yil-1> ≥%95 ve eksiz ≠),
  yıl asla r.yil'den türetilmez; ham veri data/ham/*.jsonl.gz, --ham ile API'siz
  tekrar; benzersiz kod = totalElements; tek transaction + önceki yıl sağlaması;
  yeni programda …1/2/3 → üç önceki yıl; efektif indeks EFEKTIF_SIRA_SQL ile
- gecmis-doldur: yalnız NULL alan; API …1/2/3 → eski_kod → kanıtlı 1:1 ad eşleşmesi
- eski-arsivle: güncel kılavuzda olmayanlar → programs_arsiv, netler yeni koda; --kuru
- detay: netler bağımsız + yıl sağlaması; kırılım yalnız --kirilim, alan yoksa durur,
  mevcut değerin üstüne NULL yazılmaz; `yil - 1` varsayımı kalktı
- veri-kalite: salt okunur kalite kapısı, zorunlu kontrol kalırsa çıkış kodu 1
- docs/veri/yillik-guncelleme.md: adım adım runbook + geri alma

DB commit'lenmedi (arşivleme kararı [Bilal]).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 17:03:01 +03:00
parent 38e02e0ed9
commit a773ac7beb
10 changed files with 2016 additions and 279 deletions

302
scripts/gecmis-doldur.ts Normal file
View File

@@ -0,0 +1,302 @@
/**
* Güncel kılavuzdaki programların GEÇMİŞ yıl kolonlarındaki boşlukları doldurur.
*
* Kurallar (pazarlıksız):
* - Yalnız NULL alan doldurulur. Dolu değer ASLA ezilmez; kaynak farklı bir
* değer söylüyorsa sayılır ve raporlanır (karar insanındır).
* - Tahmin yok, komşu yıldan kopya yok. Kaynak yoksa alan NULL kalır.
* - Belirsiz eşleşmede birleştirme yok (sayılır, raporlanır).
*
* Kaynak önceliği:
* 1. API `…1/…2/…3` alanları (ham dosyadan; API'ye gidilmez) → yıl-1/-2/-3
* için sıra, puan, genel kontenjan.
* 2. `eski_kod` (API `eskiKilavuzKodu`): kodu değişen programın eski satırı
* (programs ya da programs_arsiv) → tüm geçmiş yıllar, dört ölçü.
* Eski kod hâlâ güncel kılavuzdaysa ya da birden çok yeni program aynı
* eski koda işaret ediyorsa (bölünme) → belirsiz, atlanır.
* 3. Aynı (üniversite baz adı, isim, tur, onlisans) için TAM 1 güncel + TAM 1
* eski satır varsa; ortak yıllarda puan/kontenjan/yerleşen ÇELİŞMİYORSA ve
* en az bir ortak değer AYNIYSA (kanıt) → eski satırdan doldurulur, güncel
* satıra `eski_kod` yazılır. Kanıtsız ya da çelişkili eşleşme birleştirilmez.
*
* Kullanım:
* pnpm gecmis-doldur --yil 2026 --db /tmp/deneme.db [--ham data/ham/2026-….jsonl.gz] [--kuru]
*/
import path from "node:path";
import type Database from "better-sqlite3";
import { ILK_YIL } from "../src/lib/veri-yillari";
import { gecmisAlanlari, hamOku, sonHamDosya } from "./refresh";
import {
argOku,
bayrakVar,
calistir,
dbAc,
dbArgumani,
dur,
kolonAdlari,
tabloVar,
yilArgumani,
YIL_OLCULERI,
} from "./yokatlas-goc";
type Satir = Record<string, string | number | null> & { id: string };
const PUAN_TOLERANS = 0.01;
class KuruGeriAl extends Error {}
function ayniMi(olcu: string, a: number, b: number): boolean {
return olcu === "puan" ? Math.abs(a - b) <= PUAN_TOLERANS : a === b;
}
async function main() {
const yil = yilArgumani();
const dbYolu = dbArgumani();
const kuru = bayrakVar("kuru");
const hamArg = argOku("ham");
const hamDosya = hamArg
? path.resolve(process.cwd(), hamArg)
: sonHamDosya(yil, path.resolve(process.cwd(), "data", "ham"));
if (!hamDosya) {
dur(`--ham verilmedi ve data/ham altında ${yil}-*.jsonl.gz yok. Önce refresh koş.`);
}
const db = dbAc(dbYolu);
const kolonlar = new Set(kolonAdlari(db, "programs"));
if (!kolonlar.has("son_kilavuz_yili") || !kolonlar.has(`sira${yil}`)) {
dur("Şema eski: önce `pnpm refresh` (ya da `pnpm yokatlas:goc`) koşulmalı.");
}
const guncelSayisi = (
db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
).n;
if (guncelSayisi === 0) dur(`son_kilavuz_yili=${yil} satır yok: önce refresh koşulmalı.`);
const gecmisYillar: number[] = [];
for (let y = ILK_YIL; y < yil; y++) {
if (kolonlar.has(`sira${y}`)) gecmisYillar.push(y);
}
const olculer = YIL_OLCULERI.map(([o]) => o);
console.log(`DB: ${dbYolu}\nHam: ${hamDosya}${kuru ? "\nKURU KİP: hiçbir şey yazılmayacak" : ""}`);
const { kayitlar } = await hamOku(hamDosya, yil);
const doldurulan = new Map<string, number>(); // "kaynak|kolon" → adet
const farkli = new Map<string, { adet: number; ornek: string[] }>();
const say = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
const farkYaz = (kaynak: string, kolon: string, id: string, db_: number, kaynakDeger: number) => {
const k = `${kaynak}|${kolon}`;
const f = farkli.get(k) ?? { adet: 0, ornek: [] };
f.adet++;
if (f.ornek.length < 3) f.ornek.push(`${id}: DB=${db_} kaynak=${kaynakDeger}`);
farkli.set(k, f);
};
const satirGetir = db.prepare("SELECT * FROM programs WHERE id = ?");
const arsivGetir = tabloVar(db, "programs_arsiv")
? db.prepare("SELECT * FROM programs_arsiv WHERE id = ?")
: null;
const setCache = new Map<string, Database.Statement<unknown[]>>();
const alanYaz = (id: string, kolon: string, deger: number) => {
let st = setCache.get(kolon);
if (!st) {
// Çifte emniyet: WHERE … IS NULL — dolu değerin üstüne yazılamaz.
st = db.prepare(`UPDATE programs SET ${kolon} = ? WHERE id = ? AND ${kolon} IS NULL`);
setCache.set(kolon, st);
}
st.run(deger, id);
};
/** Hedefin NULL alanlarını kaynaktan doldurur; dolu+farklıyı sayar. */
const satirdanDoldur = (kaynakAdi: string, hedef: Satir, kaynak: Satir) => {
for (const y of gecmisYillar) {
for (const olcu of olculer) {
const kolon = `${olcu}${y}`;
const k = kaynak[kolon] as number | null;
if (k == null) continue;
const h = hedef[kolon] as number | null;
if (h == null) {
alanYaz(hedef.id, kolon, k);
hedef[kolon] = k;
say(doldurulan, `${kaynakAdi}|${kolon}`);
} else if (!ayniMi(olcu, h, k)) {
farkYaz(kaynakAdi, kolon, hedef.id, h, k);
}
}
}
};
const ozet = {
apiEslesen: 0,
eskiKodlu: 0,
eskiKodBulunamadi: 0,
eskiKodHalaGuncel: 0,
eskiKodBolunme: 0,
adEslesen: 0,
adBelirsiz: 0,
adPuanCelisik: 0,
adKanitsiz: 0,
};
const ornekler: Record<string, string[]> = { bolunme: [], halaGuncel: [], adEslesen: [], adCelisik: [], adKanitsiz: [] };
const ornekEkle = (k: string, s: string) => {
if (ornekler[k].length < 3) ornekler[k].push(s);
};
try {
db.transaction(() => {
// ── 1. API …1/…2/…3 ────────────────────────────────────────────────
for (const r of kayitlar) {
if (!r.kilavuzKodu) continue;
const hedef = satirGetir.get(String(r.kilavuzKodu)) as Satir | undefined;
if (!hedef || hedef.son_kilavuz_yili !== yil) continue;
ozet.apiEslesen++;
for (const g of gecmisAlanlari(r)) {
const y = yil - g.fark;
if (!gecmisYillar.includes(y)) continue;
for (const olcu of ["sira", "puan", "kontenjan"] as const) {
const k = g[olcu];
if (k == null) continue;
const kolon = `${olcu}${y}`;
const h = hedef[kolon] as number | null;
if (h == null) {
alanYaz(hedef.id, kolon, k);
hedef[kolon] = k;
say(doldurulan, `1-api|${kolon}`);
} else if (!ayniMi(olcu, h, k)) {
farkYaz("1-api", kolon, hedef.id, h, k);
}
}
}
}
// ── 2. eski_kod ────────────────────────────────────────────────────
const eskiKodlular = db
.prepare("SELECT * FROM programs WHERE son_kilavuz_yili = ? AND eski_kod IS NOT NULL")
.all(yil) as Satir[];
const eskiKullanim = new Map<string, number>();
for (const s of eskiKodlular) say(eskiKullanim, s.eski_kod as string);
for (const hedef of eskiKodlular) {
const eskiId = hedef.eski_kod as string;
if ((eskiKullanim.get(eskiId) ?? 0) > 1) {
ozet.eskiKodBolunme++;
ornekEkle("bolunme", `${hedef.id} ← ${eskiId} (${hedef.isim})`);
continue;
}
const kaynak =
(satirGetir.get(eskiId) as Satir | undefined) ??
(arsivGetir?.get(eskiId) as Satir | undefined);
if (!kaynak) {
ozet.eskiKodBulunamadi++;
continue;
}
if (kaynak.son_kilavuz_yili === yil) {
ozet.eskiKodHalaGuncel++;
ornekEkle("halaGuncel", `${hedef.id} ← ${eskiId} (${kaynak.isim})`);
continue;
}
ozet.eskiKodlu++;
satirdanDoldur("2-eski_kod", hedef, kaynak);
}
// ── 3. Benzersiz (universite, isim, tur, onlisans) ─────────────────
const sahiplenilmis = new Set(eskiKullanim.keys());
const gruplar = new Map<string, { guncel: Satir[]; eski: Satir[] }>();
for (const s of db.prepare("SELECT * FROM programs").all() as Satir[]) {
// API 2026'da üniversite adına "(İL)" eki koyuyor; eski satırlar eksiz.
const uniBaz = String(s.universite).replace(/\s*\([^)]*\)\s*$/, "").trim();
const anahtar = JSON.stringify([uniBaz, s.isim, s.tur, s.onlisans]);
const g = gruplar.get(anahtar) ?? { guncel: [], eski: [] };
if (s.son_kilavuz_yili === yil) g.guncel.push(s);
else if (!sahiplenilmis.has(s.id)) g.eski.push(s);
gruplar.set(anahtar, g);
}
const eskiKodYaz = db.prepare(
"UPDATE programs SET eski_kod = ? WHERE id = ? AND eski_kod IS NULL"
);
for (const g of gruplar.values()) {
if (g.eski.length === 0 || g.guncel.length === 0) continue;
if (g.eski.length !== 1 || g.guncel.length !== 1) {
ozet.adBelirsiz++;
continue;
}
const [hedef] = g.guncel;
const [kaynak] = g.eski;
if (hedef.eski_kod != null) {
// API başka bir eski kod söylüyor; ad eşleşmesi onun önüne geçmez.
ozet.adBelirsiz++;
continue;
}
// Aynı yılda iki satırda da veri varsa değerler AYNI olmalı (çift satır);
// farklıysa bunlar aynı anda var olmuş iki ayrı programdır (ör. 1 kişilik
// özel kontenjanlı adaş program). Sıra bilerek dışarıda: 2024 sırası iki
// kaynakta farklı tanımlı. Ortak yılı hiç olmayan eşleşme KANITSIZDIR →
// birleştirilmez, yalnız sayılır.
let kanit = 0;
let celisik = false;
for (const y of gecmisYillar) {
for (const olcu of ["puan", "kontenjan", "yerlesen"]) {
const a = hedef[`${olcu}${y}`] as number | null;
const b = kaynak[`${olcu}${y}`] as number | null;
if (a == null || b == null) continue;
if (ayniMi(olcu, a, b)) kanit++;
else celisik = true;
}
}
if (celisik) {
ozet.adPuanCelisik++;
ornekEkle("adCelisik", `${hedef.id} ~ ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
continue;
}
if (kanit === 0) {
ozet.adKanitsiz++;
ornekEkle("adKanitsiz", `${hedef.id} ? ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
continue;
}
ozet.adEslesen++;
ornekEkle("adEslesen", `${hedef.id} ← ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
satirdanDoldur("3-ad", hedef, kaynak);
eskiKodYaz.run(kaynak.id, hedef.id);
}
if (kuru) throw new KuruGeriAl();
})();
} catch (err) {
if (!(err instanceof KuruGeriAl)) throw err;
}
// ── Rapor ──────────────────────────────────────────────────────────────
console.log(`\nGüncel kılavuzdaki program: ${guncelSayisi}; API kaydıyla eşleşen: ${ozet.apiEslesen}`);
console.log(
`eski_kod: ${ozet.eskiKodlu} programın eski satırı bulundu ve boşlukları ondan dolduruldu; ` +
`eski satır yok: ${ozet.eskiKodBulunamadi}; eski kod hâlâ güncel (atlandı): ${ozet.eskiKodHalaGuncel}; ` +
`bölünme/çoklu (atlandı): ${ozet.eskiKodBolunme}`
);
console.log(
`Ad eşleşmesi: ${ozet.adEslesen} birebir; belirsiz (atlandı): ${ozet.adBelirsiz}; ` +
`aynı yılda farklı değer = ayrı program (atlandı): ${ozet.adPuanCelisik}; ` +
`ortak yılı olmayan, kanıtsız (atlandı): ${ozet.adKanitsiz}`
);
for (const [k, v] of Object.entries(ornekler)) {
if (v.length) console.log(` örnek ${k}: ${v.join(" ; ")}`);
}
console.log(`\nDoldurulan NULL alanlar${kuru ? " (kuru — yazılmadı)" : ""}:`);
const toplamlar = new Map<string, number>();
for (const [k, n] of [...doldurulan].sort()) {
const [kaynak] = k.split("|");
toplamlar.set(kaynak, (toplamlar.get(kaynak) ?? 0) + n);
console.log(` ${k.replace("|", " ")}: ${n}`);
}
for (const [kaynak, n] of toplamlar) console.log(` TOPLAM ${kaynak}: ${n}`);
if (doldurulan.size === 0) console.log(" (yok)");
console.log("\nDolu olup kaynağın FARKLI söylediği alanlar (EZİLMEDİ):");
for (const [k, f] of [...farkli].sort()) {
console.log(` ${k.replace("|", " ")}: ${f.adet} ör. ${f.ornek.join(" ; ")}`);
}
if (farkli.size === 0) console.log(" (yok)");
if (!kuru) db.pragma("wal_checkpoint(TRUNCATE)");
db.close();
}
if (process.argv[1]?.endsWith("gecmis-doldur.ts")) calistir(main);