from __future__ import annotations from dataclasses import dataclass import logging from typing import Any from app.services.domains.arxiv.application import ArxivRateLimitError from app.services.domains.openalex.client import OpenAlexBudgetExhaustedError from app.services.domains.publications.types import PublicationListItem from app.services.domains.unpaywall.application import OaResolutionOutcome, resolve_publication_oa_outcomes from app.settings import settings logger = logging.getLogger(__name__) @dataclass(frozen=True) class PipelineOutcome: outcome: OaResolutionOutcome | None scholar_candidates: Any | None # Kept for backward compatibility with calling signatures async def resolve_publication_pdf_outcome_for_row( *, row: PublicationListItem, request_email: str | None, openalex_api_key: str | None = None, ) -> PipelineOutcome: # 1. OpenAlex OA — raises OpenAlexBudgetExhaustedError if budget is gone openalex_outcome = await _openalex_outcome(row, request_email=request_email, openalex_api_key=openalex_api_key) if openalex_outcome and openalex_outcome.pdf_url: return PipelineOutcome(openalex_outcome, None) # 2. arXiv arxiv_outcome = await _arxiv_outcome(row, request_email=request_email) if arxiv_outcome and arxiv_outcome.pdf_url: return PipelineOutcome(arxiv_outcome, None) # 3. Unpaywall (which falls back to Crossref) oa_outcome = await _oa_outcome(row=row, request_email=request_email) return PipelineOutcome(oa_outcome, None) async def _openalex_outcome( row: PublicationListItem, request_email: str | None, openalex_api_key: str | None = None, ) -> OaResolutionOutcome | None: from app.services.domains.openalex.client import OpenAlexClient from app.services.domains.openalex.matching import find_best_match if not row.title: return None import re safe_title = re.sub(r"[^\w\s]", " ", row.title) safe_title = " ".join(safe_title.split()) if not safe_title: return None api_key = openalex_api_key or settings.openalex_api_key client = OpenAlexClient(api_key=api_key, mailto=request_email or settings.crossref_api_mailto) try: openalex_works = await client.get_works_by_filter({"title.search": safe_title}, limit=5) match = find_best_match( target_title=row.title, target_year=row.year, target_authors=row.scholar_label, candidates=openalex_works, ) if match and match.oa_url: return OaResolutionOutcome( publication_id=row.publication_id, doi=match.doi, pdf_url=match.oa_url, failure_reason=None, source="openalex", used_crossref=False, ) except OpenAlexBudgetExhaustedError: # Re-raise so the caller's batch loop can stop hitting the API. raise except Exception as exc: logger.warning( "publications.pdf_resolution.openalex_failed", extra={"event": "publications.pdf_resolution.openalex_failed", "error": str(exc)}, ) return None async def _arxiv_outcome(row: PublicationListItem, request_email: str | None) -> OaResolutionOutcome | None: from app.services.domains.arxiv.application import discover_arxiv_id_for_publication try: arxiv_id = await discover_arxiv_id_for_publication(item=row, request_email=request_email) if arxiv_id: pdf_url = f"https://arxiv.org/pdf/{arxiv_id}.pdf" return OaResolutionOutcome( publication_id=row.publication_id, doi=None, pdf_url=pdf_url, failure_reason=None, source="arxiv", used_crossref=False, ) except ArxivRateLimitError: raise # propagate so the batch loop can stop except Exception as exc: logger.warning( "publications.pdf_resolution.arxiv_failed", extra={"event": "publications.pdf_resolution.arxiv_failed", "error": str(exc)}, ) return None async def _oa_outcome( *, row: PublicationListItem, request_email: str | None, ) -> OaResolutionOutcome | None: outcomes = await resolve_publication_oa_outcomes([row], request_email=request_email) return outcomes.get(row.publication_id)