Upload 5 files
Browse files- src/api/__init__.py +1 -4
- src/api/admin.py +75 -26
- src/api/agent.py +29 -3
- src/api/feedback.py +30 -16
- 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 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 28 |
-
|
| 29 |
-
resp
|
| 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("/
|
| 43 |
-
async def
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@router.post("")
|
| 20 |
-
async def
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 61 |
return await search_pubmed(req)
|
| 62 |
-
|
| 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"&
|
| 81 |
-
f"&
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 153 |
return {"error": "PRIMO API timed out — please try again", "results": [], "source": "PRIMO"}
|
| 154 |
-
except Exception as
|
| 155 |
-
logger.error(
|
| 156 |
-
|
|
|
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
return {"error": str(
|
| 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 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 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"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|