diff --git a/codex/ingest.py b/codex/ingest.py index ef5cb82..a6d1663 100644 --- a/codex/ingest.py +++ b/codex/ingest.py @@ -164,10 +164,26 @@ def ingest_paper( ) break except psycopg.errors.UniqueViolation as exc: - if base_bibkey is None or "bibkey" not in str(exc).lower() or attempt == 26: - raise + msg = str(exc).lower() conn.rollback() + if "openalex" in msg: + # Two different papers.id claim the same OpenAlex work — a real data + # conflict, not something to auto-rename. Surface it clearly instead + # of the raw psycopg error (companion gap to C-15's bibkey case). + raise ValueError( + f"openalex_id {paper.openalex_id!r} is already attached to a " + f"different paper; ingesting '{paper.id}' would duplicate it — " + "resolve the conflicting paper first." + ) from exc + if base_bibkey is None or "bibkey" not in msg or attempt == 26: + raise paper.bibkey = f"{base_bibkey}{chr(ord('a') + attempt)}" + logger.warning( + "bibkey %r already exists; ingesting '%s' as %r instead", + base_bibkey, + paper.id, + paper.bibkey, + ) # Register openalex_id in paper_identifiers (alias table). # Every ingest records the primary openalex_id so the graph JOIN diff --git a/tests/ingest/test_ingest.py b/tests/ingest/test_ingest.py index 0f80f45..95f4423 100644 --- a/tests/ingest/test_ingest.py +++ b/tests/ingest/test_ingest.py @@ -560,6 +560,35 @@ def test_ingest_disambiguates_bibkey_on_unique_violation() -> None: mock_conn.rollback.assert_called_once() +def test_ingest_openalex_collision_raises_clear_error() -> None: + """An openalex_id UNIQUE collision raises a clear, actionable error (audit C-15). + + Unlike a bibkey clash (auto-suffixed), a shared openalex_id means two papers + claim the same OpenAlex work — a real data conflict that must surface, not the + raw psycopg error. + """ + paper = _make_paper() + mock_conn = MagicMock() + + def execute_side_effect(sql: str, params: Any = None) -> MagicMock: + if "INSERT INTO papers" in sql: + raise psycopg.errors.UniqueViolation( + 'duplicate key value violates unique constraint "papers_openalex_id_key"' + ) + return MagicMock() + + mock_conn.execute.side_effect = execute_side_effect + + with ( + patch("codex.ingest.openalex.fetch_paper", return_value=paper), + patch("codex.ingest.openalex.fetch_citations", return_value=[]), + patch("codex.ingest.get_embedder", return_value=_fake_embedder()), + patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)), + pytest.raises(ValueError, match="openalex_id"), + ): + ingest_paper(paper.id) + + # ---------------------------------------------------------------------------