nikeshn commited on
Commit
c68df57
·
verified ·
1 Parent(s): f26bb27

Upload search.py

Browse files
Files changed (1) hide show
  1. src/api/search.py +356 -134
src/api/search.py CHANGED
@@ -1,14 +1,6 @@
1
- """
2
- search.py — LibBee v3
3
- Key fixes:
4
- - PRIMO: resource type (articles/books) sent as rtype FACET, not q= keyword
5
- - PRIMO: year filter sent as facet_searchcreationdate FACET, not keyword
6
- - PubMed: year filter wired to mindate/maxdate params (was silently ignored before)
7
- - PubMed: peer_reviewed mapped to free full text filter
8
- - query sent to both APIs is clean topic keywords only — no rtype noise
9
- """
10
  import logging
11
- from typing import Optional
 
12
  from urllib.parse import quote
13
 
14
  import httpx
@@ -20,6 +12,90 @@ from src.config import get_settings
20
  logger = logging.getLogger(__name__)
21
  router = APIRouter()
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  class SearchRequest(BaseModel):
25
  query: str
@@ -29,8 +105,8 @@ class SearchRequest(BaseModel):
29
  open_access: bool = False
30
  year_from: Optional[str] = None
31
  year_to: Optional[str] = None
32
- # resource_type controls rtype FACET — NOT injected into q= keyword query
33
- resource_type: str = "articles" # "articles" | "books" | "both"
34
 
35
  @field_validator("query")
36
  @classmethod
@@ -61,9 +137,17 @@ class SearchRequest(BaseModel):
61
  @classmethod
62
  def valid_resource_type(cls, v: str) -> str:
63
  allowed = {"articles", "books", "both"}
64
- if v not in allowed:
65
- return "articles"
66
- return v
 
 
 
 
 
 
 
 
67
 
68
 
69
  def _safe_metrics_increment(key: str) -> None:
@@ -74,6 +158,7 @@ def _safe_metrics_increment(key: str) -> None:
74
  return
75
 
76
 
 
77
  def _safe_metrics_bucket(bucket: str, key: str) -> None:
78
  try:
79
  from app import metrics_service
@@ -82,6 +167,222 @@ def _safe_metrics_bucket(bucket: str, key: str) -> None:
82
  return
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  @router.post("")
86
  async def search_endpoint(req: SearchRequest):
87
  _safe_metrics_increment("search_requests")
@@ -94,73 +395,53 @@ async def search_endpoint(req: SearchRequest):
94
  return await search_scholar(req)
95
 
96
 
97
- # ── PRIMO ──────────────────────────────────────────────────────────────────────
98
-
99
  async def search_primo(req: SearchRequest, api_key: str):
100
- """
101
- Send clean topic keywords to q=any,contains.
102
- Resource type, peer review, open access, and year are all FACETS — never keywords.
103
-
104
- PRIMO facet reference:
105
- facet_rtype,include,articles → journal articles
106
- facet_rtype,include,books → books / ebooks
107
- facet_tlevel,include,peer_reviewed
108
- facet_tlevel,include,online_resources (open access proxy)
109
- facet_searchcreationdate,include,YYYY|,|YYYY
110
- """
111
  if not api_key:
112
  logger.warning("PRIMO_API_KEY not configured")
113
  _safe_metrics_bucket("errors", "primo_missing_api_key")
114
  return {"error": "PRIMO_API_KEY not configured", "results": [], "source": "PRIMO"}
115
 
116
- # ── The query sent to PRIMO must be ONLY topic keywords ──────────────────
117
- # Strip any residual resource-type words that may have slipped through
118
- # topic extraction (belt-and-braces defence).
119
  clean_query = _strip_resource_noise(req.query)
120
  if not clean_query:
121
- clean_query = req.query # fallback if stripping removes everything
122
-
123
- query_encoded = quote(clean_query)
124
-
125
- url = (
126
- f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
127
- f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
128
- f"&q=any,contains,{query_encoded}&lang=en&sort=rank&limit={req.limit}"
129
- f"&offset=0&mode=advanced&apikey={api_key}"
130
- )
131
-
132
- # ── Build facets — resource type, quality, year ───────────────────────────
133
- facets = []
134
-
135
- # Resource type → rtype facet (NOT a keyword)
136
- if req.resource_type == "articles":
137
- facets.append("facet_rtype,include,articles")
138
- elif req.resource_type == "books":
139
- facets.append("facet_rtype,include,books")
140
- # "both" → no rtype facet (search everything)
141
-
142
- if req.peer_reviewed:
143
- facets.append("facet_tlevel,include,peer_reviewed")
144
-
145
- if req.open_access:
146
- facets.append("facet_tlevel,include,online_resources")
147
-
148
- if req.year_from or req.year_to:
149
- yf = req.year_from or "0001"
150
- yt = req.year_to or "9999"
151
- facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
152
-
153
- for facet in facets:
154
- url += f"&multiFacets={quote(facet)}"
155
 
156
- logger.info(
157
- "PRIMO query=%r resource_type=%s peer_reviewed=%s year=%s-%s",
158
- clean_query, req.resource_type, req.peer_reviewed, req.year_from, req.year_to,
159
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
  try:
162
  async with httpx.AsyncClient(timeout=20) as client:
 
163
  r = await client.get(url, headers={"Accept": "application/json"})
 
 
 
 
164
  logger.info("PRIMO status=%s", r.status_code)
165
  if r.status_code != 200:
166
  _safe_metrics_bucket("errors", f"primo_{r.status_code}")
@@ -168,6 +449,7 @@ async def search_primo(req: SearchRequest, api_key: str):
168
 
169
  data = r.json()
170
  docs = data.get("docs", [])
 
171
  results = []
172
  for doc in docs[: req.limit]:
173
  pnx = doc.get("pnx", {})
@@ -178,17 +460,10 @@ async def search_primo(req: SearchRequest, api_key: str):
178
  "date": (d.get("date") or [""])[0],
179
  "type": (d.get("type") or [""])[0],
180
  "source": (d.get("source") or [""])[0],
181
- "primo_url": (
182
- f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
183
- f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU"
184
- ),
185
  "_source": "PRIMO",
186
  })
187
- return {
188
- "total": data.get("info", {}).get("total", 0),
189
- "results": results,
190
- "source": "PRIMO",
191
- }
192
 
193
  except httpx.TimeoutException:
194
  _safe_metrics_bucket("errors", "primo_timeout")
@@ -199,58 +474,20 @@ async def search_primo(req: SearchRequest, api_key: str):
199
  return {"error": str(exc), "results": [], "source": "PRIMO"}
200
 
201
 
202
- def _strip_resource_noise(query: str) -> str:
203
- """
204
- Remove resource-type and filter words that should be facets, not q= keywords.
205
- Called as a belt-and-braces guard before sending to PRIMO/PubMed.
206
- """
207
- noise = re.compile(
208
- r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
209
- r"|peer[- ]reviewed|open[- ]access|studies|study|research)\b",
210
- re.IGNORECASE,
211
- )
212
- cleaned = noise.sub(" ", query)
213
- # Collapse leftover AND/OR operators with no operands
214
- cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
215
- cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
216
- cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
217
- return re.sub(r"\s+", " ", cleaned).strip()
218
-
219
-
220
- import re # noqa: E402 (needed for _strip_resource_noise above)
221
-
222
-
223
- # ── PubMed ─────────────────────────────────────────────────────────────────────
224
-
225
  async def search_pubmed(req: SearchRequest):
226
- """
227
- Year filters use mindate/maxdate params — NOT injected into the search term.
228
- Peer-reviewed filter uses [pt] (publication type) in the term.
229
- """
230
- # Build clean term — topic keywords only
231
  clean_query = _strip_resource_noise(req.query)
232
  if not clean_query:
233
  clean_query = req.query
234
-
235
- # Add peer-reviewed as a PubMed publication-type filter (appended to term)
236
  term = clean_query
237
  if req.peer_reviewed:
238
  term = f"({term}) AND Journal Article[pt]"
239
-
240
  query_encoded = quote(term)
241
-
242
- # Date params (separate from search term — correct PubMed approach)
243
  date_params = ""
244
  if req.year_from or req.year_to:
245
  yf = req.year_from or "1900"
246
  yt = req.year_to or "3000"
247
  date_params = f"&mindate={yf}&maxdate={yt}&datetype=pdat"
248
-
249
- logger.info(
250
- "PubMed term=%r peer_reviewed=%s year=%s-%s",
251
- term, req.peer_reviewed, req.year_from, req.year_to,
252
- )
253
-
254
  try:
255
  async with httpx.AsyncClient(timeout=15) as client:
256
  search_url = (
@@ -261,11 +498,9 @@ async def search_pubmed(req: SearchRequest):
261
  if r.status_code != 200:
262
  _safe_metrics_bucket("errors", "pubmed_search_failed")
263
  return {"error": "PubMed search failed", "results": [], "source": "PubMed"}
264
-
265
  ids = r.json().get("esearchresult", {}).get("idlist", [])
266
  if not ids:
267
  return {"total": 0, "results": [], "source": "PubMed"}
268
-
269
  fetch_url = (
270
  f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
271
  f"?db=pubmed&id={','.join(ids)}&retmode=json"
@@ -274,7 +509,6 @@ async def search_pubmed(req: SearchRequest):
274
  if r2.status_code != 200:
275
  _safe_metrics_bucket("errors", "pubmed_fetch_failed")
276
  return {"error": "PubMed fetch failed", "results": [], "source": "PubMed"}
277
-
278
  data = r2.json().get("result", {})
279
  results = []
280
  for pid in ids:
@@ -296,31 +530,20 @@ async def search_pubmed(req: SearchRequest):
296
  "_source": "PubMed",
297
  })
298
  return {"total": len(ids), "results": results, "source": "PubMed"}
299
-
300
  except Exception as exc:
301
  logger.error("PubMed search error: %s", exc)
302
  _safe_metrics_bucket("errors", "pubmed_exception")
303
  return {"error": str(exc), "results": [], "source": "PubMed"}
304
 
305
 
306
- # ── Semantic Scholar ───────────────────────────────────────────────────────────
307
-
308
  async def search_scholar(req: SearchRequest):
309
  try:
310
- params = {
311
- "query": req.query,
312
- "limit": req.limit,
313
- "fields": "title,authors,year,venue,externalIds,citationCount",
314
- }
315
  async with httpx.AsyncClient(timeout=15) as client:
316
- r = await client.get(
317
- "https://api.semanticscholar.org/graph/v1/paper/search",
318
- params=params,
319
- )
320
  if r.status_code != 200:
321
  _safe_metrics_bucket("errors", f"semantic_{r.status_code}")
322
  return {"error": f"Semantic Scholar API: {r.status_code}", "results": [], "source": "Semantic Scholar"}
323
-
324
  data = r.json()
325
  results = []
326
  for paper in data.get("data", []):
@@ -340,7 +563,6 @@ async def search_scholar(req: SearchRequest):
340
  "_source": "Semantic Scholar",
341
  })
342
  return {"total": data.get("total", 0), "results": results, "source": "Semantic Scholar"}
343
-
344
  except Exception as exc:
345
  logger.error("Semantic Scholar search error: %s", exc)
346
  _safe_metrics_bucket("errors", "semantic_exception")
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
+ import re
3
+ from typing import Dict, List, Optional
4
  from urllib.parse import quote
5
 
6
  import httpx
 
12
  logger = logging.getLogger(__name__)
13
  router = APIRouter()
14
 
15
+ PRIMO_BASE = "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU"
16
+ PRIMO_AI_BASE = "https://khalifa.primo.exlibrisgroup.com/discovery/researchAssistant?vid=971KUOSTAR_INST:KU&query="
17
+ EBSCO_AI_BASE = "https://research-ebsco-com.khalifa.idm.oclc.org/c/ntdlkp/search?q="
18
+ SCOPUS_AI_BASE = "https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query="
19
+ LEAPSPACE_BASE = "https://www.sciencedirect.com/leapspace?query="
20
+ CONSENSUS_BASE = "https://consensus.app/results/?q="
21
+ CURRENT_YEAR = 2026
22
+
23
+ PREFIX_PATTERNS = [
24
+ r"^summari[sz]e\s+the\s+latest\s+studies\s+in\s+",
25
+ r"^summari[sz]e\s+the\s+latest\s+research\s+on\s+",
26
+ r"^summari[sz]e\s+recent\s+studies\s+in\s+",
27
+ r"^summari[sz]e\s+recent\s+research\s+on\s+",
28
+ r"^give\s+me\s+a\s+summary\s+of\s+",
29
+ r"^give\s+me\s+an\s+overview\s+of\s+",
30
+ r"^give\s+me\s+a\s+brief\s+overview\s+of\s+",
31
+ r"^find\s+(?:peer[- ]reviewed\s+)?articles?\s+on\s+",
32
+ r"^find\s+(?:peer[- ]reviewed\s+)?papers?\s+on\s+",
33
+ r"^find\s+books?\s+on\s+",
34
+ r"^search\s+for\s+articles?\s+on\s+",
35
+ r"^search\s+for\s+papers?\s+on\s+",
36
+ r"^i\s+need\s+(?:peer[- ]reviewed\s+)?articles?\s+on\s+",
37
+ r"^i\s+need\s+papers?\s+on\s+",
38
+ r"^i\s+need\s+books?\s+on\s+",
39
+ r"^show\s+me\s+articles?\s+on\s+",
40
+ r"^get\s+me\s+articles?\s+on\s+",
41
+ r"^tell\s+me\s+about\s+",
42
+ r"^explain\s+",
43
+ r"^overview\s+of\s+",
44
+ r"^summary\s+of\s+",
45
+ r"^latest\s+studies\s+in\s+",
46
+ r"^latest\s+research\s+on\s+",
47
+ r"^recent\s+research\s+on\s+",
48
+ r"^recent\s+studies\s+in\s+",
49
+ ]
50
+
51
+ RESOURCE_NOISE_RE = re.compile(
52
+ r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
53
+ r"|peer[- ]reviewed|open[- ]access|studies|study|research|latest|recent)\b",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+ STOP_WORDS = {
58
+ "the", "a", "an", "and", "or", "of", "on", "in", "for", "to", "with", "by",
59
+ "from", "at", "about", "latest", "recent", "current", "summary", "overview", "brief",
60
+ "analysis", "review", "deep", "find", "search", "show", "give", "me", "need", "please",
61
+ }
62
+
63
+ PHRASE_SYNONYMS: Dict[str, List[str]] = {
64
+ "global warming": ["climate change"],
65
+ "climate change": ["global warming"],
66
+ "machine learning": ["artificial intelligence", "AI"],
67
+ "artificial intelligence": ["AI", "machine learning"],
68
+ "deep learning": ["neural networks"],
69
+ "renewable energy": ["clean energy", "sustainable energy"],
70
+ "energy transition": ["decarbonization"],
71
+ "cyber security": ["cybersecurity", "information security"],
72
+ "cybersecurity": ["cyber security", "information security"],
73
+ "public health": ["population health"],
74
+ "gene editing": ["CRISPR"],
75
+ "mental health": ["psychological health"],
76
+ "higher education": ["tertiary education", "universities"],
77
+ }
78
+
79
+ TOKEN_SYNONYMS: Dict[str, List[str]] = {
80
+ "healthcare": ["medicine", "clinical"],
81
+ "medical": ["clinical", "medicine"],
82
+ "nursing": ["clinical", "patient care"],
83
+ "education": ["teaching", "learning"],
84
+ "students": ["student"],
85
+ "student": ["students"],
86
+ "sustainability": ["sustainable development"],
87
+ "cancer": ["oncology"],
88
+ "heart": ["cardiovascular"],
89
+ "brain": ["neurology", "neuroscience"],
90
+ "politics": ["political"],
91
+ "engineering": ["engineer*"],
92
+ }
93
+
94
+ MEDICAL_HINT_RE = re.compile(
95
+ r"\b(medicine|medical|health|clinical|nursing|pharmacy|biomedical|pubmed|embase|cinahl|cochrane|uptodate|disease|therapy|treatment|diagnosis|patient|hospital|cancer|diabetes)\b",
96
+ re.IGNORECASE,
97
+ )
98
+
99
 
100
  class SearchRequest(BaseModel):
101
  query: str
 
105
  open_access: bool = False
106
  year_from: Optional[str] = None
107
  year_to: Optional[str] = None
108
+ resource_type: str = "articles" # articles | books | both
109
+ boolean_query: Optional[str] = None
110
 
111
  @field_validator("query")
112
  @classmethod
 
137
  @classmethod
138
  def valid_resource_type(cls, v: str) -> str:
139
  allowed = {"articles", "books", "both"}
140
+ return v if v in allowed else "articles"
141
+
142
+
143
+ class SearchPlanRequest(BaseModel):
144
+ question: str
145
+ peer_reviewed: bool = False
146
+ open_access: bool = False
147
+ year_from: Optional[str] = None
148
+ year_to: Optional[str] = None
149
+ resource_type: Optional[str] = None
150
+
151
 
152
 
153
  def _safe_metrics_increment(key: str) -> None:
 
158
  return
159
 
160
 
161
+
162
  def _safe_metrics_bucket(bucket: str, key: str) -> None:
163
  try:
164
  from app import metrics_service
 
167
  return
168
 
169
 
170
+
171
+ def _normalize_whitespace(text: str) -> str:
172
+ return re.sub(r"\s+", " ", (text or "").strip())
173
+
174
+
175
+
176
+ def _strip_resource_noise(query: str) -> str:
177
+ cleaned = RESOURCE_NOISE_RE.sub(" ", query or "")
178
+ cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
179
+ cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
180
+ cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
181
+ return _normalize_whitespace(cleaned)
182
+
183
+
184
+
185
+ def _extract_topic(question: str) -> str:
186
+ q = _normalize_whitespace(question)
187
+ lower = q.lower()
188
+ for pat in PREFIX_PATTERNS:
189
+ lower_after = re.sub(pat, "", lower, count=1)
190
+ if lower_after != lower:
191
+ lower = lower_after
192
+ break
193
+ lower = re.sub(r"\b(last|past)\s+\d{1,2}\s+years?\b", " ", lower)
194
+ lower = re.sub(r"\bfrom\s+(19|20)\d{2}\s+(?:to|and|-)+\s+(19|20)\d{2}\b", " ", lower)
195
+ lower = re.sub(r"\b(19|20)\d{2}\b", " ", lower)
196
+ lower = _strip_resource_noise(lower)
197
+ return lower.strip(" .?") or _strip_resource_noise(question)
198
+
199
+
200
+
201
+ def _derive_resource_type_from_text(question: str, override: Optional[str] = None) -> str:
202
+ if override in {"articles", "books", "both"}:
203
+ return override
204
+ q = (question or "").lower()
205
+ if any(x in q for x in ["books", "book", "ebook", "ebooks"]):
206
+ return "books"
207
+ if any(x in q for x in ["articles", "article", "papers", "paper", "journals", "journal", "studies", "study"]):
208
+ return "articles"
209
+ return "both"
210
+
211
+
212
+
213
+ def _is_medical_query(question: str) -> bool:
214
+ return bool(MEDICAL_HINT_RE.search(question or ""))
215
+
216
+
217
+
218
+ def _quote_term(term: str) -> str:
219
+ term = _normalize_whitespace(term)
220
+ if not term:
221
+ return ""
222
+ if "*" in term:
223
+ return term
224
+ if " " in term or "-" in term:
225
+ return f'"{term}"'
226
+ return term
227
+
228
+
229
+
230
+ def _longest_phrase_match(tokens: List[str], start: int) -> Optional[str]:
231
+ max_len = min(3, len(tokens) - start)
232
+ for length in range(max_len, 0, -1):
233
+ phrase = " ".join(tokens[start:start + length]).lower()
234
+ if phrase in PHRASE_SYNONYMS:
235
+ return phrase
236
+ return None
237
+
238
+
239
+
240
+ def _concept_groups(topic: str) -> List[List[str]]:
241
+ topic = _normalize_whitespace(topic).lower()
242
+ raw_tokens = [t for t in re.findall(r"[a-z0-9]+", topic) if t not in STOP_WORDS and len(t) > 2]
243
+ groups: List[List[str]] = []
244
+ i = 0
245
+ while i < len(raw_tokens):
246
+ phrase = _longest_phrase_match(raw_tokens, i)
247
+ if phrase:
248
+ synonyms = PHRASE_SYNONYMS.get(phrase, [])
249
+ group = [phrase] + [s for s in synonyms if s.lower() != phrase]
250
+ groups.append(group)
251
+ i += len(phrase.split())
252
+ continue
253
+ token = raw_tokens[i]
254
+ group = [token]
255
+ group.extend([s for s in TOKEN_SYNONYMS.get(token, []) if s.lower() != token])
256
+ groups.append(group)
257
+ i += 1
258
+
259
+ if not groups and topic:
260
+ return [[topic]]
261
+
262
+ # collapse duplicate/single-token noise but preserve order
263
+ cleaned_groups: List[List[str]] = []
264
+ seen_primary = set()
265
+ for group in groups:
266
+ primary = group[0].lower()
267
+ if primary in seen_primary:
268
+ continue
269
+ seen_primary.add(primary)
270
+ unique_terms = []
271
+ seen_terms = set()
272
+ for term in group:
273
+ norm = term.lower()
274
+ if norm in seen_terms:
275
+ continue
276
+ seen_terms.add(norm)
277
+ unique_terms.append(term)
278
+ cleaned_groups.append(unique_terms)
279
+ return cleaned_groups[:4]
280
+
281
+
282
+
283
+ def build_primo_boolean_query(topic: str) -> str:
284
+ clean_topic = _strip_resource_noise(topic)
285
+ groups = _concept_groups(clean_topic)
286
+ clauses: List[str] = []
287
+ for group in groups:
288
+ if not group:
289
+ continue
290
+ if len(group) == 1:
291
+ clauses.append(_quote_term(group[0]))
292
+ else:
293
+ clauses.append("(" + " OR ".join(_quote_term(term) for term in group) + ")")
294
+ if not clauses:
295
+ return _quote_term(clean_topic or topic)
296
+ return " AND ".join(clauses)
297
+
298
+
299
+
300
+ def _primo_facets(resource_type: str = "both", peer_reviewed: bool = False, open_access: bool = False, year_from: Optional[str] = None, year_to: Optional[str] = None) -> List[str]:
301
+ facets: List[str] = []
302
+ if resource_type == "articles":
303
+ facets.append("rtype,include,articles")
304
+ elif resource_type == "books":
305
+ facets.append("rtype,include,books")
306
+ if peer_reviewed:
307
+ facets.append("tlevel,include,peer_reviewed")
308
+ if open_access:
309
+ facets.append("tlevel,include,online_resources")
310
+ if year_from or year_to:
311
+ yf = year_from or "0001"
312
+ yt = year_to or "9999"
313
+ facets.append(f"searchcreationdate,include,{yf}|,|{yt}")
314
+ return facets
315
+
316
+
317
+
318
+ def build_primo_discovery_url(boolean_query: str, resource_type: str = "both", peer_reviewed: bool = False, open_access: bool = False, year_from: Optional[str] = None, year_to: Optional[str] = None, tab: str = "Everything", scope: str = "MyInst_and_CI") -> str:
319
+ url = (
320
+ f"{PRIMO_BASE}&query=any,contains,{quote(boolean_query)}"
321
+ f"&tab={tab}&search_scope={scope}&lang=en&mode=advanced&offset=0"
322
+ )
323
+ for facet in _primo_facets(resource_type, peer_reviewed, open_access, year_from, year_to):
324
+ url += f"&facet={quote(facet)}"
325
+ return url
326
+
327
+
328
+
329
+ def build_pubmed_url(query: str, year_from: Optional[str] = None, year_to: Optional[str] = None, peer_reviewed: bool = False) -> str:
330
+ clean_query = _strip_resource_noise(query) or query
331
+ term = clean_query
332
+ if peer_reviewed:
333
+ term = f"({term}) AND Journal Article[pt]"
334
+ url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(term)}"
335
+ if year_from or year_to:
336
+ yf = year_from or "1900"
337
+ yt = year_to or str(CURRENT_YEAR)
338
+ url += f"&filter=datesearch.y_custom&dates.from={yf}&dates.to={yt}"
339
+ return url
340
+
341
+
342
+ async def _grammar_refine_query(text: str) -> str:
343
+ settings = get_settings()
344
+ if not settings.openai_api_key:
345
+ return _normalize_whitespace(text)
346
+ try:
347
+ from langchain_openai import ChatOpenAI
348
+ llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=60, openai_api_key=settings.openai_api_key)
349
+ response = await llm.ainvoke([
350
+ {"role": "system", "content": "Rewrite the user's search query in clear grammatical English. Keep meaning exactly the same. Return one sentence only."},
351
+ {"role": "user", "content": text},
352
+ ])
353
+ return _normalize_whitespace(response.content) or _normalize_whitespace(text)
354
+ except Exception:
355
+ return _normalize_whitespace(text)
356
+
357
+
358
+ @router.post("/plan")
359
+ async def search_plan_endpoint(req: SearchPlanRequest):
360
+ _safe_metrics_increment("search_plans")
361
+ topic = _extract_topic(req.question)
362
+ resource_type = _derive_resource_type_from_text(req.question, req.resource_type)
363
+ boolean_query = build_primo_boolean_query(topic)
364
+ ai_tool_query = await _grammar_refine_query(req.question)
365
+ is_medical = _is_medical_query(req.question) or _is_medical_query(topic)
366
+
367
+ return {
368
+ "topic": topic,
369
+ "display_topic": topic.title(),
370
+ "resource_type": resource_type,
371
+ "is_medical": is_medical,
372
+ "ai_tool_query": ai_tool_query,
373
+ "primo_boolean_query": boolean_query,
374
+ "primo_all_url": build_primo_discovery_url(boolean_query, "both", req.peer_reviewed, req.open_access, req.year_from, req.year_to, "Everything", "MyInst_and_CI"),
375
+ "primo_articles_url": build_primo_discovery_url(boolean_query, "articles", req.peer_reviewed, req.open_access, req.year_from, req.year_to, "CentralIndex", "CentralIndex"),
376
+ "primo_books_url": build_primo_discovery_url(boolean_query, "books", req.peer_reviewed, req.open_access, req.year_from, req.year_to, "Everything", "MyInst_and_CI"),
377
+ "primo_ai_url": f"{PRIMO_AI_BASE}{quote(ai_tool_query)}",
378
+ "pubmed_url": build_pubmed_url(topic, req.year_from, req.year_to, req.peer_reviewed) if is_medical else None,
379
+ "ebsco_ai_url": f"{EBSCO_AI_BASE}{quote(ai_tool_query)}",
380
+ "scopus_ai_url": f"{SCOPUS_AI_BASE}{quote(ai_tool_query)}",
381
+ "leapspace_url": f"{LEAPSPACE_BASE}{quote(ai_tool_query)}",
382
+ "consensus_url": f"{CONSENSUS_BASE}{quote(ai_tool_query)}",
383
+ }
384
+
385
+
386
  @router.post("")
387
  async def search_endpoint(req: SearchRequest):
388
  _safe_metrics_increment("search_requests")
 
395
  return await search_scholar(req)
396
 
397
 
 
 
398
  async def search_primo(req: SearchRequest, api_key: str):
 
 
 
 
 
 
 
 
 
 
 
399
  if not api_key:
400
  logger.warning("PRIMO_API_KEY not configured")
401
  _safe_metrics_bucket("errors", "primo_missing_api_key")
402
  return {"error": "PRIMO_API_KEY not configured", "results": [], "source": "PRIMO"}
403
 
 
 
 
404
  clean_query = _strip_resource_noise(req.query)
405
  if not clean_query:
406
+ clean_query = req.query
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
+ boolean_query = req.boolean_query or build_primo_boolean_query(clean_query)
409
+ query_for_api = boolean_query
410
+
411
+ def _build_api_url(query_text: str) -> str:
412
+ url = (
413
+ f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
414
+ f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
415
+ f"&q=any,contains,{quote(query_text)}&lang=en&sort=rank&limit={req.limit}"
416
+ f"&offset=0&mode=advanced&apikey={api_key}"
417
+ )
418
+ api_facets = []
419
+ if req.resource_type == "articles":
420
+ api_facets.append("facet_rtype,include,articles")
421
+ elif req.resource_type == "books":
422
+ api_facets.append("facet_rtype,include,books")
423
+ if req.peer_reviewed:
424
+ api_facets.append("facet_tlevel,include,peer_reviewed")
425
+ if req.open_access:
426
+ api_facets.append("facet_tlevel,include,online_resources")
427
+ if req.year_from or req.year_to:
428
+ yf = req.year_from or "0001"
429
+ yt = req.year_to or "9999"
430
+ api_facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
431
+ for facet in api_facets:
432
+ url += f"&multiFacets={quote(facet)}"
433
+ return url
434
+
435
+ logger.info("PRIMO boolean=%r clean_query=%r resource_type=%s", boolean_query, clean_query, req.resource_type)
436
 
437
  try:
438
  async with httpx.AsyncClient(timeout=20) as client:
439
+ url = _build_api_url(query_for_api)
440
  r = await client.get(url, headers={"Accept": "application/json"})
441
+ if r.status_code == 400 and boolean_query != clean_query:
442
+ logger.warning("PRIMO 400 on boolean query; falling back to clean keywords")
443
+ url = _build_api_url(clean_query)
444
+ r = await client.get(url, headers={"Accept": "application/json"})
445
  logger.info("PRIMO status=%s", r.status_code)
446
  if r.status_code != 200:
447
  _safe_metrics_bucket("errors", f"primo_{r.status_code}")
 
449
 
450
  data = r.json()
451
  docs = data.get("docs", [])
452
+ discovery_url = build_primo_discovery_url(boolean_query, req.resource_type, req.peer_reviewed, req.open_access, req.year_from, req.year_to)
453
  results = []
454
  for doc in docs[: req.limit]:
455
  pnx = doc.get("pnx", {})
 
460
  "date": (d.get("date") or [""])[0],
461
  "type": (d.get("type") or [""])[0],
462
  "source": (d.get("source") or [""])[0],
463
+ "primo_url": discovery_url,
 
 
 
464
  "_source": "PRIMO",
465
  })
466
+ return {"total": data.get("info", {}).get("total", 0), "results": results, "source": "PRIMO"}
 
 
 
 
467
 
468
  except httpx.TimeoutException:
469
  _safe_metrics_bucket("errors", "primo_timeout")
 
474
  return {"error": str(exc), "results": [], "source": "PRIMO"}
475
 
476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  async def search_pubmed(req: SearchRequest):
 
 
 
 
 
478
  clean_query = _strip_resource_noise(req.query)
479
  if not clean_query:
480
  clean_query = req.query
 
 
481
  term = clean_query
482
  if req.peer_reviewed:
483
  term = f"({term}) AND Journal Article[pt]"
 
484
  query_encoded = quote(term)
 
 
485
  date_params = ""
486
  if req.year_from or req.year_to:
487
  yf = req.year_from or "1900"
488
  yt = req.year_to or "3000"
489
  date_params = f"&mindate={yf}&maxdate={yt}&datetype=pdat"
490
+ logger.info("PubMed term=%r peer_reviewed=%s year=%s-%s", term, req.peer_reviewed, req.year_from, req.year_to)
 
 
 
 
 
491
  try:
492
  async with httpx.AsyncClient(timeout=15) as client:
493
  search_url = (
 
498
  if r.status_code != 200:
499
  _safe_metrics_bucket("errors", "pubmed_search_failed")
500
  return {"error": "PubMed search failed", "results": [], "source": "PubMed"}
 
501
  ids = r.json().get("esearchresult", {}).get("idlist", [])
502
  if not ids:
503
  return {"total": 0, "results": [], "source": "PubMed"}
 
504
  fetch_url = (
505
  f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
506
  f"?db=pubmed&id={','.join(ids)}&retmode=json"
 
509
  if r2.status_code != 200:
510
  _safe_metrics_bucket("errors", "pubmed_fetch_failed")
511
  return {"error": "PubMed fetch failed", "results": [], "source": "PubMed"}
 
512
  data = r2.json().get("result", {})
513
  results = []
514
  for pid in ids:
 
530
  "_source": "PubMed",
531
  })
532
  return {"total": len(ids), "results": results, "source": "PubMed"}
 
533
  except Exception as exc:
534
  logger.error("PubMed search error: %s", exc)
535
  _safe_metrics_bucket("errors", "pubmed_exception")
536
  return {"error": str(exc), "results": [], "source": "PubMed"}
537
 
538
 
 
 
539
  async def search_scholar(req: SearchRequest):
540
  try:
541
+ params = {"query": req.query, "limit": req.limit, "fields": "title,authors,year,venue,externalIds,citationCount"}
 
 
 
 
542
  async with httpx.AsyncClient(timeout=15) as client:
543
+ r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
 
 
 
544
  if r.status_code != 200:
545
  _safe_metrics_bucket("errors", f"semantic_{r.status_code}")
546
  return {"error": f"Semantic Scholar API: {r.status_code}", "results": [], "source": "Semantic Scholar"}
 
547
  data = r.json()
548
  results = []
549
  for paper in data.get("data", []):
 
563
  "_source": "Semantic Scholar",
564
  })
565
  return {"total": data.get("total", 0), "results": results, "source": "Semantic Scholar"}
 
566
  except Exception as exc:
567
  logger.error("Semantic Scholar search error: %s", exc)
568
  _safe_metrics_bucket("errors", "semantic_exception")