Update src/api/agent.py
Browse files- src/api/agent.py +114 -27
src/api/agent.py
CHANGED
|
@@ -31,7 +31,7 @@ class AgentRequest(BaseModel):
|
|
| 31 |
|
| 32 |
|
| 33 |
class AgentResponse(BaseModel):
|
| 34 |
-
model_config = ConfigDict(extra="ignore")
|
| 35 |
|
| 36 |
answer: str
|
| 37 |
intent: str = "general"
|
|
@@ -117,11 +117,14 @@ MEDICAL_SEARCH_RE = re.compile(
|
|
| 117 |
r"|clinical trial|biomedical literature|medical literature)\b",
|
| 118 |
re.IGNORECASE,
|
| 119 |
)
|
|
|
|
|
|
|
| 120 |
CURRENT_INFO_RE = re.compile(
|
| 121 |
r"\b(current|currently|latest|today|recent|recently|now|this week|this month"
|
| 122 |
-
r"|news|update|updated|
|
| 123 |
re.IGNORECASE,
|
| 124 |
)
|
|
|
|
| 125 |
MEDICAL_KEYWORDS = [
|
| 126 |
"medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
|
| 127 |
"anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
|
|
@@ -142,8 +145,8 @@ INTENTS β pick exactly one:
|
|
| 142 |
"library_info" β questions about KU Library services, hours, rooms, borrowing, fines, ILL, staff, databases, ORCID, RefWorks, open access, APC, account, printing, remote access, LibKey, acquisitions, Khazna, thesis submission, library policies, alumni access
|
| 143 |
"search_academic"β user wants to FIND articles / papers / books / literature on a specific academic topic
|
| 144 |
"search_medical" β same as search_academic but topic is medical/clinical/nursing/pharmacy/health sciences
|
| 145 |
-
"general_recent" β questions about current events
|
| 146 |
-
"general" β factual/knowledge question NOT about KU Library and NOT a research search
|
| 147 |
|
| 148 |
RULES:
|
| 149 |
1. When in doubt between library_info and social, choose library_info if ANY library keyword is present.
|
|
@@ -152,6 +155,7 @@ RULES:
|
|
| 152 |
4. "I need help finding articles on diabetes" = search_medical.
|
| 153 |
5. KU = Khalifa University. Do NOT confuse with Kuwait University.
|
| 154 |
6. ANY question about advances, approaches, methods, effectiveness, impact, trends on a specific academic topic = search_academic or search_medical.
|
|
|
|
| 155 |
|
| 156 |
For "social" intent ONLY: include "casual_answer" β a warm 1-3 sentence LibBee response (no markdown, no bullets).
|
| 157 |
For all other intents: omit "casual_answer".
|
|
@@ -258,14 +262,6 @@ def _campus_answer() -> str:
|
|
| 258 |
)
|
| 259 |
|
| 260 |
|
| 261 |
-
def _general_redirect_answer() -> str:
|
| 262 |
-
return (
|
| 263 |
-
"I'm <strong>LibBee</strong>, the Khalifa University <strong>Library</strong> AI Assistant β "
|
| 264 |
-
"I'm best for library resources, services, databases, research help, and staff contacts.<br><br>"
|
| 265 |
-
f'For general Khalifa University questions, please visit: <a href="{KU_MAIN_URL}" target="_blank">{KU_MAIN_URL}</a>.'
|
| 266 |
-
)
|
| 267 |
-
|
| 268 |
-
|
| 269 |
def _greeting_menu_clarify_answer() -> str:
|
| 270 |
return (
|
| 271 |
"Sure β what would you like help with?<br><br>"
|
|
@@ -281,7 +277,6 @@ def _greeting_menu_clarify_answer() -> str:
|
|
| 281 |
# ββ LLM Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 282 |
|
| 283 |
def _get_llm(model: str, temperature: float, max_tokens: int):
|
| 284 |
-
"""Instantiate LLM. Called once per request β move to singleton if latency is a concern."""
|
| 285 |
settings = get_settings()
|
| 286 |
if model == "claude" and settings.anthropic_api_key:
|
| 287 |
from langchain_anthropic import ChatAnthropic
|
|
@@ -315,7 +310,11 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
|
|
| 315 |
return _rule_based_classify(question)
|
| 316 |
|
| 317 |
try:
|
| 318 |
-
ctx_parts = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
ctx = " | ".join(ctx_parts) if ctx_parts else "none"
|
| 320 |
user_msg = f"Message: {question[:400]}\nContext: {ctx}\nJSON:"
|
| 321 |
|
|
@@ -344,7 +343,6 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
|
|
| 344 |
async def _libbee_casual_response(
|
| 345 |
question: str, history: List[ChatMessage], model: str, hint: str = ""
|
| 346 |
) -> str:
|
| 347 |
-
# Use the classifier's hint directly if it's a real answer
|
| 348 |
if hint and len(hint.strip()) > 25:
|
| 349 |
return hint.strip()
|
| 350 |
|
|
@@ -376,7 +374,6 @@ async def _rag_answer(
|
|
| 376 |
"""Generate a grounded answer from RAG-retrieved chunks."""
|
| 377 |
settings = get_settings()
|
| 378 |
if not settings.openai_api_key:
|
| 379 |
-
# No LLM β return top chunk directly
|
| 380 |
return rag_results[0]["content"] if rag_results else ""
|
| 381 |
|
| 382 |
context = "\n\n---\n\n".join(r["content"] for r in rag_results)
|
|
@@ -406,6 +403,98 @@ async def _rag_answer(
|
|
| 406 |
)
|
| 407 |
|
| 408 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
# ββ Main Endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 410 |
|
| 411 |
@router.post("", response_model=AgentResponse)
|
|
@@ -502,7 +591,6 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 502 |
# ββ LAYER 2: Intent Handlers βββββββββββββββββββββββββββββββββββββββββββ
|
| 503 |
|
| 504 |
if intent == "social":
|
| 505 |
-
# Use classifier's casual_answer directly β avoids second LLM call
|
| 506 |
answer = casual_hint if (casual_hint and len(casual_hint.strip()) > 25) else \
|
| 507 |
await _libbee_casual_response(question, history, model, casual_hint)
|
| 508 |
return AgentResponse(
|
|
@@ -514,7 +602,6 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 514 |
)
|
| 515 |
|
| 516 |
elif intent == "library_info":
|
| 517 |
-
# Import singleton from app.py
|
| 518 |
try:
|
| 519 |
from app import rag_service
|
| 520 |
rag_results = await rag_service.hybrid_search(question, top_k=4)
|
|
@@ -568,24 +655,24 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 568 |
)
|
| 569 |
|
| 570 |
elif intent == "general_recent":
|
|
|
|
|
|
|
|
|
|
| 571 |
return AgentResponse(
|
| 572 |
-
answer=
|
| 573 |
-
"I'm LibBee, a library assistant β I don't have access to real-time or current information.<br><br>"
|
| 574 |
-
"For the latest news or live data, please use a web search engine.<br><br>"
|
| 575 |
-
"If you're looking for <strong>recent academic publications</strong> on this topic, "
|
| 576 |
-
"I can help with that β just ask me to search for articles."
|
| 577 |
-
),
|
| 578 |
intent="general_recent",
|
| 579 |
-
tools_used=
|
| 580 |
model_used=model,
|
| 581 |
response_time=time.time() - start_time,
|
| 582 |
)
|
| 583 |
|
| 584 |
else: # general
|
|
|
|
|
|
|
| 585 |
return AgentResponse(
|
| 586 |
-
answer=
|
| 587 |
intent="general",
|
| 588 |
-
tools_used=["
|
| 589 |
model_used=model,
|
| 590 |
response_time=time.time() - start_time,
|
| 591 |
)
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
class AgentResponse(BaseModel):
|
| 34 |
+
model_config = ConfigDict(extra="ignore")
|
| 35 |
|
| 36 |
answer: str
|
| 37 |
intent: str = "general"
|
|
|
|
| 117 |
r"|clinical trial|biomedical literature|medical literature)\b",
|
| 118 |
re.IGNORECASE,
|
| 119 |
)
|
| 120 |
+
|
| 121 |
+
# FIX: removed "president" β was catching KU-specific questions incorrectly
|
| 122 |
CURRENT_INFO_RE = re.compile(
|
| 123 |
r"\b(current|currently|latest|today|recent|recently|now|this week|this month"
|
| 124 |
+
r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
|
| 125 |
re.IGNORECASE,
|
| 126 |
)
|
| 127 |
+
|
| 128 |
MEDICAL_KEYWORDS = [
|
| 129 |
"medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
|
| 130 |
"anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
|
|
|
|
| 145 |
"library_info" β questions about KU Library services, hours, rooms, borrowing, fines, ILL, staff, databases, ORCID, RefWorks, open access, APC, account, printing, remote access, LibKey, acquisitions, Khazna, thesis submission, library policies, alumni access
|
| 146 |
"search_academic"β user wants to FIND articles / papers / books / literature on a specific academic topic
|
| 147 |
"search_medical" β same as search_academic but topic is medical/clinical/nursing/pharmacy/health sciences
|
| 148 |
+
"general_recent" β questions about TRULY current/live events: breaking news, stock prices, live sports scores, today's weather, real-time data that changes by the hour. NOT questions about stable facts like university leadership.
|
| 149 |
+
"general" β factual/knowledge question NOT about KU Library and NOT a research search β including questions about universities, people, places, history, science, technology
|
| 150 |
|
| 151 |
RULES:
|
| 152 |
1. When in doubt between library_info and social, choose library_info if ANY library keyword is present.
|
|
|
|
| 155 |
4. "I need help finding articles on diabetes" = search_medical.
|
| 156 |
5. KU = Khalifa University. Do NOT confuse with Kuwait University.
|
| 157 |
6. ANY question about advances, approaches, methods, effectiveness, impact, trends on a specific academic topic = search_academic or search_medical.
|
| 158 |
+
7. Questions about university leadership, presidents, provosts, staff = general (not general_recent β these are stable facts).
|
| 159 |
|
| 160 |
For "social" intent ONLY: include "casual_answer" β a warm 1-3 sentence LibBee response (no markdown, no bullets).
|
| 161 |
For all other intents: omit "casual_answer".
|
|
|
|
| 262 |
)
|
| 263 |
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
def _greeting_menu_clarify_answer() -> str:
|
| 266 |
return (
|
| 267 |
"Sure β what would you like help with?<br><br>"
|
|
|
|
| 277 |
# ββ LLM Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 278 |
|
| 279 |
def _get_llm(model: str, temperature: float, max_tokens: int):
|
|
|
|
| 280 |
settings = get_settings()
|
| 281 |
if model == "claude" and settings.anthropic_api_key:
|
| 282 |
from langchain_anthropic import ChatAnthropic
|
|
|
|
| 310 |
return _rule_based_classify(question)
|
| 311 |
|
| 312 |
try:
|
| 313 |
+
ctx_parts = [
|
| 314 |
+
f"{m.role}: {m.content[:100]}"
|
| 315 |
+
for m in history[-HISTORY_WINDOW:]
|
| 316 |
+
if m.role in ("user", "assistant")
|
| 317 |
+
]
|
| 318 |
ctx = " | ".join(ctx_parts) if ctx_parts else "none"
|
| 319 |
user_msg = f"Message: {question[:400]}\nContext: {ctx}\nJSON:"
|
| 320 |
|
|
|
|
| 343 |
async def _libbee_casual_response(
|
| 344 |
question: str, history: List[ChatMessage], model: str, hint: str = ""
|
| 345 |
) -> str:
|
|
|
|
| 346 |
if hint and len(hint.strip()) > 25:
|
| 347 |
return hint.strip()
|
| 348 |
|
|
|
|
| 374 |
"""Generate a grounded answer from RAG-retrieved chunks."""
|
| 375 |
settings = get_settings()
|
| 376 |
if not settings.openai_api_key:
|
|
|
|
| 377 |
return rag_results[0]["content"] if rag_results else ""
|
| 378 |
|
| 379 |
context = "\n\n---\n\n".join(r["content"] for r in rag_results)
|
|
|
|
| 403 |
)
|
| 404 |
|
| 405 |
|
| 406 |
+
async def _llm_general_answer(
|
| 407 |
+
question: str,
|
| 408 |
+
history: List[ChatMessage],
|
| 409 |
+
model: str,
|
| 410 |
+
) -> str:
|
| 411 |
+
"""
|
| 412 |
+
Answer general factual questions from LLM training knowledge.
|
| 413 |
+
Used for: university info, people, places, science, technology, etc.
|
| 414 |
+
"""
|
| 415 |
+
settings = get_settings()
|
| 416 |
+
if not settings.openai_api_key:
|
| 417 |
+
return (
|
| 418 |
+
"I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant. "
|
| 419 |
+
"I can best help with library resources, databases, and research support. "
|
| 420 |
+
f'For general questions, please visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
system = (
|
| 424 |
+
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 425 |
+
"Answer the user's question factually and concisely in 3-5 sentences. "
|
| 426 |
+
"Use HTML <br> for line breaks. "
|
| 427 |
+
"KU means Khalifa University β do NOT confuse with Kuwait University. "
|
| 428 |
+
"If the topic is academic, add one short sentence at the end offering to find "
|
| 429 |
+
"library resources or articles on the topic."
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
try:
|
| 433 |
+
llm = _get_llm(model, temperature=0.3, max_tokens=400)
|
| 434 |
+
msgs = [{"role": "system", "content": system}]
|
| 435 |
+
msgs.extend(_build_history_messages(history))
|
| 436 |
+
msgs.append({"role": "user", "content": question})
|
| 437 |
+
response = await llm.ainvoke(msgs)
|
| 438 |
+
return response.content.strip()
|
| 439 |
+
except Exception as e:
|
| 440 |
+
logger.error(f"_llm_general_answer error: {e}")
|
| 441 |
+
return (
|
| 442 |
+
"I'm having trouble answering that right now. "
|
| 443 |
+
"For general Khalifa University questions, please visit "
|
| 444 |
+
f'<a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
async def _web_search_answer(
|
| 449 |
+
question: str,
|
| 450 |
+
history: List[ChatMessage],
|
| 451 |
+
model: str,
|
| 452 |
+
) -> str:
|
| 453 |
+
"""
|
| 454 |
+
Answer current-events questions using web search (Claude) or
|
| 455 |
+
LLM knowledge fallback (GPT β no native web search tool).
|
| 456 |
+
Used for: breaking news, live scores, real-time data.
|
| 457 |
+
"""
|
| 458 |
+
settings = get_settings()
|
| 459 |
+
|
| 460 |
+
# Claude: use Anthropic web search tool
|
| 461 |
+
if model == "claude" and settings.anthropic_api_key:
|
| 462 |
+
try:
|
| 463 |
+
import anthropic
|
| 464 |
+
client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
| 465 |
+
|
| 466 |
+
system = (
|
| 467 |
+
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 468 |
+
"Answer the question using web search results. "
|
| 469 |
+
"Be concise and factual. Use HTML <br> for line breaks. "
|
| 470 |
+
"If the topic is academic, briefly mention you can help find "
|
| 471 |
+
"scholarly articles on it."
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
response = client.messages.create(
|
| 475 |
+
model="claude-3-haiku-20240307",
|
| 476 |
+
max_tokens=500,
|
| 477 |
+
system=system,
|
| 478 |
+
tools=[{"type": "web_search_20250305", "name": "web_search"}],
|
| 479 |
+
messages=[{"role": "user", "content": question}],
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
# Extract text blocks from response
|
| 483 |
+
text = ""
|
| 484 |
+
for block in response.content:
|
| 485 |
+
if hasattr(block, "text"):
|
| 486 |
+
text += block.text
|
| 487 |
+
|
| 488 |
+
if text.strip():
|
| 489 |
+
return text.strip()
|
| 490 |
+
|
| 491 |
+
except Exception as e:
|
| 492 |
+
logger.warning(f"Claude web search failed: {e} β falling back to LLM knowledge")
|
| 493 |
+
|
| 494 |
+
# GPT or Claude fallback: answer from training knowledge
|
| 495 |
+
return await _llm_general_answer(question, history, model)
|
| 496 |
+
|
| 497 |
+
|
| 498 |
# ββ Main Endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 499 |
|
| 500 |
@router.post("", response_model=AgentResponse)
|
|
|
|
| 591 |
# ββ LAYER 2: Intent Handlers βββββββββββββββββββββββββββββββββββββββββββ
|
| 592 |
|
| 593 |
if intent == "social":
|
|
|
|
| 594 |
answer = casual_hint if (casual_hint and len(casual_hint.strip()) > 25) else \
|
| 595 |
await _libbee_casual_response(question, history, model, casual_hint)
|
| 596 |
return AgentResponse(
|
|
|
|
| 602 |
)
|
| 603 |
|
| 604 |
elif intent == "library_info":
|
|
|
|
| 605 |
try:
|
| 606 |
from app import rag_service
|
| 607 |
rag_results = await rag_service.hybrid_search(question, top_k=4)
|
|
|
|
| 655 |
)
|
| 656 |
|
| 657 |
elif intent == "general_recent":
|
| 658 |
+
# Real-time/live questions β Claude web search or GPT knowledge fallback
|
| 659 |
+
answer = await _web_search_answer(question, history, model)
|
| 660 |
+
tools_used = ["web_search" if model == "claude" else "llm_knowledge"]
|
| 661 |
return AgentResponse(
|
| 662 |
+
answer=answer,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
intent="general_recent",
|
| 664 |
+
tools_used=tools_used,
|
| 665 |
model_used=model,
|
| 666 |
response_time=time.time() - start_time,
|
| 667 |
)
|
| 668 |
|
| 669 |
else: # general
|
| 670 |
+
# Factual questions β LLM answers from training knowledge
|
| 671 |
+
answer = await _llm_general_answer(question, history, model)
|
| 672 |
return AgentResponse(
|
| 673 |
+
answer=answer,
|
| 674 |
intent="general",
|
| 675 |
+
tools_used=["llm_knowledge"],
|
| 676 |
model_used=model,
|
| 677 |
response_time=time.time() - start_time,
|
| 678 |
)
|