diff --git "a/src/api/agent.py" "b/src/api/agent.py"
--- "a/src/api/agent.py"
+++ "b/src/api/agent.py"
@@ -1,5 +1,5 @@
"""
-agent.py — LibBee v3.8.1
+agent.py — LibBee v3.6
╔══════════════════════════════════════════════════════════════════╗
║ TABLE OF CONTENTS ║
@@ -7,7 +7,7 @@ agent.py — LibBee v3.8.1
║ §1 Imports ║
║ §2 Pydantic Models ║
║ §3 Constants & Compiled Regexes ║
-║ §4 LibCal Hours & Events Service ║
+║ §4 LibCal Hours & Events Service ← v3.5/v3.6 ║
║ §5 Runtime Config & Utility Helpers ║
║ §6 Intent Classification (rule-based + LLM) ║
║ §7 LLM Wrappers ║
@@ -22,13 +22,12 @@ agent.py — LibBee v3.8.1
║ §16 FastAPI Endpoint & Utility Routes ║
╚══════════════════════════════════════════════════════════════════╝
-Changes over v3.8:
-- §3 _URL_INSTRUCTION constant added — strict URL accuracy instruction
- appended to ALL LLM system prompts to prevent hallucinated links.
-- §7 All 6 LLM system prompts now append _URL_INSTRUCTION alongside _GUARDRAIL:
- _libbee_casual_response, _rag_answer, _llm_general_answer,
- _web_search_answer, _topic_intro, _generate_cited_summary.
-- app.py v3.6: X-Frame-Options header removed (fixes HF Space iframe error).
+Changes over v3.5:
+- §4 LibCal Events: EVENTS_RE + _libcal_fetch_events() +
+ _events_answer() — live upcoming events from KU LibCal calendar
+ via Cloudflare Worker /events proxy. 30-min cache.
+- Routing: EVENTS_RE check added in _agent_query_inner.
+- worker.js: /events endpoint added (v3.6).
"""
# ── §1 Imports ────────────────────────────────────────────────────────────────
@@ -97,6 +96,7 @@ class ClientStatePayload(BaseModel):
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
@@ -241,40 +241,9 @@ _GUARDRAIL = (
"Never produce content that could embarrass Khalifa University or its library."
)
-# ── URL accuracy instruction — appended to ALL LLM system prompts ─────────────
-# Prevents the LLM from hallucinating or guessing URLs in any response.
-# All 6 LLM system prompts append this constant alongside _GUARDRAIL.
-_URL_INSTRUCTION = (
- "IMPORTANT — URLS AND LINKS: "
- "You must ONLY include URLs that are explicitly present in the context or "
- "knowledge base provided to you, OR that appear in this fixed verified list of "
- "KU Library URLs: "
- "https://library.ku.ac.ae | "
- "https://library.ku.ac.ae/hours | "
- "https://library.ku.ac.ae/AskUs | "
- "https://library.ku.ac.ae/ill/ | "
- "https://library.ku.ac.ae/rooms/ | "
- "https://library.ku.ac.ae/eresources | "
- "https://library.ku.ac.ae/orcid | "
- "https://library.ku.ac.ae/oa/ | "
- "https://library.ku.ac.ae/alumni/ | "
- "https://library.ku.ac.ae/med/ | "
- "https://library.ku.ac.ae/requesttitle/ | "
- "https://library.ku.ac.ae/google | "
- "https://khazna.ku.ac.ae | "
- "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU | "
- "https://khalifa.primo.exlibrisgroup.com/discovery/account?vid=971KUOSTAR_INST:KU§ion=overview | "
- "https://kustar.libcal.com/calendar/events | "
- "https://ku-library.github.io/researchbee/. "
- "NEVER invent, guess, or construct URLs that are not in the above list or "
- "explicitly present in the provided context. "
- "When a relevant verified URL exists for your answer, ALWAYS include it as a "
- 'clickable HTML link: descriptive link text. '
- "If you are unsure whether a URL is correct, omit it entirely and direct the "
- "user to Ask a Librarian at https://library.ku.ac.ae/AskUs instead."
-)
-
# ── Sensitive institutional topic gate ───────────────────────────────────────
+# Catches requests for negative opinions/criticism about KU, UAE, or religion
+# before any LLM or web search is invoked.
_SENSITIVE_TOPIC_RE = re.compile(
r"\b("
r"(bad|worst|negative[s]?|problem[s]?|issue[s]?|weakness|weaknesses|"
@@ -326,9 +295,11 @@ SUMMARY_RE = re.compile(
)
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"
@@ -341,6 +312,7 @@ DATABASE_RECOMMEND_RE = re.compile(
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"
@@ -349,6 +321,8 @@ DATABASE_RECOMMEND_RE = re.compile(
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",
@@ -368,6 +342,7 @@ BOOLEAN_STRATEGY_RE = re.compile(
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)"
@@ -438,6 +413,8 @@ ALT_TERMS_RE = re.compile(
re.IGNORECASE,
)
+# ─────────────────────────────────────────────────────────────────────────────
+
MEDICAL_KEYWORDS = [
"medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
"anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
@@ -471,10 +448,10 @@ INTENTS — pick exactly one:
"general" — ALL other factual questions: university info, people, history, science definitions, technology explanations
"sensitive" — block any of the following:
(a) Requests for negative opinions, criticism, or controversy about Khalifa University, UAE government/leaders/laws/institutions, or religion.
- (b) Sexually explicit, suggestive, or adult content of any kind.
+ (b) Sexually explicit, suggestive, or adult content of any kind — including questions asking for sexual information, stories, jokes, or descriptions.
(c) Political criticism, propaganda, or inflammatory commentary targeting UAE, its leadership, policies, or any Gulf state government.
(d) Content that is offensive, discriminatory, or inappropriate for a university academic environment.
- ONLY use "sensitive" when the clear intent falls into one of these categories.
+ ONLY use "sensitive" when the clear intent falls into one of these categories. Neutral academic research questions about politics, health, relationships, or social issues are NOT sensitive.
CRITICAL RULES:
1. "research" as a job title or service modifier (research librarian, research support, research help) = library_info, NOT search_academic.
@@ -528,13 +505,39 @@ For all other intents: omit "casual_answer".
Respond ONLY with valid JSON. No preamble, no markdown fences.
"""
-# ── §4 LibCal Hours Service ────────────────────────────────────────────────────
-_libcal_hours_cache: Dict[str, Any] = {}
-_LIBCAL_HOURS_TTL = 60 * 60
+# ── §4 LibCal Hours Service ────────────────────────────────────────────────────
+# Live library hours from the KU LibCal API (kustar.libcal.com).
+# Credentials read from env vars — never hardcoded in source.
+#
+# Location IDs confirmed from page source:
+# Main Campus Library → 6386
+# Habshan Library → 25028
+#
+# Token TTL: 55 min (LibCal tokens expire at 60 min)
+# Hours TTL: 60 min (hours don't change intra-day)
+# Fallback: library.ku.ac.ae/hours link if API is unreachable.
+
+# LibCal hours are fetched via the Cloudflare Worker (/hours endpoint).
+# The Worker holds the LibCal credentials and is not blocked by LibCal's
+# host allowlist. Direct calls from HF Spaces return 403.
+#
+# Worker secrets required: LIBCAL_CLIENT_ID, LIBCAL_CLIENT_SECRET
+# Worker endpoint: {CLOUDFLARE_WORKER_URL}/hours
+# Location IDs (reference): Main Campus=6386, Habshan=25028
+
+# In-process hours cache — avoids hitting the worker on every hours query
+_libcal_hours_cache: Dict[str, Any] = {} # {"data": dict, "cached_at": float}
+_LIBCAL_HOURS_TTL = 60 * 60 # 60 minutes
async def _libcal_fetch_hours() -> Optional[dict]:
+ """
+ Fetch today + next 6 days of hours via Cloudflare Worker proxy.
+ The Worker calls LibCal on our behalf (not blocked by LibCal allowlist).
+ Returns dict: {"main_campus": {...}, "habshan": {...}} or None on failure.
+ Caches result for _LIBCAL_HOURS_TTL seconds.
+ """
now = time.time()
cached_at = _libcal_hours_cache.get("cached_at", 0)
if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
@@ -572,15 +575,26 @@ async def _libcal_fetch_hours() -> Optional[dict]:
def _parse_libcal_time(raw: str) -> str:
+ """Normalise LibCal time strings: '8:00am' → '8:00 AM', '7:30am' → '7:30 AM'."""
if not raw:
return raw
raw = raw.strip().replace("\u2013", "-").replace("–", "-")
raw = re.sub(r"\s+", "", raw)
+ # Insert space before AM/PM
raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
return raw
def _format_libcal_day(day_data: dict) -> str:
+ """
+ Return a human-readable hours string for one day entry from LibCal API v1.1.
+
+ LibCal returns several possible structures:
+ Structure A: {"status": "open", "hours": [{"from": "8:00am", "to": "5:00pm"}]}
+ Structure B: {"status": "open", "opens": "8:00am", "closes": "5:00pm"}
+ Structure C: {"status": "closed", "note": "Eid Al Adha"}
+ Structure D: {"status": "24hours"}
+ """
if not day_data or not isinstance(day_data, dict):
return "See hours page"
@@ -592,6 +606,7 @@ def _format_libcal_day(day_data: dict) -> str:
if status == "24hours":
return "Open 24 hours"
+ # Structure A — hours array (LibCal API v1.1 standard)
hours_list = day_data.get("hours") or []
if hours_list and isinstance(hours_list, list):
first = hours_list[0]
@@ -600,11 +615,13 @@ def _format_libcal_day(day_data: dict) -> str:
if f and t:
return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
+ # Structure B — opens/closes at top level
opens = day_data.get("opens") or day_data.get("open") or ""
closes = day_data.get("closes") or day_data.get("close") or ""
if opens and closes:
return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
+ # Structure C — times list (some LibCal versions)
times = day_data.get("times") or []
if times and isinstance(times, list):
first = times[0]
@@ -620,6 +637,11 @@ def _format_libcal_day(day_data: dict) -> str:
async def _hours_answer() -> str:
+ """
+ Return live library hours from LibCal API.
+ Shows today's status prominently, then a 7-day table for each branch.
+ Falls back to a link to library.ku.ac.ae/hours if API is unavailable.
+ """
from datetime import timezone, timedelta
uae_now = datetime.now(timezone(timedelta(hours=4)))
today_str = uae_now.strftime("%Y-%m-%d")
@@ -628,6 +650,7 @@ async def _hours_answer() -> str:
hours_data = await _libcal_fetch_hours()
+ # ── Fallback: link to live hours page ─────────────────────────────────────
if not hours_data:
return (
"Here are the Khalifa University Library hours:
"
@@ -639,6 +662,7 @@ async def _hours_answer() -> str:
'Habshan Library · SAN Campus · +971 2 312 3160'
)
+ # ── Build per-location sections ────────────────────────────────────────────
location_labels = {
"main_campus": "Main Campus Library",
"habshan": "Habshan Library (SAN Campus)",
@@ -648,6 +672,7 @@ async def _hours_answer() -> str:
dates = loc_data.get("dates", {})
today_entry = dates.get(today_str, {})
if not today_entry:
+ # Some responses use "weeks" structure — fall back to link
return f"See live hours for today"
hours_str = _format_libcal_day(today_entry)
is_closed = hours_str.lower().startswith("closed")
@@ -719,9 +744,11 @@ async def _hours_answer() -> str:
EVENTS_RE = re.compile(
r"\b("
+ # Direct event/workshop/seminar/training queries
r"library events?|library workshops?|library seminars?|library training"
r"|library sessions?|library talks?|library webinars?|library programmes?"
r"|library classes?|library courses?|library instruction"
+ # "upcoming/any/what" + event type
r"|upcoming (events?|workshops?|seminars?|training|sessions?|talks?|webinars?)"
r"|any (events?|workshops?|seminars?|training|sessions?|talks?)"
r"|what (events?|workshops?|seminars?|training|sessions?|talks?)"
@@ -729,11 +756,14 @@ EVENTS_RE = re.compile(
r"|workshops? (at|in|by|from) (the )?library"
r"|seminars? (at|in|by|from) (the )?library"
r"|training (at|in|by|from) (the )?library"
+ # "what's on" / calendar queries
r"|what'?s? on (at |in )?(the )?library"
r"|library calendar|library programme|library schedule"
r"|library events? calendar|events? calendar"
+ # "is there a" patterns
r"|is there (a |an )?(workshop|seminar|training|session|event|talk|webinar)"
r"|are there (any )?(workshops?|seminars?|training|sessions?|events?|talks?)"
+ # database/research training specifically
r"|database training|research skills (workshop|session|training|class)"
r"|citation (workshop|training|session)|refworks (workshop|training|session)"
r"|endnote (workshop|training)|zotero (workshop|training)"
@@ -743,10 +773,14 @@ EVENTS_RE = re.compile(
)
_libcal_events_cache: Dict[str, Any] = {}
-_LIBCAL_EVENTS_TTL = 30 * 60
+_LIBCAL_EVENTS_TTL = 30 * 60 # 30 minutes — events change more often than hours
async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
+ """
+ Fetch upcoming library events via Cloudflare Worker /events proxy.
+ Caches for 30 minutes.
+ """
now = time.time()
cache_key = f"{days}_{limit}"
cached = _libcal_events_cache.get(cache_key, {})
@@ -773,6 +807,7 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
events_raw = data.get("events", [])
def _safe_str(val) -> str:
+ """Flatten LibCal fields that may be str, dict, list, or None."""
if val is None:
return ""
if isinstance(val, str):
@@ -785,6 +820,7 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
events = []
for e in events_raw:
+ # Flatten category — can be list of dicts or list of strings
cats = e.get("category") or e.get("categories") or []
if isinstance(cats, list):
cat_str = ", ".join(_safe_str(c) for c in cats)
@@ -813,10 +849,12 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
+ """Format LibCal event start/end into a readable string."""
if not start:
return ""
try:
from datetime import datetime as _dt
+ # LibCal returns ISO8601: "2026-04-20T14:00:00+04:00" or "2026-04-20"
s = _dt.fromisoformat(start.replace("Z", "+00:00"))
date_str = s.strftime("%a %d %b")
if all_day:
@@ -828,10 +866,15 @@ def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
return f"{date_str} · {time_str} – {end_str}"
return f"{date_str} · {time_str}"
except Exception:
+ # Fallback: return raw start string cleaned up
return start[:16].replace("T", " ")
async def _events_answer(days: int = 30) -> str:
+ """
+ Return upcoming library events from LibCal.
+ Falls back to library events calendar link if unavailable.
+ """
EVENTS_URL = "https://kustar.libcal.com/calendar/events"
events = await _libcal_fetch_events(days=days, limit=15)
@@ -843,6 +886,14 @@ async def _events_answer(days: int = 30) -> str:
f'style="font-weight:700;color:#003366">{EVENTS_URL}'
)
+ if not events:
+ return (
+ "📅 Khalifa University Library Events
"
+ f"No upcoming events found in the next {days} days.
"
+ f'Browse the full calendar: {EVENTS_URL}'
+ )
+
answer = f"📅 Upcoming Library Events, Workshops & Training (next {days} days)
"
for ev in events[:10]:
@@ -884,9 +935,16 @@ async def _events_answer(days: int = 30) -> str:
return answer
-# ── §5 Runtime Config & Utility Helpers ──────────────────────────────────────
+
+
+# ── Runtime config helper ────────────────────────────────────────────────────
def _get_runtime_config() -> dict:
+ """
+ Load the admin runtime config (welcome_message, custom_instructions,
+ announcement, max_results, maintenance_mode) from the JsonRuntimeStore.
+ Returns defaults silently if the file does not exist yet.
+ """
try:
from src.services.runtime_store import JsonRuntimeStore
settings = get_settings()
@@ -948,6 +1006,7 @@ def _source_badge(source: str, url: str = "") -> str:
)
+# 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
@@ -1037,6 +1096,9 @@ def _rule_based_classify(question: str) -> Dict[str, str]:
return {"intent": "general"}
+# _hours_answer() is now async and defined in §4 LibCal Hours Service above.
+
+
def _campus_answer() -> str:
return (
"Khalifa University Library has two branches:
"
@@ -1066,11 +1128,12 @@ def _greeting_menu_clarify_answer() -> str:
# ── §7 LLM Wrappers ───────────────────────────────────────────────────────────
-# NOTE v3.8.1: ALL 6 LLM system prompts now append both _GUARDRAIL and
-# _URL_INSTRUCTION to prevent hallucinated URLs in any response.
+
+# ── LLM Helpers ───────────────────────────────────────────────────────────────
def _get_llm(model: str, temperature: float, max_tokens: int):
settings = get_settings()
+ # FIX: updated model string — claude-3-haiku-20240307 is deprecated
if model == "claude" and settings.anthropic_api_key:
from langchain_anthropic import ChatAnthropic
return ChatAnthropic(
@@ -1099,6 +1162,9 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
if m.role in ("user", "assistant")
]
ctx = " | ".join(ctx_parts) if ctx_parts else "none"
+ # Always use GPT-4o-mini for classification — cheap, fast, consistent
+ # regardless of which model the user has toggled for answers.
+ # This ensures identical routing whether the user is on GPT or Claude.
classify_model = "gpt" if settings.openai_api_key else model
llm = _get_llm(classify_model, temperature=0, max_tokens=120)
response = await llm.ainvoke([
@@ -1119,7 +1185,6 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
- """LLM #1 — casual social response. Appends _GUARDRAIL + _URL_INSTRUCTION."""
if hint and len(hint.strip()) > 20:
return hint.strip()
settings = get_settings()
@@ -1130,8 +1195,7 @@ async def _libbee_casual_response(question: str, history: List[ChatMessage], mod
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. "
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
+ "No markdown, no bullet points. " + _GUARDRAIL
)}]
_cfg = _get_runtime_config()
_ci = _cfg.get("custom_instructions", "").strip()
@@ -1146,7 +1210,6 @@ async def _libbee_casual_response(question: str, history: List[ChatMessage], mod
async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
- """LLM #2 — RAG answer from KB chunks. Appends _GUARDRAIL + _URL_INSTRUCTION."""
settings = get_settings()
if not settings.openai_api_key and not settings.anthropic_api_key:
return rag_results[0]["content"] if rag_results else ""
@@ -1164,7 +1227,7 @@ async def _rag_answer(question: str, rag_results: List[dict], history: List[Chat
f'Ask a Librarian or '
f'libse@ku.ac.ae.\n\n'
f"CONTEXT:\n{context}\n\n"
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
+ + _GUARDRAIL
)
_cfg = _get_runtime_config()
_ci = _cfg.get("custom_instructions", "").strip()
@@ -1186,7 +1249,6 @@ async def _rag_answer(question: str, rag_results: List[dict], history: List[Chat
async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
- """LLM #3 — general factual answer. Appends _GUARDRAIL + _URL_INSTRUCTION."""
settings = get_settings()
if not settings.openai_api_key and not settings.anthropic_api_key:
return (
@@ -1207,7 +1269,7 @@ async def _llm_general_answer(question: str, history: List[ChatMessage], model:
"Khazna repository: https://khazna.ku.ac.ae | "
"PRIMO discovery: https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU. "
"Only use these exact URLs — never invent others. "
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
+ + _GUARDRAIL
)
_cfg = _get_runtime_config()
_ci = _cfg.get("custom_instructions", "").strip()
@@ -1226,12 +1288,11 @@ async def _llm_general_answer(question: str, history: List[ChatMessage], model:
async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
- """LLM #4 — web search answer. Appends _GUARDRAIL + _URL_INSTRUCTION."""
settings = get_settings()
_WEB_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. "
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
+ + _GUARDRAIL
)
if model == "claude" and settings.anthropic_api_key:
try:
@@ -1276,6 +1337,8 @@ async def _web_search_answer(question: str, history: List[ChatMessage], model: s
# ── §8 Topic Extraction ───────────────────────────────────────────────────────
+# ── Topic Extraction ──────────────────────────────────────────────────────────
+
def _extract_topic_regex(question: str) -> str:
q = _normalize_whitespace(question)
prefixes = [
@@ -1346,16 +1409,31 @@ async def _extract_topic(question: str, model: str) -> str:
return _extract_topic_regex(question)
-# ── §9 Query Builders ─────────────────────────────────────────────────────────
+# ── §9 Query Builders (PRIMO boolean, PubMed URL) ─────────────────────────────
+
+# ── 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|"
@@ -1364,12 +1442,14 @@ def _shared_build_primo_boolean_query(topic: str) -> str:
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)
@@ -1411,6 +1491,13 @@ Output: ("renewable energy" OR "clean energy" OR "sustainable energy") AND ("pol
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)
@@ -1421,10 +1508,14 @@ async def _llm_build_boolean_query(topic: str, model: str) -> str:
{"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
@@ -1433,7 +1524,8 @@ async def _llm_build_boolean_query(topic: str, model: str) -> str:
return _shared_build_primo_boolean_query(topic)
-def _make_primo_boolean_query(context: "SearchContextPayload") -> str:
+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)
@@ -1447,6 +1539,7 @@ def _shared_build_primo_discovery_url(
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"
@@ -1477,6 +1570,7 @@ def _shared_build_pubmed_url(
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:
@@ -1491,6 +1585,8 @@ def _shared_build_pubmed_url(
# ── §10 Search Preparation & Planning ────────────────────────────────────────
+# ── 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"]):
@@ -1517,7 +1613,13 @@ def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
return None, None
-def _primo_clean_url(context: "SearchContextPayload") -> str:
+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,
@@ -1529,7 +1631,7 @@ def _primo_clean_url(context: "SearchContextPayload") -> str:
)
-def _compose_ai_tool_query(question: str, context: "SearchContextPayload", is_follow_up: bool) -> str:
+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
@@ -1560,11 +1662,29 @@ def _compose_ai_tool_query(question: str, context: "SearchContextPayload", is_fo
return base.rstrip(".") + "."
+async def _grammar_refine_query(text: str, model: str) -> str:
+ settings = get_settings()
+ if not settings.openai_api_key and not settings.anthropic_api_key:
+ return _normalize_whitespace(text)
+ try:
+ llm = _get_llm(model, temperature=0, max_tokens=60)
+ response = await llm.ainvoke([
+ {"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
+ {"role": "user", "content": text},
+ ])
+ refined = _normalize_whitespace(response.content)
+ return refined or _normalize_whitespace(text)
+ except Exception:
+ 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)
+ # Pass 1: strip search intent verbs
cleaned = re.sub(
r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
r"research on|articles on|papers on|literature on|studies on|"
@@ -1573,11 +1693,14 @@ def _light_strip_retrieval_boilerplate(text: str) -> str:
r"i want articles on|i need papers on|i want papers on)\s+",
"", cleaned, flags=re.IGNORECASE
)
+ # Pass 2: strip residual noun boilerplate left after pass 1
+ # e.g. "find research on 33" → pass1 → "research on 33" → pass2 → "33"
cleaned = re.sub(
r"^\s*(research|articles?|papers?|books?|literature|studies|study|"
r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
"", cleaned, flags=re.IGNORECASE
)
+ # Pass 3: strip dangling prepositions
cleaned = re.sub(
r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
"", cleaned, flags=re.IGNORECASE
@@ -1586,6 +1709,7 @@ def _light_strip_retrieval_boilerplate(text: str) -> str:
_LEGACY_SYSTEM_PATTERNS = [
+ # Summon — KU's old discovery system, replaced by PRIMO
(
re.compile(
r'(you can use )?summon[™™]?\s*(to find [^.]+\.)?\s*'
@@ -1595,13 +1719,21 @@ _LEGACY_SYSTEM_PATTERNS = [
),
"PRIMO Library Discovery (https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU)",
),
+ # Any bare Summon reference
(re.compile(r'\bSummon[™™]?\b', re.IGNORECASE), "PRIMO Library Discovery"),
+ # EDS / EBSCO Discovery Service (not the same as EBSCO databases)
(re.compile(r'\bEBSCO Discovery Service\b', re.IGNORECASE), "PRIMO Library Discovery"),
+ # Encore (old III discovery)
(re.compile(r'\bEncore\b(?!\s+(?:learning|education))', re.IGNORECASE), "PRIMO Library Discovery"),
]
def _sanitize_llm_response(text: str) -> str:
+ """
+ Strip legacy discovery system references from LLM output.
+ KU migrated from Summon → PRIMO. Old KB chunks may still mention Summon
+ and the LLM may echo them. This runs as a final pass on all agent answers.
+ """
if not text:
return text
for pattern, replacement in _LEGACY_SYSTEM_PATTERNS:
@@ -1610,26 +1742,32 @@ def _sanitize_llm_response(text: str) -> str:
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:
+def _context_to_dict(context: SearchContextPayload) -> dict:
return context.model_dump()
@@ -1646,7 +1784,7 @@ The user typed a research request. Create THREE forms preserving the full intent
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}}),
+Also detect: year_from, year_to (e.g. "last 24 months" → year_from={{CURRENT_YEAR-2}}),
peer_reviewed, open_access.
STEP 1 — TOPIC EXTRACTION:
@@ -1681,6 +1819,7 @@ BOOLEAN RULES — follow strictly:
- Keep broad enough to return results
- NAMED ENTITIES: Any specific keyword that is a country, island, city, person,
organisation, or proper noun MUST be kept as its own AND group. Never drop it.
+ e.g. "Maldives" → AND ("Maldives" OR "Maldivian") ; "UAE" → AND ("UAE" OR "United Arab Emirates")
EXAMPLES:
Input: "find articles on global warming and renewable energy"
@@ -1692,11 +1831,30 @@ Input: "find research on NLP"
Input: "get me papers on AI in healthcare"
→ boolean:("AI" OR "artificial intelligence") AND ("healthcare" OR "medicine" OR "clinical")
+Input: "search ML algorithms"
+→ boolean:("ML" OR "machine learning") AND ("algorithms" OR "models" OR "techniques")
+
+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:"2021", peer_reviewed:true
+
+Input: "impact of global warming in Maldives"
+→ boolean:("global warming" OR "climate change") AND ("Maldives" OR "sea level rise" OR "small island")
+
+Input: "water scarcity solutions in UAE"
+→ boolean:("water scarcity" OR "water stress") AND ("UAE" OR "United Arab Emirates" OR "Gulf region")
+
Return ONLY valid JSON:
-{{"corrected":"...","natural":"...","boolean":"(\\"A\\" OR \\"B\\") AND (\\"C\\" OR \\"D\\")","year_from":"","year_to":"","peer_reviewed":false,"open_access":false}}"""
+{{"corrected":"...","natural":"...","boolean":"(\"A\" OR \"B\") AND (\"C\" OR \"D\")","year_from":"","year_to":"","peer_reviewed":false,"open_access":false}}"""
async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
+ """
+ Stage 2 topic validator — runs after the regex ambiguity gate.
+ Catches gibberish/unrecognisable words that passed the gate.
+ Returns (True, corrected_topic) or (False, "").
+ Fails open on any exception so it never blocks a legitimate search.
+ """
settings = get_settings()
if not settings.openai_api_key and not settings.anthropic_api_key:
return True, topic
@@ -1734,10 +1892,18 @@ async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
async def _build_search_plan(question: str, model: str) -> dict:
+ """
+ LLM-driven search plan.
+ Includes:
+ - Ambiguity gate (numbers, single chars, no content words)
+ - Stage 2 LLM topic validator (gibberish detection + typo correction)
+ Falls back to regex boolean if LLM fails.
+ """
settings = get_settings()
raw_query = _normalize_whitespace(question or "")
light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
+ # ── Stage 1: Regex ambiguity gate ────────────────────────────────────────
_stripped = light_query.strip()
_is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
_is_too_short = len(_stripped) < 2
@@ -1760,7 +1926,7 @@ async def _build_search_plan(question: str, model: str) -> dict:
f"the better I can build your search."
),
}
-
+ # ── Stage 2: LLM topic validator ─────────────────────────────────────────
if settings.openai_api_key or settings.anthropic_api_key:
_valid, _corrected_topic = await _validate_topic(_stripped, model)
if not _valid:
@@ -1779,6 +1945,7 @@ async def _build_search_plan(question: str, model: str) -> dict:
if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
logger.info(f"_validate_topic corrected: {_stripped!r} → {_corrected_topic!r}")
light_query = _corrected_topic
+ # ─────────────────────────────────────────────────────────────────────────
if settings.openai_api_key or settings.anthropic_api_key:
try:
@@ -1790,6 +1957,7 @@ async def _build_search_plan(question: str, model: str) -> dict:
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])
@@ -1800,6 +1968,7 @@ async def _build_search_plan(question: str, model: str) -> dict:
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:
@@ -1810,6 +1979,7 @@ async def _build_search_plan(question: str, model: str) -> dict:
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 ""
@@ -1828,6 +1998,7 @@ async def _build_search_plan(question: str, model: str) -> dict:
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))
@@ -1844,12 +2015,14 @@ async def _build_search_plan(question: str, model: str) -> dict:
}
-async def _prepare_queries(question: str, context: "SearchContextPayload", model: str, is_follow_up: bool) -> "SearchContextPayload":
+async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
+ """Build all query forms using _build_search_plan."""
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)
+ # Clarification gate
if plan.get("clarification_needed"):
context.clarification_needed = True
context.clarification_message = plan.get("clarification_message", "")
@@ -1872,8 +2045,12 @@ async def _prepare_queries(question: str, context: "SearchContextPayload", model
# ── §11 Search Runners ────────────────────────────────────────────────────────
+
async def _topic_intro(topic: str, model: str) -> str:
- """LLM #5 — 3-sentence topic intro. Appends _GUARDRAIL + _URL_INSTRUCTION."""
+ """
+ 3-sentence topic intro — lightweight LLM call (~180 tokens, no external APIs).
+ Prepended to all academic/medical search responses (v3.8).
+ """
settings = get_settings()
if not settings.openai_api_key and not settings.anthropic_api_key:
return ""
@@ -1886,8 +2063,7 @@ async def _topic_intro(topic: str, model: str) -> str:
"for a university student or researcher. "
"Cover: what the topic is, why it matters, and one key area of current research interest. "
"Use HTML
for line breaks only if needed. No markdown, no bullet points, no headings. "
- "Be concise and informative. "
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
+ "Be concise and informative."
)},
{"role": "user", "content": f"Research topic: {topic}"},
])
@@ -1898,6 +2074,8 @@ async def _topic_intro(topic: str, model: str) -> str:
return ""
+# ── 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:
@@ -1951,8 +2129,13 @@ async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
+ """
+ Search OpenAlex using ?search= — same relevance engine as the browser link.
+ Passes the full corrected natural language query, strips only boolean operators.
+ """
try:
from urllib.parse import quote as _oa_quote
+ # Strip boolean operators only — keep all keywords including proper nouns
clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
clean = re.sub(r'\s+', ' ', clean).strip()[:200]
encoded = _oa_quote(clean, safe='')
@@ -1976,6 +2159,8 @@ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
results = []
for work in works:
title = work.get("title") or "Untitled"
+
+ # Reconstruct abstract from inverted index
inv = work.get("abstract_inverted_index") or {}
abstract = ""
if inv:
@@ -2034,7 +2219,6 @@ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
- """LLM #6 — cited literature summary. Appends _GUARDRAIL + _URL_INSTRUCTION."""
settings = get_settings()
if not settings.openai_api_key and not settings.anthropic_api_key:
return ""
@@ -2053,8 +2237,7 @@ async def _generate_cited_summary(topic: str, papers: List[dict], model: str) ->
"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. "
- + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
+ "Do NOT invent content."
)
try:
llm = _get_llm(model, temperature=0.2, max_tokens=560)
@@ -2102,9 +2285,12 @@ def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
# ── §12 Response Builders ─────────────────────────────────────────────────────
-async def _search_primo_catalog(context: "SearchContextPayload", limit: int = 5) -> Tuple[dict, str]:
+# ── 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
settings = get_settings()
+ # Use the LLM-built boolean query if available, else fall back to plain topic
boolean = context.primo_boolean_query
if not boolean:
clean_topic = _strip_resource_noise(context.topic)
@@ -2127,7 +2313,7 @@ async def _search_primo_catalog(context: "SearchContextPayload", limit: int = 5)
return result, source_url
-async def _search_pubmed_articles(context: "SearchContextPayload", limit: int = 5) -> Tuple[dict, str]:
+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:
@@ -2148,7 +2334,9 @@ async def _search_pubmed_articles(context: "SearchContextPayload", limit: int =
return result, pubmed_url
-def _tool_urls(context: "SearchContextPayload") -> Dict[str, str]:
+# ── 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] = {
@@ -2174,6 +2362,29 @@ def _tool_urls(context: "SearchContextPayload") -> Dict[str, str]:
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:
@@ -2183,13 +2394,15 @@ def _find_staff_by_token(token: str) -> Optional[dict]:
return None
-def _contact_footer_for_query(question: str, context: Optional["SearchContextPayload"]) -> str:
+def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
q = (question or "").lower()
specialist: Optional[dict] = None
+ # ILL — always Suaad Al Jneibi, not Jason
if any(t in q for t in ["interlibrary", "ill request", "ill form", "borrow from another",
"request article", "request a book", "not available at ku",
"not in ku library", "inter library", "inter-library"]):
+ # Use hardcoded ILL contact since Suaad may not be in staff directory
footer = (
"
Library contact points
"
f'General help: Ask a Librarian · '
@@ -2228,6 +2441,9 @@ def _contact_footer_for_query(question: str, context: Optional["SearchContextPay
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."),
@@ -2259,7 +2475,11 @@ def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> st
)
-def _search_trace_block(question: str, context: "SearchContextPayload") -> str:
+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"
@@ -2314,7 +2534,7 @@ def _search_trace_block(question: str, context: "SearchContextPayload") -> str:
return block
-def _ai_tools_footer(context: "SearchContextPayload") -> str:
+def _ai_tools_footer(context):
from urllib.parse import quote as _q
ai_q = _q(context.ai_tool_query or context.display_topic or context.topic)
@@ -2342,6 +2562,7 @@ def _ai_tools_footer(context: "SearchContextPayload") -> str:
if is_med and pubmed_url:
platforms.append(("PubMed", pubmed_url, "pubmed"))
+ # Inline SVG mascots — unique per database type, no network requests
_SVGS = {
"bee": (
''
),
+ "book": (
+ ''
+ ),
+ "microscope": (
+ ''
+ ),
+ "circuit": (
+ ''
+ ),
+ "leaf": (
+ ''
+ ),
+ "gear": (
+ ''
+ ),
+ "chart": (
+ ''
+ ),
+ "atom": (
+ ''
+ ),
+ "medical": (
+ ''
+ ),
+ "wrench": (
+ ''
+ ),
+ # Platform mascots — publisher-style
"primo": (
''
),
}
+ # Per-database mascot map
+ _DB_MASCOT = {
+ "PubMed@KU": "microscope", "Embase": "medical", "CINAHL Ultimate": "medical",
+ "Cochrane Library": "microscope", "UpToDate": "medical",
+ "IEEE Xplore": "circuit", "ACM Digital Library": "circuit",
+ "ScienceDirect": "microscope", "Scopus": "microscope",
+ "Web of Science": "microscope", "SpringerLink": "book",
+ "ACS Publications": "atom", "APS Journals": "atom", "Knovel": "wrench",
+ "Business Source Complete": "chart", "Emerald Insight": "chart",
+ "Taylor & Francis": "book", "EBSCO": "book",
+ }
+ _BEE = _SVGS["bee"] # default for platform buttons
+
def _btn(label, url, mascot_key="bee"):
icon = _SVGS.get(mascot_key, _SVGS["bee"])
@@ -2480,9 +2797,11 @@ def _ai_tools_footer(context: "SearchContextPayload") -> str:
all_lower = ((context.ai_tool_query or "") + " " + (context.topic or "")).lower()
def _db_row(name, url, desc, favicon=None):
+ icon = _SVGS.get(_DB_MASCOT.get(name, "bee"), _SVGS["bee"])
return (
'
\"machine learning\" keeps them together as an exact phrase.