nikeshn commited on
Commit
ee3a0ed
Β·
verified Β·
1 Parent(s): 6f96002

Update src/api/agent.py

Browse files
Files changed (1) hide show
  1. src/api/agent.py +104 -511
src/api/agent.py CHANGED
@@ -1,33 +1,10 @@
1
  """
2
- agent.py β€” LibBee v3.6
3
-
4
- ╔══════════════════════════════════════════════════════════════════╗
5
- β•‘ TABLE OF CONTENTS β•‘
6
- ╠══════════════════════════════════════════════════════════════════╣
7
- β•‘ Β§1 Imports β•‘
8
- β•‘ Β§2 Pydantic Models β•‘
9
- β•‘ Β§3 Constants & Compiled Regexes β•‘
10
- β•‘ Β§4 LibCal Hours & Events Service ← v3.5/v3.6 β•‘
11
- β•‘ Β§5 Runtime Config & Utility Helpers β•‘
12
- β•‘ Β§6 Intent Classification (rule-based + LLM) β•‘
13
- β•‘ Β§7 LLM Wrappers β•‘
14
- β•‘ Β§8 Topic Extraction β•‘
15
- β•‘ Β§9 Query Builders (PRIMO boolean, PubMed URL) β•‘
16
- β•‘ Β§10 Search Preparation & Planning β•‘
17
- β•‘ Β§11 Search Runners (OpenAlex, Semantic Scholar, PRIMO, PubMed)β•‘
18
- β•‘ Β§12 Response Builders (badges, thought blocks, SVG mascots) β•‘
19
- β•‘ Β§13 Hardcoded Answers (campus, fulltext, DB recs, theses...) β•‘
20
- β•‘ Β§14 Follow-up System β•‘
21
- β•‘ Β§15 Main Routing (_agent_query_inner) β•‘
22
- β•‘ Β§16 FastAPI Endpoint & Utility Routes β•‘
23
- β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
24
-
25
- Changes over v3.5:
26
- - Β§4 LibCal Events: EVENTS_RE + _libcal_fetch_events() +
27
- _events_answer() β€” live upcoming events from KU LibCal calendar
28
- via Cloudflare Worker /events proxy. 30-min cache.
29
- - Routing: EVENTS_RE check added in _agent_query_inner.
30
- - worker.js: /events endpoint added (v3.6).
31
  """
32
  # ── Β§1 Imports ────────────────────────────────────────────────────────────────
33
 
@@ -241,9 +218,40 @@ _GUARDRAIL = (
241
  "Never produce content that could embarrass Khalifa University or its library."
242
  )
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  # ── Sensitive institutional topic gate ───────────────────────────────────────
245
- # Catches requests for negative opinions/criticism about KU, UAE, or religion
246
- # before any LLM or web search is invoked.
247
  _SENSITIVE_TOPIC_RE = re.compile(
248
  r"\b("
249
  r"(bad|worst|negative[s]?|problem[s]?|issue[s]?|weakness|weaknesses|"
@@ -295,11 +303,9 @@ SUMMARY_RE = re.compile(
295
  )
296
  DATABASE_RECOMMEND_RE = re.compile(
297
  r"("
298
- # explicit recommendation phrasing
299
  r"\b(best database|best databases|which database|which databases"
300
  r"|database for|databases for"
301
  r"|where should i search|where should i start)\b"
302
- # subscription / access phrasing
303
  r"|databases?\s+(does|did|do)\s+.{0,40}subscri"
304
  r"|\bsubscribed\s+databases?\b"
305
  r"|\blibrary\s+subscri"
@@ -312,7 +318,6 @@ DATABASE_RECOMMEND_RE = re.compile(
312
  r"|\be.?resources?\s+(available|subscri)\b"
313
  r"|\baccess\s+to\s+databases?\b"
314
  r"|\blist\s+of\s+databases?\b"
315
- # comparison phrasing ← NEW
316
  r"|\b(compare|comparison|difference|differences)\s+(between\s+)?(\w+\s+)?(and\s+)?\w*\s*database"
317
  r"|\bdifference\s+between.{0,60}database"
318
  r"|\bscopus\s+(vs|versus|or)\s+(web of science|wos|google scholar)\b"
@@ -321,8 +326,6 @@ DATABASE_RECOMMEND_RE = re.compile(
321
  re.IGNORECASE,
322
  )
323
 
324
- # ── New pattern detection regexes ────────────────────────────────────────────
325
-
326
  HIGHLY_CITED_RE = re.compile(
327
  r"\b(highly cited|most cited|top cited|citation count|most influential"
328
  r"|most referenced|high impact papers?|most downloaded)\b",
@@ -342,7 +345,6 @@ BOOLEAN_STRATEGY_RE = re.compile(
342
 
343
  FULLTEXT_REQUEST_RE = re.compile(
344
  r"\b("
345
- # PDF / access problem β€” only fires when there's an explicit access failure
346
  r"(can.t|cannot|couldn.t|don.t have|doesn.t have|no).{0,30}(access|pdf|full.?text)"
347
  r"|(pdf|full.?text).{0,20}(not available|unavailable|can.t find|not found|missing)"
348
  r"|found.{0,30}citation.{0,20}(but|not).{0,20}(pdf|full.?text)"
@@ -413,8 +415,6 @@ ALT_TERMS_RE = re.compile(
413
  re.IGNORECASE,
414
  )
415
 
416
- # ─────────────────────────────────────────────────────────────────────────────
417
-
418
  MEDICAL_KEYWORDS = [
419
  "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
420
  "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
@@ -507,53 +507,25 @@ Respond ONLY with valid JSON. No preamble, no markdown fences.
507
 
508
 
509
  # ── Β§4 LibCal Hours Service ────────────────────────────────────────────────────
510
- # Live library hours from the KU LibCal API (kustar.libcal.com).
511
- # Credentials read from env vars β€” never hardcoded in source.
512
- #
513
- # Location IDs confirmed from page source:
514
- # Main Campus Library β†’ 6386
515
- # Habshan Library β†’ 25028
516
- #
517
- # Token TTL: 55 min (LibCal tokens expire at 60 min)
518
- # Hours TTL: 60 min (hours don't change intra-day)
519
- # Fallback: library.ku.ac.ae/hours link if API is unreachable.
520
-
521
- # LibCal hours are fetched via the Cloudflare Worker (/hours endpoint).
522
- # The Worker holds the LibCal credentials and is not blocked by LibCal's
523
- # host allowlist. Direct calls from HF Spaces return 403.
524
- #
525
- # Worker secrets required: LIBCAL_CLIENT_ID, LIBCAL_CLIENT_SECRET
526
- # Worker endpoint: {CLOUDFLARE_WORKER_URL}/hours
527
- # Location IDs (reference): Main Campus=6386, Habshan=25028
528
-
529
- # In-process hours cache β€” avoids hitting the worker on every hours query
530
- _libcal_hours_cache: Dict[str, Any] = {} # {"data": dict, "cached_at": float}
531
- _LIBCAL_HOURS_TTL = 60 * 60 # 60 minutes
532
 
533
 
534
  async def _libcal_fetch_hours() -> Optional[dict]:
535
- """
536
- Fetch today + next 6 days of hours via Cloudflare Worker proxy.
537
- The Worker calls LibCal on our behalf (not blocked by LibCal allowlist).
538
- Returns dict: {"main_campus": {...}, "habshan": {...}} or None on failure.
539
- Caches result for _LIBCAL_HOURS_TTL seconds.
540
- """
541
  now = time.time()
542
  cached_at = _libcal_hours_cache.get("cached_at", 0)
543
  if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
544
  return _libcal_hours_cache["data"]
545
-
546
  settings = get_settings()
547
  worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
548
  if not worker_url:
549
  logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set β€” cannot fetch hours")
550
  return None
551
-
552
  from datetime import timezone, timedelta
553
  uae_now = datetime.now(timezone(timedelta(hours=4)))
554
  date_from = uae_now.strftime("%Y-%m-%d")
555
  date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
556
-
557
  try:
558
  async with httpx.AsyncClient(timeout=10) as client:
559
  r = await client.get(
@@ -575,38 +547,23 @@ async def _libcal_fetch_hours() -> Optional[dict]:
575
 
576
 
577
  def _parse_libcal_time(raw: str) -> str:
578
- """Normalise LibCal time strings: '8:00am' β†’ '8:00 AM', '7:30am' β†’ '7:30 AM'."""
579
  if not raw:
580
  return raw
581
  raw = raw.strip().replace("\u2013", "-").replace("–", "-")
582
  raw = re.sub(r"\s+", "", raw)
583
- # Insert space before AM/PM
584
  raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
585
  return raw
586
 
587
 
588
  def _format_libcal_day(day_data: dict) -> str:
589
- """
590
- Return a human-readable hours string for one day entry from LibCal API v1.1.
591
-
592
- LibCal returns several possible structures:
593
- Structure A: {"status": "open", "hours": [{"from": "8:00am", "to": "5:00pm"}]}
594
- Structure B: {"status": "open", "opens": "8:00am", "closes": "5:00pm"}
595
- Structure C: {"status": "closed", "note": "Eid Al Adha"}
596
- Structure D: {"status": "24hours"}
597
- """
598
  if not day_data or not isinstance(day_data, dict):
599
  return "See hours page"
600
-
601
  status = str(day_data.get("status") or "").lower().strip()
602
  note = str(day_data.get("note") or "").strip()
603
-
604
  if status in ("closed", "0", "false"):
605
  return f"Closed{f' β€” {note}' if note else ''}"
606
  if status == "24hours":
607
  return "Open 24 hours"
608
-
609
- # Structure A β€” hours array (LibCal API v1.1 standard)
610
  hours_list = day_data.get("hours") or []
611
  if hours_list and isinstance(hours_list, list):
612
  first = hours_list[0]
@@ -614,14 +571,10 @@ def _format_libcal_day(day_data: dict) -> str:
614
  t = first.get("to") or first.get("closes") or ""
615
  if f and t:
616
  return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
617
-
618
- # Structure B β€” opens/closes at top level
619
  opens = day_data.get("opens") or day_data.get("open") or ""
620
  closes = day_data.get("closes") or day_data.get("close") or ""
621
  if opens and closes:
622
  return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
623
-
624
- # Structure C β€” times list (some LibCal versions)
625
  times = day_data.get("times") or []
626
  if times and isinstance(times, list):
627
  first = times[0]
@@ -629,28 +582,18 @@ def _format_libcal_day(day_data: dict) -> str:
629
  t = first.get("to") or first.get("closes") or ""
630
  if f and t:
631
  return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
632
-
633
  if status in ("open",):
634
  return "Open β€” see hours page for times"
635
-
636
  return "See hours page"
637
 
638
 
639
  async def _hours_answer() -> str:
640
- """
641
- Return live library hours from LibCal API.
642
- Shows today's status prominently, then a 7-day table for each branch.
643
- Falls back to a link to library.ku.ac.ae/hours if API is unavailable.
644
- """
645
  from datetime import timezone, timedelta
646
  uae_now = datetime.now(timezone(timedelta(hours=4)))
647
  today_str = uae_now.strftime("%Y-%m-%d")
648
  day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
649
  today_name = day_names[uae_now.weekday()]
650
-
651
  hours_data = await _libcal_fetch_hours()
652
-
653
- # ── Fallback: link to live hours page ─────────────────────────────────────
654
  if not hours_data:
655
  return (
656
  "Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
@@ -661,18 +604,14 @@ async def _hours_answer() -> str:
661
  'Main Campus Library Β· E Building Β· <a href="tel:+97123124604">+971 2 312 4604</a><br>'
662
  'Habshan Library Β· SAN Campus Β· <a href="tel:+97123123160">+971 2 312 3160</a>'
663
  )
664
-
665
- # ── Build per-location sections ────────────────────────────────────────────
666
  location_labels = {
667
  "main_campus": "Main Campus Library",
668
  "habshan": "Habshan Library (SAN Campus)",
669
  }
670
-
671
  def _today_line(loc_name: str, loc_data: dict) -> str:
672
  dates = loc_data.get("dates", {})
673
  today_entry = dates.get(today_str, {})
674
  if not today_entry:
675
- # Some responses use "weeks" structure β€” fall back to link
676
  return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
677
  hours_str = _format_libcal_day(today_entry)
678
  is_closed = hours_str.lower().startswith("closed")
@@ -681,7 +620,6 @@ async def _hours_answer() -> str:
681
  f"πŸ“… <strong>Today ({today_name}):</strong> "
682
  f"<span style='color:{colour};font-weight:700'>{hours_str}</span>"
683
  )
684
-
685
  def _week_table(loc_data: dict) -> str:
686
  dates = loc_data.get("dates", {})
687
  if not dates:
@@ -713,9 +651,7 @@ async def _hours_answer() -> str:
713
  "<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
714
  + rows + "</table>"
715
  )
716
-
717
  answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
718
-
719
  for key, label in location_labels.items():
720
  loc = hours_data.get(key)
721
  if not loc:
@@ -727,7 +663,6 @@ async def _hours_answer() -> str:
727
  answer += week_tbl + "<br><br>"
728
  else:
729
  answer += "<br>"
730
-
731
  answer += (
732
  "⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
733
  f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
@@ -744,11 +679,9 @@ async def _hours_answer() -> str:
744
 
745
  EVENTS_RE = re.compile(
746
  r"\b("
747
- # Direct event/workshop/seminar/training queries
748
  r"library events?|library workshops?|library seminars?|library training"
749
  r"|library sessions?|library talks?|library webinars?|library programmes?"
750
  r"|library classes?|library courses?|library instruction"
751
- # "upcoming/any/what" + event type
752
  r"|upcoming (events?|workshops?|seminars?|training|sessions?|talks?|webinars?)"
753
  r"|any (events?|workshops?|seminars?|training|sessions?|talks?)"
754
  r"|what (events?|workshops?|seminars?|training|sessions?|talks?)"
@@ -756,14 +689,11 @@ EVENTS_RE = re.compile(
756
  r"|workshops? (at|in|by|from) (the )?library"
757
  r"|seminars? (at|in|by|from) (the )?library"
758
  r"|training (at|in|by|from) (the )?library"
759
- # "what's on" / calendar queries
760
  r"|what'?s? on (at |in )?(the )?library"
761
  r"|library calendar|library programme|library schedule"
762
  r"|library events? calendar|events? calendar"
763
- # "is there a" patterns
764
  r"|is there (a |an )?(workshop|seminar|training|session|event|talk|webinar)"
765
  r"|are there (any )?(workshops?|seminars?|training|sessions?|events?|talks?)"
766
- # database/research training specifically
767
  r"|database training|research skills (workshop|session|training|class)"
768
  r"|citation (workshop|training|session)|refworks (workshop|training|session)"
769
  r"|endnote (workshop|training)|zotero (workshop|training)"
@@ -773,26 +703,20 @@ EVENTS_RE = re.compile(
773
  )
774
 
775
  _libcal_events_cache: Dict[str, Any] = {}
776
- _LIBCAL_EVENTS_TTL = 30 * 60 # 30 minutes β€” events change more often than hours
777
 
778
 
779
  async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
780
- """
781
- Fetch upcoming library events via Cloudflare Worker /events proxy.
782
- Caches for 30 minutes.
783
- """
784
  now = time.time()
785
  cache_key = f"{days}_{limit}"
786
  cached = _libcal_events_cache.get(cache_key, {})
787
  if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
788
  return cached["data"]
789
-
790
  settings = get_settings()
791
  worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
792
  if not worker_url:
793
  logger.warning("LibCal events: CLOUDFLARE_WORKER_URL not set")
794
  return None
795
-
796
  try:
797
  async with httpx.AsyncClient(timeout=10) as client:
798
  r = await client.get(
@@ -805,9 +729,7 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
805
  logger.warning("LibCal events worker error: %s", data["error"])
806
  return None
807
  events_raw = data.get("events", [])
808
-
809
  def _safe_str(val) -> str:
810
- """Flatten LibCal fields that may be str, dict, list, or None."""
811
  if val is None:
812
  return ""
813
  if isinstance(val, str):
@@ -817,16 +739,13 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
817
  if isinstance(val, list):
818
  return ", ".join(_safe_str(v) for v in val if v)
819
  return str(val)
820
-
821
  events = []
822
  for e in events_raw:
823
- # Flatten category β€” can be list of dicts or list of strings
824
  cats = e.get("category") or e.get("categories") or []
825
  if isinstance(cats, list):
826
  cat_str = ", ".join(_safe_str(c) for c in cats)
827
  else:
828
  cat_str = _safe_str(cats)
829
-
830
  events.append({
831
  "id": e.get("id", ""),
832
  "title": _safe_str(e.get("title") or "Untitled Event"),
@@ -849,12 +768,10 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
849
 
850
 
851
  def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
852
- """Format LibCal event start/end into a readable string."""
853
  if not start:
854
  return ""
855
  try:
856
  from datetime import datetime as _dt
857
- # LibCal returns ISO8601: "2026-04-20T14:00:00+04:00" or "2026-04-20"
858
  s = _dt.fromisoformat(start.replace("Z", "+00:00"))
859
  date_str = s.strftime("%a %d %b")
860
  if all_day:
@@ -866,18 +783,12 @@ def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
866
  return f"{date_str} Β· {time_str} – {end_str}"
867
  return f"{date_str} Β· {time_str}"
868
  except Exception:
869
- # Fallback: return raw start string cleaned up
870
  return start[:16].replace("T", " ")
871
 
872
 
873
  async def _events_answer(days: int = 30) -> str:
874
- """
875
- Return upcoming library events from LibCal.
876
- Falls back to library events calendar link if unavailable.
877
- """
878
  EVENTS_URL = "https://kustar.libcal.com/calendar/events"
879
  events = await _libcal_fetch_events(days=days, limit=15)
880
-
881
  if not events:
882
  return (
883
  "<strong>πŸ“… Khalifa University Library Events</strong><br><br>"
@@ -885,7 +796,6 @@ async def _events_answer(days: int = 30) -> str:
885
  f'<a href="{EVENTS_URL}" target="_blank" '
886
  f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
887
  )
888
-
889
  if not events:
890
  return (
891
  "<strong>πŸ“… Khalifa University Library Events</strong><br><br>"
@@ -893,9 +803,7 @@ async def _events_answer(days: int = 30) -> str:
893
  f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
894
  f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
895
  )
896
-
897
  answer = f"<strong>πŸ“… Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
898
-
899
  for ev in events[:10]:
900
  title = _escape(str(ev.get("title") or "Untitled Event"))
901
  when = _format_event_datetime(
@@ -905,7 +813,6 @@ async def _events_answer(days: int = 30) -> str:
905
  desc = _escape(str(ev.get("description") or ""))
906
  url = str(ev.get("url") or EVENTS_URL)
907
  reg = bool(ev.get("registration"))
908
-
909
  answer += (
910
  f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
911
  f'background:#f8fafc;border-radius:0 8px 8px 0">'
@@ -927,7 +834,6 @@ async def _events_answer(days: int = 30) -> str:
927
  f'Register β†’</a></div>'
928
  )
929
  answer += '</div>'
930
-
931
  answer += (
932
  f'<br><a href="{EVENTS_URL}" target="_blank" '
933
  f'style="font-weight:700;color:#003366">View full library events calendar β†’</a>'
@@ -936,15 +842,9 @@ async def _events_answer(days: int = 30) -> str:
936
 
937
 
938
 
939
-
940
- # ── Runtime config helper ────────────────────────────────────────────────────
941
 
942
  def _get_runtime_config() -> dict:
943
- """
944
- Load the admin runtime config (welcome_message, custom_instructions,
945
- announcement, max_results, maintenance_mode) from the JsonRuntimeStore.
946
- Returns defaults silently if the file does not exist yet.
947
- """
948
  try:
949
  from src.services.runtime_store import JsonRuntimeStore
950
  settings = get_settings()
@@ -1006,7 +906,6 @@ def _source_badge(source: str, url: str = "") -> str:
1006
  )
1007
 
1008
 
1009
- # FIX: use get_metrics_service() accessor to avoid circular import fragility
1010
  def _safe_metrics_increment(key: str) -> None:
1011
  try:
1012
  from app import get_metrics_service
@@ -1096,9 +995,6 @@ def _rule_based_classify(question: str) -> Dict[str, str]:
1096
  return {"intent": "general"}
1097
 
1098
 
1099
- # _hours_answer() is now async and defined in Β§4 LibCal Hours Service above.
1100
-
1101
-
1102
  def _campus_answer() -> str:
1103
  return (
1104
  "Khalifa University Library has <strong>two branches</strong>:<br><br>"
@@ -1127,13 +1023,13 @@ def _greeting_menu_clarify_answer() -> str:
1127
  )
1128
 
1129
 
1130
- # ── Β§7 LLM Wrappers ───────────────────────────────────────────────────────────
1131
 
1132
- # ── LLM Helpers ───────────────────────────────────────────────────────────────
 
 
1133
 
1134
  def _get_llm(model: str, temperature: float, max_tokens: int):
1135
  settings = get_settings()
1136
- # FIX: updated model string β€” claude-3-haiku-20240307 is deprecated
1137
  if model == "claude" and settings.anthropic_api_key:
1138
  from langchain_anthropic import ChatAnthropic
1139
  return ChatAnthropic(
@@ -1162,9 +1058,6 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
1162
  if m.role in ("user", "assistant")
1163
  ]
1164
  ctx = " | ".join(ctx_parts) if ctx_parts else "none"
1165
- # Always use GPT-4o-mini for classification β€” cheap, fast, consistent
1166
- # regardless of which model the user has toggled for answers.
1167
- # This ensures identical routing whether the user is on GPT or Claude.
1168
  classify_model = "gpt" if settings.openai_api_key else model
1169
  llm = _get_llm(classify_model, temperature=0, max_tokens=120)
1170
  response = await llm.ainvoke([
@@ -1185,6 +1078,7 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
1185
 
1186
 
1187
  async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
 
1188
  if hint and len(hint.strip()) > 20:
1189
  return hint.strip()
1190
  settings = get_settings()
@@ -1195,7 +1089,8 @@ async def _libbee_casual_response(question: str, history: List[ChatMessage], mod
1195
  msgs = [{"role": "system", "content": (
1196
  "You are LibBee, the Khalifa University Library AI Assistant. "
1197
  "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
1198
- "No markdown, no bullet points. " + _GUARDRAIL
 
1199
  )}]
1200
  _cfg = _get_runtime_config()
1201
  _ci = _cfg.get("custom_instructions", "").strip()
@@ -1210,6 +1105,7 @@ async def _libbee_casual_response(question: str, history: List[ChatMessage], mod
1210
 
1211
 
1212
  async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
 
1213
  settings = get_settings()
1214
  if not settings.openai_api_key and not settings.anthropic_api_key:
1215
  return rag_results[0]["content"] if rag_results else ""
@@ -1227,7 +1123,7 @@ async def _rag_answer(question: str, rag_results: List[dict], history: List[Chat
1227
  f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> or '
1228
  f'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.\n\n'
1229
  f"CONTEXT:\n{context}\n\n"
1230
- + _GUARDRAIL
1231
  )
1232
  _cfg = _get_runtime_config()
1233
  _ci = _cfg.get("custom_instructions", "").strip()
@@ -1249,6 +1145,7 @@ async def _rag_answer(question: str, rag_results: List[dict], history: List[Chat
1249
 
1250
 
1251
  async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
 
1252
  settings = get_settings()
1253
  if not settings.openai_api_key and not settings.anthropic_api_key:
1254
  return (
@@ -1269,7 +1166,7 @@ async def _llm_general_answer(question: str, history: List[ChatMessage], model:
1269
  "Khazna repository: https://khazna.ku.ac.ae | "
1270
  "PRIMO discovery: https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU. "
1271
  "Only use these exact URLs β€” never invent others. "
1272
- + _GUARDRAIL
1273
  )
1274
  _cfg = _get_runtime_config()
1275
  _ci = _cfg.get("custom_instructions", "").strip()
@@ -1288,11 +1185,12 @@ async def _llm_general_answer(question: str, history: List[ChatMessage], model:
1288
 
1289
 
1290
  async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
 
1291
  settings = get_settings()
1292
  _WEB_SYSTEM = (
1293
  "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
1294
  "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks. "
1295
- + _GUARDRAIL
1296
  )
1297
  if model == "claude" and settings.anthropic_api_key:
1298
  try:
@@ -1310,7 +1208,6 @@ async def _web_search_answer(question: str, history: List[ChatMessage], model: s
1310
  return text.strip()
1311
  except Exception as e:
1312
  logger.warning(f"Claude web search failed: {e}")
1313
-
1314
  if settings.openai_api_key:
1315
  try:
1316
  from openai import OpenAI
@@ -1331,13 +1228,11 @@ async def _web_search_answer(question: str, history: List[ChatMessage], model: s
1331
  return text.strip()
1332
  except Exception as e:
1333
  logger.warning(f"GPT web search failed: {e}")
1334
-
1335
  return await _llm_general_answer(question, history, model)
1336
 
1337
 
1338
- # ── Β§8 Topic Extraction ───────────────────────────────────────────────────────
1339
 
1340
- # ── Topic Extraction ──────────────────────────────────────────────────────────
1341
 
1342
  def _extract_topic_regex(question: str) -> str:
1343
  q = _normalize_whitespace(question)
@@ -1409,31 +1304,16 @@ async def _extract_topic(question: str, model: str) -> str:
1409
  return _extract_topic_regex(question)
1410
 
1411
 
1412
- # ── Β§9 Query Builders (PRIMO boolean, PubMed URL) ─────────────────────────────
1413
-
1414
- # ── PRIMO / PubMed URL + Query Builders ───────────────────────────────────────
1415
- # FIX: these were previously imported from search.py as
1416
- # build_primo_boolean_query / build_primo_discovery_url / build_pubmed_url
1417
- # but those exports do not exist in search.py.
1418
- # They are now self-contained here.
1419
 
1420
  def _shared_build_primo_boolean_query(topic: str) -> str:
1421
- """
1422
- Regex fallback for boolean query construction β€” used only when LLM is unavailable.
1423
- Keeps multi-word phrases quoted, strips stop words, ANDs remaining concepts.
1424
- Does NOT split known compound terms.
1425
- """
1426
  clean = _strip_resource_noise(topic)
1427
  if not clean:
1428
  clean = topic
1429
  clean = _normalize_whitespace(clean)
1430
-
1431
- # Treat the whole cleaned topic as a single phrase if ≀ 4 words
1432
  words = clean.split()
1433
  if len(words) <= 4:
1434
  return f'"{clean}"'
1435
-
1436
- # For longer topics: split on stop words, quote resulting multi-word chunks
1437
  _BOOL_STOP = re.compile(
1438
  r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
1439
  r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
@@ -1442,14 +1322,12 @@ def _shared_build_primo_boolean_query(topic: str) -> str:
1442
  r"based|related|towards|toward|during|after|before|over|under)\b",
1443
  re.IGNORECASE,
1444
  )
1445
- # Split on stop words, keep non-stop chunks
1446
  parts = _BOOL_STOP.split(clean)
1447
  concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
1448
  if not concepts:
1449
  return f'"{clean}"'
1450
  if len(concepts) == 1:
1451
  return f'"{concepts[0]}"'
1452
- # Quote multi-word concepts, leave single words bare
1453
  quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
1454
  return " AND ".join(quoted)
1455
 
@@ -1491,13 +1369,6 @@ Output: ("renewable energy" OR "clean energy" OR "sustainable energy") AND ("pol
1491
 
1492
 
1493
  async def _llm_build_boolean_query(topic: str, model: str) -> str:
1494
- """
1495
- Use LLM to generate a high-quality PRIMO boolean query with:
1496
- - Quoted multi-word phrases
1497
- - OR synonyms within concept groups
1498
- - AND between distinct concepts
1499
- Falls back to regex version if LLM fails.
1500
- """
1501
  settings = get_settings()
1502
  if not settings.openai_api_key and not settings.anthropic_api_key:
1503
  return _shared_build_primo_boolean_query(topic)
@@ -1508,14 +1379,10 @@ async def _llm_build_boolean_query(topic: str, model: str) -> str:
1508
  {"role": "user", "content": f"Input: {topic.strip()}"},
1509
  ])
1510
  result = response.content.strip()
1511
- # Sanity checks β€” must contain quotes and AND/OR or be a short phrase
1512
  if not result or len(result) > 500:
1513
  raise ValueError("Bad LLM boolean output")
1514
- # Strip any markdown the LLM snuck in
1515
  result = re.sub(r"^```[a-z]*\n?", "", result).rstrip("`").strip()
1516
- # Must have at least one quote or look like a valid query
1517
  if '"' not in result and ' AND ' not in result and ' OR ' not in result:
1518
- # Wrap bare output as a phrase
1519
  result = f'"{result}"'
1520
  logger.info(f"LLM boolean: {result!r} ← topic: {topic!r}")
1521
  return result
@@ -1525,7 +1392,6 @@ async def _llm_build_boolean_query(topic: str, model: str) -> str:
1525
 
1526
 
1527
  def _make_primo_boolean_query(context: SearchContextPayload) -> str:
1528
- """Sync wrapper β€” returns regex boolean. Async LLM version called from _prepare_queries."""
1529
  topic = _title_case_topic(context.display_topic or context.topic)
1530
  topic = _strip_resource_noise(topic) or (context.topic or "library search")
1531
  return _shared_build_primo_boolean_query(topic)
@@ -1539,7 +1405,6 @@ def _shared_build_primo_discovery_url(
1539
  year_from: Optional[str] = None,
1540
  year_to: Optional[str] = None,
1541
  ) -> str:
1542
- """Build a full PRIMO discovery URL with facets."""
1543
  base = (
1544
  "https://khalifa.primo.exlibrisgroup.com/discovery/search"
1545
  f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
@@ -1570,7 +1435,6 @@ def _shared_build_pubmed_url(
1570
  year_to: Optional[str] = None,
1571
  peer_reviewed: bool = False,
1572
  ) -> str:
1573
- """Build a PubMed search URL."""
1574
  clean = _strip_resource_noise(topic)
1575
  term = clean or topic
1576
  if peer_reviewed:
@@ -1583,9 +1447,8 @@ def _shared_build_pubmed_url(
1583
  return url
1584
 
1585
 
1586
- # ── Β§10 Search Preparation & Planning ────────────────────────────────────────
1587
 
1588
- # ── Query Building ────────────────────────────────────────────────────────────
1589
 
1590
  def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
1591
  q = (question or "").lower()
@@ -1613,12 +1476,6 @@ def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
1613
  return None, None
1614
 
1615
 
1616
- def _make_primo_boolean_query(context: SearchContextPayload) -> str:
1617
- topic = _title_case_topic(context.display_topic or context.topic)
1618
- topic = _strip_resource_noise(topic) or (context.topic or "library search")
1619
- return _shared_build_primo_boolean_query(topic)
1620
-
1621
-
1622
  def _primo_clean_url(context: SearchContextPayload) -> str:
1623
  boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
1624
  return _shared_build_primo_discovery_url(
@@ -1644,7 +1501,6 @@ def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_foll
1644
  filters.append(f"published between {context.year_from} and {context.year_to}")
1645
  elif context.year_from:
1646
  filters.append(f"published from {context.year_from} onward")
1647
-
1648
  if is_follow_up:
1649
  if scope == "books":
1650
  base = f"Find books on {topic}"
@@ -1656,7 +1512,6 @@ def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_foll
1656
  base = _normalize_whitespace(question).strip(".?")
1657
  if len(base.split()) < 4:
1658
  base = f"Find research on {topic}"
1659
-
1660
  if filters:
1661
  return f"{base} with {', '.join(filters)}."
1662
  return base.rstrip(".") + "."
@@ -1679,12 +1534,10 @@ async def _grammar_refine_query(text: str, model: str) -> str:
1679
 
1680
 
1681
  def _light_strip_retrieval_boilerplate(text: str) -> str:
1682
- """Strip common retrieval command prefixes before building a search plan."""
1683
  cleaned = re.sub(r"^\s*(please\s+)?(?:can you|could you|would you)\s+", "", (text or "").strip(), flags=re.IGNORECASE)
1684
  cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
1685
  cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
1686
  cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
1687
- # Pass 1: strip search intent verbs
1688
  cleaned = re.sub(
1689
  r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
1690
  r"research on|articles on|papers on|literature on|studies on|"
@@ -1693,14 +1546,11 @@ def _light_strip_retrieval_boilerplate(text: str) -> str:
1693
  r"i want articles on|i need papers on|i want papers on)\s+",
1694
  "", cleaned, flags=re.IGNORECASE
1695
  )
1696
- # Pass 2: strip residual noun boilerplate left after pass 1
1697
- # e.g. "find research on 33" β†’ pass1 β†’ "research on 33" β†’ pass2 β†’ "33"
1698
  cleaned = re.sub(
1699
  r"^\s*(research|articles?|papers?|books?|literature|studies|study|"
1700
  r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
1701
  "", cleaned, flags=re.IGNORECASE
1702
  )
1703
- # Pass 3: strip dangling prepositions
1704
  cleaned = re.sub(
1705
  r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
1706
  "", cleaned, flags=re.IGNORECASE
@@ -1709,7 +1559,6 @@ def _light_strip_retrieval_boilerplate(text: str) -> str:
1709
 
1710
 
1711
  _LEGACY_SYSTEM_PATTERNS = [
1712
- # Summon β€” KU's old discovery system, replaced by PRIMO
1713
  (
1714
  re.compile(
1715
  r'(you can use )?summon[β„’β„’]?\s*(to find [^.]+\.)?\s*'
@@ -1719,21 +1568,13 @@ _LEGACY_SYSTEM_PATTERNS = [
1719
  ),
1720
  "PRIMO Library Discovery (https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU)",
1721
  ),
1722
- # Any bare Summon reference
1723
  (re.compile(r'\bSummon[β„’β„’]?\b', re.IGNORECASE), "PRIMO Library Discovery"),
1724
- # EDS / EBSCO Discovery Service (not the same as EBSCO databases)
1725
  (re.compile(r'\bEBSCO Discovery Service\b', re.IGNORECASE), "PRIMO Library Discovery"),
1726
- # Encore (old III discovery)
1727
  (re.compile(r'\bEncore\b(?!\s+(?:learning|education))', re.IGNORECASE), "PRIMO Library Discovery"),
1728
  ]
1729
 
1730
 
1731
  def _sanitize_llm_response(text: str) -> str:
1732
- """
1733
- Strip legacy discovery system references from LLM output.
1734
- KU migrated from Summon β†’ PRIMO. Old KB chunks may still mention Summon
1735
- and the LLM may echo them. This runs as a final pass on all agent answers.
1736
- """
1737
  if not text:
1738
  return text
1739
  for pattern, replacement in _LEGACY_SYSTEM_PATTERNS:
@@ -1742,26 +1583,20 @@ def _sanitize_llm_response(text: str) -> str:
1742
 
1743
 
1744
  def _sanitize_boolean_for_primo(boolean: str) -> str:
1745
- """Clean LLM-generated boolean so it works in PRIMO advanced search URLs."""
1746
  if not boolean:
1747
  return boolean
1748
- # Single quotes β†’ double quotes around terms
1749
  boolean = re.sub(r"'([^']+)'", r'"\1"', boolean)
1750
- # Remove pure-year groups like (2025 OR 2026) β€” years belong in date facets
1751
  boolean = re.sub(
1752
  r"\(\s*(?:(?:19|20)\d{2}\s*(?:OR\s*(?:19|20)\d{2}\s*)*)\)",
1753
  "", boolean, flags=re.IGNORECASE,
1754
  )
1755
- # Remove dangling AND/OR
1756
  boolean = re.sub(r"^\s*(AND|OR)\s*", "", boolean, flags=re.IGNORECASE)
1757
  boolean = re.sub(r"\s*(AND|OR)\s*$", "", boolean, flags=re.IGNORECASE)
1758
- # Collapse double AND/OR
1759
  boolean = re.sub(r"\b(AND|OR)\s+(AND|OR)\b", r"\1", boolean, flags=re.IGNORECASE)
1760
  return re.sub(r"\s+", " ", boolean).strip()
1761
 
1762
 
1763
  def _clean_database_keywords(boolean_query: str) -> str:
1764
- """Strip boolean operators/punctuation to get plain keyword string."""
1765
  return re.sub(r"\s+", " ",
1766
  re.sub(r"\b(AND|OR|NOT)\b|[()\"]", " ",
1767
  boolean_query, flags=re.IGNORECASE)).strip()
@@ -1784,7 +1619,7 @@ The user typed a research request. Create THREE forms preserving the full intent
1784
 
1785
  3. boolean: write a PRIMO/PubMed Boolean query with AND/OR/parentheses.
1786
 
1787
- Also detect: year_from, year_to (e.g. "last 24 months" β†’ year_from={{CURRENT_YEAR-2}}),
1788
  peer_reviewed, open_access.
1789
 
1790
  STEP 1 β€” TOPIC EXTRACTION:
@@ -1849,12 +1684,6 @@ Return ONLY valid JSON:
1849
 
1850
 
1851
  async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
1852
- """
1853
- Stage 2 topic validator β€” runs after the regex ambiguity gate.
1854
- Catches gibberish/unrecognisable words that passed the gate.
1855
- Returns (True, corrected_topic) or (False, "").
1856
- Fails open on any exception so it never blocks a legitimate search.
1857
- """
1858
  settings = get_settings()
1859
  if not settings.openai_api_key and not settings.anthropic_api_key:
1860
  return True, topic
@@ -1892,25 +1721,15 @@ async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
1892
 
1893
 
1894
  async def _build_search_plan(question: str, model: str) -> dict:
1895
- """
1896
- LLM-driven search plan.
1897
- Includes:
1898
- - Ambiguity gate (numbers, single chars, no content words)
1899
- - Stage 2 LLM topic validator (gibberish detection + typo correction)
1900
- Falls back to regex boolean if LLM fails.
1901
- """
1902
  settings = get_settings()
1903
  raw_query = _normalize_whitespace(question or "")
1904
  light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
1905
-
1906
- # ── Stage 1: Regex ambiguity gate ────────────────────────────────────────
1907
  _stripped = light_query.strip()
1908
  _is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
1909
  _is_too_short = len(_stripped) < 2
1910
  _is_single_char = bool(re.fullmatch(r"[a-zA-Z0-9]", _stripped))
1911
  _content_words = [w for w in _stripped.split() if len(w) > 1 and not re.fullmatch(r"[\d\.\,\-]+", w)]
1912
  _no_content = len(_content_words) == 0 and len(_stripped) > 0
1913
-
1914
  if _is_pure_number or _is_too_short or _is_single_char or _no_content:
1915
  _display = _stripped or raw_query
1916
  return {
@@ -1926,7 +1745,6 @@ async def _build_search_plan(question: str, model: str) -> dict:
1926
  f"the better I can build your search."
1927
  ),
1928
  }
1929
- # ── Stage 2: LLM topic validator ─────────────────────────────────────────
1930
  if settings.openai_api_key or settings.anthropic_api_key:
1931
  _valid, _corrected_topic = await _validate_topic(_stripped, model)
1932
  if not _valid:
@@ -1945,8 +1763,6 @@ async def _build_search_plan(question: str, model: str) -> dict:
1945
  if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
1946
  logger.info(f"_validate_topic corrected: {_stripped!r} β†’ {_corrected_topic!r}")
1947
  light_query = _corrected_topic
1948
- # ─────────────────────────────────────────────────────────────────────────
1949
-
1950
  if settings.openai_api_key or settings.anthropic_api_key:
1951
  try:
1952
  llm = _get_llm(model, temperature=0, max_tokens=300)
@@ -1957,34 +1773,25 @@ async def _build_search_plan(question: str, model: str) -> dict:
1957
  raw = response.content.strip()
1958
  if raw.startswith("```"):
1959
  raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
1960
- # Extract JSON object
1961
  s, e = raw.find("{"), raw.rfind("}")
1962
  if s != -1 and e > s:
1963
  result = json.loads(raw[s:e + 1])
1964
  else:
1965
  raise ValueError("No JSON found in response")
1966
-
1967
  corrected = (result.get("corrected") or raw_query).strip() or raw_query
1968
  natural = (result.get("natural") or corrected).strip() or corrected
1969
  boolean = (result.get("boolean") or "").strip()
1970
-
1971
- # Validate boolean β€” must have operators and structure
1972
  has_ops = bool(re.search(r"\b(AND|OR)\b", boolean)) if boolean else False
1973
  has_parens = "(" in boolean if boolean else False
1974
  if not has_ops or not has_parens:
1975
  boolean = _shared_build_primo_boolean_query(corrected)
1976
-
1977
  boolean = _sanitize_boolean_for_primo(boolean)
1978
-
1979
  year_from = str(result.get("year_from") or "").strip()
1980
  year_to = str(result.get("year_to") or "").strip()
1981
-
1982
- # If LLM didn't detect year filters, fall back to regex
1983
  if not year_from and not year_to:
1984
  yf, yt = _parse_year_filters(raw_query)
1985
  year_from = yf or ""
1986
  year_to = yt or ""
1987
-
1988
  return {
1989
  "corrected": corrected,
1990
  "natural": natural,
@@ -1997,8 +1804,6 @@ async def _build_search_plan(question: str, model: str) -> dict:
1997
  }
1998
  except Exception as e:
1999
  logger.warning(f"_build_search_plan LLM failed: {e} β€” using regex fallback")
2000
-
2001
- # Regex fallback
2002
  boolean = _shared_build_primo_boolean_query(light_query)
2003
  yf, yt = _parse_year_filters(raw_query)
2004
  pr = bool(re.search(r"\bpeer[- ]reviewed\b", raw_query, re.IGNORECASE))
@@ -2016,21 +1821,15 @@ async def _build_search_plan(question: str, model: str) -> dict:
2016
 
2017
 
2018
  async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
2019
- """Build all query forms using _build_search_plan."""
2020
  topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
2021
  q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
2022
-
2023
  plan = await _build_search_plan(q, model)
2024
-
2025
- # Clarification gate
2026
  if plan.get("clarification_needed"):
2027
  context.clarification_needed = True
2028
  context.clarification_message = plan.get("clarification_message", "")
2029
  return context
2030
-
2031
  context.ai_tool_query = plan["natural"]
2032
  context.primo_boolean_query = plan["boolean"]
2033
-
2034
  if not context.year_from and plan.get("year_from"):
2035
  context.year_from = plan["year_from"]
2036
  if not context.year_to and plan.get("year_to"):
@@ -2039,18 +1838,14 @@ async def _prepare_queries(question: str, context: SearchContextPayload, model:
2039
  context.peer_reviewed = True
2040
  if not context.open_access and plan.get("open_access"):
2041
  context.open_access = True
2042
-
2043
  return context
2044
 
2045
 
2046
- # ── Β§11 Search Runners ────────────────────────────────────────────────────────
2047
 
 
2048
 
2049
  async def _topic_intro(topic: str, model: str) -> str:
2050
- """
2051
- 3-sentence topic intro β€” lightweight LLM call (~180 tokens, no external APIs).
2052
- Prepended to all academic/medical search responses (v3.8).
2053
- """
2054
  settings = get_settings()
2055
  if not settings.openai_api_key and not settings.anthropic_api_key:
2056
  return ""
@@ -2063,7 +1858,8 @@ async def _topic_intro(topic: str, model: str) -> str:
2063
  "for a university student or researcher. "
2064
  "Cover: what the topic is, why it matters, and one key area of current research interest. "
2065
  "Use HTML <br> for line breaks only if needed. No markdown, no bullet points, no headings. "
2066
- "Be concise and informative."
 
2067
  )},
2068
  {"role": "user", "content": f"Research topic: {topic}"},
2069
  ])
@@ -2074,8 +1870,6 @@ async def _topic_intro(topic: str, model: str) -> str:
2074
  return ""
2075
 
2076
 
2077
- # ── Semantic Scholar + Summary ────────────────────────────────────────────────
2078
-
2079
  async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
2080
  clean_topic = _strip_resource_noise(topic)
2081
  if not clean_topic:
@@ -2129,13 +1923,8 @@ async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
2129
 
2130
 
2131
  async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
2132
- """
2133
- Search OpenAlex using ?search= β€” same relevance engine as the browser link.
2134
- Passes the full corrected natural language query, strips only boolean operators.
2135
- """
2136
  try:
2137
  from urllib.parse import quote as _oa_quote
2138
- # Strip boolean operators only β€” keep all keywords including proper nouns
2139
  clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
2140
  clean = re.sub(r'\s+', ' ', clean).strip()[:200]
2141
  encoded = _oa_quote(clean, safe='')
@@ -2155,12 +1944,9 @@ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
2155
  logger.warning(f"OpenAlex search failed: {r.status_code}")
2156
  return []
2157
  works = r.json().get("results", [])
2158
-
2159
  results = []
2160
  for work in works:
2161
  title = work.get("title") or "Untitled"
2162
-
2163
- # Reconstruct abstract from inverted index
2164
  inv = work.get("abstract_inverted_index") or {}
2165
  abstract = ""
2166
  if inv:
@@ -2170,10 +1956,8 @@ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
2170
  word_positions.append((pos, word))
2171
  word_positions.sort(key=lambda x: x[0])
2172
  abstract = " ".join(w for _, w in word_positions)[:700]
2173
-
2174
  if not abstract:
2175
  continue
2176
-
2177
  authorships = work.get("authorships") or []
2178
  author_names = [
2179
  a.get("author", {}).get("display_name", "")
@@ -2184,7 +1968,6 @@ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
2184
  if len(author_names) > 3:
2185
  authors_str += " et al."
2186
  first_last = author_names[0].split()[-1] if author_names else "Unknown"
2187
-
2188
  year = str(work.get("publication_year") or "n.d.")
2189
  doi = (work.get("doi") or "").replace("https://doi.org/", "")
2190
  oa_url = (work.get("open_access") or {}).get("oa_url") or ""
@@ -2197,7 +1980,6 @@ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
2197
  ((work.get("primary_location") or {}).get("source") or {})
2198
  .get("display_name") or ""
2199
  )
2200
-
2201
  results.append({
2202
  "title": title,
2203
  "authors": authors_str or "Unknown",
@@ -2209,16 +1991,15 @@ async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
2209
  "link": link,
2210
  "_source": "OpenAlex",
2211
  })
2212
-
2213
  logger.info(f"OpenAlex: {len(results)} papers for '{clean[:50]}'")
2214
  return results
2215
-
2216
  except Exception as e:
2217
  logger.warning(f"OpenAlex search failed: {e}")
2218
  return []
2219
 
2220
 
2221
  async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
 
2222
  settings = get_settings()
2223
  if not settings.openai_api_key and not settings.anthropic_api_key:
2224
  return ""
@@ -2237,7 +2018,8 @@ async def _generate_cited_summary(topic: str, papers: List[dict], model: str) ->
2237
  "Cite at least 4 different references. "
2238
  "Use HTML <br><br> for paragraph breaks. Do not use bullet points or markdown. "
2239
  "Only state what is explicitly supported by the abstracts. "
2240
- "Do NOT invent content."
 
2241
  )
2242
  try:
2243
  llm = _get_llm(model, temperature=0.2, max_tokens=560)
@@ -2283,14 +2065,12 @@ def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
2283
  return intro + "<br><br>".join(statements[:4])
2284
 
2285
 
2286
- # ── Β§12 Response Builders ─────────────────────────────────────────────────────
2287
 
2288
- # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
2289
 
2290
  async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
2291
  from src.api.search import SearchRequest, search_primo
2292
  settings = get_settings()
2293
- # Use the LLM-built boolean query if available, else fall back to plain topic
2294
  boolean = context.primo_boolean_query
2295
  if not boolean:
2296
  clean_topic = _strip_resource_noise(context.topic)
@@ -2334,8 +2114,6 @@ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5)
2334
  return result, pubmed_url
2335
 
2336
 
2337
- # ── AI Tools / Database Guidance ─────────────────────────────────────────────
2338
-
2339
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
2340
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
2341
  primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
@@ -2397,12 +2175,9 @@ def _find_staff_by_token(token: str) -> Optional[dict]:
2397
  def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
2398
  q = (question or "").lower()
2399
  specialist: Optional[dict] = None
2400
-
2401
- # ILL β€” always Suaad Al Jneibi, not Jason
2402
  if any(t in q for t in ["interlibrary", "ill request", "ill form", "borrow from another",
2403
  "request article", "request a book", "not available at ku",
2404
  "not in ku library", "inter library", "inter-library"]):
2405
- # Use hardcoded ILL contact since Suaad may not be in staff directory
2406
  footer = (
2407
  "<br><br><strong>Library contact points</strong><br>"
2408
  f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> Β· '
@@ -2422,7 +2197,6 @@ def _contact_footer_for_query(question: str, context: Optional[SearchContextPayl
2422
  specialist = _find_staff_by_token("jason")
2423
  elif context and context.intent == "search_academic":
2424
  specialist = _find_staff_by_token("nikesh")
2425
-
2426
  footer = (
2427
  "<br><br><strong>Library contact points</strong><br>"
2428
  f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> Β· '
@@ -2441,9 +2215,6 @@ def _contact_footer_for_query(question: str, context: Optional[SearchContextPayl
2441
 
2442
 
2443
  def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> str:
2444
- """
2445
- Lightweight collapsible reasoning block shown at the top of every non-search response.
2446
- """
2447
  labels = {
2448
  "library_info": ("πŸ“‹", "Library information lookup", "I searched the KU Library knowledge base for relevant content."),
2449
  "general": ("🌐", "General knowledge / web search", "I answered using a web search for current information."),
@@ -2460,7 +2231,6 @@ def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> st
2460
  icon, label, reasoning = labels.get(intent, ("πŸ€–", "AI response", "I processed your question and generated a response."))
2461
  if extra:
2462
  reasoning += " " + extra
2463
-
2464
  return (
2465
  f'<details style="margin:0 0 12px 0;border:1px solid #e5e7eb;border-radius:10px;'
2466
  f'background:#fafafa;overflow:hidden">'
@@ -2476,19 +2246,13 @@ def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> st
2476
 
2477
 
2478
  def _search_trace_block(question: str, context: SearchContextPayload) -> str:
2479
- """
2480
- Richer collapsible reasoning block for search responses.
2481
- Shows topic, boolean query, and reasoning. Also hints that AI tools are at the end.
2482
- """
2483
  topic = _escape(context.display_topic or context.topic)
2484
  primo_boolean = _escape(context.primo_boolean_query or "")
2485
  source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
2486
-
2487
  intent_label = {
2488
  "search_academic": "academic literature search",
2489
  "search_medical": "medical / clinical literature search",
2490
  }.get(context.intent, "library search")
2491
-
2492
  filters_parts = []
2493
  if context.peer_reviewed:
2494
  filters_parts.append("peer reviewed only")
@@ -2503,7 +2267,6 @@ def _search_trace_block(question: str, context: SearchContextPayload) -> str:
2503
  )
2504
  filters_parts.append(rtype)
2505
  filters_str = ", ".join(filters_parts)
2506
-
2507
  reasoning = (
2508
  f"I classified this as a <strong>{intent_label}</strong>, "
2509
  f"extracted the topic <strong>{topic}</strong>, "
@@ -2512,7 +2275,6 @@ def _search_trace_block(question: str, context: SearchContextPayload) -> str:
2512
  )
2513
  if context.intent == "search_medical" and context.source != "pubmed":
2514
  reasoning += " For medical topics, PubMed and Embase are also recommended."
2515
-
2516
  block = (
2517
  f'<details style="margin:0 0 14px 0;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff;overflow:hidden">'
2518
  f'<summary style="padding:10px 14px;cursor:pointer;font-size:.8rem;font-weight:700;color:#1e40af;list-style:none;display:flex;align-items:center;gap:6px">'
@@ -2534,6 +2296,7 @@ def _search_trace_block(question: str, context: SearchContextPayload) -> str:
2534
  return block
2535
 
2536
 
 
2537
  def _ai_tools_footer(context):
2538
  from urllib.parse import quote as _q
2539
 
@@ -2562,7 +2325,6 @@ def _ai_tools_footer(context):
2562
  if is_med and pubmed_url:
2563
  platforms.append(("PubMed", pubmed_url, "pubmed"))
2564
 
2565
- # Inline SVG mascots β€” unique per database type, no network requests
2566
  _SVGS = {
2567
  "bee": (
2568
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="16" height="16" style="flex-shrink:0">'
@@ -2664,7 +2426,6 @@ def _ai_tools_footer(context):
2664
  '<circle cx="12" cy="4" r="1.2" fill="#fef3c7"/>'
2665
  '</svg>'
2666
  ),
2667
- # Platform mascots β€” publisher-style
2668
  "primo": (
2669
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
2670
  '<rect x="1" y="1" width="14" height="14" rx="2" fill="#003366"/>'
@@ -2725,7 +2486,6 @@ def _ai_tools_footer(context):
2725
  '</svg>'
2726
  ),
2727
  }
2728
- # Per-database mascot map
2729
  _DB_MASCOT = {
2730
  "PubMed@KU": "microscope", "Embase": "medical", "CINAHL Ultimate": "medical",
2731
  "Cochrane Library": "microscope", "UpToDate": "medical",
@@ -2736,8 +2496,7 @@ def _ai_tools_footer(context):
2736
  "Business Source Complete": "chart", "Emerald Insight": "chart",
2737
  "Taylor & Francis": "book", "EBSCO": "book",
2738
  }
2739
- _BEE = _SVGS["bee"] # default for platform buttons
2740
-
2741
 
2742
  def _btn(label, url, mascot_key="bee"):
2743
  icon = _SVGS.get(mascot_key, _SVGS["bee"])
@@ -2898,11 +2657,6 @@ def _is_database_recommendation_question(question: str) -> bool:
2898
 
2899
 
2900
  def _safe_db_url(url: str) -> str:
2901
- """
2902
- Encode the inner URL in IDM proxy links so the frontend fmt() auto-linkify
2903
- regex cannot match a bare https:// inside an href attribute and break the HTML.
2904
- e.g. href="...?url=https://foo.com" β†’ href="...?url=https%3A%2F%2Ffoo.com"
2905
- """
2906
  from urllib.parse import quote as _quote
2907
  if "?url=" in url:
2908
  base, inner = url.split("?url=", 1)
@@ -2910,8 +2664,9 @@ def _safe_db_url(url: str) -> str:
2910
  return url
2911
 
2912
 
2913
- # ── Subject β†’ database mapping sourced from KU KB ────────────────────────────
2914
- # All IDM proxy URLs are encoded via _safe_db_url to prevent HTML breakage.
 
2915
  _DB_SUBJECTS: Dict[str, List[Tuple[str, str, str]]] = {
2916
  "physics": [
2917
  ("APS Journals", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://journals.aps.org"), "American Physical Society β€” core physics journals."),
@@ -3087,7 +2842,6 @@ _DB_SUBJECTS: Dict[str, List[Tuple[str, str, str]]] = {
3087
  ],
3088
  }
3089
 
3090
- # General / fallback databases (used when no subject match found)
3091
  _DB_GENERAL: List[Tuple[str, str, str]] = [
3092
  ("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."),
3093
  ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Largest abstract and citation database β€” all disciplines."),
@@ -3101,7 +2855,6 @@ _DB_GENERAL: List[Tuple[str, str, str]] = [
3101
 
3102
 
3103
  def _match_db_subject(q: str) -> Optional[str]:
3104
- """Map lowercased question to a _DB_SUBJECTS key using keyword matching."""
3105
  checks = [
3106
  (["quantum computing", "quantum algorithm", "quantum cryptography", "quantum information"], "quantum_computing"),
3107
  (["robotics", "autonomous system", "intelligent system"], "robotics"),
@@ -3136,13 +2889,7 @@ def _match_db_subject(q: str) -> Optional[str]:
3136
 
3137
 
3138
  def _extract_db_topic(question: str) -> str:
3139
- """
3140
- Extract the subject from a database-recommendation question.
3141
- e.g. "best databases for physics" β†’ "physics"
3142
- "which database should I use for machine learning" β†’ "machine learning"
3143
- """
3144
  q = _normalize_whitespace(question or "")
3145
- # Strip leading boilerplate phrases
3146
  patterns = [
3147
  r"^(best|good|recommended?)\s+databases?\s+for\s+",
3148
  r"^which\s+databases?\s+(for|should i use for|is best for|are best for)\s+",
@@ -3156,7 +2903,6 @@ def _extract_db_topic(question: str) -> str:
3156
  if m:
3157
  q = q[m.end():]
3158
  break
3159
- # Strip trailing noise
3160
  q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
3161
  q = _strip_resource_noise(q)
3162
  return _normalize_whitespace(q).strip(".?") or "this subject"
@@ -3166,26 +2912,18 @@ def _database_recommendation_answer(question: str) -> str:
3166
  q = (question or "").lower()
3167
  subject = _extract_db_topic(question)
3168
  topic = _escape(subject)
3169
-
3170
- # Match to a specific subject list; fall back to general KU databases
3171
  subject_key = _match_db_subject(q)
3172
  dbs = _DB_SUBJECTS.get(subject_key, _DB_GENERAL) if subject_key else _DB_GENERAL
3173
-
3174
- # Cap at 5 results for readability
3175
  dbs = list(dbs)[:5]
3176
-
3177
  answer = f"<strong>Recommended databases for {topic}</strong><br><br>"
3178
  answer += "Here are the strongest KU-subscribed starting points:<br>"
3179
  for name, url, why in dbs:
3180
  answer += f'<br>β€’ <a href="{url}" target="_blank"><strong>{_escape(name)}</strong></a> β€” {_escape(why)}'
3181
-
3182
  if not subject_key:
3183
- # Generic topic β€” add a note that subject-specific guides exist
3184
  answer += (
3185
  "<br><br>πŸ’‘ For subject-specific databases, ask me: "
3186
  "<em>\"best databases for [your subject]\"</em> β€” e.g. physics, chemistry, medicine, civil engineering."
3187
  )
3188
-
3189
  answer += (
3190
  f'<br><br>Browse all 50+ KU databases: '
3191
  f'<a href="https://library.ku.ac.ae/eresources" target="_blank">library.ku.ac.ae/eresources</a>'
@@ -3194,12 +2932,10 @@ def _database_recommendation_answer(question: str) -> str:
3194
  return answer
3195
 
3196
 
3197
- # ── Β§13 Hardcoded Answers ─────────────────────────────────────────────────────
3198
 
3199
- # ── Full-text access chain ────────────────────────────────────────────────────
3200
 
3201
  def _fulltext_chain_answer(question: str) -> str:
3202
- """Step-by-step guide for getting full text when PDF is unavailable."""
3203
  return (
3204
  "<strong>πŸ“„ How to get full text for an article</strong><br><br>"
3205
  "Follow this chain in order β€” each step is faster than the next:<br><br>"
@@ -3225,18 +2961,11 @@ def _fulltext_chain_answer(question: str) -> str:
3225
  )
3226
 
3227
 
3228
- # ── Search strategy / Boolean explanation ─────────────────────────────────────
3229
-
3230
  async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]:
3231
- """
3232
- Build a search plan AND explain it step by step.
3233
- Returns (explanation_html, plan_dict).
3234
- """
3235
  plan = await _build_search_plan(question, model)
3236
  topic = _escape(_light_strip_retrieval_boilerplate(question) or question)
3237
  boolean = _escape(plan.get("boolean", ""))
3238
  natural = _escape(plan.get("natural", ""))
3239
-
3240
  explanation = (
3241
  f"<strong>πŸ” Search strategy for: {topic}</strong><br><br>"
3242
  f"<strong>1. Identify your key concepts</strong><br>"
@@ -3262,10 +2991,7 @@ async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]
3262
  return explanation, plan
3263
 
3264
 
3265
- # ── Alternative search terms ──────────────────────────────────────────────────
3266
-
3267
  async def _alternative_terms_answer(question: str, context: Optional[SearchContextPayload], model: str) -> str:
3268
- """Generate alternative keywords/synonyms for the current topic without re-running the search."""
3269
  topic = (context.display_topic or context.topic) if context else question
3270
  settings = get_settings()
3271
  if not settings.openai_api_key and not settings.anthropic_api_key:
@@ -3300,8 +3026,6 @@ async def _alternative_terms_answer(question: str, context: Optional[SearchConte
3300
  return f"<strong>Alternative terms for {_escape(topic)}</strong><br><br>Try synonyms, acronyms, broader/narrower terms, and related concepts in your search."
3301
 
3302
 
3303
- # ── Citation chaining guide ───────────────────────────────────────────────────
3304
-
3305
  def _citation_chain_answer() -> str:
3306
  return (
3307
  "<strong>πŸ”— How to trace citations forward and backward from a paper</strong><br><br>"
@@ -3326,8 +3050,6 @@ def _citation_chain_answer() -> str:
3326
  )
3327
 
3328
 
3329
- # ── Predatory / peer-review evaluation guide ─────────────────────────────────
3330
-
3331
  def _predatory_eval_answer() -> str:
3332
  return (
3333
  "<strong>βœ… How to tell if an article or journal is peer-reviewed, scholarly, or predatory</strong><br><br>"
@@ -3356,8 +3078,6 @@ def _predatory_eval_answer() -> str:
3356
  )
3357
 
3358
 
3359
- # ── Theses / dissertations guide ─────────────────────────────────────────────
3360
-
3361
  def _theses_answer() -> str:
3362
  return (
3363
  "<strong>πŸ“˜ Finding KU theses, dissertations, and institutional research</strong><br><br>"
@@ -3380,10 +3100,7 @@ def _theses_answer() -> str:
3380
  )
3381
 
3382
 
3383
- # ── Highly-cited papers guide ─────────────────────────────────────────────────
3384
-
3385
  def _highly_cited_note(topic_escaped: str) -> str:
3386
- """Append to a standard search answer when highly-cited filter is detected."""
3387
  return (
3388
  f"<br><br><strong>πŸ“Š Finding highly cited papers on {topic_escaped}</strong><br>"
3389
  "PRIMO doesn't sort by citation count, but these tools do:<br>"
@@ -3398,13 +3115,7 @@ def _highly_cited_note(topic_escaped: str) -> str:
3398
  )
3399
 
3400
 
3401
- # ── Research Snapshot ─────────────────────────────────────────────────────────
3402
-
3403
  async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
3404
- """
3405
- Research snapshot β€” no API calls.
3406
- Shows PRIMO browser link + AI tools footer only.
3407
- """
3408
  topic = context.topic
3409
  primo_url = _primo_clean_url(context)
3410
  answer = _search_trace_block(f"Research on {topic}", context)
@@ -3419,9 +3130,9 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
3419
  answer += _ai_tools_footer(context)
3420
  return answer, [], []
3421
 
3422
- # ── Β§14 Follow-up System ──────────────────────────────────────────────────────
3423
 
3424
- # ── Follow-up System ──────────────────────────────────────────────────────────
 
3425
 
3426
  def _filters_summary(context: SearchContextPayload) -> str:
3427
  filters: List[str] = []
@@ -3467,7 +3178,6 @@ def _parse_refinement_action(question: str) -> Optional[str]:
3467
  return "last_5_years"
3468
  if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
3469
  return "last_10_years"
3470
- # "last 3 years", "last 2 years" etc.
3471
  m = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
3472
  if m:
3473
  return f"last_{m.group(2)}_years"
@@ -3477,17 +3187,10 @@ def _parse_refinement_action(question: str) -> Optional[str]:
3477
  return "articles_only"
3478
  if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
3479
  return "both_resources"
3480
- # Review articles only β€” NEW
3481
  if REVIEW_ONLY_RE.search(q):
3482
  return "review_articles_only"
3483
- # Alternative search terms β€” NEW
3484
  if ALT_TERMS_RE.search(q):
3485
  return "alt_terms"
3486
- # summarize_topic: ONLY match short/topicless follow-ups like "summarize this",
3487
- # "give me an overview", "what does the literature say" β€” NOT questions that
3488
- # include an explicit new topic like "summarize quantum physics".
3489
- # A question with 4+ non-stop content words almost certainly has its own topic
3490
- # and should be handled as a fresh query, not a follow-up.
3491
  if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
3492
  _STOP = {
3493
  'a','an','the','of','on','in','for','to','with','by','from','at','is','are',
@@ -3498,7 +3201,6 @@ def _parse_refinement_action(question: str) -> Optional[str]:
3498
  }
3499
  content_words = [t for t in re.findall(r'[a-z0-9]+', q)
3500
  if t not in _STOP and (len(t) > 2 or t in {'ai','ml','nlp','cv','rl','dl','uae','ku','iot'})]
3501
- # Any explicit topic word β†’ treat as a fresh query, not a follow-up
3502
  if content_words:
3503
  return None
3504
  return "summarize_topic"
@@ -3571,7 +3273,6 @@ def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional
3571
  context.year_from = str(CURRENT_YEAR - 1)
3572
  context.year_to = str(CURRENT_YEAR)
3573
  elif action and action.startswith("last_") and action.endswith("_years"):
3574
- # Generic "last N years" β€” parse N
3575
  try:
3576
  n = int(action.split("_")[1])
3577
  context.year_from = str(CURRENT_YEAR - n + 1)
@@ -3585,11 +3286,8 @@ def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional
3585
  elif action == "both_resources":
3586
  context.resource_type = "both"
3587
  elif action == "review_articles_only":
3588
- # Map to PRIMO facet for review articles β€” stored as resource_type hint
3589
  context.resource_type = "articles"
3590
- context.peer_reviewed = True # reviews are implicitly peer-reviewed
3591
- # Store the review filter flag in the context notes field if available
3592
- # The PRIMO query will add facet_rtype=reviews
3593
  elif action == "search_pubmed":
3594
  context.source = "pubmed"
3595
  context.intent = "search_medical"
@@ -3781,6 +3479,7 @@ async def _run_search_mode(
3781
 
3782
  return answer, [], [], source_url
3783
 
 
3784
  def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
3785
  dedup: Dict[str, SearchContextPayload] = {}
3786
  for ctx in contexts:
@@ -3789,18 +3488,15 @@ def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[Search
3789
  return ordered[:5]
3790
 
3791
 
3792
- # ── Β§15 Main Routing ──────────────────────────────────────────────────────────
3793
 
3794
- # ── Main Endpoint ──────────────────────────────────────────────────────────────
3795
 
3796
  def _log_agent_query(question: str, response: AgentResponse, start_time: float) -> None:
3797
- """Fire-and-forget query logging to MetricsService AND Cloudflare D1 via Worker."""
3798
  elapsed = time.time() - start_time
3799
  intent = response.intent or ""
3800
  tool = ", ".join(response.tools_used or [])[:60]
3801
  model_used = response.model_used or ""
3802
  result_count = len(response.search_results or [])
3803
- # Log to local MetricsService (in-memory counters)
3804
  try:
3805
  from app import get_metrics_service
3806
  get_metrics_service().log_query(
@@ -3813,7 +3509,6 @@ def _log_agent_query(question: str, response: AgentResponse, start_time: float)
3813
  )
3814
  except Exception:
3815
  pass
3816
- # Log to Cloudflare D1 via Worker (persistent across deploys)
3817
  try:
3818
  settings = get_settings()
3819
  worker_url = getattr(settings, "cloudflare_worker_url", "") or ""
@@ -3844,34 +3539,6 @@ def _log_agent_query(question: str, response: AgentResponse, start_time: float)
3844
  pass
3845
 
3846
 
3847
-
3848
- # ── Β§16 FastAPI Endpoint & Utility Routes ─────────────────────────────────────
3849
-
3850
- @router.post("", response_model=AgentResponse)
3851
- async def agent_query(request: Request, req: AgentRequest):
3852
- start_time = time.time()
3853
- question = _normalize_whitespace(req.question)
3854
- history = req.history
3855
- model = req.model
3856
- client_state = req.client_state
3857
- _safe_metrics_increment("agent_requests")
3858
- response = await _agent_query_inner(question, history, model, client_state, start_time)
3859
- # Strip legacy discovery system references (Summon etc.) from all responses
3860
- response.answer = _sanitize_llm_response(response.answer)
3861
- _log_agent_query(question, response, start_time)
3862
- # Append announcement banner if set in admin config
3863
- _rcfg = _get_runtime_config()
3864
- _ann = _rcfg.get("announcement", "").strip()
3865
- if _ann and response.intent not in ("social_greeting", "social"):
3866
- _ann_html = (
3867
- '<div style="margin-top:14px;padding:10px 14px;border-radius:10px;'
3868
- 'background:#fffbeb;border:1px solid #fde68a;font-size:.82rem;color:#92400e">'
3869
- f'πŸ“’ {_ann}</div>'
3870
- )
3871
- response.answer = response.answer + _ann_html
3872
- return response
3873
-
3874
-
3875
  async def _agent_query_inner(
3876
  question: str,
3877
  history,
@@ -3896,12 +3563,6 @@ async def _agent_query_inner(
3896
  response_time=time.time() - start_time,
3897
  )
3898
 
3899
- # ── Sensitive institutional topic gate ───────────────────────────────────
3900
- # Handled by LLM classifier (intent == "sensitive") further below.
3901
- # The regex gate (_SENSITIVE_TOPIC_RE) has been removed in favour of the
3902
- # LLM classifier which understands context and intent more accurately.
3903
- # ─────────────────────────────────────────────────────────────────────────
3904
-
3905
  if not question:
3906
  follow_up_question, follow_up_suggestions = _social_follow_up()
3907
  return AgentResponse(
@@ -4032,9 +3693,7 @@ async def _agent_query_inner(
4032
 
4033
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
4034
 
4035
- # ── Catalogue availability: "books available in the KU library on X" ─────────
4036
- # Catches questions asking what the library *holds*, not topic searches.
4037
- # Must fire BEFORE the LLM classifier which would route these to search_academic.
4038
  _CATALOGUE_RE = re.compile(
4039
  r"\b("
4040
  r"available in (the )?(ku|khalifa) library"
@@ -4049,8 +3708,6 @@ async def _agent_query_inner(
4049
  _BOOK_RE = re.compile(r"\b(book|books|ebook|ebooks|textbook|textbooks|copy|copies|title|titles)\b", re.IGNORECASE)
4050
 
4051
  if _CATALOGUE_RE.search(question) and _BOOK_RE.search(question):
4052
- # Route as library_info β†’ RAG will handle or fall back to Ask a Librarian
4053
- # Also build a PRIMO books URL so the user can search directly
4054
  from urllib.parse import quote as _quote
4055
  _topic_raw = re.sub(
4056
  r"\b(can you (help me )?find|find|search|look for|show me|help me find"
@@ -4069,7 +3726,6 @@ async def _agent_query_inner(
4069
  )
4070
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
4071
 
4072
- # Try RAG first for any policy/service context
4073
  rag_results = []
4074
  try:
4075
  from app import get_rag_service
@@ -4107,74 +3763,6 @@ async def _agent_query_inner(
4107
  _safe_metrics_increment("follow_up_hits")
4108
  context = _apply_follow_up_action(base_context, follow_up_action)
4109
 
4110
- if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
4111
- context = await _prepare_queries(question, context, model, True)
4112
- if follow_up_action == "summarize_topic":
4113
- answer, search_results, sources = await _research_snapshot(context, model)
4114
- else:
4115
- # deep_research_tools / best_databases follow-up
4116
- answer = (
4117
- f"{_search_trace_block(question, context)}"
4118
- f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
4119
- f"Here are the best AI tools and resources for a deep dive on this topic."
4120
- + _source_badge("libbee")
4121
- + _ai_tools_footer(context)
4122
- )
4123
- search_results = []
4124
- sources = []
4125
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
4126
- _safe_metrics_increment("search_handoffs")
4127
- return AgentResponse(
4128
- answer=answer,
4129
- intent=context.intent,
4130
- tools_used=["follow_up_resolution", follow_up_action],
4131
- search_results=search_results,
4132
- sources=sources,
4133
- model_used=model,
4134
- response_time=time.time() - start_time,
4135
- corrected_query=context.topic,
4136
- natural_query=context.ai_tool_query,
4137
- database_query=context.primo_boolean_query,
4138
- ai_tool_query=context.ai_tool_query,
4139
- primo_boolean_query=context.primo_boolean_query,
4140
- primo_search_url=_primo_clean_url(context),
4141
- primo_ai_url=_tool_urls(context).get("primo_ai"),
4142
- pubmed_search_url=_tool_urls(context).get("pubmed"),
4143
- follow_up_question=follow_up_question,
4144
- follow_up_suggestions=follow_up_suggestions,
4145
- search_context=_context_to_dict(context),
4146
- is_follow_up=True,
4147
- )
4148
-
4149
- answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
4150
- follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
4151
- return AgentResponse(
4152
- answer=answer,
4153
- intent=context.intent,
4154
- tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
4155
- search_results=search_results,
4156
- sources=sources,
4157
- model_used=model,
4158
- response_time=time.time() - start_time,
4159
- corrected_query=context.topic,
4160
- natural_query=context.ai_tool_query,
4161
- database_query=context.primo_boolean_query,
4162
- ai_tool_query=context.ai_tool_query,
4163
- primo_boolean_query=context.primo_boolean_query,
4164
- primo_search_url=source_url,
4165
- primo_ai_url=_tool_urls(context).get("primo_ai"),
4166
- pubmed_search_url=_tool_urls(context).get("pubmed"),
4167
- follow_up_question=follow_up_question,
4168
- follow_up_suggestions=follow_up_suggestions,
4169
- search_context=_context_to_dict(context),
4170
- is_follow_up=True,
4171
- )
4172
-
4173
- if is_follow_up and base_context:
4174
- _safe_metrics_increment("follow_up_hits")
4175
- context = _apply_follow_up_action(base_context, follow_up_action)
4176
-
4177
- # ── Follow-up: alternative search terms (no new search) ───────────────
4178
  if follow_up_action == "alt_terms":
4179
  alt_answer = await _alternative_terms_answer(question, base_context, model)
4180
  follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model)
@@ -4195,7 +3783,6 @@ async def _agent_query_inner(
4195
  if follow_up_action == "summarize_topic":
4196
  answer, search_results, sources = await _research_snapshot(context, model)
4197
  else:
4198
- # deep_research_tools / best_databases follow-up
4199
  answer = (
4200
  f"{_search_trace_block(question, context)}"
4201
  f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
@@ -4254,10 +3841,7 @@ async def _agent_query_inner(
4254
  )
4255
 
4256
  # ── Pre-classifier specialist handlers ───────────────────────────────────
4257
- # These fire BEFORE the LLM classifier for patterns the classifier handles
4258
- # poorly (too often routes to search_academic for library-service questions).
4259
 
4260
- # Full-text / ILL chain
4261
  if FULLTEXT_REQUEST_RE.search(question):
4262
  ft_answer = _fulltext_chain_answer(question)
4263
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
@@ -4272,7 +3856,6 @@ async def _agent_query_inner(
4272
  follow_up_suggestions=follow_up_suggestions,
4273
  )
4274
 
4275
- # Theses / dissertations / Khazna
4276
  if THESES_RE.search(question):
4277
  th_answer = _theses_answer()
4278
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
@@ -4287,7 +3870,6 @@ async def _agent_query_inner(
4287
  follow_up_suggestions=follow_up_suggestions,
4288
  )
4289
 
4290
- # Predatory / peer-review evaluation
4291
  if PREDATORY_RE.search(question):
4292
  pr_answer = _predatory_eval_answer()
4293
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
@@ -4302,7 +3884,6 @@ async def _agent_query_inner(
4302
  follow_up_suggestions=follow_up_suggestions,
4303
  )
4304
 
4305
- # Citation chaining
4306
  if CITATION_CHAIN_RE.search(question):
4307
  cc_answer = _citation_chain_answer()
4308
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
@@ -4317,7 +3898,6 @@ async def _agent_query_inner(
4317
  follow_up_suggestions=follow_up_suggestions,
4318
  )
4319
 
4320
- # Search strategy / boolean explanation
4321
  if BOOLEAN_STRATEGY_RE.search(question):
4322
  strategy_answer, _plan = await _search_strategy_answer(question, model)
4323
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
@@ -4334,7 +3914,6 @@ async def _agent_query_inner(
4334
  follow_up_suggestions=follow_up_suggestions,
4335
  )
4336
 
4337
- # Alternative search terms (standalone, not follow-up)
4338
  if ALT_TERMS_RE.search(question):
4339
  alt_answer = await _alternative_terms_answer(question, None, model)
4340
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
@@ -4348,7 +3927,6 @@ async def _agent_query_inner(
4348
  follow_up_suggestions=follow_up_suggestions,
4349
  )
4350
 
4351
- # FIX: _llm_classify always falls back to rule-based on any exception
4352
  cls_result = await _llm_classify(question, history, model)
4353
  intent = cls_result.get("intent", "general")
4354
  _safe_metrics_bucket("intents", intent)
@@ -4436,10 +4014,8 @@ async def _agent_query_inner(
4436
  )
4437
 
4438
  if intent in ("search_academic", "search_medical"):
4439
- # ── Partial title search β€” route to PRIMO title query ─────────────────
4440
  if PARTIAL_TITLE_RE.search(question):
4441
  from urllib.parse import quote as _qt
4442
- # Extract quoted fragment or the whole question as title search
4443
  _title_frag = (re.search(r'"([^"]{4,80})"', question) or
4444
  re.search(r'"([^"]{4,80})"', question))
4445
  _title_q = _title_frag.group(1) if _title_frag else _light_strip_retrieval_boilerplate(question)
@@ -4479,10 +4055,8 @@ async def _agent_query_inner(
4479
  topic = _title_case_topic(topic)
4480
  year_from, year_to = _parse_year_filters(question)
4481
 
4482
- # ── Dual resource type: articles AND books in one question ─────────────
4483
  if DUAL_RESOURCE_RE.search(question):
4484
  import asyncio as _aio
4485
- # Build two contexts: articles and books
4486
  _ctx_articles = SearchContextPayload(
4487
  context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
4488
  intent=intent, source="primo", resource_type="articles",
@@ -4500,8 +4074,6 @@ async def _agent_query_inner(
4500
  _prepare_queries(question, _ctx_articles, model, False),
4501
  _prepare_queries(question, _ctx_books, model, False),
4502
  )
4503
- # No API β€” just build PRIMO links for articles and books
4504
- _ctx_articles = await _prepare_queries(question, _ctx_articles, model, False)
4505
  _src_url_a = _primo_clean_url(_ctx_articles)
4506
  _src_url_b = _primo_clean_url(_ctx_books)
4507
 
@@ -4589,7 +4161,6 @@ async def _agent_query_inner(
4589
  )
4590
 
4591
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
4592
- # Append highly-cited guidance if requested
4593
  if is_highly_cited:
4594
  answer += _highly_cited_note(_escape(topic))
4595
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
@@ -4641,7 +4212,30 @@ async def _agent_query_inner(
4641
  )
4642
 
4643
 
4644
- # ── Utility Endpoints ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4645
 
4646
  @router.get("/test")
4647
  async def test():
@@ -4654,7 +4248,6 @@ async def test():
4654
 
4655
  @router.get("/rag-status")
4656
  async def rag_status():
4657
- # FIX: use get_rag_service() accessor
4658
  try:
4659
  from app import get_rag_service
4660
  return get_rag_service().get_stats()
 
1
  """
2
+ agent.py β€” LibBee v3.8.1
3
+
4
+ Changes over v3.6 (two targeted changes only):
5
+ - Β§3 _URL_INSTRUCTION constant added after _GUARDRAIL.
6
+ - Β§7 All 6 LLM system prompts now append _URL_INSTRUCTION alongside _GUARDRAIL.
7
+ All other logic is verbatim from v3.6.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
  # ── Β§1 Imports ────────────────────────────────────────────────────────────────
10
 
 
218
  "Never produce content that could embarrass Khalifa University or its library."
219
  )
220
 
221
+ # ── URL accuracy instruction β€” NEW v3.8.1 β€” appended to ALL LLM system prompts
222
+ # Prevents the LLM from hallucinating or guessing URLs in any response.
223
+ # All 6 LLM system prompts append this constant alongside _GUARDRAIL.
224
+ _URL_INSTRUCTION = (
225
+ "IMPORTANT β€” URLS AND LINKS: "
226
+ "You must ONLY include URLs that are explicitly present in the context or "
227
+ "knowledge base provided to you, OR that appear in this fixed verified list of "
228
+ "KU Library URLs: "
229
+ "https://library.ku.ac.ae | "
230
+ "https://library.ku.ac.ae/hours | "
231
+ "https://library.ku.ac.ae/AskUs | "
232
+ "https://library.ku.ac.ae/ill/ | "
233
+ "https://library.ku.ac.ae/rooms/ | "
234
+ "https://library.ku.ac.ae/eresources | "
235
+ "https://library.ku.ac.ae/orcid | "
236
+ "https://library.ku.ac.ae/oa/ | "
237
+ "https://library.ku.ac.ae/alumni/ | "
238
+ "https://library.ku.ac.ae/med/ | "
239
+ "https://library.ku.ac.ae/requesttitle/ | "
240
+ "https://library.ku.ac.ae/google | "
241
+ "https://khazna.ku.ac.ae | "
242
+ "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU | "
243
+ "https://khalifa.primo.exlibrisgroup.com/discovery/account?vid=971KUOSTAR_INST:KU&section=overview | "
244
+ "https://kustar.libcal.com/calendar/events | "
245
+ "https://ku-library.github.io/researchbee/. "
246
+ "NEVER invent, guess, or construct URLs that are not in the above list or "
247
+ "explicitly present in the provided context. "
248
+ "When a relevant verified URL exists for your answer, ALWAYS include it as a "
249
+ 'clickable HTML link: <a href="URL" target="_blank">descriptive link text</a>. '
250
+ "If you are unsure whether a URL is correct, omit it entirely and direct the "
251
+ "user to Ask a Librarian at https://library.ku.ac.ae/AskUs instead."
252
+ )
253
+
254
  # ── Sensitive institutional topic gate ───────────────────────────────────────
 
 
255
  _SENSITIVE_TOPIC_RE = re.compile(
256
  r"\b("
257
  r"(bad|worst|negative[s]?|problem[s]?|issue[s]?|weakness|weaknesses|"
 
303
  )
304
  DATABASE_RECOMMEND_RE = re.compile(
305
  r"("
 
306
  r"\b(best database|best databases|which database|which databases"
307
  r"|database for|databases for"
308
  r"|where should i search|where should i start)\b"
 
309
  r"|databases?\s+(does|did|do)\s+.{0,40}subscri"
310
  r"|\bsubscribed\s+databases?\b"
311
  r"|\blibrary\s+subscri"
 
318
  r"|\be.?resources?\s+(available|subscri)\b"
319
  r"|\baccess\s+to\s+databases?\b"
320
  r"|\blist\s+of\s+databases?\b"
 
321
  r"|\b(compare|comparison|difference|differences)\s+(between\s+)?(\w+\s+)?(and\s+)?\w*\s*database"
322
  r"|\bdifference\s+between.{0,60}database"
323
  r"|\bscopus\s+(vs|versus|or)\s+(web of science|wos|google scholar)\b"
 
326
  re.IGNORECASE,
327
  )
328
 
 
 
329
  HIGHLY_CITED_RE = re.compile(
330
  r"\b(highly cited|most cited|top cited|citation count|most influential"
331
  r"|most referenced|high impact papers?|most downloaded)\b",
 
345
 
346
  FULLTEXT_REQUEST_RE = re.compile(
347
  r"\b("
 
348
  r"(can.t|cannot|couldn.t|don.t have|doesn.t have|no).{0,30}(access|pdf|full.?text)"
349
  r"|(pdf|full.?text).{0,20}(not available|unavailable|can.t find|not found|missing)"
350
  r"|found.{0,30}citation.{0,20}(but|not).{0,20}(pdf|full.?text)"
 
415
  re.IGNORECASE,
416
  )
417
 
 
 
418
  MEDICAL_KEYWORDS = [
419
  "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
420
  "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
 
507
 
508
 
509
  # ── Β§4 LibCal Hours Service ────────────────────────────────────────────────────
510
+
511
+ _libcal_hours_cache: Dict[str, Any] = {}
512
+ _LIBCAL_HOURS_TTL = 60 * 60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
 
514
 
515
  async def _libcal_fetch_hours() -> Optional[dict]:
 
 
 
 
 
 
516
  now = time.time()
517
  cached_at = _libcal_hours_cache.get("cached_at", 0)
518
  if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
519
  return _libcal_hours_cache["data"]
 
520
  settings = get_settings()
521
  worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
522
  if not worker_url:
523
  logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set β€” cannot fetch hours")
524
  return None
 
525
  from datetime import timezone, timedelta
526
  uae_now = datetime.now(timezone(timedelta(hours=4)))
527
  date_from = uae_now.strftime("%Y-%m-%d")
528
  date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
 
529
  try:
530
  async with httpx.AsyncClient(timeout=10) as client:
531
  r = await client.get(
 
547
 
548
 
549
  def _parse_libcal_time(raw: str) -> str:
 
550
  if not raw:
551
  return raw
552
  raw = raw.strip().replace("\u2013", "-").replace("–", "-")
553
  raw = re.sub(r"\s+", "", raw)
 
554
  raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
555
  return raw
556
 
557
 
558
  def _format_libcal_day(day_data: dict) -> str:
 
 
 
 
 
 
 
 
 
559
  if not day_data or not isinstance(day_data, dict):
560
  return "See hours page"
 
561
  status = str(day_data.get("status") or "").lower().strip()
562
  note = str(day_data.get("note") or "").strip()
 
563
  if status in ("closed", "0", "false"):
564
  return f"Closed{f' β€” {note}' if note else ''}"
565
  if status == "24hours":
566
  return "Open 24 hours"
 
 
567
  hours_list = day_data.get("hours") or []
568
  if hours_list and isinstance(hours_list, list):
569
  first = hours_list[0]
 
571
  t = first.get("to") or first.get("closes") or ""
572
  if f and t:
573
  return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
 
 
574
  opens = day_data.get("opens") or day_data.get("open") or ""
575
  closes = day_data.get("closes") or day_data.get("close") or ""
576
  if opens and closes:
577
  return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
 
 
578
  times = day_data.get("times") or []
579
  if times and isinstance(times, list):
580
  first = times[0]
 
582
  t = first.get("to") or first.get("closes") or ""
583
  if f and t:
584
  return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
 
585
  if status in ("open",):
586
  return "Open β€” see hours page for times"
 
587
  return "See hours page"
588
 
589
 
590
  async def _hours_answer() -> str:
 
 
 
 
 
591
  from datetime import timezone, timedelta
592
  uae_now = datetime.now(timezone(timedelta(hours=4)))
593
  today_str = uae_now.strftime("%Y-%m-%d")
594
  day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
595
  today_name = day_names[uae_now.weekday()]
 
596
  hours_data = await _libcal_fetch_hours()
 
 
597
  if not hours_data:
598
  return (
599
  "Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
 
604
  'Main Campus Library Β· E Building Β· <a href="tel:+97123124604">+971 2 312 4604</a><br>'
605
  'Habshan Library Β· SAN Campus Β· <a href="tel:+97123123160">+971 2 312 3160</a>'
606
  )
 
 
607
  location_labels = {
608
  "main_campus": "Main Campus Library",
609
  "habshan": "Habshan Library (SAN Campus)",
610
  }
 
611
  def _today_line(loc_name: str, loc_data: dict) -> str:
612
  dates = loc_data.get("dates", {})
613
  today_entry = dates.get(today_str, {})
614
  if not today_entry:
 
615
  return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
616
  hours_str = _format_libcal_day(today_entry)
617
  is_closed = hours_str.lower().startswith("closed")
 
620
  f"πŸ“… <strong>Today ({today_name}):</strong> "
621
  f"<span style='color:{colour};font-weight:700'>{hours_str}</span>"
622
  )
 
623
  def _week_table(loc_data: dict) -> str:
624
  dates = loc_data.get("dates", {})
625
  if not dates:
 
651
  "<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
652
  + rows + "</table>"
653
  )
 
654
  answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
 
655
  for key, label in location_labels.items():
656
  loc = hours_data.get(key)
657
  if not loc:
 
663
  answer += week_tbl + "<br><br>"
664
  else:
665
  answer += "<br>"
 
666
  answer += (
667
  "⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
668
  f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
 
679
 
680
  EVENTS_RE = re.compile(
681
  r"\b("
 
682
  r"library events?|library workshops?|library seminars?|library training"
683
  r"|library sessions?|library talks?|library webinars?|library programmes?"
684
  r"|library classes?|library courses?|library instruction"
 
685
  r"|upcoming (events?|workshops?|seminars?|training|sessions?|talks?|webinars?)"
686
  r"|any (events?|workshops?|seminars?|training|sessions?|talks?)"
687
  r"|what (events?|workshops?|seminars?|training|sessions?|talks?)"
 
689
  r"|workshops? (at|in|by|from) (the )?library"
690
  r"|seminars? (at|in|by|from) (the )?library"
691
  r"|training (at|in|by|from) (the )?library"
 
692
  r"|what'?s? on (at |in )?(the )?library"
693
  r"|library calendar|library programme|library schedule"
694
  r"|library events? calendar|events? calendar"
 
695
  r"|is there (a |an )?(workshop|seminar|training|session|event|talk|webinar)"
696
  r"|are there (any )?(workshops?|seminars?|training|sessions?|events?|talks?)"
 
697
  r"|database training|research skills (workshop|session|training|class)"
698
  r"|citation (workshop|training|session)|refworks (workshop|training|session)"
699
  r"|endnote (workshop|training)|zotero (workshop|training)"
 
703
  )
704
 
705
  _libcal_events_cache: Dict[str, Any] = {}
706
+ _LIBCAL_EVENTS_TTL = 30 * 60
707
 
708
 
709
  async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
 
 
 
 
710
  now = time.time()
711
  cache_key = f"{days}_{limit}"
712
  cached = _libcal_events_cache.get(cache_key, {})
713
  if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
714
  return cached["data"]
 
715
  settings = get_settings()
716
  worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
717
  if not worker_url:
718
  logger.warning("LibCal events: CLOUDFLARE_WORKER_URL not set")
719
  return None
 
720
  try:
721
  async with httpx.AsyncClient(timeout=10) as client:
722
  r = await client.get(
 
729
  logger.warning("LibCal events worker error: %s", data["error"])
730
  return None
731
  events_raw = data.get("events", [])
 
732
  def _safe_str(val) -> str:
 
733
  if val is None:
734
  return ""
735
  if isinstance(val, str):
 
739
  if isinstance(val, list):
740
  return ", ".join(_safe_str(v) for v in val if v)
741
  return str(val)
 
742
  events = []
743
  for e in events_raw:
 
744
  cats = e.get("category") or e.get("categories") or []
745
  if isinstance(cats, list):
746
  cat_str = ", ".join(_safe_str(c) for c in cats)
747
  else:
748
  cat_str = _safe_str(cats)
 
749
  events.append({
750
  "id": e.get("id", ""),
751
  "title": _safe_str(e.get("title") or "Untitled Event"),
 
768
 
769
 
770
  def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
 
771
  if not start:
772
  return ""
773
  try:
774
  from datetime import datetime as _dt
 
775
  s = _dt.fromisoformat(start.replace("Z", "+00:00"))
776
  date_str = s.strftime("%a %d %b")
777
  if all_day:
 
783
  return f"{date_str} Β· {time_str} – {end_str}"
784
  return f"{date_str} Β· {time_str}"
785
  except Exception:
 
786
  return start[:16].replace("T", " ")
787
 
788
 
789
  async def _events_answer(days: int = 30) -> str:
 
 
 
 
790
  EVENTS_URL = "https://kustar.libcal.com/calendar/events"
791
  events = await _libcal_fetch_events(days=days, limit=15)
 
792
  if not events:
793
  return (
794
  "<strong>πŸ“… Khalifa University Library Events</strong><br><br>"
 
796
  f'<a href="{EVENTS_URL}" target="_blank" '
797
  f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
798
  )
 
799
  if not events:
800
  return (
801
  "<strong>πŸ“… Khalifa University Library Events</strong><br><br>"
 
803
  f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
804
  f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
805
  )
 
806
  answer = f"<strong>πŸ“… Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
 
807
  for ev in events[:10]:
808
  title = _escape(str(ev.get("title") or "Untitled Event"))
809
  when = _format_event_datetime(
 
813
  desc = _escape(str(ev.get("description") or ""))
814
  url = str(ev.get("url") or EVENTS_URL)
815
  reg = bool(ev.get("registration"))
 
816
  answer += (
817
  f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
818
  f'background:#f8fafc;border-radius:0 8px 8px 0">'
 
834
  f'Register β†’</a></div>'
835
  )
836
  answer += '</div>'
 
837
  answer += (
838
  f'<br><a href="{EVENTS_URL}" target="_blank" '
839
  f'style="font-weight:700;color:#003366">View full library events calendar β†’</a>'
 
842
 
843
 
844
 
845
+ # ── Β§5 Runtime Config & Utility Helpers ──────────────────────────────────────
 
846
 
847
  def _get_runtime_config() -> dict:
 
 
 
 
 
848
  try:
849
  from src.services.runtime_store import JsonRuntimeStore
850
  settings = get_settings()
 
906
  )
907
 
908
 
 
909
  def _safe_metrics_increment(key: str) -> None:
910
  try:
911
  from app import get_metrics_service
 
995
  return {"intent": "general"}
996
 
997
 
 
 
 
998
  def _campus_answer() -> str:
999
  return (
1000
  "Khalifa University Library has <strong>two branches</strong>:<br><br>"
 
1023
  )
1024
 
1025
 
 
1026
 
1027
+ # ── Β§7 LLM Wrappers ───────────────────────────────────────────────────────────
1028
+ # v3.8.1 change: ALL 6 LLM system prompts append both _GUARDRAIL and
1029
+ # _URL_INSTRUCTION to prevent hallucinated URLs in any response.
1030
 
1031
  def _get_llm(model: str, temperature: float, max_tokens: int):
1032
  settings = get_settings()
 
1033
  if model == "claude" and settings.anthropic_api_key:
1034
  from langchain_anthropic import ChatAnthropic
1035
  return ChatAnthropic(
 
1058
  if m.role in ("user", "assistant")
1059
  ]
1060
  ctx = " | ".join(ctx_parts) if ctx_parts else "none"
 
 
 
1061
  classify_model = "gpt" if settings.openai_api_key else model
1062
  llm = _get_llm(classify_model, temperature=0, max_tokens=120)
1063
  response = await llm.ainvoke([
 
1078
 
1079
 
1080
  async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
1081
+ """LLM #1 β€” casual social response. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
1082
  if hint and len(hint.strip()) > 20:
1083
  return hint.strip()
1084
  settings = get_settings()
 
1089
  msgs = [{"role": "system", "content": (
1090
  "You are LibBee, the Khalifa University Library AI Assistant. "
1091
  "Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
1092
+ "No markdown, no bullet points. "
1093
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
1094
  )}]
1095
  _cfg = _get_runtime_config()
1096
  _ci = _cfg.get("custom_instructions", "").strip()
 
1105
 
1106
 
1107
  async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
1108
+ """LLM #2 β€” RAG answer from KB chunks. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
1109
  settings = get_settings()
1110
  if not settings.openai_api_key and not settings.anthropic_api_key:
1111
  return rag_results[0]["content"] if rag_results else ""
 
1123
  f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> or '
1124
  f'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.\n\n'
1125
  f"CONTEXT:\n{context}\n\n"
1126
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
1127
  )
1128
  _cfg = _get_runtime_config()
1129
  _ci = _cfg.get("custom_instructions", "").strip()
 
1145
 
1146
 
1147
  async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
1148
+ """LLM #3 β€” general factual answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
1149
  settings = get_settings()
1150
  if not settings.openai_api_key and not settings.anthropic_api_key:
1151
  return (
 
1166
  "Khazna repository: https://khazna.ku.ac.ae | "
1167
  "PRIMO discovery: https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU. "
1168
  "Only use these exact URLs β€” never invent others. "
1169
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
1170
  )
1171
  _cfg = _get_runtime_config()
1172
  _ci = _cfg.get("custom_instructions", "").strip()
 
1185
 
1186
 
1187
  async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
1188
+ """LLM #4 β€” web search answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
1189
  settings = get_settings()
1190
  _WEB_SYSTEM = (
1191
  "You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
1192
  "Answer using current web search. Be concise and factual. Use HTML <br> for line breaks. "
1193
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
1194
  )
1195
  if model == "claude" and settings.anthropic_api_key:
1196
  try:
 
1208
  return text.strip()
1209
  except Exception as e:
1210
  logger.warning(f"Claude web search failed: {e}")
 
1211
  if settings.openai_api_key:
1212
  try:
1213
  from openai import OpenAI
 
1228
  return text.strip()
1229
  except Exception as e:
1230
  logger.warning(f"GPT web search failed: {e}")
 
1231
  return await _llm_general_answer(question, history, model)
1232
 
1233
 
 
1234
 
1235
+ # ── Β§8 Topic Extraction ───────────────────────────────────────────────────────
1236
 
1237
  def _extract_topic_regex(question: str) -> str:
1238
  q = _normalize_whitespace(question)
 
1304
  return _extract_topic_regex(question)
1305
 
1306
 
1307
+ # ── Β§9 Query Builders ──���──────────────────────────────────────────────────────
 
 
 
 
 
 
1308
 
1309
  def _shared_build_primo_boolean_query(topic: str) -> str:
 
 
 
 
 
1310
  clean = _strip_resource_noise(topic)
1311
  if not clean:
1312
  clean = topic
1313
  clean = _normalize_whitespace(clean)
 
 
1314
  words = clean.split()
1315
  if len(words) <= 4:
1316
  return f'"{clean}"'
 
 
1317
  _BOOL_STOP = re.compile(
1318
  r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
1319
  r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
 
1322
  r"based|related|towards|toward|during|after|before|over|under)\b",
1323
  re.IGNORECASE,
1324
  )
 
1325
  parts = _BOOL_STOP.split(clean)
1326
  concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
1327
  if not concepts:
1328
  return f'"{clean}"'
1329
  if len(concepts) == 1:
1330
  return f'"{concepts[0]}"'
 
1331
  quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
1332
  return " AND ".join(quoted)
1333
 
 
1369
 
1370
 
1371
  async def _llm_build_boolean_query(topic: str, model: str) -> str:
 
 
 
 
 
 
 
1372
  settings = get_settings()
1373
  if not settings.openai_api_key and not settings.anthropic_api_key:
1374
  return _shared_build_primo_boolean_query(topic)
 
1379
  {"role": "user", "content": f"Input: {topic.strip()}"},
1380
  ])
1381
  result = response.content.strip()
 
1382
  if not result or len(result) > 500:
1383
  raise ValueError("Bad LLM boolean output")
 
1384
  result = re.sub(r"^```[a-z]*\n?", "", result).rstrip("`").strip()
 
1385
  if '"' not in result and ' AND ' not in result and ' OR ' not in result:
 
1386
  result = f'"{result}"'
1387
  logger.info(f"LLM boolean: {result!r} ← topic: {topic!r}")
1388
  return result
 
1392
 
1393
 
1394
  def _make_primo_boolean_query(context: SearchContextPayload) -> str:
 
1395
  topic = _title_case_topic(context.display_topic or context.topic)
1396
  topic = _strip_resource_noise(topic) or (context.topic or "library search")
1397
  return _shared_build_primo_boolean_query(topic)
 
1405
  year_from: Optional[str] = None,
1406
  year_to: Optional[str] = None,
1407
  ) -> str:
 
1408
  base = (
1409
  "https://khalifa.primo.exlibrisgroup.com/discovery/search"
1410
  f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
 
1435
  year_to: Optional[str] = None,
1436
  peer_reviewed: bool = False,
1437
  ) -> str:
 
1438
  clean = _strip_resource_noise(topic)
1439
  term = clean or topic
1440
  if peer_reviewed:
 
1447
  return url
1448
 
1449
 
 
1450
 
1451
+ # ── Β§10 Search Preparation & Planning ────────────────────────────────────────
1452
 
1453
  def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
1454
  q = (question or "").lower()
 
1476
  return None, None
1477
 
1478
 
 
 
 
 
 
 
1479
  def _primo_clean_url(context: SearchContextPayload) -> str:
1480
  boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
1481
  return _shared_build_primo_discovery_url(
 
1501
  filters.append(f"published between {context.year_from} and {context.year_to}")
1502
  elif context.year_from:
1503
  filters.append(f"published from {context.year_from} onward")
 
1504
  if is_follow_up:
1505
  if scope == "books":
1506
  base = f"Find books on {topic}"
 
1512
  base = _normalize_whitespace(question).strip(".?")
1513
  if len(base.split()) < 4:
1514
  base = f"Find research on {topic}"
 
1515
  if filters:
1516
  return f"{base} with {', '.join(filters)}."
1517
  return base.rstrip(".") + "."
 
1534
 
1535
 
1536
  def _light_strip_retrieval_boilerplate(text: str) -> str:
 
1537
  cleaned = re.sub(r"^\s*(please\s+)?(?:can you|could you|would you)\s+", "", (text or "").strip(), flags=re.IGNORECASE)
1538
  cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
1539
  cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
1540
  cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
 
1541
  cleaned = re.sub(
1542
  r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
1543
  r"research on|articles on|papers on|literature on|studies on|"
 
1546
  r"i want articles on|i need papers on|i want papers on)\s+",
1547
  "", cleaned, flags=re.IGNORECASE
1548
  )
 
 
1549
  cleaned = re.sub(
1550
  r"^\s*(research|articles?|papers?|books?|literature|studies|study|"
1551
  r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
1552
  "", cleaned, flags=re.IGNORECASE
1553
  )
 
1554
  cleaned = re.sub(
1555
  r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
1556
  "", cleaned, flags=re.IGNORECASE
 
1559
 
1560
 
1561
  _LEGACY_SYSTEM_PATTERNS = [
 
1562
  (
1563
  re.compile(
1564
  r'(you can use )?summon[β„’β„’]?\s*(to find [^.]+\.)?\s*'
 
1568
  ),
1569
  "PRIMO Library Discovery (https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU)",
1570
  ),
 
1571
  (re.compile(r'\bSummon[β„’β„’]?\b', re.IGNORECASE), "PRIMO Library Discovery"),
 
1572
  (re.compile(r'\bEBSCO Discovery Service\b', re.IGNORECASE), "PRIMO Library Discovery"),
 
1573
  (re.compile(r'\bEncore\b(?!\s+(?:learning|education))', re.IGNORECASE), "PRIMO Library Discovery"),
1574
  ]
1575
 
1576
 
1577
  def _sanitize_llm_response(text: str) -> str:
 
 
 
 
 
1578
  if not text:
1579
  return text
1580
  for pattern, replacement in _LEGACY_SYSTEM_PATTERNS:
 
1583
 
1584
 
1585
  def _sanitize_boolean_for_primo(boolean: str) -> str:
 
1586
  if not boolean:
1587
  return boolean
 
1588
  boolean = re.sub(r"'([^']+)'", r'"\1"', boolean)
 
1589
  boolean = re.sub(
1590
  r"\(\s*(?:(?:19|20)\d{2}\s*(?:OR\s*(?:19|20)\d{2}\s*)*)\)",
1591
  "", boolean, flags=re.IGNORECASE,
1592
  )
 
1593
  boolean = re.sub(r"^\s*(AND|OR)\s*", "", boolean, flags=re.IGNORECASE)
1594
  boolean = re.sub(r"\s*(AND|OR)\s*$", "", boolean, flags=re.IGNORECASE)
 
1595
  boolean = re.sub(r"\b(AND|OR)\s+(AND|OR)\b", r"\1", boolean, flags=re.IGNORECASE)
1596
  return re.sub(r"\s+", " ", boolean).strip()
1597
 
1598
 
1599
  def _clean_database_keywords(boolean_query: str) -> str:
 
1600
  return re.sub(r"\s+", " ",
1601
  re.sub(r"\b(AND|OR|NOT)\b|[()\"]", " ",
1602
  boolean_query, flags=re.IGNORECASE)).strip()
 
1619
 
1620
  3. boolean: write a PRIMO/PubMed Boolean query with AND/OR/parentheses.
1621
 
1622
+ Also detect: year_from, year_to (e.g. "last 24 months" β†’ year_from={{{CURRENT_YEAR}-2}}),
1623
  peer_reviewed, open_access.
1624
 
1625
  STEP 1 β€” TOPIC EXTRACTION:
 
1684
 
1685
 
1686
  async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
 
 
 
 
 
 
1687
  settings = get_settings()
1688
  if not settings.openai_api_key and not settings.anthropic_api_key:
1689
  return True, topic
 
1721
 
1722
 
1723
  async def _build_search_plan(question: str, model: str) -> dict:
 
 
 
 
 
 
 
1724
  settings = get_settings()
1725
  raw_query = _normalize_whitespace(question or "")
1726
  light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
 
 
1727
  _stripped = light_query.strip()
1728
  _is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
1729
  _is_too_short = len(_stripped) < 2
1730
  _is_single_char = bool(re.fullmatch(r"[a-zA-Z0-9]", _stripped))
1731
  _content_words = [w for w in _stripped.split() if len(w) > 1 and not re.fullmatch(r"[\d\.\,\-]+", w)]
1732
  _no_content = len(_content_words) == 0 and len(_stripped) > 0
 
1733
  if _is_pure_number or _is_too_short or _is_single_char or _no_content:
1734
  _display = _stripped or raw_query
1735
  return {
 
1745
  f"the better I can build your search."
1746
  ),
1747
  }
 
1748
  if settings.openai_api_key or settings.anthropic_api_key:
1749
  _valid, _corrected_topic = await _validate_topic(_stripped, model)
1750
  if not _valid:
 
1763
  if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
1764
  logger.info(f"_validate_topic corrected: {_stripped!r} β†’ {_corrected_topic!r}")
1765
  light_query = _corrected_topic
 
 
1766
  if settings.openai_api_key or settings.anthropic_api_key:
1767
  try:
1768
  llm = _get_llm(model, temperature=0, max_tokens=300)
 
1773
  raw = response.content.strip()
1774
  if raw.startswith("```"):
1775
  raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
 
1776
  s, e = raw.find("{"), raw.rfind("}")
1777
  if s != -1 and e > s:
1778
  result = json.loads(raw[s:e + 1])
1779
  else:
1780
  raise ValueError("No JSON found in response")
 
1781
  corrected = (result.get("corrected") or raw_query).strip() or raw_query
1782
  natural = (result.get("natural") or corrected).strip() or corrected
1783
  boolean = (result.get("boolean") or "").strip()
 
 
1784
  has_ops = bool(re.search(r"\b(AND|OR)\b", boolean)) if boolean else False
1785
  has_parens = "(" in boolean if boolean else False
1786
  if not has_ops or not has_parens:
1787
  boolean = _shared_build_primo_boolean_query(corrected)
 
1788
  boolean = _sanitize_boolean_for_primo(boolean)
 
1789
  year_from = str(result.get("year_from") or "").strip()
1790
  year_to = str(result.get("year_to") or "").strip()
 
 
1791
  if not year_from and not year_to:
1792
  yf, yt = _parse_year_filters(raw_query)
1793
  year_from = yf or ""
1794
  year_to = yt or ""
 
1795
  return {
1796
  "corrected": corrected,
1797
  "natural": natural,
 
1804
  }
1805
  except Exception as e:
1806
  logger.warning(f"_build_search_plan LLM failed: {e} β€” using regex fallback")
 
 
1807
  boolean = _shared_build_primo_boolean_query(light_query)
1808
  yf, yt = _parse_year_filters(raw_query)
1809
  pr = bool(re.search(r"\bpeer[- ]reviewed\b", raw_query, re.IGNORECASE))
 
1821
 
1822
 
1823
  async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
 
1824
  topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
1825
  q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
 
1826
  plan = await _build_search_plan(q, model)
 
 
1827
  if plan.get("clarification_needed"):
1828
  context.clarification_needed = True
1829
  context.clarification_message = plan.get("clarification_message", "")
1830
  return context
 
1831
  context.ai_tool_query = plan["natural"]
1832
  context.primo_boolean_query = plan["boolean"]
 
1833
  if not context.year_from and plan.get("year_from"):
1834
  context.year_from = plan["year_from"]
1835
  if not context.year_to and plan.get("year_to"):
 
1838
  context.peer_reviewed = True
1839
  if not context.open_access and plan.get("open_access"):
1840
  context.open_access = True
 
1841
  return context
1842
 
1843
 
 
1844
 
1845
+ # ── Β§11 Search Runners ────────────────────────────────────────────────────────
1846
 
1847
  async def _topic_intro(topic: str, model: str) -> str:
1848
+ """LLM #5 β€” 3-sentence topic intro. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
 
 
 
1849
  settings = get_settings()
1850
  if not settings.openai_api_key and not settings.anthropic_api_key:
1851
  return ""
 
1858
  "for a university student or researcher. "
1859
  "Cover: what the topic is, why it matters, and one key area of current research interest. "
1860
  "Use HTML <br> for line breaks only if needed. No markdown, no bullet points, no headings. "
1861
+ "Be concise and informative. "
1862
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
1863
  )},
1864
  {"role": "user", "content": f"Research topic: {topic}"},
1865
  ])
 
1870
  return ""
1871
 
1872
 
 
 
1873
  async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
1874
  clean_topic = _strip_resource_noise(topic)
1875
  if not clean_topic:
 
1923
 
1924
 
1925
  async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
 
 
 
 
1926
  try:
1927
  from urllib.parse import quote as _oa_quote
 
1928
  clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
1929
  clean = re.sub(r'\s+', ' ', clean).strip()[:200]
1930
  encoded = _oa_quote(clean, safe='')
 
1944
  logger.warning(f"OpenAlex search failed: {r.status_code}")
1945
  return []
1946
  works = r.json().get("results", [])
 
1947
  results = []
1948
  for work in works:
1949
  title = work.get("title") or "Untitled"
 
 
1950
  inv = work.get("abstract_inverted_index") or {}
1951
  abstract = ""
1952
  if inv:
 
1956
  word_positions.append((pos, word))
1957
  word_positions.sort(key=lambda x: x[0])
1958
  abstract = " ".join(w for _, w in word_positions)[:700]
 
1959
  if not abstract:
1960
  continue
 
1961
  authorships = work.get("authorships") or []
1962
  author_names = [
1963
  a.get("author", {}).get("display_name", "")
 
1968
  if len(author_names) > 3:
1969
  authors_str += " et al."
1970
  first_last = author_names[0].split()[-1] if author_names else "Unknown"
 
1971
  year = str(work.get("publication_year") or "n.d.")
1972
  doi = (work.get("doi") or "").replace("https://doi.org/", "")
1973
  oa_url = (work.get("open_access") or {}).get("oa_url") or ""
 
1980
  ((work.get("primary_location") or {}).get("source") or {})
1981
  .get("display_name") or ""
1982
  )
 
1983
  results.append({
1984
  "title": title,
1985
  "authors": authors_str or "Unknown",
 
1991
  "link": link,
1992
  "_source": "OpenAlex",
1993
  })
 
1994
  logger.info(f"OpenAlex: {len(results)} papers for '{clean[:50]}'")
1995
  return results
 
1996
  except Exception as e:
1997
  logger.warning(f"OpenAlex search failed: {e}")
1998
  return []
1999
 
2000
 
2001
  async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
2002
+ """LLM #6 β€” cited literature summary. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
2003
  settings = get_settings()
2004
  if not settings.openai_api_key and not settings.anthropic_api_key:
2005
  return ""
 
2018
  "Cite at least 4 different references. "
2019
  "Use HTML <br><br> for paragraph breaks. Do not use bullet points or markdown. "
2020
  "Only state what is explicitly supported by the abstracts. "
2021
+ "Do NOT invent content. "
2022
+ + _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
2023
  )
2024
  try:
2025
  llm = _get_llm(model, temperature=0.2, max_tokens=560)
 
2065
  return intro + "<br><br>".join(statements[:4])
2066
 
2067
 
 
2068
 
2069
+ # ── Β§12 Response Builders ─────────────────────────────────────────────────────
2070
 
2071
  async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
2072
  from src.api.search import SearchRequest, search_primo
2073
  settings = get_settings()
 
2074
  boolean = context.primo_boolean_query
2075
  if not boolean:
2076
  clean_topic = _strip_resource_noise(context.topic)
 
2114
  return result, pubmed_url
2115
 
2116
 
 
 
2117
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
2118
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
2119
  primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
 
2175
  def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
2176
  q = (question or "").lower()
2177
  specialist: Optional[dict] = None
 
 
2178
  if any(t in q for t in ["interlibrary", "ill request", "ill form", "borrow from another",
2179
  "request article", "request a book", "not available at ku",
2180
  "not in ku library", "inter library", "inter-library"]):
 
2181
  footer = (
2182
  "<br><br><strong>Library contact points</strong><br>"
2183
  f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> Β· '
 
2197
  specialist = _find_staff_by_token("jason")
2198
  elif context and context.intent == "search_academic":
2199
  specialist = _find_staff_by_token("nikesh")
 
2200
  footer = (
2201
  "<br><br><strong>Library contact points</strong><br>"
2202
  f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> Β· '
 
2215
 
2216
 
2217
  def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> str:
 
 
 
2218
  labels = {
2219
  "library_info": ("πŸ“‹", "Library information lookup", "I searched the KU Library knowledge base for relevant content."),
2220
  "general": ("🌐", "General knowledge / web search", "I answered using a web search for current information."),
 
2231
  icon, label, reasoning = labels.get(intent, ("πŸ€–", "AI response", "I processed your question and generated a response."))
2232
  if extra:
2233
  reasoning += " " + extra
 
2234
  return (
2235
  f'<details style="margin:0 0 12px 0;border:1px solid #e5e7eb;border-radius:10px;'
2236
  f'background:#fafafa;overflow:hidden">'
 
2246
 
2247
 
2248
  def _search_trace_block(question: str, context: SearchContextPayload) -> str:
 
 
 
 
2249
  topic = _escape(context.display_topic or context.topic)
2250
  primo_boolean = _escape(context.primo_boolean_query or "")
2251
  source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
 
2252
  intent_label = {
2253
  "search_academic": "academic literature search",
2254
  "search_medical": "medical / clinical literature search",
2255
  }.get(context.intent, "library search")
 
2256
  filters_parts = []
2257
  if context.peer_reviewed:
2258
  filters_parts.append("peer reviewed only")
 
2267
  )
2268
  filters_parts.append(rtype)
2269
  filters_str = ", ".join(filters_parts)
 
2270
  reasoning = (
2271
  f"I classified this as a <strong>{intent_label}</strong>, "
2272
  f"extracted the topic <strong>{topic}</strong>, "
 
2275
  )
2276
  if context.intent == "search_medical" and context.source != "pubmed":
2277
  reasoning += " For medical topics, PubMed and Embase are also recommended."
 
2278
  block = (
2279
  f'<details style="margin:0 0 14px 0;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff;overflow:hidden">'
2280
  f'<summary style="padding:10px 14px;cursor:pointer;font-size:.8rem;font-weight:700;color:#1e40af;list-style:none;display:flex;align-items:center;gap:6px">'
 
2296
  return block
2297
 
2298
 
2299
+
2300
  def _ai_tools_footer(context):
2301
  from urllib.parse import quote as _q
2302
 
 
2325
  if is_med and pubmed_url:
2326
  platforms.append(("PubMed", pubmed_url, "pubmed"))
2327
 
 
2328
  _SVGS = {
2329
  "bee": (
2330
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="16" height="16" style="flex-shrink:0">'
 
2426
  '<circle cx="12" cy="4" r="1.2" fill="#fef3c7"/>'
2427
  '</svg>'
2428
  ),
 
2429
  "primo": (
2430
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
2431
  '<rect x="1" y="1" width="14" height="14" rx="2" fill="#003366"/>'
 
2486
  '</svg>'
2487
  ),
2488
  }
 
2489
  _DB_MASCOT = {
2490
  "PubMed@KU": "microscope", "Embase": "medical", "CINAHL Ultimate": "medical",
2491
  "Cochrane Library": "microscope", "UpToDate": "medical",
 
2496
  "Business Source Complete": "chart", "Emerald Insight": "chart",
2497
  "Taylor & Francis": "book", "EBSCO": "book",
2498
  }
2499
+ _BEE = _SVGS["bee"]
 
2500
 
2501
  def _btn(label, url, mascot_key="bee"):
2502
  icon = _SVGS.get(mascot_key, _SVGS["bee"])
 
2657
 
2658
 
2659
  def _safe_db_url(url: str) -> str:
 
 
 
 
 
2660
  from urllib.parse import quote as _quote
2661
  if "?url=" in url:
2662
  base, inner = url.split("?url=", 1)
 
2664
  return url
2665
 
2666
 
2667
+
2668
+ # ── Subject β†’ database mapping ────────────────────────────────────────────────
2669
+
2670
  _DB_SUBJECTS: Dict[str, List[Tuple[str, str, str]]] = {
2671
  "physics": [
2672
  ("APS Journals", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://journals.aps.org"), "American Physical Society β€” core physics journals."),
 
2842
  ],
2843
  }
2844
 
 
2845
  _DB_GENERAL: List[Tuple[str, str, str]] = [
2846
  ("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."),
2847
  ("Scopus", _safe_db_url("https://khalifa.idm.oclc.org/login?url=https://www.scopus.com"), "Largest abstract and citation database β€” all disciplines."),
 
2855
 
2856
 
2857
  def _match_db_subject(q: str) -> Optional[str]:
 
2858
  checks = [
2859
  (["quantum computing", "quantum algorithm", "quantum cryptography", "quantum information"], "quantum_computing"),
2860
  (["robotics", "autonomous system", "intelligent system"], "robotics"),
 
2889
 
2890
 
2891
  def _extract_db_topic(question: str) -> str:
 
 
 
 
 
2892
  q = _normalize_whitespace(question or "")
 
2893
  patterns = [
2894
  r"^(best|good|recommended?)\s+databases?\s+for\s+",
2895
  r"^which\s+databases?\s+(for|should i use for|is best for|are best for)\s+",
 
2903
  if m:
2904
  q = q[m.end():]
2905
  break
 
2906
  q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
2907
  q = _strip_resource_noise(q)
2908
  return _normalize_whitespace(q).strip(".?") or "this subject"
 
2912
  q = (question or "").lower()
2913
  subject = _extract_db_topic(question)
2914
  topic = _escape(subject)
 
 
2915
  subject_key = _match_db_subject(q)
2916
  dbs = _DB_SUBJECTS.get(subject_key, _DB_GENERAL) if subject_key else _DB_GENERAL
 
 
2917
  dbs = list(dbs)[:5]
 
2918
  answer = f"<strong>Recommended databases for {topic}</strong><br><br>"
2919
  answer += "Here are the strongest KU-subscribed starting points:<br>"
2920
  for name, url, why in dbs:
2921
  answer += f'<br>β€’ <a href="{url}" target="_blank"><strong>{_escape(name)}</strong></a> β€” {_escape(why)}'
 
2922
  if not subject_key:
 
2923
  answer += (
2924
  "<br><br>πŸ’‘ For subject-specific databases, ask me: "
2925
  "<em>\"best databases for [your subject]\"</em> β€” e.g. physics, chemistry, medicine, civil engineering."
2926
  )
 
2927
  answer += (
2928
  f'<br><br>Browse all 50+ KU databases: '
2929
  f'<a href="https://library.ku.ac.ae/eresources" target="_blank">library.ku.ac.ae/eresources</a>'
 
2932
  return answer
2933
 
2934
 
 
2935
 
2936
+ # ── Β§13 Hardcoded Answers ─────────────────────────────────────────────────────
2937
 
2938
  def _fulltext_chain_answer(question: str) -> str:
 
2939
  return (
2940
  "<strong>πŸ“„ How to get full text for an article</strong><br><br>"
2941
  "Follow this chain in order β€” each step is faster than the next:<br><br>"
 
2961
  )
2962
 
2963
 
 
 
2964
  async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]:
 
 
 
 
2965
  plan = await _build_search_plan(question, model)
2966
  topic = _escape(_light_strip_retrieval_boilerplate(question) or question)
2967
  boolean = _escape(plan.get("boolean", ""))
2968
  natural = _escape(plan.get("natural", ""))
 
2969
  explanation = (
2970
  f"<strong>πŸ” Search strategy for: {topic}</strong><br><br>"
2971
  f"<strong>1. Identify your key concepts</strong><br>"
 
2991
  return explanation, plan
2992
 
2993
 
 
 
2994
  async def _alternative_terms_answer(question: str, context: Optional[SearchContextPayload], model: str) -> str:
 
2995
  topic = (context.display_topic or context.topic) if context else question
2996
  settings = get_settings()
2997
  if not settings.openai_api_key and not settings.anthropic_api_key:
 
3026
  return f"<strong>Alternative terms for {_escape(topic)}</strong><br><br>Try synonyms, acronyms, broader/narrower terms, and related concepts in your search."
3027
 
3028
 
 
 
3029
  def _citation_chain_answer() -> str:
3030
  return (
3031
  "<strong>πŸ”— How to trace citations forward and backward from a paper</strong><br><br>"
 
3050
  )
3051
 
3052
 
 
 
3053
  def _predatory_eval_answer() -> str:
3054
  return (
3055
  "<strong>βœ… How to tell if an article or journal is peer-reviewed, scholarly, or predatory</strong><br><br>"
 
3078
  )
3079
 
3080
 
 
 
3081
  def _theses_answer() -> str:
3082
  return (
3083
  "<strong>πŸ“˜ Finding KU theses, dissertations, and institutional research</strong><br><br>"
 
3100
  )
3101
 
3102
 
 
 
3103
  def _highly_cited_note(topic_escaped: str) -> str:
 
3104
  return (
3105
  f"<br><br><strong>πŸ“Š Finding highly cited papers on {topic_escaped}</strong><br>"
3106
  "PRIMO doesn't sort by citation count, but these tools do:<br>"
 
3115
  )
3116
 
3117
 
 
 
3118
  async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
 
 
 
 
3119
  topic = context.topic
3120
  primo_url = _primo_clean_url(context)
3121
  answer = _search_trace_block(f"Research on {topic}", context)
 
3130
  answer += _ai_tools_footer(context)
3131
  return answer, [], []
3132
 
 
3133
 
3134
+
3135
+ # ── Β§14 Follow-up System ──────────────────────────────────────────────────────
3136
 
3137
  def _filters_summary(context: SearchContextPayload) -> str:
3138
  filters: List[str] = []
 
3178
  return "last_5_years"
3179
  if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
3180
  return "last_10_years"
 
3181
  m = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
3182
  if m:
3183
  return f"last_{m.group(2)}_years"
 
3187
  return "articles_only"
3188
  if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
3189
  return "both_resources"
 
3190
  if REVIEW_ONLY_RE.search(q):
3191
  return "review_articles_only"
 
3192
  if ALT_TERMS_RE.search(q):
3193
  return "alt_terms"
 
 
 
 
 
3194
  if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
3195
  _STOP = {
3196
  'a','an','the','of','on','in','for','to','with','by','from','at','is','are',
 
3201
  }
3202
  content_words = [t for t in re.findall(r'[a-z0-9]+', q)
3203
  if t not in _STOP and (len(t) > 2 or t in {'ai','ml','nlp','cv','rl','dl','uae','ku','iot'})]
 
3204
  if content_words:
3205
  return None
3206
  return "summarize_topic"
 
3273
  context.year_from = str(CURRENT_YEAR - 1)
3274
  context.year_to = str(CURRENT_YEAR)
3275
  elif action and action.startswith("last_") and action.endswith("_years"):
 
3276
  try:
3277
  n = int(action.split("_")[1])
3278
  context.year_from = str(CURRENT_YEAR - n + 1)
 
3286
  elif action == "both_resources":
3287
  context.resource_type = "both"
3288
  elif action == "review_articles_only":
 
3289
  context.resource_type = "articles"
3290
+ context.peer_reviewed = True
 
 
3291
  elif action == "search_pubmed":
3292
  context.source = "pubmed"
3293
  context.intent = "search_medical"
 
3479
 
3480
  return answer, [], [], source_url
3481
 
3482
+
3483
  def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
3484
  dedup: Dict[str, SearchContextPayload] = {}
3485
  for ctx in contexts:
 
3488
  return ordered[:5]
3489
 
3490
 
 
3491
 
3492
+ # ── Β§15 Main Routing ──────────────────────────────────────────────────────────
3493
 
3494
  def _log_agent_query(question: str, response: AgentResponse, start_time: float) -> None:
 
3495
  elapsed = time.time() - start_time
3496
  intent = response.intent or ""
3497
  tool = ", ".join(response.tools_used or [])[:60]
3498
  model_used = response.model_used or ""
3499
  result_count = len(response.search_results or [])
 
3500
  try:
3501
  from app import get_metrics_service
3502
  get_metrics_service().log_query(
 
3509
  )
3510
  except Exception:
3511
  pass
 
3512
  try:
3513
  settings = get_settings()
3514
  worker_url = getattr(settings, "cloudflare_worker_url", "") or ""
 
3539
  pass
3540
 
3541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3542
  async def _agent_query_inner(
3543
  question: str,
3544
  history,
 
3563
  response_time=time.time() - start_time,
3564
  )
3565
 
 
 
 
 
 
 
3566
  if not question:
3567
  follow_up_question, follow_up_suggestions = _social_follow_up()
3568
  return AgentResponse(
 
3693
 
3694
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
3695
 
3696
+ # ── Catalogue availability ────────────────────────────────────────────────
 
 
3697
  _CATALOGUE_RE = re.compile(
3698
  r"\b("
3699
  r"available in (the )?(ku|khalifa) library"
 
3708
  _BOOK_RE = re.compile(r"\b(book|books|ebook|ebooks|textbook|textbooks|copy|copies|title|titles)\b", re.IGNORECASE)
3709
 
3710
  if _CATALOGUE_RE.search(question) and _BOOK_RE.search(question):
 
 
3711
  from urllib.parse import quote as _quote
3712
  _topic_raw = re.sub(
3713
  r"\b(can you (help me )?find|find|search|look for|show me|help me find"
 
3726
  )
3727
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
3728
 
 
3729
  rag_results = []
3730
  try:
3731
  from app import get_rag_service
 
3763
  _safe_metrics_increment("follow_up_hits")
3764
  context = _apply_follow_up_action(base_context, follow_up_action)
3765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3766
  if follow_up_action == "alt_terms":
3767
  alt_answer = await _alternative_terms_answer(question, base_context, model)
3768
  follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model)
 
3783
  if follow_up_action == "summarize_topic":
3784
  answer, search_results, sources = await _research_snapshot(context, model)
3785
  else:
 
3786
  answer = (
3787
  f"{_search_trace_block(question, context)}"
3788
  f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
 
3841
  )
3842
 
3843
  # ── Pre-classifier specialist handlers ───────────────────────────────────
 
 
3844
 
 
3845
  if FULLTEXT_REQUEST_RE.search(question):
3846
  ft_answer = _fulltext_chain_answer(question)
3847
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
 
3856
  follow_up_suggestions=follow_up_suggestions,
3857
  )
3858
 
 
3859
  if THESES_RE.search(question):
3860
  th_answer = _theses_answer()
3861
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
 
3870
  follow_up_suggestions=follow_up_suggestions,
3871
  )
3872
 
 
3873
  if PREDATORY_RE.search(question):
3874
  pr_answer = _predatory_eval_answer()
3875
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
 
3884
  follow_up_suggestions=follow_up_suggestions,
3885
  )
3886
 
 
3887
  if CITATION_CHAIN_RE.search(question):
3888
  cc_answer = _citation_chain_answer()
3889
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
 
3898
  follow_up_suggestions=follow_up_suggestions,
3899
  )
3900
 
 
3901
  if BOOLEAN_STRATEGY_RE.search(question):
3902
  strategy_answer, _plan = await _search_strategy_answer(question, model)
3903
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
 
3914
  follow_up_suggestions=follow_up_suggestions,
3915
  )
3916
 
 
3917
  if ALT_TERMS_RE.search(question):
3918
  alt_answer = await _alternative_terms_answer(question, None, model)
3919
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
 
3927
  follow_up_suggestions=follow_up_suggestions,
3928
  )
3929
 
 
3930
  cls_result = await _llm_classify(question, history, model)
3931
  intent = cls_result.get("intent", "general")
3932
  _safe_metrics_bucket("intents", intent)
 
4014
  )
4015
 
4016
  if intent in ("search_academic", "search_medical"):
 
4017
  if PARTIAL_TITLE_RE.search(question):
4018
  from urllib.parse import quote as _qt
 
4019
  _title_frag = (re.search(r'"([^"]{4,80})"', question) or
4020
  re.search(r'"([^"]{4,80})"', question))
4021
  _title_q = _title_frag.group(1) if _title_frag else _light_strip_retrieval_boilerplate(question)
 
4055
  topic = _title_case_topic(topic)
4056
  year_from, year_to = _parse_year_filters(question)
4057
 
 
4058
  if DUAL_RESOURCE_RE.search(question):
4059
  import asyncio as _aio
 
4060
  _ctx_articles = SearchContextPayload(
4061
  context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
4062
  intent=intent, source="primo", resource_type="articles",
 
4074
  _prepare_queries(question, _ctx_articles, model, False),
4075
  _prepare_queries(question, _ctx_books, model, False),
4076
  )
 
 
4077
  _src_url_a = _primo_clean_url(_ctx_articles)
4078
  _src_url_b = _primo_clean_url(_ctx_books)
4079
 
 
4161
  )
4162
 
4163
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
 
4164
  if is_highly_cited:
4165
  answer += _highly_cited_note(_escape(topic))
4166
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
 
4212
  )
4213
 
4214
 
4215
+ # ── Β§16 FastAPI Endpoint & Utility Routes ─────────────────────────────────────
4216
+
4217
+ @router.post("", response_model=AgentResponse)
4218
+ async def agent_query(request: Request, req: AgentRequest):
4219
+ start_time = time.time()
4220
+ question = _normalize_whitespace(req.question)
4221
+ history = req.history
4222
+ model = req.model
4223
+ client_state = req.client_state
4224
+ _safe_metrics_increment("agent_requests")
4225
+ response = await _agent_query_inner(question, history, model, client_state, start_time)
4226
+ response.answer = _sanitize_llm_response(response.answer)
4227
+ _log_agent_query(question, response, start_time)
4228
+ _rcfg = _get_runtime_config()
4229
+ _ann = _rcfg.get("announcement", "").strip()
4230
+ if _ann and response.intent not in ("social_greeting", "social"):
4231
+ _ann_html = (
4232
+ '<div style="margin-top:14px;padding:10px 14px;border-radius:10px;'
4233
+ 'background:#fffbeb;border:1px solid #fde68a;font-size:.82rem;color:#92400e">'
4234
+ f'πŸ“’ {_ann}</div>'
4235
+ )
4236
+ response.answer = response.answer + _ann_html
4237
+ return response
4238
+
4239
 
4240
  @router.get("/test")
4241
  async def test():
 
4248
 
4249
  @router.get("/rag-status")
4250
  async def rag_status():
 
4251
  try:
4252
  from app import get_rag_service
4253
  return get_rag_service().get_stats()