| """ |
| Re-classify every Item with the Special Collections decision engine and prune |
| everything that is not a genuine special collection. |
| |
| The platform is Special-Collections-only: papers that the engine scores 0 are |
| research noise (STEM/medical/jargon) and must be removed. |
| |
| Usage: |
| python scripts/reclassify_and_prune_sc.py # DRY RUN (default) — no writes |
| python scripts/reclassify_and_prune_sc.py --apply # re-score + delete non-SC |
| |
| The --apply pass: |
| 1. Backs up uraas.db -> uraas.db.bak (SQLite only). |
| 2. Re-scores all items, writing special_collection_score / _categories. |
| 3. Deletes items with score == 0 (ORM delete so association/file rows cascade), |
| then removes orphan authors / empty collections / empty communities. |
| 4. Flushes the analytics cache. |
| |
| Run with the dashboard and any crawler STOPPED to avoid SQLite write locks. |
| """ |
|
|
| import argparse |
| import os |
| import shutil |
| import sys |
|
|
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
| from sqlalchemy import text |
|
|
| from uraas.config import config |
| from uraas.database import ( |
| Author, |
| Collection, |
| Community, |
| Item, |
| SessionLocal, |
| engine, |
| item_authors, |
| ) |
| from uraas.services.sc_engine import is_special_collection |
| from uraas.utils.analytics_cache import analytics_cache |
|
|
|
|
| def backup_sqlite(): |
| url = (config.DATABASE_URL or "").lower() |
| if not url.startswith("sqlite"): |
| print(f"[backup] Non-SQLite DB ({url[:30]}...) — skipping file backup.") |
| return |
| db_path = config.DATABASE_URL.split("///")[-1] |
| if not os.path.exists(db_path): |
| print(f"[backup] DB file not found at {db_path}; nothing to back up.") |
| return |
| bak = db_path + ".bak" |
| shutil.copy2(db_path, bak) |
| print(f"[backup] {db_path} -> {bak}") |
|
|
|
|
| def rescore(session, apply: bool): |
| """Re-score every item. Returns (keep_ids, drop_ids).""" |
| items = session.query(Item).all() |
| keep_ids, drop_ids = [], [] |
| for it in items: |
| is_sc, score, cats = is_special_collection( |
| it.title or "", it.abstract or "", it.dc_subject or "" |
| ) |
| if apply: |
| it.special_collection_score = float(score) |
| it.special_collection_categories = ",".join(cats) if is_sc else "" |
| (keep_ids if is_sc else drop_ids).append(it.id) |
| if apply: |
| session.commit() |
| return keep_ids, drop_ids |
|
|
|
|
| def prune(session, drop_ids): |
| """Delete non-SC items + orphan authors/collections/communities.""" |
| |
| session.execute(text("PRAGMA foreign_keys=ON")) |
|
|
| deleted = 0 |
| for chunk_start in range(0, len(drop_ids), 500): |
| chunk = drop_ids[chunk_start : chunk_start + 500] |
| for it in session.query(Item).filter(Item.id.in_(chunk)).all(): |
| session.delete(it) |
| deleted += 1 |
| session.commit() |
| print(f"[prune] deleted {deleted} non-SC items") |
|
|
| |
| |
| session.execute( |
| text( |
| "DELETE FROM item_authors WHERE item_id NOT IN (SELECT id FROM items) " |
| "OR author_id NOT IN (SELECT id FROM authors)" |
| ) |
| ) |
| session.execute( |
| text( |
| "DELETE FROM item_collections WHERE item_id NOT IN (SELECT id FROM items) " |
| "OR collection_id NOT IN (SELECT id FROM collections)" |
| ) |
| ) |
| session.commit() |
|
|
| |
| orphan_authors = ( |
| session.query(Author) |
| .filter(~Author.id.in_(session.query(item_authors.c.author_id))) |
| .all() |
| ) |
| for a in orphan_authors: |
| session.delete(a) |
| print(f"[prune] deleted {len(orphan_authors)} orphan authors") |
|
|
| |
| empty_colls = [c for c in session.query(Collection).all() if not c.items] |
| for c in empty_colls: |
| session.delete(c) |
| session.commit() |
| print(f"[prune] deleted {len(empty_colls)} empty collections") |
|
|
| empty_comms = [c for c in session.query(Community).all() if not c.collections] |
| for c in empty_comms: |
| session.delete(c) |
| session.commit() |
| print(f"[prune] deleted {len(empty_comms)} empty communities") |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Re-classify & prune non-SC papers") |
| parser.add_argument( |
| "--apply", action="store_true", help="Actually re-score and delete (default: dry run)" |
| ) |
| parser.add_argument( |
| "--samples", type=int, default=20, help="How many borderline drops to print" |
| ) |
| args = parser.parse_args() |
|
|
| session = SessionLocal() |
| try: |
| total = session.query(Item).count() |
| old_sc = session.query(Item).filter(Item.special_collection_score > 0).count() |
| print("=" * 64) |
| print(f"Total items: {total} (old score>0: {old_sc})") |
| print("=" * 64) |
|
|
| if args.apply: |
| backup_sqlite() |
|
|
| keep_ids, drop_ids = rescore(session, apply=args.apply) |
| print(f"\nKEEP (special collections): {len(keep_ids)}") |
| print(f"DROP (not special collections): {len(drop_ids)}") |
|
|
| |
| |
| prev_sc = { |
| i for (i,) in session.query(Item.id).filter(Item.special_collection_score >= 0).all() |
| } if not args.apply else set() |
| sample = ( |
| session.query(Item.title) |
| .filter(Item.id.in_(drop_ids[: args.samples])) |
| .all() |
| ) |
| print(f"\n--- sample of dropped titles (first {args.samples}) ---") |
| for (t,) in sample: |
| safe = (t or "").encode("ascii", "replace").decode() |
| print(" DROP:", safe[:90]) |
|
|
| if not args.apply: |
| print("\n[DRY RUN] No changes written. Re-run with --apply to prune.") |
| return 0 |
|
|
| prune(session, drop_ids) |
| analytics_cache.invalidate_all() |
|
|
| remaining = session.query(Item).count() |
| sc_remaining = ( |
| session.query(Item).filter(Item.special_collection_score > 0).count() |
| ) |
| orphan_left = ( |
| session.query(Author) |
| .filter(~Author.id.in_(session.query(item_authors.c.author_id))) |
| .count() |
| ) |
| print("\n" + "=" * 64) |
| print(f"DONE. Items remaining: {remaining} (score>0: {sc_remaining})") |
| print(f"Orphan authors remaining: {orphan_left}") |
| assert remaining == sc_remaining, "Mismatch: non-SC rows survived!" |
| assert orphan_left == 0, "Orphan authors survived!" |
| print("Invariants OK. Restart the dashboard to serve fresh data.") |
| print("=" * 64) |
| return 0 |
| finally: |
| session.close() |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|