Update src/api/agent.py
Browse files- src/api/agent.py +104 -511
src/api/agent.py
CHANGED
|
@@ -1,33 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
-
agent.py β LibBee v3.
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 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 |
-
|
| 511 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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. "
|
|
|
|
| 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
|
| 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 |
-
# ββ
|
| 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 |
-
# ββ
|
| 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"]
|
| 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 |
-
|
| 2914 |
-
#
|
|
|
|
| 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 |
-
# ββ
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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§ion=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()
|