Upload 2 files
Browse files- src/api/agent.py +200 -135
- 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 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
}
|
| 1040 |
-
|
| 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
|
| 1097 |
topic = _escape(context.display_topic or context.topic)
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
f"
|
| 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 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 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
|
| 1120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
|
| 1122 |
|
| 1123 |
-
def
|
| 1124 |
-
q = (question or
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1129 |
]
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1141 |
|
| 1142 |
|
| 1143 |
# ββ Research Snapshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1144 |
|
| 1145 |
-
async def _research_snapshot(
|
| 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"
|
| 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 +=
|
| 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"
|
| 1190 |
-
"I
|
| 1191 |
-
f"{
|
| 1192 |
)
|
| 1193 |
else:
|
| 1194 |
answer = (
|
| 1195 |
-
f"
|
| 1196 |
-
"
|
| 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
|
| 1336 |
-
|
|
|
|
| 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 |
-
"
|
| 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 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
if
|
| 1365 |
-
|
| 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 =
|
| 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 +=
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
| 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
|
| 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
|
| 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 {
|