Update src/api/agent.py
Browse files- src/api/agent.py +154 -36
src/api/agent.py
CHANGED
|
@@ -1225,23 +1225,126 @@ def _contact_footer_for_query(question: str, context: Optional[SearchContextPayl
|
|
| 1225 |
|
| 1226 |
|
| 1227 |
def _search_trace_block(question: str, context: SearchContextPayload) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1228 |
topic = _escape(context.display_topic or context.topic)
|
| 1229 |
-
question_html = _escape(question)
|
| 1230 |
primo_boolean = _escape(context.primo_boolean_query or "")
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1234 |
block = (
|
| 1235 |
-
f'<
|
| 1236 |
-
f
|
| 1237 |
-
f
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
)
|
| 1239 |
if primo_boolean:
|
| 1240 |
-
block +=
|
| 1241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1242 |
return block
|
| 1243 |
|
| 1244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1245 |
def _is_database_recommendation_question(question: str) -> bool:
|
| 1246 |
return bool(DATABASE_RECOMMEND_RE.search(question or ""))
|
| 1247 |
|
|
@@ -1548,35 +1651,42 @@ def _database_recommendation_answer(question: str) -> str:
|
|
| 1548 |
async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
|
| 1549 |
"""
|
| 1550 |
Research snapshot:
|
| 1551 |
-
-
|
| 1552 |
-
-
|
| 1553 |
-
-
|
| 1554 |
-
|
| 1555 |
-
-
|
| 1556 |
"""
|
| 1557 |
import asyncio as _asyncio
|
| 1558 |
topic = context.topic
|
| 1559 |
references: List[dict] = []
|
| 1560 |
|
| 1561 |
-
# Parallel fetch
|
| 1562 |
papers, (primo_result, primo_url) = await _asyncio.gather(
|
| 1563 |
_semantic_scholar_search(topic, limit=8),
|
| 1564 |
_search_primo_catalog(context, limit=6),
|
| 1565 |
)
|
| 1566 |
search_results = primo_result.get("results", []) or []
|
| 1567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1568 |
if papers:
|
| 1569 |
summary = await _generate_cited_summary(topic, papers, model)
|
| 1570 |
if not summary:
|
| 1571 |
_safe_metrics_increment("summary_fallbacks")
|
| 1572 |
summary = _extractive_snapshot_from_papers(topic, papers)
|
| 1573 |
|
| 1574 |
-
|
|
|
|
|
|
|
| 1575 |
if search_results:
|
| 1576 |
answer += (
|
| 1577 |
"<br><br><strong>KU Library results:</strong> "
|
| 1578 |
-
"KU-accessible items are shown below
|
| 1579 |
)
|
|
|
|
| 1580 |
answer += "<br><br><strong>Selected references:</strong><br>"
|
| 1581 |
for i, paper in enumerate(papers[:6], 1):
|
| 1582 |
answer += (
|
|
@@ -1586,29 +1696,29 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
|
|
| 1586 |
f'{_escape(paper["authors"])} Β· {paper["year"]} Β· {_escape(paper["venue"] or "")}'
|
| 1587 |
f"</span>"
|
| 1588 |
)
|
| 1589 |
-
# AI tools always at the bottom
|
| 1590 |
-
answer += f"<br><br>{_database_guidance_for_topic(context)}"
|
| 1591 |
answer += _source_badge("semantic", "https://www.semanticscholar.org/")
|
| 1592 |
if search_results:
|
| 1593 |
answer += _source_badge("primo", primo_url)
|
|
|
|
|
|
|
|
|
|
| 1594 |
references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
|
| 1595 |
return answer, search_results, references
|
| 1596 |
|
| 1597 |
-
# Semantic Scholar returned nothing β silent fallback
|
| 1598 |
if search_results:
|
| 1599 |
-
answer = (
|
| 1600 |
f"<strong>π Research starting points: {_escape(topic)}</strong><br><br>"
|
| 1601 |
-
"Here are KU-accessible results you can open immediately.
|
| 1602 |
-
"For a deeper literature synthesis, use the AI research tools below."
|
| 1603 |
-
f"<br><br>{_database_guidance_for_topic(context)}"
|
| 1604 |
)
|
| 1605 |
else:
|
| 1606 |
-
answer = (
|
| 1607 |
f"<strong>π Research starting points: {_escape(topic)}</strong><br><br>"
|
| 1608 |
-
"I didn't find direct results right now β try the AI research tools below
|
| 1609 |
-
f"<br><br>{_database_guidance_for_topic(context)}"
|
| 1610 |
)
|
| 1611 |
answer += _source_badge("primo", primo_url)
|
|
|
|
|
|
|
| 1612 |
return answer, search_results, []
|
| 1613 |
|
| 1614 |
|
|
@@ -1910,7 +2020,6 @@ async def _run_search_mode(
|
|
| 1910 |
question: str, context: SearchContextPayload, model: str, is_follow_up: bool
|
| 1911 |
) -> Tuple[str, List[dict], List[dict], str]:
|
| 1912 |
context = await _prepare_queries(question, context, model, is_follow_up)
|
| 1913 |
-
filters = _filters_summary(context)
|
| 1914 |
|
| 1915 |
if context.source == "pubmed":
|
| 1916 |
result, source_url = await _search_pubmed_articles(context, limit=6)
|
|
@@ -1922,20 +2031,29 @@ async def _run_search_mode(
|
|
| 1922 |
search_results = result.get("results", []) or []
|
| 1923 |
total = result.get("total", len(search_results))
|
| 1924 |
|
| 1925 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1926 |
if filters:
|
| 1927 |
-
answer += f"<br><br><strong>
|
|
|
|
| 1928 |
if search_results:
|
| 1929 |
-
answer += f"<br><br>
|
| 1930 |
else:
|
| 1931 |
-
answer +=
|
| 1932 |
-
|
| 1933 |
-
"You can still try the direct library links and AI research tools below."
|
| 1934 |
-
)
|
| 1935 |
if context.intent == "search_medical" and context.source != "pubmed":
|
| 1936 |
-
answer += "<br><br>For the strongest medical evidence trail,
|
| 1937 |
|
| 1938 |
answer += _source_badge(source_name, source_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1939 |
sources = []
|
| 1940 |
for item in search_results[:6]:
|
| 1941 |
link = item.get("primo_url") or item.get("link")
|
|
|
|
| 1225 |
|
| 1226 |
|
| 1227 |
def _search_trace_block(question: str, context: SearchContextPayload) -> str:
|
| 1228 |
+
"""
|
| 1229 |
+
'How I searched' reasoning panel β shown at the top of every search response.
|
| 1230 |
+
Collapsible <details> so it doesn't dominate the answer.
|
| 1231 |
+
"""
|
| 1232 |
topic = _escape(context.display_topic or context.topic)
|
|
|
|
| 1233 |
primo_boolean = _escape(context.primo_boolean_query or "")
|
| 1234 |
+
source_label = "PubMed" if context.source == "pubmed" else "KU PRIMO Library Discovery"
|
| 1235 |
+
|
| 1236 |
+
# Build reasoning sentence
|
| 1237 |
+
intent_label = {
|
| 1238 |
+
"search_academic": "academic literature search",
|
| 1239 |
+
"search_medical": "medical / clinical literature search",
|
| 1240 |
+
}.get(context.intent, "library search")
|
| 1241 |
+
|
| 1242 |
+
filters_parts = []
|
| 1243 |
+
if context.peer_reviewed:
|
| 1244 |
+
filters_parts.append("peer reviewed only")
|
| 1245 |
+
if context.open_access:
|
| 1246 |
+
filters_parts.append("open access")
|
| 1247 |
+
if context.year_from and context.year_to:
|
| 1248 |
+
filters_parts.append(f"{context.year_from}β{context.year_to}")
|
| 1249 |
+
elif context.year_from:
|
| 1250 |
+
filters_parts.append(f"from {context.year_from}")
|
| 1251 |
+
rtype = {"articles": "articles", "books": "books", "both": "articles and books"}.get(
|
| 1252 |
+
context.resource_type, "articles"
|
| 1253 |
+
)
|
| 1254 |
+
filters_parts.append(rtype)
|
| 1255 |
+
filters_str = ", ".join(filters_parts)
|
| 1256 |
+
|
| 1257 |
+
reasoning = (
|
| 1258 |
+
f"I identified this as a <strong>{intent_label}</strong>. "
|
| 1259 |
+
f"I extracted the topic <strong>{topic}</strong>, "
|
| 1260 |
+
f"built an LLM-optimised boolean query with quoted phrases and synonyms, "
|
| 1261 |
+
f"then searched <strong>{source_label}</strong> for <strong>{filters_str}</strong>."
|
| 1262 |
+
)
|
| 1263 |
+
if context.intent == "search_medical" and context.source != "pubmed":
|
| 1264 |
+
reasoning += " For medical topics, PubMed and Embase are also recommended."
|
| 1265 |
+
|
| 1266 |
block = (
|
| 1267 |
+
f'<details style="margin:0 0 14px 0;border:1px solid #dbe4ff;border-radius:12px;background:#f8fbff;overflow:hidden">'
|
| 1268 |
+
f'<summary style="padding:10px 14px;cursor:pointer;font-size:.8rem;font-weight:700;color:#1e40af;list-style:none;display:flex;align-items:center;gap:6px">'
|
| 1269 |
+
f'<span style="font-size:.9rem">π</span> How I searched β click to expand'
|
| 1270 |
+
f'</summary>'
|
| 1271 |
+
f'<div style="padding:4px 14px 12px 14px;font-size:.82rem;line-height:1.7;border-top:1px solid #dbe4ff">'
|
| 1272 |
+
f'<strong>Your question:</strong> {_escape(question)}<br>'
|
| 1273 |
+
f'<strong>Interpreted topic:</strong> {topic}<br>'
|
| 1274 |
)
|
| 1275 |
if primo_boolean:
|
| 1276 |
+
block += (
|
| 1277 |
+
f'<strong>Boolean query sent to {source_label}:</strong><br>'
|
| 1278 |
+
f'<code style="display:block;margin:4px 0;padding:6px 10px;background:#1a1a2e;color:#C8A951;'
|
| 1279 |
+
f'border-radius:7px;font-size:.78rem;word-break:break-all">{primo_boolean}</code>'
|
| 1280 |
+
)
|
| 1281 |
+
block += f'<strong>Reasoning:</strong> {reasoning}'
|
| 1282 |
+
block += '</div></details>'
|
| 1283 |
return block
|
| 1284 |
|
| 1285 |
|
| 1286 |
+
def _ai_tools_footer(context: SearchContextPayload) -> str:
|
| 1287 |
+
"""
|
| 1288 |
+
AI tools recommendation panel β always rendered at the very end of the answer.
|
| 1289 |
+
Structured as KU-subscribed tools first, then freemium, then more results.
|
| 1290 |
+
"""
|
| 1291 |
+
urls = _tool_urls(context)
|
| 1292 |
+
topic_enc = _escape(context.ai_tool_query or context.display_topic or context.topic)
|
| 1293 |
+
is_medical = context.intent == "search_medical"
|
| 1294 |
+
|
| 1295 |
+
ku_tools = [
|
| 1296 |
+
("π¬ LeapSpace", urls["leapspace"], "leapspace", "#9a3412"),
|
| 1297 |
+
("π Scopus AI", urls["scopus_ai"], "scopus", "#1e40af"),
|
| 1298 |
+
("π° EBSCO AI", urls["ebsco_ai"], "ebscoai", "#9d174d"),
|
| 1299 |
+
("β PRIMO AI Assistant", urls["primo_ai"], "primoai", "#5b21b6"),
|
| 1300 |
+
]
|
| 1301 |
+
free_tools = [
|
| 1302 |
+
("𧬠Consensus", urls["consensus"], "consensus", "#86198f"),
|
| 1303 |
+
("π OpenAlex", f"https://openalex.org/works?page=1&sort=relevance_score:desc&search={topic_enc}", "openalex", "#0c4a6e"),
|
| 1304 |
+
("π Perplexity", f"https://www.perplexity.ai/search?q={topic_enc}", "perplexity", "#4c1d95"),
|
| 1305 |
+
]
|
| 1306 |
+
more = [("π PRIMO Library Discovery", urls["primo_discovery"], "primoai", "#003366")]
|
| 1307 |
+
if is_medical and urls.get("pubmed"):
|
| 1308 |
+
more.append(("π₯ PubMed", urls["pubmed"], "pubmed_badge", "#065f46"))
|
| 1309 |
+
|
| 1310 |
+
def chip(label, url, cls, color):
|
| 1311 |
+
return (
|
| 1312 |
+
f'<a href="{url}" target="_blank" '
|
| 1313 |
+
f'style="display:inline-flex;align-items:center;padding:5px 11px;margin:3px;'
|
| 1314 |
+
f'border-radius:20px;font-size:.72rem;font-weight:700;text-decoration:none;'
|
| 1315 |
+
f'border:1.5px solid {color}33;color:{color};background:{color}11;'
|
| 1316 |
+
f'transition:all .2s;white-space:nowrap">{label}</a>'
|
| 1317 |
+
)
|
| 1318 |
+
|
| 1319 |
+
html = (
|
| 1320 |
+
'<div style="margin-top:18px;padding:12px 14px;border:1px solid #c4b5fd;'
|
| 1321 |
+
'border-radius:12px;background:linear-gradient(135deg,#f5f3ff,#fdf4ff)">'
|
| 1322 |
+
'<div style="font-size:.75rem;font-weight:800;color:#5b21b6;margin-bottom:8px">'
|
| 1323 |
+
'β¨ For deeper research β AI tools</div>'
|
| 1324 |
+
'<div style="font-size:.67rem;font-weight:700;color:#6b7280;text-transform:uppercase;'
|
| 1325 |
+
'letter-spacing:.5px;margin-bottom:4px">π KU subscribed</div>'
|
| 1326 |
+
'<div style="line-height:2">'
|
| 1327 |
+
)
|
| 1328 |
+
for label, url, cls, color in ku_tools:
|
| 1329 |
+
html += chip(label, url, cls, color)
|
| 1330 |
+
html += (
|
| 1331 |
+
'</div><div style="font-size:.67rem;font-weight:700;color:#6b7280;text-transform:uppercase;'
|
| 1332 |
+
'letter-spacing:.5px;margin:8px 0 4px">π Freemium tools</div>'
|
| 1333 |
+
'<div style="line-height:2">'
|
| 1334 |
+
)
|
| 1335 |
+
for label, url, cls, color in free_tools:
|
| 1336 |
+
html += chip(label, url, cls, color)
|
| 1337 |
+
html += (
|
| 1338 |
+
'</div><div style="font-size:.67rem;font-weight:700;color:#6b7280;text-transform:uppercase;'
|
| 1339 |
+
'letter-spacing:.5px;margin:8px 0 4px">π More results</div>'
|
| 1340 |
+
'<div style="line-height:2">'
|
| 1341 |
+
)
|
| 1342 |
+
for label, url, cls, color in more:
|
| 1343 |
+
html += chip(label, url, cls, color)
|
| 1344 |
+
html += '</div></div>'
|
| 1345 |
+
return html
|
| 1346 |
+
|
| 1347 |
+
|
| 1348 |
def _is_database_recommendation_question(question: str) -> bool:
|
| 1349 |
return bool(DATABASE_RECOMMEND_RE.search(question or ""))
|
| 1350 |
|
|
|
|
| 1651 |
async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple[str, List[dict], List[dict]]:
|
| 1652 |
"""
|
| 1653 |
Research snapshot:
|
| 1654 |
+
- Reasoning block at the top (collapsible).
|
| 1655 |
+
- Cited summary in the middle.
|
| 1656 |
+
- PRIMO results below that.
|
| 1657 |
+
- AI tools recommendation panel always at the very end.
|
| 1658 |
+
- Silent fallback if Semantic Scholar returns nothing.
|
| 1659 |
"""
|
| 1660 |
import asyncio as _asyncio
|
| 1661 |
topic = context.topic
|
| 1662 |
references: List[dict] = []
|
| 1663 |
|
| 1664 |
+
# Parallel fetch
|
| 1665 |
papers, (primo_result, primo_url) = await _asyncio.gather(
|
| 1666 |
_semantic_scholar_search(topic, limit=8),
|
| 1667 |
_search_primo_catalog(context, limit=6),
|
| 1668 |
)
|
| 1669 |
search_results = primo_result.get("results", []) or []
|
| 1670 |
|
| 1671 |
+
# ββ Top: reasoning block ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1672 |
+
q_display = context.ai_tool_query or f"Research on {topic}"
|
| 1673 |
+
answer = _search_trace_block(q_display, context)
|
| 1674 |
+
|
| 1675 |
if papers:
|
| 1676 |
summary = await _generate_cited_summary(topic, papers, model)
|
| 1677 |
if not summary:
|
| 1678 |
_safe_metrics_increment("summary_fallbacks")
|
| 1679 |
summary = _extractive_snapshot_from_papers(topic, papers)
|
| 1680 |
|
| 1681 |
+
# ββ Middle: cited summary βββββββββββββββββββββββββββββββββββββββββββββ
|
| 1682 |
+
answer += f"<strong>π Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
|
| 1683 |
+
|
| 1684 |
if search_results:
|
| 1685 |
answer += (
|
| 1686 |
"<br><br><strong>KU Library results:</strong> "
|
| 1687 |
+
"KU-accessible items are shown below."
|
| 1688 |
)
|
| 1689 |
+
|
| 1690 |
answer += "<br><br><strong>Selected references:</strong><br>"
|
| 1691 |
for i, paper in enumerate(papers[:6], 1):
|
| 1692 |
answer += (
|
|
|
|
| 1696 |
f'{_escape(paper["authors"])} Β· {paper["year"]} Β· {_escape(paper["venue"] or "")}'
|
| 1697 |
f"</span>"
|
| 1698 |
)
|
|
|
|
|
|
|
| 1699 |
answer += _source_badge("semantic", "https://www.semanticscholar.org/")
|
| 1700 |
if search_results:
|
| 1701 |
answer += _source_badge("primo", primo_url)
|
| 1702 |
+
|
| 1703 |
+
# ββ Bottom: AI tools ββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββ
|
| 1704 |
+
answer += _ai_tools_footer(context)
|
| 1705 |
references = [{"title": p["title"], "url": p["link"]} for p in papers[:6]]
|
| 1706 |
return answer, search_results, references
|
| 1707 |
|
| 1708 |
+
# Semantic Scholar returned nothing β silent fallback
|
| 1709 |
if search_results:
|
| 1710 |
+
answer += (
|
| 1711 |
f"<strong>π Research starting points: {_escape(topic)}</strong><br><br>"
|
| 1712 |
+
"Here are KU-accessible results you can open immediately."
|
|
|
|
|
|
|
| 1713 |
)
|
| 1714 |
else:
|
| 1715 |
+
answer += (
|
| 1716 |
f"<strong>π Research starting points: {_escape(topic)}</strong><br><br>"
|
| 1717 |
+
"I didn't find direct results right now β try the AI research tools below."
|
|
|
|
| 1718 |
)
|
| 1719 |
answer += _source_badge("primo", primo_url)
|
| 1720 |
+
# ββ Bottom: AI tools ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1721 |
+
answer += _ai_tools_footer(context)
|
| 1722 |
return answer, search_results, []
|
| 1723 |
|
| 1724 |
|
|
|
|
| 2020 |
question: str, context: SearchContextPayload, model: str, is_follow_up: bool
|
| 2021 |
) -> Tuple[str, List[dict], List[dict], str]:
|
| 2022 |
context = await _prepare_queries(question, context, model, is_follow_up)
|
|
|
|
| 2023 |
|
| 2024 |
if context.source == "pubmed":
|
| 2025 |
result, source_url = await _search_pubmed_articles(context, limit=6)
|
|
|
|
| 2031 |
search_results = result.get("results", []) or []
|
| 2032 |
total = result.get("total", len(search_results))
|
| 2033 |
|
| 2034 |
+
# ββ Top: reasoning / how-I-searched block ββββββββββββββββββββββββββββββββ
|
| 2035 |
+
answer = _search_trace_block(question, context)
|
| 2036 |
+
|
| 2037 |
+
# ββ Middle: answer sentence + result count ββββββββββββββββββββββββββββββββ
|
| 2038 |
+
answer += _search_answer_intro(context, is_follow_up)
|
| 2039 |
+
|
| 2040 |
+
filters = _filters_summary(context)
|
| 2041 |
if filters:
|
| 2042 |
+
answer += f"<br><br><strong>Active filters:</strong> {_escape(filters)}."
|
| 2043 |
+
|
| 2044 |
if search_results:
|
| 2045 |
+
answer += f"<br><br>Found <strong>{total:,}</strong> result(s) β top {len(search_results)} shown below."
|
| 2046 |
else:
|
| 2047 |
+
answer += "<br><br>No matching results found right now β try broadening your terms or use the AI tools below."
|
| 2048 |
+
|
|
|
|
|
|
|
| 2049 |
if context.intent == "search_medical" and context.source != "pubmed":
|
| 2050 |
+
answer += "<br><br>For the strongest medical evidence trail, also check <strong>PubMed</strong> or <strong>Embase</strong>."
|
| 2051 |
|
| 2052 |
answer += _source_badge(source_name, source_url)
|
| 2053 |
+
|
| 2054 |
+
# ββ Bottom: AI tools recommendation panel βββββββββββββββββββββββββββββββββ
|
| 2055 |
+
answer += _ai_tools_footer(context)
|
| 2056 |
+
|
| 2057 |
sources = []
|
| 2058 |
for item in search_results[:6]:
|
| 2059 |
link = item.get("primo_url") or item.get("link")
|