Upload 2 files
Browse files- src/api/agent.py +102 -37
- 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)
|
| 731 |
-
|
|
|
|
|
|
|
| 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
|
| 737 |
boolean_query,
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 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 |
-
|
| 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,
|
|
|
|
| 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 =
|
| 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 |
-
|
| 1024 |
-
|
| 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}{
|
| 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 |
-
|
| 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
|
| 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
|
| 1056 |
-
f
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 16 |
-
|
| 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 |
-
|
| 52 |
r"\b(articles?|papers?|books?|ebooks?|journals?|publications?|monographs?"
|
| 53 |
-
r"|peer[- ]reviewed|open[- ]access|studies|study|research|latest|recent
|
|
|
|
| 54 |
re.IGNORECASE,
|
| 55 |
)
|
| 56 |
|
| 57 |
-
|
| 58 |
-
"
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
}
|
| 62 |
|
| 63 |
-
|
| 64 |
-
"
|
| 65 |
-
"
|
| 66 |
-
"
|
| 67 |
-
"
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
-
"
|
| 73 |
-
"
|
| 74 |
-
"
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
TOKEN_SYNONYMS: Dict[str, List[str]] = {
|
| 80 |
"healthcare": ["medicine", "clinical"],
|
| 81 |
-
"
|
| 82 |
-
"nursing": ["clinical", "patient care"],
|
| 83 |
"education": ["teaching", "learning"],
|
| 84 |
-
"students": ["student"],
|
| 85 |
"student": ["students"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
"sustainability": ["sustainable development"],
|
| 87 |
-
"
|
| 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 |
-
|
| 140 |
-
|
|
|
|
| 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 |
-
|
| 177 |
-
cleaned =
|
|
|
|
| 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
|
| 182 |
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 187 |
lower = q.lower()
|
| 188 |
-
for
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
lower = lower_after
|
| 192 |
break
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
lower = _strip_resource_noise(lower)
|
| 197 |
-
return lower.strip(" .?") or _strip_resource_noise(question)
|
| 198 |
-
|
| 199 |
|
| 200 |
|
| 201 |
-
def
|
| 202 |
-
if
|
| 203 |
-
return
|
| 204 |
q = (question or "").lower()
|
| 205 |
-
if any(
|
| 206 |
return "books"
|
| 207 |
-
if any(
|
| 208 |
return "articles"
|
| 209 |
-
return "
|
| 210 |
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
return bool(MEDICAL_HINT_RE.search(question or ""))
|
| 215 |
-
|
| 216 |
|
| 217 |
|
| 218 |
def _quote_term(term: str) -> str:
|
| 219 |
-
term =
|
| 220 |
if not term:
|
| 221 |
return ""
|
| 222 |
-
if "
|
| 223 |
return term
|
| 224 |
-
if "
|
| 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
|
| 241 |
-
|
| 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 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
if
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
groups.append(
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
continue
|
| 269 |
-
|
| 270 |
-
|
| 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 |
-
|
| 294 |
-
if
|
| 295 |
-
|
| 296 |
-
return " AND ".join(clauses)
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
-
def
|
| 301 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
return
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 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 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 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 =
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
boolean_query = req.boolean_query or build_primo_boolean_query(clean_query)
|
| 409 |
-
query_for_api = boolean_query
|
| 410 |
|
| 411 |
-
def
|
|
|
|
| 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,{
|
| 416 |
f"&offset=0&mode=advanced&apikey={api_key}"
|
| 417 |
)
|
| 418 |
-
|
| 419 |
if req.resource_type == "articles":
|
| 420 |
-
|
| 421 |
elif req.resource_type == "books":
|
| 422 |
-
|
| 423 |
if req.peer_reviewed:
|
| 424 |
-
|
| 425 |
if req.open_access:
|
| 426 |
-
|
| 427 |
if req.year_from or req.year_to:
|
| 428 |
yf = req.year_from or "0001"
|
| 429 |
yt = req.year_to or "9999"
|
| 430 |
-
|
| 431 |
-
for facet in
|
| 432 |
url += f"&multiFacets={quote(facet)}"
|
| 433 |
return url
|
| 434 |
|
| 435 |
-
logger.info("PRIMO
|
| 436 |
|
| 437 |
try:
|
| 438 |
async with httpx.AsyncClient(timeout=20) as client:
|
| 439 |
-
|
| 440 |
-
r =
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|