nikeshn commited on
Commit
2bb3831
·
verified ·
1 Parent(s): 5849dd3

Upload 5 files

Browse files
Files changed (5) hide show
  1. src/api/__init__.py +1 -4
  2. src/api/admin.py +75 -26
  3. src/api/agent.py +29 -3
  4. src/api/feedback.py +30 -16
  5. src/api/search.py +51 -105
src/api/__init__.py CHANGED
@@ -1,4 +1 @@
1
- from . import admin
2
- from . import agent
3
- from . import feedback
4
- from . import search
 
1
+ from . import admin, agent, feedback, search
 
 
 
src/api/admin.py CHANGED
@@ -1,50 +1,99 @@
1
- from fastapi import APIRouter, HTTPException, Request, Response
2
- from pydantic import BaseModel
3
  import secrets
4
  import time
 
 
 
 
 
 
 
5
 
6
  router = APIRouter()
 
 
7
 
8
  class LoginRequest(BaseModel):
9
  password: str
10
 
 
11
  class ConfigUpdate(BaseModel):
12
  key: str
13
  value: str
14
 
15
- # Simple session storage (in-memory for HF single instance)
16
- admin_sessions = {}
 
 
 
 
 
 
 
 
 
 
17
 
18
  @router.post("/auth")
19
  async def admin_auth(req: LoginRequest):
20
- from src.config import get_settings
21
  settings = get_settings()
22
-
23
- if req.password != settings.admin_password:
24
  raise HTTPException(status_code=401, detail="Invalid password")
25
-
26
  token = secrets.token_hex(32)
27
- admin_sessions[token] = time.time() + 86400 # 24 hours
28
-
29
- resp = Response(content='{"status": "ok"}')
30
- resp.set_cookie(key="admin_session", value=token, httponly=True, max_age=86400)
31
  return resp
32
 
33
- @router.get("/config")
34
- async def get_config():
35
- from src.config import get_settings
36
- settings = get_settings()
37
- return {
38
- "max_results": settings.max_results,
39
- "maintenance_mode": settings.maintenance_mode
40
- }
41
 
42
- @router.post("/config")
43
- async def update_config(req: ConfigUpdate, request: Request):
44
- # Verify session
 
 
 
 
 
 
 
 
 
45
  token = request.cookies.get("admin_session")
46
  if not token or token not in admin_sessions:
47
  raise HTTPException(status_code=401, detail="Not authenticated")
48
-
49
- # In real implementation, update DB or env
50
- return {"status": "ok", "updated": req.key}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import secrets
2
  import time
3
+ from typing import Dict
4
+
5
+ from fastapi import APIRouter, HTTPException, Request, Response
6
+ from pydantic import BaseModel
7
+
8
+ from src.config import get_settings
9
+ from src.services.runtime_store import JsonRuntimeStore
10
 
11
  router = APIRouter()
12
+ admin_sessions: Dict[str, float] = {}
13
+
14
 
15
  class LoginRequest(BaseModel):
16
  password: str
17
 
18
+
19
  class ConfigUpdate(BaseModel):
20
  key: str
21
  value: str
22
 
23
+
24
+ def _cleanup_sessions() -> None:
25
+ now = time.time()
26
+ expired = [token for token, expiry in admin_sessions.items() if expiry <= now]
27
+ for token in expired:
28
+ admin_sessions.pop(token, None)
29
+
30
+
31
+ def _config_store() -> JsonRuntimeStore:
32
+ settings = get_settings()
33
+ return JsonRuntimeStore(settings.config_path, default={"max_results": settings.max_results, "maintenance_mode": settings.maintenance_mode})
34
+
35
 
36
  @router.post("/auth")
37
  async def admin_auth(req: LoginRequest):
 
38
  settings = get_settings()
39
+ if not settings.admin_password or req.password != settings.admin_password:
 
40
  raise HTTPException(status_code=401, detail="Invalid password")
 
41
  token = secrets.token_hex(32)
42
+ admin_sessions[token] = time.time() + 86400
43
+ resp = Response(content='{"status":"ok"}', media_type="application/json")
44
+ resp.set_cookie(key="admin_session", value=token, httponly=True, max_age=86400, samesite="lax")
 
45
  return resp
46
 
 
 
 
 
 
 
 
 
47
 
48
+ @router.post("/logout")
49
+ async def admin_logout(request: Request):
50
+ token = request.cookies.get("admin_session")
51
+ if token:
52
+ admin_sessions.pop(token, None)
53
+ resp = Response(content='{"status":"ok"}', media_type="application/json")
54
+ resp.delete_cookie("admin_session")
55
+ return resp
56
+
57
+
58
+ def _require_session(request: Request) -> None:
59
+ _cleanup_sessions()
60
  token = request.cookies.get("admin_session")
61
  if not token or token not in admin_sessions:
62
  raise HTTPException(status_code=401, detail="Not authenticated")
63
+
64
+
65
+ @router.get("/config")
66
+ async def get_config(request: Request):
67
+ _require_session(request)
68
+ return _config_store().load()
69
+
70
+
71
+ @router.post("/config")
72
+ async def update_config(req: ConfigUpdate, request: Request):
73
+ _require_session(request)
74
+ allowed = {"max_results", "maintenance_mode"}
75
+ if req.key not in allowed:
76
+ raise HTTPException(status_code=400, detail=f"Unsupported key. Allowed: {sorted(allowed)}")
77
+ value = req.value
78
+ if req.key == "max_results":
79
+ value = int(value)
80
+ if req.key == "maintenance_mode":
81
+ value = str(value).lower() in {"1", "true", "yes", "on"}
82
+ data = _config_store().load()
83
+ data[req.key] = value
84
+ _config_store().save(data)
85
+ return {"status": "ok", "updated": req.key, "value": value}
86
+
87
+
88
+ @router.get("/rag-status")
89
+ async def rag_status(request: Request):
90
+ _require_session(request)
91
+ from app import rag_service
92
+ return rag_service.get_stats()
93
+
94
+
95
+ @router.get("/metrics")
96
+ async def metrics(request: Request):
97
+ _require_session(request)
98
+ from app import metrics_service
99
+ return metrics_service.snapshot()
src/api/agent.py CHANGED
@@ -17,6 +17,7 @@ from src.services.staff_service import (
17
  STAFF_DIRECTORY,
18
  match_staff_name,
19
  match_staff_role,
 
20
  staff_name_answer,
21
  staff_role_answer,
22
  )
@@ -258,6 +259,23 @@ def _source_badge(source: str, url: str = "") -> str:
258
  )
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
262
  msgs = []
263
  for m in history[-HISTORY_WINDOW:]:
@@ -1054,6 +1072,7 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
1054
  if papers:
1055
  summary = await _generate_cited_summary(topic, papers, model)
1056
  if not summary:
 
1057
  summary = _extractive_snapshot_from_papers(topic, papers)
1058
 
1059
  answer = f"<strong>📖 Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
@@ -1078,7 +1097,7 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
1078
  if search_results:
1079
  answer = (
1080
  f"<strong>📚 KU Library starting points on {_escape(topic)}</strong><br><br>"
1081
- "I couldn't build a citation-grounded abstract snapshot right now, but I did pull KU-accessible results you can open immediately below. "
1082
  f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1083
  )
1084
  else:
@@ -1237,6 +1256,7 @@ async def agent_query(request: Request, req: AgentRequest):
1237
  history = req.history
1238
  model = req.model
1239
  client_state = req.client_state
 
1240
 
1241
  if not question:
1242
  follow_up_question, follow_up_suggestions = _social_follow_up()
@@ -1265,8 +1285,9 @@ async def agent_query(request: Request, req: AgentRequest):
1265
  follow_up_suggestions=follow_up_suggestions,
1266
  )
1267
 
1268
- staff_match = match_staff_name(question)
1269
  if staff_match:
 
1270
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1271
  return AgentResponse(
1272
  answer=staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
@@ -1278,8 +1299,9 @@ async def agent_query(request: Request, req: AgentRequest):
1278
  follow_up_suggestions=follow_up_suggestions,
1279
  )
1280
 
1281
- staff_role_match = match_staff_role(question)
1282
  if staff_role_match:
 
1283
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1284
  return AgentResponse(
1285
  answer=staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
@@ -1332,6 +1354,7 @@ async def agent_query(request: Request, req: AgentRequest):
1332
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
1333
 
1334
  if is_follow_up and base_context:
 
1335
  context = _apply_follow_up_action(base_context, follow_up_action)
1336
  if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
1337
  context = await _prepare_queries(question, context, model, True)
@@ -1347,6 +1370,7 @@ async def agent_query(request: Request, req: AgentRequest):
1347
  search_results = []
1348
  sources = []
1349
  follow_up_question, follow_up_suggestions = _search_follow_up(context, summary_mode=True)
 
1350
  return AgentResponse(
1351
  answer=answer,
1352
  intent=context.intent,
@@ -1391,6 +1415,7 @@ async def agent_query(request: Request, req: AgentRequest):
1391
 
1392
  cls_result = await _llm_classify(question, history, model)
1393
  intent = cls_result.get("intent", "general")
 
1394
  casual_hint = cls_result.get("casual_answer", "")
1395
 
1396
  if intent == "social":
@@ -1469,6 +1494,7 @@ async def agent_query(request: Request, req: AgentRequest):
1469
  if deep_research_mode and "Research guide" not in answer:
1470
  answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1471
  follow_up_question, follow_up_suggestions = _search_follow_up(context, summary_mode=True)
 
1472
  return AgentResponse(
1473
  answer=answer,
1474
  intent=intent,
 
17
  STAFF_DIRECTORY,
18
  match_staff_name,
19
  match_staff_role,
20
+ should_attempt_staff_lookup,
21
  staff_name_answer,
22
  staff_role_answer,
23
  )
 
259
  )
260
 
261
 
262
+
263
+
264
+ def _safe_metrics_increment(key: str) -> None:
265
+ try:
266
+ from app import metrics_service
267
+ metrics_service.incr(key)
268
+ except Exception:
269
+ return
270
+
271
+
272
+ def _safe_metrics_bucket(bucket: str, key: str) -> None:
273
+ try:
274
+ from app import metrics_service
275
+ metrics_service.incr_bucket(bucket, key)
276
+ except Exception:
277
+ return
278
+
279
  def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
280
  msgs = []
281
  for m in history[-HISTORY_WINDOW:]:
 
1072
  if papers:
1073
  summary = await _generate_cited_summary(topic, papers, model)
1074
  if not summary:
1075
+ _safe_metrics_increment("summary_fallbacks")
1076
  summary = _extractive_snapshot_from_papers(topic, papers)
1077
 
1078
  answer = f"<strong>📖 Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
 
1097
  if search_results:
1098
  answer = (
1099
  f"<strong>📚 KU Library starting points on {_escape(topic)}</strong><br><br>"
1100
+ "I couldn't build a citation-grounded Semantic Scholar snapshot right now, but I did pull KU-accessible library results you can open immediately below. "
1101
  f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1102
  )
1103
  else:
 
1256
  history = req.history
1257
  model = req.model
1258
  client_state = req.client_state
1259
+ _safe_metrics_increment("agent_requests")
1260
 
1261
  if not question:
1262
  follow_up_question, follow_up_suggestions = _social_follow_up()
 
1285
  follow_up_suggestions=follow_up_suggestions,
1286
  )
1287
 
1288
+ staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None
1289
  if staff_match:
1290
+ _safe_metrics_bucket("intents", "staff_lookup")
1291
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1292
  return AgentResponse(
1293
  answer=staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
 
1299
  follow_up_suggestions=follow_up_suggestions,
1300
  )
1301
 
1302
+ staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
1303
  if staff_role_match:
1304
+ _safe_metrics_bucket("intents", "staff_lookup")
1305
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1306
  return AgentResponse(
1307
  answer=staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
 
1354
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
1355
 
1356
  if is_follow_up and base_context:
1357
+ _safe_metrics_increment("follow_up_hits")
1358
  context = _apply_follow_up_action(base_context, follow_up_action)
1359
  if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
1360
  context = await _prepare_queries(question, context, model, True)
 
1370
  search_results = []
1371
  sources = []
1372
  follow_up_question, follow_up_suggestions = _search_follow_up(context, summary_mode=True)
1373
+ _safe_metrics_increment("search_handoffs")
1374
  return AgentResponse(
1375
  answer=answer,
1376
  intent=context.intent,
 
1415
 
1416
  cls_result = await _llm_classify(question, history, model)
1417
  intent = cls_result.get("intent", "general")
1418
+ _safe_metrics_bucket("intents", intent)
1419
  casual_hint = cls_result.get("casual_answer", "")
1420
 
1421
  if intent == "social":
 
1494
  if deep_research_mode and "Research guide" not in answer:
1495
  answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1496
  follow_up_question, follow_up_suggestions = _search_follow_up(context, summary_mode=True)
1497
+ _safe_metrics_increment("search_handoffs")
1498
  return AgentResponse(
1499
  answer=answer,
1500
  intent=intent,
src/api/feedback.py CHANGED
@@ -1,8 +1,12 @@
 
1
  import time
2
- from typing import List
 
3
 
4
  from fastapi import APIRouter
5
- from pydantic import BaseModel
 
 
6
 
7
  router = APIRouter()
8
 
@@ -10,22 +14,32 @@ router = APIRouter()
10
  class FeedbackRequest(BaseModel):
11
  question: str
12
  feedback: str
13
- answer_excerpt: str = ""
14
-
15
 
16
- _FEEDBACK_LOG: List[dict] = []
 
 
 
 
 
17
 
18
 
19
  @router.post("")
20
- async def store_feedback(req: FeedbackRequest):
21
- _FEEDBACK_LOG.append(
22
- {
23
- "question": req.question[:500],
24
- "feedback": req.feedback[:20],
25
- "answer_excerpt": req.answer_excerpt[:1000],
26
- "timestamp": time.time(),
27
- }
28
- )
29
- if len(_FEEDBACK_LOG) > 1000:
30
- del _FEEDBACK_LOG[:-1000]
 
 
 
 
 
 
31
  return {"status": "ok"}
 
1
+ import json
2
  import time
3
+ from pathlib import Path
4
+ from typing import Optional
5
 
6
  from fastapi import APIRouter
7
+ from pydantic import BaseModel, field_validator
8
+
9
+ from src.config import get_settings
10
 
11
  router = APIRouter()
12
 
 
14
  class FeedbackRequest(BaseModel):
15
  question: str
16
  feedback: str
17
+ answer_excerpt: Optional[str] = ""
 
18
 
19
+ @field_validator("feedback")
20
+ @classmethod
21
+ def valid_feedback(cls, v: str) -> str:
22
+ if v not in {"up", "down"}:
23
+ raise ValueError("feedback must be up or down")
24
+ return v
25
 
26
 
27
  @router.post("")
28
+ async def save_feedback(req: FeedbackRequest):
29
+ settings = get_settings()
30
+ payload = {
31
+ "timestamp": time.time(),
32
+ "question": req.question[:1000],
33
+ "feedback": req.feedback,
34
+ "answer_excerpt": (req.answer_excerpt or "")[:1500],
35
+ }
36
+ settings.feedback_path.parent.mkdir(parents=True, exist_ok=True)
37
+ with settings.feedback_path.open("a", encoding="utf-8") as handle:
38
+ handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
39
+ try:
40
+ from app import metrics_service
41
+ metrics_service.incr("feedback_total")
42
+ metrics_service.incr_bucket("feedback_breakdown", req.feedback)
43
+ except Exception:
44
+ pass
45
  return {"status": "ok"}
src/api/search.py CHANGED
@@ -3,7 +3,7 @@ from typing import Optional
3
  from urllib.parse import quote
4
 
5
  import httpx
6
- from fastapi import APIRouter, HTTPException
7
  from pydantic import BaseModel, field_validator
8
 
9
  from src.config import get_settings
@@ -12,8 +12,6 @@ logger = logging.getLogger(__name__)
12
  router = APIRouter()
13
 
14
 
15
- # ── Request Model ──────────────────────────────────────────────────────────────
16
-
17
  class SearchRequest(BaseModel):
18
  query: str
19
  source: str = "primo"
@@ -49,140 +47,106 @@ class SearchRequest(BaseModel):
49
  return v
50
 
51
 
52
- # ── Main Endpoint ──────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  @router.post("")
55
  async def search_endpoint(req: SearchRequest):
 
 
56
  settings = get_settings()
57
-
58
  if req.source == "primo":
59
  return await search_primo(req, settings.primo_api_key)
60
- elif req.source == "pubmed":
61
  return await search_pubmed(req)
62
- elif req.source == "scholar":
63
- return await search_scholar(req)
64
 
65
 
66
- # ── Primo ──────────────────────────────────────────────────────────────────────
67
-
68
  async def search_primo(req: SearchRequest, api_key: str):
69
  if not api_key:
70
  logger.warning("PRIMO_API_KEY not configured")
 
71
  return {"error": "PRIMO_API_KEY not configured", "results": [], "source": "PRIMO"}
72
 
73
  query_encoded = quote(req.query)
74
-
75
- # NOTE: Ex Libris Primo API requires apikey as a URL query parameter.
76
- # The Authorization header approach does NOT work with this API.
77
  url = (
78
  f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
79
- f"?vid=971KUOSTAR_INST:KU"
80
- f"&tab=Everything"
81
- f"&scope=MyInst_and_CI"
82
- f"&q=any,contains,{query_encoded}"
83
- f"&lang=en"
84
- f"&sort=rank"
85
- f"&limit={req.limit}"
86
- f"&offset=0"
87
- f"&mode=advanced"
88
- f"&apikey={api_key}"
89
  )
90
-
91
- # Build facets for peer reviewed / open access / year filters
92
  facets = []
93
  if req.peer_reviewed:
94
  facets.append("facet_tlevel,include,peer_reviewed")
95
  if req.open_access:
96
  facets.append("facet_tlevel,include,open_access")
97
  if req.year_from or req.year_to:
98
- yf = req.year_from or "0001"
99
- yt = req.year_to or "9999"
100
- facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
101
-
102
  for facet in facets:
103
  url += f"&multiFacets={quote(facet)}"
104
 
105
  try:
106
  async with httpx.AsyncClient(timeout=20) as client:
107
  r = await client.get(url, headers={"Accept": "application/json"})
108
-
109
- logger.info(f"Primo API status: {r.status_code} for query: {req.query[:50]}")
110
-
111
- if r.status_code == 401:
112
- logger.error("Primo API: 401 Unauthorized — check PRIMO_API_KEY")
113
- return {"error": "PRIMO API authentication failed — check API key", "results": [], "source": "PRIMO"}
114
-
115
- if r.status_code == 400:
116
- logger.error(f"Primo API: 400 Bad Request — {r.text[:200]}")
117
- return {"error": "PRIMO API bad request — query may be malformed", "results": [], "source": "PRIMO"}
118
-
119
  if r.status_code != 200:
120
- logger.warning(f"Primo API returned {r.status_code}: {r.text[:200]}")
121
  return {"error": f"PRIMO API error: {r.status_code}", "results": [], "source": "PRIMO"}
122
-
123
  data = r.json()
124
  docs = data.get("docs", [])
125
- logger.info(f"Primo returned {len(docs)} docs, total={data.get('info', {}).get('total', 0)}")
126
-
127
  results = []
128
- for doc in docs[:req.limit]:
129
  pnx = doc.get("pnx", {})
130
  d = pnx.get("display", {})
131
-
132
  results.append({
133
  "title": (d.get("title") or ["Untitled"])[0],
134
  "creator": "; ".join(d.get("creator") or d.get("contributor") or []) or "Unknown",
135
  "date": (d.get("date") or [""])[0],
136
  "type": (d.get("type") or [""])[0],
137
  "source": (d.get("source") or [""])[0],
138
- "primo_url": (
139
- f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
140
- f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU"
141
- ),
142
  "_source": "PRIMO",
143
  })
144
-
145
- return {
146
- "total": data.get("info", {}).get("total", 0),
147
- "results": results,
148
- "source": "PRIMO",
149
- }
150
-
151
  except httpx.TimeoutException:
152
- logger.error("Primo API timed out")
153
  return {"error": "PRIMO API timed out — please try again", "results": [], "source": "PRIMO"}
154
- except Exception as e:
155
- logger.error(f"Primo search error: {e}", exc_info=True)
156
- return {"error": str(e), "results": [], "source": "PRIMO"}
 
157
 
158
 
159
- # ── PubMed ─────────────────────────────────────────────────────────────────────
160
-
161
  async def search_pubmed(req: SearchRequest):
162
  query_encoded = quote(req.query)
163
-
164
  try:
165
  async with httpx.AsyncClient(timeout=15) as client:
166
- search_url = (
167
- f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
168
- f"?db=pubmed&term={query_encoded}&retmax={req.limit}&retmode=json"
169
- )
170
  r = await client.get(search_url)
171
  if r.status_code != 200:
 
172
  return {"error": "PubMed search failed", "results": [], "source": "PubMed"}
173
-
174
  ids = r.json().get("esearchresult", {}).get("idlist", [])
175
  if not ids:
176
  return {"total": 0, "results": [], "source": "PubMed"}
177
-
178
- fetch_url = (
179
- f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
180
- f"?db=pubmed&id={','.join(ids)}&retmode=json"
181
- )
182
  r2 = await client.get(fetch_url)
183
  if r2.status_code != 200:
 
184
  return {"error": "PubMed fetch failed", "results": [], "source": "PubMed"}
185
-
186
  data = r2.json().get("result", {})
187
  results = []
188
  for pid in ids:
@@ -193,7 +157,6 @@ async def search_pubmed(req: SearchRequest):
193
  authors = ", ".join(a.get("name", "") for a in author_list[:3])
194
  if len(author_list) > 3:
195
  authors += " et al."
196
-
197
  results.append({
198
  "title": rec.get("title", ""),
199
  "creator": authors,
@@ -204,32 +167,21 @@ async def search_pubmed(req: SearchRequest):
204
  "type": "Journal Article",
205
  "_source": "PubMed",
206
  })
207
-
208
  return {"total": len(ids), "results": results, "source": "PubMed"}
209
-
210
- except Exception as e:
211
- logger.error(f"PubMed search error: {e}")
212
- return {"error": str(e), "results": [], "source": "PubMed"}
213
 
214
 
215
- # ── Semantic Scholar ───────────────────────────────────────────────────────────
216
-
217
  async def search_scholar(req: SearchRequest):
218
  try:
219
- params = {
220
- "query": req.query,
221
- "limit": req.limit,
222
- "fields": "title,authors,year,venue,externalIds,citationCount",
223
- }
224
-
225
  async with httpx.AsyncClient(timeout=15) as client:
226
- r = await client.get(
227
- "https://api.semanticscholar.org/graph/v1/paper/search",
228
- params=params,
229
- )
230
  if r.status_code != 200:
 
231
  return {"error": f"Semantic Scholar API: {r.status_code}", "results": [], "source": "Semantic Scholar"}
232
-
233
  data = r.json()
234
  results = []
235
  for paper in data.get("data", []):
@@ -237,7 +189,6 @@ async def search_scholar(req: SearchRequest):
237
  authors = ", ".join(a.get("name", "") for a in author_list[:3])
238
  if len(author_list) > 3:
239
  authors += " et al."
240
-
241
  paper_id = paper.get("paperId")
242
  results.append({
243
  "title": paper.get("title", ""),
@@ -249,13 +200,8 @@ async def search_scholar(req: SearchRequest):
249
  "type": "Article",
250
  "_source": "Semantic Scholar",
251
  })
252
-
253
- return {
254
- "total": data.get("total", 0),
255
- "results": results,
256
- "source": "Semantic Scholar",
257
- }
258
-
259
- except Exception as e:
260
- logger.error(f"Semantic Scholar search error: {e}")
261
- return {"error": str(e), "results": [], "source": "Semantic Scholar"}
 
3
  from urllib.parse import quote
4
 
5
  import httpx
6
+ from fastapi import APIRouter
7
  from pydantic import BaseModel, field_validator
8
 
9
  from src.config import get_settings
 
12
  router = APIRouter()
13
 
14
 
 
 
15
  class SearchRequest(BaseModel):
16
  query: str
17
  source: str = "primo"
 
47
  return v
48
 
49
 
50
+ def _safe_metrics_increment(key: str) -> None:
51
+ try:
52
+ from app import metrics_service
53
+ metrics_service.incr(key)
54
+ except Exception:
55
+ return
56
+
57
+
58
+ def _safe_metrics_bucket(bucket: str, key: str) -> None:
59
+ try:
60
+ from app import metrics_service
61
+ metrics_service.incr_bucket(bucket, key)
62
+ except Exception:
63
+ return
64
+
65
 
66
  @router.post("")
67
  async def search_endpoint(req: SearchRequest):
68
+ _safe_metrics_increment("search_requests")
69
+ _safe_metrics_bucket("search_sources", req.source)
70
  settings = get_settings()
 
71
  if req.source == "primo":
72
  return await search_primo(req, settings.primo_api_key)
73
+ if req.source == "pubmed":
74
  return await search_pubmed(req)
75
+ return await search_scholar(req)
 
76
 
77
 
 
 
78
  async def search_primo(req: SearchRequest, api_key: str):
79
  if not api_key:
80
  logger.warning("PRIMO_API_KEY not configured")
81
+ _safe_metrics_bucket("errors", "primo_missing_api_key")
82
  return {"error": "PRIMO_API_KEY not configured", "results": [], "source": "PRIMO"}
83
 
84
  query_encoded = quote(req.query)
 
 
 
85
  url = (
86
  f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
87
+ f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
88
+ f"&q=any,contains,{query_encoded}&lang=en&sort=rank&limit={req.limit}"
89
+ f"&offset=0&mode=advanced&apikey={api_key}"
 
 
 
 
 
 
 
90
  )
 
 
91
  facets = []
92
  if req.peer_reviewed:
93
  facets.append("facet_tlevel,include,peer_reviewed")
94
  if req.open_access:
95
  facets.append("facet_tlevel,include,open_access")
96
  if req.year_from or req.year_to:
97
+ facets.append(f"facet_searchcreationdate,include,{req.year_from or '0001'}|,|{req.year_to or '9999'}")
 
 
 
98
  for facet in facets:
99
  url += f"&multiFacets={quote(facet)}"
100
 
101
  try:
102
  async with httpx.AsyncClient(timeout=20) as client:
103
  r = await client.get(url, headers={"Accept": "application/json"})
104
+ logger.info("Primo status=%s query=%s", r.status_code, req.query[:60])
 
 
 
 
 
 
 
 
 
 
105
  if r.status_code != 200:
106
+ _safe_metrics_bucket("errors", f"primo_{r.status_code}")
107
  return {"error": f"PRIMO API error: {r.status_code}", "results": [], "source": "PRIMO"}
 
108
  data = r.json()
109
  docs = data.get("docs", [])
 
 
110
  results = []
111
+ for doc in docs[: req.limit]:
112
  pnx = doc.get("pnx", {})
113
  d = pnx.get("display", {})
 
114
  results.append({
115
  "title": (d.get("title") or ["Untitled"])[0],
116
  "creator": "; ".join(d.get("creator") or d.get("contributor") or []) or "Unknown",
117
  "date": (d.get("date") or [""])[0],
118
  "type": (d.get("type") or [""])[0],
119
  "source": (d.get("source") or [""])[0],
120
+ "primo_url": f"https://khalifa.primo.exlibrisgroup.com/discovery/search?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU",
 
 
 
121
  "_source": "PRIMO",
122
  })
123
+ return {"total": data.get("info", {}).get("total", 0), "results": results, "source": "PRIMO"}
 
 
 
 
 
 
124
  except httpx.TimeoutException:
125
+ _safe_metrics_bucket("errors", "primo_timeout")
126
  return {"error": "PRIMO API timed out — please try again", "results": [], "source": "PRIMO"}
127
+ except Exception as exc:
128
+ logger.error("Primo search error: %s", exc, exc_info=True)
129
+ _safe_metrics_bucket("errors", "primo_exception")
130
+ return {"error": str(exc), "results": [], "source": "PRIMO"}
131
 
132
 
 
 
133
  async def search_pubmed(req: SearchRequest):
134
  query_encoded = quote(req.query)
 
135
  try:
136
  async with httpx.AsyncClient(timeout=15) as client:
137
+ search_url = f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term={query_encoded}&retmax={req.limit}&retmode=json"
 
 
 
138
  r = await client.get(search_url)
139
  if r.status_code != 200:
140
+ _safe_metrics_bucket("errors", "pubmed_search_failed")
141
  return {"error": "PubMed search failed", "results": [], "source": "PubMed"}
 
142
  ids = r.json().get("esearchresult", {}).get("idlist", [])
143
  if not ids:
144
  return {"total": 0, "results": [], "source": "PubMed"}
145
+ fetch_url = f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id={','.join(ids)}&retmode=json"
 
 
 
 
146
  r2 = await client.get(fetch_url)
147
  if r2.status_code != 200:
148
+ _safe_metrics_bucket("errors", "pubmed_fetch_failed")
149
  return {"error": "PubMed fetch failed", "results": [], "source": "PubMed"}
 
150
  data = r2.json().get("result", {})
151
  results = []
152
  for pid in ids:
 
157
  authors = ", ".join(a.get("name", "") for a in author_list[:3])
158
  if len(author_list) > 3:
159
  authors += " et al."
 
160
  results.append({
161
  "title": rec.get("title", ""),
162
  "creator": authors,
 
167
  "type": "Journal Article",
168
  "_source": "PubMed",
169
  })
 
170
  return {"total": len(ids), "results": results, "source": "PubMed"}
171
+ except Exception as exc:
172
+ logger.error("PubMed search error: %s", exc)
173
+ _safe_metrics_bucket("errors", "pubmed_exception")
174
+ return {"error": str(exc), "results": [], "source": "PubMed"}
175
 
176
 
 
 
177
  async def search_scholar(req: SearchRequest):
178
  try:
179
+ params = {"query": req.query, "limit": req.limit, "fields": "title,authors,year,venue,externalIds,citationCount"}
 
 
 
 
 
180
  async with httpx.AsyncClient(timeout=15) as client:
181
+ r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
 
 
 
182
  if r.status_code != 200:
183
+ _safe_metrics_bucket("errors", f"semantic_{r.status_code}")
184
  return {"error": f"Semantic Scholar API: {r.status_code}", "results": [], "source": "Semantic Scholar"}
 
185
  data = r.json()
186
  results = []
187
  for paper in data.get("data", []):
 
189
  authors = ", ".join(a.get("name", "") for a in author_list[:3])
190
  if len(author_list) > 3:
191
  authors += " et al."
 
192
  paper_id = paper.get("paperId")
193
  results.append({
194
  "title": paper.get("title", ""),
 
200
  "type": "Article",
201
  "_source": "Semantic Scholar",
202
  })
203
+ return {"total": data.get("total", 0), "results": results, "source": "Semantic Scholar"}
204
+ except Exception as exc:
205
+ logger.error("Semantic Scholar search error: %s", exc)
206
+ _safe_metrics_bucket("errors", "semantic_exception")
207
+ return {"error": str(exc), "results": [], "source": "Semantic Scholar"}