Create search.py
Browse files- src/api/search.py +156 -0
src/api/search.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import httpx
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
class SearchRequest(BaseModel):
|
| 10 |
+
query: str
|
| 11 |
+
source: str = "primo"
|
| 12 |
+
limit: int = 5
|
| 13 |
+
peer_reviewed: bool = False
|
| 14 |
+
open_access: bool = False
|
| 15 |
+
year_from: Optional[str] = None
|
| 16 |
+
year_to: Optional[str] = None
|
| 17 |
+
|
| 18 |
+
@router.post("")
|
| 19 |
+
async def search_endpoint(req: SearchRequest):
|
| 20 |
+
settings = get_settings()
|
| 21 |
+
|
| 22 |
+
if req.source == "primo":
|
| 23 |
+
return await search_primo(req, settings.primo_api_key)
|
| 24 |
+
elif req.source == "pubmed":
|
| 25 |
+
return await search_pubmed(req)
|
| 26 |
+
elif req.source == "scholar":
|
| 27 |
+
return await search_scholar(req)
|
| 28 |
+
else:
|
| 29 |
+
raise HTTPException(status_code=400, detail=f"Unknown source: {req.source}")
|
| 30 |
+
|
| 31 |
+
async def search_primo(req: SearchRequest, api_key: str):
|
| 32 |
+
if not api_key:
|
| 33 |
+
return {"error": "PRIMO_API_KEY not configured", "results": []}
|
| 34 |
+
|
| 35 |
+
# Simple query for now
|
| 36 |
+
from urllib.parse import quote
|
| 37 |
+
query = quote(req.query)
|
| 38 |
+
|
| 39 |
+
url = f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI&q=any,contains,{query}&lang=en&sort=rank&limit={req.limit}&offset=0&mode=advanced&apikey={api_key}"
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 43 |
+
r = await client.get(url, headers={"Accept": "application/json"})
|
| 44 |
+
if r.status_code != 200:
|
| 45 |
+
return {"error": f"PRIMO API error: {r.status_code}", "results": []}
|
| 46 |
+
|
| 47 |
+
data = r.json()
|
| 48 |
+
results = []
|
| 49 |
+
for doc in data.get("docs", [])[:req.limit]:
|
| 50 |
+
pnx = doc.get("pnx", {})
|
| 51 |
+
d = pnx.get("display", {})
|
| 52 |
+
a = pnx.get("addata", {})
|
| 53 |
+
|
| 54 |
+
results.append({
|
| 55 |
+
"title": (d.get("title") or ["Untitled"])[0],
|
| 56 |
+
"creator": "; ".join(d.get("creator") or d.get("contributor") or []) or "Unknown",
|
| 57 |
+
"date": (d.get("date") or [""])[0],
|
| 58 |
+
"type": (d.get("type") or [""])[0],
|
| 59 |
+
"source": (d.get("source") or [""])[0],
|
| 60 |
+
"primo_url": f"https://khalifa.primo.exlibrisgroup.com/discovery/search?query=any,contains,{query}&vid=971KUOSTAR_INST:KU",
|
| 61 |
+
"_source": "PRIMO"
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
return {"total": data.get("info", {}).get("total", 0), "results": results, "source": "PRIMO"}
|
| 65 |
+
|
| 66 |
+
except Exception as e:
|
| 67 |
+
return {"error": str(e), "results": []}
|
| 68 |
+
|
| 69 |
+
async def search_pubmed(req: SearchRequest):
|
| 70 |
+
try:
|
| 71 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 72 |
+
# Search
|
| 73 |
+
search_url = f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term={req.query}&retmax={req.limit}&retmode=json"
|
| 74 |
+
r = await client.get(search_url)
|
| 75 |
+
if r.status_code != 200:
|
| 76 |
+
return {"error": "PubMed search failed", "results": []}
|
| 77 |
+
|
| 78 |
+
ids = r.json().get("esearchresult", {}).get("idlist", [])
|
| 79 |
+
if not ids:
|
| 80 |
+
return {"total": 0, "results": [], "source": "PubMed"}
|
| 81 |
+
|
| 82 |
+
# Fetch details
|
| 83 |
+
fetch_url = f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id={','.join(ids)}&retmode=json"
|
| 84 |
+
r2 = await client.get(fetch_url)
|
| 85 |
+
if r2.status_code != 200:
|
| 86 |
+
return {"error": "PubMed fetch failed", "results": []}
|
| 87 |
+
|
| 88 |
+
data = r2.json().get("result", {})
|
| 89 |
+
results = []
|
| 90 |
+
for pid in ids:
|
| 91 |
+
rec = data.get(pid, {})
|
| 92 |
+
if not isinstance(rec, dict):
|
| 93 |
+
continue
|
| 94 |
+
authors = ", ".join(a.get("name", "") for a in rec.get("authors", [])[:3])
|
| 95 |
+
if len(rec.get("authors", [])) > 3:
|
| 96 |
+
authors += " et al."
|
| 97 |
+
|
| 98 |
+
results.append({
|
| 99 |
+
"title": rec.get("title", ""),
|
| 100 |
+
"creator": authors,
|
| 101 |
+
"date": rec.get("pubdate", ""),
|
| 102 |
+
"source": rec.get("fulljournalname", rec.get("source", "")),
|
| 103 |
+
"pmid": pid,
|
| 104 |
+
"link": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
|
| 105 |
+
"type": "Journal Article",
|
| 106 |
+
"_source": "PubMed"
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
return {"total": len(ids), "results": results, "source": "PubMed"}
|
| 110 |
+
|
| 111 |
+
except Exception as e:
|
| 112 |
+
return {"error": str(e), "results": []}
|
| 113 |
+
|
| 114 |
+
async def search_scholar(req: SearchRequest):
|
| 115 |
+
# Semantic Scholar implementation
|
| 116 |
+
try:
|
| 117 |
+
url = "https://api.semanticscholar.org/graph/v1/paper/search"
|
| 118 |
+
params = {
|
| 119 |
+
"query": req.query,
|
| 120 |
+
"limit": req.limit,
|
| 121 |
+
"fields": "title,authors,year,venue,externalIds,citationCount"
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 125 |
+
r = await client.get(url, params=params)
|
| 126 |
+
if r.status_code != 200:
|
| 127 |
+
return {"error": f"Semantic Scholar API: {r.status_code}", "results": []}
|
| 128 |
+
|
| 129 |
+
data = r.json()
|
| 130 |
+
results = []
|
| 131 |
+
for paper in data.get("data", []):
|
| 132 |
+
authors = ", ".join(a.get("name", "") for a in (paper.get("authors") or [])[:3])
|
| 133 |
+
if len(paper.get("authors") or []) > 3:
|
| 134 |
+
authors += " et al."
|
| 135 |
+
|
| 136 |
+
ext_ids = paper.get("externalIds") or {}
|
| 137 |
+
|
| 138 |
+
results.append({
|
| 139 |
+
"title": paper.get("title", ""),
|
| 140 |
+
"creator": authors,
|
| 141 |
+
"date": str(paper.get("year", "")),
|
| 142 |
+
"source": paper.get("venue", ""),
|
| 143 |
+
"citations": paper.get("citationCount", 0),
|
| 144 |
+
"link": f"https://www.semanticscholar.org/paper/{paper.get('paperId')}" if paper.get("paperId") else None,
|
| 145 |
+
"type": "Article",
|
| 146 |
+
"_source": "Semantic Scholar"
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
return {"total": data.get("total", 0), "results": results, "source": "Semantic Scholar"}
|
| 150 |
+
|
| 151 |
+
except Exception as e:
|
| 152 |
+
return {"error": str(e), "results": []}
|
| 153 |
+
|
| 154 |
+
def get_settings():
|
| 155 |
+
from src.config import get_settings as gs
|
| 156 |
+
return gs()
|