Update src/api/agent.py
Browse files- src/api/agent.py +80 -119
src/api/agent.py
CHANGED
|
@@ -416,6 +416,7 @@ def _source_badge(source: str, url: str = "") -> str:
|
|
| 416 |
"primo": ("π", "KU Library PRIMO", "#eff6ff", "#1e40af"),
|
| 417 |
"semantic": ("π", "Semantic Scholar", "#f0fdf4", "#166534"),
|
| 418 |
"consensus_badge": ("π§¬", "Consensus", "#fdf4ff", "#86198f"),
|
|
|
|
| 419 |
"pubmed": ("π₯", "PubMed", "#d1fae5", "#065f46"),
|
| 420 |
"web": ("π", "Web Search", "#f5f3ff", "#4c1d95"),
|
| 421 |
"web_live": ("π", "Web Search Β· Live", "#fdf4ff", "#86198f"),
|
|
@@ -1396,139 +1397,92 @@ async def _semantic_scholar_search(topic: str, limit: int = 8) -> List[dict]:
|
|
| 1396 |
return []
|
| 1397 |
|
| 1398 |
|
| 1399 |
-
async def
|
| 1400 |
"""
|
| 1401 |
-
Search
|
| 1402 |
-
Returns papers in the same dict format as _semantic_scholar_search
|
| 1403 |
-
|
| 1404 |
-
Falls back silently to Semantic Scholar if Consensus MCP fails.
|
| 1405 |
"""
|
| 1406 |
-
settings = get_settings()
|
| 1407 |
-
if not settings.anthropic_api_key:
|
| 1408 |
-
return []
|
| 1409 |
-
|
| 1410 |
-
prompt = (
|
| 1411 |
-
f"Search for {limit} high-quality peer-reviewed papers on this topic: {topic}\n\n"
|
| 1412 |
-
f"For each paper return: title, authors, year, abstract (2-3 sentences), "
|
| 1413 |
-
f"journal/venue, and DOI or URL. Focus on influential and recent papers."
|
| 1414 |
-
)
|
| 1415 |
try:
|
| 1416 |
-
|
| 1417 |
-
|
| 1418 |
-
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
"model": "claude-haiku-4-5-20251001",
|
| 1427 |
-
"max_tokens": 2000,
|
| 1428 |
-
"messages": [{"role": "user", "content": prompt}],
|
| 1429 |
-
"mcp_servers": [{
|
| 1430 |
-
"type": "url",
|
| 1431 |
-
"url": "https://mcp.consensus.app/mcp",
|
| 1432 |
-
"name": "consensus"
|
| 1433 |
-
}],
|
| 1434 |
-
},
|
| 1435 |
-
)
|
| 1436 |
-
if r.status_code != 200:
|
| 1437 |
-
logger.warning(f"Consensus MCP returned {r.status_code}")
|
| 1438 |
-
return []
|
| 1439 |
-
|
| 1440 |
-
data = r.json()
|
| 1441 |
-
# Collect all text blocks (tool results + final text)
|
| 1442 |
-
text_parts = []
|
| 1443 |
-
for block in data.get("content", []):
|
| 1444 |
-
if block.get("type") == "text":
|
| 1445 |
-
text_parts.append(block["text"])
|
| 1446 |
-
elif block.get("type") == "mcp_tool_result":
|
| 1447 |
-
for item in block.get("content", []):
|
| 1448 |
-
if item.get("type") == "text":
|
| 1449 |
-
text_parts.append(item["text"])
|
| 1450 |
-
full_text = "\n".join(text_parts)
|
| 1451 |
-
if not full_text.strip():
|
| 1452 |
-
return []
|
| 1453 |
-
|
| 1454 |
-
# Parse the structured text into paper dicts using LLM
|
| 1455 |
-
papers = await _parse_consensus_response(full_text, topic, settings.anthropic_api_key)
|
| 1456 |
-
logger.info(f"Consensus MCP returned {len(papers)} papers for: {topic[:50]}")
|
| 1457 |
-
return papers[:limit]
|
| 1458 |
-
|
| 1459 |
-
except Exception as e:
|
| 1460 |
-
logger.warning(f"Consensus MCP search failed: {e} β will fall back to Semantic Scholar")
|
| 1461 |
-
return []
|
| 1462 |
-
|
| 1463 |
|
| 1464 |
-
async
|
| 1465 |
-
|
| 1466 |
-
try:
|
| 1467 |
-
async with httpx.AsyncClient(timeout=15) as client:
|
| 1468 |
-
r = await client.post(
|
| 1469 |
-
"https://api.anthropic.com/v1/messages",
|
| 1470 |
-
headers={
|
| 1471 |
-
"Content-Type": "application/json",
|
| 1472 |
-
"x-api-key": api_key,
|
| 1473 |
-
"anthropic-version": "2023-06-01",
|
| 1474 |
-
},
|
| 1475 |
-
json={
|
| 1476 |
-
"model": "claude-haiku-4-5-20251001",
|
| 1477 |
-
"max_tokens": 1500,
|
| 1478 |
-
"messages": [{
|
| 1479 |
-
"role": "user",
|
| 1480 |
-
"content": (
|
| 1481 |
-
"Extract the research papers from this text into a JSON array. "
|
| 1482 |
-
"Each paper object must have: title, authors (string), year (string), "
|
| 1483 |
-
"abstract (string, keep it), venue (journal name), doi (string or null), url (string or null). "
|
| 1484 |
-
"Return ONLY valid JSON array, no markdown, no preamble.\n\n"
|
| 1485 |
-
f"Text:\n{text[:4000]}"
|
| 1486 |
-
)
|
| 1487 |
-
}],
|
| 1488 |
-
},
|
| 1489 |
-
)
|
| 1490 |
if r.status_code != 200:
|
| 1491 |
-
|
| 1492 |
-
raw = "".join(
|
| 1493 |
-
b["text"] for b in r.json().get("content", []) if b.get("type") == "text"
|
| 1494 |
-
).strip()
|
| 1495 |
-
# Strip markdown fences if present
|
| 1496 |
-
if raw.startswith("```"):
|
| 1497 |
-
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 1498 |
-
papers_raw = json.loads(raw)
|
| 1499 |
-
if not isinstance(papers_raw, list):
|
| 1500 |
return []
|
| 1501 |
|
|
|
|
| 1502 |
results = []
|
| 1503 |
-
for
|
| 1504 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1505 |
continue
|
| 1506 |
-
|
| 1507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1508 |
link = (
|
| 1509 |
-
|
| 1510 |
else (f"https://doi.org/{doi}" if doi else "")
|
| 1511 |
-
or
|
| 1512 |
)
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
first_last = author_list[0].split()[-1] if author_list else "Unknown"
|
| 1516 |
-
year = str(p.get("year", "n.d.") or "n.d.")
|
| 1517 |
results.append({
|
| 1518 |
-
"title":
|
| 1519 |
-
"authors":
|
| 1520 |
"first_author": first_last,
|
| 1521 |
"year": year,
|
| 1522 |
"citation_key": f"{first_last}, {year}",
|
| 1523 |
-
"venue":
|
| 1524 |
-
"abstract":
|
| 1525 |
"link": link,
|
| 1526 |
-
"_source": "
|
| 1527 |
})
|
|
|
|
|
|
|
| 1528 |
return results
|
|
|
|
| 1529 |
except Exception as e:
|
| 1530 |
-
logger.warning(f"
|
| 1531 |
return []
|
|
|
|
|
|
|
|
|
|
| 1532 |
settings = get_settings()
|
| 1533 |
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 1534 |
return ""
|
|
@@ -2401,15 +2355,15 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
|
|
| 2401 |
topic = context.topic
|
| 2402 |
references: List[dict] = []
|
| 2403 |
|
| 2404 |
-
# Parallel fetch:
|
| 2405 |
papers, (primo_result, primo_url) = await _asyncio.gather(
|
| 2406 |
-
|
| 2407 |
_search_primo_catalog(context, limit=6),
|
| 2408 |
)
|
| 2409 |
|
| 2410 |
-
# Silent fallback to Semantic Scholar if
|
| 2411 |
if not papers:
|
| 2412 |
-
logger.info(f"
|
| 2413 |
papers = await _semantic_scholar_search(topic, limit=8)
|
| 2414 |
|
| 2415 |
search_results = primo_result.get("results", []) or []
|
|
@@ -2424,9 +2378,16 @@ async def _research_snapshot(context: SearchContextPayload, model: str) -> Tuple
|
|
| 2424 |
_safe_metrics_increment("summary_fallbacks")
|
| 2425 |
summary = _extractive_snapshot_from_papers(topic, papers)
|
| 2426 |
|
| 2427 |
-
source_label = papers[0].get("_source", "
|
| 2428 |
-
|
| 2429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2430 |
|
| 2431 |
# ββ Middle: cited summary βββββββββββββββββββββββββββββββββββββββββββββ
|
| 2432 |
answer += f"<strong>π Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
|
|
|
|
| 416 |
"primo": ("π", "KU Library PRIMO", "#eff6ff", "#1e40af"),
|
| 417 |
"semantic": ("π", "Semantic Scholar", "#f0fdf4", "#166534"),
|
| 418 |
"consensus_badge": ("π§¬", "Consensus", "#fdf4ff", "#86198f"),
|
| 419 |
+
"openalex_badge": ("π", "OpenAlex", "#f0fdf4", "#166534"),
|
| 420 |
"pubmed": ("π₯", "PubMed", "#d1fae5", "#065f46"),
|
| 421 |
"web": ("π", "Web Search", "#f5f3ff", "#4c1d95"),
|
| 422 |
"web_live": ("π", "Web Search Β· Live", "#fdf4ff", "#86198f"),
|
|
|
|
| 1397 |
return []
|
| 1398 |
|
| 1399 |
|
| 1400 |
+
async def _openalex_search(topic: str, limit: int = 8) -> List[dict]:
|
| 1401 |
"""
|
| 1402 |
+
Search OpenAlex (free, no API key) for peer-reviewed papers with abstracts.
|
| 1403 |
+
Returns papers in the same dict format as _semantic_scholar_search.
|
| 1404 |
+
Typically responds in < 2 seconds.
|
|
|
|
| 1405 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1406 |
try:
|
| 1407 |
+
from urllib.parse import quote as _q
|
| 1408 |
+
params = {
|
| 1409 |
+
"search": topic,
|
| 1410 |
+
"filter": "has_abstract:true,type:article",
|
| 1411 |
+
"sort": "cited_by_count:desc",
|
| 1412 |
+
"per_page": limit,
|
| 1413 |
+
"select": "id,title,authorships,publication_year,primary_location,abstract_inverted_index,doi,cited_by_count,open_access",
|
| 1414 |
+
}
|
| 1415 |
+
url = "https://api.openalex.org/works?" + "&".join(f"{k}={_q(str(v))}" for k, v in params.items())
|
| 1416 |
+
headers = {"User-Agent": "LibBee/3.2 (mailto:libse@ku.ac.ae)"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1417 |
|
| 1418 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 1419 |
+
r = await client.get(url, headers=headers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1420 |
if r.status_code != 200:
|
| 1421 |
+
logger.warning(f"OpenAlex returned {r.status_code}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1422 |
return []
|
| 1423 |
|
| 1424 |
+
data = r.json()
|
| 1425 |
results = []
|
| 1426 |
+
for work in data.get("results", []):
|
| 1427 |
+
title = work.get("title") or "Untitled"
|
| 1428 |
+
|
| 1429 |
+
# Reconstruct abstract from inverted index
|
| 1430 |
+
inv = work.get("abstract_inverted_index") or {}
|
| 1431 |
+
abstract = ""
|
| 1432 |
+
if inv:
|
| 1433 |
+
word_positions = []
|
| 1434 |
+
for word, positions in inv.items():
|
| 1435 |
+
for pos in positions:
|
| 1436 |
+
word_positions.append((pos, word))
|
| 1437 |
+
word_positions.sort(key=lambda x: x[0])
|
| 1438 |
+
abstract = " ".join(w for _, w in word_positions)[:700]
|
| 1439 |
+
|
| 1440 |
+
if not abstract:
|
| 1441 |
continue
|
| 1442 |
+
|
| 1443 |
+
# Authors
|
| 1444 |
+
authorships = work.get("authorships") or []
|
| 1445 |
+
author_names = [
|
| 1446 |
+
a.get("author", {}).get("display_name", "")
|
| 1447 |
+
for a in authorships[:4]
|
| 1448 |
+
if a.get("author", {}).get("display_name")
|
| 1449 |
+
]
|
| 1450 |
+
authors_str = ", ".join(author_names[:3])
|
| 1451 |
+
if len(author_names) > 3:
|
| 1452 |
+
authors_str += " et al."
|
| 1453 |
+
first_last = (author_names[0].split()[-1] if author_names else "Unknown")
|
| 1454 |
+
|
| 1455 |
+
year = str(work.get("publication_year") or "n.d.")
|
| 1456 |
+
doi = (work.get("doi") or "").replace("https://doi.org/", "")
|
| 1457 |
+
oa_url = (work.get("open_access") or {}).get("oa_url") or ""
|
| 1458 |
link = (
|
| 1459 |
+
oa_url if oa_url
|
| 1460 |
else (f"https://doi.org/{doi}" if doi else "")
|
| 1461 |
+
or work.get("id", "https://openalex.org")
|
| 1462 |
)
|
| 1463 |
+
venue = ((work.get("primary_location") or {}).get("source") or {}).get("display_name") or ""
|
| 1464 |
+
|
|
|
|
|
|
|
| 1465 |
results.append({
|
| 1466 |
+
"title": title,
|
| 1467 |
+
"authors": authors_str or "Unknown",
|
| 1468 |
"first_author": first_last,
|
| 1469 |
"year": year,
|
| 1470 |
"citation_key": f"{first_last}, {year}",
|
| 1471 |
+
"venue": venue,
|
| 1472 |
+
"abstract": abstract,
|
| 1473 |
"link": link,
|
| 1474 |
+
"_source": "OpenAlex",
|
| 1475 |
})
|
| 1476 |
+
|
| 1477 |
+
logger.info(f"OpenAlex: {len(results)} papers for '{topic[:50]}'")
|
| 1478 |
return results
|
| 1479 |
+
|
| 1480 |
except Exception as e:
|
| 1481 |
+
logger.warning(f"OpenAlex search failed: {e}")
|
| 1482 |
return []
|
| 1483 |
+
|
| 1484 |
+
|
| 1485 |
+
async def _generate_cited_summary(topic: str, papers: List[dict], model: str) -> str:
|
| 1486 |
settings = get_settings()
|
| 1487 |
if not settings.openai_api_key and not settings.anthropic_api_key:
|
| 1488 |
return ""
|
|
|
|
| 2355 |
topic = context.topic
|
| 2356 |
references: List[dict] = []
|
| 2357 |
|
| 2358 |
+
# Parallel fetch: OpenAlex (fast, free) + PRIMO
|
| 2359 |
papers, (primo_result, primo_url) = await _asyncio.gather(
|
| 2360 |
+
_openalex_search(topic, limit=8),
|
| 2361 |
_search_primo_catalog(context, limit=6),
|
| 2362 |
)
|
| 2363 |
|
| 2364 |
+
# Silent fallback to Semantic Scholar if OpenAlex returned nothing
|
| 2365 |
if not papers:
|
| 2366 |
+
logger.info(f"OpenAlex returned no papers for '{topic}' β trying Semantic Scholar")
|
| 2367 |
papers = await _semantic_scholar_search(topic, limit=8)
|
| 2368 |
|
| 2369 |
search_results = primo_result.get("results", []) or []
|
|
|
|
| 2378 |
_safe_metrics_increment("summary_fallbacks")
|
| 2379 |
summary = _extractive_snapshot_from_papers(topic, papers)
|
| 2380 |
|
| 2381 |
+
source_label = papers[0].get("_source", "OpenAlex")
|
| 2382 |
+
if source_label == "OpenAlex":
|
| 2383 |
+
source_url = "https://openalex.org"
|
| 2384 |
+
source_key = "openalex_badge"
|
| 2385 |
+
elif source_label == "Consensus":
|
| 2386 |
+
source_url = "https://consensus.app"
|
| 2387 |
+
source_key = "consensus_badge"
|
| 2388 |
+
else:
|
| 2389 |
+
source_url = "https://www.semanticscholar.org/"
|
| 2390 |
+
source_key = "semantic"
|
| 2391 |
|
| 2392 |
# ββ Middle: cited summary βββββββββββββββββββββββββββββββββββββββββββββ
|
| 2393 |
answer += f"<strong>π Research Snapshot: {_escape(topic)}</strong><br><br>{summary}"
|