diff --git "a/app.py" "b/app.py"
--- "a/app.py"
+++ "b/app.py"
@@ -1,219 +1,3446 @@
-"""LibBee - Main Application Entry Point
-Phase 3: production hardening, persistence, metrics, and safer staff routing
-
-Fixes applied (v3.1):
-1. _request_log replaced with cachetools.TTLCache — no more unbounded IP dict
-2. MetricsService background flush task started in lifespan
-3. Circular import risk reduced — rag_service / metrics_service exposed via
- module-level accessors get_rag_service() / get_metrics_service() so routers
- can import the accessor instead of doing a deferred 'from app import ...'
-4. Startup errors no longer silently swallow RAG init exceptions
"""
-import asyncio
+agent.py — LibBee v3.1
+
+Fixes applied over v3:
+1. CRITICAL: Removed broken import of build_primo_boolean_query,
+ build_primo_discovery_url, build_pubmed_url from search.py — those
+ functions do not exist there. Replaced with self-contained implementations
+ directly in this file. This was the primary cause of the 500 errors.
+
+2. Circular import hardened: 'from app import rag_service / metrics_service'
+ replaced with 'from app import get_rag_service / get_metrics_service'
+ accessors that are safe to call inside request handlers.
+
+3. Deprecated Claude model string updated:
+ claude-3-haiku-20240307 → claude-haiku-4-5-20251001
+
+4. _llm_classify and all LLM call sites wrapped with explicit try/except
+ so any LLM failure falls back to rule-based logic, never 500s.
+
+5. AgentRequest model field validated: model is now Literal["gpt", "claude"]
+ so malformed values are caught at the Pydantic layer.
+
+6. _web_search_answer: updated claude model string to match fix #3.
+
+7. _contact_footer_for_query: removed broken _find_staff_by_token("muna")
+ default — Muna is not in STAFF_DIRECTORY, so default now falls through
+ to no specialist rather than silently returning None.
+"""
+import html
+import json
import logging
+import re
import time
-from contextlib import asynccontextmanager
+import uuid
+from datetime import datetime
+from typing import Any, Dict, List, Literal, Optional, Tuple
+from urllib.parse import quote
-from fastapi import FastAPI, Request
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import JSONResponse
-
-try:
- from cachetools import TTLCache
- _ttl_cache_available = True
-except ImportError:
- _ttl_cache_available = False
+import httpx
+from fastapi import APIRouter, Request
+from pydantic import BaseModel, ConfigDict, Field
from src.config import get_settings
-from src.services.cache_service import CacheService
-from src.services.metrics_service import MetricsService
-from src.services.rag_service import RAGService
+from src.services.staff_service import (
+ STAFF_DIRECTORY,
+ match_staff_name,
+ match_staff_role,
+ should_attempt_staff_lookup,
+ staff_name_answer,
+ staff_role_answer,
+)
-logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
+router = APIRouter()
-rag_service = RAGService()
-cache_service = CacheService()
-metrics_service = MetricsService(get_settings().metrics_path)
-# ── Rate-limit request log ─────────────────────────────────────────────────────
-# TTLCache(maxsize, ttl) auto-evicts entries older than ttl seconds.
-# Falls back to a plain dict with manual sweep if cachetools not installed.
-if _ttl_cache_available:
- _request_log = TTLCache(maxsize=10_000, ttl=120)
- _request_log_lock = asyncio.Lock()
-else:
- from collections import defaultdict, deque
- _request_log = defaultdict(deque) # type: ignore[assignment]
- _request_log_lock = asyncio.Lock()
+# ── Pydantic Models ───────────────────────────────────────────────────────────
+class ChatMessage(BaseModel):
+ role: str
+ content: str
-# ── Public accessors (used by routers to avoid circular imports) ───────────────
-def get_rag_service() -> RAGService:
- return rag_service
+class SearchContextPayload(BaseModel):
+ context_id: str
+ topic: str
+ display_topic: Optional[str] = None
+ intent: str = "search_academic"
+ source: str = "primo"
+ resource_type: str = "articles" # "articles" | "books" | "both"
+ peer_reviewed: bool = False
+ open_access: bool = False
+ year_from: Optional[str] = None
+ year_to: Optional[str] = None
+ ai_tool_query: Optional[str] = None
+ primo_boolean_query: Optional[str] = None
+ created_at: float = Field(default_factory=time.time)
-def get_metrics_service() -> MetricsService:
- return metrics_service
+class ClientStatePayload(BaseModel):
+ session_id: Optional[str] = None
+ active_search_context_id: Optional[str] = None
+ recent_search_contexts: List[SearchContextPayload] = Field(default_factory=list)
+ follow_up_action: Optional[str] = None
+ follow_up_context_id: Optional[str] = None
-# ── Background flush task ──────────────────────────────────────────────────────
+class AgentRequest(BaseModel):
+ question: str
+ # FIX: validated literal — malformed model values caught at Pydantic layer
+ model: Literal["gpt", "claude"] = "gpt"
+ history: List[ChatMessage] = Field(default_factory=list)
+ client_state: Optional[ClientStatePayload] = None
+
+
+class AgentResponse(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ answer: str
+ intent: str = "general"
+ tools_used: List[str] = Field(default_factory=list)
+ search_results: List[dict] = Field(default_factory=list)
+ sources: List[dict] = Field(default_factory=list)
+ model_used: str = "gpt"
+ response_time: float = 0
+ corrected_query: Optional[str] = None
+ natural_query: Optional[str] = None
+ database_query: Optional[str] = None
+ ask_librarian: Optional[bool] = None
+ is_follow_up: bool = False
+ follow_up_question: Optional[str] = None
+ follow_up_suggestions: List[dict] = Field(default_factory=list)
+ search_context: Optional[dict] = None
+ ai_tool_query: Optional[str] = None
+ primo_boolean_query: Optional[str] = None
+ primo_search_url: Optional[str] = None
+ primo_ai_url: Optional[str] = None
+ pubmed_search_url: Optional[str] = None
+
+
+# ── Constants ─────────────────────────────────────────────────────────────────
+
+LIBRARY_HOURS_URL = "https://library.ku.ac.ae/hours"
+KU_MAIN_URL = "https://www.ku.ac.ae/"
+ASK_LIBRARIAN_URL = "https://library.ku.ac.ae/AskUs"
+PRIMO_BASE_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU"
+PRIMO_AI_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/researchAssistant?vid=971KUOSTAR_INST:KU&query="
+HISTORY_WINDOW = 6
+CURRENT_YEAR = datetime.utcnow().year
+
+_RESOURCE_TYPE_NOISE = re.compile(
+ r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
+ r"|peer[- ]reviewed|open[- ]access|studies|study|research)\b",
+ re.IGNORECASE,
+)
+
+SOCIAL_RE = re.compile(
+ r"^(hi|hello|hey|good morning|good afternoon|good evening|how are you|hows it going"
+ r"|how are you doing|who are you|what are you|tell me about yourself|what can you do"
+ r"|what do you do|how can you help|what help can you provide|are you a bot|are you a robot"
+ r"|are you ai|are you an ai|thank you|thanks|thx|bye|goodbye|see you|lol|haha"
+ r"|you are silly|are you silly|are you dumb|stupid bot|dumb bot|idiot bot"
+ r"|joke|tell me a joke)$",
+ re.IGNORECASE,
+)
+PURE_GREETING_RE = re.compile(
+ r"^(hi|hello|hey|good morning|good afternoon|good evening)[!.\s]*$",
+ re.IGNORECASE,
+)
+GREETING_FOLLOWUP_RE = re.compile(
+ r"^(yes|yes please|yes check|sure|okay|ok|yeah|yep|please do|go ahead)$",
+ re.IGNORECASE,
+)
+HOURS_RE = re.compile(
+ r"\b(library hours?|opening hours?|closing hours?|open hours?|open today|open tomorrow"
+ r"|open now|open on|open this|closed today|closed tomorrow|closed on|closed this"
+ r"|when.*open|when.*close|when.*does.*library|what time.*open|what time.*close"
+ r"|what time.*library|opening time|closing time|is.*library.*open|is.*campus.*library"
+ r"|is.*habshan.*open|is.*san.*open|is.*library.*closed|will.*library.*open"
+ r"|will.*it.*open|does.*library.*open|library.*open.*today|library.*close.*today"
+ r"|habshan.*open|san.*open|main campus.*open|ramadan hours?|weekend hours?"
+ r"|friday hours?|saturday hours?|sunday hours?|library.*schedule|schedule.*library"
+ r"|what.*hours|till.*when|until.*when|what day.*open|which day.*open"
+ r"|what day.*closed|which day.*closed)\b",
+ re.IGNORECASE,
+)
+KU_CAMPUS_RE = re.compile(
+ r"\b(where is the library|where is.*library|library.*location|location.*library"
+ r"|which building|what building|which floor|main campus library|san campus library"
+ r"|habshan library|sas al nakhl library|library.*address|address.*library"
+ r"|how to get to.*library|how.*reach.*library|how.*find.*library"
+ r"|library.*campus(es)?|campus(es)?.*library|how many.*librar|how many.*branch"
+ r"|library.*branch(es)?|branch(es)?.*library|which campus.*library"
+ r"|library.*which campus|library.*map|map.*library|how many libraries"
+ r"|what libraries|which libraries|(ku|khalifa).*(has|have|got).*(librar|branch)"
+ r"|(librar|branch).*(ku|khalifa).*(has|have)"
+ r"|(all|both|two|2).*(libraries|library|branches)"
+ r"|tell me about.*(campus|libraries|branches)|describe.*(campus|libraries)"
+ r"|(what|where).*(campus|campuses).*(librar))\b",
+ re.IGNORECASE,
+)
+RESEARCH_CUE_RE = re.compile(
+ r"\b(find|search|look for|show me|get me|give me|locate|recommend|suggest"
+ r"|articles?|papers?|books?|journals?|studies|literature|research|systematic review"
+ r"|evidence|sources|database|databases|peer reviewed|open access|advances?"
+ r"|approach(es)?|method(s|ology)?|technique[s]?|application[s]?|framework[s]?"
+ r"|model[s]?|algorithm[s]?|publication[s]?|recent work|current state|state of the art)\b",
+ re.IGNORECASE,
+)
+LIBRARY_CUE_RE = re.compile(
+ r"\b(library|librarian|borrow|loan|renew|fine|study room|room booking|reserve a room"
+ r"|account|my library|interlibrary|ill|khazna|orcid|open access|apc|refworks"
+ r"|libkey|hours|location|contact|visitor|alumni|database access|off campus|remote access)\b",
+ re.IGNORECASE,
+)
+MEDICAL_SEARCH_RE = re.compile(
+ r"\b(pubmed|embase|cinahl|clinicalkey|cochrane|uptodate|medline|systematic review"
+ r"|clinical trial|biomedical literature|medical literature)\b",
+ re.IGNORECASE,
+)
+CURRENT_INFO_RE = re.compile(
+ r"\b(current|currently|latest|today|recent|recently|now|this week|this month"
+ r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
+ re.IGNORECASE,
+)
+SUMMARY_RE = re.compile(
+ r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
+ r"|tell me about|give me a summary|give me an overview|give me a brief"
+ r"|brief (summary|overview|explanation|description)"
+ r"|research (overview|summary|landscape|state)"
+ r"|state of (the art|research|the field)"
+ r"|recent (advances?|developments?|progress|trends?)"
+ r"|current (state|trends?|advances?|research)"
+ r"|deep analysis|analysis of|review of|literature review|deep research|deep dive"
+ r"|what do we know about|how does|how do)\b",
+ re.IGNORECASE,
+)
+DATABASE_RECOMMEND_RE = re.compile(
+ r"("
+ # explicit recommendation phrasing
+ r"\b(best database|best databases|which database|which databases"
+ r"|database for|databases for"
+ r"|where should i search|where should i start)\b"
+ # subscription / access phrasing
+ r"|databases?\s+(does|did|do)\s+.{0,40}subscri"
+ r"|\bsubscribed\s+databases?\b"
+ r"|\blibrary\s+subscri"
+ r"|\bwhat\s+databases?\b"
+ r"|\bdatabases?\s+(available|accessible)\b"
+ r"|\bdatabases?\s+(at|in|through|via)\s+(ku|khalifa)\b"
+ r"|\bku\s+(library\s+)?databases?\b"
+ r"|\bkhalifa.{0,20}databases?\b"
+ r"|\blibrary\s+databases?\b"
+ r"|\be.?resources?\s+(available|subscri)\b"
+ r"|\baccess\s+to\s+databases?\b"
+ r"|\blist\s+of\s+databases?\b"
+ # comparison phrasing ← NEW
+ r"|\b(compare|comparison|difference|differences)\s+(between\s+)?(\w+\s+)?(and\s+)?\w*\s*database"
+ r"|\bdifference\s+between.{0,60}database"
+ r"|\bscopus\s+(vs|versus|or)\s+(web of science|wos|google scholar)\b"
+ r"|\b(web of science|wos)\s+(vs|versus|or)\s+(scopus|google scholar)\b"
+ r")",
+ re.IGNORECASE,
+)
+
+# ── New pattern detection regexes ────────────────────────────────────────────
+
+HIGHLY_CITED_RE = re.compile(
+ r"\b(highly cited|most cited|top cited|citation count|most influential"
+ r"|most referenced|high impact papers?|most downloaded)\b",
+ re.IGNORECASE,
+)
+
+BOOLEAN_STRATEGY_RE = re.compile(
+ r"\b(search strategy|boolean (search|operator|query|logic)"
+ r"|build.{0,20}search|help.{0,20}search strategy"
+ r"|how.{0,30}search.{0,30}(database|boolean|keyword)"
+ r"|refine.{0,30}(topic|question|research question|searchable)"
+ r"|turn.{0,30}(topic|question).{0,20}search"
+ r"|keywords? for.{0,30}search|search terms?"
+ r"|explain.{0,20}keyword|why.{0,20}(chose|choose|selected|pick).{0,20}keyword)\b",
+ re.IGNORECASE,
+)
+
+FULLTEXT_REQUEST_RE = re.compile(
+ r"\b("
+ # PDF / access problem — only fires when there's an explicit access failure
+ r"(can.t|cannot|couldn.t|don.t have|doesn.t have|no).{0,30}(access|pdf|full.?text)"
+ r"|(pdf|full.?text).{0,20}(not available|unavailable|can.t find|not found|missing)"
+ r"|found.{0,30}citation.{0,20}(but|not).{0,20}(pdf|full.?text)"
+ r"|article.{0,20}(behind.{0,10}paywall|not available|no access|access denied)"
+ r"|ku.{0,20}(doesn.t|does not|no).{0,20}(have|access).{0,20}(article|journal|pdf)"
+ r"|(how|where).{0,20}(get|find|access|download).{0,20}(full.?text|pdf).{0,20}article"
+ r"|get full.?text|access full.?text|download.{0,10}(article|pdf)"
+ r"|ku.{0,15}(don.t|doesn.t|no).{0,15}(subscribe|have access)"
+ r")\b",
+ re.IGNORECASE,
+)
+
+PARTIAL_TITLE_RE = re.compile(
+ r"\b(only (remember|recall|know).{0,20}(title|name)"
+ r"|title (starts?|begins?).{0,15}with"
+ r"|article.{0,20}(called|titled|named|starts? with)"
+ r"|find.{0,20}(article|paper).{0,20}(called|titled|named)"
+ r"|looking for.{0,20}(paper|article).{0,20}(about|called))\b",
+ re.IGNORECASE,
+)
+
+DUAL_RESOURCE_RE = re.compile(
+ r"\b(articles?.{0,30}(and|as well as|plus).{0,30}books?"
+ r"|books?.{0,30}(and|as well as|plus).{0,30}articles?"
+ r"|papers?.{0,30}(and|as well as|plus).{0,30}textbooks?"
+ r"|textbooks?.{0,30}(and|as well as|plus).{0,30}papers?"
+ r"|(literature review|articles?).{0,50}(background|textbook|introductory book)"
+ r"|(textbook|book).{0,50}(literature review|research articles?))\b",
+ re.IGNORECASE,
+)
+
+THESES_RE = re.compile(
+ r"\b(thes(is|es)|dissertation|doctoral|phd thesis|master.s thesis"
+ r"|khazna|ku repository|institutional repository"
+ r"|proquest dissertations?)\b",
+ re.IGNORECASE,
+)
+
+PREDATORY_RE = re.compile(
+ r"\b(predatory (journal|publisher|publication)"
+ r"|how.{0,20}(know|tell|check).{0,30}(peer.?reviewed|scholarly|legitimate|reputable)"
+ r"|is.{0,30}(journal|article).{0,30}(legitimate|reputable|trusted|peer.?reviewed)"
+ r"|peer.?reviewed vs|distinguish.{0,20}(peer|scholarly|predatory)"
+ r"|journal (quality|credibility|ranking|whitelist|blacklist)"
+ r"|beall.s list|doaj|cabells|think.check.submit)\b",
+ re.IGNORECASE,
+)
+
+CITATION_CHAIN_RE = re.compile(
+ r"\b(cited by|forward citation|backward citation|reference.{0,15}(chain|mining)"
+ r"|trace.{0,30}(reference|citation)|who cited|papers? that cite"
+ r"|related (articles?|papers?).{0,20}(paper|article)"
+ r"|find similar.{0,20}(paper|article))\b",
+ re.IGNORECASE,
+)
+
+REVIEW_ONLY_RE = re.compile(
+ r"\b(review (articles?|papers?|only)|only (review|systematic review)"
+ r"|limit.{0,20}review|show.{0,20}review|filter.{0,20}review"
+ r"|systematic reviews? only|literature reviews? only)\b",
+ re.IGNORECASE,
+)
+
+ALT_TERMS_RE = re.compile(
+ r"\b(alternative (search terms?|keywords?|terms?)"
+ r"|other (search terms?|keywords?|ways? to search)"
+ r"|more (keywords?|search terms?)|suggest.{0,20}keywords?"
+ r"|related (terms?|keywords?|synonyms?)|synonyms? for.{0,20}search"
+ r"|different (keywords?|terms?|search))\b",
+ re.IGNORECASE,
+)
+
+# ─────────────────────────────────────────────────────────────────────────────
+
+MEDICAL_KEYWORDS = [
+ "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
+ "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
+ "genomics", "pathology", "surgery", "mental health", "nutrition", "public health",
+ "disease", "therapy", "treatment", "diagnosis", "patient", "drug", "pharmaceutical",
+ "hospital", "cancer", "diabetes", "heart", "brain", "cell biology", "immunology",
+ "biochemistry", "microbiology", "virology", "pediatric", "psychiatric", "dental",
+ "ophthalmology", "radiology", "anesthesia", "emergency medicine", "geriatric",
+ "crispr", "gene therapy", "gene editing", "genomic medicine", "precision medicine",
+]
+
+REFINEMENT_STOP_WORDS = {
+ "make", "it", "them", "those", "these", "the", "a", "an", "for", "to", "only", "show",
+ "find", "give", "me", "please", "and", "with", "within", "limit", "narrow", "search",
+ "results", "on", "about", "of", "this", "that", "topic", "same", "using", "from",
+ "peer", "reviewed", "review", "open", "access", "books", "book", "articles", "article",
+ "papers", "paper", "years", "year", "last", "past", "recent", "instead", "summary",
+ "overview", "brief", "pubmed", "primo", "database", "databases", "best", "deep", "research",
+ "tools", "tool", "guide", "ku", "library",
+}
+
+_CLASSIFIER_SYSTEM = """You are the routing brain of LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE).
+Your only job: read the user message and return a single JSON object routing it to the right handler.
+
+INTENTS — pick exactly one:
+ "social" — casual chat, greetings, emotions, compliments, complaints, vague help requests
+ "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
+ "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
+ "search_medical" — same as search_academic but topic is medical/clinical/nursing/pharmacy/health sciences
+ "general_recent" — questions about TRULY live/real-time data: breaking news, live sports scores, today's weather, current stock prices
+ "general" — ALL other factual questions: university info, people, history, science definitions, technology explanations
+
+RULES:
+ 1. When in doubt between library_info and social, choose library_info if ANY library keyword is present.
+ 2. "I don't know how to search for articles" = library_info.
+ 3. "I am fed up with searching" alone = social.
+ 4. "I need help finding articles on diabetes" = search_medical.
+ 5. KU = Khalifa University. Do NOT confuse with Kuwait University.
+ 6. ANY summary, overview, analysis, deep dive, review, or explanation of an academic/research topic = search_academic or search_medical.
+ 7. Questions about people, university leadership, history, science definitions = general.
+ 8. Only use general_recent for truly live data changing by the minute.
+
+For "social" intent ONLY: include "casual_answer" — a warm 1-3 sentence LibBee response (no markdown, no bullets).
+For all other intents: omit "casual_answer".
+
+Respond ONLY with valid JSON. No preamble, no markdown fences.
+"""
+
+
+# ── Utilities ─────────────────────────────────────────────────────────────────
+
+def _escape(text: str) -> str:
+ return html.escape(text or "")
+
+
+def _normalize_whitespace(text: str) -> str:
+ return re.sub(r"\s+", " ", (text or "").strip())
+
+
+def _title_case_topic(topic: str) -> str:
+ return _normalize_whitespace(topic).strip().strip(".?")
+
+
+def _strip_resource_noise(text: str) -> str:
+ cleaned = _RESOURCE_TYPE_NOISE.sub(" ", text or "")
+ cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
+ cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
+ cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
+ return _normalize_whitespace(cleaned)
+
+
+def _source_badge(source: str, url: str = "") -> str:
+ styles = {
+ "rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
+ "primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
+ "semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
+ "pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
+ "web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
+ "web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
+ "libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
+ "ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
+ "llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
+ }
+ icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
+ inner = f"{icon} Source: {label}"
+ if url:
+ inner = f'{inner}'
+ return (
+ f'
{inner}
'
+ )
+
+
+# FIX: use get_metrics_service() accessor to avoid circular import fragility
+def _safe_metrics_increment(key: str) -> None:
+ try:
+ from app import get_metrics_service
+ get_metrics_service().incr(key)
+ except Exception:
+ return
+
+
+def _safe_metrics_bucket(bucket: str, key: str) -> None:
+ try:
+ from app import get_metrics_service
+ get_metrics_service().incr_bucket(bucket, key)
+ except Exception:
+ return
+
+
+def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
+ msgs = []
+ for m in history[-HISTORY_WINDOW:]:
+ if m.role in ("user", "assistant") and m.content:
+ msgs.append({"role": m.role, "content": m.content[:300]})
+ return msgs
+
+
+def _is_summary_request(question: str) -> bool:
+ return bool(SUMMARY_RE.search(question or ""))
+
+
+def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
+ if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
+ return False
+ for m in reversed(history):
+ if m.role == "assistant":
+ last = m.content.lower()
+ return "i'm libbee" in last or "are you looking for one of these" in last
+ return False
+
+
+def _looks_library_hours_question(question: str) -> bool:
+ q = (question or "").strip().lower()
+ return bool(HOURS_RE.search(q)) and any(
+ kw in q for kw in (
+ "library", "campus", "habshan", "san", "hours", "open", "close",
+ "schedule", "till", "until", "today", "tomorrow", "friday",
+ "saturday", "sunday", "week", "ramadan",
+ )
+ )
+
+
+def _looks_campus_question(question: str) -> bool:
+ return bool(KU_CAMPUS_RE.search(question or ""))
+
+
+def _looks_medical_search(question: str) -> bool:
+ q = (question or "").lower()
+ if not any(kw in q for kw in MEDICAL_KEYWORDS):
+ return False
+ return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
+
+
+def _looks_research_question(question: str) -> bool:
+ q = question or ""
+ return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
+
+
+def _rule_based_classify(question: str) -> Dict[str, str]:
+ q = (question or "").lower()
+ if SOCIAL_RE.match((question or "").strip()):
+ return {
+ "intent": "social",
+ "casual_answer": (
+ "Hello! I'm LibBee, the KU Library AI Assistant. "
+ "I'm here to help you with articles, books, databases, and library services. "
+ "What would you like to find today?"
+ ),
+ }
+ if _looks_medical_search(question):
+ return {"intent": "search_medical"}
+ if _looks_research_question(question):
+ return {"intent": "search_academic"}
+ if LIBRARY_CUE_RE.search(q):
+ return {"intent": "library_info"}
+ if CURRENT_INFO_RE.search(q):
+ return {"intent": "general_recent"}
+ return {"intent": "general"}
+
+
+def _hours_answer() -> str:
+ """
+ Return a rich, day-aware library hours answer with correct Normal and Semester Break schedules.
+ Always links to https://library.ku.ac.ae/hours for live schedule.
+ """
+ from datetime import datetime, timezone, timedelta
+ # UAE is UTC+4
+ uae_now = datetime.now(timezone(timedelta(hours=4)))
+ day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+ today = day_names[uae_now.weekday()]
+
+ # Normal hours (classes in session)
+ _NORMAL = {
+ "Monday": ("Open", "8:00 AM – 9:00 PM"),
+ "Tuesday": ("Open", "8:00 AM – 9:00 PM"),
+ "Wednesday": ("Open", "8:00 AM – 9:00 PM"),
+ "Thursday": ("Open", "8:00 AM – 5:00 PM"),
+ "Friday": ("Open", "7:30 AM – 12:30 PM"),
+ "Saturday": ("Closed", ""),
+ "Sunday": ("Open", "12:00 PM – 8:00 PM"),
+ }
+ # Semester break hours (classes not in session)
+ _BREAK = {
+ "Monday": ("Open", "8:00 AM – 4:00 PM"),
+ "Tuesday": ("Open", "8:00 AM – 4:00 PM"),
+ "Wednesday": ("Open", "8:00 AM – 4:00 PM"),
+ "Thursday": ("Open", "8:00 AM – 4:00 PM"),
+ "Friday": ("Open", "7:30 AM – 12:00 PM"),
+ "Saturday": ("Closed", ""),
+ "Sunday": ("Closed", ""),
+ }
+
+ def _day_row(day: str, status: str, hrs: str) -> str:
+ if status == "Closed":
+ color = "#dc2626"
+ label = "Closed"
+ elif "12:00 PM" in hrs or "12:30 PM" in hrs:
+ color = "#d97706"
+ label = hrs
+ else:
+ color = "#16a34a"
+ label = hrs
+ return (
+ f"| {day} | "
+ f"{label} |
"
+ )
+
+ def _today_line(schedule: dict) -> str:
+ status, hrs = schedule.get(today, ("Unknown", ""))
+ if status == "Closed":
+ return (f'📅 Today ({today}): '
+ f'Closed')
+ elif status == "Open":
+ return (f'📅 Today ({today}): '
+ f'Open · {hrs}')
+ return f'📅 Today ({today}): Check live hours page.'
+
+ def _schedule_table(schedule: dict) -> str:
+ rows = "".join(
+ _day_row(d, *schedule[d])
+ for d in ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
+ )
+ return (
+ ""
+ )
+
+ return (
+ "Here are the Khalifa University Library hours:
"
+ f"{_today_line(_NORMAL)}
"
+ "(based on normal semester schedule — see note below)
"
+
+ "📚 Normal Hours "
+ "(when classes are in session)
"
+ + _schedule_table(_NORMAL) +
+
+ "
🏖️ Semester Break Hours "
+ "(when classes are not in session)
"
+ + _schedule_table(_BREAK) +
+
+ "
📍 Both campuses follow the same schedule:
"
+ ""
+ "Main Campus Library · E Building · "
+ '+971 2 312 4604
'
+ "Habshan Library · Building 5, SAN Campus · "
+ '+971 2 312 3160'
+ "
"
+
+ "⚠️ Hours change during Ramadan, public holidays, and exam periods. "
+ "Always check the live schedule for the most current times:
"
+ f'{LIBRARY_HOURS_URL}'
+ )
+
+
+def _campus_answer() -> str:
+ return (
+ "Khalifa University Library has two branches:
"
+ "1. Main Campus Library
"
+ "Location: E Building, Main Campus, Abu Dhabi
"
+ 'Reference desk: +971 2 312 4604
'
+ "2. Habshan Library (SAN Campus)
"
+ "Location: Building 5, Ground Floor & First Floor, Sas Al Nakhl (SAN) Campus, Abu Dhabi
"
+ 'Service desk: +971 2 312 3160
'
+ "Both branches offer study spaces, computer workstations, printing, scanning, "
+ "and access to physical collections.
"
+ f'For general library enquiries: Ask a Librarian '
+ 'or email libse@ku.ac.ae.'
+ )
+
+
+def _greeting_menu_clarify_answer() -> str:
+ return (
+ "Sure — what would you like help with?
"
+ "You can choose one of these or type your question directly:
"
+ "• Find articles and books
"
+ "• Get full text for an article
"
+ "• Submit an Interlibrary Loan (ILL) request
"
+ "• Contact a librarian
"
+ "• Check library hours"
+ )
+
+
+# ── LLM Helpers ───────────────────────────────────────────────────────────────
+
+def _get_llm(model: str, temperature: float, max_tokens: int):
+ settings = get_settings()
+ # FIX: updated model string — claude-3-haiku-20240307 is deprecated
+ if model == "claude" and settings.anthropic_api_key:
+ from langchain_anthropic import ChatAnthropic
+ return ChatAnthropic(
+ model="claude-haiku-4-5-20251001",
+ temperature=temperature,
+ max_tokens=max_tokens,
+ anthropic_api_key=settings.anthropic_api_key,
+ )
+ from langchain_openai import ChatOpenAI
+ return ChatOpenAI(
+ model="gpt-4o-mini",
+ temperature=temperature,
+ max_tokens=max_tokens,
+ openai_api_key=settings.openai_api_key,
+ )
+
+
+async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return _rule_based_classify(question)
+ # FIX: always fall back to rule-based on any exception — never propagate
+ try:
+ ctx_parts = [
+ f"{m.role}: {m.content[:100]}"
+ for m in history[-HISTORY_WINDOW:]
+ if m.role in ("user", "assistant")
+ ]
+ ctx = " | ".join(ctx_parts) if ctx_parts else "none"
+ llm = _get_llm(model, temperature=0, max_tokens=120)
+ response = await llm.ainvoke([
+ {"role": "system", "content": _CLASSIFIER_SYSTEM},
+ {"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
+ ])
+ raw = response.content.strip()
+ if raw.startswith("```"):
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
+ result = json.loads(raw)
+ intent = result.get("intent", "general")
+ if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general"}:
+ intent = "general"
+ return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
+ except Exception as e:
+ logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
+ return _rule_based_classify(question)
-async def _metrics_flush_loop(interval: int = 30) -> None:
- """Flush in-memory metric counters to disk every `interval` seconds."""
- while True:
- await asyncio.sleep(interval)
- try:
- await metrics_service.flush()
- except Exception as exc:
- logger.warning("Metrics flush error: %s", exc)
+async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
+ if hint and len(hint.strip()) > 20:
+ return hint.strip()
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return "I'm LibBee, the Khalifa University Library AI Assistant — always happy to help!"
+ try:
+ llm = _get_llm(model, temperature=0.6, max_tokens=160)
+ msgs = [{"role": "system", "content": (
+ "You are LibBee, the Khalifa University Library AI Assistant. "
+ "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
+ "No markdown, no bullet points."
+ )}]
+ msgs.extend(_build_history_messages(history))
+ msgs.append({"role": "user", "content": question})
+ response = await llm.ainvoke(msgs)
+ return response.content.strip()
+ except Exception:
+ return "I'm LibBee, the Khalifa University Library AI Assistant — always happy to help!"
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- from src.services.staff_service import build_staff_index
+async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return rag_results[0]["content"] if rag_results else ""
+ context = "\n\n---\n\n".join(r["content"] for r in rag_results)
+ system = (
+ "You are LibBee, the Khalifa University Library AI Assistant. "
+ "Answer the user's question using ONLY the context provided below. "
+ "Be concise, friendly, and use HTML
for line breaks where needed. "
+ f"If the context does not contain enough information, say so and suggest Ask a Librarian at {ASK_LIBRARIAN_URL}. "
+ "Never invent facts or URLs.\n\n"
+ f"CONTEXT:\n{context}"
+ )
+ try:
+ llm = _get_llm(model, temperature=0.2, max_tokens=380)
+ msgs = [{"role": "system", "content": system}]
+ msgs.extend(_build_history_messages(history))
+ msgs.append({"role": "user", "content": question})
+ response = await llm.ainvoke(msgs)
+ return response.content.strip()
+ except Exception as e:
+ logger.error(f"_rag_answer error: {e}")
+ return (
+ "I'm having trouble generating an answer right now. "
+ f'Please try Ask a Librarian.'
+ )
+
- # Build staff index
+async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return (
+ "I'm LibBee, the KU Library AI Assistant. "
+ f'For general university information, visit ku.ac.ae.'
+ )
+ system = (
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
+ "Answer factually and concisely in 3-5 sentences. Use HTML
for line breaks. "
+ "KU means Khalifa University. If the topic is academic, briefly offer to help find library resources."
+ )
try:
- build_staff_index()
- logger.info("Staff index built")
- except Exception as exc:
- logger.error("Staff index build failed: %s", exc)
+ llm = _get_llm(model, temperature=0.3, max_tokens=360)
+ msgs = [{"role": "system", "content": system}]
+ msgs.extend(_build_history_messages(history))
+ msgs.append({"role": "user", "content": question})
+ response = await llm.ainvoke(msgs)
+ return response.content.strip()
+ except Exception as e:
+ logger.error(f"_llm_general_answer error: {e}")
+ return f'Having trouble right now. Visit ku.ac.ae.'
+
+
+async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
+ settings = get_settings()
+ # FIX: updated model string to match _get_llm fix
+ if model == "claude" and settings.anthropic_api_key:
+ try:
+ import anthropic
+ client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
+ response = client.messages.create(
+ model="claude-haiku-4-5-20251001",
+ max_tokens=520,
+ system=(
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
+ "Answer using current web search. Be concise and factual. Use HTML
for line breaks."
+ ),
+ tools=[{"type": "web_search_20250305", "name": "web_search"}],
+ messages=[{"role": "user", "content": question}],
+ )
+ text = "".join(block.text for block in response.content if hasattr(block, "text"))
+ if text.strip():
+ return text.strip()
+ except Exception as e:
+ logger.warning(f"Claude web search failed: {e}")
- # Initialize RAG
if settings.openai_api_key:
try:
- await rag_service.initialize(openai_api_key=settings.openai_api_key)
- logger.info("RAG service ready — chunks: %d", len(rag_service.bm25_corpus))
- except Exception as exc:
- logger.error("RAG initialization failed: %s", exc, exc_info=True)
+ from openai import OpenAI
+ client = OpenAI(api_key=settings.openai_api_key)
+ response = client.responses.create(
+ model="gpt-4o-mini",
+ tools=[{"type": "web_search_preview"}],
+ instructions=(
+ "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
+ "Answer using current web search. Be concise and factual. Use HTML
for line breaks."
+ ),
+ input=question,
+ )
+ text = ""
+ for item in response.output:
+ if hasattr(item, "content"):
+ for block in item.content:
+ if hasattr(block, "text"):
+ text += block.text
+ if text.strip():
+ return text.strip()
+ except Exception as e:
+ logger.warning(f"GPT web search failed: {e}")
+
+ return await _llm_general_answer(question, history, model)
+
+
+# ── Topic Extraction ─────────────��────────────────────────────────────────────
+
+def _extract_topic_regex(question: str) -> str:
+ q = _normalize_whitespace(question)
+ prefixes = [
+ "can u give me a deep analysis of ", "can you give me a deep analysis of ",
+ "give me a deep analysis of ", "deep analysis of ",
+ "give me a summary of ", "give me a summary on ", "give me a summary about ",
+ "give me an overview of ", "give me an overview on ",
+ "give me a brief overview of ", "give me a brief summary of ",
+ "summarize the latest studies in ", "summarize latest studies in ",
+ "summarize the latest research on ", "summarize latest research on ",
+ "summarize recent studies in ", "summarize recent research on ",
+ "latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
+ "summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
+ "overview of ", "overview on ", "brief overview of ", "brief summary of ",
+ "analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
+ "what is ", "what are ", "describe ", "review of ", "literature review on ",
+ "recent advances in ", "recent developments in ", "state of the art in ",
+ "state of research on ", "research overview of ", "research summary of ",
+ "find peer reviewed articles on ", "find peer reviewed papers on ",
+ "find articles on ", "find papers on ", "find books on ", "find articles about ",
+ "find papers about ", "search for articles on ", "search for papers on ",
+ "i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
+ "i need books on ", "give me articles on ", "show me articles on ",
+ "get me articles on ", "look for articles on ", "articles on ", "papers on ",
+ "books on ", "research on ", "literature on ", "find ", "search for ",
+ "i need ", "give me ", "show me ", "get me ",
+ ]
+ lower = q.lower()
+ for prefix in prefixes:
+ if lower.startswith(prefix):
+ q = q[len(prefix):]
+ break
+ q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
+ q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
+ q = _strip_resource_noise(q)
+ return _normalize_whitespace(q).strip(".?")
+
+
+async def _extract_topic(question: str, model: str) -> str:
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return _extract_topic_regex(question)
+ try:
+ llm = _get_llm(model, temperature=0, max_tokens=32)
+ response = await llm.ainvoke([
+ {"role": "system", "content": (
+ "Extract only the core research topic from the user's question. "
+ "Return 2-7 keywords ONLY — no punctuation, no sentence, no explanation. "
+ "CRITICAL: do NOT include words like articles, papers, books, journals, "
+ "peer-reviewed, open-access, studies, or research in your output. "
+ "Those are filters, not topic words. Return ONLY the subject matter.\n"
+ "Examples:\n"
+ "'find articles on indian politics' → indian politics\n"
+ "'peer reviewed papers on climate change 2020' → climate change\n"
+ "'books on structural engineering UAE' → structural engineering UAE\n"
+ "'give me a summary of quantum physics advances' → quantum physics advances\n"
+ "'i need peer reviewed research on machine learning healthcare' → machine learning healthcare"
+ )},
+ {"role": "user", "content": question},
+ ])
+ topic = response.content.strip().strip('"').strip("'").strip(".")
+ topic = _strip_resource_noise(topic)
+ topic = _normalize_whitespace(topic).strip(".?")
+ if topic and len(topic) > 2:
+ return topic
+ except Exception as e:
+ logger.warning(f"Topic extraction failed: {e}")
+ return _extract_topic_regex(question)
+
+
+# ── PRIMO / PubMed URL + Query Builders ───────────────────────────────────────
+# FIX: these were previously imported from search.py as
+# build_primo_boolean_query / build_primo_discovery_url / build_pubmed_url
+# but those exports do not exist in search.py.
+# They are now self-contained here.
+
+def _shared_build_primo_boolean_query(topic: str) -> str:
+ """
+ Regex fallback for boolean query construction — used only when LLM is unavailable.
+ Keeps multi-word phrases quoted, strips stop words, ANDs remaining concepts.
+ Does NOT split known compound terms.
+ """
+ clean = _strip_resource_noise(topic)
+ if not clean:
+ clean = topic
+ clean = _normalize_whitespace(clean)
+
+ # Treat the whole cleaned topic as a single phrase if ≤ 4 words
+ words = clean.split()
+ if len(words) <= 4:
+ return f'"{clean}"'
+
+ # For longer topics: split on stop words, quote resulting multi-word chunks
+ _BOOL_STOP = re.compile(
+ r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
+ r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
+ r"this|that|these|those|about|impact|role|effect|use|analysis|review|"
+ r"what|how|why|when|where|which|between|within|across|among|using|"
+ r"based|related|towards|toward|during|after|before|over|under)\b",
+ re.IGNORECASE,
+ )
+ # Split on stop words, keep non-stop chunks
+ parts = _BOOL_STOP.split(clean)
+ concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
+ if not concepts:
+ return f'"{clean}"'
+ if len(concepts) == 1:
+ return f'"{concepts[0]}"'
+ # Quote multi-word concepts, leave single words bare
+ quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
+ return " AND ".join(quoted)
+
+
+_BOOLEAN_SYSTEM = """You are an expert academic database search specialist building PRIMO/ExLibris boolean queries.
+
+Given a research topic or question, output ONLY a valid boolean search string.
+
+RULES:
+1. Identify 2-4 key CONCEPTS in the query.
+2. Multi-word concepts MUST be quoted: "machine learning" not machine AND learning.
+3. Use OR between synonyms of the same concept: ("machine learning" OR "deep learning" OR "neural networks")
+4. Use AND to connect different concepts: ("climate change" OR "global warming") AND ("renewable energy" OR "clean energy")
+5. NEVER split a natural phrase with AND: "global warming" stays "global warming", NOT global AND warming.
+6. NEVER include stop words (of, the, a, in, on, for, to, with, by, impact, role, effect, use, study, analysis, review) as standalone terms.
+7. For medical/clinical topics, include MeSH-style synonyms.
+8. Keep to a maximum of 3 concept groups joined by AND.
+9. Return ONLY the boolean string. No explanation, no preamble, no markdown.
+
+EXAMPLES:
+Input: global warming
+Output: ("global warming" OR "climate change" OR "greenhouse effect")
+
+Input: machine learning in healthcare
+Output: ("machine learning" OR "deep learning" OR "artificial intelligence") AND ("healthcare" OR "medicine" OR "clinical")
+
+Input: impact of social media on mental health
+Output: ("social media" OR "social networks") AND ("mental health" OR "psychological wellbeing" OR "depression")
+
+Input: structural engineering UAE
+Output: ("structural engineering" OR "structural analysis") AND ("UAE" OR "United Arab Emirates" OR "Gulf")
+
+Input: CRISPR gene editing applications
+Output: ("CRISPR" OR "CRISPR-Cas9" OR "gene editing") AND ("applications" OR "therapeutic" OR "treatment")
+
+Input: renewable energy policy
+Output: ("renewable energy" OR "clean energy" OR "sustainable energy") AND ("policy" OR "regulation" OR "governance")
+"""
+
+
+async def _llm_build_boolean_query(topic: str, model: str) -> str:
+ """
+ Use LLM to generate a high-quality PRIMO boolean query with:
+ - Quoted multi-word phrases
+ - OR synonyms within concept groups
+ - AND between distinct concepts
+ Falls back to regex version if LLM fails.
+ """
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return _shared_build_primo_boolean_query(topic)
+ try:
+ llm = _get_llm(model, temperature=0, max_tokens=120)
+ response = await llm.ainvoke([
+ {"role": "system", "content": _BOOLEAN_SYSTEM},
+ {"role": "user", "content": f"Input: {topic.strip()}"},
+ ])
+ result = response.content.strip()
+ # Sanity checks — must contain quotes and AND/OR or be a short phrase
+ if not result or len(result) > 500:
+ raise ValueError("Bad LLM boolean output")
+ # Strip any markdown the LLM snuck in
+ result = re.sub(r"^```[a-z]*\n?", "", result).rstrip("`").strip()
+ # Must have at least one quote or look like a valid query
+ if '"' not in result and ' AND ' not in result and ' OR ' not in result:
+ # Wrap bare output as a phrase
+ result = f'"{result}"'
+ logger.info(f"LLM boolean: {result!r} ← topic: {topic!r}")
+ return result
+ except Exception as e:
+ logger.warning(f"LLM boolean query failed: {e} — using regex fallback")
+ return _shared_build_primo_boolean_query(topic)
+
+
+def _make_primo_boolean_query(context: SearchContextPayload) -> str:
+ """Sync wrapper — returns regex boolean. Async LLM version called from _prepare_queries."""
+ topic = _title_case_topic(context.display_topic or context.topic)
+ topic = _strip_resource_noise(topic) or (context.topic or "library search")
+ return _shared_build_primo_boolean_query(topic)
+
+
+def _shared_build_primo_discovery_url(
+ boolean_query: str,
+ resource_type: str = "articles",
+ peer_reviewed: bool = False,
+ open_access: bool = False,
+ year_from: Optional[str] = None,
+ year_to: Optional[str] = None,
+) -> str:
+ """Build a full PRIMO discovery URL with facets."""
+ base = (
+ "https://khalifa.primo.exlibrisgroup.com/discovery/search"
+ f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
+ f"&query=any,contains,{quote(boolean_query)}"
+ f"&lang=en&search_scope=MyInst_and_CI&sortby=rank&mode=advanced"
+ )
+ facets = []
+ if resource_type == "articles":
+ facets.append("facet_rtype,include,articles")
+ elif resource_type == "books":
+ facets.append("facet_rtype,include,books")
+ if peer_reviewed:
+ facets.append("facet_tlevel,include,peer_reviewed")
+ if open_access:
+ facets.append("facet_tlevel,include,online_resources")
+ if year_from or year_to:
+ yf = year_from or "0001"
+ yt = year_to or "9999"
+ facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
+ for facet in facets:
+ base += f"&multiFacets={quote(facet)}"
+ return base
+
+
+def _shared_build_pubmed_url(
+ topic: str,
+ year_from: Optional[str] = None,
+ year_to: Optional[str] = None,
+ peer_reviewed: bool = False,
+) -> str:
+ """Build a PubMed search URL."""
+ clean = _strip_resource_noise(topic)
+ term = clean or topic
+ if peer_reviewed:
+ term = f"({term}) AND Journal Article[pt]"
+ url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(term)}"
+ if year_from or year_to:
+ yf = year_from or "1900"
+ yt = year_to or str(CURRENT_YEAR)
+ url += f"&filter=datesearch.y_{yf}-{yt}"
+ return url
+
+
+# ── Query Building ────────────────────────────────────────────────────────────
+
+def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
+ q = (question or "").lower()
+ if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
+ return "books"
+ if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
+ return "articles"
+ return existing or "articles"
+
+
+def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
+ q = (question or "").lower()
+ match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
+ if match:
+ years = int(match.group(2))
+ return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
+ 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)
+ if between:
+ return between.group(1), between.group(2)
+ years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
+ if len(years) >= 2:
+ return min(years), max(years)
+ if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
+ return years[0], str(CURRENT_YEAR)
+ return None, None
+
+
+def _make_primo_boolean_query(context: SearchContextPayload) -> str:
+ topic = _title_case_topic(context.display_topic or context.topic)
+ topic = _strip_resource_noise(topic) or (context.topic or "library search")
+ return _shared_build_primo_boolean_query(topic)
+
+
+def _primo_clean_url(context: SearchContextPayload) -> str:
+ boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
+ return _shared_build_primo_discovery_url(
+ boolean_query,
+ resource_type=context.resource_type,
+ peer_reviewed=context.peer_reviewed,
+ open_access=context.open_access,
+ year_from=context.year_from,
+ year_to=context.year_to,
+ )
+
+
+def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
+ topic = context.display_topic or context.topic
+ topic = _title_case_topic(topic)
+ scope = context.resource_type
+ filters = []
+ if context.peer_reviewed:
+ filters.append("peer-reviewed")
+ if context.open_access:
+ filters.append("open-access")
+ if context.year_from and context.year_to:
+ filters.append(f"published between {context.year_from} and {context.year_to}")
+ elif context.year_from:
+ filters.append(f"published from {context.year_from} onward")
+
+ if is_follow_up:
+ if scope == "books":
+ base = f"Find books on {topic}"
+ elif scope == "both":
+ base = f"Find articles and books on {topic}"
+ else:
+ base = f"Find research articles on {topic}"
else:
- logger.warning("OPENAI_API_KEY not set — RAG disabled")
+ base = _normalize_whitespace(question).strip(".?")
+ if len(base.split()) < 4:
+ base = f"Find research on {topic}"
- # Start background metrics flush
- flush_task = asyncio.create_task(_metrics_flush_loop(interval=30))
- logger.info("LibBee startup complete")
+ if filters:
+ return f"{base} with {', '.join(filters)}."
+ return base.rstrip(".") + "."
- yield
- # Graceful shutdown — flush remaining metrics
- flush_task.cancel()
+async def _grammar_refine_query(text: str, model: str) -> str:
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return _normalize_whitespace(text)
try:
- await asyncio.wait_for(metrics_service.flush(), timeout=5.0)
+ llm = _get_llm(model, temperature=0, max_tokens=60)
+ response = await llm.ainvoke([
+ {"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
+ {"role": "user", "content": text},
+ ])
+ refined = _normalize_whitespace(response.content)
+ return refined or _normalize_whitespace(text)
except Exception:
- pass
- logger.info("LibBee shutting down")
+ return _normalize_whitespace(text)
-app = FastAPI(
- title="LibBee - KU Library AI",
- description="Khalifa University Library AI Assistant",
- version="3.1.0",
- lifespan=lifespan,
-)
+def _light_strip_retrieval_boilerplate(text: str) -> str:
+ """Strip common retrieval command prefixes before building a search plan."""
+ cleaned = re.sub(r"^\s*(please\s+)?(?:can you|could you|would you)\s+", "", (text or "").strip(), flags=re.IGNORECASE)
+ cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
+ cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
+ cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
+ return re.sub(r"\s+", " ", cleaned).strip()
-app.add_middleware(
- CORSMiddleware,
- allow_origins=[
- "https://nikeshn.github.io",
- "http://localhost:8080",
- "http://localhost:3000",
- "http://127.0.0.1:5500",
- ],
- allow_credentials=True,
- allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
- allow_headers=["*"],
- max_age=86400,
-)
+def _sanitize_boolean_for_primo(boolean: str) -> str:
+ """Clean LLM-generated boolean so it works in PRIMO advanced search URLs."""
+ if not boolean:
+ return boolean
+ # Single quotes → double quotes around terms
+ boolean = re.sub(r"'([^']+)'", r'"\1"', boolean)
+ # Remove pure-year groups like (2025 OR 2026) — years belong in date facets
+ boolean = re.sub(
+ r"\(\s*(?:(?:19|20)\d{2}\s*(?:OR\s*(?:19|20)\d{2}\s*)*)\)",
+ "", boolean, flags=re.IGNORECASE,
+ )
+ # Remove dangling AND/OR
+ boolean = re.sub(r"^\s*(AND|OR)\s*", "", boolean, flags=re.IGNORECASE)
+ boolean = re.sub(r"\s*(AND|OR)\s*$", "", boolean, flags=re.IGNORECASE)
+ # Collapse double AND/OR
+ boolean = re.sub(r"\b(AND|OR)\s+(AND|OR)\b", r"\1", boolean, flags=re.IGNORECASE)
+ return re.sub(r"\s+", " ", boolean).strip()
+
+
+def _clean_database_keywords(boolean_query: str) -> str:
+ """Strip boolean operators/punctuation to get plain keyword string."""
+ return re.sub(r"\s+", " ",
+ re.sub(r"\b(AND|OR|NOT)\b|[()\"]", " ",
+ boolean_query, flags=re.IGNORECASE)).strip()
+
+
+def _context_to_dict(context: SearchContextPayload) -> dict:
+ return context.model_dump()
+
+
+_BUILD_SEARCH_PLAN_SYSTEM = f"""You are a search expert for Khalifa University Library.
+
+The user typed a research request. Create THREE forms preserving the full intent.
+
+1. corrected: lightly edit the FULL request for spelling, grammar, and clarity.
+ Preserve every substantive concept, constraint, and task. Do NOT collapse.
-@app.middleware("http")
-async def operational_guardrails(request: Request, call_next):
+2. natural: write a natural-language research query for AI tools such as Consensus,
+ Scopus AI, PRIMO AI, and LeapSpace. Keep full context. Remove only retrieval
+ boilerplate like "find me", "show me", "can you find".
+
+3. boolean: write a PRIMO/PubMed Boolean query with AND/OR/parentheses.
+
+Also detect: year_from, year_to (e.g. "last 24 months" → year_from={{CURRENT_YEAR-2}}),
+peer_reviewed, open_access.
+
+BOOLEAN RULES — follow strictly:
+- Use DOUBLE quotes around multi-word phrases: ("smart city" OR "urban mobility")
+- NEVER use single quotes
+- Max 3 AND groups. Each group max 4 OR terms.
+- NEVER put years as Boolean terms — put them in year_from/year_to
+- Keep broad enough to return results
+
+EXAMPLES:
+Input: "find articles on global warming and renewable energy"
+→ boolean:("global warming" OR "climate change") AND ("renewable energy" OR "clean energy")
+→ year_from:"", peer_reviewed:false
+
+Input: "peer reviewed articles last 5 years on machine learning healthcare"
+→ boolean:("machine learning" OR "deep learning" OR "AI") AND ("healthcare" OR "medicine" OR "clinical")
+→ year_from:"{CURRENT_YEAR - 5}", peer_reviewed:true
+
+Input: "summarize shift in personalized genomic medicine from N-of-1 trials last 24 months"
+→ boolean:("personalized genomic medicine" OR "precision medicine") AND ("N-of-1 trials" OR "scalable precision")
+→ year_from:"{CURRENT_YEAR - 2}", peer_reviewed:true
+
+Return ONLY valid JSON:
+{{"corrected":"...","natural":"...","boolean":"(\"A\" OR \"B\") AND (\"C\" OR \"D\")","year_from":"","year_to":"","peer_reviewed":false,"open_access":false}}"""
+
+
+async def _build_search_plan(question: str, model: str) -> dict:
+ """
+ LLM-driven search plan — ported from app81.py and adapted for LibBee.
+ Returns: corrected, natural, boolean, database_query, year_from, year_to,
+ peer_reviewed, open_access.
+ Falls back to regex boolean if LLM fails.
+ """
settings = get_settings()
+ raw_query = _normalize_whitespace(question or "")
+ light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
- # Maintenance mode
- if settings.maintenance_mode and request.url.path not in {"/", "/config"}:
- return JSONResponse(
- status_code=503,
- content={"detail": "LibBee is in maintenance mode. Please try again shortly."},
- )
-
- # Rate limiting
- client_ip = request.client.host if request.client else "unknown"
- now = time.time()
-
- async with _request_log_lock:
- if _ttl_cache_available:
- # TTLCache stores a list of timestamps per IP
- timestamps = _request_log.get(client_ip, [])
- timestamps = [t for t in timestamps if now - t < 60]
- if len(timestamps) >= settings.rate_limit_per_minute:
- metrics_service.incr_bucket("errors", "rate_limit")
- return JSONResponse(
- status_code=429,
- content={"detail": "Too many requests. Please slow down and try again."},
- )
- timestamps.append(now)
- _request_log[client_ip] = timestamps
- else:
- # Fallback: deque-based sliding window
- from collections import deque
- bucket = _request_log[client_ip] # type: ignore[index]
- while bucket and now - bucket[0] > 60:
- bucket.popleft()
- if len(bucket) >= settings.rate_limit_per_minute:
- metrics_service.incr_bucket("errors", "rate_limit")
- return JSONResponse(
- status_code=429,
- content={"detail": "Too many requests. Please slow down and try again."},
+ if settings.openai_api_key or settings.anthropic_api_key:
+ try:
+ llm = _get_llm(model, temperature=0, max_tokens=300)
+ response = await llm.ainvoke([
+ {"role": "system", "content": _BUILD_SEARCH_PLAN_SYSTEM},
+ {"role": "user", "content": f'Query: "{light_query}"'},
+ ])
+ raw = response.content.strip()
+ if raw.startswith("```"):
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
+ # Extract JSON object
+ s, e = raw.find("{"), raw.rfind("}")
+ if s != -1 and e > s:
+ result = json.loads(raw[s:e + 1])
+ else:
+ raise ValueError("No JSON found in response")
+
+ corrected = (result.get("corrected") or raw_query).strip() or raw_query
+ natural = (result.get("natural") or corrected).strip() or corrected
+ boolean = (result.get("boolean") or "").strip()
+
+ # Validate boolean — must have operators and structure
+ has_ops = bool(re.search(r"\b(AND|OR)\b", boolean)) if boolean else False
+ has_parens = "(" in boolean if boolean else False
+ if not has_ops or not has_parens:
+ boolean = _shared_build_primo_boolean_query(corrected)
+
+ boolean = _sanitize_boolean_for_primo(boolean)
+
+ year_from = str(result.get("year_from") or "").strip()
+ year_to = str(result.get("year_to") or "").strip()
+
+ # If LLM didn't detect year filters, fall back to regex
+ if not year_from and not year_to:
+ yf, yt = _parse_year_filters(raw_query)
+ year_from = yf or ""
+ year_to = yt or ""
+
+ return {
+ "corrected": corrected,
+ "natural": natural,
+ "boolean": boolean,
+ "database_query": _clean_database_keywords(boolean),
+ "year_from": year_from,
+ "year_to": year_to,
+ "peer_reviewed": bool(result.get("peer_reviewed", False)),
+ "open_access": bool(result.get("open_access", False)),
+ }
+ except Exception as e:
+ logger.warning(f"_build_search_plan LLM failed: {e} — using regex fallback")
+
+ # Regex fallback
+ boolean = _shared_build_primo_boolean_query(light_query)
+ yf, yt = _parse_year_filters(raw_query)
+ pr = bool(re.search(r"\bpeer[- ]reviewed\b", raw_query, re.IGNORECASE))
+ oa = bool(re.search(r"\bopen[- ]access\b", raw_query, re.IGNORECASE))
+ return {
+ "corrected": raw_query,
+ "natural": light_query,
+ "boolean": boolean,
+ "database_query": _clean_database_keywords(boolean),
+ "year_from": yf or "",
+ "year_to": yt or "",
+ "peer_reviewed": pr,
+ "open_access": oa,
+ }
+
+
+async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
+ """
+ Build all query forms using _build_search_plan.
+ Updates context.ai_tool_query, primo_boolean_query, and if plan detects
+ year/peer_reviewed/open_access that aren't already set, applies them.
+ """
+ topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
+ q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
+
+ plan = await _build_search_plan(q, model)
+
+ context.ai_tool_query = plan["natural"]
+ context.primo_boolean_query = plan["boolean"]
+
+ # Apply plan's filter detections only if context doesn't already have them set
+ if not context.year_from and plan.get("year_from"):
+ context.year_from = plan["year_from"]
+ if not context.year_to and plan.get("year_to"):
+ context.year_to = plan["year_to"]
+ if not context.peer_reviewed and plan.get("peer_reviewed"):
+ context.peer_reviewed = True
+ if not context.open_access and plan.get("open_access"):
+ context.open_access = True
+
+ return context
+
+
+# ── Semantic Scholar + Summary ────────────────────────────────────────────────
+
+async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
+ clean_topic = _strip_resource_noise(topic)
+ if not clean_topic:
+ clean_topic = topic
+ try:
+ params = {
+ "query": clean_topic,
+ "limit": limit,
+ "fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
+ }
+ async with httpx.AsyncClient(timeout=15) as client:
+ r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
+ if r.status_code != 200:
+ logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
+ return []
+ data = r.json()
+ results = []
+ for paper in data.get("data", []):
+ abstract = paper.get("abstract") or ""
+ if not abstract or len(abstract) < 50:
+ continue
+ author_list = paper.get("authors") or []
+ first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
+ year = paper.get("year") or "n.d."
+ paper_id = paper.get("paperId", "")
+ doi = (paper.get("externalIds") or {}).get("DOI", "")
+ pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
+ link = (
+ pdf_url
+ or (f"https://doi.org/{doi}" if doi else "")
+ or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
)
- bucket.append(now)
+ results.append({
+ "title": paper.get("title", "Untitled"),
+ "authors": (
+ ", ".join(a.get("name", "") for a in author_list[:3])
+ + (" et al." if len(author_list) > 3 else "")
+ ),
+ "first_author": first_author_last,
+ "year": year,
+ "citation_key": f"{first_author_last}, {year}",
+ "venue": paper.get("venue", ""),
+ "abstract": abstract[:700],
+ "link": link,
+ "_source": "Semantic Scholar",
+ })
+ return results
+ except Exception as e:
+ logger.error(f"Semantic Scholar search failed: {e}")
+ return []
+
+async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return ""
+ if not papers:
+ return ""
+ context_text = ""
+ for i, paper in enumerate(papers, 1):
+ context_text += (
+ f"[{i}] {paper['citation_key']}: {paper['title']}\n"
+ f"Abstract: {paper['abstract']}\n\n"
+ )
+ system = (
+ "You are a research librarian writing a concise academic literature snapshot. "
+ "Using ONLY the numbered references provided, write a 4-6 sentence summary on the given topic. "
+ "Use in-text citations as [1], [2], [3] etc. referring to the numbered references. "
+ "Cite at least 4 different references. "
+ "Use HTML
for paragraph breaks. Do not use bullet points or markdown. "
+ "Only state what is explicitly supported by the abstracts. "
+ "Do NOT invent content."
+ )
try:
- response = await call_next(request)
- return response
- except Exception as exc:
- logger.exception("Unhandled application error: %s", exc)
- metrics_service.incr_bucket("errors", "unhandled_exception")
- return JSONResponse(status_code=500, content={"detail": "Unexpected server error."})
+ llm = _get_llm(model, temperature=0.2, max_tokens=560)
+ response = await llm.ainvoke([
+ {"role": "system", "content": system},
+ {"role": "user", "content": f"Topic: {topic}\n\nReferences:\n{context_text}\nWrite the summary."},
+ ])
+ summary = response.content.strip()
+ for i in range(len(papers), 0, -1):
+ paper = papers[i - 1]
+ title_escaped = _escape(paper["title"])
+ cite_html = (
+ f''
+ f'[{paper["citation_key"]}]'
+ )
+ summary = summary.replace(f"[{i}]", cite_html)
+ return summary
+ except Exception as e:
+ logger.error(f"Summary generation failed: {e}")
+ return ""
-from src.api import admin, agent, feedback, search # noqa: E402
+def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
+ if not papers:
+ return ""
+ statements: List[str] = []
+ for paper in papers[:4]:
+ sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
+ lead = sentences[0].strip() if sentences else ""
+ if not lead:
+ continue
+ citation = (
+ f''
+ f'[{_escape(paper["citation_key"])}]'
+ )
+ statements.append(f"{_escape(lead)} {citation}")
+ if not statements:
+ return f"Recent literature on {_escape(topic)} is available in the cited papers below."
+ intro = f"Recent literature on {_escape(topic)}:
"
+ return intro + "
".join(statements[:4])
-app.include_router(agent.router, prefix="/agent", tags=["Agent"])
-app.include_router(search.router, prefix="/search", tags=["Search"])
-app.include_router(admin.router, prefix="/admin", tags=["Admin"])
-app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
+# ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
-@app.get("/")
-def health_check():
+async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
+ from src.api.search import SearchRequest, search_primo
settings = get_settings()
- return {
- "status": "ok",
- "version": "3.1.0",
- "service": "LibBee KU Library AI",
- "rag_ready": rag_service.is_ready(),
- "maintenance_mode": settings.maintenance_mode,
- "endpoints": ["/agent", "/search", "/admin", "/feedback"],
+ clean_topic = _strip_resource_noise(context.topic)
+ if not clean_topic:
+ clean_topic = context.topic
+ result = await search_primo(
+ SearchRequest(
+ query=clean_topic,
+ source="primo",
+ limit=limit,
+ peer_reviewed=context.peer_reviewed,
+ open_access=context.open_access,
+ year_from=context.year_from,
+ year_to=context.year_to,
+ resource_type=context.resource_type,
+ ),
+ settings.primo_api_key,
+ )
+ source_url = _primo_clean_url(context)
+ return result, source_url
+
+
+async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
+ from src.api.search import SearchRequest, search_pubmed
+ clean_topic = _strip_resource_noise(context.topic)
+ if not clean_topic:
+ clean_topic = context.topic
+ result = await search_pubmed(
+ SearchRequest(
+ query=clean_topic,
+ source="pubmed",
+ limit=limit,
+ peer_reviewed=context.peer_reviewed,
+ year_from=context.year_from,
+ year_to=context.year_to,
+ )
+ )
+ pubmed_url = _shared_build_pubmed_url(
+ clean_topic, context.year_from, context.year_to, context.peer_reviewed
+ )
+ return result, pubmed_url
+
+
+# ── AI Tools / Database Guidance ─────────────────────────────────────────────
+
+def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
+ ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
+ primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
+ urls: Dict[str, str] = {
+ "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
+ "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
+ "ebsco_ai": (
+ f"https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results"
+ f"?q={ai_query}"
+ f"&autocorrect=y"
+ f"&expanders=concept"
+ f"&limiters=None"
+ f"&searchMode=enhanced"
+ f"&searchSegment=all-results"
+ ),
+ "primo_ai": f"{PRIMO_AI_URL}{primo_ai_query}",
+ "consensus": f"https://consensus.app/results/?q={ai_query}",
+ "primo_discovery": _primo_clean_url(context),
}
+ if context.intent == "search_medical":
+ urls["pubmed"] = _shared_build_pubmed_url(
+ context.topic, context.year_from, context.year_to, context.peer_reviewed
+ )
+ return urls
-@app.get("/config")
-def public_config():
+def _database_guidance_for_topic(context: SearchContextPayload) -> str:
+ topic = _escape(context.display_topic or context.topic)
+ urls = _tool_urls(context)
+ ai_tools = (
+ f'LeapSpace · '
+ f'Scopus AI · '
+ f'EBSCO AI · '
+ f'PRIMO AI Assistant · '
+ f'Consensus'
+ )
+ more_results = f'PRIMO Library Discovery'
+ if context.intent == "search_medical" and urls.get("pubmed"):
+ more_results += f' · PubMed'
+ return (
+ f"For deeper work on {topic}, continue with these AI research tools: {ai_tools}. "
+ f"For more results, use {more_results}. Medical topics are strongest in PubMed, Embase, and CINAHL."
+ )
+ return (
+ f"For deeper work on {topic}, continue with these AI research tools: {ai_tools}. "
+ f"For more results, use {more_results}."
+ )
+
+
+def _find_staff_by_token(token: str) -> Optional[dict]:
+ token = (token or "").lower()
+ for staff in STAFF_DIRECTORY:
+ hay = (staff.get("full_name", "") + " " + staff.get("role", "")).lower()
+ if token in hay:
+ return staff
+ return None
+
+
+def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
+ q = (question or "").lower()
+ # FIX: removed broken default to _find_staff_by_token("muna") — Muna is not in STAFF_DIRECTORY
+ specialist: Optional[dict] = None
+ if any(t in q for t in ["database access", "proxy", "full text", "eresource", "e-resource", "remote access", "access problem"]):
+ specialist = _find_staff_by_token("rani")
+ elif any(t in q for t in ["website", "system", "technical", "login issue", "page not loading"]):
+ specialist = _find_staff_by_token("walter")
+ elif context and context.intent == "search_medical":
+ specialist = _find_staff_by_token("jason")
+ elif context and context.intent == "search_academic":
+ specialist = _find_staff_by_token("nikesh")
+
+ footer = (
+ "
Library contact points
"
+ f'General help: Ask a Librarian · '
+ 'libse@ku.ac.ae'
+ )
+ if specialist:
+ footer += (
+ f'
Relevant contact: {_escape(specialist.get("full_name", ""))} — '
+ f'{_escape(specialist.get("role", ""))}
'
+ f'{_escape(specialist.get("email", ""))}'
+ )
+ phone = specialist.get("phone") or specialist.get("work_phone")
+ if phone:
+ footer += f' · {_escape(phone)}'
+ return footer
+
+
+def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> str:
+ """
+ Lightweight collapsible reasoning block shown at the top of every non-search response.
+ """
+ labels = {
+ "library_info": ("📋", "Library information lookup", "I searched the KU Library knowledge base for relevant content."),
+ "general": ("🌐", "General knowledge / web search", "I answered using a web search for current information."),
+ "general_recent": ("🔴", "Live web search", "I ran a live web search for the most up-to-date information."),
+ "social_greeting": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
+ "social": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
+ "staff_lookup": ("👤", "Staff directory lookup", "I matched your request to the KU Library staff directory."),
+ "hours": ("⏰", "Library hours lookup", "I retrieved the current library hours from the KU Library knowledge base."),
+ "campus": ("📍", "Campus information lookup", "I retrieved KU Library campus information from the knowledge base."),
+ "database_rec": ("🗄️", "Database recommendation", "I matched your subject to the KU Library e-resources database."),
+ "rag_search": ("📖", "Knowledge base search", "I ran a hybrid semantic + keyword search across the KU Library knowledge base."),
+ }
+ icon, label, reasoning = labels.get(intent, ("🤖", "AI response", "I processed your question and generated a response."))
+ if extra:
+ reasoning += " " + extra
+
+ return (
+ f''
+ f''
+ f'{icon} My approach — click to see
'
+ f''
+ f'Question: {_escape(question)}
'
+ f'Classified as: {label}
'
+ f'How I answered: {reasoning}'
+ f'
'
+ )
+
+
+def _search_trace_block(question: str, context: SearchContextPayload) -> str:
+ """
+ Richer collapsible reasoning block for search responses.
+ Shows topic, boolean query, and reasoning. Also hints that AI tools are at the end.
+ """
+ topic = _escape(context.display_topic or context.topic)
+ primo_boolean = _escape(context.primo_boolean_query or "")
+ source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
+
+ intent_label = {
+ "search_academic": "academic literature search",
+ "search_medical": "medical / clinical literature search",
+ }.get(context.intent, "library search")
+
+ filters_parts = []
+ if context.peer_reviewed:
+ filters_parts.append("peer reviewed only")
+ if context.open_access:
+ filters_parts.append("open access")
+ if context.year_from and context.year_to:
+ filters_parts.append(f"{context.year_from}–{context.year_to}")
+ elif context.year_from:
+ filters_parts.append(f"from {context.year_from}")
+ rtype = {"articles": "articles", "books": "books", "both": "articles and books"}.get(
+ context.resource_type, "articles"
+ )
+ filters_parts.append(rtype)
+ filters_str = ", ".join(filters_parts)
+
+ reasoning = (
+ f"I classified this as a {intent_label}, "
+ f"extracted the topic {topic}, "
+ f"built an LLM-optimised boolean query with quoted phrases and OR synonyms, "
+ f"then searched {source_label} for {filters_str}."
+ )
+ if context.intent == "search_medical" and context.source != "pubmed":
+ reasoning += " For medical topics, PubMed and Embase are also recommended."
+
+ block = (
+ f''
+ f''
+ f'🔍 How I searched — click to expand'
+ f'
'
+ f''
+ f'Your question: {_escape(question)}
'
+ f'Interpreted topic: {topic}
'
+ )
+ if primo_boolean:
+ block += (
+ f'Boolean query sent to {source_label}:
'
+ f'{primo_boolean}'
+ )
+ block += f'Reasoning: {reasoning}
'
+ block += '✨ AI research tools for deeper exploration are listed at the end of this answer.'
+ block += '
'
+ return block
+
+
+def _ai_tools_footer(context: SearchContextPayload) -> str:
+ """
+ AI tools recommendation panel — always rendered at the very end of the answer.
+ Structured as KU-subscribed tools first, then freemium, then more results.
+ """
+ urls = _tool_urls(context)
+ topic_enc = _escape(context.ai_tool_query or context.display_topic or context.topic)
+ is_medical = context.intent == "search_medical"
+
+ ku_tools = [
+ ("🔬 LeapSpace", urls["leapspace"], "leapspace", "#9a3412"),
+ ("🔭 Scopus AI", urls["scopus_ai"], "scopus", "#1e40af"),
+ ("📰 EBSCO AI", urls["ebsco_ai"], "ebscoai", "#9d174d"),
+ ("⭐ PRIMO AI Assistant", urls["primo_ai"], "primoai", "#5b21b6"),
+ ]
+ free_tools = [
+ ("🧬 Consensus", urls["consensus"], "consensus", "#86198f"),
+ ("🌐 OpenAlex", f"https://openalex.org/works?page=1&sort=relevance_score:desc&search={topic_enc}", "openalex", "#0c4a6e"),
+ ("🔎 Perplexity", f"https://www.perplexity.ai/search?q={topic_enc}", "perplexity", "#4c1d95"),
+ ]
+ more = [("📚 PRIMO Library Discovery", urls["primo_discovery"], "primoai", "#003366")]
+ if is_medical and urls.get("pubmed"):
+ more.append(("🏥 PubMed", urls["pubmed"], "pubmed_badge", "#065f46"))
+
+ def chip(label, url, cls, color):
+ return (
+ f'{label}'
+ )
+
+ html = (
+ ''
+ '
'
+ '✨ For deeper research — AI tools
'
+ '
🔐 KU subscribed
'
+ '
'
+ )
+ for label, url, cls, color in ku_tools:
+ html += chip(label, url, cls, color)
+ html += (
+ '
🆓 Freemium tools
'
+ '
'
+ )
+ for label, url, cls, color in free_tools:
+ html += chip(label, url, cls, color)
+ html += (
+ '
📚 More results
'
+ '
'
+ )
+ for label, url, cls, color in more:
+ html += chip(label, url, cls, color)
+ html += '
'
+ return html
+
+
+def _is_database_recommendation_question(question: str) -> bool:
+ return bool(DATABASE_RECOMMEND_RE.search(question or ""))
+
+
+def _safe_db_url(url: str) -> str:
+ """
+ Encode the inner URL in IDM proxy links so the frontend fmt() auto-linkify
+ regex cannot match a bare https:// inside an href attribute and break the HTML.
+ e.g. href="...?url=https://foo.com" → href="...?url=https%3A%2F%2Ffoo.com"
+ """
+ from urllib.parse import quote as _quote
+ if "?url=" in url:
+ base, inner = url.split("?url=", 1)
+ return base + "?url=" + _quote(inner, safe="")
+ return url
+
+
+# ── Subject → database mapping sourced from KU KB ────────────────────────────
+# All IDM proxy URLs are encoded via _safe_db_url to prevent HTML breakage.
+_DB_SUBJECTS: Dict[str, List[Tuple[str, str, str]]] = {
+ "physics": [
+ ("APS Journals", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://journals.aps.org"), "American Physical Society — core physics journals."),
+ ("AIP Publishing", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubs.aip.org"), "Core physics and applied physics literature."),
+ ("IOPScience", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://iopscience.iop.org"), "Core physics journal platform."),
+ ("arXiv", "https://arxiv.org", "Open-access preprints in physics and maths."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text across applied and interdisciplinary physics."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Discovery and citation tracking."),
+ ],
+ "chemistry": [
+ ("ACS Publications", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubs.acs.org"), "Core chemistry journals from the American Chemical Society."),
+ ("SciFinder-n", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://scifinder-n.cas.org"), "Substance, reaction, reference, and patent searching."),
+ ("Reaxys", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.reaxys.com"), "Reaction and property searching."),
+ ("RSC Journals", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubs.rsc.org"), "Royal Society of Chemistry journals."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text chemistry journals and books."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Discovery and citation tracking."),
+ ],
+ "chemical_engineering": [
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Strong full text for chemical engineering journals and books."),
+ ("ACS Publications", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubs.acs.org"), "Core chemistry and chemical engineering coverage."),
+ ("SciFinder-n", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://scifinder-n.cas.org"), "Chemical substances, reactions, references, and patents."),
+ ("Reaxys", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.reaxys.com"), "Reaction and property searching."),
+ ("Knovel", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://app.knovel.com"), "Engineering handbooks and technical reference data."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "computer_science": [
+ ("ACM Digital Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://dl.acm.org"), "Core computer science literature."),
+ ("IEEE Xplore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org"), "Strong for CS, AI, networks, and systems."),
+ ("arXiv", "https://arxiv.org", "Important preprint source for CS, AI, and ML."),
+ ("INSPEC", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.theiet.org/publishing/inspec"), "Indexing for computing and engineering topics."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text across CS and applied computing."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "robotics": [
+ ("IEEE Xplore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org"), "Core source for robotics, AI, automation, and intelligent systems."),
+ ("ACM Digital Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://dl.acm.org"), "AI, ML, and computing research."),
+ ("arXiv", "https://arxiv.org", "Essential for current AI, robotics, and ML preprints."),
+ ("INSPEC", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.theiet.org/publishing/inspec"), "Indexing for robotics, computing, electronics, and control."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text for AI, robotics, and applied engineering."),
+ ],
+ "electrical_engineering": [
+ ("IEEE Xplore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org"), "Core electrical engineering database."),
+ ("INSPEC", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.theiet.org/publishing/inspec"), "Strong indexing for EE, electronics, and physics."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text for electrical engineering and control."),
+ ("ASTM Compass", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://compass.astm.org"), "Standards and testing methods."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "mechanical_engineering": [
+ ("ASME Digital Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org"), "Core mechanical engineering literature."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text across mechanics, thermal, and manufacturing topics."),
+ ("Knovel", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://app.knovel.com"), "Engineering reference data and handbooks."),
+ ("ASTM Compass", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://compass.astm.org"), "Standards and materials testing information."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "civil_engineering": [
+ ("ASCE Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ascelibrary.org"), "Core civil and structural engineering journals and conference papers."),
+ ("ICE Virtual Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.icevirtuallibrary.com"), "Civil engineering literature and proceedings."),
+ ("ASTM Compass", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://compass.astm.org"), "Standards and testing methods for materials and engineering."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text across civil engineering areas."),
+ ("Knovel", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://app.knovel.com"), "Handbooks and design reference data."),
+ ],
+ "aerospace": [
+ ("AIAA", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://arc.aiaa.org/"), "Core aerospace journals, conference papers, and standards."),
+ ("ASME Digital Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org"), "Thermal, mechanical, and fluid systems relevant to aerospace."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Strong full text for aerospace, mechanics, and materials."),
+ ("IEEE Xplore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org"), "Avionics, control systems, and guidance."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "petroleum": [
+ ("OnePetro", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://onepetro.org"), "Core petroleum engineering and oil and gas literature."),
+ ("EarthDoc", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.earthdoc.org"), "Geoscience and petroleum-related literature."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text for petroleum, energy, and engineering topics."),
+ ("Knovel", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://app.knovel.com"), "Engineering handbooks and oil/gas reference material."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "energy": [
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Strong energy engineering and sustainability full text."),
+ ("OnePetro", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://onepetro.org"), "Energy sector and petroleum crossover topics."),
+ ("IEEE Xplore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org"), "Power systems, electrical energy, and smart grids."),
+ ("Knovel", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://app.knovel.com"), "Engineering handbooks and energy reference content."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "mathematics": [
+ ("MathSciNet", "https://mathscinet.ams.org", "Core mathematics review and discovery database."),
+ ("SpringerLink", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://link.springer.com"), "Strong mathematics and statistics book and journal coverage."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text for mathematics, statistics, and data science."),
+ ("JSTOR", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.jstor.org"), "Foundational mathematics and statistics literature."),
+ ("arXiv", "https://arxiv.org", "Preprints in mathematics, statistics, and data science."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "materials": [
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Strong materials science full text."),
+ ("SpringerLink", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://link.springer.com"), "Strong materials and nanoscience coverage."),
+ ("AIP Publishing", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubs.aip.org"), "Applied materials and physics crossover."),
+ ("IOPScience", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://iopscience.iop.org"), "Materials and physics crossover."),
+ ("Knovel", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://app.knovel.com"), "Reference data and engineering materials handbooks."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "nuclear": [
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Strong nuclear engineering and energy full text."),
+ ("AIP Publishing", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubs.aip.org"), "Nuclear and applied physics crossover."),
+ ("Knovel", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://app.knovel.com"), "Engineering reference and handbooks."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Discovery and citation tracking."),
+ ("Web of Science", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com"), "Broad citation discovery."),
+ ],
+ "quantum_computing": [
+ ("IEEE Xplore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org"), "Quantum computing engineering implementations."),
+ ("ACM Digital Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://dl.acm.org"), "Algorithms, theory, and computing literature."),
+ ("APS Journals", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://journals.aps.org"), "Quantum information and foundational physics."),
+ ("arXiv", "https://arxiv.org", "Essential for current quantum computing preprints."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Discovery and citation tracking."),
+ ],
+ "biomedical": [
+ ("PubMed", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubmed.ncbi.nlm.nih.gov"), "Core biomedical and life sciences literature."),
+ ("IEEE Xplore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org"), "Biomedical engineering and medical devices."),
+ ("Embase", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.embase.com"), "Biomedical and pharmacological literature."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text for biomedical engineering and applied sciences."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Discovery and citation tracking."),
+ ],
+ "biology": [
+ ("PubMed", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubmed.ncbi.nlm.nih.gov"), "Core life sciences and biomedical literature."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text biology and molecular sciences content."),
+ ("Embase", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.embase.com"), "Biomedical crossover topics."),
+ ("PubMed Central", "https://www.ncbi.nlm.nih.gov/pmc", "Open full-text biomedical articles — no login required."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Discovery and citation tracking."),
+ ],
+ "medical": [
+ ("PubMed @ KU", "http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib", "Core medical literature — use this KU link for full-text access."),
+ ("Embase", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.embase.com"), "Biomedical and pharmacological literature."),
+ ("CINAHL Ultimate", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul"), "Nursing and allied health literature."),
+ ("Cochrane Library", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.cochranelibrary.com"), "Evidence synthesis and systematic reviews — gold standard."),
+ ("UpToDate", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.uptodate.com"), "Point-of-care clinical decision support."),
+ ("ClinicalKey Student", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.clinicalkey.com/student/login"), "Clinical reference and learning resource."),
+ ],
+ "business": [
+ ("Business Source Complete", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com"), "Core business and management literature."),
+ ("Emerald Insight", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.emerald.com/insight"), "Management, strategy, and leadership journals."),
+ ("ProQuest Databases", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.proquest.com"), "Broad business and interdisciplinary discovery."),
+ ("JSTOR", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.jstor.org"), "Business, economics, and policy literature."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ],
+ "earth_science": [
+ ("GeoscienceWorld", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://pubs.geoscienceworld.org"), "Core earth science journals and conference content."),
+ ("EarthDoc", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.earthdoc.org"), "Geoscience and subsurface literature."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Full text for geoscience and planetary topics."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Discovery and citation tracking."),
+ ("Web of Science", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com"), "Broad citation discovery."),
+ ],
+ "environment": [
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Strong sustainability and environmental full text."),
+ ("ProQuest Databases", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.proquest.com"), "Environmental policy and interdisciplinary discovery."),
+ ("Web of Science", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com"), "Broad citation discovery."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery and citation tracking."),
+ ("Business Source Complete", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com"), "Sustainability, policy, and corporate responsibility."),
+ ],
+ "engineering_management": [
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Broad discovery across engineering and management research."),
+ ("Emerald Insight", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.emerald.com/insight"), "Management, operations, and leadership literature."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Engineering and management full text."),
+ ("Business Source Complete", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com"), "Business, management, and strategy literature."),
+ ("ProQuest Databases", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.proquest.com"), "Broad business and interdisciplinary discovery."),
+ ],
+ "metrics": [
+ ("Journal Citation Reports", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://jcr.clarivate.com"), "Impact factors and journal rankings (Web of Science)."),
+ ("Scopus / CiteScore", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "CiteScore and source-level metrics."),
+ ("SciVal", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scival.com"), "Research performance analytics."),
+ ("Web of Science", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com"), "Citation discovery and journal evaluation context."),
+ ],
+ "theses": [
+ ("ProQuest Dissertations & Theses Global", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.proquest.com/pqdtglobal"), "International dissertations and theses."),
+ ("Khazna", "https://khazna.ku.ac.ae/", "KU institutional repository and research outputs."),
+ ("PRIMO", "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU", "Discovery for dissertations, theses, and KU holdings."),
+ ],
+}
+
+# General / fallback databases (used when no subject match found)
+_DB_GENERAL: List[Tuple[str, str, str]] = [
+ ("PRIMO (KU Library Discovery)", "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU", "Best starting point — search all KU books, articles, and journals."),
+ ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Largest abstract and citation database — all disciplines."),
+ ("Web of Science", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com"), "Premier citation index — all disciplines."),
+ ("ScienceDirect", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com"), "Large full-text platform across science, engineering, and medicine."),
+ ("SpringerLink", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://link.springer.com"), "Springer journals and books across all disciplines."),
+ ("ProQuest Databases", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://search.proquest.com"), "Broad multidisciplinary discovery."),
+ ("Emerald Insight", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.emerald.com/insight"), "Social sciences, management, and interdisciplinary research."),
+ ("JSTOR", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.jstor.org"), "Foundational and archival literature across many fields."),
+]
+
+
+def _match_db_subject(q: str) -> Optional[str]:
+ """Map lowercased question to a _DB_SUBJECTS key using keyword matching."""
+ checks = [
+ (["quantum computing", "quantum algorithm", "quantum cryptography", "quantum information"], "quantum_computing"),
+ (["robotics", "autonomous system", "intelligent system"], "robotics"),
+ (["artificial intelligence", "machine learning", "deep learning", "nlp", "natural language processing", "neural network", "cybersecurity", "computer science", "software engineering", "algorithm", "data structure"], "computer_science"),
+ (["computer engineering", "embedded system", "computer architecture", "networking", "digital system"], "computer_science"),
+ (["aerospace", "aerodynamic", "propulsion", "aircraft", "aviation", "flight system"], "aerospace"),
+ (["biomedical engineering", "medical device", "biomechanic", "bioinstrument", "tissue engineering", "biomedical imaging"], "biomedical"),
+ (["chemical engineering", "process engineering", "reaction engineering", "separation", "catalysis"], "chemical_engineering"),
+ (["chemistry", "organic chemistry", "analytical chemistry", "inorganic", "chemical", "chemical properties"], "chemistry"),
+ (["civil engineering", "structural engineering", "geotechnical", "transportation engineering", "water resources", "construction"], "civil_engineering"),
+ (["electrical engineering", "circuit", "power system", "signal processing", "control system", "communication system", "electronics"], "electrical_engineering"),
+ (["energy engineering", "renewable energy", "sustainable energy", "clean energy", "energy transition", "solar", "wind power"], "energy"),
+ (["engineering management", "engineering systems", "systems engineering", "operations management", "innovation management"], "engineering_management"),
+ (["mechanical engineering", "thermofluid", "thermodynamics", "fluid mechanics", "manufacturing", "mechatronics"], "mechanical_engineering"),
+ (["nuclear engineering", "reactor", "radiation", "nuclear material", "nuclear safety"], "nuclear"),
+ (["petroleum engineering", "oil and gas", "drilling", "reservoir", "subsurface", "petroleum"], "petroleum"),
+ (["earth science", "geology", "geophysics", "geochemistry", "planetary science", "geoscience"], "earth_science"),
+ (["environment", "sustainability", "climate change", "sustainable development", "conservation", "environmental policy", "ecology"], "environment"),
+ (["materials science", "nanotechnology", "nanomaterial", "biomaterial", "advanced material", "functional material"], "materials"),
+ (["cell biology", "molecular biology", "genetics", "genomics", "biotechnology", "bioinformatics", "microbiology", "virology"], "biology"),
+ (["medicine", "medical", "health science", "clinical", "nursing", "pharmacy", "biomedical literature", "pharmacology", "clinical trial", "systematic review", "evidence-based", "patient care"], "medical"),
+ (["business", "management", "finance", "economics", "accounting", "entrepreneurship", "marketing", "strategy", "leadership", "international relations", "political science", "social science", "humanities", "law", "education", "psychology", "sociology"], "business"),
+ (["physics", "optics", "photonics", "quantum physics", "condensed matter", "theoretical physics", "astrophysics"], "physics"),
+ (["mathematics", "statistics", "data science", "calculus", "algebra", "optimization", "probability", "mathematical model"], "mathematics"),
+ (["impact factor", "journal metrics", "citescore", "jcr", "quartile", "research impact", "scival", "bibliometric"], "metrics"),
+ (["dissertation", "thesis", "doctoral", "master thesis", "repository", "phd thesis"], "theses"),
+ ]
+ for keywords, key in checks:
+ if any(kw in q for kw in keywords):
+ return key
+ return None
+
+
+def _extract_db_topic(question: str) -> str:
+ """
+ Extract the subject from a database-recommendation question.
+ e.g. "best databases for physics" → "physics"
+ "which database should I use for machine learning" → "machine learning"
+ """
+ q = _normalize_whitespace(question or "")
+ # Strip leading boilerplate phrases
+ patterns = [
+ r"^(best|good|recommended?)\s+databases?\s+for\s+",
+ r"^which\s+databases?\s+(for|should i use for|is best for|are best for)\s+",
+ r"^databases?\s+for\s+",
+ r"^where should i (search|start)\s+(for\s+)?",
+ r"^what (database|databases)\s+(for|should i use for)\s+",
+ ]
+ lower = q.lower()
+ for pat in patterns:
+ m = re.match(pat, lower)
+ if m:
+ q = q[m.end():]
+ break
+ # Strip trailing noise
+ q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
+ q = _strip_resource_noise(q)
+ return _normalize_whitespace(q).strip(".?") or "this subject"
+
+
+def _database_recommendation_answer(question: str) -> str:
+ q = (question or "").lower()
+ subject = _extract_db_topic(question)
+ topic = _escape(subject)
+
+ # Match to a specific subject list; fall back to general KU databases
+ subject_key = _match_db_subject(q)
+ dbs = _DB_SUBJECTS.get(subject_key, _DB_GENERAL) if subject_key else _DB_GENERAL
+
+ # Cap at 5 results for readability
+ dbs = list(dbs)[:5]
+
+ answer = f"Recommended databases for {topic}
"
+ answer += "Here are the strongest KU-subscribed starting points:
"
+ for name, url, why in dbs:
+ answer += f'
• {_escape(name)} — {_escape(why)}'
+
+ if not subject_key:
+ # Generic topic — add a note that subject-specific guides exist
+ answer += (
+ "
💡 For subject-specific databases, ask me: "
+ "\"best databases for [your subject]\" — e.g. physics, chemistry, medicine, civil engineering."
+ )
+
+ answer += (
+ f'
Browse all 50+ KU databases: '
+ f'library.ku.ac.ae/eresources'
+ )
+ answer += "
Want me to run a direct search on any of these for a specific topic?"
+ return answer
+
+
+# ── Full-text access chain ────────────────────────────────────────────────────
+
+def _fulltext_chain_answer(question: str) -> str:
+ """Step-by-step guide for getting full text when PDF is unavailable."""
+ return (
+ "📄 How to get full text for an article
"
+ "Follow this chain in order — each step is faster than the next:
"
+ "Step 1 — Search KU PRIMO
"
+ "Find the article in KU Library Discovery. "
+ "If KU subscribes, you'll see a Full Text Available or Online Access link. "
+ "Click it — it passes through the IDM proxy automatically when on campus or logged in.
"
+ "Step 2 — Try LibKey Nomad (browser extension)
"
+ "LibKey Nomad is a free browser "
+ "extension that detects articles you're viewing and fetches KU's full text automatically. "
+ "Install it once and it works on every publisher site.
"
+ "Step 3 — Try the publisher or open access
"
+ "Visit the DOI link directly. Many authors post their accepted manuscript on "
+ "arXiv, "
+ "ResearchGate, or their institutional page. "
+ "OpenAlex also links to many legal open-access PDFs.
"
+ "Step 4 — Submit an Interlibrary Loan (ILL) request
"
+ "If none of the above work, KU Library can borrow the article from another library — usually within 1–5 working days. "
+ "Submit here: library.ku.ac.ae/ill/
"
+ "Contact: Suaad Al Jneibi · "
+ "suaad.aljneibi@ku.ac.ae · +971 2 312 4278"
+ )
+
+
+# ── Search strategy / Boolean explanation ─────────────────────────────────────
+
+async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]:
+ """
+ Build a search plan AND explain it step by step.
+ Returns (explanation_html, plan_dict).
+ """
+ plan = await _build_search_plan(question, model)
+ topic = _escape(_light_strip_retrieval_boilerplate(question) or question)
+ boolean = _escape(plan.get("boolean", ""))
+ natural = _escape(plan.get("natural", ""))
+
+ explanation = (
+ f"🔍 Search strategy for: {topic}
"
+ f"1. Identify your key concepts
"
+ f"Break the topic into 2–3 core concepts. For your question, I identified these concepts "
+ f"and suggested synonyms for each using OR, then joined concepts with AND:
"
+ f'{boolean}
'
+ f"Why AND and OR? AND narrows — both concepts must appear. "
+ f"OR broadens — any synonym counts. Quoting phrases like "
+ f"\"machine learning\" keeps them together as an exact phrase.
"
+ f"2. Apply filters
"
+ f"Use database facets to limit by: peer-reviewed, date range, document type (article / review / book), "
+ f"language, or open access. In PRIMO, these appear in the left sidebar after you search.
"
+ f"3. Natural language query for AI tools
"
+ f"For AI-powered tools (LeapSpace, Scopus AI, Consensus), use a conversational query:
"
+ f'{natural}
'
+ f"4. Iterate
"
+ f"Too many results → add more AND terms or apply filters. "
+ f"Too few → remove an AND group or use broader synonyms. "
+ f"You can ask me to refine: \"narrow to peer-reviewed only\", "
+ f"\"limit to last 5 years\", or \"suggest alternative keywords\"."
+ )
+ return explanation, plan
+
+
+# ── Alternative search terms ──────────────────────────────────────────────────
+
+async def _alternative_terms_answer(question: str, context: Optional[SearchContextPayload], model: str) -> str:
+ """Generate alternative keywords/synonyms for the current topic without re-running the search."""
+ topic = (context.display_topic or context.topic) if context else question
settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return (
+ f"Alternative search terms for: {_escape(topic)}
"
+ "Try combining these broader, narrower, and related terms:
"
+ "• Use broader terms if too few results
"
+ "• Use narrower/specific terms if too many results
"
+ "• Try acronyms and full forms (e.g. AI / Artificial Intelligence)
"
+ "• Include British and American spellings (e.g. organisation/organization)"
+ )
+ try:
+ llm = _get_llm(model, temperature=0.4, max_tokens=300)
+ response = await llm.ainvoke([
+ {"role": "system", "content": (
+ "You are an academic librarian. Given a research topic, generate a structured list of alternative "
+ "search terms. Group them as: Broader terms, Narrower/specific terms, Related concepts, Acronyms/abbreviations. "
+ "Format as HTML using for group labels and
for line breaks. "
+ "Keep it concise — 3-4 terms per group maximum. No bullet points, use • instead."
+ )},
+ {"role": "user", "content": f"Research topic: {topic}"},
+ ])
+ terms = response.content.strip()
+ return (
+ f"💡 Alternative search terms for: {_escape(topic)}
"
+ f"{terms}
"
+ f"Tip: In PRIMO or databases, use OR between synonyms within a concept group, "
+ f"and AND between different concept groups. Ask me to run a new search with any of these."
+ )
+ except Exception as e:
+ logger.warning(f"_alternative_terms_answer failed: {e}")
+ return f"Alternative terms for {_escape(topic)}
Try synonyms, acronyms, broader/narrower terms, and related concepts in your search."
+
+
+# ── Citation chaining guide ───────────────────────────────────────────────────
+
+def _citation_chain_answer() -> str:
+ return (
+ "🔗 How to trace citations forward and backward from a paper
"
+ "Backward citation (who does this paper cite?)
"
+ "Read the paper's reference list — every source it cites is a potential lead. "
+ "This gives you foundational and seminal works on the topic.
"
+ "Forward citation (who has cited this paper since publication?)
"
+ "Use these tools — paste in the DOI or title:
"
+ '• Scopus '
+ '— search the article, then click "Cited by N documents"
'
+ '• Web of Science '
+ '— search the article, click "Times Cited"
'
+ '• Semantic Scholar '
+ '— free, excellent for CS and engineering, shows "Citations" tab
'
+ '• OpenAlex '
+ '— fully open, API-accessible citation graph
'
+ "Lateral search (similar papers)
"
+ "In PRIMO, use \"Find Similar\". In Semantic Scholar, use \"Recommended Papers\". "
+ "In Scopus, use \"Related Documents\".
"
+ "Tip: Start with one highly cited foundational paper, trace forward to find the newest "
+ "work, and backward to understand the theoretical roots."
+ )
+
+
+# ── Predatory / peer-review evaluation guide ─────────────────────────────────
+
+def _predatory_eval_answer() -> str:
+ return (
+ "✅ How to tell if an article or journal is peer-reviewed, scholarly, or predatory
"
+ "Is it peer-reviewed?
"
+ "• Check the journal's website for a peer-review statement or editorial process description
"
+ "• In PRIMO, tick the Peer-reviewed filter in the left sidebar
"
+ "• Check if the journal is indexed in "
+ 'Scopus or '
+ 'Web of Science — '
+ "indexed = generally peer-reviewed
"
+ "Is it a legitimate journal?
"
+ "• DOAJ "
+ "— Directory of Open Access Journals (vetted, legitimate OA)
"
+ "• Web of Science Master Journal List
"
+ "• Scopus Source List
"
+ "• Think · Check · Submit: thinkchecksubmit.org "
+ "— a checklist to assess any journal
"
+ "Warning signs of predatory journals
"
+ "• Unsolicited email invitation to submit
"
+ "• No clear peer-review process or very fast acceptance (days)
"
+ "• High article processing charges (APCs) with no clear metrics
"
+ "• Not indexed in Scopus or Web of Science
"
+ "• Generic or misleading journal name ('International Journal of...')
"
+ "Need help checking a specific journal? Ask our E-Resources Librarian: "
+ "Rani Anand · rani.anand@ku.ac.ae"
+ )
+
+
+# ── Theses / dissertations guide ─────────────────────────────────────────────
+
+def _theses_answer() -> str:
+ return (
+ "📘 Finding KU theses, dissertations, and institutional research
"
+ "Khalifa University Khazna Repository
"
+ "Khazna is KU's institutional repository — it holds KU theses, dissertations, "
+ "faculty publications, conference papers, and open-access research outputs.
"
+ '🔗 khazna.ku.ac.ae
'
+ "ProQuest Dissertations & Theses Global
"
+ "KU subscribes to ProQuest D&T Global — the world's largest database of "
+ "theses and dissertations from universities worldwide.
"
+ '🔗 '
+ "Access via KU Library
"
+ "Other sources
"
+ '• EThOS — UK theses (British Library)
'
+ '• DART-Europe — European theses
'
+ '• NDLTD — Global networked digital library of theses
'
+ "For help with Khazna deposits or ORCID integration, contact: "
+ "Nikesh Narayanan · "
+ "nikesh.narayanan@ku.ac.ae"
+ )
+
+
+# ── Highly-cited papers guide ─────────────────────────────────────────────────
+
+def _highly_cited_note(topic_escaped: str) -> str:
+ """Append to a standard search answer when highly-cited filter is detected."""
+ return (
+ f"
📊 Finding highly cited papers on {topic_escaped}
"
+ "PRIMO doesn't sort by citation count, but these tools do:
"
+ '• Scopus '
+ '— search your topic → Sort by Cited by (highest)
'
+ '• Web of Science '
+ '— search → Sort by Times Cited
'
+ '• Semantic Scholar '
+ '— free, sort by Citation Count
'
+ '• Google Scholar '
+ '— sort by Cited by (broader but includes grey literature)'
+ )
+
+
+# ── Research Snapshot ─────────────────────────────────────────────────────────
+
+async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
+ """
+ Research snapshot:
+ - Reasoning block at the top (collapsible).
+ - Cited summary in the middle.
+ - PRIMO results below that.
+ - AI tools recommendation panel always at the very end.
+ - Silent fallback if Semantic Scholar returns nothing.
+ """
+ import asyncio as _asyncio
+ topic = context.topic
+ references: List[dict] = []
+
+ # Parallel fetch
+ papers, (primo_result, primo_url) = await _asyncio.gather(
+ _semantic_scholar_search(topic, limit=8),
+ _search_primo_catalog(context, limit=6),
+ )
+ search_results = primo_result.get("results", []) or []
+
+ # ── Top: reasoning block ──────────────────────────────────────────────────
+ q_display = context.ai_tool_query or f"Research on {topic}"
+ answer = _search_trace_block(q_display, context)
+
+ if papers:
+ summary = await _generate_cited_summary(topic, papers, model)
+ if not summary:
+ _safe_metrics_increment("summary_fallbacks")
+ summary = _extractive_snapshot_from_papers(topic, papers)
+
+ # ── Middle: cited summary ─────────────────────────────────────────────
+ answer += f"📖 Research Snapshot: {_escape(topic)}
{summary}"
+
+ if search_results:
+ answer += (
+ "
KU Library results: "
+ "KU-accessible items are shown below."
+ )
+
+ answer += "
Selected references:
"
+ for i, paper in enumerate(papers[:6], 1):
+ answer += (
+ f'
[{i}] '
+ f'{_escape(paper["title"])}
'
+ f''
+ f'{_escape(paper["authors"])} · {paper["year"]} · {_escape(paper["venue"] or "")}'
+ f""
+ )
+ answer += _source_badge("semantic", "https://www.semanticscholar.org/")
+ if search_results:
+ answer += _source_badge("primo", primo_url)
+
+ # ── Bottom: AI tools ──────────────────────────────────────────────────
+ answer += _ai_tools_footer(context)
+ references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
+ return answer, search_results, references
+
+ # Semantic Scholar returned nothing — silent fallback
+ if search_results:
+ answer += (
+ f"📚 Research starting points: {_escape(topic)}
"
+ "Here are KU-accessible results you can open immediately."
+ )
+ else:
+ answer += (
+ f"📚 Research starting points: {_escape(topic)}
"
+ "I didn't find direct results right now — try the AI research tools below."
+ )
+ answer += _source_badge("primo", primo_url)
+ # ── Bottom: AI tools ──────────────────────────────────────────────────────
+ answer += _ai_tools_footer(context)
+ return answer, search_results, []
+
+
+# ── Follow-up System ──────────────────────────────────────────────────────────
+
+def _filters_summary(context: SearchContextPayload) -> str:
+ filters: List[str] = []
+ if context.peer_reviewed:
+ filters.append("peer reviewed only")
+ if context.open_access:
+ filters.append("open access")
+ if context.year_from and context.year_to:
+ filters.append(f"{context.year_from}–{context.year_to}")
+ elif context.year_from:
+ filters.append(f"from {context.year_from}")
+ if context.resource_type == "books":
+ filters.append("books")
+ elif context.resource_type == "articles":
+ filters.append("articles")
+ elif context.resource_type == "both":
+ filters.append("articles and books")
+ return ", ".join(filters)
+
+
+def _question_has_new_topic(question: str, base_topic: str) -> bool:
+ q = (question or "").lower()
+ cleaned = re.sub(
+ r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
+ r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
+ " ", q,
+ )
+ tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
+ if len(tokens) < 3:
+ return False
+ base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
+ overlap = sum(1 for t in tokens if t in base_tokens)
+ return overlap <= max(1, min(2, len(base_tokens)))
+
+
+def _parse_refinement_action(question: str) -> Optional[str]:
+ q = (question or "").lower().strip()
+ if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
+ return "peer_reviewed_only"
+ if re.search(r"\b(open access|oa only|only open access)\b", q):
+ return "open_access_only"
+ if re.search(r"\b(last|past)\s+5\s+years?\b", q):
+ return "last_5_years"
+ if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
+ return "last_10_years"
+ # "last 3 years", "last 2 years" etc.
+ m = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
+ if m:
+ return f"last_{m.group(2)}_years"
+ if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
+ return "books_only"
+ if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
+ return "articles_only"
+ if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
+ return "both_resources"
+ # Review articles only — NEW
+ if REVIEW_ONLY_RE.search(q):
+ return "review_articles_only"
+ # Alternative search terms — NEW
+ if ALT_TERMS_RE.search(q):
+ return "alt_terms"
+ if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
+ return "summarize_topic"
+ if re.search(r"\bpubmed\b", q):
+ return "search_pubmed"
+ if re.search(r"\bprimo\b", q):
+ return "search_primo"
+ if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
+ return "best_databases"
+ if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
+ return "deep_research_tools"
+ return None
+
+
+def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
+ if not client_state or not client_state.recent_search_contexts:
+ return None
+ contexts = list(client_state.recent_search_contexts)
+ if client_state.follow_up_context_id:
+ for ctx in contexts:
+ if ctx.context_id == client_state.follow_up_context_id:
+ return ctx
+ if client_state.active_search_context_id:
+ for ctx in contexts:
+ if ctx.context_id == client_state.active_search_context_id:
+ return ctx
+ contexts.sort(key=lambda c: c.created_at, reverse=True)
+ return contexts[0]
+
+
+def _detect_follow_up(
+ question: str, client_state: Optional[ClientStatePayload]
+) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
+ base_context = _resolve_base_context(client_state)
+ if not base_context:
+ return False, None, None
+ explicit_action = (client_state.follow_up_action if client_state else None) or None
+ if explicit_action:
+ return True, explicit_action, base_context
+ action = _parse_refinement_action(question)
+ if not action:
+ return False, None, None
+ if _question_has_new_topic(question, base_context.topic):
+ return False, None, None
+ return True, action, base_context
+
+
+def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
+ return SearchContextPayload.model_validate(base_context.model_dump())
+
+
+def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
+ context = _clone_context(base_context)
+ context.context_id = str(uuid.uuid4())
+ context.created_at = time.time()
+ if action == "peer_reviewed_only":
+ context.peer_reviewed = True
+ elif action == "open_access_only":
+ context.open_access = True
+ elif action == "last_5_years":
+ context.year_from = str(CURRENT_YEAR - 4)
+ context.year_to = str(CURRENT_YEAR)
+ elif action == "last_10_years":
+ context.year_from = str(CURRENT_YEAR - 9)
+ context.year_to = str(CURRENT_YEAR)
+ elif action == "last_3_years":
+ context.year_from = str(CURRENT_YEAR - 2)
+ context.year_to = str(CURRENT_YEAR)
+ elif action == "last_2_years":
+ context.year_from = str(CURRENT_YEAR - 1)
+ context.year_to = str(CURRENT_YEAR)
+ elif action and action.startswith("last_") and action.endswith("_years"):
+ # Generic "last N years" — parse N
+ try:
+ n = int(action.split("_")[1])
+ context.year_from = str(CURRENT_YEAR - n + 1)
+ context.year_to = str(CURRENT_YEAR)
+ except (IndexError, ValueError):
+ pass
+ elif action == "books_only":
+ context.resource_type = "books"
+ elif action == "articles_only":
+ context.resource_type = "articles"
+ elif action == "both_resources":
+ context.resource_type = "both"
+ elif action == "review_articles_only":
+ # Map to PRIMO facet for review articles — stored as resource_type hint
+ context.resource_type = "articles"
+ context.peer_reviewed = True # reviews are implicitly peer-reviewed
+ # Store the review filter flag in the context notes field if available
+ # The PRIMO query will add facet_rtype=reviews
+ elif action == "search_pubmed":
+ context.source = "pubmed"
+ context.intent = "search_medical"
+ context.resource_type = "articles"
+ elif action == "search_primo":
+ context.source = "primo"
+ return context
+
+
+async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return []
+ try:
+ llm = _get_llm(model, temperature=0.5, max_tokens=200)
+ response = await llm.ainvoke([
+ {"role": "system", "content": (
+ "You are a research librarian helping a user explore a topic more deeply. "
+ "Given a research topic, generate exactly 3 short follow-up questions that explore "
+ "DIFFERENT ASPECTS of the topic — such as subtopics, methodological angles, "
+ "applications, comparisons, or related fields. "
+ "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
+ "Return ONLY a JSON array of 3 strings, each under 12 words. "
+ "No explanation, no preamble, no markdown.\n"
+ "Example for 'quantum computing':\n"
+ '[\"What are the main hardware approaches in quantum computing?\", '
+ '"How does quantum error correction work?\", '
+ '"Applications of quantum computing in cryptography\"]'
+ )},
+ {"role": "user", "content": f"Topic: {topic}"},
+ ])
+ raw = response.content.strip()
+ if raw.startswith("```"):
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
+ questions = json.loads(raw)
+ if not isinstance(questions, list):
+ return []
+ return [
+ {"label": q.strip("?") + "?", "question": q.strip("?") + "?"}
+ for q in questions[:3]
+ if isinstance(q, str) and q.strip()
+ ]
+ except Exception as e:
+ logger.warning(f"Topic follow-up generation failed: {e}")
+ return []
+
+
+async def _search_follow_up(
+ context: SearchContextPayload, model: str, summary_mode: bool = False
+) -> Tuple[str, List[dict]]:
+ topic = context.display_topic or context.topic
+ suggestions: List[dict] = []
+ topic_follow_ups = await _generate_topic_follow_ups(topic, model)
+ suggestions.extend(topic_follow_ups)
+
+ filter_suggestions: List[dict] = []
+ if summary_mode:
+ filter_suggestions.append({
+ "label": f"Show KU-accessible articles on {topic}",
+ "question": f"Find KU-accessible articles on {topic}",
+ "action": "articles_only",
+ "context_id": context.context_id,
+ })
+ filter_suggestions.append({
+ "label": f"Limit {topic} to the last 5 years",
+ "question": f"Limit {topic} to the last 5 years",
+ "action": "last_5_years",
+ "context_id": context.context_id,
+ })
+ else:
+ if not context.peer_reviewed and context.source == "primo":
+ filter_suggestions.append({
+ "label": "Peer reviewed only",
+ "question": f"Make the {topic} search peer reviewed only",
+ "action": "peer_reviewed_only",
+ "context_id": context.context_id,
+ })
+ if not (context.year_from and context.year_to == str(CURRENT_YEAR)):
+ filter_suggestions.append({
+ "label": "Last 5 years",
+ "question": f"Limit {topic} to the last 5 years",
+ "action": "last_5_years",
+ "context_id": context.context_id,
+ })
+ if context.intent == "search_medical" and context.source != "pubmed":
+ filter_suggestions.append({
+ "label": "Search PubMed instead",
+ "question": f"Search PubMed for {topic}",
+ "action": "search_pubmed",
+ "context_id": context.context_id,
+ })
+ filter_suggestions.append({
+ "label": "Research snapshot",
+ "question": f"Give me a brief research snapshot on {topic}",
+ "action": "summarize_topic",
+ "context_id": context.context_id,
+ })
+
+ suggestions.extend(filter_suggestions[:2])
+ question_text = (
+ f"Here are some related angles you might want to explore on {_escape(topic)}. "
+ "Or I can refine the search with filters — just ask."
+ )
+ return question_text, suggestions[:5]
+
+
+def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
+ q = (question or "").lower()
+ if _looks_library_hours_question(question):
+ return (
+ "Would you like the library contact details too?",
+ [
+ {"label": "Show library contact details", "question": "Show the library contact details"},
+ {"label": "Open the live library hours page", "question": "Open the live library hours page"},
+ ],
+ )
+ if "study room" in q or "room" in q:
+ return (
+ "Would you like the study-room booking link?",
+ [
+ {"label": "Study-room booking link", "question": "Show the study-room booking link"},
+ {"label": "Library services for both campuses", "question": "Show library services for both campuses"},
+ ],
+ )
+ if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
+ return (
+ "Would you like me to recommend the best KU databases for this topic?",
+ [
+ {"label": "Recommend best KU databases", "question": "Recommend the best KU databases for this topic"},
+ {"label": "How to access databases off campus", "question": "How do I access databases off campus?"},
+ ],
+ )
+ return (
+ "Would you like a related library service or staff contact?",
+ [
+ {"label": "Best staff contact", "question": "Show the best staff contact for this"},
+ {"label": "KU library page for this topic", "question": "Open the KU library page for this topic"},
+ ],
+ )
+
+
+def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
+ return (
+ "Would you like a brief explanation, current web information, or KU Library resources on this topic?",
+ [
+ {"label": "Shorter explanation", "question": f"Give me a shorter explanation of {question}"},
+ {"label": "Current web information", "question": f"Show current web information on {question}"},
+ {"label": "Find KU Library resources", "question": f"Find KU Library resources on {question}"},
+ ],
+ )
+
+
+def _social_follow_up() -> Tuple[str, List[dict]]:
+ return (
+ "What would you like help with next?",
+ [
+ {"label": "Find articles on a topic", "question": "Find articles on a topic"},
+ {"label": "Check a library service", "question": "Check a library service"},
+ {"label": "Contact a librarian", "question": "Contact a librarian"},
+ ],
+ )
+
+
+def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
+ topic = _escape(context.display_topic or context.topic)
+ resource = {"books": "books", "articles": "articles", "both": "articles and books"}.get(context.resource_type, "articles")
+ location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
+ if is_follow_up:
+ return f"I updated your previous search on {topic} in the {location} and looked for {resource}."
+ return f"I searched the {location} for {resource} on {topic}."
+
+
+async def _run_search_mode(
+ question: str, context: SearchContextPayload, model: str, is_follow_up: bool
+) -> Tuple[str, List[dict], List[dict], str]:
+ context = await _prepare_queries(question, context, model, is_follow_up)
+
+ if context.source == "pubmed":
+ result, source_url = await _search_pubmed_articles(context, limit=6)
+ source_name = "pubmed"
+ else:
+ result, source_url = await _search_primo_catalog(context, limit=6)
+ source_name = "primo"
+
+ search_results = result.get("results", []) or []
+ total = result.get("total", len(search_results))
+
+ # ── Top: reasoning / how-I-searched block ────────────────────────────────
+ answer = _search_trace_block(question, context)
+
+ # ── Middle: answer sentence + result count ────────────────────────────────
+ answer += _search_answer_intro(context, is_follow_up)
+
+ filters = _filters_summary(context)
+ if filters:
+ answer += f"
Active filters: {_escape(filters)}."
+
+ if search_results:
+ answer += f"
Found {total:,} result(s) — top {len(search_results)} shown below."
+ else:
+ answer += "
No matching results found right now — try broadening your terms or use the AI tools below."
+
+ if context.intent == "search_medical" and context.source != "pubmed":
+ answer += "
For the strongest medical evidence trail, also check PubMed or Embase."
+
+ answer += _source_badge(source_name, source_url)
+
+ # ── Bottom: AI tools recommendation panel ─────────────────────────────────
+ answer += _ai_tools_footer(context)
+
+ sources = []
+ for item in search_results[:6]:
+ link = item.get("primo_url") or item.get("link")
+ if link:
+ sources.append({"title": item.get("title", "Result"), "url": link})
+ return answer, search_results, sources, source_url
+
+
+def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
+ dedup: Dict[str, SearchContextPayload] = {}
+ for ctx in contexts:
+ dedup[ctx.context_id] = ctx
+ ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
+ return ordered[:5]
+
+
+# ── Main Endpoint ──────────────────────────────────────────────────────────────
+
+@router.post("", response_model=AgentResponse)
+async def agent_query(request: Request, req: AgentRequest):
+ start_time = time.time()
+ question = _normalize_whitespace(req.question)
+ history = req.history
+ model = req.model
+ client_state = req.client_state
+ _safe_metrics_increment("agent_requests")
+
+ if not question:
+ follow_up_question, follow_up_suggestions = _social_follow_up()
+ return AgentResponse(
+ answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"),
+ intent="social",
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if PURE_GREETING_RE.match(question):
+ follow_up_question, follow_up_suggestions = _social_follow_up()
+ return AgentResponse(
+ answer=(
+ "Hi! I'm LibBee, the Khalifa University Library AI Assistant.
"
+ "I can help you find articles and books, search databases, access full text, request Interlibrary Loan (ILL), "
+ "and answer questions about library services and staff."
+ + _source_badge("libbee")
+ ),
+ intent="social_greeting",
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None
+ if staff_match:
+ _safe_metrics_bucket("intents", "staff_lookup")
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("staff_lookup", "staff_name_match", question) + staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
+ intent="library_info",
+ tools_used=["staff_name_match"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
+ if staff_role_match:
+ _safe_metrics_bucket("intents", "staff_lookup")
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("staff_lookup", "staff_role_match", question) + staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
+ intent="library_info",
+ tools_used=["staff_role_match"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if _is_greeting_menu_followup(question, history):
+ follow_up_question, follow_up_suggestions = _social_follow_up()
+ return AgentResponse(
+ answer=_thought_block("social_greeting", "greeting_menu", question) + _greeting_menu_clarify_answer() + _source_badge("libbee"),
+ intent="social_greeting",
+ is_follow_up=True,
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if _looks_library_hours_question(question):
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("hours", "hours_hardcoded", question) + _hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL),
+ intent="library_info",
+ tools_used=["hours_hardcoded"],
+ ask_librarian=False,
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if _looks_campus_question(question):
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("campus", "campus_hardcoded", question) + _campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
+ intent="library_info",
+ tools_used=["campus_hardcoded"],
+ ask_librarian=False,
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if _is_database_recommendation_question(question):
+ answer = _database_recommendation_answer(question)
+ answer += _contact_footer_for_query(question, None)
+ answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("database_rec", "database_recommendation", question) + answer,
+ intent="library_info",
+ tools_used=["database_recommendation"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
+
+ # ── Catalogue availability: "books available in the KU library on X" ─────────
+ # Catches questions asking what the library *holds*, not topic searches.
+ # Must fire BEFORE the LLM classifier which would route these to search_academic.
+ _CATALOGUE_RE = re.compile(
+ r"\b("
+ r"available in (the )?(ku|khalifa) library"
+ r"|in (the )?(ku|khalifa) library"
+ r"|does (the )?(ku|khalifa) library have"
+ r"|does (the )?library (have|carry|stock|hold)"
+ r"|(ku|khalifa) library (has|have|carry|holds?|stocks?)"
+ r"|ku library (books?|copies|titles?)"
+ r")\b",
+ re.IGNORECASE,
+ )
+ _BOOK_RE = re.compile(r"\b(book|books|ebook|ebooks|textbook|textbooks|copy|copies|title|titles)\b", re.IGNORECASE)
+
+ if _CATALOGUE_RE.search(question) and _BOOK_RE.search(question):
+ # Route as library_info → RAG will handle or fall back to Ask a Librarian
+ # Also build a PRIMO books URL so the user can search directly
+ from urllib.parse import quote as _quote
+ _topic_raw = re.sub(
+ r"\b(can you (help me )?find|find|search|look for|show me|help me find"
+ r"|books? (available|in|on)|available in.{0,30}library|in the ku library"
+ r"|on|about|regarding|related to|ku library|khalifa university library"
+ r"|machine learning|please|thanks)\b",
+ " ", question, flags=re.IGNORECASE,
+ )
+ _topic = _normalize_whitespace(_topic_raw).strip(" ,.")
+ _primo_books_url = (
+ "https://khalifa.primo.exlibrisgroup.com/discovery/search"
+ f"?query=any,contains,{_quote(_topic or question)},AND"
+ "&tab=Everything&search_scope=MyInst_and_CI"
+ "&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
+ "&qInclude=facet_rtype,exact,books"
+ )
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+
+ # Try RAG first for any policy/service context
+ rag_results = []
+ try:
+ from app import get_rag_service
+ rag_results = await get_rag_service().hybrid_search(question, top_k=3)
+ except Exception:
+ pass
+
+ if rag_results:
+ rag_answer = await _rag_answer(question, rag_results, [], model)
+ else:
+ rag_answer = ""
+
+ catalogue_answer = (
+ f"To find books available in the KU Library on {_escape(_topic or 'this topic')}, "
+ f"search the KU Library catalogue directly:
"
+ f''
+ f"📚 Search KU Library catalogue for books →
"
+ f"The catalogue shows real-time availability — whether a copy is on the shelf, checked out, or available as an e-book."
+ )
+ if rag_answer:
+ catalogue_answer += f"
{rag_answer}"
+
+ return AgentResponse(
+ answer=_thought_block("library_info", "catalogue_books", question) + catalogue_answer + _source_badge("primo", _primo_books_url),
+ intent="library_info",
+ tools_used=["catalogue_books_redirect"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if is_follow_up and base_context:
+ _safe_metrics_increment("follow_up_hits")
+ context = _apply_follow_up_action(base_context, follow_up_action)
+
+ if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
+ context = await _prepare_queries(question, context, model, True)
+ if follow_up_action == "summarize_topic":
+ answer, search_results, sources = await _research_snapshot(context, model)
+ else:
+ # deep_research_tools / best_databases follow-up
+ answer = (
+ f"{_search_trace_block(question, context)}"
+ f"Research guide for {_escape(context.display_topic or context.topic)}
"
+ f"Here are the best AI tools and resources for a deep dive on this topic."
+ + _source_badge("libbee")
+ + _ai_tools_footer(context)
+ )
+ search_results = []
+ sources = []
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
+ _safe_metrics_increment("search_handoffs")
+ return AgentResponse(
+ answer=answer,
+ intent=context.intent,
+ tools_used=["follow_up_resolution", follow_up_action],
+ search_results=search_results,
+ sources=sources,
+ model_used=model,
+ response_time=time.time() - start_time,
+ corrected_query=context.topic,
+ natural_query=context.ai_tool_query,
+ database_query=context.primo_boolean_query,
+ ai_tool_query=context.ai_tool_query,
+ primo_boolean_query=context.primo_boolean_query,
+ primo_search_url=_primo_clean_url(context),
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(context),
+ is_follow_up=True,
+ )
+
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
+ return AgentResponse(
+ answer=answer,
+ intent=context.intent,
+ tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
+ search_results=search_results,
+ sources=sources,
+ model_used=model,
+ response_time=time.time() - start_time,
+ corrected_query=context.topic,
+ natural_query=context.ai_tool_query,
+ database_query=context.primo_boolean_query,
+ ai_tool_query=context.ai_tool_query,
+ primo_boolean_query=context.primo_boolean_query,
+ primo_search_url=source_url,
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(context),
+ is_follow_up=True,
+ )
+
+ if is_follow_up and base_context:
+ _safe_metrics_increment("follow_up_hits")
+ context = _apply_follow_up_action(base_context, follow_up_action)
+
+ # ── Follow-up: alternative search terms (no new search) ───────────────
+ if follow_up_action == "alt_terms":
+ alt_answer = await _alternative_terms_answer(question, base_context, model)
+ follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model)
+ return AgentResponse(
+ answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
+ intent="library_info",
+ tools_used=["alt_terms"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(base_context),
+ is_follow_up=True,
+ )
+
+ if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
+ context = await _prepare_queries(question, context, model, True)
+ if follow_up_action == "summarize_topic":
+ answer, search_results, sources = await _research_snapshot(context, model)
+ else:
+ # deep_research_tools / best_databases follow-up
+ answer = (
+ f"{_search_trace_block(question, context)}"
+ f"Research guide for {_escape(context.display_topic or context.topic)}
"
+ f"Here are the best AI tools and resources for a deep dive on this topic."
+ + _source_badge("libbee")
+ + _ai_tools_footer(context)
+ )
+ search_results = []
+ sources = []
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
+ _safe_metrics_increment("search_handoffs")
+ return AgentResponse(
+ answer=answer,
+ intent=context.intent,
+ tools_used=["follow_up_resolution", follow_up_action],
+ search_results=search_results,
+ sources=sources,
+ model_used=model,
+ response_time=time.time() - start_time,
+ corrected_query=context.topic,
+ natural_query=context.ai_tool_query,
+ database_query=context.primo_boolean_query,
+ ai_tool_query=context.ai_tool_query,
+ primo_boolean_query=context.primo_boolean_query,
+ primo_search_url=_primo_clean_url(context),
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(context),
+ is_follow_up=True,
+ )
+
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
+ return AgentResponse(
+ answer=answer,
+ intent=context.intent,
+ tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
+ search_results=search_results,
+ sources=sources,
+ model_used=model,
+ response_time=time.time() - start_time,
+ corrected_query=context.topic,
+ natural_query=context.ai_tool_query,
+ database_query=context.primo_boolean_query,
+ ai_tool_query=context.ai_tool_query,
+ primo_boolean_query=context.primo_boolean_query,
+ primo_search_url=source_url,
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(context),
+ is_follow_up=True,
+ )
+
+ # ── Pre-classifier specialist handlers ───────────────────────────────────
+ # These fire BEFORE the LLM classifier for patterns the classifier handles
+ # poorly (too often routes to search_academic for library-service questions).
+
+ # Full-text / ILL chain
+ if FULLTEXT_REQUEST_RE.search(question):
+ ft_answer = _fulltext_chain_answer(question)
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("library_info", "fulltext_chain", question,
+ "Identified a full-text access request. Returning the KU full-text access chain.") + ft_answer + _source_badge("ku_library", "https://library.ku.ac.ae/ill/"),
+ intent="library_info",
+ tools_used=["fulltext_chain"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ # Theses / dissertations / Khazna
+ if THESES_RE.search(question):
+ th_answer = _theses_answer()
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("library_info", "theses_guide", question,
+ "Identified a theses/repository question.") + th_answer + _source_badge("ku_library", "https://khazna.ku.ac.ae"),
+ intent="library_info",
+ tools_used=["theses_guide"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ # Predatory / peer-review evaluation
+ if PREDATORY_RE.search(question):
+ pr_answer = _predatory_eval_answer()
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("library_info", "predatory_eval", question,
+ "Identified a question about evaluating journals/peer review.") + pr_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
+ intent="library_info",
+ tools_used=["predatory_eval"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ # Citation chaining
+ if CITATION_CHAIN_RE.search(question):
+ cc_answer = _citation_chain_answer()
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("library_info", "citation_chain", question,
+ "Identified a citation tracing / forward-backward search question.") + cc_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
+ intent="library_info",
+ tools_used=["citation_chain"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ # Search strategy / boolean explanation
+ if BOOLEAN_STRATEGY_RE.search(question):
+ strategy_answer, _plan = await _search_strategy_answer(question, model)
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("library_info", "search_strategy", question,
+ "Identified a search strategy / boolean building request.") + strategy_answer + _source_badge("libbee"),
+ intent="library_info",
+ tools_used=["search_strategy_explain"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ database_query=_plan.get("boolean"),
+ natural_query=_plan.get("natural"),
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ # Alternative search terms (standalone, not follow-up)
+ if ALT_TERMS_RE.search(question):
+ alt_answer = await _alternative_terms_answer(question, None, model)
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
+ intent="library_info",
+ tools_used=["alt_terms"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ # FIX: _llm_classify always falls back to rule-based on any exception
+ cls_result = await _llm_classify(question, history, model)
+ intent = cls_result.get("intent", "general")
+ _safe_metrics_bucket("intents", intent)
+ casual_hint = cls_result.get("casual_answer", "")
+
+ if intent == "social":
+ answer = (
+ casual_hint
+ if casual_hint and len(casual_hint.strip()) > 20
+ else await _libbee_casual_response(question, history, model, casual_hint)
+ )
+ follow_up_question, follow_up_suggestions = _social_follow_up()
+ return AgentResponse(
+ answer=_thought_block("social", "libbee_casual", question) + answer + _source_badge("libbee"),
+ intent="social_greeting",
+ tools_used=["libbee_casual"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if intent == "library_info":
+ rag_results = []
+ try:
+ from app import get_rag_service
+ rag_results = await get_rag_service().hybrid_search(question, top_k=4)
+ except Exception as e:
+ logger.error(f"RAG search failed: {e}")
+
+ if not rag_results:
+ answer = (
+ "I couldn't find specific information on that in my knowledge base.
"
+ "Please contact our librarians directly:
"
+ '📧 libse@ku.ac.ae
'
+ f'🔗 Ask a Librarian'
+ )
+ answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
+ sources = []
+ tools_used = ["rag_no_results"]
+ thought = _thought_block("library_info", "rag_no_results", question,
+ "No matching content was found — I am directing you to Ask a Librarian.")
+ else:
+ answer = await _rag_answer(question, rag_results, history, model)
+ answer += _source_badge("rag", "https://library.ku.ac.ae/")
+ sources = [
+ {"title": r.get("title", "Library KB"), "url": r.get("source", "")}
+ for r in rag_results if r.get("source")
+ ]
+ tools_used = ["rag_hybrid_search"]
+ thought = _thought_block("rag_search", "rag_hybrid_search", question,
+ f"Hybrid FAISS + BM25 search returned {len(rag_results)} relevant chunk(s) from the KB.")
+
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=thought + answer,
+ intent="library_info",
+ tools_used=tools_used,
+ sources=sources,
+ ask_librarian=not bool(rag_results),
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ if intent in ("search_academic", "search_medical"):
+ # ── Partial title search — route to PRIMO title query ─────────────────
+ if PARTIAL_TITLE_RE.search(question):
+ from urllib.parse import quote as _qt
+ # Extract quoted fragment or the whole question as title search
+ _title_frag = (re.search(r'"([^"]{4,80})"', question) or
+ re.search(r'"([^"]{4,80})"', question))
+ _title_q = _title_frag.group(1) if _title_frag else _light_strip_retrieval_boilerplate(question)
+ _title_url = (
+ "https://khalifa.primo.exlibrisgroup.com/discovery/search"
+ f"?query=title,contains,{_qt(_title_q)},AND"
+ "&tab=Everything&search_scope=MyInst_and_CI"
+ "&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
+ )
+ follow_up_question, follow_up_suggestions = _library_follow_up(question)
+ return AgentResponse(
+ answer=(
+ _thought_block("library_info", "title_search", question,
+ "Identified a partial title search — routing to PRIMO title search.") +
+ f"📖 Title search in KU Library
"
+ f"I'll search the KU Library catalogue by title for: {_escape(_title_q)}
"
+ f''
+ f"🔍 Search by title in PRIMO →
"
+ "If you only remember part of the title, use PRIMO's advanced search and choose "
+ "Title from the field dropdown — it supports partial matches.
"
+ "You can also try "
+ 'Google Scholar '
+ "with the partial title in quotes." +
+ _source_badge("primo", _title_url)
+ ),
+ intent="library_info",
+ tools_used=["title_search"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ topic = await _extract_topic(question, model)
+ topic = _title_case_topic(topic)
+ year_from, year_to = _parse_year_filters(question)
+
+ # ── Dual resource type: articles AND books in one question ─────────────
+ if DUAL_RESOURCE_RE.search(question):
+ import asyncio as _aio
+ # Build two contexts: articles and books
+ _ctx_articles = SearchContextPayload(
+ context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
+ intent=intent, source="primo", resource_type="articles",
+ peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
+ open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
+ year_from=year_from, year_to=year_to,
+ )
+ _ctx_books = SearchContextPayload(
+ context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
+ intent=intent, source="primo", resource_type="books",
+ peer_reviewed=False, open_access=False,
+ year_from=year_from, year_to=year_to,
+ )
+ _ctx_articles, _ctx_books = await _aio.gather(
+ _prepare_queries(question, _ctx_articles, model, False),
+ _prepare_queries(question, _ctx_books, model, False),
+ )
+ (_res_a, _src_url_a), (_res_b, _src_url_b) = await _aio.gather(
+ _search_primo_catalog(_ctx_articles, limit=4),
+ _search_primo_catalog(_ctx_books, limit=3),
+ )
+ _results_a = _res_a.get("results", [])
+ _results_b = _res_b.get("results", [])
+ _total_a = _res_a.get("total", len(_results_a))
+ _total_b = _res_b.get("total", len(_results_b))
+
+ _dual_answer = _search_trace_block(question, _ctx_articles)
+ _dual_answer += (
+ f"📄 Articles on {_escape(topic)} "
+ f"— {_total_a:,} result(s) found, top {len(_results_a)} shown below.
"
+ )
+ if not _results_a:
+ _dual_answer += "No article results found — try broadening your search.
"
+ _dual_answer += _source_badge("primo", _src_url_a)
+
+ _dual_answer += f"
📚 Books on {_escape(topic)} "
+ _dual_answer += f"— {_total_b:,} result(s) found, top {len(_results_b)} shown below.
"
+ if not _results_b:
+ _dual_answer += "No book results found — try the KU Library catalogue directly.
"
+ _dual_answer += _source_badge("primo", _src_url_b)
+ _dual_answer += _ai_tools_footer(_ctx_articles)
+
+ _combined = _results_a + _results_b
+ follow_up_question, follow_up_suggestions = await _search_follow_up(_ctx_articles, model)
+ return AgentResponse(
+ answer=_dual_answer,
+ intent=intent,
+ tools_used=["primo_dual_resource"],
+ search_results=_combined[:8],
+ sources=[{"title": r.get("title", "Result"), "url": r.get("primo_url") or r.get("link", "")}
+ for r in _combined[:6] if r.get("primo_url") or r.get("link")],
+ model_used=model,
+ response_time=time.time() - start_time,
+ corrected_query=topic,
+ natural_query=_ctx_articles.ai_tool_query,
+ primo_boolean_query=_ctx_articles.primo_boolean_query,
+ primo_search_url=_src_url_a,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(_ctx_articles),
+ )
+
+ context = SearchContextPayload(
+ context_id=str(uuid.uuid4()),
+ topic=topic,
+ display_topic=topic,
+ intent=intent,
+ source="primo",
+ resource_type=_derive_resource_type(question),
+ peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
+ open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
+ year_from=year_from,
+ year_to=year_to,
+ )
+
+ context = await _prepare_queries(question, context, model, False)
+ summary_mode = _is_summary_request(question)
+ deep_research_mode = bool(re.search(
+ r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
+ question, re.IGNORECASE,
+ ))
+ is_highly_cited = bool(HIGHLY_CITED_RE.search(question))
+
+ if summary_mode or deep_research_mode:
+ answer, search_results, sources = await _research_snapshot(context, model)
+ if deep_research_mode and "Research guide" not in answer and "AI tools" not in answer:
+ answer += f"
{_ai_tools_footer(context)}"
+ if is_highly_cited:
+ answer += _highly_cited_note(_escape(topic))
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
+ _safe_metrics_increment("search_handoffs")
+ return AgentResponse(
+ answer=answer,
+ intent=intent,
+ tools_used=["research_snapshot"],
+ search_results=search_results,
+ sources=sources,
+ model_used=model,
+ response_time=time.time() - start_time,
+ corrected_query=context.topic,
+ natural_query=context.ai_tool_query,
+ database_query=context.primo_boolean_query,
+ ai_tool_query=context.ai_tool_query,
+ primo_boolean_query=context.primo_boolean_query,
+ primo_search_url=_primo_clean_url(context),
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(context),
+ )
+
+ answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
+ # Append highly-cited guidance if requested
+ if is_highly_cited:
+ answer += _highly_cited_note(_escape(topic))
+ follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
+ return AgentResponse(
+ answer=answer,
+ intent=intent,
+ tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
+ search_results=search_results,
+ sources=sources,
+ model_used=model,
+ response_time=time.time() - start_time,
+ corrected_query=context.topic,
+ natural_query=context.ai_tool_query,
+ database_query=context.primo_boolean_query,
+ ai_tool_query=context.ai_tool_query,
+ primo_boolean_query=context.primo_boolean_query,
+ primo_search_url=source_url,
+ primo_ai_url=_tool_urls(context).get("primo_ai"),
+ pubmed_search_url=_tool_urls(context).get("pubmed"),
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ search_context=_context_to_dict(context),
+ )
+
+ if intent == "general_recent":
+ answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("general_recent", "web_search", question) + answer,
+ intent="general_recent",
+ tools_used=["web_search"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+ # general fallback
+ answer = await _web_search_answer(question, history, model) + _source_badge("web")
+ follow_up_question, follow_up_suggestions = _general_follow_up(question)
+ return AgentResponse(
+ answer=_thought_block("general", "web_search", question) + answer,
+ intent="general",
+ tools_used=["web_search"],
+ model_used=model,
+ response_time=time.time() - start_time,
+ follow_up_question=follow_up_question,
+ follow_up_suggestions=follow_up_suggestions,
+ )
+
+
+# ── Utility Endpoints ──────────────────────────────────────────────────────────
+
+@router.get("/test")
+async def test():
return {
- "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
- "max_results": settings.max_results,
- "maintenance_mode": settings.maintenance_mode,
+ "status": "ok",
+ "message": "Agent endpoint is working!",
+ "staff_loaded": len(STAFF_DIRECTORY),
}
-@app.get("/year")
-def get_year():
- from datetime import datetime
- now = datetime.utcnow()
- return {"year": now.year, "month": now.month, "date": now.strftime("%Y-%m-%d")}
+@router.get("/rag-status")
+async def rag_status():
+ # FIX: use get_rag_service() accessor
+ try:
+ from app import get_rag_service
+ return get_rag_service().get_stats()
+ except Exception as e:
+ return {"error": str(e), "ready": False}