nikeshn commited on
Commit
056486b
·
verified ·
1 Parent(s): 5d310d4

Upload 2 files

Browse files
Files changed (1) hide show
  1. src/api/agent.py +192 -137
src/api/agent.py CHANGED
@@ -114,8 +114,7 @@ class AgentResponse(BaseModel):
114
  primo_search_url: Optional[str] = None
115
  primo_ai_url: Optional[str] = None
116
  pubmed_search_url: Optional[str] = None
117
- primo_ai_url: Optional[str] = None
118
- pubmed_search_url: Optional[str] = None
119
 
120
 
121
  # ── Constants ─────────────────────────────────────────────────────────────────
@@ -204,6 +203,10 @@ CURRENT_INFO_RE = re.compile(
204
  r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
205
  re.IGNORECASE,
206
  )
 
 
 
 
207
  SUMMARY_RE = re.compile(
208
  r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
209
  r"|tell me about|give me a summary|give me an overview|give me a brief"
@@ -728,24 +731,27 @@ def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
728
 
729
 
730
  def _make_primo_boolean_query(context: SearchContextPayload) -> str:
731
- """Build synonym-aware PRIMO boolean using shared planner logic."""
 
 
 
 
732
  topic = _title_case_topic(context.display_topic or context.topic)
 
733
  topic = _strip_resource_noise(topic)
734
  if not topic:
735
  topic = context.topic or "library search"
736
- return build_boolean_query(topic)
737
 
738
 
739
  def _primo_clean_url(context: SearchContextPayload) -> str:
740
- boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
741
- return build_primo_discovery_url(
742
- boolean_query,
743
- context.resource_type,
744
- context.peer_reviewed,
745
- context.open_access,
746
- context.year_from,
747
- context.year_to,
748
- )
749
 
750
 
751
  def _topic_keywords(text: str) -> List[str]:
@@ -964,10 +970,7 @@ def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
964
 
965
  # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
966
 
967
- from src.api.search import (
968
- SearchRequest, search_primo, search_pubmed,
969
- build_boolean_query, build_primo_discovery_url, build_pubmed_url,
970
- )
971
 
972
 
973
  async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
@@ -990,8 +993,7 @@ async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -
990
  open_access=context.open_access,
991
  year_from=context.year_from,
992
  year_to=context.year_to,
993
- resource_type=context.resource_type,
994
- boolean_query=context.primo_boolean_query,
995
  ),
996
  settings.primo_api_key,
997
  )
@@ -1017,7 +1019,9 @@ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5)
1017
  year_to=context.year_to, # FIX: now actually used in search.py
1018
  )
1019
  )
1020
- pubmed_url = build_pubmed_url(clean_topic, context.year_from, context.year_to)
 
 
1021
  return result, pubmed_url
1022
 
1023
 
@@ -1026,102 +1030,134 @@ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5)
1026
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1027
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1028
  clean_topic = _strip_resource_noise(context.topic)
1029
- semantic_q = quote((context.ai_tool_query or context.display_topic or context.topic).replace(" ", "+"))
1030
  return {
1031
  "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1032
  "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
1033
- "ebsco_ai": f"https://research-ebsco-com.khalifa.idm.oclc.org/c/ntdlkp/search?q={ai_query}",
1034
- "primo_ai": f"{PRIMO_AI_URL}{ai_query}",
1035
  "consensus": f"https://consensus.app/results/?q={ai_query}",
1036
- "openalex": f"https://openalex.org/works?page=1&sort=relevance_score:desc&search.semantic={semantic_q}",
1037
- "scispace": f"https://scispace.com/search?q={ai_query}",
1038
- "perplexity": f"https://www.perplexity.ai/search?q={ai_query}",
1039
  "primo_discovery": _primo_clean_url(context),
1040
- "pubmed": build_pubmed_url(clean_topic, context.year_from, context.year_to) if context.intent == "search_medical" else "",
1041
  }
1042
 
1043
 
1044
  def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1045
  topic = _escape(context.display_topic or context.topic)
1046
- urls = _tool_urls(context)
1047
- ai_links = (
1048
- f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> · '
1049
- f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> · '
1050
- f'<a href="{urls["ebsco_ai"]}" target="_blank" style="color:#9d174d;font-weight:700;text-decoration:none">EBSCO AI</a> · '
1051
- f'<a href="{urls["primo_ai"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO AI Assistant</a> · '
1052
- f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a> · '
1053
- f'<a href="{urls["openalex"]}" target="_blank" style="color:#0c4a6e;font-weight:700;text-decoration:none">OpenAlex</a>'
1054
- )
1055
- more_results = f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
1056
- if context.intent == "search_medical" and urls["pubmed"]:
1057
- more_results += f' · <a href="{urls["pubmed"]}" target="_blank" style="color:#065f46;font-weight:700;text-decoration:none">PubMed</a>'
1058
  return (
1059
- f'<strong>AI tools:</strong> {ai_links}<br>'
1060
- f'<strong>More results:</strong> {more_results}'
 
1061
  )
1062
 
1063
 
 
 
1064
 
1065
 
1066
- GENERAL_CONTACT_HTML = (
1067
- 'General queries: <a href="https://library.ku.ac.ae/AskUs" target="_blank">Ask a Librarian</a> '
1068
- '· <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
1069
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1070
 
 
 
 
 
 
 
 
1071
 
1072
- def _contact_footer(question: str, intent: str, context: Optional[SearchContextPayload] = None) -> str:
1073
- q = (question or "").lower()
1074
- lines = [GENERAL_CONTACT_HTML]
1075
- # Public services for general library help
1076
- if intent == "library_info":
1077
- lines.append('Public services: Muna Al Blooshi · <a href="mailto:muna.alblooshi@ku.ac.ae">muna.alblooshi@ku.ac.ae</a> · +971 2 312 4212')
1078
- # E-resources contact for access/search/database/full-text issues
1079
- if intent in {"search_academic", "search_medical"} or any(t in q for t in ["database", "e-resource", "eresource", "access", "full text", "proxy", "login", "remote access", "primo", "pubmed"]):
1080
- lines.append('E-resources: Rani Anand · <a href="mailto:rani.anand@ku.ac.ae">rani.anand@ku.ac.ae</a> · +971 2 312 3935')
1081
- # Research support for scholarly/research questions
1082
- if intent in {"search_academic", "search_medical"} or any(t in q for t in ["research", "literature review", "citation", "orcid", "open access", "refworks", "bibliometrics"]):
1083
- lines.append('Research support: Nikesh Narayanan · <a href="mailto:nikesh.narayanan@ku.ac.ae">nikesh.narayanan@ku.ac.ae</a> · +971 2 312 3980')
1084
- # Systems / technology contact
1085
- if any(t in q for t in ["system", "systems", "website", "technology", "technical", "error", "broken"]):
1086
- lines.append('Systems: Walter Brian Hall · <a href="mailto:walter.hall@ku.ac.ae">walter.hall@ku.ac.ae</a> · +971 2 312 3163')
1087
- dedup = []
1088
- seen = set()
1089
- for line in lines:
1090
- if line not in seen:
1091
- seen.add(line)
1092
- dedup.append(line)
1093
- html_lines = ''.join(f'<div>{line}</div>' for line in dedup)
1094
  return (
1095
- '<div style="margin-top:12px;padding:10px 12px;background:#f8fafc;border:1px solid #dbeafe;border-radius:10px;font-size:.8rem;color:#1e3a8a">'
1096
- '<strong>Library contact points</strong>' + html_lines + '</div>'
 
 
 
 
 
 
1097
  )
1098
 
1099
 
1100
- def _scholarly_trace_html(question: str, context: SearchContextPayload, mode: str) -> str:
1101
- topic = _escape(context.display_topic or context.topic)
1102
- ai_query = _escape(context.ai_tool_query or question)
1103
- primo_boolean = _escape(context.primo_boolean_query or _make_primo_boolean_query(context))
1104
- retrieval = (
1105
- 'I corrected and normalized your question, extracted the main topic, expanded close synonyms with OR, connected concept groups with AND, '
1106
- 'then searched Semantic Scholar for scholarly evidence and PRIMO for KU-accessible results.'
1107
- if mode == 'snapshot' else
1108
- 'I corrected and normalized your question, extracted the main topic, expanded close synonyms with OR, connected concept groups with AND, '
1109
- f'then searched {"PubMed" if context.source == "pubmed" else "PRIMO Library Discovery"} using cleaned topic terms plus the active filters.'
1110
- )
1111
- extra = ''
1112
- if context.intent == 'search_medical':
1113
- extra = f'<div><strong>PubMed search link:</strong> <a href="{build_pubmed_url(context.topic, context.year_from, context.year_to)}" target="_blank">Open PubMed for this topic</a></div>'
1114
- return (
1115
- '<div style="margin:0 0 10px 0;padding:10px 12px;background:#f8fafc;border:1px solid #d1d5db;border-radius:10px;font-size:.8rem;color:#334155">'
1116
- '<div style="font-weight:700;color:#003366;margin-bottom:6px">Search strategy used</div>'
1117
- f'<div><strong>Your question:</strong> {_escape(question)}</div>'
1118
- f'<div><strong>Interpreted topic:</strong> {topic}</div>'
1119
- f'<div><strong>PRIMO Library Discovery query:</strong> <code>{primo_boolean}</code></div>'
1120
- f'<div><strong>AI tools query:</strong> {_escape(ai_query)}</div>'
1121
- f'{extra}'
1122
- f'<div><strong>How I found this answer:</strong> {retrieval}</div>'
1123
- '</div>'
 
 
 
 
 
 
 
1124
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1125
 
1126
  # ── Research Snapshot ─────────────────────────────────────────────────────────
1127
 
@@ -1162,7 +1198,6 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
1162
  answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1163
  if search_results:
1164
  answer += _source_badge("primo", primo_url)
1165
- answer = _scholarly_trace_html(topic, context, "snapshot") + answer + "<br><br>" + _database_guidance_for_topic(context) + _contact_footer(topic, context.intent, context)
1166
  references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1167
  return answer, search_results, references
1168
 
@@ -1178,7 +1213,6 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
1178
  f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1179
  )
1180
  answer += _source_badge("primo", primo_url)
1181
- answer = _scholarly_trace_html(topic, context, "snapshot") + answer + "<br><br>" + _contact_footer(topic, context.intent, context)
1182
  return answer, search_results, []
1183
 
1184
 
@@ -1269,8 +1303,6 @@ def _detect_follow_up(question: str, client_state: Optional[ClientStatePayload])
1269
  return False, None, None
1270
  explicit_action = (client_state.follow_up_action if client_state else None) or None
1271
  if explicit_action:
1272
- if explicit_action == "new_search":
1273
- return False, None, None
1274
  return True, explicit_action, base_context
1275
  action = _parse_refinement_action(question)
1276
  if not action:
@@ -1315,24 +1347,28 @@ def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional
1315
 
1316
  async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1317
  """
1318
- Generate 3 topic-specific follow-up questions that ALWAYS preserve the original topic anchor.
1319
- These should start a fresh contextual search, not a refinement action.
 
1320
  """
1321
  settings = get_settings()
1322
  if not settings.openai_api_key:
1323
  return []
1324
  try:
1325
- llm = _get_llm(model, temperature=0.5, max_tokens=220)
1326
  response = await llm.ainvoke([
1327
  {"role": "system", "content": (
1328
  "You are a research librarian helping a user explore a topic more deeply. "
1329
- "Given a research topic, generate exactly 3 short follow-up questions that explore different aspects of the SAME topic. "
1330
- "Every suggestion must keep the original topic explicitly present. "
1331
- "For example, if the topic is 'global warming', acceptable suggestions include "
1332
- "'What are the main adaptation strategies for global warming?' and "
1333
- "'How does global warming affect water security?'. "
1334
- "Do NOT suggest generic filters like peer reviewed or last 5 years. "
1335
- "Return ONLY a JSON array of 3 strings."
 
 
 
1336
  )},
1337
  {"role": "user", "content": f"Topic: {topic}"},
1338
  ])
@@ -1342,26 +1378,16 @@ async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1342
  questions = json.loads(raw)
1343
  if not isinstance(questions, list):
1344
  return []
1345
- out = []
1346
- topic_l = (topic or "").lower()
1347
- for q in questions[:3]:
1348
- if not isinstance(q, str) or not q.strip():
1349
- continue
1350
- clean = q.strip().rstrip('?')
1351
- if topic_l not in clean.lower():
1352
- clean = f"{clean} for {topic}"
1353
- full_q = clean + "?"
1354
- out.append({
1355
- "label": full_q,
1356
- "question": f"Find research articles on {full_q}",
1357
- })
1358
- return out
1359
  except Exception as e:
1360
  logger.warning(f"Topic follow-up generation failed: {e}")
1361
  return []
1362
 
1363
 
1364
-
1365
  async def _search_follow_up(
1366
  context: SearchContextPayload, model: str, summary_mode: bool = False
1367
  ) -> Tuple[str, List[dict]]:
@@ -1513,7 +1539,7 @@ async def _run_search_mode(
1513
  search_results = result.get("results", []) or []
1514
  total = result.get("total", len(search_results))
1515
 
1516
- answer = _scholarly_trace_html(question, context, "search") + _search_answer_intro(context, is_follow_up)
1517
  if filters:
1518
  answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1519
  if search_results:
@@ -1526,9 +1552,7 @@ async def _run_search_mode(
1526
  if context.intent == "search_medical" and context.source != "pubmed":
1527
  answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1528
 
1529
- answer += "<br><br>" + _database_guidance_for_topic(context)
1530
  answer += _source_badge(source_name, source_url)
1531
- answer += _contact_footer(question, context.intent, context)
1532
  sources = []
1533
  for item in search_results[:6]:
1534
  link = item.get("primo_url") or item.get("link")
@@ -1595,6 +1619,7 @@ async def agent_query(request: Request, req: AgentRequest):
1595
  response_time=time.time() - start_time,
1596
  follow_up_question=follow_up_question,
1597
  follow_up_suggestions=follow_up_suggestions,
 
1598
  )
1599
 
1600
  staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
@@ -1609,6 +1634,7 @@ async def agent_query(request: Request, req: AgentRequest):
1609
  response_time=time.time() - start_time,
1610
  follow_up_question=follow_up_question,
1611
  follow_up_suggestions=follow_up_suggestions,
 
1612
  )
1613
 
1614
  if _is_greeting_menu_followup(question, history):
@@ -1626,7 +1652,7 @@ async def agent_query(request: Request, req: AgentRequest):
1626
  if _looks_library_hours_question(question):
1627
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1628
  return AgentResponse(
1629
- answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL) + _contact_footer(question, "library_info"),
1630
  intent="library_info",
1631
  tools_used=["hours_hardcoded"],
1632
  ask_librarian=False,
@@ -1634,12 +1660,13 @@ async def agent_query(request: Request, req: AgentRequest):
1634
  response_time=time.time() - start_time,
1635
  follow_up_question=follow_up_question,
1636
  follow_up_suggestions=follow_up_suggestions,
 
1637
  )
1638
 
1639
  if _looks_campus_question(question):
1640
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1641
  return AgentResponse(
1642
- answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/") + _contact_footer(question, "library_info"),
1643
  intent="library_info",
1644
  tools_used=["campus_hardcoded"],
1645
  ask_librarian=False,
@@ -1647,6 +1674,27 @@ async def agent_query(request: Request, req: AgentRequest):
1647
  response_time=time.time() - start_time,
1648
  follow_up_question=follow_up_question,
1649
  follow_up_suggestions=follow_up_suggestions,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1650
  )
1651
 
1652
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
@@ -1683,12 +1731,13 @@ async def agent_query(request: Request, req: AgentRequest):
1683
  ai_tool_query=context.ai_tool_query,
1684
  primo_boolean_query=context.primo_boolean_query,
1685
  primo_search_url=_primo_clean_url(context),
1686
- primo_ai_url=_tool_urls(context).get("primo_ai"),
1687
- pubmed_search_url=_tool_urls(context).get("pubmed"),
1688
  follow_up_question=follow_up_question,
1689
  follow_up_suggestions=follow_up_suggestions,
1690
  search_context=_context_to_dict(context),
1691
  is_follow_up=True,
 
 
 
1692
  )
1693
 
1694
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
@@ -1707,12 +1756,13 @@ async def agent_query(request: Request, req: AgentRequest):
1707
  ai_tool_query=context.ai_tool_query,
1708
  primo_boolean_query=context.primo_boolean_query,
1709
  primo_search_url=source_url,
1710
- primo_ai_url=_tool_urls(context).get("primo_ai"),
1711
- pubmed_search_url=_tool_urls(context).get("pubmed"),
1712
  follow_up_question=follow_up_question,
1713
  follow_up_suggestions=follow_up_suggestions,
1714
  search_context=_context_to_dict(context),
1715
  is_follow_up=True,
 
 
 
1716
  )
1717
 
1718
  cls_result = await _llm_classify(question, history, model)
@@ -1749,13 +1799,11 @@ async def agent_query(request: Request, req: AgentRequest):
1749
  f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
1750
  )
1751
  answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
1752
- answer += _contact_footer(question, "library_info")
1753
  sources = []
1754
  tools_used = ["rag_no_results"]
1755
  else:
1756
  answer = await _rag_answer(question, rag_results, history, model)
1757
  answer += _source_badge("rag", "https://library.ku.ac.ae/")
1758
- answer += _contact_footer(question, "library_info")
1759
  sources = [{"title": r.get("title", "Library KB"), "url": r.get("source", "")} for r in rag_results if r.get("source")]
1760
  tools_used = ["rag_hybrid_search"]
1761
 
@@ -1770,6 +1818,7 @@ async def agent_query(request: Request, req: AgentRequest):
1770
  response_time=time.time() - start_time,
1771
  follow_up_question=follow_up_question,
1772
  follow_up_suggestions=follow_up_suggestions,
 
1773
  )
1774
 
1775
  if intent in ("search_academic", "search_medical"):
@@ -1798,6 +1847,8 @@ async def agent_query(request: Request, req: AgentRequest):
1798
 
1799
  if summary_mode or deep_research_mode:
1800
  answer, search_results, sources = await _research_snapshot(context, model)
 
 
1801
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1802
  _safe_metrics_increment("search_handoffs")
1803
  return AgentResponse(
@@ -1814,11 +1865,12 @@ async def agent_query(request: Request, req: AgentRequest):
1814
  ai_tool_query=context.ai_tool_query,
1815
  primo_boolean_query=context.primo_boolean_query,
1816
  primo_search_url=_primo_clean_url(context),
1817
- primo_ai_url=_tool_urls(context).get("primo_ai"),
1818
- pubmed_search_url=_tool_urls(context).get("pubmed"),
1819
  follow_up_question=follow_up_question,
1820
  follow_up_suggestions=follow_up_suggestions,
1821
  search_context=_context_to_dict(context),
 
 
 
1822
  )
1823
 
1824
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
@@ -1837,11 +1889,12 @@ async def agent_query(request: Request, req: AgentRequest):
1837
  ai_tool_query=context.ai_tool_query,
1838
  primo_boolean_query=context.primo_boolean_query,
1839
  primo_search_url=source_url,
1840
- primo_ai_url=_tool_urls(context).get("primo_ai"),
1841
- pubmed_search_url=_tool_urls(context).get("pubmed"),
1842
  follow_up_question=follow_up_question,
1843
  follow_up_suggestions=follow_up_suggestions,
1844
  search_context=_context_to_dict(context),
 
 
 
1845
  )
1846
 
1847
  if intent == "general_recent":
@@ -1855,6 +1908,7 @@ async def agent_query(request: Request, req: AgentRequest):
1855
  response_time=time.time() - start_time,
1856
  follow_up_question=follow_up_question,
1857
  follow_up_suggestions=follow_up_suggestions,
 
1858
  )
1859
 
1860
  answer = await _web_search_answer(question, history, model) + _source_badge("web")
@@ -1867,6 +1921,7 @@ async def agent_query(request: Request, req: AgentRequest):
1867
  response_time=time.time() - start_time,
1868
  follow_up_question=follow_up_question,
1869
  follow_up_suggestions=follow_up_suggestions,
 
1870
  )
1871
 
1872
 
 
114
  primo_search_url: Optional[str] = None
115
  primo_ai_url: Optional[str] = None
116
  pubmed_search_url: Optional[str] = None
117
+ contact_html: Optional[str] = None
 
118
 
119
 
120
  # ── Constants ─────────────────────────────────────────────────────────────────
 
203
  r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
204
  re.IGNORECASE,
205
  )
206
+ DATABASE_RECOMMENDATION_RE = re.compile(
207
+ r"\b(best database|best databases|which database|which databases|recommended databases?|database should i use|databases should i use|database for|databases for)\b",
208
+ re.IGNORECASE,
209
+ )
210
  SUMMARY_RE = re.compile(
211
  r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
212
  r"|tell me about|give me a summary|give me an overview|give me a brief"
 
731
 
732
 
733
  def _make_primo_boolean_query(context: SearchContextPayload) -> str:
734
+ """
735
+ FIX: Build a clean boolean for display purposes ONLY.
736
+ Contains ONLY topic keywords — NO resource-type words.
737
+ Resource type is expressed as a PRIMO facet in search_primo(), never here.
738
+ """
739
  topic = _title_case_topic(context.display_topic or context.topic)
740
+ # Strip any remaining noise one final time
741
  topic = _strip_resource_noise(topic)
742
  if not topic:
743
  topic = context.topic or "library search"
744
+ return f'"{_normalize_whitespace(topic)}"'
745
 
746
 
747
  def _primo_clean_url(context: SearchContextPayload) -> str:
748
+ """
749
+ Build the PRIMO discovery URL using the clean topic — matching exactly what the API searches.
750
+ """
751
+ clean_topic = _strip_resource_noise(context.topic)
752
+ if not clean_topic:
753
+ clean_topic = context.topic
754
+ return f"{PRIMO_BASE_URL}&query=any,contains,{quote(clean_topic)}&lang=en&mode=advanced&offset=0"
 
 
755
 
756
 
757
  def _topic_keywords(text: str) -> List[str]:
 
970
 
971
  # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
972
 
973
+ from src.api.search import SearchRequest, search_primo, search_pubmed
 
 
 
974
 
975
 
976
  async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
 
993
  open_access=context.open_access,
994
  year_from=context.year_from,
995
  year_to=context.year_to,
996
+ resource_type=context.resource_type, # FIX: was missing — resource type now goes as facet
 
997
  ),
998
  settings.primo_api_key,
999
  )
 
1019
  year_to=context.year_to, # FIX: now actually used in search.py
1020
  )
1021
  )
1022
+ pubmed_url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(clean_topic)}"
1023
+ if context.year_from and context.year_to:
1024
+ pubmed_url += f"&filter=datesearch.y_custom&dates.from={context.year_from}&dates.to={context.year_to}"
1025
  return result, pubmed_url
1026
 
1027
 
 
1030
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1031
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1032
  clean_topic = _strip_resource_noise(context.topic)
 
1033
  return {
1034
  "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1035
  "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
 
 
1036
  "consensus": f"https://consensus.app/results/?q={ai_query}",
 
 
 
1037
  "primo_discovery": _primo_clean_url(context),
 
1038
  }
1039
 
1040
 
1041
  def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1042
  topic = _escape(context.display_topic or context.topic)
1043
+ if context.intent == "search_medical":
1044
+ return (
1045
+ f"For deeper research on <strong>{topic}</strong>, the KU-subscribed AI research tools and freemium tools are listed at the end of this answer. "
1046
+ "Direct links to PRIMO Library Discovery and PubMed are also provided there. "
1047
+ "Some freemium tools may require account creation."
1048
+ )
 
 
 
 
 
 
1049
  return (
1050
+ f"For deeper research on <strong>{topic}</strong>, the KU-subscribed AI research tools and freemium tools are listed at the end of this answer. "
1051
+ "Direct links to PRIMO Library Discovery and, for medical topics, PubMed are also provided there. "
1052
+ "Some freemium tools may require account creation."
1053
  )
1054
 
1055
 
1056
+ def _is_database_recommendation_question(question: str) -> bool:
1057
+ return bool(DATABASE_RECOMMENDATION_RE.search(question or ""))
1058
 
1059
 
1060
+ def _primary_contact_for_question(question: str, intent: str) -> dict:
1061
+ q = (question or '').lower()
1062
+ walter = {
1063
+ 'label': 'Systems & website support',
1064
+ 'name': 'Walter Brian Hall',
1065
+ 'role': 'Digital & Technology Services Librarian / Systems Librarian',
1066
+ 'email': 'walter.hall@ku.ac.ae',
1067
+ 'phone': '+971 2 312 3163',
1068
+ }
1069
+ rani = {
1070
+ 'label': 'E-resources support',
1071
+ 'name': 'Rani Anand',
1072
+ 'role': 'E-Resources Librarian',
1073
+ 'email': 'rani.anand@ku.ac.ae',
1074
+ 'phone': '+971 2 312 3935',
1075
+ }
1076
+ nikesh = {
1077
+ 'label': 'Research support',
1078
+ 'name': 'Nikesh Narayanan',
1079
+ 'role': 'Research & Access Services Librarian',
1080
+ 'email': 'nikesh.narayanan@ku.ac.ae',
1081
+ 'phone': '+971 2 312 3980',
1082
+ }
1083
+ muna = {
1084
+ 'label': 'Public services',
1085
+ 'name': 'Muna Ahmad Mohammad Al Blooshi',
1086
+ 'role': 'Manager, Public Services',
1087
+ 'email': 'muna.alblooshi@ku.ac.ae',
1088
+ 'phone': '+971 2 312 4212',
1089
+ }
1090
 
1091
+ if any(t in q for t in ['system', 'systems', 'website', 'web site', 'technical', 'technology', 'digital', 'error', 'bug', 'broken page', 'page not loading']):
1092
+ return walter
1093
+ if any(t in q for t in ['database access', 'off campus', 'remote access', 'proxy', 'login problem', 'login issue', 'cannot access', "can't access", 'full text', 'libkey', 'vendor', 'e-resource', 'eresource', 'subscription access']):
1094
+ return rani
1095
+ if intent in {'search_academic', 'search_medical'} or any(t in q for t in ['research', 'literature', 'systematic review', 'orcid', 'open access', 'apc', 'bibliometric', 'scopus', 'scival', 'impact', 'citation', 'ai tool', 'best database', 'best databases', 'which database', 'which databases']):
1096
+ return nikesh
1097
+ return muna
1098
 
1099
+
1100
+ def _contact_footer_html(question: str, intent: str) -> str:
1101
+ primary = _primary_contact_for_question(question, intent)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1102
  return (
1103
+ '<div style="margin-top:14px;padding:12px 14px;background:#f8fafc;border:1px solid #dbeafe;border-radius:12px;font-size:.8rem;line-height:1.6">'
1104
+ '<strong>Library contact points</strong><br>'
1105
+ f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank" style="color:#003366;font-weight:700">Ask a Librarian</a> · '
1106
+ '<a href="mailto:libse@ku.ac.ae" style="color:#003366">libse@ku.ac.ae</a><br>'
1107
+ f'{_escape(primary["label"])}: <strong>{_escape(primary["name"])}</strong> — {_escape(primary["role"])} · '
1108
+ f'<a href="mailto:{primary["email"]}" style="color:#003366">{_escape(primary["email"])}</a> · '
1109
+ f'<a href="tel:{primary["phone"].replace(" ", "")}" style="color:#003366">{_escape(primary["phone"])}</a>'
1110
+ '</div>'
1111
  )
1112
 
1113
 
1114
+ async def _database_recommendation_answer(question: str, history: List[ChatMessage], model: str) -> Tuple[str, List[dict]]:
1115
+ try:
1116
+ from app import rag_service
1117
+ rag_query = f"{question} recommended databases by subject Khalifa University Library databases"
1118
+ rag_results = await rag_service.hybrid_search(rag_query, top_k=5)
1119
+ except Exception as e:
1120
+ logger.error(f"Database recommendation search failed: {e}")
1121
+ rag_results = []
1122
+
1123
+ if not rag_results:
1124
+ answer = (
1125
+ 'I could not pull the subject database guidance from the library knowledge base right now.<br><br>'
1126
+ 'A good general starting point is <strong>PRIMO Library Discovery</strong>, then move to subject databases such as Scopus, Web of Science, IEEE Xplore, ACM Digital Library, PubMed, or Embase depending on your topic.'
1127
+ )
1128
+ return answer + _source_badge('ku_library', 'https://library.ku.ac.ae/eresources'), []
1129
+
1130
+ context_text = "\n\n---\n\n".join(r['content'] for r in rag_results)
1131
+ settings = get_settings()
1132
+ if not settings.openai_api_key:
1133
+ answer = 'I found KU Library database guidance for this subject in the knowledge base.'
1134
+ answer += _source_badge('rag', 'https://library.ku.ac.ae/eresources')
1135
+ sources = [{'title': r.get('title', 'Library guide'), 'url': r.get('source', '')} for r in rag_results if r.get('source')]
1136
+ return answer, sources
1137
+
1138
+ system = (
1139
+ 'You are LibBee, the Khalifa University Library AI Assistant. '
1140
+ 'Answer as an expert academic librarian using ONLY the context provided. '
1141
+ "Recommend the best KU databases for the user's subject. "
1142
+ 'Start with one short sentence. Then list 3 to 6 databases using HTML <br>, with each database name in <strong> and a brief explanation of why or when to use it. '
1143
+ 'Where appropriate, include PRIMO Library Discovery as a starting point and then subject-specific databases. '
1144
+ 'End with one short sentence offering to search directly in PRIMO Library Discovery.'
1145
  )
1146
+ try:
1147
+ llm = _get_llm(model, temperature=0.2, max_tokens=420)
1148
+ response = await llm.ainvoke([
1149
+ {'role': 'system', 'content': system + "\n\nCONTEXT:\n" + context_text},
1150
+ {'role': 'user', 'content': question},
1151
+ ])
1152
+ answer = response.content.strip()
1153
+ except Exception as e:
1154
+ logger.error(f"Database recommendation answer failed: {e}")
1155
+ answer = 'I found subject-specific database guidance in the library knowledge base, but I had trouble formatting it into a recommendation right now.'
1156
+
1157
+ answer += _source_badge('rag', 'https://library.ku.ac.ae/eresources')
1158
+ sources = [{'title': r.get('title', 'Library guide'), 'url': r.get('source', '')} for r in rag_results if r.get('source')]
1159
+ return answer, sources
1160
+
1161
 
1162
  # ── Research Snapshot ─────────────────────────────────────────────────────────
1163
 
 
1198
  answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1199
  if search_results:
1200
  answer += _source_badge("primo", primo_url)
 
1201
  references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1202
  return answer, search_results, references
1203
 
 
1213
  f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1214
  )
1215
  answer += _source_badge("primo", primo_url)
 
1216
  return answer, search_results, []
1217
 
1218
 
 
1303
  return False, None, None
1304
  explicit_action = (client_state.follow_up_action if client_state else None) or None
1305
  if explicit_action:
 
 
1306
  return True, explicit_action, base_context
1307
  action = _parse_refinement_action(question)
1308
  if not action:
 
1347
 
1348
  async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1349
  """
1350
+ FIX: Generate 3 topic-specific intellectual follow-up questions using LLM.
1351
+ These are ASPECT-based (related subtopics, methodological angles, applications)
1352
+ not generic filter suggestions like "peer reviewed only".
1353
  """
1354
  settings = get_settings()
1355
  if not settings.openai_api_key:
1356
  return []
1357
  try:
1358
+ llm = _get_llm(model, temperature=0.5, max_tokens=200)
1359
  response = await llm.ainvoke([
1360
  {"role": "system", "content": (
1361
  "You are a research librarian helping a user explore a topic more deeply. "
1362
+ "Given a research topic, generate exactly 3 short follow-up questions that explore "
1363
+ "DIFFERENT ASPECTS of the topic — such as subtopics, methodological angles, "
1364
+ "applications, comparisons, or related fields. "
1365
+ "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
1366
+ "Return ONLY a JSON array of 3 strings, each under 12 words. "
1367
+ "No explanation, no preamble, no markdown.\n"
1368
+ "Example for 'quantum computing':\n"
1369
+ '[\"What are the main hardware approaches in quantum computing?\", '
1370
+ '"How does quantum error correction work?\", '
1371
+ '"Applications of quantum computing in cryptography\"]'
1372
  )},
1373
  {"role": "user", "content": f"Topic: {topic}"},
1374
  ])
 
1378
  questions = json.loads(raw)
1379
  if not isinstance(questions, list):
1380
  return []
1381
+ return [
1382
+ {"label": q.strip("?") + "?", "question": f"Find research articles on {_normalize_whitespace(q.strip().rstrip("?"))} in the context of {topic}", "action": None}
1383
+ for q in questions[:3]
1384
+ if isinstance(q, str) and q.strip()
1385
+ ]
 
 
 
 
 
 
 
 
 
1386
  except Exception as e:
1387
  logger.warning(f"Topic follow-up generation failed: {e}")
1388
  return []
1389
 
1390
 
 
1391
  async def _search_follow_up(
1392
  context: SearchContextPayload, model: str, summary_mode: bool = False
1393
  ) -> Tuple[str, List[dict]]:
 
1539
  search_results = result.get("results", []) or []
1540
  total = result.get("total", len(search_results))
1541
 
1542
+ answer = _search_answer_intro(context, is_follow_up)
1543
  if filters:
1544
  answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1545
  if search_results:
 
1552
  if context.intent == "search_medical" and context.source != "pubmed":
1553
  answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1554
 
 
1555
  answer += _source_badge(source_name, source_url)
 
1556
  sources = []
1557
  for item in search_results[:6]:
1558
  link = item.get("primo_url") or item.get("link")
 
1619
  response_time=time.time() - start_time,
1620
  follow_up_question=follow_up_question,
1621
  follow_up_suggestions=follow_up_suggestions,
1622
+ contact_html=_contact_footer_html(question, "library_info"),
1623
  )
1624
 
1625
  staff_role_match = match_staff_role(question) if should_attempt_staff_lookup(question) else None
 
1634
  response_time=time.time() - start_time,
1635
  follow_up_question=follow_up_question,
1636
  follow_up_suggestions=follow_up_suggestions,
1637
+ contact_html=_contact_footer_html(question, "library_info"),
1638
  )
1639
 
1640
  if _is_greeting_menu_followup(question, history):
 
1652
  if _looks_library_hours_question(question):
1653
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1654
  return AgentResponse(
1655
+ answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL),
1656
  intent="library_info",
1657
  tools_used=["hours_hardcoded"],
1658
  ask_librarian=False,
 
1660
  response_time=time.time() - start_time,
1661
  follow_up_question=follow_up_question,
1662
  follow_up_suggestions=follow_up_suggestions,
1663
+ contact_html=_contact_footer_html(question, "library_info"),
1664
  )
1665
 
1666
  if _looks_campus_question(question):
1667
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1668
  return AgentResponse(
1669
+ answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1670
  intent="library_info",
1671
  tools_used=["campus_hardcoded"],
1672
  ask_librarian=False,
 
1674
  response_time=time.time() - start_time,
1675
  follow_up_question=follow_up_question,
1676
  follow_up_suggestions=follow_up_suggestions,
1677
+ contact_html=_contact_footer_html(question, "library_info"),
1678
+ )
1679
+
1680
+ if _is_database_recommendation_question(question):
1681
+ answer, sources = await _database_recommendation_answer(question, history, model)
1682
+ follow_up_question = "Would you like me to search one of these databases directly or show the AI research tools for this topic?"
1683
+ follow_up_suggestions = [
1684
+ {"label": "Search PRIMO Library Discovery", "question": "Search PRIMO Library Discovery for this topic"},
1685
+ {"label": "Show AI research tools", "question": "Show the AI research tools for this topic"},
1686
+ ]
1687
+ return AgentResponse(
1688
+ answer=answer,
1689
+ intent="library_info",
1690
+ tools_used=["database_recommendation_rag"],
1691
+ sources=sources,
1692
+ ask_librarian=False,
1693
+ model_used=model,
1694
+ response_time=time.time() - start_time,
1695
+ follow_up_question=follow_up_question,
1696
+ follow_up_suggestions=follow_up_suggestions,
1697
+ contact_html=_contact_footer_html(question, "library_info"),
1698
  )
1699
 
1700
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
 
1731
  ai_tool_query=context.ai_tool_query,
1732
  primo_boolean_query=context.primo_boolean_query,
1733
  primo_search_url=_primo_clean_url(context),
 
 
1734
  follow_up_question=follow_up_question,
1735
  follow_up_suggestions=follow_up_suggestions,
1736
  search_context=_context_to_dict(context),
1737
  is_follow_up=True,
1738
+ primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1739
+ pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if context.intent == "search_medical" else None),
1740
+ contact_html=_contact_footer_html(question, context.intent),
1741
  )
1742
 
1743
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, True)
 
1756
  ai_tool_query=context.ai_tool_query,
1757
  primo_boolean_query=context.primo_boolean_query,
1758
  primo_search_url=source_url,
 
 
1759
  follow_up_question=follow_up_question,
1760
  follow_up_suggestions=follow_up_suggestions,
1761
  search_context=_context_to_dict(context),
1762
  is_follow_up=True,
1763
+ primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1764
+ pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if context.intent == "search_medical" else None),
1765
+ contact_html=_contact_footer_html(question, context.intent),
1766
  )
1767
 
1768
  cls_result = await _llm_classify(question, history, model)
 
1799
  f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
1800
  )
1801
  answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
 
1802
  sources = []
1803
  tools_used = ["rag_no_results"]
1804
  else:
1805
  answer = await _rag_answer(question, rag_results, history, model)
1806
  answer += _source_badge("rag", "https://library.ku.ac.ae/")
 
1807
  sources = [{"title": r.get("title", "Library KB"), "url": r.get("source", "")} for r in rag_results if r.get("source")]
1808
  tools_used = ["rag_hybrid_search"]
1809
 
 
1818
  response_time=time.time() - start_time,
1819
  follow_up_question=follow_up_question,
1820
  follow_up_suggestions=follow_up_suggestions,
1821
+ contact_html=_contact_footer_html(question, "library_info"),
1822
  )
1823
 
1824
  if intent in ("search_academic", "search_medical"):
 
1847
 
1848
  if summary_mode or deep_research_mode:
1849
  answer, search_results, sources = await _research_snapshot(context, model)
1850
+ if deep_research_mode and "Research guide" not in answer:
1851
+ answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1852
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1853
  _safe_metrics_increment("search_handoffs")
1854
  return AgentResponse(
 
1865
  ai_tool_query=context.ai_tool_query,
1866
  primo_boolean_query=context.primo_boolean_query,
1867
  primo_search_url=_primo_clean_url(context),
 
 
1868
  follow_up_question=follow_up_question,
1869
  follow_up_suggestions=follow_up_suggestions,
1870
  search_context=_context_to_dict(context),
1871
+ primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1872
+ pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if intent == "search_medical" else None),
1873
+ contact_html=_contact_footer_html(question, intent),
1874
  )
1875
 
1876
  answer, search_results, sources, source_url = await _run_search_mode(question, context, model, False)
 
1889
  ai_tool_query=context.ai_tool_query,
1890
  primo_boolean_query=context.primo_boolean_query,
1891
  primo_search_url=source_url,
 
 
1892
  follow_up_question=follow_up_question,
1893
  follow_up_suggestions=follow_up_suggestions,
1894
  search_context=_context_to_dict(context),
1895
+ primo_ai_url=f"{PRIMO_AI_URL}{quote(context.ai_tool_query or context.topic)}",
1896
+ pubmed_search_url=(f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(context.topic)}" if intent == "search_medical" else None),
1897
+ contact_html=_contact_footer_html(question, intent),
1898
  )
1899
 
1900
  if intent == "general_recent":
 
1908
  response_time=time.time() - start_time,
1909
  follow_up_question=follow_up_question,
1910
  follow_up_suggestions=follow_up_suggestions,
1911
+ contact_html=_contact_footer_html(question, "general_recent"),
1912
  )
1913
 
1914
  answer = await _web_search_answer(question, history, model) + _source_badge("web")
 
1921
  response_time=time.time() - start_time,
1922
  follow_up_question=follow_up_question,
1923
  follow_up_suggestions=follow_up_suggestions,
1924
+ contact_html=_contact_footer_html(question, "general"),
1925
  )
1926
 
1927