File size: 18,364 Bytes
7dfd4df
 
b3c541d
7dfd4df
2a4ec2b
de55d35
9043adc
b3c541d
7dfd4df
b3c541d
 
 
de55d35
 
 
7dfd4df
c0e427b
 
 
7dfd4df
038ad63
 
 
de55d35
 
 
c0e427b
 
7dfd4df
 
038ad63
2bb3831
7dfd4df
 
 
 
 
 
 
 
 
 
 
 
 
2bb3831
5ef7c27
de55d35
7dfd4df
de55d35
 
 
50ad613
95dac73
7dfd4df
 
 
2a4ec2b
 
de55d35
 
7dfd4df
2a4ec2b
de55d35
 
 
 
 
7dfd4df
2a4ec2b
 
 
7dfd4df
2a4ec2b
50ad613
84dc51d
50ad613
7dfd4df
 
 
 
 
 
 
 
 
 
de55d35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7dfd4df
2a4ec2b
50ad613
2a4ec2b
de55d35
50ad613
de55d35
 
 
 
 
7dfd4df
84dc51d
7dfd4df
2a4ec2b
7dfd4df
84dc51d
7dfd4df
 
 
 
 
de55d35
 
 
 
 
 
 
 
7dfd4df
 
 
2a4ec2b
7dfd4df
de55d35
 
 
50ad613
 
de55d35
 
7dfd4df
de55d35
 
 
 
 
2a4ec2b
7dfd4df
 
 
 
de55d35
7dfd4df
 
de55d35
 
7dfd4df
de55d35
7dfd4df
de55d35
 
 
 
7dfd4df
 
 
de55d35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7dfd4df
 
de55d35
2a4ec2b
de55d35
 
 
2a4ec2b
 
7dfd4df
 
 
 
 
 
 
 
 
 
 
 
 
2a4ec2b
 
7dfd4df
2a4ec2b
 
 
7dfd4df
 
 
 
 
 
2a4ec2b
7dfd4df
 
2a4ec2b
c0e427b
7dfd4df
 
2a4ec2b
7dfd4df
 
 
 
 
 
 
 
 
 
 
 
2167c4f
b3c541d
c0e427b
7dfd4df
50ad613
7dfd4df
2a4ec2b
7dfd4df
 
 
 
 
 
 
 
c0e427b
 
7dfd4df
 
c0e427b
7dfd4df
 
2a4ec2b
c0e427b
7dfd4df
 
 
 
 
c0e427b
7dfd4df
c0e427b
7dfd4df
 
2a4ec2b
c0e427b
 
7dfd4df
 
9043adc
c0e427b
7dfd4df
 
 
 
 
 
50ad613
b3c541d
 
 
7dfd4df
b3c541d
 
7dfd4df
b3c541d
de55d35
 
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
"""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<url>$$D<label>, rather than a bare URL.
    match = re.match(r"(?:\$\$U)?(https?://[^\s]+?)(?:\$\$|$)", raw_link)
    if not match:
        return fallback
    candidate = match.group(1)
    try:
        parsed = urlsplit(candidate)
        if parsed.hostname and not parsed.username and not parsed.password:
            return candidate
    except ValueError:
        pass
    return fallback


def _pnx_section(pnx: dict, section: str) -> dict:
    value = pnx.get(section)
    return value if isinstance(value, dict) else {}


def _primo_records(data: dict, limit: int) -> list[dict]:
    """Normalize returned PNX metadata; never infer circulation availability."""
    results = []
    seen = set()
    for doc in data.get("docs", []):
        if not isinstance(doc, dict) or not isinstance(doc.get("pnx"), dict):
            continue
        pnx = doc["pnx"]
        display = _pnx_section(pnx, "display")
        addata = _pnx_section(pnx, "addata")
        search = _pnx_section(pnx, "search")
        sort = _pnx_section(pnx, "sort")
        control = _pnx_section(pnx, "control")
        links = _pnx_section(pnx, "links")
        record_id = _first(control.get("recordid"), None) or _first(doc.get("id"), None)
        title = _first(display.get("title")) or _first(addata.get("btitle") or addata.get("atitle"))
        # A corrupt/empty item is not a real result to pad the requested count.
        if not title:
            continue
        if record_id and record_id in seen:
            continue
        if record_id:
            seen.add(record_id)
        primo_url = _build_primo_search_link(title, record_id, _first(doc.get("context")))
        creators = display.get("creator") or display.get("contributor") or addata.get("au") or []
        if isinstance(creators, str):
            creators = [creators]
        if not isinstance(creators, list):
            creators = []
        raw_link = _first(links.get("openurl") or links.get("linktorsrc"))
        results.append({
            "record_id": record_id,
            "title": title,
            "creator": "; ".join(creator for creator in creators if isinstance(creator, str)),
            "date": (_first(search.get("creationdate")) or _first(addata.get("risdate"))
                     or _first(addata.get("date")) or _first(sort.get("creationdate"))),
            "type": _first(display.get("type") or addata.get("ristype")),
            "source": _first(display.get("source") or addata.get("jtitle")),
            "description": _first(display.get("description"))[:400],
            "doi": _first(addata.get("doi"), None),
            "primo_url": primo_url,
            "link": _result_link(raw_link, primo_url),
            "open_access": True if _first(display.get("oa")) == "free_for_read" else None,
            "_source": "PRIMO",
        })
        if len(results) >= limit:
            break
    return results


def _primo_failure(message: str, code: str, status: str = "unavailable") -> dict:
    return {"error": message, "error_code": code, "status": status,
            "results": [], "total": 0, "source": "PRIMO"}


def _primo_gateways(region: str) -> tuple[str, ...]:
    """Allow only Ex Libris's documented gateway hosts; no custom secret target."""
    hosts = {
        "eu": "api-eu.hosted.exlibrisgroup.com",
        "na": "api-na.hosted.exlibrisgroup.com",
        "ap": "api-ap.hosted.exlibrisgroup.com",
        "ca": "api-ca.hosted.exlibrisgroup.com",
        "aps": "api-aps.hosted.exlibrisgroup.com",
        "cn": "api-cn.hosted.exlibrisgroup.com.cn",
    }
    if region == "auto":
        return tuple(hosts[name] for name in ("eu", "na", "ap"))
    return (hosts[region],) if region in hosts else ()


async def search_primo(req: SearchRequest, api_key: str, region: str = "auto") -> dict:
    """Search Primo using the full preferred query and documented facet syntax."""
    import httpx

    if not api_key:
        return _primo_failure("PRIMO results are not configured", "not_configured", "not_configured")

    gateways = _primo_gateways(region)
    if not gateways:
        return _primo_failure("PRIMO API region is not configured correctly", "invalid_region", "not_configured")

    query_params = _boolean_to_primo_params(req.boolean_query or req.query, req.query_field)
    facets = []
    if req.peer_reviewed:
        facets.append("facet_tlevel,exact,peer_reviewed")
    if req.open_access:
        facets.append("facet_tlevel,exact,open_access")
    if req.year_from or req.year_to:
        year_from, year_to = _year_bounds(req)
        facets.append(f"facet_searchcreationdate,exact,[{year_from} TO {year_to}]")
    if req.resource_type != "both":
        facets.append(f"facet_rtype,exact,{req.resource_type}")

    # Multiple qInclude values in the same category are ANDed, so an explicit
    # Books OR Articles request must use multiFacets for that category.
    mixed_types = "facet_rtype,include,books|,|facet_rtype,include,articles"
    mixed_params = f"&multiFacets={quote(mixed_types, safe='')}" if req.resource_type == "both" else ""
    compact_query = re.sub(r'["\s-]', "", (req.boolean_query or req.query)).upper()
    exact_identifier = bool(re.fullmatch(r"(?:[0-9]{9}[0-9X]|[0-9]{13})", compact_query))
    controlled_vocabulary = "false" if req.query_field == "title" or exact_identifier else "true"

    # qInclude accepts one value, joining ANDed facets with |,|. Repeated
    # qInclude keys are not its documented API contract.
    facet_params = f"&qInclude={quote('|,|'.join(facets), safe='')}" if facets else ""
    qs = (
        "?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
        f"&{query_params}&lang=en&sort={req.sort}&limit={req.limit}&offset=0"
        f"&conVoc={controlled_vocabulary}&skipDelivery=true"
        f"{facet_params}{mixed_params}"
    )

    deadline = asyncio.get_running_loop().time() + PRIMO_REQUEST_TIMEOUT
    last_status = None
    async with httpx.AsyncClient(timeout=PRIMO_REQUEST_TIMEOUT) as client:
        for gateway in gateways:
            remaining = deadline - asyncio.get_running_loop().time()
            if remaining <= 0:
                return _primo_failure("PRIMO took too long to respond; use the search link", "timeout")
            url = f"https://{gateway}/primo/v1/search{qs}"
            try:
                # httpx read timeouts reset per chunk. wait_for additionally
                # bounds the complete request, including a slowly streamed body.
                response = await asyncio.wait_for(
                    client.get(url, headers={"Accept": "application/json", "Authorization": f"apikey {api_key}"}),
                    timeout=remaining,
                )
                if response.status_code != 200:
                    last_status = response.status_code
                    logger.warning("PRIMO gateway %s returned HTTP %s", gateway, response.status_code)
                    if response.status_code == 429:
                        return _primo_failure("PRIMO request limit reached; try again later", "rate_limited", "rate_limited")
                    if response.status_code == 400:
                        return _primo_failure("PRIMO could not process this search; check the search terms", "invalid_query", "error")
                    if response.status_code in (401, 403, 404):
                        continue
                    return _primo_failure("PRIMO API unavailable", "upstream_error")
                data = response.json()
                if not isinstance(data, dict) or not isinstance(data.get("docs"), list):
                    raise ValueError("Invalid PRIMO response")
                results = _primo_records(data, req.limit)
                info = data.get("info") if isinstance(data.get("info"), dict) else {}
                raw_total = info.get("total")
                total = None
                # Unknown totals must stay unknown: a short page is not evidence
                # that those records exhaust all matches in the catalogue.
                if ((type(raw_total) is int) or
                        (isinstance(raw_total, str) and re.fullmatch(r"[0-9]+", raw_total.strip()))):
                    parsed_total = int(raw_total)
                    if parsed_total >= len(results):
                        total = parsed_total
                if data["docs"] and not results:
                    return _primo_failure("PRIMO returned unreadable record details; use the search link", "invalid_response")
                return {"total": total, "results": results, "source": "PRIMO", "status": "ok"}
            except (TimeoutError, httpx.TimeoutException):
                logger.warning("PRIMO search exceeded the total request deadline")
                return _primo_failure("PRIMO took too long to respond; use the search link", "timeout")
            except (httpx.HTTPError, ValueError, TypeError, AttributeError) as exc:
                # Exception strings from HTTP clients can include the full URL,
                # including the API key. Never put them in logs or responses.
                logger.warning("PRIMO gateway %s failed (%s)", gateway, type(exc).__name__)

    if last_status in (401, 403):
        return _primo_failure("PRIMO access could not be verified; use the search link", "unauthorized")
    return _primo_failure("PRIMO API unavailable", "upstream_error")


def _pubmed_query(req: SearchRequest) -> str:
    query = req.boolean_query or req.query
    if req.year_from or req.year_to:
        year_from, year_to = _year_bounds(req)
        query = f"({query}) AND ({year_from}:{year_to}[dp])"
    if req.open_access:
        query = f"({query}) AND free full text[sb]"
    if req.resource_type == "books":
        query = f'({query}) AND "pubmed books"[sb]'
    elif req.resource_type == "articles":
        query = f'({query}) NOT "pubmed books"[sb]'
    return query


async def search_pubmed(req: SearchRequest) -> dict:
    """Search PubMed; free availability does not establish an open-access licence."""
    import httpx

    base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
    warnings = []
    if req.peer_reviewed:
        warnings.append("PubMed has no general peer-reviewed-only filter; peer review must be checked for each record.")
    if req.open_access:
        warnings.append("The PubMed access filter finds free full text; it does not verify an open-access licence.")
    common = {"source": "PubMed", "warnings": warnings}

    def failed(message):
        return {**common, "error": message, "results": [], "total": 0}

    try:
        async with httpx.AsyncClient(timeout=PUBMED_REQUEST_TIMEOUT) as client:
            response = await asyncio.wait_for(client.get(
                f"{base}/esearch.fcgi",
                params={"db": "pubmed", "term": _pubmed_query(req), "retmax": req.limit,
                        "retmode": "json", "sort": "relevance"},
            ), timeout=PUBMED_REQUEST_TIMEOUT)
            if response.status_code != 200:
                return failed("PubMed search failed")
            payload = response.json()
            esearch = payload.get("esearchresult", {})
            if payload.get("error") or esearch.get("ERROR") or esearch.get("errorlist"):
                return failed("PubMed could not process this search; check the search terms")
            ids = esearch.get("idlist")
            if not isinstance(ids, list):
                return failed("PubMed returned an unreadable search response; try again")
            query_translation = esearch.get("querytranslation", "")
            count = int(esearch.get("count", 0))
            if not ids:
                return {**common, "total": count, "results": [], "query_translation": query_translation}

            summary = await asyncio.wait_for(client.get(
                f"{base}/esummary.fcgi",
                params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"},
            ), timeout=PUBMED_REQUEST_TIMEOUT)
            if summary.status_code != 200:
                return failed("PubMed fetch failed")
            summary_payload = summary.json()
            if summary_payload.get("error"):
                return failed("PubMed fetch failed")
            data = summary_payload.get("result", {})
            results = []
            for pid in ids:
                record = data.get(pid)
                if not isinstance(record, dict) or record.get("error"):
                    continue
                authors_list = record.get("authors") or []
                authors = ", ".join(author.get("name", "") for author in authors_list[:3])
                if len(authors_list) > 3:
                    authors += " et al."
                doi = next((article.get("value") for article in record.get("articleids", [])
                            if article.get("idtype") == "doi"), None)
                if not doi:
                    match = re.search(r"\bdoi:\s*(\S+)", record.get("elocationid", ""), re.IGNORECASE)
                    doi = match.group(1) if match else None
                results.append({
                    "title": record.get("title", ""),
                    "creator": authors,
                    "date": record.get("pubdate", ""),
                    "source": record.get("fulljournalname", record.get("source", "")),
                    "doi": doi,
                    "pmid": pid,
                    "link": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
                    "type": "; ".join(record.get("pubtype") or []) or "PubMed record",
                    "free_full_text": True if req.open_access else None,
                    "_source": "PubMed",
                })
            if ids and not results:
                return failed("PubMed returned no readable record details; try again")
            return {**common, "total": count, "results": results, "query_translation": query_translation}
    except (httpx.HTTPError, TimeoutError, ValueError, TypeError, AttributeError) as exc:
        logger.warning("PubMed search failed (%s)", type(exc).__name__)
        return failed("PubMed is temporarily unavailable; try again")


@router.post("")
async def search_endpoint(req: SearchRequest):
    """Search PRIMO or PubMed, preferring boolean_query when supplied."""
    from src.config import get_settings

    if req.source == "pubmed":
        return await search_pubmed(req)
    settings = get_settings()
    return await search_primo(req, settings.primo_api_key, settings.primo_api_region)