Download src/agentcore/orchestrator.py from nikeshn/LibBee: direct link, hf CLI and curl.
- Browser
- Download file 38.7 kB
-
https://huggingface.co/spaces/nikeshn/LibBee/resolve/main/src/agentcore/orchestrator.py
- Command line
-
hf download hf://spaces/nikeshn/LibBee/src/agentcore/orchestrator.py
-
curl -L -o orchestrator.py https://huggingface.co/spaces/nikeshn/LibBee/resolve/main/src/agentcore/orchestrator.py
38.7 kB
| """Stage 4 orchestration: the seven-intent routing pipeline (_agent_query_inner) and D1 logging.""" | |
| import asyncio | |
| import html | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import time | |
| import uuid | |
| from collections import deque | |
| from datetime import datetime | |
| from typing import Any, Dict, List, Literal, Optional, Tuple | |
| from urllib.parse import quote | |
| import httpx | |
| from pydantic import BaseModel, ConfigDict, Field | |
| from src.config import get_settings, LIBBEE_VERSION | |
| from src.services.staff_service import ( | |
| STAFF_DIRECTORY, | |
| match_staff_name, | |
| match_staff_role, | |
| should_attempt_staff_lookup, | |
| staff_name_answer, | |
| staff_role_answer, | |
| ) | |
| from src.agentcore.models import AgentResponse, SearchContextPayload | |
| from src.agentcore.constants import ( | |
| ALT_TERMS_RE, | |
| ASK_LIBRARIAN_URL, | |
| BOOLEAN_STRATEGY_RE, | |
| CITATION_CHAIN_RE, | |
| DUAL_RESOURCE_RE, | |
| EVENTS_RE, | |
| FULLTEXT_REQUEST_RE, | |
| HIGHLY_CITED_RE, | |
| LIBRARY_HOURS_URL, | |
| PARTIAL_TITLE_RE, | |
| PREDATORY_RE, | |
| PURE_GREETING_RE, | |
| THESES_RE, | |
| _INJECTION_RE, | |
| ) | |
| from src.agentcore.access_routing import ACCESS_RETRIEVAL_HINT, _is_access_problem | |
| from src.agentcore.utils import ( | |
| _escape, | |
| _isbn_search_value, | |
| _get_runtime_config, | |
| _light_strip_retrieval_boilerplate, | |
| _normalize_whitespace, | |
| _primo_clean_url, | |
| _safe_metrics_bucket, | |
| _safe_metrics_increment, | |
| _title_case_topic, | |
| ) | |
| from src.agentcore.classify import ( | |
| _is_greeting_menu_followup, | |
| _is_summary_request, | |
| _llm_classify, | |
| _looks_campus_question, | |
| _looks_library_hours_question, | |
| ) | |
| from src.agentcore.libcal import _events_answer, _hours_answer | |
| from src.agentcore.rendering import ( | |
| _ai_tools_footer, | |
| _contact_footer_for_query, | |
| _search_trace_block, | |
| _source_badge, | |
| _thought_block, | |
| _tool_urls, | |
| ) | |
| from src.agentcore.intents_library import ( | |
| _campus_answer, | |
| _catalogue_topic, | |
| _rag_retrieval_query, | |
| _database_recommendation_answer, | |
| _fulltext_chain_answer, | |
| _greeting_menu_clarify_answer, | |
| _is_database_recommendation_question, | |
| _library_follow_up, | |
| _rag_answer, | |
| _theses_answer, | |
| ) | |
| from src.agentcore.intents_search import ( | |
| _alternative_terms_answer, | |
| _apply_follow_up_action, | |
| _citation_chain_answer, | |
| _context_to_dict, | |
| _derive_resource_type, | |
| _detect_follow_up, | |
| _extract_topic, | |
| _highly_cited_note, | |
| _parse_year_filters, | |
| _predatory_eval_answer, | |
| _prepare_queries, | |
| _research_snapshot, | |
| _run_search_mode, | |
| _search_follow_up, | |
| _search_strategy_answer, | |
| ) | |
| from src.agentcore.intents_general import ( | |
| _general_follow_up, | |
| _libbee_casual_response, | |
| _social_follow_up, | |
| _web_search_result, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| _EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+") | |
| _LONG_DIGITS_RE = re.compile(r"\d{6,}") | |
| _TAG_RE = re.compile(r"<[^>]+>") | |
| # Strong references to in-flight analytics POSTs. | |
| # | |
| # asyncio keeps only WEAK references to tasks: a task created with | |
| # create_task() and then dropped can be garbage-collected before it finishes, | |
| # silently cancelling the request. That is invisible when the network is fast | |
| # and drops writes unpredictably when the handshake is slow. Holding a | |
| # reference until the task completes is the documented fix. | |
| _ANALYTICS_TASKS = set() | |
| def _spawn_background(coro) -> bool: | |
| """Run coro in the background, keeping a strong reference. True if scheduled.""" | |
| try: | |
| loop = asyncio.get_running_loop() | |
| except RuntimeError: | |
| try: | |
| loop = asyncio.get_event_loop() | |
| except Exception: | |
| return False | |
| if not loop.is_running(): | |
| return False | |
| task = loop.create_task(coro) | |
| _ANALYTICS_TASKS.add(task) | |
| task.add_done_callback(_ANALYTICS_TASKS.discard) | |
| return True | |
| def _redact_for_analytics(text: str, max_len: int = 200) -> str: | |
| """Privacy pass before anything reaches the analytics store: e-mail | |
| addresses and long digit runs (IDs, phone numbers) are masked and the | |
| text is truncated. Raw personal identifiers must never be persisted.""" | |
| text = _EMAIL_RE.sub("[email]", text or "") | |
| text = _LONG_DIGITS_RE.sub("[number]", text) | |
| return text[:max_len] | |
| def _answer_excerpt(answer_html: str, max_len: int = 800) -> str: | |
| """Plain-text excerpt of the rendered answer for offline relevance grading | |
| by the daily KB-gap agent. HTML is stripped and the same redaction applied.""" | |
| text = _TAG_RE.sub(" ", answer_html or "") | |
| text = re.sub(r"\s+", " ", text).strip() | |
| return _redact_for_analytics(text, max_len) | |
| # ββ Analytics retry buffer βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Transient failures reaching the Worker (rate limiting, connection resets) | |
| # previously meant the row was lost for good. Failed payloads are parked here | |
| # and re-attempted on subsequent queries, so a blocked window costs a delay | |
| # rather than data. Bounded so a long outage cannot grow memory without limit. | |
| _PENDING_LOGS: "deque" = deque(maxlen=200) | |
| _PENDING_DROPPED = {"count": 0} | |
| # Rolling record of the last few analytics-write attempts, so the outcome can be | |
| # inspected through /admin/log-status without access to container logs. | |
| _LOG_ATTEMPTS: "deque" = deque(maxlen=15) | |
| def _record_attempt(outcome: str, detail: str = "") -> None: | |
| _LOG_ATTEMPTS.append({ | |
| "at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), | |
| "outcome": outcome, | |
| "detail": detail[:200], | |
| }) | |
| def analytics_log_status() -> dict: | |
| """Snapshot of the analytics write path for the admin dashboard.""" | |
| return { | |
| "pending_buffered": len(_PENDING_LOGS), | |
| "buffer_capacity": _PENDING_LOGS.maxlen, | |
| "recent_attempts": list(_LOG_ATTEMPTS)[::-1], | |
| } | |
| async def _send_with_retry(worker_url: str, token: str, payload: dict, | |
| attempts: int = 2, backoff: float = 2.0): | |
| """_send_log_payload with one retry after a short backoff. Connectivity to | |
| workers.dev from this host drops for short windows; a single immediate | |
| retry recovers a meaningful fraction of those without involving the buffer.""" | |
| last_exc = None | |
| for i in range(attempts): | |
| try: | |
| return await _send_log_payload(worker_url, token, payload) | |
| except Exception as exc: | |
| last_exc = exc | |
| if i + 1 < attempts: | |
| await asyncio.sleep(backoff) | |
| raise last_exc | |
| async def _send_log_payload(worker_url: str, token: str, payload: dict): | |
| """POST one analytics payload. | |
| Returns (status_code, report) where report is the Worker's own JSON verdict. | |
| This matters because the Worker answers HTTP 200 even when the D1 INSERT | |
| itself failed, reporting the problem in a 'warning' field - so a status code | |
| alone cannot distinguish a stored row from a dropped one. | |
| """ | |
| from src.services.http_client import make_client as _mk | |
| headers = {"Authorization": f"Bearer {token}"} if token else {} | |
| async with _mk(timeout=10) as client: | |
| resp = await client.post(worker_url.rstrip("/") + "/log", | |
| json=payload, headers=headers) | |
| report = {} | |
| try: | |
| body = resp.json() | |
| if isinstance(body, dict): | |
| report = {k: body.get(k) for k in ("status", "logged", "schema", "warning") | |
| if body.get(k) is not None} | |
| except Exception: | |
| report = {"raw": resp.text[:120]} | |
| return resp.status_code, report | |
| _FLUSHER_STARTED = {"on": False} | |
| def _ensure_periodic_flusher(worker_url: str, token: str) -> None: | |
| """Start (once) a background loop that retries buffered rows every 90s, so | |
| parked writes land as soon as connectivity returns instead of waiting for | |
| the next user query. The task holds a strong reference via _spawn_background. | |
| Lost on container restart - like the buffer itself - which the keep-alive | |
| pings are there to make rare.""" | |
| if _FLUSHER_STARTED["on"]: | |
| return | |
| async def _loop(): | |
| while True: | |
| await asyncio.sleep(90) | |
| if _PENDING_LOGS: | |
| try: | |
| n = await _flush_pending(worker_url, token, max_items=10) | |
| if n: | |
| _record_attempt("flushed", f"{n} buffered row(s) delivered") | |
| except Exception: | |
| pass | |
| if _spawn_background(_loop()): | |
| _FLUSHER_STARTED["on"] = True | |
| logger.info("Analytics flusher started (90s interval)") | |
| async def _flush_pending(worker_url: str, token: str, max_items: int = 5): | |
| """Re-attempt parked payloads oldest-first; stop at the first failure so a | |
| still-blocked Worker is not hammered.""" | |
| sent = 0 | |
| while _PENDING_LOGS and sent < max_items: | |
| payload = _PENDING_LOGS[0] | |
| try: | |
| status, report = await _send_log_payload(worker_url, token, payload) | |
| except Exception: | |
| return sent | |
| if status >= 400 or report.get("logged") is False: | |
| return sent | |
| _PENDING_LOGS.popleft() | |
| sent += 1 | |
| if sent: | |
| logger.info(f"D1 logging: flushed {sent} buffered row(s); " | |
| f"{len(_PENDING_LOGS)} still pending") | |
| return sent | |
| def _log_agent_query(question: str, response: AgentResponse, start_time: float) -> None: | |
| """Keep only local operational counters. | |
| Persistent analytics is written by the Cloudflare gateway after it receives | |
| the origin response. This prevents duplicate rows and removes the old public | |
| /log dependency. | |
| """ | |
| elapsed = time.time() - start_time | |
| intent = response.intent or "" | |
| tool = ", ".join(response.tools_used or [])[:60] | |
| model_used = response.model_used or "" | |
| result_count = len(response.search_results or []) | |
| try: | |
| from app import get_metrics_service | |
| get_metrics_service().log_query( | |
| question=_redact_for_analytics(question), | |
| intent=intent, | |
| tool=tool, | |
| model=model_used, | |
| response_time=elapsed, | |
| result_count=result_count, | |
| ) | |
| except Exception: | |
| pass | |
| async def _library_rag_response( | |
| question: str, | |
| history, | |
| model: str, | |
| start_time: float, | |
| *, | |
| retrieval_hint: str = "", | |
| top_k: int = 4, | |
| extra_tools: Tuple[str, ...] = (), | |
| ) -> AgentResponse: | |
| """Answer a library_info question from the knowledge base (RAG). | |
| Used for classifier-routed library_info questions and, with a retrieval | |
| hint, for access problems detected before classification. | |
| """ | |
| query = _rag_retrieval_query(question, history) | |
| if retrieval_hint: | |
| query = f"{query} {retrieval_hint}" | |
| rag_results = [] | |
| try: | |
| from app import get_rag_service | |
| rag_results = await get_rag_service().hybrid_search(query, top_k=top_k) | |
| except Exception as e: | |
| logger.error(f"RAG search failed: {e}") | |
| if not rag_results: | |
| answer = ( | |
| "I couldn't find specific information on that in my knowledge base.<br><br>" | |
| "Please contact our librarians directly:<br>" | |
| 'π§ <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a><br>' | |
| f'π <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>' | |
| ) | |
| answer += _source_badge("ku_library", ASK_LIBRARIAN_URL) | |
| sources = [] | |
| tools_used = ["rag_no_results"] | |
| thought = _thought_block("library_info", "rag_no_results", question, | |
| "No matching content was found β I am directing you to Ask a Librarian.") | |
| else: | |
| answer = await _rag_answer(question, rag_results, history, model) | |
| answer += _source_badge("rag", "https://library.ku.ac.ae/") | |
| sources = list({ | |
| r["source"]: {"title": r.get("title", "Library KB"), "url": r["source"]} | |
| for r in rag_results if r.get("source") | |
| }.values()) | |
| retrieval_mode = rag_results[0].get("retrieval_mode", "hybrid") | |
| tools_used = ["rag_hybrid_search" if retrieval_mode == "hybrid" else "rag_keyword_search"] | |
| thought = _thought_block("rag_search", tools_used[0], question, | |
| f"The {retrieval_mode} search returned {len(rag_results)} matching passages.") | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=thought + answer, | |
| intent="library_info", | |
| tools_used=list(extra_tools) + tools_used, | |
| sources=sources, | |
| ask_librarian=not bool(rag_results), | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| async def _agent_query_inner( | |
| question: str, | |
| history, | |
| model: str, | |
| client_state, | |
| start_time: float, | |
| ) -> AgentResponse: | |
| settings = get_settings() | |
| if model == "gpt" and not settings.openai_api_key and settings.anthropic_api_key: | |
| model = "claude" | |
| elif model == "claude" and not settings.anthropic_api_key and settings.openai_api_key: | |
| model = "gpt" | |
| # ββ Prompt injection detection ββββββββββββββββββββββββββββββββββββββββββββ | |
| if question and _INJECTION_RE.search(question): | |
| _safe_metrics_bucket("intents", "injection_blocked") | |
| return AgentResponse( | |
| answer=( | |
| "I'm sorry, but I can't process that request. " | |
| "I'm LibBee, the KU Library AI Assistant β here to help with " | |
| "library services, research, and academic resources. " | |
| f'How can I help you today? <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>' | |
| ), | |
| intent="blocked", | |
| tools_used=["injection_guard"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| ) | |
| if not question: | |
| follow_up_question, follow_up_suggestions = _social_follow_up() | |
| return AgentResponse( | |
| answer="Please type a question and I'll do my best to help!" + _source_badge("libbee"), | |
| intent="social", | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if PURE_GREETING_RE.match(question): | |
| follow_up_question, follow_up_suggestions = _social_follow_up() | |
| return AgentResponse( | |
| answer=( | |
| _get_runtime_config().get("welcome_message", "").strip() | |
| or ( | |
| "Hi! I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant.<br><br>" | |
| "I can help you find articles and books, search databases, access full text, " | |
| "request Interlibrary Loan (ILL), and answer questions about library services and staff." | |
| ) | |
| + _source_badge("libbee") | |
| ), | |
| intent="social_greeting", | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| staff_match = match_staff_name(question) if should_attempt_staff_lookup(question) else None | |
| if staff_match: | |
| _safe_metrics_bucket("intents", "staff_lookup") | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("staff_lookup", "staff_name_match", question) + staff_name_answer(staff_match) + _source_badge("ku_library", "https://library.ku.ac.ae/librarystaff"), | |
| intent="library_info", | |
| tools_used=["staff_name_match"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None | |
| if staff_role_match: | |
| _safe_metrics_bucket("intents", "staff_lookup") | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| 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/librarystaff"), | |
| intent="library_info", | |
| tools_used=["staff_role_match"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if _is_greeting_menu_followup(question, history): | |
| follow_up_question, follow_up_suggestions = _social_follow_up() | |
| return AgentResponse( | |
| answer=_thought_block("social_greeting", "greeting_menu", question) + _greeting_menu_clarify_answer() + _source_badge("libbee"), | |
| intent="social_greeting", | |
| is_follow_up=True, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if _looks_library_hours_question(question): | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| hours_html = await _hours_answer() | |
| return AgentResponse( | |
| answer=_thought_block("hours", "libcal_live", question) + hours_html + _source_badge("ku_library", LIBRARY_HOURS_URL), | |
| intent="library_info", | |
| tools_used=["libcal_hours_lookup"], | |
| ask_librarian=False, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if EVENTS_RE.search(question): | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| events_html = await _events_answer() | |
| return AgentResponse( | |
| answer=_thought_block("events", "libcal_events", question) | |
| + events_html + _source_badge("ku_library", "https://kustar.libcal.com/calendar/events"), | |
| intent="library_info", | |
| tools_used=["libcal_events_lookup"], | |
| ask_librarian=False, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if _looks_campus_question(question): | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("campus", "campus_hardcoded", question) + _campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"), | |
| intent="library_info", | |
| tools_used=["campus_hardcoded"], | |
| ask_librarian=False, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| # ββ E-resource access problems β knowledge base βββββββββββββββββββββββββββ | |
| # Checked before the database-recommendation and full-text handlers, whose | |
| # regexes would otherwise catch "access to databases off campus" or | |
| # "can't access ScienceDirect". Skips the LLM classifier entirely. | |
| if _is_access_problem(question): | |
| _safe_metrics_bucket("intents", "access_problem") | |
| return await _library_rag_response( | |
| question, history, model, start_time, | |
| retrieval_hint=ACCESS_RETRIEVAL_HINT, top_k=5, | |
| extra_tools=("access_problem_rag",), | |
| ) | |
| if _is_database_recommendation_question(question): | |
| answer = _database_recommendation_answer(question) | |
| answer += _contact_footer_for_query(question, None) | |
| answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources") | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("database_rec", "database_recommendation", question) + answer, | |
| intent="library_info", | |
| tools_used=["database_recommendation"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state) | |
| # ββ Catalogue availability ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _CATALOGUE_RE = re.compile( | |
| r"\b(" | |
| r"available in (the )?(ku|khalifa) library" | |
| r"|in (the )?(ku|khalifa) library" | |
| r"|does (the )?(ku|khalifa) library have" | |
| r"|does (the )?library (have|carry|stock|hold)" | |
| r"|(ku|khalifa) library (has|have|carry|holds?|stocks?)" | |
| r"|ku library (books?|copies|titles?)" | |
| r")\b", | |
| re.IGNORECASE, | |
| ) | |
| _BOOK_RE = re.compile(r"\b(e[- ]?books?|text[- ]?books?|books?|copy|copies|titles?)\b", re.IGNORECASE) | |
| catalogue_books_request = bool(_CATALOGUE_RE.search(question) and _BOOK_RE.search(question)) | |
| if is_follow_up and base_context: | |
| _safe_metrics_increment("follow_up_hits") | |
| context = _apply_follow_up_action(base_context, follow_up_action) | |
| if follow_up_action == "alt_terms": | |
| alt_answer = await _alternative_terms_answer(question, base_context, model) | |
| follow_up_question, follow_up_suggestions = await _search_follow_up(base_context, model) | |
| return AgentResponse( | |
| answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"), | |
| intent="library_info", | |
| tools_used=["alt_terms"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| search_context=_context_to_dict(base_context), | |
| is_follow_up=True, | |
| ) | |
| if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}: | |
| context = await _prepare_queries(question, context, model, True) | |
| if follow_up_action == "summarize_topic": | |
| answer, search_results, sources = await _research_snapshot(context, model) | |
| else: | |
| answer = ( | |
| f"{_search_trace_block(question, context)}" | |
| f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>" | |
| f"Here are the best AI tools and resources for a deep dive on this topic." | |
| + _source_badge("libbee") | |
| + _ai_tools_footer(context) | |
| ) | |
| search_results = [] | |
| sources = [] | |
| follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True) | |
| _safe_metrics_increment("search_handoffs") | |
| return AgentResponse( | |
| answer=answer, | |
| intent=context.intent, | |
| tools_used=["follow_up_resolution", follow_up_action], | |
| search_results=search_results, | |
| sources=sources, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| corrected_query=context.topic, | |
| natural_query=context.ai_tool_query, | |
| database_query=context.primo_boolean_query, | |
| ai_tool_query=context.ai_tool_query, | |
| primo_boolean_query=context.primo_boolean_query, | |
| primo_search_url=_primo_clean_url(context), | |
| primo_ai_url=_tool_urls(context).get("primo_ai"), | |
| pubmed_search_url=_tool_urls(context).get("pubmed"), | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| search_context=_context_to_dict(context), | |
| is_follow_up=True, | |
| ) | |
| context = await _prepare_queries(question, context, model, True) | |
| search_output, follow_up_output = await asyncio.gather( | |
| _run_search_mode(question, context, model, True), | |
| _search_follow_up(context, model), | |
| ) | |
| answer, search_results, sources, source_url = search_output | |
| follow_up_question, follow_up_suggestions = follow_up_output | |
| if follow_up_action == "review_articles_only": | |
| answer += "<br><br>Use the destination databaseβs publication-type filter to select review articles. A review article and a peer-reviewed article are different categories." | |
| return AgentResponse( | |
| answer=answer, | |
| intent=context.intent, | |
| tools_used=["follow_up_resolution", follow_up_action or "contextual_refinement"] + (["primo_api_search"] if context.source == "primo" and search_results else []), | |
| search_results=search_results, | |
| sources=sources, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| corrected_query=context.topic, | |
| natural_query=context.ai_tool_query, | |
| database_query=context.primo_boolean_query, | |
| ai_tool_query=context.ai_tool_query, | |
| primo_boolean_query=context.primo_boolean_query, | |
| primo_search_url=_primo_clean_url(context), | |
| primo_ai_url=_tool_urls(context).get("primo_ai"), | |
| pubmed_search_url=_tool_urls(context).get("pubmed"), | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| search_context=_context_to_dict(context), | |
| is_follow_up=True, | |
| ) | |
| # ββ Pre-classifier specialist handlers βββββββββββββββββββββββββββββββββββ | |
| if FULLTEXT_REQUEST_RE.search(question): | |
| ft_answer = _fulltext_chain_answer(question) | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("library_info", "fulltext_chain", question, | |
| "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/"), | |
| intent="library_info", | |
| tools_used=["fulltext_chain"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if THESES_RE.search(question): | |
| th_answer = _theses_answer() | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("library_info", "theses_guide", question, | |
| "Identified a theses/repository question.") + th_answer + _source_badge("ku_library", "https://khazna.ku.ac.ae"), | |
| intent="library_info", | |
| tools_used=["theses_guide"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if PREDATORY_RE.search(question): | |
| pr_answer = _predatory_eval_answer() | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("library_info", "predatory_eval", question, | |
| "Identified a question about evaluating journals/peer review.") + pr_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"), | |
| intent="library_info", | |
| tools_used=["predatory_eval"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if CITATION_CHAIN_RE.search(question): | |
| cc_answer = _citation_chain_answer() | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("library_info", "citation_chain", question, | |
| "Identified a citation tracing / forward-backward search question.") + cc_answer + _source_badge("ku_library", "https://library.ku.ac.ae/eresources"), | |
| intent="library_info", | |
| tools_used=["citation_chain"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if BOOLEAN_STRATEGY_RE.search(question): | |
| strategy_answer, _plan = await _search_strategy_answer(question, model) | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("library_info", "search_strategy", question, | |
| "Identified a search strategy / boolean building request.") + strategy_answer + _source_badge("libbee"), | |
| intent="library_info", | |
| tools_used=["search_strategy_explain"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| database_query=_plan.get("boolean"), | |
| natural_query=_plan.get("natural"), | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if ALT_TERMS_RE.search(question): | |
| alt_answer = await _alternative_terms_answer(question, None, model) | |
| follow_up_question, follow_up_suggestions = _library_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block("library_info", "alt_terms", question) + alt_answer + _source_badge("libbee"), | |
| intent="library_info", | |
| tools_used=["alt_terms"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| # Catalogue requests use the same planner, filters and records as subject searches. | |
| cls_result = ({"intent": "search_academic"} if catalogue_books_request or _isbn_search_value(question) | |
| else await _llm_classify(question, history, model)) | |
| intent = cls_result.get("intent", "general") | |
| _safe_metrics_bucket("intents", intent) | |
| casual_hint = cls_result.get("casual_answer", "") | |
| if intent == "social": | |
| answer = ( | |
| casual_hint | |
| if casual_hint and len(casual_hint.strip()) > 20 | |
| else await _libbee_casual_response(question, history, model, casual_hint) | |
| ) | |
| follow_up_question, follow_up_suggestions = _social_follow_up() | |
| return AgentResponse( | |
| answer=_thought_block("social", "libbee_casual", question) + answer + _source_badge("libbee"), | |
| intent="social_greeting", | |
| tools_used=["libbee_casual"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if intent == "sensitive": | |
| _safe_metrics_bucket("intents", "sensitive_blocked") | |
| follow_up_question, follow_up_suggestions = _social_follow_up() | |
| return AgentResponse( | |
| answer=( | |
| "That's not something I'm able to comment on. " | |
| "As the KU Library AI Assistant, I'm here to support your academic " | |
| "and research needs β finding articles, accessing databases, " | |
| "library services, and research tools.<br><br>" | |
| "Is there something I can help you with today? " | |
| f'<a href="{ASK_LIBRARIAN_URL}" target="_blank">π¬ Ask a Librarian</a>' | |
| ), | |
| intent="sensitive_blocked", | |
| tools_used=["sensitive_intent_guard"], | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |
| if intent == "library_info": | |
| return await _library_rag_response(question, history, model, start_time) | |
| if intent in ("search_academic", "search_medical"): | |
| topic = await _extract_topic(question, model) | |
| topic = _title_case_topic(topic) | |
| year_from, year_to = _parse_year_filters(question) | |
| context = SearchContextPayload( | |
| context_id=str(uuid.uuid4()), | |
| topic=topic, | |
| display_topic=topic, | |
| intent=intent, | |
| source="pubmed" if re.search(r"\bpubmed\b", question, re.I) else "primo", | |
| resource_type="books" if catalogue_books_request else _derive_resource_type(question), | |
| peer_reviewed=bool(re.search(r"\bpeer[- ]reviewed\b", question, re.IGNORECASE)), | |
| open_access=bool(re.search(r"\bopen access\b", question, re.IGNORECASE)), | |
| year_from=year_from, | |
| year_to=year_to, | |
| ) | |
| context = await _prepare_queries(question, context, model, False) | |
| summary_mode = _is_summary_request(question) | |
| deep_research_mode = bool(re.search( | |
| r"\b(deep research|deep dive|systematic review|full literature review|exhaustive)\b", | |
| question, re.IGNORECASE, | |
| )) | |
| is_highly_cited = bool(HIGHLY_CITED_RE.search(question)) | |
| if summary_mode or deep_research_mode: | |
| snapshot_output, follow_up_output = await asyncio.gather( | |
| _research_snapshot(context, model), | |
| _search_follow_up(context, model, summary_mode=True), | |
| ) | |
| answer, search_results, sources = snapshot_output | |
| follow_up_question, follow_up_suggestions = follow_up_output | |
| if deep_research_mode and "Research guide" not in answer and "AI tools" not in answer: | |
| answer += f"<br><br>{_ai_tools_footer(context)}" | |
| if is_highly_cited: | |
| answer += _highly_cited_note(_escape(topic)) | |
| _safe_metrics_increment("search_handoffs") | |
| return AgentResponse( | |
| answer=answer, | |
| intent=intent, | |
| tools_used=["research_snapshot"] + (["primo_api_search"] if context.source == "primo" and search_results else []), | |
| search_results=search_results, | |
| sources=sources, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| corrected_query=context.topic, | |
| natural_query=context.ai_tool_query, | |
| database_query=context.primo_boolean_query, | |
| ai_tool_query=context.ai_tool_query, | |
| primo_boolean_query=context.primo_boolean_query, | |
| primo_search_url=_primo_clean_url(context), | |
| primo_ai_url=_tool_urls(context).get("primo_ai"), | |
| pubmed_search_url=_tool_urls(context).get("pubmed"), | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| search_context=_context_to_dict(context), | |
| ) | |
| search_output, follow_up_output = await asyncio.gather( | |
| _run_search_mode(question, context, model, False), | |
| _search_follow_up(context, model), | |
| ) | |
| answer, search_results, sources, source_url = search_output | |
| follow_up_question, follow_up_suggestions = follow_up_output | |
| if is_highly_cited: | |
| answer += _highly_cited_note(_escape(topic)) | |
| return AgentResponse( | |
| answer=answer, | |
| intent=intent, | |
| tools_used=["primo_browser_link" if context.source == "primo" else "pubmed_browser_link"] + (["primo_api_search" if context.source == "primo" else "scholarly_evidence"] if search_results else []), | |
| search_results=search_results, | |
| sources=sources, | |
| model_used=model, | |
| response_time=time.time() - start_time, | |
| corrected_query=context.topic, | |
| natural_query=context.ai_tool_query, | |
| database_query=context.primo_boolean_query, | |
| ai_tool_query=context.ai_tool_query, | |
| primo_boolean_query=context.primo_boolean_query, | |
| primo_search_url=_primo_clean_url(context), | |
| primo_ai_url=_tool_urls(context).get("primo_ai"), | |
| pubmed_search_url=_tool_urls(context).get("pubmed"), | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| search_context=_context_to_dict(context), | |
| ) | |
| # Preserve the provider's actual source metadata. A failed web tool must | |
| # never make an ordinary model answer appear live or source-verified. | |
| web_result = await _web_search_result(question, history, model) | |
| live = bool(web_result.get("live_search")) | |
| badge = "web_live" if live and intent == "general_recent" else "web" if live else "llm" | |
| approach = intent if live else "general_knowledge" | |
| answer = web_result["answer"] + _source_badge(badge) | |
| follow_up_question, follow_up_suggestions = _general_follow_up(question) | |
| return AgentResponse( | |
| answer=_thought_block(approach, "web_search" if live else "llm_fallback", question) + answer, | |
| intent="general_recent" if intent == "general_recent" else "general", | |
| tools_used=web_result.get("tools_used", ["llm_fallback"]), | |
| sources=web_result.get("sources", []), | |
| model_used=web_result.get("model_used", model), | |
| response_time=time.time() - start_time, | |
| follow_up_question=follow_up_question, | |
| follow_up_suggestions=follow_up_suggestions, | |
| ) | |