diff --git a/.env.example b/.env.example index 22cea76229bf2e2ca1447f4803536c1d3b029632..cfea522c8e49c8e8de6145965e6269cbd616b788 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,17 @@ LLM_PROVIDER=openai OPENAI_API_KEY=sk-your-key-here ANTHROPIC_API_KEY=sk-ant-your-key-here -# Model names (optional - sensible defaults) -OPENAI_MODEL=gpt-5.1 -ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 +# Model names (optional - sensible defaults set in config.py) +# ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 +# OPENAI_MODEL=gpt-5.1 + +# ============== EMBEDDINGS ============== + +# OpenAI Embedding Model (used if LLM_PROVIDER is openai and performing RAG/Embeddings) +OPENAI_EMBEDDING_MODEL=text-embedding-3-small + +# Local Embedding Model (used for local/offline embeddings) +LOCAL_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 # ============== HUGGINGFACE (FREE TIER) ============== @@ -20,7 +28,7 @@ ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 # WITH HF_TOKEN: Uses Llama 3.1 8B Instruct (requires accepting license) # # For HuggingFace Spaces deployment: -# Set this as a "Secret" in Space Settings → Variables and secrets +# Set this as a "Secret" in Space Settings -> Variables and secrets # Users/judges don't need their own token - the Space secret is used # HF_TOKEN=hf_your-token-here @@ -36,9 +44,5 @@ LOG_LEVEL=INFO # PubMed (optional - higher rate limits) NCBI_API_KEY=your-ncbi-key-here -# Modal Sandbox (optional - for secure code execution) -MODAL_TOKEN_ID=ak-your-modal-token-id-here -MODAL_TOKEN_SECRET=your-modal-token-secret-here - # Vector Database (optional - for LlamaIndex RAG) CHROMA_DB_PATH=./chroma_db diff --git a/.gitignore b/.gitignore index 597e7ba0796a133b36b07832e7b7098950146382..8b9c2be2dd32820057ce520015e4904a7648f6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,9 @@ logs/ .mypy_cache/ .coverage htmlcov/ + +# Database files +chroma_db/ +*.sqlite3 + # Trigger rebuild Wed Nov 26 17:51:41 EST 2025 diff --git a/docs/brainstorming/00_ROADMAP_SUMMARY.md b/docs/brainstorming/00_ROADMAP_SUMMARY.md new file mode 100644 index 0000000000000000000000000000000000000000..a67ae6741e446c774485534d2d6a2278d9b44686 --- /dev/null +++ b/docs/brainstorming/00_ROADMAP_SUMMARY.md @@ -0,0 +1,194 @@ +# DeepCritical Data Sources: Roadmap Summary + +**Created**: 2024-11-27 +**Purpose**: Future maintainability and hackathon continuation + +--- + +## Current State + +### Working Tools + +| Tool | Status | Data Quality | +|------|--------|--------------| +| PubMed | ✅ Works | Good (abstracts only) | +| ClinicalTrials.gov | ✅ Works | Good (filtered for interventional) | +| Europe PMC | ✅ Works | Good (includes preprints) | + +### Removed Tools + +| Tool | Status | Reason | +|------|--------|--------| +| bioRxiv | ❌ Removed | No search API - only date/DOI lookup | + +--- + +## Priority Improvements + +### P0: Critical (Do First) + +1. **Add Rate Limiting to PubMed** + - NCBI will block us without it + - Use `limits` library (see reference repo) + - 3/sec without key, 10/sec with key + +### P1: High Value, Medium Effort + +2. **Add OpenAlex as 4th Source** + - Citation network (huge for drug repurposing) + - Concept tagging (semantic discovery) + - Already implemented in reference repo + - Free, no API key + +3. **PubMed Full-Text via BioC** + - Get full paper text for PMC papers + - Already in reference repo + +### P2: Nice to Have + +4. **ClinicalTrials.gov Results** + - Get efficacy data from completed trials + - Requires more complex API calls + +5. **Europe PMC Annotations** + - Text-mined entities (genes, drugs, diseases) + - Automatic entity extraction + +--- + +## Effort Estimates + +| Improvement | Effort | Impact | Priority | +|-------------|--------|--------|----------| +| PubMed rate limiting | 1 hour | Stability | P0 | +| OpenAlex basic search | 2 hours | High | P1 | +| OpenAlex citations | 2 hours | Very High | P1 | +| PubMed full-text | 3 hours | Medium | P1 | +| CT.gov results | 4 hours | Medium | P2 | +| Europe PMC annotations | 3 hours | Medium | P2 | + +--- + +## Architecture Decision + +### Option A: Keep Current + Add OpenAlex + +``` + User Query + ↓ + ┌───────────────────┼───────────────────┐ + ↓ ↓ ↓ + PubMed ClinicalTrials Europe PMC + (abstracts) (trials only) (preprints) + ↓ ↓ ↓ + └───────────────────┼───────────────────┘ + ↓ + OpenAlex ← NEW + (citations, concepts) + ↓ + Orchestrator + ↓ + Report +``` + +**Pros**: Low risk, additive +**Cons**: More complexity, some overlap + +### Option B: OpenAlex as Primary + +``` + User Query + ↓ + ┌───────────────────┼───────────────────┐ + ↓ ↓ ↓ + OpenAlex ClinicalTrials Europe PMC + (primary (trials only) (full-text + search) fallback) + ↓ ↓ ↓ + └───────────────────┼───────────────────┘ + ↓ + Orchestrator + ↓ + Report +``` + +**Pros**: Simpler, citation network built-in +**Cons**: Lose some PubMed-specific features + +### Recommendation: Option A + +Keep current architecture working, add OpenAlex incrementally. + +--- + +## Quick Wins (Can Do Today) + +1. **Add `limits` to `pyproject.toml`** + ```toml + dependencies = [ + "limits>=3.0", + ] + ``` + +2. **Copy OpenAlex tool from reference repo** + - File: `reference_repos/DeepCritical/DeepResearch/src/tools/openalex_tools.py` + - Adapt to our `SearchTool` base class + +3. **Enable NCBI API Key** + - Add to `.env`: `NCBI_API_KEY=your_key` + - 10x rate limit improvement + +--- + +## External Resources Worth Exploring + +### Python Libraries + +| Library | For | Notes | +|---------|-----|-------| +| `limits` | Rate limiting | Used by reference repo | +| `pyalex` | OpenAlex wrapper | [GitHub](https://github.com/J535D165/pyalex) | +| `metapub` | PubMed | Full-featured | +| `sentence-transformers` | Semantic search | For embeddings | + +### APIs Not Yet Used + +| API | Provides | Effort | +|-----|----------|--------| +| RxNorm | Drug name normalization | Low | +| DrugBank | Drug targets/mechanisms | Medium (license) | +| UniProt | Protein data | Medium | +| ChEMBL | Bioactivity data | Medium | + +### RAG Tools (Future) + +| Tool | Purpose | +|------|---------| +| [PaperQA](https://github.com/Future-House/paper-qa) | RAG for scientific papers | +| [txtai](https://github.com/neuml/txtai) | Embeddings + search | +| [PubMedBERT](https://huggingface.co/NeuML/pubmedbert-base-embeddings) | Biomedical embeddings | + +--- + +## Files in This Directory + +| File | Contents | +|------|----------| +| `00_ROADMAP_SUMMARY.md` | This file | +| `01_PUBMED_IMPROVEMENTS.md` | PubMed enhancement details | +| `02_CLINICALTRIALS_IMPROVEMENTS.md` | ClinicalTrials.gov details | +| `03_EUROPEPMC_IMPROVEMENTS.md` | Europe PMC details | +| `04_OPENALEX_INTEGRATION.md` | OpenAlex integration plan | + +--- + +## For Future Maintainers + +If you're picking this up after the hackathon: + +1. **Start with OpenAlex** - biggest bang for buck +2. **Add rate limiting** - prevents API blocks +3. **Don't bother with bioRxiv** - use Europe PMC instead +4. **Reference repo is gold** - `reference_repos/DeepCritical/` has working implementations + +Good luck! 🚀 diff --git a/docs/brainstorming/01_PUBMED_IMPROVEMENTS.md b/docs/brainstorming/01_PUBMED_IMPROVEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..6142e17b227eccca82eba26235de9d1e1f4f03b6 --- /dev/null +++ b/docs/brainstorming/01_PUBMED_IMPROVEMENTS.md @@ -0,0 +1,125 @@ +# PubMed Tool: Current State & Future Improvements + +**Status**: Currently Implemented +**Priority**: High (Core Data Source) + +--- + +## Current Implementation + +### What We Have (`src/tools/pubmed.py`) + +- Basic E-utilities search via `esearch.fcgi` and `efetch.fcgi` +- Query preprocessing (strips question words, expands synonyms) +- Returns: title, abstract, authors, journal, PMID +- Rate limiting: None implemented (relying on NCBI defaults) + +### Current Limitations + +1. **No Full-Text Access**: Only retrieves abstracts, not full paper text +2. **No Rate Limiting**: Risk of being blocked by NCBI +3. **No BioC Format**: Missing structured full-text extraction +4. **No Figure Retrieval**: No supplementary materials access +5. **No PMC Integration**: Missing open-access full-text via PMC + +--- + +## Reference Implementation (DeepCritical Reference Repo) + +The reference repo at `reference_repos/DeepCritical/DeepResearch/src/tools/bioinformatics_tools.py` has a more sophisticated implementation: + +### Features We're Missing + +```python +# Rate limiting (lines 47-50) +from limits import parse +from limits.storage import MemoryStorage +from limits.strategies import MovingWindowRateLimiter + +storage = MemoryStorage() +limiter = MovingWindowRateLimiter(storage) +rate_limit = parse("3/second") # NCBI allows 3/sec without API key, 10/sec with + +# Full-text via BioC format (lines 108-120) +def _get_fulltext(pmid: int) -> dict[str, Any] | None: + pmid_url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + # Returns structured JSON with full text for open-access papers + +# Figure retrieval via Europe PMC (lines 123-149) +def _get_figures(pmcid: str) -> dict[str, str]: + suppl_url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/supplementaryFiles" + # Returns base64-encoded images from supplementary materials +``` + +--- + +## Recommended Improvements + +### Phase 1: Rate Limiting (Critical) + +```python +# Add to src/tools/pubmed.py +from limits import parse +from limits.storage import MemoryStorage +from limits.strategies import MovingWindowRateLimiter + +storage = MemoryStorage() +limiter = MovingWindowRateLimiter(storage) + +# With NCBI_API_KEY: 10/sec, without: 3/sec +def get_rate_limit(): + if settings.ncbi_api_key: + return parse("10/second") + return parse("3/second") +``` + +**Dependencies**: `pip install limits` + +### Phase 2: Full-Text Retrieval + +```python +async def get_fulltext(pmid: str) -> str | None: + """Get full text for open-access papers via BioC API.""" + url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + # Only works for PMC papers (open access) +``` + +### Phase 3: PMC ID Resolution + +```python +async def get_pmc_id(pmid: str) -> str | None: + """Convert PMID to PMCID for full-text access.""" + url = f"https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/?ids={pmid}&format=json" +``` + +--- + +## Python Libraries to Consider + +| Library | Purpose | Notes | +|---------|---------|-------| +| [Biopython](https://biopython.org/) | `Bio.Entrez` module | Official, well-maintained | +| [PyMed](https://pypi.org/project/pymed/) | PubMed wrapper | Simpler API, less control | +| [metapub](https://pypi.org/project/metapub/) | Full-featured | Tested on 1/3 of PubMed | +| [limits](https://pypi.org/project/limits/) | Rate limiting | Used by reference repo | + +--- + +## API Endpoints Reference + +| Endpoint | Purpose | Rate Limit | +|----------|---------|------------| +| `esearch.fcgi` | Search for PMIDs | 3/sec (10 with key) | +| `efetch.fcgi` | Fetch metadata | 3/sec (10 with key) | +| `esummary.fcgi` | Quick metadata | 3/sec (10 with key) | +| `pmcoa.cgi/BioC_json` | Full text (PMC only) | Unknown | +| `idconv/v1.0` | PMID ↔ PMCID | Unknown | + +--- + +## Sources + +- [PubMed E-utilities Documentation](https://www.ncbi.nlm.nih.gov/books/NBK25501/) +- [NCBI BioC API](https://www.ncbi.nlm.nih.gov/research/bionlp/APIs/) +- [Searching PubMed with Python](https://marcobonzanini.com/2015/01/12/searching-pubmed-with-python/) +- [PyMed on PyPI](https://pypi.org/project/pymed/) diff --git a/docs/brainstorming/02_CLINICALTRIALS_IMPROVEMENTS.md b/docs/brainstorming/02_CLINICALTRIALS_IMPROVEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..5bf5722bdd16dadc80dd5b984de1185163cdc1f2 --- /dev/null +++ b/docs/brainstorming/02_CLINICALTRIALS_IMPROVEMENTS.md @@ -0,0 +1,193 @@ +# ClinicalTrials.gov Tool: Current State & Future Improvements + +**Status**: Currently Implemented +**Priority**: High (Core Data Source for Drug Repurposing) + +--- + +## Current Implementation + +### What We Have (`src/tools/clinicaltrials.py`) + +- V2 API search via `clinicaltrials.gov/api/v2/studies` +- Filters: `INTERVENTIONAL` study type, `RECRUITING` status +- Returns: NCT ID, title, conditions, interventions, phase, status +- Query preprocessing via shared `query_utils.py` + +### Current Strengths + +1. **Good Filtering**: Already filtering for interventional + recruiting +2. **V2 API**: Using the modern API (v1 deprecated) +3. **Phase Info**: Extracting trial phases for drug development context + +### Current Limitations + +1. **No Outcome Data**: Missing primary/secondary outcomes +2. **No Eligibility Criteria**: Missing inclusion/exclusion details +3. **No Sponsor Info**: Missing who's running the trial +4. **No Result Data**: For completed trials, no efficacy data +5. **Limited Drug Mapping**: No integration with drug databases + +--- + +## API Capabilities We're Not Using + +### Fields We Could Request + +```python +# Current fields +fields = ["NCTId", "BriefTitle", "Condition", "InterventionName", "Phase", "OverallStatus"] + +# Additional valuable fields +additional_fields = [ + "PrimaryOutcomeMeasure", # What are they measuring? + "SecondaryOutcomeMeasure", # Secondary endpoints + "EligibilityCriteria", # Who can participate? + "LeadSponsorName", # Who's funding? + "ResultsFirstPostDate", # Has results? + "StudyFirstPostDate", # When started? + "CompletionDate", # When finished? + "EnrollmentCount", # Sample size + "InterventionDescription", # Drug details + "ArmGroupLabel", # Treatment arms + "InterventionOtherName", # Drug aliases +] +``` + +### Filter Enhancements + +```python +# Current +aggFilters = "studyType:INTERVENTIONAL,status:RECRUITING" + +# Could add +"status:RECRUITING,ACTIVE_NOT_RECRUITING,COMPLETED" # Include completed for results +"phase:PHASE2,PHASE3" # Only later-stage trials +"resultsFirstPostDateRange:2020-01-01_" # Trials with posted results +``` + +--- + +## Recommended Improvements + +### Phase 1: Richer Metadata + +```python +EXTENDED_FIELDS = [ + "NCTId", + "BriefTitle", + "OfficialTitle", + "Condition", + "InterventionName", + "InterventionDescription", + "InterventionOtherName", # Drug synonyms! + "Phase", + "OverallStatus", + "PrimaryOutcomeMeasure", + "EnrollmentCount", + "LeadSponsorName", + "StudyFirstPostDate", +] +``` + +### Phase 2: Results Retrieval + +For completed trials, we can get actual efficacy data: + +```python +async def get_trial_results(nct_id: str) -> dict | None: + """Fetch results for completed trials.""" + url = f"https://clinicaltrials.gov/api/v2/studies/{nct_id}" + params = { + "fields": "ResultsSection", + } + # Returns outcome measures and statistics +``` + +### Phase 3: Drug Name Normalization + +Map intervention names to standard identifiers: + +```python +# Problem: "Metformin", "Metformin HCl", "Glucophage" are the same drug +# Solution: Use RxNorm or DrugBank for normalization + +async def normalize_drug_name(intervention: str) -> str: + """Normalize drug name via RxNorm API.""" + url = f"https://rxnav.nlm.nih.gov/REST/rxcui.json?name={intervention}" + # Returns standardized RxCUI +``` + +--- + +## Integration Opportunities + +### With PubMed + +Cross-reference trials with publications: +```python +# ClinicalTrials.gov provides PMID links +# Can correlate trial results with published papers +``` + +### With DrugBank/ChEMBL + +Map interventions to: +- Mechanism of action +- Known targets +- Adverse effects +- Drug-drug interactions + +--- + +## Python Libraries to Consider + +| Library | Purpose | Notes | +|---------|---------|-------| +| [pytrials](https://pypi.org/project/pytrials/) | CT.gov wrapper | V2 API support unclear | +| [clinicaltrials](https://github.com/ebmdatalab/clinicaltrials-act-tracker) | Data tracking | More for analysis | +| [drugbank-downloader](https://pypi.org/project/drugbank-downloader/) | Drug mapping | Requires license | + +--- + +## API Quirks & Gotchas + +1. **Rate Limiting**: Undocumented, be conservative +2. **Pagination**: Max 1000 results per request +3. **Field Names**: Case-sensitive, camelCase +4. **Empty Results**: Some fields may be null even if requested +5. **Status Changes**: Trials change status frequently + +--- + +## Example Enhanced Query + +```python +async def search_drug_repurposing_trials( + drug_name: str, + condition: str, + include_completed: bool = True, +) -> list[Evidence]: + """Search for trials repurposing a drug for a new condition.""" + + statuses = ["RECRUITING", "ACTIVE_NOT_RECRUITING"] + if include_completed: + statuses.append("COMPLETED") + + params = { + "query.intr": drug_name, + "query.cond": condition, + "filter.overallStatus": ",".join(statuses), + "filter.studyType": "INTERVENTIONAL", + "fields": ",".join(EXTENDED_FIELDS), + "pageSize": 50, + } +``` + +--- + +## Sources + +- [ClinicalTrials.gov API Documentation](https://clinicaltrials.gov/data-api/api) +- [CT.gov Field Definitions](https://clinicaltrials.gov/data-api/about-api/study-data-structure) +- [RxNorm API](https://lhncbc.nlm.nih.gov/RxNav/APIs/api-RxNorm.findRxcuiByString.html) diff --git a/docs/brainstorming/03_EUROPEPMC_IMPROVEMENTS.md b/docs/brainstorming/03_EUROPEPMC_IMPROVEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..dfec6cb16ac9d0539b43153e8c12fab206bb3009 --- /dev/null +++ b/docs/brainstorming/03_EUROPEPMC_IMPROVEMENTS.md @@ -0,0 +1,211 @@ +# Europe PMC Tool: Current State & Future Improvements + +**Status**: Currently Implemented (Replaced bioRxiv) +**Priority**: High (Preprint + Open Access Source) + +--- + +## Why Europe PMC Over bioRxiv? + +### bioRxiv API Limitations (Why We Abandoned It) + +1. **No Search API**: Only returns papers by date range or DOI +2. **No Query Capability**: Cannot search for "metformin cancer" +3. **Workaround Required**: Would need to download ALL preprints and build local search +4. **Known Issue**: [Gradio Issue #8861](https://github.com/gradio-app/gradio/issues/8861) documents the limitation + +### Europe PMC Advantages + +1. **Full Search API**: Boolean queries, filters, facets +2. **Aggregates bioRxiv**: Includes bioRxiv, medRxiv content anyway +3. **Includes PubMed**: Also has MEDLINE content +4. **34 Preprint Servers**: Not just bioRxiv +5. **Open Access Focus**: Full-text when available + +--- + +## Current Implementation + +### What We Have (`src/tools/europepmc.py`) + +- REST API search via `europepmc.org/webservices/rest/search` +- Preprint flagging via `firstPublicationDate` heuristics +- Returns: title, abstract, authors, DOI, source +- Marks preprints for transparency + +### Current Limitations + +1. **No Full-Text Retrieval**: Only metadata/abstracts +2. **No Citation Network**: Missing references/citations +3. **No Supplementary Files**: Not fetching figures/data +4. **Basic Preprint Detection**: Heuristic, not explicit flag + +--- + +## Europe PMC API Capabilities + +### Endpoints We Could Use + +| Endpoint | Purpose | Currently Using | +|----------|---------|-----------------| +| `/search` | Query papers | Yes | +| `/fulltext/{ID}` | Full text (XML/JSON) | No | +| `/{PMCID}/supplementaryFiles` | Figures, data | No | +| `/citations/{ID}` | Who cited this | No | +| `/references/{ID}` | What this cites | No | +| `/annotations` | Text-mined entities | No | + +### Rich Query Syntax + +```python +# Current simple query +query = "metformin cancer" + +# Could use advanced syntax +query = "(TITLE:metformin OR ABSTRACT:metformin) AND (cancer OR oncology)" +query += " AND (SRC:PPR)" # Only preprints +query += " AND (FIRST_PDATE:[2023-01-01 TO 2024-12-31])" # Date range +query += " AND (OPEN_ACCESS:y)" # Only open access +``` + +### Source Filters + +```python +# Filter by source +"SRC:MED" # MEDLINE +"SRC:PMC" # PubMed Central +"SRC:PPR" # Preprints (bioRxiv, medRxiv, etc.) +"SRC:AGR" # Agricola +"SRC:CBA" # Chinese Biological Abstracts +``` + +--- + +## Recommended Improvements + +### Phase 1: Rich Metadata + +```python +# Add to search results +additional_fields = [ + "citedByCount", # Impact indicator + "source", # Explicit source (MED, PMC, PPR) + "isOpenAccess", # Boolean flag + "fullTextUrlList", # URLs for full text + "authorAffiliations", # Institution info + "grantsList", # Funding info +] +``` + +### Phase 2: Full-Text Retrieval + +```python +async def get_fulltext(pmcid: str) -> str | None: + """Get full text for open access papers.""" + # XML format + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/fullTextXML" + # Or JSON + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/fullTextJSON" +``` + +### Phase 3: Citation Network + +```python +async def get_citations(pmcid: str) -> list[str]: + """Get papers that cite this one.""" + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/citations" + +async def get_references(pmcid: str) -> list[str]: + """Get papers this one cites.""" + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/references" +``` + +### Phase 4: Text-Mined Annotations + +Europe PMC extracts entities automatically: + +```python +async def get_annotations(pmcid: str) -> dict: + """Get text-mined entities (genes, diseases, drugs).""" + url = f"https://www.ebi.ac.uk/europepmc/annotations_api/annotationsByArticleIds" + params = { + "articleIds": f"PMC:{pmcid}", + "type": "Gene_Proteins,Diseases,Chemicals", + "format": "JSON", + } + # Returns structured entity mentions with positions +``` + +--- + +## Supplementary File Retrieval + +From reference repo (`bioinformatics_tools.py` lines 123-149): + +```python +def get_figures(pmcid: str) -> dict[str, str]: + """Download figures and supplementary files.""" + url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/supplementaryFiles?includeInlineImage=true" + # Returns ZIP with images, returns base64-encoded +``` + +--- + +## Preprint-Specific Features + +### Identify Preprint Servers + +```python +PREPRINT_SOURCES = { + "PPR": "General preprints", + "bioRxiv": "Biology preprints", + "medRxiv": "Medical preprints", + "chemRxiv": "Chemistry preprints", + "Research Square": "Multi-disciplinary", + "Preprints.org": "MDPI preprints", +} + +# Check if published version exists +async def check_published_version(preprint_doi: str) -> str | None: + """Check if preprint has been peer-reviewed and published.""" + # Europe PMC links preprints to final versions +``` + +--- + +## Rate Limiting + +Europe PMC is more generous than NCBI: + +```python +# No documented hard limit, but be respectful +# Recommend: 10-20 requests/second max +# Use email in User-Agent for polite pool +headers = { + "User-Agent": "DeepCritical/1.0 (mailto:your@email.com)" +} +``` + +--- + +## vs. The Lens & OpenAlex + +| Feature | Europe PMC | The Lens | OpenAlex | +|---------|------------|----------|----------| +| Biomedical Focus | Yes | Partial | Partial | +| Preprints | Yes (34 servers) | Yes | Yes | +| Full Text | PMC papers | Links | No | +| Citations | Yes | Yes | Yes | +| Annotations | Yes (text-mined) | No | No | +| Rate Limits | Generous | Moderate | Very generous | +| API Key | Optional | Required | Optional | + +--- + +## Sources + +- [Europe PMC REST API](https://europepmc.org/RestfulWebService) +- [Europe PMC Annotations API](https://europepmc.org/AnnotationsApi) +- [Europe PMC Articles API](https://europepmc.org/ArticlesApi) +- [rOpenSci medrxivr](https://docs.ropensci.org/medrxivr/) +- [bioRxiv TDM Resources](https://www.biorxiv.org/tdm) diff --git a/docs/brainstorming/04_OPENALEX_INTEGRATION.md b/docs/brainstorming/04_OPENALEX_INTEGRATION.md new file mode 100644 index 0000000000000000000000000000000000000000..3a191e4ed7945003128e15ef866ddfc9a2873568 --- /dev/null +++ b/docs/brainstorming/04_OPENALEX_INTEGRATION.md @@ -0,0 +1,303 @@ +# OpenAlex Integration: The Missing Piece? + +**Status**: NOT Implemented (Candidate for Addition) +**Priority**: HIGH - Could Replace Multiple Tools +**Reference**: Already implemented in `reference_repos/DeepCritical` + +--- + +## What is OpenAlex? + +OpenAlex is a **fully open** index of the global research system: + +- **209M+ works** (papers, books, datasets) +- **2B+ author records** (disambiguated) +- **124K+ venues** (journals, repositories) +- **109K+ institutions** +- **65K+ concepts** (hierarchical, linked to Wikidata) + +**Free. Open. No API key required.** + +--- + +## Why OpenAlex for DeepCritical? + +### Current Architecture + +``` +User Query + ↓ +┌──────────────────────────────────────┐ +│ PubMed ClinicalTrials Europe PMC │ ← 3 separate APIs +└──────────────────────────────────────┘ + ↓ +Orchestrator (deduplicate, judge, synthesize) +``` + +### With OpenAlex + +``` +User Query + ↓ +┌──────────────────────────────────────┐ +│ OpenAlex │ ← Single API +│ (includes PubMed + preprints + │ +│ citations + concepts + authors) │ +└──────────────────────────────────────┘ + ↓ +Orchestrator (enrich with CT.gov for trials) +``` + +**OpenAlex already aggregates**: +- PubMed/MEDLINE +- Crossref +- ORCID +- Unpaywall (open access links) +- Microsoft Academic Graph (legacy) +- Preprint servers + +--- + +## Reference Implementation + +From `reference_repos/DeepCritical/DeepResearch/src/tools/openalex_tools.py`: + +```python +class OpenAlexFetchTool(ToolRunner): + def __init__(self): + super().__init__( + ToolSpec( + name="openalex_fetch", + description="Fetch OpenAlex work or author", + inputs={"entity": "TEXT", "identifier": "TEXT"}, + outputs={"result": "JSON"}, + ) + ) + + def run(self, params: dict[str, Any]) -> ExecutionResult: + entity = params["entity"] # "works", "authors", "venues" + identifier = params["identifier"] + base = "https://api.openalex.org" + url = f"{base}/{entity}/{identifier}" + resp = requests.get(url, timeout=30) + return ExecutionResult(success=True, data={"result": resp.json()}) +``` + +--- + +## OpenAlex API Features + +### Search Works (Papers) + +```python +# Search for metformin + cancer papers +url = "https://api.openalex.org/works" +params = { + "search": "metformin cancer drug repurposing", + "filter": "publication_year:>2020,type:article", + "sort": "cited_by_count:desc", + "per_page": 50, +} +``` + +### Rich Filtering + +```python +# Filter examples +"publication_year:2023" +"type:article" # vs preprint, book, etc. +"is_oa:true" # Open access only +"concepts.id:C71924100" # Papers about "Medicine" +"authorships.institutions.id:I27837315" # From Harvard +"cited_by_count:>100" # Highly cited +"has_fulltext:true" # Full text available +``` + +### What You Get Back + +```json +{ + "id": "W2741809807", + "title": "Metformin: A candidate drug for...", + "publication_year": 2023, + "type": "article", + "cited_by_count": 45, + "is_oa": true, + "primary_location": { + "source": {"display_name": "Nature Medicine"}, + "pdf_url": "https://...", + "landing_page_url": "https://..." + }, + "concepts": [ + {"id": "C71924100", "display_name": "Medicine", "score": 0.95}, + {"id": "C54355233", "display_name": "Pharmacology", "score": 0.88} + ], + "authorships": [ + { + "author": {"id": "A123", "display_name": "John Smith"}, + "institutions": [{"display_name": "Harvard Medical School"}] + } + ], + "referenced_works": ["W123", "W456"], # Citations + "related_works": ["W789", "W012"] # Similar papers +} +``` + +--- + +## Key Advantages Over Current Tools + +### 1. Citation Network (We Don't Have This!) + +```python +# Get papers that cite a work +url = f"https://api.openalex.org/works?filter=cites:{work_id}" + +# Get papers cited by a work +# Already in `referenced_works` field +``` + +### 2. Concept Tagging (We Don't Have This!) + +OpenAlex auto-tags papers with hierarchical concepts: +- "Medicine" → "Pharmacology" → "Drug Repurposing" +- Can search by concept, not just keywords + +### 3. Author Disambiguation (We Don't Have This!) + +```python +# Find all works by an author +url = f"https://api.openalex.org/works?filter=authorships.author.id:{author_id}" +``` + +### 4. Institution Tracking + +```python +# Find drug repurposing papers from top institutions +url = "https://api.openalex.org/works" +params = { + "search": "drug repurposing", + "filter": "authorships.institutions.id:I27837315", # Harvard +} +``` + +### 5. Related Works + +Each paper comes with `related_works` - semantically similar papers discovered by OpenAlex's ML. + +--- + +## Proposed Implementation + +### New Tool: `src/tools/openalex.py` + +```python +"""OpenAlex search tool for comprehensive scholarly data.""" + +import httpx +from src.tools.base import SearchTool +from src.utils.models import Evidence + +class OpenAlexTool(SearchTool): + """Search OpenAlex for scholarly works with rich metadata.""" + + name = "openalex" + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://api.openalex.org/works", + params={ + "search": query, + "filter": "type:article,is_oa:true", + "sort": "cited_by_count:desc", + "per_page": max_results, + "mailto": "deepcritical@example.com", # Polite pool + }, + ) + data = resp.json() + + return [ + Evidence( + source="openalex", + title=work["title"], + abstract=work.get("abstract", ""), + url=work["primary_location"]["landing_page_url"], + metadata={ + "cited_by_count": work["cited_by_count"], + "concepts": [c["display_name"] for c in work["concepts"][:5]], + "is_open_access": work["is_oa"], + "pdf_url": work["primary_location"].get("pdf_url"), + }, + ) + for work in data["results"] + ] +``` + +--- + +## Rate Limits + +OpenAlex is **extremely generous**: + +- No hard rate limit documented +- Recommended: <100,000 requests/day +- **Polite pool**: Add `mailto=your@email.com` param for faster responses +- No API key required (optional for priority support) + +--- + +## Should We Add OpenAlex? + +### Arguments FOR + +1. **Already in reference repo** - proven pattern +2. **Richer data** - citations, concepts, authors +3. **Single source** - reduces API complexity +4. **Free & open** - no keys, no limits +5. **Institution adoption** - Leiden, Sorbonne switched to it + +### Arguments AGAINST + +1. **Adds complexity** - another data source +2. **Overlap** - duplicates some PubMed data +3. **Not biomedical-focused** - covers all disciplines +4. **No full text** - still need PMC/Europe PMC for that + +### Recommendation + +**Add OpenAlex as a 4th source**, don't replace existing tools. + +Use it for: +- Citation network analysis +- Concept-based discovery +- High-impact paper finding +- Author/institution tracking + +Keep PubMed, ClinicalTrials, Europe PMC for: +- Authoritative biomedical search +- Clinical trial data +- Full-text access +- Preprint tracking + +--- + +## Implementation Priority + +| Task | Effort | Value | +|------|--------|-------| +| Basic search | Low | High | +| Citation network | Medium | Very High | +| Concept filtering | Low | High | +| Related works | Low | High | +| Author tracking | Medium | Medium | + +--- + +## Sources + +- [OpenAlex Documentation](https://docs.openalex.org) +- [OpenAlex API Overview](https://docs.openalex.org/api) +- [OpenAlex Wikipedia](https://en.wikipedia.org/wiki/OpenAlex) +- [Leiden University Announcement](https://www.leidenranking.com/information/openalex) +- [OpenAlex: A fully-open index (Paper)](https://arxiv.org/abs/2205.01833) diff --git a/docs/brainstorming/implementation/15_PHASE_OPENALEX.md b/docs/brainstorming/implementation/15_PHASE_OPENALEX.md new file mode 100644 index 0000000000000000000000000000000000000000..9fb3afcc752cb37d22bd6c31a3412b4cb002df30 --- /dev/null +++ b/docs/brainstorming/implementation/15_PHASE_OPENALEX.md @@ -0,0 +1,603 @@ +# Phase 15: OpenAlex Integration + +**Priority**: HIGH - Biggest bang for buck +**Effort**: ~2-3 hours +**Dependencies**: None (existing codebase patterns sufficient) + +--- + +## Prerequisites (COMPLETED) + +The following model changes have been implemented to support this integration: + +1. **`SourceName` Literal Updated** (`src/utils/models.py:9`) + ```python + SourceName = Literal["pubmed", "clinicaltrials", "europepmc", "preprint", "openalex"] + ``` + - Without this, `source="openalex"` would fail Pydantic validation + +2. **`Evidence.metadata` Field Added** (`src/utils/models.py:39-42`) + ```python + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Additional metadata (e.g., cited_by_count, concepts, is_open_access)", + ) + ``` + - Required for storing `cited_by_count`, `concepts`, etc. + - Model is still frozen - metadata must be passed at construction time + +3. **`__init__.py` Exports Updated** (`src/tools/__init__.py`) + - All tools are now exported: `ClinicalTrialsTool`, `EuropePMCTool`, `PubMedTool` + - OpenAlexTool should be added here after implementation + +--- + +## Overview + +Add OpenAlex as a 4th data source for comprehensive scholarly data including: +- Citation networks (who cites whom) +- Concept tagging (hierarchical topic classification) +- Author disambiguation +- 209M+ works indexed + +**Why OpenAlex?** +- Free, no API key required +- Already implemented in reference repo +- Provides citation data we don't have +- Aggregates PubMed + preprints + more + +--- + +## TDD Implementation Plan + +### Step 1: Write the Tests First + +**File**: `tests/unit/tools/test_openalex.py` + +```python +"""Tests for OpenAlex search tool.""" + +import pytest +import respx +from httpx import Response + +from src.tools.openalex import OpenAlexTool +from src.utils.models import Evidence + + +class TestOpenAlexTool: + """Test suite for OpenAlex search functionality.""" + + @pytest.fixture + def tool(self) -> OpenAlexTool: + return OpenAlexTool() + + def test_name_property(self, tool: OpenAlexTool) -> None: + """Tool should identify itself as 'openalex'.""" + assert tool.name == "openalex" + + @respx.mock + @pytest.mark.asyncio + async def test_search_returns_evidence(self, tool: OpenAlexTool) -> None: + """Search should return list of Evidence objects.""" + mock_response = { + "results": [ + { + "id": "W2741809807", + "title": "Metformin and cancer: A systematic review", + "publication_year": 2023, + "cited_by_count": 45, + "type": "article", + "is_oa": True, + "primary_location": { + "source": {"display_name": "Nature Medicine"}, + "landing_page_url": "https://doi.org/10.1038/example", + "pdf_url": None, + }, + "abstract_inverted_index": { + "Metformin": [0], + "shows": [1], + "anticancer": [2], + "effects": [3], + }, + "concepts": [ + {"display_name": "Medicine", "score": 0.95}, + {"display_name": "Oncology", "score": 0.88}, + ], + "authorships": [ + { + "author": {"display_name": "John Smith"}, + "institutions": [{"display_name": "Harvard"}], + } + ], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("metformin cancer", max_results=10) + + assert len(results) == 1 + assert isinstance(results[0], Evidence) + assert "Metformin and cancer" in results[0].citation.title + assert results[0].citation.source == "openalex" + + @respx.mock + @pytest.mark.asyncio + async def test_search_empty_results(self, tool: OpenAlexTool) -> None: + """Search with no results should return empty list.""" + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json={"results": []}) + ) + + results = await tool.search("xyznonexistentquery123") + assert results == [] + + @respx.mock + @pytest.mark.asyncio + async def test_search_handles_missing_abstract(self, tool: OpenAlexTool) -> None: + """Tool should handle papers without abstracts.""" + mock_response = { + "results": [ + { + "id": "W123", + "title": "Paper without abstract", + "publication_year": 2023, + "cited_by_count": 10, + "type": "article", + "is_oa": False, + "primary_location": { + "source": {"display_name": "Journal"}, + "landing_page_url": "https://example.com", + }, + "abstract_inverted_index": None, + "concepts": [], + "authorships": [], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("test query") + assert len(results) == 1 + assert results[0].content == "" # No abstract + + @respx.mock + @pytest.mark.asyncio + async def test_search_extracts_citation_count(self, tool: OpenAlexTool) -> None: + """Citation count should be in metadata.""" + mock_response = { + "results": [ + { + "id": "W456", + "title": "Highly cited paper", + "publication_year": 2020, + "cited_by_count": 500, + "type": "article", + "is_oa": True, + "primary_location": { + "source": {"display_name": "Science"}, + "landing_page_url": "https://example.com", + }, + "abstract_inverted_index": {"Test": [0]}, + "concepts": [], + "authorships": [], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("highly cited") + assert results[0].metadata["cited_by_count"] == 500 + + @respx.mock + @pytest.mark.asyncio + async def test_search_extracts_concepts(self, tool: OpenAlexTool) -> None: + """Concepts should be extracted for semantic discovery.""" + mock_response = { + "results": [ + { + "id": "W789", + "title": "Drug repurposing study", + "publication_year": 2023, + "cited_by_count": 25, + "type": "article", + "is_oa": True, + "primary_location": { + "source": {"display_name": "PLOS ONE"}, + "landing_page_url": "https://example.com", + }, + "abstract_inverted_index": {"Drug": [0], "repurposing": [1]}, + "concepts": [ + {"display_name": "Pharmacology", "score": 0.92}, + {"display_name": "Drug Discovery", "score": 0.85}, + {"display_name": "Medicine", "score": 0.80}, + ], + "authorships": [], + } + ] + } + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(200, json=mock_response) + ) + + results = await tool.search("drug repurposing") + assert "Pharmacology" in results[0].metadata["concepts"] + assert "Drug Discovery" in results[0].metadata["concepts"] + + @respx.mock + @pytest.mark.asyncio + async def test_search_api_error_raises_search_error( + self, tool: OpenAlexTool + ) -> None: + """API errors should raise SearchError.""" + from src.utils.exceptions import SearchError + + respx.get("https://api.openalex.org/works").mock( + return_value=Response(500, text="Internal Server Error") + ) + + with pytest.raises(SearchError): + await tool.search("test query") + + def test_reconstruct_abstract(self, tool: OpenAlexTool) -> None: + """Test abstract reconstruction from inverted index.""" + inverted_index = { + "Metformin": [0, 5], + "is": [1], + "a": [2], + "diabetes": [3], + "drug": [4], + "effective": [6], + } + abstract = tool._reconstruct_abstract(inverted_index) + assert abstract == "Metformin is a diabetes drug Metformin effective" +``` + +--- + +### Step 2: Create the Implementation + +**File**: `src/tools/openalex.py` + +```python +"""OpenAlex search tool for comprehensive scholarly data.""" + +from typing import Any + +import httpx +from tenacity import retry, stop_after_attempt, wait_exponential + +from src.utils.exceptions import SearchError +from src.utils.models import Citation, Evidence + + +class OpenAlexTool: + """ + Search OpenAlex for scholarly works with rich metadata. + + OpenAlex provides: + - 209M+ scholarly works + - Citation counts and networks + - Concept tagging (hierarchical) + - Author disambiguation + - Open access links + + API Docs: https://docs.openalex.org/ + """ + + BASE_URL = "https://api.openalex.org/works" + + def __init__(self, email: str | None = None) -> None: + """ + Initialize OpenAlex tool. + + Args: + email: Optional email for polite pool (faster responses) + """ + self.email = email or "deepcritical@example.com" + + @property + def name(self) -> str: + return "openalex" + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + """ + Search OpenAlex for scholarly works. + + Args: + query: Search terms + max_results: Maximum results to return (max 200 per request) + + Returns: + List of Evidence objects with citation metadata + + Raises: + SearchError: If API request fails + """ + params = { + "search": query, + "filter": "type:article", # Only peer-reviewed articles + "sort": "cited_by_count:desc", # Most cited first + "per_page": min(max_results, 200), + "mailto": self.email, # Polite pool for faster responses + } + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.get(self.BASE_URL, params=params) + response.raise_for_status() + + data = response.json() + results = data.get("results", []) + + return [self._to_evidence(work) for work in results[:max_results]] + + except httpx.HTTPStatusError as e: + raise SearchError(f"OpenAlex API error: {e}") from e + except httpx.RequestError as e: + raise SearchError(f"OpenAlex connection failed: {e}") from e + + def _to_evidence(self, work: dict[str, Any]) -> Evidence: + """Convert OpenAlex work to Evidence object.""" + title = work.get("title", "Untitled") + pub_year = work.get("publication_year", "Unknown") + cited_by = work.get("cited_by_count", 0) + is_oa = work.get("is_oa", False) + + # Reconstruct abstract from inverted index + abstract_index = work.get("abstract_inverted_index") + abstract = self._reconstruct_abstract(abstract_index) if abstract_index else "" + + # Extract concepts (top 5) + concepts = [ + c.get("display_name", "") + for c in work.get("concepts", [])[:5] + if c.get("display_name") + ] + + # Extract authors (top 5) + authorships = work.get("authorships", []) + authors = [ + a.get("author", {}).get("display_name", "") + for a in authorships[:5] + if a.get("author", {}).get("display_name") + ] + + # Get URL + primary_loc = work.get("primary_location") or {} + url = primary_loc.get("landing_page_url", "") + if not url: + # Fallback to OpenAlex page + work_id = work.get("id", "").replace("https://openalex.org/", "") + url = f"https://openalex.org/{work_id}" + + return Evidence( + content=abstract[:2000], + citation=Citation( + source="openalex", + title=title[:500], + url=url, + date=str(pub_year), + authors=authors, + ), + relevance=min(0.9, 0.5 + (cited_by / 1000)), # Boost by citations + metadata={ + "cited_by_count": cited_by, + "is_open_access": is_oa, + "concepts": concepts, + "pdf_url": primary_loc.get("pdf_url"), + }, + ) + + def _reconstruct_abstract( + self, inverted_index: dict[str, list[int]] + ) -> str: + """ + Reconstruct abstract from OpenAlex inverted index format. + + OpenAlex stores abstracts as {"word": [position1, position2, ...]}. + This rebuilds the original text. + """ + if not inverted_index: + return "" + + # Build position -> word mapping + position_word: dict[int, str] = {} + for word, positions in inverted_index.items(): + for pos in positions: + position_word[pos] = word + + # Reconstruct in order + if not position_word: + return "" + + max_pos = max(position_word.keys()) + words = [position_word.get(i, "") for i in range(max_pos + 1)] + return " ".join(w for w in words if w) +``` + +--- + +### Step 3: Register in Search Handler + +**File**: `src/tools/search_handler.py` (add to imports and tool list) + +```python +# Add import +from src.tools.openalex import OpenAlexTool + +# Add to _create_tools method +def _create_tools(self) -> list[SearchTool]: + return [ + PubMedTool(), + ClinicalTrialsTool(), + EuropePMCTool(), + OpenAlexTool(), # NEW + ] +``` + +--- + +### Step 4: Update `__init__.py` + +**File**: `src/tools/__init__.py` + +```python +from src.tools.openalex import OpenAlexTool + +__all__ = [ + "PubMedTool", + "ClinicalTrialsTool", + "EuropePMCTool", + "OpenAlexTool", # NEW + # ... +] +``` + +--- + +## Demo Script + +**File**: `examples/openalex_demo.py` + +```python +#!/usr/bin/env python3 +"""Demo script to verify OpenAlex integration.""" + +import asyncio +from src.tools.openalex import OpenAlexTool + + +async def main(): + """Run OpenAlex search demo.""" + tool = OpenAlexTool() + + print("=" * 60) + print("OpenAlex Integration Demo") + print("=" * 60) + + # Test 1: Basic drug repurposing search + print("\n[Test 1] Searching for 'metformin cancer drug repurposing'...") + results = await tool.search("metformin cancer drug repurposing", max_results=5) + + for i, evidence in enumerate(results, 1): + print(f"\n--- Result {i} ---") + print(f"Title: {evidence.citation.title}") + print(f"Year: {evidence.citation.date}") + print(f"Citations: {evidence.metadata.get('cited_by_count', 'N/A')}") + print(f"Concepts: {', '.join(evidence.metadata.get('concepts', []))}") + print(f"Open Access: {evidence.metadata.get('is_open_access', False)}") + print(f"URL: {evidence.citation.url}") + if evidence.content: + print(f"Abstract: {evidence.content[:200]}...") + + # Test 2: High-impact papers + print("\n" + "=" * 60) + print("[Test 2] Finding highly-cited papers on 'long COVID treatment'...") + results = await tool.search("long COVID treatment", max_results=3) + + for evidence in results: + print(f"\n- {evidence.citation.title}") + print(f" Citations: {evidence.metadata.get('cited_by_count', 0)}") + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Verification Checklist + +### Unit Tests +```bash +# Run just OpenAlex tests +uv run pytest tests/unit/tools/test_openalex.py -v + +# Expected: All tests pass +``` + +### Integration Test (Manual) +```bash +# Run demo script with real API +uv run python examples/openalex_demo.py + +# Expected: Real results from OpenAlex API +``` + +### Full Test Suite +```bash +# Ensure nothing broke +make check + +# Expected: All 110+ tests pass, mypy clean +``` + +--- + +## Success Criteria + +1. **Unit tests pass**: All mocked tests in `test_openalex.py` pass +2. **Integration works**: Demo script returns real results +3. **No regressions**: `make check` passes completely +4. **SearchHandler integration**: OpenAlex appears in search results alongside other sources +5. **Citation metadata**: Results include `cited_by_count`, `concepts`, `is_open_access` + +--- + +## Future Enhancements (P2) + +Once basic integration works: + +1. **Citation Network Queries** + ```python + # Get papers citing a specific work + async def get_citing_works(self, work_id: str) -> list[Evidence]: + params = {"filter": f"cites:{work_id}"} + ... + ``` + +2. **Concept-Based Search** + ```python + # Search by OpenAlex concept ID + async def search_by_concept(self, concept_id: str) -> list[Evidence]: + params = {"filter": f"concepts.id:{concept_id}"} + ... + ``` + +3. **Author Tracking** + ```python + # Find all works by an author + async def search_by_author(self, author_id: str) -> list[Evidence]: + params = {"filter": f"authorships.author.id:{author_id}"} + ... + ``` + +--- + +## Notes + +- OpenAlex is **very generous** with rate limits (no documented hard limit) +- Adding `mailto` parameter gives priority access (polite pool) +- Abstract is stored as inverted index - must reconstruct +- Citation count is a good proxy for paper quality/impact +- Consider caching responses for repeated queries diff --git a/docs/brainstorming/implementation/16_PHASE_PUBMED_FULLTEXT.md b/docs/brainstorming/implementation/16_PHASE_PUBMED_FULLTEXT.md new file mode 100644 index 0000000000000000000000000000000000000000..3284012fc70577f0d2cff5666b897c1799942102 --- /dev/null +++ b/docs/brainstorming/implementation/16_PHASE_PUBMED_FULLTEXT.md @@ -0,0 +1,586 @@ +# Phase 16: PubMed Full-Text Retrieval + +**Priority**: MEDIUM - Enhances evidence quality +**Effort**: ~3 hours +**Dependencies**: None (existing PubMed tool sufficient) + +--- + +## Prerequisites (COMPLETED) + +The `Evidence.metadata` field has been added to `src/utils/models.py` to support: +```python +metadata={"has_fulltext": True} +``` + +--- + +## Architecture Decision: Constructor Parameter vs Method Parameter + +**IMPORTANT**: The original spec proposed `include_fulltext` as a method parameter: +```python +# WRONG - SearchHandler won't pass this parameter +async def search(self, query: str, max_results: int = 10, include_fulltext: bool = False): +``` + +**Problem**: `SearchHandler` calls `tool.search(query, max_results)` uniformly across all tools. +It has no mechanism to pass tool-specific parameters like `include_fulltext`. + +**Solution**: Use constructor parameter instead: +```python +# CORRECT - Configured at instantiation time +class PubMedTool: + def __init__(self, api_key: str | None = None, include_fulltext: bool = False): + self.include_fulltext = include_fulltext + ... +``` + +This way, you can create a full-text-enabled PubMed tool: +```python +# In orchestrator or wherever tools are created +tools = [ + PubMedTool(include_fulltext=True), # Full-text enabled + ClinicalTrialsTool(), + EuropePMCTool(), +] +``` + +--- + +## Overview + +Add full-text retrieval for PubMed papers via the BioC API, enabling: +- Complete paper text for open-access PMC papers +- Structured sections (intro, methods, results, discussion) +- Better evidence for LLM synthesis + +**Why Full-Text?** +- Abstracts only give ~200-300 words +- Full text provides detailed methods, results, figures +- Reference repo already has this implemented +- Makes LLM judgments more accurate + +--- + +## TDD Implementation Plan + +### Step 1: Write the Tests First + +**File**: `tests/unit/tools/test_pubmed_fulltext.py` + +```python +"""Tests for PubMed full-text retrieval.""" + +import pytest +import respx +from httpx import Response + +from src.tools.pubmed import PubMedTool + + +class TestPubMedFullText: + """Test suite for PubMed full-text functionality.""" + + @pytest.fixture + def tool(self) -> PubMedTool: + return PubMedTool() + + @respx.mock + @pytest.mark.asyncio + async def test_get_pmc_id_success(self, tool: PubMedTool) -> None: + """Should convert PMID to PMCID for full-text access.""" + mock_response = { + "records": [ + { + "pmid": "12345678", + "pmcid": "PMC1234567", + } + ] + } + + respx.get("https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/").mock( + return_value=Response(200, json=mock_response) + ) + + pmcid = await tool.get_pmc_id("12345678") + assert pmcid == "PMC1234567" + + @respx.mock + @pytest.mark.asyncio + async def test_get_pmc_id_not_in_pmc(self, tool: PubMedTool) -> None: + """Should return None if paper not in PMC.""" + mock_response = { + "records": [ + { + "pmid": "12345678", + # No pmcid means not in PMC + } + ] + } + + respx.get("https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/").mock( + return_value=Response(200, json=mock_response) + ) + + pmcid = await tool.get_pmc_id("12345678") + assert pmcid is None + + @respx.mock + @pytest.mark.asyncio + async def test_get_fulltext_success(self, tool: PubMedTool) -> None: + """Should retrieve full text for PMC papers.""" + # Mock BioC API response + mock_bioc = { + "documents": [ + { + "passages": [ + { + "infons": {"section_type": "INTRO"}, + "text": "Introduction text here.", + }, + { + "infons": {"section_type": "METHODS"}, + "text": "Methods description here.", + }, + { + "infons": {"section_type": "RESULTS"}, + "text": "Results summary here.", + }, + { + "infons": {"section_type": "DISCUSS"}, + "text": "Discussion and conclusions.", + }, + ] + } + ] + } + + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/12345678/unicode" + ).mock(return_value=Response(200, json=mock_bioc)) + + fulltext = await tool.get_fulltext("12345678") + + assert fulltext is not None + assert "Introduction text here" in fulltext + assert "Methods description here" in fulltext + assert "Results summary here" in fulltext + + @respx.mock + @pytest.mark.asyncio + async def test_get_fulltext_not_available(self, tool: PubMedTool) -> None: + """Should return None if full text not available.""" + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/99999999/unicode" + ).mock(return_value=Response(404)) + + fulltext = await tool.get_fulltext("99999999") + assert fulltext is None + + @respx.mock + @pytest.mark.asyncio + async def test_get_fulltext_structured(self, tool: PubMedTool) -> None: + """Should return structured sections dict.""" + mock_bioc = { + "documents": [ + { + "passages": [ + {"infons": {"section_type": "INTRO"}, "text": "Intro..."}, + {"infons": {"section_type": "METHODS"}, "text": "Methods..."}, + {"infons": {"section_type": "RESULTS"}, "text": "Results..."}, + {"infons": {"section_type": "DISCUSS"}, "text": "Discussion..."}, + ] + } + ] + } + + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/12345678/unicode" + ).mock(return_value=Response(200, json=mock_bioc)) + + sections = await tool.get_fulltext_structured("12345678") + + assert sections is not None + assert "introduction" in sections + assert "methods" in sections + assert "results" in sections + assert "discussion" in sections + + @respx.mock + @pytest.mark.asyncio + async def test_search_with_fulltext_enabled(self) -> None: + """Search should include full text when tool is configured for it.""" + # Create tool WITH full-text enabled via constructor + tool = PubMedTool(include_fulltext=True) + + # Mock esearch + respx.get("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi").mock( + return_value=Response( + 200, json={"esearchresult": {"idlist": ["12345678"]}} + ) + ) + + # Mock efetch (abstract) + mock_xml = """ + + + + 12345678 +
+ Test Paper + Short abstract. + Smith +
+
+
+
+ """ + respx.get("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi").mock( + return_value=Response(200, text=mock_xml) + ) + + # Mock ID converter + respx.get("https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/").mock( + return_value=Response( + 200, json={"records": [{"pmid": "12345678", "pmcid": "PMC1234567"}]} + ) + ) + + # Mock BioC full text + mock_bioc = { + "documents": [ + { + "passages": [ + {"infons": {"section_type": "INTRO"}, "text": "Full intro..."}, + ] + } + ] + } + respx.get( + "https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/12345678/unicode" + ).mock(return_value=Response(200, json=mock_bioc)) + + # NOTE: No include_fulltext param - it's set via constructor + results = await tool.search("test", max_results=1) + + assert len(results) == 1 + # Full text should be appended or replace abstract + assert "Full intro" in results[0].content or "Short abstract" in results[0].content +``` + +--- + +### Step 2: Implement Full-Text Methods + +**File**: `src/tools/pubmed.py` (additions to existing class) + +```python +# Add these methods to PubMedTool class + +async def get_pmc_id(self, pmid: str) -> str | None: + """ + Convert PMID to PMCID for full-text access. + + Args: + pmid: PubMed ID + + Returns: + PMCID if paper is in PMC, None otherwise + """ + url = "https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/" + params = {"ids": pmid, "format": "json"} + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + records = data.get("records", []) + if records and records[0].get("pmcid"): + return records[0]["pmcid"] + return None + + except httpx.HTTPError: + return None + + +async def get_fulltext(self, pmid: str) -> str | None: + """ + Get full text for a PubMed paper via BioC API. + + Only works for open-access papers in PubMed Central. + + Args: + pmid: PubMed ID + + Returns: + Full text as string, or None if not available + """ + url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.get(url) + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + + # Extract text from all passages + documents = data.get("documents", []) + if not documents: + return None + + passages = documents[0].get("passages", []) + text_parts = [p.get("text", "") for p in passages if p.get("text")] + + return "\n\n".join(text_parts) if text_parts else None + + except httpx.HTTPError: + return None + + +async def get_fulltext_structured(self, pmid: str) -> dict[str, str] | None: + """ + Get structured full text with sections. + + Args: + pmid: PubMed ID + + Returns: + Dict mapping section names to text, or None if not available + """ + url = f"https://www.ncbi.nlm.nih.gov/research/bionlp/RESTful/pmcoa.cgi/BioC_json/{pmid}/unicode" + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.get(url) + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + + documents = data.get("documents", []) + if not documents: + return None + + # Map section types to readable names + section_map = { + "INTRO": "introduction", + "METHODS": "methods", + "RESULTS": "results", + "DISCUSS": "discussion", + "CONCL": "conclusion", + "ABSTRACT": "abstract", + } + + sections: dict[str, list[str]] = {} + for passage in documents[0].get("passages", []): + section_type = passage.get("infons", {}).get("section_type", "other") + section_name = section_map.get(section_type, "other") + text = passage.get("text", "") + + if text: + if section_name not in sections: + sections[section_name] = [] + sections[section_name].append(text) + + # Join multiple passages per section + return {k: "\n\n".join(v) for k, v in sections.items()} + + except httpx.HTTPError: + return None +``` + +--- + +### Step 3: Update Constructor and Search Method + +Add full-text flag to constructor and update search to use it: + +```python +class PubMedTool: + """Search tool for PubMed/NCBI.""" + + def __init__( + self, + api_key: str | None = None, + include_fulltext: bool = False, # NEW CONSTRUCTOR PARAM + ) -> None: + self.api_key = api_key or settings.ncbi_api_key + if self.api_key == "your-ncbi-key-here": + self.api_key = None + self._last_request_time = 0.0 + self.include_fulltext = include_fulltext # Store for use in search() + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + """ + Search PubMed and return evidence. + + Note: Full-text enrichment is controlled by constructor parameter, + not method parameter, because SearchHandler doesn't pass extra args. + """ + # ... existing search logic ... + + evidence_list = self._parse_pubmed_xml(fetch_resp.text) + + # Optionally enrich with full text (if configured at construction) + if self.include_fulltext: + evidence_list = await self._enrich_with_fulltext(evidence_list) + + return evidence_list + + +async def _enrich_with_fulltext( + self, evidence_list: list[Evidence] +) -> list[Evidence]: + """Attempt to add full text to evidence items.""" + enriched = [] + + for evidence in evidence_list: + # Extract PMID from URL + url = evidence.citation.url + pmid = url.rstrip("/").split("/")[-1] if url else None + + if pmid: + fulltext = await self.get_fulltext(pmid) + if fulltext: + # Replace abstract with full text (truncated) + evidence = Evidence( + content=fulltext[:8000], # Larger limit for full text + citation=evidence.citation, + relevance=evidence.relevance, + metadata={ + **evidence.metadata, + "has_fulltext": True, + }, + ) + + enriched.append(evidence) + + return enriched +``` + +--- + +## Demo Script + +**File**: `examples/pubmed_fulltext_demo.py` + +```python +#!/usr/bin/env python3 +"""Demo script to verify PubMed full-text retrieval.""" + +import asyncio +from src.tools.pubmed import PubMedTool + + +async def main(): + """Run PubMed full-text demo.""" + tool = PubMedTool() + + print("=" * 60) + print("PubMed Full-Text Demo") + print("=" * 60) + + # Test 1: Convert PMID to PMCID + print("\n[Test 1] Converting PMID to PMCID...") + # Use a known open-access paper + test_pmid = "34450029" # Example: COVID-related open-access paper + pmcid = await tool.get_pmc_id(test_pmid) + print(f"PMID {test_pmid} -> PMCID: {pmcid or 'Not in PMC'}") + + # Test 2: Get full text + print("\n[Test 2] Fetching full text...") + if pmcid: + fulltext = await tool.get_fulltext(test_pmid) + if fulltext: + print(f"Full text length: {len(fulltext)} characters") + print(f"Preview: {fulltext[:500]}...") + else: + print("Full text not available") + + # Test 3: Get structured sections + print("\n[Test 3] Fetching structured sections...") + if pmcid: + sections = await tool.get_fulltext_structured(test_pmid) + if sections: + print("Available sections:") + for section, text in sections.items(): + print(f" - {section}: {len(text)} chars") + else: + print("Structured text not available") + + # Test 4: Search with full text + print("\n[Test 4] Search with full-text enrichment...") + results = await tool.search( + "metformin cancer open access", + max_results=3, + include_fulltext=True + ) + + for i, evidence in enumerate(results, 1): + has_ft = evidence.metadata.get("has_fulltext", False) + print(f"\n--- Result {i} ---") + print(f"Title: {evidence.citation.title}") + print(f"Has Full Text: {has_ft}") + print(f"Content Length: {len(evidence.content)} chars") + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Verification Checklist + +### Unit Tests +```bash +# Run full-text tests +uv run pytest tests/unit/tools/test_pubmed_fulltext.py -v + +# Run all PubMed tests +uv run pytest tests/unit/tools/test_pubmed.py -v + +# Expected: All tests pass +``` + +### Integration Test (Manual) +```bash +# Run demo with real API +uv run python examples/pubmed_fulltext_demo.py + +# Expected: Real full text from PMC papers +``` + +### Full Test Suite +```bash +make check +# Expected: All tests pass, mypy clean +``` + +--- + +## Success Criteria + +1. **ID Conversion works**: PMID -> PMCID conversion successful +2. **Full text retrieval works**: BioC API returns paper text +3. **Structured sections work**: Can get intro/methods/results/discussion separately +4. **Search integration works**: `include_fulltext=True` enriches results +5. **No regressions**: Existing tests still pass +6. **Graceful degradation**: Non-PMC papers still return abstracts + +--- + +## Notes + +- Only ~30% of PubMed papers have full text in PMC +- BioC API has no documented rate limit, but be respectful +- Full text can be very long - truncate appropriately +- Consider caching full text responses (they don't change) +- Timeout should be longer for full text (60s vs 30s) diff --git a/docs/brainstorming/implementation/17_PHASE_RATE_LIMITING.md b/docs/brainstorming/implementation/17_PHASE_RATE_LIMITING.md new file mode 100644 index 0000000000000000000000000000000000000000..322a2c10194be56a40c1cbdbd54bd49ea0b0246c --- /dev/null +++ b/docs/brainstorming/implementation/17_PHASE_RATE_LIMITING.md @@ -0,0 +1,540 @@ +# Phase 17: Rate Limiting with `limits` Library + +**Priority**: P0 CRITICAL - Prevents API blocks +**Effort**: ~1 hour +**Dependencies**: None + +--- + +## CRITICAL: Async Safety Requirements + +**WARNING**: The rate limiter MUST be async-safe. Blocking the event loop will freeze: +- The Gradio UI +- All parallel searches +- The orchestrator + +**Rules**: +1. **NEVER use `time.sleep()`** - Always use `await asyncio.sleep()` +2. **NEVER use blocking while loops** - Use async-aware polling +3. **The `limits` library check is synchronous** - Wrap it carefully + +The implementation below uses a polling pattern that: +- Checks the limit (synchronous, fast) +- If exceeded, `await asyncio.sleep()` (non-blocking) +- Retry the check + +**Alternative**: If `limits` proves problematic, use `aiolimiter` which is pure-async. + +--- + +## Overview + +Replace naive `asyncio.sleep` rate limiting with proper rate limiter using the `limits` library, which provides: +- Moving window rate limiting +- Per-API configurable limits +- Thread-safe storage +- Already used in reference repo + +**Why This Matters?** +- NCBI will block us without proper rate limiting (3/sec without key, 10/sec with) +- Current implementation only has simple sleep delay +- Need coordinated limits across all PubMed calls +- Professional-grade rate limiting prevents production issues + +--- + +## Current State + +### What We Have (`src/tools/pubmed.py:20-21, 34-41`) + +```python +RATE_LIMIT_DELAY = 0.34 # ~3 requests/sec without API key + +async def _rate_limit(self) -> None: + """Enforce NCBI rate limiting.""" + loop = asyncio.get_running_loop() + now = loop.time() + elapsed = now - self._last_request_time + if elapsed < self.RATE_LIMIT_DELAY: + await asyncio.sleep(self.RATE_LIMIT_DELAY - elapsed) + self._last_request_time = loop.time() +``` + +### Problems + +1. **Not shared across instances**: Each `PubMedTool()` has its own counter +2. **Simple delay vs moving window**: Doesn't handle bursts properly +3. **Hardcoded rate**: Doesn't adapt to API key presence +4. **No backoff on 429**: Just retries blindly + +--- + +## TDD Implementation Plan + +### Step 1: Add Dependency + +**File**: `pyproject.toml` + +```toml +dependencies = [ + # ... existing deps ... + "limits>=3.0", +] +``` + +Then run: +```bash +uv sync +``` + +--- + +### Step 2: Write the Tests First + +**File**: `tests/unit/tools/test_rate_limiting.py` + +```python +"""Tests for rate limiting functionality.""" + +import asyncio +import time + +import pytest + +from src.tools.rate_limiter import RateLimiter, get_pubmed_limiter + + +class TestRateLimiter: + """Test suite for rate limiter.""" + + def test_create_limiter_without_api_key(self) -> None: + """Should create 3/sec limiter without API key.""" + limiter = RateLimiter(rate="3/second") + assert limiter.rate == "3/second" + + def test_create_limiter_with_api_key(self) -> None: + """Should create 10/sec limiter with API key.""" + limiter = RateLimiter(rate="10/second") + assert limiter.rate == "10/second" + + @pytest.mark.asyncio + async def test_limiter_allows_requests_under_limit(self) -> None: + """Should allow requests under the rate limit.""" + limiter = RateLimiter(rate="10/second") + + # 3 requests should all succeed immediately + for _ in range(3): + allowed = await limiter.acquire() + assert allowed is True + + @pytest.mark.asyncio + async def test_limiter_blocks_when_exceeded(self) -> None: + """Should wait when rate limit exceeded.""" + limiter = RateLimiter(rate="2/second") + + # First 2 should be instant + await limiter.acquire() + await limiter.acquire() + + # Third should block briefly + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + # Should have waited ~0.5 seconds (half second window for 2/sec) + assert elapsed >= 0.3 + + @pytest.mark.asyncio + async def test_limiter_resets_after_window(self) -> None: + """Rate limit should reset after time window.""" + limiter = RateLimiter(rate="5/second") + + # Use up the limit + for _ in range(5): + await limiter.acquire() + + # Wait for window to pass + await asyncio.sleep(1.1) + + # Should be allowed again + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + assert elapsed < 0.1 # Should be nearly instant + + +class TestGetPubmedLimiter: + """Test PubMed-specific limiter factory.""" + + def test_limiter_without_api_key(self) -> None: + """Should return 3/sec limiter without key.""" + limiter = get_pubmed_limiter(api_key=None) + assert "3" in limiter.rate + + def test_limiter_with_api_key(self) -> None: + """Should return 10/sec limiter with key.""" + limiter = get_pubmed_limiter(api_key="my-api-key") + assert "10" in limiter.rate + + def test_limiter_is_singleton(self) -> None: + """Same API key should return same limiter instance.""" + limiter1 = get_pubmed_limiter(api_key="key1") + limiter2 = get_pubmed_limiter(api_key="key1") + assert limiter1 is limiter2 + + def test_different_keys_different_limiters(self) -> None: + """Different API keys should return different limiters.""" + limiter1 = get_pubmed_limiter(api_key="key1") + limiter2 = get_pubmed_limiter(api_key="key2") + # Clear cache for clean test + # Actually, different keys SHOULD share the same limiter + # since we're limiting against the same API + assert limiter1 is limiter2 # Shared NCBI rate limit +``` + +--- + +### Step 3: Create Rate Limiter Module + +**File**: `src/tools/rate_limiter.py` + +```python +"""Rate limiting utilities using the limits library.""" + +import asyncio +from typing import ClassVar + +from limits import RateLimitItem, parse +from limits.storage import MemoryStorage +from limits.strategies import MovingWindowRateLimiter + + +class RateLimiter: + """ + Async-compatible rate limiter using limits library. + + Uses moving window algorithm for smooth rate limiting. + """ + + def __init__(self, rate: str) -> None: + """ + Initialize rate limiter. + + Args: + rate: Rate string like "3/second" or "10/second" + """ + self.rate = rate + self._storage = MemoryStorage() + self._limiter = MovingWindowRateLimiter(self._storage) + self._rate_limit: RateLimitItem = parse(rate) + self._identity = "default" # Single identity for shared limiting + + async def acquire(self, wait: bool = True) -> bool: + """ + Acquire permission to make a request. + + ASYNC-SAFE: Uses asyncio.sleep(), never time.sleep(). + The polling pattern allows other coroutines to run while waiting. + + Args: + wait: If True, wait until allowed. If False, return immediately. + + Returns: + True if allowed, False if not (only when wait=False) + """ + while True: + # Check if we can proceed (synchronous, fast - ~microseconds) + if self._limiter.hit(self._rate_limit, self._identity): + return True + + if not wait: + return False + + # CRITICAL: Use asyncio.sleep(), NOT time.sleep() + # This yields control to the event loop, allowing other + # coroutines (UI, parallel searches) to run + await asyncio.sleep(0.1) + + def reset(self) -> None: + """Reset the rate limiter (for testing).""" + self._storage.reset() + + +# Singleton limiter for PubMed/NCBI +_pubmed_limiter: RateLimiter | None = None + + +def get_pubmed_limiter(api_key: str | None = None) -> RateLimiter: + """ + Get the shared PubMed rate limiter. + + Rate depends on whether API key is provided: + - Without key: 3 requests/second + - With key: 10 requests/second + + Args: + api_key: NCBI API key (optional) + + Returns: + Shared RateLimiter instance + """ + global _pubmed_limiter + + if _pubmed_limiter is None: + rate = "10/second" if api_key else "3/second" + _pubmed_limiter = RateLimiter(rate) + + return _pubmed_limiter + + +def reset_pubmed_limiter() -> None: + """Reset the PubMed limiter (for testing).""" + global _pubmed_limiter + _pubmed_limiter = None + + +# Factory for other APIs +class RateLimiterFactory: + """Factory for creating/getting rate limiters for different APIs.""" + + _limiters: ClassVar[dict[str, RateLimiter]] = {} + + @classmethod + def get(cls, api_name: str, rate: str) -> RateLimiter: + """ + Get or create a rate limiter for an API. + + Args: + api_name: Unique identifier for the API + rate: Rate limit string (e.g., "10/second") + + Returns: + RateLimiter instance (shared for same api_name) + """ + if api_name not in cls._limiters: + cls._limiters[api_name] = RateLimiter(rate) + return cls._limiters[api_name] + + @classmethod + def reset_all(cls) -> None: + """Reset all limiters (for testing).""" + cls._limiters.clear() +``` + +--- + +### Step 4: Update PubMed Tool + +**File**: `src/tools/pubmed.py` (replace rate limiting code) + +```python +# Replace imports and rate limiting + +from src.tools.rate_limiter import get_pubmed_limiter + + +class PubMedTool: + """Search tool for PubMed/NCBI.""" + + BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" + HTTP_TOO_MANY_REQUESTS = 429 + + def __init__(self, api_key: str | None = None) -> None: + self.api_key = api_key or settings.ncbi_api_key + if self.api_key == "your-ncbi-key-here": + self.api_key = None + # Use shared rate limiter + self._limiter = get_pubmed_limiter(self.api_key) + + async def _rate_limit(self) -> None: + """Enforce NCBI rate limiting using shared limiter.""" + await self._limiter.acquire() + + # ... rest of class unchanged ... +``` + +--- + +### Step 5: Add Rate Limiters for Other APIs + +**File**: `src/tools/clinicaltrials.py` (optional) + +```python +from src.tools.rate_limiter import RateLimiterFactory + + +class ClinicalTrialsTool: + def __init__(self) -> None: + # ClinicalTrials.gov doesn't document limits, but be conservative + self._limiter = RateLimiterFactory.get("clinicaltrials", "5/second") + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + await self._limiter.acquire() + # ... rest of method ... +``` + +**File**: `src/tools/europepmc.py` (optional) + +```python +from src.tools.rate_limiter import RateLimiterFactory + + +class EuropePMCTool: + def __init__(self) -> None: + # Europe PMC is generous, but still be respectful + self._limiter = RateLimiterFactory.get("europepmc", "10/second") + + async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + await self._limiter.acquire() + # ... rest of method ... +``` + +--- + +## Demo Script + +**File**: `examples/rate_limiting_demo.py` + +```python +#!/usr/bin/env python3 +"""Demo script to verify rate limiting works correctly.""" + +import asyncio +import time + +from src.tools.rate_limiter import RateLimiter, get_pubmed_limiter, reset_pubmed_limiter +from src.tools.pubmed import PubMedTool + + +async def test_basic_limiter(): + """Test basic rate limiter behavior.""" + print("=" * 60) + print("Rate Limiting Demo") + print("=" * 60) + + # Test 1: Basic limiter + print("\n[Test 1] Testing 3/second limiter...") + limiter = RateLimiter("3/second") + + start = time.monotonic() + for i in range(6): + await limiter.acquire() + elapsed = time.monotonic() - start + print(f" Request {i+1} at {elapsed:.2f}s") + + total = time.monotonic() - start + print(f" Total time for 6 requests: {total:.2f}s (expected ~2s)") + + +async def test_pubmed_limiter(): + """Test PubMed-specific limiter.""" + print("\n[Test 2] Testing PubMed limiter (shared)...") + + reset_pubmed_limiter() # Clean state + + # Without API key: 3/sec + limiter = get_pubmed_limiter(api_key=None) + print(f" Rate without key: {limiter.rate}") + + # Multiple tools should share the same limiter + tool1 = PubMedTool() + tool2 = PubMedTool() + + # Verify they share the limiter + print(f" Tools share limiter: {tool1._limiter is tool2._limiter}") + + +async def test_concurrent_requests(): + """Test rate limiting under concurrent load.""" + print("\n[Test 3] Testing concurrent request limiting...") + + limiter = RateLimiter("5/second") + + async def make_request(i: int): + await limiter.acquire() + return time.monotonic() + + start = time.monotonic() + # Launch 10 concurrent requests + tasks = [make_request(i) for i in range(10)] + times = await asyncio.gather(*tasks) + + # Calculate distribution + relative_times = [t - start for t in times] + print(f" Request times: {[f'{t:.2f}s' for t in sorted(relative_times)]}") + + total = max(relative_times) + print(f" All 10 requests completed in {total:.2f}s (expected ~2s)") + + +async def main(): + await test_basic_limiter() + await test_pubmed_limiter() + await test_concurrent_requests() + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Verification Checklist + +### Unit Tests +```bash +# Run rate limiting tests +uv run pytest tests/unit/tools/test_rate_limiting.py -v + +# Expected: All tests pass +``` + +### Integration Test (Manual) +```bash +# Run demo +uv run python examples/rate_limiting_demo.py + +# Expected: Requests properly spaced +``` + +### Full Test Suite +```bash +make check +# Expected: All tests pass, mypy clean +``` + +--- + +## Success Criteria + +1. **`limits` library installed**: Dependency added to pyproject.toml +2. **RateLimiter class works**: Can create and use limiters +3. **PubMed uses new limiter**: Shared limiter across instances +4. **Rate adapts to API key**: 3/sec without, 10/sec with +5. **Concurrent requests handled**: Multiple async requests properly queued +6. **No regressions**: All existing tests pass + +--- + +## API Rate Limit Reference + +| API | Without Key | With Key | +|-----|-------------|----------| +| PubMed/NCBI | 3/sec | 10/sec | +| ClinicalTrials.gov | Undocumented (~5/sec safe) | N/A | +| Europe PMC | ~10-20/sec (generous) | N/A | +| OpenAlex | ~100k/day (no per-sec limit) | Faster with `mailto` | + +--- + +## Notes + +- `limits` library uses moving window algorithm (fairer than fixed window) +- Singleton pattern ensures all PubMed calls share the limit +- The factory pattern allows easy extension to other APIs +- Consider adding 429 response detection + exponential backoff +- In production, consider Redis storage for distributed rate limiting diff --git a/docs/brainstorming/implementation/README.md b/docs/brainstorming/implementation/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6df1769754e718014f30f5a452d8366a0d2065c0 --- /dev/null +++ b/docs/brainstorming/implementation/README.md @@ -0,0 +1,143 @@ +# Implementation Plans + +TDD implementation plans based on the brainstorming documents. Each phase is a self-contained vertical slice with tests, implementation, and demo scripts. + +--- + +## Prerequisites (COMPLETED) + +The following foundational changes have been implemented to support all three phases: + +| Change | File | Status | +|--------|------|--------| +| Add `"openalex"` to `SourceName` | `src/utils/models.py:9` | ✅ Done | +| Add `metadata` field to `Evidence` | `src/utils/models.py:39-42` | ✅ Done | +| Export all tools from `__init__.py` | `src/tools/__init__.py` | ✅ Done | + +All 110 tests pass after these changes. + +--- + +## Priority Order + +| Phase | Name | Priority | Effort | Value | +|-------|------|----------|--------|-------| +| **17** | Rate Limiting | P0 CRITICAL | 1 hour | Stability | +| **15** | OpenAlex | HIGH | 2-3 hours | Very High | +| **16** | PubMed Full-Text | MEDIUM | 3 hours | High | + +**Recommended implementation order**: 17 → 15 → 16 + +--- + +## Phase 15: OpenAlex Integration + +**File**: [15_PHASE_OPENALEX.md](./15_PHASE_OPENALEX.md) + +Add OpenAlex as 4th data source for: +- Citation networks (who cites whom) +- Concept tagging (semantic discovery) +- 209M+ scholarly works +- Free, no API key required + +**Quick Start**: +```bash +# Create the tool +touch src/tools/openalex.py +touch tests/unit/tools/test_openalex.py + +# Run tests first (TDD) +uv run pytest tests/unit/tools/test_openalex.py -v + +# Demo +uv run python examples/openalex_demo.py +``` + +--- + +## Phase 16: PubMed Full-Text + +**File**: [16_PHASE_PUBMED_FULLTEXT.md](./16_PHASE_PUBMED_FULLTEXT.md) + +Add full-text retrieval via BioC API for: +- Complete paper text (not just abstracts) +- Structured sections (intro, methods, results) +- Better evidence for LLM synthesis + +**Quick Start**: +```bash +# Add methods to existing pubmed.py +# Tests in test_pubmed_fulltext.py + +# Run tests +uv run pytest tests/unit/tools/test_pubmed_fulltext.py -v + +# Demo +uv run python examples/pubmed_fulltext_demo.py +``` + +--- + +## Phase 17: Rate Limiting + +**File**: [17_PHASE_RATE_LIMITING.md](./17_PHASE_RATE_LIMITING.md) + +Replace naive sleep-based rate limiting with `limits` library for: +- Moving window algorithm +- Shared limits across instances +- Configurable per-API rates +- Production-grade stability + +**Quick Start**: +```bash +# Add dependency +uv add limits + +# Create module +touch src/tools/rate_limiter.py +touch tests/unit/tools/test_rate_limiting.py + +# Run tests +uv run pytest tests/unit/tools/test_rate_limiting.py -v + +# Demo +uv run python examples/rate_limiting_demo.py +``` + +--- + +## TDD Workflow + +Each implementation doc follows this pattern: + +1. **Write tests first** - Define expected behavior +2. **Run tests** - Verify they fail (red) +3. **Implement** - Write minimal code to pass +4. **Run tests** - Verify they pass (green) +5. **Refactor** - Clean up if needed +6. **Demo** - Verify end-to-end with real APIs +7. **`make check`** - Ensure no regressions + +--- + +## Related Brainstorming Docs + +These implementation plans are derived from: + +- [00_ROADMAP_SUMMARY.md](../00_ROADMAP_SUMMARY.md) - Priority overview +- [01_PUBMED_IMPROVEMENTS.md](../01_PUBMED_IMPROVEMENTS.md) - PubMed details +- [02_CLINICALTRIALS_IMPROVEMENTS.md](../02_CLINICALTRIALS_IMPROVEMENTS.md) - CT.gov details +- [03_EUROPEPMC_IMPROVEMENTS.md](../03_EUROPEPMC_IMPROVEMENTS.md) - Europe PMC details +- [04_OPENALEX_INTEGRATION.md](../04_OPENALEX_INTEGRATION.md) - OpenAlex integration + +--- + +## Future Phases (Not Yet Documented) + +Based on brainstorming, these could be added later: + +- **Phase 18**: ClinicalTrials.gov Results Retrieval +- **Phase 19**: Europe PMC Annotations API +- **Phase 20**: Drug Name Normalization (RxNorm) +- **Phase 21**: Citation Network Queries (OpenAlex) +- **Phase 22**: Semantic Search with Embeddings diff --git a/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md b/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..77c443ae9f605904d9c55de3a729e4c06ac3f226 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md @@ -0,0 +1,189 @@ +# Situation Analysis: Pydantic-AI + Microsoft Agent Framework Integration + +**Date:** November 27, 2025 +**Status:** ACTIVE DECISION REQUIRED +**Risk Level:** HIGH - DO NOT MERGE PR #41 UNTIL RESOLVED + +--- + +## 1. The Problem + +We almost merged a refactor that would have **deleted** multi-agent orchestration capability from the codebase, mistakenly believing pydantic-ai and Microsoft Agent Framework were mutually exclusive. + +**They are not.** They are complementary: +- **pydantic-ai** (Library): Ensures LLM outputs match Pydantic schemas +- **Microsoft Agent Framework** (Framework): Orchestrates multi-agent workflows + +--- + +## 2. Current Branch State + +| Branch | Location | Has Agent Framework? | Has Pydantic-AI Improvements? | Status | +|--------|----------|---------------------|------------------------------|--------| +| `origin/dev` | GitHub | YES | NO | **SAFE - Source of Truth** | +| `huggingface-upstream/dev` | HF Spaces | YES | NO | **SAFE - Same as GitHub** | +| `origin/main` | GitHub | YES | NO | **SAFE** | +| `feat/pubmed-fulltext` | GitHub | NO (deleted) | YES | **DANGER - Has destructive refactor** | +| `refactor/pydantic-unification` | Local | NO (deleted) | YES | **DANGER - Redundant, delete** | +| Local `dev` | Local only | NO (deleted) | YES | **DANGER - NOT PUSHED (thankfully)** | + +### Key Files at Risk + +**On `origin/dev` (PRESERVED):** +```text +src/agents/ +├── analysis_agent.py # StatisticalAnalyzer wrapper +├── hypothesis_agent.py # Hypothesis generation +├── judge_agent.py # JudgeHandler wrapper +├── magentic_agents.py # Multi-agent definitions +├── report_agent.py # Report synthesis +├── search_agent.py # SearchHandler wrapper +├── state.py # Thread-safe state management +└── tools.py # @ai_function decorated tools + +src/orchestrator_magentic.py # Multi-agent orchestrator +src/utils/llm_factory.py # Centralized LLM client factory +``` + +**Deleted in refactor branch (would be lost if merged):** +- All of the above + +--- + +## 3. Target Architecture + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ Microsoft Agent Framework (Orchestration Layer) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ SearchAgent │→ │ JudgeAgent │→ │ ReportAgent │ │ +│ │ (BaseAgent) │ │ (BaseAgent) │ │ (BaseAgent) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ pydantic-ai │ │ pydantic-ai │ │ pydantic-ai │ │ +│ │ Agent() │ │ Agent() │ │ Agent() │ │ +│ │ output_type= │ │ output_type= │ │ output_type= │ │ +│ │ SearchResult │ │ JudgeAssess │ │ Report │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Why this architecture:** +1. **Agent Framework** handles: workflow coordination, state passing, middleware, observability +2. **pydantic-ai** handles: type-safe LLM calls within each agent + +--- + +## 4. CRITICAL: Naming Confusion Clarification + +> **Senior Agent Review Finding:** The codebase uses "magentic" in file names (e.g., `orchestrator_magentic.py`, `magentic_agents.py`) but this is **NOT** the `magentic` PyPI package by Jacky Liang. It's Microsoft Agent Framework (`agent-framework-core`). + +**The naming confusion:** +- `magentic` (PyPI package): A different library for structured LLM outputs +- "Magentic" (in our codebase): Our internal name for Microsoft Agent Framework integration +- `agent-framework-core` (PyPI package): Microsoft's actual multi-agent orchestration framework + +**Recommended future action:** Rename `orchestrator_magentic.py` → `orchestrator_advanced.py` to eliminate confusion. + +--- + +## 5. What the Refactor DID Get Right + +The refactor branch (`feat/pubmed-fulltext`) has some valuable improvements: + +1. **`judges.py` unified `get_model()`** - Supports OpenAI, Anthropic, AND HuggingFace via pydantic-ai +2. **HuggingFace free tier support** - `HuggingFaceModel` integration +3. **Test fix** - Properly mocks `HuggingFaceModel` class +4. **Removed broken magentic optional dependency** from pyproject.toml (this was correct - the old `magentic` package is different from Microsoft Agent Framework) + +**What it got WRONG:** +1. Deleted `src/agents/` entirely instead of refactoring them +2. Deleted `src/orchestrator_magentic.py` instead of fixing it +3. Conflated "magentic" (old package) with "Microsoft Agent Framework" (current framework) + +--- + +## 6. Options for Path Forward + +### Option A: Abandon Refactor, Start Fresh +- Close PR #41 +- Delete `feat/pubmed-fulltext` and `refactor/pydantic-unification` branches +- Reset local `dev` to match `origin/dev` +- Cherry-pick ONLY the good parts (judges.py improvements, HF support) +- **Pros:** Clean, safe +- **Cons:** Lose some work, need to redo carefully + +### Option B: Cherry-Pick Good Parts to origin/dev +- Do NOT merge PR #41 +- Create new branch from `origin/dev` +- Cherry-pick specific commits/changes that improve pydantic-ai usage +- Keep agent framework code intact +- **Pros:** Preserves both, surgical +- **Cons:** Requires careful file-by-file review + +### Option C: Revert Deletions in Refactor Branch +- On `feat/pubmed-fulltext`, restore deleted agent files from `origin/dev` +- Keep the pydantic-ai improvements +- Merge THAT to dev +- **Pros:** Gets both +- **Cons:** Complex git operations, risk of conflicts + +--- + +## 7. Recommended Action: Option B (Cherry-Pick) + +**Step-by-step:** + +1. **Close PR #41** (do not merge) +2. **Delete redundant branches:** + - `refactor/pydantic-unification` (local) + - Reset local `dev` to `origin/dev` +3. **Create new branch from origin/dev:** + ```bash + git checkout -b feat/pydantic-ai-improvements origin/dev + ``` +4. **Cherry-pick or manually port these improvements:** + - `src/agent_factory/judges.py` - the unified `get_model()` function + - `examples/free_tier_demo.py` - HuggingFace demo + - Test improvements +5. **Do NOT delete any agent framework files** +6. **Create PR for review** + +--- + +## 8. Files to Cherry-Pick (Safe Improvements) + +| File | What Changed | Safe to Port? | +|------|-------------|---------------| +| `src/agent_factory/judges.py` | Added `HuggingFaceModel` support in `get_model()` | YES | +| `examples/free_tier_demo.py` | New demo for HF inference | YES | +| `tests/unit/agent_factory/test_judges.py` | Fixed HF model mocking | YES | +| `pyproject.toml` | Removed old `magentic` optional dep | MAYBE (review carefully) | + +--- + +## 9. Questions to Answer Before Proceeding + +1. **For the hackathon**: Do we need full multi-agent orchestration, or is single-agent sufficient? +2. **For DeepCritical mainline**: Is the plan to use Microsoft Agent Framework for orchestration? +3. **Timeline**: How much time do we have to get this right? + +--- + +## 10. Immediate Actions (DO NOW) + +- [ ] **DO NOT merge PR #41** +- [ ] Close PR #41 with comment explaining the situation +- [ ] Do not push local `dev` branch anywhere +- [ ] Confirm HuggingFace Spaces is untouched (it is - verified) + +--- + +## 11. Decision Log + +| Date | Decision | Rationale | +|------|----------|-----------| +| 2025-11-27 | Pause refactor merge | Discovered agent framework and pydantic-ai are complementary, not exclusive | +| TBD | ? | Awaiting decision on path forward | diff --git a/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md b/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md new file mode 100644 index 0000000000000000000000000000000000000000..7886c89b807f1dbfb54e878bb326715ad62675f9 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md @@ -0,0 +1,289 @@ +# Architecture Specification: Dual-Mode Agent System + +**Date:** November 27, 2025 +**Status:** SPECIFICATION +**Goal:** Graceful degradation from full multi-agent orchestration to simple single-agent mode + +--- + +## 1. Core Concept: Two Operating Modes + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ USER REQUEST │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Mode Selection │ │ +│ │ (Auto-detect) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌───────────────┴───────────────┐ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ SIMPLE MODE │ │ ADVANCED MODE │ │ +│ │ (Free Tier) │ │ (Paid Tier) │ │ +│ │ │ │ │ │ +│ │ pydantic-ai │ │ MS Agent Fwk │ │ +│ │ single-agent │ │ + pydantic-ai │ │ +│ │ loop │ │ multi-agent │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ │ │ +│ └───────────────┬───────────────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Research Report │ │ +│ │ with Citations │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Mode Comparison + +| Aspect | Simple Mode | Advanced Mode | +|--------|-------------|---------------| +| **Trigger** | No API key OR `LLM_PROVIDER=huggingface` | OpenAI API key present (currently OpenAI only) | +| **Framework** | pydantic-ai only | Microsoft Agent Framework + pydantic-ai | +| **Architecture** | Single orchestrator loop | Multi-agent coordination | +| **Agents** | One agent does Search→Judge→Report | SearchAgent, JudgeAgent, ReportAgent, AnalysisAgent | +| **State Management** | Simple dict | Thread-safe `MagenticState` with context vars | +| **Quality** | Good (functional) | Better (specialized agents, coordination) | +| **Cost** | Free (HuggingFace Inference) | Paid (OpenAI/Anthropic) | +| **Use Case** | Demos, hackathon, budget-constrained | Production, research quality | + +--- + +## 3. Simple Mode Architecture (pydantic-ai Only) + +```text +┌─────────────────────────────────────────────────────┐ +│ Orchestrator │ +│ │ +│ while not sufficient and iteration < max: │ +│ 1. SearchHandler.execute(query) │ +│ 2. JudgeHandler.assess(evidence) ◄── pydantic-ai Agent │ +│ 3. if sufficient: break │ +│ 4. query = judge.next_queries │ +│ │ +│ return ReportGenerator.generate(evidence) │ +└─────────────────────────────────────────────────────┘ +``` + +**Components:** +- `src/orchestrator.py` - Simple loop orchestrator +- `src/agent_factory/judges.py` - JudgeHandler with pydantic-ai +- `src/tools/search_handler.py` - Scatter-gather search +- `src/tools/pubmed.py`, `clinicaltrials.py`, `europepmc.py` - Search tools + +--- + +## 4. Advanced Mode Architecture (MS Agent Framework + pydantic-ai) + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ Microsoft Agent Framework Orchestrator │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ SearchAgent │───▶│ JudgeAgent │───▶│ ReportAgent │ │ +│ │ (BaseAgent) │ │ (BaseAgent) │ │ (BaseAgent) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ pydantic-ai │ │ pydantic-ai │ │ pydantic-ai │ │ +│ │ Agent() │ │ Agent() │ │ Agent() │ │ +│ │ output_type=│ │ output_type=│ │ output_type=│ │ +│ │ SearchResult│ │ JudgeAssess │ │ Report │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ Shared State: MagenticState (thread-safe via contextvars) │ +│ - evidence: list[Evidence] │ +│ - embedding_service: EmbeddingService │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Components:** +- `src/orchestrator_magentic.py` - Multi-agent orchestrator +- `src/agents/search_agent.py` - SearchAgent (BaseAgent) +- `src/agents/judge_agent.py` - JudgeAgent (BaseAgent) +- `src/agents/report_agent.py` - ReportAgent (BaseAgent) +- `src/agents/analysis_agent.py` - AnalysisAgent (BaseAgent) +- `src/agents/state.py` - Thread-safe state management +- `src/agents/tools.py` - @ai_function decorated tools + +--- + +## 5. Mode Selection Logic + +```python +# src/orchestrator_factory.py (actual implementation) + +def create_orchestrator( + search_handler: SearchHandlerProtocol | None = None, + judge_handler: JudgeHandlerProtocol | None = None, + config: OrchestratorConfig | None = None, + mode: Literal["simple", "magentic", "advanced"] | None = None, +) -> Any: + """ + Auto-select orchestrator based on available credentials. + + Priority: + 1. If mode explicitly set, use that + 2. If OpenAI key available -> Advanced Mode (currently OpenAI only) + 3. Otherwise -> Simple Mode (HuggingFace free tier) + """ + effective_mode = _determine_mode(mode) + + if effective_mode == "advanced": + orchestrator_cls = _get_magentic_orchestrator_class() + return orchestrator_cls(max_rounds=config.max_iterations if config else 10) + + # Simple mode requires handlers + if search_handler is None or judge_handler is None: + raise ValueError("Simple mode requires search_handler and judge_handler") + + return Orchestrator( + search_handler=search_handler, + judge_handler=judge_handler, + config=config, + ) +``` + +--- + +## 6. Shared Components (Both Modes Use) + +These components work in both modes: + +| Component | Purpose | +|-----------|---------| +| `src/tools/pubmed.py` | PubMed search | +| `src/tools/clinicaltrials.py` | ClinicalTrials.gov search | +| `src/tools/europepmc.py` | Europe PMC search | +| `src/tools/search_handler.py` | Scatter-gather orchestration | +| `src/tools/rate_limiter.py` | Rate limiting | +| `src/utils/models.py` | Evidence, Citation, JudgeAssessment | +| `src/utils/config.py` | Settings | +| `src/services/embeddings.py` | Vector search (optional) | + +--- + +## 7. pydantic-ai Integration Points + +Both modes use pydantic-ai for structured LLM outputs: + +```python +# In JudgeHandler (both modes) +from pydantic_ai import Agent +from pydantic_ai.models.huggingface import HuggingFaceModel +from pydantic_ai.models.openai import OpenAIModel +from pydantic_ai.models.anthropic import AnthropicModel + +class JudgeHandler: + def __init__(self, model: Any = None): + self.model = model or get_model() # Auto-selects based on config + self.agent = Agent( + model=self.model, + output_type=JudgeAssessment, # Structured output! + system_prompt=SYSTEM_PROMPT, + ) + + async def assess(self, question: str, evidence: list[Evidence]) -> JudgeAssessment: + result = await self.agent.run(format_prompt(question, evidence)) + return result.output # Guaranteed to be JudgeAssessment +``` + +--- + +## 8. Microsoft Agent Framework Integration Points + +Advanced mode wraps pydantic-ai agents in BaseAgent: + +```python +# In JudgeAgent (advanced mode only) +from agent_framework import BaseAgent, AgentRunResponse, ChatMessage, Role + +class JudgeAgent(BaseAgent): + def __init__(self, judge_handler: JudgeHandlerProtocol): + super().__init__( + name="JudgeAgent", + description="Evaluates evidence quality", + ) + self._handler = judge_handler # Uses pydantic-ai internally + + async def run(self, messages, **kwargs) -> AgentRunResponse: + question = extract_question(messages) + evidence = self._evidence_store.get("current", []) + + # Delegate to pydantic-ai powered handler + assessment = await self._handler.assess(question, evidence) + + return AgentRunResponse( + messages=[ChatMessage(role=Role.ASSISTANT, text=format_response(assessment))], + additional_properties={"assessment": assessment.model_dump()}, + ) +``` + +--- + +## 9. Benefits of This Architecture + +1. **Graceful Degradation**: Works without API keys (free tier) +2. **Progressive Enhancement**: Better with API keys (orchestration) +3. **Code Reuse**: pydantic-ai handlers shared between modes +4. **Hackathon Ready**: Demo works without requiring paid keys +5. **Production Ready**: Full orchestration available when needed +6. **Future Proof**: Can add more agents to advanced mode +7. **Testable**: Simple mode is easier to unit test + +--- + +## 10. Known Risks and Mitigations + +> **From Senior Agent Review** + +### 10.1 Bridge Complexity (MEDIUM) + +**Risk:** In Advanced Mode, agents (Agent Framework) wrap handlers (pydantic-ai). Both are async. Context variables (`MagenticState`) must propagate correctly through the pydantic-ai call stack. + +**Mitigation:** +- pydantic-ai uses standard Python `contextvars`, which naturally propagate through `await` chains +- Test context propagation explicitly in integration tests +- If issues arise, pass state explicitly rather than via context vars + +### 10.2 Integration Drift (MEDIUM) + +**Risk:** Simple Mode and Advanced Mode might diverge in behavior over time (e.g., Simple Mode uses logic A, Advanced Mode uses logic B). + +**Mitigation:** +- Both modes MUST call the exact same underlying Tools (`src/tools/*`) and Handlers (`src/agent_factory/*`) +- Handlers are the single source of truth for business logic +- Agents are thin wrappers that delegate to handlers + +### 10.3 Testing Burden (LOW-MEDIUM) + +**Risk:** Two distinct orchestrators (`src/orchestrator.py` and `src/orchestrator_magentic.py`) doubles integration testing surface area. + +**Mitigation:** +- Unit test handlers independently (shared code) +- Integration tests for each mode separately +- End-to-end tests verify same output for same input (determinism permitting) + +### 10.4 Dependency Conflicts (LOW) + +**Risk:** `agent-framework-core` might conflict with `pydantic-ai`'s dependencies (e.g., different pydantic versions). + +**Status:** Both use `pydantic>=2.x`. Should be compatible. + +--- + +## 11. Naming Clarification + +> See `00_SITUATION_AND_PLAN.md` Section 4 for full details. + +**Important:** The codebase uses "magentic" in file names (`orchestrator_magentic.py`, `magentic_agents.py`) but this refers to our internal naming for Microsoft Agent Framework integration, **NOT** the `magentic` PyPI package. + +**Future action:** Rename to `orchestrator_advanced.py` to eliminate confusion. diff --git a/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md b/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md new file mode 100644 index 0000000000000000000000000000000000000000..37e2791a4123e3f2e78d2c750ddc77eff7d05814 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md @@ -0,0 +1,112 @@ +# Implementation Phases: Dual-Mode Agent System + +**Date:** November 27, 2025 +**Status:** IMPLEMENTATION PLAN (REVISED) +**Strategy:** TDD (Test-Driven Development), SOLID Principles +**Dependency Strategy:** PyPI (agent-framework-core) + +--- + +## Phase 0: Environment Validation & Cleanup + +**Goal:** Ensure clean state and dependencies are correctly installed. + +### Step 0.1: Verify PyPI Package +The `agent-framework-core` package is published on PyPI by Microsoft. Verify installation: + +```bash +uv sync --all-extras +python -c "from agent_framework import ChatAgent; print('OK')" +``` + +### Step 0.2: Branch State +We are on `feat/dual-mode-architecture`. Ensure it is up to date with `origin/dev` before starting. + +**Note:** The `reference_repos/agent-framework` folder is kept for reference/documentation only. +The production dependency uses the official PyPI release. + +--- + +## Phase 1: Pydantic-AI Improvements (Simple Mode) + +**Goal:** Implement `HuggingFaceModel` support in `JudgeHandler` using strict TDD. + +### Step 1.1: Test First (Red) +Create `tests/unit/agent_factory/test_judges_factory.py`: +- Test `get_model()` returns `HuggingFaceModel` when `LLM_PROVIDER=huggingface`. +- Test `get_model()` respects `HF_TOKEN`. +- Test fallback to OpenAI. + +### Step 1.2: Implementation (Green) +Update `src/utils/config.py`: +- Add `huggingface_model` and `hf_token` fields. + +Update `src/agent_factory/judges.py`: +- Implement `get_model` with the logic derived from the tests. +- Use dependency injection for the model where possible. + +### Step 1.3: Refactor +Ensure `JudgeHandler` is loosely coupled from the specific model provider. + +--- + +## Phase 2: Orchestrator Factory (The Switch) + +**Goal:** Implement the factory pattern to switch between Simple and Advanced modes. + +### Step 2.1: Test First (Red) +Create `tests/unit/test_orchestrator_factory.py`: +- Test `create_orchestrator` returns `Orchestrator` (simple) when API keys are missing. +- Test `create_orchestrator` returns `MagenticOrchestrator` (advanced) when OpenAI key exists. +- Test explicit mode override. + +### Step 2.2: Implementation (Green) +Update `src/orchestrator_factory.py` to implement the selection logic. + +--- + +## Phase 3: Agent Framework Integration (Advanced Mode) + +**Goal:** Integrate Microsoft Agent Framework from PyPI. + +### Step 3.1: Dependency Management +The `agent-framework-core` package is installed from PyPI: +```toml +[project.optional-dependencies] +magentic = [ + "agent-framework-core>=1.0.0b251120,<2.0.0", # Microsoft Agent Framework (PyPI) +] +``` +Install with: `uv sync --all-extras` + +### Step 3.2: Verify Imports (Test First) +Create `tests/unit/agents/test_agent_imports.py`: +- Verify `from agent_framework import ChatAgent` works. +- Verify instantiation of `ChatAgent` with a mock client. + +### Step 3.3: Update Agents +Refactor `src/agents/*.py` to ensure they match the exact signature of the local `ChatAgent` class. +- **SOLID:** Ensure agents have single responsibilities. +- **DRY:** Share tool definitions between Pydantic-AI simple mode and Agent Framework advanced mode. + +--- + +## Phase 4: UI & End-to-End Verification + +**Goal:** Update Gradio to reflect the active mode. + +### Step 4.1: UI Updates +Update `src/app.py` to display "Simple Mode" vs "Advanced Mode". + +### Step 4.2: End-to-End Test +Run the full loop: +1. Simple Mode (No Keys) -> Search -> Judge (HF) -> Report. +2. Advanced Mode (OpenAI Key) -> SearchAgent -> JudgeAgent -> ReportAgent. + +--- + +## Phase 5: Cleanup & Documentation + +- Remove unused code. +- Update main README.md. +- Final `make check`. \ No newline at end of file diff --git a/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md b/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md new file mode 100644 index 0000000000000000000000000000000000000000..b09b6db248a8ddb37fa8f6c2deba01c929f694a4 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md @@ -0,0 +1,112 @@ +# Immediate Actions Checklist + +**Date:** November 27, 2025 +**Priority:** Execute in order + +--- + +## Before Starting Implementation + +### 1. Close PR #41 (CRITICAL) + +```bash +gh pr close 41 --comment "Architecture decision changed. Cherry-picking improvements to preserve both pydantic-ai and Agent Framework capabilities." +``` + +### 2. Verify HuggingFace Spaces is Safe + +```bash +# Should show agent framework files exist +git ls-tree --name-only huggingface-upstream/dev -- src/agents/ +git ls-tree --name-only huggingface-upstream/dev -- src/orchestrator_magentic.py +``` + +Expected output: Files should exist (they do as of this writing). + +### 3. Clean Local Environment + +```bash +# Switch to main first +git checkout main + +# Delete problematic branches +git branch -D refactor/pydantic-unification 2>/dev/null || true +git branch -D feat/pubmed-fulltext 2>/dev/null || true + +# Reset local dev to origin/dev +git branch -D dev 2>/dev/null || true +git checkout -b dev origin/dev + +# Verify agent framework code exists +ls src/agents/ +# Expected: __init__.py, analysis_agent.py, hypothesis_agent.py, judge_agent.py, +# magentic_agents.py, report_agent.py, search_agent.py, state.py, tools.py + +ls src/orchestrator_magentic.py +# Expected: file exists +``` + +### 4. Create Fresh Feature Branch + +```bash +git checkout -b feat/dual-mode-architecture origin/dev +``` + +--- + +## Decision Points + +Before proceeding, confirm: + +1. **For hackathon**: Do we need advanced mode, or is simple mode sufficient? + - Simple mode = faster to implement, works today + - Advanced mode = better quality, more work + +2. **Timeline**: How much time do we have? + - If < 1 day: Focus on simple mode only + - If > 1 day: Implement dual-mode + +3. **Dependencies**: Is `agent-framework-core` available? + - Check: `pip index versions agent-framework-core` + - If not on PyPI, may need to install from GitHub + +--- + +## Quick Start (Simple Mode Only) + +If time is limited, implement only simple mode improvements: + +```bash +# On feat/dual-mode-architecture branch + +# 1. Update judges.py to add HuggingFace support +# 2. Update config.py to add HF settings +# 3. Create free_tier_demo.py +# 4. Run make check +# 5. Create PR to dev +``` + +This gives you free-tier capability without touching agent framework code. + +--- + +## Quick Start (Full Dual-Mode) + +If time permits, implement full dual-mode: + +Follow phases 1-6 in `02_IMPLEMENTATION_PHASES.md` + +--- + +## Emergency Rollback + +If anything goes wrong: + +```bash +# Reset to safe state +git checkout main +git branch -D feat/dual-mode-architecture +git checkout -b feat/dual-mode-architecture origin/dev +``` + +Origin/dev is the safe fallback - it has agent framework intact. diff --git a/docs/brainstorming/magentic-pydantic/04_FOLLOWUP_REVIEW_REQUEST.md b/docs/brainstorming/magentic-pydantic/04_FOLLOWUP_REVIEW_REQUEST.md new file mode 100644 index 0000000000000000000000000000000000000000..98b021373d2b3928be993b791ac5a9197503c92a --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/04_FOLLOWUP_REVIEW_REQUEST.md @@ -0,0 +1,158 @@ +# Follow-Up Review Request: Did We Implement Your Feedback? + +**Date:** November 27, 2025 +**Context:** You previously reviewed our dual-mode architecture plan and provided feedback. We have updated the documentation. Please verify we correctly implemented your recommendations. + +--- + +## Your Original Feedback vs Our Changes + +### 1. Naming Confusion Clarification + +**Your feedback:** "You are using Microsoft Agent Framework, but you've named your integration 'Magentic'. This caused the confusion." + +**Our change:** Added Section 4 in `00_SITUATION_AND_PLAN.md`: +```markdown +## 4. CRITICAL: Naming Confusion Clarification + +> **Senior Agent Review Finding:** The codebase uses "magentic" in file names +> (e.g., `orchestrator_magentic.py`, `magentic_agents.py`) but this is **NOT** +> the `magentic` PyPI package by Jacky Liang. It's Microsoft Agent Framework. + +**The naming confusion:** +- `magentic` (PyPI package): A different library for structured LLM outputs +- "Magentic" (in our codebase): Our internal name for Microsoft Agent Framework integration +- `agent-framework-core` (PyPI package): Microsoft's actual multi-agent orchestration framework + +**Recommended future action:** Rename `orchestrator_magentic.py` → `orchestrator_advanced.py` +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 2. Bridge Complexity Warning + +**Your feedback:** "You must ensure MagenticState (context vars) propagates correctly through the pydantic-ai call stack." + +**Our change:** Added Section 10.1 in `01_ARCHITECTURE_SPEC.md`: +```markdown +### 10.1 Bridge Complexity (MEDIUM) + +**Risk:** In Advanced Mode, agents (Agent Framework) wrap handlers (pydantic-ai). +Both are async. Context variables (`MagenticState`) must propagate correctly. + +**Mitigation:** +- pydantic-ai uses standard Python `contextvars`, which naturally propagate through `await` chains +- Test context propagation explicitly in integration tests +- If issues arise, pass state explicitly rather than via context vars +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 3. Integration Drift Warning + +**Your feedback:** "Simple Mode and Advanced Mode might diverge in behavior." + +**Our change:** Added Section 10.2 in `01_ARCHITECTURE_SPEC.md`: +```markdown +### 10.2 Integration Drift (MEDIUM) + +**Risk:** Simple Mode and Advanced Mode might diverge in behavior over time. + +**Mitigation:** +- Both modes MUST call the exact same underlying Tools (`src/tools/*`) and Handlers (`src/agent_factory/*`) +- Handlers are the single source of truth for business logic +- Agents are thin wrappers that delegate to handlers +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 4. Testing Burden Warning + +**Your feedback:** "You now have two distinct orchestrators to maintain. This doubles your integration testing surface area." + +**Our change:** Added Section 10.3 in `01_ARCHITECTURE_SPEC.md`: +```markdown +### 10.3 Testing Burden (LOW-MEDIUM) + +**Risk:** Two distinct orchestrators doubles integration testing surface area. + +**Mitigation:** +- Unit test handlers independently (shared code) +- Integration tests for each mode separately +- End-to-end tests verify same output for same input +``` + +**Status:** ✅ IMPLEMENTED + +--- + +### 5. Rename Recommendation + +**Your feedback:** "Rename `src/orchestrator_magentic.py` to `src/orchestrator_advanced.py`" + +**Our change:** Added Step 3.4 in `02_IMPLEMENTATION_PHASES.md`: +```markdown +### Step 3.4: (OPTIONAL) Rename "Magentic" to "Advanced" + +> **Senior Agent Recommendation:** Rename files to eliminate confusion. + +git mv src/orchestrator_magentic.py src/orchestrator_advanced.py +git mv src/agents/magentic_agents.py src/agents/advanced_agents.py + +**Note:** This is optional for the hackathon. Can be done in a follow-up PR. +``` + +**Status:** ✅ DOCUMENTED (marked as optional for hackathon) + +--- + +### 6. Standardize Wrapper Recommendation + +**Your feedback:** "Create a generic `PydanticAiAgentWrapper(BaseAgent)` class instead of manually wrapping each handler." + +**Our change:** NOT YET DOCUMENTED + +**Status:** ⚠️ NOT IMPLEMENTED - Should we add this? + +--- + +## Questions for Your Review + +1. **Did we correctly implement your feedback?** Are there any misunderstandings in how we interpreted your recommendations? + +2. **Is the "Standardize Wrapper" recommendation critical?** Should we add it to the implementation phases, or is it a nice-to-have for later? + +3. **Dependency versioning:** You noted `agent-framework-core>=1.0.0b251120` might be ephemeral. Should we: + - Pin to a specific version? + - Use a version range? + - Install from GitHub source? + +4. **Anything else we missed?** + +--- + +## Files to Re-Review + +1. `00_SITUATION_AND_PLAN.md` - Added Section 4 (Naming Clarification) +2. `01_ARCHITECTURE_SPEC.md` - Added Sections 10-11 (Risks, Naming) +3. `02_IMPLEMENTATION_PHASES.md` - Added Step 3.4 (Optional Rename) + +--- + +## Current Branch State + +We are now on `feat/dual-mode-architecture` branched from `origin/dev`: +- ✅ Agent framework code intact (`src/agents/`, `src/orchestrator_magentic.py`) +- ✅ Documentation committed +- ❌ PR #41 still open (need to close it) +- ❌ Cherry-pick of pydantic-ai improvements not yet done + +--- + +Please confirm: **GO / NO-GO** to proceed with Phase 1 (cherry-picking pydantic-ai improvements)? diff --git a/docs/brainstorming/magentic-pydantic/REVIEW_PROMPT_FOR_SENIOR_AGENT.md b/docs/brainstorming/magentic-pydantic/REVIEW_PROMPT_FOR_SENIOR_AGENT.md new file mode 100644 index 0000000000000000000000000000000000000000..9f25b1f52a79193a28d4d5f9029cdfece1928be5 --- /dev/null +++ b/docs/brainstorming/magentic-pydantic/REVIEW_PROMPT_FOR_SENIOR_AGENT.md @@ -0,0 +1,113 @@ +# Senior Agent Review Prompt + +Copy and paste everything below this line to a fresh Claude/AI session: + +--- + +## Context + +I am a junior developer working on a HuggingFace hackathon project called DeepCritical. We made a significant architectural mistake and are now trying to course-correct. I need you to act as a **senior staff engineer** and critically review our proposed solution. + +## The Situation + +We almost merged a refactor that would have **deleted** our multi-agent orchestration capability, mistakenly believing that `pydantic-ai` (a library for structured LLM outputs) and Microsoft's `agent-framework` (a framework for multi-agent orchestration) were mutually exclusive alternatives. + +**They are not.** They are complementary: +- `pydantic-ai` ensures LLM responses match Pydantic schemas (type-safe outputs) +- `agent-framework` orchestrates multiple agents working together (coordination layer) + +We now want to implement a **dual-mode architecture** where: +- **Simple Mode (No API key):** Uses only pydantic-ai with HuggingFace free tier +- **Advanced Mode (With API key):** Uses Microsoft Agent Framework for orchestration, with pydantic-ai inside each agent for structured outputs + +## Your Task + +Please perform a **deep, critical review** of: + +1. **The architecture diagram** (image attached: `assets/magentic-pydantic.png`) +2. **Our documentation** (4 files listed below) +3. **The actual codebase** to verify our claims + +## Specific Questions to Answer + +### Architecture Validation +1. Is our understanding correct that pydantic-ai and agent-framework are complementary, not competing? +2. Does the dual-mode architecture diagram accurately represent how these should integrate? +3. Are there any architectural flaws or anti-patterns in our proposed design? + +### Documentation Accuracy +4. Are the branch states we documented accurate? (Check `git log`, `git ls-tree`) +5. Is our understanding of what code exists where correct? +6. Are the implementation phases realistic and in the correct order? +7. Are there any missing steps or dependencies we overlooked? + +### Codebase Reality Check +8. Does `origin/dev` actually have the agent framework code intact? Verify by checking: + - `git ls-tree origin/dev -- src/agents/` + - `git ls-tree origin/dev -- src/orchestrator_magentic.py` +9. What does the current `src/agents/` code actually import? Does it use `agent_framework` or `agent-framework-core`? +10. Is the `agent-framework-core` package actually available on PyPI, or do we need to install from source? + +### Implementation Feasibility +11. Can the cherry-pick strategy we outlined actually work, or are there merge conflicts we're not seeing? +12. Is the mode auto-detection logic sound? +13. What are the risks we haven't identified? + +### Critical Errors Check +14. Did we miss anything critical in our analysis? +15. Are there any factual errors in our documentation? +16. Would a Google/DeepMind senior engineer approve this plan, or would they flag issues? + +## Files to Review + +Please read these files in order: + +1. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/00_SITUATION_AND_PLAN.md` +2. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/01_ARCHITECTURE_SPEC.md` +3. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/02_IMPLEMENTATION_PHASES.md` +4. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/docs/brainstorming/magentic-pydantic/03_IMMEDIATE_ACTIONS.md` + +And the architecture diagram: +5. `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/assets/magentic-pydantic.png` + +## Reference Repositories to Consult + +We have local clones of the source-of-truth repositories: + +- **Original DeepCritical:** `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/reference_repos/DeepCritical/` +- **Microsoft Agent Framework:** `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/reference_repos/agent-framework/` +- **Microsoft AutoGen:** `/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/reference_repos/autogen-microsoft/` + +Please cross-reference our hackathon fork against these to verify architectural alignment. + +## Codebase to Analyze + +Our hackathon fork is at: +`/Users/ray/Desktop/CLARITY-DIGITAL-TWIN/DeepCritical-1/` + +Key files to examine: +- `src/agents/` - Agent framework integration +- `src/agent_factory/judges.py` - pydantic-ai integration +- `src/orchestrator.py` - Simple mode orchestrator +- `src/orchestrator_magentic.py` - Advanced mode orchestrator +- `src/orchestrator_factory.py` - Mode selection +- `pyproject.toml` - Dependencies + +## Expected Output + +Please provide: + +1. **Validation Summary:** Is our plan sound? (YES/NO with explanation) +2. **Errors Found:** List any factual errors in our documentation +3. **Missing Items:** What did we overlook? +4. **Risk Assessment:** What could go wrong? +5. **Recommended Changes:** Specific edits to our documentation or plan +6. **Go/No-Go Recommendation:** Should we proceed with this plan? + +## Tone + +Be brutally honest. If our plan is flawed, say so directly. We would rather know now than after implementation. Don't soften criticism - we need accuracy. + +--- + +END OF PROMPT diff --git a/docs/bugs/FIX_PLAN_MAGENTIC_MODE.md b/docs/bugs/FIX_PLAN_MAGENTIC_MODE.md new file mode 100644 index 0000000000000000000000000000000000000000..a02e1a19a1de2b1937c7d181873879fbb1f1ddfb --- /dev/null +++ b/docs/bugs/FIX_PLAN_MAGENTIC_MODE.md @@ -0,0 +1,227 @@ +# Fix Plan: Magentic Mode Report Generation + +**Related Bug**: `P0_MAGENTIC_MODE_BROKEN.md` +**Approach**: Test-Driven Development (TDD) +**Estimated Scope**: 4 tasks, ~2-3 hours + +--- + +## Problem Summary + +Magentic mode runs but fails to produce readable reports due to: + +1. **Primary Bug**: `MagenticFinalResultEvent.message` returns `ChatMessage` object, not text +2. **Secondary Bug**: Max rounds (3) reached before ReportAgent completes +3. **Tertiary Issues**: Stale "bioRxiv" references in prompts + +--- + +## Fix Order (TDD) + +### Phase 1: Write Failing Tests + +**Task 1.1**: Create test for ChatMessage text extraction + +```python +# tests/unit/test_orchestrator_magentic.py + +def test_process_event_extracts_text_from_chat_message(): + """Final result event should extract text from ChatMessage object.""" + # Arrange: Mock ChatMessage with .content attribute + # Act: Call _process_event with MagenticFinalResultEvent + # Assert: Returned AgentEvent.message is a string, not object repr +``` + +**Task 1.2**: Create test for max rounds configuration + +```python +def test_orchestrator_uses_configured_max_rounds(): + """MagenticOrchestrator should use max_rounds from constructor.""" + # Arrange: Create orchestrator with max_rounds=10 + # Act: Build workflow + # Assert: Workflow has max_round_count=10 +``` + +**Task 1.3**: Create test for bioRxiv reference removal + +```python +def test_task_prompt_references_europe_pmc(): + """Task prompt should reference Europe PMC, not bioRxiv.""" + # Arrange: Create orchestrator + # Act: Check task string in run() + # Assert: Contains "Europe PMC", not "bioRxiv" +``` + +--- + +### Phase 2: Fix ChatMessage Text Extraction + +**File**: `src/orchestrator_magentic.py` +**Lines**: 192-199 + +**Current Code**: +```python +elif isinstance(event, MagenticFinalResultEvent): + text = event.message.text if event.message else "No result" +``` + +**Fixed Code**: +```python +elif isinstance(event, MagenticFinalResultEvent): + if event.message: + # ChatMessage may have .content or .text depending on version + if hasattr(event.message, 'content') and event.message.content: + text = str(event.message.content) + elif hasattr(event.message, 'text') and event.message.text: + text = str(event.message.text) + else: + # Fallback: convert entire message to string + text = str(event.message) + else: + text = "No result generated" +``` + +**Why**: The `agent_framework.ChatMessage` object structure may vary. We need defensive extraction. + +--- + +### Phase 3: Fix Max Rounds Configuration + +**File**: `src/orchestrator_magentic.py` +**Lines**: 97-99 + +**Current Code**: +```python +.with_standard_manager( + chat_client=manager_client, + max_round_count=self._max_rounds, # Already uses config + max_stall_count=3, + max_reset_count=2, +) +``` + +**Issue**: Default `max_rounds` in `__init__` is 10, but workflow may need more for complex queries. + +**Fix**: Verify the value flows through correctly. Add logging. + +```python +logger.info( + "Building Magentic workflow", + max_rounds=self._max_rounds, + max_stall=3, + max_reset=2, +) +``` + +**Also check**: `src/orchestrator_factory.py` passes config correctly: +```python +return MagenticOrchestrator( + max_rounds=config.max_iterations if config else 10, +) +``` + +--- + +### Phase 4: Fix Stale bioRxiv References + +**Files to update**: + +| File | Line | Change | +|------|------|--------| +| `src/orchestrator_magentic.py` | 131 | "bioRxiv" → "Europe PMC" | +| `src/agents/magentic_agents.py` | 32-33 | "bioRxiv" → "Europe PMC" | +| `src/app.py` | 202-203 | "bioRxiv" → "Europe PMC" | + +**Search command to verify**: +```bash +grep -rn "bioRxiv\|biorxiv" src/ +``` + +--- + +## Implementation Checklist + +``` +[ ] Phase 1: Write failing tests + [ ] 1.1 Test ChatMessage text extraction + [ ] 1.2 Test max rounds configuration + [ ] 1.3 Test Europe PMC references + +[ ] Phase 2: Fix ChatMessage extraction + [ ] Update _process_event() in orchestrator_magentic.py + [ ] Run test 1.1 - should pass + +[ ] Phase 3: Fix max rounds + [ ] Add logging to _build_workflow() + [ ] Verify factory passes config correctly + [ ] Run test 1.2 - should pass + +[ ] Phase 4: Fix bioRxiv references + [ ] Update orchestrator_magentic.py task prompt + [ ] Update magentic_agents.py descriptions + [ ] Update app.py UI text + [ ] Run test 1.3 - should pass + [ ] Run grep to verify no remaining refs + +[ ] Final Verification + [ ] make check passes + [ ] All tests pass (108+) + [ ] Manual test: run_magentic.py produces readable report +``` + +--- + +## Test Commands + +```bash +# Run specific test file +uv run pytest tests/unit/test_orchestrator_magentic.py -v + +# Run all tests +uv run pytest tests/unit/ -v + +# Full check +make check + +# Manual integration test +set -a && source .env && set +a +uv run python examples/orchestrator_demo/run_magentic.py "metformin alzheimer" +``` + +--- + +## Success Criteria + +1. `run_magentic.py` outputs a readable research report (not ``) +2. Report includes: Executive Summary, Key Findings, Drug Candidates, References +3. No "Max round count reached" error with default settings +4. No "bioRxiv" references anywhere in codebase +5. All 108+ tests pass +6. `make check` passes + +--- + +## Files Modified + +``` +src/ +├── orchestrator_magentic.py # ChatMessage fix, logging +├── agents/magentic_agents.py # bioRxiv → Europe PMC +└── app.py # bioRxiv → Europe PMC + +tests/unit/ +└── test_orchestrator_magentic.py # NEW: 3 tests +``` + +--- + +## Notes for AI Agent + +When implementing this fix plan: + +1. **DO NOT** create mock data or fake responses +2. **DO** write real tests that verify actual behavior +3. **DO** run `make check` after each phase +4. **DO** test with real OpenAI API key via `.env` +5. **DO** preserve existing functionality - simple mode must still work +6. **DO NOT** over-engineer - minimal changes to fix the specific bugs diff --git a/docs/bugs/P0_ACTIONABLE_FIXES.md b/docs/bugs/P0_ACTIONABLE_FIXES.md deleted file mode 100644 index cc995991a6ee736f02682b80d2dae7dca960cddb..0000000000000000000000000000000000000000 --- a/docs/bugs/P0_ACTIONABLE_FIXES.md +++ /dev/null @@ -1,281 +0,0 @@ -# P0 Actionable Fixes - What to Do - -**Date:** November 27, 2025 -**Status:** ACTIONABLE - ---- - -## Summary: What's Broken and What's Fixable - -| Tool | Problem | Fixable? | How | -|------|---------|----------|-----| -| BioRxiv | API has NO search endpoint | **NO** | Replace with Europe PMC | -| PubMed | No query preprocessing | **YES** | Add query cleaner | -| ClinicalTrials | No filters applied | **YES** | Add filter params | -| Magentic Framework | Nothing wrong | N/A | Already working | - ---- - -## FIX 1: Replace BioRxiv with Europe PMC (30 min) - -### Why BioRxiv Can't Be Fixed - -The bioRxiv API only has this endpoint: -``` -https://api.biorxiv.org/details/{server}/{date-range}/{cursor}/json -``` - -This returns papers **by date**, not by keyword. There is NO search endpoint. - -**Proof:** I queried `medrxiv/2024-01-01/2024-01-02` and got: -- "Global risk of Plasmodium falciparum" (malaria) -- "Multiple Endocrine Neoplasia in India" -- "Acupuncture for Acute Musculoskeletal Pain" - -**None of these are about Long COVID** because the API doesn't search. - -### Europe PMC Has Search + Preprints - -```bash -curl "https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=long+covid+treatment&resultType=core&pageSize=3&format=json" -``` - -Returns 283,058 results including: -- "Long COVID Treatment No Silver Bullets, Only a Few Bronze BBs" ✅ - -### The Fix - -Replace `src/tools/biorxiv.py` with `src/tools/europepmc.py`: - -```python -"""Europe PMC preprint and paper search tool.""" - -import httpx -from src.utils.models import Citation, Evidence - -class EuropePMCTool: - """Search Europe PMC for papers and preprints.""" - - BASE_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search" - - @property - def name(self) -> str: - return "europepmc" - - async def search(self, query: str, max_results: int = 10) -> list[Evidence]: - """Search Europe PMC (includes preprints from bioRxiv/medRxiv).""" - params = { - "query": query, - "resultType": "core", - "pageSize": max_results, - "format": "json", - } - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(self.BASE_URL, params=params) - response.raise_for_status() - - data = response.json() - results = data.get("resultList", {}).get("result", []) - - return [self._to_evidence(r) for r in results] - - def _to_evidence(self, result: dict) -> Evidence: - """Convert Europe PMC result to Evidence.""" - title = result.get("title", "Untitled") - abstract = result.get("abstractText", "No abstract") - doi = result.get("doi", "") - pub_year = result.get("pubYear", "Unknown") - source = result.get("source", "europepmc") - - # Mark preprints - pub_type = result.get("pubTypeList", {}).get("pubType", []) - is_preprint = "Preprint" in pub_type - - content = f"{'[PREPRINT] ' if is_preprint else ''}{abstract[:1800]}" - - return Evidence( - content=content, - citation=Citation( - source="europepmc" if not is_preprint else "preprint", - title=title[:500], - url=f"https://doi.org/{doi}" if doi else "", - date=str(pub_year), - ), - relevance=0.75 if is_preprint else 0.9, - ) -``` - ---- - -## FIX 2: Add PubMed Query Preprocessing (1 hour) - -### Current Problem - -User enters: `What medications show promise for Long COVID?` -PubMed receives: `What medications show promise for Long COVID?` - -The question words pollute the search. - -### The Fix - -Add `src/tools/query_utils.py`: - -```python -"""Query preprocessing utilities.""" - -import re - -# Question words to remove -QUESTION_WORDS = { - "what", "which", "how", "why", "when", "where", "who", - "is", "are", "can", "could", "would", "should", "do", "does", - "show", "promise", "help", "treat", "cure", -} - -# Medical synonyms to expand -SYNONYMS = { - "long covid": ["long COVID", "PASC", "post-COVID syndrome", "post-acute sequelae"], - "alzheimer": ["Alzheimer's disease", "AD", "Alzheimer dementia"], - "cancer": ["neoplasm", "tumor", "malignancy", "carcinoma"], -} - -def preprocess_pubmed_query(raw_query: str) -> str: - """Convert natural language to cleaner PubMed query.""" - # Lowercase - query = raw_query.lower() - - # Remove question marks - query = query.replace("?", "") - - # Remove question words - words = query.split() - words = [w for w in words if w not in QUESTION_WORDS] - query = " ".join(words) - - # Expand synonyms - for term, expansions in SYNONYMS.items(): - if term in query: - # Add OR clause - expansion = " OR ".join([f'"{e}"' for e in expansions]) - query = query.replace(term, f"({expansion})") - - return query.strip() -``` - -Then update `src/tools/pubmed.py`: - -```python -from src.tools.query_utils import preprocess_pubmed_query - -async def search(self, query: str, max_results: int = 10) -> list[Evidence]: - # Preprocess query - clean_query = preprocess_pubmed_query(query) - - search_params = self._build_params( - db="pubmed", - term=clean_query, # Use cleaned query - retmax=max_results, - sort="relevance", - ) - # ... rest unchanged -``` - ---- - -## FIX 3: Add ClinicalTrials.gov Filters (30 min) - -### Current Problem - -Returns ALL trials including withdrawn, terminated, observational studies. - -### The Fix - -The API supports `filter.overallStatus` and other filters. Update `src/tools/clinicaltrials.py`: - -```python -async def search(self, query: str, max_results: int = 10) -> list[Evidence]: - params: dict[str, str | int] = { - "query.term": query, - "pageSize": min(max_results, 100), - "fields": "|".join(self.FIELDS), - # ADD THESE FILTERS: - "filter.overallStatus": "COMPLETED|RECRUITING|ACTIVE_NOT_RECRUITING", - # Only interventional studies (not observational) - "aggFilters": "studyType:int", - } - # ... rest unchanged -``` - -**Note:** I tested the API - it supports filtering but with slightly different syntax. Check the [API docs](https://clinicaltrials.gov/data-api/api). - ---- - -## What NOT to Change - -### Microsoft Agent Framework - WORKING - -I verified: -```python -from agent_framework import MagenticBuilder, ChatAgent -from agent_framework.openai import OpenAIChatClient -# All imports OK - -orchestrator = MagenticOrchestrator(max_rounds=2) -workflow = orchestrator._build_workflow() -# Workflow built successfully -``` - -The Magentic agents are correctly wired: -- SearchAgent → GPT-5.1 ✅ -- JudgeAgent → GPT-5.1 ✅ -- HypothesisAgent → GPT-5.1 ✅ -- ReportAgent → GPT-5.1 ✅ - -**The framework is fine. The tools it calls are broken.** - ---- - -## Priority Order - -1. **Replace BioRxiv** → Immediate, fundamental -2. **Add PubMed preprocessing** → High impact, easy -3. **Add ClinicalTrials filters** → Medium impact, easy - ---- - -## Test After Fixes - -```bash -# Test Europe PMC -uv run python -c " -import asyncio -from src.tools.europepmc import EuropePMCTool -tool = EuropePMCTool() -results = asyncio.run(tool.search('long covid treatment', 3)) -for r in results: - print(r.citation.title) -" - -# Test PubMed with preprocessing -uv run python -c " -from src.tools.query_utils import preprocess_pubmed_query -q = 'What medications show promise for Long COVID?' -print(preprocess_pubmed_query(q)) -# Should output: (\"long COVID\" OR \"PASC\" OR \"post-COVID syndrome\") medications -" -``` - ---- - -## After These Fixes - -The Magentic workflow will: -1. SearchAgent calls `search_pubmed("long COVID treatment")` → Gets RELEVANT papers -2. SearchAgent calls `search_preprints("long COVID treatment")` → Gets RELEVANT preprints via Europe PMC -3. SearchAgent calls `search_clinical_trials("long COVID")` → Gets INTERVENTIONAL trials only -4. JudgeAgent evaluates GOOD evidence -5. HypothesisAgent generates hypotheses from GOOD evidence -6. ReportAgent synthesizes GOOD report - -**The framework will work once we feed it good data.** diff --git a/docs/bugs/P0_CRITICAL_BUGS.md b/docs/bugs/P0_CRITICAL_BUGS.md deleted file mode 100644 index 5caebe7df7fdad6bdd1a15dc02684d9871ddf79f..0000000000000000000000000000000000000000 --- a/docs/bugs/P0_CRITICAL_BUGS.md +++ /dev/null @@ -1,298 +0,0 @@ -# P0 CRITICAL BUGS - Why DeepCritical Produces Garbage Results - -**Date:** November 27, 2025 -**Status:** CRITICAL - App is functionally useless -**Severity:** P0 (Blocker) - -## TL;DR - -The app produces garbage because: -1. **BioRxiv search doesn't work** - returns random papers -2. **Free tier LLM is too dumb** - can't identify drugs -3. **Query construction is naive** - no optimization for PubMed/CT.gov syntax -4. **Loop terminates too early** - 5 iterations isn't enough - ---- - -## P0-001: BioRxiv Search is Fundamentally Broken - -**File:** `src/tools/biorxiv.py:248-286` - -**The Problem:** -The bioRxiv API **DOES NOT SUPPORT KEYWORD SEARCH**. - -The code does this: -```python -# Fetch recent papers (last 90 days, first 100 papers) -url = f"{self.BASE_URL}/{self.server}/{interval}/0/json" -# Then filter client-side for keywords -``` - -**What Actually Happens:** -1. Fetches the first 100 papers from medRxiv in the last 90 days (chronological order) -2. Filters those 100 random papers for query keywords -3. Returns whatever garbage matches - -**Result:** For "Long COVID medications", you get random papers like: -- "Calf muscle structure-function adaptations" -- "Work-Life Balance of Ophthalmologists During COVID" - -These papers contain "COVID" somewhere but have NOTHING to do with Long COVID treatments. - -**Root Cause:** The `/0/json` pagination only returns 100 papers. You'd need to paginate through ALL papers (thousands) to do proper keyword filtering. - -**Fix Options:** -1. **Remove BioRxiv entirely** - It's unusable without proper search API -2. **Use a different preprint aggregator** - Europe PMC has preprints WITH search -3. **Add pagination** - Fetch all papers (slow, expensive) -4. **Use Semantic Scholar API** - Has preprints and proper search - ---- - -## P0-002: Free Tier LLM Cannot Perform Drug Identification - -**File:** `src/agent_factory/judges.py:153-211` - -**The Problem:** -Without an API key, the app uses `HFInferenceJudgeHandler` with: -- Llama 3.1 8B Instruct -- Mistral 7B Instruct - -These are **7-8 billion parameter models**. They cannot: -- Reliably parse complex biomedical abstracts -- Identify drug candidates from scientific text -- Generate structured JSON output consistently -- Reason about mechanism of action - -**Evidence of Failure:** -```python -# From MockJudgeHandler - the honest fallback when LLM fails -drug_candidates=[ - "Drug identification requires AI analysis", - "Enter API key above for full results", -] -``` - -The team KNEW the free tier can't identify drugs and added this message. - -**Root Cause:** Drug repurposing requires understanding: -- Drug mechanisms -- Disease pathophysiology -- Clinical trial phases -- Statistical significance - -This requires GPT-4 / Claude Sonnet class models (100B+ parameters). - -**Fix Options:** -1. **Require API key** - No free tier, be honest -2. **Use larger HF models** - Llama 70B or Mixtral 8x7B (expensive on free tier) -3. **Hybrid approach** - Use free tier for search, require paid for synthesis - ---- - -## P0-003: PubMed Query Not Optimized - -**File:** `src/tools/pubmed.py:54-71` - -**The Problem:** -The query is passed directly to PubMed without optimization: -```python -search_params = self._build_params( - db="pubmed", - term=query, # Raw user query! - retmax=max_results, - sort="relevance", -) -``` - -**What User Enters:** "What medications show promise for Long COVID?" - -**What PubMed Receives:** `What medications show promise for Long COVID?` - -**What PubMed Should Receive:** -``` -("long covid"[Title/Abstract] OR "post-COVID"[Title/Abstract] OR "PASC"[Title/Abstract]) -AND (drug[Title/Abstract] OR treatment[Title/Abstract] OR medication[Title/Abstract] OR therapy[Title/Abstract]) -AND (clinical trial[Publication Type] OR randomized[Title/Abstract]) -``` - -**Root Cause:** No query preprocessing or medical term expansion. - -**Fix Options:** -1. **Add query preprocessor** - Extract medical entities, expand synonyms -2. **Use MeSH terms** - PubMed's controlled vocabulary for better recall -3. **LLM query generation** - Use LLM to generate optimized PubMed query - ---- - -## P0-004: Loop Terminates Too Early - -**File:** `src/app.py:42-45` and `src/utils/models.py` - -**The Problem:** -```python -config = OrchestratorConfig( - max_iterations=5, - max_results_per_tool=10, -) -``` - -5 iterations is not enough to: -1. Search multiple variations of the query -2. Gather enough evidence for the Judge to synthesize -3. Refine queries based on initial results - -**Evidence:** The user's output shows "Max Iterations Reached" with only 6 sources. - -**Root Cause:** Conservative defaults to avoid API costs, but makes app useless. - -**Fix Options:** -1. **Increase default to 10-15** - More iterations = better results -2. **Dynamic termination** - Stop when confidence > threshold, not iteration count -3. **Parallel query expansion** - Run more queries per iteration - ---- - -## P0-005: No Query Understanding Layer - -**Files:** `src/orchestrator.py`, `src/tools/search_handler.py` - -**The Problem:** -There's no NLU (Natural Language Understanding) layer. The system: -1. Takes raw user query -2. Passes directly to search tools -3. No entity extraction -4. No intent classification -5. No query expansion - -For drug repurposing, you need to extract: -- **Disease:** "Long COVID" → [Long COVID, PASC, Post-COVID syndrome, chronic COVID] -- **Drug intent:** "medications" → [drugs, treatments, therapeutics, interventions] -- **Evidence type:** "show promise" → [clinical trials, efficacy, RCT] - -**Root Cause:** No preprocessing pipeline between user input and search execution. - -**Fix Options:** -1. **Add entity extraction** - Use BioBERT or PubMedBERT for medical NER -2. **Add query expansion** - Use medical ontologies (UMLS, MeSH) -3. **LLM preprocessing** - Use LLM to generate search strategy before searching - ---- - -## P0-006: ClinicalTrials.gov Results Not Filtered - -**File:** `src/tools/clinicaltrials.py` - -**The Problem:** -ClinicalTrials.gov returns ALL matching trials including: -- Withdrawn trials -- Terminated trials -- Not yet recruiting -- Observational studies (not interventional) - -For drug repurposing, you want: -- Interventional studies -- Phase 2+ (has safety/efficacy data) -- Completed or with results - -**Root Cause:** No filtering of trial metadata. - ---- - -## Summary: Why This App Produces Garbage - -``` -User Query: "What medications show promise for Long COVID?" - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ NO QUERY PREPROCESSING │ -│ - No entity extraction │ -│ - No synonym expansion │ -│ - No medical term normalization │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ BROKEN SEARCH LAYER │ -│ - PubMed: Raw query, no MeSH, gets 1 result │ -│ - BioRxiv: Returns random papers (API doesn't support search)│ -│ - ClinicalTrials: Returns all trials, no filtering │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ GARBAGE EVIDENCE │ -│ - 6 papers, most irrelevant │ -│ - "Calf muscle adaptations" (mentions COVID once) │ -│ - "Ophthalmologist work-life balance" │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ DUMB JUDGE (Free Tier) │ -│ - Llama 8B can't identify drugs from garbage │ -│ - JSON parsing fails │ -│ - Falls back to "Drug identification requires AI analysis" │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ LOOP HITS MAX (5 iterations) │ -│ - Never finds enough good evidence │ -│ - Never synthesizes anything useful │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ - GARBAGE OUTPUT -``` - ---- - -## What Would Make This Actually Work - -### Minimum Viable Fix (1-2 days) - -1. **Remove BioRxiv** - It doesn't work -2. **Require API key** - Be honest that free tier is useless -3. **Add basic query preprocessing** - Strip question words, expand COVID synonyms -4. **Increase iterations to 10** - -### Proper Fix (1-2 weeks) - -1. **Query Understanding Layer** - - Medical NER (BioBERT/SciBERT) - - Query expansion with MeSH/UMLS - - Intent classification (drug discovery vs mechanism vs safety) - -2. **Optimized Search** - - PubMed: Proper query syntax with MeSH terms - - ClinicalTrials: Filter by phase, status, intervention type - - Replace BioRxiv with Europe PMC (has preprints + search) - -3. **Evidence Ranking** - - Score by publication type (RCT > cohort > case report) - - Score by journal impact factor - - Score by recency - - Score by citation count - -4. **Proper LLM Pipeline** - - Use GPT-4 / Claude for synthesis - - Structured extraction of: drug, mechanism, evidence level, effect size - - Multi-step reasoning: identify → validate → rank → synthesize - ---- - -## The Hard Truth - -Building a drug repurposing agent that works is HARD. The state of the art is: - -- **Drug2Disease (IBM)** - Uses knowledge graphs + ML -- **COVID-KG (Stanford)** - Dedicated COVID knowledge graph -- **Literature Mining at scale (PubMed)** - Millions of papers, not 10 - -This hackathon project is fundamentally a **search wrapper with an LLM prompt**. That's not enough. - -To make it useful: -1. Either scope it down (e.g., "find clinical trials for X disease") -2. Or invest serious engineering in the NLU + search + ranking pipeline diff --git a/docs/bugs/P0_MAGENTIC_AND_SEARCH_AUDIT.md b/docs/bugs/P0_MAGENTIC_AND_SEARCH_AUDIT.md deleted file mode 100644 index 85c32a81fd35a245e1683319e22e3d25b6d9ebc0..0000000000000000000000000000000000000000 --- a/docs/bugs/P0_MAGENTIC_AND_SEARCH_AUDIT.md +++ /dev/null @@ -1,249 +0,0 @@ -# P0 Audit: Microsoft Agent Framework (Magentic) & Search Tools - -**Date:** November 27, 2025 -**Auditor:** Claude Code -**Status:** VERIFIED - ---- - -## TL;DR - -| Component | Status | Verdict | -|-----------|--------|---------| -| Microsoft Agent Framework | ✅ WORKING | Correctly wired, no bugs | -| GPT-5.1 Model Config | ✅ CORRECT | Using `gpt-5.1` as configured | -| Search Tools | ❌ BROKEN | Root cause of garbage results | - -**The orchestration framework is fine. The search layer is garbage.** - ---- - -## Microsoft Agent Framework Verification - -### Import Test: PASSED -```python -from agent_framework import MagenticBuilder, ChatAgent -from agent_framework.openai import OpenAIChatClient -# All imports successful -``` - -### Agent Creation Test: PASSED -```python -from src.agents.magentic_agents import create_search_agent -search_agent = create_search_agent() -# SearchAgent created: SearchAgent -# Description: Searches biomedical databases (PubMed, ClinicalTrials.gov, bioRxiv) -``` - -### Workflow Build Test: PASSED -```python -from src.orchestrator_magentic import MagenticOrchestrator -orchestrator = MagenticOrchestrator(max_rounds=2) -workflow = orchestrator._build_workflow() -# Workflow built successfully: -``` - -### Model Configuration: CORRECT -```python -settings.openai_model = "gpt-5.1" # ✅ Using GPT-5.1, not GPT-4o -settings.openai_api_key = True # ✅ API key is set -``` - ---- - -## What Magentic Provides (Working) - -1. **Multi-Agent Coordination** - - Manager agent orchestrates SearchAgent, JudgeAgent, HypothesisAgent, ReportAgent - - Uses `MagenticBuilder().with_standard_manager()` for coordination - -2. **ChatAgent Pattern** - - Each agent has internal LLM (GPT-5.1) - - Can call tools via `@ai_function` decorator - - Has proper instructions for domain-specific tasks - -3. **Workflow Streaming** - - Events: `MagenticAgentMessageEvent`, `MagenticFinalResultEvent`, etc. - - Real-time UI updates via `workflow.run_stream(task)` - -4. **State Management** - - `MagenticState` persists evidence across agents - - `get_bibliography()` tool for ReportAgent - ---- - -## What's Actually Broken: The Search Tools - -### File: `src/agents/tools.py` - -The Magentic agents call these tools: -- `search_pubmed` → Uses `PubMedTool` -- `search_clinical_trials` → Uses `ClinicalTrialsTool` -- `search_preprints` → Uses `BioRxivTool` - -**These tools are the problem, not the framework.** - ---- - -## Search Tool Bugs (Detailed) - -### BUG 1: BioRxiv API Does Not Support Search - -**File:** `src/tools/biorxiv.py:248-286` - -```python -# This fetches the FIRST 100 papers from the last 90 days -# It does NOT search by keyword - the API doesn't support that -url = f"{self.BASE_URL}/{self.server}/{interval}/0/json" - -# Then filters client-side for keywords -matching = self._filter_by_keywords(papers, query_terms, max_results) -``` - -**Problem:** -- Fetches 100 random chronological papers -- Filters for ANY keyword match in title/abstract -- "Long COVID medications" returns papers about "calf muscles" because they mention "COVID" once - -**Fix:** Remove BioRxiv or use Europe PMC (which has actual search) - ---- - -### BUG 2: PubMed Query Not Optimized - -**File:** `src/tools/pubmed.py:54-71` - -```python -search_params = self._build_params( - db="pubmed", - term=query, # RAW USER QUERY - no preprocessing! - retmax=max_results, - sort="relevance", -) -``` - -**Problem:** -- User enters: "What medications show promise for Long COVID?" -- PubMed receives: `What medications show promise for Long COVID?` -- Should receive: `("long covid"[Title/Abstract] OR "PASC"[Title/Abstract]) AND (treatment[Title/Abstract] OR drug[Title/Abstract])` - -**Fix:** Add query preprocessing: -1. Strip question words (what, which, how, etc.) -2. Expand medical synonyms (Long COVID → PASC, Post-COVID) -3. Use MeSH terms for better recall - ---- - -### BUG 3: ClinicalTrials.gov No Filtering - -**File:** `src/tools/clinicaltrials.py` - -Returns ALL trials including: -- Withdrawn trials -- Terminated trials -- Observational studies (not drug interventions) -- Phase 1 (no efficacy data) - -**Fix:** Filter by: -- `studyType=INTERVENTIONAL` -- `phase=PHASE2,PHASE3,PHASE4` -- `status=COMPLETED,ACTIVE_NOT_RECRUITING,RECRUITING` - ---- - -## Evidence: Garbage In → Garbage Out - -When the Magentic SearchAgent calls these tools: - -``` -SearchAgent: "Find evidence for Long COVID medications" - │ - ▼ -search_pubmed("Long COVID medications") - → Returns 1 semi-relevant paper (raw query hits) - -search_preprints("Long COVID medications") - → Returns garbage (BioRxiv API doesn't search) - → "Calf muscle adaptations" (has "COVID" somewhere) - → "Ophthalmologist work-life balance" (mentions COVID) - -search_clinical_trials("Long COVID medications") - → Returns all trials, no filtering - │ - ▼ -JudgeAgent receives garbage evidence - │ - ▼ -HypothesisAgent can't generate good hypotheses from garbage - │ - ▼ -ReportAgent produces garbage report -``` - -**The framework is doing its job. It's orchestrating agents correctly. But the agents are being fed garbage data.** - ---- - -## Recommended Fixes - -### Priority 1: Delete or Fix BioRxiv (30 min) - -**Option A: Delete it** -```python -# In src/agents/tools.py, remove: -# from src.tools.biorxiv import BioRxivTool -# _biorxiv = BioRxivTool() -# @ai_function search_preprints(...) -``` - -**Option B: Replace with Europe PMC** -Europe PMC has preprints AND proper search API: -``` -https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=long+covid+treatment&format=json -``` - -### Priority 2: Fix PubMed Query (1 hour) - -Add query preprocessor: -```python -def preprocess_query(raw_query: str) -> str: - """Convert natural language to PubMed query syntax.""" - # Strip question words - # Expand medical synonyms - # Add field tags [Title/Abstract] - # Return optimized query -``` - -### Priority 3: Filter ClinicalTrials (30 min) - -Add parameters to API call: -```python -params = { - "query.term": query, - "filter.overallStatus": "COMPLETED,RECRUITING", - "filter.studyType": "INTERVENTIONAL", - "pageSize": max_results, -} -``` - ---- - -## Conclusion - -**Microsoft Agent Framework: NO BUGS FOUND** -- Imports work ✅ -- Agent creation works ✅ -- Workflow building works ✅ -- Model config correct (GPT-5.1) ✅ -- Streaming events work ✅ - -**Search Tools: CRITICALLY BROKEN** -- BioRxiv: API doesn't support search (fundamental) -- PubMed: No query optimization (fixable) -- ClinicalTrials: No filtering (fixable) - -**Recommendation:** -1. Delete BioRxiv immediately (unusable) -2. Add PubMed query preprocessing -3. Add ClinicalTrials filtering -4. Then the Magentic multi-agent system will work as designed diff --git a/docs/bugs/P0_MAGENTIC_MODE_BROKEN.md b/docs/bugs/P0_MAGENTIC_MODE_BROKEN.md new file mode 100644 index 0000000000000000000000000000000000000000..5df9c0ee27df1b416923f445b08be928f34432a2 --- /dev/null +++ b/docs/bugs/P0_MAGENTIC_MODE_BROKEN.md @@ -0,0 +1,116 @@ +# P0 Bug: Magentic Mode Returns ChatMessage Object Instead of Report Text + +**Status**: OPEN +**Priority**: P0 (Critical) +**Date**: 2025-11-27 + +--- + +## Actual Bug Found (Not What We Thought) + +**The OpenAI key works fine.** The real bug is different: + +### The Problem + +When Magentic mode completes, the final report returns a `ChatMessage` object instead of the actual text: + +``` +FINAL REPORT: + +``` + +### Evidence + +Full test output shows: +1. Magentic orchestrator starts correctly +2. SearchAgent finds evidence +3. HypothesisAgent generates hypotheses +4. JudgeAgent evaluates +5. **BUT**: Final output is `ChatMessage` object, not text + +### Root Cause + +In `src/orchestrator_magentic.py` line 193: + +```python +elif isinstance(event, MagenticFinalResultEvent): + text = event.message.text if event.message else "No result" +``` + +The `event.message` is a `ChatMessage` object, and `.text` may not extract the content correctly, or the message structure changed in the agent-framework library. + +--- + +## Secondary Issue: Max Rounds Reached + +The orchestrator hits max rounds before producing a report: + +``` +[ERROR] Magentic Orchestrator: Max round count reached +``` + +This means the workflow times out before the ReportAgent synthesizes the final output. + +--- + +## What Works + +- OpenAI API key: **Works** (loaded from .env) +- SearchAgent: **Works** (finds evidence from PubMed, ClinicalTrials, Europe PMC) +- HypothesisAgent: **Works** (generates Drug -> Target -> Pathway chains) +- JudgeAgent: **Partial** (evaluates but sometimes loses context) + +--- + +## Files to Fix + +| File | Line | Issue | +|------|------|-------| +| `src/orchestrator_magentic.py` | 193 | `event.message.text` returns object, not string | +| `src/orchestrator_magentic.py` | 97-99 | `max_round_count=3` too low for full pipeline | + +--- + +## Suggested Fix + +```python +# In _process_event, line 192-199 +elif isinstance(event, MagenticFinalResultEvent): + # Handle ChatMessage object properly + if event.message: + if hasattr(event.message, 'content'): + text = event.message.content + elif hasattr(event.message, 'text'): + text = event.message.text + else: + text = str(event.message) + else: + text = "No result" +``` + +And increase rounds: + +```python +# In _build_workflow, line 97 +max_round_count=self._max_rounds, # Use configured value, default 10 +``` + +--- + +## Test Command + +```bash +set -a && source .env && set +a && uv run python examples/orchestrator_demo/run_magentic.py "metformin alzheimer" +``` + +--- + +## Simple Mode Works + +For reference, simple mode produces full reports: + +```bash +uv run python examples/orchestrator_demo/run_agent.py "metformin alzheimer" +``` + +Output includes structured report with Drug Candidates, Key Findings, etc. diff --git a/docs/bugs/P1_GRADIO_SETTINGS_CLEANUP.md b/docs/bugs/P1_GRADIO_SETTINGS_CLEANUP.md new file mode 100644 index 0000000000000000000000000000000000000000..7197b1ec4ef09ea29b98cc447994264e6b4b0f54 --- /dev/null +++ b/docs/bugs/P1_GRADIO_SETTINGS_CLEANUP.md @@ -0,0 +1,81 @@ +# P1 Bug: Gradio Settings Accordion Not Collapsing + +**Priority**: P1 (UX Bug) +**Status**: OPEN +**Date**: 2025-11-27 +**Target Component**: `src/app.py` + +--- + +## 1. Problem Description + +The "Settings" accordion in the Gradio UI (containing Orchestrator Mode, API Key, Provider) fails to collapse, even when configured with `open=False`. It remains permanently expanded, cluttering the interface and obscuring the chat history. + +### Symptoms +- Accordion arrow toggles visually, but content remains visible. +- Occurs in both local development (`uv run src/app.py`) and HuggingFace Spaces. + +--- + +## 2. Root Cause Analysis + +**Definitive Cause**: Nested `Blocks` Context Bug. +`gr.ChatInterface` is itself a high-level abstraction that creates a `gr.Blocks` context. Wrapping `gr.ChatInterface` inside an external `with gr.Blocks():` context causes event listener conflicts, specifically breaking the JavaScript state management for `additional_inputs_accordion`. + +**Reference**: [Gradio Issue #8861](https://github.com/gradio-app/gradio/issues/8861) confirms that `additional_inputs_accordion` malfunctions when `ChatInterface` is not the top-level block. + +--- + +## 3. Solution Strategy: "The Unwrap Fix" + +We will remove the redundant `gr.Blocks` wrapper. This restores the native behavior of `ChatInterface`, ensuring the accordion respects `open=False`. + +### Implementation Plan + +**Refactor `src/app.py` / `create_demo()`**: + +1. **Remove** the `with gr.Blocks() as demo:` context manager. +2. **Instantiate** `gr.ChatInterface` directly as the `demo` object. +3. **Migrate UI Elements**: + * **Header**: Move the H1/Title text into the `title` parameter of `ChatInterface`. + * **Footer**: Move the footer text ("MCP Server Active...") into the `description` parameter. `ChatInterface` supports Markdown in `description`, making it the ideal place for static info below the title but above the chat. + +### Before (Buggy) +```python +def create_demo(): + with gr.Blocks() as demo: # <--- CAUSE OF BUG + gr.Markdown("# Title") + gr.ChatInterface(..., additional_inputs_accordion=gr.Accordion(open=False)) + gr.Markdown("Footer") + return demo +``` + +### After (Correct) +```python +def create_demo(): + return gr.ChatInterface( # <--- FIX: Top-level component + ..., + title="🧬 DeepCritical", + description="*AI-Powered Drug Repurposing Agent...*\n\n---\n**MCP Server Active**...", + additional_inputs_accordion=gr.Accordion(label="⚙️ Settings", open=False) + ) +``` + +--- + +## 4. Validation + +1. **Run**: `uv run python src/app.py` +2. **Check**: Open `http://localhost:7860` +3. **Verify**: + * Settings accordion starts **COLLAPSED**. + * Header title ("DeepCritical") is visible. + * Footer text ("MCP Server Active") is visible in the description area. + * Chat functionality works (Magentic/Simple modes). + +--- + +## 5. Constraints & Notes + +- **Layout**: We lose the ability to place arbitrary elements *below* the chat box (footer will move to top, under title), but this is an acceptable trade-off for a working UI. +- **CSS**: `ChatInterface` handles its own CSS; any custom class styling from the previous footer will be standardized to the description text style. \ No newline at end of file diff --git a/docs/bugs/PHASE_00_IMPLEMENTATION_ORDER.md b/docs/bugs/PHASE_00_IMPLEMENTATION_ORDER.md deleted file mode 100644 index 37d4d5a356a7c5ad03c7997ca3254348513273fb..0000000000000000000000000000000000000000 --- a/docs/bugs/PHASE_00_IMPLEMENTATION_ORDER.md +++ /dev/null @@ -1,156 +0,0 @@ -# Phase 00: Implementation Order & Summary - -**Total Effort:** 5-8 hours -**Parallelizable:** Yes (all 3 phases are independent) - ---- - -## Executive Summary - -The DeepCritical drug repurposing agent produces garbage results because the search tools are broken: - -| Tool | Problem | Fix | -|------|---------|-----| -| BioRxiv | API doesn't support search | Replace with Europe PMC | -| PubMed | Raw queries, no preprocessing | Add query cleaner | -| ClinicalTrials | No filtering | Add status/type filters | - -**The Microsoft Agent Framework (Magentic) is working correctly.** The orchestration layer is fine. The data layer is broken. - ---- - -## Phase Specs - -| Phase | Title | Effort | Priority | Dependencies | -|-------|-------|--------|----------|--------------| -| **01** | [Replace BioRxiv with Europe PMC](./PHASE_01_REPLACE_BIORXIV.md) | 2-3 hrs | P0 | None | -| **02** | [PubMed Query Preprocessing](./PHASE_02_PUBMED_QUERY_PREPROCESSING.md) | 2-3 hrs | P0 | None | -| **03** | [ClinicalTrials Filtering](./PHASE_03_CLINICALTRIALS_FILTERING.md) | 1-2 hrs | P1 | None | - ---- - -## Recommended Execution Order - -Since all phases are independent, they can be done in parallel by different developers. - -**If doing sequentially, order by impact:** - -1. **Phase 01** - BioRxiv is completely broken (returns random papers) -2. **Phase 02** - PubMed is partially broken (returns suboptimal results) -3. **Phase 03** - ClinicalTrials returns too much noise - ---- - -## TDD Workflow (Per Phase) - -``` -1. Write failing tests -2. Run tests (confirm they fail) -3. Implement fix -4. Run tests (confirm they pass) -5. Run ALL tests (confirm no regressions) -6. Manual verification -7. Commit -``` - ---- - -## Verification After All Phases - -After completing all 3 phases, run this integration test: - -```bash -# Full system test -uv run python -c " -import asyncio -from src.tools.europepmc import EuropePMCTool -from src.tools.pubmed import PubMedTool -from src.tools.clinicaltrials import ClinicalTrialsTool - -async def test_all(): - query = 'long covid treatment' - - print('=== Europe PMC (Preprints) ===') - epmc = EuropePMCTool() - results = await epmc.search(query, 2) - for r in results: - print(f' - {r.citation.title[:60]}...') - - print() - print('=== PubMed ===') - pm = PubMedTool() - results = await pm.search(query, 2) - for r in results: - print(f' - {r.citation.title[:60]}...') - - print() - print('=== ClinicalTrials.gov ===') - ct = ClinicalTrialsTool() - results = await ct.search(query, 2) - for r in results: - print(f' - {r.citation.title[:60]}...') - -asyncio.run(test_all()) -" -``` - -**Expected:** All results should be relevant to "long covid treatment" - ---- - -## Test Magentic Integration - -After all phases are complete, test the full Magentic workflow: - -```bash -# Test Magentic mode (requires OPENAI_API_KEY) -uv run python -c " -import asyncio -from src.orchestrator_magentic import MagenticOrchestrator - -async def test_magentic(): - orchestrator = MagenticOrchestrator(max_rounds=3) - - print('Running Magentic workflow...') - async for event in orchestrator.run('What drugs show promise for Long COVID?'): - print(f'[{event.type}] {event.message[:100]}...') - -asyncio.run(test_magentic()) -" -``` - ---- - -## Files Changed (All Phases) - -| File | Phase | Action | -|------|-------|--------| -| `src/tools/europepmc.py` | 01 | CREATE | -| `tests/unit/tools/test_europepmc.py` | 01 | CREATE | -| `src/agents/tools.py` | 01 | MODIFY | -| `src/tools/search_handler.py` | 01 | MODIFY | -| `src/tools/biorxiv.py` | 01 | DELETE | -| `tests/unit/tools/test_biorxiv.py` | 01 | DELETE | -| `src/tools/query_utils.py` | 02 | CREATE | -| `tests/unit/tools/test_query_utils.py` | 02 | CREATE | -| `src/tools/pubmed.py` | 02 | MODIFY | -| `src/tools/clinicaltrials.py` | 03 | MODIFY | -| `tests/unit/tools/test_clinicaltrials.py` | 03 | MODIFY | - ---- - -## Success Criteria (Overall) - -- [ ] All unit tests pass -- [ ] All integration tests pass (real APIs) -- [ ] Query "What drugs show promise for Long COVID?" returns relevant results from all 3 sources -- [ ] Magentic workflow produces a coherent research report -- [ ] No regressions in existing functionality - ---- - -## Related Documentation - -- [P0 Critical Bugs](./P0_CRITICAL_BUGS.md) - Root cause analysis -- [P0 Magentic Audit](./P0_MAGENTIC_AND_SEARCH_AUDIT.md) - Framework verification -- [P0 Actionable Fixes](./P0_ACTIONABLE_FIXES.md) - Fix summaries diff --git a/docs/bugs/PHASE_01_REPLACE_BIORXIV.md b/docs/bugs/PHASE_01_REPLACE_BIORXIV.md deleted file mode 100644 index da30dd757e4d0d8ef3b252035e294aa3aa8155f1..0000000000000000000000000000000000000000 --- a/docs/bugs/PHASE_01_REPLACE_BIORXIV.md +++ /dev/null @@ -1,371 +0,0 @@ -# Phase 01: Replace BioRxiv with Europe PMC - -**Priority:** P0 - Critical -**Effort:** 2-3 hours -**Dependencies:** None - ---- - -## Problem Statement - -The BioRxiv API does not support keyword search. It only returns papers by date range, resulting in completely irrelevant results for any query. - -## Success Criteria - -- [ ] `search_preprints("long covid treatment")` returns papers actually about Long COVID -- [ ] All existing tests pass -- [ ] New tests cover Europe PMC integration - ---- - -## TDD Implementation Order - -### Step 1: Write Failing Test - -**File:** `tests/unit/tools/test_europepmc.py` - -```python -"""Unit tests for Europe PMC tool.""" - -import pytest -from unittest.mock import AsyncMock, patch - -from src.tools.europepmc import EuropePMCTool -from src.utils.models import Evidence - - -@pytest.mark.unit -class TestEuropePMCTool: - """Tests for EuropePMCTool.""" - - @pytest.fixture - def tool(self): - return EuropePMCTool() - - def test_tool_name(self, tool): - assert tool.name == "europepmc" - - @pytest.mark.asyncio - async def test_search_returns_evidence(self, tool): - """Test that search returns Evidence objects.""" - mock_response = { - "resultList": { - "result": [ - { - "id": "12345", - "title": "Long COVID Treatment Study", - "abstractText": "This study examines treatments for Long COVID.", - "doi": "10.1234/test", - "pubYear": "2024", - "source": "MED", - "pubTypeList": {"pubType": ["research-article"]}, - } - ] - } - } - - with patch("httpx.AsyncClient") as mock_client: - mock_instance = AsyncMock() - mock_client.return_value.__aenter__.return_value = mock_instance - mock_instance.get.return_value.json.return_value = mock_response - mock_instance.get.return_value.raise_for_status = lambda: None - - results = await tool.search("long covid treatment", max_results=5) - - assert len(results) == 1 - assert isinstance(results[0], Evidence) - assert "Long COVID Treatment Study" in results[0].citation.title - - @pytest.mark.asyncio - async def test_search_marks_preprints(self, tool): - """Test that preprints are marked correctly.""" - mock_response = { - "resultList": { - "result": [ - { - "id": "PPR12345", - "title": "Preprint Study", - "abstractText": "Abstract text", - "doi": "10.1234/preprint", - "pubYear": "2024", - "source": "PPR", - "pubTypeList": {"pubType": ["Preprint"]}, - } - ] - } - } - - with patch("httpx.AsyncClient") as mock_client: - mock_instance = AsyncMock() - mock_client.return_value.__aenter__.return_value = mock_instance - mock_instance.get.return_value.json.return_value = mock_response - mock_instance.get.return_value.raise_for_status = lambda: None - - results = await tool.search("test", max_results=5) - - assert "[PREPRINT]" in results[0].content - assert results[0].citation.source == "preprint" - - @pytest.mark.asyncio - async def test_search_empty_results(self, tool): - """Test handling of empty results.""" - mock_response = {"resultList": {"result": []}} - - with patch("httpx.AsyncClient") as mock_client: - mock_instance = AsyncMock() - mock_client.return_value.__aenter__.return_value = mock_instance - mock_instance.get.return_value.json.return_value = mock_response - mock_instance.get.return_value.raise_for_status = lambda: None - - results = await tool.search("nonexistent query xyz", max_results=5) - - assert results == [] - - -@pytest.mark.integration -class TestEuropePMCIntegration: - """Integration tests with real API.""" - - @pytest.mark.asyncio - async def test_real_api_call(self): - """Test actual API returns relevant results.""" - tool = EuropePMCTool() - results = await tool.search("long covid treatment", max_results=3) - - assert len(results) > 0 - # At least one result should mention COVID - titles = " ".join([r.citation.title.lower() for r in results]) - assert "covid" in titles or "sars" in titles -``` - -### Step 2: Implement Europe PMC Tool - -**File:** `src/tools/europepmc.py` - -```python -"""Europe PMC search tool - replaces BioRxiv.""" - -from typing import Any - -import httpx -from tenacity import retry, stop_after_attempt, wait_exponential - -from src.utils.exceptions import SearchError -from src.utils.models import Citation, Evidence - - -class EuropePMCTool: - """ - Search Europe PMC for papers and preprints. - - Europe PMC indexes: - - PubMed/MEDLINE articles - - PMC full-text articles - - Preprints from bioRxiv, medRxiv, ChemRxiv, etc. - - Patents and clinical guidelines - - API Docs: https://europepmc.org/RestfulWebService - """ - - BASE_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search" - - @property - def name(self) -> str: - return "europepmc" - - @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=1, max=10), - reraise=True, - ) - async def search(self, query: str, max_results: int = 10) -> list[Evidence]: - """ - Search Europe PMC for papers matching query. - - Args: - query: Search keywords - max_results: Maximum results to return - - Returns: - List of Evidence objects - """ - params = { - "query": query, - "resultType": "core", - "pageSize": min(max_results, 100), - "format": "json", - } - - async with httpx.AsyncClient(timeout=30.0) as client: - try: - response = await client.get(self.BASE_URL, params=params) - response.raise_for_status() - - data = response.json() - results = data.get("resultList", {}).get("result", []) - - return [self._to_evidence(r) for r in results[:max_results]] - - except httpx.HTTPStatusError as e: - raise SearchError(f"Europe PMC API error: {e}") from e - except httpx.RequestError as e: - raise SearchError(f"Europe PMC connection failed: {e}") from e - - def _to_evidence(self, result: dict[str, Any]) -> Evidence: - """Convert Europe PMC result to Evidence.""" - title = result.get("title", "Untitled") - abstract = result.get("abstractText", "No abstract available.") - doi = result.get("doi", "") - pub_year = result.get("pubYear", "Unknown") - - # Get authors - author_list = result.get("authorList", {}).get("author", []) - authors = [a.get("fullName", "") for a in author_list[:5] if a.get("fullName")] - - # Check if preprint - pub_types = result.get("pubTypeList", {}).get("pubType", []) - is_preprint = "Preprint" in pub_types - source_db = result.get("source", "europepmc") - - # Build content - preprint_marker = "[PREPRINT - Not peer-reviewed] " if is_preprint else "" - content = f"{preprint_marker}{abstract[:1800]}" - - # Build URL - if doi: - url = f"https://doi.org/{doi}" - elif result.get("pmid"): - url = f"https://pubmed.ncbi.nlm.nih.gov/{result['pmid']}/" - else: - url = f"https://europepmc.org/article/{source_db}/{result.get('id', '')}" - - return Evidence( - content=content[:2000], - citation=Citation( - source="preprint" if is_preprint else "europepmc", - title=title[:500], - url=url, - date=str(pub_year), - authors=authors, - ), - relevance=0.75 if is_preprint else 0.9, - ) -``` - -### Step 3: Update Magentic Tools - -**File:** `src/agents/tools.py` - Replace biorxiv import: - -```python -# REMOVE: -# from src.tools.biorxiv import BioRxivTool -# _biorxiv = BioRxivTool() - -# ADD: -from src.tools.europepmc import EuropePMCTool -_europepmc = EuropePMCTool() - -# UPDATE search_preprints function: -@ai_function -async def search_preprints(query: str, max_results: int = 10) -> str: - """Search Europe PMC for preprints and papers. - - Use this tool to find the latest research including preprints - from bioRxiv, medRxiv, and peer-reviewed papers. - - Args: - query: Search terms (e.g., "long covid treatment") - max_results: Maximum results to return (default 10) - - Returns: - Formatted list of papers with abstracts and links - """ - state = get_magentic_state() - - results = await _europepmc.search(query, max_results) - if not results: - return f"No papers found for: {query}" - - new_count = state.add_evidence(results) - - output = [f"Found {len(results)} papers ({new_count} new stored):\n"] - for i, r in enumerate(results[:max_results], 1): - title = r.citation.title - date = r.citation.date - source = r.citation.source - content_clean = r.content[:300].replace("\n", " ") - url = r.citation.url - - output.append(f"{i}. **{title}**") - output.append(f" Source: {source} | Date: {date}") - output.append(f" {content_clean}...") - output.append(f" URL: {url}\n") - - return "\n".join(output) -``` - -### Step 4: Update Search Handler (Simple Mode) - -**File:** `src/tools/search_handler.py` - Update imports: - -```python -# REMOVE: -# from src.tools.biorxiv import BioRxivTool - -# ADD: -from src.tools.europepmc import EuropePMCTool -``` - -### Step 5: Delete Old BioRxiv Tests - -```bash -# After all new tests pass: -rm tests/unit/tools/test_biorxiv.py -``` - ---- - -## Verification - -```bash -# Run new tests -uv run pytest tests/unit/tools/test_europepmc.py -v - -# Run integration test (real API) -uv run pytest tests/unit/tools/test_europepmc.py::TestEuropePMCIntegration -v - -# Run all tests to ensure no regressions -uv run pytest tests/unit/ -v - -# Manual verification -uv run python -c " -import asyncio -from src.tools.europepmc import EuropePMCTool -tool = EuropePMCTool() -results = asyncio.run(tool.search('long covid treatment', 3)) -for r in results: - print(f'- {r.citation.title}') -" -``` - ---- - -## Files Changed - -| File | Action | -|------|--------| -| `src/tools/europepmc.py` | CREATE | -| `tests/unit/tools/test_europepmc.py` | CREATE | -| `src/agents/tools.py` | MODIFY (replace biorxiv import) | -| `src/tools/search_handler.py` | MODIFY (replace biorxiv import) | -| `src/tools/biorxiv.py` | DELETE (after verification) | -| `tests/unit/tools/test_biorxiv.py` | DELETE (after verification) | - ---- - -## Rollback Plan - -If issues arise: -1. Revert `src/agents/tools.py` to use BioRxivTool -2. Revert `src/tools/search_handler.py` -3. Keep `europepmc.py` for future use diff --git a/docs/bugs/PHASE_02_PUBMED_QUERY_PREPROCESSING.md b/docs/bugs/PHASE_02_PUBMED_QUERY_PREPROCESSING.md deleted file mode 100644 index 4a48058a4d4c5b24b647e4222229326c68c66f54..0000000000000000000000000000000000000000 --- a/docs/bugs/PHASE_02_PUBMED_QUERY_PREPROCESSING.md +++ /dev/null @@ -1,355 +0,0 @@ -# Phase 02: PubMed Query Preprocessing - -**Priority:** P0 - Critical -**Effort:** 2-3 hours -**Dependencies:** None (can run parallel with Phase 01) - ---- - -## Problem Statement - -PubMed receives raw natural language queries like "What medications show promise for Long COVID?" which include question words that pollute search results. - -## Success Criteria - -- [ ] Question words stripped from queries -- [ ] Medical synonyms expanded (Long COVID → PASC, etc.) -- [ ] Relevant results returned for natural language questions -- [ ] All existing tests pass -- [ ] New tests cover query preprocessing - ---- - -## TDD Implementation Order - -### Step 1: Write Failing Tests - -**File:** `tests/unit/tools/test_query_utils.py` - -```python -"""Unit tests for query preprocessing utilities.""" - -import pytest - -from src.tools.query_utils import preprocess_query, expand_synonyms, strip_question_words - - -@pytest.mark.unit -class TestQueryPreprocessing: - """Tests for query preprocessing.""" - - def test_strip_question_words(self): - """Test removal of question words.""" - assert strip_question_words("What drugs treat cancer") == "drugs treat cancer" - assert strip_question_words("Which medications help diabetes") == "medications diabetes" - assert strip_question_words("How can we cure alzheimer") == "cure alzheimer" - assert strip_question_words("Is metformin effective") == "metformin effective" - - def test_strip_preserves_medical_terms(self): - """Test that medical terms are preserved.""" - result = strip_question_words("What is the mechanism of metformin") - assert "metformin" in result - assert "mechanism" in result - - def test_expand_synonyms_long_covid(self): - """Test Long COVID synonym expansion.""" - result = expand_synonyms("long covid treatment") - assert "PASC" in result or "post-COVID" in result - - def test_expand_synonyms_alzheimer(self): - """Test Alzheimer's synonym expansion.""" - result = expand_synonyms("alzheimer drug") - assert "Alzheimer" in result - - def test_expand_synonyms_preserves_unknown(self): - """Test that unknown terms are preserved.""" - result = expand_synonyms("metformin diabetes") - assert "metformin" in result - assert "diabetes" in result - - def test_preprocess_query_full_pipeline(self): - """Test complete preprocessing pipeline.""" - raw = "What medications show promise for Long COVID?" - result = preprocess_query(raw) - - # Should not contain question words - assert "what" not in result.lower() - assert "show" not in result.lower() - assert "promise" not in result.lower() - - # Should contain expanded terms - assert "PASC" in result or "post-COVID" in result or "long covid" in result.lower() - assert "medications" in result.lower() or "drug" in result.lower() - - def test_preprocess_query_removes_punctuation(self): - """Test that question marks are removed.""" - result = preprocess_query("Is metformin safe?") - assert "?" not in result - - def test_preprocess_query_handles_empty(self): - """Test handling of empty/whitespace queries.""" - assert preprocess_query("") == "" - assert preprocess_query(" ") == "" - - def test_preprocess_query_already_clean(self): - """Test that clean queries pass through.""" - clean = "metformin diabetes mechanism" - result = preprocess_query(clean) - assert "metformin" in result - assert "diabetes" in result - assert "mechanism" in result -``` - -### Step 2: Implement Query Utils - -**File:** `src/tools/query_utils.py` - -```python -"""Query preprocessing utilities for biomedical search.""" - -import re -from typing import ClassVar - -# Question words and filler words to remove -QUESTION_WORDS: set[str] = { - # Question starters - "what", "which", "how", "why", "when", "where", "who", "whom", - # Auxiliary verbs in questions - "is", "are", "was", "were", "do", "does", "did", "can", "could", - "would", "should", "will", "shall", "may", "might", - # Filler words in natural questions - "show", "promise", "help", "believe", "think", "suggest", - "possible", "potential", "effective", "useful", "good", - # Articles (remove but less aggressively) - "the", "a", "an", -} - -# Medical synonym expansions -SYNONYMS: dict[str, list[str]] = { - "long covid": [ - "long COVID", - "PASC", - "post-acute sequelae of SARS-CoV-2", - "post-COVID syndrome", - "post-COVID-19 condition", - ], - "alzheimer": [ - "Alzheimer's disease", - "Alzheimer disease", - "AD", - "Alzheimer dementia", - ], - "parkinson": [ - "Parkinson's disease", - "Parkinson disease", - "PD", - ], - "diabetes": [ - "diabetes mellitus", - "type 2 diabetes", - "T2DM", - "diabetic", - ], - "cancer": [ - "cancer", - "neoplasm", - "tumor", - "malignancy", - "carcinoma", - ], - "heart disease": [ - "cardiovascular disease", - "CVD", - "coronary artery disease", - "heart failure", - ], -} - - -def strip_question_words(query: str) -> str: - """ - Remove question words and filler terms from query. - - Args: - query: Raw query string - - Returns: - Query with question words removed - """ - words = query.lower().split() - filtered = [w for w in words if w not in QUESTION_WORDS] - return " ".join(filtered) - - -def expand_synonyms(query: str) -> str: - """ - Expand medical terms to include synonyms. - - Args: - query: Query string - - Returns: - Query with synonym expansions in OR groups - """ - result = query.lower() - - for term, expansions in SYNONYMS.items(): - if term in result: - # Create OR group: ("term1" OR "term2" OR "term3") - or_group = " OR ".join([f'"{exp}"' for exp in expansions]) - result = result.replace(term, f"({or_group})") - - return result - - -def preprocess_query(raw_query: str) -> str: - """ - Full preprocessing pipeline for PubMed queries. - - Pipeline: - 1. Strip whitespace and punctuation - 2. Remove question words - 3. Expand medical synonyms - - Args: - raw_query: Natural language query from user - - Returns: - Optimized query for PubMed - """ - if not raw_query or not raw_query.strip(): - return "" - - # Remove question marks and extra whitespace - query = raw_query.replace("?", "").strip() - query = re.sub(r"\s+", " ", query) - - # Strip question words - query = strip_question_words(query) - - # Expand synonyms - query = expand_synonyms(query) - - return query.strip() -``` - -### Step 3: Update PubMed Tool - -**File:** `src/tools/pubmed.py` - Add preprocessing: - -```python -# Add import at top: -from src.tools.query_utils import preprocess_query - -# Update search method: -@retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=1, max=10), - reraise=True, -) -async def search(self, query: str, max_results: int = 10) -> list[Evidence]: - """ - Search PubMed and return evidence. - """ - await self._rate_limit() - - # PREPROCESS QUERY - clean_query = preprocess_query(query) - if not clean_query: - clean_query = query # Fallback to original if preprocessing empties it - - async with httpx.AsyncClient(timeout=30.0) as client: - search_params = self._build_params( - db="pubmed", - term=clean_query, # Use preprocessed query - retmax=max_results, - sort="relevance", - ) - # ... rest unchanged -``` - -### Step 4: Update PubMed Tests - -**File:** `tests/unit/tools/test_pubmed.py` - Add preprocessing test: - -```python -@pytest.mark.asyncio -async def test_search_preprocesses_query(self, pubmed_tool, mock_httpx_client): - """Test that queries are preprocessed before search.""" - # This test verifies the integration - the actual preprocessing - # is tested in test_query_utils.py - - mock_httpx_client.get.return_value = httpx.Response( - 200, - json={"esearchresult": {"idlist": []}}, - ) - - # Natural language query - await pubmed_tool.search("What drugs help with Long COVID?") - - # Verify the call was made (preprocessing happens internally) - assert mock_httpx_client.get.called -``` - ---- - -## Verification - -```bash -# Run query utils tests -uv run pytest tests/unit/tools/test_query_utils.py -v - -# Run pubmed tests -uv run pytest tests/unit/tools/test_pubmed.py -v - -# Run all tests -uv run pytest tests/unit/ -v - -# Manual verification -uv run python -c " -from src.tools.query_utils import preprocess_query - -queries = [ - 'What medications show promise for Long COVID?', - 'Is metformin effective for cancer treatment?', - 'How can we treat Alzheimer with existing drugs?', -] - -for q in queries: - print(f'Input: {q}') - print(f'Output: {preprocess_query(q)}') - print() -" -``` - -Expected output: -``` -Input: What medications show promise for Long COVID? -Output: medications ("long COVID" OR "PASC" OR "post-acute sequelae of SARS-CoV-2" OR "post-COVID syndrome" OR "post-COVID-19 condition") - -Input: Is metformin effective for cancer treatment? -Output: metformin for ("cancer" OR "neoplasm" OR "tumor" OR "malignancy" OR "carcinoma") treatment - -Input: How can we treat Alzheimer with existing drugs? -Output: we treat ("Alzheimer's disease" OR "Alzheimer disease" OR "AD" OR "Alzheimer dementia") with existing drugs -``` - ---- - -## Files Changed - -| File | Action | -|------|--------| -| `src/tools/query_utils.py` | CREATE | -| `tests/unit/tools/test_query_utils.py` | CREATE | -| `src/tools/pubmed.py` | MODIFY (add preprocessing) | -| `tests/unit/tools/test_pubmed.py` | MODIFY (add integration test) | - ---- - -## Future Enhancements (Out of Scope) - -- MeSH term lookup via NCBI API -- Drug name normalization (brand → generic) -- Disease ontology integration (UMLS) -- Query intent classification diff --git a/docs/bugs/PHASE_03_CLINICALTRIALS_FILTERING.md b/docs/bugs/PHASE_03_CLINICALTRIALS_FILTERING.md deleted file mode 100644 index 35179940b83f79ca2c48326dabd07dd6ab11df8c..0000000000000000000000000000000000000000 --- a/docs/bugs/PHASE_03_CLINICALTRIALS_FILTERING.md +++ /dev/null @@ -1,386 +0,0 @@ -# Phase 03: ClinicalTrials.gov Filtering - -**Priority:** P1 - High -**Effort:** 1-2 hours -**Dependencies:** None (can run parallel with Phase 01 & 02) - ---- - -## Problem Statement - -ClinicalTrials.gov returns ALL matching trials including: -- Withdrawn/Terminated trials (no useful data) -- Observational studies (not drug interventions) -- Phase 1 trials (safety only, no efficacy) - -For drug repurposing, we need interventional studies with efficacy data. - -## Success Criteria - -- [ ] Only interventional studies returned -- [ ] Withdrawn/terminated trials filtered out -- [ ] Phase information included in results -- [ ] All existing tests pass -- [ ] New tests cover filtering - ---- - -## TDD Implementation Order - -### Step 1: Write Failing Tests - -**File:** `tests/unit/tools/test_clinicaltrials.py` - Add filter tests: - -```python -"""Unit tests for ClinicalTrials.gov tool.""" - -import pytest -from unittest.mock import patch, MagicMock - -from src.tools.clinicaltrials import ClinicalTrialsTool -from src.utils.models import Evidence - - -@pytest.mark.unit -class TestClinicalTrialsTool: - """Tests for ClinicalTrialsTool.""" - - @pytest.fixture - def tool(self): - return ClinicalTrialsTool() - - def test_tool_name(self, tool): - assert tool.name == "clinicaltrials" - - @pytest.mark.asyncio - async def test_search_uses_filters(self, tool): - """Test that search applies status and type filters.""" - mock_response = MagicMock() - mock_response.json.return_value = {"studies": []} - mock_response.raise_for_status = MagicMock() - - with patch("requests.get", return_value=mock_response) as mock_get: - await tool.search("test query", max_results=5) - - # Verify filters were applied - call_args = mock_get.call_args - params = call_args.kwargs.get("params", call_args[1].get("params", {})) - - # Should filter for active/completed studies - assert "filter.overallStatus" in params - assert "COMPLETED" in params["filter.overallStatus"] - assert "RECRUITING" in params["filter.overallStatus"] - - # Should filter for interventional studies - assert "filter.studyType" in params - assert "INTERVENTIONAL" in params["filter.studyType"] - - @pytest.mark.asyncio - async def test_search_returns_evidence(self, tool): - """Test that search returns Evidence objects.""" - mock_study = { - "protocolSection": { - "identificationModule": { - "nctId": "NCT12345678", - "briefTitle": "Metformin for Long COVID Treatment", - }, - "statusModule": { - "overallStatus": "COMPLETED", - "startDateStruct": {"date": "2023-01-01"}, - }, - "descriptionModule": { - "briefSummary": "A study examining metformin for Long COVID symptoms.", - }, - "designModule": { - "phases": ["PHASE2", "PHASE3"], - }, - "conditionsModule": { - "conditions": ["Long COVID", "PASC"], - }, - "armsInterventionsModule": { - "interventions": [{"name": "Metformin"}], - }, - } - } - - mock_response = MagicMock() - mock_response.json.return_value = {"studies": [mock_study]} - mock_response.raise_for_status = MagicMock() - - with patch("requests.get", return_value=mock_response): - results = await tool.search("long covid metformin", max_results=5) - - assert len(results) == 1 - assert isinstance(results[0], Evidence) - assert "Metformin" in results[0].citation.title - assert "PHASE2" in results[0].content or "Phase" in results[0].content - - @pytest.mark.asyncio - async def test_search_includes_phase_info(self, tool): - """Test that phase information is included in content.""" - mock_study = { - "protocolSection": { - "identificationModule": { - "nctId": "NCT12345678", - "briefTitle": "Test Study", - }, - "statusModule": { - "overallStatus": "RECRUITING", - "startDateStruct": {"date": "2024-01-01"}, - }, - "descriptionModule": { - "briefSummary": "Test summary.", - }, - "designModule": { - "phases": ["PHASE3"], - }, - "conditionsModule": {"conditions": ["Test"]}, - "armsInterventionsModule": {"interventions": []}, - } - } - - mock_response = MagicMock() - mock_response.json.return_value = {"studies": [mock_study]} - mock_response.raise_for_status = MagicMock() - - with patch("requests.get", return_value=mock_response): - results = await tool.search("test", max_results=5) - - # Phase should be in content - assert "PHASE3" in results[0].content or "Phase 3" in results[0].content - - @pytest.mark.asyncio - async def test_search_empty_results(self, tool): - """Test handling of empty results.""" - mock_response = MagicMock() - mock_response.json.return_value = {"studies": []} - mock_response.raise_for_status = MagicMock() - - with patch("requests.get", return_value=mock_response): - results = await tool.search("nonexistent xyz 12345", max_results=5) - assert results == [] - - -@pytest.mark.integration -class TestClinicalTrialsIntegration: - """Integration tests with real API.""" - - @pytest.mark.asyncio - async def test_real_api_returns_interventional(self): - """Test that real API returns interventional studies.""" - tool = ClinicalTrialsTool() - results = await tool.search("long covid treatment", max_results=3) - - # Should get results - assert len(results) > 0 - - # Results should mention interventions or treatments - all_content = " ".join([r.content.lower() for r in results]) - has_intervention = ( - "intervention" in all_content - or "treatment" in all_content - or "drug" in all_content - or "phase" in all_content - ) - assert has_intervention -``` - -### Step 2: Update ClinicalTrials Tool - -**File:** `src/tools/clinicaltrials.py` - Add filters: - -```python -"""ClinicalTrials.gov search tool using API v2.""" - -import asyncio -from typing import Any, ClassVar - -import requests -from tenacity import retry, stop_after_attempt, wait_exponential - -from src.utils.exceptions import SearchError -from src.utils.models import Citation, Evidence - - -class ClinicalTrialsTool: - """Search tool for ClinicalTrials.gov. - - Note: Uses `requests` library instead of `httpx` because ClinicalTrials.gov's - WAF blocks httpx's TLS fingerprint. The `requests` library is not blocked. - See: https://clinicaltrials.gov/data-api/api - """ - - BASE_URL = "https://clinicaltrials.gov/api/v2/studies" - - # Fields to retrieve - FIELDS: ClassVar[list[str]] = [ - "NCTId", - "BriefTitle", - "Phase", - "OverallStatus", - "Condition", - "InterventionName", - "StartDate", - "BriefSummary", - ] - - # Status filter: Only active/completed studies with potential data - STATUS_FILTER = "COMPLETED|ACTIVE_NOT_RECRUITING|RECRUITING|ENROLLING_BY_INVITATION" - - # Study type filter: Only interventional (drug/treatment studies) - STUDY_TYPE_FILTER = "INTERVENTIONAL" - - @property - def name(self) -> str: - return "clinicaltrials" - - @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=1, max=10), - reraise=True, - ) - async def search(self, query: str, max_results: int = 10) -> list[Evidence]: - """Search ClinicalTrials.gov for interventional studies. - - Args: - query: Search query (e.g., "metformin alzheimer") - max_results: Maximum results to return (max 100) - - Returns: - List of Evidence objects from clinical trials - """ - params: dict[str, str | int] = { - "query.term": query, - "pageSize": min(max_results, 100), - "fields": "|".join(self.FIELDS), - # FILTERS - Only interventional, active/completed studies - "filter.overallStatus": self.STATUS_FILTER, - "filter.studyType": self.STUDY_TYPE_FILTER, - } - - try: - # Run blocking requests.get in a separate thread for async compatibility - response = await asyncio.to_thread( - requests.get, - self.BASE_URL, - params=params, - headers={"User-Agent": "DeepCritical-Research-Agent/1.0"}, - timeout=30, - ) - response.raise_for_status() - - data = response.json() - studies = data.get("studies", []) - return [self._study_to_evidence(study) for study in studies[:max_results]] - - except requests.HTTPError as e: - raise SearchError(f"ClinicalTrials.gov API error: {e}") from e - except requests.RequestException as e: - raise SearchError(f"ClinicalTrials.gov request failed: {e}") from e - - def _study_to_evidence(self, study: dict[str, Any]) -> Evidence: - """Convert a clinical trial study to Evidence.""" - # Navigate nested structure - protocol = study.get("protocolSection", {}) - id_module = protocol.get("identificationModule", {}) - status_module = protocol.get("statusModule", {}) - desc_module = protocol.get("descriptionModule", {}) - design_module = protocol.get("designModule", {}) - conditions_module = protocol.get("conditionsModule", {}) - arms_module = protocol.get("armsInterventionsModule", {}) - - nct_id = id_module.get("nctId", "Unknown") - title = id_module.get("briefTitle", "Untitled Study") - status = status_module.get("overallStatus", "Unknown") - start_date = status_module.get("startDateStruct", {}).get("date", "Unknown") - - # Get phase (might be a list) - phases = design_module.get("phases", []) - phase = phases[0] if phases else "Not Applicable" - - # Get conditions - conditions = conditions_module.get("conditions", []) - conditions_str = ", ".join(conditions[:3]) if conditions else "Unknown" - - # Get interventions - interventions = arms_module.get("interventions", []) - intervention_names = [i.get("name", "") for i in interventions[:3]] - interventions_str = ", ".join(intervention_names) if intervention_names else "Unknown" - - # Get summary - summary = desc_module.get("briefSummary", "No summary available.") - - # Build content with key trial info - content = ( - f"{summary[:500]}... " - f"Trial Phase: {phase}. " - f"Status: {status}. " - f"Conditions: {conditions_str}. " - f"Interventions: {interventions_str}." - ) - - return Evidence( - content=content[:2000], - citation=Citation( - source="clinicaltrials", - title=title[:500], - url=f"https://clinicaltrials.gov/study/{nct_id}", - date=start_date, - authors=[], # Trials don't have traditional authors - ), - relevance=0.85, # Trials are highly relevant for repurposing - ) -``` - ---- - -## Verification - -```bash -# Run clinicaltrials tests -uv run pytest tests/unit/tools/test_clinicaltrials.py -v - -# Run integration test (real API) -uv run pytest tests/unit/tools/test_clinicaltrials.py::TestClinicalTrialsIntegration -v - -# Run all tests -uv run pytest tests/unit/ -v - -# Manual verification -uv run python -c " -import asyncio -from src.tools.clinicaltrials import ClinicalTrialsTool - -tool = ClinicalTrialsTool() -results = asyncio.run(tool.search('long covid treatment', 3)) - -for r in results: - print(f'Title: {r.citation.title}') - print(f'Content: {r.content[:200]}...') - print() -" -``` - ---- - -## Files Changed - -| File | Action | -|------|--------| -| `src/tools/clinicaltrials.py` | MODIFY (add filters) | -| `tests/unit/tools/test_clinicaltrials.py` | MODIFY (add filter tests) | - ---- - -## API Filter Reference - -ClinicalTrials.gov API v2 supports these filters: - -| Parameter | Values | Purpose | -|-----------|--------|---------| -| `filter.overallStatus` | COMPLETED, RECRUITING, etc. | Trial status | -| `filter.studyType` | INTERVENTIONAL, OBSERVATIONAL | Study design | -| `filter.phase` | PHASE1, PHASE2, PHASE3, PHASE4 | Trial phase | -| `filter.geo` | Country codes | Geographic filter | - -See: https://clinicaltrials.gov/data-api/api diff --git a/examples/rate_limiting_demo.py b/examples/rate_limiting_demo.py new file mode 100644 index 0000000000000000000000000000000000000000..c6eda12a735634a30a875a59162130917544bf4c --- /dev/null +++ b/examples/rate_limiting_demo.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Demo script to verify rate limiting works correctly.""" + +import asyncio +import time + +from src.tools.pubmed import PubMedTool +from src.tools.rate_limiter import RateLimiter, get_pubmed_limiter, reset_pubmed_limiter + + +async def test_basic_limiter(): + """Test basic rate limiter behavior.""" + print("=" * 60) + print("Rate Limiting Demo") + print("=" * 60) + + # Test 1: Basic limiter + print("\n[Test 1] Testing 3/second limiter...") + limiter = RateLimiter("3/second") + + start = time.monotonic() + for i in range(6): + await limiter.acquire() + elapsed = time.monotonic() - start + print(f" Request {i+1} at {elapsed:.2f}s") + + total = time.monotonic() - start + print(f" Total time for 6 requests: {total:.2f}s (expected ~2s)") + + +async def test_pubmed_limiter(): + """Test PubMed-specific limiter.""" + print("\n[Test 2] Testing PubMed limiter (shared)...") + + reset_pubmed_limiter() # Clean state + + # Without API key: 3/sec + limiter = get_pubmed_limiter(api_key=None) + print(f" Rate without key: {limiter.rate}") + + # Multiple tools should share the same limiter + tool1 = PubMedTool() + tool2 = PubMedTool() + + # Verify they share the limiter + print(f" Tools share limiter: {tool1._limiter is tool2._limiter}") + + +async def test_concurrent_requests(): + """Test rate limiting under concurrent load.""" + print("\n[Test 3] Testing concurrent request limiting...") + + limiter = RateLimiter("5/second") + + async def make_request(i: int): + await limiter.acquire() + return time.monotonic() + + start = time.monotonic() + # Launch 10 concurrent requests + tasks = [make_request(i) for i in range(10)] + times = await asyncio.gather(*tasks) + + # Calculate distribution + relative_times = [t - start for t in times] + print(f" Request times: {[f'{t:.2f}s' for t in sorted(relative_times)]}") + + total = max(relative_times) + print(f" All 10 requests completed in {total:.2f}s (expected ~2s)") + + +async def main(): + await test_basic_limiter() + await test_pubmed_limiter() + await test_concurrent_requests() + + print("\n" + "=" * 60) + print("Demo complete!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 3c434d476d8e0b09143e9a3e612a2bfcbba64a3a..7c31491ce493287505a6e7c0ab53a2c8296f196d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "structlog>=24.1", # Structured logging "requests>=2.32.5", # ClinicalTrials.gov (httpx blocked by WAF) "pydantic-graph>=1.22.0", + "limits>=3.0", # Rate limiting + "duckduckgo-search>=5.0", # Web search ] [project.optional-dependencies] @@ -44,7 +46,7 @@ dev = [ "pre-commit>=3.7", ] magentic = [ - "agent-framework-core>=1.0.0b251120,<2.0.0", # Pin to avoid breaking changes + "agent-framework-core>=1.0.0b251120,<2.0.0", # Microsoft Agent Framework (PyPI) ] embeddings = [ "chromadb>=0.4.0", diff --git a/requirements.txt b/requirements.txt index 6ac90afdca93f0f1ca35c89ef7d24fa8d1277d10..5fbc89e0dce6aba2f08bd3af4f7c27a59b30aa5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,12 @@ pydantic-ai>=0.0.16 openai>=1.0.0 anthropic>=0.18.0 +# Multi-agent orchestration (Advanced mode) +agent-framework-core>=1.0.0b251120 + +# Web search +duckduckgo-search>=5.0 + # HTTP & Parsing httpx>=0.27 beautifulsoup4>=4.12 @@ -20,6 +26,7 @@ python-dotenv>=1.0 tenacity>=8.2 structlog>=24.1 requests>=2.32.5 +limits>=3.0 # Rate limiting (Phase 17) # Optional: Modal for code execution modal>=0.63.0 diff --git a/src/agent_factory/judges.py b/src/agent_factory/judges.py index d48ddbdcb8afdaf4438b1f9a1a78c183f2259459..6ba2d791071886ebeac4347f108748ec500e7b75 100644 --- a/src/agent_factory/judges.py +++ b/src/agent_factory/judges.py @@ -8,8 +8,10 @@ import structlog from huggingface_hub import InferenceClient from pydantic_ai import Agent from pydantic_ai.models.anthropic import AnthropicModel +from pydantic_ai.models.huggingface import HuggingFaceModel from pydantic_ai.models.openai import OpenAIModel from pydantic_ai.providers.anthropic import AnthropicProvider +from pydantic_ai.providers.huggingface import HuggingFaceProvider from pydantic_ai.providers.openai import OpenAIProvider from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential @@ -36,6 +38,12 @@ def get_model() -> Any: provider = AnthropicProvider(api_key=settings.anthropic_api_key) return AnthropicModel(settings.anthropic_model, provider=provider) + if llm_provider == "huggingface": + # Free tier - uses HF_TOKEN from environment if available + model_name = settings.huggingface_model or "meta-llama/Llama-3.1-70B-Instruct" + hf_provider = HuggingFaceProvider(api_key=settings.hf_token) + return HuggingFaceModel(model_name, provider=hf_provider) + if llm_provider != "openai": logger.warning("Unknown LLM provider, defaulting to OpenAI", provider=llm_provider) @@ -434,7 +442,7 @@ class MockJudgeHandler: clinical_evidence_score=clinical_score, clinical_reasoning=( f"Demo mode: {evidence_count} sources retrieved from PubMed, " - "ClinicalTrials.gov, and bioRxiv. Full analysis requires LLM API key." + "ClinicalTrials.gov, and Europe PMC. Full analysis requires LLM API key." ), drug_candidates=drug_candidates, key_findings=key_findings, diff --git a/src/agents/code_executor_agent.py b/src/agents/code_executor_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..37ed626e17026de64835ff9533ec2804cd20e677 --- /dev/null +++ b/src/agents/code_executor_agent.py @@ -0,0 +1,69 @@ +"""Code execution agent using Modal.""" + +import asyncio + +import structlog +from agent_framework import ChatAgent, ai_function +from agent_framework.openai import OpenAIChatClient + +from src.tools.code_execution import get_code_executor +from src.utils.config import settings + +logger = structlog.get_logger() + + +@ai_function # type: ignore[arg-type, misc] +async def execute_python_code(code: str) -> str: + """Execute Python code in a secure sandbox. + + Args: + code: The Python code to execute. + + Returns: + The standard output and standard error of the execution. + """ + logger.info("Code execution starting", code_length=len(code)) + executor = get_code_executor() + loop = asyncio.get_running_loop() + + # Run in executor to avoid blocking + try: + result = await loop.run_in_executor(None, lambda: executor.execute(code)) + if result["success"]: + logger.info("Code execution succeeded") + return f"Stdout:\n{result['stdout']}" + else: + logger.warning("Code execution failed", error=result.get("error")) + return f"Error:\n{result['error']}\nStderr:\n{result['stderr']}" + except Exception as e: + logger.error("Code execution exception", error=str(e)) + return f"Execution failed: {e}" + + +def create_code_executor_agent(chat_client: OpenAIChatClient | None = None) -> ChatAgent: + """Create a code executor agent. + + Args: + chat_client: Optional custom chat client. + + Returns: + ChatAgent configured for code execution. + """ + client = chat_client or OpenAIChatClient( + model_id=settings.openai_model, + api_key=settings.openai_api_key, + ) + + return ChatAgent( + name="CodeExecutorAgent", + description="Executes Python code for data analysis, calculation, and simulation.", + instructions="""You are a code execution expert. +When asked to analyze data or perform calculations, write Python code and execute it. +Use libraries like pandas, numpy, scipy, matplotlib. + +Always output the code you want to execute using the `execute_python_code` tool. +Check the output and interpret the results.""", + chat_client=client, + tools=[execute_python_code], + temperature=0.0, # Strict code generation + ) diff --git a/src/agents/judge_agent_llm.py b/src/agents/judge_agent_llm.py new file mode 100644 index 0000000000000000000000000000000000000000..12453e09489d9379fdf2e61c213591dcc27a5a27 --- /dev/null +++ b/src/agents/judge_agent_llm.py @@ -0,0 +1,45 @@ +"""LLM Judge for sub-iterations.""" + +from typing import Any + +import structlog +from pydantic_ai import Agent + +from src.agent_factory.judges import get_model +from src.utils.models import JudgeAssessment + +logger = structlog.get_logger() + + +class LLMSubIterationJudge: + """Judge that uses an LLM to assess sub-iteration results.""" + + def __init__(self) -> None: + self.model = get_model() + self.agent = Agent( + model=self.model, + output_type=JudgeAssessment, + system_prompt="""You are a strict judge evaluating a research task. + +Evaluate if the result is sufficient to answer the task. +Provide scores and detailed reasoning. +If not sufficient, suggest next steps.""", + retries=3, + ) + + async def assess(self, task: str, result: Any, history: list[Any]) -> JudgeAssessment: + """Assess the result using LLM.""" + logger.info("LLM judge assessing result", task=task[:100], history_len=len(history)) + + prompt = f"""Task: {task} + +Current Result: +{str(result)[:4000]} + +History of previous attempts: {len(history)} + +Evaluate validity and sufficiency.""" + + run_result = await self.agent.run(prompt) + logger.info("LLM judge assessment complete", sufficient=run_result.output.sufficient) + return run_result.output diff --git a/src/agents/magentic_agents.py b/src/agents/magentic_agents.py index 484e0d1962eecd68b43e76c144c5fc02c2a36730..65318adcd04ad65c4967faa8b6b431e7219454e9 100644 --- a/src/agents/magentic_agents.py +++ b/src/agents/magentic_agents.py @@ -29,7 +29,7 @@ def create_search_agent(chat_client: OpenAIChatClient | None = None) -> ChatAgen return ChatAgent( name="SearchAgent", description=( - "Searches biomedical databases (PubMed, ClinicalTrials.gov, bioRxiv) " + "Searches biomedical databases (PubMed, ClinicalTrials.gov, Europe PMC) " "for drug repurposing evidence" ), instructions="""You are a biomedical search specialist. When asked to find evidence: diff --git a/src/agents/retrieval_agent.py b/src/agents/retrieval_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..79acec879d29a7c07dec809be29670cda13e0724 --- /dev/null +++ b/src/agents/retrieval_agent.py @@ -0,0 +1,82 @@ +"""Retrieval agent for web search and context management.""" + +import structlog +from agent_framework import ChatAgent, ai_function +from agent_framework.openai import OpenAIChatClient + +from src.state import get_magentic_state +from src.tools.web_search import WebSearchTool +from src.utils.config import settings + +logger = structlog.get_logger() + +_web_search = WebSearchTool() + + +@ai_function # type: ignore[arg-type, misc] +async def search_web(query: str, max_results: int = 10) -> str: + """Search the web using DuckDuckGo. + + Args: + query: Search keywords. + max_results: Maximum results to return (default 10). + + Returns: + Formatted search results. + """ + logger.info("Web search starting", query=query, max_results=max_results) + state = get_magentic_state() + + results = await _web_search.search(query, max_results) + if not results.evidence: + logger.info("Web search returned no results", query=query) + return f"No web results found for: {query}" + + # Update state + # We add *all* found results to state + new_count = state.add_evidence(results.evidence) + logger.info( + "Web search complete", + query=query, + results_found=len(results.evidence), + new_evidence=new_count, + ) + + # Use embedding service for deduplication/indexing if available + if state.embedding_service: + # This method also adds to vector DB as a side effect for unique items + await state.embedding_service.deduplicate(results.evidence) + + output = [f"Found {len(results.evidence)} web results ({new_count} new stored):\n"] + for i, r in enumerate(results.evidence[:max_results], 1): + output.append(f"{i}. **{r.citation.title}**") + output.append(f" Source: {r.citation.url}") + output.append(f" {r.content[:300]}...\n") + + return "\n".join(output) + + +def create_retrieval_agent(chat_client: OpenAIChatClient | None = None) -> ChatAgent: + """Create a retrieval agent. + + Args: + chat_client: Optional custom chat client. + + Returns: + ChatAgent configured for retrieval. + """ + client = chat_client or OpenAIChatClient( + model_id=settings.openai_model, + api_key=settings.openai_api_key, + ) + + return ChatAgent( + name="RetrievalAgent", + description="Searches the web and manages context/evidence.", + instructions="""You are a retrieval specialist. +Use `search_web` to find information on the internet. +Your goal is to gather relevant evidence for the research task. +Always summarize what you found.""", + chat_client=client, + tools=[search_web], + ) diff --git a/src/app.py b/src/app.py index bdb023054d4ec1b4bf093ffd5b01c465599c5845..5d986fdb5dd3e3b7e1554f27a5dd96695208a937 100644 --- a/src/app.py +++ b/src/app.py @@ -31,7 +31,7 @@ def configure_orchestrator( Args: use_mock: If True, use MockJudgeHandler (no API key needed) - mode: Orchestrator mode ("simple" or "magentic") + mode: Orchestrator mode ("simple" or "advanced") user_api_key: Optional user-provided API key (BYOK) api_provider: API provider ("openai" or "anthropic") @@ -115,7 +115,7 @@ async def research_agent( Args: message: User's research question history: Chat history (Gradio format) - mode: Orchestrator mode ("simple" or "magentic") + mode: Orchestrator mode ("simple" or "advanced") api_key: Optional user-provided API key (BYOK - Bring Your Own Key) api_provider: API provider ("openai" or "anthropic") @@ -135,10 +135,11 @@ async def research_agent( has_user_key = bool(user_api_key) has_paid_key = has_openai or has_anthropic or has_user_key - # Magentic mode requires OpenAI specifically - if mode == "magentic" and not (has_openai or (has_user_key and api_provider == "openai")): + # Advanced mode requires OpenAI specifically (due to agent-framework binding) + if mode == "advanced" and not (has_openai or (has_user_key and api_provider == "openai")): yield ( - "⚠️ **Warning**: Magentic mode requires OpenAI API key. Falling back to simple mode.\n\n" + "⚠️ **Warning**: Advanced mode currently requires OpenAI API key. " + "Falling back to simple mode.\n\n" ) mode = "simple" @@ -186,78 +187,68 @@ async def research_agent( yield f"❌ **Error**: {e!s}" -def create_demo() -> Any: +def create_demo() -> gr.ChatInterface: """ Create the Gradio demo interface with MCP support. Returns: Configured Gradio Blocks interface with MCP server enabled """ - with gr.Blocks( - title="DeepCritical - Drug Repurposing Research Agent", - ) as demo: - # 1. Minimal Header (Option A: 2 lines max) - gr.Markdown( - "# 🧬 DeepCritical\n" - "*AI-Powered Drug Repurposing Agent — searches PubMed, ClinicalTrials.gov & bioRxiv*" - ) - - # 2. Main Chat Interface - # Config inputs will be in a collapsed accordion below the chat input - gr.ChatInterface( - fn=research_agent, - examples=[ - [ - "What drugs could be repurposed for Alzheimer's disease?", - "simple", - "", - "openai", - ], - [ - "Is metformin effective for treating cancer?", - "simple", - "", - "openai", - ], - [ - "What medications show promise for Long COVID treatment?", - "simple", - "", - "openai", - ], + # 1. Unwrapped ChatInterface (Fixes Accordion Bug) + demo = gr.ChatInterface( + fn=research_agent, + title="🧬 DeepCritical", + description=( + "*AI-Powered Drug Repurposing Agent — searches PubMed, " + "ClinicalTrials.gov & Europe PMC*\n\n" + "---\n" + "*Research tool only — not for medical advice.* \n" + "**MCP Server Active**: Connect Claude Desktop to `/gradio_api/mcp/`" + ), + examples=[ + [ + "What drugs could be repurposed for Alzheimer's disease?", + "simple", + "", + "openai", ], - additional_inputs_accordion=gr.Accordion(label="⚙️ Settings", open=False), - additional_inputs=[ - gr.Radio( - choices=["simple", "magentic"], - value="simple", - label="Orchestrator Mode", - info="Simple: Linear | Magentic: Multi-Agent (OpenAI)", - ), - gr.Textbox( - label="🔑 API Key (Optional - BYOK)", - placeholder="sk-... or sk-ant-...", - type="password", - info="Enter your own API key. Never stored.", - ), - gr.Radio( - choices=["openai", "anthropic"], - value="openai", - label="API Provider", - info="Select the provider for your API key", - ), + [ + "Is metformin effective for treating cancer?", + "simple", + "", + "openai", ], - ) - - # 3. Minimal Footer (Option C: Remove MCP Tabs, keep info) - gr.Markdown( - """ - --- - *Research tool only — not for medical advice.* - **MCP Server Active**: Connect Claude Desktop to `/gradio_api/mcp/` - """, - elem_classes=["footer"], - ) + [ + "What medications show promise for Long COVID treatment?", + "simple", + "", + "openai", + ], + ], + additional_inputs_accordion=gr.Accordion(label="⚙️ Settings", open=False), + additional_inputs=[ + gr.Radio( + choices=["simple", "advanced"], + value="simple", + label="Orchestrator Mode", + info=( + "Simple: Linear (Free Tier Friendly) | Advanced: Multi-Agent (Requires OpenAI)" + ), + ), + gr.Textbox( + label="🔑 API Key (Optional - BYOK)", + placeholder="sk-... or sk-ant-...", + type="password", + info="Enter your own API key. Never stored.", + ), + gr.Radio( + choices=["openai", "anthropic"], + value="openai", + label="API Provider", + info="Select the provider for your API key", + ), + ], + ) return demo diff --git a/src/middleware/sub_iteration.py b/src/middleware/sub_iteration.py new file mode 100644 index 0000000000000000000000000000000000000000..801a3686a6d023c39615d01548766e4c24098c66 --- /dev/null +++ b/src/middleware/sub_iteration.py @@ -0,0 +1,135 @@ +"""Middleware for orchestrating sub-iterations with research teams and judges.""" + +from typing import Any, Protocol + +import structlog + +from src.utils.models import AgentEvent, JudgeAssessment + +logger = structlog.get_logger() + + +class SubIterationTeam(Protocol): + """Protocol for a research team that executes a sub-task.""" + + async def execute(self, task: str) -> Any: + """Execute the sub-task and return a result.""" + ... + + +class SubIterationJudge(Protocol): + """Protocol for a judge that evaluates the sub-task result.""" + + async def assess(self, task: str, result: Any, history: list[Any]) -> JudgeAssessment: + """Assess the quality of the result.""" + ... + + +class SubIterationMiddleware: + """ + Middleware that manages a sub-iteration loop: + 1. Orchestrator delegates to a Research Team. + 2. Research Team produces a result. + 3. Judge evaluates the result. + 4. Loop continues until Judge approves or max iterations reached. + """ + + def __init__( + self, + team: SubIterationTeam, + judge: SubIterationJudge, + max_iterations: int = 3, + ): + self.team = team + self.judge = judge + self.max_iterations = max_iterations + + async def run( + self, + task: str, + event_callback: Any = None, # Optional callback for streaming events + ) -> tuple[Any, JudgeAssessment | None]: + """ + Run the sub-iteration loop. + + Args: + task: The research task or question. + event_callback: Async callable to report events (e.g. to UI). + + Returns: + Tuple of (best_result, final_assessment). + """ + history: list[Any] = [] + best_result: Any = None + final_assessment: JudgeAssessment | None = None + + for i in range(1, self.max_iterations + 1): + logger.info("Sub-iteration starting", iteration=i, task=task) + + if event_callback: + await event_callback( + AgentEvent( + type="looping", + message=f"Sub-iteration {i}: Executing task...", + iteration=i, + ) + ) + + # 1. Team Execution + try: + result = await self.team.execute(task) + history.append(result) + best_result = result # Assume latest is best for now + except Exception as e: + logger.error("Sub-iteration execution failed", error=str(e)) + if event_callback: + await event_callback( + AgentEvent( + type="error", + message=f"Sub-iteration execution failed: {e}", + iteration=i, + ) + ) + return best_result, final_assessment + + # 2. Judge Assessment + try: + assessment = await self.judge.assess(task, result, history) + final_assessment = assessment + except Exception as e: + logger.error("Sub-iteration judge failed", error=str(e)) + if event_callback: + await event_callback( + AgentEvent( + type="error", + message=f"Sub-iteration judge failed: {e}", + iteration=i, + ) + ) + return best_result, final_assessment + + # 3. Decision + if assessment.sufficient: + logger.info("Sub-iteration sufficient", iteration=i) + return best_result, assessment + + # If not sufficient, we might refine the task for the next iteration + # For this implementation, we assume the team is smart enough or the task stays same + # but we could append feedback to the task. + + feedback = assessment.reasoning + logger.info("Sub-iteration insufficient", feedback=feedback) + + if event_callback: + await event_callback( + AgentEvent( + type="looping", + message=( + f"Sub-iteration {i} result insufficient. Feedback: {feedback[:100]}..." + ), + iteration=i, + ) + ) + + logger.warning("Sub-iteration max iterations reached", task=task) + return best_result, final_assessment diff --git a/src/orchestrator_factory.py b/src/orchestrator_factory.py index 38f8f6274e28a3fd659a0613a113e37537aa9319..a9bc563ec91ec9b5505b2b1598c921d1198259b6 100644 --- a/src/orchestrator_factory.py +++ b/src/orchestrator_factory.py @@ -9,12 +9,29 @@ from src.legacy_orchestrator import ( ) from src.utils.models import OrchestratorConfig +import structlog + +logger = structlog.get_logger() + + +def _get_magentic_orchestrator_class() -> Any: + """Import MagenticOrchestrator lazily to avoid hard dependency.""" + try: + from src.orchestrator_magentic import MagenticOrchestrator + + return MagenticOrchestrator + except ImportError as e: + logger.error("Failed to import MagenticOrchestrator", error=str(e)) + raise ValueError( + "Advanced mode requires agent-framework-core. Please install it or use mode='simple'." + ) from e + def create_orchestrator( search_handler: SearchHandlerProtocol | None = None, judge_handler: JudgeHandlerProtocol | None = None, config: OrchestratorConfig | None = None, - mode: Literal["simple", "magentic"] = "simple", + mode: Literal["simple", "magentic", "advanced"] | None = None, ) -> Any: """ Create an orchestrator instance. @@ -23,25 +40,19 @@ def create_orchestrator( search_handler: The search handler (required for simple mode) judge_handler: The judge handler (required for simple mode) config: Optional configuration - mode: "simple" for Phase 4 loop, "magentic" for ChatAgent-based multi-agent + mode: "simple", "magentic", "advanced" or None (auto-detect) Returns: Orchestrator instance - - Note: - Magentic mode does NOT use search_handler/judge_handler. - It creates ChatAgent instances with internal LLMs that call tools directly. """ - if mode == "magentic": - try: - from src.orchestrator_magentic import MagenticOrchestrator + effective_mode = _determine_mode(mode) + logger.info("Creating orchestrator", mode=effective_mode) - return MagenticOrchestrator( - max_rounds=config.max_iterations if config else 10, - ) - except ImportError: - # Fallback to simple if agent-framework not installed - pass + if effective_mode == "advanced": + orchestrator_cls = _get_magentic_orchestrator_class() + return orchestrator_cls( + max_rounds=config.max_iterations if config else 10, + ) # Simple mode requires handlers if search_handler is None or judge_handler is None: @@ -52,3 +63,17 @@ def create_orchestrator( judge_handler=judge_handler, config=config, ) + + +def _determine_mode(explicit_mode: str | None) -> str: + """Determine which mode to use.""" + if explicit_mode: + if explicit_mode in ("magentic", "advanced"): + return "advanced" + return "simple" + + # Auto-detect: advanced if paid API key available + if settings.has_openai_key: + return "advanced" + + return "simple" diff --git a/src/orchestrator_hierarchical.py b/src/orchestrator_hierarchical.py new file mode 100644 index 0000000000000000000000000000000000000000..bf3848ad04930be14ff06e32e815de5f60d3b223 --- /dev/null +++ b/src/orchestrator_hierarchical.py @@ -0,0 +1,95 @@ +"""Hierarchical orchestrator using middleware and sub-teams.""" + +import asyncio +from collections.abc import AsyncGenerator + +import structlog + +from src.agents.judge_agent_llm import LLMSubIterationJudge +from src.agents.magentic_agents import create_search_agent +from src.middleware.sub_iteration import SubIterationMiddleware, SubIterationTeam +from src.services.embeddings import get_embedding_service +from src.state import init_magentic_state +from src.utils.models import AgentEvent + +logger = structlog.get_logger() + + +class ResearchTeam(SubIterationTeam): + """Adapts Magentic ChatAgent to SubIterationTeam protocol.""" + + def __init__(self) -> None: + self.agent = create_search_agent() + + async def execute(self, task: str) -> str: + response = await self.agent.run(task) + if response.messages: + for msg in reversed(response.messages): + if msg.role == "assistant" and msg.text: + return str(msg.text) + return "No response from agent." + + +class HierarchicalOrchestrator: + """Orchestrator that uses hierarchical teams and sub-iterations.""" + + def __init__(self) -> None: + self.team = ResearchTeam() + self.judge = LLMSubIterationJudge() + self.middleware = SubIterationMiddleware(self.team, self.judge, max_iterations=5) + + async def run(self, query: str) -> AsyncGenerator[AgentEvent, None]: + logger.info("Starting hierarchical orchestrator", query=query) + + try: + service = get_embedding_service() + init_magentic_state(service) + except Exception as e: + logger.warning( + "Embedding service initialization failed, using default state", + error=str(e), + ) + init_magentic_state() + + yield AgentEvent(type="started", message=f"Starting research: {query}") + + queue: asyncio.Queue[AgentEvent | None] = asyncio.Queue() + + async def event_callback(event: AgentEvent) -> None: + await queue.put(event) + + task_future = asyncio.create_task(self.middleware.run(query, event_callback)) + + while not task_future.done(): + get_event = asyncio.create_task(queue.get()) + done, _ = await asyncio.wait( + {task_future, get_event}, return_when=asyncio.FIRST_COMPLETED + ) + + if get_event in done: + event = get_event.result() + if event: + yield event + else: + get_event.cancel() + + # Process remaining events + while not queue.empty(): + ev = queue.get_nowait() + if ev: + yield ev + + try: + result, assessment = await task_future + + assessment_text = assessment.reasoning if assessment else "None" + yield AgentEvent( + type="complete", + message=( + f"Research complete.\n\nResult:\n{result}\n\nAssessment:\n{assessment_text}" + ), + data={"assessment": assessment.model_dump() if assessment else None}, + ) + except Exception as e: + logger.error("Orchestrator failed", error=str(e)) + yield AgentEvent(type="error", message=f"Orchestrator failed: {e}") diff --git a/src/orchestrator_magentic.py b/src/orchestrator_magentic.py index da349f3f7a7ed9f99c3db06ffcdd5a70ee0953a5..9b3860a0b53cff1526ccbb863e31cf16a3d08f7d 100644 --- a/src/orchestrator_magentic.py +++ b/src/orchestrator_magentic.py @@ -128,7 +128,7 @@ class MagenticOrchestrator: task = f"""Research drug repurposing opportunities for: {query} Workflow: -1. SearchAgent: Find evidence from PubMed, ClinicalTrials.gov, and bioRxiv +1. SearchAgent: Find evidence from PubMed, ClinicalTrials.gov, and Europe PMC 2. HypothesisAgent: Generate mechanistic hypotheses (Drug -> Target -> Pathway -> Effect) 3. JudgeAgent: Evaluate if evidence is sufficient 4. If insufficient -> SearchAgent refines search based on gaps @@ -158,10 +158,41 @@ The final output should be a structured research report.""" iteration=iteration, ) + def _extract_text(self, message: Any) -> str: + """ + Defensively extract text from a message object. + + Fixes bug where message.text might return the object itself or its repr. + """ + if not message: + return "" + + # Priority 1: .content (often the raw string or list of content) + if hasattr(message, "content") and message.content: + content = message.content + # If it's a list (e.g., Multi-modal), join text parts + if isinstance(content, list): + return " ".join([str(c.text) for c in content if hasattr(c, "text")]) + return str(content) + + # Priority 2: .text (standard, but sometimes buggy/missing) + if hasattr(message, "text") and message.text: + # Verify it's not the object itself or a repr string + text = str(message.text) + if text.startswith("<") and "object at" in text: + # Likely a repr string, ignore if possible + pass + else: + return text + + # Fallback: If we can't find clean text, return str(message) + # taking care to avoid infinite recursion if str() calls .text + return str(message) + def _process_event(self, event: Any, iteration: int) -> AgentEvent | None: """Process workflow event into AgentEvent.""" if isinstance(event, MagenticOrchestratorMessageEvent): - text = event.message.text if event.message else "" + text = self._extract_text(event.message) if text: return AgentEvent( type="judging", @@ -171,7 +202,7 @@ The final output should be a structured research report.""" elif isinstance(event, MagenticAgentMessageEvent): agent_name = event.agent_id or "unknown" - text = event.message.text if event.message else "" + text = self._extract_text(event.message) event_type = "judging" if "search" in agent_name.lower(): @@ -190,7 +221,7 @@ The final output should be a structured research report.""" ) elif isinstance(event, MagenticFinalResultEvent): - text = event.message.text if event.message else "No result" + text = self._extract_text(event.message) if event.message else "No result" return AgentEvent( type="complete", message=text, diff --git a/src/state/__init__.py b/src/state/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a2323db724ea3ee5902b1121cdb2a30406319fe5 --- /dev/null +++ b/src/state/__init__.py @@ -0,0 +1,9 @@ +"""State package - re-exports from agents.state for compatibility.""" + +from src.agents.state import ( + MagenticState, + get_magentic_state, + init_magentic_state, +) + +__all__ = ["MagenticState", "get_magentic_state", "init_magentic_state"] diff --git a/src/tools/__init__.py b/src/tools/__init__.py index b2b6eeb22b039237c1f0eddac324caf32eb3651f..6a42f7585f10f2305674533029ec5a63c29d655f 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -1,6 +1,8 @@ """Search tools package.""" from src.tools.base import SearchTool +from src.tools.clinicaltrials import ClinicalTrialsTool +from src.tools.europepmc import EuropePMCTool from src.tools.pubmed import PubMedTool from src.tools.rag_tool import RAGTool, create_rag_tool from src.tools.search_handler import SearchHandler diff --git a/src/tools/pubmed.py b/src/tools/pubmed.py index 5e05b9ce294dadc3590f9b7b1a66b2fe4a0d0a03..6787fd251aaab2c2efa67bdbbfe9de23e9351dcd 100644 --- a/src/tools/pubmed.py +++ b/src/tools/pubmed.py @@ -1,6 +1,5 @@ """PubMed search tool using NCBI E-utilities.""" -import asyncio from typing import Any import httpx @@ -8,6 +7,7 @@ import xmltodict from tenacity import retry, stop_after_attempt, wait_exponential from src.tools.query_utils import preprocess_query +from src.tools.rate_limiter import get_pubmed_limiter from src.utils.config import settings from src.utils.exceptions import RateLimitError, SearchError from src.utils.models import Citation, Evidence @@ -17,7 +17,6 @@ class PubMedTool: """Search tool for PubMed/NCBI.""" BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" - RATE_LIMIT_DELAY = 0.34 # ~3 requests/sec without API key HTTP_TOO_MANY_REQUESTS = 429 def __init__(self, api_key: str | None = None) -> None: @@ -25,7 +24,9 @@ class PubMedTool: # Ignore placeholder values from .env.example if self.api_key == "your-ncbi-key-here": self.api_key = None - self._last_request_time = 0.0 + + # Use shared rate limiter + self._limiter = get_pubmed_limiter(self.api_key) @property def name(self) -> str: @@ -33,12 +34,7 @@ class PubMedTool: async def _rate_limit(self) -> None: """Enforce NCBI rate limiting.""" - loop = asyncio.get_running_loop() - now = loop.time() - elapsed = now - self._last_request_time - if elapsed < self.RATE_LIMIT_DELAY: - await asyncio.sleep(self.RATE_LIMIT_DELAY - elapsed) - self._last_request_time = loop.time() + await self._limiter.acquire() def _build_params(self, **kwargs: Any) -> dict[str, Any]: """Build request params with optional API key.""" diff --git a/src/tools/rate_limiter.py b/src/tools/rate_limiter.py new file mode 100644 index 0000000000000000000000000000000000000000..44958d06e4f1ee6f1a8a7faa6e7c66806e19ae9f --- /dev/null +++ b/src/tools/rate_limiter.py @@ -0,0 +1,121 @@ +"""Rate limiting utilities using the limits library.""" + +import asyncio +from typing import ClassVar + +from limits import RateLimitItem, parse +from limits.storage import MemoryStorage +from limits.strategies import MovingWindowRateLimiter + + +class RateLimiter: + """ + Async-compatible rate limiter using limits library. + + Uses moving window algorithm for smooth rate limiting. + """ + + def __init__(self, rate: str) -> None: + """ + Initialize rate limiter. + + Args: + rate: Rate string like "3/second" or "10/second" + """ + self.rate = rate + self._storage = MemoryStorage() + self._limiter = MovingWindowRateLimiter(self._storage) + self._rate_limit: RateLimitItem = parse(rate) + self._identity = "default" # Single identity for shared limiting + + async def acquire(self, wait: bool = True) -> bool: + """ + Acquire permission to make a request. + + ASYNC-SAFE: Uses asyncio.sleep(), never time.sleep(). + The polling pattern allows other coroutines to run while waiting. + + Args: + wait: If True, wait until allowed. If False, return immediately. + + Returns: + True if allowed, False if not (only when wait=False) + """ + while True: + # Check if we can proceed (synchronous, fast - ~microseconds) + if self._limiter.hit(self._rate_limit, self._identity): + return True + + if not wait: + return False + + # CRITICAL: Use asyncio.sleep(), NOT time.sleep() + # This yields control to the event loop, allowing other + # coroutines (UI, parallel searches) to run. + # Using 0.01s for fine-grained responsiveness. + await asyncio.sleep(0.01) + + def reset(self) -> None: + """Reset the rate limiter (for testing).""" + self._storage.reset() + + +# Singleton limiter for PubMed/NCBI +_pubmed_limiter: RateLimiter | None = None + + +def get_pubmed_limiter(api_key: str | None = None) -> RateLimiter: + """ + Get the shared PubMed rate limiter. + + Rate depends on whether API key is provided: + - Without key: 3 requests/second + - With key: 10 requests/second + + Args: + api_key: NCBI API key (optional) + + Returns: + Shared RateLimiter instance + """ + global _pubmed_limiter + + if _pubmed_limiter is None: + rate = "10/second" if api_key else "3/second" + _pubmed_limiter = RateLimiter(rate) + + return _pubmed_limiter + + +def reset_pubmed_limiter() -> None: + """Reset the PubMed limiter (for testing).""" + global _pubmed_limiter + _pubmed_limiter = None + + +# Factory for other APIs +class RateLimiterFactory: + """Factory for creating/getting rate limiters for different APIs.""" + + _limiters: ClassVar[dict[str, RateLimiter]] = {} + + @classmethod + def get(cls, api_name: str, rate: str) -> RateLimiter: + """ + Get or create a rate limiter for an API. + + Args: + api_name: Unique identifier for the API + rate: Rate limit string (e.g., "10/second") + + Returns: + RateLimiter instance (shared for same api_name) + """ + if api_name not in cls._limiters: + cls._limiters[api_name] = RateLimiter(rate) + return cls._limiters[api_name] + + @classmethod + def reset_all(cls) -> None: + """Reset all limiters (for testing).""" + cls._limiters.clear() diff --git a/src/tools/web_search.py b/src/tools/web_search.py new file mode 100644 index 0000000000000000000000000000000000000000..460293d63e5771457688d74e2b155333b51b449a --- /dev/null +++ b/src/tools/web_search.py @@ -0,0 +1,53 @@ +"""Web search tool using DuckDuckGo.""" + +import asyncio + +import structlog +from duckduckgo_search import DDGS + +from src.utils.models import Citation, Evidence, SearchResult + +logger = structlog.get_logger() + + +class WebSearchTool: + """Tool for searching the web using DuckDuckGo.""" + + def __init__(self) -> None: + self._ddgs = DDGS() + + async def search(self, query: str, max_results: int = 10) -> SearchResult: + """Execute a web search.""" + try: + loop = asyncio.get_running_loop() + + def _do_search() -> list[dict[str, str]]: + # text() returns an iterator, need to list() it or iterate + return list(self._ddgs.text(query, max_results=max_results)) + + raw_results = await loop.run_in_executor(None, _do_search) + + evidence = [] + for r in raw_results: + ev = Evidence( + content=r.get("body", ""), + citation=Citation( + title=r.get("title", "No Title"), + url=r.get("href", ""), + source="web", + date="Unknown", + authors=[], + ), + relevance=0.0, + ) + evidence.append(ev) + + return SearchResult( + query=query, evidence=evidence, sources_searched=["web"], total_found=len(evidence) + ) + + except Exception as e: + logger.error("Web search failed", error=str(e)) + return SearchResult( + query=query, evidence=[], sources_searched=["web"], total_found=0, errors=[str(e)] + ) diff --git a/src/utils/config.py b/src/utils/config.py index 2cfdcae6a2984e1a52ca67b49d0fcbc0ffcd61b3..c5604e4d00ac6f36c66b57535483784f80c3ccf2 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -23,13 +23,20 @@ class Settings(BaseSettings): # LLM Configuration openai_api_key: str | None = Field(default=None, description="OpenAI API key") anthropic_api_key: str | None = Field(default=None, description="Anthropic API key") - llm_provider: Literal["openai", "anthropic"] = Field( + llm_provider: Literal["openai", "anthropic", "huggingface"] = Field( default="openai", description="Which LLM provider to use" ) openai_model: str = Field(default="gpt-5.1", description="OpenAI model name") anthropic_model: str = Field( default="claude-sonnet-4-5-20250929", description="Anthropic model" ) + # HuggingFace (free tier) + huggingface_model: str | None = Field( + default="meta-llama/Llama-3.1-70B-Instruct", description="HuggingFace model name" + ) + hf_token: str | None = Field( + default=None, alias="HF_TOKEN", description="HuggingFace API token" + ) # Embedding Configuration # Note: OpenAI embeddings require OPENAI_API_KEY (Anthropic has no embeddings API) @@ -175,10 +182,15 @@ class Settings(BaseSettings): """Check if Anthropic API key is available.""" return bool(self.anthropic_api_key) + @property + def has_huggingface_key(self) -> bool: + """Check if HuggingFace token is available.""" + return bool(self.hf_token) + @property def has_any_llm_key(self) -> bool: """Check if any LLM API key is available.""" - return self.has_openai_key or self.has_anthropic_key + return self.has_openai_key or self.has_anthropic_key or self.has_huggingface_key @property def has_huggingface_key(self) -> bool: diff --git a/src/utils/models.py b/src/utils/models.py index bf9667f1d3fd0e9da5e9456a1579f5bba51d613e..3c8599ac61cd0fbd615ced82b195c5e9b4c0bf12 100644 --- a/src/utils/models.py +++ b/src/utils/models.py @@ -36,6 +36,10 @@ class Evidence(BaseModel): content: str = Field(min_length=1, description="The actual text content") citation: Citation relevance: float = Field(default=0.0, ge=0.0, le=1.0, description="Relevance score 0-1") + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Additional metadata (e.g., cited_by_count, concepts, is_open_access)", + ) model_config = {"frozen": True} diff --git a/tests/integration/test_dual_mode_e2e.py b/tests/integration/test_dual_mode_e2e.py new file mode 100644 index 0000000000000000000000000000000000000000..61961e737bcaece9452426c81c89adea263c11ff --- /dev/null +++ b/tests/integration/test_dual_mode_e2e.py @@ -0,0 +1,82 @@ +"""End-to-End Integration Tests for Dual-Mode Architecture.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.slow] + +from src.orchestrator_factory import create_orchestrator +from src.utils.models import Citation, Evidence, OrchestratorConfig + + +@pytest.fixture +def mock_search_handler(): + handler = MagicMock() + handler.execute = AsyncMock( + return_value=[ + Evidence( + citation=Citation( + title="Test Paper", url="http://test", date="2024", source="pubmed" + ), + content="Metformin increases lifespan in mice.", + ) + ] + ) + return handler + + +@pytest.fixture +def mock_judge_handler(): + handler = MagicMock() + # Mock return value of assess + assessment = MagicMock() + assessment.sufficient = True + assessment.recommendation = "synthesize" + handler.assess = AsyncMock(return_value=assessment) + return handler + + +@pytest.mark.asyncio +async def test_simple_mode_e2e(mock_search_handler, mock_judge_handler): + """Test Simple Mode Orchestration flow.""" + orch = create_orchestrator( + search_handler=mock_search_handler, + judge_handler=mock_judge_handler, + mode="simple", + config=OrchestratorConfig(max_iterations=1), + ) + + # Run + results = [] + async for event in orch.run("Test query"): + results.append(event) + + assert len(results) > 0 + assert mock_search_handler.execute.called + assert mock_judge_handler.assess.called + + +@pytest.mark.asyncio +async def test_advanced_mode_explicit_instantiation(): + """Test explicit Advanced Mode instantiation (not auto-detect). + + This tests the explicit mode="advanced" path, verifying that + MagenticOrchestrator can be instantiated when explicitly requested. + The settings patch ensures any internal checks pass. + """ + with patch("src.orchestrator_factory.settings") as mock_settings: + # Settings patch ensures factory checks pass (even though mode is explicit) + mock_settings.has_openai_key = True + + with patch("src.agents.magentic_agents.OpenAIChatClient"): + # Mock agent creation to avoid real API calls during init + with ( + patch("src.orchestrator_magentic.create_search_agent"), + patch("src.orchestrator_magentic.create_judge_agent"), + patch("src.orchestrator_magentic.create_hypothesis_agent"), + patch("src.orchestrator_magentic.create_report_agent"), + ): + # Explicit mode="advanced" - tests the explicit path, not auto-detect + orch = create_orchestrator(mode="advanced") + assert orch is not None diff --git a/tests/integration/test_modal.py b/tests/integration/test_modal.py index 9116f6761fa13869fcb207f64db7367cce851f18..9b5d4a565ed7971cedb225a4a9951b0a660a0775 100644 --- a/tests/integration/test_modal.py +++ b/tests/integration/test_modal.py @@ -1,4 +1,4 @@ -"""Integration tests for Modal (requires credentials).""" +"""Integration tests for Modal (requires credentials and modal package).""" import pytest @@ -7,9 +7,18 @@ from src.utils.config import settings # Check if any LLM API key is available _llm_available = bool(settings.openai_api_key or settings.anthropic_api_key) +# Check if modal package is installed +try: + import modal # noqa: F401 + + _modal_installed = True +except ImportError: + _modal_installed = False + @pytest.mark.integration -@pytest.mark.skipif(not settings.modal_available, reason="Modal not configured") +@pytest.mark.skipif(not _modal_installed, reason="Modal package not installed") +@pytest.mark.skipif(not settings.modal_available, reason="Modal credentials not configured") class TestModalIntegration: """Integration tests requiring Modal credentials.""" diff --git a/tests/unit/agent_factory/test_judges_factory.py b/tests/unit/agent_factory/test_judges_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..8c5af6b16dc3dd12a6f1db757580c23b492e255a --- /dev/null +++ b/tests/unit/agent_factory/test_judges_factory.py @@ -0,0 +1,64 @@ +"""Unit tests for Judge Factory and Model Selection.""" + +from unittest.mock import patch + +import pytest + +pytestmark = pytest.mark.unit +from pydantic_ai.models.anthropic import AnthropicModel + +# We expect this import to exist after we implement it, or we mock it if it's not there yet +# For TDD, we assume we will use the library class +from pydantic_ai.models.huggingface import HuggingFaceModel +from pydantic_ai.models.openai import OpenAIModel + +from src.agent_factory.judges import get_model + + +@pytest.fixture +def mock_settings(): + with patch("src.agent_factory.judges.settings", autospec=True) as mock_settings: + yield mock_settings + + +def test_get_model_openai(mock_settings): + """Test that OpenAI model is returned when provider is openai.""" + mock_settings.llm_provider = "openai" + mock_settings.openai_api_key = "sk-test" + mock_settings.openai_model = "gpt-5.1" + + model = get_model() + assert isinstance(model, OpenAIModel) + assert model.model_name == "gpt-5.1" + + +def test_get_model_anthropic(mock_settings): + """Test that Anthropic model is returned when provider is anthropic.""" + mock_settings.llm_provider = "anthropic" + mock_settings.anthropic_api_key = "sk-ant-test" + mock_settings.anthropic_model = "claude-sonnet-4-5-20250929" + + model = get_model() + assert isinstance(model, AnthropicModel) + assert model.model_name == "claude-sonnet-4-5-20250929" + + +def test_get_model_huggingface(mock_settings): + """Test that HuggingFace model is returned when provider is huggingface.""" + mock_settings.llm_provider = "huggingface" + mock_settings.hf_token = "hf_test_token" + mock_settings.huggingface_model = "meta-llama/Llama-3.1-70B-Instruct" + + model = get_model() + assert isinstance(model, HuggingFaceModel) + assert model.model_name == "meta-llama/Llama-3.1-70B-Instruct" + + +def test_get_model_default_fallback(mock_settings): + """Test fallback to OpenAI if provider is unknown.""" + mock_settings.llm_provider = "unknown_provider" + mock_settings.openai_api_key = "sk-test" + mock_settings.openai_model = "gpt-5.1" + + model = get_model() + assert isinstance(model, OpenAIModel) diff --git a/tests/unit/agents/test_agent_imports.py b/tests/unit/agents/test_agent_imports.py new file mode 100644 index 0000000000000000000000000000000000000000..4d50df6b7f12cad71d310211a7d910404cfab078 --- /dev/null +++ b/tests/unit/agents/test_agent_imports.py @@ -0,0 +1,32 @@ +"""Test that agent framework dependencies are importable and usable.""" + +from unittest.mock import MagicMock + +import pytest + +pytestmark = pytest.mark.unit + +# Import conditional on package availability, but for this test we expect it to be there +try: + from agent_framework import ChatAgent + from agent_framework.openai import OpenAIChatClient +except ImportError: + ChatAgent = None + OpenAIChatClient = None + + +@pytest.mark.skipif(ChatAgent is None, reason="agent-framework-core not installed") +def test_agent_framework_import(): + """Test that agent_framework can be imported.""" + assert ChatAgent is not None + assert OpenAIChatClient is not None # Verify both imports work + + +@pytest.mark.skipif(ChatAgent is None, reason="agent-framework-core not installed") +def test_chat_agent_instantiation(): + """Test that ChatAgent can be instantiated with a mock client.""" + mock_client = MagicMock() + # We assume ChatAgent takes chat_client as first argument based on _agents.py source + agent = ChatAgent(chat_client=mock_client, name="TestAgent") + assert agent.name == "TestAgent" + assert agent.chat_client == mock_client diff --git a/tests/unit/test_hierarchical.py b/tests/unit/test_hierarchical.py new file mode 100644 index 0000000000000000000000000000000000000000..9dc044f862f3c255c6e8fd6644f74f24dc61caee --- /dev/null +++ b/tests/unit/test_hierarchical.py @@ -0,0 +1,40 @@ +"""Unit tests for hierarchical orchestration middleware.""" + +from unittest.mock import AsyncMock + +import pytest + +from src.middleware.sub_iteration import SubIterationMiddleware +from src.utils.models import AssessmentDetails, JudgeAssessment + +pytestmark = pytest.mark.unit + + +@pytest.mark.asyncio +async def test_sub_iteration_middleware(): + team = AsyncMock() + team.execute.return_value = "Result" + + judge = AsyncMock() + judge.assess.return_value = JudgeAssessment( + details=AssessmentDetails( + mechanism_score=10, + mechanism_reasoning="Good reasoning text here", + clinical_evidence_score=10, + clinical_reasoning="Good reasoning text here", + drug_candidates=[], + key_findings=[], + ), + sufficient=True, + confidence=1.0, + recommendation="synthesize", + next_search_queries=[], + reasoning="Good reasoning text here for the overall assessment which must be long enough.", + ) + + middleware = SubIterationMiddleware(team, judge) + result, assessment = await middleware.run("task") + + assert result == "Result" + assert assessment.sufficient + assert team.execute.call_count == 1 diff --git a/tests/unit/test_magentic_fix.py b/tests/unit/test_magentic_fix.py new file mode 100644 index 0000000000000000000000000000000000000000..93c1dc4d12b7460845ee3793f27394cb0d30dbb1 --- /dev/null +++ b/tests/unit/test_magentic_fix.py @@ -0,0 +1,97 @@ +"""Tests for Magentic Orchestrator fixes.""" + +from unittest.mock import MagicMock, patch + +import pytest +from agent_framework import MagenticFinalResultEvent + +from src.orchestrator_magentic import MagenticOrchestrator + + +class MockChatMessage: + """Simulates the buggy ChatMessage that returns itself as text or has complex content.""" + + def __init__(self, content_str: str) -> None: + self.content_str = content_str + self.role = "assistant" + + @property + def text(self) -> "MockChatMessage": + # Simulate the bug: .text returns the object itself or a repr string + return self + + @property + def content(self) -> str: + # The fix plan says we should look for .content + return self.content_str + + def __repr__(self) -> str: + return "" + + def __str__(self) -> str: + return "" + + +@pytest.fixture +def mock_magentic_requirements(): + """Mock the API key check so tests run in CI without OPENAI_API_KEY.""" + with patch("src.orchestrator_magentic.check_magentic_requirements"): + yield + + +class TestMagenticFixes: + """Tests for the Magentic mode fixes.""" + + def test_process_event_extracts_text_correctly(self, mock_magentic_requirements) -> None: + """ + Test that _process_event correctly extracts text from a ChatMessage. + + Verifies fix for bug where .text returns the object itself. + """ + orchestrator = MagenticOrchestrator() + + # Create a mock message that mimics the bug + buggy_message = MockChatMessage("Final Report Content") + event = MagenticFinalResultEvent(message=buggy_message) # type: ignore[arg-type] + + # Process the event + # We expect the fix to get "Final Report Content" instead of object repr + result_event = orchestrator._process_event(event, iteration=1) + + assert result_event is not None + assert result_event.type == "complete" + assert result_event.message == "Final Report Content" + + def test_max_rounds_configuration(self, mock_magentic_requirements) -> None: + """Test that max_rounds is correctly passed to the orchestrator.""" + orchestrator = MagenticOrchestrator(max_rounds=25) + assert orchestrator._max_rounds == 25 + + # Also verify it's used in _build_workflow + # Mock all the agent creation and OpenAI client calls + with ( + patch("src.orchestrator_magentic.create_search_agent") as mock_search, + patch("src.orchestrator_magentic.create_judge_agent") as mock_judge, + patch("src.orchestrator_magentic.create_hypothesis_agent") as mock_hypo, + patch("src.orchestrator_magentic.create_report_agent") as mock_report, + patch("src.orchestrator_magentic.OpenAIChatClient") as mock_client, + patch("src.orchestrator_magentic.MagenticBuilder") as mock_builder, + ): + # Setup mocks + mock_search.return_value = MagicMock() + mock_judge.return_value = MagicMock() + mock_hypo.return_value = MagicMock() + mock_report.return_value = MagicMock() + mock_client.return_value = MagicMock() + + # Mock the builder chain + mock_chain = mock_builder.return_value.participants.return_value + mock_chain.with_standard_manager.return_value.build.return_value = MagicMock() + + orchestrator._build_workflow() + + # Check that max_round_count was passed as 25 + participants_mock = mock_builder.return_value.participants.return_value + participants_mock.with_standard_manager.assert_called_once() + call_kwargs = participants_mock.with_standard_manager.call_args.kwargs + assert call_kwargs["max_round_count"] == 25 diff --git a/tests/unit/test_orchestrator_factory.py b/tests/unit/test_orchestrator_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..78870f7ec760358d09f9a0cc0922047a550df0a4 --- /dev/null +++ b/tests/unit/test_orchestrator_factory.py @@ -0,0 +1,66 @@ +"""Unit tests for Orchestrator Factory.""" + +from unittest.mock import MagicMock, patch + +import pytest + +pytestmark = pytest.mark.unit + +from src.orchestrator import Orchestrator +from src.orchestrator_factory import create_orchestrator + + +@pytest.fixture +def mock_settings(): + with patch("src.orchestrator_factory.settings", autospec=True) as mock_settings: + yield mock_settings + + +@pytest.fixture +def mock_magentic_cls(): + with patch("src.orchestrator_factory._get_magentic_orchestrator_class") as mock: + # The mock returns a class (callable), which returns an instance + mock_class = MagicMock() + mock.return_value = mock_class + yield mock_class + + +@pytest.fixture +def mock_handlers(): + return MagicMock(), MagicMock() + + +def test_create_orchestrator_simple_explicit(mock_settings, mock_handlers): + """Test explicit simple mode.""" + search, judge = mock_handlers + orch = create_orchestrator(search_handler=search, judge_handler=judge, mode="simple") + assert isinstance(orch, Orchestrator) + + +def test_create_orchestrator_advanced_explicit(mock_settings, mock_handlers, mock_magentic_cls): + """Test explicit advanced mode.""" + # Ensure has_openai_key is True so it doesn't error if we add checks + mock_settings.has_openai_key = True + + orch = create_orchestrator(mode="advanced") + # verify instantiated + mock_magentic_cls.assert_called_once() + assert orch == mock_magentic_cls.return_value + + +def test_create_orchestrator_auto_advanced(mock_settings, mock_magentic_cls): + """Test auto-detect advanced mode when OpenAI key exists.""" + mock_settings.has_openai_key = True + + orch = create_orchestrator() + mock_magentic_cls.assert_called_once() + assert orch == mock_magentic_cls.return_value + + +def test_create_orchestrator_auto_simple(mock_settings, mock_handlers): + """Test auto-detect simple mode when no paid keys.""" + mock_settings.has_openai_key = False + + search, judge = mock_handlers + orch = create_orchestrator(search_handler=search, judge_handler=judge) + assert isinstance(orch, Orchestrator) diff --git a/tests/unit/tools/test_rate_limiting.py b/tests/unit/tools/test_rate_limiting.py new file mode 100644 index 0000000000000000000000000000000000000000..b67cea1f98bce805868d6eaa5af1cebfa52f00b0 --- /dev/null +++ b/tests/unit/tools/test_rate_limiting.py @@ -0,0 +1,104 @@ +"""Tests for rate limiting functionality.""" + +import asyncio +import time + +import pytest + +from src.tools.rate_limiter import RateLimiter, get_pubmed_limiter, reset_pubmed_limiter + + +class TestRateLimiter: + """Test suite for rate limiter.""" + + def test_create_limiter_without_api_key(self) -> None: + """Should create 3/sec limiter without API key.""" + limiter = RateLimiter(rate="3/second") + assert limiter.rate == "3/second" + + def test_create_limiter_with_api_key(self) -> None: + """Should create 10/sec limiter with API key.""" + limiter = RateLimiter(rate="10/second") + assert limiter.rate == "10/second" + + @pytest.mark.asyncio + async def test_limiter_allows_requests_under_limit(self) -> None: + """Should allow requests under the rate limit.""" + limiter = RateLimiter(rate="10/second") + + # 3 requests should all succeed immediately + for _ in range(3): + allowed = await limiter.acquire() + assert allowed is True + + @pytest.mark.asyncio + async def test_limiter_blocks_when_exceeded(self) -> None: + """Should wait when rate limit exceeded.""" + limiter = RateLimiter(rate="2/second") + + # First 2 should be instant + await limiter.acquire() + await limiter.acquire() + + # Third should block briefly + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + # Should have waited ~0.5 seconds (half second window for 2/sec) + assert elapsed >= 0.3 + + @pytest.mark.asyncio + async def test_limiter_resets_after_window(self) -> None: + """Rate limit should reset after time window.""" + limiter = RateLimiter(rate="5/second") + + # Use up the limit + for _ in range(5): + await limiter.acquire() + + # Wait for window to pass + await asyncio.sleep(1.1) + + # Should be allowed again + start = time.monotonic() + await limiter.acquire() + elapsed = time.monotonic() - start + + assert elapsed < 0.1 # Should be nearly instant + + +class TestGetPubmedLimiter: + """Test PubMed-specific limiter factory.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self): + """Reset limiter before and after each test.""" + reset_pubmed_limiter() + yield + reset_pubmed_limiter() + + def test_limiter_without_api_key(self) -> None: + """Should return 3/sec limiter without key.""" + limiter = get_pubmed_limiter(api_key=None) + assert "3" in limiter.rate + + def test_limiter_with_api_key(self) -> None: + """Should return 10/sec limiter with key.""" + limiter = get_pubmed_limiter(api_key="my-api-key") + assert "10" in limiter.rate + + def test_limiter_is_singleton(self) -> None: + """Same API key should return same limiter instance.""" + limiter1 = get_pubmed_limiter(api_key="key1") + limiter2 = get_pubmed_limiter(api_key="key1") + assert limiter1 is limiter2 + + def test_different_keys_different_limiters(self) -> None: + """Different API keys should return different limiters.""" + limiter1 = get_pubmed_limiter(api_key="key1") + limiter2 = get_pubmed_limiter(api_key="key2") + # Clear cache for clean test + # Actually, different keys SHOULD share the same limiter + # since we're limiting against the same API + assert limiter1 is limiter2 # Shared NCBI rate limit diff --git a/uv.lock b/uv.lock index 3047dcc72ac7397e802d4352136616d5cb8026e7..25780ae958b8efcb83373f6dad1737ffb36bcff3 100644 --- a/uv.lock +++ b/uv.lock @@ -1063,9 +1063,11 @@ source = { editable = "." } dependencies = [ { name = "anthropic" }, { name = "beautifulsoup4" }, + { name = "duckduckgo-search" }, { name = "gradio", extra = ["mcp"] }, { name = "httpx" }, { name = "huggingface-hub" }, + { name = "limits" }, { name = "openai" }, { name = "pydantic" }, { name = "pydantic-ai" }, @@ -1120,9 +1122,11 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.12" }, { name = "chromadb", marker = "extra == 'embeddings'", specifier = ">=0.4.0" }, { name = "chromadb", marker = "extra == 'modal'", specifier = ">=0.4.0" }, + { name = "duckduckgo-search", specifier = ">=5.0" }, { name = "gradio", extras = ["mcp"], specifier = ">=6.0.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "huggingface-hub", specifier = ">=0.20.0" }, + { name = "limits", specifier = ">=3.0" }, { name = "llama-index", marker = "extra == 'modal'", specifier = ">=0.11.0" }, { name = "llama-index-embeddings-openai", marker = "extra == 'modal'" }, { name = "llama-index-llms-openai", marker = "extra == 'modal'" }, @@ -1242,6 +1246,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" }, ] +[[package]] +name = "duckduckgo-search" +version = "8.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "lxml" }, + { name = "primp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/ef/07791a05751e6cc9de1dd49fb12730259ee109b18e6d097e25e6c32d5617/duckduckgo_search-8.1.1.tar.gz", hash = "sha256:9da91c9eb26a17e016ea1da26235d40404b46b0565ea86d75a9f78cc9441f935", size = 22868 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/72/c027b3b488b1010cf71670032fcf7e681d44b81829d484bb04e31a949a8d/duckduckgo_search-8.1.1-py3-none-any.whl", hash = "sha256:f48adbb06626ee05918f7e0cef3a45639e9939805c4fc179e68c48a12f1b5062", size = 18932 }, +] + [[package]] name = "durationpy" version = "0.10" @@ -2273,6 +2291,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380, upload-time = "2025-09-29T20:23:47.684Z" }, ] +[[package]] +name = "limits" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604 }, +] + [[package]] name = "llama-cloud" version = "0.1.35" @@ -2530,6 +2562,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/2b/851e78a60b85e8e8e8c6ebb9928f8e883df0340a93e34960ed9f0a41fa82/logfire_api-4.15.1-py3-none-any.whl", hash = "sha256:a88b5c4b6e4acbf6f35a3e992a63f271cf2797aefd21e1cfc93d52b21ade65f6", size = 95031, upload-time = "2025-11-20T15:52:14.433Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365 }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793 }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362 }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152 }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539 }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853 }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133 }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944 }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535 }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343 }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419 }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008 }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906 }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357 }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583 }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591 }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887 }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818 }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807 }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179 }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044 }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685 }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127 }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958 }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541 }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426 }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917 }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795 }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759 }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666 }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989 }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456 }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793 }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836 }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494 }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146 }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932 }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060 }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000 }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496 }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779 }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072 }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171 }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175 }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688 }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655 }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695 }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841 }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700 }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347 }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248 }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801 }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403 }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974 }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953 }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054 }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421 }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684 }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463 }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437 }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890 }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185 }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895 }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246 }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797 }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404 }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072 }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617 }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930 }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380 }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632 }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171 }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109 }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061 }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233 }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739 }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119 }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665 }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997 }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957 }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372 }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653 }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795 }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023 }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420 }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837 }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205 }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829 }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277 }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433 }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119 }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314 }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768 }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -3792,6 +3926,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" }, ] +[[package]] +name = "primp" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/0b/a87556189da4de1fc6360ca1aa05e8335509633f836cdd06dd17f0743300/primp-0.15.0.tar.gz", hash = "sha256:1af8ea4b15f57571ff7fc5e282a82c5eb69bc695e19b8ddeeda324397965b30a", size = 113022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/5a/146ac964b99ea7657ad67eb66f770be6577dfe9200cb28f9a95baffd6c3f/primp-0.15.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1b281f4ca41a0c6612d4c6e68b96e28acfe786d226a427cd944baa8d7acd644f", size = 3178914 }, + { url = "https://files.pythonhosted.org/packages/bc/8a/cc2321e32db3ce64d6e32950d5bcbea01861db97bfb20b5394affc45b387/primp-0.15.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:489cbab55cd793ceb8f90bb7423c6ea64ebb53208ffcf7a044138e3c66d77299", size = 2955079 }, + { url = "https://files.pythonhosted.org/packages/c3/7b/cbd5d999a07ff2a21465975d4eb477ae6f69765e8fe8c9087dab250180d8/primp-0.15.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c18b45c23f94016215f62d2334552224236217aaeb716871ce0e4dcfa08eb161", size = 3281018 }, + { url = "https://files.pythonhosted.org/packages/1b/6e/a6221c612e61303aec2bcac3f0a02e8b67aee8c0db7bdc174aeb8010f975/primp-0.15.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e985a9cba2e3f96a323722e5440aa9eccaac3178e74b884778e926b5249df080", size = 3255229 }, + { url = "https://files.pythonhosted.org/packages/3b/54/bfeef5aca613dc660a69d0760a26c6b8747d8fdb5a7f20cb2cee53c9862f/primp-0.15.0-cp38-abi3-manylinux_2_34_armv7l.whl", hash = "sha256:6b84a6ffa083e34668ff0037221d399c24d939b5629cd38223af860de9e17a83", size = 3014522 }, + { url = "https://files.pythonhosted.org/packages/ac/96/84078e09f16a1dad208f2fe0f8a81be2cf36e024675b0f9eec0c2f6e2182/primp-0.15.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:592f6079646bdf5abbbfc3b0a28dac8de943f8907a250ce09398cda5eaebd260", size = 3418567 }, + { url = "https://files.pythonhosted.org/packages/6c/80/8a7a9587d3eb85be3d0b64319f2f690c90eb7953e3f73a9ddd9e46c8dc42/primp-0.15.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a728e5a05f37db6189eb413d22c78bd143fa59dd6a8a26dacd43332b3971fe8", size = 3606279 }, + { url = "https://files.pythonhosted.org/packages/0c/dd/f0183ed0145e58cf9d286c1b2c14f63ccee987a4ff79ac85acc31b5d86bd/primp-0.15.0-cp38-abi3-win_amd64.whl", hash = "sha256:aeb6bd20b06dfc92cfe4436939c18de88a58c640752cf7f30d9e4ae893cdec32", size = 3149967 }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52"