"""PRIMO and PubMed search, with explicit filters and bounded upstream waits.""" import asyncio import logging import re from typing import Optional from urllib.parse import quote, urlsplit from fastapi import APIRouter from pydantic import BaseModel, ConfigDict, Field, model_validator logger = logging.getLogger(__name__) router = APIRouter() # One deadline covers all gateways, rather than one wait per region. A region # selected in HF's PRIMO_API_REGION avoids even the fast discovery attempts. PRIMO_REQUEST_TIMEOUT = 6.0 PUBMED_REQUEST_TIMEOUT = 8.0 class SearchRequest(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) query: str = Field(min_length=1, max_length=1000) boolean_query: Optional[str] = Field(default=None, max_length=1500) source: str = Field(default="primo", pattern=r"^(primo|pubmed)$") limit: int = Field(default=10, ge=1, le=20) sort: str = Field(default="rank", pattern=r"^(rank|date_d|date_a)$") query_field: str = Field(default="any", pattern=r"^(any|title|creator|sub)$") peer_reviewed: bool = False open_access: bool = False year_from: Optional[str] = Field(default=None, pattern=r"^[0-9]{4}$") year_to: Optional[str] = Field(default=None, pattern=r"^[0-9]{4}$") resource_type: str = Field(default="articles", pattern=r"^(articles|books|both)$") @model_validator(mode="after") def validate_search(self): self.boolean_query = self.boolean_query or None for year in (self.year_from, self.year_to): if year == "0000": raise ValueError("Publication years must be between 0001 and 9999") if self.year_from and self.year_to and self.year_from > self.year_to: raise ValueError("year_from must not be later than year_to") # Primo reserves semicolons for additional field clauses, even when # percent-encoded; accepting one here changes the meaning of a query. if self.source == "primo" and ";" in (self.boolean_query or self.query): raise ValueError("PRIMO search terms cannot contain semicolons") return self def _boolean_to_primo_params(boolean_query: str, query_field: str = "any") -> str: """Preserve exact phrases and all Boolean operators in one Primo q value.""" if query_field not in {"any", "title", "creator", "sub"}: raise ValueError("Unsupported PRIMO search field") return f"q={query_field},contains,{quote(boolean_query.strip(), safe='')}" def _year_bounds(req: SearchRequest) -> tuple[str, str]: """Represent an omitted bound without silently excluding older/future works.""" return req.year_from or "0001", req.year_to or "9999" def _build_primo_search_link(title: str, record_id: Optional[str] = None, context: Optional[str] = None) -> Optional[str]: base = "https://khalifa.primo.exlibrisgroup.com/discovery" if record_id: context_param = f"&context={context}" if isinstance(context, str) and context in {"L", "PC"} else "" return ( f"{base}/fulldisplay?docid={quote(str(record_id), safe='')}&vid=971KUOSTAR_INST:KU" f"{context_param}&lang=en&tab=Everything&search_scope=MyInst_and_CI" ) clean_title = (title or "").strip() if not clean_title: return None return ( f"{base}/search?query=any,contains,{quote(clean_title, safe='')}" "&tab=Everything&search_scope=MyInst_and_CI&vid=971KUOSTAR_INST:KU&lang=en" ) def _first(value, default=""): """PNX values are normally arrays; tolerate scalar/missing metadata.""" if isinstance(value, list): value = value[0] if value else default return value if isinstance(value, str) else default def _result_link(raw_link: str, fallback: Optional[str]) -> Optional[str]: # PNX link fields can contain $$U$$D