Upload 12 files
Browse files- src/agentcore/__init__.py +1 -1
- src/agentcore/classify.py +130 -130
- src/agentcore/constants.py +0 -0
- src/agentcore/intents_general.py +160 -160
- src/agentcore/intents_library.py +280 -280
- src/agentcore/intents_search.py +899 -889
- src/agentcore/libcal.py +333 -333
- src/agentcore/models.py +41 -45
- src/agentcore/orchestrator.py +965 -1043
- src/agentcore/rendering.py +590 -590
- src/agentcore/scholarly.py +311 -286
- src/agentcore/utils.py +288 -288
src/agentcore/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
"""LibBee agent core: constraint-driven intent-routing pipeline, split by pipeline stage."""
|
|
|
|
| 1 |
+
"""LibBee agent core: constraint-driven intent-routing pipeline, split by pipeline stage."""
|
src/agentcore/classify.py
CHANGED
|
@@ -1,130 +1,130 @@
|
|
| 1 |
-
"""Stage 2-3 of the pipeline: rule-based pre-classifier and the GPT-4o-mini intent classifier."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
|
| 19 |
-
from src.agentcore.models import ChatMessage
|
| 20 |
-
from src.agentcore.constants import (
|
| 21 |
-
CURRENT_INFO_RE,
|
| 22 |
-
GREETING_FOLLOWUP_RE,
|
| 23 |
-
HISTORY_WINDOW,
|
| 24 |
-
HOURS_RE,
|
| 25 |
-
KU_CAMPUS_RE,
|
| 26 |
-
LIBRARY_CUE_RE,
|
| 27 |
-
MEDICAL_KEYWORDS,
|
| 28 |
-
MEDICAL_SEARCH_RE,
|
| 29 |
-
RESEARCH_CUE_RE,
|
| 30 |
-
SOCIAL_RE,
|
| 31 |
-
SUMMARY_RE,
|
| 32 |
-
_CLASSIFIER_SYSTEM,
|
| 33 |
-
)
|
| 34 |
-
from src.agentcore.utils import _get_llm
|
| 35 |
-
|
| 36 |
-
logger = logging.getLogger(__name__)
|
| 37 |
-
|
| 38 |
-
def _is_summary_request(question: str) -> bool:
|
| 39 |
-
return bool(SUMMARY_RE.search(question or ""))
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
|
| 43 |
-
if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
|
| 44 |
-
return False
|
| 45 |
-
for m in reversed(history):
|
| 46 |
-
if m.role == "assistant":
|
| 47 |
-
last = m.content.lower()
|
| 48 |
-
return "i'm libbee" in last or "are you looking for one of these" in last
|
| 49 |
-
return False
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def _looks_library_hours_question(question: str) -> bool:
|
| 53 |
-
q = (question or "").strip().lower()
|
| 54 |
-
return bool(HOURS_RE.search(q)) and any(
|
| 55 |
-
kw in q for kw in (
|
| 56 |
-
"library", "campus", "habshan", "san", "hours", "open", "close",
|
| 57 |
-
"schedule", "till", "until", "today", "tomorrow", "friday",
|
| 58 |
-
"saturday", "sunday", "week", "ramadan",
|
| 59 |
-
)
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _looks_campus_question(question: str) -> bool:
|
| 64 |
-
return bool(KU_CAMPUS_RE.search(question or ""))
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def _looks_medical_search(question: str) -> bool:
|
| 68 |
-
q = (question or "").lower()
|
| 69 |
-
if not any(kw in q for kw in MEDICAL_KEYWORDS):
|
| 70 |
-
return False
|
| 71 |
-
return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def _looks_research_question(question: str) -> bool:
|
| 75 |
-
q = question or ""
|
| 76 |
-
return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def _rule_based_classify(question: str) -> Dict[str, str]:
|
| 80 |
-
q = (question or "").lower()
|
| 81 |
-
if SOCIAL_RE.match((question or "").strip()):
|
| 82 |
-
return {
|
| 83 |
-
"intent": "social",
|
| 84 |
-
"casual_answer": (
|
| 85 |
-
"Hello! I'm LibBee, the KU Library AI Assistant. "
|
| 86 |
-
"I'm here to help you with articles, books, databases, and library services. "
|
| 87 |
-
"What would you like to find today?"
|
| 88 |
-
),
|
| 89 |
-
}
|
| 90 |
-
if _looks_medical_search(question):
|
| 91 |
-
return {"intent": "search_medical"}
|
| 92 |
-
if _looks_research_question(question):
|
| 93 |
-
return {"intent": "search_academic"}
|
| 94 |
-
if LIBRARY_CUE_RE.search(q):
|
| 95 |
-
return {"intent": "library_info"}
|
| 96 |
-
if CURRENT_INFO_RE.search(q):
|
| 97 |
-
return {"intent": "general_recent"}
|
| 98 |
-
return {"intent": "general"}
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
|
| 102 |
-
settings = get_settings()
|
| 103 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 104 |
-
return _rule_based_classify(question)
|
| 105 |
-
try:
|
| 106 |
-
ctx_parts = [
|
| 107 |
-
f"{m.role}: {m.content[:100]}"
|
| 108 |
-
for m in history[-HISTORY_WINDOW:]
|
| 109 |
-
if m.role in ("user", "assistant")
|
| 110 |
-
]
|
| 111 |
-
ctx = " | ".join(ctx_parts) if ctx_parts else "none"
|
| 112 |
-
classify_model = "gpt" if settings.openai_api_key else model
|
| 113 |
-
llm = _get_llm(classify_model, temperature=0, max_tokens=120)
|
| 114 |
-
response = await llm.ainvoke([
|
| 115 |
-
{"role": "system", "content": _CLASSIFIER_SYSTEM},
|
| 116 |
-
{"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
|
| 117 |
-
])
|
| 118 |
-
raw = response.content.strip()
|
| 119 |
-
if raw.startswith("```"):
|
| 120 |
-
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 121 |
-
result = json.loads(raw)
|
| 122 |
-
intent = result.get("intent", "general")
|
| 123 |
-
if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general", "sensitive"}:
|
| 124 |
-
intent = "general"
|
| 125 |
-
return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
|
| 126 |
-
except Exception as e:
|
| 127 |
-
logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
|
| 128 |
-
return _rule_based_classify(question)
|
| 129 |
-
|
| 130 |
-
|
|
|
|
| 1 |
+
"""Stage 2-3 of the pipeline: rule-based pre-classifier and the GPT-4o-mini intent classifier."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
|
| 19 |
+
from src.agentcore.models import ChatMessage
|
| 20 |
+
from src.agentcore.constants import (
|
| 21 |
+
CURRENT_INFO_RE,
|
| 22 |
+
GREETING_FOLLOWUP_RE,
|
| 23 |
+
HISTORY_WINDOW,
|
| 24 |
+
HOURS_RE,
|
| 25 |
+
KU_CAMPUS_RE,
|
| 26 |
+
LIBRARY_CUE_RE,
|
| 27 |
+
MEDICAL_KEYWORDS,
|
| 28 |
+
MEDICAL_SEARCH_RE,
|
| 29 |
+
RESEARCH_CUE_RE,
|
| 30 |
+
SOCIAL_RE,
|
| 31 |
+
SUMMARY_RE,
|
| 32 |
+
_CLASSIFIER_SYSTEM,
|
| 33 |
+
)
|
| 34 |
+
from src.agentcore.utils import _get_llm
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
def _is_summary_request(question: str) -> bool:
|
| 39 |
+
return bool(SUMMARY_RE.search(question or ""))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _is_greeting_menu_followup(question: str, history: List[ChatMessage]) -> bool:
|
| 43 |
+
if not GREETING_FOLLOWUP_RE.match((question or "").strip()):
|
| 44 |
+
return False
|
| 45 |
+
for m in reversed(history):
|
| 46 |
+
if m.role == "assistant":
|
| 47 |
+
last = m.content.lower()
|
| 48 |
+
return "i'm libbee" in last or "are you looking for one of these" in last
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _looks_library_hours_question(question: str) -> bool:
|
| 53 |
+
q = (question or "").strip().lower()
|
| 54 |
+
return bool(HOURS_RE.search(q)) and any(
|
| 55 |
+
kw in q for kw in (
|
| 56 |
+
"library", "campus", "habshan", "san", "hours", "open", "close",
|
| 57 |
+
"schedule", "till", "until", "today", "tomorrow", "friday",
|
| 58 |
+
"saturday", "sunday", "week", "ramadan",
|
| 59 |
+
)
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _looks_campus_question(question: str) -> bool:
|
| 64 |
+
return bool(KU_CAMPUS_RE.search(question or ""))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _looks_medical_search(question: str) -> bool:
|
| 68 |
+
q = (question or "").lower()
|
| 69 |
+
if not any(kw in q for kw in MEDICAL_KEYWORDS):
|
| 70 |
+
return False
|
| 71 |
+
return bool(MEDICAL_SEARCH_RE.search(q) or RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _looks_research_question(question: str) -> bool:
|
| 75 |
+
q = question or ""
|
| 76 |
+
return bool(RESEARCH_CUE_RE.search(q) or SUMMARY_RE.search(q))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _rule_based_classify(question: str) -> Dict[str, str]:
|
| 80 |
+
q = (question or "").lower()
|
| 81 |
+
if SOCIAL_RE.match((question or "").strip()):
|
| 82 |
+
return {
|
| 83 |
+
"intent": "social",
|
| 84 |
+
"casual_answer": (
|
| 85 |
+
"Hello! I'm LibBee, the KU Library AI Assistant. "
|
| 86 |
+
"I'm here to help you with articles, books, databases, and library services. "
|
| 87 |
+
"What would you like to find today?"
|
| 88 |
+
),
|
| 89 |
+
}
|
| 90 |
+
if _looks_medical_search(question):
|
| 91 |
+
return {"intent": "search_medical"}
|
| 92 |
+
if _looks_research_question(question):
|
| 93 |
+
return {"intent": "search_academic"}
|
| 94 |
+
if LIBRARY_CUE_RE.search(q):
|
| 95 |
+
return {"intent": "library_info"}
|
| 96 |
+
if CURRENT_INFO_RE.search(q):
|
| 97 |
+
return {"intent": "general_recent"}
|
| 98 |
+
return {"intent": "general"}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def _llm_classify(question: str, history: List[ChatMessage], model: str) -> Dict[str, str]:
|
| 102 |
+
settings = get_settings()
|
| 103 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 104 |
+
return _rule_based_classify(question)
|
| 105 |
+
try:
|
| 106 |
+
ctx_parts = [
|
| 107 |
+
f"{m.role}: {m.content[:100]}"
|
| 108 |
+
for m in history[-HISTORY_WINDOW:]
|
| 109 |
+
if m.role in ("user", "assistant")
|
| 110 |
+
]
|
| 111 |
+
ctx = " | ".join(ctx_parts) if ctx_parts else "none"
|
| 112 |
+
classify_model = "gpt" if settings.openai_api_key else model
|
| 113 |
+
llm = _get_llm(classify_model, temperature=0, max_tokens=120)
|
| 114 |
+
response = await llm.ainvoke([
|
| 115 |
+
{"role": "system", "content": _CLASSIFIER_SYSTEM},
|
| 116 |
+
{"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
|
| 117 |
+
])
|
| 118 |
+
raw = response.content.strip()
|
| 119 |
+
if raw.startswith("```"):
|
| 120 |
+
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 121 |
+
result = json.loads(raw)
|
| 122 |
+
intent = result.get("intent", "general")
|
| 123 |
+
if intent not in {"social", "library_info", "search_academic", "search_medical", "general_recent", "general", "sensitive"}:
|
| 124 |
+
intent = "general"
|
| 125 |
+
return {"intent": intent, "casual_answer": result.get("casual_answer", "")}
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.warning(f"LLM classifier error: {e} — falling back to rule-based")
|
| 128 |
+
return _rule_based_classify(question)
|
| 129 |
+
|
| 130 |
+
|
src/agentcore/constants.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/agentcore/intents_general.py
CHANGED
|
@@ -1,160 +1,160 @@
|
|
| 1 |
-
"""general / social intents: web-search answers, general LLM answers, casual persona replies."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
|
| 19 |
-
from src.agentcore.models import ChatMessage
|
| 20 |
-
from src.agentcore.constants import KU_MAIN_URL, _GUARDRAIL, _URL_INSTRUCTION
|
| 21 |
-
from src.agentcore.utils import _build_history_messages, _get_llm, _get_runtime_config
|
| 22 |
-
|
| 23 |
-
logger = logging.getLogger(__name__)
|
| 24 |
-
|
| 25 |
-
async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
|
| 26 |
-
"""LLM #1 — casual social response. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 27 |
-
if hint and len(hint.strip()) > 20:
|
| 28 |
-
return hint.strip()
|
| 29 |
-
settings = get_settings()
|
| 30 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 31 |
-
return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
|
| 32 |
-
try:
|
| 33 |
-
llm = _get_llm(model, temperature=0.6, max_tokens=160)
|
| 34 |
-
msgs = [{"role": "system", "content": (
|
| 35 |
-
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 36 |
-
"Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
|
| 37 |
-
"No markdown, no bullet points. "
|
| 38 |
-
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 39 |
-
)}]
|
| 40 |
-
_cfg = _get_runtime_config()
|
| 41 |
-
_ci = _cfg.get("custom_instructions", "").strip()
|
| 42 |
-
if _ci:
|
| 43 |
-
msgs[0]["content"] += " " + _ci
|
| 44 |
-
msgs.extend(_build_history_messages(history))
|
| 45 |
-
msgs.append({"role": "user", "content": question})
|
| 46 |
-
response = await llm.ainvoke(msgs)
|
| 47 |
-
return response.content.strip()
|
| 48 |
-
except Exception:
|
| 49 |
-
return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
|
| 53 |
-
"""LLM #3 — general factual answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 54 |
-
settings = get_settings()
|
| 55 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 56 |
-
return (
|
| 57 |
-
"I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
|
| 58 |
-
f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 59 |
-
)
|
| 60 |
-
system = (
|
| 61 |
-
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 62 |
-
"Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
|
| 63 |
-
"KU means Khalifa University. If the topic is academic, briefly offer to help find library resources. "
|
| 64 |
-
"When referencing KU Library services, include these verified links where relevant: "
|
| 65 |
-
"Library homepage: https://library.ku.ac.ae | "
|
| 66 |
-
"Ask a Librarian: https://library.ku.ac.ae/AskUs | "
|
| 67 |
-
"Library hours: https://library.ku.ac.ae/hours | "
|
| 68 |
-
"ILL requests: https://library.ku.ac.ae/ill/ | "
|
| 69 |
-
"Study rooms: https://library.ku.ac.ae/rooms/ | "
|
| 70 |
-
"E-resources: https://library.ku.ac.ae/eresources | "
|
| 71 |
-
"Khazna repository: https://khazna.ku.ac.ae | "
|
| 72 |
-
"PRIMO discovery: https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU. "
|
| 73 |
-
"Only use these exact URLs — never invent others. "
|
| 74 |
-
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 75 |
-
)
|
| 76 |
-
_cfg = _get_runtime_config()
|
| 77 |
-
_ci = _cfg.get("custom_instructions", "").strip()
|
| 78 |
-
if _ci:
|
| 79 |
-
system += " " + _ci
|
| 80 |
-
try:
|
| 81 |
-
llm = _get_llm(model, temperature=0.3, max_tokens=360)
|
| 82 |
-
msgs = [{"role": "system", "content": system}]
|
| 83 |
-
msgs.extend(_build_history_messages(history))
|
| 84 |
-
msgs.append({"role": "user", "content": question})
|
| 85 |
-
response = await llm.ainvoke(msgs)
|
| 86 |
-
return response.content.strip()
|
| 87 |
-
except Exception as e:
|
| 88 |
-
logger.error(f"_llm_general_answer error: {e}")
|
| 89 |
-
return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
|
| 93 |
-
"""LLM #4 — web search answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 94 |
-
settings = get_settings()
|
| 95 |
-
_WEB_SYSTEM = (
|
| 96 |
-
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 97 |
-
"Answer using current web search. Be concise and factual. Use HTML <br> for line breaks. "
|
| 98 |
-
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 99 |
-
)
|
| 100 |
-
if model == "claude" and settings.anthropic_api_key:
|
| 101 |
-
try:
|
| 102 |
-
import anthropic
|
| 103 |
-
client = anthropic.
|
| 104 |
-
response = client.messages.create(
|
| 105 |
-
model="claude-haiku-4-5-20251001",
|
| 106 |
-
max_tokens=520,
|
| 107 |
-
system=_WEB_SYSTEM,
|
| 108 |
-
tools=[{"type": "web_search_20250305", "name": "web_search"}],
|
| 109 |
-
messages=[{"role": "user", "content": question}],
|
| 110 |
-
)
|
| 111 |
-
text = "".join(block.text for block in response.content if hasattr(block, "text"))
|
| 112 |
-
if text.strip():
|
| 113 |
-
return text.strip()
|
| 114 |
-
except Exception as e:
|
| 115 |
-
logger.warning(f"Claude web search failed: {e}")
|
| 116 |
-
if settings.openai_api_key:
|
| 117 |
-
try:
|
| 118 |
-
from openai import
|
| 119 |
-
client =
|
| 120 |
-
response = client.responses.create(
|
| 121 |
-
model="gpt-4o-mini",
|
| 122 |
-
tools=[{"type": "web_search_preview"}],
|
| 123 |
-
instructions=_WEB_SYSTEM,
|
| 124 |
-
input=question,
|
| 125 |
-
)
|
| 126 |
-
text = ""
|
| 127 |
-
for item in response.output:
|
| 128 |
-
if hasattr(item, "content"):
|
| 129 |
-
for block in item.content:
|
| 130 |
-
if hasattr(block, "text"):
|
| 131 |
-
text += block.text
|
| 132 |
-
if text.strip():
|
| 133 |
-
return text.strip()
|
| 134 |
-
except Exception as e:
|
| 135 |
-
logger.warning(f"GPT web search failed: {e}")
|
| 136 |
-
return await _llm_general_answer(question, history, model)
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
|
| 140 |
-
return (
|
| 141 |
-
"Would you like a brief explanation, current web information, or KU Library resources on this topic?",
|
| 142 |
-
[
|
| 143 |
-
{"label": "Shorter explanation", "question": f"Give me a shorter explanation of {question}"},
|
| 144 |
-
{"label": "Current web information", "question": f"Show current web information on {question}"},
|
| 145 |
-
{"label": "Find KU Library resources", "question": f"Find KU Library resources on {question}"},
|
| 146 |
-
],
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def _social_follow_up() -> Tuple[str, List[dict]]:
|
| 151 |
-
return (
|
| 152 |
-
"What would you like help with next?",
|
| 153 |
-
[
|
| 154 |
-
{"label": "Find articles on a topic", "question": "Find articles on a topic"},
|
| 155 |
-
{"label": "Check a library service", "question": "Check a library service"},
|
| 156 |
-
{"label": "Contact a librarian", "question": "Contact a librarian"},
|
| 157 |
-
],
|
| 158 |
-
)
|
| 159 |
-
|
| 160 |
-
|
|
|
|
| 1 |
+
"""general / social intents: web-search answers, general LLM answers, casual persona replies."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
|
| 19 |
+
from src.agentcore.models import ChatMessage
|
| 20 |
+
from src.agentcore.constants import KU_MAIN_URL, _GUARDRAIL, _URL_INSTRUCTION
|
| 21 |
+
from src.agentcore.utils import _build_history_messages, _get_llm, _get_runtime_config
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
|
| 26 |
+
"""LLM #1 — casual social response. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 27 |
+
if hint and len(hint.strip()) > 20:
|
| 28 |
+
return hint.strip()
|
| 29 |
+
settings = get_settings()
|
| 30 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 31 |
+
return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
|
| 32 |
+
try:
|
| 33 |
+
llm = _get_llm(model, temperature=0.6, max_tokens=160)
|
| 34 |
+
msgs = [{"role": "system", "content": (
|
| 35 |
+
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 36 |
+
"Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
|
| 37 |
+
"No markdown, no bullet points. "
|
| 38 |
+
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 39 |
+
)}]
|
| 40 |
+
_cfg = _get_runtime_config()
|
| 41 |
+
_ci = _cfg.get("custom_instructions", "").strip()
|
| 42 |
+
if _ci:
|
| 43 |
+
msgs[0]["content"] += " " + _ci
|
| 44 |
+
msgs.extend(_build_history_messages(history))
|
| 45 |
+
msgs.append({"role": "user", "content": question})
|
| 46 |
+
response = await llm.ainvoke(msgs)
|
| 47 |
+
return response.content.strip()
|
| 48 |
+
except Exception:
|
| 49 |
+
return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant — always happy to help!"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
|
| 53 |
+
"""LLM #3 — general factual answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 54 |
+
settings = get_settings()
|
| 55 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 56 |
+
return (
|
| 57 |
+
"I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
|
| 58 |
+
f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 59 |
+
)
|
| 60 |
+
system = (
|
| 61 |
+
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 62 |
+
"Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
|
| 63 |
+
"KU means Khalifa University. If the topic is academic, briefly offer to help find library resources. "
|
| 64 |
+
"When referencing KU Library services, include these verified links where relevant: "
|
| 65 |
+
"Library homepage: https://library.ku.ac.ae | "
|
| 66 |
+
"Ask a Librarian: https://library.ku.ac.ae/AskUs | "
|
| 67 |
+
"Library hours: https://library.ku.ac.ae/hours | "
|
| 68 |
+
"ILL requests: https://library.ku.ac.ae/ill/ | "
|
| 69 |
+
"Study rooms: https://library.ku.ac.ae/rooms/ | "
|
| 70 |
+
"E-resources: https://library.ku.ac.ae/eresources | "
|
| 71 |
+
"Khazna repository: https://khazna.ku.ac.ae | "
|
| 72 |
+
"PRIMO discovery: https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU. "
|
| 73 |
+
"Only use these exact URLs — never invent others. "
|
| 74 |
+
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 75 |
+
)
|
| 76 |
+
_cfg = _get_runtime_config()
|
| 77 |
+
_ci = _cfg.get("custom_instructions", "").strip()
|
| 78 |
+
if _ci:
|
| 79 |
+
system += " " + _ci
|
| 80 |
+
try:
|
| 81 |
+
llm = _get_llm(model, temperature=0.3, max_tokens=360)
|
| 82 |
+
msgs = [{"role": "system", "content": system}]
|
| 83 |
+
msgs.extend(_build_history_messages(history))
|
| 84 |
+
msgs.append({"role": "user", "content": question})
|
| 85 |
+
response = await llm.ainvoke(msgs)
|
| 86 |
+
return response.content.strip()
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(f"_llm_general_answer error: {e}")
|
| 89 |
+
return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
|
| 93 |
+
"""LLM #4 — web search answer. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 94 |
+
settings = get_settings()
|
| 95 |
+
_WEB_SYSTEM = (
|
| 96 |
+
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 97 |
+
"Answer using current web search. Be concise and factual. Use HTML <br> for line breaks. "
|
| 98 |
+
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 99 |
+
)
|
| 100 |
+
if model == "claude" and settings.anthropic_api_key:
|
| 101 |
+
try:
|
| 102 |
+
import anthropic
|
| 103 |
+
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
| 104 |
+
response = await client.messages.create(
|
| 105 |
+
model="claude-haiku-4-5-20251001",
|
| 106 |
+
max_tokens=520,
|
| 107 |
+
system=_WEB_SYSTEM,
|
| 108 |
+
tools=[{"type": "web_search_20250305", "name": "web_search"}],
|
| 109 |
+
messages=[{"role": "user", "content": question}],
|
| 110 |
+
)
|
| 111 |
+
text = "".join(block.text for block in response.content if hasattr(block, "text"))
|
| 112 |
+
if text.strip():
|
| 113 |
+
return text.strip()
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.warning(f"Claude web search failed: {e}")
|
| 116 |
+
if settings.openai_api_key:
|
| 117 |
+
try:
|
| 118 |
+
from openai import AsyncOpenAI
|
| 119 |
+
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
| 120 |
+
response = await client.responses.create(
|
| 121 |
+
model="gpt-4o-mini",
|
| 122 |
+
tools=[{"type": "web_search_preview"}],
|
| 123 |
+
instructions=_WEB_SYSTEM,
|
| 124 |
+
input=question,
|
| 125 |
+
)
|
| 126 |
+
text = ""
|
| 127 |
+
for item in response.output:
|
| 128 |
+
if hasattr(item, "content"):
|
| 129 |
+
for block in item.content:
|
| 130 |
+
if hasattr(block, "text"):
|
| 131 |
+
text += block.text
|
| 132 |
+
if text.strip():
|
| 133 |
+
return text.strip()
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.warning(f"GPT web search failed: {e}")
|
| 136 |
+
return await _llm_general_answer(question, history, model)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _general_follow_up(question: str) -> Tuple[str, List[dict]]:
|
| 140 |
+
return (
|
| 141 |
+
"Would you like a brief explanation, current web information, or KU Library resources on this topic?",
|
| 142 |
+
[
|
| 143 |
+
{"label": "Shorter explanation", "question": f"Give me a shorter explanation of {question}"},
|
| 144 |
+
{"label": "Current web information", "question": f"Show current web information on {question}"},
|
| 145 |
+
{"label": "Find KU Library resources", "question": f"Find KU Library resources on {question}"},
|
| 146 |
+
],
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _social_follow_up() -> Tuple[str, List[dict]]:
|
| 151 |
+
return (
|
| 152 |
+
"What would you like help with next?",
|
| 153 |
+
[
|
| 154 |
+
{"label": "Find articles on a topic", "question": "Find articles on a topic"},
|
| 155 |
+
{"label": "Check a library service", "question": "Check a library service"},
|
| 156 |
+
{"label": "Contact a librarian", "question": "Contact a librarian"},
|
| 157 |
+
],
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
src/agentcore/intents_library.py
CHANGED
|
@@ -1,280 +1,280 @@
|
|
| 1 |
-
"""library_info intent handlers: campus, RAG answers, database recommendations, full-text chain, theses."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
from src.services.staff_service import (
|
| 19 |
-
STAFF_DIRECTORY,
|
| 20 |
-
match_staff_name,
|
| 21 |
-
match_staff_role,
|
| 22 |
-
should_attempt_staff_lookup,
|
| 23 |
-
staff_name_answer,
|
| 24 |
-
staff_role_answer,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
from src.agentcore.models import ChatMessage
|
| 28 |
-
from src.agentcore.constants import (
|
| 29 |
-
ASK_LIBRARIAN_URL,
|
| 30 |
-
DATABASE_RECOMMEND_RE,
|
| 31 |
-
_DB_GENERAL,
|
| 32 |
-
_DB_SUBJECTS,
|
| 33 |
-
_GUARDRAIL,
|
| 34 |
-
_URL_INSTRUCTION,
|
| 35 |
-
)
|
| 36 |
-
from src.agentcore.utils import (
|
| 37 |
-
_build_history_messages,
|
| 38 |
-
_escape,
|
| 39 |
-
_get_llm,
|
| 40 |
-
_get_runtime_config,
|
| 41 |
-
_normalize_whitespace,
|
| 42 |
-
_strip_resource_noise,
|
| 43 |
-
)
|
| 44 |
-
from src.agentcore.classify import _looks_library_hours_question
|
| 45 |
-
|
| 46 |
-
logger = logging.getLogger(__name__)
|
| 47 |
-
|
| 48 |
-
def _campus_answer() -> str:
|
| 49 |
-
return (
|
| 50 |
-
"Khalifa University Library has <strong>two branches</strong>:<br><br>"
|
| 51 |
-
"<strong>1. Main Campus Library</strong><br>"
|
| 52 |
-
"Location: E Building, Main Campus, Abu Dhabi<br>"
|
| 53 |
-
'Reference desk: <a href="tel:+97123124604">+971 2 312 4604</a><br><br>'
|
| 54 |
-
"<strong>2. Habshan Library (SAN Campus)</strong><br>"
|
| 55 |
-
"Location: Building 5, Ground Floor & First Floor, Sas Al Nakhl (SAN) Campus, Abu Dhabi<br>"
|
| 56 |
-
'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
|
| 57 |
-
"Both branches offer study spaces, computer workstations, printing, scanning, "
|
| 58 |
-
"and access to physical collections.<br><br>"
|
| 59 |
-
f'For general library enquiries: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> '
|
| 60 |
-
'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _greeting_menu_clarify_answer() -> str:
|
| 65 |
-
return (
|
| 66 |
-
"Sure — what would you like help with?<br><br>"
|
| 67 |
-
"You can choose one of these or type your question directly:<br>"
|
| 68 |
-
"• Find articles and books<br>"
|
| 69 |
-
"• Get full text for an article<br>"
|
| 70 |
-
"• Submit an Interlibrary Loan (ILL) request<br>"
|
| 71 |
-
"• Contact a librarian<br>"
|
| 72 |
-
"• Check library hours"
|
| 73 |
-
)
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
|
| 77 |
-
"""LLM #2 — RAG answer from KB chunks. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 78 |
-
settings = get_settings()
|
| 79 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 80 |
-
return rag_results[0]["content"] if rag_results else ""
|
| 81 |
-
context = "\n\n---\n\n".join(r["content"] for r in rag_results)
|
| 82 |
-
system = (
|
| 83 |
-
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 84 |
-
"Answer the user's question using ONLY the context provided below. "
|
| 85 |
-
"Be concise, friendly, and use HTML <br> for line breaks where needed. "
|
| 86 |
-
"IMPORTANT — LINKS: When the context contains a URL (starting with https:// or http://), "
|
| 87 |
-
"include it as a clickable HTML link using <a href=\"URL\" target=\"_blank\">link text</a>. "
|
| 88 |
-
"For example, if context mentions 'library.ku.ac.ae/ill/' include it as a link. "
|
| 89 |
-
"NEVER invent or guess URLs that do not appear in the context. "
|
| 90 |
-
"Only link to URLs that are explicitly present in the context below. "
|
| 91 |
-
f"If the context does not contain enough information, say so and suggest Ask a Librarian at "
|
| 92 |
-
f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> or '
|
| 93 |
-
f'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.\n\n'
|
| 94 |
-
f"CONTEXT:\n{context}\n\n"
|
| 95 |
-
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 96 |
-
)
|
| 97 |
-
_cfg = _get_runtime_config()
|
| 98 |
-
_ci = _cfg.get("custom_instructions", "").strip()
|
| 99 |
-
if _ci:
|
| 100 |
-
system += "\n\nAdditional instructions: " + _ci
|
| 101 |
-
try:
|
| 102 |
-
llm = _get_llm(model, temperature=0.2, max_tokens=380)
|
| 103 |
-
msgs = [{"role": "system", "content": system}]
|
| 104 |
-
msgs.extend(_build_history_messages(history))
|
| 105 |
-
msgs.append({"role": "user", "content": question})
|
| 106 |
-
response = await llm.ainvoke(msgs)
|
| 107 |
-
return response.content.strip()
|
| 108 |
-
except Exception as e:
|
| 109 |
-
logger.error(f"_rag_answer error: {e}")
|
| 110 |
-
return (
|
| 111 |
-
"I'm having trouble generating an answer right now. "
|
| 112 |
-
f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
|
| 113 |
-
)
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def _is_database_recommendation_question(question: str) -> bool:
|
| 117 |
-
return bool(DATABASE_RECOMMEND_RE.search(question or ""))
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _match_db_subject(q: str) -> Optional[str]:
|
| 121 |
-
checks = [
|
| 122 |
-
(["quantum computing", "quantum algorithm", "quantum cryptography", "quantum information"], "quantum_computing"),
|
| 123 |
-
(["robotics", "autonomous system", "intelligent system"], "robotics"),
|
| 124 |
-
(["artificial intelligence", "machine learning", "deep learning", "nlp", "natural language processing", "neural network", "cybersecurity", "computer science", "software engineering", "algorithm", "data structure"], "computer_science"),
|
| 125 |
-
(["computer engineering", "embedded system", "computer architecture", "networking", "digital system"], "computer_science"),
|
| 126 |
-
(["aerospace", "aerodynamic", "propulsion", "aircraft", "aviation", "flight system"], "aerospace"),
|
| 127 |
-
(["biomedical engineering", "medical device", "biomechanic", "bioinstrument", "tissue engineering", "biomedical imaging"], "biomedical"),
|
| 128 |
-
(["chemical engineering", "process engineering", "reaction engineering", "separation", "catalysis"], "chemical_engineering"),
|
| 129 |
-
(["chemistry", "organic chemistry", "analytical chemistry", "inorganic", "chemical", "chemical properties"], "chemistry"),
|
| 130 |
-
(["civil engineering", "structural engineering", "geotechnical", "transportation engineering", "water resources", "construction"], "civil_engineering"),
|
| 131 |
-
(["electrical engineering", "circuit", "power system", "signal processing", "control system", "communication system", "electronics"], "electrical_engineering"),
|
| 132 |
-
(["energy engineering", "renewable energy", "sustainable energy", "clean energy", "energy transition", "solar", "wind power"], "energy"),
|
| 133 |
-
(["engineering management", "engineering systems", "systems engineering", "operations management", "innovation management"], "engineering_management"),
|
| 134 |
-
(["mechanical engineering", "thermofluid", "thermodynamics", "fluid mechanics", "manufacturing", "mechatronics"], "mechanical_engineering"),
|
| 135 |
-
(["nuclear engineering", "reactor", "radiation", "nuclear material", "nuclear safety"], "nuclear"),
|
| 136 |
-
(["petroleum engineering", "oil and gas", "drilling", "reservoir", "subsurface", "petroleum"], "petroleum"),
|
| 137 |
-
(["earth science", "geology", "geophysics", "geochemistry", "planetary science", "geoscience"], "earth_science"),
|
| 138 |
-
(["environment", "sustainability", "climate change", "sustainable development", "conservation", "environmental policy", "ecology"], "environment"),
|
| 139 |
-
(["materials science", "nanotechnology", "nanomaterial", "biomaterial", "advanced material", "functional material"], "materials"),
|
| 140 |
-
(["cell biology", "molecular biology", "genetics", "genomics", "biotechnology", "bioinformatics", "microbiology", "virology"], "biology"),
|
| 141 |
-
(["medicine", "medical", "health science", "clinical", "nursing", "pharmacy", "biomedical literature", "pharmacology", "clinical trial", "systematic review", "evidence-based", "patient care"], "medical"),
|
| 142 |
-
(["business", "management", "finance", "economics", "accounting", "entrepreneurship", "marketing", "strategy", "leadership", "international relations", "political science", "social science", "humanities", "law", "education", "psychology", "sociology"], "business"),
|
| 143 |
-
(["physics", "optics", "photonics", "quantum physics", "condensed matter", "theoretical physics", "astrophysics"], "physics"),
|
| 144 |
-
(["mathematics", "statistics", "data science", "calculus", "algebra", "optimization", "probability", "mathematical model"], "mathematics"),
|
| 145 |
-
(["impact factor", "journal metrics", "citescore", "jcr", "quartile", "research impact", "scival", "bibliometric"], "metrics"),
|
| 146 |
-
(["dissertation", "thesis", "doctoral", "master thesis", "repository", "phd thesis"], "theses"),
|
| 147 |
-
]
|
| 148 |
-
for keywords, key in checks:
|
| 149 |
-
if any(kw in q for kw in keywords):
|
| 150 |
-
return key
|
| 151 |
-
return None
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def _extract_db_topic(question: str) -> str:
|
| 155 |
-
q = _normalize_whitespace(question or "")
|
| 156 |
-
patterns = [
|
| 157 |
-
r"^(best|good|recommended?)\s+databases?\s+for\s+",
|
| 158 |
-
r"^which\s+databases?\s+(for|should i use for|is best for|are best for)\s+",
|
| 159 |
-
r"^databases?\s+for\s+",
|
| 160 |
-
r"^where should i (search|start)\s+(for\s+)?",
|
| 161 |
-
r"^what (database|databases)\s+(for|should i use for)\s+",
|
| 162 |
-
]
|
| 163 |
-
lower = q.lower()
|
| 164 |
-
for pat in patterns:
|
| 165 |
-
m = re.match(pat, lower)
|
| 166 |
-
if m:
|
| 167 |
-
q = q[m.end():]
|
| 168 |
-
break
|
| 169 |
-
q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
|
| 170 |
-
q = _strip_resource_noise(q)
|
| 171 |
-
return _normalize_whitespace(q).strip(".?") or "this subject"
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def _database_recommendation_answer(question: str) -> str:
|
| 175 |
-
q = (question or "").lower()
|
| 176 |
-
subject = _extract_db_topic(question)
|
| 177 |
-
topic = _escape(subject)
|
| 178 |
-
subject_key = _match_db_subject(q)
|
| 179 |
-
dbs = _DB_SUBJECTS.get(subject_key, _DB_GENERAL) if subject_key else _DB_GENERAL
|
| 180 |
-
dbs = list(dbs)[:5]
|
| 181 |
-
answer = f"<strong>Recommended databases for {topic}</strong><br><br>"
|
| 182 |
-
answer += "Here are the strongest KU-subscribed starting points:<br>"
|
| 183 |
-
for name, url, why in dbs:
|
| 184 |
-
answer += f'<br>• <a href="{url}" target="_blank"><strong>{_escape(name)}</strong></a> — {_escape(why)}'
|
| 185 |
-
if not subject_key:
|
| 186 |
-
answer += (
|
| 187 |
-
"<br><br>💡 For subject-specific databases, ask me: "
|
| 188 |
-
"<em>\"best databases for [your subject]\"</em> — e.g. physics, chemistry, medicine, civil engineering."
|
| 189 |
-
)
|
| 190 |
-
answer += (
|
| 191 |
-
f'<br><br>Browse all 50+ KU databases: '
|
| 192 |
-
f'<a href="https://library.ku.ac.ae/eresources" target="_blank">library.ku.ac.ae/eresources</a>'
|
| 193 |
-
)
|
| 194 |
-
answer += "<br><br>Want me to run a direct search on any of these for a specific topic?"
|
| 195 |
-
return answer
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
def _fulltext_chain_answer(question: str) -> str:
|
| 199 |
-
return (
|
| 200 |
-
"<strong>📄 How to get full text for an article</strong><br><br>"
|
| 201 |
-
"Follow this chain in order — each step is faster than the next:<br><br>"
|
| 202 |
-
"<strong>Step 1 — Search KU PRIMO</strong><br>"
|
| 203 |
-
"Find the article in <a href=\"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 204 |
-
"?vid=971KUOSTAR_INST:KU\" target=\"_blank\">KU Library Discovery</a>. "
|
| 205 |
-
"If KU subscribes, you'll see a <em>Full Text Available</em> or <em>Online Access</em> link. "
|
| 206 |
-
"Click it — it passes through the IDM proxy automatically when on campus or logged in.<br><br>"
|
| 207 |
-
"<strong>Step 2 — Try LibKey Nomad (browser extension)</strong><br>"
|
| 208 |
-
"<a href=\"https://libkey.io/libraries/3025/\" target=\"_blank\">LibKey Nomad</a> is a free browser "
|
| 209 |
-
"extension that detects articles you're viewing and fetches KU's full text automatically. "
|
| 210 |
-
"Install it once and it works on every publisher site.<br><br>"
|
| 211 |
-
"<strong>Step 3 — Try the publisher or open access</strong><br>"
|
| 212 |
-
"Visit the DOI link directly. Many authors post their accepted manuscript on "
|
| 213 |
-
"<a href=\"https://arxiv.org\" target=\"_blank\">arXiv</a>, "
|
| 214 |
-
"<a href=\"https://www.researchgate.net\" target=\"_blank\">ResearchGate</a>, or their institutional page. "
|
| 215 |
-
"<a href=\"https://openalex.org\" target=\"_blank\">OpenAlex</a> also links to many legal open-access PDFs.<br><br>"
|
| 216 |
-
"<strong>Step 4 — Submit an Interlibrary Loan (ILL) request</strong><br>"
|
| 217 |
-
"If none of the above work, KU Library can borrow the article from another library — usually within 1–5 working days. "
|
| 218 |
-
"Submit here: <a href=\"https://library.ku.ac.ae/ill/\" target=\"_blank\">library.ku.ac.ae/ill/</a><br>"
|
| 219 |
-
"Contact: <strong>Suaad Al Jneibi</strong> · "
|
| 220 |
-
"<a href=\"mailto:suaad.aljneibi@ku.ac.ae\">suaad.aljneibi@ku.ac.ae</a> · +971 2 312 4278"
|
| 221 |
-
)
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
def _theses_answer() -> str:
|
| 225 |
-
return (
|
| 226 |
-
"<strong>📘 Finding KU theses, dissertations, and institutional research</strong><br><br>"
|
| 227 |
-
"<strong>Khalifa University Khazna Repository</strong><br>"
|
| 228 |
-
"Khazna is KU's institutional repository — it holds KU theses, dissertations, "
|
| 229 |
-
"faculty publications, conference papers, and open-access research outputs.<br>"
|
| 230 |
-
'🔗 <a href="https://khazna.ku.ac.ae" target="_blank">khazna.ku.ac.ae</a><br><br>'
|
| 231 |
-
"<strong>ProQuest Dissertations & Theses Global</strong><br>"
|
| 232 |
-
"KU subscribes to ProQuest D&T Global — the world's largest database of "
|
| 233 |
-
"theses and dissertations from universities worldwide.<br>"
|
| 234 |
-
'🔗 <a href="https://khalifa.idm.oclc.org/login?url=https://www.proquest.com/pqdtglobal" target="_blank">'
|
| 235 |
-
"Access via KU Library</a><br><br>"
|
| 236 |
-
"<strong>Other sources</strong><br>"
|
| 237 |
-
'• <a href="https://ethos.bl.uk" target="_blank">EThOS</a> — UK theses (British Library)<br>'
|
| 238 |
-
'• <a href="https://www.dart-europe.org" target="_blank">DART-Europe</a> — European theses<br>'
|
| 239 |
-
'• <a href="https://ndltd.org" target="_blank">NDLTD</a> — Global networked digital library of theses<br><br>'
|
| 240 |
-
"For help with Khazna deposits or ORCID integration, contact: "
|
| 241 |
-
"<strong>Nikesh Narayanan</strong> · "
|
| 242 |
-
"<a href=\"mailto:nikesh.narayanan@ku.ac.ae\">nikesh.narayanan@ku.ac.ae</a>"
|
| 243 |
-
)
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
|
| 247 |
-
q = (question or "").lower()
|
| 248 |
-
if _looks_library_hours_question(question):
|
| 249 |
-
return (
|
| 250 |
-
"Would you like the library contact details too?",
|
| 251 |
-
[
|
| 252 |
-
{"label": "Show library contact details", "question": "Show the library contact details"},
|
| 253 |
-
{"label": "Open the live library hours page", "question": "Open the live library hours page"},
|
| 254 |
-
],
|
| 255 |
-
)
|
| 256 |
-
if "study room" in q or "room" in q:
|
| 257 |
-
return (
|
| 258 |
-
"Would you like the study-room booking link?",
|
| 259 |
-
[
|
| 260 |
-
{"label": "Study-room booking link", "question": "Show the study-room booking link"},
|
| 261 |
-
{"label": "Library services for both campuses", "question": "Show library services for both campuses"},
|
| 262 |
-
],
|
| 263 |
-
)
|
| 264 |
-
if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
|
| 265 |
-
return (
|
| 266 |
-
"Would you like me to recommend the best KU databases for this topic?",
|
| 267 |
-
[
|
| 268 |
-
{"label": "Recommend best KU databases", "question": "Recommend the best KU databases for this topic"},
|
| 269 |
-
{"label": "How to access databases off campus", "question": "How do I access databases off campus?"},
|
| 270 |
-
],
|
| 271 |
-
)
|
| 272 |
-
return (
|
| 273 |
-
"Would you like a related library service or staff contact?",
|
| 274 |
-
[
|
| 275 |
-
{"label": "Best staff contact", "question": "Show the best staff contact for this"},
|
| 276 |
-
{"label": "KU library page for this topic", "question": "Open the KU library page for this topic"},
|
| 277 |
-
],
|
| 278 |
-
)
|
| 279 |
-
|
| 280 |
-
|
|
|
|
| 1 |
+
"""library_info intent handlers: campus, RAG answers, database recommendations, full-text chain, theses."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
from src.services.staff_service import (
|
| 19 |
+
STAFF_DIRECTORY,
|
| 20 |
+
match_staff_name,
|
| 21 |
+
match_staff_role,
|
| 22 |
+
should_attempt_staff_lookup,
|
| 23 |
+
staff_name_answer,
|
| 24 |
+
staff_role_answer,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
from src.agentcore.models import ChatMessage
|
| 28 |
+
from src.agentcore.constants import (
|
| 29 |
+
ASK_LIBRARIAN_URL,
|
| 30 |
+
DATABASE_RECOMMEND_RE,
|
| 31 |
+
_DB_GENERAL,
|
| 32 |
+
_DB_SUBJECTS,
|
| 33 |
+
_GUARDRAIL,
|
| 34 |
+
_URL_INSTRUCTION,
|
| 35 |
+
)
|
| 36 |
+
from src.agentcore.utils import (
|
| 37 |
+
_build_history_messages,
|
| 38 |
+
_escape,
|
| 39 |
+
_get_llm,
|
| 40 |
+
_get_runtime_config,
|
| 41 |
+
_normalize_whitespace,
|
| 42 |
+
_strip_resource_noise,
|
| 43 |
+
)
|
| 44 |
+
from src.agentcore.classify import _looks_library_hours_question
|
| 45 |
+
|
| 46 |
+
logger = logging.getLogger(__name__)
|
| 47 |
+
|
| 48 |
+
def _campus_answer() -> str:
|
| 49 |
+
return (
|
| 50 |
+
"Khalifa University Library has <strong>two branches</strong>:<br><br>"
|
| 51 |
+
"<strong>1. Main Campus Library</strong><br>"
|
| 52 |
+
"Location: E Building, Main Campus, Abu Dhabi<br>"
|
| 53 |
+
'Reference desk: <a href="tel:+97123124604">+971 2 312 4604</a><br><br>'
|
| 54 |
+
"<strong>2. Habshan Library (SAN Campus)</strong><br>"
|
| 55 |
+
"Location: Building 5, Ground Floor & First Floor, Sas Al Nakhl (SAN) Campus, Abu Dhabi<br>"
|
| 56 |
+
'Service desk: <a href="tel:+97123123160">+971 2 312 3160</a><br><br>'
|
| 57 |
+
"Both branches offer study spaces, computer workstations, printing, scanning, "
|
| 58 |
+
"and access to physical collections.<br><br>"
|
| 59 |
+
f'For general library enquiries: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> '
|
| 60 |
+
'or email <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.'
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _greeting_menu_clarify_answer() -> str:
|
| 65 |
+
return (
|
| 66 |
+
"Sure — what would you like help with?<br><br>"
|
| 67 |
+
"You can choose one of these or type your question directly:<br>"
|
| 68 |
+
"• Find articles and books<br>"
|
| 69 |
+
"• Get full text for an article<br>"
|
| 70 |
+
"• Submit an Interlibrary Loan (ILL) request<br>"
|
| 71 |
+
"• Contact a librarian<br>"
|
| 72 |
+
"• Check library hours"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
|
| 77 |
+
"""LLM #2 — RAG answer from KB chunks. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 78 |
+
settings = get_settings()
|
| 79 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 80 |
+
return rag_results[0]["content"] if rag_results else ""
|
| 81 |
+
context = "\n\n---\n\n".join(r["content"] for r in rag_results)
|
| 82 |
+
system = (
|
| 83 |
+
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 84 |
+
"Answer the user's question using ONLY the context provided below. "
|
| 85 |
+
"Be concise, friendly, and use HTML <br> for line breaks where needed. "
|
| 86 |
+
"IMPORTANT — LINKS: When the context contains a URL (starting with https:// or http://), "
|
| 87 |
+
"include it as a clickable HTML link using <a href=\"URL\" target=\"_blank\">link text</a>. "
|
| 88 |
+
"For example, if context mentions 'library.ku.ac.ae/ill/' include it as a link. "
|
| 89 |
+
"NEVER invent or guess URLs that do not appear in the context. "
|
| 90 |
+
"Only link to URLs that are explicitly present in the context below. "
|
| 91 |
+
f"If the context does not contain enough information, say so and suggest Ask a Librarian at "
|
| 92 |
+
f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> or '
|
| 93 |
+
f'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>.\n\n'
|
| 94 |
+
f"CONTEXT:\n{context}\n\n"
|
| 95 |
+
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 96 |
+
)
|
| 97 |
+
_cfg = _get_runtime_config()
|
| 98 |
+
_ci = _cfg.get("custom_instructions", "").strip()
|
| 99 |
+
if _ci:
|
| 100 |
+
system += "\n\nAdditional instructions: " + _ci
|
| 101 |
+
try:
|
| 102 |
+
llm = _get_llm(model, temperature=0.2, max_tokens=380)
|
| 103 |
+
msgs = [{"role": "system", "content": system}]
|
| 104 |
+
msgs.extend(_build_history_messages(history))
|
| 105 |
+
msgs.append({"role": "user", "content": question})
|
| 106 |
+
response = await llm.ainvoke(msgs)
|
| 107 |
+
return response.content.strip()
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"_rag_answer error: {e}")
|
| 110 |
+
return (
|
| 111 |
+
"I'm having trouble generating an answer right now. "
|
| 112 |
+
f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _is_database_recommendation_question(question: str) -> bool:
|
| 117 |
+
return bool(DATABASE_RECOMMEND_RE.search(question or ""))
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _match_db_subject(q: str) -> Optional[str]:
|
| 121 |
+
checks = [
|
| 122 |
+
(["quantum computing", "quantum algorithm", "quantum cryptography", "quantum information"], "quantum_computing"),
|
| 123 |
+
(["robotics", "autonomous system", "intelligent system"], "robotics"),
|
| 124 |
+
(["artificial intelligence", "machine learning", "deep learning", "nlp", "natural language processing", "neural network", "cybersecurity", "computer science", "software engineering", "algorithm", "data structure"], "computer_science"),
|
| 125 |
+
(["computer engineering", "embedded system", "computer architecture", "networking", "digital system"], "computer_science"),
|
| 126 |
+
(["aerospace", "aerodynamic", "propulsion", "aircraft", "aviation", "flight system"], "aerospace"),
|
| 127 |
+
(["biomedical engineering", "medical device", "biomechanic", "bioinstrument", "tissue engineering", "biomedical imaging"], "biomedical"),
|
| 128 |
+
(["chemical engineering", "process engineering", "reaction engineering", "separation", "catalysis"], "chemical_engineering"),
|
| 129 |
+
(["chemistry", "organic chemistry", "analytical chemistry", "inorganic", "chemical", "chemical properties"], "chemistry"),
|
| 130 |
+
(["civil engineering", "structural engineering", "geotechnical", "transportation engineering", "water resources", "construction"], "civil_engineering"),
|
| 131 |
+
(["electrical engineering", "circuit", "power system", "signal processing", "control system", "communication system", "electronics"], "electrical_engineering"),
|
| 132 |
+
(["energy engineering", "renewable energy", "sustainable energy", "clean energy", "energy transition", "solar", "wind power"], "energy"),
|
| 133 |
+
(["engineering management", "engineering systems", "systems engineering", "operations management", "innovation management"], "engineering_management"),
|
| 134 |
+
(["mechanical engineering", "thermofluid", "thermodynamics", "fluid mechanics", "manufacturing", "mechatronics"], "mechanical_engineering"),
|
| 135 |
+
(["nuclear engineering", "reactor", "radiation", "nuclear material", "nuclear safety"], "nuclear"),
|
| 136 |
+
(["petroleum engineering", "oil and gas", "drilling", "reservoir", "subsurface", "petroleum"], "petroleum"),
|
| 137 |
+
(["earth science", "geology", "geophysics", "geochemistry", "planetary science", "geoscience"], "earth_science"),
|
| 138 |
+
(["environment", "sustainability", "climate change", "sustainable development", "conservation", "environmental policy", "ecology"], "environment"),
|
| 139 |
+
(["materials science", "nanotechnology", "nanomaterial", "biomaterial", "advanced material", "functional material"], "materials"),
|
| 140 |
+
(["cell biology", "molecular biology", "genetics", "genomics", "biotechnology", "bioinformatics", "microbiology", "virology"], "biology"),
|
| 141 |
+
(["medicine", "medical", "health science", "clinical", "nursing", "pharmacy", "biomedical literature", "pharmacology", "clinical trial", "systematic review", "evidence-based", "patient care"], "medical"),
|
| 142 |
+
(["business", "management", "finance", "economics", "accounting", "entrepreneurship", "marketing", "strategy", "leadership", "international relations", "political science", "social science", "humanities", "law", "education", "psychology", "sociology"], "business"),
|
| 143 |
+
(["physics", "optics", "photonics", "quantum physics", "condensed matter", "theoretical physics", "astrophysics"], "physics"),
|
| 144 |
+
(["mathematics", "statistics", "data science", "calculus", "algebra", "optimization", "probability", "mathematical model"], "mathematics"),
|
| 145 |
+
(["impact factor", "journal metrics", "citescore", "jcr", "quartile", "research impact", "scival", "bibliometric"], "metrics"),
|
| 146 |
+
(["dissertation", "thesis", "doctoral", "master thesis", "repository", "phd thesis"], "theses"),
|
| 147 |
+
]
|
| 148 |
+
for keywords, key in checks:
|
| 149 |
+
if any(kw in q for kw in keywords):
|
| 150 |
+
return key
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _extract_db_topic(question: str) -> str:
|
| 155 |
+
q = _normalize_whitespace(question or "")
|
| 156 |
+
patterns = [
|
| 157 |
+
r"^(best|good|recommended?)\s+databases?\s+for\s+",
|
| 158 |
+
r"^which\s+databases?\s+(for|should i use for|is best for|are best for)\s+",
|
| 159 |
+
r"^databases?\s+for\s+",
|
| 160 |
+
r"^where should i (search|start)\s+(for\s+)?",
|
| 161 |
+
r"^what (database|databases)\s+(for|should i use for)\s+",
|
| 162 |
+
]
|
| 163 |
+
lower = q.lower()
|
| 164 |
+
for pat in patterns:
|
| 165 |
+
m = re.match(pat, lower)
|
| 166 |
+
if m:
|
| 167 |
+
q = q[m.end():]
|
| 168 |
+
break
|
| 169 |
+
q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
|
| 170 |
+
q = _strip_resource_noise(q)
|
| 171 |
+
return _normalize_whitespace(q).strip(".?") or "this subject"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _database_recommendation_answer(question: str) -> str:
|
| 175 |
+
q = (question or "").lower()
|
| 176 |
+
subject = _extract_db_topic(question)
|
| 177 |
+
topic = _escape(subject)
|
| 178 |
+
subject_key = _match_db_subject(q)
|
| 179 |
+
dbs = _DB_SUBJECTS.get(subject_key, _DB_GENERAL) if subject_key else _DB_GENERAL
|
| 180 |
+
dbs = list(dbs)[:5]
|
| 181 |
+
answer = f"<strong>Recommended databases for {topic}</strong><br><br>"
|
| 182 |
+
answer += "Here are the strongest KU-subscribed starting points:<br>"
|
| 183 |
+
for name, url, why in dbs:
|
| 184 |
+
answer += f'<br>• <a href="{url}" target="_blank"><strong>{_escape(name)}</strong></a> — {_escape(why)}'
|
| 185 |
+
if not subject_key:
|
| 186 |
+
answer += (
|
| 187 |
+
"<br><br>💡 For subject-specific databases, ask me: "
|
| 188 |
+
"<em>\"best databases for [your subject]\"</em> — e.g. physics, chemistry, medicine, civil engineering."
|
| 189 |
+
)
|
| 190 |
+
answer += (
|
| 191 |
+
f'<br><br>Browse all 50+ KU databases: '
|
| 192 |
+
f'<a href="https://library.ku.ac.ae/eresources" target="_blank">library.ku.ac.ae/eresources</a>'
|
| 193 |
+
)
|
| 194 |
+
answer += "<br><br>Want me to run a direct search on any of these for a specific topic?"
|
| 195 |
+
return answer
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _fulltext_chain_answer(question: str) -> str:
|
| 199 |
+
return (
|
| 200 |
+
"<strong>📄 How to get full text for an article</strong><br><br>"
|
| 201 |
+
"Follow this chain in order — each step is faster than the next:<br><br>"
|
| 202 |
+
"<strong>Step 1 — Search KU PRIMO</strong><br>"
|
| 203 |
+
"Find the article in <a href=\"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 204 |
+
"?vid=971KUOSTAR_INST:KU\" target=\"_blank\">KU Library Discovery</a>. "
|
| 205 |
+
"If KU subscribes, you'll see a <em>Full Text Available</em> or <em>Online Access</em> link. "
|
| 206 |
+
"Click it — it passes through the IDM proxy automatically when on campus or logged in.<br><br>"
|
| 207 |
+
"<strong>Step 2 — Try LibKey Nomad (browser extension)</strong><br>"
|
| 208 |
+
"<a href=\"https://libkey.io/libraries/3025/\" target=\"_blank\">LibKey Nomad</a> is a free browser "
|
| 209 |
+
"extension that detects articles you're viewing and fetches KU's full text automatically. "
|
| 210 |
+
"Install it once and it works on every publisher site.<br><br>"
|
| 211 |
+
"<strong>Step 3 — Try the publisher or open access</strong><br>"
|
| 212 |
+
"Visit the DOI link directly. Many authors post their accepted manuscript on "
|
| 213 |
+
"<a href=\"https://arxiv.org\" target=\"_blank\">arXiv</a>, "
|
| 214 |
+
"<a href=\"https://www.researchgate.net\" target=\"_blank\">ResearchGate</a>, or their institutional page. "
|
| 215 |
+
"<a href=\"https://openalex.org\" target=\"_blank\">OpenAlex</a> also links to many legal open-access PDFs.<br><br>"
|
| 216 |
+
"<strong>Step 4 — Submit an Interlibrary Loan (ILL) request</strong><br>"
|
| 217 |
+
"If none of the above work, KU Library can borrow the article from another library — usually within 1–5 working days. "
|
| 218 |
+
"Submit here: <a href=\"https://library.ku.ac.ae/ill/\" target=\"_blank\">library.ku.ac.ae/ill/</a><br>"
|
| 219 |
+
"Contact: <strong>Suaad Al Jneibi</strong> · "
|
| 220 |
+
"<a href=\"mailto:suaad.aljneibi@ku.ac.ae\">suaad.aljneibi@ku.ac.ae</a> · +971 2 312 4278"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _theses_answer() -> str:
|
| 225 |
+
return (
|
| 226 |
+
"<strong>📘 Finding KU theses, dissertations, and institutional research</strong><br><br>"
|
| 227 |
+
"<strong>Khalifa University Khazna Repository</strong><br>"
|
| 228 |
+
"Khazna is KU's institutional repository — it holds KU theses, dissertations, "
|
| 229 |
+
"faculty publications, conference papers, and open-access research outputs.<br>"
|
| 230 |
+
'🔗 <a href="https://khazna.ku.ac.ae" target="_blank">khazna.ku.ac.ae</a><br><br>'
|
| 231 |
+
"<strong>ProQuest Dissertations & Theses Global</strong><br>"
|
| 232 |
+
"KU subscribes to ProQuest D&T Global — the world's largest database of "
|
| 233 |
+
"theses and dissertations from universities worldwide.<br>"
|
| 234 |
+
'🔗 <a href="https://khalifa.idm.oclc.org/login?url=https://www.proquest.com/pqdtglobal" target="_blank">'
|
| 235 |
+
"Access via KU Library</a><br><br>"
|
| 236 |
+
"<strong>Other sources</strong><br>"
|
| 237 |
+
'• <a href="https://ethos.bl.uk" target="_blank">EThOS</a> — UK theses (British Library)<br>'
|
| 238 |
+
'• <a href="https://www.dart-europe.org" target="_blank">DART-Europe</a> — European theses<br>'
|
| 239 |
+
'• <a href="https://ndltd.org" target="_blank">NDLTD</a> — Global networked digital library of theses<br><br>'
|
| 240 |
+
"For help with Khazna deposits or ORCID integration, contact: "
|
| 241 |
+
"<strong>Nikesh Narayanan</strong> · "
|
| 242 |
+
"<a href=\"mailto:nikesh.narayanan@ku.ac.ae\">nikesh.narayanan@ku.ac.ae</a>"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _library_follow_up(question: str) -> Tuple[str, List[dict]]:
|
| 247 |
+
q = (question or "").lower()
|
| 248 |
+
if _looks_library_hours_question(question):
|
| 249 |
+
return (
|
| 250 |
+
"Would you like the library contact details too?",
|
| 251 |
+
[
|
| 252 |
+
{"label": "Show library contact details", "question": "Show the library contact details"},
|
| 253 |
+
{"label": "Open the live library hours page", "question": "Open the live library hours page"},
|
| 254 |
+
],
|
| 255 |
+
)
|
| 256 |
+
if "study room" in q or "room" in q:
|
| 257 |
+
return (
|
| 258 |
+
"Would you like the study-room booking link?",
|
| 259 |
+
[
|
| 260 |
+
{"label": "Study-room booking link", "question": "Show the study-room booking link"},
|
| 261 |
+
{"label": "Library services for both campuses", "question": "Show library services for both campuses"},
|
| 262 |
+
],
|
| 263 |
+
)
|
| 264 |
+
if any(token in q for token in ["database", "databases", "search", "articles", "research"]):
|
| 265 |
+
return (
|
| 266 |
+
"Would you like me to recommend the best KU databases for this topic?",
|
| 267 |
+
[
|
| 268 |
+
{"label": "Recommend best KU databases", "question": "Recommend the best KU databases for this topic"},
|
| 269 |
+
{"label": "How to access databases off campus", "question": "How do I access databases off campus?"},
|
| 270 |
+
],
|
| 271 |
+
)
|
| 272 |
+
return (
|
| 273 |
+
"Would you like a related library service or staff contact?",
|
| 274 |
+
[
|
| 275 |
+
{"label": "Best staff contact", "question": "Show the best staff contact for this"},
|
| 276 |
+
{"label": "KU library page for this topic", "question": "Open the KU library page for this topic"},
|
| 277 |
+
],
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
|
src/agentcore/intents_search.py
CHANGED
|
@@ -1,891 +1,901 @@
|
|
| 1 |
-
"""search_academic / search_medical machinery: topic extraction, boolean building, URLs, research snapshot, follow-up state."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
|
| 19 |
-
from src.agentcore.models import ClientStatePayload, SearchContextPayload
|
| 20 |
-
from src.agentcore.scholarly import fetch_evidence_panel
|
| 21 |
-
from src.agentcore.constants import (
|
| 22 |
-
ALT_TERMS_RE,
|
| 23 |
-
CURRENT_YEAR,
|
| 24 |
-
REFINEMENT_STOP_WORDS,
|
| 25 |
-
REVIEW_ONLY_RE,
|
| 26 |
-
_BOOLEAN_SYSTEM,
|
| 27 |
-
_BUILD_SEARCH_PLAN_SYSTEM,
|
| 28 |
-
_GUARDRAIL,
|
| 29 |
-
_URL_INSTRUCTION,
|
| 30 |
-
)
|
| 31 |
-
from src.agentcore.utils import (
|
| 32 |
-
_clean_database_keywords,
|
| 33 |
-
_escape,
|
| 34 |
-
_get_llm,
|
| 35 |
-
_light_strip_retrieval_boilerplate,
|
| 36 |
-
_normalize_whitespace,
|
| 37 |
-
_primo_clean_url,
|
| 38 |
-
_sanitize_boolean_for_primo,
|
| 39 |
-
_shared_build_primo_boolean_query,
|
| 40 |
-
_strip_resource_noise,
|
| 41 |
-
_title_case_topic,
|
| 42 |
-
)
|
| 43 |
-
from src.agentcore.rendering import _ai_tools_footer, _search_trace_block, _tool_urls
|
| 44 |
-
|
| 45 |
-
logger = logging.getLogger(__name__)
|
| 46 |
-
|
| 47 |
-
def _extract_topic_regex(question: str) -> str:
|
| 48 |
-
q = _normalize_whitespace(question)
|
| 49 |
-
prefixes = [
|
| 50 |
-
"can u give me a deep analysis of ", "can you give me a deep analysis of ",
|
| 51 |
-
"give me a deep analysis of ", "deep analysis of ",
|
| 52 |
-
"give me a summary of ", "give me a summary on ", "give me a summary about ",
|
| 53 |
-
"give me an overview of ", "give me an overview on ",
|
| 54 |
-
"give me a brief overview of ", "give me a brief summary of ",
|
| 55 |
-
"summarize the latest studies in ", "summarize latest studies in ",
|
| 56 |
-
"summarize the latest research on ", "summarize latest research on ",
|
| 57 |
-
"summarize recent studies in ", "summarize recent research on ",
|
| 58 |
-
"latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
|
| 59 |
-
"summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
|
| 60 |
-
"overview of ", "overview on ", "brief overview of ", "brief summary of ",
|
| 61 |
-
"analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
|
| 62 |
-
"what is ", "what are ", "describe ", "review of ", "literature review on ",
|
| 63 |
-
"recent advances in ", "recent developments in ", "state of the art in ",
|
| 64 |
-
"state of research on ", "research overview of ", "research summary of ",
|
| 65 |
-
"find peer reviewed articles on ", "find peer reviewed papers on ",
|
| 66 |
-
"find articles on ", "find papers on ", "find books on ", "find articles about ",
|
| 67 |
-
"find papers about ", "search for articles on ", "search for papers on ",
|
| 68 |
-
"i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
|
| 69 |
-
"i need books on ", "give me articles on ", "show me articles on ",
|
| 70 |
-
"get me articles on ", "look for articles on ", "articles on ", "papers on ",
|
| 71 |
-
"books on ", "research on ", "literature on ", "find ", "search for ",
|
| 72 |
-
"i need ", "give me ", "show me ", "get me ",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
]
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
if
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
if
|
| 120 |
-
return
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
return
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
if
|
| 160 |
-
return
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
else:
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
"corrected": raw_query, "natural": light_query, "boolean": "",
|
| 250 |
-
"database_query": "", "year_from": "", "year_to": "",
|
| 251 |
-
"peer_reviewed": False, "open_access": False,
|
| 252 |
-
"clarification_needed": True,
|
| 253 |
-
"clarification_message": (
|
| 254 |
-
f"I want to make sure I search for the right thing. "
|
| 255 |
-
f"Could you clarify what <strong>{_escape(_display)}</strong> refers to? "
|
| 256 |
-
f"For example, is it a course code, a specific topic name, a year, "
|
| 257 |
-
f"or something else? The more detail you provide, "
|
| 258 |
-
f"the better I can build your search."
|
| 259 |
-
),
|
| 260 |
-
}
|
| 261 |
-
if settings.openai_api_key or settings.anthropic_api_key:
|
| 262 |
-
_valid, _corrected_topic = await _validate_topic(_stripped, model)
|
| 263 |
-
if not _valid:
|
| 264 |
-
return {
|
| 265 |
-
"corrected": raw_query, "natural": light_query, "boolean": "",
|
| 266 |
-
"database_query": "", "year_from": "", "year_to": "",
|
| 267 |
-
"peer_reviewed": False, "open_access": False,
|
| 268 |
-
"clarification_needed": True,
|
| 269 |
-
"clarification_message": (
|
| 270 |
-
f"I couldn't recognise <strong>{_escape(_stripped)}</strong> as a research topic. "
|
| 271 |
-
f"Could you check the spelling, or describe what you're looking for in more detail? "
|
| 272 |
-
f"For example: <em>\"find research on machine learning\"</em> or "
|
| 273 |
-
f"<em>\"articles on renewable energy in UAE\"</em>."
|
| 274 |
-
),
|
| 275 |
-
}
|
| 276 |
-
if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
|
| 277 |
-
logger.info(f"_validate_topic corrected: {_stripped!r} → {_corrected_topic!r}")
|
| 278 |
-
light_query = _corrected_topic
|
| 279 |
-
if settings.openai_api_key or settings.anthropic_api_key:
|
| 280 |
-
try:
|
| 281 |
-
llm = _get_llm(model, temperature=0, max_tokens=300)
|
| 282 |
-
response = await llm.ainvoke([
|
| 283 |
-
{"role": "system", "content": _BUILD_SEARCH_PLAN_SYSTEM},
|
| 284 |
-
{"role": "user", "content": f'Query: "{light_query}"'},
|
| 285 |
-
])
|
| 286 |
-
raw = response.content.strip()
|
| 287 |
-
if raw.startswith("```"):
|
| 288 |
-
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 289 |
-
s, e = raw.find("{"), raw.rfind("}")
|
| 290 |
-
if s != -1 and e > s:
|
| 291 |
-
result = json.loads(raw[s:e + 1])
|
| 292 |
-
else:
|
| 293 |
-
raise ValueError("No JSON found in response")
|
| 294 |
-
corrected = (result.get("corrected") or raw_query).strip() or raw_query
|
| 295 |
-
natural = (result.get("natural") or corrected).strip() or corrected
|
| 296 |
-
boolean = (result.get("boolean") or "").strip()
|
| 297 |
-
has_ops = bool(re.search(r"\b(AND|OR)\b", boolean)) if boolean else False
|
| 298 |
-
has_parens = "(" in boolean if boolean else False
|
| 299 |
-
if not has_ops or not has_parens:
|
| 300 |
-
boolean = _shared_build_primo_boolean_query(corrected)
|
| 301 |
-
boolean = _sanitize_boolean_for_primo(boolean)
|
| 302 |
-
year_from = str(result.get("year_from") or "").strip()
|
| 303 |
-
year_to = str(result.get("year_to") or "").strip()
|
| 304 |
-
if not year_from and not year_to:
|
| 305 |
-
yf, yt = _parse_year_filters(raw_query)
|
| 306 |
-
year_from = yf or ""
|
| 307 |
-
year_to = yt or ""
|
| 308 |
-
return {
|
| 309 |
-
"corrected": corrected,
|
| 310 |
-
"natural": natural,
|
| 311 |
-
"boolean": boolean,
|
| 312 |
-
"database_query": _clean_database_keywords(boolean),
|
| 313 |
-
"year_from": year_from,
|
| 314 |
-
"year_to": year_to,
|
| 315 |
-
"peer_reviewed": bool(result.get("peer_reviewed", False)),
|
| 316 |
-
"open_access": bool(result.get("open_access", False)),
|
| 317 |
-
}
|
| 318 |
-
except Exception as e:
|
| 319 |
-
logger.warning(f"_build_search_plan LLM failed: {e} — using regex fallback")
|
| 320 |
-
boolean = _shared_build_primo_boolean_query(light_query)
|
| 321 |
-
yf, yt = _parse_year_filters(raw_query)
|
| 322 |
-
pr = bool(re.search(r"\bpeer[- ]reviewed\b", raw_query, re.IGNORECASE))
|
| 323 |
-
oa = bool(re.search(r"\bopen[- ]access\b", raw_query, re.IGNORECASE))
|
| 324 |
-
return {
|
| 325 |
-
"corrected": raw_query,
|
| 326 |
-
"natural": light_query,
|
| 327 |
-
"boolean": boolean,
|
| 328 |
-
"database_query": _clean_database_keywords(boolean),
|
| 329 |
-
"year_from": yf or "",
|
| 330 |
-
"year_to": yt or "",
|
| 331 |
-
"peer_reviewed": pr,
|
| 332 |
-
"open_access": oa,
|
| 333 |
-
}
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
|
| 337 |
-
topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
|
| 338 |
-
q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
|
| 339 |
-
plan = await _build_search_plan(q, model)
|
| 340 |
-
if plan.get("clarification_needed"):
|
| 341 |
-
context.clarification_needed = True
|
| 342 |
-
context.clarification_message = plan.get("clarification_message", "")
|
| 343 |
-
return context
|
| 344 |
-
context.ai_tool_query = plan["natural"]
|
| 345 |
-
context.primo_boolean_query = plan["boolean"]
|
| 346 |
-
if not context.year_from and plan.get("year_from"):
|
| 347 |
-
context.year_from = plan["year_from"]
|
| 348 |
-
if not context.year_to and plan.get("year_to"):
|
| 349 |
-
context.year_to = plan["year_to"]
|
| 350 |
-
if not context.peer_reviewed and plan.get("peer_reviewed"):
|
| 351 |
-
context.peer_reviewed = True
|
| 352 |
-
if not context.open_access and plan.get("open_access"):
|
| 353 |
-
context.open_access = True
|
| 354 |
-
return context
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
async def _topic_intro(topic: str, model: str) -> str:
|
| 358 |
-
"""LLM #5 — 3-sentence topic intro. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 359 |
-
settings = get_settings()
|
| 360 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 361 |
-
return ""
|
| 362 |
-
try:
|
| 363 |
-
llm = _get_llm(model, temperature=0.2, max_tokens=180)
|
| 364 |
-
response = await llm.ainvoke([
|
| 365 |
-
{"role": "system", "content": (
|
| 366 |
-
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 367 |
-
"Write exactly 3 clear, factual sentences introducing the given research topic "
|
| 368 |
-
"for a university student or researcher. "
|
| 369 |
-
"Cover: what the topic is, why it matters, and one key area of current research interest. "
|
| 370 |
-
"Use HTML <br> for line breaks only if needed. No markdown, no bullet points, no headings. "
|
| 371 |
-
"Be concise and informative. "
|
| 372 |
-
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 373 |
-
)},
|
| 374 |
-
{"role": "user", "content": f"Research topic: {topic}"},
|
| 375 |
-
])
|
| 376 |
-
intro = response.content.strip()
|
| 377 |
-
return intro if intro else ""
|
| 378 |
-
except Exception as e:
|
| 379 |
-
logger.warning(f"_topic_intro failed: {e}")
|
| 380 |
-
return ""
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
|
| 385 |
-
if not papers:
|
| 386 |
-
return ""
|
| 387 |
-
statements: List[str] = []
|
| 388 |
-
for paper in papers[:4]:
|
| 389 |
-
sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
|
| 390 |
-
lead = sentences[0].strip() if sentences else ""
|
| 391 |
-
if not lead:
|
| 392 |
-
continue
|
| 393 |
-
citation = (
|
| 394 |
-
f'<a href="{paper["link"]}" target="_blank" '
|
| 395 |
-
f'style="color:#1e40af;font-weight:600;text-decoration:none">'
|
| 396 |
-
f'[{_escape(paper["citation_key"])}]</a>'
|
| 397 |
-
)
|
| 398 |
-
statements.append(f"{_escape(lead)} {citation}")
|
| 399 |
-
if not statements:
|
| 400 |
-
return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
|
| 401 |
-
intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
|
| 402 |
-
return intro + "<br><br>".join(statements[:4])
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
def _database_guidance_for_topic(context: SearchContextPayload) -> str:
|
| 406 |
-
topic = _escape(context.display_topic or context.topic)
|
| 407 |
-
urls = _tool_urls(context)
|
| 408 |
-
ai_tools = (
|
| 409 |
-
f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> · '
|
| 410 |
-
f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> · '
|
| 411 |
-
f'<a href="{urls["ebsco_ai"]}" target="_blank" style="color:#9d174d;font-weight:700;text-decoration:none">EBSCO AI</a> · '
|
| 412 |
-
f'<a href="{urls["primo_ai"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO AI Assistant</a> · '
|
| 413 |
-
f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a>'
|
| 414 |
-
)
|
| 415 |
-
more_results = f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
|
| 416 |
-
if context.intent == "search_medical" and urls.get("pubmed"):
|
| 417 |
-
more_results += f' · <a href="{urls["pubmed"]}" target="_blank" style="color:#065f46;font-weight:700;text-decoration:none">PubMed</a>'
|
| 418 |
-
return (
|
| 419 |
-
f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
|
| 420 |
-
f"For more results, use {more_results}. Medical topics are strongest in PubMed, Embase, and CINAHL."
|
| 421 |
-
)
|
| 422 |
-
return (
|
| 423 |
-
f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
|
| 424 |
-
f"For more results, use {more_results}."
|
| 425 |
-
)
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]:
|
| 429 |
-
plan = await _build_search_plan(question, model)
|
| 430 |
-
topic = _escape(_light_strip_retrieval_boilerplate(question) or question)
|
| 431 |
-
boolean = _escape(plan.get("boolean", ""))
|
| 432 |
-
natural = _escape(plan.get("natural", ""))
|
| 433 |
-
explanation = (
|
| 434 |
-
f"<strong>🔍 Search strategy for: {topic}</strong><br><br>"
|
| 435 |
-
f"<strong>1. Identify your key concepts</strong><br>"
|
| 436 |
-
f"Break the topic into 2–3 core concepts. For your question, I identified these concepts "
|
| 437 |
-
f"and suggested synonyms for each using OR, then joined concepts with AND:<br><br>"
|
| 438 |
-
f'<code style="display:block;padding:8px 12px;background:#1a1a2e;color:#C8A951;'
|
| 439 |
-
f'border-radius:8px;font-size:.82rem;word-break:break-all">{boolean}</code><br>'
|
| 440 |
-
f"<strong>Why AND and OR?</strong> AND narrows — both concepts must appear. "
|
| 441 |
-
f"OR broadens — any synonym counts. Quoting phrases like "
|
| 442 |
-
f"<code>\"machine learning\"</code> keeps them together as an exact phrase.<br><br>"
|
| 443 |
-
f"<strong>2. Apply filters</strong><br>"
|
| 444 |
-
f"Use database facets to limit by: peer-reviewed, date range, document type (article / review / book), "
|
| 445 |
-
f"language, or open access. In PRIMO, these appear in the left sidebar after you search.<br><br>"
|
| 446 |
-
f"<strong>3. Natural language query for AI tools</strong><br>"
|
| 447 |
-
f"For AI-powered tools (LeapSpace, Scopus AI, Consensus), use a conversational query:<br>"
|
| 448 |
-
f'<em style="color:var(--color-text-secondary)">{natural}</em><br><br>'
|
| 449 |
-
f"<strong>4. Iterate</strong><br>"
|
| 450 |
-
f"Too many results → add more AND terms or apply filters. "
|
| 451 |
-
f"Too few → remove an AND group or use broader synonyms. "
|
| 452 |
-
f"You can ask me to refine: <em>\"narrow to peer-reviewed only\"</em>, "
|
| 453 |
-
f"<em>\"limit to last 5 years\"</em>, or <em>\"suggest alternative keywords\"</em>."
|
| 454 |
-
)
|
| 455 |
-
return explanation, plan
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
async def _alternative_terms_answer(question: str, context: Optional[SearchContextPayload], model: str) -> str:
|
| 459 |
-
topic = (context.display_topic or context.topic) if context else question
|
| 460 |
-
settings = get_settings()
|
| 461 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 462 |
-
return (
|
| 463 |
-
f"<strong>Alternative search terms for: {_escape(topic)}</strong><br><br>"
|
| 464 |
-
"Try combining these broader, narrower, and related terms:<br>"
|
| 465 |
-
"• Use broader terms if too few results<br>"
|
| 466 |
-
"• Use narrower/specific terms if too many results<br>"
|
| 467 |
-
"• Try acronyms and full forms (e.g. AI / Artificial Intelligence)<br>"
|
| 468 |
-
"• Include British and American spellings (e.g. organisation/organization)"
|
| 469 |
-
)
|
| 470 |
-
try:
|
| 471 |
-
llm = _get_llm(model, temperature=0.4, max_tokens=300)
|
| 472 |
-
response = await llm.ainvoke([
|
| 473 |
-
{"role": "system", "content": (
|
| 474 |
-
"You are an academic librarian. Given a research topic, generate a structured list of alternative "
|
| 475 |
-
"search terms. Group them as: Broader terms, Narrower/specific terms, Related concepts, Acronyms/abbreviations. "
|
| 476 |
-
"Format as HTML using <strong> for group labels and <br> for line breaks. "
|
| 477 |
-
"Keep it concise — 3-4 terms per group maximum. No bullet points, use • instead."
|
| 478 |
-
)},
|
| 479 |
-
{"role": "user", "content": f"Research topic: {topic}"},
|
| 480 |
-
])
|
| 481 |
-
terms = response.content.strip()
|
| 482 |
-
return (
|
| 483 |
-
f"<strong>💡 Alternative search terms for: {_escape(topic)}</strong><br><br>"
|
| 484 |
-
f"{terms}<br><br>"
|
| 485 |
-
f"<strong>Tip:</strong> In PRIMO or databases, use OR between synonyms within a concept group, "
|
| 486 |
-
f"and AND between different concept groups. Ask me to run a new search with any of these."
|
| 487 |
-
)
|
| 488 |
-
except Exception as e:
|
| 489 |
-
logger.warning(f"_alternative_terms_answer failed: {e}")
|
| 490 |
-
return f"<strong>Alternative terms for {_escape(topic)}</strong><br><br>Try synonyms, acronyms, broader/narrower terms, and related concepts in your search."
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
def _citation_chain_answer() -> str:
|
| 494 |
-
return (
|
| 495 |
-
"<strong>🔗 How to trace citations forward and backward from a paper</strong><br><br>"
|
| 496 |
-
"<strong>Backward citation (who does this paper cite?)</strong><br>"
|
| 497 |
-
"Read the paper's reference list — every source it cites is a potential lead. "
|
| 498 |
-
"This gives you foundational and seminal works on the topic.<br><br>"
|
| 499 |
-
"<strong>Forward citation (who has cited this paper since publication?)</strong><br>"
|
| 500 |
-
"Use these tools — paste in the DOI or title:<br>"
|
| 501 |
-
'• <a href="https://www-scopus-com.khalifa.idm.oclc.org/pages/ai" target="_blank"><strong>Scopus</strong></a> '
|
| 502 |
-
'— search the article, then click "Cited by N documents"<br>'
|
| 503 |
-
'• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
|
| 504 |
-
'— search the article, click "Times Cited"<br>'
|
| 505 |
-
'• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
|
| 506 |
-
'— free, excellent for CS and engineering, shows "Citations" tab<br>'
|
| 507 |
-
'• <a href="https://openalex.org" target="_blank"><strong>OpenAlex</strong></a> '
|
| 508 |
-
'— fully open, API-accessible citation graph<br><br>'
|
| 509 |
-
"<strong>Lateral search (similar papers)</strong><br>"
|
| 510 |
-
"In PRIMO, use <em>\"Find Similar\"</em>. In Semantic Scholar, use <em>\"Recommended Papers\"</em>. "
|
| 511 |
-
"In Scopus, use <em>\"Related Documents\"</em>.<br><br>"
|
| 512 |
-
"<strong>Tip:</strong> Start with one highly cited foundational paper, trace forward to find the newest "
|
| 513 |
-
"work, and backward to understand the theoretical roots."
|
| 514 |
-
)
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
def _predatory_eval_answer() -> str:
|
| 518 |
-
return (
|
| 519 |
-
"<strong>✅ How to tell if an article or journal is peer-reviewed, scholarly, or predatory</strong><br><br>"
|
| 520 |
-
"<strong>Is it peer-reviewed?</strong><br>"
|
| 521 |
-
"• Check the journal's website for a peer-review statement or editorial process description<br>"
|
| 522 |
-
"• In PRIMO, tick the <em>Peer-reviewed</em> filter in the left sidebar<br>"
|
| 523 |
-
"• Check if the journal is indexed in "
|
| 524 |
-
'<a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank">Scopus</a> or '
|
| 525 |
-
'<a href="https://www.webofscience.com" target="_blank">Web of Science</a> — '
|
| 526 |
-
"indexed = generally peer-reviewed<br><br>"
|
| 527 |
-
"<strong>Is it a legitimate journal?</strong><br>"
|
| 528 |
-
"• <a href=\"https://doaj.org\" target=\"_blank\"><strong>DOAJ</strong></a> "
|
| 529 |
-
"— Directory of Open Access Journals (vetted, legitimate OA)<br>"
|
| 530 |
-
"• <a href=\"https://mjl.clarivate.com\" target=\"_blank\"><strong>Web of Science Master Journal List</strong></a><br>"
|
| 531 |
-
"• <a href=\"https://www.scopus.com/sources\" target=\"_blank\"><strong>Scopus Source List</strong></a><br>"
|
| 532 |
-
"• Think · Check · Submit: <a href=\"https://thinkchecksubmit.org\" target=\"_blank\">thinkchecksubmit.org</a> "
|
| 533 |
-
"— a checklist to assess any journal<br><br>"
|
| 534 |
-
"<strong>Warning signs of predatory journals</strong><br>"
|
| 535 |
-
"• Unsolicited email invitation to submit<br>"
|
| 536 |
-
"• No clear peer-review process or very fast acceptance (days)<br>"
|
| 537 |
-
"• High article processing charges (APCs) with no clear metrics<br>"
|
| 538 |
-
"• Not indexed in Scopus or Web of Science<br>"
|
| 539 |
-
"• Generic or misleading journal name ('International Journal of...')<br><br>"
|
| 540 |
-
"<strong>Need help checking a specific journal?</strong> Ask our E-Resources Librarian: "
|
| 541 |
-
"<strong>Rani Anand</strong> · <a href=\"mailto:rani.anand@ku.ac.ae\">rani.anand@ku.ac.ae</a>"
|
| 542 |
-
)
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
def _highly_cited_note(topic_escaped: str) -> str:
|
| 546 |
-
return (
|
| 547 |
-
f"<br><br><strong>📊 Finding highly cited papers on {topic_escaped}</strong><br>"
|
| 548 |
-
"PRIMO doesn't sort by citation count, but these tools do:<br>"
|
| 549 |
-
'• <a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank"><strong>Scopus</strong></a> '
|
| 550 |
-
'— search your topic → Sort by <em>Cited by (highest)</em><br>'
|
| 551 |
-
'• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
|
| 552 |
-
'— search → Sort by <em>Times Cited</em><br>'
|
| 553 |
-
'• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
|
| 554 |
-
'— free, sort by <em>Citation Count</em><br>'
|
| 555 |
-
'• <a href="https://scholar.google.com" target="_blank"><strong>Google Scholar</strong></a> '
|
| 556 |
-
'— sort by <em>Cited by</em> (broader but includes grey literature)'
|
| 557 |
-
)
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
|
| 561 |
-
"""Research snapshot = prepared platform links + a live open-evidence panel.
|
| 562 |
-
|
| 563 |
-
The evidence panel queries OpenAlex first (Semantic Scholar as fallback),
|
| 564 |
-
then enriches results with legal open-access links via Unpaywall and fills
|
| 565 |
-
metadata gaps via Crossref. Every external call is best-effort: if the open
|
| 566 |
-
indexes are unreachable the handler degrades to the prepared-links block
|
| 567 |
-
(the entire pre-3.8 behaviour), so the user always gets an answer.
|
| 568 |
-
"""
|
| 569 |
-
topic = context.topic
|
| 570 |
-
primo_url = _primo_clean_url(context)
|
| 571 |
-
answer = _search_trace_block(f"Research on {topic}", context)
|
| 572 |
-
answer += (
|
| 573 |
-
f"<strong>📚 Research starting points: {_escape(topic)}</strong><br><br>"
|
| 574 |
-
"Your query has been prepared and pre-loaded across multiple platforms. "
|
| 575 |
-
"Click any platform below to search instantly, or use the boolean search tip "
|
| 576 |
-
"to search directly in any KU database."
|
| 577 |
-
f'<br><br><a href="{primo_url}" target="_blank" style="color:#003366;font-weight:700">'
|
| 578 |
-
f'Search PRIMO for: {_escape(topic)} →</a>'
|
| 579 |
-
)
|
| 580 |
-
|
| 581 |
-
# Shared open-evidence panel (see scholarly.fetch_evidence_panel)
|
| 582 |
-
papers, panel_html = await fetch_evidence_panel(topic)
|
| 583 |
-
answer += panel_html
|
| 584 |
-
|
| 585 |
-
answer += _ai_tools_footer(context)
|
| 586 |
-
return answer, papers, []
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
def _filters_summary(context: SearchContextPayload) -> str:
|
| 590 |
-
filters: List[str] = []
|
| 591 |
-
if context.peer_reviewed:
|
| 592 |
-
filters.append("peer reviewed only")
|
| 593 |
-
if context.open_access:
|
| 594 |
-
filters.append("open access")
|
| 595 |
-
if context.year_from and context.year_to:
|
| 596 |
-
filters.append(f"{context.year_from}–{context.year_to}")
|
| 597 |
-
elif context.year_from:
|
| 598 |
-
filters.append(f"from {context.year_from}")
|
| 599 |
-
if context.resource_type == "books":
|
| 600 |
-
filters.append("books")
|
| 601 |
-
elif context.resource_type == "articles":
|
| 602 |
-
filters.append("articles")
|
| 603 |
-
elif context.resource_type == "both":
|
| 604 |
-
filters.append("articles and books")
|
| 605 |
-
return ", ".join(filters)
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
def _question_has_new_topic(question: str, base_topic: str) -> bool:
|
| 609 |
-
q = (question or "").lower()
|
| 610 |
-
cleaned = re.sub(
|
| 611 |
-
r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
|
| 612 |
-
r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
|
| 613 |
-
" ", q,
|
| 614 |
-
)
|
| 615 |
-
tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
|
| 616 |
-
if len(tokens) < 3:
|
| 617 |
-
return False
|
| 618 |
-
base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
|
| 619 |
-
overlap = sum(1 for t in tokens if t in base_tokens)
|
| 620 |
-
return overlap <= max(1, min(2, len(base_tokens)))
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
def _parse_refinement_action(question: str) -> Optional[str]:
|
| 624 |
-
q = (question or "").lower().strip()
|
| 625 |
-
if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
|
| 626 |
-
return "peer_reviewed_only"
|
| 627 |
-
if re.search(r"\b(open access|oa only|only open access)\b", q):
|
| 628 |
-
return "open_access_only"
|
| 629 |
-
if re.search(r"\b(last|past)\s+5\s+years?\b", q):
|
| 630 |
-
return "last_5_years"
|
| 631 |
-
if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
|
| 632 |
-
return "last_10_years"
|
| 633 |
-
m = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
|
| 634 |
-
if m:
|
| 635 |
-
return f"last_{m.group(2)}_years"
|
| 636 |
-
if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
|
| 637 |
-
return "books_only"
|
| 638 |
-
if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
|
| 639 |
-
return "articles_only"
|
| 640 |
-
if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
|
| 641 |
-
return "both_resources"
|
| 642 |
-
if REVIEW_ONLY_RE.search(q):
|
| 643 |
-
return "review_articles_only"
|
| 644 |
-
if ALT_TERMS_RE.search(q):
|
| 645 |
-
return "alt_terms"
|
| 646 |
-
if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
|
| 647 |
-
_STOP = {
|
| 648 |
-
'a','an','the','of','on','in','for','to','with','by','from','at','is','are',
|
| 649 |
-
'was','were','be','been','have','has','had','do','does','did','will','would',
|
| 650 |
-
'could','should','may','its','this','that','these','those','about','me','my',
|
| 651 |
-
'give','show','tell','please','can','you','i','need','want','get','find',
|
| 652 |
-
'research','summary','overview','summarize','summarise','brief','literature',
|
| 653 |
-
}
|
| 654 |
-
content_words = [t for t in re.findall(r'[a-z0-9]+', q)
|
| 655 |
-
if t not in _STOP and (len(t) > 2 or t in {'ai','ml','nlp','cv','rl','dl','uae','ku','iot'})]
|
| 656 |
-
if content_words:
|
| 657 |
-
return None
|
| 658 |
-
return "summarize_topic"
|
| 659 |
-
if re.search(r"\bpubmed\b", q):
|
| 660 |
-
return "search_pubmed"
|
| 661 |
-
if re.search(r"\bprimo\b", q):
|
| 662 |
-
return "search_primo"
|
| 663 |
-
if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
|
| 664 |
-
return "best_databases"
|
| 665 |
-
if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
|
| 666 |
-
return "deep_research_tools"
|
| 667 |
-
return None
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
|
| 671 |
-
if not client_state or not client_state.recent_search_contexts:
|
| 672 |
-
return None
|
| 673 |
-
contexts = list(client_state.recent_search_contexts)
|
| 674 |
-
if client_state.follow_up_context_id:
|
| 675 |
-
for ctx in contexts:
|
| 676 |
-
if ctx.context_id == client_state.follow_up_context_id:
|
| 677 |
-
return ctx
|
| 678 |
-
if client_state.active_search_context_id:
|
| 679 |
-
for ctx in contexts:
|
| 680 |
-
if ctx.context_id == client_state.active_search_context_id:
|
| 681 |
-
return ctx
|
| 682 |
-
contexts.sort(key=lambda c: c.created_at, reverse=True)
|
| 683 |
-
return contexts[0]
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
def _detect_follow_up(
|
| 687 |
-
question: str, client_state: Optional[ClientStatePayload]
|
| 688 |
-
) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
|
| 689 |
-
base_context = _resolve_base_context(client_state)
|
| 690 |
-
if not base_context:
|
| 691 |
-
return False, None, None
|
| 692 |
-
explicit_action = (client_state.follow_up_action if client_state else None) or None
|
| 693 |
-
if explicit_action:
|
| 694 |
-
return True, explicit_action, base_context
|
| 695 |
-
action = _parse_refinement_action(question)
|
| 696 |
-
if not action:
|
| 697 |
-
return False, None, None
|
| 698 |
-
if _question_has_new_topic(question, base_context.topic):
|
| 699 |
-
return False, None, None
|
| 700 |
-
return True, action, base_context
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
|
| 704 |
-
return SearchContextPayload.model_validate(base_context.model_dump())
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
|
| 708 |
-
context = _clone_context(base_context)
|
| 709 |
-
context.context_id = str(uuid.uuid4())
|
| 710 |
-
context.created_at = time.time()
|
| 711 |
-
if action == "peer_reviewed_only":
|
| 712 |
-
context.peer_reviewed = True
|
| 713 |
-
elif action == "open_access_only":
|
| 714 |
-
context.open_access = True
|
| 715 |
-
elif action == "last_5_years":
|
| 716 |
-
context.year_from = str(CURRENT_YEAR - 4)
|
| 717 |
-
context.year_to = str(CURRENT_YEAR)
|
| 718 |
-
elif action == "last_10_years":
|
| 719 |
-
context.year_from = str(CURRENT_YEAR - 9)
|
| 720 |
-
context.year_to = str(CURRENT_YEAR)
|
| 721 |
-
elif action == "last_3_years":
|
| 722 |
-
context.year_from = str(CURRENT_YEAR - 2)
|
| 723 |
-
context.year_to = str(CURRENT_YEAR)
|
| 724 |
-
elif action == "last_2_years":
|
| 725 |
-
context.year_from = str(CURRENT_YEAR - 1)
|
| 726 |
-
context.year_to = str(CURRENT_YEAR)
|
| 727 |
-
elif action and action.startswith("last_") and action.endswith("_years"):
|
| 728 |
-
try:
|
| 729 |
-
n = int(action.split("_")[1])
|
| 730 |
-
context.year_from = str(CURRENT_YEAR - n + 1)
|
| 731 |
-
context.year_to = str(CURRENT_YEAR)
|
| 732 |
-
except (IndexError, ValueError):
|
| 733 |
-
pass
|
| 734 |
-
elif action == "books_only":
|
| 735 |
-
context.resource_type = "books"
|
| 736 |
-
elif action == "articles_only":
|
| 737 |
-
context.resource_type = "articles"
|
| 738 |
-
elif action == "both_resources":
|
| 739 |
-
context.resource_type = "both"
|
| 740 |
-
elif action == "review_articles_only":
|
| 741 |
-
context.resource_type = "articles"
|
| 742 |
-
context.peer_reviewed = True
|
| 743 |
-
elif action == "search_pubmed":
|
| 744 |
-
context.source = "pubmed"
|
| 745 |
-
context.intent = "search_medical"
|
| 746 |
-
context.resource_type = "articles"
|
| 747 |
-
elif action == "search_primo":
|
| 748 |
-
context.source = "primo"
|
| 749 |
-
return context
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
|
| 753 |
-
settings = get_settings()
|
| 754 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 755 |
-
return []
|
| 756 |
-
try:
|
| 757 |
-
llm = _get_llm(model, temperature=0.5, max_tokens=200)
|
| 758 |
-
response = await llm.ainvoke([
|
| 759 |
-
{"role": "system", "content": (
|
| 760 |
-
"You are a research librarian helping a user explore a topic more deeply. "
|
| 761 |
-
"Given a research topic, generate exactly 3 short follow-up questions that explore "
|
| 762 |
-
"DIFFERENT ASPECTS of the topic — such as subtopics, methodological angles, "
|
| 763 |
-
"applications, comparisons, or related fields. "
|
| 764 |
-
"Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
|
| 765 |
-
"Return ONLY a JSON array of 3 strings, each under 12 words. "
|
| 766 |
-
"No explanation, no preamble, no markdown.\n"
|
| 767 |
-
"Example for 'quantum computing':\n"
|
| 768 |
-
'[\"What are the main hardware approaches in quantum computing?\", '
|
| 769 |
-
'"How does quantum error correction work?\", '
|
| 770 |
-
'"Applications of quantum computing in cryptography\"]'
|
| 771 |
-
)},
|
| 772 |
-
{"role": "user", "content": f"Topic: {topic}"},
|
| 773 |
-
])
|
| 774 |
-
raw = response.content.strip()
|
| 775 |
-
if raw.startswith("```"):
|
| 776 |
-
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 777 |
-
questions = json.loads(raw)
|
| 778 |
-
if not isinstance(questions, list):
|
| 779 |
-
return []
|
| 780 |
-
return [
|
| 781 |
-
{"label": q.strip("?") + "?", "question": q.strip("?") + "?"}
|
| 782 |
-
for q in questions[:3]
|
| 783 |
-
if isinstance(q, str) and q.strip()
|
| 784 |
-
]
|
| 785 |
-
except Exception as e:
|
| 786 |
-
logger.warning(f"Topic follow-up generation failed: {e}")
|
| 787 |
-
return []
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
async def _search_follow_up(
|
| 791 |
-
context: SearchContextPayload, model: str, summary_mode: bool = False
|
| 792 |
-
) -> Tuple[str, List[dict]]:
|
| 793 |
-
topic = context.display_topic or context.topic
|
| 794 |
-
suggestions: List[dict] = []
|
| 795 |
-
topic_follow_ups = await _generate_topic_follow_ups(topic, model)
|
| 796 |
-
suggestions.extend(topic_follow_ups)
|
| 797 |
-
|
| 798 |
-
filter_suggestions: List[dict] = []
|
| 799 |
-
if summary_mode:
|
| 800 |
-
filter_suggestions.append({
|
| 801 |
-
"label": f"Show KU-accessible articles on {topic}",
|
| 802 |
-
"question": f"Find KU-accessible articles on {topic}",
|
| 803 |
-
"action": "articles_only",
|
| 804 |
-
"context_id": context.context_id,
|
| 805 |
-
})
|
| 806 |
-
if context.intent == "search_medical" and context.source != "pubmed":
|
| 807 |
-
filter_suggestions.append({
|
| 808 |
-
"label": "Search PubMed instead",
|
| 809 |
-
"question": f"Search PubMed for {topic}",
|
| 810 |
-
"action": "search_pubmed",
|
| 811 |
-
"context_id": context.context_id,
|
| 812 |
-
})
|
| 813 |
-
filter_suggestions.append({
|
| 814 |
-
"label": "Research snapshot",
|
| 815 |
-
"question": f"Give me a brief research snapshot on {topic}",
|
| 816 |
-
"action": "summarize_topic",
|
| 817 |
-
"context_id": context.context_id,
|
| 818 |
-
})
|
| 819 |
-
|
| 820 |
-
suggestions.extend(filter_suggestions[:2])
|
| 821 |
-
question_text = (
|
| 822 |
-
f"Here are some related angles you might want to explore on <strong>{_escape(topic)}</strong>. "
|
| 823 |
-
"Or I can refine the search with filters — just ask."
|
| 824 |
-
)
|
| 825 |
-
return question_text, suggestions[:5]
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
|
| 829 |
-
topic = _escape(context.display_topic or context.topic)
|
| 830 |
-
resource = {"books": "books", "articles": "articles", "both": "articles and books"}.get(context.resource_type, "articles")
|
| 831 |
-
location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
|
| 832 |
-
if is_follow_up:
|
| 833 |
-
action = f"I updated your previous search on <strong>{topic}</strong> in the <strong>{location}</strong> and looked for <strong>{resource}</strong>."
|
| 834 |
-
else:
|
| 835 |
-
action = f"I searched for <strong>{resource}</strong> on <strong>{topic}</strong>."
|
| 836 |
-
return action
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
async def _run_search_mode(
|
| 840 |
-
question: str, context: SearchContextPayload, model: str, is_follow_up: bool
|
| 841 |
-
) -> Tuple[str, List[dict], List[dict], str]:
|
| 842 |
-
"""
|
| 843 |
-
Search mode — builds PRIMO link + 3-sentence topic intro + AI tools footer.
|
| 844 |
-
No external academic API calls (v3.8).
|
| 845 |
-
Includes clarification gate for ambiguous/gibberish queries.
|
| 846 |
-
"""
|
| 847 |
-
context = await _prepare_queries(question, context, model, is_follow_up)
|
| 848 |
-
|
| 849 |
-
# Clarification gate
|
| 850 |
-
if context.clarification_needed and context.clarification_message:
|
| 851 |
-
return context.clarification_message, [], [], ""
|
| 852 |
-
|
| 853 |
-
source_url = _primo_clean_url(context)
|
| 854 |
-
topic = _escape(context.display_topic or context.topic)
|
| 855 |
-
|
| 856 |
-
# 3-sentence topic intro AND the open-evidence panel, fetched concurrently:
|
| 857 |
-
# the panel's network time hides inside the intro's LLM latency instead of
|
| 858 |
-
# adding to it. Both degrade independently if they fail.
|
| 859 |
-
intro, (papers, panel_html) = await asyncio.gather(
|
| 860 |
-
_topic_intro(context.display_topic or context.topic, model),
|
| 861 |
-
fetch_evidence_panel(context.display_topic or context.topic),
|
| 862 |
-
)
|
| 863 |
-
|
| 864 |
-
answer = _search_trace_block(question, context)
|
| 865 |
-
if intro:
|
| 866 |
-
answer += f'<div style="margin-bottom:12px;color:#374151;font-size:.88rem;line-height:1.7">{intro}</div>'
|
| 867 |
-
answer += _search_answer_intro(context, is_follow_up)
|
| 868 |
-
answer += (
|
| 869 |
-
f'<br><br>'
|
| 870 |
-
f'<a href="{source_url}" target="_blank" '
|
| 871 |
-
f'style="display:inline-block;padding:9px 18px;background:#003366;color:#fff;'
|
| 872 |
-
f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
|
| 873 |
-
f'🔍 Search PRIMO Library Discovery for: {topic} →</a>'
|
| 874 |
-
f'<br><div style="margin-top:6px;font-size:.78rem;color:#6b7280">'
|
| 875 |
-
f'💡 Inside PRIMO you can filter by <strong>Articles</strong>, <strong>Books</strong>, '
|
| 876 |
-
f'<strong>Peer Reviewed</strong>, date range, and more.</div>'
|
| 877 |
-
)
|
| 878 |
-
answer += panel_html
|
| 879 |
-
answer += _ai_tools_footer(context)
|
| 880 |
-
|
| 881 |
-
return answer, papers, [], source_url
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
|
| 885 |
-
dedup: Dict[str, SearchContextPayload] = {}
|
| 886 |
-
for ctx in contexts:
|
| 887 |
-
dedup[ctx.context_id] = ctx
|
| 888 |
-
ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
|
| 889 |
-
return ordered[:5]
|
| 890 |
-
|
| 891 |
-
|
|
|
|
| 1 |
+
"""search_academic / search_medical machinery: topic extraction, boolean building, URLs, research snapshot, follow-up state."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
|
| 19 |
+
from src.agentcore.models import ClientStatePayload, SearchContextPayload
|
| 20 |
+
from src.agentcore.scholarly import fetch_evidence_panel
|
| 21 |
+
from src.agentcore.constants import (
|
| 22 |
+
ALT_TERMS_RE,
|
| 23 |
+
CURRENT_YEAR,
|
| 24 |
+
REFINEMENT_STOP_WORDS,
|
| 25 |
+
REVIEW_ONLY_RE,
|
| 26 |
+
_BOOLEAN_SYSTEM,
|
| 27 |
+
_BUILD_SEARCH_PLAN_SYSTEM,
|
| 28 |
+
_GUARDRAIL,
|
| 29 |
+
_URL_INSTRUCTION,
|
| 30 |
+
)
|
| 31 |
+
from src.agentcore.utils import (
|
| 32 |
+
_clean_database_keywords,
|
| 33 |
+
_escape,
|
| 34 |
+
_get_llm,
|
| 35 |
+
_light_strip_retrieval_boilerplate,
|
| 36 |
+
_normalize_whitespace,
|
| 37 |
+
_primo_clean_url,
|
| 38 |
+
_sanitize_boolean_for_primo,
|
| 39 |
+
_shared_build_primo_boolean_query,
|
| 40 |
+
_strip_resource_noise,
|
| 41 |
+
_title_case_topic,
|
| 42 |
+
)
|
| 43 |
+
from src.agentcore.rendering import _ai_tools_footer, _search_trace_block, _tool_urls
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
def _extract_topic_regex(question: str) -> str:
|
| 48 |
+
q = _normalize_whitespace(question)
|
| 49 |
+
prefixes = [
|
| 50 |
+
"can u give me a deep analysis of ", "can you give me a deep analysis of ",
|
| 51 |
+
"give me a deep analysis of ", "deep analysis of ",
|
| 52 |
+
"give me a summary of ", "give me a summary on ", "give me a summary about ",
|
| 53 |
+
"give me an overview of ", "give me an overview on ",
|
| 54 |
+
"give me a brief overview of ", "give me a brief summary of ",
|
| 55 |
+
"summarize the latest studies in ", "summarize latest studies in ",
|
| 56 |
+
"summarize the latest research on ", "summarize latest research on ",
|
| 57 |
+
"summarize recent studies in ", "summarize recent research on ",
|
| 58 |
+
"latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
|
| 59 |
+
"summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
|
| 60 |
+
"overview of ", "overview on ", "brief overview of ", "brief summary of ",
|
| 61 |
+
"analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
|
| 62 |
+
"what is ", "what are ", "describe ", "review of ", "literature review on ",
|
| 63 |
+
"recent advances in ", "recent developments in ", "state of the art in ",
|
| 64 |
+
"state of research on ", "research overview of ", "research summary of ",
|
| 65 |
+
"find peer reviewed articles on ", "find peer reviewed papers on ",
|
| 66 |
+
"find articles on ", "find papers on ", "find books on ", "find articles about ",
|
| 67 |
+
"find papers about ", "search for articles on ", "search for papers on ",
|
| 68 |
+
"i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
|
| 69 |
+
"i need books on ", "give me articles on ", "show me articles on ",
|
| 70 |
+
"get me articles on ", "look for articles on ", "articles on ", "papers on ",
|
| 71 |
+
"books on ", "research on ", "literature on ", "find ", "search for ",
|
| 72 |
+
"i need ", "give me ", "show me ", "get me ",
|
| 73 |
+
]
|
| 74 |
+
lower = q.lower()
|
| 75 |
+
for prefix in prefixes:
|
| 76 |
+
if lower.startswith(prefix):
|
| 77 |
+
q = q[len(prefix):]
|
| 78 |
+
break
|
| 79 |
+
q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
|
| 80 |
+
q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
|
| 81 |
+
q = _strip_resource_noise(q)
|
| 82 |
+
return _normalize_whitespace(q).strip(".?")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def _extract_topic(question: str, model: str) -> str:
|
| 86 |
+
settings = get_settings()
|
| 87 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 88 |
+
return _extract_topic_regex(question)
|
| 89 |
+
try:
|
| 90 |
+
llm = _get_llm(model, temperature=0, max_tokens=32)
|
| 91 |
+
response = await llm.ainvoke([
|
| 92 |
+
{"role": "system", "content": (
|
| 93 |
+
"Extract only the core research topic from the user's question. "
|
| 94 |
+
"Return 2-7 keywords ONLY — no punctuation, no sentence, no explanation. "
|
| 95 |
+
"CRITICAL: do NOT include words like articles, papers, books, journals, "
|
| 96 |
+
"peer-reviewed, open-access, studies, or research in your output. "
|
| 97 |
+
"Those are filters, not topic words. Return ONLY the subject matter.\n"
|
| 98 |
+
"Examples:\n"
|
| 99 |
+
"'find articles on indian politics' → indian politics\n"
|
| 100 |
+
"'peer reviewed papers on climate change 2020' → climate change\n"
|
| 101 |
+
"'books on structural engineering UAE' → structural engineering UAE\n"
|
| 102 |
+
"'give me a summary of quantum physics advances' → quantum physics advances\n"
|
| 103 |
+
"'i need peer reviewed research on machine learning healthcare' → machine learning healthcare"
|
| 104 |
+
)},
|
| 105 |
+
{"role": "user", "content": question},
|
| 106 |
+
])
|
| 107 |
+
topic = response.content.strip().strip('"').strip("'").strip(".")
|
| 108 |
+
topic = _strip_resource_noise(topic)
|
| 109 |
+
topic = _normalize_whitespace(topic).strip(".?")
|
| 110 |
+
if topic and len(topic) > 2:
|
| 111 |
+
return topic
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.warning(f"Topic extraction failed: {e}")
|
| 114 |
+
return _extract_topic_regex(question)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def _llm_build_boolean_query(topic: str, model: str) -> str:
|
| 118 |
+
settings = get_settings()
|
| 119 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 120 |
+
return _shared_build_primo_boolean_query(topic)
|
| 121 |
+
try:
|
| 122 |
+
llm = _get_llm(model, temperature=0, max_tokens=120)
|
| 123 |
+
response = await llm.ainvoke([
|
| 124 |
+
{"role": "system", "content": _BOOLEAN_SYSTEM},
|
| 125 |
+
{"role": "user", "content": f"Input: {topic.strip()}"},
|
| 126 |
+
])
|
| 127 |
+
result = response.content.strip()
|
| 128 |
+
if not result or len(result) > 500:
|
| 129 |
+
raise ValueError("Bad LLM boolean output")
|
| 130 |
+
result = re.sub(r"^```[a-z]*\n?", "", result).rstrip("`").strip()
|
| 131 |
+
if '"' not in result and ' AND ' not in result and ' OR ' not in result:
|
| 132 |
+
result = f'"{result}"'
|
| 133 |
+
logger.info(f"LLM boolean: {result!r} ← topic: {topic!r}")
|
| 134 |
+
return result
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.warning(f"LLM boolean query failed: {e} — using regex fallback")
|
| 137 |
+
return _shared_build_primo_boolean_query(topic)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
|
| 141 |
+
q = (question or "").lower()
|
| 142 |
+
if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
|
| 143 |
+
return "books"
|
| 144 |
+
if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
|
| 145 |
+
return "articles"
|
| 146 |
+
return existing or "articles"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
|
| 150 |
+
q = (question or "").lower()
|
| 151 |
+
match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
|
| 152 |
+
if match:
|
| 153 |
+
years = int(match.group(2))
|
| 154 |
+
return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
|
| 155 |
+
between = re.search(r"\b(?:from|between)\s+(20\d{2}|19\d{2})\s+(?:to|and|-)\s+(20\d{2}|19\d{2})\b", q)
|
| 156 |
+
if between:
|
| 157 |
+
return between.group(1), between.group(2)
|
| 158 |
+
years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
|
| 159 |
+
if len(years) >= 2:
|
| 160 |
+
return min(years), max(years)
|
| 161 |
+
if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
|
| 162 |
+
return years[0], str(CURRENT_YEAR)
|
| 163 |
+
return None, None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
|
| 167 |
+
topic = context.display_topic or context.topic
|
| 168 |
+
topic = _title_case_topic(topic)
|
| 169 |
+
scope = context.resource_type
|
| 170 |
+
filters = []
|
| 171 |
+
if context.peer_reviewed:
|
| 172 |
+
filters.append("peer-reviewed")
|
| 173 |
+
if context.open_access:
|
| 174 |
+
filters.append("open-access")
|
| 175 |
+
if context.year_from and context.year_to:
|
| 176 |
+
filters.append(f"published between {context.year_from} and {context.year_to}")
|
| 177 |
+
elif context.year_from:
|
| 178 |
+
filters.append(f"published from {context.year_from} onward")
|
| 179 |
+
if is_follow_up:
|
| 180 |
+
if scope == "books":
|
| 181 |
+
base = f"Find books on {topic}"
|
| 182 |
+
elif scope == "both":
|
| 183 |
+
base = f"Find articles and books on {topic}"
|
| 184 |
+
else:
|
| 185 |
+
base = f"Find research articles on {topic}"
|
| 186 |
+
else:
|
| 187 |
+
base = _normalize_whitespace(question).strip(".?")
|
| 188 |
+
if len(base.split()) < 4:
|
| 189 |
+
base = f"Find research on {topic}"
|
| 190 |
+
if filters:
|
| 191 |
+
return f"{base} with {', '.join(filters)}."
|
| 192 |
+
return base.rstrip(".") + "."
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _context_to_dict(context: SearchContextPayload) -> dict:
|
| 196 |
+
return context.model_dump()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
async def _validate_topic(topic: str, model: str) -> Tuple[bool, str]:
|
| 200 |
+
settings = get_settings()
|
| 201 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 202 |
+
return True, topic
|
| 203 |
+
try:
|
| 204 |
+
llm = _get_llm(model, temperature=0, max_tokens=40)
|
| 205 |
+
response = await llm.ainvoke([
|
| 206 |
+
{"role": "system", "content": (
|
| 207 |
+
"You are a topic validator for an academic library search system. "
|
| 208 |
+
"Given a word or phrase, decide if it is a recognisable research topic, "
|
| 209 |
+
"subject area, acronym, proper noun, or concept — even if misspelled.\n\n"
|
| 210 |
+
"Rules:\n"
|
| 211 |
+
"- If recognisable or a fixable typo: return JSON {\"valid\": true, \"corrected\": \"<corrected spelling>\"}\n"
|
| 212 |
+
"- If gibberish, random characters, or completely unrecognisable: return JSON {\"valid\": false, \"corrected\": \"\"}\n"
|
| 213 |
+
"- Acronyms like NLP, ML, AI, CRISPR, IoT are always valid.\n"
|
| 214 |
+
"- Proper nouns (country names, people, organisations) are always valid.\n"
|
| 215 |
+
"- Misspellings like 'machne lernig' → corrected: 'machine learning' → valid: true\n"
|
| 216 |
+
"- Random strings like 'sdfmdnoc', 'xyzabc123', 'qwerty' → valid: false\n"
|
| 217 |
+
"Return ONLY valid JSON. No explanation."
|
| 218 |
+
)},
|
| 219 |
+
{"role": "user", "content": f"Topic: {topic}"},
|
| 220 |
+
])
|
| 221 |
+
raw = response.content.strip()
|
| 222 |
+
if raw.startswith("```"):
|
| 223 |
+
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 224 |
+
s, e = raw.find("{"), raw.rfind("}")
|
| 225 |
+
if s != -1 and e > s:
|
| 226 |
+
result = json.loads(raw[s:e + 1])
|
| 227 |
+
is_valid = bool(result.get("valid", True))
|
| 228 |
+
corrected = str(result.get("corrected") or topic).strip()
|
| 229 |
+
return is_valid, corrected
|
| 230 |
+
return True, topic
|
| 231 |
+
except Exception as exc:
|
| 232 |
+
logger.warning(f"_validate_topic failed: {exc} — failing open")
|
| 233 |
+
return True, topic
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def _build_search_plan(question: str, model: str) -> dict:
|
| 237 |
+
settings = get_settings()
|
| 238 |
+
raw_query = _normalize_whitespace(question or "")
|
| 239 |
+
light_query = _light_strip_retrieval_boilerplate(raw_query) or raw_query
|
| 240 |
+
_stripped = light_query.strip()
|
| 241 |
+
_is_pure_number = bool(re.fullmatch(r"[\d\s\.\,\-\/]+", _stripped))
|
| 242 |
+
_is_too_short = len(_stripped) < 2
|
| 243 |
+
_is_single_char = bool(re.fullmatch(r"[a-zA-Z0-9]", _stripped))
|
| 244 |
+
_content_words = [w for w in _stripped.split() if len(w) > 1 and not re.fullmatch(r"[\d\.\,\-]+", w)]
|
| 245 |
+
_no_content = len(_content_words) == 0 and len(_stripped) > 0
|
| 246 |
+
if _is_pure_number or _is_too_short or _is_single_char or _no_content:
|
| 247 |
+
_display = _stripped or raw_query
|
| 248 |
+
return {
|
| 249 |
+
"corrected": raw_query, "natural": light_query, "boolean": "",
|
| 250 |
+
"database_query": "", "year_from": "", "year_to": "",
|
| 251 |
+
"peer_reviewed": False, "open_access": False,
|
| 252 |
+
"clarification_needed": True,
|
| 253 |
+
"clarification_message": (
|
| 254 |
+
f"I want to make sure I search for the right thing. "
|
| 255 |
+
f"Could you clarify what <strong>{_escape(_display)}</strong> refers to? "
|
| 256 |
+
f"For example, is it a course code, a specific topic name, a year, "
|
| 257 |
+
f"or something else? The more detail you provide, "
|
| 258 |
+
f"the better I can build your search."
|
| 259 |
+
),
|
| 260 |
+
}
|
| 261 |
+
if settings.openai_api_key or settings.anthropic_api_key:
|
| 262 |
+
_valid, _corrected_topic = await _validate_topic(_stripped, model)
|
| 263 |
+
if not _valid:
|
| 264 |
+
return {
|
| 265 |
+
"corrected": raw_query, "natural": light_query, "boolean": "",
|
| 266 |
+
"database_query": "", "year_from": "", "year_to": "",
|
| 267 |
+
"peer_reviewed": False, "open_access": False,
|
| 268 |
+
"clarification_needed": True,
|
| 269 |
+
"clarification_message": (
|
| 270 |
+
f"I couldn't recognise <strong>{_escape(_stripped)}</strong> as a research topic. "
|
| 271 |
+
f"Could you check the spelling, or describe what you're looking for in more detail? "
|
| 272 |
+
f"For example: <em>\"find research on machine learning\"</em> or "
|
| 273 |
+
f"<em>\"articles on renewable energy in UAE\"</em>."
|
| 274 |
+
),
|
| 275 |
+
}
|
| 276 |
+
if _corrected_topic and _corrected_topic.lower() != _stripped.lower():
|
| 277 |
+
logger.info(f"_validate_topic corrected: {_stripped!r} → {_corrected_topic!r}")
|
| 278 |
+
light_query = _corrected_topic
|
| 279 |
+
if settings.openai_api_key or settings.anthropic_api_key:
|
| 280 |
+
try:
|
| 281 |
+
llm = _get_llm(model, temperature=0, max_tokens=300)
|
| 282 |
+
response = await llm.ainvoke([
|
| 283 |
+
{"role": "system", "content": _BUILD_SEARCH_PLAN_SYSTEM},
|
| 284 |
+
{"role": "user", "content": f'Query: "{light_query}"'},
|
| 285 |
+
])
|
| 286 |
+
raw = response.content.strip()
|
| 287 |
+
if raw.startswith("```"):
|
| 288 |
+
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 289 |
+
s, e = raw.find("{"), raw.rfind("}")
|
| 290 |
+
if s != -1 and e > s:
|
| 291 |
+
result = json.loads(raw[s:e + 1])
|
| 292 |
+
else:
|
| 293 |
+
raise ValueError("No JSON found in response")
|
| 294 |
+
corrected = (result.get("corrected") or raw_query).strip() or raw_query
|
| 295 |
+
natural = (result.get("natural") or corrected).strip() or corrected
|
| 296 |
+
boolean = (result.get("boolean") or "").strip()
|
| 297 |
+
has_ops = bool(re.search(r"\b(AND|OR)\b", boolean)) if boolean else False
|
| 298 |
+
has_parens = "(" in boolean if boolean else False
|
| 299 |
+
if not has_ops or not has_parens:
|
| 300 |
+
boolean = _shared_build_primo_boolean_query(corrected)
|
| 301 |
+
boolean = _sanitize_boolean_for_primo(boolean)
|
| 302 |
+
year_from = str(result.get("year_from") or "").strip()
|
| 303 |
+
year_to = str(result.get("year_to") or "").strip()
|
| 304 |
+
if not year_from and not year_to:
|
| 305 |
+
yf, yt = _parse_year_filters(raw_query)
|
| 306 |
+
year_from = yf or ""
|
| 307 |
+
year_to = yt or ""
|
| 308 |
+
return {
|
| 309 |
+
"corrected": corrected,
|
| 310 |
+
"natural": natural,
|
| 311 |
+
"boolean": boolean,
|
| 312 |
+
"database_query": _clean_database_keywords(boolean),
|
| 313 |
+
"year_from": year_from,
|
| 314 |
+
"year_to": year_to,
|
| 315 |
+
"peer_reviewed": bool(result.get("peer_reviewed", False)),
|
| 316 |
+
"open_access": bool(result.get("open_access", False)),
|
| 317 |
+
}
|
| 318 |
+
except Exception as e:
|
| 319 |
+
logger.warning(f"_build_search_plan LLM failed: {e} — using regex fallback")
|
| 320 |
+
boolean = _shared_build_primo_boolean_query(light_query)
|
| 321 |
+
yf, yt = _parse_year_filters(raw_query)
|
| 322 |
+
pr = bool(re.search(r"\bpeer[- ]reviewed\b", raw_query, re.IGNORECASE))
|
| 323 |
+
oa = bool(re.search(r"\bopen[- ]access\b", raw_query, re.IGNORECASE))
|
| 324 |
+
return {
|
| 325 |
+
"corrected": raw_query,
|
| 326 |
+
"natural": light_query,
|
| 327 |
+
"boolean": boolean,
|
| 328 |
+
"database_query": _clean_database_keywords(boolean),
|
| 329 |
+
"year_from": yf or "",
|
| 330 |
+
"year_to": yt or "",
|
| 331 |
+
"peer_reviewed": pr,
|
| 332 |
+
"open_access": oa,
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
|
| 337 |
+
topic = _strip_resource_noise(context.display_topic or context.topic) or context.topic
|
| 338 |
+
q = question if not is_follow_up else f"Find {context.resource_type} on {topic}"
|
| 339 |
+
plan = await _build_search_plan(q, model)
|
| 340 |
+
if plan.get("clarification_needed"):
|
| 341 |
+
context.clarification_needed = True
|
| 342 |
+
context.clarification_message = plan.get("clarification_message", "")
|
| 343 |
+
return context
|
| 344 |
+
context.ai_tool_query = plan["natural"]
|
| 345 |
+
context.primo_boolean_query = plan["boolean"]
|
| 346 |
+
if not context.year_from and plan.get("year_from"):
|
| 347 |
+
context.year_from = plan["year_from"]
|
| 348 |
+
if not context.year_to and plan.get("year_to"):
|
| 349 |
+
context.year_to = plan["year_to"]
|
| 350 |
+
if not context.peer_reviewed and plan.get("peer_reviewed"):
|
| 351 |
+
context.peer_reviewed = True
|
| 352 |
+
if not context.open_access and plan.get("open_access"):
|
| 353 |
+
context.open_access = True
|
| 354 |
+
return context
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
async def _topic_intro(topic: str, model: str) -> str:
|
| 358 |
+
"""LLM #5 — 3-sentence topic intro. v3.8.1: appends _GUARDRAIL + _URL_INSTRUCTION."""
|
| 359 |
+
settings = get_settings()
|
| 360 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 361 |
+
return ""
|
| 362 |
+
try:
|
| 363 |
+
llm = _get_llm(model, temperature=0.2, max_tokens=180)
|
| 364 |
+
response = await llm.ainvoke([
|
| 365 |
+
{"role": "system", "content": (
|
| 366 |
+
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 367 |
+
"Write exactly 3 clear, factual sentences introducing the given research topic "
|
| 368 |
+
"for a university student or researcher. "
|
| 369 |
+
"Cover: what the topic is, why it matters, and one key area of current research interest. "
|
| 370 |
+
"Use HTML <br> for line breaks only if needed. No markdown, no bullet points, no headings. "
|
| 371 |
+
"Be concise and informative. "
|
| 372 |
+
+ _GUARDRAIL + "\n\n" + _URL_INSTRUCTION
|
| 373 |
+
)},
|
| 374 |
+
{"role": "user", "content": f"Research topic: {topic}"},
|
| 375 |
+
])
|
| 376 |
+
intro = response.content.strip()
|
| 377 |
+
return intro if intro else ""
|
| 378 |
+
except Exception as e:
|
| 379 |
+
logger.warning(f"_topic_intro failed: {e}")
|
| 380 |
+
return ""
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
|
| 385 |
+
if not papers:
|
| 386 |
+
return ""
|
| 387 |
+
statements: List[str] = []
|
| 388 |
+
for paper in papers[:4]:
|
| 389 |
+
sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
|
| 390 |
+
lead = sentences[0].strip() if sentences else ""
|
| 391 |
+
if not lead:
|
| 392 |
+
continue
|
| 393 |
+
citation = (
|
| 394 |
+
f'<a href="{paper["link"]}" target="_blank" '
|
| 395 |
+
f'style="color:#1e40af;font-weight:600;text-decoration:none">'
|
| 396 |
+
f'[{_escape(paper["citation_key"])}]</a>'
|
| 397 |
+
)
|
| 398 |
+
statements.append(f"{_escape(lead)} {citation}")
|
| 399 |
+
if not statements:
|
| 400 |
+
return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
|
| 401 |
+
intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
|
| 402 |
+
return intro + "<br><br>".join(statements[:4])
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _database_guidance_for_topic(context: SearchContextPayload) -> str:
|
| 406 |
+
topic = _escape(context.display_topic or context.topic)
|
| 407 |
+
urls = _tool_urls(context)
|
| 408 |
+
ai_tools = (
|
| 409 |
+
f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> · '
|
| 410 |
+
f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> · '
|
| 411 |
+
f'<a href="{urls["ebsco_ai"]}" target="_blank" style="color:#9d174d;font-weight:700;text-decoration:none">EBSCO AI</a> · '
|
| 412 |
+
f'<a href="{urls["primo_ai"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO AI Assistant</a> · '
|
| 413 |
+
f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a>'
|
| 414 |
+
)
|
| 415 |
+
more_results = f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
|
| 416 |
+
if context.intent == "search_medical" and urls.get("pubmed"):
|
| 417 |
+
more_results += f' · <a href="{urls["pubmed"]}" target="_blank" style="color:#065f46;font-weight:700;text-decoration:none">PubMed</a>'
|
| 418 |
+
return (
|
| 419 |
+
f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
|
| 420 |
+
f"For more results, use {more_results}. Medical topics are strongest in PubMed, Embase, and CINAHL."
|
| 421 |
+
)
|
| 422 |
+
return (
|
| 423 |
+
f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
|
| 424 |
+
f"For more results, use {more_results}."
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
async def _search_strategy_answer(question: str, model: str) -> Tuple[str, dict]:
|
| 429 |
+
plan = await _build_search_plan(question, model)
|
| 430 |
+
topic = _escape(_light_strip_retrieval_boilerplate(question) or question)
|
| 431 |
+
boolean = _escape(plan.get("boolean", ""))
|
| 432 |
+
natural = _escape(plan.get("natural", ""))
|
| 433 |
+
explanation = (
|
| 434 |
+
f"<strong>🔍 Search strategy for: {topic}</strong><br><br>"
|
| 435 |
+
f"<strong>1. Identify your key concepts</strong><br>"
|
| 436 |
+
f"Break the topic into 2–3 core concepts. For your question, I identified these concepts "
|
| 437 |
+
f"and suggested synonyms for each using OR, then joined concepts with AND:<br><br>"
|
| 438 |
+
f'<code style="display:block;padding:8px 12px;background:#1a1a2e;color:#C8A951;'
|
| 439 |
+
f'border-radius:8px;font-size:.82rem;word-break:break-all">{boolean}</code><br>'
|
| 440 |
+
f"<strong>Why AND and OR?</strong> AND narrows — both concepts must appear. "
|
| 441 |
+
f"OR broadens — any synonym counts. Quoting phrases like "
|
| 442 |
+
f"<code>\"machine learning\"</code> keeps them together as an exact phrase.<br><br>"
|
| 443 |
+
f"<strong>2. Apply filters</strong><br>"
|
| 444 |
+
f"Use database facets to limit by: peer-reviewed, date range, document type (article / review / book), "
|
| 445 |
+
f"language, or open access. In PRIMO, these appear in the left sidebar after you search.<br><br>"
|
| 446 |
+
f"<strong>3. Natural language query for AI tools</strong><br>"
|
| 447 |
+
f"For AI-powered tools (LeapSpace, Scopus AI, Consensus), use a conversational query:<br>"
|
| 448 |
+
f'<em style="color:var(--color-text-secondary)">{natural}</em><br><br>'
|
| 449 |
+
f"<strong>4. Iterate</strong><br>"
|
| 450 |
+
f"Too many results → add more AND terms or apply filters. "
|
| 451 |
+
f"Too few → remove an AND group or use broader synonyms. "
|
| 452 |
+
f"You can ask me to refine: <em>\"narrow to peer-reviewed only\"</em>, "
|
| 453 |
+
f"<em>\"limit to last 5 years\"</em>, or <em>\"suggest alternative keywords\"</em>."
|
| 454 |
+
)
|
| 455 |
+
return explanation, plan
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
async def _alternative_terms_answer(question: str, context: Optional[SearchContextPayload], model: str) -> str:
|
| 459 |
+
topic = (context.display_topic or context.topic) if context else question
|
| 460 |
+
settings = get_settings()
|
| 461 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 462 |
+
return (
|
| 463 |
+
f"<strong>Alternative search terms for: {_escape(topic)}</strong><br><br>"
|
| 464 |
+
"Try combining these broader, narrower, and related terms:<br>"
|
| 465 |
+
"• Use broader terms if too few results<br>"
|
| 466 |
+
"• Use narrower/specific terms if too many results<br>"
|
| 467 |
+
"• Try acronyms and full forms (e.g. AI / Artificial Intelligence)<br>"
|
| 468 |
+
"• Include British and American spellings (e.g. organisation/organization)"
|
| 469 |
+
)
|
| 470 |
+
try:
|
| 471 |
+
llm = _get_llm(model, temperature=0.4, max_tokens=300)
|
| 472 |
+
response = await llm.ainvoke([
|
| 473 |
+
{"role": "system", "content": (
|
| 474 |
+
"You are an academic librarian. Given a research topic, generate a structured list of alternative "
|
| 475 |
+
"search terms. Group them as: Broader terms, Narrower/specific terms, Related concepts, Acronyms/abbreviations. "
|
| 476 |
+
"Format as HTML using <strong> for group labels and <br> for line breaks. "
|
| 477 |
+
"Keep it concise — 3-4 terms per group maximum. No bullet points, use • instead."
|
| 478 |
+
)},
|
| 479 |
+
{"role": "user", "content": f"Research topic: {topic}"},
|
| 480 |
+
])
|
| 481 |
+
terms = response.content.strip()
|
| 482 |
+
return (
|
| 483 |
+
f"<strong>💡 Alternative search terms for: {_escape(topic)}</strong><br><br>"
|
| 484 |
+
f"{terms}<br><br>"
|
| 485 |
+
f"<strong>Tip:</strong> In PRIMO or databases, use OR between synonyms within a concept group, "
|
| 486 |
+
f"and AND between different concept groups. Ask me to run a new search with any of these."
|
| 487 |
+
)
|
| 488 |
+
except Exception as e:
|
| 489 |
+
logger.warning(f"_alternative_terms_answer failed: {e}")
|
| 490 |
+
return f"<strong>Alternative terms for {_escape(topic)}</strong><br><br>Try synonyms, acronyms, broader/narrower terms, and related concepts in your search."
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def _citation_chain_answer() -> str:
|
| 494 |
+
return (
|
| 495 |
+
"<strong>🔗 How to trace citations forward and backward from a paper</strong><br><br>"
|
| 496 |
+
"<strong>Backward citation (who does this paper cite?)</strong><br>"
|
| 497 |
+
"Read the paper's reference list — every source it cites is a potential lead. "
|
| 498 |
+
"This gives you foundational and seminal works on the topic.<br><br>"
|
| 499 |
+
"<strong>Forward citation (who has cited this paper since publication?)</strong><br>"
|
| 500 |
+
"Use these tools — paste in the DOI or title:<br>"
|
| 501 |
+
'• <a href="https://www-scopus-com.khalifa.idm.oclc.org/pages/ai" target="_blank"><strong>Scopus</strong></a> '
|
| 502 |
+
'— search the article, then click "Cited by N documents"<br>'
|
| 503 |
+
'• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
|
| 504 |
+
'— search the article, click "Times Cited"<br>'
|
| 505 |
+
'• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
|
| 506 |
+
'— free, excellent for CS and engineering, shows "Citations" tab<br>'
|
| 507 |
+
'• <a href="https://openalex.org" target="_blank"><strong>OpenAlex</strong></a> '
|
| 508 |
+
'— fully open, API-accessible citation graph<br><br>'
|
| 509 |
+
"<strong>Lateral search (similar papers)</strong><br>"
|
| 510 |
+
"In PRIMO, use <em>\"Find Similar\"</em>. In Semantic Scholar, use <em>\"Recommended Papers\"</em>. "
|
| 511 |
+
"In Scopus, use <em>\"Related Documents\"</em>.<br><br>"
|
| 512 |
+
"<strong>Tip:</strong> Start with one highly cited foundational paper, trace forward to find the newest "
|
| 513 |
+
"work, and backward to understand the theoretical roots."
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def _predatory_eval_answer() -> str:
|
| 518 |
+
return (
|
| 519 |
+
"<strong>✅ How to tell if an article or journal is peer-reviewed, scholarly, or predatory</strong><br><br>"
|
| 520 |
+
"<strong>Is it peer-reviewed?</strong><br>"
|
| 521 |
+
"• Check the journal's website for a peer-review statement or editorial process description<br>"
|
| 522 |
+
"• In PRIMO, tick the <em>Peer-reviewed</em> filter in the left sidebar<br>"
|
| 523 |
+
"• Check if the journal is indexed in "
|
| 524 |
+
'<a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank">Scopus</a> or '
|
| 525 |
+
'<a href="https://www.webofscience.com" target="_blank">Web of Science</a> — '
|
| 526 |
+
"indexed = generally peer-reviewed<br><br>"
|
| 527 |
+
"<strong>Is it a legitimate journal?</strong><br>"
|
| 528 |
+
"• <a href=\"https://doaj.org\" target=\"_blank\"><strong>DOAJ</strong></a> "
|
| 529 |
+
"— Directory of Open Access Journals (vetted, legitimate OA)<br>"
|
| 530 |
+
"• <a href=\"https://mjl.clarivate.com\" target=\"_blank\"><strong>Web of Science Master Journal List</strong></a><br>"
|
| 531 |
+
"• <a href=\"https://www.scopus.com/sources\" target=\"_blank\"><strong>Scopus Source List</strong></a><br>"
|
| 532 |
+
"• Think · Check · Submit: <a href=\"https://thinkchecksubmit.org\" target=\"_blank\">thinkchecksubmit.org</a> "
|
| 533 |
+
"— a checklist to assess any journal<br><br>"
|
| 534 |
+
"<strong>Warning signs of predatory journals</strong><br>"
|
| 535 |
+
"• Unsolicited email invitation to submit<br>"
|
| 536 |
+
"• No clear peer-review process or very fast acceptance (days)<br>"
|
| 537 |
+
"• High article processing charges (APCs) with no clear metrics<br>"
|
| 538 |
+
"• Not indexed in Scopus or Web of Science<br>"
|
| 539 |
+
"• Generic or misleading journal name ('International Journal of...')<br><br>"
|
| 540 |
+
"<strong>Need help checking a specific journal?</strong> Ask our E-Resources Librarian: "
|
| 541 |
+
"<strong>Rani Anand</strong> · <a href=\"mailto:rani.anand@ku.ac.ae\">rani.anand@ku.ac.ae</a>"
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def _highly_cited_note(topic_escaped: str) -> str:
|
| 546 |
+
return (
|
| 547 |
+
f"<br><br><strong>📊 Finding highly cited papers on {topic_escaped}</strong><br>"
|
| 548 |
+
"PRIMO doesn't sort by citation count, but these tools do:<br>"
|
| 549 |
+
'• <a href="https://www-scopus-com.khalifa.idm.oclc.org" target="_blank"><strong>Scopus</strong></a> '
|
| 550 |
+
'— search your topic → Sort by <em>Cited by (highest)</em><br>'
|
| 551 |
+
'• <a href="https://www.webofscience.com" target="_blank"><strong>Web of Science</strong></a> '
|
| 552 |
+
'— search → Sort by <em>Times Cited</em><br>'
|
| 553 |
+
'• <a href="https://www.semanticscholar.org" target="_blank"><strong>Semantic Scholar</strong></a> '
|
| 554 |
+
'— free, sort by <em>Citation Count</em><br>'
|
| 555 |
+
'• <a href="https://scholar.google.com" target="_blank"><strong>Google Scholar</strong></a> '
|
| 556 |
+
'— sort by <em>Cited by</em> (broader but includes grey literature)'
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
|
| 561 |
+
"""Research snapshot = prepared platform links + a live open-evidence panel.
|
| 562 |
+
|
| 563 |
+
The evidence panel queries OpenAlex first (Semantic Scholar as fallback),
|
| 564 |
+
then enriches results with legal open-access links via Unpaywall and fills
|
| 565 |
+
metadata gaps via Crossref. Every external call is best-effort: if the open
|
| 566 |
+
indexes are unreachable the handler degrades to the prepared-links block
|
| 567 |
+
(the entire pre-3.8 behaviour), so the user always gets an answer.
|
| 568 |
+
"""
|
| 569 |
+
topic = context.topic
|
| 570 |
+
primo_url = _primo_clean_url(context)
|
| 571 |
+
answer = _search_trace_block(f"Research on {topic}", context)
|
| 572 |
+
answer += (
|
| 573 |
+
f"<strong>📚 Research starting points: {_escape(topic)}</strong><br><br>"
|
| 574 |
+
"Your query has been prepared and pre-loaded across multiple platforms. "
|
| 575 |
+
"Click any platform below to search instantly, or use the boolean search tip "
|
| 576 |
+
"to search directly in any KU database."
|
| 577 |
+
f'<br><br><a href="{primo_url}" target="_blank" style="color:#003366;font-weight:700">'
|
| 578 |
+
f'Search PRIMO for: {_escape(topic)} →</a>'
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
# Shared open-evidence panel (see scholarly.fetch_evidence_panel)
|
| 582 |
+
papers, panel_html = await fetch_evidence_panel(topic, context=context)
|
| 583 |
+
answer += panel_html
|
| 584 |
+
|
| 585 |
+
answer += _ai_tools_footer(context)
|
| 586 |
+
sources = [
|
| 587 |
+
{"title": paper.get("title", ""), "url": paper.get("link", ""),
|
| 588 |
+
"doi": paper.get("doi", ""), "provider": paper.get("_source", "")}
|
| 589 |
+
for paper in papers
|
| 590 |
]
|
| 591 |
+
return answer, papers, sources
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _filters_summary(context: SearchContextPayload) -> str:
|
| 595 |
+
filters: List[str] = []
|
| 596 |
+
if context.peer_reviewed:
|
| 597 |
+
filters.append("peer reviewed only")
|
| 598 |
+
if context.open_access:
|
| 599 |
+
filters.append("open access")
|
| 600 |
+
if context.year_from and context.year_to:
|
| 601 |
+
filters.append(f"{context.year_from}–{context.year_to}")
|
| 602 |
+
elif context.year_from:
|
| 603 |
+
filters.append(f"from {context.year_from}")
|
| 604 |
+
if context.resource_type == "books":
|
| 605 |
+
filters.append("books")
|
| 606 |
+
elif context.resource_type == "articles":
|
| 607 |
+
filters.append("articles")
|
| 608 |
+
elif context.resource_type == "both":
|
| 609 |
+
filters.append("articles and books")
|
| 610 |
+
return ", ".join(filters)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _question_has_new_topic(question: str, base_topic: str) -> bool:
|
| 614 |
+
q = (question or "").lower()
|
| 615 |
+
cleaned = re.sub(
|
| 616 |
+
r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
|
| 617 |
+
r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
|
| 618 |
+
" ", q,
|
| 619 |
+
)
|
| 620 |
+
tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
|
| 621 |
+
if len(tokens) < 3:
|
| 622 |
+
return False
|
| 623 |
+
base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
|
| 624 |
+
overlap = sum(1 for t in tokens if t in base_tokens)
|
| 625 |
+
return overlap <= max(1, min(2, len(base_tokens)))
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def _parse_refinement_action(question: str) -> Optional[str]:
|
| 629 |
+
q = (question or "").lower().strip()
|
| 630 |
+
if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
|
| 631 |
+
return "peer_reviewed_only"
|
| 632 |
+
if re.search(r"\b(open access|oa only|only open access)\b", q):
|
| 633 |
+
return "open_access_only"
|
| 634 |
+
if re.search(r"\b(last|past)\s+5\s+years?\b", q):
|
| 635 |
+
return "last_5_years"
|
| 636 |
+
if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
|
| 637 |
+
return "last_10_years"
|
| 638 |
+
m = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
|
| 639 |
+
if m:
|
| 640 |
+
return f"last_{m.group(2)}_years"
|
| 641 |
+
if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
|
| 642 |
+
return "books_only"
|
| 643 |
+
if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
|
| 644 |
+
return "articles_only"
|
| 645 |
+
if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
|
| 646 |
+
return "both_resources"
|
| 647 |
+
if REVIEW_ONLY_RE.search(q):
|
| 648 |
+
return "review_articles_only"
|
| 649 |
+
if ALT_TERMS_RE.search(q):
|
| 650 |
+
return "alt_terms"
|
| 651 |
+
if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
|
| 652 |
+
_STOP = {
|
| 653 |
+
'a','an','the','of','on','in','for','to','with','by','from','at','is','are',
|
| 654 |
+
'was','were','be','been','have','has','had','do','does','did','will','would',
|
| 655 |
+
'could','should','may','its','this','that','these','those','about','me','my',
|
| 656 |
+
'give','show','tell','please','can','you','i','need','want','get','find',
|
| 657 |
+
'research','summary','overview','summarize','summarise','brief','literature',
|
| 658 |
+
}
|
| 659 |
+
content_words = [t for t in re.findall(r'[a-z0-9]+', q)
|
| 660 |
+
if t not in _STOP and (len(t) > 2 or t in {'ai','ml','nlp','cv','rl','dl','uae','ku','iot'})]
|
| 661 |
+
if content_words:
|
| 662 |
+
return None
|
| 663 |
+
return "summarize_topic"
|
| 664 |
+
if re.search(r"\bpubmed\b", q):
|
| 665 |
+
return "search_pubmed"
|
| 666 |
+
if re.search(r"\bprimo\b", q):
|
| 667 |
+
return "search_primo"
|
| 668 |
+
if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
|
| 669 |
+
return "best_databases"
|
| 670 |
+
if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
|
| 671 |
+
return "deep_research_tools"
|
| 672 |
+
return None
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
|
| 676 |
+
if not client_state or not client_state.recent_search_contexts:
|
| 677 |
+
return None
|
| 678 |
+
contexts = list(client_state.recent_search_contexts)
|
| 679 |
+
if client_state.follow_up_context_id:
|
| 680 |
+
for ctx in contexts:
|
| 681 |
+
if ctx.context_id == client_state.follow_up_context_id:
|
| 682 |
+
return ctx
|
| 683 |
+
if client_state.active_search_context_id:
|
| 684 |
+
for ctx in contexts:
|
| 685 |
+
if ctx.context_id == client_state.active_search_context_id:
|
| 686 |
+
return ctx
|
| 687 |
+
contexts.sort(key=lambda c: c.created_at, reverse=True)
|
| 688 |
+
return contexts[0]
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def _detect_follow_up(
|
| 692 |
+
question: str, client_state: Optional[ClientStatePayload]
|
| 693 |
+
) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
|
| 694 |
+
base_context = _resolve_base_context(client_state)
|
| 695 |
+
if not base_context:
|
| 696 |
+
return False, None, None
|
| 697 |
+
explicit_action = (client_state.follow_up_action if client_state else None) or None
|
| 698 |
+
if explicit_action:
|
| 699 |
+
return True, explicit_action, base_context
|
| 700 |
+
action = _parse_refinement_action(question)
|
| 701 |
+
if not action:
|
| 702 |
+
return False, None, None
|
| 703 |
+
if _question_has_new_topic(question, base_context.topic):
|
| 704 |
+
return False, None, None
|
| 705 |
+
return True, action, base_context
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
|
| 709 |
+
return SearchContextPayload.model_validate(base_context.model_dump())
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
|
| 713 |
+
context = _clone_context(base_context)
|
| 714 |
+
context.context_id = str(uuid.uuid4())
|
| 715 |
+
context.created_at = time.time()
|
| 716 |
+
if action == "peer_reviewed_only":
|
| 717 |
+
context.peer_reviewed = True
|
| 718 |
+
elif action == "open_access_only":
|
| 719 |
+
context.open_access = True
|
| 720 |
+
elif action == "last_5_years":
|
| 721 |
+
context.year_from = str(CURRENT_YEAR - 4)
|
| 722 |
+
context.year_to = str(CURRENT_YEAR)
|
| 723 |
+
elif action == "last_10_years":
|
| 724 |
+
context.year_from = str(CURRENT_YEAR - 9)
|
| 725 |
+
context.year_to = str(CURRENT_YEAR)
|
| 726 |
+
elif action == "last_3_years":
|
| 727 |
+
context.year_from = str(CURRENT_YEAR - 2)
|
| 728 |
+
context.year_to = str(CURRENT_YEAR)
|
| 729 |
+
elif action == "last_2_years":
|
| 730 |
+
context.year_from = str(CURRENT_YEAR - 1)
|
| 731 |
+
context.year_to = str(CURRENT_YEAR)
|
| 732 |
+
elif action and action.startswith("last_") and action.endswith("_years"):
|
| 733 |
+
try:
|
| 734 |
+
n = int(action.split("_")[1])
|
| 735 |
+
context.year_from = str(CURRENT_YEAR - n + 1)
|
| 736 |
+
context.year_to = str(CURRENT_YEAR)
|
| 737 |
+
except (IndexError, ValueError):
|
| 738 |
+
pass
|
| 739 |
+
elif action == "books_only":
|
| 740 |
+
context.resource_type = "books"
|
| 741 |
+
elif action == "articles_only":
|
| 742 |
+
context.resource_type = "articles"
|
| 743 |
+
elif action == "both_resources":
|
| 744 |
+
context.resource_type = "both"
|
| 745 |
+
elif action == "review_articles_only":
|
| 746 |
+
context.resource_type = "articles"
|
| 747 |
+
context.peer_reviewed = True
|
| 748 |
+
elif action == "search_pubmed":
|
| 749 |
+
context.source = "pubmed"
|
| 750 |
+
context.intent = "search_medical"
|
| 751 |
+
context.resource_type = "articles"
|
| 752 |
+
elif action == "search_primo":
|
| 753 |
+
context.source = "primo"
|
| 754 |
+
return context
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
|
| 758 |
+
settings = get_settings()
|
| 759 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 760 |
+
return []
|
| 761 |
+
try:
|
| 762 |
+
llm = _get_llm(model, temperature=0.5, max_tokens=200)
|
| 763 |
+
response = await llm.ainvoke([
|
| 764 |
+
{"role": "system", "content": (
|
| 765 |
+
"You are a research librarian helping a user explore a topic more deeply. "
|
| 766 |
+
"Given a research topic, generate exactly 3 short follow-up questions that explore "
|
| 767 |
+
"DIFFERENT ASPECTS of the topic — such as subtopics, methodological angles, "
|
| 768 |
+
"applications, comparisons, or related fields. "
|
| 769 |
+
"Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
|
| 770 |
+
"Return ONLY a JSON array of 3 strings, each under 12 words. "
|
| 771 |
+
"No explanation, no preamble, no markdown.\n"
|
| 772 |
+
"Example for 'quantum computing':\n"
|
| 773 |
+
'[\"What are the main hardware approaches in quantum computing?\", '
|
| 774 |
+
'"How does quantum error correction work?\", '
|
| 775 |
+
'"Applications of quantum computing in cryptography\"]'
|
| 776 |
+
)},
|
| 777 |
+
{"role": "user", "content": f"Topic: {topic}"},
|
| 778 |
+
])
|
| 779 |
+
raw = response.content.strip()
|
| 780 |
+
if raw.startswith("```"):
|
| 781 |
+
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 782 |
+
questions = json.loads(raw)
|
| 783 |
+
if not isinstance(questions, list):
|
| 784 |
+
return []
|
| 785 |
+
return [
|
| 786 |
+
{"label": q.strip("?") + "?", "question": q.strip("?") + "?"}
|
| 787 |
+
for q in questions[:3]
|
| 788 |
+
if isinstance(q, str) and q.strip()
|
| 789 |
+
]
|
| 790 |
+
except Exception as e:
|
| 791 |
+
logger.warning(f"Topic follow-up generation failed: {e}")
|
| 792 |
+
return []
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
async def _search_follow_up(
|
| 796 |
+
context: SearchContextPayload, model: str, summary_mode: bool = False
|
| 797 |
+
) -> Tuple[str, List[dict]]:
|
| 798 |
+
topic = context.display_topic or context.topic
|
| 799 |
+
suggestions: List[dict] = []
|
| 800 |
+
topic_follow_ups = await _generate_topic_follow_ups(topic, model)
|
| 801 |
+
suggestions.extend(topic_follow_ups)
|
| 802 |
+
|
| 803 |
+
filter_suggestions: List[dict] = []
|
| 804 |
+
if summary_mode:
|
| 805 |
+
filter_suggestions.append({
|
| 806 |
+
"label": f"Show KU-accessible articles on {topic}",
|
| 807 |
+
"question": f"Find KU-accessible articles on {topic}",
|
| 808 |
+
"action": "articles_only",
|
| 809 |
+
"context_id": context.context_id,
|
| 810 |
+
})
|
| 811 |
+
if context.intent == "search_medical" and context.source != "pubmed":
|
| 812 |
+
filter_suggestions.append({
|
| 813 |
+
"label": "Search PubMed instead",
|
| 814 |
+
"question": f"Search PubMed for {topic}",
|
| 815 |
+
"action": "search_pubmed",
|
| 816 |
+
"context_id": context.context_id,
|
| 817 |
+
})
|
| 818 |
+
filter_suggestions.append({
|
| 819 |
+
"label": "Research snapshot",
|
| 820 |
+
"question": f"Give me a brief research snapshot on {topic}",
|
| 821 |
+
"action": "summarize_topic",
|
| 822 |
+
"context_id": context.context_id,
|
| 823 |
+
})
|
| 824 |
+
|
| 825 |
+
suggestions.extend(filter_suggestions[:2])
|
| 826 |
+
question_text = (
|
| 827 |
+
f"Here are some related angles you might want to explore on <strong>{_escape(topic)}</strong>. "
|
| 828 |
+
"Or I can refine the search with filters — just ask."
|
| 829 |
+
)
|
| 830 |
+
return question_text, suggestions[:5]
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def _search_answer_intro(context: SearchContextPayload, is_follow_up: bool) -> str:
|
| 834 |
+
topic = _escape(context.display_topic or context.topic)
|
| 835 |
+
resource = {"books": "books", "articles": "articles", "both": "articles and books"}.get(context.resource_type, "articles")
|
| 836 |
+
location = "PubMed" if context.source == "pubmed" else "KU Library catalogue"
|
| 837 |
+
if is_follow_up:
|
| 838 |
+
action = f"I updated your prepared search on <strong>{topic}</strong> for the <strong>{location}</strong> and <strong>{resource}</strong>."
|
| 839 |
else:
|
| 840 |
+
action = f"I prepared a search for <strong>{resource}</strong> on <strong>{topic}</strong>."
|
| 841 |
+
return action
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
async def _run_search_mode(
|
| 845 |
+
question: str, context: SearchContextPayload, model: str, is_follow_up: bool
|
| 846 |
+
) -> Tuple[str, List[dict], List[dict], str]:
|
| 847 |
+
"""
|
| 848 |
+
Search mode — builds PRIMO link + 3-sentence topic intro + AI tools footer.
|
| 849 |
+
No external academic API calls (v3.8).
|
| 850 |
+
Includes clarification gate for ambiguous/gibberish queries.
|
| 851 |
+
"""
|
| 852 |
+
context = await _prepare_queries(question, context, model, is_follow_up)
|
| 853 |
+
|
| 854 |
+
# Clarification gate
|
| 855 |
+
if context.clarification_needed and context.clarification_message:
|
| 856 |
+
return context.clarification_message, [], [], ""
|
| 857 |
+
|
| 858 |
+
source_url = _primo_clean_url(context)
|
| 859 |
+
topic = _escape(context.display_topic or context.topic)
|
| 860 |
+
|
| 861 |
+
# 3-sentence topic intro AND the open-evidence panel, fetched concurrently:
|
| 862 |
+
# the panel's network time hides inside the intro's LLM latency instead of
|
| 863 |
+
# adding to it. Both degrade independently if they fail.
|
| 864 |
+
intro, (papers, panel_html) = await asyncio.gather(
|
| 865 |
+
_topic_intro(context.display_topic or context.topic, model),
|
| 866 |
+
fetch_evidence_panel(context.display_topic or context.topic, context=context),
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
answer = _search_trace_block(question, context)
|
| 870 |
+
if intro:
|
| 871 |
+
answer += f'<div style="margin-bottom:12px;color:#374151;font-size:.88rem;line-height:1.7">{intro}</div>'
|
| 872 |
+
answer += _search_answer_intro(context, is_follow_up)
|
| 873 |
+
answer += (
|
| 874 |
+
f'<br><br>'
|
| 875 |
+
f'<a href="{source_url}" target="_blank" '
|
| 876 |
+
f'style="display:inline-block;padding:9px 18px;background:#003366;color:#fff;'
|
| 877 |
+
f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
|
| 878 |
+
f'🔍 Search PRIMO Library Discovery for: {topic} →</a>'
|
| 879 |
+
f'<br><div style="margin-top:6px;font-size:.78rem;color:#6b7280">'
|
| 880 |
+
f'💡 Inside PRIMO you can filter by <strong>Articles</strong>, <strong>Books</strong>, '
|
| 881 |
+
f'<strong>Peer Reviewed</strong>, date range, and more.</div>'
|
| 882 |
+
)
|
| 883 |
+
answer += panel_html
|
| 884 |
+
answer += _ai_tools_footer(context)
|
| 885 |
+
|
| 886 |
+
sources = [
|
| 887 |
+
{"title": paper.get("title", ""), "url": paper.get("link", ""),
|
| 888 |
+
"doi": paper.get("doi", ""), "provider": paper.get("_source", "")}
|
| 889 |
+
for paper in papers
|
| 890 |
+
]
|
| 891 |
+
return answer, papers, sources, source_url
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[SearchContextPayload]:
|
| 895 |
+
dedup: Dict[str, SearchContextPayload] = {}
|
| 896 |
+
for ctx in contexts:
|
| 897 |
+
dedup[ctx.context_id] = ctx
|
| 898 |
+
ordered = sorted(dedup.values(), key=lambda c: c.created_at, reverse=True)
|
| 899 |
+
return ordered[:5]
|
| 900 |
+
|
| 901 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/agentcore/libcal.py
CHANGED
|
@@ -1,333 +1,333 @@
|
|
| 1 |
-
"""LibCal client + formatting: live opening hours and events via the Cloudflare Worker proxy."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
|
| 19 |
-
from src.agentcore.constants import LIBRARY_HOURS_URL
|
| 20 |
-
from src.agentcore.utils import _escape
|
| 21 |
-
|
| 22 |
-
logger = logging.getLogger(__name__)
|
| 23 |
-
|
| 24 |
-
_libcal_hours_cache: Dict[str, Any] = {}
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
_LIBCAL_HOURS_TTL = 60 * 60
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
async def _libcal_fetch_hours() -> Optional[dict]:
|
| 31 |
-
now = time.time()
|
| 32 |
-
cached_at = _libcal_hours_cache.get("cached_at", 0)
|
| 33 |
-
if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
|
| 34 |
-
return _libcal_hours_cache["data"]
|
| 35 |
-
settings = get_settings()
|
| 36 |
-
worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
|
| 37 |
-
if not worker_url:
|
| 38 |
-
logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set — cannot fetch hours")
|
| 39 |
-
return None
|
| 40 |
-
from datetime import timezone, timedelta
|
| 41 |
-
uae_now = datetime.now(timezone(timedelta(hours=4)))
|
| 42 |
-
date_from = uae_now.strftime("%Y-%m-%d")
|
| 43 |
-
date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
|
| 44 |
-
try:
|
| 45 |
-
async with httpx.AsyncClient(timeout=10) as client:
|
| 46 |
-
r = await client.get(
|
| 47 |
-
f"{worker_url.rstrip('/')}/hours",
|
| 48 |
-
params={"from": date_from, "to": date_to},
|
| 49 |
-
)
|
| 50 |
-
r.raise_for_status()
|
| 51 |
-
data = r.json()
|
| 52 |
-
if data.get("error"):
|
| 53 |
-
logger.warning("LibCal worker error: %s", data["error"])
|
| 54 |
-
return None
|
| 55 |
-
_libcal_hours_cache["data"] = data
|
| 56 |
-
_libcal_hours_cache["cached_at"] = now
|
| 57 |
-
logger.info("LibCal: hours refreshed via worker")
|
| 58 |
-
return data
|
| 59 |
-
except Exception as exc:
|
| 60 |
-
logger.warning("LibCal hours fetch failed: %s", exc)
|
| 61 |
-
return None
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _parse_libcal_time(raw: str) -> str:
|
| 65 |
-
if not raw:
|
| 66 |
-
return raw
|
| 67 |
-
raw = raw.strip().replace("\u2013", "-").replace("–", "-")
|
| 68 |
-
raw = re.sub(r"\s+", "", raw)
|
| 69 |
-
raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
|
| 70 |
-
return raw
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _format_libcal_day(day_data: dict) -> str:
|
| 74 |
-
if not day_data or not isinstance(day_data, dict):
|
| 75 |
-
return "See hours page"
|
| 76 |
-
status = str(day_data.get("status") or "").lower().strip()
|
| 77 |
-
note = str(day_data.get("note") or "").strip()
|
| 78 |
-
if status in ("closed", "0", "false"):
|
| 79 |
-
return f"Closed{f' — {note}' if note else ''}"
|
| 80 |
-
if status == "24hours":
|
| 81 |
-
return "Open 24 hours"
|
| 82 |
-
hours_list = day_data.get("hours") or []
|
| 83 |
-
if hours_list and isinstance(hours_list, list):
|
| 84 |
-
first = hours_list[0]
|
| 85 |
-
f = first.get("from") or first.get("opens") or ""
|
| 86 |
-
t = first.get("to") or first.get("closes") or ""
|
| 87 |
-
if f and t:
|
| 88 |
-
return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
|
| 89 |
-
opens = day_data.get("opens") or day_data.get("open") or ""
|
| 90 |
-
closes = day_data.get("closes") or day_data.get("close") or ""
|
| 91 |
-
if opens and closes:
|
| 92 |
-
return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
|
| 93 |
-
times = day_data.get("times") or []
|
| 94 |
-
if times and isinstance(times, list):
|
| 95 |
-
first = times[0]
|
| 96 |
-
f = first.get("from") or first.get("opens") or ""
|
| 97 |
-
t = first.get("to") or first.get("closes") or ""
|
| 98 |
-
if f and t:
|
| 99 |
-
return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
|
| 100 |
-
if status in ("open",):
|
| 101 |
-
return "Open — see hours page for times"
|
| 102 |
-
return "See hours page"
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
async def _hours_answer() -> str:
|
| 106 |
-
from datetime import timezone, timedelta
|
| 107 |
-
uae_now = datetime.now(timezone(timedelta(hours=4)))
|
| 108 |
-
today_str = uae_now.strftime("%Y-%m-%d")
|
| 109 |
-
day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
|
| 110 |
-
today_name = day_names[uae_now.weekday()]
|
| 111 |
-
hours_data = await _libcal_fetch_hours()
|
| 112 |
-
if not hours_data:
|
| 113 |
-
return (
|
| 114 |
-
"Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
|
| 115 |
-
"⚠️ Live hours are currently unavailable. Please check the official schedule:<br>"
|
| 116 |
-
f'<a href="{LIBRARY_HOURS_URL}" target="_blank" '
|
| 117 |
-
f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
|
| 118 |
-
"<strong>📍 Contacts:</strong><br>"
|
| 119 |
-
'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
|
| 120 |
-
'Habshan Library · SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
|
| 121 |
-
)
|
| 122 |
-
location_labels = {
|
| 123 |
-
"main_campus": "Main Campus Library",
|
| 124 |
-
"habshan": "Habshan Library (SAN Campus)",
|
| 125 |
-
}
|
| 126 |
-
def _today_line(loc_name: str, loc_data: dict) -> str:
|
| 127 |
-
dates = loc_data.get("dates", {})
|
| 128 |
-
today_entry = dates.get(today_str, {})
|
| 129 |
-
if not today_entry:
|
| 130 |
-
return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
|
| 131 |
-
hours_str = _format_libcal_day(today_entry)
|
| 132 |
-
is_closed = hours_str.lower().startswith("closed")
|
| 133 |
-
colour = "#dc2626" if is_closed else "#16a34a"
|
| 134 |
-
return (
|
| 135 |
-
f"📅 <strong>Today ({today_name}):</strong> "
|
| 136 |
-
f"<span style='color:{colour};font-weight:700'>{hours_str}</span>"
|
| 137 |
-
)
|
| 138 |
-
def _week_table(loc_data: dict) -> str:
|
| 139 |
-
dates = loc_data.get("dates", {})
|
| 140 |
-
if not dates:
|
| 141 |
-
return ""
|
| 142 |
-
rows = ""
|
| 143 |
-
for date_key in sorted(dates.keys())[:7]:
|
| 144 |
-
try:
|
| 145 |
-
from datetime import date as _date
|
| 146 |
-
d = _date.fromisoformat(date_key)
|
| 147 |
-
day_label = d.strftime("%a %d %b")
|
| 148 |
-
is_today = date_key == today_str
|
| 149 |
-
except ValueError:
|
| 150 |
-
day_label = date_key
|
| 151 |
-
is_today = False
|
| 152 |
-
hours_str = _format_libcal_day(dates[date_key])
|
| 153 |
-
is_closed = hours_str.lower().startswith("closed")
|
| 154 |
-
colour = "#dc2626" if is_closed else "#16a34a"
|
| 155 |
-
weight = "700" if is_today else "400"
|
| 156 |
-
bg = "#fffbeb" if is_today else "transparent"
|
| 157 |
-
rows += (
|
| 158 |
-
f"<tr style='background:{bg}'>"
|
| 159 |
-
f"<td style='padding:3px 12px 3px 0;color:#374151;font-weight:{weight}'>{day_label}</td>"
|
| 160 |
-
f"<td style='padding:3px 0;font-weight:600;color:{colour}'>{hours_str}</td>"
|
| 161 |
-
f"</tr>"
|
| 162 |
-
)
|
| 163 |
-
if not rows:
|
| 164 |
-
return ""
|
| 165 |
-
return (
|
| 166 |
-
"<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
|
| 167 |
-
+ rows + "</table>"
|
| 168 |
-
)
|
| 169 |
-
answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
|
| 170 |
-
for key, label in location_labels.items():
|
| 171 |
-
loc = hours_data.get(key)
|
| 172 |
-
if not loc:
|
| 173 |
-
continue
|
| 174 |
-
answer += f"<strong>📍 {label}</strong><br>"
|
| 175 |
-
answer += _today_line(label, loc) + "<br>"
|
| 176 |
-
week_tbl = _week_table(loc)
|
| 177 |
-
if week_tbl:
|
| 178 |
-
answer += week_tbl + "<br><br>"
|
| 179 |
-
else:
|
| 180 |
-
answer += "<br>"
|
| 181 |
-
answer += (
|
| 182 |
-
"⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
|
| 183 |
-
f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
|
| 184 |
-
f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
|
| 185 |
-
'<span style="font-size:.82rem;color:#6b7280">'
|
| 186 |
-
'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
|
| 187 |
-
'Habshan Library · Building 5, SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
|
| 188 |
-
'</span>'
|
| 189 |
-
)
|
| 190 |
-
return answer
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
_libcal_events_cache: Dict[str, Any] = {}
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
_LIBCAL_EVENTS_TTL = 30 * 60
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
|
| 200 |
-
now = time.time()
|
| 201 |
-
cache_key = f"{days}_{limit}"
|
| 202 |
-
cached = _libcal_events_cache.get(cache_key, {})
|
| 203 |
-
if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
|
| 204 |
-
return cached["data"]
|
| 205 |
-
settings = get_settings()
|
| 206 |
-
worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
|
| 207 |
-
if not worker_url:
|
| 208 |
-
logger.warning("LibCal events: CLOUDFLARE_WORKER_URL not set")
|
| 209 |
-
return None
|
| 210 |
-
try:
|
| 211 |
-
async with httpx.AsyncClient(timeout=10) as client:
|
| 212 |
-
r = await client.get(
|
| 213 |
-
f"{worker_url.rstrip('/')}/events",
|
| 214 |
-
params={"days": days, "limit": limit},
|
| 215 |
-
)
|
| 216 |
-
r.raise_for_status()
|
| 217 |
-
data = r.json()
|
| 218 |
-
if data.get("error"):
|
| 219 |
-
logger.warning("LibCal events worker error: %s", data["error"])
|
| 220 |
-
return None
|
| 221 |
-
events_raw = data.get("events", [])
|
| 222 |
-
def _safe_str(val) -> str:
|
| 223 |
-
if val is None:
|
| 224 |
-
return ""
|
| 225 |
-
if isinstance(val, str):
|
| 226 |
-
return val
|
| 227 |
-
if isinstance(val, dict):
|
| 228 |
-
return str(val.get("name") or val.get("title") or "")
|
| 229 |
-
if isinstance(val, list):
|
| 230 |
-
return ", ".join(_safe_str(v) for v in val if v)
|
| 231 |
-
return str(val)
|
| 232 |
-
events = []
|
| 233 |
-
for e in events_raw:
|
| 234 |
-
cats = e.get("category") or e.get("categories") or []
|
| 235 |
-
if isinstance(cats, list):
|
| 236 |
-
cat_str = ", ".join(_safe_str(c) for c in cats)
|
| 237 |
-
else:
|
| 238 |
-
cat_str = _safe_str(cats)
|
| 239 |
-
events.append({
|
| 240 |
-
"id": e.get("id", ""),
|
| 241 |
-
"title": _safe_str(e.get("title") or "Untitled Event"),
|
| 242 |
-
"start": _safe_str(e.get("start") or ""),
|
| 243 |
-
"end": _safe_str(e.get("end") or ""),
|
| 244 |
-
"allDay": bool(e.get("allday") or e.get("allDay")),
|
| 245 |
-
"location": _safe_str(e.get("location") or ""),
|
| 246 |
-
"description": _safe_str(e.get("description") or e.get("summary") or "")[:300],
|
| 247 |
-
"url": _safe_str(e.get("url") or ""),
|
| 248 |
-
"registration": bool(e.get("registration") or e.get("seats")),
|
| 249 |
-
"campus": _safe_str(e.get("campus") or ""),
|
| 250 |
-
"category": cat_str,
|
| 251 |
-
})
|
| 252 |
-
_libcal_events_cache[cache_key] = {"data": events, "cached_at": now}
|
| 253 |
-
logger.info("LibCal: %d events fetched", len(events))
|
| 254 |
-
return events
|
| 255 |
-
except Exception as exc:
|
| 256 |
-
logger.warning("LibCal events fetch failed: %s", exc)
|
| 257 |
-
return None
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
|
| 261 |
-
if not start:
|
| 262 |
-
return ""
|
| 263 |
-
try:
|
| 264 |
-
from datetime import datetime as _dt
|
| 265 |
-
s = _dt.fromisoformat(start.replace("Z", "+00:00"))
|
| 266 |
-
date_str = s.strftime("%a %d %b")
|
| 267 |
-
if all_day:
|
| 268 |
-
return date_str
|
| 269 |
-
time_str = s.strftime("%I:%M %p").lstrip("0")
|
| 270 |
-
if end:
|
| 271 |
-
e = _dt.fromisoformat(end.replace("Z", "+00:00"))
|
| 272 |
-
end_str = e.strftime("%I:%M %p").lstrip("0")
|
| 273 |
-
return f"{date_str} · {time_str} – {end_str}"
|
| 274 |
-
return f"{date_str} · {time_str}"
|
| 275 |
-
except Exception:
|
| 276 |
-
return start[:16].replace("T", " ")
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
async def _events_answer(days: int = 30) -> str:
|
| 280 |
-
EVENTS_URL = "https://kustar.libcal.com/calendar/events"
|
| 281 |
-
events = await _libcal_fetch_events(days=days, limit=15)
|
| 282 |
-
if not events:
|
| 283 |
-
return (
|
| 284 |
-
"<strong>📅 Khalifa University Library Events</strong><br><br>"
|
| 285 |
-
"Live event data is currently unavailable. Browse all upcoming events here:<br>"
|
| 286 |
-
f'<a href="{EVENTS_URL}" target="_blank" '
|
| 287 |
-
f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
|
| 288 |
-
)
|
| 289 |
-
if not events:
|
| 290 |
-
return (
|
| 291 |
-
"<strong>📅 Khalifa University Library Events</strong><br><br>"
|
| 292 |
-
f"No upcoming events found in the next {days} days.<br><br>"
|
| 293 |
-
f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
|
| 294 |
-
f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
|
| 295 |
-
)
|
| 296 |
-
answer = f"<strong>📅 Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
|
| 297 |
-
for ev in events[:10]:
|
| 298 |
-
title = _escape(str(ev.get("title") or "Untitled Event"))
|
| 299 |
-
when = _format_event_datetime(
|
| 300 |
-
str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay"))
|
| 301 |
-
)
|
| 302 |
-
location = _escape(str(ev.get("location") or ""))
|
| 303 |
-
desc = _escape(str(ev.get("description") or ""))
|
| 304 |
-
url = str(ev.get("url") or EVENTS_URL)
|
| 305 |
-
reg = bool(ev.get("registration"))
|
| 306 |
-
answer += (
|
| 307 |
-
f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
|
| 308 |
-
f'background:#f8fafc;border-radius:0 8px 8px 0">'
|
| 309 |
-
f'<div style="font-weight:700;font-size:.88rem;color:#003366">'
|
| 310 |
-
f'<a href="{url}" target="_blank" style="color:#003366;text-decoration:none">{title}</a></div>'
|
| 311 |
-
)
|
| 312 |
-
if when:
|
| 313 |
-
answer += f'<div style="font-size:.80rem;color:#374151;margin-top:2px">🕐 {when}</div>'
|
| 314 |
-
if location:
|
| 315 |
-
answer += f'<div style="font-size:.80rem;color:#374151">📍 {location}</div>'
|
| 316 |
-
if desc:
|
| 317 |
-
answer += f'<div style="font-size:.78rem;color:#6b7280;margin-top:4px">{desc[:180]}{"…" if len(desc) > 180 else ""}</div>'
|
| 318 |
-
if reg:
|
| 319 |
-
answer += (
|
| 320 |
-
f'<div style="margin-top:5px">'
|
| 321 |
-
f'<a href="{url}" target="_blank" '
|
| 322 |
-
f'style="font-size:.76rem;font-weight:700;color:#003366;'
|
| 323 |
-
f'padding:3px 10px;border:1px solid #003366;border-radius:6px;text-decoration:none">'
|
| 324 |
-
f'Register →</a></div>'
|
| 325 |
-
)
|
| 326 |
-
answer += '</div>'
|
| 327 |
-
answer += (
|
| 328 |
-
f'<br><a href="{EVENTS_URL}" target="_blank" '
|
| 329 |
-
f'style="font-weight:700;color:#003366">View full library events calendar →</a>'
|
| 330 |
-
)
|
| 331 |
-
return answer
|
| 332 |
-
|
| 333 |
-
|
|
|
|
| 1 |
+
"""LibCal client + formatting: live opening hours and events via the Cloudflare Worker proxy."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
|
| 19 |
+
from src.agentcore.constants import LIBRARY_HOURS_URL
|
| 20 |
+
from src.agentcore.utils import _escape
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
_libcal_hours_cache: Dict[str, Any] = {}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
_LIBCAL_HOURS_TTL = 60 * 60
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def _libcal_fetch_hours() -> Optional[dict]:
|
| 31 |
+
now = time.time()
|
| 32 |
+
cached_at = _libcal_hours_cache.get("cached_at", 0)
|
| 33 |
+
if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
|
| 34 |
+
return _libcal_hours_cache["data"]
|
| 35 |
+
settings = get_settings()
|
| 36 |
+
worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
|
| 37 |
+
if not worker_url:
|
| 38 |
+
logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set — cannot fetch hours")
|
| 39 |
+
return None
|
| 40 |
+
from datetime import timezone, timedelta
|
| 41 |
+
uae_now = datetime.now(timezone(timedelta(hours=4)))
|
| 42 |
+
date_from = uae_now.strftime("%Y-%m-%d")
|
| 43 |
+
date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
|
| 44 |
+
try:
|
| 45 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 46 |
+
r = await client.get(
|
| 47 |
+
f"{worker_url.rstrip('/')}/hours",
|
| 48 |
+
params={"from": date_from, "to": date_to},
|
| 49 |
+
)
|
| 50 |
+
r.raise_for_status()
|
| 51 |
+
data = r.json()
|
| 52 |
+
if data.get("error"):
|
| 53 |
+
logger.warning("LibCal worker error: %s", data["error"])
|
| 54 |
+
return None
|
| 55 |
+
_libcal_hours_cache["data"] = data
|
| 56 |
+
_libcal_hours_cache["cached_at"] = now
|
| 57 |
+
logger.info("LibCal: hours refreshed via worker")
|
| 58 |
+
return data
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
logger.warning("LibCal hours fetch failed: %s", exc)
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _parse_libcal_time(raw: str) -> str:
|
| 65 |
+
if not raw:
|
| 66 |
+
return raw
|
| 67 |
+
raw = raw.strip().replace("\u2013", "-").replace("–", "-")
|
| 68 |
+
raw = re.sub(r"\s+", "", raw)
|
| 69 |
+
raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
|
| 70 |
+
return raw
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _format_libcal_day(day_data: dict) -> str:
|
| 74 |
+
if not day_data or not isinstance(day_data, dict):
|
| 75 |
+
return "See hours page"
|
| 76 |
+
status = str(day_data.get("status") or "").lower().strip()
|
| 77 |
+
note = str(day_data.get("note") or "").strip()
|
| 78 |
+
if status in ("closed", "0", "false"):
|
| 79 |
+
return f"Closed{f' — {note}' if note else ''}"
|
| 80 |
+
if status == "24hours":
|
| 81 |
+
return "Open 24 hours"
|
| 82 |
+
hours_list = day_data.get("hours") or []
|
| 83 |
+
if hours_list and isinstance(hours_list, list):
|
| 84 |
+
first = hours_list[0]
|
| 85 |
+
f = first.get("from") or first.get("opens") or ""
|
| 86 |
+
t = first.get("to") or first.get("closes") or ""
|
| 87 |
+
if f and t:
|
| 88 |
+
return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
|
| 89 |
+
opens = day_data.get("opens") or day_data.get("open") or ""
|
| 90 |
+
closes = day_data.get("closes") or day_data.get("close") or ""
|
| 91 |
+
if opens and closes:
|
| 92 |
+
return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
|
| 93 |
+
times = day_data.get("times") or []
|
| 94 |
+
if times and isinstance(times, list):
|
| 95 |
+
first = times[0]
|
| 96 |
+
f = first.get("from") or first.get("opens") or ""
|
| 97 |
+
t = first.get("to") or first.get("closes") or ""
|
| 98 |
+
if f and t:
|
| 99 |
+
return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
|
| 100 |
+
if status in ("open",):
|
| 101 |
+
return "Open — see hours page for times"
|
| 102 |
+
return "See hours page"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
async def _hours_answer() -> str:
|
| 106 |
+
from datetime import timezone, timedelta
|
| 107 |
+
uae_now = datetime.now(timezone(timedelta(hours=4)))
|
| 108 |
+
today_str = uae_now.strftime("%Y-%m-%d")
|
| 109 |
+
day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
|
| 110 |
+
today_name = day_names[uae_now.weekday()]
|
| 111 |
+
hours_data = await _libcal_fetch_hours()
|
| 112 |
+
if not hours_data:
|
| 113 |
+
return (
|
| 114 |
+
"Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
|
| 115 |
+
"⚠️ Live hours are currently unavailable. Please check the official schedule:<br>"
|
| 116 |
+
f'<a href="{LIBRARY_HOURS_URL}" target="_blank" '
|
| 117 |
+
f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
|
| 118 |
+
"<strong>📍 Contacts:</strong><br>"
|
| 119 |
+
'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
|
| 120 |
+
'Habshan Library · SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
|
| 121 |
+
)
|
| 122 |
+
location_labels = {
|
| 123 |
+
"main_campus": "Main Campus Library",
|
| 124 |
+
"habshan": "Habshan Library (SAN Campus)",
|
| 125 |
+
}
|
| 126 |
+
def _today_line(loc_name: str, loc_data: dict) -> str:
|
| 127 |
+
dates = loc_data.get("dates", {})
|
| 128 |
+
today_entry = dates.get(today_str, {})
|
| 129 |
+
if not today_entry:
|
| 130 |
+
return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
|
| 131 |
+
hours_str = _format_libcal_day(today_entry)
|
| 132 |
+
is_closed = hours_str.lower().startswith("closed")
|
| 133 |
+
colour = "#dc2626" if is_closed else "#16a34a"
|
| 134 |
+
return (
|
| 135 |
+
f"📅 <strong>Today ({today_name}):</strong> "
|
| 136 |
+
f"<span style='color:{colour};font-weight:700'>{hours_str}</span>"
|
| 137 |
+
)
|
| 138 |
+
def _week_table(loc_data: dict) -> str:
|
| 139 |
+
dates = loc_data.get("dates", {})
|
| 140 |
+
if not dates:
|
| 141 |
+
return ""
|
| 142 |
+
rows = ""
|
| 143 |
+
for date_key in sorted(dates.keys())[:7]:
|
| 144 |
+
try:
|
| 145 |
+
from datetime import date as _date
|
| 146 |
+
d = _date.fromisoformat(date_key)
|
| 147 |
+
day_label = d.strftime("%a %d %b")
|
| 148 |
+
is_today = date_key == today_str
|
| 149 |
+
except ValueError:
|
| 150 |
+
day_label = date_key
|
| 151 |
+
is_today = False
|
| 152 |
+
hours_str = _format_libcal_day(dates[date_key])
|
| 153 |
+
is_closed = hours_str.lower().startswith("closed")
|
| 154 |
+
colour = "#dc2626" if is_closed else "#16a34a"
|
| 155 |
+
weight = "700" if is_today else "400"
|
| 156 |
+
bg = "#fffbeb" if is_today else "transparent"
|
| 157 |
+
rows += (
|
| 158 |
+
f"<tr style='background:{bg}'>"
|
| 159 |
+
f"<td style='padding:3px 12px 3px 0;color:#374151;font-weight:{weight}'>{day_label}</td>"
|
| 160 |
+
f"<td style='padding:3px 0;font-weight:600;color:{colour}'>{hours_str}</td>"
|
| 161 |
+
f"</tr>"
|
| 162 |
+
)
|
| 163 |
+
if not rows:
|
| 164 |
+
return ""
|
| 165 |
+
return (
|
| 166 |
+
"<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
|
| 167 |
+
+ rows + "</table>"
|
| 168 |
+
)
|
| 169 |
+
answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
|
| 170 |
+
for key, label in location_labels.items():
|
| 171 |
+
loc = hours_data.get(key)
|
| 172 |
+
if not loc:
|
| 173 |
+
continue
|
| 174 |
+
answer += f"<strong>📍 {label}</strong><br>"
|
| 175 |
+
answer += _today_line(label, loc) + "<br>"
|
| 176 |
+
week_tbl = _week_table(loc)
|
| 177 |
+
if week_tbl:
|
| 178 |
+
answer += week_tbl + "<br><br>"
|
| 179 |
+
else:
|
| 180 |
+
answer += "<br>"
|
| 181 |
+
answer += (
|
| 182 |
+
"⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
|
| 183 |
+
f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
|
| 184 |
+
f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
|
| 185 |
+
'<span style="font-size:.82rem;color:#6b7280">'
|
| 186 |
+
'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
|
| 187 |
+
'Habshan Library · Building 5, SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
|
| 188 |
+
'</span>'
|
| 189 |
+
)
|
| 190 |
+
return answer
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
_libcal_events_cache: Dict[str, Any] = {}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
_LIBCAL_EVENTS_TTL = 30 * 60
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
|
| 200 |
+
now = time.time()
|
| 201 |
+
cache_key = f"{days}_{limit}"
|
| 202 |
+
cached = _libcal_events_cache.get(cache_key, {})
|
| 203 |
+
if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
|
| 204 |
+
return cached["data"]
|
| 205 |
+
settings = get_settings()
|
| 206 |
+
worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
|
| 207 |
+
if not worker_url:
|
| 208 |
+
logger.warning("LibCal events: CLOUDFLARE_WORKER_URL not set")
|
| 209 |
+
return None
|
| 210 |
+
try:
|
| 211 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 212 |
+
r = await client.get(
|
| 213 |
+
f"{worker_url.rstrip('/')}/events",
|
| 214 |
+
params={"days": days, "limit": limit},
|
| 215 |
+
)
|
| 216 |
+
r.raise_for_status()
|
| 217 |
+
data = r.json()
|
| 218 |
+
if data.get("error"):
|
| 219 |
+
logger.warning("LibCal events worker error: %s", data["error"])
|
| 220 |
+
return None
|
| 221 |
+
events_raw = data.get("events", [])
|
| 222 |
+
def _safe_str(val) -> str:
|
| 223 |
+
if val is None:
|
| 224 |
+
return ""
|
| 225 |
+
if isinstance(val, str):
|
| 226 |
+
return val
|
| 227 |
+
if isinstance(val, dict):
|
| 228 |
+
return str(val.get("name") or val.get("title") or "")
|
| 229 |
+
if isinstance(val, list):
|
| 230 |
+
return ", ".join(_safe_str(v) for v in val if v)
|
| 231 |
+
return str(val)
|
| 232 |
+
events = []
|
| 233 |
+
for e in events_raw:
|
| 234 |
+
cats = e.get("category") or e.get("categories") or []
|
| 235 |
+
if isinstance(cats, list):
|
| 236 |
+
cat_str = ", ".join(_safe_str(c) for c in cats)
|
| 237 |
+
else:
|
| 238 |
+
cat_str = _safe_str(cats)
|
| 239 |
+
events.append({
|
| 240 |
+
"id": e.get("id", ""),
|
| 241 |
+
"title": _safe_str(e.get("title") or "Untitled Event"),
|
| 242 |
+
"start": _safe_str(e.get("start") or ""),
|
| 243 |
+
"end": _safe_str(e.get("end") or ""),
|
| 244 |
+
"allDay": bool(e.get("allday") or e.get("allDay")),
|
| 245 |
+
"location": _safe_str(e.get("location") or ""),
|
| 246 |
+
"description": _safe_str(e.get("description") or e.get("summary") or "")[:300],
|
| 247 |
+
"url": _safe_str(e.get("url") or ""),
|
| 248 |
+
"registration": bool(e.get("registration") or e.get("seats")),
|
| 249 |
+
"campus": _safe_str(e.get("campus") or ""),
|
| 250 |
+
"category": cat_str,
|
| 251 |
+
})
|
| 252 |
+
_libcal_events_cache[cache_key] = {"data": events, "cached_at": now}
|
| 253 |
+
logger.info("LibCal: %d events fetched", len(events))
|
| 254 |
+
return events
|
| 255 |
+
except Exception as exc:
|
| 256 |
+
logger.warning("LibCal events fetch failed: %s", exc)
|
| 257 |
+
return None
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
|
| 261 |
+
if not start:
|
| 262 |
+
return ""
|
| 263 |
+
try:
|
| 264 |
+
from datetime import datetime as _dt
|
| 265 |
+
s = _dt.fromisoformat(start.replace("Z", "+00:00"))
|
| 266 |
+
date_str = s.strftime("%a %d %b")
|
| 267 |
+
if all_day:
|
| 268 |
+
return date_str
|
| 269 |
+
time_str = s.strftime("%I:%M %p").lstrip("0")
|
| 270 |
+
if end:
|
| 271 |
+
e = _dt.fromisoformat(end.replace("Z", "+00:00"))
|
| 272 |
+
end_str = e.strftime("%I:%M %p").lstrip("0")
|
| 273 |
+
return f"{date_str} · {time_str} – {end_str}"
|
| 274 |
+
return f"{date_str} · {time_str}"
|
| 275 |
+
except Exception:
|
| 276 |
+
return start[:16].replace("T", " ")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
async def _events_answer(days: int = 30) -> str:
|
| 280 |
+
EVENTS_URL = "https://kustar.libcal.com/calendar/events"
|
| 281 |
+
events = await _libcal_fetch_events(days=days, limit=15)
|
| 282 |
+
if not events:
|
| 283 |
+
return (
|
| 284 |
+
"<strong>📅 Khalifa University Library Events</strong><br><br>"
|
| 285 |
+
"Live event data is currently unavailable. Browse all upcoming events here:<br>"
|
| 286 |
+
f'<a href="{EVENTS_URL}" target="_blank" '
|
| 287 |
+
f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
|
| 288 |
+
)
|
| 289 |
+
if not events:
|
| 290 |
+
return (
|
| 291 |
+
"<strong>📅 Khalifa University Library Events</strong><br><br>"
|
| 292 |
+
f"No upcoming events found in the next {days} days.<br><br>"
|
| 293 |
+
f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
|
| 294 |
+
f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
|
| 295 |
+
)
|
| 296 |
+
answer = f"<strong>📅 Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
|
| 297 |
+
for ev in events[:10]:
|
| 298 |
+
title = _escape(str(ev.get("title") or "Untitled Event"))
|
| 299 |
+
when = _format_event_datetime(
|
| 300 |
+
str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay"))
|
| 301 |
+
)
|
| 302 |
+
location = _escape(str(ev.get("location") or ""))
|
| 303 |
+
desc = _escape(str(ev.get("description") or ""))
|
| 304 |
+
url = str(ev.get("url") or EVENTS_URL)
|
| 305 |
+
reg = bool(ev.get("registration"))
|
| 306 |
+
answer += (
|
| 307 |
+
f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
|
| 308 |
+
f'background:#f8fafc;border-radius:0 8px 8px 0">'
|
| 309 |
+
f'<div style="font-weight:700;font-size:.88rem;color:#003366">'
|
| 310 |
+
f'<a href="{url}" target="_blank" style="color:#003366;text-decoration:none">{title}</a></div>'
|
| 311 |
+
)
|
| 312 |
+
if when:
|
| 313 |
+
answer += f'<div style="font-size:.80rem;color:#374151;margin-top:2px">🕐 {when}</div>'
|
| 314 |
+
if location:
|
| 315 |
+
answer += f'<div style="font-size:.80rem;color:#374151">📍 {location}</div>'
|
| 316 |
+
if desc:
|
| 317 |
+
answer += f'<div style="font-size:.78rem;color:#6b7280;margin-top:4px">{desc[:180]}{"…" if len(desc) > 180 else ""}</div>'
|
| 318 |
+
if reg:
|
| 319 |
+
answer += (
|
| 320 |
+
f'<div style="margin-top:5px">'
|
| 321 |
+
f'<a href="{url}" target="_blank" '
|
| 322 |
+
f'style="font-size:.76rem;font-weight:700;color:#003366;'
|
| 323 |
+
f'padding:3px 10px;border:1px solid #003366;border-radius:6px;text-decoration:none">'
|
| 324 |
+
f'Register →</a></div>'
|
| 325 |
+
)
|
| 326 |
+
answer += '</div>'
|
| 327 |
+
answer += (
|
| 328 |
+
f'<br><a href="{EVENTS_URL}" target="_blank" '
|
| 329 |
+
f'style="font-weight:700;color:#003366">View full library events calendar →</a>'
|
| 330 |
+
)
|
| 331 |
+
return answer
|
| 332 |
+
|
| 333 |
+
|
src/agentcore/models.py
CHANGED
|
@@ -1,71 +1,69 @@
|
|
| 1 |
-
"""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
|
| 14 |
-
import
|
| 15 |
-
from
|
| 16 |
|
| 17 |
-
from
|
| 18 |
|
| 19 |
-
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
class ChatMessage(BaseModel):
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class SearchContextPayload(BaseModel):
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
peer_reviewed: bool = False
|
| 34 |
open_access: bool = False
|
| 35 |
-
year_from: Optional[str] = None
|
| 36 |
-
year_to: Optional[str] = None
|
| 37 |
-
ai_tool_query: Optional[str] = None
|
| 38 |
-
primo_boolean_query: Optional[str] = None
|
| 39 |
created_at: float = Field(default_factory=time.time)
|
| 40 |
-
# v3.8 — ambiguity gate
|
| 41 |
clarification_needed: bool = False
|
| 42 |
-
clarification_message: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
class ClientStatePayload(BaseModel):
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
class AgentRequest(BaseModel):
|
| 54 |
-
|
| 55 |
-
|
| 56 |
model: Literal["gpt", "claude"] = "gpt"
|
| 57 |
-
history:
|
| 58 |
client_state: Optional[ClientStatePayload] = None
|
| 59 |
|
| 60 |
|
| 61 |
class AgentResponse(BaseModel):
|
| 62 |
model_config = ConfigDict(extra="ignore")
|
| 63 |
-
|
| 64 |
answer: str
|
| 65 |
intent: str = "general"
|
| 66 |
-
tools_used:
|
| 67 |
-
search_results:
|
| 68 |
-
sources:
|
| 69 |
model_used: str = "gpt"
|
| 70 |
response_time: float = 0
|
| 71 |
corrected_query: Optional[str] = None
|
|
@@ -74,12 +72,10 @@ class AgentResponse(BaseModel):
|
|
| 74 |
ask_librarian: Optional[bool] = None
|
| 75 |
is_follow_up: bool = False
|
| 76 |
follow_up_question: Optional[str] = None
|
| 77 |
-
follow_up_suggestions:
|
| 78 |
search_context: Optional[dict] = None
|
| 79 |
ai_tool_query: Optional[str] = None
|
| 80 |
primo_boolean_query: Optional[str] = None
|
| 81 |
primo_search_url: Optional[str] = None
|
| 82 |
primo_ai_url: Optional[str] = None
|
| 83 |
pubmed_search_url: Optional[str] = None
|
| 84 |
-
|
| 85 |
-
|
|
|
|
| 1 |
+
"""Strict request and response contracts for the LibBee agent."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
import time
|
| 4 |
+
from typing import Literal, Optional
|
| 5 |
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 7 |
|
|
|
|
| 8 |
|
| 9 |
class ChatMessage(BaseModel):
|
| 10 |
+
model_config = ConfigDict(extra="forbid")
|
| 11 |
+
role: Literal["user", "assistant"]
|
| 12 |
+
content: str = Field(min_length=1, max_length=4000)
|
| 13 |
|
| 14 |
|
| 15 |
class SearchContextPayload(BaseModel):
|
| 16 |
+
model_config = ConfigDict(extra="ignore")
|
| 17 |
+
context_id: str = Field(min_length=1, max_length=80)
|
| 18 |
+
topic: str = Field(min_length=1, max_length=500)
|
| 19 |
+
display_topic: Optional[str] = Field(default=None, max_length=500)
|
| 20 |
+
intent: Literal["search_academic", "search_medical"] = "search_academic"
|
| 21 |
+
source: Literal["primo", "pubmed"] = "primo"
|
| 22 |
+
resource_type: Literal["articles", "books", "both"] = "articles"
|
| 23 |
peer_reviewed: bool = False
|
| 24 |
open_access: bool = False
|
| 25 |
+
year_from: Optional[str] = Field(default=None, pattern=r"^\d{4}$")
|
| 26 |
+
year_to: Optional[str] = Field(default=None, pattern=r"^\d{4}$")
|
| 27 |
+
ai_tool_query: Optional[str] = Field(default=None, max_length=1000)
|
| 28 |
+
primo_boolean_query: Optional[str] = Field(default=None, max_length=1500)
|
| 29 |
created_at: float = Field(default_factory=time.time)
|
|
|
|
| 30 |
clarification_needed: bool = False
|
| 31 |
+
clarification_message: Optional[str] = Field(default=None, max_length=500)
|
| 32 |
+
|
| 33 |
+
@field_validator("year_to")
|
| 34 |
+
@classmethod
|
| 35 |
+
def years_in_order(cls, value: Optional[str], info):
|
| 36 |
+
start = info.data.get("year_from")
|
| 37 |
+
if value and start and int(value) < int(start):
|
| 38 |
+
raise ValueError("year_to must not be earlier than year_from")
|
| 39 |
+
return value
|
| 40 |
|
| 41 |
|
| 42 |
class ClientStatePayload(BaseModel):
|
| 43 |
+
model_config = ConfigDict(extra="ignore")
|
| 44 |
+
session_id: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9_-]{8,80}$")
|
| 45 |
+
active_search_context_id: Optional[str] = Field(default=None, max_length=80)
|
| 46 |
+
recent_search_contexts: list[SearchContextPayload] = Field(default_factory=list, max_length=5)
|
| 47 |
+
follow_up_action: Optional[str] = Field(default=None, max_length=80)
|
| 48 |
+
follow_up_context_id: Optional[str] = Field(default=None, max_length=80)
|
| 49 |
|
| 50 |
|
| 51 |
class AgentRequest(BaseModel):
|
| 52 |
+
model_config = ConfigDict(extra="forbid")
|
| 53 |
+
question: str = Field(min_length=1, max_length=2000)
|
| 54 |
model: Literal["gpt", "claude"] = "gpt"
|
| 55 |
+
history: list[ChatMessage] = Field(default_factory=list, max_length=12)
|
| 56 |
client_state: Optional[ClientStatePayload] = None
|
| 57 |
|
| 58 |
|
| 59 |
class AgentResponse(BaseModel):
|
| 60 |
model_config = ConfigDict(extra="ignore")
|
| 61 |
+
request_id: str = ""
|
| 62 |
answer: str
|
| 63 |
intent: str = "general"
|
| 64 |
+
tools_used: list[str] = Field(default_factory=list)
|
| 65 |
+
search_results: list[dict] = Field(default_factory=list)
|
| 66 |
+
sources: list[dict] = Field(default_factory=list)
|
| 67 |
model_used: str = "gpt"
|
| 68 |
response_time: float = 0
|
| 69 |
corrected_query: Optional[str] = None
|
|
|
|
| 72 |
ask_librarian: Optional[bool] = None
|
| 73 |
is_follow_up: bool = False
|
| 74 |
follow_up_question: Optional[str] = None
|
| 75 |
+
follow_up_suggestions: list[dict] = Field(default_factory=list)
|
| 76 |
search_context: Optional[dict] = None
|
| 77 |
ai_tool_query: Optional[str] = None
|
| 78 |
primo_boolean_query: Optional[str] = None
|
| 79 |
primo_search_url: Optional[str] = None
|
| 80 |
primo_ai_url: Optional[str] = None
|
| 81 |
pubmed_search_url: Optional[str] = None
|
|
|
|
|
|
src/agentcore/orchestrator.py
CHANGED
|
@@ -1,1049 +1,971 @@
|
|
| 1 |
-
"""Stage 4 orchestration: the seven-intent routing pipeline (_agent_query_inner) and D1 logging."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from collections import deque
|
| 11 |
-
from datetime import datetime
|
| 12 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 13 |
-
from urllib.parse import quote
|
| 14 |
-
|
| 15 |
-
import httpx
|
| 16 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 17 |
-
|
| 18 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 19 |
-
from src.services.staff_service import (
|
| 20 |
-
STAFF_DIRECTORY,
|
| 21 |
-
match_staff_name,
|
| 22 |
-
match_staff_role,
|
| 23 |
-
should_attempt_staff_lookup,
|
| 24 |
-
staff_name_answer,
|
| 25 |
-
staff_role_answer,
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
from src.agentcore.models import AgentResponse, SearchContextPayload
|
| 29 |
-
from src.agentcore.constants import (
|
| 30 |
-
ALT_TERMS_RE,
|
| 31 |
-
ASK_LIBRARIAN_URL,
|
| 32 |
-
BOOLEAN_STRATEGY_RE,
|
| 33 |
-
CITATION_CHAIN_RE,
|
| 34 |
-
DUAL_RESOURCE_RE,
|
| 35 |
-
EVENTS_RE,
|
| 36 |
-
FULLTEXT_REQUEST_RE,
|
| 37 |
-
HIGHLY_CITED_RE,
|
| 38 |
-
LIBRARY_HOURS_URL,
|
| 39 |
-
PARTIAL_TITLE_RE,
|
| 40 |
-
PREDATORY_RE,
|
| 41 |
-
PURE_GREETING_RE,
|
| 42 |
-
THESES_RE,
|
| 43 |
-
_INJECTION_RE,
|
| 44 |
-
)
|
| 45 |
-
from src.agentcore.utils import (
|
| 46 |
-
_escape,
|
| 47 |
-
_get_runtime_config,
|
| 48 |
-
_light_strip_retrieval_boilerplate,
|
| 49 |
-
_normalize_whitespace,
|
| 50 |
-
_primo_clean_url,
|
| 51 |
-
_safe_metrics_bucket,
|
| 52 |
-
_safe_metrics_increment,
|
| 53 |
-
_title_case_topic,
|
| 54 |
-
)
|
| 55 |
-
from src.agentcore.classify import (
|
| 56 |
-
_is_greeting_menu_followup,
|
| 57 |
-
_is_summary_request,
|
| 58 |
-
_llm_classify,
|
| 59 |
-
_looks_campus_question,
|
| 60 |
-
_looks_library_hours_question,
|
| 61 |
-
)
|
| 62 |
-
from src.agentcore.libcal import _events_answer, _hours_answer
|
| 63 |
-
from src.agentcore.rendering import (
|
| 64 |
-
_ai_tools_footer,
|
| 65 |
-
_contact_footer_for_query,
|
| 66 |
-
_search_trace_block,
|
| 67 |
-
_source_badge,
|
| 68 |
-
_thought_block,
|
| 69 |
-
_tool_urls,
|
| 70 |
-
)
|
| 71 |
-
from src.agentcore.intents_library import (
|
| 72 |
-
_campus_answer,
|
| 73 |
-
_database_recommendation_answer,
|
| 74 |
-
_fulltext_chain_answer,
|
| 75 |
-
_greeting_menu_clarify_answer,
|
| 76 |
-
_is_database_recommendation_question,
|
| 77 |
-
_library_follow_up,
|
| 78 |
-
_rag_answer,
|
| 79 |
-
_theses_answer,
|
| 80 |
-
)
|
| 81 |
-
from src.agentcore.intents_search import (
|
| 82 |
-
_alternative_terms_answer,
|
| 83 |
-
_apply_follow_up_action,
|
| 84 |
-
_citation_chain_answer,
|
| 85 |
-
_context_to_dict,
|
| 86 |
-
_derive_resource_type,
|
| 87 |
-
_detect_follow_up,
|
| 88 |
-
_extract_topic,
|
| 89 |
-
_highly_cited_note,
|
| 90 |
-
_parse_year_filters,
|
| 91 |
-
_predatory_eval_answer,
|
| 92 |
-
_prepare_queries,
|
| 93 |
-
_research_snapshot,
|
| 94 |
-
_run_search_mode,
|
| 95 |
-
_search_follow_up,
|
| 96 |
-
_search_strategy_answer,
|
| 97 |
-
)
|
| 98 |
-
from src.agentcore.intents_general import (
|
| 99 |
-
_general_follow_up,
|
| 100 |
-
_libbee_casual_response,
|
| 101 |
-
_social_follow_up,
|
| 102 |
-
_web_search_answer,
|
| 103 |
-
)
|
| 104 |
-
|
| 105 |
-
logger = logging.getLogger(__name__)
|
| 106 |
-
|
| 107 |
-
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
| 108 |
-
_LONG_DIGITS_RE = re.compile(r"\d{6,}")
|
| 109 |
-
_TAG_RE = re.compile(r"<[^>]+>")
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
# Strong references to in-flight analytics POSTs.
|
| 113 |
-
#
|
| 114 |
-
# asyncio keeps only WEAK references to tasks: a task created with
|
| 115 |
-
# create_task() and then dropped can be garbage-collected before it finishes,
|
| 116 |
-
# silently cancelling the request. That is invisible when the network is fast
|
| 117 |
-
# and drops writes unpredictably when the handshake is slow. Holding a
|
| 118 |
-
# reference until the task completes is the documented fix.
|
| 119 |
-
_ANALYTICS_TASKS = set()
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def _spawn_background(coro) -> bool:
|
| 123 |
-
"""Run coro in the background, keeping a strong reference. True if scheduled."""
|
| 124 |
-
try:
|
| 125 |
-
loop = asyncio.get_running_loop()
|
| 126 |
-
except RuntimeError:
|
| 127 |
-
try:
|
| 128 |
-
loop = asyncio.get_event_loop()
|
| 129 |
-
except Exception:
|
| 130 |
-
return False
|
| 131 |
-
if not loop.is_running():
|
| 132 |
-
return False
|
| 133 |
-
task = loop.create_task(coro)
|
| 134 |
-
_ANALYTICS_TASKS.add(task)
|
| 135 |
-
task.add_done_callback(_ANALYTICS_TASKS.discard)
|
| 136 |
-
return True
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def _redact_for_analytics(text: str, max_len: int = 200) -> str:
|
| 140 |
-
"""Privacy pass before anything reaches the analytics store: e-mail
|
| 141 |
-
addresses and long digit runs (IDs, phone numbers) are masked and the
|
| 142 |
-
text is truncated. Raw personal identifiers must never be persisted."""
|
| 143 |
-
text = _EMAIL_RE.sub("[email]", text or "")
|
| 144 |
-
text = _LONG_DIGITS_RE.sub("[number]", text)
|
| 145 |
-
return text[:max_len]
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _answer_excerpt(answer_html: str, max_len: int = 800) -> str:
|
| 149 |
-
"""Plain-text excerpt of the rendered answer for offline relevance grading
|
| 150 |
-
by the daily KB-gap agent. HTML is stripped and the same redaction applied."""
|
| 151 |
-
text = _TAG_RE.sub(" ", answer_html or "")
|
| 152 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 153 |
-
return _redact_for_analytics(text, max_len)
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
# ── Analytics retry buffer ───────────────────────────────────────────────────
|
| 157 |
-
# Transient failures reaching the Worker (rate limiting, connection resets)
|
| 158 |
-
# previously meant the row was lost for good. Failed payloads are parked here
|
| 159 |
-
# and re-attempted on subsequent queries, so a blocked window costs a delay
|
| 160 |
-
# rather than data. Bounded so a long outage cannot grow memory without limit.
|
| 161 |
-
|
| 162 |
-
_PENDING_LOGS: "deque" = deque(maxlen=200)
|
| 163 |
-
_PENDING_DROPPED = {"count": 0}
|
| 164 |
-
|
| 165 |
-
# Rolling record of the last few analytics-write attempts, so the outcome can be
|
| 166 |
-
# inspected through /admin/log-status without access to container logs.
|
| 167 |
-
_LOG_ATTEMPTS: "deque" = deque(maxlen=15)
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def _record_attempt(outcome: str, detail: str = "") -> None:
|
| 171 |
-
_LOG_ATTEMPTS.append({
|
| 172 |
-
"at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
| 173 |
-
"outcome": outcome,
|
| 174 |
-
"detail": detail[:200],
|
| 175 |
-
})
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def analytics_log_status() -> dict:
|
| 179 |
-
"""Snapshot of the analytics write path for the admin dashboard."""
|
| 180 |
-
return {
|
| 181 |
-
"pending_buffered": len(_PENDING_LOGS),
|
| 182 |
-
"buffer_capacity": _PENDING_LOGS.maxlen,
|
| 183 |
-
"recent_attempts": list(_LOG_ATTEMPTS)[::-1],
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
async def _send_with_retry(worker_url: str, token: str, payload: dict,
|
| 188 |
-
attempts: int = 2, backoff: float = 2.0):
|
| 189 |
-
"""_send_log_payload with one retry after a short backoff. Connectivity to
|
| 190 |
-
workers.dev from this host drops for short windows; a single immediate
|
| 191 |
-
retry recovers a meaningful fraction of those without involving the buffer."""
|
| 192 |
-
last_exc = None
|
| 193 |
-
for i in range(attempts):
|
| 194 |
-
try:
|
| 195 |
-
return await _send_log_payload(worker_url, token, payload)
|
| 196 |
-
except Exception as exc:
|
| 197 |
-
last_exc = exc
|
| 198 |
-
if i + 1 < attempts:
|
| 199 |
-
await asyncio.sleep(backoff)
|
| 200 |
-
raise last_exc
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
async def _send_log_payload(worker_url: str, token: str, payload: dict):
|
| 204 |
-
"""POST one analytics payload.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
alone cannot distinguish a stored row from a dropped one.
|
| 210 |
"""
|
| 211 |
-
from src.services.http_client import make_client as _mk
|
| 212 |
-
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
| 213 |
-
async with _mk(timeout=10) as client:
|
| 214 |
-
resp = await client.post(worker_url.rstrip("/") + "/log",
|
| 215 |
-
json=payload, headers=headers)
|
| 216 |
-
report = {}
|
| 217 |
-
try:
|
| 218 |
-
body = resp.json()
|
| 219 |
-
if isinstance(body, dict):
|
| 220 |
-
report = {k: body.get(k) for k in ("status", "logged", "schema", "warning")
|
| 221 |
-
if body.get(k) is not None}
|
| 222 |
-
except Exception:
|
| 223 |
-
report = {"raw": resp.text[:120]}
|
| 224 |
-
return resp.status_code, report
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
_FLUSHER_STARTED = {"on": False}
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
def _ensure_periodic_flusher(worker_url: str, token: str) -> None:
|
| 231 |
-
"""Start (once) a background loop that retries buffered rows every 90s, so
|
| 232 |
-
parked writes land as soon as connectivity returns instead of waiting for
|
| 233 |
-
the next user query. The task holds a strong reference via _spawn_background.
|
| 234 |
-
Lost on container restart - like the buffer itself - which the keep-alive
|
| 235 |
-
pings are there to make rare."""
|
| 236 |
-
if _FLUSHER_STARTED["on"]:
|
| 237 |
-
return
|
| 238 |
-
|
| 239 |
-
async def _loop():
|
| 240 |
-
while True:
|
| 241 |
-
await asyncio.sleep(90)
|
| 242 |
-
if _PENDING_LOGS:
|
| 243 |
-
try:
|
| 244 |
-
n = await _flush_pending(worker_url, token, max_items=10)
|
| 245 |
-
if n:
|
| 246 |
-
_record_attempt("flushed", f"{n} buffered row(s) delivered")
|
| 247 |
-
except Exception:
|
| 248 |
-
pass
|
| 249 |
-
|
| 250 |
-
if _spawn_background(_loop()):
|
| 251 |
-
_FLUSHER_STARTED["on"] = True
|
| 252 |
-
logger.info("Analytics flusher started (90s interval)")
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
async def _flush_pending(worker_url: str, token: str, max_items: int = 5):
|
| 256 |
-
"""Re-attempt parked payloads oldest-first; stop at the first failure so a
|
| 257 |
-
still-blocked Worker is not hammered."""
|
| 258 |
-
sent = 0
|
| 259 |
-
while _PENDING_LOGS and sent < max_items:
|
| 260 |
-
payload = _PENDING_LOGS[0]
|
| 261 |
-
try:
|
| 262 |
-
status, report = await _send_log_payload(worker_url, token, payload)
|
| 263 |
-
except Exception:
|
| 264 |
-
return sent
|
| 265 |
-
if status >= 400 or report.get("logged") is False:
|
| 266 |
-
return sent
|
| 267 |
-
_PENDING_LOGS.popleft()
|
| 268 |
-
sent += 1
|
| 269 |
-
if sent:
|
| 270 |
-
logger.info(f"D1 logging: flushed {sent} buffered row(s); "
|
| 271 |
-
f"{len(_PENDING_LOGS)} still pending")
|
| 272 |
-
return sent
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
def _log_agent_query(question: str, response: AgentResponse, start_time: float) -> None:
|
| 276 |
elapsed = time.time() - start_time
|
| 277 |
-
intent = response.intent or ""
|
| 278 |
-
tool = ", ".join(response.tools_used or [])[:60]
|
| 279 |
-
model_used = response.model_used or ""
|
| 280 |
-
result_count = len(response.search_results or [])
|
| 281 |
-
try:
|
| 282 |
-
from app import get_metrics_service
|
| 283 |
-
get_metrics_service().log_query(
|
| 284 |
-
question=_redact_for_analytics(question),
|
| 285 |
-
intent=intent,
|
| 286 |
-
tool=tool,
|
| 287 |
-
model=model_used,
|
| 288 |
-
response_time=elapsed,
|
| 289 |
-
result_count=result_count,
|
| 290 |
-
)
|
| 291 |
except Exception:
|
| 292 |
pass
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
"
|
| 309 |
-
|
| 310 |
-
"
|
| 311 |
-
"
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
)
|
| 402 |
-
|
| 403 |
-
if
|
| 404 |
-
follow_up_question, follow_up_suggestions =
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 437 |
-
return AgentResponse(
|
| 438 |
-
answer=_thought_block("
|
| 439 |
-
intent="library_info",
|
| 440 |
-
tools_used=["
|
| 441 |
-
model_used=model,
|
| 442 |
-
response_time=time.time() - start_time,
|
| 443 |
-
follow_up_question=follow_up_question,
|
| 444 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 445 |
-
)
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
)
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
)
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
return AgentResponse(
|
| 506 |
-
answer=_thought_block("
|
| 507 |
-
intent="library_info",
|
| 508 |
-
tools_used=["
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
)
|
| 545 |
-
_BOOK_RE = re.compile(r"\b(book|books|ebook|ebooks|textbook|textbooks|copy|copies|title|titles)\b", re.IGNORECASE)
|
| 546 |
-
|
| 547 |
-
if _CATALOGUE_RE.search(question) and _BOOK_RE.search(question):
|
| 548 |
-
from urllib.parse import quote as _quote
|
| 549 |
-
_topic_raw = re.sub(
|
| 550 |
-
r"\b(can you (help me )?find|find|search|look for|show me|help me find"
|
| 551 |
-
r"|books? (available|in|on)|available in.{0,30}library|in the ku library"
|
| 552 |
-
r"|on|about|regarding|related to|ku library|khalifa university library"
|
| 553 |
-
r"|machine learning|please|thanks)\b",
|
| 554 |
-
" ", question, flags=re.IGNORECASE,
|
| 555 |
-
)
|
| 556 |
-
_topic = _normalize_whitespace(_topic_raw).strip(" ,.")
|
| 557 |
-
_primo_books_url = (
|
| 558 |
-
"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 559 |
-
f"?query=any,contains,{_quote(_topic or question)},AND"
|
| 560 |
-
"&tab=Everything&search_scope=MyInst_and_CI"
|
| 561 |
-
"&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
|
| 562 |
-
"&qInclude=facet_rtype,exact,books"
|
| 563 |
-
)
|
| 564 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 565 |
-
|
| 566 |
-
rag_results = []
|
| 567 |
-
try:
|
| 568 |
-
from app import get_rag_service
|
| 569 |
-
rag_results = await get_rag_service().hybrid_search(question, top_k=3)
|
| 570 |
-
except Exception:
|
| 571 |
-
pass
|
| 572 |
-
|
| 573 |
-
if rag_results:
|
| 574 |
-
rag_answer = await _rag_answer(question, rag_results, [], model)
|
| 575 |
-
else:
|
| 576 |
-
rag_answer = ""
|
| 577 |
-
|
| 578 |
-
catalogue_answer = (
|
| 579 |
-
f"To find books available in the KU Library on <strong>{_escape(_topic or 'this topic')}</strong>, "
|
| 580 |
-
f"search the KU Library catalogue directly:<br><br>"
|
| 581 |
-
f'<a href="{_primo_books_url}" target="_blank" style="display:inline-block;padding:9px 16px;'
|
| 582 |
-
f'background:#003366;color:#fff;border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
|
| 583 |
-
f"📚 Search KU Library catalogue for books →</a><br><br>"
|
| 584 |
-
f"The catalogue shows real-time availability — whether a copy is on the shelf, checked out, or available as an e-book."
|
| 585 |
-
)
|
| 586 |
-
if rag_answer:
|
| 587 |
-
catalogue_answer += f"<br><br>{rag_answer}"
|
| 588 |
-
|
| 589 |
-
return AgentResponse(
|
| 590 |
-
answer=_thought_block("library_info", "catalogue_books", question) + catalogue_answer + _source_badge("primo", _primo_books_url),
|
| 591 |
-
intent="library_info",
|
| 592 |
-
tools_used=["catalogue_books_redirect"],
|
| 593 |
-
model_used=model,
|
| 594 |
-
response_time=time.time() - start_time,
|
| 595 |
-
follow_up_question=follow_up_question,
|
| 596 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 597 |
-
)
|
| 598 |
-
|
| 599 |
-
if is_follow_up and base_context:
|
| 600 |
-
_safe_metrics_increment("follow_up_hits")
|
| 601 |
-
context = _apply_follow_up_action(base_context, follow_up_action)
|
| 602 |
-
|
| 603 |
-
if follow_up_action == "alt_terms":
|
| 604 |
-
alt_answer = await _alternative_terms_answer(question, base_context, model)
|
| 605 |
-
follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model)
|
| 606 |
-
return AgentResponse(
|
| 607 |
-
answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
|
| 608 |
-
intent="library_info",
|
| 609 |
-
tools_used=["alt_terms"],
|
| 610 |
-
model_used=model,
|
| 611 |
-
response_time=time.time() - start_time,
|
| 612 |
-
follow_up_question=follow_up_question,
|
| 613 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 614 |
-
search_context=_context_to_dict(base_context),
|
| 615 |
-
is_follow_up=True,
|
| 616 |
-
)
|
| 617 |
-
|
| 618 |
-
if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
|
| 619 |
-
context = await _prepare_queries(question, context, model, True)
|
| 620 |
-
if follow_up_action == "summarize_topic":
|
| 621 |
-
answer, search_results, sources = await _research_snapshot(context, model)
|
| 622 |
-
else:
|
| 623 |
-
answer = (
|
| 624 |
-
f"{_search_trace_block(question, context)}"
|
| 625 |
-
f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
|
| 626 |
-
f"Here are the best AI tools and resources for a deep dive on this topic."
|
| 627 |
-
+ _source_badge("libbee")
|
| 628 |
-
+ _ai_tools_footer(context)
|
| 629 |
-
)
|
| 630 |
-
search_results = []
|
| 631 |
-
sources = []
|
| 632 |
-
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
|
| 633 |
-
_safe_metrics_increment("search_handoffs")
|
| 634 |
-
return AgentResponse(
|
| 635 |
-
answer=answer,
|
| 636 |
-
intent=context.intent,
|
| 637 |
-
tools_used=["follow_up_resolution", follow_up_action],
|
| 638 |
-
search_results=search_results,
|
| 639 |
-
sources=sources,
|
| 640 |
-
model_used=model,
|
| 641 |
-
response_time=time.time() - start_time,
|
| 642 |
-
corrected_query=context.topic,
|
| 643 |
-
natural_query=context.ai_tool_query,
|
| 644 |
-
database_query=context.primo_boolean_query,
|
| 645 |
-
ai_tool_query=context.ai_tool_query,
|
| 646 |
-
primo_boolean_query=context.primo_boolean_query,
|
| 647 |
-
primo_search_url=_primo_clean_url(context),
|
| 648 |
-
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 649 |
-
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 650 |
-
follow_up_question=follow_up_question,
|
| 651 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 652 |
-
search_context=_context_to_dict(context),
|
| 653 |
-
is_follow_up=True,
|
| 654 |
-
)
|
| 655 |
-
|
| 656 |
-
answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
|
| 657 |
-
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
|
| 658 |
-
return AgentResponse(
|
| 659 |
-
answer=answer,
|
| 660 |
-
intent=context.intent,
|
| 661 |
-
tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
|
| 662 |
-
search_results=search_results,
|
| 663 |
-
sources=sources,
|
| 664 |
-
model_used=model,
|
| 665 |
-
response_time=time.time() - start_time,
|
| 666 |
-
corrected_query=context.topic,
|
| 667 |
-
natural_query=context.ai_tool_query,
|
| 668 |
-
database_query=context.primo_boolean_query,
|
| 669 |
-
ai_tool_query=context.ai_tool_query,
|
| 670 |
-
primo_boolean_query=context.primo_boolean_query,
|
| 671 |
-
primo_search_url=source_url,
|
| 672 |
-
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 673 |
-
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 674 |
-
follow_up_question=follow_up_question,
|
| 675 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 676 |
-
search_context=_context_to_dict(context),
|
| 677 |
-
is_follow_up=True,
|
| 678 |
-
)
|
| 679 |
-
|
| 680 |
-
# ── Pre-classifier specialist handlers ───────────────────────────────────
|
| 681 |
-
|
| 682 |
-
if FULLTEXT_REQUEST_RE.search(question):
|
| 683 |
-
ft_answer = _fulltext_chain_answer(question)
|
| 684 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 685 |
-
return AgentResponse(
|
| 686 |
-
answer=_thought_block("library_info", "fulltext_chain", question,
|
| 687 |
-
"Identified a full-text access request. Returning the KU full-text access chain.") + ft_answer + _source_badge("ku_library", "https://library.ku.ac.ae/ill/"),
|
| 688 |
-
intent="library_info",
|
| 689 |
-
tools_used=["fulltext_chain"],
|
| 690 |
-
model_used=model,
|
| 691 |
-
response_time=time.time() - start_time,
|
| 692 |
-
follow_up_question=follow_up_question,
|
| 693 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 694 |
-
)
|
| 695 |
-
|
| 696 |
-
if THESES_RE.search(question):
|
| 697 |
-
th_answer = _theses_answer()
|
| 698 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 699 |
-
return AgentResponse(
|
| 700 |
-
answer=_thought_block("library_info", "theses_guide", question,
|
| 701 |
-
"Identified a theses/repository question.") + th_answer + _source_badge("ku_library", "https://khazna.ku.ac.ae"),
|
| 702 |
-
intent="library_info",
|
| 703 |
-
tools_used=["theses_guide"],
|
| 704 |
-
model_used=model,
|
| 705 |
-
response_time=time.time() - start_time,
|
| 706 |
-
follow_up_question=follow_up_question,
|
| 707 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 708 |
-
)
|
| 709 |
-
|
| 710 |
-
if PREDATORY_RE.search(question):
|
| 711 |
-
pr_answer = _predatory_eval_answer()
|
| 712 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 713 |
-
return AgentResponse(
|
| 714 |
-
answer=_thought_block("library_info", "predatory_eval", question,
|
| 715 |
-
"Identified a question about evaluating journals/peer review.") + pr_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
|
| 716 |
-
intent="library_info",
|
| 717 |
-
tools_used=["predatory_eval"],
|
| 718 |
-
model_used=model,
|
| 719 |
-
response_time=time.time() - start_time,
|
| 720 |
-
follow_up_question=follow_up_question,
|
| 721 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 722 |
-
)
|
| 723 |
-
|
| 724 |
-
if CITATION_CHAIN_RE.search(question):
|
| 725 |
-
cc_answer = _citation_chain_answer()
|
| 726 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 727 |
-
return AgentResponse(
|
| 728 |
-
answer=_thought_block("library_info", "citation_chain", question,
|
| 729 |
-
"Identified a citation tracing / forward-backward search question.") + cc_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
|
| 730 |
-
intent="library_info",
|
| 731 |
-
tools_used=["citation_chain"],
|
| 732 |
-
model_used=model,
|
| 733 |
-
response_time=time.time() - start_time,
|
| 734 |
-
follow_up_question=follow_up_question,
|
| 735 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 736 |
-
)
|
| 737 |
-
|
| 738 |
-
if BOOLEAN_STRATEGY_RE.search(question):
|
| 739 |
-
strategy_answer, _plan = await _search_strategy_answer(question, model)
|
| 740 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 741 |
-
return AgentResponse(
|
| 742 |
-
answer=_thought_block("library_info", "search_strategy", question,
|
| 743 |
-
"Identified a search strategy / boolean building request.") + strategy_answer + _source_badge("libbee"),
|
| 744 |
-
intent="library_info",
|
| 745 |
-
tools_used=["search_strategy_explain"],
|
| 746 |
-
model_used=model,
|
| 747 |
-
response_time=time.time() - start_time,
|
| 748 |
-
database_query=_plan.get("boolean"),
|
| 749 |
-
natural_query=_plan.get("natural"),
|
| 750 |
-
follow_up_question=follow_up_question,
|
| 751 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 752 |
-
)
|
| 753 |
-
|
| 754 |
-
if ALT_TERMS_RE.search(question):
|
| 755 |
-
alt_answer = await _alternative_terms_answer(question, None, model)
|
| 756 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 757 |
-
return AgentResponse(
|
| 758 |
-
answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
|
| 759 |
-
intent="library_info",
|
| 760 |
-
tools_used=["alt_terms"],
|
| 761 |
-
model_used=model,
|
| 762 |
-
response_time=time.time() - start_time,
|
| 763 |
-
follow_up_question=follow_up_question,
|
| 764 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 765 |
-
)
|
| 766 |
-
|
| 767 |
-
cls_result = await _llm_classify(question, history, model)
|
| 768 |
-
intent = cls_result.get("intent", "general")
|
| 769 |
-
_safe_metrics_bucket("intents", intent)
|
| 770 |
-
casual_hint = cls_result.get("casual_answer", "")
|
| 771 |
-
|
| 772 |
-
if intent == "social":
|
| 773 |
-
answer = (
|
| 774 |
-
casual_hint
|
| 775 |
-
if casual_hint and len(casual_hint.strip()) > 20
|
| 776 |
-
else await _libbee_casual_response(question, history, model, casual_hint)
|
| 777 |
-
)
|
| 778 |
-
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 779 |
-
return AgentResponse(
|
| 780 |
-
answer=_thought_block("social", "libbee_casual", question) + answer + _source_badge("libbee"),
|
| 781 |
-
intent="social_greeting",
|
| 782 |
-
tools_used=["libbee_casual"],
|
| 783 |
-
model_used=model,
|
| 784 |
-
response_time=time.time() - start_time,
|
| 785 |
-
follow_up_question=follow_up_question,
|
| 786 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 787 |
-
)
|
| 788 |
-
|
| 789 |
-
if intent == "sensitive":
|
| 790 |
-
_safe_metrics_bucket("intents", "sensitive_blocked")
|
| 791 |
-
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 792 |
-
return AgentResponse(
|
| 793 |
-
answer=(
|
| 794 |
-
"That's not something I'm able to comment on. "
|
| 795 |
-
"As the KU Library AI Assistant, I'm here to support your academic "
|
| 796 |
-
"and research needs — finding articles, accessing databases, "
|
| 797 |
-
"library services, and research tools.<br><br>"
|
| 798 |
-
"Is there something I can help you with today? "
|
| 799 |
-
f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">💬 Ask a Librarian</a>'
|
| 800 |
-
),
|
| 801 |
-
intent="sensitive_blocked",
|
| 802 |
-
tools_used=["sensitive_intent_guard"],
|
| 803 |
-
model_used=model,
|
| 804 |
-
response_time=time.time() - start_time,
|
| 805 |
-
follow_up_question=follow_up_question,
|
| 806 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 807 |
-
)
|
| 808 |
-
|
| 809 |
-
if intent == "library_info":
|
| 810 |
-
rag_results = []
|
| 811 |
-
try:
|
| 812 |
-
from app import get_rag_service
|
| 813 |
-
rag_results = await get_rag_service().hybrid_search(question, top_k=4)
|
| 814 |
-
except Exception as e:
|
| 815 |
-
logger.error(f"RAG search failed: {e}")
|
| 816 |
-
|
| 817 |
-
if not rag_results:
|
| 818 |
-
answer = (
|
| 819 |
-
"I couldn't find specific information on that in my knowledge base.<br><br>"
|
| 820 |
-
"Please contact our librarians directly:<br>"
|
| 821 |
-
'📧 <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
|
| 822 |
-
f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
|
| 823 |
-
)
|
| 824 |
-
answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
|
| 825 |
-
sources = []
|
| 826 |
-
tools_used = ["rag_no_results"]
|
| 827 |
-
thought = _thought_block("library_info", "rag_no_results", question,
|
| 828 |
-
"No matching content was found — I am directing you to Ask a Librarian.")
|
| 829 |
-
else:
|
| 830 |
-
answer = await _rag_answer(question, rag_results, history, model)
|
| 831 |
-
answer += _source_badge("rag", "https://library.ku.ac.ae/")
|
| 832 |
-
sources = [
|
| 833 |
-
{"title": r.get("title", "Library KB"), "url": r.get("source", "")}
|
| 834 |
-
for r in rag_results if r.get("source")
|
| 835 |
-
]
|
| 836 |
-
tools_used = ["rag_hybrid_search"]
|
| 837 |
-
thought = _thought_block("rag_search", "rag_hybrid_search", question,
|
| 838 |
-
f"Hybrid FAISS + BM25 search returned {len(rag_results)} relevant chunk(s) from the KB.")
|
| 839 |
-
|
| 840 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 841 |
-
return AgentResponse(
|
| 842 |
-
answer=thought + answer,
|
| 843 |
-
intent="library_info",
|
| 844 |
-
tools_used=tools_used,
|
| 845 |
-
sources=sources,
|
| 846 |
-
ask_librarian=not bool(rag_results),
|
| 847 |
-
model_used=model,
|
| 848 |
-
response_time=time.time() - start_time,
|
| 849 |
-
follow_up_question=follow_up_question,
|
| 850 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 851 |
-
)
|
| 852 |
-
|
| 853 |
-
if intent in ("search_academic", "search_medical"):
|
| 854 |
-
if PARTIAL_TITLE_RE.search(question):
|
| 855 |
-
from urllib.parse import quote as _qt
|
| 856 |
-
_title_frag = (re.search(r'"([^"]{4,80})"', question) or
|
| 857 |
-
re.search(r'"([^"]{4,80})"', question))
|
| 858 |
-
_title_q = _title_frag.group(1) if _title_frag else _light_strip_retrieval_boilerplate(question)
|
| 859 |
-
_title_url = (
|
| 860 |
-
"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 861 |
-
f"?query=title,contains,{_qt(_title_q)},AND"
|
| 862 |
-
"&tab=Everything&search_scope=MyInst_and_CI"
|
| 863 |
-
"&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
|
| 864 |
-
)
|
| 865 |
-
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 866 |
-
return AgentResponse(
|
| 867 |
-
answer=(
|
| 868 |
-
_thought_block("library_info", "title_search", question,
|
| 869 |
-
"Identified a partial title search — routing to PRIMO title search.") +
|
| 870 |
-
f"<strong>📖 Title search in KU Library</strong><br><br>"
|
| 871 |
-
f"I'll search the KU Library catalogue by title for: <em>{_escape(_title_q)}</em><br><br>"
|
| 872 |
-
f'<a href="{_title_url}" target="_blank" '
|
| 873 |
-
f'style="display:inline-block;padding:9px 16px;background:#003366;color:#fff;'
|
| 874 |
-
f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
|
| 875 |
-
f"🔍 Search by title in PRIMO →</a><br><br>"
|
| 876 |
-
"If you only remember part of the title, use PRIMO's advanced search and choose "
|
| 877 |
-
"<em>Title</em> from the field dropdown — it supports partial matches.<br>"
|
| 878 |
-
"You can also try "
|
| 879 |
-
'<a href="https://scholar.google.com" target="_blank">Google Scholar</a> '
|
| 880 |
-
"with the partial title in quotes." +
|
| 881 |
-
_source_badge("primo", _title_url)
|
| 882 |
-
),
|
| 883 |
-
intent="library_info",
|
| 884 |
-
tools_used=["title_search"],
|
| 885 |
-
model_used=model,
|
| 886 |
-
response_time=time.time() - start_time,
|
| 887 |
-
follow_up_question=follow_up_question,
|
| 888 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 889 |
-
)
|
| 890 |
-
|
| 891 |
-
topic = await _extract_topic(question, model)
|
| 892 |
-
topic = _title_case_topic(topic)
|
| 893 |
-
year_from, year_to = _parse_year_filters(question)
|
| 894 |
-
|
| 895 |
-
if DUAL_RESOURCE_RE.search(question):
|
| 896 |
-
import asyncio as _aio
|
| 897 |
-
_ctx_articles = SearchContextPayload(
|
| 898 |
-
context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
|
| 899 |
-
intent=intent, source="primo", resource_type="articles",
|
| 900 |
-
peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
|
| 901 |
-
open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
|
| 902 |
-
year_from=year_from, year_to=year_to,
|
| 903 |
-
)
|
| 904 |
-
_ctx_books = SearchContextPayload(
|
| 905 |
-
context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
|
| 906 |
-
intent=intent, source="primo", resource_type="books",
|
| 907 |
-
peer_reviewed=False, open_access=False,
|
| 908 |
-
year_from=year_from, year_to=year_to,
|
| 909 |
-
)
|
| 910 |
-
_ctx_articles, _ctx_books = await _aio.gather(
|
| 911 |
-
_prepare_queries(question, _ctx_articles, model, False),
|
| 912 |
-
_prepare_queries(question, _ctx_books, model, False),
|
| 913 |
-
)
|
| 914 |
-
_src_url_a = _primo_clean_url(_ctx_articles)
|
| 915 |
-
_src_url_b = _primo_clean_url(_ctx_books)
|
| 916 |
-
|
| 917 |
-
_dual_answer = _search_trace_block(question, _ctx_articles)
|
| 918 |
-
_dual_answer += (
|
| 919 |
-
f"<strong>📄 Articles on {_escape(topic)}</strong><br>"
|
| 920 |
-
f'<a href="{_src_url_a}" target="_blank" style="color:#003366;font-weight:700">'
|
| 921 |
-
f'Search PRIMO for articles →</a>'
|
| 922 |
-
)
|
| 923 |
-
_dual_answer += _source_badge("primo", _src_url_a)
|
| 924 |
-
_dual_answer += f"<br><br><strong>📚 Books on {_escape(topic)}</strong><br>"
|
| 925 |
-
_dual_answer += (
|
| 926 |
-
f'<a href="{_src_url_b}" target="_blank" style="color:#003366;font-weight:700">'
|
| 927 |
-
f'Search PRIMO for books →</a>'
|
| 928 |
-
)
|
| 929 |
-
_dual_answer += _source_badge("primo", _src_url_b)
|
| 930 |
-
_dual_answer += _ai_tools_footer(_ctx_articles)
|
| 931 |
-
|
| 932 |
-
follow_up_question, follow_up_suggestions = await _search_follow_up(_ctx_articles, model)
|
| 933 |
-
return AgentResponse(
|
| 934 |
-
answer=_dual_answer,
|
| 935 |
-
intent=intent,
|
| 936 |
-
tools_used=["primo_browser_link"],
|
| 937 |
-
search_results=[],
|
| 938 |
-
sources=[],
|
| 939 |
-
model_used=model,
|
| 940 |
-
response_time=time.time() - start_time,
|
| 941 |
-
corrected_query=topic,
|
| 942 |
-
natural_query=_ctx_articles.ai_tool_query,
|
| 943 |
-
primo_boolean_query=_ctx_articles.primo_boolean_query,
|
| 944 |
-
primo_search_url=_src_url_a,
|
| 945 |
-
follow_up_question=follow_up_question,
|
| 946 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 947 |
-
search_context=_context_to_dict(_ctx_articles),
|
| 948 |
-
)
|
| 949 |
-
|
| 950 |
-
context = SearchContextPayload(
|
| 951 |
-
context_id=str(uuid.uuid4()),
|
| 952 |
-
topic=topic,
|
| 953 |
-
display_topic=topic,
|
| 954 |
-
intent=intent,
|
| 955 |
-
source="primo",
|
| 956 |
-
resource_type=_derive_resource_type(question),
|
| 957 |
-
peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
|
| 958 |
-
open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
|
| 959 |
-
year_from=year_from,
|
| 960 |
-
year_to=year_to,
|
| 961 |
-
)
|
| 962 |
-
|
| 963 |
-
context = await _prepare_queries(question, context, model, False)
|
| 964 |
-
summary_mode = _is_summary_request(question)
|
| 965 |
-
deep_research_mode = bool(re.search(
|
| 966 |
-
r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
|
| 967 |
-
question, re.IGNORECASE,
|
| 968 |
-
))
|
| 969 |
-
is_highly_cited = bool(HIGHLY_CITED_RE.search(question))
|
| 970 |
-
|
| 971 |
-
if summary_mode or deep_research_mode:
|
| 972 |
-
answer, search_results, sources = await _research_snapshot(context, model)
|
| 973 |
-
if deep_research_mode and "Research guide" not in answer and "AI tools" not in answer:
|
| 974 |
-
answer += f"<br><br>{_ai_tools_footer(context)}"
|
| 975 |
-
if is_highly_cited:
|
| 976 |
-
answer += _highly_cited_note(_escape(topic))
|
| 977 |
-
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
|
| 978 |
-
_safe_metrics_increment("search_handoffs")
|
| 979 |
-
return AgentResponse(
|
| 980 |
-
answer=answer,
|
| 981 |
-
intent=intent,
|
| 982 |
-
tools_used=["research_snapshot"],
|
| 983 |
-
search_results=search_results,
|
| 984 |
-
sources=sources,
|
| 985 |
-
model_used=model,
|
| 986 |
-
response_time=time.time() - start_time,
|
| 987 |
-
corrected_query=context.topic,
|
| 988 |
-
natural_query=context.ai_tool_query,
|
| 989 |
-
database_query=context.primo_boolean_query,
|
| 990 |
-
ai_tool_query=context.ai_tool_query,
|
| 991 |
-
primo_boolean_query=context.primo_boolean_query,
|
| 992 |
-
primo_search_url=_primo_clean_url(context),
|
| 993 |
-
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 994 |
-
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 995 |
-
follow_up_question=follow_up_question,
|
| 996 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 997 |
-
search_context=_context_to_dict(context),
|
| 998 |
-
)
|
| 999 |
-
|
| 1000 |
-
answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
|
| 1001 |
-
if is_highly_cited:
|
| 1002 |
-
answer += _highly_cited_note(_escape(topic))
|
| 1003 |
-
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
|
| 1004 |
-
return AgentResponse(
|
| 1005 |
-
answer=answer,
|
| 1006 |
-
intent=intent,
|
| 1007 |
-
tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
|
| 1008 |
-
search_results=search_results,
|
| 1009 |
-
sources=sources,
|
| 1010 |
-
model_used=model,
|
| 1011 |
-
response_time=time.time() - start_time,
|
| 1012 |
-
corrected_query=context.topic,
|
| 1013 |
-
natural_query=context.ai_tool_query,
|
| 1014 |
-
database_query=context.primo_boolean_query,
|
| 1015 |
-
ai_tool_query=context.ai_tool_query,
|
| 1016 |
-
primo_boolean_query=context.primo_boolean_query,
|
| 1017 |
-
primo_search_url=source_url,
|
| 1018 |
-
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 1019 |
-
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 1020 |
-
follow_up_question=follow_up_question,
|
| 1021 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 1022 |
-
search_context=_context_to_dict(context),
|
| 1023 |
-
)
|
| 1024 |
-
|
| 1025 |
-
if intent == "general_recent":
|
| 1026 |
-
answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
|
| 1027 |
-
follow_up_question, follow_up_suggestions = _general_follow_up(question)
|
| 1028 |
-
return AgentResponse(
|
| 1029 |
-
answer=_thought_block("general_recent", "web_search", question) + answer,
|
| 1030 |
-
intent="general_recent",
|
| 1031 |
-
tools_used=["web_search"],
|
| 1032 |
-
model_used=model,
|
| 1033 |
-
response_time=time.time() - start_time,
|
| 1034 |
-
follow_up_question=follow_up_question,
|
| 1035 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 1036 |
-
)
|
| 1037 |
-
|
| 1038 |
-
# general fallback
|
| 1039 |
-
answer = await _web_search_answer(question, history, model) + _source_badge("web")
|
| 1040 |
-
follow_up_question, follow_up_suggestions = _general_follow_up(question)
|
| 1041 |
-
return AgentResponse(
|
| 1042 |
-
answer=_thought_block("general", "web_search", question) + answer,
|
| 1043 |
-
intent="general",
|
| 1044 |
-
tools_used=["web_search"],
|
| 1045 |
-
model_used=model,
|
| 1046 |
-
response_time=time.time() - start_time,
|
| 1047 |
-
follow_up_question=follow_up_question,
|
| 1048 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 1049 |
-
)
|
|
|
|
| 1 |
+
"""Stage 4 orchestration: the seven-intent routing pipeline (_agent_query_inner) and D1 logging."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from collections import deque
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 13 |
+
from urllib.parse import quote
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 17 |
+
|
| 18 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 19 |
+
from src.services.staff_service import (
|
| 20 |
+
STAFF_DIRECTORY,
|
| 21 |
+
match_staff_name,
|
| 22 |
+
match_staff_role,
|
| 23 |
+
should_attempt_staff_lookup,
|
| 24 |
+
staff_name_answer,
|
| 25 |
+
staff_role_answer,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
from src.agentcore.models import AgentResponse, SearchContextPayload
|
| 29 |
+
from src.agentcore.constants import (
|
| 30 |
+
ALT_TERMS_RE,
|
| 31 |
+
ASK_LIBRARIAN_URL,
|
| 32 |
+
BOOLEAN_STRATEGY_RE,
|
| 33 |
+
CITATION_CHAIN_RE,
|
| 34 |
+
DUAL_RESOURCE_RE,
|
| 35 |
+
EVENTS_RE,
|
| 36 |
+
FULLTEXT_REQUEST_RE,
|
| 37 |
+
HIGHLY_CITED_RE,
|
| 38 |
+
LIBRARY_HOURS_URL,
|
| 39 |
+
PARTIAL_TITLE_RE,
|
| 40 |
+
PREDATORY_RE,
|
| 41 |
+
PURE_GREETING_RE,
|
| 42 |
+
THESES_RE,
|
| 43 |
+
_INJECTION_RE,
|
| 44 |
+
)
|
| 45 |
+
from src.agentcore.utils import (
|
| 46 |
+
_escape,
|
| 47 |
+
_get_runtime_config,
|
| 48 |
+
_light_strip_retrieval_boilerplate,
|
| 49 |
+
_normalize_whitespace,
|
| 50 |
+
_primo_clean_url,
|
| 51 |
+
_safe_metrics_bucket,
|
| 52 |
+
_safe_metrics_increment,
|
| 53 |
+
_title_case_topic,
|
| 54 |
+
)
|
| 55 |
+
from src.agentcore.classify import (
|
| 56 |
+
_is_greeting_menu_followup,
|
| 57 |
+
_is_summary_request,
|
| 58 |
+
_llm_classify,
|
| 59 |
+
_looks_campus_question,
|
| 60 |
+
_looks_library_hours_question,
|
| 61 |
+
)
|
| 62 |
+
from src.agentcore.libcal import _events_answer, _hours_answer
|
| 63 |
+
from src.agentcore.rendering import (
|
| 64 |
+
_ai_tools_footer,
|
| 65 |
+
_contact_footer_for_query,
|
| 66 |
+
_search_trace_block,
|
| 67 |
+
_source_badge,
|
| 68 |
+
_thought_block,
|
| 69 |
+
_tool_urls,
|
| 70 |
+
)
|
| 71 |
+
from src.agentcore.intents_library import (
|
| 72 |
+
_campus_answer,
|
| 73 |
+
_database_recommendation_answer,
|
| 74 |
+
_fulltext_chain_answer,
|
| 75 |
+
_greeting_menu_clarify_answer,
|
| 76 |
+
_is_database_recommendation_question,
|
| 77 |
+
_library_follow_up,
|
| 78 |
+
_rag_answer,
|
| 79 |
+
_theses_answer,
|
| 80 |
+
)
|
| 81 |
+
from src.agentcore.intents_search import (
|
| 82 |
+
_alternative_terms_answer,
|
| 83 |
+
_apply_follow_up_action,
|
| 84 |
+
_citation_chain_answer,
|
| 85 |
+
_context_to_dict,
|
| 86 |
+
_derive_resource_type,
|
| 87 |
+
_detect_follow_up,
|
| 88 |
+
_extract_topic,
|
| 89 |
+
_highly_cited_note,
|
| 90 |
+
_parse_year_filters,
|
| 91 |
+
_predatory_eval_answer,
|
| 92 |
+
_prepare_queries,
|
| 93 |
+
_research_snapshot,
|
| 94 |
+
_run_search_mode,
|
| 95 |
+
_search_follow_up,
|
| 96 |
+
_search_strategy_answer,
|
| 97 |
+
)
|
| 98 |
+
from src.agentcore.intents_general import (
|
| 99 |
+
_general_follow_up,
|
| 100 |
+
_libbee_casual_response,
|
| 101 |
+
_social_follow_up,
|
| 102 |
+
_web_search_answer,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
logger = logging.getLogger(__name__)
|
| 106 |
+
|
| 107 |
+
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
| 108 |
+
_LONG_DIGITS_RE = re.compile(r"\d{6,}")
|
| 109 |
+
_TAG_RE = re.compile(r"<[^>]+>")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# Strong references to in-flight analytics POSTs.
|
| 113 |
+
#
|
| 114 |
+
# asyncio keeps only WEAK references to tasks: a task created with
|
| 115 |
+
# create_task() and then dropped can be garbage-collected before it finishes,
|
| 116 |
+
# silently cancelling the request. That is invisible when the network is fast
|
| 117 |
+
# and drops writes unpredictably when the handshake is slow. Holding a
|
| 118 |
+
# reference until the task completes is the documented fix.
|
| 119 |
+
_ANALYTICS_TASKS = set()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _spawn_background(coro) -> bool:
|
| 123 |
+
"""Run coro in the background, keeping a strong reference. True if scheduled."""
|
| 124 |
+
try:
|
| 125 |
+
loop = asyncio.get_running_loop()
|
| 126 |
+
except RuntimeError:
|
| 127 |
+
try:
|
| 128 |
+
loop = asyncio.get_event_loop()
|
| 129 |
+
except Exception:
|
| 130 |
+
return False
|
| 131 |
+
if not loop.is_running():
|
| 132 |
+
return False
|
| 133 |
+
task = loop.create_task(coro)
|
| 134 |
+
_ANALYTICS_TASKS.add(task)
|
| 135 |
+
task.add_done_callback(_ANALYTICS_TASKS.discard)
|
| 136 |
+
return True
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _redact_for_analytics(text: str, max_len: int = 200) -> str:
|
| 140 |
+
"""Privacy pass before anything reaches the analytics store: e-mail
|
| 141 |
+
addresses and long digit runs (IDs, phone numbers) are masked and the
|
| 142 |
+
text is truncated. Raw personal identifiers must never be persisted."""
|
| 143 |
+
text = _EMAIL_RE.sub("[email]", text or "")
|
| 144 |
+
text = _LONG_DIGITS_RE.sub("[number]", text)
|
| 145 |
+
return text[:max_len]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _answer_excerpt(answer_html: str, max_len: int = 800) -> str:
|
| 149 |
+
"""Plain-text excerpt of the rendered answer for offline relevance grading
|
| 150 |
+
by the daily KB-gap agent. HTML is stripped and the same redaction applied."""
|
| 151 |
+
text = _TAG_RE.sub(" ", answer_html or "")
|
| 152 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 153 |
+
return _redact_for_analytics(text, max_len)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ── Analytics retry buffer ───────────────────────────────────────────────────
|
| 157 |
+
# Transient failures reaching the Worker (rate limiting, connection resets)
|
| 158 |
+
# previously meant the row was lost for good. Failed payloads are parked here
|
| 159 |
+
# and re-attempted on subsequent queries, so a blocked window costs a delay
|
| 160 |
+
# rather than data. Bounded so a long outage cannot grow memory without limit.
|
| 161 |
+
|
| 162 |
+
_PENDING_LOGS: "deque" = deque(maxlen=200)
|
| 163 |
+
_PENDING_DROPPED = {"count": 0}
|
| 164 |
+
|
| 165 |
+
# Rolling record of the last few analytics-write attempts, so the outcome can be
|
| 166 |
+
# inspected through /admin/log-status without access to container logs.
|
| 167 |
+
_LOG_ATTEMPTS: "deque" = deque(maxlen=15)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _record_attempt(outcome: str, detail: str = "") -> None:
|
| 171 |
+
_LOG_ATTEMPTS.append({
|
| 172 |
+
"at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
| 173 |
+
"outcome": outcome,
|
| 174 |
+
"detail": detail[:200],
|
| 175 |
+
})
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def analytics_log_status() -> dict:
|
| 179 |
+
"""Snapshot of the analytics write path for the admin dashboard."""
|
| 180 |
+
return {
|
| 181 |
+
"pending_buffered": len(_PENDING_LOGS),
|
| 182 |
+
"buffer_capacity": _PENDING_LOGS.maxlen,
|
| 183 |
+
"recent_attempts": list(_LOG_ATTEMPTS)[::-1],
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
async def _send_with_retry(worker_url: str, token: str, payload: dict,
|
| 188 |
+
attempts: int = 2, backoff: float = 2.0):
|
| 189 |
+
"""_send_log_payload with one retry after a short backoff. Connectivity to
|
| 190 |
+
workers.dev from this host drops for short windows; a single immediate
|
| 191 |
+
retry recovers a meaningful fraction of those without involving the buffer."""
|
| 192 |
+
last_exc = None
|
| 193 |
+
for i in range(attempts):
|
| 194 |
+
try:
|
| 195 |
+
return await _send_log_payload(worker_url, token, payload)
|
| 196 |
+
except Exception as exc:
|
| 197 |
+
last_exc = exc
|
| 198 |
+
if i + 1 < attempts:
|
| 199 |
+
await asyncio.sleep(backoff)
|
| 200 |
+
raise last_exc
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def _send_log_payload(worker_url: str, token: str, payload: dict):
|
| 204 |
+
"""POST one analytics payload.
|
| 205 |
+
|
| 206 |
+
Returns (status_code, report) where report is the Worker's own JSON verdict.
|
| 207 |
+
This matters because the Worker answers HTTP 200 even when the D1 INSERT
|
| 208 |
+
itself failed, reporting the problem in a 'warning' field - so a status code
|
| 209 |
+
alone cannot distinguish a stored row from a dropped one.
|
| 210 |
+
"""
|
| 211 |
+
from src.services.http_client import make_client as _mk
|
| 212 |
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
| 213 |
+
async with _mk(timeout=10) as client:
|
| 214 |
+
resp = await client.post(worker_url.rstrip("/") + "/log",
|
| 215 |
+
json=payload, headers=headers)
|
| 216 |
+
report = {}
|
| 217 |
+
try:
|
| 218 |
+
body = resp.json()
|
| 219 |
+
if isinstance(body, dict):
|
| 220 |
+
report = {k: body.get(k) for k in ("status", "logged", "schema", "warning")
|
| 221 |
+
if body.get(k) is not None}
|
| 222 |
+
except Exception:
|
| 223 |
+
report = {"raw": resp.text[:120]}
|
| 224 |
+
return resp.status_code, report
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
_FLUSHER_STARTED = {"on": False}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _ensure_periodic_flusher(worker_url: str, token: str) -> None:
|
| 231 |
+
"""Start (once) a background loop that retries buffered rows every 90s, so
|
| 232 |
+
parked writes land as soon as connectivity returns instead of waiting for
|
| 233 |
+
the next user query. The task holds a strong reference via _spawn_background.
|
| 234 |
+
Lost on container restart - like the buffer itself - which the keep-alive
|
| 235 |
+
pings are there to make rare."""
|
| 236 |
+
if _FLUSHER_STARTED["on"]:
|
| 237 |
+
return
|
| 238 |
+
|
| 239 |
+
async def _loop():
|
| 240 |
+
while True:
|
| 241 |
+
await asyncio.sleep(90)
|
| 242 |
+
if _PENDING_LOGS:
|
| 243 |
+
try:
|
| 244 |
+
n = await _flush_pending(worker_url, token, max_items=10)
|
| 245 |
+
if n:
|
| 246 |
+
_record_attempt("flushed", f"{n} buffered row(s) delivered")
|
| 247 |
+
except Exception:
|
| 248 |
+
pass
|
| 249 |
+
|
| 250 |
+
if _spawn_background(_loop()):
|
| 251 |
+
_FLUSHER_STARTED["on"] = True
|
| 252 |
+
logger.info("Analytics flusher started (90s interval)")
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
async def _flush_pending(worker_url: str, token: str, max_items: int = 5):
|
| 256 |
+
"""Re-attempt parked payloads oldest-first; stop at the first failure so a
|
| 257 |
+
still-blocked Worker is not hammered."""
|
| 258 |
+
sent = 0
|
| 259 |
+
while _PENDING_LOGS and sent < max_items:
|
| 260 |
+
payload = _PENDING_LOGS[0]
|
| 261 |
+
try:
|
| 262 |
+
status, report = await _send_log_payload(worker_url, token, payload)
|
| 263 |
+
except Exception:
|
| 264 |
+
return sent
|
| 265 |
+
if status >= 400 or report.get("logged") is False:
|
| 266 |
+
return sent
|
| 267 |
+
_PENDING_LOGS.popleft()
|
| 268 |
+
sent += 1
|
| 269 |
+
if sent:
|
| 270 |
+
logger.info(f"D1 logging: flushed {sent} buffered row(s); "
|
| 271 |
+
f"{len(_PENDING_LOGS)} still pending")
|
| 272 |
+
return sent
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _log_agent_query(question: str, response: AgentResponse, start_time: float) -> None:
|
| 276 |
+
"""Keep only local operational counters.
|
| 277 |
|
| 278 |
+
Persistent analytics is written by the Cloudflare gateway after it receives
|
| 279 |
+
the origin response. This prevents duplicate rows and removes the old public
|
| 280 |
+
/log dependency.
|
|
|
|
| 281 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
elapsed = time.time() - start_time
|
| 283 |
+
intent = response.intent or ""
|
| 284 |
+
tool = ", ".join(response.tools_used or [])[:60]
|
| 285 |
+
model_used = response.model_used or ""
|
| 286 |
+
result_count = len(response.search_results or [])
|
| 287 |
+
try:
|
| 288 |
+
from app import get_metrics_service
|
| 289 |
+
get_metrics_service().log_query(
|
| 290 |
+
question=_redact_for_analytics(question),
|
| 291 |
+
intent=intent,
|
| 292 |
+
tool=tool,
|
| 293 |
+
model=model_used,
|
| 294 |
+
response_time=elapsed,
|
| 295 |
+
result_count=result_count,
|
| 296 |
+
)
|
| 297 |
except Exception:
|
| 298 |
pass
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
async def _agent_query_inner(
|
| 302 |
+
question: str,
|
| 303 |
+
history,
|
| 304 |
+
model: str,
|
| 305 |
+
client_state,
|
| 306 |
+
start_time: float,
|
| 307 |
+
) -> AgentResponse:
|
| 308 |
+
|
| 309 |
+
# ── Prompt injection detection ────────────────────────────────────────────
|
| 310 |
+
if question and _INJECTION_RE.search(question):
|
| 311 |
+
_safe_metrics_bucket("intents", "injection_blocked")
|
| 312 |
+
return AgentResponse(
|
| 313 |
+
answer=(
|
| 314 |
+
"I'm sorry, but I can't process that request. "
|
| 315 |
+
"I'm LibBee, the KU Library AI Assistant — here to help with "
|
| 316 |
+
"library services, research, and academic resources. "
|
| 317 |
+
f'How can I help you today? <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
|
| 318 |
+
),
|
| 319 |
+
intent="blocked",
|
| 320 |
+
tools_used=["injection_guard"],
|
| 321 |
+
model_used=model,
|
| 322 |
+
response_time=time.time() - start_time,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
if not question:
|
| 326 |
+
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 327 |
+
return AgentResponse(
|
| 328 |
+
answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"),
|
| 329 |
+
intent="social",
|
| 330 |
+
model_used=model,
|
| 331 |
+
response_time=time.time() - start_time,
|
| 332 |
+
follow_up_question=follow_up_question,
|
| 333 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
if PURE_GREETING_RE.match(question):
|
| 337 |
+
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 338 |
+
return AgentResponse(
|
| 339 |
+
answer=(
|
| 340 |
+
_get_runtime_config().get("welcome_message", "").strip()
|
| 341 |
+
or (
|
| 342 |
+
"Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>"
|
| 343 |
+
"I can help you find articles and books, search databases, access full text, "
|
| 344 |
+
"request Interlibrary Loan (ILL), and answer questions about library services and staff."
|
| 345 |
+
)
|
| 346 |
+
+ _source_badge("libbee")
|
| 347 |
+
),
|
| 348 |
+
intent="social_greeting",
|
| 349 |
+
model_used=model,
|
| 350 |
+
response_time=time.time() - start_time,
|
| 351 |
+
follow_up_question=follow_up_question,
|
| 352 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None
|
| 356 |
+
if staff_match:
|
| 357 |
+
_safe_metrics_bucket("intents", "staff_lookup")
|
| 358 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 359 |
+
return AgentResponse(
|
| 360 |
+
answer=_thought_block("staff_lookup", "staff_name_match", question) + staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
|
| 361 |
+
intent="library_info",
|
| 362 |
+
tools_used=["staff_name_match"],
|
| 363 |
+
model_used=model,
|
| 364 |
+
response_time=time.time() - start_time,
|
| 365 |
+
follow_up_question=follow_up_question,
|
| 366 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
|
| 370 |
+
if staff_role_match:
|
| 371 |
+
_safe_metrics_bucket("intents", "staff_lookup")
|
| 372 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 373 |
+
return AgentResponse(
|
| 374 |
+
answer=_thought_block("staff_lookup", "staff_role_match", question) + staff_role_answer(staff_role_match, question) + _source_badge("ku_library", "https://library.ku.ac.ae/"),
|
| 375 |
+
intent="library_info",
|
| 376 |
+
tools_used=["staff_role_match"],
|
| 377 |
+
model_used=model,
|
| 378 |
+
response_time=time.time() - start_time,
|
| 379 |
+
follow_up_question=follow_up_question,
|
| 380 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
if _is_greeting_menu_followup(question, history):
|
| 384 |
+
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 385 |
+
return AgentResponse(
|
| 386 |
+
answer=_thought_block("social_greeting", "greeting_menu", question) + _greeting_menu_clarify_answer() + _source_badge("libbee"),
|
| 387 |
+
intent="social_greeting",
|
| 388 |
+
is_follow_up=True,
|
| 389 |
+
model_used=model,
|
| 390 |
+
response_time=time.time() - start_time,
|
| 391 |
+
follow_up_question=follow_up_question,
|
| 392 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
if _looks_library_hours_question(question):
|
| 396 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 397 |
+
hours_html = await _hours_answer()
|
| 398 |
+
return AgentResponse(
|
| 399 |
+
answer=_thought_block("hours", "libcal_live", question) + hours_html + _source_badge("ku_library", LIBRARY_HOURS_URL),
|
| 400 |
+
intent="library_info",
|
| 401 |
+
tools_used=["libcal_live_hours"],
|
| 402 |
+
ask_librarian=False,
|
| 403 |
+
model_used=model,
|
| 404 |
+
response_time=time.time() - start_time,
|
| 405 |
+
follow_up_question=follow_up_question,
|
| 406 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
if EVENTS_RE.search(question):
|
| 410 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 411 |
+
events_html = await _events_answer()
|
| 412 |
+
return AgentResponse(
|
| 413 |
+
answer=_thought_block("library_info", "libcal_events", question,
|
| 414 |
+
"I fetched upcoming events from the KU LibCal events calendar.")
|
| 415 |
+
+ events_html + _source_badge("ku_library", "https://kustar.libcal.com/calendar/events"),
|
| 416 |
+
intent="library_info",
|
| 417 |
+
tools_used=["libcal_live_events"],
|
| 418 |
+
ask_librarian=False,
|
| 419 |
+
model_used=model,
|
| 420 |
+
response_time=time.time() - start_time,
|
| 421 |
+
follow_up_question=follow_up_question,
|
| 422 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
if _looks_campus_question(question):
|
| 426 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 427 |
+
return AgentResponse(
|
| 428 |
+
answer=_thought_block("campus", "campus_hardcoded", question) + _campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
|
| 429 |
+
intent="library_info",
|
| 430 |
+
tools_used=["campus_hardcoded"],
|
| 431 |
+
ask_librarian=False,
|
| 432 |
+
model_used=model,
|
| 433 |
+
response_time=time.time() - start_time,
|
| 434 |
+
follow_up_question=follow_up_question,
|
| 435 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
if _is_database_recommendation_question(question):
|
| 439 |
+
answer = _database_recommendation_answer(question)
|
| 440 |
+
answer += _contact_footer_for_query(question, None)
|
| 441 |
+
answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
|
| 442 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 443 |
+
return AgentResponse(
|
| 444 |
+
answer=_thought_block("database_rec", "database_recommendation", question) + answer,
|
| 445 |
+
intent="library_info",
|
| 446 |
+
tools_used=["database_recommendation"],
|
| 447 |
+
model_used=model,
|
| 448 |
+
response_time=time.time() - start_time,
|
| 449 |
+
follow_up_question=follow_up_question,
|
| 450 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
|
| 454 |
+
|
| 455 |
+
# ── Catalogue availability ────────────────────────────────────────────────
|
| 456 |
+
_CATALOGUE_RE = re.compile(
|
| 457 |
+
r"\b("
|
| 458 |
+
r"available in (the )?(ku|khalifa) library"
|
| 459 |
+
r"|in (the )?(ku|khalifa) library"
|
| 460 |
+
r"|does (the )?(ku|khalifa) library have"
|
| 461 |
+
r"|does (the )?library (have|carry|stock|hold)"
|
| 462 |
+
r"|(ku|khalifa) library (has|have|carry|holds?|stocks?)"
|
| 463 |
+
r"|ku library (books?|copies|titles?)"
|
| 464 |
+
r")\b",
|
| 465 |
+
re.IGNORECASE,
|
| 466 |
+
)
|
| 467 |
+
_BOOK_RE = re.compile(r"\b(book|books|ebook|ebooks|textbook|textbooks|copy|copies|title|titles)\b", re.IGNORECASE)
|
| 468 |
+
|
| 469 |
+
if _CATALOGUE_RE.search(question) and _BOOK_RE.search(question):
|
| 470 |
+
from urllib.parse import quote as _quote
|
| 471 |
+
_topic_raw = re.sub(
|
| 472 |
+
r"\b(can you (help me )?find|find|search|look for|show me|help me find"
|
| 473 |
+
r"|books? (available|in|on)|available in.{0,30}library|in the ku library"
|
| 474 |
+
r"|on|about|regarding|related to|ku library|khalifa university library"
|
| 475 |
+
r"|machine learning|please|thanks)\b",
|
| 476 |
+
" ", question, flags=re.IGNORECASE,
|
| 477 |
+
)
|
| 478 |
+
_topic = _normalize_whitespace(_topic_raw).strip(" ,.")
|
| 479 |
+
_primo_books_url = (
|
| 480 |
+
"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 481 |
+
f"?query=any,contains,{_quote(_topic or question)},AND"
|
| 482 |
+
"&tab=Everything&search_scope=MyInst_and_CI"
|
| 483 |
+
"&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
|
| 484 |
+
"&qInclude=facet_rtype,exact,books"
|
| 485 |
+
)
|
| 486 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 487 |
+
|
| 488 |
+
rag_results = []
|
| 489 |
+
try:
|
| 490 |
+
from app import get_rag_service
|
| 491 |
+
rag_results = await get_rag_service().hybrid_search(question, top_k=3)
|
| 492 |
+
except Exception:
|
| 493 |
+
pass
|
| 494 |
+
|
| 495 |
+
if rag_results:
|
| 496 |
+
rag_answer = await _rag_answer(question, rag_results, [], model)
|
| 497 |
+
else:
|
| 498 |
+
rag_answer = ""
|
| 499 |
+
|
| 500 |
+
catalogue_answer = (
|
| 501 |
+
f"To find books available in the KU Library on <strong>{_escape(_topic or 'this topic')}</strong>, "
|
| 502 |
+
f"search the KU Library catalogue directly:<br><br>"
|
| 503 |
+
f'<a href="{_primo_books_url}" target="_blank" style="display:inline-block;padding:9px 16px;'
|
| 504 |
+
f'background:#003366;color:#fff;border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
|
| 505 |
+
f"📚 Search KU Library catalogue for books →</a><br><br>"
|
| 506 |
+
f"The catalogue shows real-time availability — whether a copy is on the shelf, checked out, or available as an e-book."
|
| 507 |
+
)
|
| 508 |
+
if rag_answer:
|
| 509 |
+
catalogue_answer += f"<br><br>{rag_answer}"
|
| 510 |
+
|
| 511 |
+
return AgentResponse(
|
| 512 |
+
answer=_thought_block("library_info", "catalogue_books", question) + catalogue_answer + _source_badge("primo", _primo_books_url),
|
| 513 |
+
intent="library_info",
|
| 514 |
+
tools_used=["catalogue_books_redirect"],
|
| 515 |
+
model_used=model,
|
| 516 |
+
response_time=time.time() - start_time,
|
| 517 |
+
follow_up_question=follow_up_question,
|
| 518 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
if is_follow_up and base_context:
|
| 522 |
+
_safe_metrics_increment("follow_up_hits")
|
| 523 |
+
context = _apply_follow_up_action(base_context, follow_up_action)
|
| 524 |
+
|
| 525 |
+
if follow_up_action == "alt_terms":
|
| 526 |
+
alt_answer = await _alternative_terms_answer(question, base_context, model)
|
| 527 |
+
follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model)
|
| 528 |
+
return AgentResponse(
|
| 529 |
+
answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
|
| 530 |
+
intent="library_info",
|
| 531 |
+
tools_used=["alt_terms"],
|
| 532 |
+
model_used=model,
|
| 533 |
+
response_time=time.time() - start_time,
|
| 534 |
+
follow_up_question=follow_up_question,
|
| 535 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 536 |
+
search_context=_context_to_dict(base_context),
|
| 537 |
+
is_follow_up=True,
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
|
| 541 |
+
context = await _prepare_queries(question, context, model, True)
|
| 542 |
+
if follow_up_action == "summarize_topic":
|
| 543 |
+
answer, search_results, sources = await _research_snapshot(context, model)
|
| 544 |
+
else:
|
| 545 |
+
answer = (
|
| 546 |
+
f"{_search_trace_block(question, context)}"
|
| 547 |
+
f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
|
| 548 |
+
f"Here are the best AI tools and resources for a deep dive on this topic."
|
| 549 |
+
+ _source_badge("libbee")
|
| 550 |
+
+ _ai_tools_footer(context)
|
| 551 |
+
)
|
| 552 |
+
search_results = []
|
| 553 |
+
sources = []
|
| 554 |
+
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
|
| 555 |
+
_safe_metrics_increment("search_handoffs")
|
| 556 |
+
return AgentResponse(
|
| 557 |
+
answer=answer,
|
| 558 |
+
intent=context.intent,
|
| 559 |
+
tools_used=["follow_up_resolution", follow_up_action],
|
| 560 |
+
search_results=search_results,
|
| 561 |
+
sources=sources,
|
| 562 |
+
model_used=model,
|
| 563 |
+
response_time=time.time() - start_time,
|
| 564 |
+
corrected_query=context.topic,
|
| 565 |
+
natural_query=context.ai_tool_query,
|
| 566 |
+
database_query=context.primo_boolean_query,
|
| 567 |
+
ai_tool_query=context.ai_tool_query,
|
| 568 |
+
primo_boolean_query=context.primo_boolean_query,
|
| 569 |
+
primo_search_url=_primo_clean_url(context),
|
| 570 |
+
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 571 |
+
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 572 |
+
follow_up_question=follow_up_question,
|
| 573 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 574 |
+
search_context=_context_to_dict(context),
|
| 575 |
+
is_follow_up=True,
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
|
| 579 |
+
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
|
| 580 |
+
return AgentResponse(
|
| 581 |
+
answer=answer,
|
| 582 |
+
intent=context.intent,
|
| 583 |
+
tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"],
|
| 584 |
+
search_results=search_results,
|
| 585 |
+
sources=sources,
|
| 586 |
+
model_used=model,
|
| 587 |
+
response_time=time.time() - start_time,
|
| 588 |
+
corrected_query=context.topic,
|
| 589 |
+
natural_query=context.ai_tool_query,
|
| 590 |
+
database_query=context.primo_boolean_query,
|
| 591 |
+
ai_tool_query=context.ai_tool_query,
|
| 592 |
+
primo_boolean_query=context.primo_boolean_query,
|
| 593 |
+
primo_search_url=source_url,
|
| 594 |
+
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 595 |
+
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 596 |
+
follow_up_question=follow_up_question,
|
| 597 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 598 |
+
search_context=_context_to_dict(context),
|
| 599 |
+
is_follow_up=True,
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
# ── Pre-classifier specialist handlers ───────────────────────────────────
|
| 603 |
+
|
| 604 |
+
if FULLTEXT_REQUEST_RE.search(question):
|
| 605 |
+
ft_answer = _fulltext_chain_answer(question)
|
| 606 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 607 |
+
return AgentResponse(
|
| 608 |
+
answer=_thought_block("library_info", "fulltext_chain", question,
|
| 609 |
+
"Identified a full-text access request. Returning the KU full-text access chain.") + ft_answer + _source_badge("ku_library", "https://library.ku.ac.ae/ill/"),
|
| 610 |
+
intent="library_info",
|
| 611 |
+
tools_used=["fulltext_chain"],
|
| 612 |
+
model_used=model,
|
| 613 |
+
response_time=time.time() - start_time,
|
| 614 |
+
follow_up_question=follow_up_question,
|
| 615 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 616 |
+
)
|
| 617 |
+
|
| 618 |
+
if THESES_RE.search(question):
|
| 619 |
+
th_answer = _theses_answer()
|
| 620 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 621 |
+
return AgentResponse(
|
| 622 |
+
answer=_thought_block("library_info", "theses_guide", question,
|
| 623 |
+
"Identified a theses/repository question.") + th_answer + _source_badge("ku_library", "https://khazna.ku.ac.ae"),
|
| 624 |
+
intent="library_info",
|
| 625 |
+
tools_used=["theses_guide"],
|
| 626 |
+
model_used=model,
|
| 627 |
+
response_time=time.time() - start_time,
|
| 628 |
+
follow_up_question=follow_up_question,
|
| 629 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 630 |
+
)
|
| 631 |
+
|
| 632 |
+
if PREDATORY_RE.search(question):
|
| 633 |
+
pr_answer = _predatory_eval_answer()
|
| 634 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 635 |
+
return AgentResponse(
|
| 636 |
+
answer=_thought_block("library_info", "predatory_eval", question,
|
| 637 |
+
"Identified a question about evaluating journals/peer review.") + pr_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
|
| 638 |
+
intent="library_info",
|
| 639 |
+
tools_used=["predatory_eval"],
|
| 640 |
+
model_used=model,
|
| 641 |
+
response_time=time.time() - start_time,
|
| 642 |
+
follow_up_question=follow_up_question,
|
| 643 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 644 |
+
)
|
| 645 |
+
|
| 646 |
+
if CITATION_CHAIN_RE.search(question):
|
| 647 |
+
cc_answer = _citation_chain_answer()
|
| 648 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 649 |
+
return AgentResponse(
|
| 650 |
+
answer=_thought_block("library_info", "citation_chain", question,
|
| 651 |
+
"Identified a citation tracing / forward-backward search question.") + cc_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"),
|
| 652 |
+
intent="library_info",
|
| 653 |
+
tools_used=["citation_chain"],
|
| 654 |
+
model_used=model,
|
| 655 |
+
response_time=time.time() - start_time,
|
| 656 |
+
follow_up_question=follow_up_question,
|
| 657 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 658 |
+
)
|
| 659 |
+
|
| 660 |
+
if BOOLEAN_STRATEGY_RE.search(question):
|
| 661 |
+
strategy_answer, _plan = await _search_strategy_answer(question, model)
|
| 662 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 663 |
+
return AgentResponse(
|
| 664 |
+
answer=_thought_block("library_info", "search_strategy", question,
|
| 665 |
+
"Identified a search strategy / boolean building request.") + strategy_answer + _source_badge("libbee"),
|
| 666 |
+
intent="library_info",
|
| 667 |
+
tools_used=["search_strategy_explain"],
|
| 668 |
+
model_used=model,
|
| 669 |
+
response_time=time.time() - start_time,
|
| 670 |
+
database_query=_plan.get("boolean"),
|
| 671 |
+
natural_query=_plan.get("natural"),
|
| 672 |
+
follow_up_question=follow_up_question,
|
| 673 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 674 |
+
)
|
| 675 |
+
|
| 676 |
+
if ALT_TERMS_RE.search(question):
|
| 677 |
+
alt_answer = await _alternative_terms_answer(question, None, model)
|
| 678 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 679 |
+
return AgentResponse(
|
| 680 |
+
answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"),
|
| 681 |
+
intent="library_info",
|
| 682 |
+
tools_used=["alt_terms"],
|
| 683 |
+
model_used=model,
|
| 684 |
+
response_time=time.time() - start_time,
|
| 685 |
+
follow_up_question=follow_up_question,
|
| 686 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
cls_result = await _llm_classify(question, history, model)
|
| 690 |
+
intent = cls_result.get("intent", "general")
|
| 691 |
+
_safe_metrics_bucket("intents", intent)
|
| 692 |
+
casual_hint = cls_result.get("casual_answer", "")
|
| 693 |
+
|
| 694 |
+
if intent == "social":
|
| 695 |
+
answer = (
|
| 696 |
+
casual_hint
|
| 697 |
+
if casual_hint and len(casual_hint.strip()) > 20
|
| 698 |
+
else await _libbee_casual_response(question, history, model, casual_hint)
|
| 699 |
+
)
|
| 700 |
+
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 701 |
+
return AgentResponse(
|
| 702 |
+
answer=_thought_block("social", "libbee_casual", question) + answer + _source_badge("libbee"),
|
| 703 |
+
intent="social_greeting",
|
| 704 |
+
tools_used=["libbee_casual"],
|
| 705 |
+
model_used=model,
|
| 706 |
+
response_time=time.time() - start_time,
|
| 707 |
+
follow_up_question=follow_up_question,
|
| 708 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
if intent == "sensitive":
|
| 712 |
+
_safe_metrics_bucket("intents", "sensitive_blocked")
|
| 713 |
+
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 714 |
+
return AgentResponse(
|
| 715 |
+
answer=(
|
| 716 |
+
"That's not something I'm able to comment on. "
|
| 717 |
+
"As the KU Library AI Assistant, I'm here to support your academic "
|
| 718 |
+
"and research needs — finding articles, accessing databases, "
|
| 719 |
+
"library services, and research tools.<br><br>"
|
| 720 |
+
"Is there something I can help you with today? "
|
| 721 |
+
f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">💬 Ask a Librarian</a>'
|
| 722 |
+
),
|
| 723 |
+
intent="sensitive_blocked",
|
| 724 |
+
tools_used=["sensitive_intent_guard"],
|
| 725 |
+
model_used=model,
|
| 726 |
+
response_time=time.time() - start_time,
|
| 727 |
+
follow_up_question=follow_up_question,
|
| 728 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 729 |
+
)
|
| 730 |
+
|
| 731 |
+
if intent == "library_info":
|
| 732 |
+
rag_results = []
|
| 733 |
+
try:
|
| 734 |
+
from app import get_rag_service
|
| 735 |
+
rag_results = await get_rag_service().hybrid_search(question, top_k=4)
|
| 736 |
+
except Exception as e:
|
| 737 |
+
logger.error(f"RAG search failed: {e}")
|
| 738 |
+
|
| 739 |
+
if not rag_results:
|
| 740 |
+
answer = (
|
| 741 |
+
"I couldn't find specific information on that in my knowledge base.<br><br>"
|
| 742 |
+
"Please contact our librarians directly:<br>"
|
| 743 |
+
'📧 <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
|
| 744 |
+
f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
|
| 745 |
+
)
|
| 746 |
+
answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
|
| 747 |
+
sources = []
|
| 748 |
+
tools_used = ["rag_no_results"]
|
| 749 |
+
thought = _thought_block("library_info", "rag_no_results", question,
|
| 750 |
+
"No matching content was found — I am directing you to Ask a Librarian.")
|
| 751 |
+
else:
|
| 752 |
+
answer = await _rag_answer(question, rag_results, history, model)
|
| 753 |
+
answer += _source_badge("rag", "https://library.ku.ac.ae/")
|
| 754 |
+
sources = [
|
| 755 |
+
{"title": r.get("title", "Library KB"), "url": r.get("source", "")}
|
| 756 |
+
for r in rag_results if r.get("source")
|
| 757 |
+
]
|
| 758 |
+
tools_used = ["rag_hybrid_search"]
|
| 759 |
+
thought = _thought_block("rag_search", "rag_hybrid_search", question,
|
| 760 |
+
f"Hybrid FAISS + BM25 search returned {len(rag_results)} relevant chunk(s) from the KB.")
|
| 761 |
+
|
| 762 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 763 |
+
return AgentResponse(
|
| 764 |
+
answer=thought + answer,
|
| 765 |
+
intent="library_info",
|
| 766 |
+
tools_used=tools_used,
|
| 767 |
+
sources=sources,
|
| 768 |
+
ask_librarian=not bool(rag_results),
|
| 769 |
+
model_used=model,
|
| 770 |
+
response_time=time.time() - start_time,
|
| 771 |
+
follow_up_question=follow_up_question,
|
| 772 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
if intent in ("search_academic", "search_medical"):
|
| 776 |
+
if PARTIAL_TITLE_RE.search(question):
|
| 777 |
+
from urllib.parse import quote as _qt
|
| 778 |
+
_title_frag = (re.search(r'"([^"]{4,80})"', question) or
|
| 779 |
+
re.search(r'"([^"]{4,80})"', question))
|
| 780 |
+
_title_q = _title_frag.group(1) if _title_frag else _light_strip_retrieval_boilerplate(question)
|
| 781 |
+
_title_url = (
|
| 782 |
+
"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 783 |
+
f"?query=title,contains,{_qt(_title_q)},AND"
|
| 784 |
+
"&tab=Everything&search_scope=MyInst_and_CI"
|
| 785 |
+
"&vid=971KUOSTAR_INST:KU&lang=en&mode=advanced"
|
| 786 |
+
)
|
| 787 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 788 |
+
return AgentResponse(
|
| 789 |
+
answer=(
|
| 790 |
+
_thought_block("library_info", "title_search", question,
|
| 791 |
+
"Identified a partial title search — routing to PRIMO title search.") +
|
| 792 |
+
f"<strong>📖 Title search in KU Library</strong><br><br>"
|
| 793 |
+
f"I'll search the KU Library catalogue by title for: <em>{_escape(_title_q)}</em><br><br>"
|
| 794 |
+
f'<a href="{_title_url}" target="_blank" '
|
| 795 |
+
f'style="display:inline-block;padding:9px 16px;background:#003366;color:#fff;'
|
| 796 |
+
f'border-radius:8px;font-weight:700;text-decoration:none;font-size:.88rem">'
|
| 797 |
+
f"🔍 Search by title in PRIMO →</a><br><br>"
|
| 798 |
+
"If you only remember part of the title, use PRIMO's advanced search and choose "
|
| 799 |
+
"<em>Title</em> from the field dropdown — it supports partial matches.<br>"
|
| 800 |
+
"You can also try "
|
| 801 |
+
'<a href="https://scholar.google.com" target="_blank">Google Scholar</a> '
|
| 802 |
+
"with the partial title in quotes." +
|
| 803 |
+
_source_badge("primo", _title_url)
|
| 804 |
+
),
|
| 805 |
+
intent="library_info",
|
| 806 |
+
tools_used=["title_search"],
|
| 807 |
+
model_used=model,
|
| 808 |
+
response_time=time.time() - start_time,
|
| 809 |
+
follow_up_question=follow_up_question,
|
| 810 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 811 |
+
)
|
| 812 |
+
|
| 813 |
+
topic = await _extract_topic(question, model)
|
| 814 |
+
topic = _title_case_topic(topic)
|
| 815 |
+
year_from, year_to = _parse_year_filters(question)
|
| 816 |
+
|
| 817 |
+
if DUAL_RESOURCE_RE.search(question):
|
| 818 |
+
import asyncio as _aio
|
| 819 |
+
_ctx_articles = SearchContextPayload(
|
| 820 |
+
context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
|
| 821 |
+
intent=intent, source="primo", resource_type="articles",
|
| 822 |
+
peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
|
| 823 |
+
open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
|
| 824 |
+
year_from=year_from, year_to=year_to,
|
| 825 |
+
)
|
| 826 |
+
_ctx_books = SearchContextPayload(
|
| 827 |
+
context_id=str(uuid.uuid4()), topic=topic, display_topic=topic,
|
| 828 |
+
intent=intent, source="primo", resource_type="books",
|
| 829 |
+
peer_reviewed=False, open_access=False,
|
| 830 |
+
year_from=year_from, year_to=year_to,
|
| 831 |
+
)
|
| 832 |
+
_ctx_articles, _ctx_books = await _aio.gather(
|
| 833 |
+
_prepare_queries(question, _ctx_articles, model, False),
|
| 834 |
+
_prepare_queries(question, _ctx_books, model, False),
|
| 835 |
+
)
|
| 836 |
+
_src_url_a = _primo_clean_url(_ctx_articles)
|
| 837 |
+
_src_url_b = _primo_clean_url(_ctx_books)
|
| 838 |
+
|
| 839 |
+
_dual_answer = _search_trace_block(question, _ctx_articles)
|
| 840 |
+
_dual_answer += (
|
| 841 |
+
f"<strong>📄 Articles on {_escape(topic)}</strong><br>"
|
| 842 |
+
f'<a href="{_src_url_a}" target="_blank" style="color:#003366;font-weight:700">'
|
| 843 |
+
f'Search PRIMO for articles →</a>'
|
| 844 |
+
)
|
| 845 |
+
_dual_answer += _source_badge("primo", _src_url_a)
|
| 846 |
+
_dual_answer += f"<br><br><strong>📚 Books on {_escape(topic)}</strong><br>"
|
| 847 |
+
_dual_answer += (
|
| 848 |
+
f'<a href="{_src_url_b}" target="_blank" style="color:#003366;font-weight:700">'
|
| 849 |
+
f'Search PRIMO for books →</a>'
|
| 850 |
+
)
|
| 851 |
+
_dual_answer += _source_badge("primo", _src_url_b)
|
| 852 |
+
_dual_answer += _ai_tools_footer(_ctx_articles)
|
| 853 |
+
|
| 854 |
+
follow_up_question, follow_up_suggestions = await _search_follow_up(_ctx_articles, model)
|
| 855 |
+
return AgentResponse(
|
| 856 |
+
answer=_dual_answer,
|
| 857 |
+
intent=intent,
|
| 858 |
+
tools_used=["primo_browser_link"],
|
| 859 |
+
search_results=[],
|
| 860 |
+
sources=[],
|
| 861 |
+
model_used=model,
|
| 862 |
+
response_time=time.time() - start_time,
|
| 863 |
+
corrected_query=topic,
|
| 864 |
+
natural_query=_ctx_articles.ai_tool_query,
|
| 865 |
+
primo_boolean_query=_ctx_articles.primo_boolean_query,
|
| 866 |
+
primo_search_url=_src_url_a,
|
| 867 |
+
follow_up_question=follow_up_question,
|
| 868 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 869 |
+
search_context=_context_to_dict(_ctx_articles),
|
| 870 |
+
)
|
| 871 |
+
|
| 872 |
+
context = SearchContextPayload(
|
| 873 |
+
context_id=str(uuid.uuid4()),
|
| 874 |
+
topic=topic,
|
| 875 |
+
display_topic=topic,
|
| 876 |
+
intent=intent,
|
| 877 |
+
source="primo",
|
| 878 |
+
resource_type=_derive_resource_type(question),
|
| 879 |
+
peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)),
|
| 880 |
+
open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)),
|
| 881 |
+
year_from=year_from,
|
| 882 |
+
year_to=year_to,
|
| 883 |
+
)
|
| 884 |
+
|
| 885 |
+
context = await _prepare_queries(question, context, model, False)
|
| 886 |
+
summary_mode = _is_summary_request(question)
|
| 887 |
+
deep_research_mode = bool(re.search(
|
| 888 |
+
r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b",
|
| 889 |
+
question, re.IGNORECASE,
|
| 890 |
+
))
|
| 891 |
+
is_highly_cited = bool(HIGHLY_CITED_RE.search(question))
|
| 892 |
+
|
| 893 |
+
if summary_mode or deep_research_mode:
|
| 894 |
+
answer, search_results, sources = await _research_snapshot(context, model)
|
| 895 |
+
if deep_research_mode and "Research guide" not in answer and "AI tools" not in answer:
|
| 896 |
+
answer += f"<br><br>{_ai_tools_footer(context)}"
|
| 897 |
+
if is_highly_cited:
|
| 898 |
+
answer += _highly_cited_note(_escape(topic))
|
| 899 |
+
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
|
| 900 |
+
_safe_metrics_increment("search_handoffs")
|
| 901 |
+
return AgentResponse(
|
| 902 |
+
answer=answer,
|
| 903 |
+
intent=intent,
|
| 904 |
+
tools_used=["research_snapshot"],
|
| 905 |
+
search_results=search_results,
|
| 906 |
+
sources=sources,
|
| 907 |
+
model_used=model,
|
| 908 |
+
response_time=time.time() - start_time,
|
| 909 |
+
corrected_query=context.topic,
|
| 910 |
+
natural_query=context.ai_tool_query,
|
| 911 |
+
database_query=context.primo_boolean_query,
|
| 912 |
+
ai_tool_query=context.ai_tool_query,
|
| 913 |
+
primo_boolean_query=context.primo_boolean_query,
|
| 914 |
+
primo_search_url=_primo_clean_url(context),
|
| 915 |
+
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 916 |
+
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 917 |
+
follow_up_question=follow_up_question,
|
| 918 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 919 |
+
search_context=_context_to_dict(context),
|
| 920 |
+
)
|
| 921 |
+
|
| 922 |
+
answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
|
| 923 |
+
if is_highly_cited:
|
| 924 |
+
answer += _highly_cited_note(_escape(topic))
|
| 925 |
+
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model)
|
| 926 |
+
return AgentResponse(
|
| 927 |
+
answer=answer,
|
| 928 |
+
intent=intent,
|
| 929 |
+
tools_used=["primo_inline" if context.source == "primo" else "pubmed_inline"],
|
| 930 |
+
search_results=search_results,
|
| 931 |
+
sources=sources,
|
| 932 |
+
model_used=model,
|
| 933 |
+
response_time=time.time() - start_time,
|
| 934 |
+
corrected_query=context.topic,
|
| 935 |
+
natural_query=context.ai_tool_query,
|
| 936 |
+
database_query=context.primo_boolean_query,
|
| 937 |
+
ai_tool_query=context.ai_tool_query,
|
| 938 |
+
primo_boolean_query=context.primo_boolean_query,
|
| 939 |
+
primo_search_url=source_url,
|
| 940 |
+
primo_ai_url=_tool_urls(context).get("primo_ai"),
|
| 941 |
+
pubmed_search_url=_tool_urls(context).get("pubmed"),
|
| 942 |
+
follow_up_question=follow_up_question,
|
| 943 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 944 |
+
search_context=_context_to_dict(context),
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
if intent == "general_recent":
|
| 948 |
+
answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
|
| 949 |
+
follow_up_question, follow_up_suggestions = _general_follow_up(question)
|
| 950 |
+
return AgentResponse(
|
| 951 |
+
answer=_thought_block("general_recent", "web_search", question) + answer,
|
| 952 |
+
intent="general_recent",
|
| 953 |
+
tools_used=["web_search"],
|
| 954 |
+
model_used=model,
|
| 955 |
+
response_time=time.time() - start_time,
|
| 956 |
+
follow_up_question=follow_up_question,
|
| 957 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 958 |
+
)
|
| 959 |
+
|
| 960 |
+
# general fallback
|
| 961 |
+
answer = await _web_search_answer(question, history, model) + _source_badge("web")
|
| 962 |
+
follow_up_question, follow_up_suggestions = _general_follow_up(question)
|
| 963 |
+
return AgentResponse(
|
| 964 |
+
answer=_thought_block("general", "web_search", question) + answer,
|
| 965 |
+
intent="general",
|
| 966 |
+
tools_used=["web_search"],
|
| 967 |
+
model_used=model,
|
| 968 |
+
response_time=time.time() - start_time,
|
| 969 |
+
follow_up_question=follow_up_question,
|
| 970 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 971 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/agentcore/rendering.py
CHANGED
|
@@ -1,590 +1,590 @@
|
|
| 1 |
-
"""HTML fragment renderers: source badges, reasoning trace blocks, AI-tools footer, contacts."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
from src.services.staff_service import (
|
| 19 |
-
STAFF_DIRECTORY,
|
| 20 |
-
match_staff_name,
|
| 21 |
-
match_staff_role,
|
| 22 |
-
should_attempt_staff_lookup,
|
| 23 |
-
staff_name_answer,
|
| 24 |
-
staff_role_answer,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
from src.agentcore.models import SearchContextPayload
|
| 28 |
-
from src.agentcore.constants import ASK_LIBRARIAN_URL, PRIMO_AI_URL
|
| 29 |
-
from src.agentcore.utils import (
|
| 30 |
-
_escape,
|
| 31 |
-
_find_staff_by_token,
|
| 32 |
-
_primo_clean_url,
|
| 33 |
-
_shared_build_pubmed_url,
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
logger = logging.getLogger(__name__)
|
| 37 |
-
|
| 38 |
-
def _source_badge(source: str, url: str = "") -> str:
|
| 39 |
-
styles = {
|
| 40 |
-
"rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
|
| 41 |
-
"primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
|
| 42 |
-
"semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
|
| 43 |
-
"consensus_badge": ("🧬", "Consensus", "#fdf4ff", "#86198f"),
|
| 44 |
-
"semantic_badge": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
|
| 45 |
-
"pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
|
| 46 |
-
"web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
|
| 47 |
-
"web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
|
| 48 |
-
"libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
|
| 49 |
-
"ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
|
| 50 |
-
"llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
|
| 51 |
-
}
|
| 52 |
-
icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
|
| 53 |
-
inner = f"{icon} Source: {label}"
|
| 54 |
-
if url:
|
| 55 |
-
inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
|
| 56 |
-
return (
|
| 57 |
-
f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
|
| 58 |
-
f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
|
| 59 |
-
f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">{inner}</div>'
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
|
| 64 |
-
ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
|
| 65 |
-
primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
|
| 66 |
-
urls: Dict[str, str] = {
|
| 67 |
-
"leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
|
| 68 |
-
"scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
|
| 69 |
-
"ebsco_ai": (
|
| 70 |
-
f"https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results"
|
| 71 |
-
f"?q={ai_query}"
|
| 72 |
-
f"&autocorrect=y"
|
| 73 |
-
f"&expanders=concept"
|
| 74 |
-
f"&limiters=None"
|
| 75 |
-
f"&searchMode=enhanced"
|
| 76 |
-
f"&searchSegment=all-results"
|
| 77 |
-
),
|
| 78 |
-
"primo_ai": f"{PRIMO_AI_URL}{primo_ai_query}",
|
| 79 |
-
"consensus": f"https://consensus.app/results/?q={ai_query}",
|
| 80 |
-
"primo_discovery": _primo_clean_url(context),
|
| 81 |
-
}
|
| 82 |
-
if context.intent == "search_medical":
|
| 83 |
-
urls["pubmed"] = _shared_build_pubmed_url(
|
| 84 |
-
context.topic, context.year_from, context.year_to, context.peer_reviewed
|
| 85 |
-
)
|
| 86 |
-
return urls
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
|
| 90 |
-
q = (question or "").lower()
|
| 91 |
-
specialist: Optional[dict] = None
|
| 92 |
-
if any(t in q for t in ["interlibrary", "ill request", "ill form", "borrow from another",
|
| 93 |
-
"request article", "request a book", "not available at ku",
|
| 94 |
-
"not in ku library", "inter library", "inter-library"]):
|
| 95 |
-
footer = (
|
| 96 |
-
"<br><br><strong>Library contact points</strong><br>"
|
| 97 |
-
f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
|
| 98 |
-
'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
|
| 99 |
-
'ILL requests: <a href="mailto:ill@ku.ac.ae">ill@ku.ac.ae</a> · '
|
| 100 |
-
'<strong>Suaad Al Jneibi</strong> · '
|
| 101 |
-
'<a href="mailto:suaad.aljneibi@ku.ac.ae">suaad.aljneibi@ku.ac.ae</a> · '
|
| 102 |
-
'<a href="tel:+97123124278">+971 2 312 4278</a>'
|
| 103 |
-
)
|
| 104 |
-
return footer
|
| 105 |
-
elif any(t in q for t in [
|
| 106 |
-
"apc", "article processing charge", "article processing fee",
|
| 107 |
-
"open access fee", "oa fee", "oa fund", "oa publishing",
|
| 108 |
-
"publish open access", "open access publish", "how to publish open access",
|
| 109 |
-
"open access journal", "open access mandate", "open access policy",
|
| 110 |
-
"waiver", "gold open access", "diamond open access", "hybrid journal",
|
| 111 |
-
"creative commons", "cc by", "cc licence", "openaccess", "open access",
|
| 112 |
-
]):
|
| 113 |
-
# Open Access & APC → Walter Brian Hall (OA lead at KU Library)
|
| 114 |
-
specialist = _find_staff_by_token("walter")
|
| 115 |
-
elif any(t in q for t in [
|
| 116 |
-
"research support", "research service", "research consultation",
|
| 117 |
-
"research librarian", "research help", "research advice",
|
| 118 |
-
"scholarly communication", "bibliometric", "research impact",
|
| 119 |
-
"citation analysis", "h-index", "journal ranking", "impact factor",
|
| 120 |
-
"orcid", "khazna", "repository", "scival", "scopus profile",
|
| 121 |
-
"author profile", "research data", "data management",
|
| 122 |
-
"libguide", "research guide", "ai tool", "ai for research",
|
| 123 |
-
"publication advice", "journal selection", "where to publish",
|
| 124 |
-
]):
|
| 125 |
-
# Research services → Nikesh Narayanan (Research & Access Services Librarian)
|
| 126 |
-
specialist = _find_staff_by_token("nikesh")
|
| 127 |
-
elif any(t in q for t in ["database access", "proxy", "eresource",
|
| 128 |
-
"e-resource", "remote access", "access problem", "off campus"]):
|
| 129 |
-
specialist = _find_staff_by_token("rani")
|
| 130 |
-
elif any(t in q for t in ["website", "system", "technical", "login issue", "page not loading"]):
|
| 131 |
-
specialist = _find_staff_by_token("walter")
|
| 132 |
-
elif context and context.intent == "search_medical":
|
| 133 |
-
# Medical/clinical research questions only → Jason Fetty (Medical Librarian)
|
| 134 |
-
specialist = _find_staff_by_token("jason")
|
| 135 |
-
elif context and context.intent == "search_academic":
|
| 136 |
-
specialist = _find_staff_by_token("nikesh")
|
| 137 |
-
footer = (
|
| 138 |
-
"<br><br><strong>Library contact points</strong><br>"
|
| 139 |
-
f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
|
| 140 |
-
'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
|
| 141 |
-
)
|
| 142 |
-
if specialist:
|
| 143 |
-
footer += (
|
| 144 |
-
f'<br>Relevant contact: <strong>{_escape(specialist.get("full_name", ""))}</strong> — '
|
| 145 |
-
f'{_escape(specialist.get("role", ""))}<br>'
|
| 146 |
-
f'<a href="mailto:{_escape(specialist.get("email", ""))}">{_escape(specialist.get("email", ""))}</a>'
|
| 147 |
-
)
|
| 148 |
-
phone = specialist.get("phone") or specialist.get("work_phone")
|
| 149 |
-
if phone:
|
| 150 |
-
footer += f' · <a href="tel:{_escape(str(phone).replace(" ", ""))}">{_escape(phone)}</a>'
|
| 151 |
-
return footer
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> str:
|
| 155 |
-
labels = {
|
| 156 |
-
"library_info": ("📋", "Library information lookup", "I searched the KU Library knowledge base for relevant content."),
|
| 157 |
-
"general": ("🌐", "General knowledge / web search", "I answered using a web search for current information."),
|
| 158 |
-
"general_recent": ("🔴", "Live web search", "I ran a live web search for the most up-to-date information."),
|
| 159 |
-
"social_greeting": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
|
| 160 |
-
"social": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
|
| 161 |
-
"staff_lookup": ("👤", "Staff directory lookup", "I matched your request to the KU Library staff directory."),
|
| 162 |
-
"hours": ("⏰", "Library hours lookup", "I fetched live library hours from the KU LibCal API."),
|
| 163 |
-
"libcal_live": ("⏰", "Live library hours", "I fetched real-time hours from the KU LibCal API."),
|
| 164 |
-
"campus": ("📍", "Campus information lookup", "I retrieved KU Library campus information from the knowledge base."),
|
| 165 |
-
"database_rec": ("🗄️", "Database recommendation", "I matched your subject to the KU Library e-resources database."),
|
| 166 |
-
"rag_search": ("📖", "Knowledge base search", "I ran a hybrid semantic + keyword search across the KU Library knowledge base."),
|
| 167 |
-
}
|
| 168 |
-
icon, label, reasoning = labels.get(intent, ("🤖", "AI response", "I processed your question and generated a response."))
|
| 169 |
-
if extra:
|
| 170 |
-
reasoning += " " + extra
|
| 171 |
-
return (
|
| 172 |
-
f'<details style="margin:0 0 12px 0;border:1px solid #e5e7eb;border-radius:10px;'
|
| 173 |
-
f'background:#fafafa;overflow:hidden">'
|
| 174 |
-
f'<summary style="padding:8px 12px;cursor:pointer;font-size:.75rem;font-weight:700;'
|
| 175 |
-
f'color:#6b7280;list-style:none;display:flex;align-items:center;gap:5px">'
|
| 176 |
-
f'<span>{icon}</span> My approach — click to see</summary>'
|
| 177 |
-
f'<div style="padding:4px 12px 10px 12px;font-size:.79rem;line-height:1.7;border-top:1px solid #e5e7eb">'
|
| 178 |
-
f'<strong>Question:</strong> {_escape(question)}<br>'
|
| 179 |
-
f'<strong>Classified as:</strong> {label}<br>'
|
| 180 |
-
f'<strong>How I answered:</strong> {reasoning}'
|
| 181 |
-
f'</div></details>'
|
| 182 |
-
)
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
def _search_trace_block(question: str, context: SearchContextPayload) -> str:
|
| 186 |
-
topic = _escape(context.display_topic or context.topic)
|
| 187 |
-
primo_boolean = _escape(context.primo_boolean_query or "")
|
| 188 |
-
source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
|
| 189 |
-
intent_label = {
|
| 190 |
-
"search_academic": "academic literature search",
|
| 191 |
-
"search_medical": "medical / clinical literature search",
|
| 192 |
-
}.get(context.intent, "library search")
|
| 193 |
-
filters_parts = []
|
| 194 |
-
if context.peer_reviewed:
|
| 195 |
-
filters_parts.append("peer reviewed only")
|
| 196 |
-
if context.open_access:
|
| 197 |
-
filters_parts.append("open access")
|
| 198 |
-
if context.year_from and context.year_to:
|
| 199 |
-
filters_parts.append(f"{context.year_from}–{context.year_to}")
|
| 200 |
-
elif context.year_from:
|
| 201 |
-
filters_parts.append(f"from {context.year_from}")
|
| 202 |
-
rtype = {"articles": "articles", "books": "books", "both": "articles and books"}.get(
|
| 203 |
-
context.resource_type, "articles"
|
| 204 |
-
)
|
| 205 |
-
filters_parts.append(rtype)
|
| 206 |
-
filters_str = ", ".join(filters_parts)
|
| 207 |
-
reasoning = (
|
| 208 |
-
f"I classified this as a <strong>{intent_label}</strong>, "
|
| 209 |
-
f"extracted the topic <strong>{topic}</strong>, "
|
| 210 |
-
f"built an LLM-optimised boolean query with quoted phrases and OR synonyms, "
|
| 211 |
-
f"then searched <strong>{source_label}</strong> for <strong>{filters_str}</strong>."
|
| 212 |
-
)
|
| 213 |
-
if context.intent == "search_medical" and context.source != "pubmed":
|
| 214 |
-
reasoning += " For medical topics, PubMed and Embase are also recommended."
|
| 215 |
-
block = (
|
| 216 |
-
f'<details style="margin:0 0 14px 0;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff;overflow:hidden">'
|
| 217 |
-
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">'
|
| 218 |
-
f'<span style="font-size:.9rem">🔍</span> How I searched — click to expand'
|
| 219 |
-
f'</summary>'
|
| 220 |
-
f'<div style="padding:4px 14px 12px 14px;font-size:.82rem;line-height:1.7;border-top:1px solid #dbe4ff">'
|
| 221 |
-
f'<strong>Your question:</strong> {_escape(question)}<br>'
|
| 222 |
-
f'<strong>Interpreted topic:</strong> {topic}<br>'
|
| 223 |
-
)
|
| 224 |
-
if primo_boolean:
|
| 225 |
-
block += (
|
| 226 |
-
f'<strong>Boolean query sent to {source_label}:</strong><br>'
|
| 227 |
-
f'<code style="display:block;margin:4px 0;padding:6px 10px;background:#1a1a2e;color:#C8A951;'
|
| 228 |
-
f'border-radius:7px;font-size:.78rem;word-break:break-all">{primo_boolean}</code>'
|
| 229 |
-
)
|
| 230 |
-
block += f'<strong>Reasoning:</strong> {reasoning}<br>'
|
| 231 |
-
block += '<span style="color:#5b21b6;font-size:.78rem">✨ AI research tools for deeper exploration are listed at the end of this answer.</span>'
|
| 232 |
-
block += '</div></details>'
|
| 233 |
-
return block
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
def _ai_tools_footer(context):
|
| 237 |
-
from urllib.parse import quote as _q
|
| 238 |
-
|
| 239 |
-
ai_q = _q(context.ai_tool_query or context.display_topic or context.topic)
|
| 240 |
-
bool_q = context.primo_boolean_query or ""
|
| 241 |
-
primo_url = _primo_clean_url(context)
|
| 242 |
-
primo_ai = PRIMO_AI_URL + ai_q
|
| 243 |
-
is_med = context.intent == "search_medical"
|
| 244 |
-
pubmed_url = _shared_build_pubmed_url(
|
| 245 |
-
context.topic, context.year_from, context.year_to, context.peer_reviewed
|
| 246 |
-
) if is_med else ""
|
| 247 |
-
|
| 248 |
-
KU = "https://khalifa.idm.oclc.org/login?url="
|
| 249 |
-
|
| 250 |
-
platforms = [
|
| 251 |
-
("PRIMO AI Assistant", primo_ai, "primo"),
|
| 252 |
-
("LeapSpace", "https://www.sciencedirect.com/leapspace?query=" + ai_q, "leapspace"),
|
| 253 |
-
("Scopus AI", "https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query=" + ai_q, "scopus"),
|
| 254 |
-
("EBSCO Research AI",
|
| 255 |
-
"https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results?q=" + ai_q
|
| 256 |
-
+ "&autocorrect=y&expanders=concept&limiters=None&searchMode=enhanced&searchSegment=all-results", "ebsco"),
|
| 257 |
-
("Consensus", "https://consensus.app/results/?q=" + ai_q, "consensus"),
|
| 258 |
-
("Semantic Scholar", "https://www.semanticscholar.org/search?q=" + ai_q + "&sort=relevance", "semanticscholar"),
|
| 259 |
-
("Perplexity", "https://www.perplexity.ai/search?q=" + ai_q, "perplexity"),
|
| 260 |
-
]
|
| 261 |
-
if is_med and pubmed_url:
|
| 262 |
-
platforms.append(("PubMed", pubmed_url, "pubmed"))
|
| 263 |
-
|
| 264 |
-
_SVGS = {
|
| 265 |
-
"bee": (
|
| 266 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="16" height="16" style="flex-shrink:0">'
|
| 267 |
-
'<ellipse cx="5.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
|
| 268 |
-
'<ellipse cx="14.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
|
| 269 |
-
'<ellipse cx="10" cy="13" rx="5" ry="6" fill="#1a3a6b"/>'
|
| 270 |
-
'<ellipse cx="10" cy="15" rx="3" ry="1.5" fill="#243f7a"/>'
|
| 271 |
-
'<circle cx="10" cy="8" r="5" fill="#1a3a6b"/>'
|
| 272 |
-
'<rect x="6" y="6.5" width="8" height="3.5" rx="1.5" fill="#001a40"/>'
|
| 273 |
-
'<ellipse cx="8.2" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
|
| 274 |
-
'<ellipse cx="11.8" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
|
| 275 |
-
'<path d="M8 10 Q10 11.5 12 10" fill="none" stroke="#00ccee" stroke-width=".8" stroke-linecap="round"/>'
|
| 276 |
-
'<line x1="8.5" y1="3.5" x2="6.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
|
| 277 |
-
'<circle cx="6.5" cy="1.5" r="1.2" fill="#00ccee" opacity=".8"/>'
|
| 278 |
-
'<line x1="11.5" y1="3.5" x2="13.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
|
| 279 |
-
'<ellipse cx="13.5" cy="1.2" rx="1.5" ry="1.8" fill="#e8e8e8" stroke="#aaa" stroke-width=".5"/>'
|
| 280 |
-
'<text x="10" y="17.5" font-family="Arial,sans-serif" font-size="3" font-weight="bold" fill="#C8A951" text-anchor="middle">LB</text>'
|
| 281 |
-
'</svg>'
|
| 282 |
-
),
|
| 283 |
-
"book": (
|
| 284 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 285 |
-
'<rect x="2" y="2" width="9" height="12" rx="1" fill="#2563eb"/>'
|
| 286 |
-
'<rect x="3" y="2" width="1.5" height="12" fill="#1d4ed8"/>'
|
| 287 |
-
'<rect x="4" y="4" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
|
| 288 |
-
'<rect x="4" y="6" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
|
| 289 |
-
'<rect x="4" y="8" width="3" height="1" rx=".5" fill="#bfdbfe"/>'
|
| 290 |
-
'<rect x="11" y="3" width="3" height="10" rx="1" fill="#1e40af"/>'
|
| 291 |
-
'</svg>'
|
| 292 |
-
),
|
| 293 |
-
"microscope": (
|
| 294 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 295 |
-
'<rect x="7" y="1" width="2" height="5" rx="1" fill="#6b7280"/>'
|
| 296 |
-
'<ellipse cx="8" cy="1.5" rx="2.5" ry="1.5" fill="#9ca3af"/>'
|
| 297 |
-
'<rect x="6.5" y="5" width="3" height="5" rx=".5" fill="#374151"/>'
|
| 298 |
-
'<rect x="5" y="9.5" width="6" height="1.5" rx=".5" fill="#374151"/>'
|
| 299 |
-
'<rect x="3" y="13" width="10" height="1.5" rx=".5" fill="#374151"/>'
|
| 300 |
-
'<line x1="8" y1="11" x2="8" y2="13" stroke="#374151" stroke-width="1.5"/>'
|
| 301 |
-
'<circle cx="8" cy="6" r="1.5" fill="#60a5fa" opacity=".8"/>'
|
| 302 |
-
'</svg>'
|
| 303 |
-
),
|
| 304 |
-
"circuit": (
|
| 305 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 306 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#1e293b"/>'
|
| 307 |
-
'<circle cx="4" cy="4" r="1.5" fill="#22d3ee"/>'
|
| 308 |
-
'<circle cx="12" cy="4" r="1.5" fill="#22d3ee"/>'
|
| 309 |
-
'<circle cx="4" cy="12" r="1.5" fill="#22d3ee"/>'
|
| 310 |
-
'<circle cx="12" cy="12" r="1.5" fill="#22d3ee"/>'
|
| 311 |
-
'<circle cx="8" cy="8" r="2" fill="#06b6d4"/>'
|
| 312 |
-
'<line x1="4" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 313 |
-
'<line x1="12" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 314 |
-
'<line x1="4" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 315 |
-
'<line x1="12" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 316 |
-
'</svg>'
|
| 317 |
-
),
|
| 318 |
-
"leaf": (
|
| 319 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 320 |
-
'<path d="M8 14 C8 14 2 10 2 5 C2 2 5 1 8 1 C11 1 14 2 14 5 C14 10 8 14 8 14Z" fill="#16a34a"/>'
|
| 321 |
-
'<line x1="8" y1="1" x2="8" y2="14" stroke="#bbf7d0" stroke-width=".8"/>'
|
| 322 |
-
'<line x1="8" y1="5" x2="5" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 323 |
-
'<line x1="8" y1="7" x2="5" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 324 |
-
'<line x1="8" y1="5" x2="11" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 325 |
-
'<line x1="8" y1="7" x2="11" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 326 |
-
'</svg>'
|
| 327 |
-
),
|
| 328 |
-
"gear": (
|
| 329 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 330 |
-
'<path d="M6.5 1h3l.5 1.5a5 5 0 011.2.7l1.5-.5 1.5 2.6-1.2 1a5 5 0 010 1.4l1.2 1-1.5 2.6-1.5-.5a5 5 0 01-1.2.7L9.5 15h-3l-.5-1.5a5 5 0 01-1.2-.7l-1.5.5L1.8 10.8l1.2-1a5 5 0 010-1.4l-1.2-1L3.3 4.8l1.5.5A5 5 0 016 4.5L6.5 1z" fill="#64748b"/>'
|
| 331 |
-
'<circle cx="8" cy="8" r="2.5" fill="#f1f5f9"/>'
|
| 332 |
-
'<circle cx="8" cy="8" r="1.2" fill="#64748b"/>'
|
| 333 |
-
'</svg>'
|
| 334 |
-
),
|
| 335 |
-
"chart": (
|
| 336 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 337 |
-
'<rect x="1" y="1" width="14" height="14" rx="1.5" fill="#f0fdf4"/>'
|
| 338 |
-
'<rect x="3" y="9" width="2" height="5" rx=".5" fill="#16a34a"/>'
|
| 339 |
-
'<rect x="7" y="6" width="2" height="8" rx=".5" fill="#15803d"/>'
|
| 340 |
-
'<rect x="11" y="3" width="2" height="11" rx=".5" fill="#166534"/>'
|
| 341 |
-
'<line x1="1" y1="13.5" x2="15" y2="13.5" stroke="#166534" stroke-width=".8"/>'
|
| 342 |
-
'</svg>'
|
| 343 |
-
),
|
| 344 |
-
"atom": (
|
| 345 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 346 |
-
'<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9"/>'
|
| 347 |
-
'<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(60 8 8)"/>'
|
| 348 |
-
'<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(120 8 8)"/>'
|
| 349 |
-
'<circle cx="8" cy="8" r="1.8" fill="#7c3aed"/>'
|
| 350 |
-
'</svg>'
|
| 351 |
-
),
|
| 352 |
-
"medical": (
|
| 353 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 354 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#dc2626"/>'
|
| 355 |
-
'<rect x="6.5" y="3" width="3" height="10" rx="1" fill="white"/>'
|
| 356 |
-
'<rect x="3" y="6.5" width="10" height="3" rx="1" fill="white"/>'
|
| 357 |
-
'</svg>'
|
| 358 |
-
),
|
| 359 |
-
"wrench": (
|
| 360 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 361 |
-
'<path d="M13 2a3 3 0 00-3 3c0 .4.1.8.2 1.1L2.6 13.7a1 1 0 001.4 1.4L11.7 7.8c.3.1.7.2 1.1.2a3 3 0 000-6z" fill="#d97706"/>'
|
| 362 |
-
'<circle cx="12" cy="4" r="1.2" fill="#fef3c7"/>'
|
| 363 |
-
'</svg>'
|
| 364 |
-
),
|
| 365 |
-
"primo": (
|
| 366 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 367 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#003366"/>'
|
| 368 |
-
'<rect x="3" y="4" width="10" height="1.5" rx=".5" fill="#C8A951"/>'
|
| 369 |
-
'<rect x="3" y="7" width="7" height="1.5" rx=".5" fill="white" opacity=".9"/>'
|
| 370 |
-
'<rect x="3" y="10" width="9" height="1.5" rx=".5" fill="white" opacity=".7"/>'
|
| 371 |
-
'<circle cx="12" cy="11" r="2.5" fill="#C8A951"/>'
|
| 372 |
-
'<line x1="11" y1="11" x2="13" y2="11" stroke="#003366" stroke-width=".8"/>'
|
| 373 |
-
'<line x1="12" y1="10" x2="12" y2="12" stroke="#003366" stroke-width=".8"/>'
|
| 374 |
-
'</svg>'
|
| 375 |
-
),
|
| 376 |
-
"scopus": (
|
| 377 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 378 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#e8650a"/>'
|
| 379 |
-
'<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="8" font-weight="bold" fill="white" text-anchor="middle">Sc</text>'
|
| 380 |
-
'</svg>'
|
| 381 |
-
),
|
| 382 |
-
"ebsco": (
|
| 383 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 384 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#00427a"/>'
|
| 385 |
-
'<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="7" font-weight="bold" fill="white" text-anchor="middle">EBS</text>'
|
| 386 |
-
'</svg>'
|
| 387 |
-
),
|
| 388 |
-
"consensus": (
|
| 389 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 390 |
-
'<rect x="1" y="1" width="14" height="14" rx="7" fill="#6c47ff"/>'
|
| 391 |
-
'<circle cx="6" cy="7" r="1.2" fill="white"/>'
|
| 392 |
-
'<circle cx="10" cy="7" r="1.2" fill="white"/>'
|
| 393 |
-
'<path d="M5 10 Q8 12.5 11 10" fill="none" stroke="white" stroke-width="1.2" stroke-linecap="round"/>'
|
| 394 |
-
'</svg>'
|
| 395 |
-
),
|
| 396 |
-
"leapspace": (
|
| 397 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 398 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#ff6b00"/>'
|
| 399 |
-
'<path d="M4 12 L4 7 L8 4 L12 7 L12 12 Z" fill="white" opacity=".9"/>'
|
| 400 |
-
'<rect x="6.5" y="9" width="3" height="3" fill="#ff6b00"/>'
|
| 401 |
-
'</svg>'
|
| 402 |
-
),
|
| 403 |
-
"perplexity": (
|
| 404 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 405 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#1a1a2e"/>'
|
| 406 |
-
'<circle cx="8" cy="6.5" r="3" fill="none" stroke="#20b2aa" stroke-width="1.2"/>'
|
| 407 |
-
'<line x1="8" y1="9.5" x2="8" y2="13" stroke="#20b2aa" stroke-width="1.2" stroke-linecap="round"/>'
|
| 408 |
-
'<circle cx="8" cy="13" r=".8" fill="#20b2aa"/>'
|
| 409 |
-
'</svg>'
|
| 410 |
-
),
|
| 411 |
-
"semanticscholar": (
|
| 412 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 413 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#1857b6"/>'
|
| 414 |
-
'<path d="M4 11 Q8 4 12 11" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round"/>'
|
| 415 |
-
'<circle cx="8" cy="7" r="1.8" fill="#4fc3f7"/>'
|
| 416 |
-
'</svg>'
|
| 417 |
-
),
|
| 418 |
-
"pubmed": (
|
| 419 |
-
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 420 |
-
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#336699"/>'
|
| 421 |
-
'<text x="8" y="11" font-family="Arial,sans-serif" font-size="6.5" font-weight="bold" fill="white" text-anchor="middle">PubMed</text>'
|
| 422 |
-
'</svg>'
|
| 423 |
-
),
|
| 424 |
-
}
|
| 425 |
-
_DB_MASCOT = {
|
| 426 |
-
"PubMed@KU": "microscope", "Embase": "medical", "CINAHL Ultimate": "medical",
|
| 427 |
-
"Cochrane Library": "microscope", "UpToDate": "medical",
|
| 428 |
-
"IEEE Xplore": "circuit", "ACM Digital Library": "circuit",
|
| 429 |
-
"ScienceDirect": "microscope", "Scopus": "microscope",
|
| 430 |
-
"Web of Science": "microscope", "SpringerLink": "book",
|
| 431 |
-
"ACS Publications": "atom", "APS Journals": "atom", "Knovel": "wrench",
|
| 432 |
-
"Business Source Complete": "chart", "Emerald Insight": "chart",
|
| 433 |
-
"Taylor & Francis": "book", "EBSCO": "book",
|
| 434 |
-
}
|
| 435 |
-
_BEE = _SVGS["bee"]
|
| 436 |
-
|
| 437 |
-
def _btn(label, url, mascot_key="bee"):
|
| 438 |
-
icon = _SVGS.get(mascot_key, _SVGS["bee"])
|
| 439 |
-
style = (
|
| 440 |
-
'display:inline-flex;align-items:center;gap:7px;'
|
| 441 |
-
'padding:8px 14px;border-radius:10px;text-decoration:none;'
|
| 442 |
-
'font-size:.8rem;font-weight:600;color:#1a1a2e;'
|
| 443 |
-
'background:#fff;border:1.5px solid #d1d5db;'
|
| 444 |
-
'transition:box-shadow .2s;width:100%;box-sizing:border-box'
|
| 445 |
-
)
|
| 446 |
-
return '<a href="' + url + '" target="_blank" style="' + style + '">' + icon + label + '</a>'
|
| 447 |
-
|
| 448 |
-
btn_rows = ""
|
| 449 |
-
for k in range(0, len(platforms), 2):
|
| 450 |
-
pair = platforms[k:k+2]
|
| 451 |
-
cells = ""
|
| 452 |
-
for lbl, url, mascot_key in pair:
|
| 453 |
-
cells += '<td style="width:50%;padding:3px 4px 3px 0">' + _btn(lbl, url, mascot_key) + '</td>'
|
| 454 |
-
if len(pair) == 1:
|
| 455 |
-
cells += '<td style="width:50%"></td>'
|
| 456 |
-
btn_rows += "<tr>" + cells + "</tr>"
|
| 457 |
-
|
| 458 |
-
platforms_html = (
|
| 459 |
-
'<div style="margin-top:18px;padding:14px 16px;border:1px solid #c4b5fd;'
|
| 460 |
-
'border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#fdf4ff)">'
|
| 461 |
-
'<div style="font-size:.8rem;font-weight:800;color:#5b21b6;margin-bottom:4px">'
|
| 462 |
-
'🤖 Search your query across AI platforms</div>'
|
| 463 |
-
'<div style="font-size:.73rem;color:#6b7280;margin-bottom:10px">'
|
| 464 |
-
'Your query is pre-loaded. Click any platform to search instantly.</div>'
|
| 465 |
-
'<table style="width:100%;border-collapse:collapse">' + btn_rows + '</table>'
|
| 466 |
-
'</div>'
|
| 467 |
-
)
|
| 468 |
-
|
| 469 |
-
if bool_q:
|
| 470 |
-
and_count = bool_q.upper().count(" AND ")
|
| 471 |
-
if and_count >= 1:
|
| 472 |
-
bool_label = "Use this in PRIMO Advanced Search or any database for precise results:"
|
| 473 |
-
else:
|
| 474 |
-
bool_label = "Try adding synonyms with OR to find more relevant results:"
|
| 475 |
-
bool_html = (
|
| 476 |
-
'<div style="margin-top:12px;padding:12px 14px;border:1px solid #fde68a;'
|
| 477 |
-
'border-radius:10px;background:#fffbeb">'
|
| 478 |
-
'<div style="font-size:.78rem;font-weight:700;color:#92400e;margin-bottom:6px">'
|
| 479 |
-
'\U0001f4a1 Boolean search tip</div>'
|
| 480 |
-
'<div style="font-size:.74rem;color:#6b7280;margin-bottom:6px">' + bool_label + '</div>'
|
| 481 |
-
'<code style="display:block;padding:8px 10px;background:#1a1a2e;color:#C8A951;'
|
| 482 |
-
'border-radius:7px;font-size:.76rem;word-break:break-all;white-space:pre-wrap">'
|
| 483 |
-
+ _escape(bool_q) +
|
| 484 |
-
'</code>'
|
| 485 |
-
'<div style="font-size:.71rem;color:#92400e;margin-top:6px">'
|
| 486 |
-
'Paste into the Advanced Search box of any database below.</div>'
|
| 487 |
-
'</div>'
|
| 488 |
-
)
|
| 489 |
-
else:
|
| 490 |
-
bool_html = ""
|
| 491 |
-
|
| 492 |
-
all_lower = ((context.ai_tool_query or "") + " " + (context.topic or "")).lower()
|
| 493 |
-
|
| 494 |
-
def _db_row(name, url, desc, favicon=None):
|
| 495 |
-
icon = _SVGS.get(_DB_MASCOT.get(name, "bee"), _SVGS["bee"])
|
| 496 |
-
return (
|
| 497 |
-
'<div style="display:flex;align-items:center;gap:8px;'
|
| 498 |
-
'padding:7px 0;border-bottom:1px solid #f3f4f6">'
|
| 499 |
-
+ icon +
|
| 500 |
-
'<div><a href="' + url + '" target="_blank" '
|
| 501 |
-
'style="font-size:.8rem;font-weight:700;color:#003366;text-decoration:none">'
|
| 502 |
-
+ name + '</a>'
|
| 503 |
-
'<span style="font-size:.73rem;color:#6b7280"> — ' + desc + '</span></div>'
|
| 504 |
-
'</div>'
|
| 505 |
-
)
|
| 506 |
-
|
| 507 |
-
if any(k in all_lower for k in ["medicine","medical","clinical","nursing","pharmacy","health","pubmed","embase","cinahl","cochrane","uptodate"]):
|
| 508 |
-
db_subject = "Medicine & Health Sciences"
|
| 509 |
-
dbs = [
|
| 510 |
-
("PubMed@KU","http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib","Core biomedical literature"),
|
| 511 |
-
("Embase",KU+"https://www.embase.com","Biomedical, drugs, devices, clinical medicine"),
|
| 512 |
-
("CINAHL Ultimate",KU+"https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul","Nursing and allied health"),
|
| 513 |
-
("Cochrane Library",KU+"https://www.cochranelibrary.com","Systematic reviews and evidence-based medicine"),
|
| 514 |
-
("UpToDate",KU+"https://www.uptodate.com","Point-of-care clinical decision support"),
|
| 515 |
-
]
|
| 516 |
-
elif any(k in all_lower for k in ["machine learning","deep learning","artificial intelligence","neural network","computer science","software","algorithm","cybersecurity","robotics","nlp"]):
|
| 517 |
-
db_subject = "Computer Science & AI"
|
| 518 |
-
dbs = [
|
| 519 |
-
("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Core CS, AI, electronics and systems"),
|
| 520 |
-
("ACM Digital Library",KU+"https://dl.acm.org","Computer science journals, proceedings and books"),
|
| 521 |
-
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across CS and applied computing"),
|
| 522 |
-
("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
|
| 523 |
-
("Web of Science",KU+"https://www.webofscience.com","Citation index"),
|
| 524 |
-
]
|
| 525 |
-
elif any(k in all_lower for k in ["climate","global warming","environment","sustainability","ecology","carbon","emission","renewable","sea level","maldives","island"]):
|
| 526 |
-
db_subject = "Environment & Climate"
|
| 527 |
-
dbs = [
|
| 528 |
-
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across science, environment and engineering"),
|
| 529 |
-
("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
|
| 530 |
-
("Web of Science",KU+"https://www.webofscience.com","High-impact journal coverage and citation index"),
|
| 531 |
-
("SpringerLink",KU+"https://link.springer.com","Springer journals and books"),
|
| 532 |
-
]
|
| 533 |
-
elif any(k in all_lower for k in ["engineering","mechanical","civil","electrical","aerospace","structural","petroleum","chemical","materials"]):
|
| 534 |
-
db_subject = "Engineering"
|
| 535 |
-
dbs = [
|
| 536 |
-
("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Electrical, electronics and systems engineering"),
|
| 537 |
-
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across engineering disciplines"),
|
| 538 |
-
("Knovel",KU+"https://app.knovel.com","Engineering handbooks and reference data"),
|
| 539 |
-
("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
|
| 540 |
-
("Web of Science",KU+"https://www.webofscience.com","Citation index"),
|
| 541 |
-
]
|
| 542 |
-
elif any(k in all_lower for k in ["business","management","finance","economics","leadership","marketing","strategy","entrepreneurship"]):
|
| 543 |
-
db_subject = "Business & Management"
|
| 544 |
-
dbs = [
|
| 545 |
-
("Business Source Complete",KU+"https://search.ebscohost.com","Core business and management literature"),
|
| 546 |
-
("Emerald Insight",KU+"https://www.emerald.com/insight","Management, strategy and leadership"),
|
| 547 |
-
("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
|
| 548 |
-
("ScienceDirect",KU+"https://www.sciencedirect.com","Business and social sciences full text"),
|
| 549 |
-
]
|
| 550 |
-
elif any(k in all_lower for k in ["physics","chemistry","quantum","nuclear","materials science","nanotechnology","optics"]):
|
| 551 |
-
db_subject = "Physics, Chemistry & Materials"
|
| 552 |
-
dbs = [
|
| 553 |
-
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across physical sciences"),
|
| 554 |
-
("ACS Publications",KU+"https://pubs.acs.org","Core chemistry and chemical engineering"),
|
| 555 |
-
("APS Journals",KU+"https://journals.aps.org","American Physical Society core physics journals"),
|
| 556 |
-
("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
|
| 557 |
-
("Web of Science",KU+"https://www.webofscience.com","Citation index"),
|
| 558 |
-
]
|
| 559 |
-
else:
|
| 560 |
-
db_subject = "General"
|
| 561 |
-
dbs = [
|
| 562 |
-
("EBSCO",KU+"https://search.ebscohost.com","Multidisciplinary full text"),
|
| 563 |
-
("ScienceDirect",KU+"https://www.sciencedirect.com","Science, engineering and medicine"),
|
| 564 |
-
("SpringerLink",KU+"https://link.springer.com","Journals and books across all disciplines"),
|
| 565 |
-
("Taylor & Francis",KU+"https://www.tandfonline.com","Humanities, social sciences and STEM"),
|
| 566 |
-
("Emerald Insight",KU+"https://www.emerald.com/insight","Management and social sciences"),
|
| 567 |
-
("Scopus",KU+"https://www.scopus.com","Citation discovery, all disciplines"),
|
| 568 |
-
("Web of Science",KU+"https://www.webofscience.com","Citation index, all disciplines"),
|
| 569 |
-
]
|
| 570 |
-
|
| 571 |
-
dbs_html_rows = ""
|
| 572 |
-
for name, url, desc in dbs:
|
| 573 |
-
dbs_html_rows += _db_row(name, url, desc)
|
| 574 |
-
|
| 575 |
-
db_section = (
|
| 576 |
-
'<div style="margin-top:12px;padding:12px 14px;border:1px solid #d1d5db;'
|
| 577 |
-
'border-radius:10px;background:#f9fafb">'
|
| 578 |
-
'<div style="font-size:.78rem;font-weight:700;color:#003366;margin-bottom:8px">'
|
| 579 |
-
'\U0001f4c2 Recommended KU databases \u2014 ' + db_subject + '</div>'
|
| 580 |
-
+ dbs_html_rows +
|
| 581 |
-
'<div style="padding-top:8px;font-size:.74rem">'
|
| 582 |
-
'<a href="https://library.ku.ac.ae/eresources" target="_blank" '
|
| 583 |
-
'style="color:#003366;font-weight:700">'
|
| 584 |
-
'\U0001f5c4\ufe0f Browse all 50+ KU databases \u2192</a></div>'
|
| 585 |
-
'</div>'
|
| 586 |
-
)
|
| 587 |
-
|
| 588 |
-
return platforms_html + bool_html + db_section
|
| 589 |
-
|
| 590 |
-
|
|
|
|
| 1 |
+
"""HTML fragment renderers: source badges, reasoning trace blocks, AI-tools footer, contacts."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
from src.services.staff_service import (
|
| 19 |
+
STAFF_DIRECTORY,
|
| 20 |
+
match_staff_name,
|
| 21 |
+
match_staff_role,
|
| 22 |
+
should_attempt_staff_lookup,
|
| 23 |
+
staff_name_answer,
|
| 24 |
+
staff_role_answer,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
from src.agentcore.models import SearchContextPayload
|
| 28 |
+
from src.agentcore.constants import ASK_LIBRARIAN_URL, PRIMO_AI_URL
|
| 29 |
+
from src.agentcore.utils import (
|
| 30 |
+
_escape,
|
| 31 |
+
_find_staff_by_token,
|
| 32 |
+
_primo_clean_url,
|
| 33 |
+
_shared_build_pubmed_url,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
def _source_badge(source: str, url: str = "") -> str:
|
| 39 |
+
styles = {
|
| 40 |
+
"rag": ("📖", "KU Library Knowledge Base", "#fef3c7", "#92400e"),
|
| 41 |
+
"primo": ("📚", "KU Library PRIMO", "#eff6ff", "#1e40af"),
|
| 42 |
+
"semantic": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
|
| 43 |
+
"consensus_badge": ("🧬", "Consensus", "#fdf4ff", "#86198f"),
|
| 44 |
+
"semantic_badge": ("🎓", "Semantic Scholar", "#f0fdf4", "#166534"),
|
| 45 |
+
"pubmed": ("🏥", "PubMed", "#d1fae5", "#065f46"),
|
| 46 |
+
"web": ("🌐", "Web Search", "#f5f3ff", "#4c1d95"),
|
| 47 |
+
"web_live": ("🌐", "Web Search · Live", "#fdf4ff", "#86198f"),
|
| 48 |
+
"libbee": ("🐝", "LibBee", "#fef9c3", "#713f12"),
|
| 49 |
+
"ku_library": ("📋", "KU Library", "#eff6ff", "#1e40af"),
|
| 50 |
+
"llm": ("🤖", "AI Knowledge Base", "#f3f4f6", "#374151"),
|
| 51 |
+
}
|
| 52 |
+
icon, label, bg, fg = styles.get(source, ("ℹ️", source, "#f3f4f6", "#374151"))
|
| 53 |
+
inner = f"{icon} Source: {label}"
|
| 54 |
+
if url:
|
| 55 |
+
inner = f'<a href="{url}" target="_blank" style="color:{fg};text-decoration:none">{inner}</a>'
|
| 56 |
+
return (
|
| 57 |
+
f'<div style="margin-top:10px;display:inline-flex;align-items:center;gap:5px;'
|
| 58 |
+
f'padding:4px 10px;background:{bg};color:{fg};border-radius:20px;'
|
| 59 |
+
f'font-size:.72rem;font-weight:600;border:1px solid {fg}33">{inner}</div>'
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
|
| 64 |
+
ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
|
| 65 |
+
primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
|
| 66 |
+
urls: Dict[str, str] = {
|
| 67 |
+
"leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
|
| 68 |
+
"scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
|
| 69 |
+
"ebsco_ai": (
|
| 70 |
+
f"https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results"
|
| 71 |
+
f"?q={ai_query}"
|
| 72 |
+
f"&autocorrect=y"
|
| 73 |
+
f"&expanders=concept"
|
| 74 |
+
f"&limiters=None"
|
| 75 |
+
f"&searchMode=enhanced"
|
| 76 |
+
f"&searchSegment=all-results"
|
| 77 |
+
),
|
| 78 |
+
"primo_ai": f"{PRIMO_AI_URL}{primo_ai_query}",
|
| 79 |
+
"consensus": f"https://consensus.app/results/?q={ai_query}",
|
| 80 |
+
"primo_discovery": _primo_clean_url(context),
|
| 81 |
+
}
|
| 82 |
+
if context.intent == "search_medical":
|
| 83 |
+
urls["pubmed"] = _shared_build_pubmed_url(
|
| 84 |
+
context.topic, context.year_from, context.year_to, context.peer_reviewed
|
| 85 |
+
)
|
| 86 |
+
return urls
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
|
| 90 |
+
q = (question or "").lower()
|
| 91 |
+
specialist: Optional[dict] = None
|
| 92 |
+
if any(t in q for t in ["interlibrary", "ill request", "ill form", "borrow from another",
|
| 93 |
+
"request article", "request a book", "not available at ku",
|
| 94 |
+
"not in ku library", "inter library", "inter-library"]):
|
| 95 |
+
footer = (
|
| 96 |
+
"<br><br><strong>Library contact points</strong><br>"
|
| 97 |
+
f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
|
| 98 |
+
'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>'
|
| 99 |
+
'ILL requests: <a href="mailto:ill@ku.ac.ae">ill@ku.ac.ae</a> · '
|
| 100 |
+
'<strong>Suaad Al Jneibi</strong> · '
|
| 101 |
+
'<a href="mailto:suaad.aljneibi@ku.ac.ae">suaad.aljneibi@ku.ac.ae</a> · '
|
| 102 |
+
'<a href="tel:+97123124278">+971 2 312 4278</a>'
|
| 103 |
+
)
|
| 104 |
+
return footer
|
| 105 |
+
elif any(t in q for t in [
|
| 106 |
+
"apc", "article processing charge", "article processing fee",
|
| 107 |
+
"open access fee", "oa fee", "oa fund", "oa publishing",
|
| 108 |
+
"publish open access", "open access publish", "how to publish open access",
|
| 109 |
+
"open access journal", "open access mandate", "open access policy",
|
| 110 |
+
"waiver", "gold open access", "diamond open access", "hybrid journal",
|
| 111 |
+
"creative commons", "cc by", "cc licence", "openaccess", "open access",
|
| 112 |
+
]):
|
| 113 |
+
# Open Access & APC → Walter Brian Hall (OA lead at KU Library)
|
| 114 |
+
specialist = _find_staff_by_token("walter")
|
| 115 |
+
elif any(t in q for t in [
|
| 116 |
+
"research support", "research service", "research consultation",
|
| 117 |
+
"research librarian", "research help", "research advice",
|
| 118 |
+
"scholarly communication", "bibliometric", "research impact",
|
| 119 |
+
"citation analysis", "h-index", "journal ranking", "impact factor",
|
| 120 |
+
"orcid", "khazna", "repository", "scival", "scopus profile",
|
| 121 |
+
"author profile", "research data", "data management",
|
| 122 |
+
"libguide", "research guide", "ai tool", "ai for research",
|
| 123 |
+
"publication advice", "journal selection", "where to publish",
|
| 124 |
+
]):
|
| 125 |
+
# Research services → Nikesh Narayanan (Research & Access Services Librarian)
|
| 126 |
+
specialist = _find_staff_by_token("nikesh")
|
| 127 |
+
elif any(t in q for t in ["database access", "proxy", "eresource",
|
| 128 |
+
"e-resource", "remote access", "access problem", "off campus"]):
|
| 129 |
+
specialist = _find_staff_by_token("rani")
|
| 130 |
+
elif any(t in q for t in ["website", "system", "technical", "login issue", "page not loading"]):
|
| 131 |
+
specialist = _find_staff_by_token("walter")
|
| 132 |
+
elif context and context.intent == "search_medical":
|
| 133 |
+
# Medical/clinical research questions only → Jason Fetty (Medical Librarian)
|
| 134 |
+
specialist = _find_staff_by_token("jason")
|
| 135 |
+
elif context and context.intent == "search_academic":
|
| 136 |
+
specialist = _find_staff_by_token("nikesh")
|
| 137 |
+
footer = (
|
| 138 |
+
"<br><br><strong>Library contact points</strong><br>"
|
| 139 |
+
f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> · '
|
| 140 |
+
'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
|
| 141 |
+
)
|
| 142 |
+
if specialist:
|
| 143 |
+
footer += (
|
| 144 |
+
f'<br>Relevant contact: <strong>{_escape(specialist.get("full_name", ""))}</strong> — '
|
| 145 |
+
f'{_escape(specialist.get("role", ""))}<br>'
|
| 146 |
+
f'<a href="mailto:{_escape(specialist.get("email", ""))}">{_escape(specialist.get("email", ""))}</a>'
|
| 147 |
+
)
|
| 148 |
+
phone = specialist.get("phone") or specialist.get("work_phone")
|
| 149 |
+
if phone:
|
| 150 |
+
footer += f' · <a href="tel:{_escape(str(phone).replace(" ", ""))}">{_escape(phone)}</a>'
|
| 151 |
+
return footer
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _thought_block(intent: str, tool: str, question: str, extra: str = "") -> str:
|
| 155 |
+
labels = {
|
| 156 |
+
"library_info": ("📋", "Library information lookup", "I searched the KU Library knowledge base for relevant content."),
|
| 157 |
+
"general": ("🌐", "General knowledge / web search", "I answered using a web search for current information."),
|
| 158 |
+
"general_recent": ("🔴", "Live web search", "I ran a live web search for the most up-to-date information."),
|
| 159 |
+
"social_greeting": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
|
| 160 |
+
"social": ("🐝", "Conversational response", "I responded directly as LibBee, the KU Library AI Assistant."),
|
| 161 |
+
"staff_lookup": ("👤", "Staff directory lookup", "I matched your request to the KU Library staff directory."),
|
| 162 |
+
"hours": ("⏰", "Library hours lookup", "I fetched live library hours from the KU LibCal API."),
|
| 163 |
+
"libcal_live": ("⏰", "Live library hours", "I fetched real-time hours from the KU LibCal API."),
|
| 164 |
+
"campus": ("📍", "Campus information lookup", "I retrieved KU Library campus information from the knowledge base."),
|
| 165 |
+
"database_rec": ("🗄️", "Database recommendation", "I matched your subject to the KU Library e-resources database."),
|
| 166 |
+
"rag_search": ("📖", "Knowledge base search", "I ran a hybrid semantic + keyword search across the KU Library knowledge base."),
|
| 167 |
+
}
|
| 168 |
+
icon, label, reasoning = labels.get(intent, ("🤖", "AI response", "I processed your question and generated a response."))
|
| 169 |
+
if extra:
|
| 170 |
+
reasoning += " " + extra
|
| 171 |
+
return (
|
| 172 |
+
f'<details style="margin:0 0 12px 0;border:1px solid #e5e7eb;border-radius:10px;'
|
| 173 |
+
f'background:#fafafa;overflow:hidden">'
|
| 174 |
+
f'<summary style="padding:8px 12px;cursor:pointer;font-size:.75rem;font-weight:700;'
|
| 175 |
+
f'color:#6b7280;list-style:none;display:flex;align-items:center;gap:5px">'
|
| 176 |
+
f'<span>{icon}</span> My approach — click to see</summary>'
|
| 177 |
+
f'<div style="padding:4px 12px 10px 12px;font-size:.79rem;line-height:1.7;border-top:1px solid #e5e7eb">'
|
| 178 |
+
f'<strong>Question:</strong> {_escape(question)}<br>'
|
| 179 |
+
f'<strong>Classified as:</strong> {label}<br>'
|
| 180 |
+
f'<strong>How I answered:</strong> {reasoning}'
|
| 181 |
+
f'</div></details>'
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _search_trace_block(question: str, context: SearchContextPayload) -> str:
|
| 186 |
+
topic = _escape(context.display_topic or context.topic)
|
| 187 |
+
primo_boolean = _escape(context.primo_boolean_query or "")
|
| 188 |
+
source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
|
| 189 |
+
intent_label = {
|
| 190 |
+
"search_academic": "academic literature search",
|
| 191 |
+
"search_medical": "medical / clinical literature search",
|
| 192 |
+
}.get(context.intent, "library search")
|
| 193 |
+
filters_parts = []
|
| 194 |
+
if context.peer_reviewed:
|
| 195 |
+
filters_parts.append("peer reviewed only")
|
| 196 |
+
if context.open_access:
|
| 197 |
+
filters_parts.append("open access")
|
| 198 |
+
if context.year_from and context.year_to:
|
| 199 |
+
filters_parts.append(f"{context.year_from}–{context.year_to}")
|
| 200 |
+
elif context.year_from:
|
| 201 |
+
filters_parts.append(f"from {context.year_from}")
|
| 202 |
+
rtype = {"articles": "articles", "books": "books", "both": "articles and books"}.get(
|
| 203 |
+
context.resource_type, "articles"
|
| 204 |
+
)
|
| 205 |
+
filters_parts.append(rtype)
|
| 206 |
+
filters_str = ", ".join(filters_parts)
|
| 207 |
+
reasoning = (
|
| 208 |
+
f"I classified this as a <strong>{intent_label}</strong>, "
|
| 209 |
+
f"extracted the topic <strong>{topic}</strong>, "
|
| 210 |
+
f"built an LLM-optimised boolean query with quoted phrases and OR synonyms, "
|
| 211 |
+
f"then searched <strong>{source_label}</strong> for <strong>{filters_str}</strong>."
|
| 212 |
+
)
|
| 213 |
+
if context.intent == "search_medical" and context.source != "pubmed":
|
| 214 |
+
reasoning += " For medical topics, PubMed and Embase are also recommended."
|
| 215 |
+
block = (
|
| 216 |
+
f'<details style="margin:0 0 14px 0;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff;overflow:hidden">'
|
| 217 |
+
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">'
|
| 218 |
+
f'<span style="font-size:.9rem">🔍</span> How I searched — click to expand'
|
| 219 |
+
f'</summary>'
|
| 220 |
+
f'<div style="padding:4px 14px 12px 14px;font-size:.82rem;line-height:1.7;border-top:1px solid #dbe4ff">'
|
| 221 |
+
f'<strong>Your question:</strong> {_escape(question)}<br>'
|
| 222 |
+
f'<strong>Interpreted topic:</strong> {topic}<br>'
|
| 223 |
+
)
|
| 224 |
+
if primo_boolean:
|
| 225 |
+
block += (
|
| 226 |
+
f'<strong>Boolean query sent to {source_label}:</strong><br>'
|
| 227 |
+
f'<code style="display:block;margin:4px 0;padding:6px 10px;background:#1a1a2e;color:#C8A951;'
|
| 228 |
+
f'border-radius:7px;font-size:.78rem;word-break:break-all">{primo_boolean}</code>'
|
| 229 |
+
)
|
| 230 |
+
block += f'<strong>Reasoning:</strong> {reasoning}<br>'
|
| 231 |
+
block += '<span style="color:#5b21b6;font-size:.78rem">✨ AI research tools for deeper exploration are listed at the end of this answer.</span>'
|
| 232 |
+
block += '</div></details>'
|
| 233 |
+
return block
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _ai_tools_footer(context):
|
| 237 |
+
from urllib.parse import quote as _q
|
| 238 |
+
|
| 239 |
+
ai_q = _q(context.ai_tool_query or context.display_topic or context.topic)
|
| 240 |
+
bool_q = context.primo_boolean_query or ""
|
| 241 |
+
primo_url = _primo_clean_url(context)
|
| 242 |
+
primo_ai = PRIMO_AI_URL + ai_q
|
| 243 |
+
is_med = context.intent == "search_medical"
|
| 244 |
+
pubmed_url = _shared_build_pubmed_url(
|
| 245 |
+
context.topic, context.year_from, context.year_to, context.peer_reviewed
|
| 246 |
+
) if is_med else ""
|
| 247 |
+
|
| 248 |
+
KU = "https://khalifa.idm.oclc.org/login?url="
|
| 249 |
+
|
| 250 |
+
platforms = [
|
| 251 |
+
("PRIMO AI Assistant", primo_ai, "primo"),
|
| 252 |
+
("LeapSpace", "https://www.sciencedirect.com/leapspace?query=" + ai_q, "leapspace"),
|
| 253 |
+
("Scopus AI", "https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query=" + ai_q, "scopus"),
|
| 254 |
+
("EBSCO Research AI",
|
| 255 |
+
"https://research-ebsco-com.khalifa.idm.oclc.org/c/yst6t2/search/results?q=" + ai_q
|
| 256 |
+
+ "&autocorrect=y&expanders=concept&limiters=None&searchMode=enhanced&searchSegment=all-results", "ebsco"),
|
| 257 |
+
("Consensus", "https://consensus.app/results/?q=" + ai_q, "consensus"),
|
| 258 |
+
("Semantic Scholar", "https://www.semanticscholar.org/search?q=" + ai_q + "&sort=relevance", "semanticscholar"),
|
| 259 |
+
("Perplexity", "https://www.perplexity.ai/search?q=" + ai_q, "perplexity"),
|
| 260 |
+
]
|
| 261 |
+
if is_med and pubmed_url:
|
| 262 |
+
platforms.append(("PubMed", pubmed_url, "pubmed"))
|
| 263 |
+
|
| 264 |
+
_SVGS = {
|
| 265 |
+
"bee": (
|
| 266 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="16" height="16" style="flex-shrink:0">'
|
| 267 |
+
'<ellipse cx="5.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
|
| 268 |
+
'<ellipse cx="14.5" cy="9" rx="5" ry="2.5" fill="#a8d4f0" opacity=".85"/>'
|
| 269 |
+
'<ellipse cx="10" cy="13" rx="5" ry="6" fill="#1a3a6b"/>'
|
| 270 |
+
'<ellipse cx="10" cy="15" rx="3" ry="1.5" fill="#243f7a"/>'
|
| 271 |
+
'<circle cx="10" cy="8" r="5" fill="#1a3a6b"/>'
|
| 272 |
+
'<rect x="6" y="6.5" width="8" height="3.5" rx="1.5" fill="#001a40"/>'
|
| 273 |
+
'<ellipse cx="8.2" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
|
| 274 |
+
'<ellipse cx="11.8" cy="8.2" rx="1.5" ry="1.2" fill="#00ccee" opacity=".9"/>'
|
| 275 |
+
'<path d="M8 10 Q10 11.5 12 10" fill="none" stroke="#00ccee" stroke-width=".8" stroke-linecap="round"/>'
|
| 276 |
+
'<line x1="8.5" y1="3.5" x2="6.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
|
| 277 |
+
'<circle cx="6.5" cy="1.5" r="1.2" fill="#00ccee" opacity=".8"/>'
|
| 278 |
+
'<line x1="11.5" y1="3.5" x2="13.5" y2="1.5" stroke="#1a3a6b" stroke-width="1.2" stroke-linecap="round"/>'
|
| 279 |
+
'<ellipse cx="13.5" cy="1.2" rx="1.5" ry="1.8" fill="#e8e8e8" stroke="#aaa" stroke-width=".5"/>'
|
| 280 |
+
'<text x="10" y="17.5" font-family="Arial,sans-serif" font-size="3" font-weight="bold" fill="#C8A951" text-anchor="middle">LB</text>'
|
| 281 |
+
'</svg>'
|
| 282 |
+
),
|
| 283 |
+
"book": (
|
| 284 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 285 |
+
'<rect x="2" y="2" width="9" height="12" rx="1" fill="#2563eb"/>'
|
| 286 |
+
'<rect x="3" y="2" width="1.5" height="12" fill="#1d4ed8"/>'
|
| 287 |
+
'<rect x="4" y="4" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
|
| 288 |
+
'<rect x="4" y="6" width="5" height="1" rx=".5" fill="#bfdbfe"/>'
|
| 289 |
+
'<rect x="4" y="8" width="3" height="1" rx=".5" fill="#bfdbfe"/>'
|
| 290 |
+
'<rect x="11" y="3" width="3" height="10" rx="1" fill="#1e40af"/>'
|
| 291 |
+
'</svg>'
|
| 292 |
+
),
|
| 293 |
+
"microscope": (
|
| 294 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 295 |
+
'<rect x="7" y="1" width="2" height="5" rx="1" fill="#6b7280"/>'
|
| 296 |
+
'<ellipse cx="8" cy="1.5" rx="2.5" ry="1.5" fill="#9ca3af"/>'
|
| 297 |
+
'<rect x="6.5" y="5" width="3" height="5" rx=".5" fill="#374151"/>'
|
| 298 |
+
'<rect x="5" y="9.5" width="6" height="1.5" rx=".5" fill="#374151"/>'
|
| 299 |
+
'<rect x="3" y="13" width="10" height="1.5" rx=".5" fill="#374151"/>'
|
| 300 |
+
'<line x1="8" y1="11" x2="8" y2="13" stroke="#374151" stroke-width="1.5"/>'
|
| 301 |
+
'<circle cx="8" cy="6" r="1.5" fill="#60a5fa" opacity=".8"/>'
|
| 302 |
+
'</svg>'
|
| 303 |
+
),
|
| 304 |
+
"circuit": (
|
| 305 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 306 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#1e293b"/>'
|
| 307 |
+
'<circle cx="4" cy="4" r="1.5" fill="#22d3ee"/>'
|
| 308 |
+
'<circle cx="12" cy="4" r="1.5" fill="#22d3ee"/>'
|
| 309 |
+
'<circle cx="4" cy="12" r="1.5" fill="#22d3ee"/>'
|
| 310 |
+
'<circle cx="12" cy="12" r="1.5" fill="#22d3ee"/>'
|
| 311 |
+
'<circle cx="8" cy="8" r="2" fill="#06b6d4"/>'
|
| 312 |
+
'<line x1="4" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 313 |
+
'<line x1="12" y1="4" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 314 |
+
'<line x1="4" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 315 |
+
'<line x1="12" y1="12" x2="8" y2="8" stroke="#22d3ee" stroke-width=".8"/>'
|
| 316 |
+
'</svg>'
|
| 317 |
+
),
|
| 318 |
+
"leaf": (
|
| 319 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 320 |
+
'<path d="M8 14 C8 14 2 10 2 5 C2 2 5 1 8 1 C11 1 14 2 14 5 C14 10 8 14 8 14Z" fill="#16a34a"/>'
|
| 321 |
+
'<line x1="8" y1="1" x2="8" y2="14" stroke="#bbf7d0" stroke-width=".8"/>'
|
| 322 |
+
'<line x1="8" y1="5" x2="5" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 323 |
+
'<line x1="8" y1="7" x2="5" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 324 |
+
'<line x1="8" y1="5" x2="11" y2="7" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 325 |
+
'<line x1="8" y1="7" x2="11" y2="9" stroke="#bbf7d0" stroke-width=".6"/>'
|
| 326 |
+
'</svg>'
|
| 327 |
+
),
|
| 328 |
+
"gear": (
|
| 329 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 330 |
+
'<path d="M6.5 1h3l.5 1.5a5 5 0 011.2.7l1.5-.5 1.5 2.6-1.2 1a5 5 0 010 1.4l1.2 1-1.5 2.6-1.5-.5a5 5 0 01-1.2.7L9.5 15h-3l-.5-1.5a5 5 0 01-1.2-.7l-1.5.5L1.8 10.8l1.2-1a5 5 0 010-1.4l-1.2-1L3.3 4.8l1.5.5A5 5 0 016 4.5L6.5 1z" fill="#64748b"/>'
|
| 331 |
+
'<circle cx="8" cy="8" r="2.5" fill="#f1f5f9"/>'
|
| 332 |
+
'<circle cx="8" cy="8" r="1.2" fill="#64748b"/>'
|
| 333 |
+
'</svg>'
|
| 334 |
+
),
|
| 335 |
+
"chart": (
|
| 336 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 337 |
+
'<rect x="1" y="1" width="14" height="14" rx="1.5" fill="#f0fdf4"/>'
|
| 338 |
+
'<rect x="3" y="9" width="2" height="5" rx=".5" fill="#16a34a"/>'
|
| 339 |
+
'<rect x="7" y="6" width="2" height="8" rx=".5" fill="#15803d"/>'
|
| 340 |
+
'<rect x="11" y="3" width="2" height="11" rx=".5" fill="#166534"/>'
|
| 341 |
+
'<line x1="1" y1="13.5" x2="15" y2="13.5" stroke="#166534" stroke-width=".8"/>'
|
| 342 |
+
'</svg>'
|
| 343 |
+
),
|
| 344 |
+
"atom": (
|
| 345 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 346 |
+
'<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9"/>'
|
| 347 |
+
'<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(60 8 8)"/>'
|
| 348 |
+
'<ellipse cx="8" cy="8" rx="7" ry="3" fill="none" stroke="#7c3aed" stroke-width=".9" transform="rotate(120 8 8)"/>'
|
| 349 |
+
'<circle cx="8" cy="8" r="1.8" fill="#7c3aed"/>'
|
| 350 |
+
'</svg>'
|
| 351 |
+
),
|
| 352 |
+
"medical": (
|
| 353 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 354 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#dc2626"/>'
|
| 355 |
+
'<rect x="6.5" y="3" width="3" height="10" rx="1" fill="white"/>'
|
| 356 |
+
'<rect x="3" y="6.5" width="10" height="3" rx="1" fill="white"/>'
|
| 357 |
+
'</svg>'
|
| 358 |
+
),
|
| 359 |
+
"wrench": (
|
| 360 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 361 |
+
'<path d="M13 2a3 3 0 00-3 3c0 .4.1.8.2 1.1L2.6 13.7a1 1 0 001.4 1.4L11.7 7.8c.3.1.7.2 1.1.2a3 3 0 000-6z" fill="#d97706"/>'
|
| 362 |
+
'<circle cx="12" cy="4" r="1.2" fill="#fef3c7"/>'
|
| 363 |
+
'</svg>'
|
| 364 |
+
),
|
| 365 |
+
"primo": (
|
| 366 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 367 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#003366"/>'
|
| 368 |
+
'<rect x="3" y="4" width="10" height="1.5" rx=".5" fill="#C8A951"/>'
|
| 369 |
+
'<rect x="3" y="7" width="7" height="1.5" rx=".5" fill="white" opacity=".9"/>'
|
| 370 |
+
'<rect x="3" y="10" width="9" height="1.5" rx=".5" fill="white" opacity=".7"/>'
|
| 371 |
+
'<circle cx="12" cy="11" r="2.5" fill="#C8A951"/>'
|
| 372 |
+
'<line x1="11" y1="11" x2="13" y2="11" stroke="#003366" stroke-width=".8"/>'
|
| 373 |
+
'<line x1="12" y1="10" x2="12" y2="12" stroke="#003366" stroke-width=".8"/>'
|
| 374 |
+
'</svg>'
|
| 375 |
+
),
|
| 376 |
+
"scopus": (
|
| 377 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 378 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#e8650a"/>'
|
| 379 |
+
'<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="8" font-weight="bold" fill="white" text-anchor="middle">Sc</text>'
|
| 380 |
+
'</svg>'
|
| 381 |
+
),
|
| 382 |
+
"ebsco": (
|
| 383 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 384 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#00427a"/>'
|
| 385 |
+
'<text x="8" y="11.5" font-family="Arial,sans-serif" font-size="7" font-weight="bold" fill="white" text-anchor="middle">EBS</text>'
|
| 386 |
+
'</svg>'
|
| 387 |
+
),
|
| 388 |
+
"consensus": (
|
| 389 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 390 |
+
'<rect x="1" y="1" width="14" height="14" rx="7" fill="#6c47ff"/>'
|
| 391 |
+
'<circle cx="6" cy="7" r="1.2" fill="white"/>'
|
| 392 |
+
'<circle cx="10" cy="7" r="1.2" fill="white"/>'
|
| 393 |
+
'<path d="M5 10 Q8 12.5 11 10" fill="none" stroke="white" stroke-width="1.2" stroke-linecap="round"/>'
|
| 394 |
+
'</svg>'
|
| 395 |
+
),
|
| 396 |
+
"leapspace": (
|
| 397 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 398 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#ff6b00"/>'
|
| 399 |
+
'<path d="M4 12 L4 7 L8 4 L12 7 L12 12 Z" fill="white" opacity=".9"/>'
|
| 400 |
+
'<rect x="6.5" y="9" width="3" height="3" fill="#ff6b00"/>'
|
| 401 |
+
'</svg>'
|
| 402 |
+
),
|
| 403 |
+
"perplexity": (
|
| 404 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 405 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#1a1a2e"/>'
|
| 406 |
+
'<circle cx="8" cy="6.5" r="3" fill="none" stroke="#20b2aa" stroke-width="1.2"/>'
|
| 407 |
+
'<line x1="8" y1="9.5" x2="8" y2="13" stroke="#20b2aa" stroke-width="1.2" stroke-linecap="round"/>'
|
| 408 |
+
'<circle cx="8" cy="13" r=".8" fill="#20b2aa"/>'
|
| 409 |
+
'</svg>'
|
| 410 |
+
),
|
| 411 |
+
"semanticscholar": (
|
| 412 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 413 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#1857b6"/>'
|
| 414 |
+
'<path d="M4 11 Q8 4 12 11" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round"/>'
|
| 415 |
+
'<circle cx="8" cy="7" r="1.8" fill="#4fc3f7"/>'
|
| 416 |
+
'</svg>'
|
| 417 |
+
),
|
| 418 |
+
"pubmed": (
|
| 419 |
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" style="flex-shrink:0">'
|
| 420 |
+
'<rect x="1" y="1" width="14" height="14" rx="2" fill="#336699"/>'
|
| 421 |
+
'<text x="8" y="11" font-family="Arial,sans-serif" font-size="6.5" font-weight="bold" fill="white" text-anchor="middle">PubMed</text>'
|
| 422 |
+
'</svg>'
|
| 423 |
+
),
|
| 424 |
+
}
|
| 425 |
+
_DB_MASCOT = {
|
| 426 |
+
"PubMed@KU": "microscope", "Embase": "medical", "CINAHL Ultimate": "medical",
|
| 427 |
+
"Cochrane Library": "microscope", "UpToDate": "medical",
|
| 428 |
+
"IEEE Xplore": "circuit", "ACM Digital Library": "circuit",
|
| 429 |
+
"ScienceDirect": "microscope", "Scopus": "microscope",
|
| 430 |
+
"Web of Science": "microscope", "SpringerLink": "book",
|
| 431 |
+
"ACS Publications": "atom", "APS Journals": "atom", "Knovel": "wrench",
|
| 432 |
+
"Business Source Complete": "chart", "Emerald Insight": "chart",
|
| 433 |
+
"Taylor & Francis": "book", "EBSCO": "book",
|
| 434 |
+
}
|
| 435 |
+
_BEE = _SVGS["bee"]
|
| 436 |
+
|
| 437 |
+
def _btn(label, url, mascot_key="bee"):
|
| 438 |
+
icon = _SVGS.get(mascot_key, _SVGS["bee"])
|
| 439 |
+
style = (
|
| 440 |
+
'display:inline-flex;align-items:center;gap:7px;'
|
| 441 |
+
'padding:8px 14px;border-radius:10px;text-decoration:none;'
|
| 442 |
+
'font-size:.8rem;font-weight:600;color:#1a1a2e;'
|
| 443 |
+
'background:#fff;border:1.5px solid #d1d5db;'
|
| 444 |
+
'transition:box-shadow .2s;width:100%;box-sizing:border-box'
|
| 445 |
+
)
|
| 446 |
+
return '<a href="' + url + '" target="_blank" style="' + style + '">' + icon + label + '</a>'
|
| 447 |
+
|
| 448 |
+
btn_rows = ""
|
| 449 |
+
for k in range(0, len(platforms), 2):
|
| 450 |
+
pair = platforms[k:k+2]
|
| 451 |
+
cells = ""
|
| 452 |
+
for lbl, url, mascot_key in pair:
|
| 453 |
+
cells += '<td style="width:50%;padding:3px 4px 3px 0">' + _btn(lbl, url, mascot_key) + '</td>'
|
| 454 |
+
if len(pair) == 1:
|
| 455 |
+
cells += '<td style="width:50%"></td>'
|
| 456 |
+
btn_rows += "<tr>" + cells + "</tr>"
|
| 457 |
+
|
| 458 |
+
platforms_html = (
|
| 459 |
+
'<div style="margin-top:18px;padding:14px 16px;border:1px solid #c4b5fd;'
|
| 460 |
+
'border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#fdf4ff)">'
|
| 461 |
+
'<div style="font-size:.8rem;font-weight:800;color:#5b21b6;margin-bottom:4px">'
|
| 462 |
+
'🤖 Search your query across AI platforms</div>'
|
| 463 |
+
'<div style="font-size:.73rem;color:#6b7280;margin-bottom:10px">'
|
| 464 |
+
'Your query is pre-loaded. Click any platform to search instantly.</div>'
|
| 465 |
+
'<table style="width:100%;border-collapse:collapse">' + btn_rows + '</table>'
|
| 466 |
+
'</div>'
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
if bool_q:
|
| 470 |
+
and_count = bool_q.upper().count(" AND ")
|
| 471 |
+
if and_count >= 1:
|
| 472 |
+
bool_label = "Use this in PRIMO Advanced Search or any database for precise results:"
|
| 473 |
+
else:
|
| 474 |
+
bool_label = "Try adding synonyms with OR to find more relevant results:"
|
| 475 |
+
bool_html = (
|
| 476 |
+
'<div style="margin-top:12px;padding:12px 14px;border:1px solid #fde68a;'
|
| 477 |
+
'border-radius:10px;background:#fffbeb">'
|
| 478 |
+
'<div style="font-size:.78rem;font-weight:700;color:#92400e;margin-bottom:6px">'
|
| 479 |
+
'\U0001f4a1 Boolean search tip</div>'
|
| 480 |
+
'<div style="font-size:.74rem;color:#6b7280;margin-bottom:6px">' + bool_label + '</div>'
|
| 481 |
+
'<code style="display:block;padding:8px 10px;background:#1a1a2e;color:#C8A951;'
|
| 482 |
+
'border-radius:7px;font-size:.76rem;word-break:break-all;white-space:pre-wrap">'
|
| 483 |
+
+ _escape(bool_q) +
|
| 484 |
+
'</code>'
|
| 485 |
+
'<div style="font-size:.71rem;color:#92400e;margin-top:6px">'
|
| 486 |
+
'Paste into the Advanced Search box of any database below.</div>'
|
| 487 |
+
'</div>'
|
| 488 |
+
)
|
| 489 |
+
else:
|
| 490 |
+
bool_html = ""
|
| 491 |
+
|
| 492 |
+
all_lower = ((context.ai_tool_query or "") + " " + (context.topic or "")).lower()
|
| 493 |
+
|
| 494 |
+
def _db_row(name, url, desc, favicon=None):
|
| 495 |
+
icon = _SVGS.get(_DB_MASCOT.get(name, "bee"), _SVGS["bee"])
|
| 496 |
+
return (
|
| 497 |
+
'<div style="display:flex;align-items:center;gap:8px;'
|
| 498 |
+
'padding:7px 0;border-bottom:1px solid #f3f4f6">'
|
| 499 |
+
+ icon +
|
| 500 |
+
'<div><a href="' + url + '" target="_blank" '
|
| 501 |
+
'style="font-size:.8rem;font-weight:700;color:#003366;text-decoration:none">'
|
| 502 |
+
+ name + '</a>'
|
| 503 |
+
'<span style="font-size:.73rem;color:#6b7280"> — ' + desc + '</span></div>'
|
| 504 |
+
'</div>'
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
if any(k in all_lower for k in ["medicine","medical","clinical","nursing","pharmacy","health","pubmed","embase","cinahl","cochrane","uptodate"]):
|
| 508 |
+
db_subject = "Medicine & Health Sciences"
|
| 509 |
+
dbs = [
|
| 510 |
+
("PubMed@KU","http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib","Core biomedical literature"),
|
| 511 |
+
("Embase",KU+"https://www.embase.com","Biomedical, drugs, devices, clinical medicine"),
|
| 512 |
+
("CINAHL Ultimate",KU+"https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul","Nursing and allied health"),
|
| 513 |
+
("Cochrane Library",KU+"https://www.cochranelibrary.com","Systematic reviews and evidence-based medicine"),
|
| 514 |
+
("UpToDate",KU+"https://www.uptodate.com","Point-of-care clinical decision support"),
|
| 515 |
+
]
|
| 516 |
+
elif any(k in all_lower for k in ["machine learning","deep learning","artificial intelligence","neural network","computer science","software","algorithm","cybersecurity","robotics","nlp"]):
|
| 517 |
+
db_subject = "Computer Science & AI"
|
| 518 |
+
dbs = [
|
| 519 |
+
("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Core CS, AI, electronics and systems"),
|
| 520 |
+
("ACM Digital Library",KU+"https://dl.acm.org","Computer science journals, proceedings and books"),
|
| 521 |
+
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across CS and applied computing"),
|
| 522 |
+
("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
|
| 523 |
+
("Web of Science",KU+"https://www.webofscience.com","Citation index"),
|
| 524 |
+
]
|
| 525 |
+
elif any(k in all_lower for k in ["climate","global warming","environment","sustainability","ecology","carbon","emission","renewable","sea level","maldives","island"]):
|
| 526 |
+
db_subject = "Environment & Climate"
|
| 527 |
+
dbs = [
|
| 528 |
+
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across science, environment and engineering"),
|
| 529 |
+
("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
|
| 530 |
+
("Web of Science",KU+"https://www.webofscience.com","High-impact journal coverage and citation index"),
|
| 531 |
+
("SpringerLink",KU+"https://link.springer.com","Springer journals and books"),
|
| 532 |
+
]
|
| 533 |
+
elif any(k in all_lower for k in ["engineering","mechanical","civil","electrical","aerospace","structural","petroleum","chemical","materials"]):
|
| 534 |
+
db_subject = "Engineering"
|
| 535 |
+
dbs = [
|
| 536 |
+
("IEEE Xplore",KU+"https://ieeexplore.ieee.org","Electrical, electronics and systems engineering"),
|
| 537 |
+
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across engineering disciplines"),
|
| 538 |
+
("Knovel",KU+"https://app.knovel.com","Engineering handbooks and reference data"),
|
| 539 |
+
("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
|
| 540 |
+
("Web of Science",KU+"https://www.webofscience.com","Citation index"),
|
| 541 |
+
]
|
| 542 |
+
elif any(k in all_lower for k in ["business","management","finance","economics","leadership","marketing","strategy","entrepreneurship"]):
|
| 543 |
+
db_subject = "Business & Management"
|
| 544 |
+
dbs = [
|
| 545 |
+
("Business Source Complete",KU+"https://search.ebscohost.com","Core business and management literature"),
|
| 546 |
+
("Emerald Insight",KU+"https://www.emerald.com/insight","Management, strategy and leadership"),
|
| 547 |
+
("Scopus",KU+"https://www.scopus.com","Broad discovery and citation tracking"),
|
| 548 |
+
("ScienceDirect",KU+"https://www.sciencedirect.com","Business and social sciences full text"),
|
| 549 |
+
]
|
| 550 |
+
elif any(k in all_lower for k in ["physics","chemistry","quantum","nuclear","materials science","nanotechnology","optics"]):
|
| 551 |
+
db_subject = "Physics, Chemistry & Materials"
|
| 552 |
+
dbs = [
|
| 553 |
+
("ScienceDirect",KU+"https://www.sciencedirect.com","Full text across physical sciences"),
|
| 554 |
+
("ACS Publications",KU+"https://pubs.acs.org","Core chemistry and chemical engineering"),
|
| 555 |
+
("APS Journals",KU+"https://journals.aps.org","American Physical Society core physics journals"),
|
| 556 |
+
("Scopus",KU+"https://www.scopus.com","Discovery and citation tracking"),
|
| 557 |
+
("Web of Science",KU+"https://www.webofscience.com","Citation index"),
|
| 558 |
+
]
|
| 559 |
+
else:
|
| 560 |
+
db_subject = "General"
|
| 561 |
+
dbs = [
|
| 562 |
+
("EBSCO",KU+"https://search.ebscohost.com","Multidisciplinary full text"),
|
| 563 |
+
("ScienceDirect",KU+"https://www.sciencedirect.com","Science, engineering and medicine"),
|
| 564 |
+
("SpringerLink",KU+"https://link.springer.com","Journals and books across all disciplines"),
|
| 565 |
+
("Taylor & Francis",KU+"https://www.tandfonline.com","Humanities, social sciences and STEM"),
|
| 566 |
+
("Emerald Insight",KU+"https://www.emerald.com/insight","Management and social sciences"),
|
| 567 |
+
("Scopus",KU+"https://www.scopus.com","Citation discovery, all disciplines"),
|
| 568 |
+
("Web of Science",KU+"https://www.webofscience.com","Citation index, all disciplines"),
|
| 569 |
+
]
|
| 570 |
+
|
| 571 |
+
dbs_html_rows = ""
|
| 572 |
+
for name, url, desc in dbs:
|
| 573 |
+
dbs_html_rows += _db_row(name, url, desc)
|
| 574 |
+
|
| 575 |
+
db_section = (
|
| 576 |
+
'<div style="margin-top:12px;padding:12px 14px;border:1px solid #d1d5db;'
|
| 577 |
+
'border-radius:10px;background:#f9fafb">'
|
| 578 |
+
'<div style="font-size:.78rem;font-weight:700;color:#003366;margin-bottom:8px">'
|
| 579 |
+
'\U0001f4c2 Recommended KU databases \u2014 ' + db_subject + '</div>'
|
| 580 |
+
+ dbs_html_rows +
|
| 581 |
+
'<div style="padding-top:8px;font-size:.74rem">'
|
| 582 |
+
'<a href="https://library.ku.ac.ae/eresources" target="_blank" '
|
| 583 |
+
'style="color:#003366;font-weight:700">'
|
| 584 |
+
'\U0001f5c4\ufe0f Browse all 50+ KU databases \u2192</a></div>'
|
| 585 |
+
'</div>'
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
return platforms_html + bool_html + db_section
|
| 589 |
+
|
| 590 |
+
|
src/agentcore/scholarly.py
CHANGED
|
@@ -1,298 +1,323 @@
|
|
| 1 |
-
"""Open scholarly-index clients: Semantic Scholar, OpenAlex, Crossref and Unpaywall (OA links)."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
|
| 19 |
-
from src.agentcore.utils import _escape, _strip_resource_noise
|
| 20 |
-
|
| 21 |
-
logger = logging.getLogger(__name__)
|
| 22 |
-
|
| 23 |
-
async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
|
| 24 |
-
clean_topic = _strip_resource_noise(topic)
|
| 25 |
-
if not clean_topic:
|
| 26 |
-
clean_topic = topic
|
| 27 |
-
try:
|
| 28 |
-
params = {
|
| 29 |
-
"query": clean_topic,
|
| 30 |
-
"limit": limit,
|
| 31 |
-
"fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
|
| 32 |
-
}
|
| 33 |
-
async with httpx.AsyncClient(timeout=15) as client:
|
| 34 |
-
r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
|
| 35 |
-
if r.status_code != 200:
|
| 36 |
-
logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
|
| 37 |
-
return []
|
| 38 |
-
data = r.json()
|
| 39 |
-
results = []
|
| 40 |
-
for paper in data.get("data", []):
|
| 41 |
-
abstract = paper.get("abstract") or ""
|
| 42 |
-
if not abstract or len(abstract) < 50:
|
| 43 |
-
continue
|
| 44 |
-
author_list = paper.get("authors") or []
|
| 45 |
-
first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
|
| 46 |
-
year = paper.get("year") or "n.d."
|
| 47 |
-
paper_id = paper.get("paperId", "")
|
| 48 |
-
doi = (paper.get("externalIds") or {}).get("DOI", "")
|
| 49 |
-
pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
|
| 50 |
-
link = (
|
| 51 |
-
pdf_url
|
| 52 |
-
or (f"https://doi.org/{doi}" if doi else "")
|
| 53 |
-
or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
|
| 54 |
-
)
|
| 55 |
-
results.append({
|
| 56 |
-
"title": paper.get("title", "Untitled"),
|
| 57 |
-
"authors": (
|
| 58 |
-
", ".join(a.get("name", "") for a in author_list[:3])
|
| 59 |
-
+ (" et al." if len(author_list) > 3 else "")
|
| 60 |
-
),
|
| 61 |
-
"first_author": first_author_last,
|
| 62 |
-
"year": year,
|
| 63 |
-
"citation_key": f"{first_author_last}, {year}",
|
| 64 |
"venue": paper.get("venue", ""),
|
| 65 |
"abstract": abstract[:700],
|
|
|
|
|
|
|
|
|
|
| 66 |
"link": link,
|
| 67 |
-
"_source": "Semantic Scholar",
|
| 68 |
-
})
|
| 69 |
-
return results
|
| 70 |
-
except Exception as e:
|
| 71 |
-
logger.error(f"Semantic Scholar search failed: {e}")
|
| 72 |
-
return []
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
|
| 76 |
-
try:
|
| 77 |
-
from urllib.parse import quote as _oa_quote
|
| 78 |
-
clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
|
| 79 |
-
clean = re.sub(r'\s+', ' ', clean).strip()[:200]
|
| 80 |
-
encoded = _oa_quote(clean, safe='')
|
| 81 |
-
select = "id,title,authorships,publication_year,primary_location,abstract_inverted_index,doi,cited_by_count,open_access"
|
| 82 |
-
url = (
|
| 83 |
-
f"https://api.openalex.org/works"
|
| 84 |
-
f"?search={encoded}"
|
| 85 |
-
f"&filter=has_abstract:true,type:article,is_paratext:false"
|
| 86 |
-
f"&sort=relevance_score:desc"
|
| 87 |
-
f"&per_page={limit}"
|
| 88 |
-
f"&select={select}"
|
| 89 |
-
f"&mailto={quote(get_settings().contact_email, safe='')}"
|
| 90 |
-
)
|
| 91 |
-
_contact = get_settings().contact_email
|
| 92 |
-
headers = {"User-Agent": f"LibBee/{LIBBEE_VERSION} (mailto:{_contact})"}
|
| 93 |
-
async with httpx.AsyncClient(timeout=10) as client:
|
| 94 |
-
r = await client.get(url, headers=headers)
|
| 95 |
-
if r.status_code != 200:
|
| 96 |
-
logger.warning(f"OpenAlex search failed: {r.status_code}")
|
| 97 |
-
return []
|
| 98 |
-
works = r.json().get("results", [])
|
| 99 |
-
results = []
|
| 100 |
-
for work in works:
|
| 101 |
-
title = work.get("title") or "Untitled"
|
| 102 |
-
inv = work.get("abstract_inverted_index") or {}
|
| 103 |
-
abstract = ""
|
| 104 |
-
if inv:
|
| 105 |
-
word_positions = []
|
| 106 |
-
for word, positions in inv.items():
|
| 107 |
-
for pos in positions:
|
| 108 |
-
word_positions.append((pos, word))
|
| 109 |
-
word_positions.sort(key=lambda x: x[0])
|
| 110 |
-
abstract = " ".join(w for _, w in word_positions)[:700]
|
| 111 |
-
if not abstract:
|
| 112 |
-
continue
|
| 113 |
-
authorships = work.get("authorships") or []
|
| 114 |
-
author_names = [
|
| 115 |
-
a.get("author", {}).get("display_name", "")
|
| 116 |
-
for a in authorships[:4]
|
| 117 |
-
if a.get("author", {}).get("display_name")
|
| 118 |
-
]
|
| 119 |
-
authors_str = ", ".join(author_names[:3])
|
| 120 |
-
if len(author_names) > 3:
|
| 121 |
-
authors_str += " et al."
|
| 122 |
-
first_last = author_names[0].split()[-1] if author_names else "Unknown"
|
| 123 |
-
year = str(work.get("publication_year") or "n.d.")
|
| 124 |
-
doi = (work.get("doi") or "").replace("https://doi.org/", "")
|
| 125 |
-
oa_url = (work.get("open_access") or {}).get("oa_url") or ""
|
| 126 |
-
link = (
|
| 127 |
-
oa_url if oa_url
|
| 128 |
-
else (f"https://doi.org/{doi}" if doi else "")
|
| 129 |
-
or work.get("id", "https://openalex.org")
|
| 130 |
-
)
|
| 131 |
-
venue = (
|
| 132 |
-
((work.get("primary_location") or {}).get("source") or {})
|
| 133 |
-
.get("display_name") or ""
|
| 134 |
-
)
|
| 135 |
-
results.append({
|
| 136 |
-
"title": title,
|
| 137 |
-
"authors": authors_str or "Unknown",
|
| 138 |
-
"first_author": first_last,
|
| 139 |
-
"year": year,
|
| 140 |
-
"citation_key": f"{first_last}, {year}",
|
| 141 |
"venue": venue,
|
| 142 |
"abstract": abstract,
|
|
|
|
|
|
|
|
|
|
| 143 |
"link": link,
|
| 144 |
-
"_source": "OpenAlex",
|
| 145 |
-
})
|
| 146 |
-
logger.info(f"OpenAlex: {len(results)} papers for '{clean[:50]}'")
|
| 147 |
-
return results
|
| 148 |
-
except Exception as e:
|
| 149 |
-
logger.warning(f"OpenAlex search failed: {e}")
|
| 150 |
-
return []
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
# ── Unpaywall + Crossref enrichment ──────────────────────────────────────────
|
| 156 |
-
# Unpaywall (https://unpaywall.org/products/api) resolves a DOI to its best
|
| 157 |
-
# legal open-access location. Crossref fills publication-year / venue gaps.
|
| 158 |
-
# Both are free, keyless "polite pool" APIs identified by contact e-mail.
|
| 159 |
-
# Every call is best-effort: failures never break the response.
|
| 160 |
-
|
| 161 |
-
_UNPAYWALL_TTL = 24 * 60 * 60
|
| 162 |
-
_unpaywall_cache: Dict[str, Any] = {}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
async def _unpaywall_oa_url(doi: str) -> str:
|
| 166 |
-
"""Return the best legal OA PDF/landing URL for a DOI, or '' if none/unknown."""
|
| 167 |
-
doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
|
| 168 |
-
if not doi:
|
| 169 |
-
return ""
|
| 170 |
-
now = time.time()
|
| 171 |
-
hit = _unpaywall_cache.get(doi)
|
| 172 |
-
if hit and now - hit[0] < _UNPAYWALL_TTL:
|
| 173 |
-
return hit[1]
|
| 174 |
-
url = ""
|
| 175 |
-
try:
|
| 176 |
-
email = get_settings().contact_email
|
| 177 |
-
async with httpx.AsyncClient(timeout=4) as client:
|
| 178 |
-
r = await client.get(
|
| 179 |
-
f"https://api.unpaywall.org/v2/{quote(doi, safe='')}",
|
| 180 |
-
params={"email": email},
|
| 181 |
-
)
|
| 182 |
-
if r.status_code == 200:
|
| 183 |
-
loc = (r.json() or {}).get("best_oa_location") or {}
|
| 184 |
-
url = loc.get("url_for_pdf") or loc.get("url") or ""
|
| 185 |
-
except Exception as exc:
|
| 186 |
-
logger.debug(f"Unpaywall lookup failed for {doi}: {exc}")
|
| 187 |
-
_unpaywall_cache[doi] = (now, url)
|
| 188 |
-
return url
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
async def _crossref_fill(doi: str) -> dict:
|
| 192 |
-
"""Return {'year': int|None, 'venue': str} from Crossref for a DOI (best effort)."""
|
| 193 |
-
doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
|
| 194 |
-
if not doi:
|
| 195 |
-
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
try:
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
if issued and issued[0]:
|
| 208 |
-
year = issued[0]
|
| 209 |
-
venue = (msg.get("container-title") or [""])[0]
|
| 210 |
-
return {"year": year, "venue": venue}
|
| 211 |
-
except Exception as exc:
|
| 212 |
-
logger.debug(f"Crossref lookup failed for {doi}: {exc}")
|
| 213 |
-
return {}
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
async def enrich_papers_with_oa(papers: List[dict], max_lookups: int = 5) -> List[dict]:
|
| 217 |
-
"""Concurrently attach 'oa_pdf_url' (Unpaywall) and fill missing year/venue
|
| 218 |
-
(Crossref) for papers that carry a DOI. Mutates and returns the list."""
|
| 219 |
-
targets = [p for p in papers if p.get("doi") and not p.get("oa_pdf_url")][:max_lookups]
|
| 220 |
-
if not targets:
|
| 221 |
-
return papers
|
| 222 |
-
oa_results = await asyncio.gather(
|
| 223 |
-
*(_unpaywall_oa_url(p["doi"]) for p in targets), return_exceptions=True
|
| 224 |
-
)
|
| 225 |
-
for p, oa in zip(targets, oa_results):
|
| 226 |
-
if isinstance(oa, str) and oa:
|
| 227 |
-
p["oa_pdf_url"] = oa
|
| 228 |
-
missing_meta = [p for p in targets if not p.get("year") or p.get("year") == "n.d."]
|
| 229 |
-
if missing_meta:
|
| 230 |
-
cf = await asyncio.gather(
|
| 231 |
-
*(_crossref_fill(p["doi"]) for p in missing_meta), return_exceptions=True
|
| 232 |
)
|
| 233 |
-
for p, meta in zip(missing_meta, cf):
|
| 234 |
-
if isinstance(meta, dict):
|
| 235 |
-
if meta.get("year") and (not p.get("year") or p.get("year") == "n.d."):
|
| 236 |
-
p["year"] = meta["year"]
|
| 237 |
-
if meta.get("venue") and not p.get("venue"):
|
| 238 |
-
p["venue"] = meta["venue"]
|
| 239 |
-
return papers
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
# ── Shared evidence panel ────────────────────────────────────────────────────
|
| 243 |
-
# Used by BOTH search handlers (_run_search_mode and _research_snapshot) so the
|
| 244 |
-
# panel appears on ordinary searches, not only on "deep research" phrasing.
|
| 245 |
-
# Returns (papers, html). Never raises: on any failure returns ([], "") and the
|
| 246 |
-
# caller renders exactly its pre-3.8 output.
|
| 247 |
-
|
| 248 |
-
async def fetch_evidence_panel(topic: str, limit: int = 6, max_lookups: int = 5):
|
| 249 |
papers: List[dict] = []
|
| 250 |
-
try:
|
| 251 |
-
papers = await _openalex_search(topic, limit=limit)
|
| 252 |
-
if not papers:
|
| 253 |
-
papers = await _semantic_scholar_search(topic, limit=limit)
|
| 254 |
if papers:
|
| 255 |
papers = await enrich_papers_with_oa(papers, max_lookups=max_lookups)
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
)
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
)
|
| 283 |
-
|
| 284 |
-
f'<
|
| 285 |
-
f'{
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
html = (
|
| 289 |
-
'<div
|
| 290 |
-
'
|
| 291 |
-
'<div
|
| 292 |
-
'
|
| 293 |
-
'
|
| 294 |
-
'
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
)
|
| 298 |
-
return papers, html
|
|
|
|
| 1 |
+
"""Open scholarly-index clients: Semantic Scholar, OpenAlex, Crossref and Unpaywall (OA links)."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
|
| 19 |
+
from src.agentcore.utils import _escape, _get_runtime_config, _strip_resource_noise
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
|
| 24 |
+
clean_topic = _strip_resource_noise(topic)
|
| 25 |
+
if not clean_topic:
|
| 26 |
+
clean_topic = topic
|
| 27 |
+
try:
|
| 28 |
+
params = {
|
| 29 |
+
"query": clean_topic,
|
| 30 |
+
"limit": limit,
|
| 31 |
+
"fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf,citationCount",
|
| 32 |
+
}
|
| 33 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 34 |
+
r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
|
| 35 |
+
if r.status_code != 200:
|
| 36 |
+
logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
|
| 37 |
+
return []
|
| 38 |
+
data = r.json()
|
| 39 |
+
results = []
|
| 40 |
+
for paper in data.get("data", []):
|
| 41 |
+
abstract = paper.get("abstract") or ""
|
| 42 |
+
if not abstract or len(abstract) < 50:
|
| 43 |
+
continue
|
| 44 |
+
author_list = paper.get("authors") or []
|
| 45 |
+
first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
|
| 46 |
+
year = paper.get("year") or "n.d."
|
| 47 |
+
paper_id = paper.get("paperId", "")
|
| 48 |
+
doi = (paper.get("externalIds") or {}).get("DOI", "")
|
| 49 |
+
pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
|
| 50 |
+
link = (
|
| 51 |
+
pdf_url
|
| 52 |
+
or (f"https://doi.org/{doi}" if doi else "")
|
| 53 |
+
or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
|
| 54 |
+
)
|
| 55 |
+
results.append({
|
| 56 |
+
"title": paper.get("title", "Untitled"),
|
| 57 |
+
"authors": (
|
| 58 |
+
", ".join(a.get("name", "") for a in author_list[:3])
|
| 59 |
+
+ (" et al." if len(author_list) > 3 else "")
|
| 60 |
+
),
|
| 61 |
+
"first_author": first_author_last,
|
| 62 |
+
"year": year,
|
| 63 |
+
"citation_key": f"{first_author_last}, {year}",
|
| 64 |
"venue": paper.get("venue", ""),
|
| 65 |
"abstract": abstract[:700],
|
| 66 |
+
"doi": doi,
|
| 67 |
+
"oa_pdf_url": pdf_url,
|
| 68 |
+
"cited_by_count": paper.get("citationCount") or 0,
|
| 69 |
"link": link,
|
| 70 |
+
"_source": "Semantic Scholar",
|
| 71 |
+
})
|
| 72 |
+
return results
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"Semantic Scholar search failed: {e}")
|
| 75 |
+
return []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
|
| 79 |
+
try:
|
| 80 |
+
from urllib.parse import quote as _oa_quote
|
| 81 |
+
clean = re.sub(r'\b(AND|OR|NOT)\b|[()"\'\\]', ' ', topic, flags=re.IGNORECASE)
|
| 82 |
+
clean = re.sub(r'\s+', ' ', clean).strip()[:200]
|
| 83 |
+
encoded = _oa_quote(clean, safe='')
|
| 84 |
+
select = "id,title,authorships,publication_year,primary_location,abstract_inverted_index,doi,cited_by_count,open_access"
|
| 85 |
+
url = (
|
| 86 |
+
f"https://api.openalex.org/works"
|
| 87 |
+
f"?search={encoded}"
|
| 88 |
+
f"&filter=has_abstract:true,type:article,is_paratext:false"
|
| 89 |
+
f"&sort=relevance_score:desc"
|
| 90 |
+
f"&per_page={limit}"
|
| 91 |
+
f"&select={select}"
|
| 92 |
+
f"&mailto={quote(get_settings().contact_email, safe='')}"
|
| 93 |
+
)
|
| 94 |
+
_contact = get_settings().contact_email
|
| 95 |
+
headers = {"User-Agent": f"LibBee/{LIBBEE_VERSION} (mailto:{_contact})"}
|
| 96 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 97 |
+
r = await client.get(url, headers=headers)
|
| 98 |
+
if r.status_code != 200:
|
| 99 |
+
logger.warning(f"OpenAlex search failed: {r.status_code}")
|
| 100 |
+
return []
|
| 101 |
+
works = r.json().get("results", [])
|
| 102 |
+
results = []
|
| 103 |
+
for work in works:
|
| 104 |
+
title = work.get("title") or "Untitled"
|
| 105 |
+
inv = work.get("abstract_inverted_index") or {}
|
| 106 |
+
abstract = ""
|
| 107 |
+
if inv:
|
| 108 |
+
word_positions = []
|
| 109 |
+
for word, positions in inv.items():
|
| 110 |
+
for pos in positions:
|
| 111 |
+
word_positions.append((pos, word))
|
| 112 |
+
word_positions.sort(key=lambda x: x[0])
|
| 113 |
+
abstract = " ".join(w for _, w in word_positions)[:700]
|
| 114 |
+
if not abstract:
|
| 115 |
+
continue
|
| 116 |
+
authorships = work.get("authorships") or []
|
| 117 |
+
author_names = [
|
| 118 |
+
a.get("author", {}).get("display_name", "")
|
| 119 |
+
for a in authorships[:4]
|
| 120 |
+
if a.get("author", {}).get("display_name")
|
| 121 |
+
]
|
| 122 |
+
authors_str = ", ".join(author_names[:3])
|
| 123 |
+
if len(author_names) > 3:
|
| 124 |
+
authors_str += " et al."
|
| 125 |
+
first_last = author_names[0].split()[-1] if author_names else "Unknown"
|
| 126 |
+
year = str(work.get("publication_year") or "n.d.")
|
| 127 |
+
doi = (work.get("doi") or "").replace("https://doi.org/", "")
|
| 128 |
+
oa_url = (work.get("open_access") or {}).get("oa_url") or ""
|
| 129 |
+
link = (
|
| 130 |
+
oa_url if oa_url
|
| 131 |
+
else (f"https://doi.org/{doi}" if doi else "")
|
| 132 |
+
or work.get("id", "https://openalex.org")
|
| 133 |
+
)
|
| 134 |
+
venue = (
|
| 135 |
+
((work.get("primary_location") or {}).get("source") or {})
|
| 136 |
+
.get("display_name") or ""
|
| 137 |
+
)
|
| 138 |
+
results.append({
|
| 139 |
+
"title": title,
|
| 140 |
+
"authors": authors_str or "Unknown",
|
| 141 |
+
"first_author": first_last,
|
| 142 |
+
"year": year,
|
| 143 |
+
"citation_key": f"{first_last}, {year}",
|
| 144 |
"venue": venue,
|
| 145 |
"abstract": abstract,
|
| 146 |
+
"doi": doi,
|
| 147 |
+
"oa_pdf_url": oa_url,
|
| 148 |
+
"cited_by_count": int(work.get("cited_by_count") or 0),
|
| 149 |
"link": link,
|
| 150 |
+
"_source": "OpenAlex",
|
| 151 |
+
})
|
| 152 |
+
logger.info(f"OpenAlex: {len(results)} papers for '{clean[:50]}'")
|
| 153 |
+
return results
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.warning(f"OpenAlex search failed: {e}")
|
| 156 |
+
return []
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ── Unpaywall + Crossref enrichment ──────────────────────────────────────────
|
| 162 |
+
# Unpaywall (https://unpaywall.org/products/api) resolves a DOI to its best
|
| 163 |
+
# legal open-access location. Crossref fills publication-year / venue gaps.
|
| 164 |
+
# Both are free, keyless "polite pool" APIs identified by contact e-mail.
|
| 165 |
+
# Every call is best-effort: failures never break the response.
|
| 166 |
+
|
| 167 |
+
_UNPAYWALL_TTL = 24 * 60 * 60
|
| 168 |
+
_unpaywall_cache: Dict[str, Any] = {}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def _unpaywall_oa_url(doi: str) -> str:
|
| 172 |
+
"""Return the best legal OA PDF/landing URL for a DOI, or '' if none/unknown."""
|
| 173 |
+
doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
|
| 174 |
+
if not doi:
|
| 175 |
+
return ""
|
| 176 |
+
now = time.time()
|
| 177 |
+
hit = _unpaywall_cache.get(doi)
|
| 178 |
+
if hit and now - hit[0] < _UNPAYWALL_TTL:
|
| 179 |
+
return hit[1]
|
| 180 |
+
url = ""
|
| 181 |
+
try:
|
| 182 |
+
email = get_settings().contact_email
|
| 183 |
+
async with httpx.AsyncClient(timeout=4) as client:
|
| 184 |
+
r = await client.get(
|
| 185 |
+
f"https://api.unpaywall.org/v2/{quote(doi, safe='')}",
|
| 186 |
+
params={"email": email},
|
| 187 |
+
)
|
| 188 |
+
if r.status_code == 200:
|
| 189 |
+
loc = (r.json() or {}).get("best_oa_location") or {}
|
| 190 |
+
url = loc.get("url_for_pdf") or loc.get("url") or ""
|
| 191 |
+
except Exception as exc:
|
| 192 |
+
logger.debug(f"Unpaywall lookup failed for {doi}: {exc}")
|
| 193 |
+
_unpaywall_cache[doi] = (now, url)
|
| 194 |
+
return url
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
async def _crossref_fill(doi: str) -> dict:
|
| 198 |
+
"""Return {'year': int|None, 'venue': str} from Crossref for a DOI (best effort)."""
|
| 199 |
+
doi = (doi or "").strip().lower().removeprefix("https://doi.org/")
|
| 200 |
+
if not doi:
|
| 201 |
+
return {}
|
| 202 |
+
try:
|
| 203 |
+
_contact = get_settings().contact_email
|
| 204 |
+
headers = {"User-Agent": f"LibBee/{LIBBEE_VERSION} (mailto:{_contact})"}
|
| 205 |
+
async with httpx.AsyncClient(timeout=4) as client:
|
| 206 |
+
r = await client.get(f"https://api.crossref.org/works/{quote(doi, safe='')}",
|
| 207 |
+
headers=headers)
|
| 208 |
+
if r.status_code != 200:
|
| 209 |
+
return {}
|
| 210 |
+
msg = (r.json() or {}).get("message") or {}
|
| 211 |
+
year = None
|
| 212 |
+
issued = ((msg.get("issued") or {}).get("date-parts") or [[None]])[0]
|
| 213 |
+
if issued and issued[0]:
|
| 214 |
+
year = issued[0]
|
| 215 |
+
venue = (msg.get("container-title") or [""])[0]
|
| 216 |
+
return {"year": year, "venue": venue}
|
| 217 |
+
except Exception as exc:
|
| 218 |
+
logger.debug(f"Crossref lookup failed for {doi}: {exc}")
|
| 219 |
+
return {}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
async def enrich_papers_with_oa(papers: List[dict], max_lookups: int = 5) -> List[dict]:
|
| 223 |
+
"""Concurrently attach 'oa_pdf_url' (Unpaywall) and fill missing year/venue
|
| 224 |
+
(Crossref) for papers that carry a DOI. Mutates and returns the list."""
|
| 225 |
+
targets = [p for p in papers if p.get("doi") and not p.get("oa_pdf_url")][:max_lookups]
|
| 226 |
+
if not targets:
|
| 227 |
+
return papers
|
| 228 |
+
oa_results = await asyncio.gather(
|
| 229 |
+
*(_unpaywall_oa_url(p["doi"]) for p in targets), return_exceptions=True
|
| 230 |
+
)
|
| 231 |
+
for p, oa in zip(targets, oa_results):
|
| 232 |
+
if isinstance(oa, str) and oa:
|
| 233 |
+
p["oa_pdf_url"] = oa
|
| 234 |
+
missing_meta = [p for p in targets if not p.get("year") or p.get("year") == "n.d."]
|
| 235 |
+
if missing_meta:
|
| 236 |
+
cf = await asyncio.gather(
|
| 237 |
+
*(_crossref_fill(p["doi"]) for p in missing_meta), return_exceptions=True
|
| 238 |
+
)
|
| 239 |
+
for p, meta in zip(missing_meta, cf):
|
| 240 |
+
if isinstance(meta, dict):
|
| 241 |
+
if meta.get("year") and (not p.get("year") or p.get("year") == "n.d."):
|
| 242 |
+
p["year"] = meta["year"]
|
| 243 |
+
if meta.get("venue") and not p.get("venue"):
|
| 244 |
+
p["venue"] = meta["venue"]
|
| 245 |
+
return papers
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ── Shared evidence panel ────────────────────────────────────────────────────
|
| 249 |
+
# Used by BOTH search handlers (_run_search_mode and _research_snapshot) so the
|
| 250 |
+
# panel appears on ordinary searches, not only on "deep research" phrasing.
|
| 251 |
+
# Returns (papers, html). Never raises: on any failure returns ([], "") and the
|
| 252 |
+
# caller renders exactly its pre-3.8 output.
|
| 253 |
+
|
| 254 |
+
async def fetch_evidence_panel(topic: str, limit: int = 6, max_lookups: int = 5, context=None):
|
| 255 |
try:
|
| 256 |
+
configured_limit = int(_get_runtime_config().get("max_results", get_settings().max_results))
|
| 257 |
+
except (TypeError, ValueError):
|
| 258 |
+
configured_limit = get_settings().max_results
|
| 259 |
+
limit = max(1, min(limit, configured_limit, 20))
|
| 260 |
+
if context is not None and getattr(context, "peer_reviewed", False):
|
| 261 |
+
return [], (
|
| 262 |
+
'<div class="evidence-note" role="note"><strong>Peer-reviewed filter:</strong> '
|
| 263 |
+
'Open-index evidence is not displayed because those records do not provide a '
|
| 264 |
+
'reliable peer-review guarantee. Use the prepared PRIMO search, where the '
|
| 265 |
+
'Peer Reviewed filter is applied.</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
papers: List[dict] = []
|
| 268 |
+
try:
|
| 269 |
+
papers = await _openalex_search(topic, limit=limit)
|
| 270 |
+
if not papers:
|
| 271 |
+
papers = await _semantic_scholar_search(topic, limit=limit)
|
| 272 |
if papers:
|
| 273 |
papers = await enrich_papers_with_oa(papers, max_lookups=max_lookups)
|
| 274 |
+
if context is not None:
|
| 275 |
+
year_from = int(context.year_from) if getattr(context, "year_from", None) else None
|
| 276 |
+
year_to = int(context.year_to) if getattr(context, "year_to", None) else None
|
| 277 |
+
if year_from:
|
| 278 |
+
papers = [paper for paper in papers if str(paper.get("year", "")).isdigit() and int(paper["year"]) >= year_from]
|
| 279 |
+
if year_to:
|
| 280 |
+
papers = [paper for paper in papers if str(paper.get("year", "")).isdigit() and int(paper["year"]) <= year_to]
|
| 281 |
+
if getattr(context, "open_access", False):
|
| 282 |
+
papers = [paper for paper in papers if paper.get("oa_pdf_url")]
|
| 283 |
+
except Exception as exc:
|
| 284 |
+
logger.warning(f"Evidence panel failed for '{topic}': {exc}")
|
| 285 |
+
return [], ""
|
| 286 |
+
if not papers:
|
| 287 |
+
return [], ""
|
| 288 |
+
|
| 289 |
+
rows = ""
|
| 290 |
+
for paper in papers[:5]:
|
| 291 |
+
_t = _escape(str(paper.get("title") or "Untitled"))
|
| 292 |
+
_y = _escape(str(paper.get("year") or "n.d."))
|
| 293 |
+
_v = _escape(str(paper.get("venue") or ""))
|
| 294 |
+
_c = paper.get("cited_by_count")
|
| 295 |
+
_link = paper.get("link") or (f"https://doi.org/{paper['doi']}" if paper.get("doi") else "")
|
| 296 |
+
_oa = paper.get("oa_pdf_url") or ""
|
| 297 |
+
_bits = [b for b in (_y, _v) if b]
|
| 298 |
+
if isinstance(_c, int) and _c > 0:
|
| 299 |
+
_bits.append(f"{_c:,} citations")
|
| 300 |
+
_meta = " \u00b7 ".join(_bits)
|
| 301 |
+
_title_html = (
|
| 302 |
+
f'<a href="{_escape(_link)}" target="_blank" style="color:#003366;font-weight:600">{_t}</a>'
|
| 303 |
+
if _link else f'<span style="font-weight:600">{_t}</span>'
|
| 304 |
+
)
|
| 305 |
+
_oa_html = (
|
| 306 |
+
f' \u00b7 <a href="{_escape(_oa)}" target="_blank" '
|
| 307 |
+
f'style="color:#047857;font-weight:700">\U0001f513 OA full text</a>'
|
| 308 |
+
if _oa else ""
|
| 309 |
+
)
|
| 310 |
+
rows += (
|
| 311 |
+
f'<div style="margin:7px 0;padding-left:2px;font-size:.84rem;line-height:1.55">'
|
| 312 |
+
f'{_title_html}<br>'
|
| 313 |
+
f'<span style="color:#6b7280;font-size:.78rem">{_meta}{_oa_html}</span></div>'
|
| 314 |
+
)
|
| 315 |
html = (
|
| 316 |
+
'<div class="evidence-panel">'
|
| 317 |
+
'<div class="evidence-title">\U0001f50e Open scholarly evidence</div>'
|
| 318 |
+
'<div class="evidence-description">'
|
| 319 |
+
'Top open-index results (OpenAlex / Semantic Scholar) with legal open-access '
|
| 320 |
+
'links via Unpaywall and metadata via Crossref where available.</div>'
|
| 321 |
+
f'{rows}</div>'
|
| 322 |
+
)
|
| 323 |
+
return papers, html
|
|
|
|
|
|
src/agentcore/utils.py
CHANGED
|
@@ -1,288 +1,288 @@
|
|
| 1 |
-
"""Small shared helpers: HTML escaping, metrics, history windowing, LLM factory, sanitizers."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import html
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import uuid
|
| 10 |
-
from datetime import datetime
|
| 11 |
-
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
-
from urllib.parse import quote
|
| 13 |
-
|
| 14 |
-
import httpx
|
| 15 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
-
|
| 17 |
-
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
-
from src.services.staff_service import (
|
| 19 |
-
STAFF_DIRECTORY,
|
| 20 |
-
match_staff_name,
|
| 21 |
-
match_staff_role,
|
| 22 |
-
should_attempt_staff_lookup,
|
| 23 |
-
staff_name_answer,
|
| 24 |
-
staff_role_answer,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
from src.agentcore.models import ChatMessage, SearchContextPayload
|
| 28 |
-
from src.agentcore.constants import (
|
| 29 |
-
CURRENT_YEAR,
|
| 30 |
-
HISTORY_WINDOW,
|
| 31 |
-
_LEGACY_SYSTEM_PATTERNS,
|
| 32 |
-
_RESOURCE_TYPE_NOISE,
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
logger = logging.getLogger(__name__)
|
| 36 |
-
|
| 37 |
-
def _get_runtime_config() -> dict:
|
| 38 |
-
try:
|
| 39 |
-
from src.services.runtime_store import JsonRuntimeStore
|
| 40 |
-
settings = get_settings()
|
| 41 |
-
store = JsonRuntimeStore(settings.config_path, default={
|
| 42 |
-
"max_results": 5,
|
| 43 |
-
"maintenance_mode": False,
|
| 44 |
-
"welcome_message": "",
|
| 45 |
-
"custom_instructions": "",
|
| 46 |
-
"announcement": "",
|
| 47 |
-
"maintenance_message": "",
|
| 48 |
-
})
|
| 49 |
-
return store.load()
|
| 50 |
-
except Exception:
|
| 51 |
-
return {}
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def _escape(text: str) -> str:
|
| 55 |
-
return html.escape(text or "")
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def _normalize_whitespace(text: str) -> str:
|
| 59 |
-
return re.sub(r"\s+", " ", (text or "").strip())
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _title_case_topic(topic: str) -> str:
|
| 63 |
-
return _normalize_whitespace(topic).strip().strip(".?")
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def _strip_resource_noise(text: str) -> str:
|
| 67 |
-
cleaned = _RESOURCE_TYPE_NOISE.sub(" ", text or "")
|
| 68 |
-
cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
|
| 69 |
-
cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
|
| 70 |
-
cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
|
| 71 |
-
return _normalize_whitespace(cleaned)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def _safe_metrics_increment(key: str) -> None:
|
| 75 |
-
try:
|
| 76 |
-
from app import get_metrics_service
|
| 77 |
-
get_metrics_service().incr(key)
|
| 78 |
-
except Exception:
|
| 79 |
-
return
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def _safe_metrics_bucket(bucket: str, key: str) -> None:
|
| 83 |
-
try:
|
| 84 |
-
from app import get_metrics_service
|
| 85 |
-
get_metrics_service().incr_bucket(bucket, key)
|
| 86 |
-
except Exception:
|
| 87 |
-
return
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
|
| 91 |
-
msgs = []
|
| 92 |
-
for m in history[-HISTORY_WINDOW:]:
|
| 93 |
-
if m.role in ("user", "assistant") and m.content:
|
| 94 |
-
msgs.append({"role": m.role, "content": m.content[:300]})
|
| 95 |
-
return msgs
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def _get_llm(model: str, temperature: float, max_tokens: int):
|
| 99 |
-
settings = get_settings()
|
| 100 |
-
if model == "claude" and settings.anthropic_api_key:
|
| 101 |
-
from langchain_anthropic import ChatAnthropic
|
| 102 |
-
return ChatAnthropic(
|
| 103 |
-
model="claude-haiku-4-5-20251001",
|
| 104 |
-
temperature=temperature,
|
| 105 |
-
max_tokens=max_tokens,
|
| 106 |
-
anthropic_api_key=settings.anthropic_api_key,
|
| 107 |
-
)
|
| 108 |
-
from langchain_openai import ChatOpenAI
|
| 109 |
-
return ChatOpenAI(
|
| 110 |
-
model="gpt-4o-mini",
|
| 111 |
-
temperature=temperature,
|
| 112 |
-
max_tokens=max_tokens,
|
| 113 |
-
openai_api_key=settings.openai_api_key,
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
def _shared_build_primo_boolean_query(topic: str) -> str:
|
| 118 |
-
clean = _strip_resource_noise(topic)
|
| 119 |
-
if not clean:
|
| 120 |
-
clean = topic
|
| 121 |
-
clean = _normalize_whitespace(clean)
|
| 122 |
-
words = clean.split()
|
| 123 |
-
if len(words) <= 4:
|
| 124 |
-
return f'"{clean}"'
|
| 125 |
-
_BOOL_STOP = re.compile(
|
| 126 |
-
r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
|
| 127 |
-
r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
|
| 128 |
-
r"this|that|these|those|about|impact|role|effect|use|analysis|review|"
|
| 129 |
-
r"what|how|why|when|where|which|between|within|across|among|using|"
|
| 130 |
-
r"based|related|towards|toward|during|after|before|over|under)\b",
|
| 131 |
-
re.IGNORECASE,
|
| 132 |
-
)
|
| 133 |
-
parts = _BOOL_STOP.split(clean)
|
| 134 |
-
concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
|
| 135 |
-
if not concepts:
|
| 136 |
-
return f'"{clean}"'
|
| 137 |
-
if len(concepts) == 1:
|
| 138 |
-
return f'"{concepts[0]}"'
|
| 139 |
-
quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
|
| 140 |
-
return " AND ".join(quoted)
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
def _make_primo_boolean_query(context: SearchContextPayload) -> str:
|
| 144 |
-
topic = _title_case_topic(context.display_topic or context.topic)
|
| 145 |
-
topic = _strip_resource_noise(topic) or (context.topic or "library search")
|
| 146 |
-
return _shared_build_primo_boolean_query(topic)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def _shared_build_primo_discovery_url(
|
| 150 |
-
boolean_query: str,
|
| 151 |
-
resource_type: str = "articles",
|
| 152 |
-
peer_reviewed: bool = False,
|
| 153 |
-
open_access: bool = False,
|
| 154 |
-
year_from: Optional[str] = None,
|
| 155 |
-
year_to: Optional[str] = None,
|
| 156 |
-
) -> str:
|
| 157 |
-
base = (
|
| 158 |
-
"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 159 |
-
f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
|
| 160 |
-
f"&query=any,contains,{quote(boolean_query)}"
|
| 161 |
-
f"&lang=en&search_scope=MyInst_and_CI&sortby=rank&mode=advanced"
|
| 162 |
-
)
|
| 163 |
-
facets = []
|
| 164 |
-
if resource_type == "articles":
|
| 165 |
-
facets.append("facet_rtype,include,articles")
|
| 166 |
-
elif resource_type == "books":
|
| 167 |
-
facets.append("facet_rtype,include,books")
|
| 168 |
-
if peer_reviewed:
|
| 169 |
-
facets.append("facet_tlevel,include,peer_reviewed")
|
| 170 |
-
if open_access:
|
| 171 |
-
facets.append("facet_tlevel,include,online_resources")
|
| 172 |
-
if year_from or year_to:
|
| 173 |
-
yf = year_from or "0001"
|
| 174 |
-
yt = year_to or "9999"
|
| 175 |
-
facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
|
| 176 |
-
for facet in facets:
|
| 177 |
-
base += f"&multiFacets={quote(facet)}"
|
| 178 |
-
return base
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def _shared_build_pubmed_url(
|
| 182 |
-
topic: str,
|
| 183 |
-
year_from: Optional[str] = None,
|
| 184 |
-
year_to: Optional[str] = None,
|
| 185 |
-
peer_reviewed: bool = False,
|
| 186 |
-
) -> str:
|
| 187 |
-
clean = _strip_resource_noise(topic)
|
| 188 |
-
term = clean or topic
|
| 189 |
-
if peer_reviewed:
|
| 190 |
-
term = f"({term}) AND Journal Article[pt]"
|
| 191 |
-
url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(term)}"
|
| 192 |
-
if year_from or year_to:
|
| 193 |
-
yf = year_from or "1900"
|
| 194 |
-
yt = year_to or str(CURRENT_YEAR)
|
| 195 |
-
url += f"&filter=datesearch.y_{yf}-{yt}"
|
| 196 |
-
return url
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def _primo_clean_url(context: SearchContextPayload) -> str:
|
| 200 |
-
boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
|
| 201 |
-
return _shared_build_primo_discovery_url(
|
| 202 |
-
boolean_query,
|
| 203 |
-
resource_type=context.resource_type,
|
| 204 |
-
peer_reviewed=context.peer_reviewed,
|
| 205 |
-
open_access=context.open_access,
|
| 206 |
-
year_from=context.year_from,
|
| 207 |
-
year_to=context.year_to,
|
| 208 |
-
)
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
async def _grammar_refine_query(text: str, model: str) -> str:
|
| 212 |
-
settings = get_settings()
|
| 213 |
-
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 214 |
-
return _normalize_whitespace(text)
|
| 215 |
-
try:
|
| 216 |
-
llm = _get_llm(model, temperature=0, max_tokens=60)
|
| 217 |
-
response = await llm.ainvoke([
|
| 218 |
-
{"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
|
| 219 |
-
{"role": "user", "content": text},
|
| 220 |
-
])
|
| 221 |
-
refined = _normalize_whitespace(response.content)
|
| 222 |
-
return refined or _normalize_whitespace(text)
|
| 223 |
-
except Exception:
|
| 224 |
-
return _normalize_whitespace(text)
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
def _light_strip_retrieval_boilerplate(text: str) -> str:
|
| 228 |
-
cleaned = re.sub(r"^\s*(please\s+)?(?:can you|could you|would you)\s+", "", (text or "").strip(), flags=re.IGNORECASE)
|
| 229 |
-
cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
|
| 230 |
-
cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
|
| 231 |
-
cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
|
| 232 |
-
cleaned = re.sub(
|
| 233 |
-
r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
|
| 234 |
-
r"research on|articles on|papers on|literature on|studies on|"
|
| 235 |
-
r"tell me about|i need|i want|can you find|help me find|"
|
| 236 |
-
r"i am looking for|i'm looking for|i need articles on|"
|
| 237 |
-
r"i want articles on|i need papers on|i want papers on)\s+",
|
| 238 |
-
"", cleaned, flags=re.IGNORECASE
|
| 239 |
-
)
|
| 240 |
-
cleaned = re.sub(
|
| 241 |
-
r"^\s*(research|articles?|papers?|books?|literature|studies|study|"
|
| 242 |
-
r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
|
| 243 |
-
"", cleaned, flags=re.IGNORECASE
|
| 244 |
-
)
|
| 245 |
-
cleaned = re.sub(
|
| 246 |
-
r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
|
| 247 |
-
"", cleaned, flags=re.IGNORECASE
|
| 248 |
-
)
|
| 249 |
-
return re.sub(r"\s+", " ", cleaned).strip()
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
def _sanitize_llm_response(text: str) -> str:
|
| 253 |
-
if not text:
|
| 254 |
-
return text
|
| 255 |
-
for pattern, replacement in _LEGACY_SYSTEM_PATTERNS:
|
| 256 |
-
text = pattern.sub(replacement, text)
|
| 257 |
-
return text
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def _sanitize_boolean_for_primo(boolean: str) -> str:
|
| 261 |
-
if not boolean:
|
| 262 |
-
return boolean
|
| 263 |
-
boolean = re.sub(r"'([^']+)'", r'"\1"', boolean)
|
| 264 |
-
boolean = re.sub(
|
| 265 |
-
r"\(\s*(?:(?:19|20)\d{2}\s*(?:OR\s*(?:19|20)\d{2}\s*)*)\)",
|
| 266 |
-
"", boolean, flags=re.IGNORECASE,
|
| 267 |
-
)
|
| 268 |
-
boolean = re.sub(r"^\s*(AND|OR)\s*", "", boolean, flags=re.IGNORECASE)
|
| 269 |
-
boolean = re.sub(r"\s*(AND|OR)\s*$", "", boolean, flags=re.IGNORECASE)
|
| 270 |
-
boolean = re.sub(r"\b(AND|OR)\s+(AND|OR)\b", r"\1", boolean, flags=re.IGNORECASE)
|
| 271 |
-
return re.sub(r"\s+", " ", boolean).strip()
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
def _clean_database_keywords(boolean_query: str) -> str:
|
| 275 |
-
return re.sub(r"\s+", " ",
|
| 276 |
-
re.sub(r"\b(AND|OR|NOT)\b|[()\"]", " ",
|
| 277 |
-
boolean_query, flags=re.IGNORECASE)).strip()
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def _find_staff_by_token(token: str) -> Optional[dict]:
|
| 281 |
-
token = (token or "").lower()
|
| 282 |
-
for staff in STAFF_DIRECTORY:
|
| 283 |
-
hay = (staff.get("full_name", "") + " " + staff.get("role", "")).lower()
|
| 284 |
-
if token in hay:
|
| 285 |
-
return staff
|
| 286 |
-
return None
|
| 287 |
-
|
| 288 |
-
|
|
|
|
| 1 |
+
"""Small shared helpers: HTML escaping, metrics, history windowing, LLM factory, sanitizers."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 16 |
+
|
| 17 |
+
from src.config import get_settings, LIBBEE_VERSION
|
| 18 |
+
from src.services.staff_service import (
|
| 19 |
+
STAFF_DIRECTORY,
|
| 20 |
+
match_staff_name,
|
| 21 |
+
match_staff_role,
|
| 22 |
+
should_attempt_staff_lookup,
|
| 23 |
+
staff_name_answer,
|
| 24 |
+
staff_role_answer,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
from src.agentcore.models import ChatMessage, SearchContextPayload
|
| 28 |
+
from src.agentcore.constants import (
|
| 29 |
+
CURRENT_YEAR,
|
| 30 |
+
HISTORY_WINDOW,
|
| 31 |
+
_LEGACY_SYSTEM_PATTERNS,
|
| 32 |
+
_RESOURCE_TYPE_NOISE,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
def _get_runtime_config() -> dict:
|
| 38 |
+
try:
|
| 39 |
+
from src.services.runtime_store import JsonRuntimeStore
|
| 40 |
+
settings = get_settings()
|
| 41 |
+
store = JsonRuntimeStore(settings.config_path, default={
|
| 42 |
+
"max_results": 5,
|
| 43 |
+
"maintenance_mode": False,
|
| 44 |
+
"welcome_message": "",
|
| 45 |
+
"custom_instructions": "",
|
| 46 |
+
"announcement": "",
|
| 47 |
+
"maintenance_message": "",
|
| 48 |
+
})
|
| 49 |
+
return store.load()
|
| 50 |
+
except Exception:
|
| 51 |
+
return {}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _escape(text: str) -> str:
|
| 55 |
+
return html.escape(text or "")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _normalize_whitespace(text: str) -> str:
|
| 59 |
+
return re.sub(r"\s+", " ", (text or "").strip())
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _title_case_topic(topic: str) -> str:
|
| 63 |
+
return _normalize_whitespace(topic).strip().strip(".?")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _strip_resource_noise(text: str) -> str:
|
| 67 |
+
cleaned = _RESOURCE_TYPE_NOISE.sub(" ", text or "")
|
| 68 |
+
cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
|
| 69 |
+
cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
|
| 70 |
+
cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
|
| 71 |
+
return _normalize_whitespace(cleaned)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _safe_metrics_increment(key: str) -> None:
|
| 75 |
+
try:
|
| 76 |
+
from app import get_metrics_service
|
| 77 |
+
get_metrics_service().incr(key)
|
| 78 |
+
except Exception:
|
| 79 |
+
return
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _safe_metrics_bucket(bucket: str, key: str) -> None:
|
| 83 |
+
try:
|
| 84 |
+
from app import get_metrics_service
|
| 85 |
+
get_metrics_service().incr_bucket(bucket, key)
|
| 86 |
+
except Exception:
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _build_history_messages(history: List[ChatMessage]) -> List[dict]:
|
| 91 |
+
msgs = []
|
| 92 |
+
for m in history[-HISTORY_WINDOW:]:
|
| 93 |
+
if m.role in ("user", "assistant") and m.content:
|
| 94 |
+
msgs.append({"role": m.role, "content": m.content[:300]})
|
| 95 |
+
return msgs
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _get_llm(model: str, temperature: float, max_tokens: int):
|
| 99 |
+
settings = get_settings()
|
| 100 |
+
if model == "claude" and settings.anthropic_api_key:
|
| 101 |
+
from langchain_anthropic import ChatAnthropic
|
| 102 |
+
return ChatAnthropic(
|
| 103 |
+
model="claude-haiku-4-5-20251001",
|
| 104 |
+
temperature=temperature,
|
| 105 |
+
max_tokens=max_tokens,
|
| 106 |
+
anthropic_api_key=settings.anthropic_api_key,
|
| 107 |
+
)
|
| 108 |
+
from langchain_openai import ChatOpenAI
|
| 109 |
+
return ChatOpenAI(
|
| 110 |
+
model="gpt-4o-mini",
|
| 111 |
+
temperature=temperature,
|
| 112 |
+
max_tokens=max_tokens,
|
| 113 |
+
openai_api_key=settings.openai_api_key,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _shared_build_primo_boolean_query(topic: str) -> str:
|
| 118 |
+
clean = _strip_resource_noise(topic)
|
| 119 |
+
if not clean:
|
| 120 |
+
clean = topic
|
| 121 |
+
clean = _normalize_whitespace(clean)
|
| 122 |
+
words = clean.split()
|
| 123 |
+
if len(words) <= 4:
|
| 124 |
+
return f'"{clean}"'
|
| 125 |
+
_BOOL_STOP = re.compile(
|
| 126 |
+
r"\b(of|on|in|the|a|an|and|or|for|to|with|by|from|at|is|are|was|were|"
|
| 127 |
+
r"be|been|have|has|had|do|does|did|will|would|could|should|may|its|"
|
| 128 |
+
r"this|that|these|those|about|impact|role|effect|use|analysis|review|"
|
| 129 |
+
r"what|how|why|when|where|which|between|within|across|among|using|"
|
| 130 |
+
r"based|related|towards|toward|during|after|before|over|under)\b",
|
| 131 |
+
re.IGNORECASE,
|
| 132 |
+
)
|
| 133 |
+
parts = _BOOL_STOP.split(clean)
|
| 134 |
+
concepts = [_normalize_whitespace(p) for p in parts if _normalize_whitespace(p) and len(_normalize_whitespace(p)) > 2]
|
| 135 |
+
if not concepts:
|
| 136 |
+
return f'"{clean}"'
|
| 137 |
+
if len(concepts) == 1:
|
| 138 |
+
return f'"{concepts[0]}"'
|
| 139 |
+
quoted = [f'"{c}"' if ' ' in c else c for c in concepts[:4]]
|
| 140 |
+
return " AND ".join(quoted)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _make_primo_boolean_query(context: SearchContextPayload) -> str:
|
| 144 |
+
topic = _title_case_topic(context.display_topic or context.topic)
|
| 145 |
+
topic = _strip_resource_noise(topic) or (context.topic or "library search")
|
| 146 |
+
return _shared_build_primo_boolean_query(topic)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _shared_build_primo_discovery_url(
|
| 150 |
+
boolean_query: str,
|
| 151 |
+
resource_type: str = "articles",
|
| 152 |
+
peer_reviewed: bool = False,
|
| 153 |
+
open_access: bool = False,
|
| 154 |
+
year_from: Optional[str] = None,
|
| 155 |
+
year_to: Optional[str] = None,
|
| 156 |
+
) -> str:
|
| 157 |
+
base = (
|
| 158 |
+
"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 159 |
+
f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
|
| 160 |
+
f"&query=any,contains,{quote(boolean_query)}"
|
| 161 |
+
f"&lang=en&search_scope=MyInst_and_CI&sortby=rank&mode=advanced"
|
| 162 |
+
)
|
| 163 |
+
facets = []
|
| 164 |
+
if resource_type == "articles":
|
| 165 |
+
facets.append("facet_rtype,include,articles")
|
| 166 |
+
elif resource_type == "books":
|
| 167 |
+
facets.append("facet_rtype,include,books")
|
| 168 |
+
if peer_reviewed:
|
| 169 |
+
facets.append("facet_tlevel,include,peer_reviewed")
|
| 170 |
+
if open_access:
|
| 171 |
+
facets.append("facet_tlevel,include,online_resources")
|
| 172 |
+
if year_from or year_to:
|
| 173 |
+
yf = year_from or "0001"
|
| 174 |
+
yt = year_to or "9999"
|
| 175 |
+
facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
|
| 176 |
+
for facet in facets:
|
| 177 |
+
base += f"&multiFacets={quote(facet)}"
|
| 178 |
+
return base
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _shared_build_pubmed_url(
|
| 182 |
+
topic: str,
|
| 183 |
+
year_from: Optional[str] = None,
|
| 184 |
+
year_to: Optional[str] = None,
|
| 185 |
+
peer_reviewed: bool = False,
|
| 186 |
+
) -> str:
|
| 187 |
+
clean = _strip_resource_noise(topic)
|
| 188 |
+
term = clean or topic
|
| 189 |
+
if peer_reviewed:
|
| 190 |
+
term = f"({term}) AND Journal Article[pt]"
|
| 191 |
+
url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(term)}"
|
| 192 |
+
if year_from or year_to:
|
| 193 |
+
yf = year_from or "1900"
|
| 194 |
+
yt = year_to or str(CURRENT_YEAR)
|
| 195 |
+
url += f"&filter=datesearch.y_{yf}-{yt}"
|
| 196 |
+
return url
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _primo_clean_url(context: SearchContextPayload) -> str:
|
| 200 |
+
boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
|
| 201 |
+
return _shared_build_primo_discovery_url(
|
| 202 |
+
boolean_query,
|
| 203 |
+
resource_type=context.resource_type,
|
| 204 |
+
peer_reviewed=context.peer_reviewed,
|
| 205 |
+
open_access=context.open_access,
|
| 206 |
+
year_from=context.year_from,
|
| 207 |
+
year_to=context.year_to,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def _grammar_refine_query(text: str, model: str) -> str:
|
| 212 |
+
settings = get_settings()
|
| 213 |
+
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 214 |
+
return _normalize_whitespace(text)
|
| 215 |
+
try:
|
| 216 |
+
llm = _get_llm(model, temperature=0, max_tokens=60)
|
| 217 |
+
response = await llm.ainvoke([
|
| 218 |
+
{"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
|
| 219 |
+
{"role": "user", "content": text},
|
| 220 |
+
])
|
| 221 |
+
refined = _normalize_whitespace(response.content)
|
| 222 |
+
return refined or _normalize_whitespace(text)
|
| 223 |
+
except Exception:
|
| 224 |
+
return _normalize_whitespace(text)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _light_strip_retrieval_boilerplate(text: str) -> str:
|
| 228 |
+
cleaned = re.sub(r"^\s*(please\s+)?(?:can you|could you|would you)\s+", "", (text or "").strip(), flags=re.IGNORECASE)
|
| 229 |
+
cleaned = re.sub(r"^\s*(please\s+)?help me\s+", "", cleaned, flags=re.IGNORECASE)
|
| 230 |
+
cleaned = re.sub(r"^\s*please\s+", "", cleaned, flags=re.IGNORECASE)
|
| 231 |
+
cleaned = re.sub(r"\s+(please|thanks|thank you|asap)$", "", cleaned, flags=re.IGNORECASE)
|
| 232 |
+
cleaned = re.sub(
|
| 233 |
+
r"^\s*(find|search for|search|look for|get me|show me|give me|fetch|retrieve|"
|
| 234 |
+
r"research on|articles on|papers on|literature on|studies on|"
|
| 235 |
+
r"tell me about|i need|i want|can you find|help me find|"
|
| 236 |
+
r"i am looking for|i'm looking for|i need articles on|"
|
| 237 |
+
r"i want articles on|i need papers on|i want papers on)\s+",
|
| 238 |
+
"", cleaned, flags=re.IGNORECASE
|
| 239 |
+
)
|
| 240 |
+
cleaned = re.sub(
|
| 241 |
+
r"^\s*(research|articles?|papers?|books?|literature|studies|study|"
|
| 242 |
+
r"journals?|publications?|resources?)\s+(on|about|for|regarding|into)\s+",
|
| 243 |
+
"", cleaned, flags=re.IGNORECASE
|
| 244 |
+
)
|
| 245 |
+
cleaned = re.sub(
|
| 246 |
+
r"^\s*(on|about|for|regarding|concerning|into|around|of|in|the)\s+",
|
| 247 |
+
"", cleaned, flags=re.IGNORECASE
|
| 248 |
+
)
|
| 249 |
+
return re.sub(r"\s+", " ", cleaned).strip()
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _sanitize_llm_response(text: str) -> str:
|
| 253 |
+
if not text:
|
| 254 |
+
return text
|
| 255 |
+
for pattern, replacement in _LEGACY_SYSTEM_PATTERNS:
|
| 256 |
+
text = pattern.sub(replacement, text)
|
| 257 |
+
return text
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _sanitize_boolean_for_primo(boolean: str) -> str:
|
| 261 |
+
if not boolean:
|
| 262 |
+
return boolean
|
| 263 |
+
boolean = re.sub(r"'([^']+)'", r'"\1"', boolean)
|
| 264 |
+
boolean = re.sub(
|
| 265 |
+
r"\(\s*(?:(?:19|20)\d{2}\s*(?:OR\s*(?:19|20)\d{2}\s*)*)\)",
|
| 266 |
+
"", boolean, flags=re.IGNORECASE,
|
| 267 |
+
)
|
| 268 |
+
boolean = re.sub(r"^\s*(AND|OR)\s*", "", boolean, flags=re.IGNORECASE)
|
| 269 |
+
boolean = re.sub(r"\s*(AND|OR)\s*$", "", boolean, flags=re.IGNORECASE)
|
| 270 |
+
boolean = re.sub(r"\b(AND|OR)\s+(AND|OR)\b", r"\1", boolean, flags=re.IGNORECASE)
|
| 271 |
+
return re.sub(r"\s+", " ", boolean).strip()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _clean_database_keywords(boolean_query: str) -> str:
|
| 275 |
+
return re.sub(r"\s+", " ",
|
| 276 |
+
re.sub(r"\b(AND|OR|NOT)\b|[()\"]", " ",
|
| 277 |
+
boolean_query, flags=re.IGNORECASE)).strip()
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _find_staff_by_token(token: str) -> Optional[dict]:
|
| 281 |
+
token = (token or "").lower()
|
| 282 |
+
for staff in STAFF_DIRECTORY:
|
| 283 |
+
hay = (staff.get("full_name", "") + " " + staff.get("role", "")).lower()
|
| 284 |
+
if token in hay:
|
| 285 |
+
return staff
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
|