nikeshn commited on
Commit
3a4bcd3
·
verified ·
1 Parent(s): c68df57

Upload 2 files

Browse files
Files changed (2) hide show
  1. src/api/agent.py +102 -37
  2. src/api/search.py +218 -262
src/api/agent.py CHANGED
@@ -726,20 +726,23 @@ def _parse_year_filters(question: str) -> Tuple[Optional[str], Optional[str]]:
726
 
727
 
728
  def _make_primo_boolean_query(context: SearchContextPayload) -> str:
 
729
  topic = _title_case_topic(context.display_topic or context.topic)
730
- topic = _strip_resource_noise(topic) or (context.topic or "library search")
731
- return _shared_build_primo_boolean_query(topic)
 
 
732
 
733
 
734
  def _primo_clean_url(context: SearchContextPayload) -> str:
735
  boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
736
- return _shared_build_primo_discovery_url(
737
  boolean_query,
738
- resource_type=context.resource_type,
739
- peer_reviewed=context.peer_reviewed,
740
- open_access=context.open_access,
741
- year_from=context.year_from,
742
- year_to=context.year_to,
743
  )
744
 
745
 
@@ -960,12 +963,8 @@ def _extractive_snapshot_from_papers(topic: str, papers: List[dict]) -> str:
960
  # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
961
 
962
  from src.api.search import (
963
- SearchRequest,
964
- build_primo_boolean_query as _shared_build_primo_boolean_query,
965
- build_primo_discovery_url as _shared_build_primo_discovery_url,
966
- build_pubmed_url as _shared_build_pubmed_url,
967
- search_primo,
968
- search_pubmed,
969
  )
970
 
971
 
@@ -989,7 +988,8 @@ async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -
989
  open_access=context.open_access,
990
  year_from=context.year_from,
991
  year_to=context.year_to,
992
- resource_type=context.resource_type, # FIX: was missing — resource type now goes as facet
 
993
  ),
994
  settings.primo_api_key,
995
  )
@@ -998,6 +998,9 @@ async def _search_primo_catalog(context: SearchContextPayload, limit: int = 5) -
998
 
999
 
1000
  async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
 
 
 
1001
  clean_topic = _strip_resource_noise(context.topic)
1002
  if not clean_topic:
1003
  clean_topic = context.topic
@@ -1008,11 +1011,11 @@ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5)
1008
  source="pubmed",
1009
  limit=limit,
1010
  peer_reviewed=context.peer_reviewed,
1011
- year_from=context.year_from,
1012
- year_to=context.year_to,
1013
  )
1014
  )
1015
- pubmed_url = _shared_build_pubmed_url(clean_topic, context.year_from, context.year_to, context.peer_reviewed)
1016
  return result, pubmed_url
1017
 
1018
 
@@ -1020,24 +1023,22 @@ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5)
1020
 
1021
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1022
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1023
- primo_ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1024
- urls = {
1025
  "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1026
  "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
1027
  "ebsco_ai": f"https://research-ebsco-com.khalifa.idm.oclc.org/c/ntdlkp/search?q={ai_query}",
1028
- "primo_ai": f"{PRIMO_AI_URL}{primo_ai_query}",
1029
  "consensus": f"https://consensus.app/results/?q={ai_query}",
1030
  "primo_discovery": _primo_clean_url(context),
 
1031
  }
1032
- if context.intent == "search_medical":
1033
- urls["pubmed"] = _shared_build_pubmed_url(context.topic, context.year_from, context.year_to, context.peer_reviewed)
1034
- return urls
1035
 
1036
 
1037
  def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1038
  topic = _escape(context.display_topic or context.topic)
1039
  urls = _tool_urls(context)
1040
- ai_tools = (
1041
  f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> · '
1042
  f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> · '
1043
  f'<a href="{urls["ebsco_ai"]}" target="_blank" style="color:#9d174d;font-weight:700;text-decoration:none">EBSCO AI</a> · '
@@ -1045,18 +1046,76 @@ def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1045
  f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a>'
1046
  )
1047
  more_results = f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
1048
- if context.intent == "search_medical" and urls.get("pubmed"):
1049
  more_results += f' · <a href="{urls["pubmed"]}" target="_blank" style="color:#065f46;font-weight:700;text-decoration:none">PubMed</a>'
1050
- return (
1051
- f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
1052
- f"For more results, use {more_results}. Medical topics are strongest in PubMed, Embase, and CINAHL."
1053
- )
1054
  return (
1055
- f"For deeper work on <strong>{topic}</strong>, continue with these AI research tools: {ai_tools}. "
1056
- f"For more results, use {more_results}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  )
1058
 
1059
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
  # ── Research Snapshot ─────────────────────────────────────────────────────────
1061
 
1062
  async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
@@ -1096,6 +1155,7 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
1096
  answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1097
  if search_results:
1098
  answer += _source_badge("primo", primo_url)
 
1099
  references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1100
  return answer, search_results, references
1101
 
@@ -1111,6 +1171,7 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
1111
  f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1112
  )
1113
  answer += _source_badge("primo", primo_url)
 
1114
  return answer, search_results, []
1115
 
1116
 
@@ -1201,6 +1262,8 @@ def _detect_follow_up(question: str, client_state: Optional[ClientStatePayload])
1201
  return False, None, None
1202
  explicit_action = (client_state.follow_up_action if client_state else None) or None
1203
  if explicit_action:
 
 
1204
  return True, explicit_action, base_context
1205
  action = _parse_refinement_action(question)
1206
  if not action:
@@ -1437,7 +1500,7 @@ async def _run_search_mode(
1437
  search_results = result.get("results", []) or []
1438
  total = result.get("total", len(search_results))
1439
 
1440
- answer = _search_answer_intro(context, is_follow_up)
1441
  if filters:
1442
  answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1443
  if search_results:
@@ -1450,7 +1513,9 @@ async def _run_search_mode(
1450
  if context.intent == "search_medical" and context.source != "pubmed":
1451
  answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1452
 
 
1453
  answer += _source_badge(source_name, source_url)
 
1454
  sources = []
1455
  for item in search_results[:6]:
1456
  link = item.get("primo_url") or item.get("link")
@@ -1548,7 +1613,7 @@ async def agent_query(request: Request, req: AgentRequest):
1548
  if _looks_library_hours_question(question):
1549
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1550
  return AgentResponse(
1551
- answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL),
1552
  intent="library_info",
1553
  tools_used=["hours_hardcoded"],
1554
  ask_librarian=False,
@@ -1561,7 +1626,7 @@ async def agent_query(request: Request, req: AgentRequest):
1561
  if _looks_campus_question(question):
1562
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1563
  return AgentResponse(
1564
- answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/"),
1565
  intent="library_info",
1566
  tools_used=["campus_hardcoded"],
1567
  ask_librarian=False,
@@ -1671,11 +1736,13 @@ async def agent_query(request: Request, req: AgentRequest):
1671
  f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
1672
  )
1673
  answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
 
1674
  sources = []
1675
  tools_used = ["rag_no_results"]
1676
  else:
1677
  answer = await _rag_answer(question, rag_results, history, model)
1678
  answer += _source_badge("rag", "https://library.ku.ac.ae/")
 
1679
  sources = [{"title": r.get("title", "Library KB"), "url": r.get("source", "")} for r in rag_results if r.get("source")]
1680
  tools_used = ["rag_hybrid_search"]
1681
 
@@ -1718,8 +1785,6 @@ async def agent_query(request: Request, req: AgentRequest):
1718
 
1719
  if summary_mode or deep_research_mode:
1720
  answer, search_results, sources = await _research_snapshot(context, model)
1721
- if deep_research_mode and "Research guide" not in answer:
1722
- answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1723
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1724
  _safe_metrics_increment("search_handoffs")
1725
  return AgentResponse(
 
726
 
727
 
728
  def _make_primo_boolean_query(context: SearchContextPayload) -> str:
729
+ """Build synonym-aware PRIMO boolean using shared planner logic."""
730
  topic = _title_case_topic(context.display_topic or context.topic)
731
+ topic = _strip_resource_noise(topic)
732
+ if not topic:
733
+ topic = context.topic or "library search"
734
+ return build_boolean_query(topic)
735
 
736
 
737
  def _primo_clean_url(context: SearchContextPayload) -> str:
738
  boolean_query = context.primo_boolean_query or _make_primo_boolean_query(context)
739
+ return build_primo_discovery_url(
740
  boolean_query,
741
+ context.resource_type,
742
+ context.peer_reviewed,
743
+ context.open_access,
744
+ context.year_from,
745
+ context.year_to,
746
  )
747
 
748
 
 
963
  # ── PRIMO / PubMed Search Wrappers ────────────────────────────────────────────
964
 
965
  from src.api.search import (
966
+ SearchRequest, search_primo, search_pubmed,
967
+ build_boolean_query, build_primo_discovery_url, build_pubmed_url,
 
 
 
 
968
  )
969
 
970
 
 
988
  open_access=context.open_access,
989
  year_from=context.year_from,
990
  year_to=context.year_to,
991
+ resource_type=context.resource_type,
992
+ boolean_query=context.primo_boolean_query,
993
  ),
994
  settings.primo_api_key,
995
  )
 
998
 
999
 
1000
  async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5) -> Tuple[dict, str]:
1001
+ """
1002
+ FIX: year_from/year_to now correctly wired through SearchRequest → mindate/maxdate in search.py.
1003
+ """
1004
  clean_topic = _strip_resource_noise(context.topic)
1005
  if not clean_topic:
1006
  clean_topic = context.topic
 
1011
  source="pubmed",
1012
  limit=limit,
1013
  peer_reviewed=context.peer_reviewed,
1014
+ year_from=context.year_from, # FIX: now actually used in search.py
1015
+ year_to=context.year_to, # FIX: now actually used in search.py
1016
  )
1017
  )
1018
+ pubmed_url = build_pubmed_url(clean_topic, context.year_from, context.year_to)
1019
  return result, pubmed_url
1020
 
1021
 
 
1023
 
1024
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1025
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1026
+ clean_topic = _strip_resource_noise(context.topic)
1027
+ return {
1028
  "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1029
  "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
1030
  "ebsco_ai": f"https://research-ebsco-com.khalifa.idm.oclc.org/c/ntdlkp/search?q={ai_query}",
1031
+ "primo_ai": f"{PRIMO_AI_URL}{ai_query}",
1032
  "consensus": f"https://consensus.app/results/?q={ai_query}",
1033
  "primo_discovery": _primo_clean_url(context),
1034
+ "pubmed": build_pubmed_url(clean_topic, context.year_from, context.year_to) if context.intent == "search_medical" else "",
1035
  }
 
 
 
1036
 
1037
 
1038
  def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1039
  topic = _escape(context.display_topic or context.topic)
1040
  urls = _tool_urls(context)
1041
+ ai_links = (
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["ebsco_ai"]}" target="_blank" style="color:#9d174d;font-weight:700;text-decoration:none">EBSCO AI</a> · '
 
1046
  f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a>'
1047
  )
1048
  more_results = f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
1049
+ if context.intent == "search_medical" and urls["pubmed"]:
1050
  more_results += f' · <a href="{urls["pubmed"]}" target="_blank" style="color:#065f46;font-weight:700;text-decoration:none">PubMed</a>'
 
 
 
 
1051
  return (
1052
+ f'<strong>AI tools:</strong> {ai_links}<br>'
1053
+ f'<strong>More results:</strong> {more_results}'
1054
+ )
1055
+
1056
+
1057
+
1058
+
1059
+ GENERAL_CONTACT_HTML = (
1060
+ 'General queries: <a href="https://library.ku.ac.ae/AskUs" target="_blank">Ask a Librarian</a> '
1061
+ '· <a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
1062
+ )
1063
+
1064
+
1065
+ def _contact_footer(question: str, intent: str, context: Optional[SearchContextPayload] = None) -> str:
1066
+ q = (question or "").lower()
1067
+ lines = [GENERAL_CONTACT_HTML]
1068
+ # Public services for general library help
1069
+ if intent == "library_info":
1070
+ 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')
1071
+ # E-resources contact for access/search/database/full-text issues
1072
+ 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"]):
1073
+ lines.append('E-resources: Rani Anand · <a href="mailto:rani.anand@ku.ac.ae">rani.anand@ku.ac.ae</a> · +971 2 312 3935')
1074
+ # Research support for scholarly/research questions
1075
+ if intent in {"search_academic", "search_medical"} or any(t in q for t in ["research", "literature review", "citation", "orcid", "open access", "refworks", "bibliometrics"]):
1076
+ lines.append('Research support: Nikesh Narayanan · <a href="mailto:nikesh.narayanan@ku.ac.ae">nikesh.narayanan@ku.ac.ae</a> · +971 2 312 3980')
1077
+ # Systems / technology contact
1078
+ if any(t in q for t in ["system", "systems", "website", "technology", "technical", "error", "broken"]):
1079
+ lines.append('Systems: Walter Brian Hall · <a href="mailto:walter.hall@ku.ac.ae">walter.hall@ku.ac.ae</a> · +971 2 312 3163')
1080
+ dedup = []
1081
+ seen = set()
1082
+ for line in lines:
1083
+ if line not in seen:
1084
+ seen.add(line)
1085
+ dedup.append(line)
1086
+ html_lines = ''.join(f'<div>{line}</div>' for line in dedup)
1087
+ return (
1088
+ '<div style="margin-top:12px;padding:10px 12px;background:#f8fafc;border:1px solid #dbeafe;border-radius:10px;font-size:.8rem;color:#1e3a8a">'
1089
+ '<strong>Library contact points</strong>' + html_lines + '</div>'
1090
  )
1091
 
1092
 
1093
+ def _scholarly_trace_html(question: str, context: SearchContextPayload, mode: str) -> str:
1094
+ topic = _escape(context.display_topic or context.topic)
1095
+ ai_query = _escape(context.ai_tool_query or question)
1096
+ primo_boolean = _escape(context.primo_boolean_query or _make_primo_boolean_query(context))
1097
+ retrieval = (
1098
+ 'I corrected and normalized your question, extracted the main topic, expanded close synonyms with OR, connected concept groups with AND, '
1099
+ 'then searched Semantic Scholar for scholarly evidence and PRIMO for KU-accessible results.'
1100
+ if mode == 'snapshot' else
1101
+ 'I corrected and normalized your question, extracted the main topic, expanded close synonyms with OR, connected concept groups with AND, '
1102
+ f'then searched {"PubMed" if context.source == "pubmed" else "PRIMO Library Discovery"} using cleaned topic terms plus the active filters.'
1103
+ )
1104
+ extra = ''
1105
+ if context.intent == 'search_medical':
1106
+ 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>'
1107
+ return (
1108
+ '<div style="margin:0 0 10px 0;padding:10px 12px;background:#f8fafc;border:1px solid #d1d5db;border-radius:10px;font-size:.8rem;color:#334155">'
1109
+ '<div style="font-weight:700;color:#003366;margin-bottom:6px">Search strategy used</div>'
1110
+ f'<div><strong>Your question:</strong> {_escape(question)}</div>'
1111
+ f'<div><strong>Interpreted topic:</strong> {topic}</div>'
1112
+ f'<div><strong>PRIMO Library Discovery query:</strong> <code>{primo_boolean}</code></div>'
1113
+ f'<div><strong>AI tools query:</strong> {_escape(ai_query)}</div>'
1114
+ f'{extra}'
1115
+ f'<div><strong>How I found this answer:</strong> {retrieval}</div>'
1116
+ '</div>'
1117
+ )
1118
+
1119
  # ── Research Snapshot ─────────────────────────────────────────────────────────
1120
 
1121
  async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
 
1155
  answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1156
  if search_results:
1157
  answer += _source_badge("primo", primo_url)
1158
+ answer = _scholarly_trace_html(topic, context, "snapshot") + answer + "<br><br>" + _database_guidance_for_topic(context) + _contact_footer(topic, context.intent, context)
1159
  references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1160
  return answer, search_results, references
1161
 
 
1171
  f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1172
  )
1173
  answer += _source_badge("primo", primo_url)
1174
+ answer = _scholarly_trace_html(topic, context, "snapshot") + answer + "<br><br>" + _contact_footer(topic, context.intent, context)
1175
  return answer, search_results, []
1176
 
1177
 
 
1262
  return False, None, None
1263
  explicit_action = (client_state.follow_up_action if client_state else None) or None
1264
  if explicit_action:
1265
+ if explicit_action == "new_search":
1266
+ return False, None, None
1267
  return True, explicit_action, base_context
1268
  action = _parse_refinement_action(question)
1269
  if not action:
 
1500
  search_results = result.get("results", []) or []
1501
  total = result.get("total", len(search_results))
1502
 
1503
+ answer = _scholarly_trace_html(question, context, "search") + _search_answer_intro(context, is_follow_up)
1504
  if filters:
1505
  answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1506
  if search_results:
 
1513
  if context.intent == "search_medical" and context.source != "pubmed":
1514
  answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1515
 
1516
+ answer += "<br><br>" + _database_guidance_for_topic(context)
1517
  answer += _source_badge(source_name, source_url)
1518
+ answer += _contact_footer(question, context.intent, context)
1519
  sources = []
1520
  for item in search_results[:6]:
1521
  link = item.get("primo_url") or item.get("link")
 
1613
  if _looks_library_hours_question(question):
1614
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1615
  return AgentResponse(
1616
+ answer=_hours_answer() + _source_badge("ku_library", LIBRARY_HOURS_URL) + _contact_footer(question, "library_info"),
1617
  intent="library_info",
1618
  tools_used=["hours_hardcoded"],
1619
  ask_librarian=False,
 
1626
  if _looks_campus_question(question):
1627
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1628
  return AgentResponse(
1629
+ answer=_campus_answer() + _source_badge("ku_library", "https://library.ku.ac.ae/") + _contact_footer(question, "library_info"),
1630
  intent="library_info",
1631
  tools_used=["campus_hardcoded"],
1632
  ask_librarian=False,
 
1736
  f'🔗 <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
1737
  )
1738
  answer += _source_badge("ku_library", ASK_LIBRARIAN_URL)
1739
+ answer += _contact_footer(question, "library_info")
1740
  sources = []
1741
  tools_used = ["rag_no_results"]
1742
  else:
1743
  answer = await _rag_answer(question, rag_results, history, model)
1744
  answer += _source_badge("rag", "https://library.ku.ac.ae/")
1745
+ answer += _contact_footer(question, "library_info")
1746
  sources = [{"title": r.get("title", "Library KB"), "url": r.get("source", "")} for r in rag_results if r.get("source")]
1747
  tools_used = ["rag_hybrid_search"]
1748
 
 
1785
 
1786
  if summary_mode or deep_research_mode:
1787
  answer, search_results, sources = await _research_snapshot(context, model)
 
 
1788
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
1789
  _safe_metrics_increment("search_handoffs")
1790
  return AgentResponse(
src/api/search.py CHANGED
@@ -1,6 +1,6 @@
1
  import logging
2
  import re
3
- from typing import Dict, List, Optional
4
  from urllib.parse import quote
5
 
6
  import httpx
@@ -12,90 +12,68 @@ from src.config import get_settings
12
  logger = logging.getLogger(__name__)
13
  router = APIRouter()
14
 
15
- PRIMO_BASE = "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU"
16
- PRIMO_AI_BASE = "https://khalifa.primo.exlibrisgroup.com/discovery/researchAssistant?vid=971KUOSTAR_INST:KU&query="
17
- EBSCO_AI_BASE = "https://research-ebsco-com.khalifa.idm.oclc.org/c/ntdlkp/search?q="
18
- SCOPUS_AI_BASE = "https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query="
19
- LEAPSPACE_BASE = "https://www.sciencedirect.com/leapspace?query="
20
- CONSENSUS_BASE = "https://consensus.app/results/?q="
21
- CURRENT_YEAR = 2026
22
-
23
- PREFIX_PATTERNS = [
24
- r"^summari[sz]e\s+the\s+latest\s+studies\s+in\s+",
25
- r"^summari[sz]e\s+the\s+latest\s+research\s+on\s+",
26
- r"^summari[sz]e\s+recent\s+studies\s+in\s+",
27
- r"^summari[sz]e\s+recent\s+research\s+on\s+",
28
- r"^give\s+me\s+a\s+summary\s+of\s+",
29
- r"^give\s+me\s+an\s+overview\s+of\s+",
30
- r"^give\s+me\s+a\s+brief\s+overview\s+of\s+",
31
- r"^find\s+(?:peer[- ]reviewed\s+)?articles?\s+on\s+",
32
- r"^find\s+(?:peer[- ]reviewed\s+)?papers?\s+on\s+",
33
- r"^find\s+books?\s+on\s+",
34
- r"^search\s+for\s+articles?\s+on\s+",
35
- r"^search\s+for\s+papers?\s+on\s+",
36
- r"^i\s+need\s+(?:peer[- ]reviewed\s+)?articles?\s+on\s+",
37
- r"^i\s+need\s+papers?\s+on\s+",
38
- r"^i\s+need\s+books?\s+on\s+",
39
- r"^show\s+me\s+articles?\s+on\s+",
40
- r"^get\s+me\s+articles?\s+on\s+",
41
- r"^tell\s+me\s+about\s+",
42
- r"^explain\s+",
43
- r"^overview\s+of\s+",
44
- r"^summary\s+of\s+",
45
- r"^latest\s+studies\s+in\s+",
46
- r"^latest\s+research\s+on\s+",
47
- r"^recent\s+research\s+on\s+",
48
- r"^recent\s+studies\s+in\s+",
49
- ]
50
 
51
- RESOURCE_NOISE_RE = re.compile(
52
  r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
53
- r"|peer[- ]reviewed|open[- ]access|studies|study|research|latest|recent)\b",
 
54
  re.IGNORECASE,
55
  )
56
 
57
- STOP_WORDS = {
58
- "the", "a", "an", "and", "or", "of", "on", "in", "for", "to", "with", "by",
59
- "from", "at", "about", "latest", "recent", "current", "summary", "overview", "brief",
60
- "analysis", "review", "deep", "find", "search", "show", "give", "me", "need", "please",
61
- }
62
 
63
- PHRASE_SYNONYMS: Dict[str, List[str]] = {
64
- "global warming": ["climate change"],
65
- "climate change": ["global warming"],
66
- "machine learning": ["artificial intelligence", "AI"],
67
- "artificial intelligence": ["AI", "machine learning"],
68
- "deep learning": ["neural networks"],
69
- "renewable energy": ["clean energy", "sustainable energy"],
70
- "energy transition": ["decarbonization"],
71
- "cyber security": ["cybersecurity", "information security"],
72
- "cybersecurity": ["cyber security", "information security"],
73
- "public health": ["population health"],
74
- "gene editing": ["CRISPR"],
75
- "mental health": ["psychological health"],
76
- "higher education": ["tertiary education", "universities"],
77
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  TOKEN_SYNONYMS: Dict[str, List[str]] = {
80
  "healthcare": ["medicine", "clinical"],
81
- "medical": ["clinical", "medicine"],
82
- "nursing": ["clinical", "patient care"],
83
  "education": ["teaching", "learning"],
84
- "students": ["student"],
85
  "student": ["students"],
 
 
 
 
86
  "sustainability": ["sustainable development"],
87
- "cancer": ["oncology"],
88
- "heart": ["cardiovascular"],
89
- "brain": ["neurology", "neuroscience"],
90
- "politics": ["political"],
91
- "engineering": ["engineer*"],
92
  }
93
 
94
- MEDICAL_HINT_RE = re.compile(
95
- r"\b(medicine|medical|health|clinical|nursing|pharmacy|biomedical|pubmed|embase|cinahl|cochrane|uptodate|disease|therapy|treatment|diagnosis|patient|hospital|cancer|diabetes)\b",
96
- re.IGNORECASE,
97
- )
98
-
99
 
100
  class SearchRequest(BaseModel):
101
  query: str
@@ -136,8 +114,9 @@ class SearchRequest(BaseModel):
136
  @field_validator("resource_type")
137
  @classmethod
138
  def valid_resource_type(cls, v: str) -> str:
139
- allowed = {"articles", "books", "both"}
140
- return v if v in allowed else "articles"
 
141
 
142
 
143
  class SearchPlanRequest(BaseModel):
@@ -158,7 +137,6 @@ def _safe_metrics_increment(key: str) -> None:
158
  return
159
 
160
 
161
-
162
  def _safe_metrics_bucket(bucket: str, key: str) -> None:
163
  try:
164
  from app import metrics_service
@@ -167,142 +145,142 @@ def _safe_metrics_bucket(bucket: str, key: str) -> None:
167
  return
168
 
169
 
170
-
171
- def _normalize_whitespace(text: str) -> str:
172
  return re.sub(r"\s+", " ", (text or "").strip())
173
 
174
 
175
-
176
- def _strip_resource_noise(query: str) -> str:
177
- cleaned = RESOURCE_NOISE_RE.sub(" ", query or "")
 
178
  cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
179
  cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
180
  cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
181
- return _normalize_whitespace(cleaned)
182
 
183
 
 
 
 
 
 
 
 
 
184
 
185
- def _extract_topic(question: str) -> str:
186
- q = _normalize_whitespace(question)
 
187
  lower = q.lower()
188
- for pat in PREFIX_PATTERNS:
189
- lower_after = re.sub(pat, "", lower, count=1)
190
- if lower_after != lower:
191
- lower = lower_after
192
  break
193
- lower = re.sub(r"\b(last|past)\s+\d{1,2}\s+years?\b", " ", lower)
194
- lower = re.sub(r"\bfrom\s+(19|20)\d{2}\s+(?:to|and|-)+\s+(19|20)\d{2}\b", " ", lower)
195
- lower = re.sub(r"\b(19|20)\d{2}\b", " ", lower)
196
- lower = _strip_resource_noise(lower)
197
- return lower.strip(" .?") or _strip_resource_noise(question)
198
-
199
 
200
 
201
- def _derive_resource_type_from_text(question: str, override: Optional[str] = None) -> str:
202
- if override in {"articles", "books", "both"}:
203
- return override
204
  q = (question or "").lower()
205
- if any(x in q for x in ["books", "book", "ebook", "ebooks"]):
206
  return "books"
207
- if any(x in q for x in ["articles", "article", "papers", "paper", "journals", "journal", "studies", "study"]):
208
  return "articles"
209
- return "both"
210
 
211
 
212
-
213
- def _is_medical_query(question: str) -> bool:
214
- return bool(MEDICAL_HINT_RE.search(question or ""))
215
-
216
 
217
 
218
  def _quote_term(term: str) -> str:
219
- term = _normalize_whitespace(term)
220
  if not term:
221
  return ""
222
- if "*" in term:
223
  return term
224
- if " " in term or "-" in term:
225
  return f'"{term}"'
226
- return term
227
-
228
-
229
 
230
- def _longest_phrase_match(tokens: List[str], start: int) -> Optional[str]:
231
- max_len = min(3, len(tokens) - start)
232
- for length in range(max_len, 0, -1):
233
- phrase = " ".join(tokens[start:start + length]).lower()
234
- if phrase in PHRASE_SYNONYMS:
235
- return phrase
236
- return None
237
 
 
 
 
 
 
 
 
 
 
 
238
 
239
 
240
- def _concept_groups(topic: str) -> List[List[str]]:
241
- topic = _normalize_whitespace(topic).lower()
242
- raw_tokens = [t for t in re.findall(r"[a-z0-9]+", topic) if t not in STOP_WORDS and len(t) > 2]
243
  groups: List[List[str]] = []
244
- i = 0
245
- while i < len(raw_tokens):
246
- phrase = _longest_phrase_match(raw_tokens, i)
247
- if phrase:
248
- synonyms = PHRASE_SYNONYMS.get(phrase, [])
249
- group = [phrase] + [s for s in synonyms if s.lower() != phrase]
250
- groups.append(group)
251
- i += len(phrase.split())
252
- continue
253
- token = raw_tokens[i]
254
- group = [token]
255
- group.extend([s for s in TOKEN_SYNONYMS.get(token, []) if s.lower() != token])
256
- groups.append(group)
257
- i += 1
258
-
259
- if not groups and topic:
260
- return [[topic]]
261
-
262
- # collapse duplicate/single-token noise but preserve order
263
- cleaned_groups: List[List[str]] = []
264
- seen_primary = set()
265
- for group in groups:
266
- primary = group[0].lower()
267
- if primary in seen_primary:
 
 
 
 
268
  continue
269
- seen_primary.add(primary)
270
- unique_terms = []
271
- seen_terms = set()
272
- for term in group:
273
- norm = term.lower()
274
- if norm in seen_terms:
275
- continue
276
- seen_terms.add(norm)
277
- unique_terms.append(term)
278
- cleaned_groups.append(unique_terms)
279
- return cleaned_groups[:4]
280
-
281
-
282
-
283
- def build_primo_boolean_query(topic: str) -> str:
284
- clean_topic = _strip_resource_noise(topic)
285
- groups = _concept_groups(clean_topic)
286
- clauses: List[str] = []
287
- for group in groups:
288
- if not group:
289
- continue
290
- if len(group) == 1:
291
- clauses.append(_quote_term(group[0]))
292
  else:
293
- clauses.append("(" + " OR ".join(_quote_term(term) for term in group) + ")")
294
- if not clauses:
295
- return _quote_term(clean_topic or topic)
296
- return " AND ".join(clauses)
297
 
 
 
 
 
 
 
 
298
 
299
 
300
- def _primo_facets(resource_type: str = "both", peer_reviewed: bool = False, open_access: bool = False, year_from: Optional[str] = None, year_to: Optional[str] = None) -> List[str]:
301
- facets: List[str] = []
 
302
  if resource_type == "articles":
303
  facets.append("rtype,include,articles")
 
 
304
  elif resource_type == "books":
305
  facets.append("rtype,include,books")
 
 
 
 
 
306
  if peer_reviewed:
307
  facets.append("tlevel,include,peer_reviewed")
308
  if open_access:
@@ -311,76 +289,49 @@ def _primo_facets(resource_type: str = "both", peer_reviewed: bool = False, open
311
  yf = year_from or "0001"
312
  yt = year_to or "9999"
313
  facets.append(f"searchcreationdate,include,{yf}|,|{yt}")
314
- return facets
315
-
316
-
317
-
318
- def build_primo_discovery_url(boolean_query: str, resource_type: str = "both", peer_reviewed: bool = False, open_access: bool = False, year_from: Optional[str] = None, year_to: Optional[str] = None, tab: str = "Everything", scope: str = "MyInst_and_CI") -> str:
319
- url = (
320
- f"{PRIMO_BASE}&query=any,contains,{quote(boolean_query)}"
321
- f"&tab={tab}&search_scope={scope}&lang=en&mode=advanced&offset=0"
322
- )
323
- for facet in _primo_facets(resource_type, peer_reviewed, open_access, year_from, year_to):
324
  url += f"&facet={quote(facet)}"
325
  return url
326
 
327
 
328
-
329
- def build_pubmed_url(query: str, year_from: Optional[str] = None, year_to: Optional[str] = None, peer_reviewed: bool = False) -> str:
330
- clean_query = _strip_resource_noise(query) or query
331
- term = clean_query
332
- if peer_reviewed:
333
- term = f"({term}) AND Journal Article[pt]"
334
- url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(term)}"
335
- if year_from or year_to:
336
- yf = year_from or "1900"
337
- yt = year_to or str(CURRENT_YEAR)
338
- url += f"&filter=datesearch.y_custom&dates.from={yf}&dates.to={yt}"
339
- return url
340
-
341
-
342
- async def _grammar_refine_query(text: str) -> str:
343
- settings = get_settings()
344
- if not settings.openai_api_key:
345
- return _normalize_whitespace(text)
346
- try:
347
- from langchain_openai import ChatOpenAI
348
- llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=60, openai_api_key=settings.openai_api_key)
349
- response = await llm.ainvoke([
350
- {"role": "system", "content": "Rewrite the user's search query in clear grammatical English. Keep meaning exactly the same. Return one sentence only."},
351
- {"role": "user", "content": text},
352
- ])
353
- return _normalize_whitespace(response.content) or _normalize_whitespace(text)
354
- except Exception:
355
- return _normalize_whitespace(text)
356
 
357
 
358
  @router.post("/plan")
359
  async def search_plan_endpoint(req: SearchPlanRequest):
360
- _safe_metrics_increment("search_plans")
361
- topic = _extract_topic(req.question)
362
- resource_type = _derive_resource_type_from_text(req.question, req.resource_type)
363
- boolean_query = build_primo_boolean_query(topic)
364
- ai_tool_query = await _grammar_refine_query(req.question)
365
- is_medical = _is_medical_query(req.question) or _is_medical_query(topic)
366
-
367
- return {
368
- "topic": topic,
369
- "display_topic": topic.title(),
370
- "resource_type": resource_type,
371
- "is_medical": is_medical,
372
- "ai_tool_query": ai_tool_query,
373
- "primo_boolean_query": boolean_query,
374
- "primo_all_url": build_primo_discovery_url(boolean_query, "both", req.peer_reviewed, req.open_access, req.year_from, req.year_to, "Everything", "MyInst_and_CI"),
375
- "primo_articles_url": build_primo_discovery_url(boolean_query, "articles", req.peer_reviewed, req.open_access, req.year_from, req.year_to, "CentralIndex", "CentralIndex"),
376
- "primo_books_url": build_primo_discovery_url(boolean_query, "books", req.peer_reviewed, req.open_access, req.year_from, req.year_to, "Everything", "MyInst_and_CI"),
377
- "primo_ai_url": f"{PRIMO_AI_BASE}{quote(ai_tool_query)}",
378
- "pubmed_url": build_pubmed_url(topic, req.year_from, req.year_to, req.peer_reviewed) if is_medical else None,
379
- "ebsco_ai_url": f"{EBSCO_AI_BASE}{quote(ai_tool_query)}",
380
- "scopus_ai_url": f"{SCOPUS_AI_BASE}{quote(ai_tool_query)}",
381
- "leapspace_url": f"{LEAPSPACE_BASE}{quote(ai_tool_query)}",
382
- "consensus_url": f"{CONSENSUS_BASE}{quote(ai_tool_query)}",
383
- }
384
 
385
 
386
  @router.post("")
@@ -401,55 +352,59 @@ async def search_primo(req: SearchRequest, api_key: str):
401
  _safe_metrics_bucket("errors", "primo_missing_api_key")
402
  return {"error": "PRIMO_API_KEY not configured", "results": [], "source": "PRIMO"}
403
 
404
- clean_query = _strip_resource_noise(req.query)
405
- if not clean_query:
406
- clean_query = req.query
407
-
408
- boolean_query = req.boolean_query or build_primo_boolean_query(clean_query)
409
- query_for_api = boolean_query
410
 
411
- def _build_api_url(query_text: str) -> str:
 
412
  url = (
413
  f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
414
  f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
415
- f"&q=any,contains,{quote(query_text)}&lang=en&sort=rank&limit={req.limit}"
416
  f"&offset=0&mode=advanced&apikey={api_key}"
417
  )
418
- api_facets = []
419
  if req.resource_type == "articles":
420
- api_facets.append("facet_rtype,include,articles")
421
  elif req.resource_type == "books":
422
- api_facets.append("facet_rtype,include,books")
423
  if req.peer_reviewed:
424
- api_facets.append("facet_tlevel,include,peer_reviewed")
425
  if req.open_access:
426
- api_facets.append("facet_tlevel,include,online_resources")
427
  if req.year_from or req.year_to:
428
  yf = req.year_from or "0001"
429
  yt = req.year_to or "9999"
430
- api_facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
431
- for facet in api_facets:
432
  url += f"&multiFacets={quote(facet)}"
433
  return url
434
 
435
- logger.info("PRIMO boolean=%r clean_query=%r resource_type=%s", boolean_query, clean_query, req.resource_type)
436
 
437
  try:
438
  async with httpx.AsyncClient(timeout=20) as client:
439
- url = _build_api_url(query_for_api)
440
- r = await client.get(url, headers={"Accept": "application/json"})
441
- if r.status_code == 400 and boolean_query != clean_query:
442
- logger.warning("PRIMO 400 on boolean query; falling back to clean keywords")
443
- url = _build_api_url(clean_query)
444
- r = await client.get(url, headers={"Accept": "application/json"})
445
  logger.info("PRIMO status=%s", r.status_code)
446
  if r.status_code != 200:
447
  _safe_metrics_bucket("errors", f"primo_{r.status_code}")
448
  return {"error": f"PRIMO API error: {r.status_code}", "results": [], "source": "PRIMO"}
449
-
450
  data = r.json()
451
  docs = data.get("docs", [])
452
- discovery_url = build_primo_discovery_url(boolean_query, req.resource_type, req.peer_reviewed, req.open_access, req.year_from, req.year_to)
 
 
 
 
 
 
 
 
453
  results = []
454
  for doc in docs[: req.limit]:
455
  pnx = doc.get("pnx", {})
@@ -464,7 +419,6 @@ async def search_primo(req: SearchRequest, api_key: str):
464
  "_source": "PRIMO",
465
  })
466
  return {"total": data.get("info", {}).get("total", 0), "results": results, "source": "PRIMO"}
467
-
468
  except httpx.TimeoutException:
469
  _safe_metrics_bucket("errors", "primo_timeout")
470
  return {"error": "PRIMO API timed out — please try again", "results": [], "source": "PRIMO"}
@@ -475,9 +429,7 @@ async def search_primo(req: SearchRequest, api_key: str):
475
 
476
 
477
  async def search_pubmed(req: SearchRequest):
478
- clean_query = _strip_resource_noise(req.query)
479
- if not clean_query:
480
- clean_query = req.query
481
  term = clean_query
482
  if req.peer_reviewed:
483
  term = f"({term}) AND Journal Article[pt]"
@@ -538,7 +490,11 @@ async def search_pubmed(req: SearchRequest):
538
 
539
  async def search_scholar(req: SearchRequest):
540
  try:
541
- params = {"query": req.query, "limit": req.limit, "fields": "title,authors,year,venue,externalIds,citationCount"}
 
 
 
 
542
  async with httpx.AsyncClient(timeout=15) as client:
543
  r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
544
  if r.status_code != 200:
 
1
  import logging
2
  import re
3
+ from typing import Optional, List, Dict
4
  from urllib.parse import quote
5
 
6
  import httpx
 
12
  logger = logging.getLogger(__name__)
13
  router = APIRouter()
14
 
15
+ PRIMO_BASE_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU"
16
+ PRIMO_AI_URL = "https://khalifa.primo.exlibrisgroup.com/discovery/researchAssistant?vid=971KUOSTAR_INST:KU&query="
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ _RESOURCE_NOISE_RE = re.compile(
19
  r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
20
+ r"|peer[- ]reviewed|open[- ]access|studies|study|research|latest|recent|current"
21
+ r"|summary|summarize|summarise|overview|brief|analysis|review|deep|dive)\b",
22
  re.IGNORECASE,
23
  )
24
 
25
+ _MEDICAL_HINT_RE = re.compile(
26
+ r"\b(pubmed|embase|cinahl|clinicalkey|cochrane|uptodate|medline|medicine|medical|health|clinical|nursing|pharmacy|biomedical|disease|therapy|treatment|diagnosis|patient|cancer|diabetes|epidemiology|oncology|cardiology)\b",
27
+ re.IGNORECASE,
28
+ )
 
29
 
30
+ _PREFIXES = [
31
+ "summarize the latest studies in ", "summarize latest studies in ",
32
+ "summarize the latest research on ", "summarize latest research on ",
33
+ "summarize recent studies in ", "summarize recent research on ",
34
+ "latest studies in ", "latest research on ", "recent studies in ", "recent research on ",
35
+ "give me a summary of ", "give me an overview of ", "give me a brief on ",
36
+ "give me articles on ", "peer reviewed papers on ", "peer-reviewed papers on ", "peer reviewed articles on ", "peer-reviewed articles on ",
37
+ "find peer reviewed articles on ", "find peer-reviewed articles on ",
38
+ "find articles on ", "find papers on ", "find books on ",
39
+ "search for articles on ", "search for papers on ", "look for articles on ",
40
+ "i need articles on ", "i need books on ", "research on ", "literature on ",
41
+ "tell me about ", "explain ", "what is ", "what are ",
42
+ ]
43
+
44
+ PHRASE_SYNONYMS: List[tuple[str, List[str]]] = [
45
+ ("global warming", ["climate change"]),
46
+ ("climate change", ["global warming"]),
47
+ ("machine learning", ["artificial intelligence", "AI"]),
48
+ ("artificial intelligence", ["AI", "machine learning"]),
49
+ ("higher education", ["universities", "tertiary education"]),
50
+ ("university libraries", ["academic libraries", "academic library"]),
51
+ ("university library", ["academic library"]),
52
+ ("renewable energy", ["sustainable energy", "clean energy"]),
53
+ ("solar energy", ["solar power", "photovoltaics"]),
54
+ ("cyber security", ["cybersecurity", "information security"]),
55
+ ("internet of things", ["IoT"]),
56
+ ("big data", ["data analytics"]),
57
+ ("public health", ["population health"]),
58
+ ("gene editing", ["CRISPR", "genome editing"]),
59
+ ("mental health", ["psychological wellbeing", "wellbeing"]),
60
+ ("supply chain", ["logistics"]),
61
+ ("academic writing", ["scholarly writing"]),
62
+ ]
63
 
64
  TOKEN_SYNONYMS: Dict[str, List[str]] = {
65
  "healthcare": ["medicine", "clinical"],
66
+ "agriculture": ["farming"],
 
67
  "education": ["teaching", "learning"],
 
68
  "student": ["students"],
69
+ "students": ["student"],
70
+ "library": ["libraries"],
71
+ "libraries": ["library"],
72
+ "energy": ["power"],
73
  "sustainability": ["sustainable development"],
74
+ "older": ["aging"],
 
 
 
 
75
  }
76
 
 
 
 
 
 
77
 
78
  class SearchRequest(BaseModel):
79
  query: str
 
114
  @field_validator("resource_type")
115
  @classmethod
116
  def valid_resource_type(cls, v: str) -> str:
117
+ if v not in {"articles", "books", "both"}:
118
+ return "articles"
119
+ return v
120
 
121
 
122
  class SearchPlanRequest(BaseModel):
 
137
  return
138
 
139
 
 
140
  def _safe_metrics_bucket(bucket: str, key: str) -> None:
141
  try:
142
  from app import metrics_service
 
145
  return
146
 
147
 
148
+ def _normalize(text: str) -> str:
 
149
  return re.sub(r"\s+", " ", (text or "").strip())
150
 
151
 
152
+ def strip_resource_noise(query: str) -> str:
153
+ cleaned = _RESOURCE_NOISE_RE.sub(" ", query or "")
154
+ cleaned = re.sub(r"\b(last|past)\s+\d+\s+years?\b", " ", cleaned, flags=re.IGNORECASE)
155
+ cleaned = re.sub(r"\b(19\d{2}|20\d{2})\b", " ", cleaned)
156
  cleaned = re.sub(r"\s+(AND|OR)\s+(AND|OR)\s+", " AND ", cleaned, flags=re.IGNORECASE)
157
  cleaned = re.sub(r"^\s*(AND|OR)\s+", "", cleaned, flags=re.IGNORECASE)
158
  cleaned = re.sub(r"\s+(AND|OR)\s*$", "", cleaned, flags=re.IGNORECASE)
159
+ return _normalize(cleaned).strip(".?,")
160
 
161
 
162
+ def _light_correct_question(question: str) -> str:
163
+ q = _normalize(question)
164
+ q = re.sub(r"\bpeer reviewd\b", "peer reviewed", q, flags=re.IGNORECASE)
165
+ q = re.sub(r"\bpubmedd\b", "PubMed", q, flags=re.IGNORECASE)
166
+ q = q.rstrip(".?")
167
+ if q and not q.endswith("."):
168
+ q += "."
169
+ return q
170
 
171
+
172
+ def extract_topic_from_question(question: str) -> str:
173
+ q = _normalize(question)
174
  lower = q.lower()
175
+ for prefix in _PREFIXES:
176
+ if lower.startswith(prefix):
177
+ q = q[len(prefix):]
 
178
  break
179
+ q = strip_resource_noise(q)
180
+ q = re.sub(r"^(on|about|for)\s+", "", q, flags=re.IGNORECASE)
181
+ return _normalize(q).strip(".?") or _normalize(question).strip(".?")
 
 
 
182
 
183
 
184
+ def derive_resource_type(question: str, explicit: Optional[str] = None) -> str:
185
+ if explicit in {"articles", "books", "both"}:
186
+ return explicit
187
  q = (question or "").lower()
188
+ if any(t in q for t in ["book", "books", "ebook", "ebooks"]):
189
  return "books"
190
+ if any(t in q for t in ["article", "articles", "paper", "papers", "journal", "journals", "study", "studies"]):
191
  return "articles"
192
+ return "articles"
193
 
194
 
195
+ def is_medical_query(question: str, topic: str = "") -> bool:
196
+ return bool(_MEDICAL_HINT_RE.search(f"{question} {topic}"))
 
 
197
 
198
 
199
  def _quote_term(term: str) -> str:
200
+ term = _normalize(term)
201
  if not term:
202
  return ""
203
+ if re.fullmatch(r"[A-Z]{2,6}", term):
204
  return term
205
+ if re.fullmatch(r"[A-Za-z0-9_-]+", term):
206
  return f'"{term}"'
207
+ return f'"{term}"'
 
 
208
 
 
 
 
 
 
 
 
209
 
210
+ def _dedupe_keep_order(items: List[str]) -> List[str]:
211
+ seen = set()
212
+ out = []
213
+ for item in items:
214
+ norm = item.lower().strip()
215
+ if not norm or norm in seen:
216
+ continue
217
+ seen.add(norm)
218
+ out.append(item)
219
+ return out
220
 
221
 
222
+ def _build_concept_groups(topic: str) -> List[List[str]]:
223
+ work = f" {_normalize(topic).lower()} "
 
224
  groups: List[List[str]] = []
225
+ phrase_keys = sorted(PHRASE_SYNONYMS, key=lambda x: len(x[0]), reverse=True)
226
+ for phrase, syns in phrase_keys:
227
+ needle = f" {phrase} "
228
+ if needle in work:
229
+ groups.append(_dedupe_keep_order([phrase] + syns))
230
+ work = work.replace(needle, " ")
231
+
232
+ leftovers = [w for w in re.findall(r"[a-z0-9]+", work) if len(w) > 2 and w not in {
233
+ "and", "or", "for", "with", "from", "into", "onto", "about", "using", "based", "among",
234
+ "between", "effects", "effect", "impact", "role", "use", "uses", "application", "applications",
235
+ }]
236
+ for token in leftovers:
237
+ groups.append(_dedupe_keep_order([token] + TOKEN_SYNONYMS.get(token, [])))
238
+
239
+ if not groups:
240
+ cleaned = strip_resource_noise(topic)
241
+ if cleaned:
242
+ groups = [[cleaned]]
243
+ return groups
244
+
245
+
246
+ def build_boolean_query(topic: str) -> str:
247
+ groups = _build_concept_groups(topic)
248
+ parts = []
249
+ for group in groups[:4]:
250
+ tokens = [_quote_term(term) for term in group if term]
251
+ tokens = [t for t in tokens if t]
252
+ if not tokens:
253
  continue
254
+ if len(tokens) == 1:
255
+ parts.append(f"({tokens[0]})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  else:
257
+ parts.append("(" + " OR ".join(tokens) + ")")
258
+ return " AND ".join(parts) if parts else f'({_quote_term(topic)})'
259
+
 
260
 
261
+ def build_pubmed_url(query: str, year_from: Optional[str] = None, year_to: Optional[str] = None) -> str:
262
+ url = f"https://pubmed.ncbi.nlm.nih.gov/?term={quote(strip_resource_noise(query) or query)}"
263
+ if year_from or year_to:
264
+ yf = year_from or "1900"
265
+ yt = year_to or "3000"
266
+ url += f"&filter=datesearch.y_custom&dates.from={yf}&dates.to={yt}"
267
+ return url
268
 
269
 
270
+ def build_primo_discovery_url(boolean_query: str, resource_type: str = "both", peer_reviewed: bool = False,
271
+ open_access: bool = False, year_from: Optional[str] = None, year_to: Optional[str] = None) -> str:
272
+ facets = []
273
  if resource_type == "articles":
274
  facets.append("rtype,include,articles")
275
+ tab = "CentralIndex"
276
+ scope = "CentralIndex"
277
  elif resource_type == "books":
278
  facets.append("rtype,include,books")
279
+ tab = "Everything"
280
+ scope = "MyInst_and_CI"
281
+ else:
282
+ tab = "Everything"
283
+ scope = "MyInst_and_CI"
284
  if peer_reviewed:
285
  facets.append("tlevel,include,peer_reviewed")
286
  if open_access:
 
289
  yf = year_from or "0001"
290
  yt = year_to or "9999"
291
  facets.append(f"searchcreationdate,include,{yf}|,|{yt}")
292
+ url = f"{PRIMO_BASE_URL}&query=any,contains,{quote(boolean_query)}&lang=en&mode=advanced&offset=0&tab={tab}&search_scope={scope}"
293
+ for facet in facets:
 
 
 
 
 
 
 
 
294
  url += f"&facet={quote(facet)}"
295
  return url
296
 
297
 
298
+ def build_search_plan(question: str, peer_reviewed: bool = False, open_access: bool = False,
299
+ year_from: Optional[str] = None, year_to: Optional[str] = None,
300
+ resource_type: Optional[str] = None) -> dict:
301
+ corrected_question = _light_correct_question(question)
302
+ topic = extract_topic_from_question(corrected_question)
303
+ resource = derive_resource_type(corrected_question, resource_type)
304
+ medical = is_medical_query(corrected_question, topic)
305
+ bool_query = build_boolean_query(topic)
306
+ all_url = build_primo_discovery_url(bool_query, "both", peer_reviewed, open_access, year_from, year_to)
307
+ art_url = build_primo_discovery_url(bool_query, "articles", peer_reviewed, open_access, year_from, year_to)
308
+ book_url = build_primo_discovery_url(bool_query, "books", peer_reviewed, open_access, year_from, year_to)
309
+ return {
310
+ "question": corrected_question,
311
+ "topic": topic,
312
+ "display_topic": topic,
313
+ "resource_type": resource,
314
+ "is_medical": medical,
315
+ "ai_tool_query": corrected_question,
316
+ "primo_boolean_query": bool_query,
317
+ "primo_all_url": all_url,
318
+ "primo_articles_url": art_url,
319
+ "primo_books_url": book_url,
320
+ "primo_ai_url": f"{PRIMO_AI_URL}{quote(corrected_question)}",
321
+ "pubmed_url": build_pubmed_url(topic, year_from, year_to) if medical else "",
322
+ }
 
 
 
323
 
324
 
325
  @router.post("/plan")
326
  async def search_plan_endpoint(req: SearchPlanRequest):
327
+ return build_search_plan(
328
+ req.question,
329
+ peer_reviewed=req.peer_reviewed,
330
+ open_access=req.open_access,
331
+ year_from=req.year_from,
332
+ year_to=req.year_to,
333
+ resource_type=req.resource_type,
334
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
 
337
  @router.post("")
 
352
  _safe_metrics_bucket("errors", "primo_missing_api_key")
353
  return {"error": "PRIMO_API_KEY not configured", "results": [], "source": "PRIMO"}
354
 
355
+ clean_query = strip_resource_noise(req.query) or req.query
356
+ use_boolean = bool(req.boolean_query and req.boolean_query.strip())
357
+ actual_query = req.boolean_query.strip() if use_boolean else clean_query
 
 
 
358
 
359
+ def make_url(q: str) -> str:
360
+ query_encoded = quote(q)
361
  url = (
362
  f"https://api-eu.hosted.exlibrisgroup.com/primo/v1/search"
363
  f"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
364
+ f"&q=any,contains,{query_encoded}&lang=en&sort=rank&limit={req.limit}"
365
  f"&offset=0&mode=advanced&apikey={api_key}"
366
  )
367
+ facets = []
368
  if req.resource_type == "articles":
369
+ facets.append("facet_rtype,include,articles")
370
  elif req.resource_type == "books":
371
+ facets.append("facet_rtype,include,books")
372
  if req.peer_reviewed:
373
+ facets.append("facet_tlevel,include,peer_reviewed")
374
  if req.open_access:
375
+ facets.append("facet_tlevel,include,online_resources")
376
  if req.year_from or req.year_to:
377
  yf = req.year_from or "0001"
378
  yt = req.year_to or "9999"
379
+ facets.append(f"facet_searchcreationdate,include,{yf}|,|{yt}")
380
+ for facet in facets:
381
  url += f"&multiFacets={quote(facet)}"
382
  return url
383
 
384
+ logger.info("PRIMO actual_query=%r clean_query=%r boolean=%s", actual_query, clean_query, use_boolean)
385
 
386
  try:
387
  async with httpx.AsyncClient(timeout=20) as client:
388
+ r = await client.get(make_url(actual_query), headers={"Accept": "application/json"})
389
+ if r.status_code == 400 and use_boolean:
390
+ logger.warning("PRIMO rejected boolean query; falling back to clean keywords")
391
+ r = await client.get(make_url(clean_query), headers={"Accept": "application/json"})
392
+ actual_query = clean_query
 
393
  logger.info("PRIMO status=%s", r.status_code)
394
  if r.status_code != 200:
395
  _safe_metrics_bucket("errors", f"primo_{r.status_code}")
396
  return {"error": f"PRIMO API error: {r.status_code}", "results": [], "source": "PRIMO"}
 
397
  data = r.json()
398
  docs = data.get("docs", [])
399
+ display_query = req.boolean_query.strip() if req.boolean_query else clean_query
400
+ discovery_url = build_primo_discovery_url(
401
+ display_query,
402
+ req.resource_type,
403
+ req.peer_reviewed,
404
+ req.open_access,
405
+ req.year_from,
406
+ req.year_to,
407
+ )
408
  results = []
409
  for doc in docs[: req.limit]:
410
  pnx = doc.get("pnx", {})
 
419
  "_source": "PRIMO",
420
  })
421
  return {"total": data.get("info", {}).get("total", 0), "results": results, "source": "PRIMO"}
 
422
  except httpx.TimeoutException:
423
  _safe_metrics_bucket("errors", "primo_timeout")
424
  return {"error": "PRIMO API timed out — please try again", "results": [], "source": "PRIMO"}
 
429
 
430
 
431
  async def search_pubmed(req: SearchRequest):
432
+ clean_query = strip_resource_noise(req.query) or req.query
 
 
433
  term = clean_query
434
  if req.peer_reviewed:
435
  term = f"({term}) AND Journal Article[pt]"
 
490
 
491
  async def search_scholar(req: SearchRequest):
492
  try:
493
+ params = {
494
+ "query": req.query,
495
+ "limit": req.limit,
496
+ "fields": "title,authors,year,venue,externalIds,citationCount",
497
+ }
498
  async with httpx.AsyncClient(timeout=15) as client:
499
  r = await client.get("https://api.semanticscholar.org/graph/v1/paper/search", params=params)
500
  if r.status_code != 200: