File size: 11,095 Bytes
50ad613 95dac73 2a4ec2b 95dac73 53795f8 95dac73 50ad613 2a4ec2b b3c541d 2a4ec2b 9043adc b3c541d 2a4ec2b b3c541d c0e427b 2a4ec2b 9043adc c0e427b b3c541d c0e427b b3c541d 2bb3831 95dac73 5ef7c27 2a4ec2b 50ad613 95dac73 50ad613 95dac73 50ad613 2a4ec2b 95dac73 50ad613 2a4ec2b 95dac73 2a4ec2b b3c541d 2a4ec2b 95dac73 2a4ec2b b3c541d 2a4ec2b 50ad613 84dc51d 50ad613 2a4ec2b 50ad613 2a4ec2b b3c541d 50ad613 2a4ec2b 50ad613 2a4ec2b 50ad613 b3c541d 50ad613 2a4ec2b b3c541d 2a4ec2b b3c541d 2a4ec2b 50ad613 2a4ec2b 84dc51d 2a4ec2b 84dc51d 2a4ec2b 50ad613 2a4ec2b b3c541d 2a4ec2b b3c541d 2a4ec2b c0e427b 2a4ec2b 84dc51d c0e427b 9043adc 50ad613 b3c541d 2167c4f b3c541d c0e427b 2167c4f 50ad613 2a4ec2b 84dc51d c0e427b 9043adc 50ad613 c0e427b 2a4ec2b c0e427b 2a4ec2b c0e427b 2a4ec2b c0e427b 9043adc c0e427b 2167c4f 50ad613 2a4ec2b b3c541d | 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 | """
src/api/search.py β PRIMO + PubMed search endpoint for LibBee v3.4.
Search approach: send the full boolean query as a single q=any,contains,<boolean>
parameter β exactly how the PRIMO web UI works. This preserves phrase quotes,
parentheses, AND/OR operators, and gives results identical to the web UI.
Earlier versions split the boolean into multiple q= params (one per AND group),
but PRIMO does not reliably handle OR within a single q= value in multi-param mode.
The correct approach is a single q= with the full boolean, URL-encoded.
"""
import re
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel
from urllib.parse import quote
logger = logging.getLogger(__name__)
router = APIRouter()
# ββ Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class SearchRequest(BaseModel):
query: str
boolean_query: Optional[str] = None # LLM-built boolean; used for PRIMO if present
source: str = "primo"
limit: int = 5
peer_reviewed: bool = False
open_access: bool = False
year_from: Optional[str] = None
year_to: Optional[str] = None
resource_type: str = "articles" # "articles" | "books" | "both"
# ββ PRIMO query builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _boolean_to_primo_params(boolean_query: str) -> str:
"""
Convert a boolean query into a single PRIMO q= parameter.
Sends the full boolean as: q=any,contains,<url-encoded boolean>
This matches exactly how the PRIMO web UI constructs advanced search URLs.
Examples:
'("machine learning" OR "deep learning") AND ("healthcare")'
β 'q=any,contains,%28%22machine+learning%22+OR+%22deep+learning%22%29...'
'"climate change"'
β 'q=any,contains,%22climate+change%22'
'petroleum engineering'
β 'q=any,contains,petroleum+engineering'
"""
if not boolean_query or not boolean_query.strip():
return "q=any,contains,library"
# Send the full boolean as a single q= parameter β PRIMO parses it correctly
return f"q=any,contains,{quote(boolean_query.strip())}"
# ββ PRIMO helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _build_primo_search_link(title: str, record_id: Optional[str] = None) -> Optional[str]:
base = 'https://khalifa.primo.exlibrisgroup.com/discovery'
if record_id:
return f"{base}/fulldisplay?docid={quote(str(record_id))}&vid=971KUOSTAR_INST:KU"
clean_title = (title or '').strip()
if not clean_title:
return None
return (
f"{base}/search?query=any,contains,{quote(clean_title)}"
"&tab=Everything&search_scope=MyInst_and_CI&vid=971KUOSTAR_INST:KU&lang=en"
)
async def search_primo(req: SearchRequest, api_key: str) -> dict:
"""
Search the PRIMO API.
Prefers req.boolean_query (LLM-built) over req.query for structured results.
Converts boolean to multi-q-param format for accurate PRIMO results.
"""
import httpx
if not api_key:
return {"error": "PRIMO_API_KEY not configured", "results": [], "total": 0}
raw_query = (req.boolean_query or req.query or "").strip()
has_bool = bool(re.search(r'\b(AND|OR)\b', raw_query)) and '(' in raw_query
if has_bool:
query_params = _boolean_to_primo_params(raw_query)
else:
clean = re.sub(r'["\']', '', raw_query).strip()
query_params = f"q=any,contains,{quote(clean)}"
facets = ""
if req.peer_reviewed:
facets += "&qInclude=facet_tlevel,exact,peer_reviewed"
if req.open_access:
facets += "&qInclude=facet_tlevel,exact,open_access"
if req.year_from or req.year_to:
yf = req.year_from or "1900"
yt = req.year_to or str(datetime.now().year)
facets += f"&multiFacets=facet_searchcreationdate,include,{yf}%7C,%7C{yt}"
if req.resource_type == "books":
facets += "&qInclude=facet_rtype,exact,books"
elif req.resource_type == "articles":
facets += "&qInclude=facet_rtype,exact,articles"
vid = "971KUOSTAR_INST:KU"
qs = (
f"?vid={vid}&tab=Everything&scope=MyInst_and_CI"
f"&{query_params}"
f"&lang=en&sort=rank&limit={req.limit}&offset=0&mode=advanced"
f"&apikey={api_key}{facets}"
)
base = "https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
async with httpx.AsyncClient(timeout=15) as client:
for region in ["api-eu", "api-na", "api-ap"]:
url = base.replace("api-eu", region) + qs
try:
r = await client.get(url, headers={"Accept": "application/json"})
if r.status_code == 200:
data = r.json()
total = data.get("info", {}).get("total", 0)
results = []
for doc in data.get("docs", []):
pnx = doc.get("pnx", {})
d = pnx.get("display", {})
a = pnx.get("addata", {})
s = pnx.get("search", {})
c = pnx.get("control", {})
l = pnx.get("links", {})
record_id = (c.get("recordid") or [None])[0]
title = (d.get("title") or ["Untitled"])[0]
primo_url = _build_primo_search_link(title, record_id)
doi = (a.get("doi") or [None])[0]
raw_link = (l.get("openurl") or l.get("linktorsrc") or [None])[0]
results.append({
"record_id": record_id,
"title": title,
"creator": "; ".join(
d.get("creator") or d.get("contributor") or []
) or "Unknown",
"date": (
s.get("creationdate") or a.get("risdate") or a.get("date") or [""]
)[0],
"type": (d.get("type") or [""])[0],
"source": (d.get("source") or a.get("jtitle") or [""])[0],
"description": ((d.get("description") or [""])[0] or "")[:400],
"doi": doi,
"primo_url": primo_url,
"link": raw_link or primo_url,
"open_access": (d.get("oa") or [""])[0] == "free_for_read",
"_source": "PRIMO",
})
return {"total": total, "results": results, "source": "PRIMO"}
except Exception as e:
logger.warning("PRIMO region %s failed: %s", region, e)
continue
return {"error": "PRIMO API unavailable", "results": [], "total": 0, "source": "PRIMO"}
# ββ PubMed search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def search_pubmed(req: SearchRequest) -> dict:
"""Search PubMed via E-utilities."""
import httpx
base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
query = (req.query or "").strip()
if req.year_from or req.year_to:
yf = req.year_from or "1900"
yt = req.year_to or str(datetime.now().year)
query = f"({query}) AND ({yf}:{yt}[dp])"
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(
f"{base}/esearch.fcgi",
params={
"db": "pubmed", "term": query,
"retmax": req.limit, "retmode": "json", "sort": "relevance"
}
)
if r.status_code != 200:
return {"error": "PubMed search failed", "results": [], "source": "PubMed"}
esearch = r.json().get("esearchresult", {})
ids = esearch.get("idlist", [])
# PubMed Automatic Term Mapping: how the free-text query was
# expanded to MeSH terms - surfaced to teach search strategy.
query_translation = esearch.get("querytranslation", "")
count = int(esearch.get("count", 0))
if not ids:
return {"total": 0, "results": [], "source": "PubMed", "query_translation": query_translation}
r2 = await client.get(
f"{base}/esummary.fcgi",
params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"}
)
if r2.status_code != 200:
return {"error": "PubMed fetch failed", "results": [], "source": "PubMed"}
data = r2.json().get("result", {})
results = []
for pid in ids:
rec = data.get(pid, {})
if not isinstance(rec, dict):
continue
authors_list = rec.get("authors", [])
authors = ", ".join(a.get("name", "") for a in authors_list[:3])
if len(authors_list) > 3:
authors += " et al."
eloc = rec.get("elocationid", "")
doi = eloc.replace("doi: ", "") if "doi:" in eloc else None
results.append({
"title": rec.get("title", ""),
"creator": authors,
"date": rec.get("pubdate", ""),
"source": rec.get("fulljournalname", rec.get("source", "")),
"doi": doi,
"pmid": pid,
"link": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
"type": "Journal Article",
"_source": "PubMed",
})
return {"total": count, "results": results, "source": "PubMed", "query_translation": query_translation}
except Exception as e:
return {"error": f"PubMed: {str(e)}", "results": [], "source": "PubMed"}
# ββ FastAPI endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post("")
async def search_endpoint(req: SearchRequest):
"""
Search PRIMO or PubMed.
Accepts boolean_query (preferred, LLM-built) or falls back to query.
"""
from src.config import get_settings
settings = get_settings()
source = (req.source or "primo").lower()
if source == "pubmed":
return await search_pubmed(req)
return await search_primo(req, settings.primo_api_key)
|