harden domain architecture and add lazy Crossref+Unpaywall PDF enrichment with publications workspace refactor

This commit is contained in:
Justin Visser 2026-02-20 22:56:52 +01:00
parent a527e7a535
commit 01454162bb
62 changed files with 2562 additions and 688 deletions

View file

@ -0,0 +1,75 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.services.domains.crossref import application as crossref_app
def _item(*, title: str, year: int | None, scholar_label: str = "Shinya Yamanaka"):
return SimpleNamespace(
publication_id=1,
scholar_label=scholar_label,
title=title,
year=year,
)
@pytest.mark.asyncio
async def test_crossref_discovers_doi_from_best_title_match(monkeypatch: pytest.MonkeyPatch) -> None:
responses = [
[],
[
{
"DOI": "10.1000/noisy",
"title": ["Completely unrelated paper"],
"issued": {"date-parts": [[2014]]},
"author": [{"family": "Other"}],
},
{
"DOI": "10.1016/j.cell.2007.11.019",
"title": ["Induction of Pluripotent Stem Cells from Adult Human Fibroblasts"],
"issued": {"date-parts": [[2007]]},
"author": [{"family": "Yamanaka"}],
},
],
]
async def _fake_fetch_items(**_kwargs):
return responses.pop(0) if responses else []
monkeypatch.setattr(crossref_app, "_fetch_items", _fake_fetch_items)
doi = await crossref_app.discover_doi_for_publication(
item=_item(
title="Induction of Pluripotent Stem Cells from Adult Human Fibroblasts",
year=2007,
),
max_rows=10,
email="user@example.com",
)
assert doi == "10.1016/j.cell.2007.11.019"
@pytest.mark.asyncio
async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_fetch_items(**_kwargs):
return [
{
"DOI": "10.1000/wrong-year",
"title": ["Induction of Pluripotent Stem Cells from Adult Human Fibroblasts"],
"issued": {"date-parts": [[2014]]},
"author": [{"family": "Yamanaka"}],
}
]
monkeypatch.setattr(crossref_app, "_fetch_items", _fake_fetch_items)
doi = await crossref_app.discover_doi_for_publication(
item=_item(
title="Induction of Pluripotent Stem Cells from Adult Human Fibroblasts",
year=2007,
),
max_rows=10,
email=None,
)
assert doi is None

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from app.services.domains.doi.normalize import first_doi_from_texts, normalize_doi
def test_normalize_doi_extracts_and_lowercases() -> None:
value = "https://doi.org/10.48550/ARXIV.1412.6980"
assert normalize_doi(value) == "10.48550/arxiv.1412.6980"
def test_first_doi_from_texts_prefers_first_match() -> None:
result = first_doi_from_texts(
"no doi here",
"venue text 10.1000/ABC-123",
"title with 10.9999/ignored",
)
assert result == "10.1000/abc-123"

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from app.db.models import UserSetting
from app.services import run_safety
from app.services.domains.ingestion import safety as run_safety
def test_apply_run_safety_outcome_triggers_blocked_cooldown() -> None:

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from app.services.runs import extract_run_summary
from app.services.domains.runs.application import extract_run_summary
def test_extract_run_summary_includes_extended_metrics() -> None:

View file

@ -2,12 +2,15 @@ from __future__ import annotations
from pathlib import Path
from app.services.scholar_parser import (
import pytest
from app.services.domains.scholar.parser import (
ParseState,
ScholarDomInvariantError,
parse_author_search_page,
parse_profile_page,
)
from app.services.scholar_source import FetchResult
from app.services.domains.scholar.source import FetchResult
def _fixture(name: str) -> str:
@ -98,7 +101,7 @@ def test_parse_profile_page_handles_missing_optional_metadata() -> None:
assert publication.venue_text is None
def test_parse_profile_page_extracts_direct_pdf_link() -> None:
def test_parse_profile_page_ignores_direct_pdf_link_markup() -> None:
html = """
<html>
<div id="gsc_prf_in">Direct PDF Test</div>
@ -131,7 +134,7 @@ def test_parse_profile_page_extracts_direct_pdf_link() -> None:
assert parsed.state == ParseState.OK
assert len(parsed.publications) == 1
assert parsed.publications[0].pdf_url == "https://example.org/paper.pdf"
assert parsed.publications[0].pdf_url is None
def test_parse_profile_page_fails_fast_when_citation_markup_is_unparseable() -> None:
@ -160,10 +163,9 @@ def test_parse_profile_page_fails_fast_when_citation_markup_is_unparseable() ->
error=None,
)
parsed = parse_profile_page(fetch_result)
assert parsed.state == ParseState.LAYOUT_CHANGED
assert parsed.state_reason == "layout_row_citation_unparseable"
with pytest.raises(ScholarDomInvariantError) as exc:
parse_profile_page(fetch_result)
assert exc.value.code == "layout_row_citation_unparseable"
def test_parse_profile_page_detects_layout_change_when_markers_absent() -> None:
@ -175,11 +177,9 @@ def test_parse_profile_page_detects_layout_change_when_markers_absent() -> None:
error=None,
)
parsed = parse_profile_page(fetch_result)
assert parsed.state == ParseState.LAYOUT_CHANGED
assert parsed.state_reason == "layout_markers_missing"
assert "no_rows_detected" in parsed.warnings
with pytest.raises(ScholarDomInvariantError) as exc:
parse_profile_page(fetch_result)
assert exc.value.code == "layout_markers_missing"
def test_parse_profile_page_reports_network_reason_when_status_missing() -> None:

View file

@ -3,9 +3,9 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.services import scholars as scholar_service
from app.services.scholar_parser import ParseState
from app.services.scholar_source import FetchResult
from app.services.domains.scholars import application as scholar_service
from app.services.domains.scholar.parser import ParseState
from app.services.domains.scholar.source import FetchResult
pytestmark = [pytest.mark.integration, pytest.mark.db]

View file

@ -1,4 +1,4 @@
from app.services.scholar_source import _build_profile_url
from app.services.domains.scholar.source import _build_profile_url
def test_build_profile_url_includes_pagesize_for_initial_page() -> None:

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
from app.services.user_settings import (
from app.services.domains.settings.application import (
DEFAULT_NAV_VISIBLE_PAGES,
HARD_MIN_REQUEST_DELAY_SECONDS,
HARD_MIN_RUN_INTERVAL_MINUTES,