"""Targeted recovery. Stop backend first. Default is read-only inspection.""" import argparse import json import sqlite3 from datetime import datetime, timezone from pathlib import Path EXPECTED = {'Manajemen Informatika', 'Manajemen Informatika Internasional', 'Teknik Komputer Internasional', 'Teknik Informatika Internasional'} REASON = 'Input keliru: empat nama prodi digunakan sebagai ulasan. Tidak boleh diaktifkan.' def repair(store, apply=False): path = store / 'registry.sqlite3' if not path.is_file(): raise ValueError('Registry tidak ditemukan.') db = sqlite3.connect(path.as_uri() + ('?mode=rw' if apply else '?mode=ro'), uri=True) db.row_factory = sqlite3.Row try: if apply: db.execute('BEGIN IMMEDIATE') versions = {r['id']: json.loads(r['metadata']) for r in db.execute('SELECT * FROM versions')} if set(versions) != {'v1', 'v2', 'v3'}: raise ValueError('Versi berbeda dari kondisi yang diperiksa. Pemulihan dibatalkan.') if db.execute("SELECT COUNT(*) FROM jobs WHERE status IN ('queued','running')").fetchone()[0]: raise ValueError('Masih ada training berjalan. Hentikan dan periksa dahulu.') rows = [dict(r) for r in db.execute("SELECT * FROM additions WHERE version='v3'")] count = db.execute('SELECT COUNT(*) FROM additions').fetchone()[0] if not rows and versions['v3'].get('invalidated') and count == 49: print('Sudah dipulihkan. Kumpulan data latih 505; v3 tidak valid.') return if len(rows) != 4 or {r['text'] for r in rows} != EXPECTED or count != 53: raise ValueError('Data tidak cocok dengan empat prodi yang diperiksa. Tidak ada perubahan.') if versions['v2']['jumlah_data_training'] != 505: raise ValueError('Metadata v2 tidak sesuai.') for name in ('model_logistic_regression.joblib', 'tfidf_vectorizer.joblib'): if not (store / 'v2' / name).is_file(): raise ValueError('Berkas model v2 tidak lengkap.') print('Terverifikasi: 4 nama prodi pada v3; 509 -> 505 data latih.') if not apply: print('Pemeriksaan saja. Belum ada data yang diubah. Jalankan dengan --apply untuk memulihkan.') return # Make an SQLite-consistent recovery copy before changing any rows. backup = store.parent / ('registry_sebelum_pemulihan_' + datetime.now().strftime('%Y%m%d_%H%M%S_%f') + '.sqlite3') source = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) dest = sqlite3.connect(backup) try: source.backup(dest) finally: source.close(); dest.close() db.execute('CREATE TABLE IF NOT EXISTS quarantined_additions (text_key TEXT PRIMARY KEY, record_json TEXT NOT NULL, reason TEXT NOT NULL, quarantined_at TEXT NOT NULL)') stamp = datetime.now(timezone.utc).isoformat() for row in rows: db.execute('INSERT INTO quarantined_additions VALUES (?,?,?,?)', (row['text_key'], json.dumps(row), REASON, stamp)) db.execute('DELETE FROM additions WHERE text_key=? AND version=?', (row['text_key'], 'v3')) versions['v3'].update(invalidated=True, invalid_reason=REASON, invalidated_at=stamp) db.execute("UPDATE versions SET metadata=? WHERE id='v3'", (json.dumps(versions['v3']),)) db.execute("UPDATE settings SET value='v2' WHERE key='active'") db.execute("UPDATE jobs SET message=? WHERE version='v3'", ('Model v3 tidak valid: input prodi. Empat data telah dikarantina; kumpulan training dipulihkan.',)) db.commit() print('Selesai: 4 data dikarantina, kumpulan data latih 505, v2 aktif, v3 ditandai tidak valid.') print('Berkas model dan riwayat analisis tidak dihapus. Cadangan tambahan:', backup) finally: db.close() if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--store', type=Path, default=Path(__file__).resolve().parent / 'model_store') parser.add_argument('--apply', action='store_true') args = parser.parse_args() try: repair(args.store.resolve(), args.apply) except (ValueError, sqlite3.Error) as error: raise SystemExit(str(error))