Upload 2 files
Browse files- src/api/agent.py +204 -953
- src/api/search.py +57 -26
src/api/agent.py
CHANGED
|
@@ -213,11 +213,6 @@ SUMMARY_RE = re.compile(
|
|
| 213 |
re.IGNORECASE,
|
| 214 |
)
|
| 215 |
|
| 216 |
-
BEST_DATABASES_RE = re.compile(
|
| 217 |
-
r"\b(best databases?|which databases?|which database|best database|recommended databases?)\b",
|
| 218 |
-
re.IGNORECASE,
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
MEDICAL_KEYWORDS = [
|
| 222 |
"medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
|
| 223 |
"anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
|
|
@@ -229,6 +224,11 @@ MEDICAL_KEYWORDS = [
|
|
| 229 |
"crispr", "gene therapy", "gene editing", "genomic medicine", "precision medicine",
|
| 230 |
]
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
REFINEMENT_STOP_WORDS = {
|
| 233 |
"make", "it", "them", "those", "these", "the", "a", "an", "for", "to", "only", "show",
|
| 234 |
"find", "give", "me", "please", "and", "with", "within", "limit", "narrow", "search",
|
|
@@ -478,7 +478,7 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
|
|
| 478 |
if m.role in ("user", "assistant")
|
| 479 |
]
|
| 480 |
ctx = " | ".join(ctx_parts) if ctx_parts else "none"
|
| 481 |
-
llm = _get_llm(model, temperature=0, max_tokens=
|
| 482 |
response = await llm.ainvoke([
|
| 483 |
{"role": "system", "content": _CLASSIFIER_SYSTEM},
|
| 484 |
{"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
|
|
@@ -496,930 +496,6 @@ async def _llm_classify(question: str, history: List[ChatMessage], model: str) -
|
|
| 496 |
return _rule_based_classify(question)
|
| 497 |
|
| 498 |
|
| 499 |
-
async def _libbee_casual_response(question: str, history: List[ChatMessage], model: str, hint: str = "") -> str:
|
| 500 |
-
if hint and len(hint.strip()) > 20:
|
| 501 |
-
return hint.strip()
|
| 502 |
-
settings = get_settings()
|
| 503 |
-
if not settings.openai_api_key:
|
| 504 |
-
return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant β always happy to help!"
|
| 505 |
-
try:
|
| 506 |
-
llm = _get_llm(model, temperature=0.6, max_tokens=160)
|
| 507 |
-
msgs = [{"role": "system", "content": (
|
| 508 |
-
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 509 |
-
"Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
|
| 510 |
-
"No markdown, no bullet points."
|
| 511 |
-
)}]
|
| 512 |
-
msgs.extend(_build_history_messages(history))
|
| 513 |
-
msgs.append({"role": "user", "content": question})
|
| 514 |
-
response = await llm.ainvoke(msgs)
|
| 515 |
-
return response.content.strip()
|
| 516 |
-
except Exception:
|
| 517 |
-
return "I'm <strong>LibBee</strong>, the Khalifa University Library AI Assistant β always happy to help!"
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
async def _rag_answer(question: str, rag_results: List[dict], history: List[ChatMessage], model: str) -> str:
|
| 521 |
-
settings = get_settings()
|
| 522 |
-
if not settings.openai_api_key:
|
| 523 |
-
return rag_results[0]["content"] if rag_results else ""
|
| 524 |
-
context = "\n\n---\n\n".join(r["content"] for r in rag_results)
|
| 525 |
-
system = (
|
| 526 |
-
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 527 |
-
"Answer the user's question using ONLY the context provided below. "
|
| 528 |
-
"Be concise, friendly, and use HTML <br> for line breaks where needed. "
|
| 529 |
-
f"If the context does not contain enough information, say so and suggest Ask a Librarian at {ASK_LIBRARIAN_URL}. "
|
| 530 |
-
"Never invent facts or URLs.\n\n"
|
| 531 |
-
f"CONTEXT:\n{context}"
|
| 532 |
-
)
|
| 533 |
-
try:
|
| 534 |
-
llm = _get_llm(model, temperature=0.2, max_tokens=380)
|
| 535 |
-
msgs = [{"role": "system", "content": system}]
|
| 536 |
-
msgs.extend(_build_history_messages(history))
|
| 537 |
-
msgs.append({"role": "user", "content": question})
|
| 538 |
-
response = await llm.ainvoke(msgs)
|
| 539 |
-
return response.content.strip()
|
| 540 |
-
except Exception as e:
|
| 541 |
-
logger.error(f"_rag_answer error: {e}")
|
| 542 |
-
return (
|
| 543 |
-
"I'm having trouble generating an answer right now. "
|
| 544 |
-
f'Please try <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>.'
|
| 545 |
-
)
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
async def _llm_general_answer(question: str, history: List[ChatMessage], model: str) -> str:
|
| 549 |
-
settings = get_settings()
|
| 550 |
-
if not settings.openai_api_key:
|
| 551 |
-
return (
|
| 552 |
-
"I'm <strong>LibBee</strong>, the KU Library AI Assistant. "
|
| 553 |
-
f'For general university information, visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 554 |
-
)
|
| 555 |
-
system = (
|
| 556 |
-
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 557 |
-
"Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
|
| 558 |
-
"KU means Khalifa University. If the topic is academic, briefly offer to help find library resources."
|
| 559 |
-
)
|
| 560 |
-
try:
|
| 561 |
-
llm = _get_llm(model, temperature=0.3, max_tokens=360)
|
| 562 |
-
msgs = [{"role": "system", "content": system}]
|
| 563 |
-
msgs.extend(_build_history_messages(history))
|
| 564 |
-
msgs.append({"role": "user", "content": question})
|
| 565 |
-
response = await llm.ainvoke(msgs)
|
| 566 |
-
return response.content.strip()
|
| 567 |
-
except Exception as e:
|
| 568 |
-
logger.error(f"_llm_general_answer error: {e}")
|
| 569 |
-
return f'Having trouble right now. Visit <a href="{KU_MAIN_URL}" target="_blank">ku.ac.ae</a>.'
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
async def _web_search_answer(question: str, history: List[ChatMessage], model: str) -> str:
|
| 573 |
-
settings = get_settings()
|
| 574 |
-
if model == "claude" and settings.anthropic_api_key:
|
| 575 |
-
try:
|
| 576 |
-
import anthropic
|
| 577 |
-
client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
| 578 |
-
response = client.messages.create(
|
| 579 |
-
model="claude-3-haiku-20240307",
|
| 580 |
-
max_tokens=520,
|
| 581 |
-
system=(
|
| 582 |
-
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 583 |
-
"Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
|
| 584 |
-
),
|
| 585 |
-
tools=[{"type": "web_search_20250305", "name": "web_search"}],
|
| 586 |
-
messages=[{"role": "user", "content": question}],
|
| 587 |
-
)
|
| 588 |
-
text = "".join(block.text for block in response.content if hasattr(block, "text"))
|
| 589 |
-
if text.strip():
|
| 590 |
-
return text.strip()
|
| 591 |
-
except Exception as e:
|
| 592 |
-
logger.warning(f"Claude web search failed: {e}")
|
| 593 |
-
|
| 594 |
-
if settings.openai_api_key:
|
| 595 |
-
try:
|
| 596 |
-
from openai import OpenAI
|
| 597 |
-
client = OpenAI(api_key=settings.openai_api_key)
|
| 598 |
-
response = client.responses.create(
|
| 599 |
-
model="gpt-4o-mini",
|
| 600 |
-
tools=[{"type": "web_search_preview"}],
|
| 601 |
-
instructions=(
|
| 602 |
-
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 603 |
-
"Answer using current web search. Be concise and factual. Use HTML <br> for line breaks."
|
| 604 |
-
),
|
| 605 |
-
input=question,
|
| 606 |
-
)
|
| 607 |
-
text = ""
|
| 608 |
-
for item in response.output:
|
| 609 |
-
if hasattr(item, "content"):
|
| 610 |
-
for block in item.content:
|
| 611 |
-
if hasattr(block, "text"):
|
| 612 |
-
text += block.text
|
| 613 |
-
if text.strip():
|
| 614 |
-
return text.strip()
|
| 615 |
-
except Exception as e:
|
| 616 |
-
logger.warning(f"GPT web search failed: {e}")
|
| 617 |
-
|
| 618 |
-
return await _llm_general_answer(question, history, model)
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
# ββ Topic Extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 622 |
-
|
| 623 |
-
def _extract_topic_regex(question: str) -> str:
|
| 624 |
-
"""Fast regex-based topic extraction β strips boilerplate prefixes and filter words."""
|
| 625 |
-
q = _normalize_whitespace(question)
|
| 626 |
-
prefixes = [
|
| 627 |
-
"can u give me a deep analysis of ", "can you give me a deep analysis of ",
|
| 628 |
-
"give me a deep analysis of ", "deep analysis of ",
|
| 629 |
-
"give me a summary of ", "give me a summary on ", "give me a summary about ",
|
| 630 |
-
"give me an overview of ", "give me an overview on ",
|
| 631 |
-
"give me a brief overview of ", "give me a brief summary of ",
|
| 632 |
-
"summarize the latest studies in ", "summarize latest studies in ",
|
| 633 |
-
"summarize the latest research on ", "summarize latest research on ",
|
| 634 |
-
"summarize recent studies in ", "summarize recent research on ",
|
| 635 |
-
"latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
|
| 636 |
-
"summarize ", "summarise ", "summary of ", "summary on ", "summary about ",
|
| 637 |
-
"overview of ", "overview on ", "brief overview of ", "brief summary of ",
|
| 638 |
-
"analysis of ", "analyze ", "analyse ", "explain ", "tell me about ",
|
| 639 |
-
"what is ", "what are ", "describe ", "review of ", "literature review on ",
|
| 640 |
-
"recent advances in ", "recent developments in ", "state of the art in ",
|
| 641 |
-
"state of research on ", "research overview of ", "research summary of ",
|
| 642 |
-
"find peer reviewed articles on ", "find peer reviewed papers on ",
|
| 643 |
-
"find articles on ", "find papers on ", "find books on ", "find articles about ",
|
| 644 |
-
"find papers about ", "search for articles on ", "search for papers on ",
|
| 645 |
-
"i need peer reviewed articles on ", "i need articles on ", "i need papers on ",
|
| 646 |
-
"i need books on ", "give me articles on ", "show me articles on ",
|
| 647 |
-
"get me articles on ", "look for articles on ", "articles on ", "papers on ",
|
| 648 |
-
"books on ", "research on ", "literature on ", "find ", "search for ",
|
| 649 |
-
"i need ", "give me ", "show me ", "get me ",
|
| 650 |
-
]
|
| 651 |
-
lower = q.lower()
|
| 652 |
-
for prefix in prefixes:
|
| 653 |
-
if lower.startswith(prefix):
|
| 654 |
-
q = q[len(prefix):]
|
| 655 |
-
break
|
| 656 |
-
# Strip trailing noise
|
| 657 |
-
q = re.sub(r"\s+(please|thanks|thank you)\.?$", "", q, flags=re.IGNORECASE)
|
| 658 |
-
q = re.sub(r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?)\b", "", q, flags=re.IGNORECASE)
|
| 659 |
-
# FIX: strip resource-type words that leaked through prefix stripping
|
| 660 |
-
q = _strip_resource_noise(q)
|
| 661 |
-
return _normalize_whitespace(q).strip(".?")
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
async def _extract_topic(question: str, model: str) -> str:
|
| 665 |
-
"""
|
| 666 |
-
LLM-based topic extraction.
|
| 667 |
-
CRITICAL: instructs LLM to never return resource-type words (articles/books/papers).
|
| 668 |
-
Falls back to regex if LLM unavailable or returns empty.
|
| 669 |
-
"""
|
| 670 |
-
settings = get_settings()
|
| 671 |
-
if not settings.openai_api_key:
|
| 672 |
-
return _extract_topic_regex(question)
|
| 673 |
-
try:
|
| 674 |
-
llm = _get_llm(model, temperature=0, max_tokens=32)
|
| 675 |
-
response = await llm.ainvoke([
|
| 676 |
-
{"role": "system", "content": (
|
| 677 |
-
"Extract only the core research topic from the user's question. "
|
| 678 |
-
"Return 2-7 keywords ONLY β no punctuation, no sentence, no explanation. "
|
| 679 |
-
"CRITICAL: do NOT include words like articles, papers, books, journals, "
|
| 680 |
-
"peer-reviewed, open-access, studies, or research in your output. "
|
| 681 |
-
"Those are filters, not topic words. Return ONLY the subject matter.\n"
|
| 682 |
-
"Examples:\n"
|
| 683 |
-
"'find articles on indian politics' β indian politics\n"
|
| 684 |
-
"'peer reviewed papers on climate change 2020' β climate change\n"
|
| 685 |
-
"'books on structural engineering UAE' β structural engineering UAE\n"
|
| 686 |
-
"'give me a summary of quantum physics advances' β quantum physics advances\n"
|
| 687 |
-
"'i need peer reviewed research on machine learning healthcare' β machine learning healthcare"
|
| 688 |
-
)},
|
| 689 |
-
{"role": "user", "content": question},
|
| 690 |
-
])
|
| 691 |
-
topic = response.content.strip().strip('"').strip("'").strip(".")
|
| 692 |
-
# Belt-and-braces: strip any noise the LLM snuck in anyway
|
| 693 |
-
topic = _strip_resource_noise(topic)
|
| 694 |
-
topic = _normalize_whitespace(topic).strip(".?")
|
| 695 |
-
if topic and len(topic) > 2:
|
| 696 |
-
logger.info(f"LLM topic: '{topic}' β '{question[:60]}'")
|
| 697 |
-
return topic
|
| 698 |
-
except Exception as e:
|
| 699 |
-
logger.warning(f"Topic extraction failed: {e}")
|
| 700 |
-
return _extract_topic_regex(question)
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
# ββ Query Building ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 704 |
-
|
| 705 |
-
def _derive_resource_type(question: str, existing: Optional[str] = None) -> str:
|
| 706 |
-
q = (question or "").lower()
|
| 707 |
-
if any(token in q for token in ["book", "books", "ebook", "ebooks"]):
|
| 708 |
-
return "books"
|
| 709 |
-
if any(token in q for token in ["article", "articles", "paper", "papers", "study", "studies", "journal", "journals"]):
|
| 710 |
-
return "articles"
|
| 711 |
-
return existing or "articles"
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
|
| 715 |
-
q = (question or "").lower()
|
| 716 |
-
match = re.search(r"\b(last|past)\s+(\d{1,2})\s+years?\b", q)
|
| 717 |
-
if match:
|
| 718 |
-
years = int(match.group(2))
|
| 719 |
-
return str(CURRENT_YEAR - years + 1), str(CURRENT_YEAR)
|
| 720 |
-
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)
|
| 721 |
-
if between:
|
| 722 |
-
return between.group(1), between.group(2)
|
| 723 |
-
years = re.findall(r"\b(19\d{2}|20\d{2})\b", q)
|
| 724 |
-
if len(years) >= 2:
|
| 725 |
-
return min(years), max(years)
|
| 726 |
-
if len(years) == 1 and any(word in q for word in ["since", "from", "after"]):
|
| 727 |
-
return years[0], str(CURRENT_YEAR)
|
| 728 |
-
return None, None
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
def _make_primo_boolean_query(context: SearchContextPayload) -> str:
|
| 732 |
-
"""
|
| 733 |
-
FIX: Build a clean boolean for display purposes ONLY.
|
| 734 |
-
Contains ONLY topic keywords β NO resource-type words.
|
| 735 |
-
Resource type is expressed as a PRIMO facet in search_primo(), never here.
|
| 736 |
-
"""
|
| 737 |
-
topic = _title_case_topic(context.display_topic or context.topic)
|
| 738 |
-
# Strip any remaining noise one final time
|
| 739 |
-
topic = _strip_resource_noise(topic)
|
| 740 |
-
if not topic:
|
| 741 |
-
topic = context.topic or "library search"
|
| 742 |
-
return f'"{_normalize_whitespace(topic)}"'
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
def _primo_clean_url(context: SearchContextPayload) -> str:
|
| 746 |
-
"""
|
| 747 |
-
Build the PRIMO discovery URL using the clean topic β matching exactly what the API searches.
|
| 748 |
-
"""
|
| 749 |
-
clean_topic = _strip_resource_noise(context.topic)
|
| 750 |
-
if not clean_topic:
|
| 751 |
-
clean_topic = context.topic
|
| 752 |
-
return f"{PRIMO_BASE_URL}&query=any,contains,{quote(clean_topic)}&lang=en&mode=advanced&offset=0"
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
def _topic_keywords(text: str) -> List[str]:
|
| 756 |
-
cleaned = _normalize_whitespace(re.sub(r"[^a-zA-Z0-9\s-]", " ", text or ""))
|
| 757 |
-
stop = {
|
| 758 |
-
"of", "on", "in", "the", "a", "an", "and", "or", "for", "to", "with", "by", "from", "at",
|
| 759 |
-
"is", "are", "was", "were", "be", "been", "have", "has", "had", "do", "does", "did", "will",
|
| 760 |
-
"would", "could", "should", "may", "its", "this", "that", "these", "those", "about", "impact",
|
| 761 |
-
"role", "effect", "use", "study", "analysis", "review", "what", "how", "why", "when", "where",
|
| 762 |
-
"which", "recent", "latest", "current", "summary", "summarize", "overview", "brief",
|
| 763 |
-
# FIX: resource-type words must never appear as keywords
|
| 764 |
-
"research", "studies", "articles", "article", "papers", "paper", "books", "book",
|
| 765 |
-
"journals", "journal", "publications", "publication",
|
| 766 |
-
}
|
| 767 |
-
tokens: List[str] = []
|
| 768 |
-
for word in cleaned.split():
|
| 769 |
-
word = word.strip("-").lower()
|
| 770 |
-
if not word or word in stop or len(word) < 3:
|
| 771 |
-
continue
|
| 772 |
-
if word not in tokens:
|
| 773 |
-
tokens.append(word)
|
| 774 |
-
return tokens[:6]
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
def _compose_ai_tool_query(question: str, context: SearchContextPayload, is_follow_up: bool) -> str:
|
| 778 |
-
"""Build the clean natural language query for AI tools (LeapSpace, Scopus AI, Consensus)."""
|
| 779 |
-
topic = context.display_topic or context.topic
|
| 780 |
-
topic = _title_case_topic(topic)
|
| 781 |
-
scope = context.resource_type
|
| 782 |
-
filters = []
|
| 783 |
-
if context.peer_reviewed:
|
| 784 |
-
filters.append("peer-reviewed")
|
| 785 |
-
if context.open_access:
|
| 786 |
-
filters.append("open-access")
|
| 787 |
-
if context.year_from and context.year_to:
|
| 788 |
-
filters.append(f"published between {context.year_from} and {context.year_to}")
|
| 789 |
-
elif context.year_from:
|
| 790 |
-
filters.append(f"published from {context.year_from} onward")
|
| 791 |
-
|
| 792 |
-
if is_follow_up:
|
| 793 |
-
if scope == "books":
|
| 794 |
-
base = f"Find books on {topic}"
|
| 795 |
-
elif scope == "both":
|
| 796 |
-
base = f"Find articles and books on {topic}"
|
| 797 |
-
else:
|
| 798 |
-
base = f"Find research articles on {topic}"
|
| 799 |
-
else:
|
| 800 |
-
base = _normalize_whitespace(question).strip(".?")
|
| 801 |
-
if len(base.split()) < 4:
|
| 802 |
-
base = f"Find research on {topic}"
|
| 803 |
-
|
| 804 |
-
if filters:
|
| 805 |
-
return f"{base} with {', '.join(filters)}."
|
| 806 |
-
return base.rstrip(".") + "."
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
async def _grammar_refine_query(text: str, model: str) -> str:
|
| 810 |
-
settings = get_settings()
|
| 811 |
-
if not settings.openai_api_key:
|
| 812 |
-
return _normalize_whitespace(text)
|
| 813 |
-
try:
|
| 814 |
-
llm = _get_llm(model, temperature=0, max_tokens=60)
|
| 815 |
-
response = await llm.ainvoke([
|
| 816 |
-
{"role": "system", "content": "Rewrite the user's search question in clear grammatical English. Keep the meaning exactly the same. Return one sentence only."},
|
| 817 |
-
{"role": "user", "content": text},
|
| 818 |
-
])
|
| 819 |
-
refined = _normalize_whitespace(response.content)
|
| 820 |
-
return refined or _normalize_whitespace(text)
|
| 821 |
-
except Exception:
|
| 822 |
-
return _normalize_whitespace(text)
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
def _context_to_dict(context: SearchContextPayload) -> dict:
|
| 826 |
-
return context.model_dump()
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
async def _prepare_queries(question: str, context: SearchContextPayload, model: str, is_follow_up: bool) -> SearchContextPayload:
|
| 830 |
-
"""Populate ai_tool_query and primo_boolean_query on the context."""
|
| 831 |
-
natural = _compose_ai_tool_query(question, context, is_follow_up)
|
| 832 |
-
natural = await _grammar_refine_query(natural, model)
|
| 833 |
-
context.ai_tool_query = natural
|
| 834 |
-
# FIX: boolean is now clean topic only β resource type goes to PRIMO as a facet
|
| 835 |
-
context.primo_boolean_query = _make_primo_boolean_query(context)
|
| 836 |
-
return context
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
# ββ Semantic Scholar + Summary ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 840 |
-
|
| 841 |
-
async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
|
| 842 |
-
"""Search Semantic Scholar. Returns only papers that have abstracts."""
|
| 843 |
-
# FIX: strip resource noise from topic before hitting external API
|
| 844 |
-
clean_topic = _strip_resource_noise(topic)
|
| 845 |
-
if not clean_topic:
|
| 846 |
-
clean_topic = topic
|
| 847 |
-
try:
|
| 848 |
-
params = {
|
| 849 |
-
"query": clean_topic,
|
| 850 |
-
"limit": limit,
|
| 851 |
-
"fields": "title,authors,year,venue,abstract,externalIds,openAccessPdf",
|
| 852 |
-
}
|
| 853 |
-
async with httpx.AsyncClient(timeout=15) as client:
|
| 854 |
-
r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
|
| 855 |
-
if r.status_code != 200:
|
| 856 |
-
logger.warning(f"Semantic Scholar {r.status_code} for '{clean_topic}'")
|
| 857 |
-
return []
|
| 858 |
-
data = r.json()
|
| 859 |
-
results = []
|
| 860 |
-
for paper in data.get("data", []):
|
| 861 |
-
abstract = paper.get("abstract") or ""
|
| 862 |
-
if not abstract or len(abstract) < 50:
|
| 863 |
-
continue
|
| 864 |
-
author_list = paper.get("authors") or []
|
| 865 |
-
first_author_last = author_list[0].get("name", "Unknown").split()[-1] if author_list else "Unknown"
|
| 866 |
-
year = paper.get("year") or "n.d."
|
| 867 |
-
paper_id = paper.get("paperId", "")
|
| 868 |
-
doi = (paper.get("externalIds") or {}).get("DOI", "")
|
| 869 |
-
pdf_url = (paper.get("openAccessPdf") or {}).get("url", "")
|
| 870 |
-
link = (
|
| 871 |
-
pdf_url
|
| 872 |
-
or (f"https://doi.org/{doi}" if doi else "")
|
| 873 |
-
or (f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else "#")
|
| 874 |
-
)
|
| 875 |
-
results.append({
|
| 876 |
-
"title": paper.get("title", "Untitled"),
|
| 877 |
-
"authors": (
|
| 878 |
-
", ".join(a.get("name", "") for a in author_list[:3])
|
| 879 |
-
+ (" et al." if len(author_list) > 3 else "")
|
| 880 |
-
),
|
| 881 |
-
"first_author": first_author_last,
|
| 882 |
-
"year": year,
|
| 883 |
-
"citation_key": f"{first_author_last}, {year}",
|
| 884 |
-
"venue": paper.get("venue", ""),
|
| 885 |
-
"abstract": abstract[:700],
|
| 886 |
-
"link": link,
|
| 887 |
-
"_source": "Semantic Scholar",
|
| 888 |
-
})
|
| 889 |
-
logger.info(f"Semantic Scholar: {len(results)} papers with abstracts for '{clean_topic}'")
|
| 890 |
-
return results
|
| 891 |
-
except Exception as e:
|
| 892 |
-
logger.error(f"Semantic Scholar search failed: {e}")
|
| 893 |
-
return []
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
|
| 897 |
-
"""
|
| 898 |
-
Generate a research summary with clickable in-text citations [Author, Year].
|
| 899 |
-
Each [N] in the LLM output is replaced with a hyperlink to the actual paper.
|
| 900 |
-
"""
|
| 901 |
-
settings = get_settings()
|
| 902 |
-
if not settings.openai_api_key or not papers:
|
| 903 |
-
return ""
|
| 904 |
-
|
| 905 |
-
context_text = ""
|
| 906 |
-
for i, paper in enumerate(papers, 1):
|
| 907 |
-
context_text += (
|
| 908 |
-
f"[{i}] {paper['citation_key']}: {paper['title']}\n"
|
| 909 |
-
f"Abstract: {paper['abstract']}\n\n"
|
| 910 |
-
)
|
| 911 |
-
|
| 912 |
-
system = (
|
| 913 |
-
"You are a research librarian writing a concise academic literature snapshot. "
|
| 914 |
-
"Using ONLY the numbered references provided, write a 4-6 sentence summary on the given topic. "
|
| 915 |
-
"Use in-text citations as [1], [2], [3] etc. referring to the numbered references. "
|
| 916 |
-
"Cite at least 4 different references. "
|
| 917 |
-
"Use HTML <br><br> for paragraph breaks. Do not use bullet points or markdown. "
|
| 918 |
-
"Only state what is explicitly supported by the abstracts. "
|
| 919 |
-
"Do NOT invent content."
|
| 920 |
-
)
|
| 921 |
-
|
| 922 |
-
try:
|
| 923 |
-
llm = _get_llm(model, temperature=0.2, max_tokens=560)
|
| 924 |
-
response = await llm.ainvoke([
|
| 925 |
-
{"role": "system", "content": system},
|
| 926 |
-
{"role": "user", "content": f"Topic: {topic}\n\nReferences:\n{context_text}\nWrite the summary."},
|
| 927 |
-
])
|
| 928 |
-
summary = response.content.strip()
|
| 929 |
-
|
| 930 |
-
# Replace [N] with clickable citation links β work backwards to avoid [1] matching inside [10]
|
| 931 |
-
for i in range(len(papers), 0, -1):
|
| 932 |
-
paper = papers[i - 1]
|
| 933 |
-
title_escaped = _escape(paper["title"])
|
| 934 |
-
cite_html = (
|
| 935 |
-
f'<a href="{paper["link"]}" target="_blank" '
|
| 936 |
-
f'title="{title_escaped}" '
|
| 937 |
-
f'style="color:#1e40af;font-weight:600;text-decoration:none">'
|
| 938 |
-
f'[{paper["citation_key"]}]</a>'
|
| 939 |
-
)
|
| 940 |
-
summary = summary.replace(f"[{i}]", cite_html)
|
| 941 |
-
return summary
|
| 942 |
-
except Exception as e:
|
| 943 |
-
logger.error(f"Summary generation failed: {e}")
|
| 944 |
-
return ""
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
|
| 948 |
-
"""Fallback: extract lead sentences from abstracts when LLM summary fails."""
|
| 949 |
-
if not papers:
|
| 950 |
-
return ""
|
| 951 |
-
statements: List[str] = []
|
| 952 |
-
for paper in papers[:4]:
|
| 953 |
-
sentences = re.split(r"(?<=[.!?])\s+", paper.get("abstract", ""))
|
| 954 |
-
lead = sentences[0].strip() if sentences else ""
|
| 955 |
-
if not lead:
|
| 956 |
-
continue
|
| 957 |
-
citation = (
|
| 958 |
-
f'<a href="{paper["link"]}" target="_blank" '
|
| 959 |
-
f'style="color:#1e40af;font-weight:600;text-decoration:none">'
|
| 960 |
-
f'[{_escape(paper["citation_key"])}]</a>'
|
| 961 |
-
)
|
| 962 |
-
statements.append(f"{_escape(lead)} {citation}")
|
| 963 |
-
if not statements:
|
| 964 |
-
return f"Recent literature on <strong>{_escape(topic)}</strong> is available in the cited papers below."
|
| 965 |
-
intro = f"Recent literature on <strong>{_escape(topic)}</strong>:<br><br>"
|
| 966 |
-
return intro + "<br><br>".join(statements[:4])
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
# ββ PRIMO / PubMed Search Wrappers ββββββββββββββββββββββββββββββββββββββββββββ
|
| 970 |
-
|
| 971 |
-
from src.api.search import SearchRequest, search_primo, search_pubmed
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
|
| 975 |
-
"""
|
| 976 |
-
FIX: now passes resource_type so the rtype FACET is correctly sent to PRIMO.
|
| 977 |
-
Topic is stripped of noise before hitting the API.
|
| 978 |
-
"""
|
| 979 |
-
settings = get_settings()
|
| 980 |
-
# FIX: strip resource noise from topic β belt-and-braces
|
| 981 |
-
clean_topic = _strip_resource_noise(context.topic)
|
| 982 |
-
if not clean_topic:
|
| 983 |
-
clean_topic = context.topic
|
| 984 |
-
|
| 985 |
-
result = await search_primo(
|
| 986 |
-
SearchRequest(
|
| 987 |
-
query=clean_topic,
|
| 988 |
-
source="primo",
|
| 989 |
-
limit=limit,
|
| 990 |
-
peer_reviewed=context.peer_reviewed,
|
| 991 |
-
open_access=context.open_access,
|
| 992 |
-
year_from=context.year_from,
|
| 993 |
-
year_to=context.year_to,
|
| 994 |
-
resource_type=context.resource_type, # FIX: was missing β resource type now goes as facet
|
| 995 |
-
),
|
| 996 |
-
settings.primo_api_key,
|
| 997 |
-
)
|
| 998 |
-
source_url = _primo_clean_url(context)
|
| 999 |
-
return result, source_url
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
|
| 1003 |
-
"""
|
| 1004 |
-
FIX: year_from/year_to now correctly wired through SearchRequest β mindate/maxdate in search.py.
|
| 1005 |
-
"""
|
| 1006 |
-
clean_topic = _strip_resource_noise(context.topic)
|
| 1007 |
-
if not clean_topic:
|
| 1008 |
-
clean_topic = context.topic
|
| 1009 |
-
|
| 1010 |
-
result = await search_pubmed(
|
| 1011 |
-
SearchRequest(
|
| 1012 |
-
query=clean_topic,
|
| 1013 |
-
source="pubmed",
|
| 1014 |
-
limit=limit,
|
| 1015 |
-
peer_reviewed=context.peer_reviewed,
|
| 1016 |
-
year_from=context.year_from, # FIX: now actually used in search.py
|
| 1017 |
-
year_to=context.year_to, # FIX: now actually used in search.py
|
| 1018 |
-
)
|
| 1019 |
-
)
|
| 1020 |
-
pubmed_url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(clean_topic)}"
|
| 1021 |
-
if context.year_from and context.year_to:
|
| 1022 |
-
pubmed_url += f"&filter=datesearch.y_custom&dates.from={context.year_from}&dates.to={context.year_to}"
|
| 1023 |
-
return result, pubmed_url
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
# ββ AI Tools / Database Guidance βββββββββββββββββββββββββββββββββββββββββββββ
|
| 1027 |
-
|
| 1028 |
-
def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
|
| 1029 |
-
ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
|
| 1030 |
-
clean_topic = _strip_resource_noise(context.topic)
|
| 1031 |
-
return {
|
| 1032 |
-
"leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
|
| 1033 |
-
"scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
|
| 1034 |
-
"consensus": f"https://consensus.app/results/?q={ai_query}",
|
| 1035 |
-
"primo_discovery": _primo_clean_url(context),
|
| 1036 |
-
}
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
def _ai_source_links_html(context: SearchContextPayload) -> str:
|
| 1040 |
-
urls = _tool_urls(context)
|
| 1041 |
-
return (
|
| 1042 |
-
f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> Β· '
|
| 1043 |
-
f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> Β· '
|
| 1044 |
-
f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a> Β· '
|
| 1045 |
-
f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
|
| 1046 |
-
)
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
def _database_guidance_for_topic(context: SearchContextPayload) -> str:
|
| 1050 |
-
topic = _escape(context.display_topic or context.topic)
|
| 1051 |
-
links = _ai_source_links_html(context)
|
| 1052 |
-
if context.intent == "search_medical":
|
| 1053 |
-
return (
|
| 1054 |
-
f"For a stronger evidence trail on <strong>{topic}</strong>, start with <strong>PubMed</strong> or <strong>Embase</strong> for the core medical literature, "
|
| 1055 |
-
f"then continue with {links}."
|
| 1056 |
-
)
|
| 1057 |
-
return (
|
| 1058 |
-
f"For deeper work on <strong>{topic}</strong>, continue with {links}. "
|
| 1059 |
-
"Use Scopus AI for broader scholarly mapping, LeapSpace for guided evidence exploration, "
|
| 1060 |
-
"Consensus for quick paper-grounded answers, and PRIMO Library Discovery for KU-accessible material."
|
| 1061 |
-
)
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
def _looks_database_recommendation_question(question: str) -> bool:
|
| 1065 |
-
q = (question or "").lower()
|
| 1066 |
-
return bool(BEST_DATABASES_RE.search(q)) and any(kw in q for kw in [
|
| 1067 |
-
"database", "databases", "subject", "engineering", "medicine", "medical", "ai", "computer",
|
| 1068 |
-
"physics", "chemistry", "chemical", "civil", "mechanical", "electrical", "business",
|
| 1069 |
-
"biomedical", "nursing", "systematic review", "health"
|
| 1070 |
-
])
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
def _database_recommendation_payload(question: str) -> Dict[str, Any]:
|
| 1074 |
-
q = (question or "").lower()
|
| 1075 |
-
general = {
|
| 1076 |
-
"title": "General / multidisciplinary research",
|
| 1077 |
-
"intro": "These are the best starting databases for broad academic searching at KU.",
|
| 1078 |
-
"items": [
|
| 1079 |
-
{"name": "PRIMO Library Discovery", "url": "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU", "why": "Best first stop for KU-accessible books, articles, journals, and holdings."},
|
| 1080 |
-
{"name": "Scopus", "url": "https://khalifa.idm.oclc.org/login?url=https://www.scopus.com", "why": "Broad multidisciplinary database for scholarly discovery and citation tracking."},
|
| 1081 |
-
{"name": "Web of Science", "url": "https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com", "why": "Strong citation database for high-level literature mapping and journal evaluation."},
|
| 1082 |
-
{"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Large full-text platform across science, engineering, and medicine."},
|
| 1083 |
-
],
|
| 1084 |
-
}
|
| 1085 |
-
subjects = [
|
| 1086 |
-
(["systematic review", "medicine", "medical", "health", "nursing", "clinical", "biomedical", "pharmacy"], {
|
| 1087 |
-
"title": "Medical and health sciences",
|
| 1088 |
-
"intro": "For medical evidence and subject searching, start with these databases.",
|
| 1089 |
-
"items": [
|
| 1090 |
-
{"name": "PubMed@KU", "url": "http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib", "why": "Core biomedical literature with KU full-text linking."},
|
| 1091 |
-
{"name": "Embase", "url": "https://khalifa.idm.oclc.org/login?url=https://www.embase.com", "why": "Excellent for biomedical, drugs, devices, and evidence searching."},
|
| 1092 |
-
{"name": "CINAHL Ultimate", "url": "https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul", "why": "Best for nursing and allied health literature."},
|
| 1093 |
-
{"name": "Cochrane Library", "url": "https://khalifa.idm.oclc.org/login?url=https://www.cochranelibrary.com", "why": "Key source for systematic reviews and evidence-based medicine."},
|
| 1094 |
-
{"name": "UpToDate", "url": "https://khalifa.idm.oclc.org/login?url=https://www.uptodate.com", "why": "Clinical point-of-care resource for evidence summaries."},
|
| 1095 |
-
],
|
| 1096 |
-
}),
|
| 1097 |
-
(["computer science", "machine learning", "artificial intelligence", " ai", "cyber", "software", "algorithm", "computer", "data science"], {
|
| 1098 |
-
"title": "Computer science and AI",
|
| 1099 |
-
"intro": "For computing, AI, software, and algorithms, these are the best starting points.",
|
| 1100 |
-
"items": [
|
| 1101 |
-
{"name": "ACM Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://dl.acm.org", "why": "Core computer science literature."},
|
| 1102 |
-
{"name": "IEEE Xplore", "url": "https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org", "why": "Strong for AI, computing systems, networks, and engineering."},
|
| 1103 |
-
{"name": "arXiv", "url": "https://arxiv.org", "why": "Useful for fast-moving preprints in AI and computer science."},
|
| 1104 |
-
{"name": "Scopus", "url": "https://khalifa.idm.oclc.org/login?url=https://www.scopus.com", "why": "Good for citation tracking and broader discovery."},
|
| 1105 |
-
],
|
| 1106 |
-
}),
|
| 1107 |
-
(["electrical", "electronics", "signals", "power systems", "communications"], {
|
| 1108 |
-
"title": "Electrical engineering",
|
| 1109 |
-
"intro": "For electrical engineering topics, start here.",
|
| 1110 |
-
"items": [
|
| 1111 |
-
{"name": "IEEE Xplore", "url": "https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org", "why": "Core database for electrical engineering literature and standards."},
|
| 1112 |
-
{"name": "INSPEC", "url": "https://khalifa.idm.oclc.org/login?url=https://www.theiet.org/publishing/inspec", "why": "Strong indexing for EE, electronics, communications, and computing."},
|
| 1113 |
-
{"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Good full-text coverage across electrical engineering and controls."},
|
| 1114 |
-
],
|
| 1115 |
-
}),
|
| 1116 |
-
(["mechanical", "thermo", "fluids", "manufacturing"], {
|
| 1117 |
-
"title": "Mechanical engineering",
|
| 1118 |
-
"intro": "For mechanical engineering, these are the strongest starts.",
|
| 1119 |
-
"items": [
|
| 1120 |
-
{"name": "ASME Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org", "why": "Core mechanical engineering journals and conference papers."},
|
| 1121 |
-
{"name": "Knovel", "url": "https://khalifa.idm.oclc.org/login?url=https://app.knovel.com", "why": "Engineering reference books and technical data."},
|
| 1122 |
-
{"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Strong full-text support for applied engineering topics."},
|
| 1123 |
-
],
|
| 1124 |
-
}),
|
| 1125 |
-
(["civil", "geotechnical", "transportation", "construction", "structures"], {
|
| 1126 |
-
"title": "Civil engineering",
|
| 1127 |
-
"intro": "For civil engineering and infrastructure topics, start here.",
|
| 1128 |
-
"items": [
|
| 1129 |
-
{"name": "ASCE Library", "url": "https://khalifa.idm.oclc.org/login?url=https://ascelibrary.org", "why": "Core civil engineering journals and conference papers."},
|
| 1130 |
-
{"name": "ASTM Compass", "url": "https://khalifa.idm.oclc.org/login?url=https://compass.astm.org", "why": "Important standards and testing information for materials and engineering."},
|
| 1131 |
-
{"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Useful full-text coverage across civil engineering subjects."},
|
| 1132 |
-
],
|
| 1133 |
-
}),
|
| 1134 |
-
(["aerospace", "aerodynamics", "propulsion", "aircraft"], {
|
| 1135 |
-
"title": "Aerospace engineering",
|
| 1136 |
-
"intro": "For aerospace topics, these are the best databases to begin with.",
|
| 1137 |
-
"items": [
|
| 1138 |
-
{"name": "AIAA Aerospace Research Central", "url": "https://khalifa.idm.oclc.org/login?url=https://arc.aiaa.org/", "why": "Core aerospace journals and conference literature."},
|
| 1139 |
-
{"name": "ASME Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org", "why": "Useful for thermal, mechanical, and fluid systems relevant to aerospace."},
|
| 1140 |
-
{"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Good full-text support for aerospace materials and systems."},
|
| 1141 |
-
],
|
| 1142 |
-
}),
|
| 1143 |
-
(["chemical engineering", "catalysis", "separations", "chemistry", "chemical"], {
|
| 1144 |
-
"title": "Chemistry and chemical engineering",
|
| 1145 |
-
"intro": "For chemistry and chemical engineering, these are the strongest starting points.",
|
| 1146 |
-
"items": [
|
| 1147 |
-
{"name": "ACS Publications", "url": "https://khalifa.idm.oclc.org/login?url=https://pubs.acs.org", "why": "Core chemistry journals."},
|
| 1148 |
-
{"name": "SciFindern", "url": "https://khalifa.idm.oclc.org/login?url=https://scifinder-n.cas.org", "why": "Best for substances, reactions, and chemistry references."},
|
| 1149 |
-
{"name": "Reaxys", "url": "https://khalifa.idm.oclc.org/login?url=https://www.reaxys.com", "why": "Useful for chemistry reactions and property searching."},
|
| 1150 |
-
{"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Strong full-text source for chemical engineering journals."},
|
| 1151 |
-
],
|
| 1152 |
-
}),
|
| 1153 |
-
(["physics", "quantum", "optics", "plasma"], {
|
| 1154 |
-
"title": "Physics",
|
| 1155 |
-
"intro": "For physics topics, start with these databases and platforms.",
|
| 1156 |
-
"items": [
|
| 1157 |
-
{"name": "APS Journals", "url": "https://khalifa.idm.oclc.org/login?url=https://journals.aps.org", "why": "Core American Physical Society journals."},
|
| 1158 |
-
{"name": "AIP", "url": "https://khalifa.idm.oclc.org/login?url=https://aip.scitation.org", "why": "Major physics and applied physics content."},
|
| 1159 |
-
{"name": "arXiv", "url": "https://arxiv.org", "why": "Open preprints in physics and related areas."},
|
| 1160 |
-
],
|
| 1161 |
-
}),
|
| 1162 |
-
(["business", "management", "marketing", "economics", "finance"], {
|
| 1163 |
-
"title": "Business and management",
|
| 1164 |
-
"intro": "For business-related topics, start with these databases.",
|
| 1165 |
-
"items": [
|
| 1166 |
-
{"name": "Business Source Complete", "url": "https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=bsh", "why": "Comprehensive business database."},
|
| 1167 |
-
{"name": "Emerald Insight", "url": "https://khalifa.idm.oclc.org/login?url=https://www.emerald.com/insight", "why": "Strong for management, marketing, and business journals."},
|
| 1168 |
-
{"name": "ProQuest Databases", "url": "https://khalifa.idm.oclc.org/login?url=https://search.proquest.com", "why": "Broad business and dissertation coverage."},
|
| 1169 |
-
],
|
| 1170 |
-
}),
|
| 1171 |
-
]
|
| 1172 |
-
for keywords, payload in subjects:
|
| 1173 |
-
if any(k in q for k in keywords):
|
| 1174 |
-
return payload
|
| 1175 |
-
return general
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
def _database_recommendation_answer(question: str) -> str:
|
| 1179 |
-
payload = _database_recommendation_payload(question)
|
| 1180 |
-
answer = f"<strong>Best databases for { _escape(payload['title']) }</strong><br><br>{_escape(payload['intro'])}<br>"
|
| 1181 |
-
for item in payload['items']:
|
| 1182 |
-
answer += (
|
| 1183 |
-
f'<br>β’ <a href="{item["url"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(item["name"])}</a><br>'
|
| 1184 |
-
f'<span style="font-size:.9rem;color:#4b5563">{_escape(item["why"])}</span>'
|
| 1185 |
-
)
|
| 1186 |
-
answer += (
|
| 1187 |
-
f'<br><br>You can also start in <a href="{PRIMO_BASE_URL}" target="_blank">PRIMO Library Discovery</a> '
|
| 1188 |
-
'and then narrow by resource type, date, and peer-reviewed content.'
|
| 1189 |
-
)
|
| 1190 |
-
answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
|
| 1191 |
-
return answer
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
# ββ Research Snapshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1195 |
-
|
| 1196 |
-
async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
|
| 1197 |
-
"""
|
| 1198 |
-
Full research snapshot:
|
| 1199 |
-
1. Semantic Scholar β cited summary with hyperlinked [Author, Year] citations
|
| 1200 |
-
2. PRIMO β KU-accessible results appended below
|
| 1201 |
-
"""
|
| 1202 |
-
topic = context.topic
|
| 1203 |
-
papers = await _semantic_scholar_search(topic, limit=8)
|
| 1204 |
-
references: List[dict] = []
|
| 1205 |
-
primo_result, primo_url = await _search_primo_catalog(context, limit=6)
|
| 1206 |
-
search_results = primo_result.get("results", []) or []
|
| 1207 |
-
|
| 1208 |
-
if papers:
|
| 1209 |
-
summary = await _generate_cited_summary(topic, papers, model)
|
| 1210 |
-
if not summary:
|
| 1211 |
-
_safe_metrics_increment("summary_fallbacks")
|
| 1212 |
-
summary = _extractive_snapshot_from_papers(topic, papers)
|
| 1213 |
-
|
| 1214 |
-
answer = f"<strong>π Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
|
| 1215 |
-
if search_results:
|
| 1216 |
-
answer += (
|
| 1217 |
-
"<br><br><strong>KU Library starting points:</strong> "
|
| 1218 |
-
"I also pulled KU-accessible results below so you can move directly from the snapshot to subscribed material."
|
| 1219 |
-
)
|
| 1220 |
-
answer += f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
|
| 1221 |
-
answer += "<br><br><strong>Selected references:</strong><br>"
|
| 1222 |
-
for i, paper in enumerate(papers[:6], 1):
|
| 1223 |
-
answer += (
|
| 1224 |
-
f'<br><strong>[{i}]</strong> '
|
| 1225 |
-
f'<a href="{paper["link"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(paper["title"])}</a><br>'
|
| 1226 |
-
f'<span style="font-size:.8rem;color:#6b7280">'
|
| 1227 |
-
f'{_escape(paper["authors"])} Β· {paper["year"]} Β· {_escape(paper["venue"] or "")}'
|
| 1228 |
-
f'</span>'
|
| 1229 |
-
)
|
| 1230 |
-
answer += _source_badge("semantic", "https://www.semanticscholar.org/")
|
| 1231 |
-
if search_results:
|
| 1232 |
-
answer += _source_badge("primo", primo_url)
|
| 1233 |
-
references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
|
| 1234 |
-
return answer, search_results, references
|
| 1235 |
-
|
| 1236 |
-
if search_results:
|
| 1237 |
-
answer = (
|
| 1238 |
-
f"<strong>π KU Library starting points on {_escape(topic)}</strong><br><br>"
|
| 1239 |
-
"I couldn't build a Semantic Scholar snapshot right now, but here are KU-accessible library results you can open immediately. "
|
| 1240 |
-
f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
|
| 1241 |
-
)
|
| 1242 |
-
else:
|
| 1243 |
-
answer = (
|
| 1244 |
-
f"I couldn't retrieve a research snapshot for <strong>{_escape(topic)}</strong> right now.<br><br>"
|
| 1245 |
-
f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
|
| 1246 |
-
)
|
| 1247 |
-
answer += _source_badge("primo", primo_url)
|
| 1248 |
-
return answer, search_results, []
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
# ββ Follow-up System ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1252 |
-
|
| 1253 |
-
def _filters_summary(context: SearchContextPayload) -> str:
|
| 1254 |
-
filters: List[str] = []
|
| 1255 |
-
if context.peer_reviewed:
|
| 1256 |
-
filters.append("peer reviewed only")
|
| 1257 |
-
if context.open_access:
|
| 1258 |
-
filters.append("open access")
|
| 1259 |
-
if context.year_from and context.year_to:
|
| 1260 |
-
filters.append(f"{context.year_from}β{context.year_to}")
|
| 1261 |
-
elif context.year_from:
|
| 1262 |
-
filters.append(f"from {context.year_from}")
|
| 1263 |
-
if context.resource_type == "books":
|
| 1264 |
-
filters.append("books")
|
| 1265 |
-
elif context.resource_type == "articles":
|
| 1266 |
-
filters.append("articles")
|
| 1267 |
-
elif context.resource_type == "both":
|
| 1268 |
-
filters.append("articles and books")
|
| 1269 |
-
return ", ".join(filters)
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
def _question_has_new_topic(question: str, base_topic: str) -> bool:
|
| 1273 |
-
q = (question or "").lower()
|
| 1274 |
-
cleaned = re.sub(
|
| 1275 |
-
r"\b(peer[- ]reviewed|open access|last \d+ years?|past \d+ years?|books? instead|articles? instead|"
|
| 1276 |
-
r"summari[sz]e( this topic)?|overview|brief|use pubmed|search pubmed|search primo|best databases?|deep research tools?)\b",
|
| 1277 |
-
" ", q,
|
| 1278 |
-
)
|
| 1279 |
-
tokens = [t for t in re.findall(r"[a-z0-9]+", cleaned) if t not in REFINEMENT_STOP_WORDS and len(t) > 2]
|
| 1280 |
-
if len(tokens) < 3:
|
| 1281 |
-
return False
|
| 1282 |
-
base_tokens = {t for t in re.findall(r"[a-z0-9]+", (base_topic or "").lower()) if t not in REFINEMENT_STOP_WORDS}
|
| 1283 |
-
overlap = sum(1 for t in tokens if t in base_tokens)
|
| 1284 |
-
return overlap <= max(1, min(2, len(base_tokens)))
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
def _parse_refinement_action(question: str) -> Optional[str]:
|
| 1288 |
-
q = (question or "").lower().strip()
|
| 1289 |
-
if re.search(r"\b(peer[- ]reviewed|peer reviewed only|peer reviewd|peer review only)\b", q):
|
| 1290 |
-
return "peer_reviewed_only"
|
| 1291 |
-
if re.search(r"\b(open access|oa only|only open access)\b", q):
|
| 1292 |
-
return "open_access_only"
|
| 1293 |
-
if re.search(r"\b(last|past)\s+5\s+years?\b", q):
|
| 1294 |
-
return "last_5_years"
|
| 1295 |
-
if re.search(r"\b(last|past)\s+10\s+years?\b|\bpast decade\b", q):
|
| 1296 |
-
return "last_10_years"
|
| 1297 |
-
if re.search(r"\bbooks? instead\b|\bbooks? only\b|\bfind books?\b|\bshow books?\b", q):
|
| 1298 |
-
return "books_only"
|
| 1299 |
-
if re.search(r"\barticles? instead\b|\barticles? only\b|\bpapers? only\b", q):
|
| 1300 |
-
return "articles_only"
|
| 1301 |
-
if re.search(r"\b(?:both|articles and books|books and articles)\b", q):
|
| 1302 |
-
return "both_resources"
|
| 1303 |
-
if re.search(r"\b(summar(y|ize|ise)|overview|brief|what does the literature say|research snapshot)\b", q):
|
| 1304 |
-
return "summarize_topic"
|
| 1305 |
-
if re.search(r"\bpubmed\b", q):
|
| 1306 |
-
return "search_pubmed"
|
| 1307 |
-
if re.search(r"\bprimo\b", q):
|
| 1308 |
-
return "search_primo"
|
| 1309 |
-
if re.search(r"\b(best database|best databases|which database|which databases)\b", q):
|
| 1310 |
-
return "best_databases"
|
| 1311 |
-
if re.search(r"\b(deep research|deep dive|full literature review|exhaustive)\b", q):
|
| 1312 |
-
return "deep_research_tools"
|
| 1313 |
-
return None
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
def _resolve_base_context(client_state: Optional[ClientStatePayload]) -> Optional[SearchContextPayload]:
|
| 1317 |
-
if not client_state or not client_state.recent_search_contexts:
|
| 1318 |
-
return None
|
| 1319 |
-
contexts = list(client_state.recent_search_contexts)
|
| 1320 |
-
if client_state.follow_up_context_id:
|
| 1321 |
-
for ctx in contexts:
|
| 1322 |
-
if ctx.context_id == client_state.follow_up_context_id:
|
| 1323 |
-
return ctx
|
| 1324 |
-
if client_state.active_search_context_id:
|
| 1325 |
-
for ctx in contexts:
|
| 1326 |
-
if ctx.context_id == client_state.active_search_context_id:
|
| 1327 |
-
return ctx
|
| 1328 |
-
contexts.sort(key=lambda c: c.created_at, reverse=True)
|
| 1329 |
-
return contexts[0]
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
def _detect_follow_up(question: str, client_state: Optional[ClientStatePayload]) -> Tuple[bool, Optional[str], Optional[SearchContextPayload]]:
|
| 1333 |
-
base_context = _resolve_base_context(client_state)
|
| 1334 |
-
if not base_context:
|
| 1335 |
-
return False, None, None
|
| 1336 |
-
explicit_action = (client_state.follow_up_action if client_state else None) or None
|
| 1337 |
-
if explicit_action:
|
| 1338 |
-
return True, explicit_action, base_context
|
| 1339 |
-
action = _parse_refinement_action(question)
|
| 1340 |
-
if not action:
|
| 1341 |
-
return False, None, None
|
| 1342 |
-
if _question_has_new_topic(question, base_context.topic):
|
| 1343 |
-
return False, None, None
|
| 1344 |
-
return True, action, base_context
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
def _clone_context(base_context: SearchContextPayload) -> SearchContextPayload:
|
| 1348 |
-
return SearchContextPayload.model_validate(base_context.model_dump())
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional[str]) -> SearchContextPayload:
|
| 1352 |
-
context = _clone_context(base_context)
|
| 1353 |
-
context.context_id = str(uuid.uuid4())
|
| 1354 |
-
context.created_at = time.time()
|
| 1355 |
-
if action == "peer_reviewed_only":
|
| 1356 |
-
context.peer_reviewed = True
|
| 1357 |
-
elif action == "open_access_only":
|
| 1358 |
-
context.open_access = True
|
| 1359 |
-
elif action == "last_5_years":
|
| 1360 |
-
context.year_from = str(CURRENT_YEAR - 4)
|
| 1361 |
-
context.year_to = str(CURRENT_YEAR)
|
| 1362 |
-
elif action == "last_10_years":
|
| 1363 |
-
context.year_from = str(CURRENT_YEAR - 9)
|
| 1364 |
-
context.year_to = str(CURRENT_YEAR)
|
| 1365 |
-
elif action == "books_only":
|
| 1366 |
-
context.resource_type = "books"
|
| 1367 |
-
elif action == "articles_only":
|
| 1368 |
-
context.resource_type = "articles"
|
| 1369 |
-
elif action == "both_resources":
|
| 1370 |
-
context.resource_type = "both"
|
| 1371 |
-
elif action == "search_pubmed":
|
| 1372 |
-
context.source = "pubmed"
|
| 1373 |
-
context.intent = "search_medical"
|
| 1374 |
-
context.resource_type = "articles"
|
| 1375 |
-
elif action == "search_primo":
|
| 1376 |
-
context.source = "primo"
|
| 1377 |
-
return context
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
|
| 1381 |
-
"""
|
| 1382 |
-
FIX: Generate 3 topic-specific intellectual follow-up questions using LLM.
|
| 1383 |
-
These are ASPECT-based (related subtopics, methodological angles, applications)
|
| 1384 |
-
not generic filter suggestions like "peer reviewed only".
|
| 1385 |
-
"""
|
| 1386 |
-
settings = get_settings()
|
| 1387 |
-
if not settings.openai_api_key:
|
| 1388 |
-
return []
|
| 1389 |
-
try:
|
| 1390 |
-
llm = _get_llm(model, temperature=0.5, max_tokens=200)
|
| 1391 |
-
response = await llm.ainvoke([
|
| 1392 |
-
{"role": "system", "content": (
|
| 1393 |
-
"You are a research librarian helping a user explore a topic more deeply. "
|
| 1394 |
-
"Given a research topic, generate exactly 3 short follow-up questions that explore "
|
| 1395 |
-
"DIFFERENT ASPECTS of the topic β such as subtopics, methodological angles, "
|
| 1396 |
-
"applications, comparisons, or related fields. "
|
| 1397 |
-
"Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
|
| 1398 |
-
"Return ONLY a JSON array of 3 strings, each under 12 words. "
|
| 1399 |
-
"No explanation, no preamble, no markdown.\n"
|
| 1400 |
-
"Example for 'quantum computing':\n"
|
| 1401 |
-
'[\"What are the main hardware approaches in quantum computing?\", '
|
| 1402 |
-
'"How does quantum error correction work?\", '
|
| 1403 |
-
'"Applications of quantum computing in cryptography\"]'
|
| 1404 |
-
)},
|
| 1405 |
-
{"role": "user", "content": f"Topic: {topic}"},
|
| 1406 |
-
])
|
| 1407 |
-
raw = response.content.strip()
|
| 1408 |
-
if raw.startswith("```"):
|
| 1409 |
-
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 1410 |
-
questions = json.loads(raw)
|
| 1411 |
-
if not isinstance(questions, list):
|
| 1412 |
-
return []
|
| 1413 |
-
return [
|
| 1414 |
-
{"label": q.strip("?") + "?", "question": q.strip("?") + " β find articles", "action": "new_search"}
|
| 1415 |
-
for q in questions[:3]
|
| 1416 |
-
if isinstance(q, str) and q.strip()
|
| 1417 |
-
]
|
| 1418 |
-
except Exception as e:
|
| 1419 |
-
logger.warning(f"Topic follow-up generation failed: {e}")
|
| 1420 |
-
return []
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
async def _search_follow_up(
|
| 1424 |
context: SearchContextPayload, model: str, summary_mode: bool = False
|
| 1425 |
) -> Tuple[str, List[dict]]:
|
|
@@ -1571,20 +647,18 @@ async def _run_search_mode(
|
|
| 1571 |
search_results = result.get("results", []) or []
|
| 1572 |
total = result.get("total", len(search_results))
|
| 1573 |
|
| 1574 |
-
answer = _search_answer_intro(context, is_follow_up)
|
| 1575 |
if filters:
|
| 1576 |
answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
|
| 1577 |
if search_results:
|
| 1578 |
answer += f"<br><br>I found <strong>{total}</strong> result(s). The top items are shown below."
|
| 1579 |
else:
|
| 1580 |
-
answer +=
|
| 1581 |
-
"<br><br>I couldn't retrieve matching results right now. "
|
| 1582 |
-
"You can still try the direct library links and AI research tools below."
|
| 1583 |
-
)
|
| 1584 |
if context.intent == "search_medical" and context.source != "pubmed":
|
| 1585 |
answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
|
| 1586 |
|
| 1587 |
answer += _source_badge(source_name, source_url)
|
|
|
|
| 1588 |
sources = []
|
| 1589 |
for item in search_results[:6]:
|
| 1590 |
link = item.get("primo_url") or item.get("link")
|
|
@@ -1601,6 +675,181 @@ def _sort_and_trim_contexts(contexts: List[SearchContextPayload]) -> List[Search
|
|
| 1601 |
return ordered[:5]
|
| 1602 |
|
| 1603 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1604 |
# ββ Main Endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1605 |
|
| 1606 |
@router.post("", response_model=AgentResponse)
|
|
@@ -1705,22 +954,6 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 1705 |
follow_up_suggestions=follow_up_suggestions,
|
| 1706 |
)
|
| 1707 |
|
| 1708 |
-
if _looks_database_recommendation_question(question):
|
| 1709 |
-
follow_up_question = "Would you like me to search one of these databases for you next?"
|
| 1710 |
-
follow_up_suggestions = [
|
| 1711 |
-
{"label": "Search PRIMO on this topic", "question": question.replace("best databases", "find articles").replace("which database should I use for", "find articles on")},
|
| 1712 |
-
{"label": "Show off-campus access help", "question": "How do I access databases off campus?"},
|
| 1713 |
-
]
|
| 1714 |
-
return AgentResponse(
|
| 1715 |
-
answer=_database_recommendation_answer(question),
|
| 1716 |
-
intent="library_info",
|
| 1717 |
-
tools_used=["database_recommendation"],
|
| 1718 |
-
model_used=model,
|
| 1719 |
-
response_time=time.time() - start_time,
|
| 1720 |
-
follow_up_question=follow_up_question,
|
| 1721 |
-
follow_up_suggestions=follow_up_suggestions,
|
| 1722 |
-
)
|
| 1723 |
-
|
| 1724 |
is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
|
| 1725 |
|
| 1726 |
if is_follow_up and base_context:
|
|
@@ -1732,7 +965,7 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 1732 |
if follow_up_action == "summarize_topic":
|
| 1733 |
answer, search_results, sources = await _research_snapshot(context, model)
|
| 1734 |
elif follow_up_action == "best_databases":
|
| 1735 |
-
answer = _database_recommendation_answer(context.
|
| 1736 |
search_results = []
|
| 1737 |
sources = []
|
| 1738 |
else:
|
|
@@ -1740,6 +973,7 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 1740 |
f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
|
| 1741 |
f"{_database_guidance_for_topic(context)}"
|
| 1742 |
+ _source_badge("libbee")
|
|
|
|
| 1743 |
)
|
| 1744 |
search_results = []
|
| 1745 |
sources = []
|
|
@@ -1805,6 +1039,21 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 1805 |
follow_up_suggestions=follow_up_suggestions,
|
| 1806 |
)
|
| 1807 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1808 |
if intent == "library_info":
|
| 1809 |
try:
|
| 1810 |
from app import rag_service
|
|
@@ -1868,8 +1117,10 @@ async def agent_query(request: Request, req: AgentRequest):
|
|
| 1868 |
|
| 1869 |
if summary_mode or deep_research_mode:
|
| 1870 |
answer, search_results, sources = await _research_snapshot(context, model)
|
|
|
|
| 1871 |
if deep_research_mode and "Research guide" not in answer:
|
| 1872 |
-
answer +=
|
|
|
|
| 1873 |
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
|
| 1874 |
_safe_metrics_increment("search_handoffs")
|
| 1875 |
return AgentResponse(
|
|
|
|
| 213 |
re.IGNORECASE,
|
| 214 |
)
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
MEDICAL_KEYWORDS = [
|
| 217 |
"medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
|
| 218 |
"anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
|
|
|
|
| 224 |
"crispr", "gene therapy", "gene editing", "genomic medicine", "precision medicine",
|
| 225 |
]
|
| 226 |
|
| 227 |
+
DATABASE_RECOMMEND_RE = re.compile(
|
| 228 |
+
r"\b(best database|best databases|which database|which databases|database for|databases for|where should i search|where should i start)\b",
|
| 229 |
+
re.IGNORECASE,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
REFINEMENT_STOP_WORDS = {
|
| 233 |
"make", "it", "them", "those", "these", "the", "a", "an", "for", "to", "only", "show",
|
| 234 |
"find", "give", "me", "please", "and", "with", "within", "limit", "narrow", "search",
|
|
|
|
| 478 |
if m.role in ("user", "assistant")
|
| 479 |
]
|
| 480 |
ctx = " | ".join(ctx_parts) if ctx_parts else "none"
|
| 481 |
+
llm = _get_llm(model, temperature=0, max_tokens=150)
|
| 482 |
response = await llm.ainvoke([
|
| 483 |
{"role": "system", "content": _CLASSIFIER_SYSTEM},
|
| 484 |
{"role": "user", "content": f"Message: {question[:400]}\nContext: {ctx}\nJSON:"},
|
|
|
|
| 496 |
return _rule_based_classify(question)
|
| 497 |
|
| 498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
async def _search_follow_up(
|
| 500 |
context: SearchContextPayload, model: str, summary_mode: bool = False
|
| 501 |
) -> Tuple[str, List[dict]]:
|
|
|
|
| 647 |
search_results = result.get("results", []) or []
|
| 648 |
total = result.get("total", len(search_results))
|
| 649 |
|
| 650 |
+
answer = _search_trace_block(question, context) + _search_answer_intro(context, is_follow_up)
|
| 651 |
if filters:
|
| 652 |
answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
|
| 653 |
if search_results:
|
| 654 |
answer += f"<br><br>I found <strong>{total}</strong> result(s). The top items are shown below."
|
| 655 |
else:
|
| 656 |
+
answer += "<br><br>I didn't find matching results right now, but you can still try the direct discovery links and related tools below."
|
|
|
|
|
|
|
|
|
|
| 657 |
if context.intent == "search_medical" and context.source != "pubmed":
|
| 658 |
answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
|
| 659 |
|
| 660 |
answer += _source_badge(source_name, source_url)
|
| 661 |
+
answer += _contact_footer_for_query(question, context)
|
| 662 |
sources = []
|
| 663 |
for item in search_results[:6]:
|
| 664 |
link = item.get("primo_url") or item.get("link")
|
|
|
|
| 675 |
return ordered[:5]
|
| 676 |
|
| 677 |
|
| 678 |
+
|
| 679 |
+
def _find_staff_by_token(token: str) -> Optional[dict]:
|
| 680 |
+
token = (token or '').lower()
|
| 681 |
+
for staff in STAFF_DIRECTORY:
|
| 682 |
+
hay = (staff.get('full_name', '') + ' ' + staff.get('role', '')).lower()
|
| 683 |
+
if token in hay:
|
| 684 |
+
return staff
|
| 685 |
+
return None
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def _contact_footer_for_query(question: str, context: Optional[SearchContextPayload]) -> str:
|
| 689 |
+
q = (question or '').lower()
|
| 690 |
+
specialist = _find_staff_by_token('muna')
|
| 691 |
+
if any(t in q for t in ['database access', 'proxy', 'full text', 'eresource', 'e-resource', 'remote access', 'access problem']):
|
| 692 |
+
specialist = _find_staff_by_token('rani')
|
| 693 |
+
elif any(t in q for t in ['website', 'system', 'technical', 'login issue', 'page not loading']):
|
| 694 |
+
specialist = _find_staff_by_token('walter')
|
| 695 |
+
elif context and context.intent == 'search_medical':
|
| 696 |
+
specialist = _find_staff_by_token('jason')
|
| 697 |
+
elif context and context.intent in {'search_academic', 'search_medical'}:
|
| 698 |
+
specialist = _find_staff_by_token('nikesh') if context.intent == 'search_academic' else _find_staff_by_token('jason')
|
| 699 |
+
footer = (
|
| 700 |
+
'<br><br><strong>Library contact points</strong><br>'
|
| 701 |
+
f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> Β· '
|
| 702 |
+
'<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
|
| 703 |
+
)
|
| 704 |
+
if specialist:
|
| 705 |
+
footer += (
|
| 706 |
+
f'<br>Relevant contact: <strong>{_escape(specialist.get("full_name", ""))}</strong> β '
|
| 707 |
+
f'{_escape(specialist.get("role", ""))}<br>'
|
| 708 |
+
f'<a href="mailto:{_escape(specialist.get("email", ""))}">{_escape(specialist.get("email", ""))}</a>'
|
| 709 |
+
)
|
| 710 |
+
phone = specialist.get('phone') or specialist.get('work_phone')
|
| 711 |
+
if phone:
|
| 712 |
+
footer += f' Β· <a href="tel:{_escape(str(phone).replace(" ", ""))}">{_escape(phone)}</a>'
|
| 713 |
+
return footer
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def _search_trace_block(question: str, context: SearchContextPayload) -> str:
|
| 717 |
+
topic = _escape(context.display_topic or context.topic)
|
| 718 |
+
question_html = _escape(question)
|
| 719 |
+
primo_boolean = _escape(context.primo_boolean_query or '')
|
| 720 |
+
found_via = 'I identified the main topic, built a PRIMO search query, and looked for KU-accessible results.'
|
| 721 |
+
if context.intent == 'search_medical':
|
| 722 |
+
found_via += ' Because this is a medical topic, PubMed is also recommended for broader evidence searching.'
|
| 723 |
+
block = (
|
| 724 |
+
f'<div style="margin:10px 0 14px 0;padding:12px 14px;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff">'
|
| 725 |
+
f'<strong>Your question:</strong> {question_html}<br>'
|
| 726 |
+
f'<strong>Interpreted topic:</strong> {topic}<br>'
|
| 727 |
+
)
|
| 728 |
+
if primo_boolean:
|
| 729 |
+
block += f'<strong>PRIMO query used:</strong> <code>{primo_boolean}</code><br>'
|
| 730 |
+
block += f'<strong>How I found this:</strong> {found_via}</div>'
|
| 731 |
+
return block
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _is_database_recommendation_question(question: str) -> bool:
|
| 735 |
+
return bool(DATABASE_RECOMMEND_RE.search(question or ''))
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
def _database_recommendation_answer(question: str) -> str:
|
| 739 |
+
q = (question or '').lower()
|
| 740 |
+
topic = _escape(_strip_resource_noise(question))
|
| 741 |
+
dbs = []
|
| 742 |
+
if any(t in q for t in ['computer science', 'artificial intelligence', 'machine learning', ' ai ', 'cybersecurity']):
|
| 743 |
+
dbs = [
|
| 744 |
+
('ACM Digital Library', 'https://khalifa.idm.oclc.org/login?url=https://dl.acm.org', 'Core computer science literature.'),
|
| 745 |
+
('IEEE Xplore', 'https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org', 'Strong for AI, systems, and engineering computing.'),
|
| 746 |
+
('Scopus', 'https://khalifa.idm.oclc.org/login?url=https://www.scopus.com', 'Broad discovery and citation tracking.'),
|
| 747 |
+
]
|
| 748 |
+
elif any(t in q for t in ['medical', 'health', 'nursing', 'clinical', 'biomedical']):
|
| 749 |
+
dbs = [
|
| 750 |
+
('PubMed@KU', 'http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib', 'Core biomedical literature with KU full-text links.'),
|
| 751 |
+
('Embase', 'https://khalifa.idm.oclc.org/login?url=https://www.embase.com', 'Biomedical and pharmacological literature.'),
|
| 752 |
+
('CINAHL Ultimate', 'https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul', 'Nursing and allied health literature.'),
|
| 753 |
+
]
|
| 754 |
+
elif any(t in q for t in ['civil']):
|
| 755 |
+
dbs = [
|
| 756 |
+
('ASCE Library', 'https://khalifa.idm.oclc.org/login?url=https://ascelibrary.org', 'Core civil engineering journals and conference papers.'),
|
| 757 |
+
('ScienceDirect', 'https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com', 'Strong full text across civil engineering topics.'),
|
| 758 |
+
('Scopus', 'https://khalifa.idm.oclc.org/login?url=https://www.scopus.com', 'Discovery and citation tracking.'),
|
| 759 |
+
]
|
| 760 |
+
elif any(t in q for t in ['mechanical']):
|
| 761 |
+
dbs = [
|
| 762 |
+
('ASME Digital Library', 'https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org', 'Core mechanical engineering literature.'),
|
| 763 |
+
('Knovel', 'https://khalifa.idm.oclc.org/login?url=https://app.knovel.com/kn', 'Engineering reference books and data.'),
|
| 764 |
+
('ScienceDirect', 'https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com', 'Broad full text.'),
|
| 765 |
+
]
|
| 766 |
+
elif any(t in q for t in ['electrical']):
|
| 767 |
+
dbs = [
|
| 768 |
+
('IEEE Xplore', 'https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org/Xplore/dynhome.jsp', 'Core electrical engineering database.'),
|
| 769 |
+
('INSPEC', 'https://khalifa.idm.oclc.org/login?url=https://www.theiet.org/publishing/inspec', 'Strong indexing for EE and electronics.'),
|
| 770 |
+
('Scopus', 'https://khalifa.idm.oclc.org/login?url=https://www.scopus.com', 'Discovery and citation tracking.'),
|
| 771 |
+
]
|
| 772 |
+
else:
|
| 773 |
+
dbs = [
|
| 774 |
+
('PRIMO', 'https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU', 'Best starting point for KU-accessible books, articles, and journals.'),
|
| 775 |
+
('Scopus', 'https://khalifa.idm.oclc.org/login?url=https://www.scopus.com', 'Broad multidisciplinary discovery.'),
|
| 776 |
+
('Web of Science', 'https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com', 'Broad multidisciplinary citation searching.'),
|
| 777 |
+
]
|
| 778 |
+
answer = f'<strong>Best databases for {topic}</strong><br><br>'
|
| 779 |
+
answer += 'Here are strong starting points for this subject:<br>'
|
| 780 |
+
for name, url, why in dbs:
|
| 781 |
+
answer += f'<br>β’ <a href="{url}" target="_blank"><strong>{name}</strong></a> β {_escape(why)}'
|
| 782 |
+
answer += '<br><br>If you want, I can also turn this into a direct article search.'
|
| 783 |
+
return answer
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
def _database_guidance_for_topic(context: SearchContextPayload) -> str:
|
| 787 |
+
topic = _escape(context.display_topic or context.topic)
|
| 788 |
+
return f'For deeper research on <strong>{topic}</strong>, the recommended AI research tools are listed at the end of this answer.'
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
|
| 792 |
+
settings = get_settings()
|
| 793 |
+
if not settings.openai_api_key:
|
| 794 |
+
return []
|
| 795 |
+
try:
|
| 796 |
+
llm = _get_llm(model, temperature=0.4, max_tokens=200)
|
| 797 |
+
response = await llm.ainvoke([
|
| 798 |
+
{"role": "system", "content": "Generate exactly 3 short follow-up questions exploring different angles of the topic. Return only a JSON array of strings."},
|
| 799 |
+
{"role": "user", "content": f"Topic: {topic}"},
|
| 800 |
+
])
|
| 801 |
+
raw = response.content.strip()
|
| 802 |
+
if raw.startswith('```'):
|
| 803 |
+
raw = raw.split('\\n', 1)[1].rsplit('```', 1)[0].strip()
|
| 804 |
+
questions = json.loads(raw)
|
| 805 |
+
if not isinstance(questions, list):
|
| 806 |
+
return []
|
| 807 |
+
suggestions: List[dict] = []
|
| 808 |
+
for q in questions[:3]:
|
| 809 |
+
if not isinstance(q, str) or not q.strip():
|
| 810 |
+
continue
|
| 811 |
+
q_clean = _normalize_whitespace(q.strip().rstrip('?'))
|
| 812 |
+
contextual = q_clean if topic.lower() in q_clean.lower() else f'{q_clean} for {topic}'
|
| 813 |
+
suggestions.append({'label': q_clean + '?', 'question': f'Find research articles on {contextual}', 'action': None})
|
| 814 |
+
return suggestions
|
| 815 |
+
except Exception as e:
|
| 816 |
+
logger.warning(f'Topic follow-up generation failed: {e}')
|
| 817 |
+
return []
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
|
| 821 |
+
topic = context.topic
|
| 822 |
+
papers = await _semantic_scholar_search(topic, limit=8)
|
| 823 |
+
references: List[dict] = []
|
| 824 |
+
primo_result, primo_url = await _search_primo_catalog(context, limit=6)
|
| 825 |
+
search_results = primo_result.get('results', []) or []
|
| 826 |
+
answer = _search_trace_block(context.ai_tool_query or f'Research on {topic}', context)
|
| 827 |
+
if papers:
|
| 828 |
+
summary = await _generate_cited_summary(topic, papers, model)
|
| 829 |
+
if not summary:
|
| 830 |
+
_safe_metrics_increment('summary_fallbacks')
|
| 831 |
+
summary = _extractive_snapshot_from_papers(topic, papers)
|
| 832 |
+
answer += f'<strong>π Research Snapshot: {_escape(topic)}</strong><br><br>{summary}'
|
| 833 |
+
if search_results:
|
| 834 |
+
answer += '<br><br><strong>KU Library starting points:</strong> I also found KU-accessible results below so you can continue with subscribed material.'
|
| 835 |
+
answer += '<br><br><strong>Selected references:</strong><br>'
|
| 836 |
+
for i, paper in enumerate(papers[:6], 1):
|
| 837 |
+
answer += f'<br><strong>[{i}]</strong> <a href="{paper["link"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(paper["title"])}</a><br><span style="font-size:.8rem;color:#6b7280">{_escape(paper["authors"])} Β· {paper["year"]} Β· {_escape(paper["venue"] or "")}</span>'
|
| 838 |
+
answer += '<br><br>For deeper research, the recommended AI research tools are listed at the end of this answer.'
|
| 839 |
+
answer += _source_badge('semantic', 'https://www.semanticscholar.org/')
|
| 840 |
+
if search_results:
|
| 841 |
+
answer += _source_badge('primo', primo_url)
|
| 842 |
+
answer += _contact_footer_for_query(topic, context)
|
| 843 |
+
references = [{'title': p['title'], 'url': p['link']} for p in papers[:6]]
|
| 844 |
+
return answer, search_results, references
|
| 845 |
+
if search_results:
|
| 846 |
+
answer += f'<strong>π KU Library starting points on {_escape(topic)}</strong><br><br>I found KU-accessible library results you can open immediately below.<br><br>For deeper research, the recommended AI research tools are listed at the end of this answer.'
|
| 847 |
+
else:
|
| 848 |
+
answer += f'<strong>π Research starting points on {_escape(topic)}</strong><br><br>I found direct library discovery routes you can use to continue this search.<br><br>For deeper research, the recommended AI research tools are listed at the end of this answer.'
|
| 849 |
+
answer += _source_badge('primo', primo_url)
|
| 850 |
+
answer += _contact_footer_for_query(topic, context)
|
| 851 |
+
return answer, search_results, []
|
| 852 |
+
|
| 853 |
# ββ Main Endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 854 |
|
| 855 |
@router.post("", response_model=AgentResponse)
|
|
|
|
| 954 |
follow_up_suggestions=follow_up_suggestions,
|
| 955 |
)
|
| 956 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 957 |
is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
|
| 958 |
|
| 959 |
if is_follow_up and base_context:
|
|
|
|
| 965 |
if follow_up_action == "summarize_topic":
|
| 966 |
answer, search_results, sources = await _research_snapshot(context, model)
|
| 967 |
elif follow_up_action == "best_databases":
|
| 968 |
+
answer = _database_recommendation_answer(context.topic) + _contact_footer_for_query(question, context) + _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
|
| 969 |
search_results = []
|
| 970 |
sources = []
|
| 971 |
else:
|
|
|
|
| 973 |
f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
|
| 974 |
f"{_database_guidance_for_topic(context)}"
|
| 975 |
+ _source_badge("libbee")
|
| 976 |
+
+ _contact_footer_for_query(question, context)
|
| 977 |
)
|
| 978 |
search_results = []
|
| 979 |
sources = []
|
|
|
|
| 1039 |
follow_up_suggestions=follow_up_suggestions,
|
| 1040 |
)
|
| 1041 |
|
| 1042 |
+
if _is_database_recommendation_question(question):
|
| 1043 |
+
answer = _database_recommendation_answer(question)
|
| 1044 |
+
answer += _contact_footer_for_query(question, None)
|
| 1045 |
+
answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
|
| 1046 |
+
follow_up_question, follow_up_suggestions = _library_follow_up(question)
|
| 1047 |
+
return AgentResponse(
|
| 1048 |
+
answer=answer,
|
| 1049 |
+
intent="library_info",
|
| 1050 |
+
tools_used=["database_recommendation"],
|
| 1051 |
+
model_used=model,
|
| 1052 |
+
response_time=time.time() - start_time,
|
| 1053 |
+
follow_up_question=follow_up_question,
|
| 1054 |
+
follow_up_suggestions=follow_up_suggestions,
|
| 1055 |
+
)
|
| 1056 |
+
|
| 1057 |
if intent == "library_info":
|
| 1058 |
try:
|
| 1059 |
from app import rag_service
|
|
|
|
| 1117 |
|
| 1118 |
if summary_mode or deep_research_mode:
|
| 1119 |
answer, search_results, sources = await _research_snapshot(context, model)
|
| 1120 |
+
answer = _search_trace_block(question, context) + answer
|
| 1121 |
if deep_research_mode and "Research guide" not in answer:
|
| 1122 |
+
answer += "<br><br><em>For deeper research, recommended AI research tools are listed at the end of this answer.</em>"
|
| 1123 |
+
answer += _contact_footer_for_query(question, context)
|
| 1124 |
follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
|
| 1125 |
_safe_metrics_increment("search_handoffs")
|
| 1126 |
return AgentResponse(
|
src/api/search.py
CHANGED
|
@@ -8,7 +8,7 @@ Key fixes:
|
|
| 8 |
- query sent to both APIs is clean topic keywords only β no rtype noise
|
| 9 |
"""
|
| 10 |
import logging
|
| 11 |
-
from typing import Optional
|
| 12 |
from urllib.parse import quote
|
| 13 |
|
| 14 |
import httpx
|
|
@@ -94,38 +94,69 @@ async def search_endpoint(req: SearchRequest):
|
|
| 94 |
return await search_scholar(req)
|
| 95 |
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
pnx = doc.get("pnx", {}) or {}
|
| 99 |
control = pnx.get("control", {}) or {}
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
control.get("
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
return (
|
| 117 |
"https://khalifa.primo.exlibrisgroup.com/discovery/fulldisplay"
|
| 118 |
-
f"?docid={quote(
|
| 119 |
-
f"&search_scope=MyInst_and_CI&tab=Everything&query=any,contains,{query_encoded}&offset=0"
|
| 120 |
)
|
| 121 |
return (
|
| 122 |
-
|
| 123 |
-
f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU"
|
| 124 |
)
|
| 125 |
|
| 126 |
-
|
| 127 |
-
# ββ PRIMO ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
-
|
| 129 |
async def search_primo(req: SearchRequest, api_key: str):
|
| 130 |
"""
|
| 131 |
Send clean topic keywords to q=any,contains.
|
|
@@ -208,7 +239,7 @@ async def search_primo(req: SearchRequest, api_key: str):
|
|
| 208 |
"date": (d.get("date") or [""])[0],
|
| 209 |
"type": (d.get("type") or [""])[0],
|
| 210 |
"source": (d.get("source") or [""])[0],
|
| 211 |
-
"primo_url":
|
| 212 |
"_source": "PRIMO",
|
| 213 |
})
|
| 214 |
return {
|
|
|
|
| 8 |
- query sent to both APIs is clean topic keywords only β no rtype noise
|
| 9 |
"""
|
| 10 |
import logging
|
| 11 |
+
from typing import Optional, List
|
| 12 |
from urllib.parse import quote
|
| 13 |
|
| 14 |
import httpx
|
|
|
|
| 94 |
return await search_scholar(req)
|
| 95 |
|
| 96 |
|
| 97 |
+
# ββ PRIMO ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
# ββ PRIMO helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
|
| 101 |
+
def _first_str(value):
|
| 102 |
+
if isinstance(value, list) and value:
|
| 103 |
+
return value[0]
|
| 104 |
+
if isinstance(value, str):
|
| 105 |
+
return value
|
| 106 |
+
return ""
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _extract_primo_docid(doc: dict) -> str:
|
| 110 |
pnx = doc.get("pnx", {}) or {}
|
| 111 |
control = pnx.get("control", {}) or {}
|
| 112 |
+
search_sec = pnx.get("search", {}) or {}
|
| 113 |
+
candidates: List[str] = [
|
| 114 |
+
_first_str(control.get("recordid")),
|
| 115 |
+
_first_str(control.get("sourcerecordid")),
|
| 116 |
+
_first_str(search_sec.get("recordid")),
|
| 117 |
+
_first_str(search_sec.get("addsrcrecordid")),
|
| 118 |
+
]
|
| 119 |
+
for key in ("recordid", "pnxId", "id"):
|
| 120 |
+
val = doc.get(key)
|
| 121 |
+
if isinstance(val, str):
|
| 122 |
+
candidates.append(val)
|
| 123 |
+
elif isinstance(val, list) and val:
|
| 124 |
+
candidates.append(val[0])
|
| 125 |
+
for cand in candidates:
|
| 126 |
+
cand = (cand or "").strip()
|
| 127 |
+
if cand:
|
| 128 |
+
return cand
|
| 129 |
+
return ""
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _primo_record_url(doc: dict, query_encoded: str) -> str:
|
| 133 |
+
delivery = doc.get("delivery", {}) or {}
|
| 134 |
+
bestlocation = delivery.get("bestlocation", {}) or {}
|
| 135 |
+
for key in ("link", "url"):
|
| 136 |
+
val = bestlocation.get(key)
|
| 137 |
+
if isinstance(val, str) and "fulldisplay" in val:
|
| 138 |
+
return val
|
| 139 |
+
links = delivery.get("links", []) or []
|
| 140 |
+
for link_obj in links:
|
| 141 |
+
if isinstance(link_obj, dict):
|
| 142 |
+
val = link_obj.get("link") or link_obj.get("url") or ""
|
| 143 |
+
if isinstance(val, str) and "fulldisplay" in val:
|
| 144 |
+
return val
|
| 145 |
+
docid = _extract_primo_docid(doc)
|
| 146 |
+
if docid:
|
| 147 |
+
pnx = doc.get("pnx", {}) or {}
|
| 148 |
+
control = pnx.get("control", {}) or {}
|
| 149 |
+
sourceid = _first_str(control.get("sourceid")).lower()
|
| 150 |
+
context = "PC" if docid.lower().startswith("cdi_") or "central" in sourceid else "L"
|
| 151 |
return (
|
| 152 |
"https://khalifa.primo.exlibrisgroup.com/discovery/fulldisplay"
|
| 153 |
+
f"?docid={quote(docid)}&context={context}&vid=971KUOSTAR_INST:KU&lang=en&search_scope=MyInst_and_CI&tab=Everything"
|
|
|
|
| 154 |
)
|
| 155 |
return (
|
| 156 |
+
"https://khalifa.primo.exlibrisgroup.com/discovery/search"
|
| 157 |
+
f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU&lang=en&search_scope=MyInst_and_CI&tab=Everything"
|
| 158 |
)
|
| 159 |
|
|
|
|
|
|
|
|
|
|
| 160 |
async def search_primo(req: SearchRequest, api_key: str):
|
| 161 |
"""
|
| 162 |
Send clean topic keywords to q=any,contains.
|
|
|
|
| 239 |
"date": (d.get("date") or [""])[0],
|
| 240 |
"type": (d.get("type") or [""])[0],
|
| 241 |
"source": (d.get("source") or [""])[0],
|
| 242 |
+
"primo_url": _primo_record_url(doc, query_encoded),
|
| 243 |
"_source": "PRIMO",
|
| 244 |
})
|
| 245 |
return {
|