Spaces:
Running
Running
Commit
·
2f8ae1f
1
Parent(s):
0a480cb
refactor(tools): replace BioRxiv with Europe PMC (Phase 01)
Browse filesReplaced broken BioRxivTool with EuropePMCTool which supports keyword search. Updated all agents, MCP tools, and examples to use the new tool. Deleted legacy BioRxiv implementation.
- docs/bugs/P0_ACTIONABLE_FIXES.md +281 -0
- docs/bugs/P0_CRITICAL_BUGS.md +298 -0
- docs/bugs/P0_MAGENTIC_AND_SEARCH_AUDIT.md +249 -0
- docs/bugs/PHASE_00_IMPLEMENTATION_ORDER.md +156 -0
- docs/bugs/PHASE_01_REPLACE_BIORXIV.md +371 -0
- docs/bugs/PHASE_02_PUBMED_QUERY_PREPROCESSING.md +355 -0
- docs/bugs/PHASE_03_CLINICALTRIALS_FILTERING.md +386 -0
- examples/full_stack_demo/run_full.py +3 -3
- examples/hypothesis_demo/run_hypothesis.py +2 -2
- examples/orchestrator_demo/run_agent.py +2 -2
- examples/orchestrator_demo/run_magentic.py +2 -2
- examples/search_demo/run_search.py +3 -3
- src/agents/tools.py +10 -10
- src/app.py +2 -2
- src/mcp_tools.py +17 -17
- src/tools/biorxiv.py +0 -352
- src/tools/europepmc.py +107 -0
- src/utils/models.py +1 -1
- tests/unit/test_app_smoke.py +2 -2
- tests/unit/test_mcp_tools.py +16 -16
- tests/unit/tools/test_biorxiv.py +0 -178
- tests/unit/tools/test_europepmc.py +123 -0
docs/bugs/P0_ACTIONABLE_FIXES.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# P0 Actionable Fixes - What to Do
|
| 2 |
+
|
| 3 |
+
**Date:** November 27, 2025
|
| 4 |
+
**Status:** ACTIONABLE
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Summary: What's Broken and What's Fixable
|
| 9 |
+
|
| 10 |
+
| Tool | Problem | Fixable? | How |
|
| 11 |
+
|------|---------|----------|-----|
|
| 12 |
+
| BioRxiv | API has NO search endpoint | **NO** | Replace with Europe PMC |
|
| 13 |
+
| PubMed | No query preprocessing | **YES** | Add query cleaner |
|
| 14 |
+
| ClinicalTrials | No filters applied | **YES** | Add filter params |
|
| 15 |
+
| Magentic Framework | Nothing wrong | N/A | Already working |
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## FIX 1: Replace BioRxiv with Europe PMC (30 min)
|
| 20 |
+
|
| 21 |
+
### Why BioRxiv Can't Be Fixed
|
| 22 |
+
|
| 23 |
+
The bioRxiv API only has this endpoint:
|
| 24 |
+
```
|
| 25 |
+
https://api.biorxiv.org/details/{server}/{date-range}/{cursor}/json
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
This returns papers **by date**, not by keyword. There is NO search endpoint.
|
| 29 |
+
|
| 30 |
+
**Proof:** I queried `medrxiv/2024-01-01/2024-01-02` and got:
|
| 31 |
+
- "Global risk of Plasmodium falciparum" (malaria)
|
| 32 |
+
- "Multiple Endocrine Neoplasia in India"
|
| 33 |
+
- "Acupuncture for Acute Musculoskeletal Pain"
|
| 34 |
+
|
| 35 |
+
**None of these are about Long COVID** because the API doesn't search.
|
| 36 |
+
|
| 37 |
+
### Europe PMC Has Search + Preprints
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
curl "https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=long+covid+treatment&resultType=core&pageSize=3&format=json"
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Returns 283,058 results including:
|
| 44 |
+
- "Long COVID Treatment No Silver Bullets, Only a Few Bronze BBs" ✅
|
| 45 |
+
|
| 46 |
+
### The Fix
|
| 47 |
+
|
| 48 |
+
Replace `src/tools/biorxiv.py` with `src/tools/europepmc.py`:
|
| 49 |
+
|
| 50 |
+
```python
|
| 51 |
+
"""Europe PMC preprint and paper search tool."""
|
| 52 |
+
|
| 53 |
+
import httpx
|
| 54 |
+
from src.utils.models import Citation, Evidence
|
| 55 |
+
|
| 56 |
+
class EuropePMCTool:
|
| 57 |
+
"""Search Europe PMC for papers and preprints."""
|
| 58 |
+
|
| 59 |
+
BASE_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def name(self) -> str:
|
| 63 |
+
return "europepmc"
|
| 64 |
+
|
| 65 |
+
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 66 |
+
"""Search Europe PMC (includes preprints from bioRxiv/medRxiv)."""
|
| 67 |
+
params = {
|
| 68 |
+
"query": query,
|
| 69 |
+
"resultType": "core",
|
| 70 |
+
"pageSize": max_results,
|
| 71 |
+
"format": "json",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 75 |
+
response = await client.get(self.BASE_URL, params=params)
|
| 76 |
+
response.raise_for_status()
|
| 77 |
+
|
| 78 |
+
data = response.json()
|
| 79 |
+
results = data.get("resultList", {}).get("result", [])
|
| 80 |
+
|
| 81 |
+
return [self._to_evidence(r) for r in results]
|
| 82 |
+
|
| 83 |
+
def _to_evidence(self, result: dict) -> Evidence:
|
| 84 |
+
"""Convert Europe PMC result to Evidence."""
|
| 85 |
+
title = result.get("title", "Untitled")
|
| 86 |
+
abstract = result.get("abstractText", "No abstract")
|
| 87 |
+
doi = result.get("doi", "")
|
| 88 |
+
pub_year = result.get("pubYear", "Unknown")
|
| 89 |
+
source = result.get("source", "europepmc")
|
| 90 |
+
|
| 91 |
+
# Mark preprints
|
| 92 |
+
pub_type = result.get("pubTypeList", {}).get("pubType", [])
|
| 93 |
+
is_preprint = "Preprint" in pub_type
|
| 94 |
+
|
| 95 |
+
content = f"{'[PREPRINT] ' if is_preprint else ''}{abstract[:1800]}"
|
| 96 |
+
|
| 97 |
+
return Evidence(
|
| 98 |
+
content=content,
|
| 99 |
+
citation=Citation(
|
| 100 |
+
source="europepmc" if not is_preprint else "preprint",
|
| 101 |
+
title=title[:500],
|
| 102 |
+
url=f"https://doi.org/{doi}" if doi else "",
|
| 103 |
+
date=str(pub_year),
|
| 104 |
+
),
|
| 105 |
+
relevance=0.75 if is_preprint else 0.9,
|
| 106 |
+
)
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## FIX 2: Add PubMed Query Preprocessing (1 hour)
|
| 112 |
+
|
| 113 |
+
### Current Problem
|
| 114 |
+
|
| 115 |
+
User enters: `What medications show promise for Long COVID?`
|
| 116 |
+
PubMed receives: `What medications show promise for Long COVID?`
|
| 117 |
+
|
| 118 |
+
The question words pollute the search.
|
| 119 |
+
|
| 120 |
+
### The Fix
|
| 121 |
+
|
| 122 |
+
Add `src/tools/query_utils.py`:
|
| 123 |
+
|
| 124 |
+
```python
|
| 125 |
+
"""Query preprocessing utilities."""
|
| 126 |
+
|
| 127 |
+
import re
|
| 128 |
+
|
| 129 |
+
# Question words to remove
|
| 130 |
+
QUESTION_WORDS = {
|
| 131 |
+
"what", "which", "how", "why", "when", "where", "who",
|
| 132 |
+
"is", "are", "can", "could", "would", "should", "do", "does",
|
| 133 |
+
"show", "promise", "help", "treat", "cure",
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
# Medical synonyms to expand
|
| 137 |
+
SYNONYMS = {
|
| 138 |
+
"long covid": ["long COVID", "PASC", "post-COVID syndrome", "post-acute sequelae"],
|
| 139 |
+
"alzheimer": ["Alzheimer's disease", "AD", "Alzheimer dementia"],
|
| 140 |
+
"cancer": ["neoplasm", "tumor", "malignancy", "carcinoma"],
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
def preprocess_pubmed_query(raw_query: str) -> str:
|
| 144 |
+
"""Convert natural language to cleaner PubMed query."""
|
| 145 |
+
# Lowercase
|
| 146 |
+
query = raw_query.lower()
|
| 147 |
+
|
| 148 |
+
# Remove question marks
|
| 149 |
+
query = query.replace("?", "")
|
| 150 |
+
|
| 151 |
+
# Remove question words
|
| 152 |
+
words = query.split()
|
| 153 |
+
words = [w for w in words if w not in QUESTION_WORDS]
|
| 154 |
+
query = " ".join(words)
|
| 155 |
+
|
| 156 |
+
# Expand synonyms
|
| 157 |
+
for term, expansions in SYNONYMS.items():
|
| 158 |
+
if term in query:
|
| 159 |
+
# Add OR clause
|
| 160 |
+
expansion = " OR ".join([f'"{e}"' for e in expansions])
|
| 161 |
+
query = query.replace(term, f"({expansion})")
|
| 162 |
+
|
| 163 |
+
return query.strip()
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
Then update `src/tools/pubmed.py`:
|
| 167 |
+
|
| 168 |
+
```python
|
| 169 |
+
from src.tools.query_utils import preprocess_pubmed_query
|
| 170 |
+
|
| 171 |
+
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 172 |
+
# Preprocess query
|
| 173 |
+
clean_query = preprocess_pubmed_query(query)
|
| 174 |
+
|
| 175 |
+
search_params = self._build_params(
|
| 176 |
+
db="pubmed",
|
| 177 |
+
term=clean_query, # Use cleaned query
|
| 178 |
+
retmax=max_results,
|
| 179 |
+
sort="relevance",
|
| 180 |
+
)
|
| 181 |
+
# ... rest unchanged
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
---
|
| 185 |
+
|
| 186 |
+
## FIX 3: Add ClinicalTrials.gov Filters (30 min)
|
| 187 |
+
|
| 188 |
+
### Current Problem
|
| 189 |
+
|
| 190 |
+
Returns ALL trials including withdrawn, terminated, observational studies.
|
| 191 |
+
|
| 192 |
+
### The Fix
|
| 193 |
+
|
| 194 |
+
The API supports `filter.overallStatus` and other filters. Update `src/tools/clinicaltrials.py`:
|
| 195 |
+
|
| 196 |
+
```python
|
| 197 |
+
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 198 |
+
params: dict[str, str | int] = {
|
| 199 |
+
"query.term": query,
|
| 200 |
+
"pageSize": min(max_results, 100),
|
| 201 |
+
"fields": "|".join(self.FIELDS),
|
| 202 |
+
# ADD THESE FILTERS:
|
| 203 |
+
"filter.overallStatus": "COMPLETED|RECRUITING|ACTIVE_NOT_RECRUITING",
|
| 204 |
+
# Only interventional studies (not observational)
|
| 205 |
+
"aggFilters": "studyType:int",
|
| 206 |
+
}
|
| 207 |
+
# ... rest unchanged
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
**Note:** I tested the API - it supports filtering but with slightly different syntax. Check the [API docs](https://clinicaltrials.gov/data-api/api).
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## What NOT to Change
|
| 215 |
+
|
| 216 |
+
### Microsoft Agent Framework - WORKING
|
| 217 |
+
|
| 218 |
+
I verified:
|
| 219 |
+
```python
|
| 220 |
+
from agent_framework import MagenticBuilder, ChatAgent
|
| 221 |
+
from agent_framework.openai import OpenAIChatClient
|
| 222 |
+
# All imports OK
|
| 223 |
+
|
| 224 |
+
orchestrator = MagenticOrchestrator(max_rounds=2)
|
| 225 |
+
workflow = orchestrator._build_workflow()
|
| 226 |
+
# Workflow built successfully
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
The Magentic agents are correctly wired:
|
| 230 |
+
- SearchAgent → GPT-5.1 ✅
|
| 231 |
+
- JudgeAgent → GPT-5.1 ✅
|
| 232 |
+
- HypothesisAgent → GPT-5.1 ✅
|
| 233 |
+
- ReportAgent → GPT-5.1 ✅
|
| 234 |
+
|
| 235 |
+
**The framework is fine. The tools it calls are broken.**
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
|
| 239 |
+
## Priority Order
|
| 240 |
+
|
| 241 |
+
1. **Replace BioRxiv** → Immediate, fundamental
|
| 242 |
+
2. **Add PubMed preprocessing** → High impact, easy
|
| 243 |
+
3. **Add ClinicalTrials filters** → Medium impact, easy
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
## Test After Fixes
|
| 248 |
+
|
| 249 |
+
```bash
|
| 250 |
+
# Test Europe PMC
|
| 251 |
+
uv run python -c "
|
| 252 |
+
import asyncio
|
| 253 |
+
from src.tools.europepmc import EuropePMCTool
|
| 254 |
+
tool = EuropePMCTool()
|
| 255 |
+
results = asyncio.run(tool.search('long covid treatment', 3))
|
| 256 |
+
for r in results:
|
| 257 |
+
print(r.citation.title)
|
| 258 |
+
"
|
| 259 |
+
|
| 260 |
+
# Test PubMed with preprocessing
|
| 261 |
+
uv run python -c "
|
| 262 |
+
from src.tools.query_utils import preprocess_pubmed_query
|
| 263 |
+
q = 'What medications show promise for Long COVID?'
|
| 264 |
+
print(preprocess_pubmed_query(q))
|
| 265 |
+
# Should output: (\"long COVID\" OR \"PASC\" OR \"post-COVID syndrome\") medications
|
| 266 |
+
"
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
## After These Fixes
|
| 272 |
+
|
| 273 |
+
The Magentic workflow will:
|
| 274 |
+
1. SearchAgent calls `search_pubmed("long COVID treatment")` → Gets RELEVANT papers
|
| 275 |
+
2. SearchAgent calls `search_preprints("long COVID treatment")` → Gets RELEVANT preprints via Europe PMC
|
| 276 |
+
3. SearchAgent calls `search_clinical_trials("long COVID")` → Gets INTERVENTIONAL trials only
|
| 277 |
+
4. JudgeAgent evaluates GOOD evidence
|
| 278 |
+
5. HypothesisAgent generates hypotheses from GOOD evidence
|
| 279 |
+
6. ReportAgent synthesizes GOOD report
|
| 280 |
+
|
| 281 |
+
**The framework will work once we feed it good data.**
|
docs/bugs/P0_CRITICAL_BUGS.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# P0 CRITICAL BUGS - Why DeepCritical Produces Garbage Results
|
| 2 |
+
|
| 3 |
+
**Date:** November 27, 2025
|
| 4 |
+
**Status:** CRITICAL - App is functionally useless
|
| 5 |
+
**Severity:** P0 (Blocker)
|
| 6 |
+
|
| 7 |
+
## TL;DR
|
| 8 |
+
|
| 9 |
+
The app produces garbage because:
|
| 10 |
+
1. **BioRxiv search doesn't work** - returns random papers
|
| 11 |
+
2. **Free tier LLM is too dumb** - can't identify drugs
|
| 12 |
+
3. **Query construction is naive** - no optimization for PubMed/CT.gov syntax
|
| 13 |
+
4. **Loop terminates too early** - 5 iterations isn't enough
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## P0-001: BioRxiv Search is Fundamentally Broken
|
| 18 |
+
|
| 19 |
+
**File:** `src/tools/biorxiv.py:248-286`
|
| 20 |
+
|
| 21 |
+
**The Problem:**
|
| 22 |
+
The bioRxiv API **DOES NOT SUPPORT KEYWORD SEARCH**.
|
| 23 |
+
|
| 24 |
+
The code does this:
|
| 25 |
+
```python
|
| 26 |
+
# Fetch recent papers (last 90 days, first 100 papers)
|
| 27 |
+
url = f"{self.BASE_URL}/{self.server}/{interval}/0/json"
|
| 28 |
+
# Then filter client-side for keywords
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
**What Actually Happens:**
|
| 32 |
+
1. Fetches the first 100 papers from medRxiv in the last 90 days (chronological order)
|
| 33 |
+
2. Filters those 100 random papers for query keywords
|
| 34 |
+
3. Returns whatever garbage matches
|
| 35 |
+
|
| 36 |
+
**Result:** For "Long COVID medications", you get random papers like:
|
| 37 |
+
- "Calf muscle structure-function adaptations"
|
| 38 |
+
- "Work-Life Balance of Ophthalmologists During COVID"
|
| 39 |
+
|
| 40 |
+
These papers contain "COVID" somewhere but have NOTHING to do with Long COVID treatments.
|
| 41 |
+
|
| 42 |
+
**Root Cause:** The `/0/json` pagination only returns 100 papers. You'd need to paginate through ALL papers (thousands) to do proper keyword filtering.
|
| 43 |
+
|
| 44 |
+
**Fix Options:**
|
| 45 |
+
1. **Remove BioRxiv entirely** - It's unusable without proper search API
|
| 46 |
+
2. **Use a different preprint aggregator** - Europe PMC has preprints WITH search
|
| 47 |
+
3. **Add pagination** - Fetch all papers (slow, expensive)
|
| 48 |
+
4. **Use Semantic Scholar API** - Has preprints and proper search
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## P0-002: Free Tier LLM Cannot Perform Drug Identification
|
| 53 |
+
|
| 54 |
+
**File:** `src/agent_factory/judges.py:153-211`
|
| 55 |
+
|
| 56 |
+
**The Problem:**
|
| 57 |
+
Without an API key, the app uses `HFInferenceJudgeHandler` with:
|
| 58 |
+
- Llama 3.1 8B Instruct
|
| 59 |
+
- Mistral 7B Instruct
|
| 60 |
+
|
| 61 |
+
These are **7-8 billion parameter models**. They cannot:
|
| 62 |
+
- Reliably parse complex biomedical abstracts
|
| 63 |
+
- Identify drug candidates from scientific text
|
| 64 |
+
- Generate structured JSON output consistently
|
| 65 |
+
- Reason about mechanism of action
|
| 66 |
+
|
| 67 |
+
**Evidence of Failure:**
|
| 68 |
+
```python
|
| 69 |
+
# From MockJudgeHandler - the honest fallback when LLM fails
|
| 70 |
+
drug_candidates=[
|
| 71 |
+
"Drug identification requires AI analysis",
|
| 72 |
+
"Enter API key above for full results",
|
| 73 |
+
]
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
The team KNEW the free tier can't identify drugs and added this message.
|
| 77 |
+
|
| 78 |
+
**Root Cause:** Drug repurposing requires understanding:
|
| 79 |
+
- Drug mechanisms
|
| 80 |
+
- Disease pathophysiology
|
| 81 |
+
- Clinical trial phases
|
| 82 |
+
- Statistical significance
|
| 83 |
+
|
| 84 |
+
This requires GPT-4 / Claude Sonnet class models (100B+ parameters).
|
| 85 |
+
|
| 86 |
+
**Fix Options:**
|
| 87 |
+
1. **Require API key** - No free tier, be honest
|
| 88 |
+
2. **Use larger HF models** - Llama 70B or Mixtral 8x7B (expensive on free tier)
|
| 89 |
+
3. **Hybrid approach** - Use free tier for search, require paid for synthesis
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## P0-003: PubMed Query Not Optimized
|
| 94 |
+
|
| 95 |
+
**File:** `src/tools/pubmed.py:54-71`
|
| 96 |
+
|
| 97 |
+
**The Problem:**
|
| 98 |
+
The query is passed directly to PubMed without optimization:
|
| 99 |
+
```python
|
| 100 |
+
search_params = self._build_params(
|
| 101 |
+
db="pubmed",
|
| 102 |
+
term=query, # Raw user query!
|
| 103 |
+
retmax=max_results,
|
| 104 |
+
sort="relevance",
|
| 105 |
+
)
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**What User Enters:** "What medications show promise for Long COVID?"
|
| 109 |
+
|
| 110 |
+
**What PubMed Receives:** `What medications show promise for Long COVID?`
|
| 111 |
+
|
| 112 |
+
**What PubMed Should Receive:**
|
| 113 |
+
```
|
| 114 |
+
("long covid"[Title/Abstract] OR "post-COVID"[Title/Abstract] OR "PASC"[Title/Abstract])
|
| 115 |
+
AND (drug[Title/Abstract] OR treatment[Title/Abstract] OR medication[Title/Abstract] OR therapy[Title/Abstract])
|
| 116 |
+
AND (clinical trial[Publication Type] OR randomized[Title/Abstract])
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**Root Cause:** No query preprocessing or medical term expansion.
|
| 120 |
+
|
| 121 |
+
**Fix Options:**
|
| 122 |
+
1. **Add query preprocessor** - Extract medical entities, expand synonyms
|
| 123 |
+
2. **Use MeSH terms** - PubMed's controlled vocabulary for better recall
|
| 124 |
+
3. **LLM query generation** - Use LLM to generate optimized PubMed query
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## P0-004: Loop Terminates Too Early
|
| 129 |
+
|
| 130 |
+
**File:** `src/app.py:42-45` and `src/utils/models.py`
|
| 131 |
+
|
| 132 |
+
**The Problem:**
|
| 133 |
+
```python
|
| 134 |
+
config = OrchestratorConfig(
|
| 135 |
+
max_iterations=5,
|
| 136 |
+
max_results_per_tool=10,
|
| 137 |
+
)
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
5 iterations is not enough to:
|
| 141 |
+
1. Search multiple variations of the query
|
| 142 |
+
2. Gather enough evidence for the Judge to synthesize
|
| 143 |
+
3. Refine queries based on initial results
|
| 144 |
+
|
| 145 |
+
**Evidence:** The user's output shows "Max Iterations Reached" with only 6 sources.
|
| 146 |
+
|
| 147 |
+
**Root Cause:** Conservative defaults to avoid API costs, but makes app useless.
|
| 148 |
+
|
| 149 |
+
**Fix Options:**
|
| 150 |
+
1. **Increase default to 10-15** - More iterations = better results
|
| 151 |
+
2. **Dynamic termination** - Stop when confidence > threshold, not iteration count
|
| 152 |
+
3. **Parallel query expansion** - Run more queries per iteration
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## P0-005: No Query Understanding Layer
|
| 157 |
+
|
| 158 |
+
**Files:** `src/orchestrator.py`, `src/tools/search_handler.py`
|
| 159 |
+
|
| 160 |
+
**The Problem:**
|
| 161 |
+
There's no NLU (Natural Language Understanding) layer. The system:
|
| 162 |
+
1. Takes raw user query
|
| 163 |
+
2. Passes directly to search tools
|
| 164 |
+
3. No entity extraction
|
| 165 |
+
4. No intent classification
|
| 166 |
+
5. No query expansion
|
| 167 |
+
|
| 168 |
+
For drug repurposing, you need to extract:
|
| 169 |
+
- **Disease:** "Long COVID" → [Long COVID, PASC, Post-COVID syndrome, chronic COVID]
|
| 170 |
+
- **Drug intent:** "medications" → [drugs, treatments, therapeutics, interventions]
|
| 171 |
+
- **Evidence type:** "show promise" → [clinical trials, efficacy, RCT]
|
| 172 |
+
|
| 173 |
+
**Root Cause:** No preprocessing pipeline between user input and search execution.
|
| 174 |
+
|
| 175 |
+
**Fix Options:**
|
| 176 |
+
1. **Add entity extraction** - Use BioBERT or PubMedBERT for medical NER
|
| 177 |
+
2. **Add query expansion** - Use medical ontologies (UMLS, MeSH)
|
| 178 |
+
3. **LLM preprocessing** - Use LLM to generate search strategy before searching
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
## P0-006: ClinicalTrials.gov Results Not Filtered
|
| 183 |
+
|
| 184 |
+
**File:** `src/tools/clinicaltrials.py`
|
| 185 |
+
|
| 186 |
+
**The Problem:**
|
| 187 |
+
ClinicalTrials.gov returns ALL matching trials including:
|
| 188 |
+
- Withdrawn trials
|
| 189 |
+
- Terminated trials
|
| 190 |
+
- Not yet recruiting
|
| 191 |
+
- Observational studies (not interventional)
|
| 192 |
+
|
| 193 |
+
For drug repurposing, you want:
|
| 194 |
+
- Interventional studies
|
| 195 |
+
- Phase 2+ (has safety/efficacy data)
|
| 196 |
+
- Completed or with results
|
| 197 |
+
|
| 198 |
+
**Root Cause:** No filtering of trial metadata.
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## Summary: Why This App Produces Garbage
|
| 203 |
+
|
| 204 |
+
```
|
| 205 |
+
User Query: "What medications show promise for Long COVID?"
|
| 206 |
+
│
|
| 207 |
+
▼
|
| 208 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 209 |
+
│ NO QUERY PREPROCESSING │
|
| 210 |
+
│ - No entity extraction │
|
| 211 |
+
│ - No synonym expansion │
|
| 212 |
+
│ - No medical term normalization │
|
| 213 |
+
└─────────────────────────────────────────────────────────────┘
|
| 214 |
+
│
|
| 215 |
+
▼
|
| 216 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 217 |
+
│ BROKEN SEARCH LAYER │
|
| 218 |
+
│ - PubMed: Raw query, no MeSH, gets 1 result │
|
| 219 |
+
│ - BioRxiv: Returns random papers (API doesn't support search)│
|
| 220 |
+
│ - ClinicalTrials: Returns all trials, no filtering │
|
| 221 |
+
└─────────────────────────────────────────────────────────────┘
|
| 222 |
+
│
|
| 223 |
+
▼
|
| 224 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 225 |
+
│ GARBAGE EVIDENCE │
|
| 226 |
+
│ - 6 papers, most irrelevant │
|
| 227 |
+
│ - "Calf muscle adaptations" (mentions COVID once) │
|
| 228 |
+
│ - "Ophthalmologist work-life balance" │
|
| 229 |
+
└─────────────────────────────────────────────────────────────┘
|
| 230 |
+
│
|
| 231 |
+
▼
|
| 232 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 233 |
+
│ DUMB JUDGE (Free Tier) │
|
| 234 |
+
│ - Llama 8B can't identify drugs from garbage │
|
| 235 |
+
│ - JSON parsing fails │
|
| 236 |
+
│ - Falls back to "Drug identification requires AI analysis" │
|
| 237 |
+
└─────────────────────────────────────────────────────────────┘
|
| 238 |
+
│
|
| 239 |
+
▼
|
| 240 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 241 |
+
│ LOOP HITS MAX (5 iterations) │
|
| 242 |
+
│ - Never finds enough good evidence │
|
| 243 |
+
│ - Never synthesizes anything useful │
|
| 244 |
+
└─────────────────────────────────────────────────────────────┘
|
| 245 |
+
│
|
| 246 |
+
▼
|
| 247 |
+
GARBAGE OUTPUT
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
---
|
| 251 |
+
|
| 252 |
+
## What Would Make This Actually Work
|
| 253 |
+
|
| 254 |
+
### Minimum Viable Fix (1-2 days)
|
| 255 |
+
|
| 256 |
+
1. **Remove BioRxiv** - It doesn't work
|
| 257 |
+
2. **Require API key** - Be honest that free tier is useless
|
| 258 |
+
3. **Add basic query preprocessing** - Strip question words, expand COVID synonyms
|
| 259 |
+
4. **Increase iterations to 10**
|
| 260 |
+
|
| 261 |
+
### Proper Fix (1-2 weeks)
|
| 262 |
+
|
| 263 |
+
1. **Query Understanding Layer**
|
| 264 |
+
- Medical NER (BioBERT/SciBERT)
|
| 265 |
+
- Query expansion with MeSH/UMLS
|
| 266 |
+
- Intent classification (drug discovery vs mechanism vs safety)
|
| 267 |
+
|
| 268 |
+
2. **Optimized Search**
|
| 269 |
+
- PubMed: Proper query syntax with MeSH terms
|
| 270 |
+
- ClinicalTrials: Filter by phase, status, intervention type
|
| 271 |
+
- Replace BioRxiv with Europe PMC (has preprints + search)
|
| 272 |
+
|
| 273 |
+
3. **Evidence Ranking**
|
| 274 |
+
- Score by publication type (RCT > cohort > case report)
|
| 275 |
+
- Score by journal impact factor
|
| 276 |
+
- Score by recency
|
| 277 |
+
- Score by citation count
|
| 278 |
+
|
| 279 |
+
4. **Proper LLM Pipeline**
|
| 280 |
+
- Use GPT-4 / Claude for synthesis
|
| 281 |
+
- Structured extraction of: drug, mechanism, evidence level, effect size
|
| 282 |
+
- Multi-step reasoning: identify → validate → rank → synthesize
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
+
|
| 286 |
+
## The Hard Truth
|
| 287 |
+
|
| 288 |
+
Building a drug repurposing agent that works is HARD. The state of the art is:
|
| 289 |
+
|
| 290 |
+
- **Drug2Disease (IBM)** - Uses knowledge graphs + ML
|
| 291 |
+
- **COVID-KG (Stanford)** - Dedicated COVID knowledge graph
|
| 292 |
+
- **Literature Mining at scale (PubMed)** - Millions of papers, not 10
|
| 293 |
+
|
| 294 |
+
This hackathon project is fundamentally a **search wrapper with an LLM prompt**. That's not enough.
|
| 295 |
+
|
| 296 |
+
To make it useful:
|
| 297 |
+
1. Either scope it down (e.g., "find clinical trials for X disease")
|
| 298 |
+
2. Or invest serious engineering in the NLU + search + ranking pipeline
|
docs/bugs/P0_MAGENTIC_AND_SEARCH_AUDIT.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# P0 Audit: Microsoft Agent Framework (Magentic) & Search Tools
|
| 2 |
+
|
| 3 |
+
**Date:** November 27, 2025
|
| 4 |
+
**Auditor:** Claude Code
|
| 5 |
+
**Status:** VERIFIED
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## TL;DR
|
| 10 |
+
|
| 11 |
+
| Component | Status | Verdict |
|
| 12 |
+
|-----------|--------|---------|
|
| 13 |
+
| Microsoft Agent Framework | ✅ WORKING | Correctly wired, no bugs |
|
| 14 |
+
| GPT-5.1 Model Config | ✅ CORRECT | Using `gpt-5.1` as configured |
|
| 15 |
+
| Search Tools | ❌ BROKEN | Root cause of garbage results |
|
| 16 |
+
|
| 17 |
+
**The orchestration framework is fine. The search layer is garbage.**
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## Microsoft Agent Framework Verification
|
| 22 |
+
|
| 23 |
+
### Import Test: PASSED
|
| 24 |
+
```python
|
| 25 |
+
from agent_framework import MagenticBuilder, ChatAgent
|
| 26 |
+
from agent_framework.openai import OpenAIChatClient
|
| 27 |
+
# All imports successful
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### Agent Creation Test: PASSED
|
| 31 |
+
```python
|
| 32 |
+
from src.agents.magentic_agents import create_search_agent
|
| 33 |
+
search_agent = create_search_agent()
|
| 34 |
+
# SearchAgent created: SearchAgent
|
| 35 |
+
# Description: Searches biomedical databases (PubMed, ClinicalTrials.gov, bioRxiv)
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### Workflow Build Test: PASSED
|
| 39 |
+
```python
|
| 40 |
+
from src.orchestrator_magentic import MagenticOrchestrator
|
| 41 |
+
orchestrator = MagenticOrchestrator(max_rounds=2)
|
| 42 |
+
workflow = orchestrator._build_workflow()
|
| 43 |
+
# Workflow built successfully: <class 'agent_framework._workflows._workflow.Workflow'>
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Model Configuration: CORRECT
|
| 47 |
+
```python
|
| 48 |
+
settings.openai_model = "gpt-5.1" # ✅ Using GPT-5.1, not GPT-4o
|
| 49 |
+
settings.openai_api_key = True # ✅ API key is set
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## What Magentic Provides (Working)
|
| 55 |
+
|
| 56 |
+
1. **Multi-Agent Coordination**
|
| 57 |
+
- Manager agent orchestrates SearchAgent, JudgeAgent, HypothesisAgent, ReportAgent
|
| 58 |
+
- Uses `MagenticBuilder().with_standard_manager()` for coordination
|
| 59 |
+
|
| 60 |
+
2. **ChatAgent Pattern**
|
| 61 |
+
- Each agent has internal LLM (GPT-5.1)
|
| 62 |
+
- Can call tools via `@ai_function` decorator
|
| 63 |
+
- Has proper instructions for domain-specific tasks
|
| 64 |
+
|
| 65 |
+
3. **Workflow Streaming**
|
| 66 |
+
- Events: `MagenticAgentMessageEvent`, `MagenticFinalResultEvent`, etc.
|
| 67 |
+
- Real-time UI updates via `workflow.run_stream(task)`
|
| 68 |
+
|
| 69 |
+
4. **State Management**
|
| 70 |
+
- `MagenticState` persists evidence across agents
|
| 71 |
+
- `get_bibliography()` tool for ReportAgent
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## What's Actually Broken: The Search Tools
|
| 76 |
+
|
| 77 |
+
### File: `src/agents/tools.py`
|
| 78 |
+
|
| 79 |
+
The Magentic agents call these tools:
|
| 80 |
+
- `search_pubmed` → Uses `PubMedTool`
|
| 81 |
+
- `search_clinical_trials` → Uses `ClinicalTrialsTool`
|
| 82 |
+
- `search_preprints` → Uses `BioRxivTool`
|
| 83 |
+
|
| 84 |
+
**These tools are the problem, not the framework.**
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Search Tool Bugs (Detailed)
|
| 89 |
+
|
| 90 |
+
### BUG 1: BioRxiv API Does Not Support Search
|
| 91 |
+
|
| 92 |
+
**File:** `src/tools/biorxiv.py:248-286`
|
| 93 |
+
|
| 94 |
+
```python
|
| 95 |
+
# This fetches the FIRST 100 papers from the last 90 days
|
| 96 |
+
# It does NOT search by keyword - the API doesn't support that
|
| 97 |
+
url = f"{self.BASE_URL}/{self.server}/{interval}/0/json"
|
| 98 |
+
|
| 99 |
+
# Then filters client-side for keywords
|
| 100 |
+
matching = self._filter_by_keywords(papers, query_terms, max_results)
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
**Problem:**
|
| 104 |
+
- Fetches 100 random chronological papers
|
| 105 |
+
- Filters for ANY keyword match in title/abstract
|
| 106 |
+
- "Long COVID medications" returns papers about "calf muscles" because they mention "COVID" once
|
| 107 |
+
|
| 108 |
+
**Fix:** Remove BioRxiv or use Europe PMC (which has actual search)
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
### BUG 2: PubMed Query Not Optimized
|
| 113 |
+
|
| 114 |
+
**File:** `src/tools/pubmed.py:54-71`
|
| 115 |
+
|
| 116 |
+
```python
|
| 117 |
+
search_params = self._build_params(
|
| 118 |
+
db="pubmed",
|
| 119 |
+
term=query, # RAW USER QUERY - no preprocessing!
|
| 120 |
+
retmax=max_results,
|
| 121 |
+
sort="relevance",
|
| 122 |
+
)
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
**Problem:**
|
| 126 |
+
- User enters: "What medications show promise for Long COVID?"
|
| 127 |
+
- PubMed receives: `What medications show promise for Long COVID?`
|
| 128 |
+
- Should receive: `("long covid"[Title/Abstract] OR "PASC"[Title/Abstract]) AND (treatment[Title/Abstract] OR drug[Title/Abstract])`
|
| 129 |
+
|
| 130 |
+
**Fix:** Add query preprocessing:
|
| 131 |
+
1. Strip question words (what, which, how, etc.)
|
| 132 |
+
2. Expand medical synonyms (Long COVID → PASC, Post-COVID)
|
| 133 |
+
3. Use MeSH terms for better recall
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
### BUG 3: ClinicalTrials.gov No Filtering
|
| 138 |
+
|
| 139 |
+
**File:** `src/tools/clinicaltrials.py`
|
| 140 |
+
|
| 141 |
+
Returns ALL trials including:
|
| 142 |
+
- Withdrawn trials
|
| 143 |
+
- Terminated trials
|
| 144 |
+
- Observational studies (not drug interventions)
|
| 145 |
+
- Phase 1 (no efficacy data)
|
| 146 |
+
|
| 147 |
+
**Fix:** Filter by:
|
| 148 |
+
- `studyType=INTERVENTIONAL`
|
| 149 |
+
- `phase=PHASE2,PHASE3,PHASE4`
|
| 150 |
+
- `status=COMPLETED,ACTIVE_NOT_RECRUITING,RECRUITING`
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## Evidence: Garbage In → Garbage Out
|
| 155 |
+
|
| 156 |
+
When the Magentic SearchAgent calls these tools:
|
| 157 |
+
|
| 158 |
+
```
|
| 159 |
+
SearchAgent: "Find evidence for Long COVID medications"
|
| 160 |
+
│
|
| 161 |
+
▼
|
| 162 |
+
search_pubmed("Long COVID medications")
|
| 163 |
+
→ Returns 1 semi-relevant paper (raw query hits)
|
| 164 |
+
|
| 165 |
+
search_preprints("Long COVID medications")
|
| 166 |
+
→ Returns garbage (BioRxiv API doesn't search)
|
| 167 |
+
→ "Calf muscle adaptations" (has "COVID" somewhere)
|
| 168 |
+
→ "Ophthalmologist work-life balance" (mentions COVID)
|
| 169 |
+
|
| 170 |
+
search_clinical_trials("Long COVID medications")
|
| 171 |
+
→ Returns all trials, no filtering
|
| 172 |
+
│
|
| 173 |
+
▼
|
| 174 |
+
JudgeAgent receives garbage evidence
|
| 175 |
+
│
|
| 176 |
+
▼
|
| 177 |
+
HypothesisAgent can't generate good hypotheses from garbage
|
| 178 |
+
│
|
| 179 |
+
▼
|
| 180 |
+
ReportAgent produces garbage report
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
**The framework is doing its job. It's orchestrating agents correctly. But the agents are being fed garbage data.**
|
| 184 |
+
|
| 185 |
+
---
|
| 186 |
+
|
| 187 |
+
## Recommended Fixes
|
| 188 |
+
|
| 189 |
+
### Priority 1: Delete or Fix BioRxiv (30 min)
|
| 190 |
+
|
| 191 |
+
**Option A: Delete it**
|
| 192 |
+
```python
|
| 193 |
+
# In src/agents/tools.py, remove:
|
| 194 |
+
# from src.tools.biorxiv import BioRxivTool
|
| 195 |
+
# _biorxiv = BioRxivTool()
|
| 196 |
+
# @ai_function search_preprints(...)
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
**Option B: Replace with Europe PMC**
|
| 200 |
+
Europe PMC has preprints AND proper search API:
|
| 201 |
+
```
|
| 202 |
+
https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=long+covid+treatment&format=json
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
### Priority 2: Fix PubMed Query (1 hour)
|
| 206 |
+
|
| 207 |
+
Add query preprocessor:
|
| 208 |
+
```python
|
| 209 |
+
def preprocess_query(raw_query: str) -> str:
|
| 210 |
+
"""Convert natural language to PubMed query syntax."""
|
| 211 |
+
# Strip question words
|
| 212 |
+
# Expand medical synonyms
|
| 213 |
+
# Add field tags [Title/Abstract]
|
| 214 |
+
# Return optimized query
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
### Priority 3: Filter ClinicalTrials (30 min)
|
| 218 |
+
|
| 219 |
+
Add parameters to API call:
|
| 220 |
+
```python
|
| 221 |
+
params = {
|
| 222 |
+
"query.term": query,
|
| 223 |
+
"filter.overallStatus": "COMPLETED,RECRUITING",
|
| 224 |
+
"filter.studyType": "INTERVENTIONAL",
|
| 225 |
+
"pageSize": max_results,
|
| 226 |
+
}
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## Conclusion
|
| 232 |
+
|
| 233 |
+
**Microsoft Agent Framework: NO BUGS FOUND**
|
| 234 |
+
- Imports work ✅
|
| 235 |
+
- Agent creation works ✅
|
| 236 |
+
- Workflow building works ✅
|
| 237 |
+
- Model config correct (GPT-5.1) ✅
|
| 238 |
+
- Streaming events work ✅
|
| 239 |
+
|
| 240 |
+
**Search Tools: CRITICALLY BROKEN**
|
| 241 |
+
- BioRxiv: API doesn't support search (fundamental)
|
| 242 |
+
- PubMed: No query optimization (fixable)
|
| 243 |
+
- ClinicalTrials: No filtering (fixable)
|
| 244 |
+
|
| 245 |
+
**Recommendation:**
|
| 246 |
+
1. Delete BioRxiv immediately (unusable)
|
| 247 |
+
2. Add PubMed query preprocessing
|
| 248 |
+
3. Add ClinicalTrials filtering
|
| 249 |
+
4. Then the Magentic multi-agent system will work as designed
|
docs/bugs/PHASE_00_IMPLEMENTATION_ORDER.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 00: Implementation Order & Summary
|
| 2 |
+
|
| 3 |
+
**Total Effort:** 5-8 hours
|
| 4 |
+
**Parallelizable:** Yes (all 3 phases are independent)
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Executive Summary
|
| 9 |
+
|
| 10 |
+
The DeepCritical drug repurposing agent produces garbage results because the search tools are broken:
|
| 11 |
+
|
| 12 |
+
| Tool | Problem | Fix |
|
| 13 |
+
|------|---------|-----|
|
| 14 |
+
| BioRxiv | API doesn't support search | Replace with Europe PMC |
|
| 15 |
+
| PubMed | Raw queries, no preprocessing | Add query cleaner |
|
| 16 |
+
| ClinicalTrials | No filtering | Add status/type filters |
|
| 17 |
+
|
| 18 |
+
**The Microsoft Agent Framework (Magentic) is working correctly.** The orchestration layer is fine. The data layer is broken.
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## Phase Specs
|
| 23 |
+
|
| 24 |
+
| Phase | Title | Effort | Priority | Dependencies |
|
| 25 |
+
|-------|-------|--------|----------|--------------|
|
| 26 |
+
| **01** | [Replace BioRxiv with Europe PMC](./PHASE_01_REPLACE_BIORXIV.md) | 2-3 hrs | P0 | None |
|
| 27 |
+
| **02** | [PubMed Query Preprocessing](./PHASE_02_PUBMED_QUERY_PREPROCESSING.md) | 2-3 hrs | P0 | None |
|
| 28 |
+
| **03** | [ClinicalTrials Filtering](./PHASE_03_CLINICALTRIALS_FILTERING.md) | 1-2 hrs | P1 | None |
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Recommended Execution Order
|
| 33 |
+
|
| 34 |
+
Since all phases are independent, they can be done in parallel by different developers.
|
| 35 |
+
|
| 36 |
+
**If doing sequentially, order by impact:**
|
| 37 |
+
|
| 38 |
+
1. **Phase 01** - BioRxiv is completely broken (returns random papers)
|
| 39 |
+
2. **Phase 02** - PubMed is partially broken (returns suboptimal results)
|
| 40 |
+
3. **Phase 03** - ClinicalTrials returns too much noise
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## TDD Workflow (Per Phase)
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
1. Write failing tests
|
| 48 |
+
2. Run tests (confirm they fail)
|
| 49 |
+
3. Implement fix
|
| 50 |
+
4. Run tests (confirm they pass)
|
| 51 |
+
5. Run ALL tests (confirm no regressions)
|
| 52 |
+
6. Manual verification
|
| 53 |
+
7. Commit
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## Verification After All Phases
|
| 59 |
+
|
| 60 |
+
After completing all 3 phases, run this integration test:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
# Full system test
|
| 64 |
+
uv run python -c "
|
| 65 |
+
import asyncio
|
| 66 |
+
from src.tools.europepmc import EuropePMCTool
|
| 67 |
+
from src.tools.pubmed import PubMedTool
|
| 68 |
+
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 69 |
+
|
| 70 |
+
async def test_all():
|
| 71 |
+
query = 'long covid treatment'
|
| 72 |
+
|
| 73 |
+
print('=== Europe PMC (Preprints) ===')
|
| 74 |
+
epmc = EuropePMCTool()
|
| 75 |
+
results = await epmc.search(query, 2)
|
| 76 |
+
for r in results:
|
| 77 |
+
print(f' - {r.citation.title[:60]}...')
|
| 78 |
+
|
| 79 |
+
print()
|
| 80 |
+
print('=== PubMed ===')
|
| 81 |
+
pm = PubMedTool()
|
| 82 |
+
results = await pm.search(query, 2)
|
| 83 |
+
for r in results:
|
| 84 |
+
print(f' - {r.citation.title[:60]}...')
|
| 85 |
+
|
| 86 |
+
print()
|
| 87 |
+
print('=== ClinicalTrials.gov ===')
|
| 88 |
+
ct = ClinicalTrialsTool()
|
| 89 |
+
results = await ct.search(query, 2)
|
| 90 |
+
for r in results:
|
| 91 |
+
print(f' - {r.citation.title[:60]}...')
|
| 92 |
+
|
| 93 |
+
asyncio.run(test_all())
|
| 94 |
+
"
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
**Expected:** All results should be relevant to "long covid treatment"
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Test Magentic Integration
|
| 102 |
+
|
| 103 |
+
After all phases are complete, test the full Magentic workflow:
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
# Test Magentic mode (requires OPENAI_API_KEY)
|
| 107 |
+
uv run python -c "
|
| 108 |
+
import asyncio
|
| 109 |
+
from src.orchestrator_magentic import MagenticOrchestrator
|
| 110 |
+
|
| 111 |
+
async def test_magentic():
|
| 112 |
+
orchestrator = MagenticOrchestrator(max_rounds=3)
|
| 113 |
+
|
| 114 |
+
print('Running Magentic workflow...')
|
| 115 |
+
async for event in orchestrator.run('What drugs show promise for Long COVID?'):
|
| 116 |
+
print(f'[{event.type}] {event.message[:100]}...')
|
| 117 |
+
|
| 118 |
+
asyncio.run(test_magentic())
|
| 119 |
+
"
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## Files Changed (All Phases)
|
| 125 |
+
|
| 126 |
+
| File | Phase | Action |
|
| 127 |
+
|------|-------|--------|
|
| 128 |
+
| `src/tools/europepmc.py` | 01 | CREATE |
|
| 129 |
+
| `tests/unit/tools/test_europepmc.py` | 01 | CREATE |
|
| 130 |
+
| `src/agents/tools.py` | 01 | MODIFY |
|
| 131 |
+
| `src/tools/search_handler.py` | 01 | MODIFY |
|
| 132 |
+
| `src/tools/biorxiv.py` | 01 | DELETE |
|
| 133 |
+
| `tests/unit/tools/test_biorxiv.py` | 01 | DELETE |
|
| 134 |
+
| `src/tools/query_utils.py` | 02 | CREATE |
|
| 135 |
+
| `tests/unit/tools/test_query_utils.py` | 02 | CREATE |
|
| 136 |
+
| `src/tools/pubmed.py` | 02 | MODIFY |
|
| 137 |
+
| `src/tools/clinicaltrials.py` | 03 | MODIFY |
|
| 138 |
+
| `tests/unit/tools/test_clinicaltrials.py` | 03 | MODIFY |
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## Success Criteria (Overall)
|
| 143 |
+
|
| 144 |
+
- [ ] All unit tests pass
|
| 145 |
+
- [ ] All integration tests pass (real APIs)
|
| 146 |
+
- [ ] Query "What drugs show promise for Long COVID?" returns relevant results from all 3 sources
|
| 147 |
+
- [ ] Magentic workflow produces a coherent research report
|
| 148 |
+
- [ ] No regressions in existing functionality
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## Related Documentation
|
| 153 |
+
|
| 154 |
+
- [P0 Critical Bugs](./P0_CRITICAL_BUGS.md) - Root cause analysis
|
| 155 |
+
- [P0 Magentic Audit](./P0_MAGENTIC_AND_SEARCH_AUDIT.md) - Framework verification
|
| 156 |
+
- [P0 Actionable Fixes](./P0_ACTIONABLE_FIXES.md) - Fix summaries
|
docs/bugs/PHASE_01_REPLACE_BIORXIV.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 01: Replace BioRxiv with Europe PMC
|
| 2 |
+
|
| 3 |
+
**Priority:** P0 - Critical
|
| 4 |
+
**Effort:** 2-3 hours
|
| 5 |
+
**Dependencies:** None
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Problem Statement
|
| 10 |
+
|
| 11 |
+
The BioRxiv API does not support keyword search. It only returns papers by date range, resulting in completely irrelevant results for any query.
|
| 12 |
+
|
| 13 |
+
## Success Criteria
|
| 14 |
+
|
| 15 |
+
- [ ] `search_preprints("long covid treatment")` returns papers actually about Long COVID
|
| 16 |
+
- [ ] All existing tests pass
|
| 17 |
+
- [ ] New tests cover Europe PMC integration
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## TDD Implementation Order
|
| 22 |
+
|
| 23 |
+
### Step 1: Write Failing Test
|
| 24 |
+
|
| 25 |
+
**File:** `tests/unit/tools/test_europepmc.py`
|
| 26 |
+
|
| 27 |
+
```python
|
| 28 |
+
"""Unit tests for Europe PMC tool."""
|
| 29 |
+
|
| 30 |
+
import pytest
|
| 31 |
+
from unittest.mock import AsyncMock, patch
|
| 32 |
+
|
| 33 |
+
from src.tools.europepmc import EuropePMCTool
|
| 34 |
+
from src.utils.models import Evidence
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.mark.unit
|
| 38 |
+
class TestEuropePMCTool:
|
| 39 |
+
"""Tests for EuropePMCTool."""
|
| 40 |
+
|
| 41 |
+
@pytest.fixture
|
| 42 |
+
def tool(self):
|
| 43 |
+
return EuropePMCTool()
|
| 44 |
+
|
| 45 |
+
def test_tool_name(self, tool):
|
| 46 |
+
assert tool.name == "europepmc"
|
| 47 |
+
|
| 48 |
+
@pytest.mark.asyncio
|
| 49 |
+
async def test_search_returns_evidence(self, tool):
|
| 50 |
+
"""Test that search returns Evidence objects."""
|
| 51 |
+
mock_response = {
|
| 52 |
+
"resultList": {
|
| 53 |
+
"result": [
|
| 54 |
+
{
|
| 55 |
+
"id": "12345",
|
| 56 |
+
"title": "Long COVID Treatment Study",
|
| 57 |
+
"abstractText": "This study examines treatments for Long COVID.",
|
| 58 |
+
"doi": "10.1234/test",
|
| 59 |
+
"pubYear": "2024",
|
| 60 |
+
"source": "MED",
|
| 61 |
+
"pubTypeList": {"pubType": ["research-article"]},
|
| 62 |
+
}
|
| 63 |
+
]
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 68 |
+
mock_instance = AsyncMock()
|
| 69 |
+
mock_client.return_value.__aenter__.return_value = mock_instance
|
| 70 |
+
mock_instance.get.return_value.json.return_value = mock_response
|
| 71 |
+
mock_instance.get.return_value.raise_for_status = lambda: None
|
| 72 |
+
|
| 73 |
+
results = await tool.search("long covid treatment", max_results=5)
|
| 74 |
+
|
| 75 |
+
assert len(results) == 1
|
| 76 |
+
assert isinstance(results[0], Evidence)
|
| 77 |
+
assert "Long COVID Treatment Study" in results[0].citation.title
|
| 78 |
+
|
| 79 |
+
@pytest.mark.asyncio
|
| 80 |
+
async def test_search_marks_preprints(self, tool):
|
| 81 |
+
"""Test that preprints are marked correctly."""
|
| 82 |
+
mock_response = {
|
| 83 |
+
"resultList": {
|
| 84 |
+
"result": [
|
| 85 |
+
{
|
| 86 |
+
"id": "PPR12345",
|
| 87 |
+
"title": "Preprint Study",
|
| 88 |
+
"abstractText": "Abstract text",
|
| 89 |
+
"doi": "10.1234/preprint",
|
| 90 |
+
"pubYear": "2024",
|
| 91 |
+
"source": "PPR",
|
| 92 |
+
"pubTypeList": {"pubType": ["Preprint"]},
|
| 93 |
+
}
|
| 94 |
+
]
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 99 |
+
mock_instance = AsyncMock()
|
| 100 |
+
mock_client.return_value.__aenter__.return_value = mock_instance
|
| 101 |
+
mock_instance.get.return_value.json.return_value = mock_response
|
| 102 |
+
mock_instance.get.return_value.raise_for_status = lambda: None
|
| 103 |
+
|
| 104 |
+
results = await tool.search("test", max_results=5)
|
| 105 |
+
|
| 106 |
+
assert "[PREPRINT]" in results[0].content
|
| 107 |
+
assert results[0].citation.source == "preprint"
|
| 108 |
+
|
| 109 |
+
@pytest.mark.asyncio
|
| 110 |
+
async def test_search_empty_results(self, tool):
|
| 111 |
+
"""Test handling of empty results."""
|
| 112 |
+
mock_response = {"resultList": {"result": []}}
|
| 113 |
+
|
| 114 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 115 |
+
mock_instance = AsyncMock()
|
| 116 |
+
mock_client.return_value.__aenter__.return_value = mock_instance
|
| 117 |
+
mock_instance.get.return_value.json.return_value = mock_response
|
| 118 |
+
mock_instance.get.return_value.raise_for_status = lambda: None
|
| 119 |
+
|
| 120 |
+
results = await tool.search("nonexistent query xyz", max_results=5)
|
| 121 |
+
|
| 122 |
+
assert results == []
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@pytest.mark.integration
|
| 126 |
+
class TestEuropePMCIntegration:
|
| 127 |
+
"""Integration tests with real API."""
|
| 128 |
+
|
| 129 |
+
@pytest.mark.asyncio
|
| 130 |
+
async def test_real_api_call(self):
|
| 131 |
+
"""Test actual API returns relevant results."""
|
| 132 |
+
tool = EuropePMCTool()
|
| 133 |
+
results = await tool.search("long covid treatment", max_results=3)
|
| 134 |
+
|
| 135 |
+
assert len(results) > 0
|
| 136 |
+
# At least one result should mention COVID
|
| 137 |
+
titles = " ".join([r.citation.title.lower() for r in results])
|
| 138 |
+
assert "covid" in titles or "sars" in titles
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
### Step 2: Implement Europe PMC Tool
|
| 142 |
+
|
| 143 |
+
**File:** `src/tools/europepmc.py`
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
"""Europe PMC search tool - replaces BioRxiv."""
|
| 147 |
+
|
| 148 |
+
from typing import Any
|
| 149 |
+
|
| 150 |
+
import httpx
|
| 151 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 152 |
+
|
| 153 |
+
from src.utils.exceptions import SearchError
|
| 154 |
+
from src.utils.models import Citation, Evidence
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class EuropePMCTool:
|
| 158 |
+
"""
|
| 159 |
+
Search Europe PMC for papers and preprints.
|
| 160 |
+
|
| 161 |
+
Europe PMC indexes:
|
| 162 |
+
- PubMed/MEDLINE articles
|
| 163 |
+
- PMC full-text articles
|
| 164 |
+
- Preprints from bioRxiv, medRxiv, ChemRxiv, etc.
|
| 165 |
+
- Patents and clinical guidelines
|
| 166 |
+
|
| 167 |
+
API Docs: https://europepmc.org/RestfulWebService
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
BASE_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
|
| 171 |
+
|
| 172 |
+
@property
|
| 173 |
+
def name(self) -> str:
|
| 174 |
+
return "europepmc"
|
| 175 |
+
|
| 176 |
+
@retry(
|
| 177 |
+
stop=stop_after_attempt(3),
|
| 178 |
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
| 179 |
+
reraise=True,
|
| 180 |
+
)
|
| 181 |
+
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 182 |
+
"""
|
| 183 |
+
Search Europe PMC for papers matching query.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
query: Search keywords
|
| 187 |
+
max_results: Maximum results to return
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
List of Evidence objects
|
| 191 |
+
"""
|
| 192 |
+
params = {
|
| 193 |
+
"query": query,
|
| 194 |
+
"resultType": "core",
|
| 195 |
+
"pageSize": min(max_results, 100),
|
| 196 |
+
"format": "json",
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 200 |
+
try:
|
| 201 |
+
response = await client.get(self.BASE_URL, params=params)
|
| 202 |
+
response.raise_for_status()
|
| 203 |
+
|
| 204 |
+
data = response.json()
|
| 205 |
+
results = data.get("resultList", {}).get("result", [])
|
| 206 |
+
|
| 207 |
+
return [self._to_evidence(r) for r in results[:max_results]]
|
| 208 |
+
|
| 209 |
+
except httpx.HTTPStatusError as e:
|
| 210 |
+
raise SearchError(f"Europe PMC API error: {e}") from e
|
| 211 |
+
except httpx.RequestError as e:
|
| 212 |
+
raise SearchError(f"Europe PMC connection failed: {e}") from e
|
| 213 |
+
|
| 214 |
+
def _to_evidence(self, result: dict[str, Any]) -> Evidence:
|
| 215 |
+
"""Convert Europe PMC result to Evidence."""
|
| 216 |
+
title = result.get("title", "Untitled")
|
| 217 |
+
abstract = result.get("abstractText", "No abstract available.")
|
| 218 |
+
doi = result.get("doi", "")
|
| 219 |
+
pub_year = result.get("pubYear", "Unknown")
|
| 220 |
+
|
| 221 |
+
# Get authors
|
| 222 |
+
author_list = result.get("authorList", {}).get("author", [])
|
| 223 |
+
authors = [a.get("fullName", "") for a in author_list[:5] if a.get("fullName")]
|
| 224 |
+
|
| 225 |
+
# Check if preprint
|
| 226 |
+
pub_types = result.get("pubTypeList", {}).get("pubType", [])
|
| 227 |
+
is_preprint = "Preprint" in pub_types
|
| 228 |
+
source_db = result.get("source", "europepmc")
|
| 229 |
+
|
| 230 |
+
# Build content
|
| 231 |
+
preprint_marker = "[PREPRINT - Not peer-reviewed] " if is_preprint else ""
|
| 232 |
+
content = f"{preprint_marker}{abstract[:1800]}"
|
| 233 |
+
|
| 234 |
+
# Build URL
|
| 235 |
+
if doi:
|
| 236 |
+
url = f"https://doi.org/{doi}"
|
| 237 |
+
elif result.get("pmid"):
|
| 238 |
+
url = f"https://pubmed.ncbi.nlm.nih.gov/{result['pmid']}/"
|
| 239 |
+
else:
|
| 240 |
+
url = f"https://europepmc.org/article/{source_db}/{result.get('id', '')}"
|
| 241 |
+
|
| 242 |
+
return Evidence(
|
| 243 |
+
content=content[:2000],
|
| 244 |
+
citation=Citation(
|
| 245 |
+
source="preprint" if is_preprint else "europepmc",
|
| 246 |
+
title=title[:500],
|
| 247 |
+
url=url,
|
| 248 |
+
date=str(pub_year),
|
| 249 |
+
authors=authors,
|
| 250 |
+
),
|
| 251 |
+
relevance=0.75 if is_preprint else 0.9,
|
| 252 |
+
)
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
### Step 3: Update Magentic Tools
|
| 256 |
+
|
| 257 |
+
**File:** `src/agents/tools.py` - Replace biorxiv import:
|
| 258 |
+
|
| 259 |
+
```python
|
| 260 |
+
# REMOVE:
|
| 261 |
+
# from src.tools.biorxiv import BioRxivTool
|
| 262 |
+
# _biorxiv = BioRxivTool()
|
| 263 |
+
|
| 264 |
+
# ADD:
|
| 265 |
+
from src.tools.europepmc import EuropePMCTool
|
| 266 |
+
_europepmc = EuropePMCTool()
|
| 267 |
+
|
| 268 |
+
# UPDATE search_preprints function:
|
| 269 |
+
@ai_function
|
| 270 |
+
async def search_preprints(query: str, max_results: int = 10) -> str:
|
| 271 |
+
"""Search Europe PMC for preprints and papers.
|
| 272 |
+
|
| 273 |
+
Use this tool to find the latest research including preprints
|
| 274 |
+
from bioRxiv, medRxiv, and peer-reviewed papers.
|
| 275 |
+
|
| 276 |
+
Args:
|
| 277 |
+
query: Search terms (e.g., "long covid treatment")
|
| 278 |
+
max_results: Maximum results to return (default 10)
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
Formatted list of papers with abstracts and links
|
| 282 |
+
"""
|
| 283 |
+
state = get_magentic_state()
|
| 284 |
+
|
| 285 |
+
results = await _europepmc.search(query, max_results)
|
| 286 |
+
if not results:
|
| 287 |
+
return f"No papers found for: {query}"
|
| 288 |
+
|
| 289 |
+
new_count = state.add_evidence(results)
|
| 290 |
+
|
| 291 |
+
output = [f"Found {len(results)} papers ({new_count} new stored):\n"]
|
| 292 |
+
for i, r in enumerate(results[:max_results], 1):
|
| 293 |
+
title = r.citation.title
|
| 294 |
+
date = r.citation.date
|
| 295 |
+
source = r.citation.source
|
| 296 |
+
content_clean = r.content[:300].replace("\n", " ")
|
| 297 |
+
url = r.citation.url
|
| 298 |
+
|
| 299 |
+
output.append(f"{i}. **{title}**")
|
| 300 |
+
output.append(f" Source: {source} | Date: {date}")
|
| 301 |
+
output.append(f" {content_clean}...")
|
| 302 |
+
output.append(f" URL: {url}\n")
|
| 303 |
+
|
| 304 |
+
return "\n".join(output)
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
### Step 4: Update Search Handler (Simple Mode)
|
| 308 |
+
|
| 309 |
+
**File:** `src/tools/search_handler.py` - Update imports:
|
| 310 |
+
|
| 311 |
+
```python
|
| 312 |
+
# REMOVE:
|
| 313 |
+
# from src.tools.biorxiv import BioRxivTool
|
| 314 |
+
|
| 315 |
+
# ADD:
|
| 316 |
+
from src.tools.europepmc import EuropePMCTool
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
### Step 5: Delete Old BioRxiv Tests
|
| 320 |
+
|
| 321 |
+
```bash
|
| 322 |
+
# After all new tests pass:
|
| 323 |
+
rm tests/unit/tools/test_biorxiv.py
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## Verification
|
| 329 |
+
|
| 330 |
+
```bash
|
| 331 |
+
# Run new tests
|
| 332 |
+
uv run pytest tests/unit/tools/test_europepmc.py -v
|
| 333 |
+
|
| 334 |
+
# Run integration test (real API)
|
| 335 |
+
uv run pytest tests/unit/tools/test_europepmc.py::TestEuropePMCIntegration -v
|
| 336 |
+
|
| 337 |
+
# Run all tests to ensure no regressions
|
| 338 |
+
uv run pytest tests/unit/ -v
|
| 339 |
+
|
| 340 |
+
# Manual verification
|
| 341 |
+
uv run python -c "
|
| 342 |
+
import asyncio
|
| 343 |
+
from src.tools.europepmc import EuropePMCTool
|
| 344 |
+
tool = EuropePMCTool()
|
| 345 |
+
results = asyncio.run(tool.search('long covid treatment', 3))
|
| 346 |
+
for r in results:
|
| 347 |
+
print(f'- {r.citation.title}')
|
| 348 |
+
"
|
| 349 |
+
```
|
| 350 |
+
|
| 351 |
+
---
|
| 352 |
+
|
| 353 |
+
## Files Changed
|
| 354 |
+
|
| 355 |
+
| File | Action |
|
| 356 |
+
|------|--------|
|
| 357 |
+
| `src/tools/europepmc.py` | CREATE |
|
| 358 |
+
| `tests/unit/tools/test_europepmc.py` | CREATE |
|
| 359 |
+
| `src/agents/tools.py` | MODIFY (replace biorxiv import) |
|
| 360 |
+
| `src/tools/search_handler.py` | MODIFY (replace biorxiv import) |
|
| 361 |
+
| `src/tools/biorxiv.py` | DELETE (after verification) |
|
| 362 |
+
| `tests/unit/tools/test_biorxiv.py` | DELETE (after verification) |
|
| 363 |
+
|
| 364 |
+
---
|
| 365 |
+
|
| 366 |
+
## Rollback Plan
|
| 367 |
+
|
| 368 |
+
If issues arise:
|
| 369 |
+
1. Revert `src/agents/tools.py` to use BioRxivTool
|
| 370 |
+
2. Revert `src/tools/search_handler.py`
|
| 371 |
+
3. Keep `europepmc.py` for future use
|
docs/bugs/PHASE_02_PUBMED_QUERY_PREPROCESSING.md
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 02: PubMed Query Preprocessing
|
| 2 |
+
|
| 3 |
+
**Priority:** P0 - Critical
|
| 4 |
+
**Effort:** 2-3 hours
|
| 5 |
+
**Dependencies:** None (can run parallel with Phase 01)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Problem Statement
|
| 10 |
+
|
| 11 |
+
PubMed receives raw natural language queries like "What medications show promise for Long COVID?" which include question words that pollute search results.
|
| 12 |
+
|
| 13 |
+
## Success Criteria
|
| 14 |
+
|
| 15 |
+
- [ ] Question words stripped from queries
|
| 16 |
+
- [ ] Medical synonyms expanded (Long COVID → PASC, etc.)
|
| 17 |
+
- [ ] Relevant results returned for natural language questions
|
| 18 |
+
- [ ] All existing tests pass
|
| 19 |
+
- [ ] New tests cover query preprocessing
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## TDD Implementation Order
|
| 24 |
+
|
| 25 |
+
### Step 1: Write Failing Tests
|
| 26 |
+
|
| 27 |
+
**File:** `tests/unit/tools/test_query_utils.py`
|
| 28 |
+
|
| 29 |
+
```python
|
| 30 |
+
"""Unit tests for query preprocessing utilities."""
|
| 31 |
+
|
| 32 |
+
import pytest
|
| 33 |
+
|
| 34 |
+
from src.tools.query_utils import preprocess_query, expand_synonyms, strip_question_words
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.mark.unit
|
| 38 |
+
class TestQueryPreprocessing:
|
| 39 |
+
"""Tests for query preprocessing."""
|
| 40 |
+
|
| 41 |
+
def test_strip_question_words(self):
|
| 42 |
+
"""Test removal of question words."""
|
| 43 |
+
assert strip_question_words("What drugs treat cancer") == "drugs treat cancer"
|
| 44 |
+
assert strip_question_words("Which medications help diabetes") == "medications diabetes"
|
| 45 |
+
assert strip_question_words("How can we cure alzheimer") == "cure alzheimer"
|
| 46 |
+
assert strip_question_words("Is metformin effective") == "metformin effective"
|
| 47 |
+
|
| 48 |
+
def test_strip_preserves_medical_terms(self):
|
| 49 |
+
"""Test that medical terms are preserved."""
|
| 50 |
+
result = strip_question_words("What is the mechanism of metformin")
|
| 51 |
+
assert "metformin" in result
|
| 52 |
+
assert "mechanism" in result
|
| 53 |
+
|
| 54 |
+
def test_expand_synonyms_long_covid(self):
|
| 55 |
+
"""Test Long COVID synonym expansion."""
|
| 56 |
+
result = expand_synonyms("long covid treatment")
|
| 57 |
+
assert "PASC" in result or "post-COVID" in result
|
| 58 |
+
|
| 59 |
+
def test_expand_synonyms_alzheimer(self):
|
| 60 |
+
"""Test Alzheimer's synonym expansion."""
|
| 61 |
+
result = expand_synonyms("alzheimer drug")
|
| 62 |
+
assert "Alzheimer" in result
|
| 63 |
+
|
| 64 |
+
def test_expand_synonyms_preserves_unknown(self):
|
| 65 |
+
"""Test that unknown terms are preserved."""
|
| 66 |
+
result = expand_synonyms("metformin diabetes")
|
| 67 |
+
assert "metformin" in result
|
| 68 |
+
assert "diabetes" in result
|
| 69 |
+
|
| 70 |
+
def test_preprocess_query_full_pipeline(self):
|
| 71 |
+
"""Test complete preprocessing pipeline."""
|
| 72 |
+
raw = "What medications show promise for Long COVID?"
|
| 73 |
+
result = preprocess_query(raw)
|
| 74 |
+
|
| 75 |
+
# Should not contain question words
|
| 76 |
+
assert "what" not in result.lower()
|
| 77 |
+
assert "show" not in result.lower()
|
| 78 |
+
assert "promise" not in result.lower()
|
| 79 |
+
|
| 80 |
+
# Should contain expanded terms
|
| 81 |
+
assert "PASC" in result or "post-COVID" in result or "long covid" in result.lower()
|
| 82 |
+
assert "medications" in result.lower() or "drug" in result.lower()
|
| 83 |
+
|
| 84 |
+
def test_preprocess_query_removes_punctuation(self):
|
| 85 |
+
"""Test that question marks are removed."""
|
| 86 |
+
result = preprocess_query("Is metformin safe?")
|
| 87 |
+
assert "?" not in result
|
| 88 |
+
|
| 89 |
+
def test_preprocess_query_handles_empty(self):
|
| 90 |
+
"""Test handling of empty/whitespace queries."""
|
| 91 |
+
assert preprocess_query("") == ""
|
| 92 |
+
assert preprocess_query(" ") == ""
|
| 93 |
+
|
| 94 |
+
def test_preprocess_query_already_clean(self):
|
| 95 |
+
"""Test that clean queries pass through."""
|
| 96 |
+
clean = "metformin diabetes mechanism"
|
| 97 |
+
result = preprocess_query(clean)
|
| 98 |
+
assert "metformin" in result
|
| 99 |
+
assert "diabetes" in result
|
| 100 |
+
assert "mechanism" in result
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
### Step 2: Implement Query Utils
|
| 104 |
+
|
| 105 |
+
**File:** `src/tools/query_utils.py`
|
| 106 |
+
|
| 107 |
+
```python
|
| 108 |
+
"""Query preprocessing utilities for biomedical search."""
|
| 109 |
+
|
| 110 |
+
import re
|
| 111 |
+
from typing import ClassVar
|
| 112 |
+
|
| 113 |
+
# Question words and filler words to remove
|
| 114 |
+
QUESTION_WORDS: set[str] = {
|
| 115 |
+
# Question starters
|
| 116 |
+
"what", "which", "how", "why", "when", "where", "who", "whom",
|
| 117 |
+
# Auxiliary verbs in questions
|
| 118 |
+
"is", "are", "was", "were", "do", "does", "did", "can", "could",
|
| 119 |
+
"would", "should", "will", "shall", "may", "might",
|
| 120 |
+
# Filler words in natural questions
|
| 121 |
+
"show", "promise", "help", "believe", "think", "suggest",
|
| 122 |
+
"possible", "potential", "effective", "useful", "good",
|
| 123 |
+
# Articles (remove but less aggressively)
|
| 124 |
+
"the", "a", "an",
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
# Medical synonym expansions
|
| 128 |
+
SYNONYMS: dict[str, list[str]] = {
|
| 129 |
+
"long covid": [
|
| 130 |
+
"long COVID",
|
| 131 |
+
"PASC",
|
| 132 |
+
"post-acute sequelae of SARS-CoV-2",
|
| 133 |
+
"post-COVID syndrome",
|
| 134 |
+
"post-COVID-19 condition",
|
| 135 |
+
],
|
| 136 |
+
"alzheimer": [
|
| 137 |
+
"Alzheimer's disease",
|
| 138 |
+
"Alzheimer disease",
|
| 139 |
+
"AD",
|
| 140 |
+
"Alzheimer dementia",
|
| 141 |
+
],
|
| 142 |
+
"parkinson": [
|
| 143 |
+
"Parkinson's disease",
|
| 144 |
+
"Parkinson disease",
|
| 145 |
+
"PD",
|
| 146 |
+
],
|
| 147 |
+
"diabetes": [
|
| 148 |
+
"diabetes mellitus",
|
| 149 |
+
"type 2 diabetes",
|
| 150 |
+
"T2DM",
|
| 151 |
+
"diabetic",
|
| 152 |
+
],
|
| 153 |
+
"cancer": [
|
| 154 |
+
"cancer",
|
| 155 |
+
"neoplasm",
|
| 156 |
+
"tumor",
|
| 157 |
+
"malignancy",
|
| 158 |
+
"carcinoma",
|
| 159 |
+
],
|
| 160 |
+
"heart disease": [
|
| 161 |
+
"cardiovascular disease",
|
| 162 |
+
"CVD",
|
| 163 |
+
"coronary artery disease",
|
| 164 |
+
"heart failure",
|
| 165 |
+
],
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def strip_question_words(query: str) -> str:
|
| 170 |
+
"""
|
| 171 |
+
Remove question words and filler terms from query.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
query: Raw query string
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
Query with question words removed
|
| 178 |
+
"""
|
| 179 |
+
words = query.lower().split()
|
| 180 |
+
filtered = [w for w in words if w not in QUESTION_WORDS]
|
| 181 |
+
return " ".join(filtered)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def expand_synonyms(query: str) -> str:
|
| 185 |
+
"""
|
| 186 |
+
Expand medical terms to include synonyms.
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
query: Query string
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
Query with synonym expansions in OR groups
|
| 193 |
+
"""
|
| 194 |
+
result = query.lower()
|
| 195 |
+
|
| 196 |
+
for term, expansions in SYNONYMS.items():
|
| 197 |
+
if term in result:
|
| 198 |
+
# Create OR group: ("term1" OR "term2" OR "term3")
|
| 199 |
+
or_group = " OR ".join([f'"{exp}"' for exp in expansions])
|
| 200 |
+
result = result.replace(term, f"({or_group})")
|
| 201 |
+
|
| 202 |
+
return result
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def preprocess_query(raw_query: str) -> str:
|
| 206 |
+
"""
|
| 207 |
+
Full preprocessing pipeline for PubMed queries.
|
| 208 |
+
|
| 209 |
+
Pipeline:
|
| 210 |
+
1. Strip whitespace and punctuation
|
| 211 |
+
2. Remove question words
|
| 212 |
+
3. Expand medical synonyms
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
raw_query: Natural language query from user
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
Optimized query for PubMed
|
| 219 |
+
"""
|
| 220 |
+
if not raw_query or not raw_query.strip():
|
| 221 |
+
return ""
|
| 222 |
+
|
| 223 |
+
# Remove question marks and extra whitespace
|
| 224 |
+
query = raw_query.replace("?", "").strip()
|
| 225 |
+
query = re.sub(r"\s+", " ", query)
|
| 226 |
+
|
| 227 |
+
# Strip question words
|
| 228 |
+
query = strip_question_words(query)
|
| 229 |
+
|
| 230 |
+
# Expand synonyms
|
| 231 |
+
query = expand_synonyms(query)
|
| 232 |
+
|
| 233 |
+
return query.strip()
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
### Step 3: Update PubMed Tool
|
| 237 |
+
|
| 238 |
+
**File:** `src/tools/pubmed.py` - Add preprocessing:
|
| 239 |
+
|
| 240 |
+
```python
|
| 241 |
+
# Add import at top:
|
| 242 |
+
from src.tools.query_utils import preprocess_query
|
| 243 |
+
|
| 244 |
+
# Update search method:
|
| 245 |
+
@retry(
|
| 246 |
+
stop=stop_after_attempt(3),
|
| 247 |
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
| 248 |
+
reraise=True,
|
| 249 |
+
)
|
| 250 |
+
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 251 |
+
"""
|
| 252 |
+
Search PubMed and return evidence.
|
| 253 |
+
"""
|
| 254 |
+
await self._rate_limit()
|
| 255 |
+
|
| 256 |
+
# PREPROCESS QUERY
|
| 257 |
+
clean_query = preprocess_query(query)
|
| 258 |
+
if not clean_query:
|
| 259 |
+
clean_query = query # Fallback to original if preprocessing empties it
|
| 260 |
+
|
| 261 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 262 |
+
search_params = self._build_params(
|
| 263 |
+
db="pubmed",
|
| 264 |
+
term=clean_query, # Use preprocessed query
|
| 265 |
+
retmax=max_results,
|
| 266 |
+
sort="relevance",
|
| 267 |
+
)
|
| 268 |
+
# ... rest unchanged
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
### Step 4: Update PubMed Tests
|
| 272 |
+
|
| 273 |
+
**File:** `tests/unit/tools/test_pubmed.py` - Add preprocessing test:
|
| 274 |
+
|
| 275 |
+
```python
|
| 276 |
+
@pytest.mark.asyncio
|
| 277 |
+
async def test_search_preprocesses_query(self, pubmed_tool, mock_httpx_client):
|
| 278 |
+
"""Test that queries are preprocessed before search."""
|
| 279 |
+
# This test verifies the integration - the actual preprocessing
|
| 280 |
+
# is tested in test_query_utils.py
|
| 281 |
+
|
| 282 |
+
mock_httpx_client.get.return_value = httpx.Response(
|
| 283 |
+
200,
|
| 284 |
+
json={"esearchresult": {"idlist": []}},
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Natural language query
|
| 288 |
+
await pubmed_tool.search("What drugs help with Long COVID?")
|
| 289 |
+
|
| 290 |
+
# Verify the call was made (preprocessing happens internally)
|
| 291 |
+
assert mock_httpx_client.get.called
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
## Verification
|
| 297 |
+
|
| 298 |
+
```bash
|
| 299 |
+
# Run query utils tests
|
| 300 |
+
uv run pytest tests/unit/tools/test_query_utils.py -v
|
| 301 |
+
|
| 302 |
+
# Run pubmed tests
|
| 303 |
+
uv run pytest tests/unit/tools/test_pubmed.py -v
|
| 304 |
+
|
| 305 |
+
# Run all tests
|
| 306 |
+
uv run pytest tests/unit/ -v
|
| 307 |
+
|
| 308 |
+
# Manual verification
|
| 309 |
+
uv run python -c "
|
| 310 |
+
from src.tools.query_utils import preprocess_query
|
| 311 |
+
|
| 312 |
+
queries = [
|
| 313 |
+
'What medications show promise for Long COVID?',
|
| 314 |
+
'Is metformin effective for cancer treatment?',
|
| 315 |
+
'How can we treat Alzheimer with existing drugs?',
|
| 316 |
+
]
|
| 317 |
+
|
| 318 |
+
for q in queries:
|
| 319 |
+
print(f'Input: {q}')
|
| 320 |
+
print(f'Output: {preprocess_query(q)}')
|
| 321 |
+
print()
|
| 322 |
+
"
|
| 323 |
+
```
|
| 324 |
+
|
| 325 |
+
Expected output:
|
| 326 |
+
```
|
| 327 |
+
Input: What medications show promise for Long COVID?
|
| 328 |
+
Output: medications ("long COVID" OR "PASC" OR "post-acute sequelae of SARS-CoV-2" OR "post-COVID syndrome" OR "post-COVID-19 condition")
|
| 329 |
+
|
| 330 |
+
Input: Is metformin effective for cancer treatment?
|
| 331 |
+
Output: metformin for ("cancer" OR "neoplasm" OR "tumor" OR "malignancy" OR "carcinoma") treatment
|
| 332 |
+
|
| 333 |
+
Input: How can we treat Alzheimer with existing drugs?
|
| 334 |
+
Output: we treat ("Alzheimer's disease" OR "Alzheimer disease" OR "AD" OR "Alzheimer dementia") with existing drugs
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## Files Changed
|
| 340 |
+
|
| 341 |
+
| File | Action |
|
| 342 |
+
|------|--------|
|
| 343 |
+
| `src/tools/query_utils.py` | CREATE |
|
| 344 |
+
| `tests/unit/tools/test_query_utils.py` | CREATE |
|
| 345 |
+
| `src/tools/pubmed.py` | MODIFY (add preprocessing) |
|
| 346 |
+
| `tests/unit/tools/test_pubmed.py` | MODIFY (add integration test) |
|
| 347 |
+
|
| 348 |
+
---
|
| 349 |
+
|
| 350 |
+
## Future Enhancements (Out of Scope)
|
| 351 |
+
|
| 352 |
+
- MeSH term lookup via NCBI API
|
| 353 |
+
- Drug name normalization (brand → generic)
|
| 354 |
+
- Disease ontology integration (UMLS)
|
| 355 |
+
- Query intent classification
|
docs/bugs/PHASE_03_CLINICALTRIALS_FILTERING.md
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 03: ClinicalTrials.gov Filtering
|
| 2 |
+
|
| 3 |
+
**Priority:** P1 - High
|
| 4 |
+
**Effort:** 1-2 hours
|
| 5 |
+
**Dependencies:** None (can run parallel with Phase 01 & 02)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Problem Statement
|
| 10 |
+
|
| 11 |
+
ClinicalTrials.gov returns ALL matching trials including:
|
| 12 |
+
- Withdrawn/Terminated trials (no useful data)
|
| 13 |
+
- Observational studies (not drug interventions)
|
| 14 |
+
- Phase 1 trials (safety only, no efficacy)
|
| 15 |
+
|
| 16 |
+
For drug repurposing, we need interventional studies with efficacy data.
|
| 17 |
+
|
| 18 |
+
## Success Criteria
|
| 19 |
+
|
| 20 |
+
- [ ] Only interventional studies returned
|
| 21 |
+
- [ ] Withdrawn/terminated trials filtered out
|
| 22 |
+
- [ ] Phase information included in results
|
| 23 |
+
- [ ] All existing tests pass
|
| 24 |
+
- [ ] New tests cover filtering
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## TDD Implementation Order
|
| 29 |
+
|
| 30 |
+
### Step 1: Write Failing Tests
|
| 31 |
+
|
| 32 |
+
**File:** `tests/unit/tools/test_clinicaltrials.py` - Add filter tests:
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
"""Unit tests for ClinicalTrials.gov tool."""
|
| 36 |
+
|
| 37 |
+
import pytest
|
| 38 |
+
from unittest.mock import patch, MagicMock
|
| 39 |
+
|
| 40 |
+
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 41 |
+
from src.utils.models import Evidence
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.mark.unit
|
| 45 |
+
class TestClinicalTrialsTool:
|
| 46 |
+
"""Tests for ClinicalTrialsTool."""
|
| 47 |
+
|
| 48 |
+
@pytest.fixture
|
| 49 |
+
def tool(self):
|
| 50 |
+
return ClinicalTrialsTool()
|
| 51 |
+
|
| 52 |
+
def test_tool_name(self, tool):
|
| 53 |
+
assert tool.name == "clinicaltrials"
|
| 54 |
+
|
| 55 |
+
@pytest.mark.asyncio
|
| 56 |
+
async def test_search_uses_filters(self, tool):
|
| 57 |
+
"""Test that search applies status and type filters."""
|
| 58 |
+
mock_response = MagicMock()
|
| 59 |
+
mock_response.json.return_value = {"studies": []}
|
| 60 |
+
mock_response.raise_for_status = MagicMock()
|
| 61 |
+
|
| 62 |
+
with patch("requests.get", return_value=mock_response) as mock_get:
|
| 63 |
+
await tool.search("test query", max_results=5)
|
| 64 |
+
|
| 65 |
+
# Verify filters were applied
|
| 66 |
+
call_args = mock_get.call_args
|
| 67 |
+
params = call_args.kwargs.get("params", call_args[1].get("params", {}))
|
| 68 |
+
|
| 69 |
+
# Should filter for active/completed studies
|
| 70 |
+
assert "filter.overallStatus" in params
|
| 71 |
+
assert "COMPLETED" in params["filter.overallStatus"]
|
| 72 |
+
assert "RECRUITING" in params["filter.overallStatus"]
|
| 73 |
+
|
| 74 |
+
# Should filter for interventional studies
|
| 75 |
+
assert "filter.studyType" in params
|
| 76 |
+
assert "INTERVENTIONAL" in params["filter.studyType"]
|
| 77 |
+
|
| 78 |
+
@pytest.mark.asyncio
|
| 79 |
+
async def test_search_returns_evidence(self, tool):
|
| 80 |
+
"""Test that search returns Evidence objects."""
|
| 81 |
+
mock_study = {
|
| 82 |
+
"protocolSection": {
|
| 83 |
+
"identificationModule": {
|
| 84 |
+
"nctId": "NCT12345678",
|
| 85 |
+
"briefTitle": "Metformin for Long COVID Treatment",
|
| 86 |
+
},
|
| 87 |
+
"statusModule": {
|
| 88 |
+
"overallStatus": "COMPLETED",
|
| 89 |
+
"startDateStruct": {"date": "2023-01-01"},
|
| 90 |
+
},
|
| 91 |
+
"descriptionModule": {
|
| 92 |
+
"briefSummary": "A study examining metformin for Long COVID symptoms.",
|
| 93 |
+
},
|
| 94 |
+
"designModule": {
|
| 95 |
+
"phases": ["PHASE2", "PHASE3"],
|
| 96 |
+
},
|
| 97 |
+
"conditionsModule": {
|
| 98 |
+
"conditions": ["Long COVID", "PASC"],
|
| 99 |
+
},
|
| 100 |
+
"armsInterventionsModule": {
|
| 101 |
+
"interventions": [{"name": "Metformin"}],
|
| 102 |
+
},
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
mock_response = MagicMock()
|
| 107 |
+
mock_response.json.return_value = {"studies": [mock_study]}
|
| 108 |
+
mock_response.raise_for_status = MagicMock()
|
| 109 |
+
|
| 110 |
+
with patch("requests.get", return_value=mock_response):
|
| 111 |
+
results = await tool.search("long covid metformin", max_results=5)
|
| 112 |
+
|
| 113 |
+
assert len(results) == 1
|
| 114 |
+
assert isinstance(results[0], Evidence)
|
| 115 |
+
assert "Metformin" in results[0].citation.title
|
| 116 |
+
assert "PHASE2" in results[0].content or "Phase" in results[0].content
|
| 117 |
+
|
| 118 |
+
@pytest.mark.asyncio
|
| 119 |
+
async def test_search_includes_phase_info(self, tool):
|
| 120 |
+
"""Test that phase information is included in content."""
|
| 121 |
+
mock_study = {
|
| 122 |
+
"protocolSection": {
|
| 123 |
+
"identificationModule": {
|
| 124 |
+
"nctId": "NCT12345678",
|
| 125 |
+
"briefTitle": "Test Study",
|
| 126 |
+
},
|
| 127 |
+
"statusModule": {
|
| 128 |
+
"overallStatus": "RECRUITING",
|
| 129 |
+
"startDateStruct": {"date": "2024-01-01"},
|
| 130 |
+
},
|
| 131 |
+
"descriptionModule": {
|
| 132 |
+
"briefSummary": "Test summary.",
|
| 133 |
+
},
|
| 134 |
+
"designModule": {
|
| 135 |
+
"phases": ["PHASE3"],
|
| 136 |
+
},
|
| 137 |
+
"conditionsModule": {"conditions": ["Test"]},
|
| 138 |
+
"armsInterventionsModule": {"interventions": []},
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
mock_response = MagicMock()
|
| 143 |
+
mock_response.json.return_value = {"studies": [mock_study]}
|
| 144 |
+
mock_response.raise_for_status = MagicMock()
|
| 145 |
+
|
| 146 |
+
with patch("requests.get", return_value=mock_response):
|
| 147 |
+
results = await tool.search("test", max_results=5)
|
| 148 |
+
|
| 149 |
+
# Phase should be in content
|
| 150 |
+
assert "PHASE3" in results[0].content or "Phase 3" in results[0].content
|
| 151 |
+
|
| 152 |
+
@pytest.mark.asyncio
|
| 153 |
+
async def test_search_empty_results(self, tool):
|
| 154 |
+
"""Test handling of empty results."""
|
| 155 |
+
mock_response = MagicMock()
|
| 156 |
+
mock_response.json.return_value = {"studies": []}
|
| 157 |
+
mock_response.raise_for_status = MagicMock()
|
| 158 |
+
|
| 159 |
+
with patch("requests.get", return_value=mock_response):
|
| 160 |
+
results = await tool.search("nonexistent xyz 12345", max_results=5)
|
| 161 |
+
assert results == []
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@pytest.mark.integration
|
| 165 |
+
class TestClinicalTrialsIntegration:
|
| 166 |
+
"""Integration tests with real API."""
|
| 167 |
+
|
| 168 |
+
@pytest.mark.asyncio
|
| 169 |
+
async def test_real_api_returns_interventional(self):
|
| 170 |
+
"""Test that real API returns interventional studies."""
|
| 171 |
+
tool = ClinicalTrialsTool()
|
| 172 |
+
results = await tool.search("long covid treatment", max_results=3)
|
| 173 |
+
|
| 174 |
+
# Should get results
|
| 175 |
+
assert len(results) > 0
|
| 176 |
+
|
| 177 |
+
# Results should mention interventions or treatments
|
| 178 |
+
all_content = " ".join([r.content.lower() for r in results])
|
| 179 |
+
has_intervention = (
|
| 180 |
+
"intervention" in all_content
|
| 181 |
+
or "treatment" in all_content
|
| 182 |
+
or "drug" in all_content
|
| 183 |
+
or "phase" in all_content
|
| 184 |
+
)
|
| 185 |
+
assert has_intervention
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### Step 2: Update ClinicalTrials Tool
|
| 189 |
+
|
| 190 |
+
**File:** `src/tools/clinicaltrials.py` - Add filters:
|
| 191 |
+
|
| 192 |
+
```python
|
| 193 |
+
"""ClinicalTrials.gov search tool using API v2."""
|
| 194 |
+
|
| 195 |
+
import asyncio
|
| 196 |
+
from typing import Any, ClassVar
|
| 197 |
+
|
| 198 |
+
import requests
|
| 199 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 200 |
+
|
| 201 |
+
from src.utils.exceptions import SearchError
|
| 202 |
+
from src.utils.models import Citation, Evidence
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class ClinicalTrialsTool:
|
| 206 |
+
"""Search tool for ClinicalTrials.gov.
|
| 207 |
+
|
| 208 |
+
Note: Uses `requests` library instead of `httpx` because ClinicalTrials.gov's
|
| 209 |
+
WAF blocks httpx's TLS fingerprint. The `requests` library is not blocked.
|
| 210 |
+
See: https://clinicaltrials.gov/data-api/api
|
| 211 |
+
"""
|
| 212 |
+
|
| 213 |
+
BASE_URL = "https://clinicaltrials.gov/api/v2/studies"
|
| 214 |
+
|
| 215 |
+
# Fields to retrieve
|
| 216 |
+
FIELDS: ClassVar[list[str]] = [
|
| 217 |
+
"NCTId",
|
| 218 |
+
"BriefTitle",
|
| 219 |
+
"Phase",
|
| 220 |
+
"OverallStatus",
|
| 221 |
+
"Condition",
|
| 222 |
+
"InterventionName",
|
| 223 |
+
"StartDate",
|
| 224 |
+
"BriefSummary",
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
+
# Status filter: Only active/completed studies with potential data
|
| 228 |
+
STATUS_FILTER = "COMPLETED|ACTIVE_NOT_RECRUITING|RECRUITING|ENROLLING_BY_INVITATION"
|
| 229 |
+
|
| 230 |
+
# Study type filter: Only interventional (drug/treatment studies)
|
| 231 |
+
STUDY_TYPE_FILTER = "INTERVENTIONAL"
|
| 232 |
+
|
| 233 |
+
@property
|
| 234 |
+
def name(self) -> str:
|
| 235 |
+
return "clinicaltrials"
|
| 236 |
+
|
| 237 |
+
@retry(
|
| 238 |
+
stop=stop_after_attempt(3),
|
| 239 |
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
| 240 |
+
reraise=True,
|
| 241 |
+
)
|
| 242 |
+
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 243 |
+
"""Search ClinicalTrials.gov for interventional studies.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
query: Search query (e.g., "metformin alzheimer")
|
| 247 |
+
max_results: Maximum results to return (max 100)
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
List of Evidence objects from clinical trials
|
| 251 |
+
"""
|
| 252 |
+
params: dict[str, str | int] = {
|
| 253 |
+
"query.term": query,
|
| 254 |
+
"pageSize": min(max_results, 100),
|
| 255 |
+
"fields": "|".join(self.FIELDS),
|
| 256 |
+
# FILTERS - Only interventional, active/completed studies
|
| 257 |
+
"filter.overallStatus": self.STATUS_FILTER,
|
| 258 |
+
"filter.studyType": self.STUDY_TYPE_FILTER,
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
# Run blocking requests.get in a separate thread for async compatibility
|
| 263 |
+
response = await asyncio.to_thread(
|
| 264 |
+
requests.get,
|
| 265 |
+
self.BASE_URL,
|
| 266 |
+
params=params,
|
| 267 |
+
headers={"User-Agent": "DeepCritical-Research-Agent/1.0"},
|
| 268 |
+
timeout=30,
|
| 269 |
+
)
|
| 270 |
+
response.raise_for_status()
|
| 271 |
+
|
| 272 |
+
data = response.json()
|
| 273 |
+
studies = data.get("studies", [])
|
| 274 |
+
return [self._study_to_evidence(study) for study in studies[:max_results]]
|
| 275 |
+
|
| 276 |
+
except requests.HTTPError as e:
|
| 277 |
+
raise SearchError(f"ClinicalTrials.gov API error: {e}") from e
|
| 278 |
+
except requests.RequestException as e:
|
| 279 |
+
raise SearchError(f"ClinicalTrials.gov request failed: {e}") from e
|
| 280 |
+
|
| 281 |
+
def _study_to_evidence(self, study: dict[str, Any]) -> Evidence:
|
| 282 |
+
"""Convert a clinical trial study to Evidence."""
|
| 283 |
+
# Navigate nested structure
|
| 284 |
+
protocol = study.get("protocolSection", {})
|
| 285 |
+
id_module = protocol.get("identificationModule", {})
|
| 286 |
+
status_module = protocol.get("statusModule", {})
|
| 287 |
+
desc_module = protocol.get("descriptionModule", {})
|
| 288 |
+
design_module = protocol.get("designModule", {})
|
| 289 |
+
conditions_module = protocol.get("conditionsModule", {})
|
| 290 |
+
arms_module = protocol.get("armsInterventionsModule", {})
|
| 291 |
+
|
| 292 |
+
nct_id = id_module.get("nctId", "Unknown")
|
| 293 |
+
title = id_module.get("briefTitle", "Untitled Study")
|
| 294 |
+
status = status_module.get("overallStatus", "Unknown")
|
| 295 |
+
start_date = status_module.get("startDateStruct", {}).get("date", "Unknown")
|
| 296 |
+
|
| 297 |
+
# Get phase (might be a list)
|
| 298 |
+
phases = design_module.get("phases", [])
|
| 299 |
+
phase = phases[0] if phases else "Not Applicable"
|
| 300 |
+
|
| 301 |
+
# Get conditions
|
| 302 |
+
conditions = conditions_module.get("conditions", [])
|
| 303 |
+
conditions_str = ", ".join(conditions[:3]) if conditions else "Unknown"
|
| 304 |
+
|
| 305 |
+
# Get interventions
|
| 306 |
+
interventions = arms_module.get("interventions", [])
|
| 307 |
+
intervention_names = [i.get("name", "") for i in interventions[:3]]
|
| 308 |
+
interventions_str = ", ".join(intervention_names) if intervention_names else "Unknown"
|
| 309 |
+
|
| 310 |
+
# Get summary
|
| 311 |
+
summary = desc_module.get("briefSummary", "No summary available.")
|
| 312 |
+
|
| 313 |
+
# Build content with key trial info
|
| 314 |
+
content = (
|
| 315 |
+
f"{summary[:500]}... "
|
| 316 |
+
f"Trial Phase: {phase}. "
|
| 317 |
+
f"Status: {status}. "
|
| 318 |
+
f"Conditions: {conditions_str}. "
|
| 319 |
+
f"Interventions: {interventions_str}."
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
return Evidence(
|
| 323 |
+
content=content[:2000],
|
| 324 |
+
citation=Citation(
|
| 325 |
+
source="clinicaltrials",
|
| 326 |
+
title=title[:500],
|
| 327 |
+
url=f"https://clinicaltrials.gov/study/{nct_id}",
|
| 328 |
+
date=start_date,
|
| 329 |
+
authors=[], # Trials don't have traditional authors
|
| 330 |
+
),
|
| 331 |
+
relevance=0.85, # Trials are highly relevant for repurposing
|
| 332 |
+
)
|
| 333 |
+
```
|
| 334 |
+
|
| 335 |
+
---
|
| 336 |
+
|
| 337 |
+
## Verification
|
| 338 |
+
|
| 339 |
+
```bash
|
| 340 |
+
# Run clinicaltrials tests
|
| 341 |
+
uv run pytest tests/unit/tools/test_clinicaltrials.py -v
|
| 342 |
+
|
| 343 |
+
# Run integration test (real API)
|
| 344 |
+
uv run pytest tests/unit/tools/test_clinicaltrials.py::TestClinicalTrialsIntegration -v
|
| 345 |
+
|
| 346 |
+
# Run all tests
|
| 347 |
+
uv run pytest tests/unit/ -v
|
| 348 |
+
|
| 349 |
+
# Manual verification
|
| 350 |
+
uv run python -c "
|
| 351 |
+
import asyncio
|
| 352 |
+
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 353 |
+
|
| 354 |
+
tool = ClinicalTrialsTool()
|
| 355 |
+
results = asyncio.run(tool.search('long covid treatment', 3))
|
| 356 |
+
|
| 357 |
+
for r in results:
|
| 358 |
+
print(f'Title: {r.citation.title}')
|
| 359 |
+
print(f'Content: {r.content[:200]}...')
|
| 360 |
+
print()
|
| 361 |
+
"
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
---
|
| 365 |
+
|
| 366 |
+
## Files Changed
|
| 367 |
+
|
| 368 |
+
| File | Action |
|
| 369 |
+
|------|--------|
|
| 370 |
+
| `src/tools/clinicaltrials.py` | MODIFY (add filters) |
|
| 371 |
+
| `tests/unit/tools/test_clinicaltrials.py` | MODIFY (add filter tests) |
|
| 372 |
+
|
| 373 |
+
---
|
| 374 |
+
|
| 375 |
+
## API Filter Reference
|
| 376 |
+
|
| 377 |
+
ClinicalTrials.gov API v2 supports these filters:
|
| 378 |
+
|
| 379 |
+
| Parameter | Values | Purpose |
|
| 380 |
+
|-----------|--------|---------|
|
| 381 |
+
| `filter.overallStatus` | COMPLETED, RECRUITING, etc. | Trial status |
|
| 382 |
+
| `filter.studyType` | INTERVENTIONAL, OBSERVATIONAL | Study design |
|
| 383 |
+
| `filter.phase` | PHASE1, PHASE2, PHASE3, PHASE4 | Trial phase |
|
| 384 |
+
| `filter.geo` | Country codes | Geographic filter |
|
| 385 |
+
|
| 386 |
+
See: https://clinicaltrials.gov/data-api/api
|
examples/full_stack_demo/run_full.py
CHANGED
|
@@ -114,8 +114,8 @@ async def run_full_demo(query: str, max_iterations: int) -> None:
|
|
| 114 |
from src.agents.hypothesis_agent import HypothesisAgent
|
| 115 |
from src.agents.report_agent import ReportAgent
|
| 116 |
from src.services.embeddings import EmbeddingService
|
| 117 |
-
from src.tools.biorxiv import BioRxivTool
|
| 118 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 119 |
from src.tools.pubmed import PubMedTool
|
| 120 |
from src.tools.search_handler import SearchHandler
|
| 121 |
|
|
@@ -123,7 +123,7 @@ async def run_full_demo(query: str, max_iterations: int) -> None:
|
|
| 123 |
print("[Init] Loading embedding model...")
|
| 124 |
embedding_service = EmbeddingService()
|
| 125 |
search_handler = SearchHandler(
|
| 126 |
-
tools=[PubMedTool(), ClinicalTrialsTool(),
|
| 127 |
)
|
| 128 |
judge_handler = JudgeHandler()
|
| 129 |
|
|
@@ -135,7 +135,7 @@ async def run_full_demo(query: str, max_iterations: int) -> None:
|
|
| 135 |
print_step(iteration, f"ITERATION {iteration}/{max_iterations}")
|
| 136 |
|
| 137 |
# Step 1: REAL Search
|
| 138 |
-
print("\n[Search] Querying PubMed + ClinicalTrials +
|
| 139 |
all_evidence = await _run_search_iteration(
|
| 140 |
query, iteration, evidence_store, all_evidence, search_handler, embedding_service
|
| 141 |
)
|
|
|
|
| 114 |
from src.agents.hypothesis_agent import HypothesisAgent
|
| 115 |
from src.agents.report_agent import ReportAgent
|
| 116 |
from src.services.embeddings import EmbeddingService
|
|
|
|
| 117 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 118 |
+
from src.tools.europepmc import EuropePMCTool
|
| 119 |
from src.tools.pubmed import PubMedTool
|
| 120 |
from src.tools.search_handler import SearchHandler
|
| 121 |
|
|
|
|
| 123 |
print("[Init] Loading embedding model...")
|
| 124 |
embedding_service = EmbeddingService()
|
| 125 |
search_handler = SearchHandler(
|
| 126 |
+
tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0
|
| 127 |
)
|
| 128 |
judge_handler = JudgeHandler()
|
| 129 |
|
|
|
|
| 135 |
print_step(iteration, f"ITERATION {iteration}/{max_iterations}")
|
| 136 |
|
| 137 |
# Step 1: REAL Search
|
| 138 |
+
print("\n[Search] Querying PubMed + ClinicalTrials + Europe PMC (REAL API calls)...")
|
| 139 |
all_evidence = await _run_search_iteration(
|
| 140 |
query, iteration, evidence_store, all_evidence, search_handler, embedding_service
|
| 141 |
)
|
examples/hypothesis_demo/run_hypothesis.py
CHANGED
|
@@ -21,8 +21,8 @@ from typing import Any
|
|
| 21 |
|
| 22 |
from src.agents.hypothesis_agent import HypothesisAgent
|
| 23 |
from src.services.embeddings import EmbeddingService
|
| 24 |
-
from src.tools.biorxiv import BioRxivTool
|
| 25 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 26 |
from src.tools.pubmed import PubMedTool
|
| 27 |
from src.tools.search_handler import SearchHandler
|
| 28 |
|
|
@@ -39,7 +39,7 @@ async def run_hypothesis_demo(query: str) -> None:
|
|
| 39 |
# Step 1: REAL Search
|
| 40 |
print("[Step 1] Searching PubMed + ClinicalTrials + bioRxiv...")
|
| 41 |
search_handler = SearchHandler(
|
| 42 |
-
tools=[PubMedTool(), ClinicalTrialsTool(),
|
| 43 |
)
|
| 44 |
result = await search_handler.execute(query, max_results_per_tool=5)
|
| 45 |
|
|
|
|
| 21 |
|
| 22 |
from src.agents.hypothesis_agent import HypothesisAgent
|
| 23 |
from src.services.embeddings import EmbeddingService
|
|
|
|
| 24 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 25 |
+
from src.tools.europepmc import EuropePMCTool
|
| 26 |
from src.tools.pubmed import PubMedTool
|
| 27 |
from src.tools.search_handler import SearchHandler
|
| 28 |
|
|
|
|
| 39 |
# Step 1: REAL Search
|
| 40 |
print("[Step 1] Searching PubMed + ClinicalTrials + bioRxiv...")
|
| 41 |
search_handler = SearchHandler(
|
| 42 |
+
tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0
|
| 43 |
)
|
| 44 |
result = await search_handler.execute(query, max_results_per_tool=5)
|
| 45 |
|
examples/orchestrator_demo/run_agent.py
CHANGED
|
@@ -24,8 +24,8 @@ import sys
|
|
| 24 |
|
| 25 |
from src.agent_factory.judges import JudgeHandler
|
| 26 |
from src.orchestrator import Orchestrator
|
| 27 |
-
from src.tools.biorxiv import BioRxivTool
|
| 28 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 29 |
from src.tools.pubmed import PubMedTool
|
| 30 |
from src.tools.search_handler import SearchHandler
|
| 31 |
from src.utils.models import OrchestratorConfig
|
|
@@ -80,7 +80,7 @@ Examples:
|
|
| 80 |
|
| 81 |
# Setup REAL components
|
| 82 |
search_handler = SearchHandler(
|
| 83 |
-
tools=[PubMedTool(), ClinicalTrialsTool(),
|
| 84 |
)
|
| 85 |
judge_handler = JudgeHandler() # REAL LLM judge
|
| 86 |
|
|
|
|
| 24 |
|
| 25 |
from src.agent_factory.judges import JudgeHandler
|
| 26 |
from src.orchestrator import Orchestrator
|
|
|
|
| 27 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 28 |
+
from src.tools.europepmc import EuropePMCTool
|
| 29 |
from src.tools.pubmed import PubMedTool
|
| 30 |
from src.tools.search_handler import SearchHandler
|
| 31 |
from src.utils.models import OrchestratorConfig
|
|
|
|
| 80 |
|
| 81 |
# Setup REAL components
|
| 82 |
search_handler = SearchHandler(
|
| 83 |
+
tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0
|
| 84 |
)
|
| 85 |
judge_handler = JudgeHandler() # REAL LLM judge
|
| 86 |
|
examples/orchestrator_demo/run_magentic.py
CHANGED
|
@@ -18,8 +18,8 @@ import sys
|
|
| 18 |
|
| 19 |
from src.agent_factory.judges import JudgeHandler
|
| 20 |
from src.orchestrator_factory import create_orchestrator
|
| 21 |
-
from src.tools.biorxiv import BioRxivTool
|
| 22 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 23 |
from src.tools.pubmed import PubMedTool
|
| 24 |
from src.tools.search_handler import SearchHandler
|
| 25 |
from src.utils.models import OrchestratorConfig
|
|
@@ -45,7 +45,7 @@ async def main() -> None:
|
|
| 45 |
|
| 46 |
# 1. Setup Search Tools
|
| 47 |
search_handler = SearchHandler(
|
| 48 |
-
tools=[PubMedTool(), ClinicalTrialsTool(),
|
| 49 |
)
|
| 50 |
|
| 51 |
# 2. Setup Judge
|
|
|
|
| 18 |
|
| 19 |
from src.agent_factory.judges import JudgeHandler
|
| 20 |
from src.orchestrator_factory import create_orchestrator
|
|
|
|
| 21 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 22 |
+
from src.tools.europepmc import EuropePMCTool
|
| 23 |
from src.tools.pubmed import PubMedTool
|
| 24 |
from src.tools.search_handler import SearchHandler
|
| 25 |
from src.utils.models import OrchestratorConfig
|
|
|
|
| 45 |
|
| 46 |
# 1. Setup Search Tools
|
| 47 |
search_handler = SearchHandler(
|
| 48 |
+
tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()], timeout=30.0
|
| 49 |
)
|
| 50 |
|
| 51 |
# 2. Setup Judge
|
examples/search_demo/run_search.py
CHANGED
|
@@ -21,8 +21,8 @@ Requirements:
|
|
| 21 |
import asyncio
|
| 22 |
import sys
|
| 23 |
|
| 24 |
-
from src.tools.biorxiv import BioRxivTool
|
| 25 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 26 |
from src.tools.pubmed import PubMedTool
|
| 27 |
from src.tools.search_handler import SearchHandler
|
| 28 |
|
|
@@ -37,11 +37,11 @@ async def main(query: str) -> None:
|
|
| 37 |
# Initialize tools
|
| 38 |
pubmed = PubMedTool()
|
| 39 |
trials = ClinicalTrialsTool()
|
| 40 |
-
preprints =
|
| 41 |
handler = SearchHandler(tools=[pubmed, trials, preprints], timeout=30.0)
|
| 42 |
|
| 43 |
# Execute search
|
| 44 |
-
print("Searching PubMed, ClinicalTrials.gov, and
|
| 45 |
result = await handler.execute(query, max_results_per_tool=5)
|
| 46 |
|
| 47 |
# Display results
|
|
|
|
| 21 |
import asyncio
|
| 22 |
import sys
|
| 23 |
|
|
|
|
| 24 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 25 |
+
from src.tools.europepmc import EuropePMCTool
|
| 26 |
from src.tools.pubmed import PubMedTool
|
| 27 |
from src.tools.search_handler import SearchHandler
|
| 28 |
|
|
|
|
| 37 |
# Initialize tools
|
| 38 |
pubmed = PubMedTool()
|
| 39 |
trials = ClinicalTrialsTool()
|
| 40 |
+
preprints = EuropePMCTool()
|
| 41 |
handler = SearchHandler(tools=[pubmed, trials, preprints], timeout=30.0)
|
| 42 |
|
| 43 |
# Execute search
|
| 44 |
+
print("Searching PubMed, ClinicalTrials.gov, and Europe PMC in parallel...")
|
| 45 |
result = await handler.execute(query, max_results_per_tool=5)
|
| 46 |
|
| 47 |
# Display results
|
src/agents/tools.py
CHANGED
|
@@ -7,14 +7,14 @@ They also interact with the thread-safe MagenticState to persist evidence.
|
|
| 7 |
from agent_framework import ai_function
|
| 8 |
|
| 9 |
from src.agents.state import get_magentic_state
|
| 10 |
-
from src.tools.biorxiv import BioRxivTool
|
| 11 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 12 |
from src.tools.pubmed import PubMedTool
|
| 13 |
|
| 14 |
# Singleton tool instances (stateless wrappers)
|
| 15 |
_pubmed = PubMedTool()
|
| 16 |
_clinicaltrials = ClinicalTrialsTool()
|
| 17 |
-
|
| 18 |
|
| 19 |
|
| 20 |
@ai_function # type: ignore[arg-type, misc]
|
|
@@ -116,28 +116,28 @@ async def search_clinical_trials(query: str, max_results: int = 10) -> str:
|
|
| 116 |
|
| 117 |
@ai_function # type: ignore[arg-type, misc]
|
| 118 |
async def search_preprints(query: str, max_results: int = 10) -> str:
|
| 119 |
-
"""Search
|
| 120 |
|
| 121 |
-
Use this tool to find the latest research
|
| 122 |
-
|
| 123 |
|
| 124 |
Args:
|
| 125 |
query: Search terms (e.g., "long covid treatment")
|
| 126 |
max_results: Maximum results to return (default 10)
|
| 127 |
|
| 128 |
Returns:
|
| 129 |
-
Formatted list of
|
| 130 |
"""
|
| 131 |
state = get_magentic_state()
|
| 132 |
|
| 133 |
-
results = await
|
| 134 |
if not results:
|
| 135 |
-
return f"No
|
| 136 |
|
| 137 |
# Update state
|
| 138 |
new_count = state.add_evidence(results)
|
| 139 |
|
| 140 |
-
output = [f"Found {len(results)}
|
| 141 |
for i, r in enumerate(results[:max_results], 1):
|
| 142 |
title = r.citation.title
|
| 143 |
date = r.citation.date
|
|
@@ -146,7 +146,7 @@ async def search_preprints(query: str, max_results: int = 10) -> str:
|
|
| 146 |
url = r.citation.url
|
| 147 |
|
| 148 |
output.append(f"{i}. **{title}**")
|
| 149 |
-
output.append(f"
|
| 150 |
output.append(f" {content_clean}...")
|
| 151 |
output.append(f" URL: {url}\n")
|
| 152 |
|
|
|
|
| 7 |
from agent_framework import ai_function
|
| 8 |
|
| 9 |
from src.agents.state import get_magentic_state
|
|
|
|
| 10 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 11 |
+
from src.tools.europepmc import EuropePMCTool
|
| 12 |
from src.tools.pubmed import PubMedTool
|
| 13 |
|
| 14 |
# Singleton tool instances (stateless wrappers)
|
| 15 |
_pubmed = PubMedTool()
|
| 16 |
_clinicaltrials = ClinicalTrialsTool()
|
| 17 |
+
_europepmc = EuropePMCTool()
|
| 18 |
|
| 19 |
|
| 20 |
@ai_function # type: ignore[arg-type, misc]
|
|
|
|
| 116 |
|
| 117 |
@ai_function # type: ignore[arg-type, misc]
|
| 118 |
async def search_preprints(query: str, max_results: int = 10) -> str:
|
| 119 |
+
"""Search Europe PMC for preprints and papers.
|
| 120 |
|
| 121 |
+
Use this tool to find the latest research including preprints
|
| 122 |
+
from bioRxiv, medRxiv, and peer-reviewed papers.
|
| 123 |
|
| 124 |
Args:
|
| 125 |
query: Search terms (e.g., "long covid treatment")
|
| 126 |
max_results: Maximum results to return (default 10)
|
| 127 |
|
| 128 |
Returns:
|
| 129 |
+
Formatted list of papers with abstracts and links
|
| 130 |
"""
|
| 131 |
state = get_magentic_state()
|
| 132 |
|
| 133 |
+
results = await _europepmc.search(query, max_results)
|
| 134 |
if not results:
|
| 135 |
+
return f"No papers found for: {query}"
|
| 136 |
|
| 137 |
# Update state
|
| 138 |
new_count = state.add_evidence(results)
|
| 139 |
|
| 140 |
+
output = [f"Found {len(results)} papers ({new_count} new stored):\n"]
|
| 141 |
for i, r in enumerate(results[:max_results], 1):
|
| 142 |
title = r.citation.title
|
| 143 |
date = r.citation.date
|
|
|
|
| 146 |
url = r.citation.url
|
| 147 |
|
| 148 |
output.append(f"{i}. **{title}**")
|
| 149 |
+
output.append(f" Source: {source} | Date: {date}")
|
| 150 |
output.append(f" {content_clean}...")
|
| 151 |
output.append(f" URL: {url}\n")
|
| 152 |
|
src/app.py
CHANGED
|
@@ -12,8 +12,8 @@ from pydantic_ai.providers.openai import OpenAIProvider
|
|
| 12 |
|
| 13 |
from src.agent_factory.judges import HFInferenceJudgeHandler, JudgeHandler, MockJudgeHandler
|
| 14 |
from src.orchestrator_factory import create_orchestrator
|
| 15 |
-
from src.tools.biorxiv import BioRxivTool
|
| 16 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 17 |
from src.tools.pubmed import PubMedTool
|
| 18 |
from src.tools.search_handler import SearchHandler
|
| 19 |
from src.utils.config import settings
|
|
@@ -46,7 +46,7 @@ def configure_orchestrator(
|
|
| 46 |
|
| 47 |
# Create search tools
|
| 48 |
search_handler = SearchHandler(
|
| 49 |
-
tools=[PubMedTool(), ClinicalTrialsTool(),
|
| 50 |
timeout=config.search_timeout,
|
| 51 |
)
|
| 52 |
|
|
|
|
| 12 |
|
| 13 |
from src.agent_factory.judges import HFInferenceJudgeHandler, JudgeHandler, MockJudgeHandler
|
| 14 |
from src.orchestrator_factory import create_orchestrator
|
|
|
|
| 15 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 16 |
+
from src.tools.europepmc import EuropePMCTool
|
| 17 |
from src.tools.pubmed import PubMedTool
|
| 18 |
from src.tools.search_handler import SearchHandler
|
| 19 |
from src.utils.config import settings
|
|
|
|
| 46 |
|
| 47 |
# Create search tools
|
| 48 |
search_handler = SearchHandler(
|
| 49 |
+
tools=[PubMedTool(), ClinicalTrialsTool(), EuropePMCTool()],
|
| 50 |
timeout=config.search_timeout,
|
| 51 |
)
|
| 52 |
|
src/mcp_tools.py
CHANGED
|
@@ -7,14 +7,14 @@ Each function follows the MCP tool contract:
|
|
| 7 |
- Formatted string returns
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
from src.tools.biorxiv import BioRxivTool
|
| 11 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
|
|
|
| 12 |
from src.tools.pubmed import PubMedTool
|
| 13 |
|
| 14 |
# Singleton instances (avoid recreating on each call)
|
| 15 |
_pubmed = PubMedTool()
|
| 16 |
_trials = ClinicalTrialsTool()
|
| 17 |
-
|
| 18 |
|
| 19 |
|
| 20 |
async def search_pubmed(query: str, max_results: int = 10) -> str:
|
|
@@ -78,27 +78,27 @@ async def search_clinical_trials(query: str, max_results: int = 10) -> str:
|
|
| 78 |
return "\n".join(formatted)
|
| 79 |
|
| 80 |
|
| 81 |
-
async def
|
| 82 |
-
"""Search
|
| 83 |
|
| 84 |
-
Searches
|
| 85 |
-
|
| 86 |
|
| 87 |
Args:
|
| 88 |
query: Search query (e.g., "metformin neuroprotection", "long covid treatment")
|
| 89 |
max_results: Maximum results to return (1-50, default 10)
|
| 90 |
|
| 91 |
Returns:
|
| 92 |
-
Formatted
|
| 93 |
"""
|
| 94 |
max_results = max(1, min(50, max_results))
|
| 95 |
|
| 96 |
-
results = await
|
| 97 |
|
| 98 |
if not results:
|
| 99 |
-
return f"No
|
| 100 |
|
| 101 |
-
formatted = [f"##
|
| 102 |
for i, evidence in enumerate(results, 1):
|
| 103 |
formatted.append(f"### {i}. {evidence.citation.title}")
|
| 104 |
formatted.append(f"**Authors**: {', '.join(evidence.citation.authors[:3])}")
|
|
@@ -112,7 +112,7 @@ async def search_biorxiv(query: str, max_results: int = 10) -> str:
|
|
| 112 |
async def search_all_sources(query: str, max_per_source: int = 5) -> str:
|
| 113 |
"""Search all biomedical sources simultaneously.
|
| 114 |
|
| 115 |
-
Performs parallel search across PubMed, ClinicalTrials.gov, and
|
| 116 |
This is the most comprehensive search option for drug repurposing research.
|
| 117 |
|
| 118 |
Args:
|
|
@@ -129,10 +129,10 @@ async def search_all_sources(query: str, max_per_source: int = 5) -> str:
|
|
| 129 |
# Run all searches in parallel
|
| 130 |
pubmed_task = search_pubmed(query, max_per_source)
|
| 131 |
trials_task = search_clinical_trials(query, max_per_source)
|
| 132 |
-
|
| 133 |
|
| 134 |
-
pubmed_results, trials_results,
|
| 135 |
-
pubmed_task, trials_task,
|
| 136 |
)
|
| 137 |
|
| 138 |
formatted = [f"# Comprehensive Search: {query}\n"]
|
|
@@ -148,10 +148,10 @@ async def search_all_sources(query: str, max_per_source: int = 5) -> str:
|
|
| 148 |
else:
|
| 149 |
formatted.append(f"## Clinical Trials\n*Error: {trials_results}*\n")
|
| 150 |
|
| 151 |
-
if isinstance(
|
| 152 |
-
formatted.append(
|
| 153 |
else:
|
| 154 |
-
formatted.append(f"##
|
| 155 |
|
| 156 |
return "\n---\n".join(formatted)
|
| 157 |
|
|
|
|
| 7 |
- Formatted string returns
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 10 |
from src.tools.clinicaltrials import ClinicalTrialsTool
|
| 11 |
+
from src.tools.europepmc import EuropePMCTool
|
| 12 |
from src.tools.pubmed import PubMedTool
|
| 13 |
|
| 14 |
# Singleton instances (avoid recreating on each call)
|
| 15 |
_pubmed = PubMedTool()
|
| 16 |
_trials = ClinicalTrialsTool()
|
| 17 |
+
_europepmc = EuropePMCTool()
|
| 18 |
|
| 19 |
|
| 20 |
async def search_pubmed(query: str, max_results: int = 10) -> str:
|
|
|
|
| 78 |
return "\n".join(formatted)
|
| 79 |
|
| 80 |
|
| 81 |
+
async def search_europepmc(query: str, max_results: int = 10) -> str:
|
| 82 |
+
"""Search Europe PMC for preprints and papers.
|
| 83 |
|
| 84 |
+
Searches Europe PMC, which includes bioRxiv, medRxiv, and peer-reviewed content.
|
| 85 |
+
Useful for finding cutting-edge preprints and open access papers.
|
| 86 |
|
| 87 |
Args:
|
| 88 |
query: Search query (e.g., "metformin neuroprotection", "long covid treatment")
|
| 89 |
max_results: Maximum results to return (1-50, default 10)
|
| 90 |
|
| 91 |
Returns:
|
| 92 |
+
Formatted results with titles, authors, and abstracts
|
| 93 |
"""
|
| 94 |
max_results = max(1, min(50, max_results))
|
| 95 |
|
| 96 |
+
results = await _europepmc.search(query, max_results)
|
| 97 |
|
| 98 |
if not results:
|
| 99 |
+
return f"No Europe PMC results found for: {query}"
|
| 100 |
|
| 101 |
+
formatted = [f"## Europe PMC Results for: {query}\n"]
|
| 102 |
for i, evidence in enumerate(results, 1):
|
| 103 |
formatted.append(f"### {i}. {evidence.citation.title}")
|
| 104 |
formatted.append(f"**Authors**: {', '.join(evidence.citation.authors[:3])}")
|
|
|
|
| 112 |
async def search_all_sources(query: str, max_per_source: int = 5) -> str:
|
| 113 |
"""Search all biomedical sources simultaneously.
|
| 114 |
|
| 115 |
+
Performs parallel search across PubMed, ClinicalTrials.gov, and Europe PMC.
|
| 116 |
This is the most comprehensive search option for drug repurposing research.
|
| 117 |
|
| 118 |
Args:
|
|
|
|
| 129 |
# Run all searches in parallel
|
| 130 |
pubmed_task = search_pubmed(query, max_per_source)
|
| 131 |
trials_task = search_clinical_trials(query, max_per_source)
|
| 132 |
+
europepmc_task = search_europepmc(query, max_per_source)
|
| 133 |
|
| 134 |
+
pubmed_results, trials_results, europepmc_results = await asyncio.gather(
|
| 135 |
+
pubmed_task, trials_task, europepmc_task, return_exceptions=True
|
| 136 |
)
|
| 137 |
|
| 138 |
formatted = [f"# Comprehensive Search: {query}\n"]
|
|
|
|
| 148 |
else:
|
| 149 |
formatted.append(f"## Clinical Trials\n*Error: {trials_results}*\n")
|
| 150 |
|
| 151 |
+
if isinstance(europepmc_results, str):
|
| 152 |
+
formatted.append(europepmc_results)
|
| 153 |
else:
|
| 154 |
+
formatted.append(f"## Europe PMC\n*Error: {europepmc_results}*\n")
|
| 155 |
|
| 156 |
return "\n---\n".join(formatted)
|
| 157 |
|
src/tools/biorxiv.py
DELETED
|
@@ -1,352 +0,0 @@
|
|
| 1 |
-
"""bioRxiv/medRxiv preprint search tool."""
|
| 2 |
-
|
| 3 |
-
import re
|
| 4 |
-
from datetime import datetime, timedelta
|
| 5 |
-
from typing import Any, ClassVar
|
| 6 |
-
|
| 7 |
-
import httpx
|
| 8 |
-
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 9 |
-
|
| 10 |
-
from src.utils.exceptions import SearchError
|
| 11 |
-
from src.utils.models import Citation, Evidence
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class BioRxivTool:
|
| 15 |
-
"""Search tool for bioRxiv and medRxiv preprints."""
|
| 16 |
-
|
| 17 |
-
BASE_URL = "https://api.biorxiv.org/details"
|
| 18 |
-
# Use medRxiv for medical/clinical content (more relevant for drug repurposing)
|
| 19 |
-
DEFAULT_SERVER = "medrxiv"
|
| 20 |
-
# Fetch papers from last N days
|
| 21 |
-
DEFAULT_DAYS = 90
|
| 22 |
-
|
| 23 |
-
# Comprehensive stop words list - these are too common to be useful for filtering
|
| 24 |
-
STOP_WORDS: ClassVar[set[str]] = {
|
| 25 |
-
# Articles and prepositions
|
| 26 |
-
"the",
|
| 27 |
-
"a",
|
| 28 |
-
"an",
|
| 29 |
-
"in",
|
| 30 |
-
"on",
|
| 31 |
-
"at",
|
| 32 |
-
"to",
|
| 33 |
-
"for",
|
| 34 |
-
"of",
|
| 35 |
-
"with",
|
| 36 |
-
"by",
|
| 37 |
-
"from",
|
| 38 |
-
"as",
|
| 39 |
-
"into",
|
| 40 |
-
"through",
|
| 41 |
-
"during",
|
| 42 |
-
"before",
|
| 43 |
-
"after",
|
| 44 |
-
"above",
|
| 45 |
-
"below",
|
| 46 |
-
"between",
|
| 47 |
-
"under",
|
| 48 |
-
"about",
|
| 49 |
-
"against",
|
| 50 |
-
"among",
|
| 51 |
-
# Conjunctions
|
| 52 |
-
"and",
|
| 53 |
-
"or",
|
| 54 |
-
"but",
|
| 55 |
-
"nor",
|
| 56 |
-
"so",
|
| 57 |
-
"yet",
|
| 58 |
-
"both",
|
| 59 |
-
"either",
|
| 60 |
-
"neither",
|
| 61 |
-
# Pronouns
|
| 62 |
-
"i",
|
| 63 |
-
"you",
|
| 64 |
-
"he",
|
| 65 |
-
"she",
|
| 66 |
-
"it",
|
| 67 |
-
"we",
|
| 68 |
-
"they",
|
| 69 |
-
"me",
|
| 70 |
-
"him",
|
| 71 |
-
"her",
|
| 72 |
-
"us",
|
| 73 |
-
"them",
|
| 74 |
-
"my",
|
| 75 |
-
"your",
|
| 76 |
-
"his",
|
| 77 |
-
"its",
|
| 78 |
-
"our",
|
| 79 |
-
"their",
|
| 80 |
-
"this",
|
| 81 |
-
"that",
|
| 82 |
-
"these",
|
| 83 |
-
"those",
|
| 84 |
-
"which",
|
| 85 |
-
"who",
|
| 86 |
-
"whom",
|
| 87 |
-
"whose",
|
| 88 |
-
"what",
|
| 89 |
-
"whatever",
|
| 90 |
-
# Question words
|
| 91 |
-
"when",
|
| 92 |
-
"where",
|
| 93 |
-
"why",
|
| 94 |
-
"how",
|
| 95 |
-
# Modal and auxiliary verbs
|
| 96 |
-
"is",
|
| 97 |
-
"are",
|
| 98 |
-
"was",
|
| 99 |
-
"were",
|
| 100 |
-
"be",
|
| 101 |
-
"been",
|
| 102 |
-
"being",
|
| 103 |
-
"am",
|
| 104 |
-
"have",
|
| 105 |
-
"has",
|
| 106 |
-
"had",
|
| 107 |
-
"having",
|
| 108 |
-
"do",
|
| 109 |
-
"does",
|
| 110 |
-
"did",
|
| 111 |
-
"doing",
|
| 112 |
-
"will",
|
| 113 |
-
"would",
|
| 114 |
-
"shall",
|
| 115 |
-
"should",
|
| 116 |
-
"can",
|
| 117 |
-
"could",
|
| 118 |
-
"may",
|
| 119 |
-
"might",
|
| 120 |
-
"must",
|
| 121 |
-
"need",
|
| 122 |
-
"ought",
|
| 123 |
-
# Common verbs
|
| 124 |
-
"get",
|
| 125 |
-
"got",
|
| 126 |
-
"make",
|
| 127 |
-
"made",
|
| 128 |
-
"take",
|
| 129 |
-
"taken",
|
| 130 |
-
"give",
|
| 131 |
-
"given",
|
| 132 |
-
"go",
|
| 133 |
-
"went",
|
| 134 |
-
"gone",
|
| 135 |
-
"come",
|
| 136 |
-
"came",
|
| 137 |
-
"see",
|
| 138 |
-
"saw",
|
| 139 |
-
"seen",
|
| 140 |
-
"know",
|
| 141 |
-
"knew",
|
| 142 |
-
"known",
|
| 143 |
-
"think",
|
| 144 |
-
"thought",
|
| 145 |
-
"find",
|
| 146 |
-
"found",
|
| 147 |
-
"show",
|
| 148 |
-
"shown",
|
| 149 |
-
"showed",
|
| 150 |
-
"use",
|
| 151 |
-
"used",
|
| 152 |
-
"using",
|
| 153 |
-
# Generic scientific terms (too common to filter on)
|
| 154 |
-
# Note: Keep medical terms like treatment, disease, drug - meaningful for queries
|
| 155 |
-
"study",
|
| 156 |
-
"studies",
|
| 157 |
-
"studied",
|
| 158 |
-
"result",
|
| 159 |
-
"results",
|
| 160 |
-
"method",
|
| 161 |
-
"methods",
|
| 162 |
-
"analysis",
|
| 163 |
-
"data",
|
| 164 |
-
"group",
|
| 165 |
-
"groups",
|
| 166 |
-
"research",
|
| 167 |
-
"findings",
|
| 168 |
-
"significant",
|
| 169 |
-
"associated",
|
| 170 |
-
"compared",
|
| 171 |
-
"observed",
|
| 172 |
-
"reported",
|
| 173 |
-
"participants",
|
| 174 |
-
"sample",
|
| 175 |
-
"samples",
|
| 176 |
-
# Other common words
|
| 177 |
-
"also",
|
| 178 |
-
"however",
|
| 179 |
-
"therefore",
|
| 180 |
-
"thus",
|
| 181 |
-
"although",
|
| 182 |
-
"because",
|
| 183 |
-
"since",
|
| 184 |
-
"while",
|
| 185 |
-
"if",
|
| 186 |
-
"then",
|
| 187 |
-
"than",
|
| 188 |
-
"such",
|
| 189 |
-
"same",
|
| 190 |
-
"different",
|
| 191 |
-
"other",
|
| 192 |
-
"another",
|
| 193 |
-
"each",
|
| 194 |
-
"every",
|
| 195 |
-
"all",
|
| 196 |
-
"any",
|
| 197 |
-
"some",
|
| 198 |
-
"no",
|
| 199 |
-
"not",
|
| 200 |
-
"only",
|
| 201 |
-
"just",
|
| 202 |
-
"more",
|
| 203 |
-
"most",
|
| 204 |
-
"less",
|
| 205 |
-
"least",
|
| 206 |
-
"very",
|
| 207 |
-
"much",
|
| 208 |
-
"many",
|
| 209 |
-
"few",
|
| 210 |
-
"new",
|
| 211 |
-
"old",
|
| 212 |
-
"first",
|
| 213 |
-
"last",
|
| 214 |
-
"next",
|
| 215 |
-
"previous",
|
| 216 |
-
"high",
|
| 217 |
-
"low",
|
| 218 |
-
"large",
|
| 219 |
-
"small",
|
| 220 |
-
"long",
|
| 221 |
-
"short",
|
| 222 |
-
"good",
|
| 223 |
-
"well",
|
| 224 |
-
"better",
|
| 225 |
-
"best",
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
def __init__(self, server: str = DEFAULT_SERVER, days: int = DEFAULT_DAYS) -> None:
|
| 229 |
-
"""
|
| 230 |
-
Initialize bioRxiv tool.
|
| 231 |
-
|
| 232 |
-
Args:
|
| 233 |
-
server: "biorxiv" or "medrxiv"
|
| 234 |
-
days: How many days back to search
|
| 235 |
-
"""
|
| 236 |
-
self.server = server
|
| 237 |
-
self.days = days
|
| 238 |
-
|
| 239 |
-
@property
|
| 240 |
-
def name(self) -> str:
|
| 241 |
-
return "biorxiv"
|
| 242 |
-
|
| 243 |
-
@retry(
|
| 244 |
-
stop=stop_after_attempt(3),
|
| 245 |
-
wait=wait_exponential(multiplier=1, min=1, max=10),
|
| 246 |
-
reraise=True,
|
| 247 |
-
)
|
| 248 |
-
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 249 |
-
"""
|
| 250 |
-
Search bioRxiv/medRxiv for preprints matching query.
|
| 251 |
-
|
| 252 |
-
Note: bioRxiv API doesn't support keyword search directly.
|
| 253 |
-
We fetch recent papers and filter client-side.
|
| 254 |
-
|
| 255 |
-
Args:
|
| 256 |
-
query: Search query (keywords)
|
| 257 |
-
max_results: Maximum results to return
|
| 258 |
-
|
| 259 |
-
Returns:
|
| 260 |
-
List of Evidence objects from preprints
|
| 261 |
-
"""
|
| 262 |
-
# Build date range for last N days
|
| 263 |
-
end_date = datetime.now().strftime("%Y-%m-%d")
|
| 264 |
-
start_date = (datetime.now() - timedelta(days=self.days)).strftime("%Y-%m-%d")
|
| 265 |
-
interval = f"{start_date}/{end_date}"
|
| 266 |
-
|
| 267 |
-
# Fetch recent papers
|
| 268 |
-
url = f"{self.BASE_URL}/{self.server}/{interval}/0/json"
|
| 269 |
-
|
| 270 |
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 271 |
-
try:
|
| 272 |
-
response = await client.get(url)
|
| 273 |
-
response.raise_for_status()
|
| 274 |
-
except httpx.HTTPStatusError as e:
|
| 275 |
-
raise SearchError(f"bioRxiv search failed: {e}") from e
|
| 276 |
-
except httpx.RequestError as e:
|
| 277 |
-
raise SearchError(f"bioRxiv connection failed: {e}") from e
|
| 278 |
-
|
| 279 |
-
data = response.json()
|
| 280 |
-
papers = data.get("collection", [])
|
| 281 |
-
|
| 282 |
-
# Filter papers by query keywords
|
| 283 |
-
query_terms = self._extract_terms(query)
|
| 284 |
-
matching = self._filter_by_keywords(papers, query_terms, max_results)
|
| 285 |
-
|
| 286 |
-
return [self._paper_to_evidence(paper) for paper in matching]
|
| 287 |
-
|
| 288 |
-
def _extract_terms(self, query: str) -> list[str]:
|
| 289 |
-
"""Extract meaningful search terms from query."""
|
| 290 |
-
# Simple tokenization, lowercase
|
| 291 |
-
terms = re.findall(r"\b\w+\b", query.lower())
|
| 292 |
-
# Filter out stop words and short terms
|
| 293 |
-
return [t for t in terms if t not in self.STOP_WORDS and len(t) > 2]
|
| 294 |
-
|
| 295 |
-
def _filter_by_keywords(
|
| 296 |
-
self, papers: list[dict[str, Any]], terms: list[str], max_results: int
|
| 297 |
-
) -> list[dict[str, Any]]:
|
| 298 |
-
"""Filter papers that contain query terms in title or abstract."""
|
| 299 |
-
scored_papers = []
|
| 300 |
-
|
| 301 |
-
# Require at least 2 matching terms, or all terms if fewer than 2
|
| 302 |
-
min_matches = min(2, len(terms)) if terms else 1
|
| 303 |
-
|
| 304 |
-
for paper in papers:
|
| 305 |
-
title = paper.get("title", "").lower()
|
| 306 |
-
abstract = paper.get("abstract", "").lower()
|
| 307 |
-
text = f"{title} {abstract}"
|
| 308 |
-
|
| 309 |
-
# Count matching terms
|
| 310 |
-
matches = sum(1 for term in terms if term in text)
|
| 311 |
-
|
| 312 |
-
# Only include papers meeting minimum match threshold
|
| 313 |
-
if matches >= min_matches:
|
| 314 |
-
scored_papers.append((matches, paper))
|
| 315 |
-
|
| 316 |
-
# Sort by match count (descending)
|
| 317 |
-
scored_papers.sort(key=lambda x: x[0], reverse=True)
|
| 318 |
-
|
| 319 |
-
return [paper for _, paper in scored_papers[:max_results]]
|
| 320 |
-
|
| 321 |
-
def _paper_to_evidence(self, paper: dict[str, Any]) -> Evidence:
|
| 322 |
-
"""Convert a preprint paper to Evidence."""
|
| 323 |
-
doi = paper.get("doi", "")
|
| 324 |
-
title = paper.get("title", "Untitled")
|
| 325 |
-
authors_str = paper.get("authors", "Unknown")
|
| 326 |
-
date = paper.get("date", "Unknown")
|
| 327 |
-
abstract = paper.get("abstract", "No abstract available.")
|
| 328 |
-
category = paper.get("category", "")
|
| 329 |
-
|
| 330 |
-
# Parse authors (format: "Smith, J; Jones, A")
|
| 331 |
-
authors = [a.strip() for a in authors_str.split(";")][:5]
|
| 332 |
-
|
| 333 |
-
# Truncate abstract if needed
|
| 334 |
-
truncated_abstract = abstract[:1800]
|
| 335 |
-
suffix = "..." if len(abstract) > 1800 else ""
|
| 336 |
-
|
| 337 |
-
# Note this is a preprint in the content
|
| 338 |
-
content = (
|
| 339 |
-
f"[PREPRINT - Not peer-reviewed] {truncated_abstract}{suffix} Category: {category}."
|
| 340 |
-
)
|
| 341 |
-
|
| 342 |
-
return Evidence(
|
| 343 |
-
content=content[:2000],
|
| 344 |
-
citation=Citation(
|
| 345 |
-
source="biorxiv",
|
| 346 |
-
title=title[:500],
|
| 347 |
-
url=f"https://doi.org/{doi}" if doi else "https://www.medrxiv.org/",
|
| 348 |
-
date=date,
|
| 349 |
-
authors=authors,
|
| 350 |
-
),
|
| 351 |
-
relevance=0.75, # Slightly lower than peer-reviewed
|
| 352 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tools/europepmc.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Europe PMC search tool - replaces BioRxiv."""
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 7 |
+
|
| 8 |
+
from src.utils.exceptions import SearchError
|
| 9 |
+
from src.utils.models import Citation, Evidence
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EuropePMCTool:
|
| 13 |
+
"""
|
| 14 |
+
Search Europe PMC for papers and preprints.
|
| 15 |
+
|
| 16 |
+
Europe PMC indexes:
|
| 17 |
+
- PubMed/MEDLINE articles
|
| 18 |
+
- PMC full-text articles
|
| 19 |
+
- Preprints from bioRxiv, medRxiv, ChemRxiv, etc.
|
| 20 |
+
- Patents and clinical guidelines
|
| 21 |
+
|
| 22 |
+
API Docs: https://europepmc.org/RestfulWebService
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
BASE_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def name(self) -> str:
|
| 29 |
+
return "europepmc"
|
| 30 |
+
|
| 31 |
+
@retry(
|
| 32 |
+
stop=stop_after_attempt(3),
|
| 33 |
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
| 34 |
+
reraise=True,
|
| 35 |
+
)
|
| 36 |
+
async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
|
| 37 |
+
"""
|
| 38 |
+
Search Europe PMC for papers matching query.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
query: Search keywords
|
| 42 |
+
max_results: Maximum results to return
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
List of Evidence objects
|
| 46 |
+
"""
|
| 47 |
+
params: dict[str, str | int] = {
|
| 48 |
+
"query": query,
|
| 49 |
+
"resultType": "core",
|
| 50 |
+
"pageSize": min(max_results, 100),
|
| 51 |
+
"format": "json",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 55 |
+
try:
|
| 56 |
+
response = await client.get(self.BASE_URL, params=params)
|
| 57 |
+
response.raise_for_status()
|
| 58 |
+
|
| 59 |
+
data = response.json()
|
| 60 |
+
results = data.get("resultList", {}).get("result", [])
|
| 61 |
+
|
| 62 |
+
return [self._to_evidence(r) for r in results[:max_results]]
|
| 63 |
+
|
| 64 |
+
except httpx.HTTPStatusError as e:
|
| 65 |
+
raise SearchError(f"Europe PMC API error: {e}") from e
|
| 66 |
+
except httpx.RequestError as e:
|
| 67 |
+
raise SearchError(f"Europe PMC connection failed: {e}") from e
|
| 68 |
+
|
| 69 |
+
def _to_evidence(self, result: dict[str, Any]) -> Evidence:
|
| 70 |
+
"""Convert Europe PMC result to Evidence."""
|
| 71 |
+
title = result.get("title", "Untitled")
|
| 72 |
+
abstract = result.get("abstractText", "No abstract available.")
|
| 73 |
+
doi = result.get("doi", "")
|
| 74 |
+
pub_year = result.get("pubYear", "Unknown")
|
| 75 |
+
|
| 76 |
+
# Get authors
|
| 77 |
+
author_list = result.get("authorList", {}).get("author", [])
|
| 78 |
+
authors = [a.get("fullName", "") for a in author_list[:5] if a.get("fullName")]
|
| 79 |
+
|
| 80 |
+
# Check if preprint
|
| 81 |
+
pub_types = result.get("pubTypeList", {}).get("pubType", [])
|
| 82 |
+
is_preprint = "Preprint" in pub_types
|
| 83 |
+
source_db = result.get("source", "europepmc")
|
| 84 |
+
|
| 85 |
+
# Build content
|
| 86 |
+
preprint_marker = "[PREPRINT - Not peer-reviewed] " if is_preprint else ""
|
| 87 |
+
content = f"{preprint_marker}{abstract[:1800]}"
|
| 88 |
+
|
| 89 |
+
# Build URL
|
| 90 |
+
if doi:
|
| 91 |
+
url = f"https://doi.org/{doi}"
|
| 92 |
+
elif result.get("pmid"):
|
| 93 |
+
url = f"https://pubmed.ncbi.nlm.nih.gov/{result['pmid']}/"
|
| 94 |
+
else:
|
| 95 |
+
url = f"https://europepmc.org/article/{source_db}/{result.get('id', '')}"
|
| 96 |
+
|
| 97 |
+
return Evidence(
|
| 98 |
+
content=content[:2000],
|
| 99 |
+
citation=Citation(
|
| 100 |
+
source="preprint" if is_preprint else "europepmc",
|
| 101 |
+
title=title[:500],
|
| 102 |
+
url=url,
|
| 103 |
+
date=str(pub_year),
|
| 104 |
+
authors=authors,
|
| 105 |
+
),
|
| 106 |
+
relevance=0.75 if is_preprint else 0.9,
|
| 107 |
+
)
|
src/utils/models.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import Any, ClassVar, Literal
|
|
| 6 |
from pydantic import BaseModel, Field
|
| 7 |
|
| 8 |
# Centralized source type - add new sources here (e.g., "biorxiv" in Phase 11)
|
| 9 |
-
SourceName = Literal["pubmed", "clinicaltrials", "biorxiv"]
|
| 10 |
|
| 11 |
|
| 12 |
class Citation(BaseModel):
|
|
|
|
| 6 |
from pydantic import BaseModel, Field
|
| 7 |
|
| 8 |
# Centralized source type - add new sources here (e.g., "biorxiv" in Phase 11)
|
| 9 |
+
SourceName = Literal["pubmed", "clinicaltrials", "biorxiv", "europepmc", "preprint"]
|
| 10 |
|
| 11 |
|
| 12 |
class Citation(BaseModel):
|
tests/unit/test_app_smoke.py
CHANGED
|
@@ -37,14 +37,14 @@ class TestAppSmoke:
|
|
| 37 |
from src.mcp_tools import (
|
| 38 |
analyze_hypothesis,
|
| 39 |
search_all_sources,
|
| 40 |
-
search_biorxiv,
|
| 41 |
search_clinical_trials,
|
|
|
|
| 42 |
search_pubmed,
|
| 43 |
)
|
| 44 |
|
| 45 |
# Just verify they're callable
|
| 46 |
assert callable(search_pubmed)
|
| 47 |
assert callable(search_clinical_trials)
|
| 48 |
-
assert callable(
|
| 49 |
assert callable(search_all_sources)
|
| 50 |
assert callable(analyze_hypothesis)
|
|
|
|
| 37 |
from src.mcp_tools import (
|
| 38 |
analyze_hypothesis,
|
| 39 |
search_all_sources,
|
|
|
|
| 40 |
search_clinical_trials,
|
| 41 |
+
search_europepmc,
|
| 42 |
search_pubmed,
|
| 43 |
)
|
| 44 |
|
| 45 |
# Just verify they're callable
|
| 46 |
assert callable(search_pubmed)
|
| 47 |
assert callable(search_clinical_trials)
|
| 48 |
+
assert callable(search_europepmc)
|
| 49 |
assert callable(search_all_sources)
|
| 50 |
assert callable(analyze_hypothesis)
|
tests/unit/test_mcp_tools.py
CHANGED
|
@@ -6,8 +6,8 @@ import pytest
|
|
| 6 |
|
| 7 |
from src.mcp_tools import (
|
| 8 |
search_all_sources,
|
| 9 |
-
search_biorxiv,
|
| 10 |
search_clinical_trials,
|
|
|
|
| 11 |
search_pubmed,
|
| 12 |
)
|
| 13 |
from src.utils.models import Citation, Evidence
|
|
@@ -87,21 +87,21 @@ class TestSearchClinicalTrials:
|
|
| 87 |
assert "Clinical Trials" in result
|
| 88 |
|
| 89 |
|
| 90 |
-
class
|
| 91 |
-
"""Tests for
|
| 92 |
|
| 93 |
@pytest.mark.asyncio
|
| 94 |
async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None:
|
| 95 |
"""Should return formatted markdown string."""
|
| 96 |
-
mock_evidence.citation.source = "
|
| 97 |
|
| 98 |
-
with patch("src.mcp_tools.
|
| 99 |
mock_tool.search = AsyncMock(return_value=[mock_evidence])
|
| 100 |
|
| 101 |
-
result = await
|
| 102 |
|
| 103 |
assert isinstance(result, str)
|
| 104 |
-
assert "
|
| 105 |
|
| 106 |
|
| 107 |
class TestSearchAllSources:
|
|
@@ -113,18 +113,18 @@ class TestSearchAllSources:
|
|
| 113 |
with (
|
| 114 |
patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed,
|
| 115 |
patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials,
|
| 116 |
-
patch("src.mcp_tools.
|
| 117 |
):
|
| 118 |
mock_pubmed.return_value = "## PubMed Results"
|
| 119 |
mock_trials.return_value = "## Clinical Trials"
|
| 120 |
-
|
| 121 |
|
| 122 |
result = await search_all_sources("metformin", 5)
|
| 123 |
|
| 124 |
assert "Comprehensive Search" in result
|
| 125 |
assert "PubMed" in result
|
| 126 |
assert "Clinical Trials" in result
|
| 127 |
-
assert "
|
| 128 |
|
| 129 |
@pytest.mark.asyncio
|
| 130 |
async def test_handles_partial_failures(self) -> None:
|
|
@@ -132,17 +132,17 @@ class TestSearchAllSources:
|
|
| 132 |
with (
|
| 133 |
patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed,
|
| 134 |
patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials,
|
| 135 |
-
patch("src.mcp_tools.
|
| 136 |
):
|
| 137 |
mock_pubmed.return_value = "## PubMed Results"
|
| 138 |
mock_trials.side_effect = Exception("API Error")
|
| 139 |
-
|
| 140 |
|
| 141 |
result = await search_all_sources("metformin", 5)
|
| 142 |
|
| 143 |
# Should still contain working sources
|
| 144 |
assert "PubMed" in result
|
| 145 |
-
assert "
|
| 146 |
# Should show error for failed source
|
| 147 |
assert "Error" in result
|
| 148 |
|
|
@@ -163,10 +163,10 @@ class TestMCPDocstrings:
|
|
| 163 |
assert search_clinical_trials.__doc__ is not None
|
| 164 |
assert "Args:" in search_clinical_trials.__doc__
|
| 165 |
|
| 166 |
-
def
|
| 167 |
"""Docstring must have Args section for MCP schema generation."""
|
| 168 |
-
assert
|
| 169 |
-
assert "Args:" in
|
| 170 |
|
| 171 |
def test_search_all_sources_has_args_section(self) -> None:
|
| 172 |
"""Docstring must have Args section for MCP schema generation."""
|
|
|
|
| 6 |
|
| 7 |
from src.mcp_tools import (
|
| 8 |
search_all_sources,
|
|
|
|
| 9 |
search_clinical_trials,
|
| 10 |
+
search_europepmc,
|
| 11 |
search_pubmed,
|
| 12 |
)
|
| 13 |
from src.utils.models import Citation, Evidence
|
|
|
|
| 87 |
assert "Clinical Trials" in result
|
| 88 |
|
| 89 |
|
| 90 |
+
class TestSearchEuropePMC:
|
| 91 |
+
"""Tests for search_europepmc MCP tool."""
|
| 92 |
|
| 93 |
@pytest.mark.asyncio
|
| 94 |
async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None:
|
| 95 |
"""Should return formatted markdown string."""
|
| 96 |
+
mock_evidence.citation.source = "europepmc" # type: ignore
|
| 97 |
|
| 98 |
+
with patch("src.mcp_tools._europepmc") as mock_tool:
|
| 99 |
mock_tool.search = AsyncMock(return_value=[mock_evidence])
|
| 100 |
|
| 101 |
+
result = await search_europepmc("preprint search", 10)
|
| 102 |
|
| 103 |
assert isinstance(result, str)
|
| 104 |
+
assert "Europe PMC Results" in result
|
| 105 |
|
| 106 |
|
| 107 |
class TestSearchAllSources:
|
|
|
|
| 113 |
with (
|
| 114 |
patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed,
|
| 115 |
patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials,
|
| 116 |
+
patch("src.mcp_tools.search_europepmc", new_callable=AsyncMock) as mock_europepmc,
|
| 117 |
):
|
| 118 |
mock_pubmed.return_value = "## PubMed Results"
|
| 119 |
mock_trials.return_value = "## Clinical Trials"
|
| 120 |
+
mock_europepmc.return_value = "## Europe PMC Results"
|
| 121 |
|
| 122 |
result = await search_all_sources("metformin", 5)
|
| 123 |
|
| 124 |
assert "Comprehensive Search" in result
|
| 125 |
assert "PubMed" in result
|
| 126 |
assert "Clinical Trials" in result
|
| 127 |
+
assert "Europe PMC" in result
|
| 128 |
|
| 129 |
@pytest.mark.asyncio
|
| 130 |
async def test_handles_partial_failures(self) -> None:
|
|
|
|
| 132 |
with (
|
| 133 |
patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed,
|
| 134 |
patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials,
|
| 135 |
+
patch("src.mcp_tools.search_europepmc", new_callable=AsyncMock) as mock_europepmc,
|
| 136 |
):
|
| 137 |
mock_pubmed.return_value = "## PubMed Results"
|
| 138 |
mock_trials.side_effect = Exception("API Error")
|
| 139 |
+
mock_europepmc.return_value = "## Europe PMC Results"
|
| 140 |
|
| 141 |
result = await search_all_sources("metformin", 5)
|
| 142 |
|
| 143 |
# Should still contain working sources
|
| 144 |
assert "PubMed" in result
|
| 145 |
+
assert "Europe PMC" in result
|
| 146 |
# Should show error for failed source
|
| 147 |
assert "Error" in result
|
| 148 |
|
|
|
|
| 163 |
assert search_clinical_trials.__doc__ is not None
|
| 164 |
assert "Args:" in search_clinical_trials.__doc__
|
| 165 |
|
| 166 |
+
def test_search_europepmc_has_args_section(self) -> None:
|
| 167 |
"""Docstring must have Args section for MCP schema generation."""
|
| 168 |
+
assert search_europepmc.__doc__ is not None
|
| 169 |
+
assert "Args:" in search_europepmc.__doc__
|
| 170 |
|
| 171 |
def test_search_all_sources_has_args_section(self) -> None:
|
| 172 |
"""Docstring must have Args section for MCP schema generation."""
|
tests/unit/tools/test_biorxiv.py
DELETED
|
@@ -1,178 +0,0 @@
|
|
| 1 |
-
"""Unit tests for bioRxiv tool."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
import respx
|
| 5 |
-
from httpx import Response
|
| 6 |
-
|
| 7 |
-
from src.tools.biorxiv import BioRxivTool
|
| 8 |
-
from src.utils.models import Evidence
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
@pytest.fixture
|
| 12 |
-
def mock_biorxiv_response():
|
| 13 |
-
"""Mock bioRxiv API response."""
|
| 14 |
-
return {
|
| 15 |
-
"collection": [
|
| 16 |
-
{
|
| 17 |
-
"doi": "10.1101/2024.01.15.24301234",
|
| 18 |
-
"title": "Metformin repurposing for Alzheimer's disease: a systematic review",
|
| 19 |
-
"authors": "Smith, John; Jones, Alice; Brown, Bob",
|
| 20 |
-
"date": "2024-01-15",
|
| 21 |
-
"category": "neurology",
|
| 22 |
-
"abstract": "Background: Metformin has shown neuroprotective effects. "
|
| 23 |
-
"We conducted a systematic review of metformin's potential "
|
| 24 |
-
"for Alzheimer's disease treatment.",
|
| 25 |
-
},
|
| 26 |
-
{
|
| 27 |
-
"doi": "10.1101/2024.01.10.24301111",
|
| 28 |
-
"title": "COVID-19 vaccine efficacy study",
|
| 29 |
-
"authors": "Wilson, C",
|
| 30 |
-
"date": "2024-01-10",
|
| 31 |
-
"category": "infectious diseases",
|
| 32 |
-
"abstract": "This study evaluates COVID-19 vaccine efficacy.",
|
| 33 |
-
},
|
| 34 |
-
],
|
| 35 |
-
"messages": [{"status": "ok", "count": 2}],
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class TestBioRxivTool:
|
| 40 |
-
"""Tests for BioRxivTool."""
|
| 41 |
-
|
| 42 |
-
def test_tool_name(self):
|
| 43 |
-
"""Tool should have correct name."""
|
| 44 |
-
tool = BioRxivTool()
|
| 45 |
-
assert tool.name == "biorxiv"
|
| 46 |
-
|
| 47 |
-
def test_default_server_is_medrxiv(self):
|
| 48 |
-
"""Default server should be medRxiv for medical relevance."""
|
| 49 |
-
tool = BioRxivTool()
|
| 50 |
-
assert tool.server == "medrxiv"
|
| 51 |
-
|
| 52 |
-
@pytest.mark.asyncio
|
| 53 |
-
@respx.mock
|
| 54 |
-
async def test_search_returns_evidence(self, mock_biorxiv_response):
|
| 55 |
-
"""Search should return Evidence objects."""
|
| 56 |
-
respx.get(url__startswith="https://api.biorxiv.org/details").mock(
|
| 57 |
-
return_value=Response(200, json=mock_biorxiv_response)
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
tool = BioRxivTool()
|
| 61 |
-
results = await tool.search("metformin alzheimer", max_results=5)
|
| 62 |
-
|
| 63 |
-
assert len(results) == 1 # Only the matching paper
|
| 64 |
-
assert isinstance(results[0], Evidence)
|
| 65 |
-
assert results[0].citation.source == "biorxiv"
|
| 66 |
-
assert "metformin" in results[0].citation.title.lower()
|
| 67 |
-
|
| 68 |
-
@pytest.mark.asyncio
|
| 69 |
-
@respx.mock
|
| 70 |
-
async def test_search_filters_by_keywords(self, mock_biorxiv_response):
|
| 71 |
-
"""Search should filter papers by query keywords."""
|
| 72 |
-
respx.get(url__startswith="https://api.biorxiv.org/details").mock(
|
| 73 |
-
return_value=Response(200, json=mock_biorxiv_response)
|
| 74 |
-
)
|
| 75 |
-
|
| 76 |
-
tool = BioRxivTool()
|
| 77 |
-
|
| 78 |
-
# Search for metformin - should match first paper
|
| 79 |
-
results = await tool.search("metformin")
|
| 80 |
-
assert len(results) == 1
|
| 81 |
-
assert "metformin" in results[0].citation.title.lower()
|
| 82 |
-
|
| 83 |
-
# Search for COVID - should match second paper
|
| 84 |
-
results = await tool.search("covid vaccine")
|
| 85 |
-
assert len(results) == 1
|
| 86 |
-
assert "covid" in results[0].citation.title.lower()
|
| 87 |
-
|
| 88 |
-
@pytest.mark.asyncio
|
| 89 |
-
@respx.mock
|
| 90 |
-
async def test_search_marks_as_preprint(self, mock_biorxiv_response):
|
| 91 |
-
"""Evidence content should note it's a preprint."""
|
| 92 |
-
respx.get(url__startswith="https://api.biorxiv.org/details").mock(
|
| 93 |
-
return_value=Response(200, json=mock_biorxiv_response)
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
tool = BioRxivTool()
|
| 97 |
-
results = await tool.search("metformin")
|
| 98 |
-
|
| 99 |
-
assert "PREPRINT" in results[0].content
|
| 100 |
-
assert "Not peer-reviewed" in results[0].content
|
| 101 |
-
|
| 102 |
-
@pytest.mark.asyncio
|
| 103 |
-
@respx.mock
|
| 104 |
-
async def test_search_empty_results(self):
|
| 105 |
-
"""Search should handle empty results gracefully."""
|
| 106 |
-
respx.get(url__startswith="https://api.biorxiv.org/details").mock(
|
| 107 |
-
return_value=Response(200, json={"collection": [], "messages": []})
|
| 108 |
-
)
|
| 109 |
-
|
| 110 |
-
tool = BioRxivTool()
|
| 111 |
-
results = await tool.search("xyznonexistent")
|
| 112 |
-
|
| 113 |
-
assert results == []
|
| 114 |
-
|
| 115 |
-
@pytest.mark.asyncio
|
| 116 |
-
@respx.mock
|
| 117 |
-
async def test_search_api_error(self):
|
| 118 |
-
"""Search should raise SearchError on API failure."""
|
| 119 |
-
from src.utils.exceptions import SearchError
|
| 120 |
-
|
| 121 |
-
respx.get(url__startswith="https://api.biorxiv.org/details").mock(
|
| 122 |
-
return_value=Response(500, text="Internal Server Error")
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
tool = BioRxivTool()
|
| 126 |
-
|
| 127 |
-
with pytest.raises(SearchError):
|
| 128 |
-
await tool.search("metformin")
|
| 129 |
-
|
| 130 |
-
@pytest.mark.asyncio
|
| 131 |
-
@respx.mock
|
| 132 |
-
async def test_search_network_error(self):
|
| 133 |
-
"""Search should raise SearchError on network failure."""
|
| 134 |
-
import httpx
|
| 135 |
-
|
| 136 |
-
from src.utils.exceptions import SearchError
|
| 137 |
-
|
| 138 |
-
respx.get(url__startswith="https://api.biorxiv.org/details").mock(
|
| 139 |
-
side_effect=httpx.RequestError("Network connection failed")
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
tool = BioRxivTool()
|
| 143 |
-
|
| 144 |
-
with pytest.raises(SearchError) as exc_info:
|
| 145 |
-
await tool.search("metformin")
|
| 146 |
-
|
| 147 |
-
assert "connection failed" in str(exc_info.value)
|
| 148 |
-
|
| 149 |
-
def test_extract_terms(self):
|
| 150 |
-
"""Should extract meaningful search terms."""
|
| 151 |
-
tool = BioRxivTool()
|
| 152 |
-
|
| 153 |
-
terms = tool._extract_terms("metformin for Alzheimer's disease")
|
| 154 |
-
|
| 155 |
-
assert "metformin" in terms
|
| 156 |
-
assert "alzheimer" in terms
|
| 157 |
-
assert "disease" in terms
|
| 158 |
-
assert "for" not in terms # Stop word
|
| 159 |
-
assert "the" not in terms # Stop word
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
class TestBioRxivIntegration:
|
| 163 |
-
"""Integration tests (marked for separate run)."""
|
| 164 |
-
|
| 165 |
-
@pytest.mark.integration
|
| 166 |
-
@pytest.mark.asyncio
|
| 167 |
-
async def test_real_api_call(self):
|
| 168 |
-
"""Test actual API call (requires network)."""
|
| 169 |
-
tool = BioRxivTool(days=30) # Last 30 days
|
| 170 |
-
results = await tool.search("diabetes", max_results=3)
|
| 171 |
-
|
| 172 |
-
# May or may not find results depending on recent papers
|
| 173 |
-
# But we want to ensure the code runs without crashing
|
| 174 |
-
assert isinstance(results, list)
|
| 175 |
-
if results:
|
| 176 |
-
r = results[0]
|
| 177 |
-
assert isinstance(r, Evidence)
|
| 178 |
-
assert r.citation.source == "biorxiv"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/unit/tools/test_europepmc.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for Europe PMC tool."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src.tools.europepmc import EuropePMCTool
|
| 8 |
+
from src.utils.models import Evidence
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.mark.unit
|
| 12 |
+
class TestEuropePMCTool:
|
| 13 |
+
"""Tests for EuropePMCTool."""
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def tool(self):
|
| 17 |
+
return EuropePMCTool()
|
| 18 |
+
|
| 19 |
+
def test_tool_name(self, tool):
|
| 20 |
+
assert tool.name == "europepmc"
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_search_returns_evidence(self, tool):
|
| 24 |
+
"""Test that search returns Evidence objects."""
|
| 25 |
+
mock_response = {
|
| 26 |
+
"resultList": {
|
| 27 |
+
"result": [
|
| 28 |
+
{
|
| 29 |
+
"id": "12345",
|
| 30 |
+
"title": "Long COVID Treatment Study",
|
| 31 |
+
"abstractText": "This study examines treatments for Long COVID.",
|
| 32 |
+
"doi": "10.1234/test",
|
| 33 |
+
"pubYear": "2024",
|
| 34 |
+
"source": "MED",
|
| 35 |
+
"pubTypeList": {"pubType": ["research-article"]},
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 42 |
+
mock_instance = AsyncMock()
|
| 43 |
+
mock_client.return_value.__aenter__.return_value = mock_instance
|
| 44 |
+
|
| 45 |
+
# Create response mock
|
| 46 |
+
mock_resp = MagicMock()
|
| 47 |
+
mock_resp.json.return_value = mock_response
|
| 48 |
+
mock_resp.raise_for_status.return_value = None
|
| 49 |
+
|
| 50 |
+
mock_instance.get.return_value = mock_resp
|
| 51 |
+
|
| 52 |
+
results = await tool.search("long covid treatment", max_results=5)
|
| 53 |
+
|
| 54 |
+
assert len(results) == 1
|
| 55 |
+
assert isinstance(results[0], Evidence)
|
| 56 |
+
assert "Long COVID Treatment Study" in results[0].citation.title
|
| 57 |
+
|
| 58 |
+
@pytest.mark.asyncio
|
| 59 |
+
async def test_search_marks_preprints(self, tool):
|
| 60 |
+
"""Test that preprints are marked correctly."""
|
| 61 |
+
mock_response = {
|
| 62 |
+
"resultList": {
|
| 63 |
+
"result": [
|
| 64 |
+
{
|
| 65 |
+
"id": "PPR12345",
|
| 66 |
+
"title": "Preprint Study",
|
| 67 |
+
"abstractText": "Abstract text",
|
| 68 |
+
"doi": "10.1234/preprint",
|
| 69 |
+
"pubYear": "2024",
|
| 70 |
+
"source": "PPR",
|
| 71 |
+
"pubTypeList": {"pubType": ["Preprint"]},
|
| 72 |
+
}
|
| 73 |
+
]
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 78 |
+
mock_instance = AsyncMock()
|
| 79 |
+
mock_client.return_value.__aenter__.return_value = mock_instance
|
| 80 |
+
|
| 81 |
+
mock_resp = MagicMock()
|
| 82 |
+
mock_resp.json.return_value = mock_response
|
| 83 |
+
mock_resp.raise_for_status.return_value = None
|
| 84 |
+
mock_instance.get.return_value = mock_resp
|
| 85 |
+
|
| 86 |
+
results = await tool.search("test", max_results=5)
|
| 87 |
+
|
| 88 |
+
assert "PREPRINT" in results[0].content
|
| 89 |
+
assert results[0].citation.source == "preprint"
|
| 90 |
+
|
| 91 |
+
@pytest.mark.asyncio
|
| 92 |
+
async def test_search_empty_results(self, tool):
|
| 93 |
+
"""Test handling of empty results."""
|
| 94 |
+
mock_response = {"resultList": {"result": []}}
|
| 95 |
+
|
| 96 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 97 |
+
mock_instance = AsyncMock()
|
| 98 |
+
mock_client.return_value.__aenter__.return_value = mock_instance
|
| 99 |
+
|
| 100 |
+
mock_resp = MagicMock()
|
| 101 |
+
mock_resp.json.return_value = mock_response
|
| 102 |
+
mock_resp.raise_for_status.return_value = None
|
| 103 |
+
mock_instance.get.return_value = mock_resp
|
| 104 |
+
|
| 105 |
+
results = await tool.search("nonexistent query xyz", max_results=5)
|
| 106 |
+
|
| 107 |
+
assert results == []
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@pytest.mark.integration
|
| 111 |
+
class TestEuropePMCIntegration:
|
| 112 |
+
"""Integration tests with real API."""
|
| 113 |
+
|
| 114 |
+
@pytest.mark.asyncio
|
| 115 |
+
async def test_real_api_call(self):
|
| 116 |
+
"""Test actual API returns relevant results."""
|
| 117 |
+
tool = EuropePMCTool()
|
| 118 |
+
results = await tool.search("long covid treatment", max_results=3)
|
| 119 |
+
|
| 120 |
+
assert len(results) > 0
|
| 121 |
+
# At least one result should mention COVID
|
| 122 |
+
titles = " ".join([r.citation.title.lower() for r in results])
|
| 123 |
+
assert "covid" in titles or "sars" in titles
|