nikeshn commited on
Commit
5ef7c27
Β·
verified Β·
1 Parent(s): 48d5d76

Upload 2 files

Browse files
Files changed (2) hide show
  1. src/api/agent.py +200 -135
  2. src/api/search.py +31 -4
src/api/agent.py CHANGED
@@ -213,6 +213,11 @@ SUMMARY_RE = re.compile(
213
  re.IGNORECASE,
214
  )
215
 
 
 
 
 
 
216
  MEDICAL_KEYWORDS = [
217
  "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
218
  "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
@@ -1022,6 +1027,7 @@ async def _search_pubmed_articles(context: SearchContextPayload, limit: int = 5)
1022
 
1023
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1024
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
 
1025
  return {
1026
  "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1027
  "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
@@ -1030,119 +1036,164 @@ def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1030
  }
1031
 
1032
 
1033
- _BOOLEAN_PHRASE_SYNONYMS: Dict[str, List[str]] = {
1034
- "global warming": ["climate change"],
1035
- "artificial intelligence": ["AI", "machine learning"],
1036
- "machine learning": ["artificial intelligence", "AI"],
1037
- "healthcare": ["clinical", "medicine"],
1038
- "energy transition": ["decarbonization", "low carbon"],
1039
- }
1040
- _BOOLEAN_TOKEN_SYNONYMS: Dict[str, List[str]] = {
1041
- "india": ["indian"],
1042
- "adaptation": ["resilience"],
1043
- "mitigation": ["emissions reduction"],
1044
- "transport": ["transportation"],
1045
- "climate": ["warming"],
1046
- }
1047
-
1048
-
1049
- def _boolean_or_group(terms: List[str]) -> str:
1050
- clean=[]
1051
- seen=set()
1052
- for term in terms:
1053
- t=_normalize_whitespace(term).strip('"').strip()
1054
- if not t:
1055
- continue
1056
- key=t.lower()
1057
- if key in seen:
1058
- continue
1059
- seen.add(key)
1060
- if re.search(r"\s", t):
1061
- clean.append(f'"{t}"')
1062
- else:
1063
- clean.append(t)
1064
- if not clean:
1065
- return '"library search"'
1066
- if len(clean)==1:
1067
- return clean[0]
1068
- return '(' + ' OR '.join(clean) + ')'
1069
-
1070
-
1071
- def _make_primo_boolean_query(context: SearchContextPayload) -> str:
1072
- topic = _strip_resource_noise(_title_case_topic(context.display_topic or context.topic))
1073
- if not topic:
1074
- return '"library search"'
1075
- lower = topic.lower()
1076
- groups: List[str] = []
1077
- consumed = lower
1078
- for phrase, syns in sorted(_BOOLEAN_PHRASE_SYNONYMS.items(), key=lambda kv: len(kv[0]), reverse=True):
1079
- if phrase in consumed:
1080
- groups.append(_boolean_or_group([phrase] + syns))
1081
- consumed = consumed.replace(phrase, ' ')
1082
- for token in re.findall(r"[a-z0-9-]+", consumed):
1083
- if len(token) < 3:
1084
- continue
1085
- groups.append(_boolean_or_group([token] + _BOOLEAN_TOKEN_SYNONYMS.get(token, [])))
1086
- if not groups:
1087
- groups = [_boolean_or_group([topic])]
1088
- return ' AND '.join(groups)
1089
-
1090
-
1091
- def _primo_clean_url(context: SearchContextPayload) -> str:
1092
- clean_topic = _strip_resource_noise(context.topic) or context.topic
1093
- return f"{PRIMO_BASE_URL}&query=any,contains,{quote(clean_topic)}&lang=en&mode=advanced&offset=0"
1094
 
1095
 
1096
- def _build_trace_block(user_question: str, context: SearchContextPayload, used_summary: bool) -> str:
1097
  topic = _escape(context.display_topic or context.topic)
1098
- q = _escape(_normalize_whitespace(user_question))
1099
- boolean_q = _escape(context.primo_boolean_query or _make_primo_boolean_query(context))
1100
- if used_summary:
1101
- how = (
1102
- f"I interpreted your question as <strong>{topic}</strong>, used Semantic Scholar abstracts to prepare a brief literature snapshot when available, "
1103
- f"and searched PRIMO for KU-accessible results."
1104
- )
1105
- else:
1106
- how = (
1107
- f"I interpreted your question as <strong>{topic}</strong> and searched PRIMO for KU-accessible results using the Boolean query below."
1108
  )
1109
  return (
1110
- '<div style="margin:10px 0 14px 0;padding:12px 14px;background:#f8fafc;border:1px solid #dbeafe;border-radius:12px">'
1111
- f"<strong>Your question:</strong> {q}<br>"
1112
- f"<strong>Interpreted topic:</strong> {topic}<br>"
1113
- f"<strong>PRIMO query used:</strong> <code>{boolean_q}</code><br>"
1114
- f"<strong>How I found this:</strong> {how}"
1115
- '</div>'
1116
  )
1117
 
1118
 
1119
- def _research_end_note() -> str:
1120
- return "<br><br><em>For deeper research, recommended AI research tools are listed at the end of this answer.</em>"
 
 
 
 
 
1121
 
1122
 
1123
- def _contact_footer_for_query(intent: str, question: str, context: Optional[SearchContextPayload] = None) -> str:
1124
- q = (question or '').lower()
1125
- blocks = [
1126
- '<br><br><strong>Library contact points</strong><br>'
1127
- f'General help: <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a> Β· '
1128
- '<a href="mailto:libse@ku.ac.ae">libse@ku.ac.ae</a>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1129
  ]
1130
- if intent == 'search_medical' or 'pubmed' in q or 'clinical' in q or 'medical' in q or 'nursing' in q:
1131
- blocks.append('Medical support: <strong>Jason Fetty</strong> Β· <a href="mailto:jason.fetty@ku.ac.ae">jason.fetty@ku.ac.ae</a> Β· <a href="tel:+97123124722">+971 2 312 4722</a>')
1132
- elif any(tok in q for tok in ['database access', 'full text', 'proxy', 'login', 'eresource', 'e-resource', 'access problem']):
1133
- blocks.append('E-resources support: <strong>Rani Anand</strong> Β· <a href="mailto:rani.anand@ku.ac.ae">rani.anand@ku.ac.ae</a> Β· <a href="tel:+97123123935">+971 2 312 3935</a>')
1134
- elif any(tok in q for tok in ['website', 'system', 'technical', 'error', 'bug']):
1135
- blocks.append('Systems support: <strong>Walter Brian Hall</strong> Β· <a href="mailto:walter.hall@ku.ac.ae">walter.hall@ku.ac.ae</a> Β· <a href="tel:+97123123163">+971 2 312 3163</a>')
1136
- elif intent in {'search_academic', 'search_medical'} or any(tok in q for tok in ['research', 'review', 'literature', 'citation', 'orcid', 'scopus', 'bibliometric']):
1137
- blocks.append('Research support: <strong>Nikesh Narayanan</strong> Β· <a href="mailto:nikesh.narayanan@ku.ac.ae">nikesh.narayanan@ku.ac.ae</a> Β· <a href="tel:+97123123980">+971 2 312 3980</a>')
1138
- else:
1139
- blocks.append('Public services: <strong>Muna Al Blooshi</strong> Β· <a href="mailto:muna.alblooshi@ku.ac.ae">muna.alblooshi@ku.ac.ae</a> Β· <a href="tel:+97123124212">+971 2 312 4212</a>')
1140
- return '<div style="margin-top:14px;padding-top:10px;border-top:1px solid #e5e7eb;font-size:.95rem">' + '<br>'.join(blocks) + '</div>'
 
 
 
 
 
 
 
 
 
1141
 
1142
 
1143
  # ── Research Snapshot ─────────────────────────────────────────────────────────
1144
 
1145
- async def _research_snapshot(question: str, context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
1146
  """
1147
  Full research snapshot:
1148
  1. Semantic Scholar β†’ cited summary with hyperlinked [Author, Year] citations
@@ -1153,7 +1204,6 @@ async def _research_snapshot(question: str, context: SearchContextPayload, model
1153
  references: List[dict] = []
1154
  primo_result, primo_url = await _search_primo_catalog(context, limit=6)
1155
  search_results = primo_result.get("results", []) or []
1156
- trace = _build_trace_block(question, context, used_summary=bool(papers))
1157
 
1158
  if papers:
1159
  summary = await _generate_cited_summary(topic, papers, model)
@@ -1161,13 +1211,13 @@ async def _research_snapshot(question: str, context: SearchContextPayload, model
1161
  _safe_metrics_increment("summary_fallbacks")
1162
  summary = _extractive_snapshot_from_papers(topic, papers)
1163
 
1164
- answer = f"{trace}<strong>πŸ“– Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
1165
  if search_results:
1166
  answer += (
1167
  "<br><br><strong>KU Library starting points:</strong> "
1168
  "I also pulled KU-accessible results below so you can move directly from the snapshot to subscribed material."
1169
  )
1170
- answer += _research_end_note()
1171
  answer += "<br><br><strong>Selected references:</strong><br>"
1172
  for i, paper in enumerate(papers[:6], 1):
1173
  answer += (
@@ -1180,24 +1230,21 @@ async def _research_snapshot(question: str, context: SearchContextPayload, model
1180
  answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1181
  if search_results:
1182
  answer += _source_badge("primo", primo_url)
1183
- answer += _contact_footer_for_query(context.intent, question, context)
1184
  references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1185
  return answer, search_results, references
1186
 
1187
  if search_results:
1188
  answer = (
1189
- f"{trace}<strong>πŸ“š KU Library starting points on {_escape(topic)}</strong><br><br>"
1190
- "I found KU-accessible library results you can open immediately."
1191
- f"{_research_end_note()}"
1192
  )
1193
  else:
1194
  answer = (
1195
- f"{trace}<strong>Research starting points for {_escape(topic)}</strong><br><br>"
1196
- "I prepared a library discovery path for this topic."
1197
- f"{_research_end_note()}"
1198
  )
1199
  answer += _source_badge("primo", primo_url)
1200
- answer += _contact_footer_for_query(context.intent, question, context)
1201
  return answer, search_results, []
1202
 
1203
 
@@ -1332,8 +1379,9 @@ def _apply_follow_up_action(base_context: SearchContextPayload, action: Optional
1332
 
1333
  async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1334
  """
1335
- Generate 3 topic-specific follow-up questions, but preserve the original anchor topic
1336
- in the next clicked search query.
 
1337
  """
1338
  settings = get_settings()
1339
  if not settings.openai_api_key:
@@ -1344,11 +1392,15 @@ async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1344
  {"role": "system", "content": (
1345
  "You are a research librarian helping a user explore a topic more deeply. "
1346
  "Given a research topic, generate exactly 3 short follow-up questions that explore "
1347
- "different aspects of the topic β€” such as subtopics, methodological angles, "
1348
  "applications, comparisons, or related fields. "
1349
  "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
1350
  "Return ONLY a JSON array of 3 strings, each under 12 words. "
1351
- "No explanation, no preamble, no markdown."
 
 
 
 
1352
  )},
1353
  {"role": "user", "content": f"Topic: {topic}"},
1354
  ])
@@ -1358,19 +1410,11 @@ async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1358
  questions = json.loads(raw)
1359
  if not isinstance(questions, list):
1360
  return []
1361
- out=[]
1362
- topic_l = topic.lower()
1363
- for q in questions[:3]:
1364
- if not isinstance(q, str) or not q.strip():
1365
- continue
1366
- label = q.strip().rstrip('?') + '?'
1367
- q_clean = _normalize_whitespace(q.strip().rstrip('?'))
1368
- if topic_l not in q_clean.lower():
1369
- full_q = f"Find research articles on {q_clean} in the context of {topic}"
1370
- else:
1371
- full_q = f"Find research articles on {q_clean}"
1372
- out.append({"label": label, "question": full_q, "action": None})
1373
- return out
1374
  except Exception as e:
1375
  logger.warning(f"Topic follow-up generation failed: {e}")
1376
  return []
@@ -1527,19 +1571,20 @@ async def _run_search_mode(
1527
  search_results = result.get("results", []) or []
1528
  total = result.get("total", len(search_results))
1529
 
1530
- answer = _build_trace_block(question, context, used_summary=False) + _search_answer_intro(context, is_follow_up)
1531
  if filters:
1532
  answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1533
  if search_results:
1534
  answer += f"<br><br>I found <strong>{total}</strong> result(s). The top items are shown below."
1535
  else:
1536
- answer += "<br><br>I prepared a direct discovery path for this topic using the search shown above."
 
 
 
1537
  if context.intent == "search_medical" and context.source != "pubmed":
1538
  answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1539
 
1540
- answer += _research_end_note()
1541
  answer += _source_badge(source_name, source_url)
1542
- answer += _contact_footer_for_query(context.intent, question, context)
1543
  sources = []
1544
  for item in search_results[:6]:
1545
  link = item.get("primo_url") or item.get("link")
@@ -1660,6 +1705,22 @@ async def agent_query(request: Request, req: AgentRequest):
1660
  follow_up_suggestions=follow_up_suggestions,
1661
  )
1662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1663
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
1664
 
1665
  if is_follow_up and base_context:
@@ -1669,7 +1730,11 @@ async def agent_query(request: Request, req: AgentRequest):
1669
  if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
1670
  context = await _prepare_queries(question, context, model, True)
1671
  if follow_up_action == "summarize_topic":
1672
- answer, search_results, sources = await _research_snapshot(question, context, model)
 
 
 
 
1673
  else:
1674
  answer = (
1675
  f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
@@ -1766,7 +1831,7 @@ async def agent_query(request: Request, req: AgentRequest):
1766
 
1767
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1768
  return AgentResponse(
1769
- answer=answer + _contact_footer_for_query("library_info", question),
1770
  intent="library_info",
1771
  tools_used=tools_used,
1772
  sources=sources,
@@ -1802,7 +1867,7 @@ async def agent_query(request: Request, req: AgentRequest):
1802
  ))
1803
 
1804
  if summary_mode or deep_research_mode:
1805
- answer, search_results, sources = await _research_snapshot(question, context, model)
1806
  if deep_research_mode and "Research guide" not in answer:
1807
  answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1808
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
@@ -1851,7 +1916,7 @@ async def agent_query(request: Request, req: AgentRequest):
1851
  answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
1852
  follow_up_question, follow_up_suggestions = _general_follow_up(question)
1853
  return AgentResponse(
1854
- answer=answer + _contact_footer_for_query("general_recent", question),
1855
  intent="general_recent",
1856
  tools_used=["web_search"],
1857
  model_used=model,
@@ -1863,7 +1928,7 @@ async def agent_query(request: Request, req: AgentRequest):
1863
  answer = await _web_search_answer(question, history, model) + _source_badge("web")
1864
  follow_up_question, follow_up_suggestions = _general_follow_up(question)
1865
  return AgentResponse(
1866
- answer=answer + _contact_footer_for_query("general", question),
1867
  intent="general",
1868
  tools_used=["web_search"],
1869
  model_used=model,
 
213
  re.IGNORECASE,
214
  )
215
 
216
+ BEST_DATABASES_RE = re.compile(
217
+ r"\b(best databases?|which databases?|which database|best database|recommended databases?)\b",
218
+ re.IGNORECASE,
219
+ )
220
+
221
  MEDICAL_KEYWORDS = [
222
  "medicine", "medical", "health", "clinical", "nursing", "pharmacy", "biomedical",
223
  "anatomy", "physiology", "epidemiology", "oncology", "cardiology", "neuroscience",
 
1027
 
1028
  def _tool_urls(context: SearchContextPayload) -> Dict[str, str]:
1029
  ai_query = quote(context.ai_tool_query or context.display_topic or context.topic)
1030
+ clean_topic = _strip_resource_noise(context.topic)
1031
  return {
1032
  "leapspace": f"https://www.sciencedirect.com/leapspace?query={ai_query}",
1033
  "scopus_ai": f"https://www-scopus-com.khalifa.idm.oclc.org/pages/ai?query={ai_query}",
 
1036
  }
1037
 
1038
 
1039
+ def _ai_source_links_html(context: SearchContextPayload) -> str:
1040
+ urls = _tool_urls(context)
1041
+ return (
1042
+ f'<a href="{urls["leapspace"]}" target="_blank" style="color:#9a3412;font-weight:700;text-decoration:none">LeapSpace</a> Β· '
1043
+ f'<a href="{urls["scopus_ai"]}" target="_blank" style="color:#1e40af;font-weight:700;text-decoration:none">Scopus AI</a> Β· '
1044
+ f'<a href="{urls["consensus"]}" target="_blank" style="color:#86198f;font-weight:700;text-decoration:none">Consensus</a> Β· '
1045
+ f'<a href="{urls["primo_discovery"]}" target="_blank" style="color:#5b21b6;font-weight:700;text-decoration:none">PRIMO Library Discovery</a>'
1046
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1047
 
1048
 
1049
+ def _database_guidance_for_topic(context: SearchContextPayload) -> str:
1050
  topic = _escape(context.display_topic or context.topic)
1051
+ links = _ai_source_links_html(context)
1052
+ if context.intent == "search_medical":
1053
+ return (
1054
+ f"For a stronger evidence trail on <strong>{topic}</strong>, start with <strong>PubMed</strong> or <strong>Embase</strong> for the core medical literature, "
1055
+ f"then continue with {links}."
 
 
 
 
 
1056
  )
1057
  return (
1058
+ f"For deeper work on <strong>{topic}</strong>, continue with {links}. "
1059
+ "Use Scopus AI for broader scholarly mapping, LeapSpace for guided evidence exploration, "
1060
+ "Consensus for quick paper-grounded answers, and PRIMO Library Discovery for KU-accessible material."
 
 
 
1061
  )
1062
 
1063
 
1064
+ def _looks_database_recommendation_question(question: str) -> bool:
1065
+ q = (question or "").lower()
1066
+ return bool(BEST_DATABASES_RE.search(q)) and any(kw in q for kw in [
1067
+ "database", "databases", "subject", "engineering", "medicine", "medical", "ai", "computer",
1068
+ "physics", "chemistry", "chemical", "civil", "mechanical", "electrical", "business",
1069
+ "biomedical", "nursing", "systematic review", "health"
1070
+ ])
1071
 
1072
 
1073
+ def _database_recommendation_payload(question: str) -> Dict[str, Any]:
1074
+ q = (question or "").lower()
1075
+ general = {
1076
+ "title": "General / multidisciplinary research",
1077
+ "intro": "These are the best starting databases for broad academic searching at KU.",
1078
+ "items": [
1079
+ {"name": "PRIMO Library Discovery", "url": "https://khalifa.primo.exlibrisgroup.com/discovery/search?vid=971KUOSTAR_INST:KU", "why": "Best first stop for KU-accessible books, articles, journals, and holdings."},
1080
+ {"name": "Scopus", "url": "https://khalifa.idm.oclc.org/login?url=https://www.scopus.com", "why": "Broad multidisciplinary database for scholarly discovery and citation tracking."},
1081
+ {"name": "Web of Science", "url": "https://khalifa.idm.oclc.org/login?url=https://www.webofscience.com", "why": "Strong citation database for high-level literature mapping and journal evaluation."},
1082
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Large full-text platform across science, engineering, and medicine."},
1083
+ ],
1084
+ }
1085
+ subjects = [
1086
+ (["systematic review", "medicine", "medical", "health", "nursing", "clinical", "biomedical", "pharmacy"], {
1087
+ "title": "Medical and health sciences",
1088
+ "intro": "For medical evidence and subject searching, start with these databases.",
1089
+ "items": [
1090
+ {"name": "PubMed@KU", "url": "http://www.ncbi.nlm.nih.gov/pubmed/?otool=iaekustlib", "why": "Core biomedical literature with KU full-text linking."},
1091
+ {"name": "Embase", "url": "https://khalifa.idm.oclc.org/login?url=https://www.embase.com", "why": "Excellent for biomedical, drugs, devices, and evidence searching."},
1092
+ {"name": "CINAHL Ultimate", "url": "https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=cul", "why": "Best for nursing and allied health literature."},
1093
+ {"name": "Cochrane Library", "url": "https://khalifa.idm.oclc.org/login?url=https://www.cochranelibrary.com", "why": "Key source for systematic reviews and evidence-based medicine."},
1094
+ {"name": "UpToDate", "url": "https://khalifa.idm.oclc.org/login?url=https://www.uptodate.com", "why": "Clinical point-of-care resource for evidence summaries."},
1095
+ ],
1096
+ }),
1097
+ (["computer science", "machine learning", "artificial intelligence", " ai", "cyber", "software", "algorithm", "computer", "data science"], {
1098
+ "title": "Computer science and AI",
1099
+ "intro": "For computing, AI, software, and algorithms, these are the best starting points.",
1100
+ "items": [
1101
+ {"name": "ACM Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://dl.acm.org", "why": "Core computer science literature."},
1102
+ {"name": "IEEE Xplore", "url": "https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org", "why": "Strong for AI, computing systems, networks, and engineering."},
1103
+ {"name": "arXiv", "url": "https://arxiv.org", "why": "Useful for fast-moving preprints in AI and computer science."},
1104
+ {"name": "Scopus", "url": "https://khalifa.idm.oclc.org/login?url=https://www.scopus.com", "why": "Good for citation tracking and broader discovery."},
1105
+ ],
1106
+ }),
1107
+ (["electrical", "electronics", "signals", "power systems", "communications"], {
1108
+ "title": "Electrical engineering",
1109
+ "intro": "For electrical engineering topics, start here.",
1110
+ "items": [
1111
+ {"name": "IEEE Xplore", "url": "https://khalifa.idm.oclc.org/login?url=https://ieeexplore.ieee.org", "why": "Core database for electrical engineering literature and standards."},
1112
+ {"name": "INSPEC", "url": "https://khalifa.idm.oclc.org/login?url=https://www.theiet.org/publishing/inspec", "why": "Strong indexing for EE, electronics, communications, and computing."},
1113
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Good full-text coverage across electrical engineering and controls."},
1114
+ ],
1115
+ }),
1116
+ (["mechanical", "thermo", "fluids", "manufacturing"], {
1117
+ "title": "Mechanical engineering",
1118
+ "intro": "For mechanical engineering, these are the strongest starts.",
1119
+ "items": [
1120
+ {"name": "ASME Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org", "why": "Core mechanical engineering journals and conference papers."},
1121
+ {"name": "Knovel", "url": "https://khalifa.idm.oclc.org/login?url=https://app.knovel.com", "why": "Engineering reference books and technical data."},
1122
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Strong full-text support for applied engineering topics."},
1123
+ ],
1124
+ }),
1125
+ (["civil", "geotechnical", "transportation", "construction", "structures"], {
1126
+ "title": "Civil engineering",
1127
+ "intro": "For civil engineering and infrastructure topics, start here.",
1128
+ "items": [
1129
+ {"name": "ASCE Library", "url": "https://khalifa.idm.oclc.org/login?url=https://ascelibrary.org", "why": "Core civil engineering journals and conference papers."},
1130
+ {"name": "ASTM Compass", "url": "https://khalifa.idm.oclc.org/login?url=https://compass.astm.org", "why": "Important standards and testing information for materials and engineering."},
1131
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Useful full-text coverage across civil engineering subjects."},
1132
+ ],
1133
+ }),
1134
+ (["aerospace", "aerodynamics", "propulsion", "aircraft"], {
1135
+ "title": "Aerospace engineering",
1136
+ "intro": "For aerospace topics, these are the best databases to begin with.",
1137
+ "items": [
1138
+ {"name": "AIAA Aerospace Research Central", "url": "https://khalifa.idm.oclc.org/login?url=https://arc.aiaa.org/", "why": "Core aerospace journals and conference literature."},
1139
+ {"name": "ASME Digital Library", "url": "https://khalifa.idm.oclc.org/login?url=https://asmedigitalcollection.asme.org", "why": "Useful for thermal, mechanical, and fluid systems relevant to aerospace."},
1140
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Good full-text support for aerospace materials and systems."},
1141
+ ],
1142
+ }),
1143
+ (["chemical engineering", "catalysis", "separations", "chemistry", "chemical"], {
1144
+ "title": "Chemistry and chemical engineering",
1145
+ "intro": "For chemistry and chemical engineering, these are the strongest starting points.",
1146
+ "items": [
1147
+ {"name": "ACS Publications", "url": "https://khalifa.idm.oclc.org/login?url=https://pubs.acs.org", "why": "Core chemistry journals."},
1148
+ {"name": "SciFindern", "url": "https://khalifa.idm.oclc.org/login?url=https://scifinder-n.cas.org", "why": "Best for substances, reactions, and chemistry references."},
1149
+ {"name": "Reaxys", "url": "https://khalifa.idm.oclc.org/login?url=https://www.reaxys.com", "why": "Useful for chemistry reactions and property searching."},
1150
+ {"name": "ScienceDirect", "url": "https://khalifa.idm.oclc.org/login?url=https://www.sciencedirect.com", "why": "Strong full-text source for chemical engineering journals."},
1151
+ ],
1152
+ }),
1153
+ (["physics", "quantum", "optics", "plasma"], {
1154
+ "title": "Physics",
1155
+ "intro": "For physics topics, start with these databases and platforms.",
1156
+ "items": [
1157
+ {"name": "APS Journals", "url": "https://khalifa.idm.oclc.org/login?url=https://journals.aps.org", "why": "Core American Physical Society journals."},
1158
+ {"name": "AIP", "url": "https://khalifa.idm.oclc.org/login?url=https://aip.scitation.org", "why": "Major physics and applied physics content."},
1159
+ {"name": "arXiv", "url": "https://arxiv.org", "why": "Open preprints in physics and related areas."},
1160
+ ],
1161
+ }),
1162
+ (["business", "management", "marketing", "economics", "finance"], {
1163
+ "title": "Business and management",
1164
+ "intro": "For business-related topics, start with these databases.",
1165
+ "items": [
1166
+ {"name": "Business Source Complete", "url": "https://khalifa.idm.oclc.org/login?url=https://search.ebscohost.com/login.aspx?authtype=ip,uid&profile=ehost&defaultdb=bsh", "why": "Comprehensive business database."},
1167
+ {"name": "Emerald Insight", "url": "https://khalifa.idm.oclc.org/login?url=https://www.emerald.com/insight", "why": "Strong for management, marketing, and business journals."},
1168
+ {"name": "ProQuest Databases", "url": "https://khalifa.idm.oclc.org/login?url=https://search.proquest.com", "why": "Broad business and dissertation coverage."},
1169
+ ],
1170
+ }),
1171
  ]
1172
+ for keywords, payload in subjects:
1173
+ if any(k in q for k in keywords):
1174
+ return payload
1175
+ return general
1176
+
1177
+
1178
+ def _database_recommendation_answer(question: str) -> str:
1179
+ payload = _database_recommendation_payload(question)
1180
+ answer = f"<strong>Best databases for { _escape(payload['title']) }</strong><br><br>{_escape(payload['intro'])}<br>"
1181
+ for item in payload['items']:
1182
+ answer += (
1183
+ f'<br>β€’ <a href="{item["url"]}" target="_blank" style="color:#003366;font-weight:600">{_escape(item["name"])}</a><br>'
1184
+ f'<span style="font-size:.9rem;color:#4b5563">{_escape(item["why"])}</span>'
1185
+ )
1186
+ answer += (
1187
+ f'<br><br>You can also start in <a href="{PRIMO_BASE_URL}" target="_blank">PRIMO Library Discovery</a> '
1188
+ 'and then narrow by resource type, date, and peer-reviewed content.'
1189
+ )
1190
+ answer += _source_badge("ku_library", "https://library.ku.ac.ae/eresources")
1191
+ return answer
1192
 
1193
 
1194
  # ── Research Snapshot ─────────────────────────────────────────────────────────
1195
 
1196
+ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
1197
  """
1198
  Full research snapshot:
1199
  1. Semantic Scholar β†’ cited summary with hyperlinked [Author, Year] citations
 
1204
  references: List[dict] = []
1205
  primo_result, primo_url = await _search_primo_catalog(context, limit=6)
1206
  search_results = primo_result.get("results", []) or []
 
1207
 
1208
  if papers:
1209
  summary = await _generate_cited_summary(topic, papers, model)
 
1211
  _safe_metrics_increment("summary_fallbacks")
1212
  summary = _extractive_snapshot_from_papers(topic, papers)
1213
 
1214
+ answer = f"<strong>πŸ“– Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
1215
  if search_results:
1216
  answer += (
1217
  "<br><br><strong>KU Library starting points:</strong> "
1218
  "I also pulled KU-accessible results below so you can move directly from the snapshot to subscribed material."
1219
  )
1220
+ answer += f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1221
  answer += "<br><br><strong>Selected references:</strong><br>"
1222
  for i, paper in enumerate(papers[:6], 1):
1223
  answer += (
 
1230
  answer += _source_badge("semantic", "https://www.semanticscholar.org/")
1231
  if search_results:
1232
  answer += _source_badge("primo", primo_url)
 
1233
  references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
1234
  return answer, search_results, references
1235
 
1236
  if search_results:
1237
  answer = (
1238
+ f"<strong>πŸ“š KU Library starting points on {_escape(topic)}</strong><br><br>"
1239
+ "I couldn't build a Semantic Scholar snapshot right now, but here are KU-accessible library results you can open immediately. "
1240
+ f"<br><br><strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
1241
  )
1242
  else:
1243
  answer = (
1244
+ f"I couldn't retrieve a research snapshot for <strong>{_escape(topic)}</strong> right now.<br><br>"
1245
+ f"<strong>AI sources:</strong> {_database_guidance_for_topic(context)}"
 
1246
  )
1247
  answer += _source_badge("primo", primo_url)
 
1248
  return answer, search_results, []
1249
 
1250
 
 
1379
 
1380
  async def _generate_topic_follow_ups(topic: str, model: str) -> List[dict]:
1381
  """
1382
+ FIX: Generate 3 topic-specific intellectual follow-up questions using LLM.
1383
+ These are ASPECT-based (related subtopics, methodological angles, applications)
1384
+ not generic filter suggestions like "peer reviewed only".
1385
  """
1386
  settings = get_settings()
1387
  if not settings.openai_api_key:
 
1392
  {"role": "system", "content": (
1393
  "You are a research librarian helping a user explore a topic more deeply. "
1394
  "Given a research topic, generate exactly 3 short follow-up questions that explore "
1395
+ "DIFFERENT ASPECTS of the topic β€” such as subtopics, methodological angles, "
1396
  "applications, comparisons, or related fields. "
1397
  "Do NOT suggest filter actions like peer-reviewed, date ranges, or format changes. "
1398
  "Return ONLY a JSON array of 3 strings, each under 12 words. "
1399
+ "No explanation, no preamble, no markdown.\n"
1400
+ "Example for 'quantum computing':\n"
1401
+ '[\"What are the main hardware approaches in quantum computing?\", '
1402
+ '"How does quantum error correction work?\", '
1403
+ '"Applications of quantum computing in cryptography\"]'
1404
  )},
1405
  {"role": "user", "content": f"Topic: {topic}"},
1406
  ])
 
1410
  questions = json.loads(raw)
1411
  if not isinstance(questions, list):
1412
  return []
1413
+ return [
1414
+ {"label": q.strip("?") + "?", "question": q.strip("?") + " β€” find articles", "action": "new_search"}
1415
+ for q in questions[:3]
1416
+ if isinstance(q, str) and q.strip()
1417
+ ]
 
 
 
 
 
 
 
 
1418
  except Exception as e:
1419
  logger.warning(f"Topic follow-up generation failed: {e}")
1420
  return []
 
1571
  search_results = result.get("results", []) or []
1572
  total = result.get("total", len(search_results))
1573
 
1574
+ answer = _search_answer_intro(context, is_follow_up)
1575
  if filters:
1576
  answer += f"<br><br><strong>Current filters:</strong> {_escape(filters)}."
1577
  if search_results:
1578
  answer += f"<br><br>I found <strong>{total}</strong> result(s). The top items are shown below."
1579
  else:
1580
+ answer += (
1581
+ "<br><br>I couldn't retrieve matching results right now. "
1582
+ "You can still try the direct library links and AI research tools below."
1583
+ )
1584
  if context.intent == "search_medical" and context.source != "pubmed":
1585
  answer += "<br><br>For the strongest medical evidence trail, I also recommend checking <strong>PubMed</strong> or <strong>Embase</strong>."
1586
 
 
1587
  answer += _source_badge(source_name, source_url)
 
1588
  sources = []
1589
  for item in search_results[:6]:
1590
  link = item.get("primo_url") or item.get("link")
 
1705
  follow_up_suggestions=follow_up_suggestions,
1706
  )
1707
 
1708
+ if _looks_database_recommendation_question(question):
1709
+ follow_up_question = "Would you like me to search one of these databases for you next?"
1710
+ follow_up_suggestions = [
1711
+ {"label": "Search PRIMO on this topic", "question": question.replace("best databases", "find articles").replace("which database should I use for", "find articles on")},
1712
+ {"label": "Show off-campus access help", "question": "How do I access databases off campus?"},
1713
+ ]
1714
+ return AgentResponse(
1715
+ answer=_database_recommendation_answer(question),
1716
+ intent="library_info",
1717
+ tools_used=["database_recommendation"],
1718
+ model_used=model,
1719
+ response_time=time.time() - start_time,
1720
+ follow_up_question=follow_up_question,
1721
+ follow_up_suggestions=follow_up_suggestions,
1722
+ )
1723
+
1724
  is_follow_up, follow_up_action, base_context = _detect_follow_up(question, client_state)
1725
 
1726
  if is_follow_up and base_context:
 
1730
  if follow_up_action in {"summarize_topic", "deep_research_tools", "best_databases"}:
1731
  context = await _prepare_queries(question, context, model, True)
1732
  if follow_up_action == "summarize_topic":
1733
+ answer, search_results, sources = await _research_snapshot(context, model)
1734
+ elif follow_up_action == "best_databases":
1735
+ answer = _database_recommendation_answer(context.display_topic or context.topic)
1736
+ search_results = []
1737
+ sources = []
1738
  else:
1739
  answer = (
1740
  f"<strong>Research guide for {_escape(context.display_topic or context.topic)}</strong><br><br>"
 
1831
 
1832
  follow_up_question, follow_up_suggestions = _library_follow_up(question)
1833
  return AgentResponse(
1834
+ answer=answer,
1835
  intent="library_info",
1836
  tools_used=tools_used,
1837
  sources=sources,
 
1867
  ))
1868
 
1869
  if summary_mode or deep_research_mode:
1870
+ answer, search_results, sources = await _research_snapshot(context, model)
1871
  if deep_research_mode and "Research guide" not in answer:
1872
  answer += f"<br><br><strong>Research guide:</strong> {_database_guidance_for_topic(context)}"
1873
  follow_up_question, follow_up_suggestions = await _search_follow_up(context, model, summary_mode=True)
 
1916
  answer = await _web_search_answer(question, history, model) + _source_badge("web_live")
1917
  follow_up_question, follow_up_suggestions = _general_follow_up(question)
1918
  return AgentResponse(
1919
+ answer=answer,
1920
  intent="general_recent",
1921
  tools_used=["web_search"],
1922
  model_used=model,
 
1928
  answer = await _web_search_answer(question, history, model) + _source_badge("web")
1929
  follow_up_question, follow_up_suggestions = _general_follow_up(question)
1930
  return AgentResponse(
1931
+ answer=answer,
1932
  intent="general",
1933
  tools_used=["web_search"],
1934
  model_used=model,
src/api/search.py CHANGED
@@ -94,6 +94,36 @@ async def search_endpoint(req: SearchRequest):
94
  return await search_scholar(req)
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  # ── PRIMO ──────────────────────────────────────────────────────────────────────
98
 
99
  async def search_primo(req: SearchRequest, api_key: str):
@@ -178,10 +208,7 @@ async def search_primo(req: SearchRequest, api_key: str):
178
  "date": (d.get("date") or [""])[0],
179
  "type": (d.get("type") or [""])[0],
180
  "source": (d.get("source") or [""])[0],
181
- "primo_url": (
182
- f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
183
- f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU"
184
- ),
185
  "_source": "PRIMO",
186
  })
187
  return {
 
94
  return await search_scholar(req)
95
 
96
 
97
+ def _build_exact_primo_url(doc: dict, query_encoded: str) -> str:
98
+ pnx = doc.get("pnx", {}) or {}
99
+ control = pnx.get("control", {}) or {}
100
+ search = pnx.get("search", {}) or {}
101
+ recordid = ""
102
+ for candidate in [
103
+ control.get("recordid"),
104
+ control.get("sourcerecordid"),
105
+ search.get("recordid"),
106
+ search.get("addsrcrecordid"),
107
+ ]:
108
+ if isinstance(candidate, list) and candidate:
109
+ recordid = str(candidate[0]).strip()
110
+ if recordid:
111
+ break
112
+ elif isinstance(candidate, str) and candidate.strip():
113
+ recordid = candidate.strip()
114
+ break
115
+ if recordid:
116
+ return (
117
+ "https://khalifa.primo.exlibrisgroup.com/discovery/fulldisplay"
118
+ f"?docid={quote(recordid)}&context=L&vid=971KUOSTAR_INST:KU&lang=en"
119
+ f"&search_scope=MyInst_and_CI&tab=Everything&query=any,contains,{query_encoded}&offset=0"
120
+ )
121
+ return (
122
+ f"https://khalifa.primo.exlibrisgroup.com/discovery/search"
123
+ f"?query=any,contains,{query_encoded}&vid=971KUOSTAR_INST:KU"
124
+ )
125
+
126
+
127
  # ── PRIMO ──────────────────────────────────────────────────────────────────────
128
 
129
  async def search_primo(req: SearchRequest, api_key: str):
 
208
  "date": (d.get("date") or [""])[0],
209
  "type": (d.get("type") or [""])[0],
210
  "source": (d.get("source") or [""])[0],
211
+ "primo_url": _build_exact_primo_url(doc, query_encoded),
 
 
 
212
  "_source": "PRIMO",
213
  })
214
  return {