import json import os import numpy as np import pytest from common.db import init_db, get_paper from common.vector_index import load_index from sync.run_sync import run_sync def test_run_sync_embeds_only_new_papers(tmp_path, monkeypatch): work_dir = str(tmp_path / "work") monkeypatch.setattr("sync.run_sync.download_snapshot", lambda repo_id, local_dir, token: {"papers": {}, "last_synced_at": None}) uploaded = {} monkeypatch.setattr("sync.run_sync.upload_snapshot", lambda repo_id, local_dir, token: uploaded.setdefault("called", True)) papers = [ {"id": "p1", "title": "Title One", "abstract": "Abstract one", "authors": "A", "venue": "ACL", "year": 2023, "url": "http://x/p1", "bibtex": "@inproceedings{p1}", "pdf_url": "http://x/p1.pdf"}, ] monkeypatch.setattr("sync.run_sync.iter_papers", lambda path: iter(papers)) embed_calls = [] class FakeClient: def __init__(self, *a, **kw): pass def embed_batch(self, texts): embed_calls.append(texts) return [np.ones(1024, dtype=np.float32) for _ in texts] monkeypatch.setattr("sync.run_sync.EmbeddingsClient", FakeClient) run_sync( anthology_path="/fake", hf_repo_id="org/repo", hf_token="tok", embedding_base_url="http://fake", embedding_api_key="key", work_dir=work_dir, ) assert embed_calls == [["Title One\n\nAbstract one"]] assert uploaded.get("called") is True conn = init_db(os.path.join(work_dir, "papers.db")) paper = get_paper(conn, "p1") assert paper["title"] == "Title One" assert paper["bibtex"] == "@inproceedings{p1}" assert paper["pdf_url"] == "http://x/p1.pdf" index = load_index(os.path.join(work_dir, "index.faiss")) assert index.ntotal == 1 assert paper["faiss_id"] == 0 with open(os.path.join(work_dir, "state.json")) as f: state = json.load(f) assert "last_synced_at" in state assert state["last_synced_at"] is not None assert "papers" in state def test_run_sync_raises_and_skips_upload_on_embedding_failure(tmp_path, monkeypatch): work_dir = str(tmp_path / "work") monkeypatch.setattr("sync.run_sync.download_snapshot", lambda repo_id, local_dir, token: {"papers": {}, "last_synced_at": None}) uploaded = {} monkeypatch.setattr("sync.run_sync.upload_snapshot", lambda repo_id, local_dir, token: uploaded.setdefault("called", True)) monkeypatch.setattr("sync.run_sync.iter_papers", lambda path: iter([ {"id": "p1", "title": "T", "abstract": "", "authors": "", "venue": "ACL", "year": 2023, "url": ""}, ])) class FailingClient: def __init__(self, *a, **kw): pass def embed_batch(self, texts): raise RuntimeError("embedding API down") monkeypatch.setattr("sync.run_sync.EmbeddingsClient", FailingClient) with pytest.raises(RuntimeError): run_sync( anthology_path="/fake", hf_repo_id="org/repo", hf_token="tok", embedding_base_url="http://fake", embedding_api_key="key", work_dir=work_dir, ) assert "called" not in uploaded def test_run_sync_twice_updates_faiss_id_mapping_on_reembed(tmp_path, monkeypatch): """Regression test for Fix #1: re-embedding an existing paper on a second sync run must supersede its old faiss_id rather than leaving a stale mapping, since add_vector always appends (never overwrites) in FAISS.""" from common.db import get_paper_id_by_faiss_id work_dir = str(tmp_path / "work") # download_snapshot reads the on-disk state.json produced by the previous # run_sync call, mimicking real behavior across two syncs against the # same work_dir. def fake_download_snapshot(repo_id, local_dir, token): state_path = os.path.join(local_dir, "state.json") if os.path.exists(state_path): with open(state_path) as f: content = f.read().strip() return json.loads(content) if content else {"papers": {}, "last_synced_at": None} return {"papers": {}, "last_synced_at": None} monkeypatch.setattr("sync.run_sync.download_snapshot", fake_download_snapshot) monkeypatch.setattr("sync.run_sync.upload_snapshot", lambda repo_id, local_dir, token: None) class FakeClient: def __init__(self, *a, **kw): pass def embed_batch(self, texts): return [np.ones(1024, dtype=np.float32) for _ in texts] monkeypatch.setattr("sync.run_sync.EmbeddingsClient", FakeClient) papers_round1 = [ {"id": "A", "title": "Paper A", "abstract": "", "authors": "", "venue": "ACL", "year": 2023, "url": "http://x/a"}, {"id": "B", "title": "Paper B", "abstract": "original", "authors": "", "venue": "ACL", "year": 2023, "url": "http://x/b"}, ] monkeypatch.setattr("sync.run_sync.iter_papers", lambda path: iter(papers_round1)) run_sync( anthology_path="/fake", hf_repo_id="org/repo", hf_token="tok", embedding_base_url="http://fake", embedding_api_key="key", work_dir=work_dir, ) conn = init_db(os.path.join(work_dir, "papers.db")) b_old_faiss_id = get_paper(conn, "B")["faiss_id"] assert get_paper_id_by_faiss_id(conn, b_old_faiss_id) == "B" # Second sync: B changes content (forcing re-embed/append), C is new. papers_round2 = [ {"id": "A", "title": "Paper A", "abstract": "", "authors": "", "venue": "ACL", "year": 2023, "url": "http://x/a"}, {"id": "B", "title": "Paper B", "abstract": "changed abstract", "authors": "", "venue": "ACL", "year": 2023, "url": "http://x/b"}, {"id": "C", "title": "Paper C", "abstract": "", "authors": "", "venue": "ACL", "year": 2023, "url": "http://x/c"}, ] monkeypatch.setattr("sync.run_sync.iter_papers", lambda path: iter(papers_round2)) run_sync( anthology_path="/fake", hf_repo_id="org/repo", hf_token="tok", embedding_base_url="http://fake", embedding_api_key="key", work_dir=work_dir, ) conn = init_db(os.path.join(work_dir, "papers.db")) b_new_faiss_id = get_paper(conn, "B")["faiss_id"] assert b_new_faiss_id > b_old_faiss_id assert get_paper_id_by_faiss_id(conn, b_old_faiss_id) is None assert get_paper_id_by_faiss_id(conn, b_new_faiss_id) == "B" def test_run_sync_backfills_bibtex_without_reembedding(tmp_path, monkeypatch): """Bibtex and pdf_url must reach unchanged papers via the decoupled metadata write, not via re-embedding. Round 1 simulates pre-feature data (no bibtex/pdf_url keys -> stored as ''); round 2 supplies the same paper, now with both fields, but identical content hash -> unchanged -> zero embed calls, yet both fields end up populated. This is the guarantee that adding these fields does NOT trigger a full re-embed (which would orphan FAISS vectors, since add_vector only appends).""" work_dir = str(tmp_path / "work") def fake_download_snapshot(repo_id, local_dir, token): state_path = os.path.join(local_dir, "state.json") if os.path.exists(state_path): with open(state_path) as f: content = f.read().strip() return json.loads(content) if content else {"papers": {}, "last_synced_at": None} return {"papers": {}, "last_synced_at": None} monkeypatch.setattr("sync.run_sync.download_snapshot", fake_download_snapshot) monkeypatch.setattr("sync.run_sync.upload_snapshot", lambda repo_id, local_dir, token: None) embed_calls = [] class FakeClient: def __init__(self, *a, **kw): pass def embed_batch(self, texts): embed_calls.append(list(texts)) return [np.ones(1024, dtype=np.float32) for _ in texts] monkeypatch.setattr("sync.run_sync.EmbeddingsClient", FakeClient) # Round 1: legacy paper without a bibtex key (stored as ''). papers_round1 = [ {"id": "A", "title": "Paper A", "abstract": "", "authors": "", "venue": "ACL", "year": 2023, "url": "http://x/a"}, ] monkeypatch.setattr("sync.run_sync.iter_papers", lambda path: iter(papers_round1)) run_sync( anthology_path="/fake", hf_repo_id="org/repo", hf_token="tok", embedding_base_url="http://fake", embedding_api_key="key", work_dir=work_dir, ) conn = init_db(os.path.join(work_dir, "papers.db")) assert get_paper(conn, "A")["bibtex"] == "" assert get_paper(conn, "A")["pdf_url"] == "" embed_calls.clear() # Round 2: same content (unchanged -> not re-embedded) but now with bibtex/pdf_url. papers_round2 = [ {"id": "A", "title": "Paper A", "abstract": "", "authors": "", "venue": "ACL", "year": 2023, "url": "http://x/a", "bibtex": "@inproceedings{a}", "pdf_url": "http://x/a.pdf"}, ] monkeypatch.setattr("sync.run_sync.iter_papers", lambda path: iter(papers_round2)) run_sync( anthology_path="/fake", hf_repo_id="org/repo", hf_token="tok", embedding_base_url="http://fake", embedding_api_key="key", work_dir=work_dir, ) assert embed_calls == [] # no re-embedding of an unchanged paper conn = init_db(os.path.join(work_dir, "papers.db")) paper = get_paper(conn, "A") assert paper["bibtex"] == "@inproceedings{a}" assert paper["pdf_url"] == "http://x/a.pdf" index = load_index(os.path.join(work_dir, "index.faiss")) assert index.ntotal == 1 # no orphaned second vector