nikeshn commited on
Commit
9dc2635
Β·
verified Β·
1 Parent(s): 3abe8dd

Update src/api/agent.py

Browse files
Files changed (1) hide show
  1. 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 _consensus_mcp_search(topic: str, model: str, limit: int = 8) -> List[dict]:
1400
  """
1401
- Search Consensus via Anthropic MCP to get real peer-reviewed papers with abstracts.
1402
- Returns papers in the same dict format as _semantic_scholar_search so the rest of
1403
- _research_snapshot works unchanged.
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
- async with httpx.AsyncClient(timeout=30) as client:
1417
- r = await client.post(
1418
- "https://api.anthropic.com/v1/messages",
1419
- headers={
1420
- "Content-Type": "application/json",
1421
- "x-api-key": settings.anthropic_api_key,
1422
- "anthropic-version": "2023-06-01",
1423
- "anthropic-beta": "mcp-client-2025-04-04",
1424
- },
1425
- json={
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 def _parse_consensus_response(text: str, topic: str, api_key: str) -> List[dict]:
1465
- """Parse Consensus MCP response text into structured paper dicts."""
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
- return []
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 p in papers_raw:
1504
- if not isinstance(p, dict) or not p.get("title"):
 
 
 
 
 
 
 
 
 
 
 
 
 
1505
  continue
1506
- doi = (p.get("doi") or "").strip()
1507
- url = (p.get("url") or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1508
  link = (
1509
- url if url and url.startswith("http")
1510
  else (f"https://doi.org/{doi}" if doi else "")
1511
- or f"https://consensus.app/results/?q={quote(p.get('title', ''))}"
1512
  )
1513
- authors_raw = p.get("authors", "Unknown")
1514
- author_list = [a.strip() for a in str(authors_raw).split(",")]
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": p.get("title", "Untitled"),
1519
- "authors": ", ".join(author_list[:3]) + (" et al." if len(author_list) > 3 else ""),
1520
  "first_author": first_last,
1521
  "year": year,
1522
  "citation_key": f"{first_last}, {year}",
1523
- "venue": p.get("venue", "") or "",
1524
- "abstract": (p.get("abstract") or "")[:700],
1525
  "link": link,
1526
- "_source": "Consensus",
1527
  })
 
 
1528
  return results
 
1529
  except Exception as e:
1530
- logger.warning(f"_parse_consensus_response failed: {e}")
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: Consensus MCP + PRIMO
2405
  papers, (primo_result, primo_url) = await _asyncio.gather(
2406
- _consensus_mcp_search(topic, model, limit=8),
2407
  _search_primo_catalog(context, limit=6),
2408
  )
2409
 
2410
- # Silent fallback to Semantic Scholar if Consensus returned nothing
2411
  if not papers:
2412
- logger.info(f"Consensus returned no papers for '{topic}' β€” trying Semantic Scholar")
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", "Consensus")
2428
- source_url = "https://consensus.app" if source_label == "Consensus" else "https://www.semanticscholar.org/"
2429
- source_key = "consensus_badge" if source_label == "Consensus" else "semantic"
 
 
 
 
 
 
 
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}"