nikeshn commited on
Commit
f2d4cec
·
verified ·
1 Parent(s): 056486b

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +86 -1904
app.py CHANGED
@@ -1,1945 +1,127 @@
 
 
1
  """
2
- agent.py — LibBee v3 (fixed)
3
-
4
- Fixes applied:
5
- 1. _extract_topic() — explicitly strips resource-type words (articles/books/papers)
6
- from LLM output and regex fallback. Topic is ALWAYS clean keywords only.
7
-
8
- 2. _make_primo_discovery_query() — removed all resource-type words from boolean.
9
- Boolean is now: "keyword1 keyword2 keyword3" only.
10
- Resource type handled exclusively via SearchRequest.resource_type → PRIMO facet.
11
-
12
- 3. _search_primo_catalog() — now passes resource_type=context.resource_type so
13
- the rtype FACET is correctly sent to PRIMO.
14
-
15
- 4. _search_pubmed_articles() — year_from/year_to now wired through SearchRequest,
16
- fixed in search.py to use mindate/maxdate params (not keywords).
17
-
18
- 5. _search_follow_up() — now calls LLM to generate 3 topic-specific intellectual
19
- follow-up questions (related subtopics, methodological angles, applications).
20
- Generic filter options remain as a secondary set after the topic-specific ones.
21
-
22
- 6. _primo_url_from_boolean() — now uses clean topic (not polluted boolean) for the
23
- displayed PRIMO URL, matching exactly what the API actually searches.
24
-
25
- 7. _generate_cited_summary() / Semantic Scholar — improved: relevance filter on
26
- abstracts, better fallback, clearer citation replacement.
27
- """
28
- import html
29
- import json
30
  import logging
31
- import re
32
  import time
33
- import uuid
 
34
  from datetime import datetime
35
- from typing import Any, Dict, List, Optional, Tuple
36
- from urllib.parse import quote
37
 
38
- import httpx
39
- from fastapi import APIRouter, Request
40
- from pydantic import BaseModel, ConfigDict, Field
41
 
42
  from src.config import get_settings
43
- from src.services.staff_service import (
44
- STAFF_DIRECTORY,
45
- match_staff_name,
46
- match_staff_role,
47
- should_attempt_staff_lookup,
48
- staff_name_answer,
49
- staff_role_answer,
50
- )
51
 
 
52
  logger = logging.getLogger(__name__)
53
- router = APIRouter()
54
-
55
-
56
- # ── Pydantic Models ───────────────────────────────────────────────────────────
57
-
58
- class ChatMessage(BaseModel):
59
- role: str
60
- content: str
61
-
62
-
63
- class SearchContextPayload(BaseModel):
64
- context_id: str
65
- topic: str
66
- display_topic: Optional[str] = None
67
- intent: str = "search_academic"
68
- source: str = "primo"
69
- resource_type: str = "articles" # "articles" | "books" | "both"
70
- peer_reviewed: bool = False
71
- open_access: bool = False
72
- year_from: Optional[str] = None
73
- year_to: Optional[str] = None
74
- ai_tool_query: Optional[str] = None
75
- primo_boolean_query: Optional[str] = None
76
- created_at: float = Field(default_factory=time.time)
77
-
78
-
79
- class ClientStatePayload(BaseModel):
80
- session_id: Optional[str] = None
81
- active_search_context_id: Optional[str] = None
82
- recent_search_contexts: List[SearchContextPayload] = Field(default_factory=list)
83
- follow_up_action: Optional[str] = None
84
- follow_up_context_id: Optional[str] = None
85
-
86
-
87
- class AgentRequest(BaseModel):
88
- question: str
89
- model: str = "gpt"
90
- history: List[ChatMessage] = Field(default_factory=list)
91
- client_state: Optional[ClientStatePayload] = None
92
-
93
-
94
- class AgentResponse(BaseModel):
95
- model_config = ConfigDict(extra="ignore")
96
-
97
- answer: str
98
- intent: str = "general"
99
- tools_used: List[str] = Field(default_factory=list)
100
- search_results: List[dict] = Field(default_factory=list)
101
- sources: List[dict] = Field(default_factory=list)
102
- model_used: str = "gpt"
103
- response_time: float = 0
104
- corrected_query: Optional[str] = None
105
- natural_query: Optional[str] = None
106
- database_query: Optional[str] = None
107
- ask_librarian: Optional[bool] = None
108
- is_follow_up: bool = False
109
- follow_up_question: Optional[str] = None
110
- follow_up_suggestions: List[dict] = Field(default_factory=list)
111
- search_context: Optional[dict] = None
112
- ai_tool_query: Optional[str] = None
113
- primo_boolean_query: Optional[str] = None
114
- primo_search_url: Optional[str] = None
115
- primo_ai_url: Optional[str] = None
116
- pubmed_search_url: Optional[str] = None
117
- contact_html: Optional[str] = None
118
-
119
-
120
- # ── Constants ─────────────────────────────────────────────────────────────────
121
-
122
- LIBRARY_HOURS_URL = "https://library.ku.ac.ae/hours"
123
- KU_MAIN_URL = "https://www.ku.ac.ae/"
124
- ASK_LIBRARIAN_URL = "https://library.ku.ac.ae/AskUs"
125
- PRIMO_BASE_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU"
126
- PRIMO_AI_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/researchAssistant?vid=971KUOSTAR_INST:KU&query="
127
- HISTORY_WINDOW = 6
128
- CURRENT_YEAR = datetime.utcnow().year
129
-
130
- # Words that must never appear in a PRIMO/PubMed keyword query — they are filter signals
131
- _RESOURCE_TYPE_NOISE = re.compile(
132
- r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
133
- r"|peer[- ]reviewed|open[- ]access|studies|study|research)\b",
134
- re.IGNORECASE,
135
- )
136
-
137
- SOCIAL_RE = re.compile(
138
- r"^(hi|hello|hey|good morning|good afternoon|good evening|how are you|hows it going"
139
- r"|how are you doing|who are you|what are you|tell me about yourself|what can you do"
140
- r"|what do you do|how can you help|what help can you provide|are you a bot|are you a robot"
141
- r"|are you ai|are you an ai|thank you|thanks|thx|bye|goodbye|see you|lol|haha"
142
- r"|you are silly|are you silly|are you dumb|stupid bot|dumb bot|idiot bot"
143
- r"|joke|tell me a joke)$",
144
- re.IGNORECASE,
145
- )
146
- PURE_GREETING_RE = re.compile(
147
- r"^(hi|hello|hey|good morning|good afternoon|good evening)[!.\s]*$",
148
- re.IGNORECASE,
149
- )
150
- GREETING_FOLLOWUP_RE = re.compile(
151
- r"^(yes|yes please|yes check|sure|okay|ok|yeah|yep|please do|go ahead)$",
152
- re.IGNORECASE,
153
- )
154
- HOURS_RE = re.compile(
155
- r"\b(library hours?|opening hours?|closing hours?|open hours?|open today|open tomorrow"
156
- r"|open now|open on|open this|closed today|closed tomorrow|closed on|closed this"
157
- r"|when.*open|when.*close|when.*does.*library|what time.*open|what time.*close"
158
- r"|what time.*library|opening time|closing time|is.*library.*open|is.*campus.*library"
159
- r"|is.*habshan.*open|is.*san.*open|is.*library.*closed|will.*library.*open"
160
- r"|will.*it.*open|does.*library.*open|library.*open.*today|library.*close.*today"
161
- r"|habshan.*open|san.*open|main campus.*open|ramadan hours?|weekend hours?"
162
- r"|friday hours?|saturday hours?|sunday hours?|library.*schedule|schedule.*library"
163
- r"|what.*hours|till.*when|until.*when|what day.*open|which day.*open"
164
- r"|what day.*closed|which day.*closed)\b",
165
- re.IGNORECASE,
166
- )
167
- KU_CAMPUS_RE = re.compile(
168
- r"\b(where is the library|where is.*library|library.*location|location.*library"
169
- r"|which building|what building|which floor|main campus library|san campus library"
170
- r"|habshan library|sas al nakhl library|library.*address|address.*library"
171
- r"|how to get to.*library|how.*reach.*library|how.*find.*library"
172
- r"|library.*campus(es)?|campus(es)?.*library|how many.*librar|how many.*branch"
173
- r"|library.*branch(es)?|branch(es)?.*library|which campus.*library"
174
- r"|library.*which campus|library.*map|map.*library|how many libraries"
175
- r"|what libraries|which libraries|(ku|khalifa).*(has|have|got).*(librar|branch)"
176
- r"|(librar|branch).*(ku|khalifa).*(has|have)"
177
- r"|(all|both|two|2).*(libraries|library|branches)"
178
- r"|tell me about.*(campus|libraries|branches)|describe.*(campus|libraries)"
179
- r"|(what|where).*(campus|campuses).*(librar))\b",
180
- re.IGNORECASE,
181
- )
182
- RESEARCH_CUE_RE = re.compile(
183
- r"\b(find|search|look for|show me|get me|give me|locate|recommend|suggest"
184
- r"|articles?|papers?|books?|journals?|studies|literature|research|systematic review"
185
- r"|evidence|sources|database|databases|peer reviewed|open access|advances?"
186
- r"|approach(es)?|method(s|ology)?|technique[s]?|application[s]?|framework[s]?"
187
- r"|model[s]?|algorithm[s]?|publication[s]?|recent work|current state|state of the art)\b",
188
- re.IGNORECASE,
189
- )
190
- LIBRARY_CUE_RE = re.compile(
191
- r"\b(library|librarian|borrow|loan|renew|fine|study room|room booking|reserve a room"
192
- r"|account|my library|interlibrary|ill|khazna|orcid|open access|apc|refworks"
193
- r"|libkey|hours|location|contact|visitor|alumni|database access|off campus|remote access)\b",
194
- re.IGNORECASE,
195
- )
196
- MEDICAL_SEARCH_RE = re.compile(
197
- r"\b(pubmed|embase|cinahl|clinicalkey|cochrane|uptodate|medline|systematic review"
198
- r"|clinical trial|biomedical literature|medical literature)\b",
199
- re.IGNORECASE,
200
- )
201
- CURRENT_INFO_RE = re.compile(
202
- r"\b(current|currently|latest|today|recent|recently|now|this week|this month"
203
- r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
204
- re.IGNORECASE,
205
- )
206
- DATABASE_RECOMMENDATION_RE = re.compile(
207
- r"\b(best database|best databases|which database|which databases|recommended databases?|database should i use|databases should i use|database for|databases for)\b",
208
- re.IGNORECASE,
209
- )
210
- SUMMARY_RE = re.compile(
211
- r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
212
- r"|tell me about|give me a summary|give me an overview|give me a brief"
213
- r"|brief (summary|overview|explanation|description)"
214
- r"|research (overview|summary|landscape|state)"
215
- r"|state of (the art|research|the field)"
216
- r"|recent (advances?|developments?|progress|trends?)"
217
- r"|current (state|trends?|advances?|research)"
218
- r"|deep analysis|analysis of|review of|literature review|deep research|deep dive"
219
- r"|what do we know about|how does|how do)\b",
220
- re.IGNORECASE,
221
- )
222
-
223
- MEDICAL_KEYWORDS = [
224
- "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
225
- "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
226
- "genomics", "pathology", "surgery", "mental health", "nutrition", "public health",
227
- "disease", "therapy", "treatment", "diagnosis", "patient", "drug", "pharmaceutical",
228
- "hospital", "cancer", "diabetes", "heart", "brain", "cell biology", "immunology",
229
- "biochemistry", "microbiology", "virology", "pediatric", "psychiatric", "dental",
230
- "ophthalmology", "radiology", "anesthesia", "emergency medicine", "geriatric",
231
- "crispr", "gene therapy", "gene editing", "genomic medicine", "precision medicine",
232
- ]
233
-
234
- REFINEMENT_STOP_WORDS = {
235
- "make", "it", "them", "those", "these", "the", "a", "an", "for", "to", "only", "show",
236
- "find", "give", "me", "please", "and", "with", "within", "limit", "narrow", "search",
237
- "results", "on", "about", "of", "this", "that", "topic", "same", "using", "from",
238
- "peer", "reviewed", "review", "open", "access", "books", "book", "articles", "article",
239
- "papers", "paper", "years", "year", "last", "past", "recent", "instead", "summary",
240
- "overview", "brief", "pubmed", "primo", "database", "databases", "best", "deep", "research",
241
- "tools", "tool", "guide", "ku", "library",
242
- }
243
-
244
- _CLASSIFIER_SYSTEM = """You are the routing brain of LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE).
245
- Your only job: read the user message and return a single JSON object routing it to the right handler.
246
-
247
- INTENTS — pick exactly one:
248
- "social" — casual chat, greetings, emotions, compliments, complaints, vague help requests
249
- "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
250
- "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
251
- "search_medical" — same as search_academic but topic is medical/clinical/nursing/pharmacy/health sciences
252
- "general_recent" — questions about TRULY live/real-time data: breaking news, live sports scores, today's weather, current stock prices
253
- "general" — ALL other factual questions: university info, people, history, science definitions, technology explanations
254
-
255
- RULES:
256
- 1. When in doubt between library_info and social, choose library_info if ANY library keyword is present.
257
- 2. "I don't know how to search for articles" = library_info.
258
- 3. "I am fed up with searching" alone = social.
259
- 4. "I need help finding articles on diabetes" = search_medical.
260
- 5. KU = Khalifa University. Do NOT confuse with Kuwait University.
261
- 6. ANY summary, overview, analysis, deep dive, review, or explanation of an academic/research topic = search_academic or search_medical.
262
- 7. Questions about people, university leadership, history, science definitions = general.
263
- 8. Only use general_recent for truly live data changing by the minute.
264
-
265
- For "social" intent ONLY: include "casual_answer" — a warm 1-3 sentence LibBee response (no markdown, no bullets).
266
- For all other intents: omit "casual_answer".
267
-
268
- Respond ONLY with valid JSON. No preamble, no markdown fences.
269
- """
270
-
271
-
272
- # ── Utilities ─────────────────────────────────────────────────────────────────
273
-
274
- def _escape(text: str) -> str:
275
- return html.escape(text or "")
276
-
277
-
278
- def _normalize_whitespace(text: str) -> str:
279
- return re.sub(r"\s+", " ", (text or "").strip())
280
-
281
-
282
- def _title_case_topic(topic: str) -> str:
283
- return _normalize_whitespace(topic).strip().strip(".?")
284
-
285
-
286
- def _strip_resource_noise(text: str) -> str:
287
- """
288
- Remove resource-type and filter words that are PRIMO/PubMed facets, not q= keywords.
289
- Belt-and-braces: called on every topic before it touches an external search API.
290
- """
291
- cleaned = _RESOURCE_TYPE_NOISE.sub(" ", text or "")
292
- # Clean up dangling boolean operators
293
- cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
294
- cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
295
- cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
296
- return _normalize_whitespace(cleaned)
297
-
298
-
299
- def _source_badge(source: str, url: str = "") -> str:
300
- styles = {
301
- "rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
302
- "primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
303
- "semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
304
- "pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
305
- "web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
306
- "web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
307
- "libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
308
- "ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
309
- "llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
310
- }
311
- icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
312
- inner = f"{icon} Source: {label}"
313
- if url:
314
- inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
315
- return (
316
- f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
317
- f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
318
- f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">{inner}</div>'
319
- )
320
-
321
-
322
- def _safe_metrics_increment(key: str) -> None:
323
- try:
324
- from app import metrics_service
325
- metrics_service.incr(key)
326
- except Exception:
327
- return
328
-
329
-
330
- def _safe_metrics_bucket(bucket: str, key: str) -> None:
331
- try:
332
- from app import metrics_service
333
- metrics_service.incr_bucket(bucket, key)
334
- except Exception:
335
- return
336
-
337
-
338
- def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
339
- msgs = []
340
- for m in history[-HISTORY_WINDOW:]:
341
- if m.role in ("user", "assistant") and m.content:
342
- msgs.append({"role": m.role, "content": m.content[:300]})
343
- return msgs
344
-
345
-
346
- def _is_summary_request(question: str) -> bool:
347
- return bool(SUMMARY_RE.search(question or ""))
348
-
349
-
350
- def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
351
- if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
352
- return False
353
- for m in reversed(history):
354
- if m.role == "assistant":
355
- last = m.content.lower()
356
- return "i'm libbee" in last or "are you looking for one of these" in last
357
- return False
358
-
359
-
360
- def _looks_library_hours_question(question: str) -> bool:
361
- q = (question or "").strip().lower()
362
- return bool(HOURS_RE.search(q)) and any(
363
- kw in q for kw in (
364
- "library", "campus", "habshan", "san", "hours", "open", "close",
365
- "schedule", "till", "until", "today", "tomorrow", "friday",
366
- "saturday", "sunday", "week", "ramadan",
367
- )
368
- )
369
-
370
-
371
- def _looks_campus_question(question: str) -> bool:
372
- return bool(KU_CAMPUS_RE.search(question or ""))
373
-
374
-
375
- def _looks_medical_search(question: str) -> bool:
376
- q = (question or "").lower()
377
- if not any(kw in q for kw in MEDICAL_KEYWORDS):
378
- return False
379
- return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
380
-
381
-
382
- def _looks_research_question(question: str) -> bool:
383
- q = question or ""
384
- return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
385
-
386
 
387
- def _rule_based_classify(question: str) -> Dict[str, str]:
388
- q = (question or "").lower()
389
- if SOCIAL_RE.match((question or "").strip()):
390
- return {
391
- "intent": "social",
392
- "casual_answer": (
393
- "Hello! I'm LibBee, the KU Library AI Assistant. "
394
- "I'm here to help you with articles, books, databases, and library services. "
395
- "What would you like to find today?"
396
- ),
397
- }
398
- if _looks_medical_search(question):
399
- return {"intent": "search_medical"}
400
- if _looks_research_question(question):
401
- return {"intent": "search_academic"}
402
- if LIBRARY_CUE_RE.search(q):
403
- return {"intent": "library_info"}
404
- if CURRENT_INFO_RE.search(q):
405
- return {"intent": "general_recent"}
406
- return {"intent": "general"}
407
 
408
 
409
- def _hours_answer() -> str:
410
- return (
411
- "Here are the current <strong>Khalifa University Library</strong> hours:<br><br>"
412
- "<strong>Habshan Library (SAN Campus):</strong><br>"
413
- "Sunday – Wednesday: 8:00 AM – 7:00 PM<br>"
414
- "Thursday: 8:00 AM – 4:00 PM<br>"
415
- "Friday: Closed<br>"
416
- "Saturday: 12:00 PM – 8:00 PM<br><br>"
417
- "⚠️ Hours may change during exam periods, public holidays, and Ramadan. "
418
- "Always check the official Library Hours page for the latest times:<br>"
419
- f'<a href="{LIBRARY_HOURS_URL}" target="_blank">{LIBRARY_HOURS_URL}</a>'
420
- )
421
 
422
-
423
- def _campus_answer() -> str:
424
- return (
425
- "Khalifa University Library has <strong>two branches</strong>:<br><br>"
426
- "<strong>1. Main Campus Library</strong><br>"
427
- "Location: E Building, Main Campus, Abu Dhabi<br>"
428
- 'Reference desk: <a href="tel:+97123124604">+971 2 312 4604</a><br><br>'
429
- "<strong>2. Habshan Library (SAN Campus)</strong><br>"
430
- "Location: Building 5, Ground Floor &amp; First Floor, Sas Al Nakhl (SAN) Campus, Abu Dhabi<br>"
431
- 'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
432
- "Both branches offer study spaces, computer workstations, printing, scanning, "
433
- "and access to physical collections.<br><br>"
434
- f'For general library enquiries: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> '
435
- 'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
436
- )
437
-
438
-
439
- def _greeting_menu_clarify_answer() -> str:
440
- return (
441
- "Sure — what would you like help with?<br><br>"
442
- "You can choose one of these or type your question directly:<br>"
443
- "• Find articles and books<br>"
444
- "• Get full text for an article<br>"
445
- "• Submit an Interlibrary Loan (ILL) request<br>"
446
- "• Contact a librarian<br>"
447
- "• Check library hours"
448
- )
449
-
450
-
451
- # ── LLM Helpers ───────────────────────────────────────────────────────────────
452
-
453
- def _get_llm(model: str, temperature: float, max_tokens: int):
454
- settings = get_settings()
455
- if model == "claude" and settings.anthropic_api_key:
456
- from langchain_anthropic import ChatAnthropic
457
- return ChatAnthropic(
458
- model="claude-3-haiku-20240307",
459
- temperature=temperature,
460
- max_tokens=max_tokens,
461
- anthropic_api_key=settings.anthropic_api_key,
462
- )
463
- from langchain_openai import ChatOpenAI
464
- return ChatOpenAI(
465
- model="gpt-4o-mini",
466
- temperature=temperature,
467
- max_tokens=max_tokens,
468
- openai_api_key=settings.openai_api_key,
469
- )
470
-
471
-
472
- async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
473
- settings = get_settings()
474
- if not settings.openai_api_key:
475
- return _rule_based_classify(question)
476
- try:
477
- ctx_parts = [
478
- f"{m.role}: {m.content[:100]}"
479
- for m in history[-HISTORY_WINDOW:]
480
- if m.role in ("user", "assistant")
481
- ]
482
- ctx = " | ".join(ctx_parts) if ctx_parts else "none"
483
- llm = _get_llm(model, temperature=0, max_tokens=120)
484
- response = await llm.ainvoke([
485
- {"role": "system", "content": _CLASSIFIER_SYSTEM},
486
- {"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
487
- ])
488
- raw = response.content.strip()
489
- if raw.startswith("```"):
490
- raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
491
- result = json.loads(raw)
492
- intent = result.get("intent", "general")
493
- if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general"}:
494
- intent = "general"
495
- return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
496
- except Exception as e:
497
- logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
498
- return _rule_based_classify(question)
499
-
500
-
501
- async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
502
- if hint and len(hint.strip()) > 20:
503
- return hint.strip()
504
  settings = get_settings()
505
- if not settings.openai_api_key:
506
- return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
507
- try:
508
- llm = _get_llm(model, temperature=0.6, max_tokens=160)
509
- msgs = [{"role": "system", "content": (
510
- "You are LibBee, the Khalifa University Library AI Assistant. "
511
- "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
512
- "No markdown, no bullet points."
513
- )}]
514
- msgs.extend(_build_history_messages(history))
515
- msgs.append({"role": "user", "content": question})
516
- response = await llm.ainvoke(msgs)
517
- return response.content.strip()
518
- except Exception:
519
- return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
520
-
521
-
522
- async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
523
- settings = get_settings()
524
- if not settings.openai_api_key:
525
- return rag_results[0]["content"] if rag_results else ""
526
- context = "\n\n---\n\n".join(r["content"] for r in rag_results)
527
- system = (
528
- "You are LibBee, the Khalifa University Library AI Assistant. "
529
- "Answer the user's question using ONLY the context provided below. "
530
- "Be concise, friendly, and use HTML <br> for line breaks where needed. "
531
- f"If the context does not contain enough information, say so and suggest Ask a Librarian at {ASK_LIBRARIAN_URL}. "
532
- "Never invent facts or URLs.\n\n"
533
- f"CONTEXT:\n{context}"
534
- )
535
- try:
536
- llm = _get_llm(model, temperature=0.2, max_tokens=380)
537
- msgs = [{"role": "system", "content": system}]
538
- msgs.extend(_build_history_messages(history))
539
- msgs.append({"role": "user", "content": question})
540
- response = await llm.ainvoke(msgs)
541
- return response.content.strip()
542
- except Exception as e:
543
- logger.error(f"_rag_answer error: {e}")
544
- return (
545
- "I'm having trouble generating an answer right now. "
546
- f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
547
- )
548
-
549
-
550
- async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
551
- settings = get_settings()
552
- if not settings.openai_api_key:
553
- return (
554
- "I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
555
- f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
556
- )
557
- system = (
558
- "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
559
- "Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
560
- "KU means Khalifa University. If the topic is academic, briefly offer to help find library resources."
561
- )
562
- try:
563
- llm = _get_llm(model, temperature=0.3, max_tokens=360)
564
- msgs = [{"role": "system", "content": system}]
565
- msgs.extend(_build_history_messages(history))
566
- msgs.append({"role": "user", "content": question})
567
- response = await llm.ainvoke(msgs)
568
- return response.content.strip()
569
- except Exception as e:
570
- logger.error(f"_llm_general_answer error: {e}")
571
- return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
572
-
573
-
574
- async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
575
- settings = get_settings()
576
- if model == "claude" and settings.anthropic_api_key:
577
- try:
578
- import anthropic
579
- client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
580
- response = client.messages.create(
581
- model="claude-3-haiku-20240307",
582
- max_tokens=520,
583
- system=(
584
- "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
585
- "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
586
- ),
587
- tools=[{"type": "web_search_20250305", "name": "web_search"}],
588
- messages=[{"role": "user", "content": question}],
589
- )
590
- text = "".join(block.text for block in response.content if hasattr(block, "text"))
591
- if text.strip():
592
- return text.strip()
593
- except Exception as e:
594
- logger.warning(f"Claude web search failed: {e}")
595
 
596
  if settings.openai_api_key:
597
- try:
598
- from openai import OpenAI
599
- client = OpenAI(api_key=settings.openai_api_key)
600
- response = client.responses.create(
601
- model="gpt-4o-mini",
602
- tools=[{"type": "web_search_preview"}],
603
- instructions=(
604
- "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
605
- "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
606
- ),
607
- input=question,
608
- )
609
- text = ""
610
- for item in response.output:
611
- if hasattr(item, "content"):
612
- for block in item.content:
613
- if hasattr(block, "text"):
614
- text += block.text
615
- if text.strip():
616
- return text.strip()
617
- except Exception as e:
618
- logger.warning(f"GPT web search failed: {e}")
619
-
620
- return await _llm_general_answer(question, history, model)
621
-
622
-
623
- # ── Topic Extraction ──────────────────────────────────────────────────────────
624
-
625
- def _extract_topic_regex(question: str) -> str:
626
- """Fast regex-based topic extraction — strips boilerplate prefixes and filter words."""
627
- q = _normalize_whitespace(question)
628
- prefixes = [
629
- "can u give me a deep analysis of ", "can you give me a deep analysis of ",
630
- "give me a deep analysis of ", "deep analysis of ",
631
- "give me a summary of ", "give me a summary on ", "give me a summary about ",
632
- "give me an overview of ", "give me an overview on ",
633
- "give me a brief overview of ", "give me a brief summary of ",
634
- "summarize the latest studies in ", "summarize latest studies in ",
635
- "summarize the latest research on ", "summarize latest research on ",
636
- "summarize recent studies in ", "summarize recent research on ",
637
- "latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
638
- "summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
639
- "overview of ", "overview on ", "brief overview of ", "brief summary of ",
640
- "analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
641
- "what is ", "what are ", "describe ", "review of ", "literature review on ",
642
- "recent advances in ", "recent developments in ", "state of the art in ",
643
- "state of research on ", "research overview of ", "research summary of ",
644
- "find peer reviewed articles on ", "find peer reviewed papers on ",
645
- "find articles on ", "find papers on ", "find books on ", "find articles about ",
646
- "find papers about ", "search for articles on ", "search for papers on ",
647
- "i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
648
- "i need books on ", "give me articles on ", "show me articles on ",
649
- "get me articles on ", "look for articles on ", "articles on ", "papers on ",
650
- "books on ", "research on ", "literature on ", "find ", "search for ",
651
- "i need ", "give me ", "show me ", "get me ",
652
- ]
653
- lower = q.lower()
654
- for prefix in prefixes:
655
- if lower.startswith(prefix):
656
- q = q[len(prefix):]
657
- break
658
- # Strip trailing noise
659
- q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
660
- q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
661
- # FIX: strip resource-type words that leaked through prefix stripping
662
- q = _strip_resource_noise(q)
663
- return _normalize_whitespace(q).strip(".?")
664
-
665
-
666
- async def _extract_topic(question: str, model: str) -> str:
667
- """
668
- LLM-based topic extraction.
669
- CRITICAL: instructs LLM to never return resource-type words (articles/books/papers).
670
- Falls back to regex if LLM unavailable or returns empty.
671
- """
672
- settings = get_settings()
673
- if not settings.openai_api_key:
674
- return _extract_topic_regex(question)
675
- try:
676
- llm = _get_llm(model, temperature=0, max_tokens=32)
677
- response = await llm.ainvoke([
678
- {"role": "system", "content": (
679
- "Extract only the core research topic from the user's question. "
680
- "Return 2-7 keywords ONLY — no punctuation, no sentence, no explanation. "
681
- "CRITICAL: do NOT include words like articles, papers, books, journals, "
682
- "peer-reviewed, open-access, studies, or research in your output. "
683
- "Those are filters, not topic words. Return ONLY the subject matter.\n"
684
- "Examples:\n"
685
- "'find articles on indian politics' → indian politics\n"
686
- "'peer reviewed papers on climate change 2020' → climate change\n"
687
- "'books on structural engineering UAE' → structural engineering UAE\n"
688
- "'give me a summary of quantum physics advances' → quantum physics advances\n"
689
- "'i need peer reviewed research on machine learning healthcare' → machine learning healthcare"
690
- )},
691
- {"role": "user", "content": question},
692
- ])
693
- topic = response.content.strip().strip('"').strip("'").strip(".")
694
- # Belt-and-braces: strip any noise the LLM snuck in anyway
695
- topic = _strip_resource_noise(topic)
696
- topic = _normalize_whitespace(topic).strip(".?")
697
- if topic and len(topic) > 2:
698
- logger.info(f"LLM topic: '{topic}' ← '{question[:60]}'")
699
- return topic
700
- except Exception as e:
701
- logger.warning(f"Topic extraction failed: {e}")
702
- return _extract_topic_regex(question)
703
-
704
-
705
- # ── Query Building ────────────────────────────────────────────────────────────
706
-
707
- def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
708
- q = (question or "").lower()
709
- if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
710
- return "books"
711
- if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
712
- return "articles"
713
- return existing or "articles"
714
-
715
-
716
- def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
717
- q = (question or "").lower()
718
- match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
719
- if match:
720
- years = int(match.group(2))
721
- return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
722
- 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)
723
- if between:
724
- return between.group(1), between.group(2)
725
- years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
726
- if len(years) >= 2:
727
- return min(years), max(years)
728
- if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
729
- return years[0], str(CURRENT_YEAR)
730
- return None, None
731
-
732
-
733
- def _make_primo_boolean_query(context: SearchContextPayload) -> str:
734
- """
735
- FIX: Build a clean boolean for display purposes ONLY.
736
- Contains ONLY topic keywords — NO resource-type words.
737
- Resource type is expressed as a PRIMO facet in search_primo(), never here.
738
- """
739
- topic = _title_case_topic(context.display_topic or context.topic)
740
- # Strip any remaining noise one final time
741
- topic = _strip_resource_noise(topic)
742
- if not topic:
743
- topic = context.topic or "library search"
744
- return f'"{_normalize_whitespace(topic)}"'
745
-
746
-
747
- def _primo_clean_url(context: SearchContextPayload) -> str:
748
- """
749
- Build the PRIMO discovery URL using the clean topic — matching exactly what the API searches.
750
- """
751
- clean_topic = _strip_resource_noise(context.topic)
752
- if not clean_topic:
753
- clean_topic = context.topic
754
- return f"{PRIMO_BASE_URL}&query=any,contains,{quote(clean_topic)}&lang=en&mode=advanced&offset=0"
755
-
756
-
757
- def _topic_keywords(text: str) -> List[str]:
758
- cleaned = _normalize_whitespace(re.sub(r"[^a-zA-Z0-9\s-]", " ", text or ""))
759
- stop = {
760
- "of", "on", "in", "the", "a", "an", "and", "or", "for", "to", "with", "by", "from", "at",
761
- "is", "are", "was", "were", "be", "been", "have", "has", "had", "do", "does", "did", "will",
762
- "would", "could", "should", "may", "its", "this", "that", "these", "those", "about", "impact",
763
- "role", "effect", "use", "study", "analysis", "review", "what", "how", "why", "when", "where",
764
- "which", "recent", "latest", "current", "summary", "summarize", "overview", "brief",
765
- # FIX: resource-type words must never appear as keywords
766
- "research", "studies", "articles", "article", "papers", "paper", "books", "book",
767
- "journals", "journal", "publications", "publication",
768
- }
769
- tokens: List[str] = []
770
- for word in cleaned.split():
771
- word = word.strip("-").lower()
772
- if not word or word in stop or len(word) < 3:
773
- continue
774
- if word not in tokens:
775
- tokens.append(word)
776
- return tokens[:6]
777
-
778
-
779
- def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
780
- """Build the clean natural language query for AI tools (LeapSpace, Scopus AI, Consensus)."""
781
- topic = context.display_topic or context.topic
782
- topic = _title_case_topic(topic)
783
- scope = context.resource_type
784
- filters = []
785
- if context.peer_reviewed:
786
- filters.append("peer-reviewed")
787
- if context.open_access:
788
- filters.append("open-access")
789
- if context.year_from and context.year_to:
790
- filters.append(f"published between {context.year_from} and {context.year_to}")
791
- elif context.year_from:
792
- filters.append(f"published from {context.year_from} onward")
793
-
794
- if is_follow_up:
795
- if scope == "books":
796
- base = f"Find books on {topic}"
797
- elif scope == "both":
798
- base = f"Find articles and books on {topic}"
799
- else:
800
- base = f"Find research articles on {topic}"
801
  else:
802
- base = _normalize_whitespace(question).strip(".?")
803
- if len(base.split()) < 4:
804
- base = f"Find research on {topic}"
805
-
806
- if filters:
807
- return f"{base} with {', '.join(filters)}."
808
- return base.rstrip(".") + "."
809
-
810
-
811
- async def _grammar_refine_query(text: str, model: str) -> str:
812
- settings = get_settings()
813
- if not settings.openai_api_key:
814
- return _normalize_whitespace(text)
815
- try:
816
- llm = _get_llm(model, temperature=0, max_tokens=60)
817
- response = await llm.ainvoke([
818
- {"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
819
- {"role": "user", "content": text},
820
- ])
821
- refined = _normalize_whitespace(response.content)
822
- return refined or _normalize_whitespace(text)
823
- except Exception:
824
- return _normalize_whitespace(text)
825
-
826
-
827
- def _context_to_dict(context: SearchContextPayload) -> dict:
828
- return context.model_dump()
829
 
 
 
 
830
 
831
- async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
832
- """Populate ai_tool_query and primo_boolean_query on the context."""
833
- natural = _compose_ai_tool_query(question, context, is_follow_up)
834
- natural = await _grammar_refine_query(natural, model)
835
- context.ai_tool_query = natural
836
- # FIX: boolean is now clean topic only — resource type goes to PRIMO as a facet
837
- context.primo_boolean_query = _make_primo_boolean_query(context)
838
- return context
839
 
 
 
 
 
 
 
840
 
841
- # ── Semantic Scholar + Summary ───────────────────────���────────────────────────
842
-
843
- async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
844
- """Search Semantic Scholar. Returns only papers that have abstracts."""
845
- # FIX: strip resource noise from topic before hitting external API
846
- clean_topic = _strip_resource_noise(topic)
847
- if not clean_topic:
848
- clean_topic = topic
849
- try:
850
- params = {
851
- "query": clean_topic,
852
- "limit": limit,
853
- "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
854
- }
855
- async with httpx.AsyncClient(timeout=15) as client:
856
- r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
857
- if r.status_code != 200:
858
- logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
859
- return []
860
- data = r.json()
861
- results = []
862
- for paper in data.get("data", []):
863
- abstract = paper.get("abstract") or ""
864
- if not abstract or len(abstract) < 50:
865
- continue
866
- author_list = paper.get("authors") or []
867
- first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
868
- year = paper.get("year") or "n.d."
869
- paper_id = paper.get("paperId", "")
870
- doi = (paper.get("externalIds") or {}).get("DOI", "")
871
- pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
872
- link = (
873
- pdf_url
874
- or (f"https://doi.org/{doi}" if doi else "")
875
- or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
876
- )
877
- results.append({
878
- "title": paper.get("title", "Untitled"),
879
- "authors": (
880
- ", ".join(a.get("name", "") for a in author_list[:3])
881
- + (" et al." if len(author_list) > 3 else "")
882
- ),
883
- "first_author": first_author_last,
884
- "year": year,
885
- "citation_key": f"{first_author_last}, {year}",
886
- "venue": paper.get("venue", ""),
887
- "abstract": abstract[:700],
888
- "link": link,
889
- "_source": "Semantic Scholar",
890
- })
891
- logger.info(f"Semantic Scholar: {len(results)} papers with abstracts for '{clean_topic}'")
892
- return results
893
- except Exception as e:
894
- logger.error(f"Semantic Scholar search failed: {e}")
895
- return []
896
 
897
 
898
- async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
899
- """
900
- Generate a research summary with clickable in-text citations [Author, Year].
901
- Each [N] in the LLM output is replaced with a hyperlink to the actual paper.
902
- """
903
  settings = get_settings()
904
- if not settings.openai_api_key or not papers:
905
- return ""
906
-
907
- context_text = ""
908
- for i, paper in enumerate(papers, 1):
909
- context_text += (
910
- f"[{i}] {paper['citation_key']}: {paper['title']}\n"
911
- f"Abstract: {paper['abstract']}\n\n"
912
- )
913
-
914
- system = (
915
- "You are a research librarian writing a concise academic literature snapshot. "
916
- "Using ONLY the numbered references provided, write a 4-6 sentence summary on the given topic. "
917
- "Use in-text citations as [1], [2], [3] etc. referring to the numbered references. "
918
- "Cite at least 4 different references. "
919
- "Use HTML <br><br> for paragraph breaks. Do not use bullet points or markdown. "
920
- "Only state what is explicitly supported by the abstracts. "
921
- "Do NOT invent content."
922
- )
923
 
924
  try:
925
- llm = _get_llm(model, temperature=0.2, max_tokens=560)
926
- response = await llm.ainvoke([
927
- {"role": "system", "content": system},
928
- {"role": "user", "content": f"Topic: {topic}\n\nReferences:\n{context_text}\nWrite the summary."},
929
- ])
930
- summary = response.content.strip()
931
-
932
- # Replace [N] with clickable citation links — work backwards to avoid [1] matching inside [10]
933
- for i in range(len(papers), 0, -1):
934
- paper = papers[i - 1]
935
- title_escaped = _escape(paper["title"])
936
- cite_html = (
937
- f'<a href="{paper["link"]}" target="_blank" '
938
- f'title="{title_escaped}" '
939
- f'style="color:#1e40af;font-weight:600;text-decoration:none">'
940
- f'[{paper["citation_key"]}]</a>'
941
- )
942
- summary = summary.replace(f"[{i}]", cite_html)
943
- return summary
944
- except Exception as e:
945
- logger.error(f"Summary generation failed: {e}")
946
- return ""
947
-
948
 
949
- def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
950
- """Fallback: extract lead sentences from abstracts when LLM summary fails."""
951
- if not papers:
952
- return ""
953
- statements: List[str] = []
954
- for paper in papers[:4]:
955
- sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
956
- lead = sentences[0].strip() if sentences else ""
957
- if not lead:
958
- continue
959
- citation = (
960
- f'<a href="{paper["link"]}" target="_blank" '
961
- f'style="color:#1e40af;font-weight:600;text-decoration:none">'
962
- f'[{_escape(paper["citation_key"])}]</a>'
963
- )
964
- statements.append(f"{_escape(lead)} {citation}")
965
- if not statements:
966
- return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
967
- intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
968
- return intro + "<br><br>".join(statements[:4])
969
 
 
970
 
971
- # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
 
 
 
972
 
973
- from src.api.search import SearchRequest, search_primo, search_pubmed
974
 
975
-
976
- async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
977
- """
978
- FIX: now passes resource_type so the rtype FACET is correctly sent to PRIMO.
979
- Topic is stripped of noise before hitting the API.
980
- """
981
  settings = get_settings()
982
- # FIX: strip resource noise from topic — belt-and-braces
983
- clean_topic = _strip_resource_noise(context.topic)
984
- if not clean_topic:
985
- clean_topic = context.topic
986
-
987
- result = await search_primo(
988
- SearchRequest(
989
- query=clean_topic,
990
- source="primo",
991
- limit=limit,
992
- peer_reviewed=context.peer_reviewed,
993
- open_access=context.open_access,
994
- year_from=context.year_from,
995
- year_to=context.year_to,
996
- resource_type=context.resource_type, # FIX: was missing — resource type now goes as facet
997
- ),
998
- settings.primo_api_key,
999
- )
1000
- source_url = _primo_clean_url(context)
1001
- return result, source_url
1002
-
1003
-
1004
- async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
1005
- """
1006
- FIX: year_from/year_to now correctly wired through SearchRequest → mindate/maxdate in search.py.
1007
- """
1008
- clean_topic = _strip_resource_noise(context.topic)
1009
- if not clean_topic:
1010
- clean_topic = context.topic
1011
-
1012
- result = await search_pubmed(
1013
- SearchRequest(
1014
- query=clean_topic,
1015
- source="pubmed",
1016
- limit=limit,
1017
- peer_reviewed=context.peer_reviewed,
1018
- year_from=context.year_from, # FIX: now actually used in search.py
1019
- year_to=context.year_to, # FIX: now actually used in search.py
1020
- )
1021
- )
1022
- pubmed_url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(clean_topic)}"
1023
- if context.year_from and context.year_to:
1024
- pubmed_url += f"&filter=datesearch.y_custom&dates.from={context.year_from}&dates.to={context.year_to}"
1025
- return result, pubmed_url
1026
-
1027
-
1028
- # ── AI Tools / Database Guidance ─────────────────────────────────────────────
1029
-
1030
- def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1031
- ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1032
- clean_topic = _strip_resource_noise(context.topic)
1033
  return {
1034
- "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1035
- "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
1036
- "consensus": f"https://consensus.app/results/?q={ai_query}",
1037
- "primo_discovery": _primo_clean_url(context),
1038
- }
1039
-
1040
-
1041
- def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1042
- topic = _escape(context.display_topic or context.topic)
1043
- if context.intent == "search_medical":
1044
- return (
1045
- f"For deeper research on <strong>{topic}</strong>, the KU-subscribed AI research tools and freemium tools are listed at the end of this answer. "
1046
- "Direct links to PRIMO Library Discovery and PubMed are also provided there. "
1047
- "Some freemium tools may require account creation."
1048
- )
1049
- return (
1050
- f"For deeper research on <strong>{topic}</strong>, the KU-subscribed AI research tools and freemium tools are listed at the end of this answer. "
1051
- "Direct links to PRIMO Library Discovery and, for medical topics, PubMed are also provided there. "
1052
- "Some freemium tools may require account creation."
1053
- )
1054
-
1055
-
1056
- def _is_database_recommendation_question(question: str) -> bool:
1057
- return bool(DATABASE_RECOMMENDATION_RE.search(question or ""))
1058
-
1059
-
1060
- def _primary_contact_for_question(question: str, intent: str) -> dict:
1061
- q = (question or '').lower()
1062
- walter = {
1063
- 'label': 'Systems & website support',
1064
- 'name': 'Walter Brian Hall',
1065
- 'role': 'Digital & Technology Services Librarian / Systems Librarian',
1066
- 'email': 'walter.hall@ku.ac.ae',
1067
- 'phone': '+971 2 312 3163',
1068
- }
1069
- rani = {
1070
- 'label': 'E-resources support',
1071
- 'name': 'Rani Anand',
1072
- 'role': 'E-Resources Librarian',
1073
- 'email': 'rani.anand@ku.ac.ae',
1074
- 'phone': '+971 2 312 3935',
1075
- }
1076
- nikesh = {
1077
- 'label': 'Research support',
1078
- 'name': 'Nikesh Narayanan',
1079
- 'role': 'Research & Access Services Librarian',
1080
- 'email': 'nikesh.narayanan@ku.ac.ae',
1081
- 'phone': '+971 2 312 3980',
1082
- }
1083
- muna = {
1084
- 'label': 'Public services',
1085
- 'name': 'Muna Ahmad Mohammad Al Blooshi',
1086
- 'role': 'Manager, Public Services',
1087
- 'email': 'muna.alblooshi@ku.ac.ae',
1088
- 'phone': '+971 2 312 4212',
1089
  }
1090
 
1091
- if any(t in q for t in ['system', 'systems', 'website', 'web site', 'technical', 'technology', 'digital', 'error', 'bug', 'broken page', 'page not loading']):
1092
- return walter
1093
- if any(t in q for t in ['database access', 'off campus', 'remote access', 'proxy', 'login problem', 'login issue', 'cannot access', "can't access", 'full text', 'libkey', 'vendor', 'e-resource', 'eresource', 'subscription access']):
1094
- return rani
1095
- if intent in {'search_academic', 'search_medical'} or any(t in q for t in ['research', 'literature', 'systematic review', 'orcid', 'open access', 'apc', 'bibliometric', 'scopus', 'scival', 'impact', 'citation', 'ai tool', 'best database', 'best databases', 'which database', 'which databases']):
1096
- return nikesh
1097
- return muna
1098
-
1099
-
1100
- def _contact_footer_html(question: str, intent: str) -> str:
1101
- primary = _primary_contact_for_question(question, intent)
1102
- return (
1103
- '<div style="margin-top:14px;padding:12px 14px;background:#f8fafc;border:1px solid #dbeafe;border-radius:12px;font-size:.8rem;line-height:1.6">'
1104
- '<strong>Library contact points</strong><br>'
1105
- f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank" style="color:#003366;font-weight:700">Ask a Librarian</a> · '
1106
- '<a href="mailto:libse@ku.ac.ae" style="color:#003366">libse@ku.ac.ae</a><br>'
1107
- f'{_escape(primary["label"])}: <strong>{_escape(primary["name"])}</strong> — {_escape(primary["role"])} · '
1108
- f'<a href="mailto:{primary["email"]}" style="color:#003366">{_escape(primary["email"])}</a> · '
1109
- f'<a href="tel:{primary["phone"].replace(" ", "")}" style="color:#003366">{_escape(primary["phone"])}</a>'
1110
- '</div>'
1111
- )
1112
-
1113
-
1114
- async def _database_recommendation_answer(question: str, history: List[ChatMessage], model: str) -> Tuple[str, List[dict]]:
1115
- try:
1116
- from app import rag_service
1117
- rag_query = f"{question} recommended databases by subject Khalifa University Library databases"
1118
- rag_results = await rag_service.hybrid_search(rag_query, top_k=5)
1119
- except Exception as e:
1120
- logger.error(f"Database recommendation search failed: {e}")
1121
- rag_results = []
1122
-
1123
- if not rag_results:
1124
- answer = (
1125
- 'I could not pull the subject database guidance from the library knowledge base right now.<br><br>'
1126
- 'A good general starting point is <strong>PRIMO Library Discovery</strong>, then move to subject databases such as Scopus, Web of Science, IEEE Xplore, ACM Digital Library, PubMed, or Embase depending on your topic.'
1127
- )
1128
- return answer + _source_badge('ku_library', 'https://library.ku.ac.ae/eresources'), []
1129
-
1130
- context_text = "\n\n---\n\n".join(r['content'] for r in rag_results)
1131
- settings = get_settings()
1132
- if not settings.openai_api_key:
1133
- answer = 'I found KU Library database guidance for this subject in the knowledge base.'
1134
- answer += _source_badge('rag', 'https://library.ku.ac.ae/eresources')
1135
- sources = [{'title': r.get('title', 'Library guide'), 'url': r.get('source', '')} for r in rag_results if r.get('source')]
1136
- return answer, sources
1137
-
1138
- system = (
1139
- 'You are LibBee, the Khalifa University Library AI Assistant. '
1140
- 'Answer as an expert academic librarian using ONLY the context provided. '
1141
- "Recommend the best KU databases for the user's subject. "
1142
- 'Start with one short sentence. Then list 3 to 6 databases using HTML <br>, with each database name in <strong> and a brief explanation of why or when to use it. '
1143
- 'Where appropriate, include PRIMO Library Discovery as a starting point and then subject-specific databases. '
1144
- 'End with one short sentence offering to search directly in PRIMO Library Discovery.'
1145
- )
1146
- try:
1147
- llm = _get_llm(model, temperature=0.2, max_tokens=420)
1148
- response = await llm.ainvoke([
1149
- {'role': 'system', 'content': system + "\n\nCONTEXT:\n" + context_text},
1150
- {'role': 'user', 'content': question},
1151
- ])
1152
- answer = response.content.strip()
1153
- except Exception as e:
1154
- logger.error(f"Database recommendation answer failed: {e}")
1155
- answer = 'I found subject-specific database guidance in the library knowledge base, but I had trouble formatting it into a recommendation right now.'
1156
-
1157
- answer += _source_badge('rag', 'https://library.ku.ac.ae/eresources')
1158
- sources = [{'title': r.get('title', 'Library guide'), 'url': r.get('source', '')} for r in rag_results if r.get('source')]
1159
- return answer, sources
1160
-
1161
-
1162
- # ── Research Snapshot ─────────────────────────────────────────────────────────
1163
-
1164
- async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
1165
- """
1166
- Full research snapshot:
1167
- 1. Semantic Scholar → cited summary with hyperlinked [Author, Year] citations
1168
- 2. PRIMO → KU-accessible results appended below
1169
- """
1170
- topic = context.topic
1171
- papers = await _semantic_scholar_search(topic, limit=8)
1172
- references: List[dict] = []
1173
- primo_result, primo_url = await _search_primo_catalog(context, limit=6)
1174
- search_results = primo_result.get("results", []) or []
1175
-
1176
- if papers:
1177
- summary = await _generate_cited_summary(topic, papers, model)
1178
- if not summary:
1179
- _safe_metrics_increment("summary_fallbacks")
1180
- summary = _extractive_snapshot_from_papers(topic, papers)
1181
-
1182
- answer = f"<strong>📖 Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
1183
- if search_results:
1184
- answer += (
1185
- "<br><br><strong>KU Library starting points:</strong> "
1186
- "I also pulled KU-accessible results below so you can move directly from the snapshot to subscribed material."
1187
- )
1188
- answer += f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1189
- answer += "<br><br><strong>Selected references:</strong><br>"
1190
- for i, paper in enumerate(papers[:6], 1):
1191
- answer += (
1192
- f'<br><strong>[{i}]</strong> '
1193
- f'<a href="{paper["link"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(paper["title"])}</a><br>'
1194
- f'<span style="font-size:.8rem;color:#6b7280">'
1195
- f'{_escape(paper["authors"])} · {paper["year"]} · {_escape(paper["venue"] or "")}'
1196
- f'</span>'
1197
- )
1198
- answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1199
- if search_results:
1200
- answer += _source_badge("primo", primo_url)
1201
- references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1202
- return answer, search_results, references
1203
-
1204
- if search_results:
1205
- answer = (
1206
- f"<strong>📚 KU Library starting points on {_escape(topic)}</strong><br><br>"
1207
- "I couldn't build a Semantic Scholar snapshot right now, but here are KU-accessible library results you can open immediately. "
1208
- f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1209
- )
1210
- else:
1211
- answer = (
1212
- f"I couldn't retrieve a research snapshot for <strong>{_escape(topic)}</strong> right now.<br><br>"
1213
- f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1214
- )
1215
- answer += _source_badge("primo", primo_url)
1216
- return answer, search_results, []
1217
-
1218
-
1219
- # ── Follow-up System ──────────────────────────────────────────────────────────
1220
-
1221
- def _filters_summary(context: SearchContextPayload) -> str:
1222
- filters: List[str] = []
1223
- if context.peer_reviewed:
1224
- filters.append("peer reviewed only")
1225
- if context.open_access:
1226
- filters.append("open access")
1227
- if context.year_from and context.year_to:
1228
- filters.append(f"{context.year_from}–{context.year_to}")
1229
- elif context.year_from:
1230
- filters.append(f"from {context.year_from}")
1231
- if context.resource_type == "books":
1232
- filters.append("books")
1233
- elif context.resource_type == "articles":
1234
- filters.append("articles")
1235
- elif context.resource_type == "both":
1236
- filters.append("articles and books")
1237
- return ", ".join(filters)
1238
-
1239
-
1240
- def _question_has_new_topic(question: str, base_topic: str) -> bool:
1241
- q = (question or "").lower()
1242
- cleaned = re.sub(
1243
- r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
1244
- r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
1245
- " ", q,
1246
- )
1247
- tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
1248
- if len(tokens) < 3:
1249
- return False
1250
- base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
1251
- overlap = sum(1 for t in tokens if t in base_tokens)
1252
- return overlap <= max(1, min(2, len(base_tokens)))
1253
-
1254
-
1255
- def _parse_refinement_action(question: str) -> Optional[str]:
1256
- q = (question or "").lower().strip()
1257
- if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
1258
- return "peer_reviewed_only"
1259
- if re.search(r"\b(open access|oa only|only open access)\b", q):
1260
- return "open_access_only"
1261
- if re.search(r"\b(last|past)\s+5\s+years?\b", q):
1262
- return "last_5_years"
1263
- if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
1264
- return "last_10_years"
1265
- if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
1266
- return "books_only"
1267
- if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
1268
- return "articles_only"
1269
- if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
1270
- return "both_resources"
1271
- if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
1272
- return "summarize_topic"
1273
- if re.search(r"\bpubmed\b", q):
1274
- return "search_pubmed"
1275
- if re.search(r"\bprimo\b", q):
1276
- return "search_primo"
1277
- if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
1278
- return "best_databases"
1279
- if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
1280
- return "deep_research_tools"
1281
- return None
1282
-
1283
-
1284
- def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
1285
- if not client_state or not client_state.recent_search_contexts:
1286
- return None
1287
- contexts = list(client_state.recent_search_contexts)
1288
- if client_state.follow_up_context_id:
1289
- for ctx in contexts:
1290
- if ctx.context_id == client_state.follow_up_context_id:
1291
- return ctx
1292
- if client_state.active_search_context_id:
1293
- for ctx in contexts:
1294
- if ctx.context_id == client_state.active_search_context_id:
1295
- return ctx
1296
- contexts.sort(key=lambda c: c.created_at, reverse=True)
1297
- return contexts[0]
1298
-
1299
 
1300
- def _detect_follow_up(question: str, client_state: Optional[ClientStatePayload]) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
1301
- base_context = _resolve_base_context(client_state)
1302
- if not base_context:
1303
- return False, None, None
1304
- explicit_action = (client_state.follow_up_action if client_state else None) or None
1305
- if explicit_action:
1306
- return True, explicit_action, base_context
1307
- action = _parse_refinement_action(question)
1308
- if not action:
1309
- return False, None, None
1310
- if _question_has_new_topic(question, base_context.topic):
1311
- return False, None, None
1312
- return True, action, base_context
1313
-
1314
-
1315
- def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
1316
- return SearchContextPayload.model_validate(base_context.model_dump())
1317
-
1318
-
1319
- def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
1320
- context = _clone_context(base_context)
1321
- context.context_id = str(uuid.uuid4())
1322
- context.created_at = time.time()
1323
- if action == "peer_reviewed_only":
1324
- context.peer_reviewed = True
1325
- elif action == "open_access_only":
1326
- context.open_access = True
1327
- elif action == "last_5_years":
1328
- context.year_from = str(CURRENT_YEAR - 4)
1329
- context.year_to = str(CURRENT_YEAR)
1330
- elif action == "last_10_years":
1331
- context.year_from = str(CURRENT_YEAR - 9)
1332
- context.year_to = str(CURRENT_YEAR)
1333
- elif action == "books_only":
1334
- context.resource_type = "books"
1335
- elif action == "articles_only":
1336
- context.resource_type = "articles"
1337
- elif action == "both_resources":
1338
- context.resource_type = "both"
1339
- elif action == "search_pubmed":
1340
- context.source = "pubmed"
1341
- context.intent = "search_medical"
1342
- context.resource_type = "articles"
1343
- elif action == "search_primo":
1344
- context.source = "primo"
1345
- return context
1346
-
1347
-
1348
- async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1349
- """
1350
- FIX: Generate 3 topic-specific intellectual follow-up questions using LLM.
1351
- These are ASPECT-based (related subtopics, methodological angles, applications)
1352
- not generic filter suggestions like "peer reviewed only".
1353
- """
1354
  settings = get_settings()
1355
- if not settings.openai_api_key:
1356
- return []
1357
- try:
1358
- llm = _get_llm(model, temperature=0.5, max_tokens=200)
1359
- response = await llm.ainvoke([
1360
- {"role": "system", "content": (
1361
- "You are a research librarian helping a user explore a topic more deeply. "
1362
- "Given a research topic, generate exactly 3 short follow-up questions that explore "
1363
- "DIFFERENT ASPECTS of the topic — such as subtopics, methodological angles, "
1364
- "applications, comparisons, or related fields. "
1365
- "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
1366
- "Return ONLY a JSON array of 3 strings, each under 12 words. "
1367
- "No explanation, no preamble, no markdown.\n"
1368
- "Example for 'quantum computing':\n"
1369
- '[\"What are the main hardware approaches in quantum computing?\", '
1370
- '"How does quantum error correction work?\", '
1371
- '"Applications of quantum computing in cryptography\"]'
1372
- )},
1373
- {"role": "user", "content": f"Topic: {topic}"},
1374
- ])
1375
- raw = response.content.strip()
1376
- if raw.startswith("```"):
1377
- raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
1378
- questions = json.loads(raw)
1379
- if not isinstance(questions, list):
1380
- return []
1381
- return [
1382
- {"label": q.strip("?") + "?", "question": f"Find research articles on {_normalize_whitespace(q.strip().rstrip("?"))} in the context of {topic}", "action": None}
1383
- for q in questions[:3]
1384
- if isinstance(q, str) and q.strip()
1385
- ]
1386
- except Exception as e:
1387
- logger.warning(f"Topic follow-up generation failed: {e}")
1388
- return []
1389
-
1390
-
1391
- async def _search_follow_up(
1392
- context: SearchContextPayload, model: str, summary_mode: bool = False
1393
- ) -> Tuple[str, List[dict]]:
1394
- """
1395
- FIX: Now generates topic-specific intellectual follow-ups (LLM-based)
1396
- PLUS a smaller set of generic filter options as secondary suggestions.
1397
- Total suggestions: 3 topic-specific + up to 2 filter options = max 5.
1398
- """
1399
- topic = context.display_topic or context.topic
1400
- suggestions: List[dict] = []
1401
-
1402
- # ── Topic-specific intellectual follow-ups (LLM-generated) ───────────────
1403
- topic_follow_ups = await _generate_topic_follow_ups(topic, model)
1404
- suggestions.extend(topic_follow_ups)
1405
-
1406
- # ── Generic filter options (secondary) ────────────────────────────────────
1407
- filter_suggestions: List[dict] = []
1408
- if summary_mode:
1409
- filter_suggestions.append({
1410
- "label": f"Show KU-accessible articles on {topic}",
1411
- "question": f"Find KU-accessible articles on {topic}",
1412
- "action": "articles_only",
1413
- "context_id": context.context_id,
1414
- })
1415
- filter_suggestions.append({
1416
- "label": f"Limit {topic} to the last 5 years",
1417
- "question": f"Limit {topic} to the last 5 years",
1418
- "action": "last_5_years",
1419
- "context_id": context.context_id,
1420
- })
1421
- else:
1422
- if not context.peer_reviewed and context.source == "primo":
1423
- filter_suggestions.append({
1424
- "label": f"Peer reviewed only",
1425
- "question": f"Make the {topic} search peer reviewed only",
1426
- "action": "peer_reviewed_only",
1427
- "context_id": context.context_id,
1428
- })
1429
- if not (context.year_from and context.year_to == str(CURRENT_YEAR)):
1430
- filter_suggestions.append({
1431
- "label": "Last 5 years",
1432
- "question": f"Limit {topic} to the last 5 years",
1433
- "action": "last_5_years",
1434
- "context_id": context.context_id,
1435
- })
1436
- if context.intent == "search_medical" and context.source != "pubmed":
1437
- filter_suggestions.append({
1438
- "label": "Search PubMed instead",
1439
- "question": f"Search PubMed for {topic}",
1440
- "action": "search_pubmed",
1441
- "context_id": context.context_id,
1442
- })
1443
- filter_suggestions.append({
1444
- "label": "Research snapshot",
1445
- "question": f"Give me a brief research snapshot on {topic}",
1446
- "action": "summarize_topic",
1447
- "context_id": context.context_id,
1448
- })
1449
-
1450
- # Add up to 2 filter suggestions after topic-specific ones
1451
- suggestions.extend(filter_suggestions[:2])
1452
-
1453
- question_text = (
1454
- f"Here are some related angles you might want to explore on <strong>{_escape(topic)}</strong>. "
1455
- "Or I can refine the search with filters — just ask."
1456
- )
1457
- return question_text, suggestions[:5]
1458
-
1459
-
1460
- def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
1461
- q = (question or "").lower()
1462
- if _looks_library_hours_question(question):
1463
- return (
1464
- "Would you like the library contact details too?",
1465
- [
1466
- {"label": "Show library contact details", "question": "Show the library contact details"},
1467
- {"label": "Open the live library hours page", "question": "Open the live library hours page"},
1468
- ],
1469
- )
1470
- if "study room" in q or "room" in q:
1471
- return (
1472
- "Would you like the study-room booking link?",
1473
- [
1474
- {"label": "Study-room booking link", "question": "Show the study-room booking link"},
1475
- {"label": "Library services for both campuses", "question": "Show library services for both campuses"},
1476
- ],
1477
- )
1478
- if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
1479
- return (
1480
- "Would you like me to recommend the best KU databases for this topic?",
1481
- [
1482
- {"label": "Recommend best KU databases", "question": "Recommend the best KU databases for this topic"},
1483
- {"label": "How to access databases off campus", "question": "How do I access databases off campus?"},
1484
- ],
1485
- )
1486
- return (
1487
- "Would you like a related library service or staff contact?",
1488
- [
1489
- {"label": "Best staff contact", "question": "Show the best staff contact for this"},
1490
- {"label": "KU library page for this topic", "question": "Open the KU library page for this topic"},
1491
- ],
1492
- )
1493
-
1494
-
1495
- def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
1496
- return (
1497
- "Would you like a brief explanation, current web information, or KU Library resources on this topic?",
1498
- [
1499
- {"label": "Shorter explanation", "question": f"Give me a shorter explanation of {question}"},
1500
- {"label": "Current web information", "question": f"Show current web information on {question}"},
1501
- {"label": "Find KU Library resources", "question": f"Find KU Library resources on {question}"},
1502
- ],
1503
- )
1504
-
1505
-
1506
- def _social_follow_up() -> Tuple[str, List[dict]]:
1507
- return (
1508
- "What would you like help with next?",
1509
- [
1510
- {"label": "Find articles on a topic", "question": "Find articles on a topic"},
1511
- {"label": "Check a library service", "question": "Check a library service"},
1512
- {"label": "Contact a librarian", "question": "Contact a librarian"},
1513
- ],
1514
- )
1515
-
1516
-
1517
- def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
1518
- topic = _escape(context.display_topic or context.topic)
1519
- resource = {"books": "books", "articles": "articles", "both": "articles and books"}.get(context.resource_type, "articles")
1520
- location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
1521
- if is_follow_up:
1522
- return f"I updated your previous search on <strong>{topic}</strong> in the <strong>{location}</strong> and looked for <strong>{resource}</strong>."
1523
- return f"I searched the <strong>{location}</strong> for <strong>{resource}</strong> on <strong>{topic}</strong>."
1524
-
1525
-
1526
- async def _run_search_mode(
1527
- question: str, context: SearchContextPayload, model: str, is_follow_up: bool
1528
- ) -> Tuple[str, List[dict], List[dict], str]:
1529
- context = await _prepare_queries(question, context, model, is_follow_up)
1530
- filters = _filters_summary(context)
1531
-
1532
- if context.source == "pubmed":
1533
- result, source_url = await _search_pubmed_articles(context, limit=6)
1534
- source_name = "pubmed"
1535
- else:
1536
- result, source_url = await _search_primo_catalog(context, limit=6)
1537
- source_name = "primo"
1538
-
1539
- search_results = result.get("results", []) or []
1540
- total = result.get("total", len(search_results))
1541
-
1542
- answer = _search_answer_intro(context, is_follow_up)
1543
- if filters:
1544
- answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1545
- if search_results:
1546
- answer += f"<br><br>I found <strong>{total}</strong> result(s). The top items are shown below."
1547
- else:
1548
- answer += (
1549
- "<br><br>I couldn't retrieve matching results right now. "
1550
- "You can still try the direct library links and AI research tools below."
1551
- )
1552
- if context.intent == "search_medical" and context.source != "pubmed":
1553
- answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1554
-
1555
- answer += _source_badge(source_name, source_url)
1556
- sources = []
1557
- for item in search_results[:6]:
1558
- link = item.get("primo_url") or item.get("link")
1559
- if link:
1560
- sources.append({"title": item.get("title", "Result"), "url": link})
1561
- return answer, search_results, sources, source_url
1562
-
1563
-
1564
- def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
1565
- dedup: Dict[str, SearchContextPayload] = {}
1566
- for ctx in contexts:
1567
- dedup[ctx.context_id] = ctx
1568
- ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
1569
- return ordered[:5]
1570
-
1571
-
1572
- # ── Main Endpoint ──────────────────────────────────────────────────────────────
1573
-
1574
- @router.post("", response_model=AgentResponse)
1575
- async def agent_query(request: Request, req: AgentRequest):
1576
- start_time = time.time()
1577
- question = _normalize_whitespace(req.question)
1578
- history = req.history
1579
- model = req.model
1580
- client_state = req.client_state
1581
- _safe_metrics_increment("agent_requests")
1582
-
1583
- if not question:
1584
- follow_up_question, follow_up_suggestions = _social_follow_up()
1585
- return AgentResponse(
1586
- answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"),
1587
- intent="social",
1588
- model_used=model,
1589
- response_time=time.time() - start_time,
1590
- follow_up_question=follow_up_question,
1591
- follow_up_suggestions=follow_up_suggestions,
1592
- )
1593
-
1594
- if PURE_GREETING_RE.match(question):
1595
- follow_up_question, follow_up_suggestions = _social_follow_up()
1596
- return AgentResponse(
1597
- answer=(
1598
- "Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>"
1599
- "I can help you find articles and books, search databases, access full text, request Interlibrary Loan (ILL), "
1600
- "and answer questions about library services and staff."
1601
- + _source_badge("libbee")
1602
- ),
1603
- intent="social_greeting",
1604
- model_used=model,
1605
- response_time=time.time() - start_time,
1606
- follow_up_question=follow_up_question,
1607
- follow_up_suggestions=follow_up_suggestions,
1608
- )
1609
-
1610
- staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None
1611
- if staff_match:
1612
- _safe_metrics_bucket("intents", "staff_lookup")
1613
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
1614
- return AgentResponse(
1615
- answer=staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1616
- intent="library_info",
1617
- tools_used=["staff_name_match"],
1618
- model_used=model,
1619
- response_time=time.time() - start_time,
1620
- follow_up_question=follow_up_question,
1621
- follow_up_suggestions=follow_up_suggestions,
1622
- contact_html=_contact_footer_html(question, "library_info"),
1623
- )
1624
-
1625
- staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
1626
- if staff_role_match:
1627
- _safe_metrics_bucket("intents", "staff_lookup")
1628
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
1629
- return AgentResponse(
1630
- answer=staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1631
- intent="library_info",
1632
- tools_used=["staff_role_match"],
1633
- model_used=model,
1634
- response_time=time.time() - start_time,
1635
- follow_up_question=follow_up_question,
1636
- follow_up_suggestions=follow_up_suggestions,
1637
- contact_html=_contact_footer_html(question, "library_info"),
1638
- )
1639
-
1640
- if _is_greeting_menu_followup(question, history):
1641
- follow_up_question, follow_up_suggestions = _social_follow_up()
1642
- return AgentResponse(
1643
- answer=_greeting_menu_clarify_answer() + _source_badge("libbee"),
1644
- intent="social_greeting",
1645
- is_follow_up=True,
1646
- model_used=model,
1647
- response_time=time.time() - start_time,
1648
- follow_up_question=follow_up_question,
1649
- follow_up_suggestions=follow_up_suggestions,
1650
- )
1651
-
1652
- if _looks_library_hours_question(question):
1653
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
1654
- return AgentResponse(
1655
- answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL),
1656
- intent="library_info",
1657
- tools_used=["hours_hardcoded"],
1658
- ask_librarian=False,
1659
- model_used=model,
1660
- response_time=time.time() - start_time,
1661
- follow_up_question=follow_up_question,
1662
- follow_up_suggestions=follow_up_suggestions,
1663
- contact_html=_contact_footer_html(question, "library_info"),
1664
- )
1665
-
1666
- if _looks_campus_question(question):
1667
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
1668
- return AgentResponse(
1669
- answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1670
- intent="library_info",
1671
- tools_used=["campus_hardcoded"],
1672
- ask_librarian=False,
1673
- model_used=model,
1674
- response_time=time.time() - start_time,
1675
- follow_up_question=follow_up_question,
1676
- follow_up_suggestions=follow_up_suggestions,
1677
- contact_html=_contact_footer_html(question, "library_info"),
1678
- )
1679
-
1680
- if _is_database_recommendation_question(question):
1681
- answer, sources = await _database_recommendation_answer(question, history, model)
1682
- follow_up_question = "Would you like me to search one of these databases directly or show the AI research tools for this topic?"
1683
- follow_up_suggestions = [
1684
- {"label": "Search PRIMO Library Discovery", "question": "Search PRIMO Library Discovery for this topic"},
1685
- {"label": "Show AI research tools", "question": "Show the AI research tools for this topic"},
1686
- ]
1687
- return AgentResponse(
1688
- answer=answer,
1689
- intent="library_info",
1690
- tools_used=["database_recommendation_rag"],
1691
- sources=sources,
1692
- ask_librarian=False,
1693
- model_used=model,
1694
- response_time=time.time() - start_time,
1695
- follow_up_question=follow_up_question,
1696
- follow_up_suggestions=follow_up_suggestions,
1697
- contact_html=_contact_footer_html(question, "library_info"),
1698
- )
1699
-
1700
- is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
1701
-
1702
- if is_follow_up and base_context:
1703
- _safe_metrics_increment("follow_up_hits")
1704
- context = _apply_follow_up_action(base_context, follow_up_action)
1705
-
1706
- if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
1707
- context = await _prepare_queries(question, context, model, True)
1708
- if follow_up_action == "summarize_topic":
1709
- answer, search_results, sources = await _research_snapshot(context, model)
1710
- else:
1711
- answer = (
1712
- f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
1713
- f"{_database_guidance_for_topic(context)}"
1714
- + _source_badge("libbee")
1715
- )
1716
- search_results = []
1717
- sources = []
1718
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1719
- _safe_metrics_increment("search_handoffs")
1720
- return AgentResponse(
1721
- answer=answer,
1722
- intent=context.intent,
1723
- tools_used=["follow_up_resolution", follow_up_action],
1724
- search_results=search_results,
1725
- sources=sources,
1726
- model_used=model,
1727
- response_time=time.time() - start_time,
1728
- corrected_query=context.topic,
1729
- natural_query=context.ai_tool_query,
1730
- database_query=context.primo_boolean_query,
1731
- ai_tool_query=context.ai_tool_query,
1732
- primo_boolean_query=context.primo_boolean_query,
1733
- primo_search_url=_primo_clean_url(context),
1734
- follow_up_question=follow_up_question,
1735
- follow_up_suggestions=follow_up_suggestions,
1736
- search_context=_context_to_dict(context),
1737
- is_follow_up=True,
1738
- primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1739
- pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if context.intent == "search_medical" else None),
1740
- contact_html=_contact_footer_html(question, context.intent),
1741
- )
1742
-
1743
- answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
1744
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
1745
- return AgentResponse(
1746
- answer=answer,
1747
- intent=context.intent,
1748
- tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
1749
- search_results=search_results,
1750
- sources=sources,
1751
- model_used=model,
1752
- response_time=time.time() - start_time,
1753
- corrected_query=context.topic,
1754
- natural_query=context.ai_tool_query,
1755
- database_query=context.primo_boolean_query,
1756
- ai_tool_query=context.ai_tool_query,
1757
- primo_boolean_query=context.primo_boolean_query,
1758
- primo_search_url=source_url,
1759
- follow_up_question=follow_up_question,
1760
- follow_up_suggestions=follow_up_suggestions,
1761
- search_context=_context_to_dict(context),
1762
- is_follow_up=True,
1763
- primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1764
- pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if context.intent == "search_medical" else None),
1765
- contact_html=_contact_footer_html(question, context.intent),
1766
- )
1767
-
1768
- cls_result = await _llm_classify(question, history, model)
1769
- intent = cls_result.get("intent", "general")
1770
- _safe_metrics_bucket("intents", intent)
1771
- casual_hint = cls_result.get("casual_answer", "")
1772
-
1773
- if intent == "social":
1774
- answer = casual_hint if casual_hint and len(casual_hint.strip()) > 20 else await _libbee_casual_response(question, history, model, casual_hint)
1775
- follow_up_question, follow_up_suggestions = _social_follow_up()
1776
- return AgentResponse(
1777
- answer=answer + _source_badge("libbee"),
1778
- intent="social_greeting",
1779
- tools_used=["libbee_casual"],
1780
- model_used=model,
1781
- response_time=time.time() - start_time,
1782
- follow_up_question=follow_up_question,
1783
- follow_up_suggestions=follow_up_suggestions,
1784
- )
1785
-
1786
- if intent == "library_info":
1787
- try:
1788
- from app import rag_service
1789
- rag_results = await rag_service.hybrid_search(question, top_k=4)
1790
- except Exception as e:
1791
- logger.error(f"RAG search failed: {e}")
1792
- rag_results = []
1793
-
1794
- if not rag_results:
1795
- answer = (
1796
- "I couldn't find specific information on that in my knowledge base.<br><br>"
1797
- "Please contact our librarians directly:<br>"
1798
- '📧 <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
1799
- f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
1800
- )
1801
- answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
1802
- sources = []
1803
- tools_used = ["rag_no_results"]
1804
- else:
1805
- answer = await _rag_answer(question, rag_results, history, model)
1806
- answer += _source_badge("rag", "https://library.ku.ac.ae/")
1807
- sources = [{"title": r.get("title", "Library KB"), "url": r.get("source", "")} for r in rag_results if r.get("source")]
1808
- tools_used = ["rag_hybrid_search"]
1809
-
1810
- follow_up_question, follow_up_suggestions = _library_follow_up(question)
1811
- return AgentResponse(
1812
- answer=answer,
1813
- intent="library_info",
1814
- tools_used=tools_used,
1815
- sources=sources,
1816
- ask_librarian=not bool(rag_results),
1817
- model_used=model,
1818
- response_time=time.time() - start_time,
1819
- follow_up_question=follow_up_question,
1820
- follow_up_suggestions=follow_up_suggestions,
1821
- contact_html=_contact_footer_html(question, "library_info"),
1822
- )
1823
-
1824
- if intent in ("search_academic", "search_medical"):
1825
- topic = await _extract_topic(question, model)
1826
- topic = _title_case_topic(topic)
1827
- year_from, year_to = _parse_year_filters(question)
1828
- context = SearchContextPayload(
1829
- context_id=str(uuid.uuid4()),
1830
- topic=topic,
1831
- display_topic=topic,
1832
- intent=intent,
1833
- source="primo",
1834
- resource_type=_derive_resource_type(question),
1835
- peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
1836
- open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
1837
- year_from=year_from,
1838
- year_to=year_to,
1839
- )
1840
-
1841
- context = await _prepare_queries(question, context, model, False)
1842
- summary_mode = _is_summary_request(question)
1843
- deep_research_mode = bool(re.search(
1844
- r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
1845
- question, re.IGNORECASE,
1846
- ))
1847
-
1848
- if summary_mode or deep_research_mode:
1849
- answer, search_results, sources = await _research_snapshot(context, model)
1850
- if deep_research_mode and "Research guide" not in answer:
1851
- answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1852
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1853
- _safe_metrics_increment("search_handoffs")
1854
- return AgentResponse(
1855
- answer=answer,
1856
- intent=intent,
1857
- tools_used=["research_snapshot"],
1858
- search_results=search_results,
1859
- sources=sources,
1860
- model_used=model,
1861
- response_time=time.time() - start_time,
1862
- corrected_query=context.topic,
1863
- natural_query=context.ai_tool_query,
1864
- database_query=context.primo_boolean_query,
1865
- ai_tool_query=context.ai_tool_query,
1866
- primo_boolean_query=context.primo_boolean_query,
1867
- primo_search_url=_primo_clean_url(context),
1868
- follow_up_question=follow_up_question,
1869
- follow_up_suggestions=follow_up_suggestions,
1870
- search_context=_context_to_dict(context),
1871
- primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1872
- pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if intent == "search_medical" else None),
1873
- contact_html=_contact_footer_html(question, intent),
1874
- )
1875
-
1876
- answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
1877
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
1878
- return AgentResponse(
1879
- answer=answer,
1880
- intent=intent,
1881
- tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
1882
- search_results=search_results,
1883
- sources=sources,
1884
- model_used=model,
1885
- response_time=time.time() - start_time,
1886
- corrected_query=context.topic,
1887
- natural_query=context.ai_tool_query,
1888
- database_query=context.primo_boolean_query,
1889
- ai_tool_query=context.ai_tool_query,
1890
- primo_boolean_query=context.primo_boolean_query,
1891
- primo_search_url=source_url,
1892
- follow_up_question=follow_up_question,
1893
- follow_up_suggestions=follow_up_suggestions,
1894
- search_context=_context_to_dict(context),
1895
- primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1896
- pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if intent == "search_medical" else None),
1897
- contact_html=_contact_footer_html(question, intent),
1898
- )
1899
-
1900
- if intent == "general_recent":
1901
- answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
1902
- follow_up_question, follow_up_suggestions = _general_follow_up(question)
1903
- return AgentResponse(
1904
- answer=answer,
1905
- intent="general_recent",
1906
- tools_used=["web_search"],
1907
- model_used=model,
1908
- response_time=time.time() - start_time,
1909
- follow_up_question=follow_up_question,
1910
- follow_up_suggestions=follow_up_suggestions,
1911
- contact_html=_contact_footer_html(question, "general_recent"),
1912
- )
1913
-
1914
- answer = await _web_search_answer(question, history, model) + _source_badge("web")
1915
- follow_up_question, follow_up_suggestions = _general_follow_up(question)
1916
- return AgentResponse(
1917
- answer=answer,
1918
- intent="general",
1919
- tools_used=["web_search"],
1920
- model_used=model,
1921
- response_time=time.time() - start_time,
1922
- follow_up_question=follow_up_question,
1923
- follow_up_suggestions=follow_up_suggestions,
1924
- contact_html=_contact_footer_html(question, "general"),
1925
- )
1926
-
1927
-
1928
- # ── Utility Endpoints ──────────────────────────────────────────────────────────
1929
-
1930
- @router.get("/test")
1931
- async def test():
1932
  return {
1933
- "status": "ok",
1934
- "message": "Agent endpoint is working!",
1935
- "staff_loaded": len(STAFF_DIRECTORY),
1936
  }
1937
 
1938
 
1939
- @router.get("/rag-status")
1940
- async def rag_status():
1941
- try:
1942
- from app import rag_service
1943
- return rag_service.get_stats()
1944
- except Exception as e:
1945
- return {"error": str(e), "ready": False}
 
1
+ """LibBee - Main Application Entry Point
2
+ Phase 3: production hardening, persistence, metrics, and safer staff routing
3
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import logging
 
5
  import time
6
+ from collections import defaultdict, deque
7
+ from contextlib import asynccontextmanager
8
  from datetime import datetime
 
 
9
 
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import JSONResponse
13
 
14
  from src.config import get_settings
15
+ from src.services.cache_service import CacheService
16
+ from src.services.metrics_service import MetricsService
17
+ from src.services.rag_service import RAGService
 
 
 
 
 
18
 
19
+ logging.basicConfig(level=logging.INFO)
20
  logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ rag_service = RAGService()
23
+ cache_service = CacheService()
24
+ metrics_service = MetricsService(get_settings().metrics_path)
25
+ _request_log: dict[str, deque] = defaultdict(deque)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
+ @asynccontextmanager
29
+ async def lifespan(app: FastAPI):
30
+ from src.services.staff_service import build_staff_index
 
 
 
 
 
 
 
 
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  settings = get_settings()
33
+ build_staff_index()
34
+ logger.info("Staff index built")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  if settings.openai_api_key:
37
+ await rag_service.initialize(openai_api_key=settings.openai_api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  else:
39
+ logger.warning("OPENAI_API_KEY not set - RAG disabled")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ logger.info("LibBee startup complete")
42
+ yield
43
+ logger.info("LibBee shutting down")
44
 
 
 
 
 
 
 
 
 
45
 
46
+ app = FastAPI(
47
+ title="LibBee - KU Library AI",
48
+ description="Khalifa University Library AI Assistant",
49
+ version="3.0.0",
50
+ lifespan=lifespan,
51
+ )
52
 
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=[
56
+ "https://nikeshn.github.io",
57
+ "http://localhost:8080",
58
+ "http://localhost:3000",
59
+ "http://127.0.0.1:5500",
60
+ ],
61
+ allow_credentials=True,
62
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
63
+ allow_headers=["*"],
64
+ max_age=86400,
65
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
 
68
+ @app.middleware("http")
69
+ async def operational_guardrails(request: Request, call_next):
 
 
 
70
  settings = get_settings()
71
+ if settings.maintenance_mode and request.url.path not in {"/", "/config"}:
72
+ return JSONResponse(status_code=503, content={"detail": "LibBee is in maintenance mode. Please try again shortly."})
73
+
74
+ client_ip = request.client.host if request.client else "unknown"
75
+ now = time.time()
76
+ bucket = _request_log[client_ip]
77
+ while bucket and now - bucket[0] > 60:
78
+ bucket.popleft()
79
+ if len(bucket) >= settings.rate_limit_per_minute:
80
+ metrics_service.incr_bucket("errors", "rate_limit")
81
+ return JSONResponse(status_code=429, content={"detail": "Too many requests. Please slow down and try again."})
82
+ bucket.append(now)
 
 
 
 
 
 
 
83
 
84
  try:
85
+ response = await call_next(request)
86
+ return response
87
+ except Exception as exc:
88
+ logger.exception("Unhandled application error: %s", exc)
89
+ metrics_service.incr_bucket("errors", "unhandled_exception")
90
+ return JSONResponse(status_code=500, content={"detail": "Unexpected server error."})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
+ from src.api import admin, agent, feedback, search # noqa: E402
94
 
95
+ app.include_router(agent.router, prefix="/agent", tags=["Agent"])
96
+ app.include_router(search.router, prefix="/search", tags=["Search"])
97
+ app.include_router(admin.router, prefix="/admin", tags=["Admin"])
98
+ app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
99
 
 
100
 
101
+ @app.get("/")
102
+ def health_check():
 
 
 
 
103
  settings = get_settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  return {
105
+ "status": "ok",
106
+ "version": "3.0.0",
107
+ "service": "LibBee KU Library AI",
108
+ "rag_ready": rag_service.is_ready(),
109
+ "maintenance_mode": settings.maintenance_mode,
110
+ "endpoints": ["/agent", "/search", "/admin", "/feedback"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ @app.get("/config")
115
+ def public_config():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  settings = get_settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  return {
118
+ "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
119
+ "max_results": settings.max_results,
120
+ "maintenance_mode": settings.maintenance_mode,
121
  }
122
 
123
 
124
+ @app.get("/year")
125
+ def get_year():
126
+ now = datetime.utcnow()
127
+ return {"year": now.year, "month": now.month, "date": now.strftime("%Y-%m-%d")}