nikeshn commited on
Commit
de55d35
·
verified ·
1 Parent(s): c597e9f

Upload 5 files

Browse files
Files changed (1) hide show
  1. src/api/search.py +162 -60
src/api/search.py CHANGED
@@ -3,14 +3,16 @@ import asyncio
3
  import logging
4
  import re
5
  from typing import Optional
6
- from urllib.parse import quote
7
 
8
  from fastapi import APIRouter
9
  from pydantic import BaseModel, ConfigDict, Field, model_validator
10
 
11
  logger = logging.getLogger(__name__)
12
  router = APIRouter()
13
- PRIMO_REQUEST_TIMEOUT = 5.0
 
 
14
  PUBMED_REQUEST_TIMEOUT = 8.0
15
 
16
 
@@ -19,7 +21,9 @@ class SearchRequest(BaseModel):
19
  query: str = Field(min_length=1, max_length=1000)
20
  boolean_query: Optional[str] = Field(default=None, max_length=1500)
21
  source: str = Field(default="primo", pattern=r"^(primo|pubmed)$")
22
- limit: int = Field(default=5, ge=1, le=20)
 
 
23
  peer_reviewed: bool = False
24
  open_access: bool = False
25
  year_from: Optional[str] = Field(default=None, pattern=r"^[0-9]{4}$")
@@ -41,9 +45,11 @@ class SearchRequest(BaseModel):
41
  return self
42
 
43
 
44
- def _boolean_to_primo_params(boolean_query: str) -> str:
45
  """Preserve exact phrases and all Boolean operators in one Primo q value."""
46
- return f"q=any,contains,{quote(boolean_query.strip(), safe='')}"
 
 
47
 
48
 
49
  def _year_bounds(req: SearchRequest) -> tuple[str, str]:
@@ -51,10 +57,15 @@ def _year_bounds(req: SearchRequest) -> tuple[str, str]:
51
  return req.year_from or "0001", req.year_to or "9999"
52
 
53
 
54
- def _build_primo_search_link(title: str, record_id: Optional[str] = None) -> Optional[str]:
 
55
  base = "https://khalifa.primo.exlibrisgroup.com/discovery"
56
  if record_id:
57
- return f"{base}/fulldisplay?docid={quote(str(record_id), safe='')}&vid=971KUOSTAR_INST:KU"
 
 
 
 
58
  clean_title = (title or "").strip()
59
  if not clean_title:
60
  return None
@@ -74,17 +85,105 @@ def _first(value, default=""):
74
  def _result_link(raw_link: str, fallback: Optional[str]) -> Optional[str]:
75
  # PNX link fields can contain $$U<url>$$D<label>, rather than a bare URL.
76
  match = re.match(r"(?:\$\$U)?(https?://[^\s]+?)(?:\$\$|$)", raw_link)
77
- return match.group(1) if match else fallback
78
-
79
-
80
- async def search_primo(req: SearchRequest, api_key: str) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  """Search Primo using the full preferred query and documented facet syntax."""
82
  import httpx
83
 
84
  if not api_key:
85
- return {"error": "PRIMO_API_KEY not configured", "results": [], "total": 0, "source": "PRIMO"}
86
 
87
- query_params = _boolean_to_primo_params(req.boolean_query or req.query)
 
 
 
 
88
  facets = []
89
  if req.peer_reviewed:
90
  facets.append("facet_tlevel,exact,peer_reviewed")
@@ -96,75 +195,77 @@ async def search_primo(req: SearchRequest, api_key: str) -> dict:
96
  if req.resource_type != "both":
97
  facets.append(f"facet_rtype,exact,{req.resource_type}")
98
 
 
 
 
 
 
 
 
 
99
  # qInclude accepts one value, joining ANDed facets with |,|. Repeated
100
  # qInclude keys are not its documented API contract.
101
  facet_params = f"&qInclude={quote('|,|'.join(facets), safe='')}" if facets else ""
102
  qs = (
103
  "?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
104
- f"&{query_params}&lang=en&sort=rank&limit={req.limit}&offset=0"
105
- f"{facet_params}"
 
106
  )
107
 
 
 
108
  async with httpx.AsyncClient(timeout=PRIMO_REQUEST_TIMEOUT) as client:
109
- for region in ("api-eu", "api-na", "api-ap"):
110
- url = f"https://{region}.hosted.exlibrisgroup.com/primo/v1/search{qs}"
 
 
 
111
  try:
112
  # httpx read timeouts reset per chunk. wait_for additionally
113
  # bounds the complete request, including a slowly streamed body.
114
  response = await asyncio.wait_for(
115
  client.get(url, headers={"Accept": "application/json", "Authorization": f"apikey {api_key}"}),
116
- timeout=PRIMO_REQUEST_TIMEOUT,
117
  )
118
  if response.status_code != 200:
119
- logger.warning("PRIMO region %s returned HTTP %s", region, response.status_code)
 
120
  if response.status_code == 429:
121
- return {"error": "PRIMO request limit reached; try again later", "results": [], "total": 0, "source": "PRIMO"}
122
  if response.status_code == 400:
123
- return {"error": "PRIMO could not process this search; check the search terms", "results": [], "total": 0, "source": "PRIMO"}
124
- continue
 
 
125
  data = response.json()
126
  if not isinstance(data, dict) or not isinstance(data.get("docs"), list):
127
  raise ValueError("Invalid PRIMO response")
128
- results = []
129
- for doc in data.get("docs", []):
130
- if not isinstance(doc, dict):
131
- continue
132
- pnx = doc.get("pnx") or {}
133
- if not isinstance(pnx, dict):
134
- continue
135
- display = pnx.get("display") or {}
136
- addata = pnx.get("addata") or {}
137
- search = pnx.get("search") or {}
138
- control = pnx.get("control") or {}
139
- links = pnx.get("links") or {}
140
- record_id = _first(control.get("recordid"), None)
141
- title = _first(display.get("title"), "Untitled")
142
- primo_url = _build_primo_search_link(title, record_id)
143
- creators = display.get("creator") or display.get("contributor") or []
144
- if isinstance(creators, str):
145
- creators = [creators]
146
- raw_link = _first(links.get("openurl") or links.get("linktorsrc"))
147
- results.append({
148
- "record_id": record_id,
149
- "title": title,
150
- "creator": "; ".join(str(creator) for creator in creators) or "Unknown",
151
- "date": _first(search.get("creationdate") or addata.get("risdate") or addata.get("date")),
152
- "type": _first(display.get("type")),
153
- "source": _first(display.get("source") or addata.get("jtitle")),
154
- "description": _first(display.get("description"))[:400],
155
- "doi": _first(addata.get("doi"), None),
156
- "primo_url": primo_url,
157
- "link": _result_link(raw_link, primo_url),
158
- "open_access": _first(display.get("oa")) == "free_for_read",
159
- "_source": "PRIMO",
160
- })
161
- return {"total": (data.get("info") or {}).get("total", 0), "results": results, "source": "PRIMO"}
162
- except (httpx.HTTPError, TimeoutError, ValueError, TypeError, AttributeError) as exc:
163
  # Exception strings from HTTP clients can include the full URL,
164
  # including the API key. Never put them in logs or responses.
165
- logger.warning("PRIMO region %s failed (%s)", region, type(exc).__name__)
166
 
167
- return {"error": "PRIMO API unavailable", "results": [], "total": 0, "source": "PRIMO"}
 
 
168
 
169
 
170
  def _pubmed_query(req: SearchRequest) -> str:
@@ -268,4 +369,5 @@ async def search_endpoint(req: SearchRequest):
268
 
269
  if req.source == "pubmed":
270
  return await search_pubmed(req)
271
- return await search_primo(req, get_settings().primo_api_key)
 
 
3
  import logging
4
  import re
5
  from typing import Optional
6
+ from urllib.parse import quote, urlsplit
7
 
8
  from fastapi import APIRouter
9
  from pydantic import BaseModel, ConfigDict, Field, model_validator
10
 
11
  logger = logging.getLogger(__name__)
12
  router = APIRouter()
13
+ # One deadline covers all gateways, rather than one wait per region. A region
14
+ # selected in HF's PRIMO_API_REGION avoids even the fast discovery attempts.
15
+ PRIMO_REQUEST_TIMEOUT = 6.0
16
  PUBMED_REQUEST_TIMEOUT = 8.0
17
 
18
 
 
21
  query: str = Field(min_length=1, max_length=1000)
22
  boolean_query: Optional[str] = Field(default=None, max_length=1500)
23
  source: str = Field(default="primo", pattern=r"^(primo|pubmed)$")
24
+ limit: int = Field(default=10, ge=1, le=20)
25
+ sort: str = Field(default="rank", pattern=r"^(rank|date_d|date_a)$")
26
+ query_field: str = Field(default="any", pattern=r"^(any|title|creator|sub)$")
27
  peer_reviewed: bool = False
28
  open_access: bool = False
29
  year_from: Optional[str] = Field(default=None, pattern=r"^[0-9]{4}$")
 
45
  return self
46
 
47
 
48
+ def _boolean_to_primo_params(boolean_query: str, query_field: str = "any") -> str:
49
  """Preserve exact phrases and all Boolean operators in one Primo q value."""
50
+ if query_field not in {"any", "title", "creator", "sub"}:
51
+ raise ValueError("Unsupported PRIMO search field")
52
+ return f"q={query_field},contains,{quote(boolean_query.strip(), safe='')}"
53
 
54
 
55
  def _year_bounds(req: SearchRequest) -> tuple[str, str]:
 
57
  return req.year_from or "0001", req.year_to or "9999"
58
 
59
 
60
+ def _build_primo_search_link(title: str, record_id: Optional[str] = None,
61
+ context: Optional[str] = None) -> Optional[str]:
62
  base = "https://khalifa.primo.exlibrisgroup.com/discovery"
63
  if record_id:
64
+ context_param = f"&context={context}" if isinstance(context, str) and context in {"L", "PC"} else ""
65
+ return (
66
+ f"{base}/fulldisplay?docid={quote(str(record_id), safe='')}&vid=971KUOSTAR_INST:KU"
67
+ f"{context_param}&lang=en&tab=Everything&search_scope=MyInst_and_CI"
68
+ )
69
  clean_title = (title or "").strip()
70
  if not clean_title:
71
  return None
 
85
  def _result_link(raw_link: str, fallback: Optional[str]) -> Optional[str]:
86
  # PNX link fields can contain $$U<url>$$D<label>, rather than a bare URL.
87
  match = re.match(r"(?:\$\$U)?(https?://[^\s]+?)(?:\$\$|$)", raw_link)
88
+ if not match:
89
+ return fallback
90
+ candidate = match.group(1)
91
+ try:
92
+ parsed = urlsplit(candidate)
93
+ if parsed.hostname and not parsed.username and not parsed.password:
94
+ return candidate
95
+ except ValueError:
96
+ pass
97
+ return fallback
98
+
99
+
100
+ def _pnx_section(pnx: dict, section: str) -> dict:
101
+ value = pnx.get(section)
102
+ return value if isinstance(value, dict) else {}
103
+
104
+
105
+ def _primo_records(data: dict, limit: int) -> list[dict]:
106
+ """Normalize returned PNX metadata; never infer circulation availability."""
107
+ results = []
108
+ seen = set()
109
+ for doc in data.get("docs", []):
110
+ if not isinstance(doc, dict) or not isinstance(doc.get("pnx"), dict):
111
+ continue
112
+ pnx = doc["pnx"]
113
+ display = _pnx_section(pnx, "display")
114
+ addata = _pnx_section(pnx, "addata")
115
+ search = _pnx_section(pnx, "search")
116
+ sort = _pnx_section(pnx, "sort")
117
+ control = _pnx_section(pnx, "control")
118
+ links = _pnx_section(pnx, "links")
119
+ record_id = _first(control.get("recordid"), None) or _first(doc.get("id"), None)
120
+ title = _first(display.get("title")) or _first(addata.get("btitle") or addata.get("atitle"))
121
+ # A corrupt/empty item is not a real result to pad the requested count.
122
+ if not title:
123
+ continue
124
+ if record_id and record_id in seen:
125
+ continue
126
+ if record_id:
127
+ seen.add(record_id)
128
+ primo_url = _build_primo_search_link(title, record_id, _first(doc.get("context")))
129
+ creators = display.get("creator") or display.get("contributor") or addata.get("au") or []
130
+ if isinstance(creators, str):
131
+ creators = [creators]
132
+ if not isinstance(creators, list):
133
+ creators = []
134
+ raw_link = _first(links.get("openurl") or links.get("linktorsrc"))
135
+ results.append({
136
+ "record_id": record_id,
137
+ "title": title,
138
+ "creator": "; ".join(creator for creator in creators if isinstance(creator, str)),
139
+ "date": (_first(search.get("creationdate")) or _first(addata.get("risdate"))
140
+ or _first(addata.get("date")) or _first(sort.get("creationdate"))),
141
+ "type": _first(display.get("type") or addata.get("ristype")),
142
+ "source": _first(display.get("source") or addata.get("jtitle")),
143
+ "description": _first(display.get("description"))[:400],
144
+ "doi": _first(addata.get("doi"), None),
145
+ "primo_url": primo_url,
146
+ "link": _result_link(raw_link, primo_url),
147
+ "open_access": True if _first(display.get("oa")) == "free_for_read" else None,
148
+ "_source": "PRIMO",
149
+ })
150
+ if len(results) >= limit:
151
+ break
152
+ return results
153
+
154
+
155
+ def _primo_failure(message: str, code: str, status: str = "unavailable") -> dict:
156
+ return {"error": message, "error_code": code, "status": status,
157
+ "results": [], "total": 0, "source": "PRIMO"}
158
+
159
+
160
+ def _primo_gateways(region: str) -> tuple[str, ...]:
161
+ """Allow only Ex Libris's documented gateway hosts; no custom secret target."""
162
+ hosts = {
163
+ "eu": "api-eu.hosted.exlibrisgroup.com",
164
+ "na": "api-na.hosted.exlibrisgroup.com",
165
+ "ap": "api-ap.hosted.exlibrisgroup.com",
166
+ "ca": "api-ca.hosted.exlibrisgroup.com",
167
+ "aps": "api-aps.hosted.exlibrisgroup.com",
168
+ "cn": "api-cn.hosted.exlibrisgroup.com.cn",
169
+ }
170
+ if region == "auto":
171
+ return tuple(hosts[name] for name in ("eu", "na", "ap"))
172
+ return (hosts[region],) if region in hosts else ()
173
+
174
+
175
+ async def search_primo(req: SearchRequest, api_key: str, region: str = "auto") -> dict:
176
  """Search Primo using the full preferred query and documented facet syntax."""
177
  import httpx
178
 
179
  if not api_key:
180
+ return _primo_failure("PRIMO results are not configured", "not_configured", "not_configured")
181
 
182
+ gateways = _primo_gateways(region)
183
+ if not gateways:
184
+ return _primo_failure("PRIMO API region is not configured correctly", "invalid_region", "not_configured")
185
+
186
+ query_params = _boolean_to_primo_params(req.boolean_query or req.query, req.query_field)
187
  facets = []
188
  if req.peer_reviewed:
189
  facets.append("facet_tlevel,exact,peer_reviewed")
 
195
  if req.resource_type != "both":
196
  facets.append(f"facet_rtype,exact,{req.resource_type}")
197
 
198
+ # Multiple qInclude values in the same category are ANDed, so an explicit
199
+ # Books OR Articles request must use multiFacets for that category.
200
+ mixed_types = "facet_rtype,include,books|,|facet_rtype,include,articles"
201
+ mixed_params = f"&multiFacets={quote(mixed_types, safe='')}" if req.resource_type == "both" else ""
202
+ compact_query = re.sub(r'["\s-]', "", (req.boolean_query or req.query)).upper()
203
+ exact_identifier = bool(re.fullmatch(r"(?:[0-9]{9}[0-9X]|[0-9]{13})", compact_query))
204
+ controlled_vocabulary = "false" if req.query_field == "title" or exact_identifier else "true"
205
+
206
  # qInclude accepts one value, joining ANDed facets with |,|. Repeated
207
  # qInclude keys are not its documented API contract.
208
  facet_params = f"&qInclude={quote('|,|'.join(facets), safe='')}" if facets else ""
209
  qs = (
210
  "?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
211
+ f"&{query_params}&lang=en&sort={req.sort}&limit={req.limit}&offset=0"
212
+ f"&conVoc={controlled_vocabulary}&skipDelivery=true"
213
+ f"{facet_params}{mixed_params}"
214
  )
215
 
216
+ deadline = asyncio.get_running_loop().time() + PRIMO_REQUEST_TIMEOUT
217
+ last_status = None
218
  async with httpx.AsyncClient(timeout=PRIMO_REQUEST_TIMEOUT) as client:
219
+ for gateway in gateways:
220
+ remaining = deadline - asyncio.get_running_loop().time()
221
+ if remaining <= 0:
222
+ return _primo_failure("PRIMO took too long to respond; use the search link", "timeout")
223
+ url = f"https://{gateway}/primo/v1/search{qs}"
224
  try:
225
  # httpx read timeouts reset per chunk. wait_for additionally
226
  # bounds the complete request, including a slowly streamed body.
227
  response = await asyncio.wait_for(
228
  client.get(url, headers={"Accept": "application/json", "Authorization": f"apikey {api_key}"}),
229
+ timeout=remaining,
230
  )
231
  if response.status_code != 200:
232
+ last_status = response.status_code
233
+ logger.warning("PRIMO gateway %s returned HTTP %s", gateway, response.status_code)
234
  if response.status_code == 429:
235
+ return _primo_failure("PRIMO request limit reached; try again later", "rate_limited", "rate_limited")
236
  if response.status_code == 400:
237
+ return _primo_failure("PRIMO could not process this search; check the search terms", "invalid_query", "error")
238
+ if response.status_code in (401, 403, 404):
239
+ continue
240
+ return _primo_failure("PRIMO API unavailable", "upstream_error")
241
  data = response.json()
242
  if not isinstance(data, dict) or not isinstance(data.get("docs"), list):
243
  raise ValueError("Invalid PRIMO response")
244
+ results = _primo_records(data, req.limit)
245
+ info = data.get("info") if isinstance(data.get("info"), dict) else {}
246
+ raw_total = info.get("total")
247
+ total = None
248
+ # Unknown totals must stay unknown: a short page is not evidence
249
+ # that those records exhaust all matches in the catalogue.
250
+ if ((type(raw_total) is int) or
251
+ (isinstance(raw_total, str) and re.fullmatch(r"[0-9]+", raw_total.strip()))):
252
+ parsed_total = int(raw_total)
253
+ if parsed_total >= len(results):
254
+ total = parsed_total
255
+ if data["docs"] and not results:
256
+ return _primo_failure("PRIMO returned unreadable record details; use the search link", "invalid_response")
257
+ return {"total": total, "results": results, "source": "PRIMO", "status": "ok"}
258
+ except (TimeoutError, httpx.TimeoutException):
259
+ logger.warning("PRIMO search exceeded the total request deadline")
260
+ return _primo_failure("PRIMO took too long to respond; use the search link", "timeout")
261
+ except (httpx.HTTPError, ValueError, TypeError, AttributeError) as exc:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  # Exception strings from HTTP clients can include the full URL,
263
  # including the API key. Never put them in logs or responses.
264
+ logger.warning("PRIMO gateway %s failed (%s)", gateway, type(exc).__name__)
265
 
266
+ if last_status in (401, 403):
267
+ return _primo_failure("PRIMO access could not be verified; use the search link", "unauthorized")
268
+ return _primo_failure("PRIMO API unavailable", "upstream_error")
269
 
270
 
271
  def _pubmed_query(req: SearchRequest) -> str:
 
369
 
370
  if req.source == "pubmed":
371
  return await search_pubmed(req)
372
+ settings = get_settings()
373
+ return await search_primo(req, settings.primo_api_key, settings.primo_api_region)