""" SQLite layer. Idempotente: cada corrida hace upsert por arxiv_id sin duplicar. Decisiones de diseƱo: - Listas (autores, categorias, topics, code_urls) se serializan a JSON. Para volumen <1M de papers, esto es mas simple que tablas hijas y mantiene la query principal en una sola tabla. - Indices solo en columnas usadas para filtrar/ordenar: published, primary_cat, score. - updated_at se setea automaticamente en cada upsert para detectar staleness. """ from __future__ import annotations import json import sqlite3 from contextlib import contextmanager from datetime import datetime, date from pathlib import Path from typing import Any, Iterable, Iterator SCHEMA = """ CREATE TABLE IF NOT EXISTS papers ( arxiv_id TEXT PRIMARY KEY, title TEXT NOT NULL, abstract TEXT, authors_json TEXT, -- JSON array of strings categories_json TEXT, -- JSON array of strings primary_cat TEXT, published TEXT, -- ISO date YYYY-MM-DD updated TEXT, -- ISO date YYYY-MM-DD doi TEXT, pdf_url TEXT, comment TEXT, -- arXiv "comments" field (puede traer github) journal_ref TEXT, -- OpenAlex enrichment openalex_id TEXT, cited_by_count INTEGER, fwci REAL, topics_json TEXT, -- JSON array publication_year INTEGER, -- ano de publicacion oficial (journal o arXiv) openalex_abstract TEXT, -- abstract reconstruido del inverted index -- Semantic Scholar enrichment (opcional) ss_id TEXT, influential_citation_count INTEGER, -- Detected artifacts has_code INTEGER, -- 0/1 has_data INTEGER, -- 0/1 code_urls_json TEXT, -- JSON array data_urls_json TEXT, -- JSON array -- Ranking score REAL, -- Metadata fetched_at TEXT NOT NULL, -- ISO datetime updated_at TEXT NOT NULL -- ISO datetime ); CREATE INDEX IF NOT EXISTS idx_published ON papers(published); CREATE INDEX IF NOT EXISTS idx_primary_cat ON papers(primary_cat); CREATE INDEX IF NOT EXISTS idx_score ON papers(score DESC); CREATE INDEX IF NOT EXISTS idx_cited_by ON papers(cited_by_count DESC); -- Embeddings vectoriales para busqueda semantica. -- Tabla separada (no en papers) para mantener exports CSV/Parquet lean. CREATE TABLE IF NOT EXISTS embeddings ( arxiv_id TEXT PRIMARY KEY, model TEXT NOT NULL, -- nombre del modelo (ej. allenai-specter) dim INTEGER NOT NULL, vector BLOB NOT NULL, -- np.float32 bytes, ya normalizado L2 computed_at TEXT NOT NULL, FOREIGN KEY (arxiv_id) REFERENCES papers(arxiv_id) ); CREATE INDEX IF NOT EXISTS idx_emb_model ON embeddings(model); -- Tabla auxiliar: log de corridas para auditoria y debugging CREATE TABLE IF NOT EXISTS fetch_runs ( run_id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, finished_at TEXT, category TEXT, n_fetched INTEGER, n_new INTEGER, n_updated INTEGER, status TEXT, -- 'ok' | 'partial' | 'error' error_msg TEXT ); """ # ------------------------------------------------------------ # Connection helpers # ------------------------------------------------------------ def connect(db_path: str | Path) -> sqlite3.Connection: """Abre conexion con pragmas razonables para uso single-writer.""" db_path = Path(db_path) db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row # WAL = mejor concurrencia lectura/escritura, menos lock contention conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA synchronous = NORMAL") conn.execute("PRAGMA foreign_keys = ON") return conn def init_schema(conn: sqlite3.Connection) -> None: conn.executescript(SCHEMA) migrate_schema(conn) conn.commit() def migrate_schema(conn: sqlite3.Connection) -> None: """ Migraciones idempotentes para DBs creadas con versiones viejas del schema. SQLite no soporta `ALTER TABLE ADD COLUMN IF NOT EXISTS`, asi que consultamos `PRAGMA table_info` y agregamos solo lo que falta. """ existing = {row[1] for row in conn.execute("PRAGMA table_info(papers)")} additions = { "publication_year": "INTEGER", "openalex_abstract": "TEXT", "user_rating": "INTEGER", # -1 dislike, 0/NULL neutral, +1 like "user_note": "TEXT", # nota libre del usuario "rated_at": "TEXT", # ISO datetime del ultimo rating "methodology_json": "TEXT", # JSON: [{"family","sem","lex"}] metodologias detectadas } for col, coltype in additions.items(): if col not in existing: conn.execute(f"ALTER TABLE papers ADD COLUMN {col} {coltype}") # Indice para queries de "mi biblioteca" rapidas conn.execute("CREATE INDEX IF NOT EXISTS idx_user_rating ON papers(user_rating)") @contextmanager def session(db_path: str | Path) -> Iterator[sqlite3.Connection]: conn = connect(db_path) try: init_schema(conn) yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() # ------------------------------------------------------------ # Serialization helpers # ------------------------------------------------------------ def _to_iso(d: Any) -> str | None: if d is None: return None if isinstance(d, (datetime, date)): return d.isoformat() if isinstance(d, datetime) else d.isoformat() return str(d) def _jdump(x: Any) -> str | None: if x is None: return None return json.dumps(x, ensure_ascii=False) def _jload(s: str | None) -> Any: if s is None or s == "": return None return json.loads(s) # ------------------------------------------------------------ # Upserts # ------------------------------------------------------------ # Campos provistos por el fetch inicial de arXiv (sin enrichment ni score). ARXIV_FIELDS = [ "arxiv_id", "title", "abstract", "authors_json", "categories_json", "primary_cat", "published", "updated", "doi", "pdf_url", "comment", "journal_ref", ] def upsert_arxiv_paper(conn: sqlite3.Connection, paper: dict) -> str: """ Inserta o actualiza un paper con datos crudos de arXiv. No toca campos de enrichment ni score. Returns: 'inserted' | 'updated' """ now = datetime.utcnow().isoformat(timespec="seconds") row = { "arxiv_id": paper["arxiv_id"], "title": paper["title"], "abstract": paper.get("abstract"), "authors_json": _jdump(paper.get("authors") or []), "categories_json": _jdump(paper.get("categories") or []), "primary_cat": paper.get("primary_cat"), "published": _to_iso(paper.get("published")), "updated": _to_iso(paper.get("updated")), "doi": paper.get("doi"), "pdf_url": paper.get("pdf_url"), "comment": paper.get("comment"), "journal_ref": paper.get("journal_ref"), "fetched_at": now, "updated_at": now, } cur = conn.execute( "SELECT 1 FROM papers WHERE arxiv_id = ?", (row["arxiv_id"],) ) exists = cur.fetchone() is not None if exists: # UPDATE solo de campos de arXiv; preserva enrichment y score. conn.execute( """ UPDATE papers SET title=:title, abstract=:abstract, authors_json=:authors_json, categories_json=:categories_json, primary_cat=:primary_cat, published=:published, updated=:updated, doi=:doi, pdf_url=:pdf_url, comment=:comment, journal_ref=:journal_ref, updated_at=:updated_at WHERE arxiv_id=:arxiv_id """, row, ) return "updated" else: conn.execute( """ INSERT INTO papers ( arxiv_id, title, abstract, authors_json, categories_json, primary_cat, published, updated, doi, pdf_url, comment, journal_ref, fetched_at, updated_at ) VALUES ( :arxiv_id, :title, :abstract, :authors_json, :categories_json, :primary_cat, :published, :updated, :doi, :pdf_url, :comment, :journal_ref, :fetched_at, :updated_at ) """, row, ) return "inserted" def upsert_many(conn: sqlite3.Connection, papers: Iterable[dict]) -> dict: """Bulk upsert. Devuelve contadores.""" counts = {"inserted": 0, "updated": 0} for p in papers: result = upsert_arxiv_paper(conn, p) counts[result] += 1 conn.commit() return counts # ------------------------------------------------------------ # Run log # ------------------------------------------------------------ def start_run(conn: sqlite3.Connection, category: str) -> int: cur = conn.execute( "INSERT INTO fetch_runs (started_at, category, status) VALUES (?, ?, 'running')", (datetime.utcnow().isoformat(timespec="seconds"), category), ) conn.commit() return cur.lastrowid def finish_run( conn: sqlite3.Connection, run_id: int, n_fetched: int, n_new: int, n_updated: int, status: str = "ok", error_msg: str | None = None, ) -> None: conn.execute( """ UPDATE fetch_runs SET finished_at = ?, n_fetched = ?, n_new = ?, n_updated = ?, status = ?, error_msg = ? WHERE run_id = ? """, ( datetime.utcnow().isoformat(timespec="seconds"), n_fetched, n_new, n_updated, status, error_msg, run_id, ), ) conn.commit() # ------------------------------------------------------------ # Read helpers (utiles para los siguientes modulos) # ------------------------------------------------------------ def count_papers(conn: sqlite3.Connection) -> int: return conn.execute("SELECT COUNT(*) FROM papers").fetchone()[0] def papers_missing_openalex(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]: sql = ( "SELECT arxiv_id, doi, title FROM papers " "WHERE openalex_id IS NULL ORDER BY published DESC" ) if limit: sql += f" LIMIT {int(limit)}" return conn.execute(sql).fetchall() # ------------------------------------------------------------ # Enrichment upserts # ------------------------------------------------------------ def update_openalex( conn: sqlite3.Connection, arxiv_id: str, openalex_id: str, cited_by_count: int | None, fwci: float | None, topics: list[dict] | None, publication_year: int | None = None, openalex_abstract: str | None = None, ) -> None: """Rellena los campos OpenAlex de un paper ya existente.""" conn.execute( """ UPDATE papers SET openalex_id = ?, cited_by_count = ?, fwci = ?, topics_json = ?, publication_year = ?, openalex_abstract = ?, updated_at = ? WHERE arxiv_id = ? """, ( openalex_id, cited_by_count, fwci, _jdump(topics) if topics else None, publication_year, openalex_abstract, datetime.utcnow().isoformat(timespec="seconds"), arxiv_id, ), ) def update_methodology( conn: sqlite3.Connection, arxiv_id: str, tags: list[dict] | None, ) -> None: """ Persiste las metodologias detectadas para un paper. tags = [{"family": str, "sem": float, "lex": int}, ...] ordenadas por confianza. No hace commit (el caller comitea por chunk). """ conn.execute( "UPDATE papers SET methodology_json = ?, updated_at = ? WHERE arxiv_id = ?", ( _jdump(tags) if tags else None, datetime.utcnow().isoformat(timespec="seconds"), arxiv_id, ), ) def all_methodology_families(conn: sqlite3.Connection) -> list[str]: """Lista deduplicada de familias presentes en el corpus (para poblar filtros).""" fams: set[str] = set() for (mj,) in conn.execute( "SELECT methodology_json FROM papers WHERE methodology_json IS NOT NULL" ): try: for t in json.loads(mj): f = t.get("family") if f: fams.add(f) except Exception: pass return sorted(fams) def update_artifacts( conn: sqlite3.Connection, arxiv_id: str, has_code: bool, has_data: bool, code_urls: list[str], data_urls: list[str], ) -> None: """Persiste el resultado del detector de artefactos.""" conn.execute( """ UPDATE papers SET has_code = ?, has_data = ?, code_urls_json = ?, data_urls_json = ?, updated_at = ? WHERE arxiv_id = ? """, ( int(has_code), int(has_data), _jdump(code_urls) if code_urls else None, _jdump(data_urls) if data_urls else None, datetime.utcnow().isoformat(timespec="seconds"), arxiv_id, ), ) # ------------------------------------------------------------ # User ratings (likes / dislikes / notes) # ------------------------------------------------------------ def rate_paper( conn: sqlite3.Connection, arxiv_id: str, rating: int, # -1 dislike, 0 limpia, +1 like note: str | None = None, ) -> None: """Setea/limpia el rating del usuario. rating=0 limpia.""" now = datetime.utcnow().isoformat(timespec="seconds") if rating == 0: conn.execute( "UPDATE papers SET user_rating = NULL, rated_at = ? WHERE arxiv_id = ?", (now, arxiv_id), ) else: conn.execute( "UPDATE papers SET user_rating = ?, user_note = COALESCE(?, user_note), " "rated_at = ? WHERE arxiv_id = ?", (rating, note, now, arxiv_id), ) conn.commit() def set_note(conn: sqlite3.Connection, arxiv_id: str, note: str) -> None: """Guarda solo la nota libre, sin tocar el rating.""" now = datetime.utcnow().isoformat(timespec="seconds") conn.execute( "UPDATE papers SET user_note = ?, rated_at = ? WHERE arxiv_id = ?", (note or None, now, arxiv_id), ) conn.commit() # ------------------------------------------------------------ # Embeddings # ------------------------------------------------------------ def upsert_embedding( conn: sqlite3.Connection, arxiv_id: str, model: str, dim: int, vector_bytes: bytes, ) -> None: """Persiste un embedding ya normalizado L2 como BLOB float32.""" conn.execute( """ INSERT INTO embeddings (arxiv_id, model, dim, vector, computed_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(arxiv_id) DO UPDATE SET model = excluded.model, dim = excluded.dim, vector = excluded.vector, computed_at = excluded.computed_at """, ( arxiv_id, model, dim, vector_bytes, datetime.utcnow().isoformat(timespec="seconds"), ), ) def papers_missing_embedding( conn: sqlite3.Connection, model: str, ) -> list[sqlite3.Row]: """Papers que aun no tienen embedding para ese modelo.""" return conn.execute( """ SELECT p.arxiv_id, p.title, p.abstract, p.openalex_abstract FROM papers p WHERE p.arxiv_id NOT IN ( SELECT arxiv_id FROM embeddings WHERE model = ? ) AND (p.title IS NOT NULL OR p.abstract IS NOT NULL) ORDER BY p.published DESC """, (model,), ).fetchall() def load_all_embeddings( conn: sqlite3.Connection, model: str, ) -> list[sqlite3.Row]: """Carga todos los embeddings de un modelo (arxiv_id, vector blob).""" return conn.execute( "SELECT arxiv_id, vector FROM embeddings WHERE model = ?", (model,), ).fetchall() def count_embeddings(conn: sqlite3.Connection, model: str | None = None) -> int: if model: return conn.execute( "SELECT COUNT(*) FROM embeddings WHERE model = ?", (model,) ).fetchone()[0] return conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0] def reset_openalex(conn: sqlite3.Connection) -> int: """ Limpia todos los campos OpenAlex para forzar re-enrichment (util cuando se agregan campos nuevos al pipeline). Devuelve numero de papers reseteados. """ cur = conn.execute( """ UPDATE papers SET openalex_id = NULL, cited_by_count = NULL, fwci = NULL, topics_json = NULL, publication_year = NULL, openalex_abstract = NULL """ ) conn.commit() return cur.rowcount def mark_openalex_unmatched(conn: sqlite3.Connection, arxiv_id: str) -> None: """ Marca explicitamente un paper como buscado-pero-no-encontrado en OpenAlex. Usa openalex_id = 'NOT_FOUND' como sentinela para evitar re-querying en futuras corridas (no son free). """ conn.execute( "UPDATE papers SET openalex_id = 'NOT_FOUND', updated_at = ? " "WHERE arxiv_id = ?", (datetime.utcnow().isoformat(timespec="seconds"), arxiv_id), )