nikeshn commited on
Commit
997c8f3
Β·
verified Β·
1 Parent(s): 5ef7c27

Upload 2 files

Browse files
Files changed (2) hide show
  1. src/api/agent.py +204 -953
  2. 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=120)
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.display_topic or context.topic)
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 += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
 
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
- def _build_exact_primo_url(doc: dict, query_encoded: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
98
  pnx = doc.get("pnx", {}) or {}
99
  control = pnx.get("control", {}) or {}
100
- search = pnx.get("search", {}) or {}
101
- recordid = ""
102
- for candidate in [
103
- control.get("recordid"),
104
- control.get("sourcerecordid"),
105
- search.get("recordid"),
106
- search.get("addsrcrecordid"),
107
- ]:
108
- if isinstance(candidate, list) and candidate:
109
- recordid = str(candidate[0]).strip()
110
- if recordid:
111
- break
112
- elif isinstance(candidate, str) and candidate.strip():
113
- recordid = candidate.strip()
114
- break
115
- if recordid:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  return (
117
  "https://khalifa.primo.exlibrisgroup.com/discovery/fulldisplay"
118
- f"?docid={quote(recordid)}&context=L&vid=971KUOSTAR_INST:KU&lang=en"
119
- f"&search_scope=MyInst_and_CI&tab=Everything&query=any,contains,{query_encoded}&offset=0"
120
  )
121
  return (
122
- f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
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": _build_exact_primo_url(doc, query_encoded),
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 {