diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,3446 +1,219 @@ +"""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 """ -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 asyncio import logging -import re import time -import uuid -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional, Tuple -from urllib.parse import quote +from contextlib import asynccontextmanager -import httpx -from fastapi import APIRouter, Request -from pydantic import BaseModel, ConfigDict, Field +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 from src.config import get_settings -from src.services.staff_service import ( - STAFF_DIRECTORY, - match_staff_name, - match_staff_role, - should_attempt_staff_lookup, - staff_name_answer, - staff_role_answer, -) +from src.services.cache_service import CacheService +from src.services.metrics_service import MetricsService +from src.services.rag_service import RAGService +logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -router = APIRouter() - - -# ── Pydantic Models ─────────────────────────────────────────────────────────── - -class ChatMessage(BaseModel): - role: str - content: str - - -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) - - -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 - - -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 ( - "" - + rows + "
" - ) - - 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}' - ) +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() -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.' - ) +# ── Public accessors (used by routers to avoid circular imports) ─────────────── -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" - ) +def get_rag_service() -> RAGService: + return rag_service -# ── LLM Helpers ─────────────────────────────────────────────────────────────── +def get_metrics_service() -> MetricsService: + return metrics_service -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, - ) +# ── Background flush task ────────────────────────────────────────────────────── -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.' - ) - -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." - ) + # Build staff index try: - 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}") + build_staff_index() + logger.info("Staff index built") + except Exception as exc: + logger.error("Staff index build failed: %s", exc) + # Initialize RAG if settings.openai_api_key: try: - 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}" + 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) else: - base = _normalize_whitespace(question).strip(".?") - if len(base.split()) < 4: - base = f"Find research on {topic}" + logger.warning("OPENAI_API_KEY not set — RAG disabled") - if filters: - return f"{base} with {', '.join(filters)}." - return base.rstrip(".") + "." + # Start background metrics flush + flush_task = asyncio.create_task(_metrics_flush_loop(interval=30)) + logger.info("LibBee startup complete") + yield -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) + # Graceful shutdown — flush remaining metrics + flush_task.cancel() try: - 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) + await asyncio.wait_for(metrics_service.flush(), timeout=5.0) except Exception: - return _normalize_whitespace(text) - - -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() - - -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. + pass + logger.info("LibBee shutting down") -1. corrected: lightly edit the FULL request for spelling, grammar, and clarity. - Preserve every substantive concept, constraint, and task. Do NOT collapse. -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 +app = FastAPI( + title="LibBee - KU Library AI", + description="Khalifa University Library AI Assistant", + version="3.1.0", + lifespan=lifespan, +) -Return ONLY valid JSON: -{{"corrected":"...","natural":"...","boolean":"(\"A\" OR \"B\") AND (\"C\" OR \"D\")","year_from":"","year_to":"","peer_reviewed":false,"open_access":false}}""" +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, +) -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. - """ +@app.middleware("http") +async def operational_guardrails(request: Request, call_next): settings = get_settings() - raw_query = _normalize_whitespace(question or "") - light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query - - 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 "#") + # 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."}, ) - 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 [] - + 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."}, + ) + bucket.append(now) -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: - 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 "" + 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."}) -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]) +from src.api import admin, agent, feedback, search # noqa: E402 +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 ──────────────────────────────────────────── -async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]: - from src.api.search import SearchRequest, search_primo +@app.get("/") +def health_check(): settings = get_settings() - 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 - - -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."), + 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"], } - 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 +@app.get("/config") +def public_config(): 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 { - "status": "ok", - "message": "Agent endpoint is working!", - "staff_loaded": len(STAFF_DIRECTORY), + "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.", + "max_results": settings.max_results, + "maintenance_mode": settings.maintenance_mode, } -@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} +@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")}