nikeshn commited on
Commit
7dfd4df
·
verified ·
1 Parent(s): 194e844

Upload 5 files

Browse files
Files changed (4) hide show
  1. src/api/admin.py +0 -0
  2. src/api/agent.py +57 -47
  3. src/api/feedback.py +8 -3
  4. src/api/search.py +188 -176
src/api/admin.py CHANGED
The diff for this file is too large to render. See raw diff
 
src/api/agent.py CHANGED
@@ -1,52 +1,62 @@
1
- """Authenticated agent endpoint; Cloudflare is the only intended caller."""
2
-
3
  import html
4
- import time
5
- import uuid
6
-
7
- from fastapi import APIRouter, Request
8
-
9
- from src.agentcore.models import AgentRequest, AgentResponse
10
- from src.agentcore.orchestrator import _agent_query_inner, _log_agent_query
11
- from src.agentcore.utils import _get_runtime_config, _normalize_whitespace, _safe_metrics_increment, _sanitize_llm_response
 
12
  from src.services.html_sanitizer import sanitize_answer_html
13
-
14
- router = APIRouter()
15
-
16
-
17
- @router.post("", response_model=AgentResponse)
 
18
  async def agent_query(request: Request, req: AgentRequest):
19
  start_time = time.time()
20
- request_id = request.headers.get("x-request-id", "")[:80] or str(uuid.uuid4())
 
 
 
 
 
 
21
  question = _normalize_whitespace(req.question)
22
- history = list(req.history)
23
- if history and history[-1].role == "user" and _normalize_whitespace(history[-1].content) == question:
24
- history.pop()
25
- is_canary = request.headers.get("x-libbee-canary") == "1"
26
- if not is_canary:
27
- _safe_metrics_increment("agent_requests")
28
- response = await _agent_query_inner(question, history, req.model, req.client_state, start_time)
29
- response.request_id = request_id
30
- response.answer = _sanitize_llm_response(response.answer)
31
- runtime_config = _get_runtime_config()
32
- announcement = str(runtime_config.get("announcement", "")).strip()
33
- if announcement and response.intent not in {"social_greeting", "social"}:
34
- response.answer += '<div class="libbee-announcement" role="note"><strong>Announcement:</strong> ' + html.escape(announcement) + "</div>"
35
- response.answer = sanitize_answer_html(response.answer)
36
- if not is_canary:
37
- _log_agent_query(question, response, start_time)
38
- return response
39
-
40
-
41
- @router.get("/test")
42
- async def test():
43
- return {"status": "ok"}
44
-
45
-
46
- @router.get("/rag-status")
47
- async def rag_status():
48
- try:
49
- from app import get_rag_service
50
- return get_rag_service().get_stats()
51
- except Exception:
52
- return {"ready": False}
 
 
 
1
+ """Authenticated agent endpoint; Cloudflare is the only intended caller."""
2
+
3
  import html
4
+ import re
5
+ import time
6
+ import uuid
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+
10
+ from src.agentcore.models import AgentRequest, AgentResponse
11
+ from src.agentcore.orchestrator import _agent_query_inner, _log_agent_query
12
+ from src.agentcore.utils import _get_runtime_config, _normalize_whitespace, _safe_metrics_increment, _sanitize_llm_response
13
  from src.services.html_sanitizer import sanitize_answer_html
14
+ from src.config import get_settings
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.post("", response_model=AgentResponse)
20
  async def agent_query(request: Request, req: AgentRequest):
21
  start_time = time.time()
22
+ settings = get_settings()
23
+ if (len(req.question) > settings.max_question_chars or
24
+ len(req.history) > settings.max_history_messages or
25
+ any(len(message.content) > settings.max_message_chars for message in req.history)):
26
+ raise HTTPException(status_code=422, detail="Question or history exceeds the configured text limit.")
27
+ supplied_id = request.headers.get("x-request-id", "")
28
+ request_id = supplied_id if re.fullmatch(r"[A-Za-z0-9_-]{8,80}", supplied_id) else str(uuid.uuid4())
29
  question = _normalize_whitespace(req.question)
30
+ if not question:
31
+ raise HTTPException(status_code=422, detail="Question must contain text.")
32
+ history = list(req.history)
33
+ if history and history[-1].role == "user" and _normalize_whitespace(history[-1].content) == question:
34
+ history.pop()
35
+ is_canary = request.headers.get("x-libbee-canary") == "1"
36
+ if not is_canary:
37
+ _safe_metrics_increment("agent_requests")
38
+ response = await _agent_query_inner(question, history, req.model, req.client_state, start_time)
39
+ response.request_id = request_id
40
+ response.answer = _sanitize_llm_response(response.answer)
41
+ runtime_config = _get_runtime_config()
42
+ announcement = str(runtime_config.get("announcement", "")).strip()
43
+ if announcement and response.intent not in {"social_greeting", "social"}:
44
+ response.answer += '<div class="libbee-announcement" role="note"><strong>Announcement:</strong> ' + html.escape(announcement) + "</div>"
45
+ response.answer = sanitize_answer_html(response.answer)
46
+ if not is_canary:
47
+ _log_agent_query(question, response, start_time)
48
+ return response
49
+
50
+
51
+ @router.get("/test")
52
+ async def test():
53
+ return {"status": "ok"}
54
+
55
+
56
+ @router.get("/rag-status")
57
+ async def rag_status():
58
+ try:
59
+ from app import get_rag_service
60
+ return get_rag_service().get_stats()
61
+ except Exception:
62
+ return {"ready": False}
src/api/feedback.py CHANGED
@@ -12,9 +12,12 @@ router = APIRouter()
12
 
13
  class FeedbackRequest(BaseModel):
14
  model_config = ConfigDict(extra="forbid")
15
- request_id: str = Field(pattern=r"^[A-Za-z0-9-]{8,80}$")
16
  feedback: str
17
  comment: str = Field(default="", max_length=500)
 
 
 
18
 
19
  @field_validator("feedback")
20
  @classmethod
@@ -26,12 +29,14 @@ class FeedbackRequest(BaseModel):
26
 
27
  @router.post("")
28
  async def save_feedback(req: FeedbackRequest):
29
- settings = get_settings()
 
 
30
  payload = {
31
  "timestamp": time.time(),
32
  "request_id": req.request_id,
33
  "feedback": req.feedback,
34
- "comment": re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[email]", req.comment)[:500],
35
  }
36
  settings.feedback_path.parent.mkdir(parents=True, exist_ok=True)
37
  with settings.feedback_path.open("a", encoding="utf-8") as handle:
 
12
 
13
  class FeedbackRequest(BaseModel):
14
  model_config = ConfigDict(extra="forbid")
15
+ request_id: str = Field(pattern=r"^[A-Za-z0-9_-]{8,80}$")
16
  feedback: str
17
  comment: str = Field(default="", max_length=500)
18
+ # The existing LibGuides client sends this field to its Worker. Accept it
19
+ # if forwarded, but do not store it in the local feedback fallback.
20
+ session_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{8,80}$")
21
 
22
  @field_validator("feedback")
23
  @classmethod
 
29
 
30
  @router.post("")
31
  async def save_feedback(req: FeedbackRequest):
32
+ settings = get_settings()
33
+ comment = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[email]", req.comment)
34
+ comment = re.sub(r"\b\d{6,}\b", "[number]", comment)
35
  payload = {
36
  "timestamp": time.time(),
37
  "request_id": req.request_id,
38
  "feedback": req.feedback,
39
+ "comment": comment[:500],
40
  }
41
  settings.feedback_path.parent.mkdir(parents=True, exist_ok=True)
42
  with settings.feedback_path.open("a", encoding="utf-8") as handle:
src/api/search.py CHANGED
@@ -1,259 +1,271 @@
1
- """
2
- src/api/search.py — PRIMO + PubMed search endpoint for LibBee v3.4.
3
-
4
- Search approach: send the full boolean query as a single q=any,contains,<boolean>
5
- parameter — exactly how the PRIMO web UI works. This preserves phrase quotes,
6
- parentheses, AND/OR operators, and gives results identical to the web UI.
7
-
8
- Earlier versions split the boolean into multiple q= params (one per AND group),
9
- but PRIMO does not reliably handle OR within a single q= value in multi-param mode.
10
- The correct approach is a single q= with the full boolean, URL-encoded.
11
- """
12
- import re
13
  import logging
14
- from datetime import datetime
15
  from typing import Optional
 
16
 
17
  from fastapi import APIRouter
18
- from pydantic import BaseModel, ConfigDict, Field
19
- from urllib.parse import quote
20
 
21
  logger = logging.getLogger(__name__)
22
  router = APIRouter()
 
 
23
 
24
 
25
- # ── Models ─────────────────────────────────────────────────────────────────────
26
-
27
  class SearchRequest(BaseModel):
28
- model_config = ConfigDict(extra="forbid")
29
  query: str = Field(min_length=1, max_length=1000)
30
  boolean_query: Optional[str] = Field(default=None, max_length=1500)
31
  source: str = Field(default="primo", pattern=r"^(primo|pubmed)$")
32
  limit: int = Field(default=5, ge=1, le=20)
33
  peer_reviewed: bool = False
34
  open_access: bool = False
35
- year_from: Optional[str] = Field(default=None, pattern=r"^\d{4}$")
36
- year_to: Optional[str] = Field(default=None, pattern=r"^\d{4}$")
37
  resource_type: str = Field(default="articles", pattern=r"^(articles|books|both)$")
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- # ── PRIMO query builder ────────────────────────────────────────────────────────
41
 
42
  def _boolean_to_primo_params(boolean_query: str) -> str:
43
- """
44
- Convert a boolean query into a single PRIMO q= parameter.
45
-
46
- Sends the full boolean as: q=any,contains,<url-encoded boolean>
47
- This matches exactly how the PRIMO web UI constructs advanced search URLs.
48
-
49
- Examples:
50
- '("machine learning" OR "deep learning") AND ("healthcare")'
51
- → 'q=any,contains,%28%22machine+learning%22+OR+%22deep+learning%22%29...'
52
 
53
- '"climate change"'
54
- → 'q=any,contains,%22climate+change%22'
55
 
56
- 'petroleum engineering'
57
- → 'q=any,contains,petroleum+engineering'
58
- """
59
- if not boolean_query or not boolean_query.strip():
60
- return "q=any,contains,library"
61
 
62
- # Send the full boolean as a single q= parameter — PRIMO parses it correctly
63
- return f"q=any,contains,{quote(boolean_query.strip())}"
64
-
65
-
66
- # ── PRIMO helpers ──────────────────────────────────────────────────────────────
67
 
68
  def _build_primo_search_link(title: str, record_id: Optional[str] = None) -> Optional[str]:
69
- base = 'https://khalifa.primo.exlibrisgroup.com/discovery'
70
  if record_id:
71
- return f"{base}/fulldisplay?docid={quote(str(record_id))}&vid=971KUOSTAR_INST:KU"
72
- clean_title = (title or '').strip()
73
  if not clean_title:
74
  return None
75
  return (
76
- f"{base}/search?query=any,contains,{quote(clean_title)}"
77
  "&tab=Everything&search_scope=MyInst_and_CI&vid=971KUOSTAR_INST:KU&lang=en"
78
  )
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  async def search_primo(req: SearchRequest, api_key: str) -> dict:
82
- """
83
- Search the PRIMO API.
84
- Prefers req.boolean_query (LLM-built) over req.query for structured results.
85
- Converts boolean to multi-q-param format for accurate PRIMO results.
86
- """
87
  import httpx
88
 
89
  if not api_key:
90
- return {"error": "PRIMO_API_KEY not configured", "results": [], "total": 0}
91
-
92
- raw_query = (req.boolean_query or req.query or "").strip()
93
- has_bool = bool(re.search(r'\b(AND|OR)\b', raw_query)) and '(' in raw_query
94
 
95
- if has_bool:
96
- query_params = _boolean_to_primo_params(raw_query)
97
- else:
98
- clean = re.sub(r'["\']', '', raw_query).strip()
99
- query_params = f"q=any,contains,{quote(clean)}"
100
-
101
- facets = ""
102
  if req.peer_reviewed:
103
- facets += "&qInclude=facet_tlevel,exact,peer_reviewed"
104
  if req.open_access:
105
- facets += "&qInclude=facet_tlevel,exact,open_access"
106
  if req.year_from or req.year_to:
107
- yf = req.year_from or "1900"
108
- yt = req.year_to or str(datetime.now().year)
109
- facets += f"&multiFacets=facet_searchcreationdate,include,{yf}%7C,%7C{yt}"
110
- if req.resource_type == "books":
111
- facets += "&qInclude=facet_rtype,exact,books"
112
- elif req.resource_type == "articles":
113
- facets += "&qInclude=facet_rtype,exact,articles"
114
-
115
- vid = "971KUOSTAR_INST:KU"
116
  qs = (
117
- f"?vid={vid}&tab=Everything&scope=MyInst_and_CI"
118
- f"&{query_params}"
119
- f"&lang=en&sort=rank&limit={req.limit}&offset=0&mode=advanced"
120
- f"&apikey={api_key}{facets}"
121
  )
122
 
123
- base = "https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
124
-
125
- async with httpx.AsyncClient(timeout=15) as client:
126
- for region in ["api-eu", "api-na", "api-ap"]:
127
- url = base.replace("api-eu", region) + qs
128
  try:
129
- r = await client.get(url, headers={"Accept": "application/json"})
130
- if r.status_code == 200:
131
- data = r.json()
132
- total = data.get("info", {}).get("total", 0)
133
- results = []
134
- for doc in data.get("docs", []):
135
- pnx = doc.get("pnx", {})
136
- d = pnx.get("display", {})
137
- a = pnx.get("addata", {})
138
- s = pnx.get("search", {})
139
- c = pnx.get("control", {})
140
- l = pnx.get("links", {})
141
- record_id = (c.get("recordid") or [None])[0]
142
- title = (d.get("title") or ["Untitled"])[0]
143
- primo_url = _build_primo_search_link(title, record_id)
144
- doi = (a.get("doi") or [None])[0]
145
- raw_link = (l.get("openurl") or l.get("linktorsrc") or [None])[0]
146
- results.append({
147
- "record_id": record_id,
148
- "title": title,
149
- "creator": "; ".join(
150
- d.get("creator") or d.get("contributor") or []
151
- ) or "Unknown",
152
- "date": (
153
- s.get("creationdate") or a.get("risdate") or a.get("date") or [""]
154
- )[0],
155
- "type": (d.get("type") or [""])[0],
156
- "source": (d.get("source") or a.get("jtitle") or [""])[0],
157
- "description": ((d.get("description") or [""])[0] or "")[:400],
158
- "doi": doi,
159
- "primo_url": primo_url,
160
- "link": raw_link or primo_url,
161
- "open_access": (d.get("oa") or [""])[0] == "free_for_read",
162
- "_source": "PRIMO",
163
- })
164
- return {"total": total, "results": results, "source": "PRIMO"}
165
- except Exception as e:
166
- logger.warning("PRIMO region %s failed: %s", region, e)
167
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
  return {"error": "PRIMO API unavailable", "results": [], "total": 0, "source": "PRIMO"}
170
 
171
 
172
- # ── PubMed search ──────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
  async def search_pubmed(req: SearchRequest) -> dict:
175
- """Search PubMed via E-utilities."""
176
  import httpx
177
 
178
  base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
179
- query = (req.query or "").strip()
 
 
 
 
 
180
 
181
- if req.year_from or req.year_to:
182
- yf = req.year_from or "1900"
183
- yt = req.year_to or str(datetime.now().year)
184
- query = f"({query}) AND ({yf}:{yt}[dp])"
185
 
186
  try:
187
- async with httpx.AsyncClient(timeout=15) as client:
188
- r = await client.get(
189
  f"{base}/esearch.fcgi",
190
- params={
191
- "db": "pubmed", "term": query,
192
- "retmax": req.limit, "retmode": "json", "sort": "relevance"
193
- }
194
- )
195
- if r.status_code != 200:
196
- return {"error": "PubMed search failed", "results": [], "source": "PubMed"}
197
-
198
- esearch = r.json().get("esearchresult", {})
199
- ids = esearch.get("idlist", [])
200
- # PubMed Automatic Term Mapping: how the free-text query was
201
- # expanded to MeSH terms - surfaced to teach search strategy.
202
  query_translation = esearch.get("querytranslation", "")
203
  count = int(esearch.get("count", 0))
204
  if not ids:
205
- return {"total": 0, "results": [], "source": "PubMed", "query_translation": query_translation}
206
 
207
- r2 = await client.get(
208
  f"{base}/esummary.fcgi",
209
- params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"}
210
- )
211
- if r2.status_code != 200:
212
- return {"error": "PubMed fetch failed", "results": [], "source": "PubMed"}
213
-
214
- data = r2.json().get("result", {})
 
 
215
  results = []
216
  for pid in ids:
217
- rec = data.get(pid, {})
218
- if not isinstance(rec, dict):
219
  continue
220
- authors_list = rec.get("authors", [])
221
- authors = ", ".join(a.get("name", "") for a in authors_list[:3])
222
  if len(authors_list) > 3:
223
  authors += " et al."
224
- eloc = rec.get("elocationid", "")
225
- doi = eloc.replace("doi: ", "") if "doi:" in eloc else None
 
 
 
226
  results.append({
227
- "title": rec.get("title", ""),
228
  "creator": authors,
229
- "date": rec.get("pubdate", ""),
230
- "source": rec.get("fulljournalname", rec.get("source", "")),
231
  "doi": doi,
232
  "pmid": pid,
233
  "link": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
234
- "type": "Journal Article",
 
235
  "_source": "PubMed",
236
  })
237
- return {"total": count, "results": results, "source": "PubMed", "query_translation": query_translation}
 
 
 
 
 
238
 
239
- except Exception as e:
240
- return {"error": f"PubMed: {str(e)}", "results": [], "source": "PubMed"}
241
-
242
-
243
- # ── FastAPI endpoint ───────────────────────────────────────────────────────────
244
 
245
  @router.post("")
246
  async def search_endpoint(req: SearchRequest):
247
- """
248
- Search PRIMO or PubMed.
249
- Accepts boolean_query (preferred, LLM-built) or falls back to query.
250
- """
251
  from src.config import get_settings
252
- settings = get_settings()
253
 
254
- source = (req.source or "primo").lower()
255
-
256
- if source == "pubmed":
257
  return await search_pubmed(req)
258
-
259
- return await search_primo(req, settings.primo_api_key)
 
1
+ """PRIMO and PubMed search, with explicit filters and bounded upstream waits."""
2
+ 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
 
 
 
17
  class SearchRequest(BaseModel):
18
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
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}$")
26
+ year_to: Optional[str] = Field(default=None, pattern=r"^[0-9]{4}$")
27
  resource_type: str = Field(default="articles", pattern=r"^(articles|books|both)$")
28
 
29
+ @model_validator(mode="after")
30
+ def validate_search(self):
31
+ self.boolean_query = self.boolean_query or None
32
+ for year in (self.year_from, self.year_to):
33
+ if year == "0000":
34
+ raise ValueError("Publication years must be between 0001 and 9999")
35
+ if self.year_from and self.year_to and self.year_from > self.year_to:
36
+ raise ValueError("year_from must not be later than year_to")
37
+ # Primo reserves semicolons for additional field clauses, even when
38
+ # percent-encoded; accepting one here changes the meaning of a query.
39
+ if self.source == "primo" and ";" in (self.boolean_query or self.query):
40
+ raise ValueError("PRIMO search terms cannot contain semicolons")
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]:
50
+ """Represent an omitted bound without silently excluding older/future works."""
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
61
  return (
62
+ f"{base}/search?query=any,contains,{quote(clean_title, safe='')}"
63
  "&tab=Everything&search_scope=MyInst_and_CI&vid=971KUOSTAR_INST:KU&lang=en"
64
  )
65
 
66
 
67
+ def _first(value, default=""):
68
+ """PNX values are normally arrays; tolerate scalar/missing metadata."""
69
+ if isinstance(value, list):
70
+ value = value[0] if value else default
71
+ return value if isinstance(value, str) else default
72
+
73
+
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")
91
  if req.open_access:
92
+ facets.append("facet_tlevel,exact,open_access")
93
  if req.year_from or req.year_to:
94
+ year_from, year_to = _year_bounds(req)
95
+ facets.append(f"facet_searchcreationdate,exact,[{year_from} TO {year_to}]")
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:
171
+ query = req.boolean_query or req.query
172
+ if req.year_from or req.year_to:
173
+ year_from, year_to = _year_bounds(req)
174
+ query = f"({query}) AND ({year_from}:{year_to}[dp])"
175
+ if req.open_access:
176
+ query = f"({query}) AND free full text[sb]"
177
+ if req.resource_type == "books":
178
+ query = f'({query}) AND "pubmed books"[sb]'
179
+ elif req.resource_type == "articles":
180
+ query = f'({query}) NOT "pubmed books"[sb]'
181
+ return query
182
+
183
 
184
  async def search_pubmed(req: SearchRequest) -> dict:
185
+ """Search PubMed; free availability does not establish an open-access licence."""
186
  import httpx
187
 
188
  base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
189
+ warnings = []
190
+ if req.peer_reviewed:
191
+ warnings.append("PubMed has no general peer-reviewed-only filter; peer review must be checked for each record.")
192
+ if req.open_access:
193
+ warnings.append("The PubMed access filter finds free full text; it does not verify an open-access licence.")
194
+ common = {"source": "PubMed", "warnings": warnings}
195
 
196
+ def failed(message):
197
+ return {**common, "error": message, "results": [], "total": 0}
 
 
198
 
199
  try:
200
+ async with httpx.AsyncClient(timeout=PUBMED_REQUEST_TIMEOUT) as client:
201
+ response = await asyncio.wait_for(client.get(
202
  f"{base}/esearch.fcgi",
203
+ params={"db": "pubmed", "term": _pubmed_query(req), "retmax": req.limit,
204
+ "retmode": "json", "sort": "relevance"},
205
+ ), timeout=PUBMED_REQUEST_TIMEOUT)
206
+ if response.status_code != 200:
207
+ return failed("PubMed search failed")
208
+ payload = response.json()
209
+ esearch = payload.get("esearchresult", {})
210
+ if payload.get("error") or esearch.get("ERROR") or esearch.get("errorlist"):
211
+ return failed("PubMed could not process this search; check the search terms")
212
+ ids = esearch.get("idlist")
213
+ if not isinstance(ids, list):
214
+ return failed("PubMed returned an unreadable search response; try again")
215
  query_translation = esearch.get("querytranslation", "")
216
  count = int(esearch.get("count", 0))
217
  if not ids:
218
+ return {**common, "total": count, "results": [], "query_translation": query_translation}
219
 
220
+ summary = await asyncio.wait_for(client.get(
221
  f"{base}/esummary.fcgi",
222
+ params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"},
223
+ ), timeout=PUBMED_REQUEST_TIMEOUT)
224
+ if summary.status_code != 200:
225
+ return failed("PubMed fetch failed")
226
+ summary_payload = summary.json()
227
+ if summary_payload.get("error"):
228
+ return failed("PubMed fetch failed")
229
+ data = summary_payload.get("result", {})
230
  results = []
231
  for pid in ids:
232
+ record = data.get(pid)
233
+ if not isinstance(record, dict) or record.get("error"):
234
  continue
235
+ authors_list = record.get("authors") or []
236
+ authors = ", ".join(author.get("name", "") for author in authors_list[:3])
237
  if len(authors_list) > 3:
238
  authors += " et al."
239
+ doi = next((article.get("value") for article in record.get("articleids", [])
240
+ if article.get("idtype") == "doi"), None)
241
+ if not doi:
242
+ match = re.search(r"\bdoi:\s*(\S+)", record.get("elocationid", ""), re.IGNORECASE)
243
+ doi = match.group(1) if match else None
244
  results.append({
245
+ "title": record.get("title", ""),
246
  "creator": authors,
247
+ "date": record.get("pubdate", ""),
248
+ "source": record.get("fulljournalname", record.get("source", "")),
249
  "doi": doi,
250
  "pmid": pid,
251
  "link": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
252
+ "type": "; ".join(record.get("pubtype") or []) or "PubMed record",
253
+ "free_full_text": True if req.open_access else None,
254
  "_source": "PubMed",
255
  })
256
+ if ids and not results:
257
+ return failed("PubMed returned no readable record details; try again")
258
+ return {**common, "total": count, "results": results, "query_translation": query_translation}
259
+ except (httpx.HTTPError, TimeoutError, ValueError, TypeError, AttributeError) as exc:
260
+ logger.warning("PubMed search failed (%s)", type(exc).__name__)
261
+ return failed("PubMed is temporarily unavailable; try again")
262
 
 
 
 
 
 
263
 
264
  @router.post("")
265
  async def search_endpoint(req: SearchRequest):
266
+ """Search PRIMO or PubMed, preferring boolean_query when supplied."""
 
 
 
267
  from src.config import get_settings
 
268
 
269
+ if req.source == "pubmed":
 
 
270
  return await search_pubmed(req)
271
+ return await search_primo(req, get_settings().primo_api_key)