nikeshn commited on
Commit
1b56c45
·
verified ·
1 Parent(s): b10389a

Upload 5 files

Browse files
Files changed (4) hide show
  1. src/api/__init__.py +2 -1
  2. src/api/admin.py +23 -94
  3. src/api/agent.py +845 -477
  4. src/api/feedback.py +31 -0
src/api/__init__.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from . import agent
 
2
  from . import search
3
- from . import admin
 
1
+ from . import admin
2
  from . import agent
3
+ from . import feedback
4
  from . import search
 
src/api/admin.py CHANGED
@@ -1,121 +1,50 @@
1
- import logging
 
2
  import secrets
3
  import time
4
- from typing import Annotated
5
-
6
- from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
7
- from pydantic import BaseModel
8
-
9
- from src.config import get_settings
10
 
11
- logger = logging.getLogger(__name__)
12
  router = APIRouter()
13
 
14
- # In-memory session store — single instance on HF Spaces free tier
15
- # Resets on every container restart (expected behaviour)
16
- _admin_sessions: dict[str, float] = {} # token → expiry timestamp
17
-
18
-
19
- # ── Auth Dependency ────────────────────────────────────────────────────────────
20
-
21
- def _cleanup_sessions():
22
- """Evict expired tokens to prevent unbounded growth."""
23
- now = time.time()
24
- expired = [t for t, exp in _admin_sessions.items() if exp < now]
25
- for t in expired:
26
- del _admin_sessions[t]
27
-
28
-
29
- def require_admin(admin_session: Annotated[str | None, Cookie()] = None):
30
- """FastAPI dependency — raises 401 if session is missing or expired."""
31
- _cleanup_sessions()
32
- if not admin_session or admin_session not in _admin_sessions:
33
- raise HTTPException(status_code=401, detail="Not authenticated")
34
- if _admin_sessions[admin_session] < time.time():
35
- del _admin_sessions[admin_session]
36
- raise HTTPException(status_code=401, detail="Session expired")
37
-
38
-
39
- # ── Request Models ─────────────────────────────────────────────────────────────
40
-
41
  class LoginRequest(BaseModel):
42
  password: str
43
 
44
-
45
  class ConfigUpdate(BaseModel):
46
  key: str
47
  value: str
48
 
49
-
50
- # ── Endpoints ──────────────────────────────────────────────────────────────────
51
 
52
  @router.post("/auth")
53
  async def admin_auth(req: LoginRequest):
 
54
  settings = get_settings()
55
-
56
- if not settings.admin_password:
57
- raise HTTPException(status_code=503, detail="Admin password not configured")
58
-
59
  if req.password != settings.admin_password:
60
  raise HTTPException(status_code=401, detail="Invalid password")
61
-
62
  token = secrets.token_hex(32)
63
- _admin_sessions[token] = time.time() + 86400 # 24 hours
64
-
65
- resp = Response(content='{"status": "ok"}', media_type="application/json")
66
- resp.set_cookie(
67
- key="admin_session",
68
- value=token,
69
- httponly=True,
70
- secure=True, # HTTPS only — HF Spaces always serves HTTPS
71
- samesite="strict",
72
- max_age=86400,
73
- )
74
- return resp
75
-
76
-
77
- @router.post("/logout")
78
- async def admin_logout(admin_session: Annotated[str | None, Cookie()] = None):
79
- if admin_session and admin_session in _admin_sessions:
80
- del _admin_sessions[admin_session]
81
- resp = Response(content='{"status": "ok"}', media_type="application/json")
82
- resp.delete_cookie("admin_session")
83
  return resp
84
 
85
-
86
- @router.get("/config", dependencies=[Depends(require_admin)])
87
  async def get_config():
 
88
  settings = get_settings()
89
  return {
90
  "max_results": settings.max_results,
91
- "maintenance_mode": settings.maintenance_mode,
92
- "environment": settings.environment,
93
  }
94
 
95
-
96
- @router.post("/config", dependencies=[Depends(require_admin)])
97
- async def update_config(req: ConfigUpdate):
98
- # Runtime-only config update — does not persist across restarts.
99
- # To persist, write to a file under /data (paid HF tier) or use an external store.
100
- allowed_keys = {"maintenance_mode", "max_results"}
101
- if req.key not in allowed_keys:
102
- raise HTTPException(status_code=400, detail=f"Key '{req.key}' is not configurable at runtime")
103
- # TODO: update a shared runtime config object here
104
- logger.info(f"Admin config update: {req.key} = {req.value}")
105
- return {"status": "ok", "updated": req.key, "note": "runtime only — resets on restart"}
106
-
107
-
108
- @router.get("/rag-status", dependencies=[Depends(require_admin)])
109
- async def rag_status():
110
- """Admin view of RAG initialization state."""
111
- try:
112
- from app import rag_service
113
- return rag_service.get_stats()
114
- except Exception as e:
115
- return {"error": str(e), "ready": False}
116
-
117
-
118
- @router.get("/sessions", dependencies=[Depends(require_admin)])
119
- async def session_info():
120
- _cleanup_sessions()
121
- return {"active_sessions": len(_admin_sessions)}
 
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}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/api/agent.py CHANGED
@@ -1,35 +1,64 @@
 
 
1
  import logging
2
- import time
3
  import re
4
- import json
5
- from typing import List, Optional, Dict, Tuple
 
 
6
  from urllib.parse import quote
7
 
8
  import httpx
9
  from fastapi import APIRouter, Request
10
- from pydantic import BaseModel, ConfigDict
11
 
12
  from src.config import get_settings
13
  from src.services.staff_service import (
14
- match_staff_name, match_staff_role,
15
- staff_name_answer, staff_role_answer,
16
  STAFF_DIRECTORY,
 
 
 
 
17
  )
18
 
19
  logger = logging.getLogger(__name__)
20
  router = APIRouter()
21
 
22
- # ── Pydantic Models ───────────────────────────────────────────────────────────
23
 
24
  class ChatMessage(BaseModel):
25
  role: str
26
  content: str
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  class AgentRequest(BaseModel):
30
  question: str
31
  model: str = "gpt"
32
- history: List[ChatMessage] = []
 
33
 
34
 
35
  class AgentResponse(BaseModel):
@@ -37,9 +66,9 @@ class AgentResponse(BaseModel):
37
 
38
  answer: str
39
  intent: str = "general"
40
- tools_used: List[str] = []
41
- search_results: List[dict] = []
42
- sources: List[dict] = []
43
  model_used: str = "gpt"
44
  response_time: float = 0
45
  corrected_query: Optional[str] = None
@@ -47,13 +76,21 @@ class AgentResponse(BaseModel):
47
  database_query: Optional[str] = None
48
  ask_librarian: Optional[bool] = None
49
  is_follow_up: bool = False
 
 
 
 
 
 
50
 
51
 
52
- # ── Constants ─────────────────────────────────────────────────────────────────
53
-
54
  LIBRARY_HOURS_URL = "https://library.ku.ac.ae/hours"
55
  KU_MAIN_URL = "https://www.ku.ac.ae/"
 
 
 
56
  HISTORY_WINDOW = 6
 
57
 
58
  SOCIAL_RE = re.compile(
59
  r"^(hi|hello|hey|good morning|good afternoon|good evening|how are you|hows it going"
@@ -132,10 +169,11 @@ SUMMARY_RE = re.compile(
132
  r"|state of (the art|research|the field)"
133
  r"|recent (advances?|developments?|progress|trends?)"
134
  r"|current (state|trends?|advances?|research)"
135
- r"|deep analysis|analysis of|review of|literature review"
136
  r"|what do we know about|how does|how do)\b",
137
  re.IGNORECASE,
138
  )
 
139
  MEDICAL_KEYWORDS = [
140
  "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
141
  "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
@@ -147,172 +185,103 @@ MEDICAL_KEYWORDS = [
147
  "crispr", "gene therapy", "gene editing", "genomic medicine", "precision medicine",
148
  ]
149
 
150
- # ── Classifier Prompt ─────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
151
 
152
  _CLASSIFIER_SYSTEM = """You are the routing brain of LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE).
153
  Your only job: read the user message and return a single JSON object routing it to the right handler.
154
 
155
  INTENTS — pick exactly one:
156
- "social" — casual chat, greetings, emotions, compliments, complaints, vague help requests
157
- "library_info" — questions about KU Library services, hours, rooms, borrowing, fines, ILL, staff, databases, ORCID, RefWorks, open access, APC, account, printing, remote access, LibKey, acquisitions, Khazna, thesis submission, library policies, alumni access
158
- "search_academic"— user wants to FIND/GET articles/papers/books on a topic, OR wants a summary/overview/analysis/explanation of an academic/research topic
159
- "search_medical" — same as search_academic but topic is medical/clinical/nursing/pharmacy/health sciences
160
- "general_recent" — questions about TRULY live/real-time data: breaking news, live sports scores, today's weather, current stock prices
161
- "general" — ALL other factual questions: university info, people, history, science definitions, technology explanations
162
 
163
  RULES:
164
  1. When in doubt between library_info and social, choose library_info if ANY library keyword is present.
165
- 2. "I don't know how to search for articles" = library_info.
166
- 3. "I am fed up with searching" alone = social.
167
- 4. "I need help finding articles on diabetes" = search_medical.
168
  5. KU = Khalifa University. Do NOT confuse with Kuwait University.
169
  6. ANY summary, overview, analysis, deep dive, review, or explanation of an academic/research topic = search_academic or search_medical.
170
  7. Questions about people, university leadership, history, science definitions = general.
171
  8. Only use general_recent for truly live data changing by the minute.
172
 
173
- For "social" intent ONLY: include "casual_answer" — a warm 1-3 sentence LibBee response (no markdown, no bullets).
174
- For all other intents: omit "casual_answer".
175
 
176
  Respond ONLY with valid JSON. No preamble, no markdown fences.
 
 
 
 
 
177
 
178
- Examples:
179
- {"intent":"social","casual_answer":"Of course! I'm LibBee, the KU Library AI Assistant. What can I find for you?"}
180
- {"intent":"library_info"}
181
- {"intent":"search_academic"}
182
- {"intent":"search_medical"}
183
- {"intent":"general_recent"}
184
- {"intent":"general"}"""
185
 
 
 
 
 
 
 
186
 
187
- # ── Source Badge ──────────────────────────────────────────────────────────────
188
 
189
  def _source_badge(source: str, url: str = "") -> str:
190
- """
191
- Returns a small HTML pill badge showing where the answer came from.
192
- Appended to the end of every bot response.
193
- """
194
  styles = {
195
- "rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
196
- "primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
197
- "semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
198
- "pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
199
- "web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
200
- "web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
201
- "libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
202
- "ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
203
- "llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
204
  }
205
  icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
206
- inner = f'{icon} Source: {label}'
207
  if url:
208
  inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
209
  return (
210
  f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
211
  f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
212
- f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">'
213
- f'{inner}</div>'
214
  )
215
 
216
 
217
- # ── Helper Functions ───────────────────────────────────────────────────────────
218
-
219
- def _is_summary_request(question: str) -> bool:
220
- return bool(SUMMARY_RE.search(question))
221
-
222
-
223
- def _extract_topic_regex(question: str) -> str:
224
- """Regex-based topic extraction — fast fallback."""
225
- q = question.strip()
226
- prefixes = [
227
- "can u give me a deep analysis of ", "can you give me a deep analysis of ",
228
- "give me a deep analysis of ", "deep analysis of ",
229
- "give me a summary of ", "give me a summary on ", "give me a summary about ",
230
- "give me an overview of ", "give me an overview on ",
231
- "give me a brief overview of ", "give me a brief summary of ",
232
- "summarize ", "summarise ",
233
- "summary of ", "summary on ", "summary about ",
234
- "overview of ", "overview on ",
235
- "brief overview of ", "brief summary of ",
236
- "analysis of ", "analyze ", "analyse ",
237
- "explain ", "tell me about ",
238
- "what is ", "what are ",
239
- "describe ",
240
- "review of ", "literature review on ",
241
- "recent advances in ", "recent developments in ",
242
- "state of the art in ", "state of research on ",
243
- "research overview of ", "research summary of ",
244
- "find articles on ", "find papers on ", "find books on ",
245
- "find articles about ", "find papers about ",
246
- "search for articles on ", "search for papers on ",
247
- "i need articles on ", "i need papers on ", "i need books on ",
248
- "give me articles on ", "show me articles on ",
249
- "get me articles on ", "look for articles on ",
250
- "articles on ", "papers on ", "books on ",
251
- "research on ", "literature on ",
252
- "find ", "search for ", "i need ", "give me ",
253
- "show me ", "get me ",
254
- ]
255
- lower = q.lower()
256
- for prefix in prefixes:
257
- if lower.startswith(prefix):
258
- q = q[len(prefix):]
259
- break
260
- q = re.sub(r'\s+(please|thanks|thank you)\.?$', '', q, flags=re.IGNORECASE)
261
- return q.strip()
262
-
263
-
264
- async def _extract_topic(question: str, model: str) -> str:
265
- """
266
- LLM-based topic extraction for messy natural language queries.
267
- Falls back to regex if LLM unavailable.
268
- Always returns clean 2-5 keyword search terms.
269
- """
270
- settings = get_settings()
271
- if not settings.openai_api_key:
272
- return _extract_topic_regex(question)
273
 
274
- try:
275
- llm = _get_llm(model, temperature=0, max_tokens=30)
276
- response = await llm.ainvoke([
277
- {"role": "system", "content": (
278
- "Extract only the core research topic from the user's question. "
279
- "Return 2-5 keywords ONLY. No sentences, no punctuation, no explanation.\n"
280
- "Examples:\n"
281
- "'can u give me a deep analysis of quantum physics advancement in recent years' "
282
- "→ quantum physics advances\n"
283
- "'summarize recent research on machine learning in healthcare' "
284
- "→ machine learning healthcare\n"
285
- "'find articles on the impact of climate change on biodiversity' "
286
- "→ climate change biodiversity\n"
287
- "'give me an overview of CRISPR gene editing' "
288
- "→ CRISPR gene editing\n"
289
- "'i need books on structural engineering UAE' "
290
- "→ structural engineering UAE"
291
- )},
292
- {"role": "user", "content": question},
293
- ])
294
- topic = response.content.strip().strip('"').strip("'").strip(".")
295
- if topic and len(topic) > 2:
296
- logger.info(f"LLM topic: '{topic}' ← '{question[:60]}'")
297
- return topic
298
- except Exception as e:
299
- logger.warning(f"LLM topic extraction failed: {e}")
300
 
301
- return _extract_topic_regex(question)
 
302
 
303
 
304
  def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
305
- if not GREETING_FOLLOWUP_RE.match(question.strip()):
306
  return False
307
  for m in reversed(history):
308
  if m.role == "assistant":
309
  last = m.content.lower()
310
- return "i'm <strong>libbee</strong>" in last or "are you looking for one of these" in last
311
  return False
312
 
313
 
314
  def _looks_library_hours_question(question: str) -> bool:
315
- q = question.strip().lower()
316
  return bool(HOURS_RE.search(q)) and any(
317
  kw in q for kw in (
318
  "library", "campus", "habshan", "san", "hours", "open", "close",
@@ -323,23 +292,24 @@ def _looks_library_hours_question(question: str) -> bool:
323
 
324
 
325
  def _looks_campus_question(question: str) -> bool:
326
- return bool(KU_CAMPUS_RE.search(question))
327
 
328
 
329
  def _looks_medical_search(question: str) -> bool:
330
- q = question.lower()
331
  if not any(kw in q for kw in MEDICAL_KEYWORDS):
332
  return False
333
- return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q))
334
 
335
 
336
  def _looks_research_question(question: str) -> bool:
337
- return bool(RESEARCH_CUE_RE.search(question.lower()))
 
338
 
339
 
340
- def _rule_based_classify(question: str) -> Dict:
341
- q = question.lower()
342
- if SOCIAL_RE.match(question.strip()):
343
  return {
344
  "intent": "social",
345
  "casual_answer": (
@@ -350,7 +320,7 @@ def _rule_based_classify(question: str) -> Dict:
350
  }
351
  if _looks_medical_search(question):
352
  return {"intent": "search_medical"}
353
- if _looks_research_question(question) or _is_summary_request(question):
354
  return {"intent": "search_academic"}
355
  if LIBRARY_CUE_RE.search(q):
356
  return {"intent": "library_info"}
@@ -384,7 +354,7 @@ def _campus_answer() -> str:
384
  'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
385
  "Both branches offer study spaces, computer workstations, printing, scanning, "
386
  "and access to physical collections.<br><br>"
387
- 'For general library enquiries: <a href="https://library.ku.ac.ae/AskUs" target="_blank">Ask a Librarian</a> '
388
  'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
389
  )
390
 
@@ -401,8 +371,6 @@ def _greeting_menu_clarify_answer() -> str:
401
  )
402
 
403
 
404
- # ── LLM Helpers ───────────────────────────────────────────────────────────────
405
-
406
  def _get_llm(model: str, temperature: float, max_tokens: int):
407
  settings = get_settings()
408
  if model == "claude" and settings.anthropic_api_key:
@@ -422,15 +390,7 @@ def _get_llm(model: str, temperature: float, max_tokens: int):
422
  )
423
 
424
 
425
- def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
426
- msgs = []
427
- for m in history[-HISTORY_WINDOW:]:
428
- if m.role in ("user", "assistant") and m.content:
429
- msgs.append({"role": m.role, "content": m.content[:300]})
430
- return msgs
431
-
432
-
433
- async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict:
434
  settings = get_settings()
435
  if not settings.openai_api_key:
436
  return _rule_based_classify(question)
@@ -442,40 +402,32 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
442
  if m.role in ("user", "assistant")
443
  ]
444
  ctx = " | ".join(ctx_parts) if ctx_parts else "none"
445
- user_msg = f"Message: {question[:400]}\nContext: {ctx}\nJSON:"
446
-
447
- llm = _get_llm(model, temperature=0, max_tokens=150)
448
  response = await llm.ainvoke([
449
  {"role": "system", "content": _CLASSIFIER_SYSTEM},
450
- {"role": "user", "content": user_msg},
451
  ])
452
-
453
  raw = response.content.strip()
454
  if raw.startswith("```"):
455
  raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
456
-
457
  result = json.loads(raw)
458
  intent = result.get("intent", "general")
459
  if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general"}:
460
  intent = "general"
461
-
462
  return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
463
-
464
  except Exception as e:
465
  logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
466
  return _rule_based_classify(question)
467
 
468
 
469
- async def _libbee_casual_response(
470
- question: str, history: List[ChatMessage], model: str, hint: str = ""
471
- ) -> str:
472
- if hint and len(hint.strip()) > 25:
473
  return hint.strip()
474
  settings = get_settings()
475
  if not settings.openai_api_key:
476
  return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
477
  try:
478
- llm = _get_llm(model, temperature=0.6, max_tokens=200)
479
  msgs = [{"role": "system", "content": (
480
  "You are LibBee, the Khalifa University Library AI Assistant. "
481
  "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
@@ -489,9 +441,7 @@ async def _libbee_casual_response(
489
  return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
490
 
491
 
492
- async def _rag_answer(
493
- question: str, rag_results: List[dict], history: List[ChatMessage], model: str
494
- ) -> str:
495
  settings = get_settings()
496
  if not settings.openai_api_key:
497
  return rag_results[0]["content"] if rag_results else ""
@@ -501,13 +451,12 @@ async def _rag_answer(
501
  "You are LibBee, the Khalifa University Library AI Assistant. "
502
  "Answer the user's question using ONLY the context provided below. "
503
  "Be concise, friendly, and use HTML <br> for line breaks where needed. "
504
- "If the context does not contain enough information, say so and suggest "
505
- "Ask a Librarian at https://library.ku.ac.ae/AskUs. "
506
  "Never invent facts or URLs.\n\n"
507
  f"CONTEXT:\n{context}"
508
  )
509
  try:
510
- llm = _get_llm(model, temperature=0.2, max_tokens=400)
511
  msgs = [{"role": "system", "content": system}]
512
  msgs.extend(_build_history_messages(history))
513
  msgs.append({"role": "user", "content": question})
@@ -516,35 +465,52 @@ async def _rag_answer(
516
  except Exception as e:
517
  logger.error(f"_rag_answer error: {e}")
518
  return (
519
- "I'm having trouble generating an answer. Please try "
520
- '<a href="https://library.ku.ac.ae/AskUs" target="_blank">Ask a Librarian</a>.'
521
  )
522
 
523
 
524
- async def _web_search_answer(
525
- question: str, history: List[ChatMessage], model: str
526
- ) -> str:
527
- """Web search — Claude tool or GPT Responses API. Falls back to LLM knowledge."""
528
  settings = get_settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
 
 
 
530
  if model == "claude" and settings.anthropic_api_key:
531
  try:
532
  import anthropic
533
  client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
534
  response = client.messages.create(
535
  model="claude-3-haiku-20240307",
536
- max_tokens=600,
537
  system=(
538
  "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
539
- "Answer using current web search. Be concise and factual. "
540
- "Use HTML <br> for line breaks. KU = Khalifa University."
541
  ),
542
  tools=[{"type": "web_search_20250305", "name": "web_search"}],
543
  messages=[{"role": "user", "content": question}],
544
  )
545
- text = "".join(
546
- block.text for block in response.content if hasattr(block, "text")
547
- )
548
  if text.strip():
549
  return text.strip()
550
  except Exception as e:
@@ -559,8 +525,7 @@ async def _web_search_answer(
559
  tools=[{"type": "web_search_preview"}],
560
  instructions=(
561
  "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
562
- "Answer using current web search. Be concise and factual. "
563
- "Use HTML <br> for line breaks. KU = Khalifa University."
564
  ),
565
  input=question,
566
  )
@@ -578,88 +543,314 @@ async def _web_search_answer(
578
  return await _llm_general_answer(question, history, model)
579
 
580
 
581
- async def _llm_general_answer(
582
- question: str, history: List[ChatMessage], model: str
583
- ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  settings = get_settings()
585
  if not settings.openai_api_key:
586
- return (
587
- "I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
588
- f'For general questions visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
589
- )
590
- system = (
591
- "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
592
- "Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
593
- "KU = Khalifa University. If topic is academic, briefly offer to find library resources."
594
- )
595
  try:
596
- llm = _get_llm(model, temperature=0.3, max_tokens=400)
597
- msgs = [{"role": "system", "content": system}]
598
- msgs.extend(_build_history_messages(history))
599
- msgs.append({"role": "user", "content": question})
600
- response = await llm.ainvoke(msgs)
601
- return response.content.strip()
 
 
 
 
602
  except Exception as e:
603
- logger.error(f"_llm_general_answer error: {e}")
604
- return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
605
 
606
 
607
- # ── PRIMO Search ──────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
 
609
- async def _primo_inline_search(topic: str, limit: int = 5) -> List[dict]:
610
- """Search PRIMO — API key as URL param (Ex Libris requirement)."""
611
- settings = get_settings()
612
- if not settings.primo_api_key:
613
- logger.warning("PRIMO_API_KEY not set")
614
- return []
615
 
616
- query_encoded = quote(topic)
617
- url = (
618
- f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
619
- f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
620
- f"&q=any,contains,{query_encoded}&lang=en&sort=rank"
621
- f"&limit={limit}&offset=0&mode=advanced"
622
- f"&apikey={settings.primo_api_key}"
623
- )
624
 
 
 
 
 
625
  try:
626
- async with httpx.AsyncClient(timeout=15) as client:
627
- r = await client.get(url, headers={"Accept": "application/json"})
628
- if r.status_code != 200:
629
- logger.warning(f"PRIMO {r.status_code} for '{topic}'")
630
- return []
631
- data = r.json()
632
- results = []
633
- for doc in data.get("docs", [])[:limit]:
634
- pnx = doc.get("pnx", {})
635
- d = pnx.get("display", {})
636
- results.append({
637
- "title": (d.get("title") or ["Untitled"])[0],
638
- "creator": "; ".join(d.get("creator") or d.get("contributor") or []) or "Unknown",
639
- "date": (d.get("date") or [""])[0],
640
- "type": (d.get("type") or [""])[0],
641
- "source": (d.get("source") or [""])[0],
642
- "primo_url": (
643
- f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
644
- f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU"
645
- ),
646
- "_source": "PRIMO",
647
- })
648
- logger.info(f"PRIMO: {len(results)} results for '{topic}'")
649
- return results
650
- except Exception as e:
651
- logger.error(f"PRIMO search failed: {e}")
652
- return []
653
 
 
 
 
 
 
 
654
 
655
- # ── Semantic Scholar Search ───────────────────────────────────────────────────
656
 
657
  async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
658
- """
659
- Search Semantic Scholar for papers WITH abstracts.
660
- Used for summary generation — abstracts enable real in-text citations.
661
- Free API, no key required.
662
- """
663
  try:
664
  params = {
665
  "query": topic,
@@ -667,279 +858,381 @@ async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
667
  "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
668
  }
669
  async with httpx.AsyncClient(timeout=15) as client:
670
- r = await client.get(
671
- "https://api.semanticscholar.org/graph/v1/paper/search",
672
- params=params,
673
- )
674
  if r.status_code != 200:
675
  logger.warning(f"Semantic Scholar {r.status_code} for '{topic}'")
676
  return []
677
-
678
  data = r.json()
679
  results = []
680
  for paper in data.get("data", []):
681
  abstract = paper.get("abstract") or ""
682
  if not abstract:
683
- continue # skip papers without abstracts
684
-
685
  author_list = paper.get("authors") or []
686
- first_author_last = (
687
- author_list[0].get("name", "Unknown").split()[-1]
688
- if author_list else "Unknown"
689
- )
690
  year = paper.get("year") or "n.d."
691
  paper_id = paper.get("paperId", "")
692
  doi = (paper.get("externalIds") or {}).get("DOI", "")
693
  pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
694
- link = (
695
- pdf_url or
696
- (f"https://doi.org/{doi}" if doi else "") or
697
- (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
698
- )
699
-
700
  results.append({
701
  "title": paper.get("title", "Untitled"),
702
- "authors": (
703
- ", ".join(a.get("name", "") for a in author_list[:3])
704
- + (" et al." if len(author_list) > 3 else "")
705
- ),
706
  "first_author": first_author_last,
707
  "year": year,
708
  "citation_key": f"{first_author_last}, {year}",
709
  "venue": paper.get("venue", ""),
710
- "abstract": abstract[:600],
711
  "link": link,
712
  "_source": "Semantic Scholar",
713
  })
714
-
715
- logger.info(f"Semantic Scholar: {len(results)} papers with abstracts for '{topic}'")
716
  return results
717
-
718
  except Exception as e:
719
  logger.error(f"Semantic Scholar search failed: {e}")
720
  return []
721
 
722
 
723
- # ── Cited Summary Generation ──────────────────────────────────────────────────
724
-
725
- async def _generate_cited_summary(
726
- topic: str, papers: List[dict], model: str
727
- ) -> str:
728
- """
729
- Generate a research summary with clickable in-text citations [Author, Year].
730
- Each citation links to the actual paper.
731
- Grounded only in the abstracts provided — no hallucination.
732
- """
733
  settings = get_settings()
734
  if not settings.openai_api_key or not papers:
735
  return ""
736
 
737
- # Build numbered reference context for LLM
738
  context = ""
739
- for i, p in enumerate(papers, 1):
740
- context += (
741
- f"[{i}] {p['citation_key']}: {p['title']}\n"
742
- f"Abstract: {p['abstract']}\n\n"
743
- )
744
 
745
  system = (
746
- "You are a research librarian writing an academic literature summary. "
747
- "Using ONLY the numbered references provided, write a 4-6 sentence "
748
- "research summary on the given topic. "
749
- "Use in-text citations as [1], [2], [3] etc. referring to the numbered references. "
750
- "Cite at least 4 different references throughout the summary. "
751
- "Use HTML <br><br> to separate paragraphs. No bullet points, no markdown headers. "
752
- "Only state what is explicitly supported by the abstracts. "
753
- "Do NOT invent content beyond what the abstracts say."
754
- )
755
-
756
- user_msg = (
757
- f"Topic: {topic}\n\n"
758
- f"References:\n{context}\n"
759
- f"Write a cited research summary on '{topic}'."
760
  )
761
 
762
  try:
763
- llm = _get_llm(model, temperature=0.2, max_tokens=600)
764
  response = await llm.ainvoke([
765
  {"role": "system", "content": system},
766
- {"role": "user", "content": user_msg},
767
  ])
768
- summary_text = response.content.strip()
769
-
770
- # Replace [N] with clickable citation links
771
- for i, p in enumerate(papers, 1):
772
- link = p.get("link", "#")
773
- title_escaped = p["title"].replace('"', "&quot;")
774
  cite_html = (
775
- f'<a href="{link}" target="_blank" '
776
- f'title="{title_escaped}" '
777
- f'style="color:#1e40af;font-weight:600;text-decoration:none">'
778
- f'[{p["citation_key"]}]</a>'
779
  )
780
- summary_text = summary_text.replace(f"[{i}]", cite_html)
781
-
782
- return summary_text
783
-
784
  except Exception as e:
785
  logger.error(f"Summary generation failed: {e}")
786
  return ""
787
 
788
 
789
- # ── Research Summary (Semantic Scholar → cited summary) ───────────────────────
790
 
791
- async def _research_summary(
792
- topic: str, history: List[ChatMessage], model: str
793
- ) -> Tuple[str, List[dict]]:
794
- """
795
- 1. Search Semantic Scholar for papers with abstracts
796
- 2. Generate LLM summary with clickable in-text citations
797
- 3. Show numbered reference list with links
798
- Falls back to PRIMO list if Semantic Scholar returns nothing.
799
- Returns (answer_html, search_results_for_frontend).
800
- """
801
- # Try Semantic Scholar first (has abstracts)
802
- papers = await _semantic_scholar_search(topic, limit=8)
803
 
804
- if papers:
805
- summary = await _generate_cited_summary(topic, papers, model)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806
 
807
- if not summary:
808
- summary = f"Here are key academic resources on <strong>{topic}</strong>:"
809
 
810
- answer = (
811
- f"<strong>📖 Research Summary: {topic}</strong><br><br>"
812
- f"{summary}<br><br>"
813
- f"<strong>📚 References ({len(papers)} papers):</strong><br>"
 
 
 
814
  )
 
 
 
 
 
815
 
816
- for i, p in enumerate(papers, 1):
817
- safe_title = p["title"].replace("<", "&lt;").replace(">", "&gt;")
 
 
 
 
 
 
 
 
 
 
 
 
818
  answer += (
819
- f'<br><strong>[{i}]</strong> '
820
- f'<a href="{p["link"]}" target="_blank" '
821
- f'style="color:#003366;font-weight:600">{safe_title}</a><br>'
822
- f'<span style="font-size:.8rem;color:#6b7280">'
823
- f'{p["authors"]} · {p["year"]} · {p["venue"]}'
824
- f'</span>'
825
  )
 
 
 
826
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
827
  answer += (
828
- "<br><br>💡 Want me to:<br>"
829
- "• Also search the <strong>KU Library catalogue</strong> (PRIMO) for physical access?<br>"
830
- "• Narrow to <strong>peer reviewed</strong> or a specific <strong>year range</strong>?<br>"
831
- "Just tell me!"
832
  )
833
- answer += _source_badge("semantic", "https://www.semanticscholar.org/")
834
 
835
- # Return empty list — references already in answer HTML, avoid double render
836
- return answer, []
 
 
 
 
 
 
 
 
837
 
838
- # Fallback: PRIMO list (no abstracts, no cited summary)
839
- logger.info(f"Semantic Scholar returned nothing for '{topic}' — falling back to PRIMO")
840
- primo_results = await _primo_inline_search(topic, limit=8)
841
 
842
- if not primo_results:
843
- primo_url = (
844
- f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
845
- f"?query=any,contains,{quote(topic)}&vid=971KUOSTAR_INST:KU"
 
 
 
 
 
846
  )
847
- answer = (
848
- f"I couldn't find academic resources on <em>{topic}</em> right now.<br><br>"
849
- f'Try searching directly in <a href="{primo_url}" target="_blank">PRIMO</a>.'
 
 
 
 
850
  )
851
- answer += _source_badge("primo", primo_url)
852
- return answer, []
853
-
854
- answer = f"<strong>📚 Resources on <em>{topic}</em> from KU Library catalogue:</strong><br>"
855
- for i, r in enumerate(primo_results, 1):
856
- safe = r["title"].replace("<", "&lt;").replace(">", "&gt;")
857
- answer += (
858
- f'<br><strong>{i}.</strong> '
859
- f'<a href="{r["primo_url"]}" target="_blank" '
860
- f'style="color:#003366;font-weight:600">{safe}</a><br>'
861
- f'<span style="font-size:.8rem;color:#6b7280">'
862
- f'{r["creator"]} · {r["date"]}'
863
- f'</span>'
864
  )
865
- answer += (
866
- "<br><br>💡 Want a deeper summary? "
867
- "Ask me to <strong>summarize</strong> this topic and I'll use academic abstracts."
 
 
 
868
  )
869
- answer += _source_badge("primo", "https://khalifa.primo.exlibrisgroup.com/")
870
 
871
- # Return results for frontend card rendering
872
- return answer, primo_results
873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
 
875
- # ── Main Endpoint ──────────────────────────────────────────────────────────────
876
 
877
  @router.post("", response_model=AgentResponse)
878
  async def agent_query(request: Request, req: AgentRequest):
879
  start_time = time.time()
880
- question = req.question.strip()
881
  history = req.history
882
  model = req.model
 
883
 
884
  if not question:
 
885
  return AgentResponse(
886
- answer="Please type a question and I'll do my best to help!",
887
  intent="social",
888
  model_used=model,
889
  response_time=time.time() - start_time,
 
 
890
  )
891
 
892
- # ── LAYER 0: Instant rule-based handlers ─────────────────────────────
893
-
894
- # 1. Pure greeting
895
  if PURE_GREETING_RE.match(question):
 
896
  return AgentResponse(
897
  answer=(
898
  "Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>"
899
- "I can help you find articles and books, search databases, access full text, "
900
- "request Interlibrary Loan (ILL), and answer questions about library services and staff.<br><br>"
901
- "Are you looking for one of these? You can also type your question directly."
902
  + _source_badge("libbee")
903
  ),
904
  intent="social_greeting",
905
  model_used=model,
906
  response_time=time.time() - start_time,
 
 
907
  )
908
 
909
- # 2. Staff name match
910
  staff_match = match_staff_name(question)
911
  if staff_match:
 
912
  return AgentResponse(
913
  answer=staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
914
  intent="library_info",
915
  tools_used=["staff_name_match"],
916
  model_used=model,
917
  response_time=time.time() - start_time,
 
 
918
  )
919
 
920
- # 3. Staff role match
921
  staff_role_match = match_staff_role(question)
922
  if staff_role_match:
 
923
  return AgentResponse(
924
  answer=staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
925
  intent="library_info",
926
  tools_used=["staff_role_match"],
927
  model_used=model,
928
  response_time=time.time() - start_time,
 
 
929
  )
930
 
931
- # 4. Greeting menu followup
932
  if _is_greeting_menu_followup(question, history):
 
933
  return AgentResponse(
934
  answer=_greeting_menu_clarify_answer() + _source_badge("libbee"),
935
  intent="social_greeting",
936
  is_follow_up=True,
937
  model_used=model,
938
  response_time=time.time() - start_time,
 
 
939
  )
940
 
941
- # 5. Library hours
942
  if _looks_library_hours_question(question):
 
943
  return AgentResponse(
944
  answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL),
945
  intent="library_info",
@@ -947,10 +1240,12 @@ async def agent_query(request: Request, req: AgentRequest):
947
  ask_librarian=False,
948
  model_used=model,
949
  response_time=time.time() - start_time,
 
 
950
  )
951
 
952
- # 6. Campus/location
953
  if _looks_campus_question(question):
 
954
  return AgentResponse(
955
  answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
956
  intent="library_info",
@@ -958,28 +1253,88 @@ async def agent_query(request: Request, req: AgentRequest):
958
  ask_librarian=False,
959
  model_used=model,
960
  response_time=time.time() - start_time,
 
 
961
  )
962
 
963
- # ── LAYER 1: LLM Classification ────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
964
 
965
  cls_result = await _llm_classify(question, history, model)
966
  intent = cls_result.get("intent", "general")
967
  casual_hint = cls_result.get("casual_answer", "")
968
 
969
- # ── LAYER 2: Intent Handlers ───────────────��───────────────────────────
970
-
971
  if intent == "social":
972
- answer = casual_hint if (casual_hint and len(casual_hint.strip()) > 25) else \
973
- await _libbee_casual_response(question, history, model, casual_hint)
974
  return AgentResponse(
975
  answer=answer + _source_badge("libbee"),
976
  intent="social_greeting",
977
  tools_used=["libbee_casual"],
978
  model_used=model,
979
  response_time=time.time() - start_time,
 
 
980
  )
981
 
982
- elif intent == "library_info":
983
  try:
984
  from app import rag_service
985
  rag_results = await rag_service.hybrid_search(question, top_k=4)
@@ -992,20 +1347,18 @@ async def agent_query(request: Request, req: AgentRequest):
992
  "I couldn't find specific information on that in my knowledge base.<br><br>"
993
  "Please contact our librarians directly:<br>"
994
  '📧 <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
995
- '🔗 <a href="https://library.ku.ac.ae/AskUs" target="_blank">Ask a Librarian</a>'
996
  )
997
- answer += _source_badge("ku_library", "https://library.ku.ac.ae/AskUs")
998
- tools_used = ["rag_no_results"]
999
  sources = []
 
1000
  else:
1001
  answer = await _rag_answer(question, rag_results, history, model)
1002
  answer += _source_badge("rag", "https://library.ku.ac.ae/")
 
1003
  tools_used = ["rag_hybrid_search"]
1004
- sources = [
1005
- {"title": r["title"], "url": r["source"]}
1006
- for r in rag_results if r.get("source")
1007
- ]
1008
 
 
1009
  return AgentResponse(
1010
  answer=answer,
1011
  intent="library_info",
@@ -1014,86 +1367,101 @@ async def agent_query(request: Request, req: AgentRequest):
1014
  ask_librarian=not bool(rag_results),
1015
  model_used=model,
1016
  response_time=time.time() - start_time,
 
 
1017
  )
1018
 
1019
- elif intent in ("search_academic", "search_medical"):
1020
- # Step 1: extract clean topic using LLM
1021
  topic = await _extract_topic(question, model)
1022
- is_summary = _is_summary_request(question)
1023
- is_medical = intent == "search_medical"
1024
-
1025
- if is_summary:
1026
- # SUMMARY MODE → Semantic Scholar + cited summary
1027
- answer, search_results = await _research_summary(topic, history, model)
1028
- tools_used = ["semantic_scholar_summary"]
1029
- else:
1030
- # FIND MODE → PRIMO inline results
1031
- search_results = await _primo_inline_search(topic, limit=5)
 
 
 
 
1032
 
1033
- if search_results:
1034
- answer = f"<strong>📚 Resources on <em>{topic}</em> from the KU Library catalogue:</strong>"
1035
- else:
1036
- primo_url = (
1037
- f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
1038
- f"?query=any,contains,{quote(topic)}&vid=971KUOSTAR_INST:KU"
1039
- )
1040
- answer = (
1041
- f"I didn't find results for <em>{topic}</em> in the KU catalogue right now. "
1042
- f'Try searching directly in <a href="{primo_url}" target="_blank">PRIMO</a>.'
1043
- )
1044
-
1045
- pubmed_offer = (
1046
- "<br>• 🏥 <strong>Search PubMed</strong> for medical/clinical literature?"
1047
- if is_medical else ""
1048
- )
1049
- answer += (
1050
- "<br><br>💡 <strong>Want me to refine this?</strong><br>"
1051
- "• Narrow to <strong>peer reviewed</strong> only?<br>"
1052
- "• Limit to a specific <strong>year range</strong>?<br>"
1053
- "• Find <strong>books</strong> instead?"
1054
- "• Ask for a <strong>summary</strong> of this topic with cited papers?"
1055
- + pubmed_offer +
1056
- "<br><br>Just tell me what you need!"
 
 
1057
  )
1058
- answer += _source_badge("primo", "https://khalifa.primo.exlibrisgroup.com/")
1059
- tools_used = ["primo_inline"]
1060
 
 
 
1061
  return AgentResponse(
1062
  answer=answer,
1063
  intent=intent,
1064
- tools_used=tools_used,
1065
  search_results=search_results,
1066
- corrected_query=topic,
1067
- natural_query=topic,
1068
- database_query=topic,
1069
  model_used=model,
1070
  response_time=time.time() - start_time,
 
 
 
 
 
 
 
 
 
1071
  )
1072
 
1073
- elif intent == "general_recent":
1074
- answer = await _web_search_answer(question, history, model)
1075
- answer += _source_badge("web_live")
1076
  return AgentResponse(
1077
  answer=answer,
1078
  intent="general_recent",
1079
  tools_used=["web_search"],
1080
  model_used=model,
1081
  response_time=time.time() - start_time,
 
 
1082
  )
1083
 
1084
- else: # general
1085
- answer = await _web_search_answer(question, history, model)
1086
- answer += _source_badge("web")
1087
- return AgentResponse(
1088
- answer=answer,
1089
- intent="general",
1090
- tools_used=["web_search"],
1091
- model_used=model,
1092
- response_time=time.time() - start_time,
1093
- )
1094
-
1095
 
1096
- # ── Utility Endpoints ──────────────────────────────────────────────────────────
1097
 
1098
  @router.get("/test")
1099
  async def test():
 
1
+ import html
2
+ import json
3
  import logging
 
4
  import re
5
+ import time
6
+ import uuid
7
+ from datetime import datetime
8
+ from typing import Any, Dict, List, Optional, Tuple
9
  from urllib.parse import quote
10
 
11
  import httpx
12
  from fastapi import APIRouter, Request
13
+ from pydantic import BaseModel, ConfigDict, Field
14
 
15
  from src.config import get_settings
16
  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
  )
23
 
24
  logger = logging.getLogger(__name__)
25
  router = APIRouter()
26
 
 
27
 
28
  class ChatMessage(BaseModel):
29
  role: str
30
  content: str
31
 
32
 
33
+ class SearchContextPayload(BaseModel):
34
+ context_id: str
35
+ topic: str
36
+ display_topic: Optional[str] = None
37
+ intent: str = "search_academic"
38
+ source: str = "primo"
39
+ resource_type: str = "articles"
40
+ peer_reviewed: bool = False
41
+ open_access: bool = False
42
+ year_from: Optional[str] = None
43
+ year_to: Optional[str] = None
44
+ ai_tool_query: Optional[str] = None
45
+ primo_boolean_query: Optional[str] = None
46
+ created_at: float = Field(default_factory=time.time)
47
+
48
+
49
+ class ClientStatePayload(BaseModel):
50
+ session_id: Optional[str] = None
51
+ active_search_context_id: Optional[str] = None
52
+ recent_search_contexts: List[SearchContextPayload] = Field(default_factory=list)
53
+ follow_up_action: Optional[str] = None
54
+ follow_up_context_id: Optional[str] = None
55
+
56
+
57
  class AgentRequest(BaseModel):
58
  question: str
59
  model: str = "gpt"
60
+ history: List[ChatMessage] = Field(default_factory=list)
61
+ client_state: Optional[ClientStatePayload] = None
62
 
63
 
64
  class AgentResponse(BaseModel):
 
66
 
67
  answer: str
68
  intent: str = "general"
69
+ tools_used: List[str] = Field(default_factory=list)
70
+ search_results: List[dict] = Field(default_factory=list)
71
+ sources: List[dict] = Field(default_factory=list)
72
  model_used: str = "gpt"
73
  response_time: float = 0
74
  corrected_query: Optional[str] = None
 
76
  database_query: Optional[str] = None
77
  ask_librarian: Optional[bool] = None
78
  is_follow_up: bool = False
79
+ follow_up_question: Optional[str] = None
80
+ follow_up_suggestions: List[dict] = Field(default_factory=list)
81
+ search_context: Optional[dict] = None
82
+ ai_tool_query: Optional[str] = None
83
+ primo_boolean_query: Optional[str] = None
84
+ primo_search_url: Optional[str] = None
85
 
86
 
 
 
87
  LIBRARY_HOURS_URL = "https://library.ku.ac.ae/hours"
88
  KU_MAIN_URL = "https://www.ku.ac.ae/"
89
+ ASK_LIBRARIAN_URL = "https://library.ku.ac.ae/AskUs"
90
+ PRIMO_BASE_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU"
91
+ PRIMO_AI_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/researchAssistant?vid=971KUOSTAR_INST:KU&query="
92
  HISTORY_WINDOW = 6
93
+ CURRENT_YEAR = datetime.utcnow().year
94
 
95
  SOCIAL_RE = re.compile(
96
  r"^(hi|hello|hey|good morning|good afternoon|good evening|how are you|hows it going"
 
169
  r"|state of (the art|research|the field)"
170
  r"|recent (advances?|developments?|progress|trends?)"
171
  r"|current (state|trends?|advances?|research)"
172
+ r"|deep analysis|analysis of|review of|literature review|deep research|deep dive"
173
  r"|what do we know about|how does|how do)\b",
174
  re.IGNORECASE,
175
  )
176
+
177
  MEDICAL_KEYWORDS = [
178
  "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
179
  "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
 
185
  "crispr", "gene therapy", "gene editing", "genomic medicine", "precision medicine",
186
  ]
187
 
188
+ REFINEMENT_STOP_WORDS = {
189
+ "make", "it", "them", "those", "these", "the", "a", "an", "for", "to", "only", "show",
190
+ "find", "give", "me", "please", "and", "with", "within", "limit", "narrow", "search",
191
+ "results", "on", "about", "of", "this", "that", "topic", "same", "using", "from",
192
+ "peer", "reviewed", "review", "open", "access", "books", "book", "articles", "article",
193
+ "papers", "paper", "years", "year", "last", "past", "recent", "instead", "summary",
194
+ "overview", "brief", "pubmed", "primo", "database", "databases", "best", "deep", "research",
195
+ "tools", "tool", "guide", "ku", "library",
196
+ }
197
 
198
  _CLASSIFIER_SYSTEM = """You are the routing brain of LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE).
199
  Your only job: read the user message and return a single JSON object routing it to the right handler.
200
 
201
  INTENTS — pick exactly one:
202
+ \"social\" — casual chat, greetings, emotions, compliments, complaints, vague help requests
203
+ \"library_info\" — questions about KU Library services, hours, rooms, borrowing, fines, ILL, staff, databases, ORCID, RefWorks, open access, APC, account, printing, remote access, LibKey, acquisitions, Khazna, thesis submission, library policies, alumni access
204
+ \"search_academic\"— user wants to FIND/GET articles/papers/books on a topic, OR wants a summary/overview/analysis/explanation of an academic/research topic
205
+ \"search_medical\" — same as search_academic but topic is medical/clinical/nursing/pharmacy/health sciences
206
+ \"general_recent\" — questions about TRULY live/real-time data: breaking news, live sports scores, today's weather, current stock prices
207
+ \"general\" — ALL other factual questions: university info, people, history, science definitions, technology explanations
208
 
209
  RULES:
210
  1. When in doubt between library_info and social, choose library_info if ANY library keyword is present.
211
+ 2. \"I don't know how to search for articles\" = library_info.
212
+ 3. \"I am fed up with searching\" alone = social.
213
+ 4. \"I need help finding articles on diabetes\" = search_medical.
214
  5. KU = Khalifa University. Do NOT confuse with Kuwait University.
215
  6. ANY summary, overview, analysis, deep dive, review, or explanation of an academic/research topic = search_academic or search_medical.
216
  7. Questions about people, university leadership, history, science definitions = general.
217
  8. Only use general_recent for truly live data changing by the minute.
218
 
219
+ For \"social\" intent ONLY: include \"casual_answer\" — a warm 1-3 sentence LibBee response (no markdown, no bullets).
220
+ For all other intents: omit \"casual_answer\".
221
 
222
  Respond ONLY with valid JSON. No preamble, no markdown fences.
223
+ """
224
+
225
+
226
+ def _escape(text: str) -> str:
227
+ return html.escape(text or "")
228
 
 
 
 
 
 
 
 
229
 
230
+ def _normalize_whitespace(text: str) -> str:
231
+ return re.sub(r"\s+", " ", (text or "").strip())
232
+
233
+
234
+ def _title_case_topic(topic: str) -> str:
235
+ return _normalize_whitespace(topic).strip().strip(".?")
236
 
 
237
 
238
  def _source_badge(source: str, url: str = "") -> str:
 
 
 
 
239
  styles = {
240
+ "rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
241
+ "primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
242
+ "semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
243
+ "pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
244
+ "web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
245
+ "web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
246
+ "libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
247
+ "ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
248
+ "llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
249
  }
250
  icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
251
+ inner = f"{icon} Source: {label}"
252
  if url:
253
  inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
254
  return (
255
  f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
256
  f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
257
+ f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">{inner}</div>'
 
258
  )
259
 
260
 
261
+ def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
262
+ msgs = []
263
+ for m in history[-HISTORY_WINDOW:]:
264
+ if m.role in ("user", "assistant") and m.content:
265
+ msgs.append({"role": m.role, "content": m.content[:300]})
266
+ return msgs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
+ def _is_summary_request(question: str) -> bool:
270
+ return bool(SUMMARY_RE.search(question or ""))
271
 
272
 
273
  def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
274
+ if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
275
  return False
276
  for m in reversed(history):
277
  if m.role == "assistant":
278
  last = m.content.lower()
279
+ return "i'm libbee" in last or "are you looking for one of these" in last
280
  return False
281
 
282
 
283
  def _looks_library_hours_question(question: str) -> bool:
284
+ q = (question or "").strip().lower()
285
  return bool(HOURS_RE.search(q)) and any(
286
  kw in q for kw in (
287
  "library", "campus", "habshan", "san", "hours", "open", "close",
 
292
 
293
 
294
  def _looks_campus_question(question: str) -> bool:
295
+ return bool(KU_CAMPUS_RE.search(question or ""))
296
 
297
 
298
  def _looks_medical_search(question: str) -> bool:
299
+ q = (question or "").lower()
300
  if not any(kw in q for kw in MEDICAL_KEYWORDS):
301
  return False
302
+ return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
303
 
304
 
305
  def _looks_research_question(question: str) -> bool:
306
+ q = question or ""
307
+ return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
308
 
309
 
310
+ def _rule_based_classify(question: str) -> Dict[str, str]:
311
+ q = (question or "").lower()
312
+ if SOCIAL_RE.match((question or "").strip()):
313
  return {
314
  "intent": "social",
315
  "casual_answer": (
 
320
  }
321
  if _looks_medical_search(question):
322
  return {"intent": "search_medical"}
323
+ if _looks_research_question(question):
324
  return {"intent": "search_academic"}
325
  if LIBRARY_CUE_RE.search(q):
326
  return {"intent": "library_info"}
 
354
  'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
355
  "Both branches offer study spaces, computer workstations, printing, scanning, "
356
  "and access to physical collections.<br><br>"
357
+ f'For general library enquiries: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> '
358
  'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
359
  )
360
 
 
371
  )
372
 
373
 
 
 
374
  def _get_llm(model: str, temperature: float, max_tokens: int):
375
  settings = get_settings()
376
  if model == "claude" and settings.anthropic_api_key:
 
390
  )
391
 
392
 
393
+ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
 
 
 
 
 
 
 
 
394
  settings = get_settings()
395
  if not settings.openai_api_key:
396
  return _rule_based_classify(question)
 
402
  if m.role in ("user", "assistant")
403
  ]
404
  ctx = " | ".join(ctx_parts) if ctx_parts else "none"
405
+ llm = _get_llm(model, temperature=0, max_tokens=120)
 
 
406
  response = await llm.ainvoke([
407
  {"role": "system", "content": _CLASSIFIER_SYSTEM},
408
+ {"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
409
  ])
 
410
  raw = response.content.strip()
411
  if raw.startswith("```"):
412
  raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
 
413
  result = json.loads(raw)
414
  intent = result.get("intent", "general")
415
  if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general"}:
416
  intent = "general"
 
417
  return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
 
418
  except Exception as e:
419
  logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
420
  return _rule_based_classify(question)
421
 
422
 
423
+ async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
424
+ if hint and len(hint.strip()) > 20:
 
 
425
  return hint.strip()
426
  settings = get_settings()
427
  if not settings.openai_api_key:
428
  return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
429
  try:
430
+ llm = _get_llm(model, temperature=0.6, max_tokens=160)
431
  msgs = [{"role": "system", "content": (
432
  "You are LibBee, the Khalifa University Library AI Assistant. "
433
  "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
 
441
  return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
442
 
443
 
444
+ async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
 
 
445
  settings = get_settings()
446
  if not settings.openai_api_key:
447
  return rag_results[0]["content"] if rag_results else ""
 
451
  "You are LibBee, the Khalifa University Library AI Assistant. "
452
  "Answer the user's question using ONLY the context provided below. "
453
  "Be concise, friendly, and use HTML <br> for line breaks where needed. "
454
+ f"If the context does not contain enough information, say so and suggest Ask a Librarian at {ASK_LIBRARIAN_URL}. "
 
455
  "Never invent facts or URLs.\n\n"
456
  f"CONTEXT:\n{context}"
457
  )
458
  try:
459
+ llm = _get_llm(model, temperature=0.2, max_tokens=380)
460
  msgs = [{"role": "system", "content": system}]
461
  msgs.extend(_build_history_messages(history))
462
  msgs.append({"role": "user", "content": question})
 
465
  except Exception as e:
466
  logger.error(f"_rag_answer error: {e}")
467
  return (
468
+ "I'm having trouble generating an answer right now. "
469
+ f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
470
  )
471
 
472
 
473
+ async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
 
 
 
474
  settings = get_settings()
475
+ if not settings.openai_api_key:
476
+ return (
477
+ "I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
478
+ f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
479
+ )
480
+ system = (
481
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
482
+ "Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
483
+ "KU means Khalifa University. If the topic is academic, briefly offer to help find library resources."
484
+ )
485
+ try:
486
+ llm = _get_llm(model, temperature=0.3, max_tokens=360)
487
+ msgs = [{"role": "system", "content": system}]
488
+ msgs.extend(_build_history_messages(history))
489
+ msgs.append({"role": "user", "content": question})
490
+ response = await llm.ainvoke(msgs)
491
+ return response.content.strip()
492
+ except Exception as e:
493
+ logger.error(f"_llm_general_answer error: {e}")
494
+ return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
495
+
496
 
497
+ async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
498
+ settings = get_settings()
499
  if model == "claude" and settings.anthropic_api_key:
500
  try:
501
  import anthropic
502
  client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
503
  response = client.messages.create(
504
  model="claude-3-haiku-20240307",
505
+ max_tokens=520,
506
  system=(
507
  "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
508
+ "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
 
509
  ),
510
  tools=[{"type": "web_search_20250305", "name": "web_search"}],
511
  messages=[{"role": "user", "content": question}],
512
  )
513
+ text = "".join(block.text for block in response.content if hasattr(block, "text"))
 
 
514
  if text.strip():
515
  return text.strip()
516
  except Exception as e:
 
525
  tools=[{"type": "web_search_preview"}],
526
  instructions=(
527
  "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
528
+ "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
 
529
  ),
530
  input=question,
531
  )
 
543
  return await _llm_general_answer(question, history, model)
544
 
545
 
546
+ def _extract_topic_regex(question: str) -> str:
547
+ q = _normalize_whitespace(question)
548
+ prefixes = [
549
+ "can u give me a deep analysis of ", "can you give me a deep analysis of ",
550
+ "give me a deep analysis of ", "deep analysis of ",
551
+ "give me a summary of ", "give me a summary on ", "give me a summary about ",
552
+ "give me an overview of ", "give me an overview on ",
553
+ "give me a brief overview of ", "give me a brief summary of ",
554
+ "summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
555
+ "overview of ", "overview on ", "brief overview of ", "brief summary of ",
556
+ "analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
557
+ "what is ", "what are ", "describe ", "review of ", "literature review on ",
558
+ "recent advances in ", "recent developments in ", "state of the art in ",
559
+ "state of research on ", "research overview of ", "research summary of ",
560
+ "find articles on ", "find papers on ", "find books on ", "find articles about ",
561
+ "find papers about ", "search for articles on ", "search for papers on ",
562
+ "i need articles on ", "i need papers on ", "i need books on ",
563
+ "give me articles on ", "show me articles on ", "get me articles on ",
564
+ "look for articles on ", "articles on ", "papers on ", "books on ",
565
+ "research on ", "literature on ", "find ", "search for ", "i need ",
566
+ "give me ", "show me ", "get me ",
567
+ ]
568
+ lower = q.lower()
569
+ for prefix in prefixes:
570
+ if lower.startswith(prefix):
571
+ q = q[len(prefix):]
572
+ break
573
+ q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
574
+ q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
575
+ return _normalize_whitespace(q).strip(".?")
576
+
577
+
578
+ async def _extract_topic(question: str, model: str) -> str:
579
  settings = get_settings()
580
  if not settings.openai_api_key:
581
+ return _extract_topic_regex(question)
 
 
 
 
 
 
 
 
582
  try:
583
+ llm = _get_llm(model, temperature=0, max_tokens=32)
584
+ response = await llm.ainvoke([
585
+ {"role": "system", "content": (
586
+ "Extract only the core research topic from the user's question. "
587
+ "Return 2-7 keywords only. No sentence, no punctuation, no explanation."
588
+ )},
589
+ {"role": "user", "content": question},
590
+ ])
591
+ topic = response.content.strip().strip('"').strip("'").strip(".")
592
+ return topic if topic else _extract_topic_regex(question)
593
  except Exception as e:
594
+ logger.warning(f"Topic extraction failed: {e}")
595
+ return _extract_topic_regex(question)
596
 
597
 
598
+ def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
599
+ q = (question or "").lower()
600
+ if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
601
+ return "books"
602
+ if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
603
+ return "articles"
604
+ return existing or "articles"
605
+
606
+
607
+ def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
608
+ q = (question or "").lower()
609
+ match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
610
+ if match:
611
+ years = int(match.group(2))
612
+ return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
613
+
614
+ between = re.search(r"\b(?:from|between)\s+(20\d{2}|19\d{2})\s+(?:to|and|-)\s+(20\d{2}|19\d{2})\b", q)
615
+ if between:
616
+ return between.group(1), between.group(2)
617
+
618
+ years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
619
+ if len(years) >= 2:
620
+ return min(years), max(years)
621
+ if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
622
+ return years[0], str(CURRENT_YEAR)
623
+ return None, None
624
+
625
+
626
+ def _make_primo_boolean(topic: str) -> str:
627
+ text = _normalize_whitespace(topic)
628
+ stop = {
629
+ "of", "on", "in", "the", "a", "an", "and", "or", "for", "to", "with", "by", "from", "at",
630
+ "is", "are", "was", "were", "be", "been", "have", "has", "had", "do", "does", "did", "will",
631
+ "would", "could", "should", "may", "its", "this", "that", "these", "those", "about", "impact",
632
+ "role", "effect", "use", "study", "analysis", "review", "what", "how", "why", "when", "where",
633
+ "which", "recent", "latest", "current",
634
+ }
635
+ words = re.sub(r"[^\w\s-]", " ", text).split()
636
+ concepts: List[str] = []
637
+ phrase: List[str] = []
638
+ for word in words:
639
+ cleaned = word.strip("-")
640
+ if cleaned and cleaned.lower() not in stop and len(cleaned) > 2:
641
+ phrase.append(cleaned)
642
+ else:
643
+ if phrase:
644
+ concepts.append(" ".join(phrase))
645
+ phrase = []
646
+ if phrase:
647
+ concepts.append(" ".join(phrase))
648
+ concepts = concepts[:4]
649
+ if len(concepts) >= 2:
650
+ return " AND ".join(f'\"{c}\"' for c in concepts)
651
+ if len(concepts) == 1:
652
+ return f'\"{concepts[0]}\"'
653
+ return f'\"{text}\"'
654
+
655
+
656
+ def _primo_url_from_boolean(boolean_query: str) -> str:
657
+ safe = re.sub(r"\s+", " ", boolean_query or "").strip()
658
+ return f"{PRIMO_BASE_URL}&query=any,contains,{quote(safe)}&lang=en&mode=advanced&offset=0"
659
+
660
+
661
+ def _question_has_new_topic(question: str, base_topic: str) -> bool:
662
+ q = (question or "").lower()
663
+ cleaned = re.sub(
664
+ r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
665
+ r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
666
+ " ",
667
+ q,
668
+ )
669
+ tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
670
+ if len(tokens) < 3:
671
+ return False
672
+ base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
673
+ overlap = sum(1 for t in tokens if t in base_tokens)
674
+ return overlap <= max(1, min(2, len(base_tokens)))
675
+
676
+
677
+ def _parse_refinement_action(question: str) -> Optional[str]:
678
+ q = (question or "").lower().strip()
679
+ if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
680
+ return "peer_reviewed_only"
681
+ if re.search(r"\b(open access|oa only|only open access)\b", q):
682
+ return "open_access_only"
683
+ if re.search(r"\b(last|past)\s+5\s+years?\b", q):
684
+ return "last_5_years"
685
+ if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
686
+ return "last_10_years"
687
+ if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
688
+ return "books_only"
689
+ if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
690
+ return "articles_only"
691
+ if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
692
+ return "both_resources"
693
+ if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
694
+ return "summarize_topic"
695
+ if re.search(r"\bpubmed\b", q):
696
+ return "search_pubmed"
697
+ if re.search(r"\bprimo\b", q):
698
+ return "search_primo"
699
+ if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
700
+ return "best_databases"
701
+ if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
702
+ return "deep_research_tools"
703
+ return None
704
+
705
+
706
+ def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
707
+ if not client_state or not client_state.recent_search_contexts:
708
+ return None
709
+ contexts = list(client_state.recent_search_contexts)
710
+ if client_state.follow_up_context_id:
711
+ for ctx in contexts:
712
+ if ctx.context_id == client_state.follow_up_context_id:
713
+ return ctx
714
+ if client_state.active_search_context_id:
715
+ for ctx in contexts:
716
+ if ctx.context_id == client_state.active_search_context_id:
717
+ return ctx
718
+ contexts.sort(key=lambda c: c.created_at, reverse=True)
719
+ return contexts[0]
720
+
721
+
722
+ def _detect_follow_up(question: str, client_state: Optional[ClientStatePayload]) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
723
+ base_context = _resolve_base_context(client_state)
724
+ if not base_context:
725
+ return False, None, None
726
+
727
+ explicit_action = (client_state.follow_up_action if client_state else None) or None
728
+ if explicit_action:
729
+ return True, explicit_action, base_context
730
+
731
+ action = _parse_refinement_action(question)
732
+ if not action:
733
+ return False, None, None
734
+
735
+ if _question_has_new_topic(question, base_context.topic):
736
+ return False, None, None
737
+
738
+ return True, action, base_context
739
+
740
+
741
+ def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
742
+ return SearchContextPayload.model_validate(base_context.model_dump())
743
+
744
+
745
+ def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
746
+ context = _clone_context(base_context)
747
+ context.context_id = str(uuid.uuid4())
748
+ context.created_at = time.time()
749
+
750
+ if action == "peer_reviewed_only":
751
+ context.peer_reviewed = True
752
+ elif action == "open_access_only":
753
+ context.open_access = True
754
+ elif action == "last_5_years":
755
+ context.year_from = str(CURRENT_YEAR - 4)
756
+ context.year_to = str(CURRENT_YEAR)
757
+ elif action == "last_10_years":
758
+ context.year_from = str(CURRENT_YEAR - 9)
759
+ context.year_to = str(CURRENT_YEAR)
760
+ elif action == "books_only":
761
+ context.resource_type = "books"
762
+ elif action == "articles_only":
763
+ context.resource_type = "articles"
764
+ elif action == "both_resources":
765
+ context.resource_type = "both"
766
+ elif action == "search_pubmed":
767
+ context.source = "pubmed"
768
+ context.intent = "search_medical"
769
+ context.resource_type = "articles"
770
+ elif action == "search_primo":
771
+ context.source = "primo"
772
+ return context
773
+
774
+
775
+ def _filters_summary(context: SearchContextPayload) -> str:
776
+ filters: List[str] = []
777
+ if context.peer_reviewed:
778
+ filters.append("peer reviewed only")
779
+ if context.open_access:
780
+ filters.append("open access")
781
+ if context.year_from and context.year_to:
782
+ filters.append(f"{context.year_from}–{context.year_to}")
783
+ elif context.year_from:
784
+ filters.append(f"from {context.year_from}")
785
+ if context.resource_type == "books":
786
+ filters.append("books")
787
+ elif context.resource_type == "articles":
788
+ filters.append("articles")
789
+ elif context.resource_type == "both":
790
+ filters.append("articles and books")
791
+ return ", ".join(filters)
792
+
793
+
794
+ def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
795
+ topic = context.display_topic or context.topic
796
+ topic = _title_case_topic(topic)
797
+ scope = context.resource_type
798
+ filters = []
799
+ if context.peer_reviewed:
800
+ filters.append("peer-reviewed")
801
+ if context.open_access:
802
+ filters.append("open-access")
803
+ if context.year_from and context.year_to:
804
+ filters.append(f"published between {context.year_from} and {context.year_to}")
805
+ elif context.year_from:
806
+ filters.append(f"published from {context.year_from} onward")
807
+
808
+ if is_follow_up:
809
+ if scope == "books":
810
+ base = f"Find books on {topic}"
811
+ elif scope == "both":
812
+ base = f"Find articles and books on {topic}"
813
+ else:
814
+ base = f"Find research articles on {topic}"
815
+ else:
816
+ base = _normalize_whitespace(question).strip(".?")
817
+ if len(base.split()) < 4:
818
+ base = f"Find research on {topic}"
819
 
820
+ if filters:
821
+ return f"{base} with {', '.join(filters)}."
822
+ return base.rstrip(".") + "."
 
 
 
823
 
 
 
 
 
 
 
 
 
824
 
825
+ async def _grammar_refine_query(text: str, model: str) -> str:
826
+ settings = get_settings()
827
+ if not settings.openai_api_key:
828
+ return _normalize_whitespace(text)
829
  try:
830
+ llm = _get_llm(model, temperature=0, max_tokens=60)
831
+ response = await llm.ainvoke([
832
+ {"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
833
+ {"role": "user", "content": text},
834
+ ])
835
+ refined = _normalize_whitespace(response.content)
836
+ return refined or _normalize_whitespace(text)
837
+ except Exception:
838
+ return _normalize_whitespace(text)
839
+
840
+
841
+ def _context_to_dict(context: SearchContextPayload) -> dict:
842
+ return context.model_dump()
843
+
 
 
 
 
 
 
 
 
 
 
 
 
 
844
 
845
+ async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
846
+ natural = _compose_ai_tool_query(question, context, is_follow_up)
847
+ natural = await _grammar_refine_query(natural, model)
848
+ context.ai_tool_query = natural
849
+ context.primo_boolean_query = _make_primo_boolean(context.topic)
850
+ return context
851
 
 
852
 
853
  async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
 
 
 
 
 
854
  try:
855
  params = {
856
  "query": topic,
 
858
  "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
859
  }
860
  async with httpx.AsyncClient(timeout=15) as client:
861
+ r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
 
 
 
862
  if r.status_code != 200:
863
  logger.warning(f"Semantic Scholar {r.status_code} for '{topic}'")
864
  return []
 
865
  data = r.json()
866
  results = []
867
  for paper in data.get("data", []):
868
  abstract = paper.get("abstract") or ""
869
  if not abstract:
870
+ continue
 
871
  author_list = paper.get("authors") or []
872
+ first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
 
 
 
873
  year = paper.get("year") or "n.d."
874
  paper_id = paper.get("paperId", "")
875
  doi = (paper.get("externalIds") or {}).get("DOI", "")
876
  pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
877
+ link = pdf_url or (f"https://doi.org/{doi}" if doi else "") or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
 
 
 
 
 
878
  results.append({
879
  "title": paper.get("title", "Untitled"),
880
+ "authors": ", ".join(a.get("name", "") for a in author_list[:3]) + (" et al." if len(author_list) > 3 else ""),
 
 
 
881
  "first_author": first_author_last,
882
  "year": year,
883
  "citation_key": f"{first_author_last}, {year}",
884
  "venue": paper.get("venue", ""),
885
+ "abstract": abstract[:700],
886
  "link": link,
887
  "_source": "Semantic Scholar",
888
  })
 
 
889
  return results
 
890
  except Exception as e:
891
  logger.error(f"Semantic Scholar search failed: {e}")
892
  return []
893
 
894
 
895
+ async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
 
 
 
 
 
 
 
 
 
896
  settings = get_settings()
897
  if not settings.openai_api_key or not papers:
898
  return ""
899
 
 
900
  context = ""
901
+ for i, paper in enumerate(papers, 1):
902
+ context += f"[{i}] {paper['citation_key']}: {paper['title']}\nAbstract: {paper['abstract']}\n\n"
 
 
 
903
 
904
  system = (
905
+ "You are a research librarian writing a concise academic literature snapshot. "
906
+ "Using ONLY the numbered references provided, write a 4-6 sentence summary on the given topic. "
907
+ "Use in-text citations as [1], [2], [3] etc. Cite at least 4 references. "
908
+ "Use HTML <br><br> for paragraph breaks. Do not invent facts."
 
 
 
 
 
 
 
 
 
 
909
  )
910
 
911
  try:
912
+ llm = _get_llm(model, temperature=0.2, max_tokens=560)
913
  response = await llm.ainvoke([
914
  {"role": "system", "content": system},
915
+ {"role": "user", "content": f"Topic: {topic}\n\nReferences:\n{context}\nWrite the summary."},
916
  ])
917
+ summary = response.content.strip()
918
+ for i, paper in enumerate(papers, 1):
919
+ title_escaped = _escape(paper["title"])
 
 
 
920
  cite_html = (
921
+ f'<a href="{paper["link"]}" target="_blank" title="{title_escaped}" '
922
+ f'style="color:#1e40af;font-weight:600;text-decoration:none">[{paper["citation_key"]}]</a>'
 
 
923
  )
924
+ summary = summary.replace(f"[{i}]", cite_html)
925
+ return summary
 
 
926
  except Exception as e:
927
  logger.error(f"Summary generation failed: {e}")
928
  return ""
929
 
930
 
931
+ from src.api.search import SearchRequest, search_primo, search_pubmed
932
 
 
 
 
 
 
 
 
 
 
 
 
 
933
 
934
+ async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
935
+ settings = get_settings()
936
+ query = context.topic
937
+ result = await search_primo(
938
+ SearchRequest(
939
+ query=query,
940
+ source="primo",
941
+ limit=limit,
942
+ peer_reviewed=context.peer_reviewed,
943
+ open_access=context.open_access,
944
+ year_from=context.year_from,
945
+ year_to=context.year_to,
946
+ ),
947
+ settings.primo_api_key,
948
+ )
949
+ source_url = _primo_url_from_boolean(context.primo_boolean_query or _make_primo_boolean(context.topic))
950
+ return result, source_url
951
+
952
+
953
+ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
954
+ query = context.topic
955
+ result = await search_pubmed(
956
+ SearchRequest(
957
+ query=query,
958
+ source="pubmed",
959
+ limit=limit,
960
+ year_from=context.year_from,
961
+ year_to=context.year_to,
962
+ )
963
+ )
964
+ pubmed_url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(query)}"
965
+ return result, pubmed_url
966
 
 
 
967
 
968
+ def _database_guidance_for_topic(context: SearchContextPayload) -> str:
969
+ topic = _escape(context.display_topic or context.topic)
970
+ if context.intent == "search_medical":
971
+ return (
972
+ f"For a fuller research workflow on <strong>{topic}</strong>, start with <strong>PubMed</strong> for biomedical coverage, "
973
+ "then use <strong>EBSCO Research AI</strong> or <strong>Scopus AI</strong> to expand the evidence map. "
974
+ "For KU-accessible full text, continue in <strong>PRIMO AI</strong>."
975
  )
976
+ return (
977
+ f"For deeper work on <strong>{topic}</strong>, use <strong>Scopus AI</strong> for broad scholarly mapping, "
978
+ "<strong>EBSCO Research AI</strong> for AI-assisted database discovery, and <strong>PRIMO AI</strong> for KU-accessible resources. "
979
+ "If you want a more agentic literature exploration workflow, continue in <strong>LeapSpace</strong> or another deep-research tool from the library guide."
980
+ )
981
 
982
+
983
+ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
984
+ topic = context.topic
985
+ papers = await _semantic_scholar_search(topic, limit=8)
986
+ references: List[dict] = []
987
+
988
+ if papers:
989
+ summary = await _generate_cited_summary(topic, papers, model)
990
+ if not summary:
991
+ summary = f"Here is a concise research snapshot on <strong>{_escape(topic)}</strong>."
992
+ answer = f"<strong>📖 Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
993
+ answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
994
+ answer += "<br><br><strong>Selected references:</strong><br>"
995
+ for i, paper in enumerate(papers[:6], 1):
996
  answer += (
997
+ f'<br><strong>[{i}]</strong> <a href="{paper["link"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(paper["title"])}</a><br>'
998
+ f'<span style="font-size:.8rem;color:#6b7280">{_escape(paper["authors"])} · {paper["year"]} · {_escape(paper["venue"])}</span>'
 
 
 
 
999
  )
1000
+ answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1001
+ references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1002
+ return answer, [], references
1003
 
1004
+ result, source_url = await _search_primo_catalog(context, limit=6)
1005
+ search_results = result.get("results", []) or []
1006
+ if search_results:
1007
+ answer = (
1008
+ f"<strong>📚 KU Library starting points on {_escape(topic)}</strong><br><br>"
1009
+ "I couldn't build a cited abstract summary right now, so I've pulled KU Library results you can open immediately. "
1010
+ f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1011
+ )
1012
+ else:
1013
+ answer = (
1014
+ f"I couldn't retrieve results for <strong>{_escape(topic)}</strong> right now.<br><br>"
1015
+ f"<strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1016
+ )
1017
+ answer += _source_badge("primo", source_url)
1018
+ return answer, search_results, []
1019
+
1020
+
1021
+ def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
1022
+ topic = _escape(context.display_topic or context.topic)
1023
+ resource = {
1024
+ "books": "books",
1025
+ "articles": "articles",
1026
+ "both": "articles and books",
1027
+ }.get(context.resource_type, "articles")
1028
+ location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
1029
+ if is_follow_up:
1030
+ return f"I updated your previous search on <strong>{topic}</strong> in the <strong>{location}</strong> and looked for <strong>{resource}</strong>."
1031
+ return f"I searched the <strong>{location}</strong> for <strong>{resource}</strong> on <strong>{topic}</strong>."
1032
+
1033
+
1034
+ async def _run_search_mode(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> Tuple[str, List[dict], List[dict], str]:
1035
+ context = await _prepare_queries(question, context, model, is_follow_up)
1036
+ filters = _filters_summary(context)
1037
+
1038
+ if context.source == "pubmed":
1039
+ result, source_url = await _search_pubmed_articles(context, limit=6)
1040
+ source_name = "pubmed"
1041
+ else:
1042
+ result, source_url = await _search_primo_catalog(context, limit=6)
1043
+ source_name = "primo"
1044
+
1045
+ search_results = result.get("results", []) or []
1046
+ total = result.get("total", len(search_results))
1047
+ answer = _search_answer_intro(context, is_follow_up)
1048
+ if filters:
1049
+ answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1050
+
1051
+ if search_results:
1052
+ answer += f"<br><br>I found <strong>{total}</strong> result(s). The top items are shown below."
1053
+ else:
1054
  answer += (
1055
+ "<br><br>I couldn't retrieve matching results right now. "
1056
+ "You can still try the direct library links and AI research tools below."
 
 
1057
  )
 
1058
 
1059
+ if context.intent == "search_medical" and context.source != "pubmed":
1060
+ answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1061
+
1062
+ answer += _source_badge(source_name, source_url)
1063
+ sources = []
1064
+ for item in search_results[:6]:
1065
+ link = item.get("primo_url") or item.get("link")
1066
+ if link:
1067
+ sources.append({"title": item.get("title", "Result"), "url": link})
1068
+ return answer, search_results, sources, source_url
1069
 
 
 
 
1070
 
1071
+ def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
1072
+ q = (question or "").lower()
1073
+ if _looks_library_hours_question(question):
1074
+ return (
1075
+ "Would you like the library contact details too, or should I show you how to check the live hours page for updates?",
1076
+ [
1077
+ {"label": "Show the library contact details", "question": "Show the library contact details"},
1078
+ {"label": "Open the live library hours page", "question": "Open the live library hours page"},
1079
+ ],
1080
  )
1081
+ if "study room" in q or "room" in q:
1082
+ return (
1083
+ "Would you like the study-room booking link, or the general library services for both campuses?",
1084
+ [
1085
+ {"label": "Show the study-room booking link", "question": "Show the study-room booking link"},
1086
+ {"label": "Show library services for both campuses", "question": "Show library services for both campuses"},
1087
+ ],
1088
  )
1089
+ if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
1090
+ return (
1091
+ "Would you like me to recommend the best KU databases for this topic, or show how to access them off campus?",
1092
+ [
1093
+ {"label": "Recommend the best KU databases for this topic", "question": "Recommend the best KU databases for this topic"},
1094
+ {"label": "How do I access databases off campus?", "question": "How do I access databases off campus?"},
1095
+ ],
 
 
 
 
 
 
1096
  )
1097
+ return (
1098
+ "Would you like a related library service, a staff contact, or the direct KU page for this topic?",
1099
+ [
1100
+ {"label": "Show the best staff contact", "question": "Show the best staff contact for this"},
1101
+ {"label": "Open the KU library page for this topic", "question": "Open the KU library page for this topic"},
1102
+ ],
1103
  )
 
1104
 
 
 
1105
 
1106
+ def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
1107
+ return (
1108
+ "Would you like a brief explanation, current web information, or KU Library resources on this topic?",
1109
+ [
1110
+ {"label": "Give me a shorter explanation", "question": f"Give me a shorter explanation of {question}"},
1111
+ {"label": "Show current web information", "question": f"Show current web information on {question}"},
1112
+ {"label": "Find KU Library resources on this topic", "question": f"Find KU Library resources on {question}"},
1113
+ ],
1114
+ )
1115
+
1116
+
1117
+ def _search_follow_up(context: SearchContextPayload, summary_mode: bool = False) -> Tuple[str, List[dict]]:
1118
+ topic = context.display_topic or context.topic
1119
+ suggestions: List[dict] = []
1120
+ question: str
1121
+
1122
+ if summary_mode:
1123
+ question = f"Would you like me to search KU-accessible sources on {topic}, narrow the snapshot, or point you to the best deep-research tool?"
1124
+ suggestions.append({"label": f"Search KU-accessible articles on {topic}", "question": f"Find articles on {topic}", "action": "articles_only", "context_id": context.context_id})
1125
+ suggestions.append({"label": f"Narrow {topic} to the last 5 years", "question": f"Limit {topic} to the last 5 years", "action": "last_5_years", "context_id": context.context_id})
1126
+ suggestions.append({"label": f"Which deep-research tool is best for {topic}?", "question": f"Which deep-research tool is best for {topic}?", "action": "deep_research_tools", "context_id": context.context_id})
1127
+ return question, suggestions
1128
+
1129
+ question = f"Would you like me to refine the {topic} search, switch source, or turn it into a short research snapshot?"
1130
+ if not context.peer_reviewed and context.source == "primo":
1131
+ suggestions.append({"label": f"Make the {topic} search peer reviewed only", "question": f"Make the {topic} search peer reviewed only", "action": "peer_reviewed_only", "context_id": context.context_id})
1132
+ if not (context.year_from and context.year_to == str(CURRENT_YEAR)):
1133
+ suggestions.append({"label": f"Limit {topic} to the last 5 years", "question": f"Limit {topic} to the last 5 years", "action": "last_5_years", "context_id": context.context_id})
1134
+ if context.resource_type != "books":
1135
+ suggestions.append({"label": f"Show books on {topic}", "question": f"Show books on {topic}", "action": "books_only", "context_id": context.context_id})
1136
+ if context.intent == "search_medical" and context.source != "pubmed":
1137
+ suggestions.append({"label": f"Search PubMed for {topic}", "question": f"Search PubMed for {topic}", "action": "search_pubmed", "context_id": context.context_id})
1138
+ suggestions.append({"label": f"Give me a brief research snapshot on {topic}", "question": f"Give me a brief research snapshot on {topic}", "action": "summarize_topic", "context_id": context.context_id})
1139
+ return question, suggestions[:4]
1140
+
1141
+
1142
+ def _social_follow_up() -> Tuple[str, List[dict]]:
1143
+ return (
1144
+ "What would you like help with next — finding articles, checking a library service, or contacting a librarian?",
1145
+ [
1146
+ {"label": "Find articles on a topic", "question": "Find articles on a topic"},
1147
+ {"label": "Check a library service", "question": "Check a library service"},
1148
+ {"label": "Contact a librarian", "question": "Contact a librarian"},
1149
+ ],
1150
+ )
1151
+
1152
+
1153
+ def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
1154
+ dedup: Dict[str, SearchContextPayload] = {}
1155
+ for ctx in contexts:
1156
+ dedup[ctx.context_id] = ctx
1157
+ ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
1158
+ return ordered[:5]
1159
 
 
1160
 
1161
  @router.post("", response_model=AgentResponse)
1162
  async def agent_query(request: Request, req: AgentRequest):
1163
  start_time = time.time()
1164
+ question = _normalize_whitespace(req.question)
1165
  history = req.history
1166
  model = req.model
1167
+ client_state = req.client_state
1168
 
1169
  if not question:
1170
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1171
  return AgentResponse(
1172
+ answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"),
1173
  intent="social",
1174
  model_used=model,
1175
  response_time=time.time() - start_time,
1176
+ follow_up_question=follow_up_question,
1177
+ follow_up_suggestions=follow_up_suggestions,
1178
  )
1179
 
 
 
 
1180
  if PURE_GREETING_RE.match(question):
1181
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1182
  return AgentResponse(
1183
  answer=(
1184
  "Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>"
1185
+ "I can help you find articles and books, search databases, access full text, request Interlibrary Loan (ILL), "
1186
+ "and answer questions about library services and staff."
 
1187
  + _source_badge("libbee")
1188
  ),
1189
  intent="social_greeting",
1190
  model_used=model,
1191
  response_time=time.time() - start_time,
1192
+ follow_up_question=follow_up_question,
1193
+ follow_up_suggestions=follow_up_suggestions,
1194
  )
1195
 
 
1196
  staff_match = match_staff_name(question)
1197
  if staff_match:
1198
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1199
  return AgentResponse(
1200
  answer=staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1201
  intent="library_info",
1202
  tools_used=["staff_name_match"],
1203
  model_used=model,
1204
  response_time=time.time() - start_time,
1205
+ follow_up_question=follow_up_question,
1206
+ follow_up_suggestions=follow_up_suggestions,
1207
  )
1208
 
 
1209
  staff_role_match = match_staff_role(question)
1210
  if staff_role_match:
1211
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1212
  return AgentResponse(
1213
  answer=staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1214
  intent="library_info",
1215
  tools_used=["staff_role_match"],
1216
  model_used=model,
1217
  response_time=time.time() - start_time,
1218
+ follow_up_question=follow_up_question,
1219
+ follow_up_suggestions=follow_up_suggestions,
1220
  )
1221
 
 
1222
  if _is_greeting_menu_followup(question, history):
1223
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1224
  return AgentResponse(
1225
  answer=_greeting_menu_clarify_answer() + _source_badge("libbee"),
1226
  intent="social_greeting",
1227
  is_follow_up=True,
1228
  model_used=model,
1229
  response_time=time.time() - start_time,
1230
+ follow_up_question=follow_up_question,
1231
+ follow_up_suggestions=follow_up_suggestions,
1232
  )
1233
 
 
1234
  if _looks_library_hours_question(question):
1235
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1236
  return AgentResponse(
1237
  answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL),
1238
  intent="library_info",
 
1240
  ask_librarian=False,
1241
  model_used=model,
1242
  response_time=time.time() - start_time,
1243
+ follow_up_question=follow_up_question,
1244
+ follow_up_suggestions=follow_up_suggestions,
1245
  )
1246
 
 
1247
  if _looks_campus_question(question):
1248
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1249
  return AgentResponse(
1250
  answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1251
  intent="library_info",
 
1253
  ask_librarian=False,
1254
  model_used=model,
1255
  response_time=time.time() - start_time,
1256
+ follow_up_question=follow_up_question,
1257
+ follow_up_suggestions=follow_up_suggestions,
1258
  )
1259
 
1260
+ is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
1261
+
1262
+ if is_follow_up and base_context:
1263
+ context = _apply_follow_up_action(base_context, follow_up_action)
1264
+ if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
1265
+ context = await _prepare_queries(question, context, model, True)
1266
+ answer = (
1267
+ f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
1268
+ f"{_database_guidance_for_topic(context)}"
1269
+ )
1270
+ if follow_up_action == "summarize_topic":
1271
+ snapshot_answer, search_results, sources = await _research_snapshot(context, model)
1272
+ answer = snapshot_answer
1273
+ else:
1274
+ answer += _source_badge("libbee")
1275
+ search_results = []
1276
+ sources = []
1277
+ follow_up_question, follow_up_suggestions = _search_follow_up(context, summary_mode=True)
1278
+ return AgentResponse(
1279
+ answer=answer,
1280
+ intent=context.intent,
1281
+ tools_used=["follow_up_resolution", follow_up_action],
1282
+ search_results=search_results,
1283
+ sources=sources,
1284
+ model_used=model,
1285
+ response_time=time.time() - start_time,
1286
+ corrected_query=context.topic,
1287
+ natural_query=context.ai_tool_query,
1288
+ database_query=context.primo_boolean_query,
1289
+ ai_tool_query=context.ai_tool_query,
1290
+ primo_boolean_query=context.primo_boolean_query,
1291
+ primo_search_url=_primo_url_from_boolean(context.primo_boolean_query or _make_primo_boolean(context.topic)),
1292
+ follow_up_question=follow_up_question,
1293
+ follow_up_suggestions=follow_up_suggestions,
1294
+ search_context=_context_to_dict(context),
1295
+ is_follow_up=True,
1296
+ )
1297
+
1298
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
1299
+ follow_up_question, follow_up_suggestions = _search_follow_up(context)
1300
+ return AgentResponse(
1301
+ answer=answer,
1302
+ intent=context.intent,
1303
+ tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
1304
+ search_results=search_results,
1305
+ sources=sources,
1306
+ model_used=model,
1307
+ response_time=time.time() - start_time,
1308
+ corrected_query=context.topic,
1309
+ natural_query=context.ai_tool_query,
1310
+ database_query=context.primo_boolean_query,
1311
+ ai_tool_query=context.ai_tool_query,
1312
+ primo_boolean_query=context.primo_boolean_query,
1313
+ primo_search_url=source_url,
1314
+ follow_up_question=follow_up_question,
1315
+ follow_up_suggestions=follow_up_suggestions,
1316
+ search_context=_context_to_dict(context),
1317
+ is_follow_up=True,
1318
+ )
1319
 
1320
  cls_result = await _llm_classify(question, history, model)
1321
  intent = cls_result.get("intent", "general")
1322
  casual_hint = cls_result.get("casual_answer", "")
1323
 
 
 
1324
  if intent == "social":
1325
+ answer = casual_hint if casual_hint and len(casual_hint.strip()) > 20 else await _libbee_casual_response(question, history, model, casual_hint)
1326
+ follow_up_question, follow_up_suggestions = _social_follow_up()
1327
  return AgentResponse(
1328
  answer=answer + _source_badge("libbee"),
1329
  intent="social_greeting",
1330
  tools_used=["libbee_casual"],
1331
  model_used=model,
1332
  response_time=time.time() - start_time,
1333
+ follow_up_question=follow_up_question,
1334
+ follow_up_suggestions=follow_up_suggestions,
1335
  )
1336
 
1337
+ if intent == "library_info":
1338
  try:
1339
  from app import rag_service
1340
  rag_results = await rag_service.hybrid_search(question, top_k=4)
 
1347
  "I couldn't find specific information on that in my knowledge base.<br><br>"
1348
  "Please contact our librarians directly:<br>"
1349
  '📧 <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
1350
+ f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
1351
  )
1352
+ answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
 
1353
  sources = []
1354
+ tools_used = ["rag_no_results"]
1355
  else:
1356
  answer = await _rag_answer(question, rag_results, history, model)
1357
  answer += _source_badge("rag", "https://library.ku.ac.ae/")
1358
+ sources = [{"title": r.get("title", "Library KB"), "url": r.get("source", "")} for r in rag_results if r.get("source")]
1359
  tools_used = ["rag_hybrid_search"]
 
 
 
 
1360
 
1361
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
1362
  return AgentResponse(
1363
  answer=answer,
1364
  intent="library_info",
 
1367
  ask_librarian=not bool(rag_results),
1368
  model_used=model,
1369
  response_time=time.time() - start_time,
1370
+ follow_up_question=follow_up_question,
1371
+ follow_up_suggestions=follow_up_suggestions,
1372
  )
1373
 
1374
+ if intent in ("search_academic", "search_medical"):
 
1375
  topic = await _extract_topic(question, model)
1376
+ topic = _title_case_topic(topic)
1377
+ year_from, year_to = _parse_year_filters(question)
1378
+ context = SearchContextPayload(
1379
+ context_id=str(uuid.uuid4()),
1380
+ topic=topic,
1381
+ display_topic=topic,
1382
+ intent=intent,
1383
+ source="primo",
1384
+ resource_type=_derive_resource_type(question),
1385
+ peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
1386
+ open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
1387
+ year_from=year_from,
1388
+ year_to=year_to,
1389
+ )
1390
 
1391
+ context = await _prepare_queries(question, context, model, False)
1392
+ summary_mode = _is_summary_request(question)
1393
+ deep_research_mode = bool(re.search(r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b", question, re.IGNORECASE))
1394
+
1395
+ if summary_mode or deep_research_mode:
1396
+ answer, search_results, sources = await _research_snapshot(context, model)
1397
+ if deep_research_mode and "Research guide" not in answer:
1398
+ answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1399
+ follow_up_question, follow_up_suggestions = _search_follow_up(context, summary_mode=True)
1400
+ return AgentResponse(
1401
+ answer=answer,
1402
+ intent=intent,
1403
+ tools_used=["research_snapshot"],
1404
+ search_results=search_results,
1405
+ sources=sources,
1406
+ model_used=model,
1407
+ response_time=time.time() - start_time,
1408
+ corrected_query=context.topic,
1409
+ natural_query=context.ai_tool_query,
1410
+ database_query=context.primo_boolean_query,
1411
+ ai_tool_query=context.ai_tool_query,
1412
+ primo_boolean_query=context.primo_boolean_query,
1413
+ primo_search_url=_primo_url_from_boolean(context.primo_boolean_query or _make_primo_boolean(context.topic)),
1414
+ follow_up_question=follow_up_question,
1415
+ follow_up_suggestions=follow_up_suggestions,
1416
+ search_context=_context_to_dict(context),
1417
  )
 
 
1418
 
1419
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
1420
+ follow_up_question, follow_up_suggestions = _search_follow_up(context)
1421
  return AgentResponse(
1422
  answer=answer,
1423
  intent=intent,
1424
+ tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
1425
  search_results=search_results,
1426
+ sources=sources,
 
 
1427
  model_used=model,
1428
  response_time=time.time() - start_time,
1429
+ corrected_query=context.topic,
1430
+ natural_query=context.ai_tool_query,
1431
+ database_query=context.primo_boolean_query,
1432
+ ai_tool_query=context.ai_tool_query,
1433
+ primo_boolean_query=context.primo_boolean_query,
1434
+ primo_search_url=source_url,
1435
+ follow_up_question=follow_up_question,
1436
+ follow_up_suggestions=follow_up_suggestions,
1437
+ search_context=_context_to_dict(context),
1438
  )
1439
 
1440
+ if intent == "general_recent":
1441
+ answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
1442
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
1443
  return AgentResponse(
1444
  answer=answer,
1445
  intent="general_recent",
1446
  tools_used=["web_search"],
1447
  model_used=model,
1448
  response_time=time.time() - start_time,
1449
+ follow_up_question=follow_up_question,
1450
+ follow_up_suggestions=follow_up_suggestions,
1451
  )
1452
 
1453
+ answer = await _web_search_answer(question, history, model) + _source_badge("web")
1454
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
1455
+ return AgentResponse(
1456
+ answer=answer,
1457
+ intent="general",
1458
+ tools_used=["web_search"],
1459
+ model_used=model,
1460
+ response_time=time.time() - start_time,
1461
+ follow_up_question=follow_up_question,
1462
+ follow_up_suggestions=follow_up_suggestions,
1463
+ )
1464
 
 
1465
 
1466
  @router.get("/test")
1467
  async def test():
src/api/feedback.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import List
3
+
4
+ from fastapi import APIRouter
5
+ from pydantic import BaseModel
6
+
7
+ router = APIRouter()
8
+
9
+
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"}