File size: 9,412 Bytes
03b3d27
69437d8
 
 
 
 
 
 
 
 
 
03b3d27
69437d8
 
 
 
cbd62a3
69437d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03b3d27
 
9ca8ee5
cbd62a3
69437d8
 
03b3d27
 
 
 
 
 
 
69437d8
 
 
03b3d27
69437d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03b3d27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ca8ee5
 
 
cbd62a3
 
 
 
 
 
 
9ca8ee5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbd62a3
9ca8ee5
 
cbd62a3
9ca8ee5
cbd62a3
9ca8ee5
 
 
 
 
 
 
 
 
cbd62a3
 
 
9ca8ee5
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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