Spaces:
Running
Running
initial commit
Browse files- Dockerfile +25 -0
- README.md +157 -12
- __init__.py +3 -0
- __main__.py +7 -0
- config.py +44 -0
- core/__init__.py +7 -0
- core/base_provider.py +58 -0
- core/decorators.py +85 -0
- core/http_client.py +35 -0
- providers/__init__.py +20 -0
- providers/clinical_guidelines_provider.py +512 -0
- providers/cms_pricing_provider.py +420 -0
- providers/openfda_provider.py +573 -0
- requirements.txt +6 -0
- server.py +223 -0
- server_http.py +265 -0
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dependencies
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy application code
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# Environment variables
|
| 13 |
+
ENV ENABLED_PROVIDERS="openfda,clinical_guidelines,cms_pricing"
|
| 14 |
+
ENV LOG_LEVEL="INFO"
|
| 15 |
+
ENV PYTHONUNBUFFERED=1
|
| 16 |
+
|
| 17 |
+
# Expose port
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
|
| 20 |
+
# Health check
|
| 21 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 22 |
+
CMD python -c "import httpx; httpx.get('http://localhost:7860/health')"
|
| 23 |
+
|
| 24 |
+
# Run HTTP streaming server
|
| 25 |
+
CMD ["python", "server_http.py"]
|
README.md
CHANGED
|
@@ -1,12 +1,157 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Healthcare API MCP
|
| 2 |
+
|
| 3 |
+
A Model Context Protocol (MCP) server providing access to public healthcare data APIs including FDA adverse events, clinical guidelines, and Medicare pricing information.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
### OpenFDA Provider (14 tools)
|
| 8 |
+
- **Drug Adverse Events**: Query FAERS database for medication safety information
|
| 9 |
+
- **Drug Labels**: Search FDA-approved drug labeling information
|
| 10 |
+
- **Drug Recalls**: Find drug recall and enforcement reports
|
| 11 |
+
- **NDC Directory**: Look up National Drug Codes
|
| 12 |
+
- **Drugs@FDA**: Search approved drug products
|
| 13 |
+
- **Device Events & Recalls**: Medical device adverse events and recalls
|
| 14 |
+
- **Device Classifications**: Device classification database
|
| 15 |
+
- **510(k) & PMA**: Device clearances and approvals
|
| 16 |
+
- **Food Recalls & Events**: Food safety information
|
| 17 |
+
|
| 18 |
+
### Clinical Guidelines Provider (5 tools)
|
| 19 |
+
- **Preventive Recommendations**: USPSTF evidence-based screening guidelines
|
| 20 |
+
- **Vaccine Schedule**: CDC immunization recommendations
|
| 21 |
+
- **Search Guidelines**: Find guidelines by condition or screening type
|
| 22 |
+
- **Lifestyle Recommendations**: Evidence-based wellness guidance
|
| 23 |
+
- **Care Plan Generator**: Comprehensive 6-month care planning
|
| 24 |
+
|
| 25 |
+
### CMS Pricing Provider (5 tools)
|
| 26 |
+
- **Medicare Fee Schedule**: Get reimbursement rates by HCPCS code
|
| 27 |
+
- **Procedure Cost Estimates**: Typical cost ranges by procedure
|
| 28 |
+
- **Search Procedure Codes**: Find HCPCS/CPT codes
|
| 29 |
+
- **Facility Cost Comparison**: Compare prices across facilities
|
| 30 |
+
- **Out-of-Pocket Estimates**: Calculate patient responsibility
|
| 31 |
+
|
| 32 |
+
## Installation
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
pip install -r requirements.txt
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Configuration
|
| 39 |
+
|
| 40 |
+
Set environment variables to configure the server:
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
# Enable specific providers (comma-separated)
|
| 44 |
+
export ENABLED_PROVIDERS="openfda,clinical_guidelines,cms_pricing"
|
| 45 |
+
|
| 46 |
+
# Optional: OpenFDA API key for higher rate limits
|
| 47 |
+
export OPENFDA_API_KEY="your_api_key_here"
|
| 48 |
+
|
| 49 |
+
# Logging level
|
| 50 |
+
export LOG_LEVEL="INFO"
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
## Usage
|
| 54 |
+
|
| 55 |
+
### Run as MCP Server (stdio)
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
python -m healthcare_api_mcp
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Use with Claude Desktop
|
| 62 |
+
|
| 63 |
+
Add to your Claude Desktop config (`claude_desktop_config.json`):
|
| 64 |
+
|
| 65 |
+
```json
|
| 66 |
+
{
|
| 67 |
+
"mcpServers": {
|
| 68 |
+
"healthcare-api": {
|
| 69 |
+
"command": "python",
|
| 70 |
+
"args": ["-m", "healthcare_api_mcp"],
|
| 71 |
+
"env": {
|
| 72 |
+
"ENABLED_PROVIDERS": "openfda,clinical_guidelines,cms_pricing"
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### Filter Providers
|
| 80 |
+
|
| 81 |
+
Run only specific providers:
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
# Only FDA data
|
| 85 |
+
ENABLED_PROVIDERS=openfda python -m healthcare_api_mcp
|
| 86 |
+
|
| 87 |
+
# Only clinical guidelines
|
| 88 |
+
ENABLED_PROVIDERS=clinical_guidelines python -m healthcare_api_mcp
|
| 89 |
+
|
| 90 |
+
# FDA and CMS pricing
|
| 91 |
+
ENABLED_PROVIDERS=openfda,cms_pricing python -m healthcare_api_mcp
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
## Tool Naming
|
| 95 |
+
|
| 96 |
+
All tools are namespaced by provider:
|
| 97 |
+
- OpenFDA: `openfda_*` (e.g., `openfda_search_adverse_events`)
|
| 98 |
+
- Clinical Guidelines: `clinical_*` (e.g., `clinical_get_vaccine_schedule`)
|
| 99 |
+
- CMS Pricing: `cms_*` (e.g., `cms_get_medicare_fee`)
|
| 100 |
+
|
| 101 |
+
## Example Queries
|
| 102 |
+
|
| 103 |
+
### Check Drug Safety
|
| 104 |
+
```
|
| 105 |
+
Agent: "Check if Simvastatin has any FDA recalls"
|
| 106 |
+
→ Uses: openfda_search_drug_recalls
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### Get Preventive Care Recommendations
|
| 110 |
+
```
|
| 111 |
+
Agent: "What screenings does a 55-year-old female need?"
|
| 112 |
+
→ Uses: clinical_get_preventive_recommendations
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
### Estimate Procedure Cost
|
| 116 |
+
```
|
| 117 |
+
Agent: "How much does a colonoscopy cost with Medicare?"
|
| 118 |
+
→ Uses: cms_estimate_procedure_cost, cms_estimate_out_of_pocket
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
## Data Sources
|
| 122 |
+
|
| 123 |
+
- **OpenFDA**: Real-time access to FDA's public APIs
|
| 124 |
+
- **Clinical Guidelines**: Evidence-based recommendations from USPSTF, CDC, AHRQ
|
| 125 |
+
- **CMS Pricing**: Sample data based on Medicare fee schedules (2024)
|
| 126 |
+
|
| 127 |
+
## Architecture
|
| 128 |
+
|
| 129 |
+
```
|
| 130 |
+
healthcare_api_mcp/
|
| 131 |
+
├── server.py # Main MCP server with stdio
|
| 132 |
+
├── config.py # Configuration management
|
| 133 |
+
├── core/
|
| 134 |
+
│ ├── base_provider.py # Abstract provider base class
|
| 135 |
+
│ ├── decorators.py # @safe_json_return, @with_retry
|
| 136 |
+
│ └── http_client.py # Shared HTTP client factory
|
| 137 |
+
└── providers/
|
| 138 |
+
├── openfda_provider.py # FDA APIs
|
| 139 |
+
├── clinical_guidelines_provider.py
|
| 140 |
+
└── cms_pricing_provider.py
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
## Benefits
|
| 144 |
+
|
| 145 |
+
✅ **Zero duplication**: Shared utilities across all providers
|
| 146 |
+
✅ **Proper JSON returns**: All tools return JSON strings (no dict inconsistencies)
|
| 147 |
+
✅ **Modular**: Enable only the providers you need
|
| 148 |
+
✅ **Agent-friendly**: Standard MCP stdio protocol
|
| 149 |
+
✅ **Production-ready**: Error handling, retry logic, logging
|
| 150 |
+
|
| 151 |
+
## License
|
| 152 |
+
|
| 153 |
+
Copyright (c) 2025. All Rights Reserved.
|
| 154 |
+
|
| 155 |
+
## HuggingFace MCP 1st Birthday Hackathon
|
| 156 |
+
|
| 157 |
+
This project is submitted for Track 1 of the HuggingFace MCP 1st Birthday Hackathon.
|
__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Healthcare API MCP - Public health data APIs."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
__main__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entry point for running the MCP server as a module."""
|
| 2 |
+
|
| 3 |
+
from .server import main
|
| 4 |
+
import asyncio
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
asyncio.run(main())
|
config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration for Healthcare API MCP."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ProviderConfig(BaseModel):
|
| 9 |
+
"""Configuration for a provider."""
|
| 10 |
+
name: str
|
| 11 |
+
enabled: bool = True
|
| 12 |
+
api_key: Optional[str] = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class MCPConfig(BaseModel):
|
| 16 |
+
"""Main MCP server configuration."""
|
| 17 |
+
|
| 18 |
+
# Enabled providers (comma-separated or list)
|
| 19 |
+
enabled_providers: List[str] = Field(
|
| 20 |
+
default_factory=lambda: ["openfda", "clinical_guidelines", "cms_pricing"]
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# OpenFDA API key (optional but recommended for higher rate limits)
|
| 24 |
+
openfda_api_key: Optional[str] = Field(default=None)
|
| 25 |
+
|
| 26 |
+
# Logging level
|
| 27 |
+
log_level: str = Field(default="INFO")
|
| 28 |
+
|
| 29 |
+
@classmethod
|
| 30 |
+
def from_env(cls) -> "MCPConfig":
|
| 31 |
+
"""Load configuration from environment variables."""
|
| 32 |
+
|
| 33 |
+
# Parse enabled providers
|
| 34 |
+
enabled_str = os.getenv("ENABLED_PROVIDERS", "")
|
| 35 |
+
if enabled_str:
|
| 36 |
+
enabled_providers = [p.strip() for p in enabled_str.split(",")]
|
| 37 |
+
else:
|
| 38 |
+
enabled_providers = ["openfda", "clinical_guidelines", "cms_pricing"]
|
| 39 |
+
|
| 40 |
+
return cls(
|
| 41 |
+
enabled_providers=enabled_providers,
|
| 42 |
+
openfda_api_key=os.getenv("OPENFDA_API_KEY"),
|
| 43 |
+
log_level=os.getenv("LOG_LEVEL", "INFO")
|
| 44 |
+
)
|
core/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core utilities for Healthcare API MCP."""
|
| 2 |
+
|
| 3 |
+
from .base_provider import BaseProvider
|
| 4 |
+
from .decorators import safe_json_return, with_retry
|
| 5 |
+
from .http_client import create_http_client
|
| 6 |
+
|
| 7 |
+
__all__ = ["BaseProvider", "safe_json_return", "with_retry", "create_http_client"]
|
core/base_provider.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base provider class for healthcare APIs."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import List, Callable
|
| 5 |
+
import httpx
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BaseProvider(ABC):
|
| 12 |
+
"""Abstract base class for healthcare API providers."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, name: str, client: httpx.AsyncClient):
|
| 15 |
+
"""
|
| 16 |
+
Initialize provider.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
name: Provider name (used for tool prefixing)
|
| 20 |
+
client: Async HTTP client
|
| 21 |
+
"""
|
| 22 |
+
self.name = name
|
| 23 |
+
self.client = client
|
| 24 |
+
self._tools = []
|
| 25 |
+
|
| 26 |
+
@abstractmethod
|
| 27 |
+
async def initialize(self) -> None:
|
| 28 |
+
"""
|
| 29 |
+
Initialize provider (e.g., test API connection).
|
| 30 |
+
Override in subclasses if needed.
|
| 31 |
+
"""
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
@abstractmethod
|
| 35 |
+
def get_tools(self) -> List[Callable]:
|
| 36 |
+
"""
|
| 37 |
+
Return list of MCP tool functions provided by this provider.
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
List of tool functions
|
| 41 |
+
"""
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
async def cleanup(self) -> None:
|
| 45 |
+
"""
|
| 46 |
+
Cleanup provider resources.
|
| 47 |
+
Override in subclasses if needed.
|
| 48 |
+
"""
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
def register_tool(self, func: Callable) -> None:
|
| 52 |
+
"""Register a tool function."""
|
| 53 |
+
self._tools.append(func)
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def tools(self) -> List[Callable]:
|
| 57 |
+
"""Get all registered tools."""
|
| 58 |
+
return self._tools
|
core/decorators.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared decorators for MCP tools."""
|
| 2 |
+
|
| 3 |
+
import functools
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any, Callable, TypeVar
|
| 7 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
F = TypeVar('F', bound=Callable[..., Any])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def safe_json_return(func: F) -> F:
|
| 15 |
+
"""
|
| 16 |
+
Decorator that ensures MCP tools always return JSON strings.
|
| 17 |
+
Handles dicts, lists, None, and exceptions consistently.
|
| 18 |
+
"""
|
| 19 |
+
@functools.wraps(func)
|
| 20 |
+
async def async_wrapper(*args, **kwargs):
|
| 21 |
+
try:
|
| 22 |
+
result = await func(*args, **kwargs)
|
| 23 |
+
|
| 24 |
+
# Handle None
|
| 25 |
+
if result is None:
|
| 26 |
+
return json.dumps({"result": None})
|
| 27 |
+
|
| 28 |
+
# Handle dict or list
|
| 29 |
+
if isinstance(result, (dict, list)):
|
| 30 |
+
return json.dumps(result, ensure_ascii=False)
|
| 31 |
+
|
| 32 |
+
# Handle string (might already be JSON)
|
| 33 |
+
if isinstance(result, str):
|
| 34 |
+
return result
|
| 35 |
+
|
| 36 |
+
# Handle other types
|
| 37 |
+
return json.dumps({"result": str(result)})
|
| 38 |
+
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.exception(f"Error in {func.__name__}")
|
| 41 |
+
return json.dumps({
|
| 42 |
+
"error": str(e),
|
| 43 |
+
"error_type": type(e).__name__
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
@functools.wraps(func)
|
| 47 |
+
def sync_wrapper(*args, **kwargs):
|
| 48 |
+
try:
|
| 49 |
+
result = func(*args, **kwargs)
|
| 50 |
+
|
| 51 |
+
if result is None:
|
| 52 |
+
return json.dumps({"result": None})
|
| 53 |
+
|
| 54 |
+
if isinstance(result, (dict, list)):
|
| 55 |
+
return json.dumps(result, ensure_ascii=False)
|
| 56 |
+
|
| 57 |
+
if isinstance(result, str):
|
| 58 |
+
return result
|
| 59 |
+
|
| 60 |
+
return json.dumps({"result": str(result)})
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.exception(f"Error in {func.__name__}")
|
| 64 |
+
return json.dumps({
|
| 65 |
+
"error": str(e),
|
| 66 |
+
"error_type": type(e).__name__
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
# Return appropriate wrapper based on function type
|
| 70 |
+
import inspect
|
| 71 |
+
if inspect.iscoroutinefunction(func):
|
| 72 |
+
return async_wrapper
|
| 73 |
+
else:
|
| 74 |
+
return sync_wrapper
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def with_retry(func: F) -> F:
|
| 78 |
+
"""
|
| 79 |
+
Decorator for retrying failed HTTP requests with exponential backoff.
|
| 80 |
+
"""
|
| 81 |
+
return retry(
|
| 82 |
+
stop=stop_after_attempt(3),
|
| 83 |
+
wait=wait_exponential(multiplier=1, min=2, max=8),
|
| 84 |
+
reraise=True
|
| 85 |
+
)(func)
|
core/http_client.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared HTTP client factory."""
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
from typing import Optional, Dict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def create_http_client(
|
| 8 |
+
timeout: float = 30.0,
|
| 9 |
+
headers: Optional[Dict[str, str]] = None,
|
| 10 |
+
follow_redirects: bool = True
|
| 11 |
+
) -> httpx.AsyncClient:
|
| 12 |
+
"""
|
| 13 |
+
Create a configured async HTTP client.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
timeout: Request timeout in seconds
|
| 17 |
+
headers: Default headers to include
|
| 18 |
+
follow_redirects: Whether to follow redirects
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
Configured httpx.AsyncClient
|
| 22 |
+
"""
|
| 23 |
+
default_headers = {
|
| 24 |
+
"Accept": "application/json",
|
| 25 |
+
"Content-Type": "application/json",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if headers:
|
| 29 |
+
default_headers.update(headers)
|
| 30 |
+
|
| 31 |
+
return httpx.AsyncClient(
|
| 32 |
+
timeout=timeout,
|
| 33 |
+
headers=default_headers,
|
| 34 |
+
follow_redirects=follow_redirects
|
| 35 |
+
)
|
providers/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Healthcare API providers."""
|
| 2 |
+
|
| 3 |
+
from typing import Dict, Type
|
| 4 |
+
from ..core.base_provider import BaseProvider
|
| 5 |
+
|
| 6 |
+
# Provider registry
|
| 7 |
+
PROVIDER_REGISTRY: Dict[str, Type[BaseProvider]] = {}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def register_provider(name: str):
|
| 11 |
+
"""Decorator to register a provider."""
|
| 12 |
+
def decorator(cls: Type[BaseProvider]):
|
| 13 |
+
PROVIDER_REGISTRY[name] = cls
|
| 14 |
+
return cls
|
| 15 |
+
return decorator
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_provider_class(name: str) -> Type[BaseProvider]:
|
| 19 |
+
"""Get provider class by name."""
|
| 20 |
+
return PROVIDER_REGISTRY.get(name)
|
providers/clinical_guidelines_provider.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Clinical Guidelines Provider for USPSTF, CDC, and AHRQ guidelines."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List, Dict, Any, Callable
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from ..core.base_provider import BaseProvider
|
| 8 |
+
from ..core.decorators import safe_json_return
|
| 9 |
+
from . import register_provider
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# Evidence-based preventive care guidelines
|
| 14 |
+
PREVENTIVE_GUIDELINES = {
|
| 15 |
+
"diabetes_screening": [
|
| 16 |
+
{
|
| 17 |
+
"title": "Type 2 Diabetes Screening",
|
| 18 |
+
"recommendation": "Screen for prediabetes and type 2 diabetes in adults aged 35 to 70 years who have overweight or obesity",
|
| 19 |
+
"grade": "B",
|
| 20 |
+
"age_range": "35-70 years",
|
| 21 |
+
"frequency": "Every 3 years if normal",
|
| 22 |
+
"source": "USPSTF",
|
| 23 |
+
"conditions": ["overweight", "obesity"],
|
| 24 |
+
"citation": "USPSTF 2021"
|
| 25 |
+
}
|
| 26 |
+
],
|
| 27 |
+
"colorectal_screening": [
|
| 28 |
+
{
|
| 29 |
+
"title": "Colorectal Cancer Screening",
|
| 30 |
+
"recommendation": "Screen for colorectal cancer starting at age 45 years",
|
| 31 |
+
"grade": "A",
|
| 32 |
+
"age_range": "45-75 years",
|
| 33 |
+
"frequency": "Every 10 years (colonoscopy) or annually (FIT)",
|
| 34 |
+
"source": "USPSTF",
|
| 35 |
+
"methods": ["Colonoscopy every 10 years", "FIT annually", "CT colonography every 5 years"],
|
| 36 |
+
"citation": "USPSTF 2021"
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"hypertension_screening": [
|
| 40 |
+
{
|
| 41 |
+
"title": "Hypertension Screening",
|
| 42 |
+
"recommendation": "Screen for high blood pressure in adults 18 years or older",
|
| 43 |
+
"grade": "A",
|
| 44 |
+
"age_range": "18+ years",
|
| 45 |
+
"frequency": "Annually for adults 40+ or at increased risk; every 3-5 years for adults 18-39 with normal BP",
|
| 46 |
+
"source": "USPSTF",
|
| 47 |
+
"citation": "USPSTF 2021"
|
| 48 |
+
}
|
| 49 |
+
],
|
| 50 |
+
"cholesterol_screening": [
|
| 51 |
+
{
|
| 52 |
+
"title": "Statin Use for Primary Prevention of CVD",
|
| 53 |
+
"recommendation": "Screen for cardiovascular disease risk factors beginning at age 40",
|
| 54 |
+
"grade": "B",
|
| 55 |
+
"age_range": "40-75 years",
|
| 56 |
+
"frequency": "Every 4-6 years",
|
| 57 |
+
"source": "USPSTF",
|
| 58 |
+
"conditions": ["1+ CVD risk factors"],
|
| 59 |
+
"citation": "USPSTF 2016"
|
| 60 |
+
}
|
| 61 |
+
]
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# CDC vaccine schedule recommendations
|
| 65 |
+
VACCINE_SCHEDULE = {
|
| 66 |
+
"influenza": {
|
| 67 |
+
"vaccine": "Influenza (Flu)",
|
| 68 |
+
"recommendation": "Annual vaccination for all persons aged 6 months and older",
|
| 69 |
+
"age_groups": ["6 months+"],
|
| 70 |
+
"frequency": "Annually",
|
| 71 |
+
"source": "CDC",
|
| 72 |
+
"priority_groups": ["65+", "Pregnant", "Chronic conditions"]
|
| 73 |
+
},
|
| 74 |
+
"covid19": {
|
| 75 |
+
"vaccine": "COVID-19",
|
| 76 |
+
"recommendation": "Vaccination recommended for all persons 6 months and older",
|
| 77 |
+
"age_groups": ["6 months+"],
|
| 78 |
+
"frequency": "Updated annually",
|
| 79 |
+
"source": "CDC",
|
| 80 |
+
"priority_groups": ["65+", "Immunocompromised", "Healthcare workers"]
|
| 81 |
+
},
|
| 82 |
+
"pneumococcal": {
|
| 83 |
+
"vaccine": "Pneumococcal (PCV/PPSV)",
|
| 84 |
+
"recommendation": "Vaccination for adults 65 years or older and younger adults with certain conditions",
|
| 85 |
+
"age_groups": ["65+", "19-64 with risk factors"],
|
| 86 |
+
"frequency": "One-time or based on risk",
|
| 87 |
+
"source": "CDC",
|
| 88 |
+
"conditions": ["Chronic heart/lung disease", "Diabetes", "Immunocompromised"]
|
| 89 |
+
},
|
| 90 |
+
"shingles": {
|
| 91 |
+
"vaccine": "Shingles (Zoster)",
|
| 92 |
+
"recommendation": "2-dose series for adults 50 years and older",
|
| 93 |
+
"age_groups": ["50+"],
|
| 94 |
+
"frequency": "2 doses, 2-6 months apart",
|
| 95 |
+
"source": "CDC"
|
| 96 |
+
},
|
| 97 |
+
"tdap": {
|
| 98 |
+
"vaccine": "Tdap (Tetanus, Diphtheria, Pertussis)",
|
| 99 |
+
"recommendation": "One dose of Tdap, then Td booster every 10 years",
|
| 100 |
+
"age_groups": ["All adults"],
|
| 101 |
+
"frequency": "Every 10 years",
|
| 102 |
+
"source": "CDC"
|
| 103 |
+
},
|
| 104 |
+
"hepatitis_b": {
|
| 105 |
+
"vaccine": "Hepatitis B",
|
| 106 |
+
"recommendation": "Vaccination for adults at increased risk",
|
| 107 |
+
"age_groups": ["19-59 years (universal)", "60+ with risk factors"],
|
| 108 |
+
"frequency": "2 or 3-dose series",
|
| 109 |
+
"source": "CDC",
|
| 110 |
+
"risk_factors": ["Healthcare workers", "Diabetes", "Chronic liver disease"]
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@register_provider("clinical_guidelines")
|
| 116 |
+
class ClinicalGuidelinesProvider(BaseProvider):
|
| 117 |
+
"""Provider for clinical guidelines and preventive care recommendations."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, client: httpx.AsyncClient):
|
| 120 |
+
super().__init__("clinical_guidelines", client)
|
| 121 |
+
|
| 122 |
+
async def initialize(self) -> None:
|
| 123 |
+
"""Initialize clinical guidelines provider."""
|
| 124 |
+
logger.info("Clinical Guidelines provider initialized")
|
| 125 |
+
|
| 126 |
+
def get_tools(self) -> List[Callable]:
|
| 127 |
+
"""Return all clinical guideline tools."""
|
| 128 |
+
return [
|
| 129 |
+
self.clinical_get_preventive_recommendations,
|
| 130 |
+
self.clinical_get_vaccine_schedule,
|
| 131 |
+
self.clinical_search_guidelines,
|
| 132 |
+
self.clinical_get_lifestyle_recommendations,
|
| 133 |
+
self.clinical_generate_care_plan,
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
@safe_json_return
|
| 137 |
+
async def clinical_get_preventive_recommendations(
|
| 138 |
+
self,
|
| 139 |
+
age: int,
|
| 140 |
+
sex: str,
|
| 141 |
+
conditions: List[str] = None
|
| 142 |
+
) -> Dict[str, Any]:
|
| 143 |
+
"""
|
| 144 |
+
Get personalized preventive care recommendations based on USPSTF guidelines.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
age: Patient age in years
|
| 148 |
+
sex: Patient sex (male/female)
|
| 149 |
+
conditions: List of existing conditions (e.g., ["diabetes", "overweight"])
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Evidence-based screening and prevention recommendations with grades
|
| 153 |
+
"""
|
| 154 |
+
if conditions is None:
|
| 155 |
+
conditions = []
|
| 156 |
+
|
| 157 |
+
recommendations = []
|
| 158 |
+
condition_lower = [c.lower() for c in conditions]
|
| 159 |
+
|
| 160 |
+
# Diabetes screening
|
| 161 |
+
if 35 <= age <= 70 and any(c in condition_lower for c in ["overweight", "obesity", "prediabetes"]):
|
| 162 |
+
recommendations.append({
|
| 163 |
+
**PREVENTIVE_GUIDELINES["diabetes_screening"][0],
|
| 164 |
+
"applicable": True,
|
| 165 |
+
"rationale": "Age and BMI criteria met"
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
# Colorectal cancer screening
|
| 169 |
+
if 45 <= age <= 75:
|
| 170 |
+
recommendations.append({
|
| 171 |
+
**PREVENTIVE_GUIDELINES["colorectal_screening"][0],
|
| 172 |
+
"applicable": True,
|
| 173 |
+
"rationale": "Age-based screening recommendation"
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
# Hypertension screening
|
| 177 |
+
if age >= 18:
|
| 178 |
+
freq = "Annually" if age >= 40 else "Every 3-5 years"
|
| 179 |
+
rec = PREVENTIVE_GUIDELINES["hypertension_screening"][0].copy()
|
| 180 |
+
rec["frequency"] = freq
|
| 181 |
+
rec["applicable"] = True
|
| 182 |
+
rec["rationale"] = "Universal screening for adults"
|
| 183 |
+
recommendations.append(rec)
|
| 184 |
+
|
| 185 |
+
# Cholesterol/CVD screening
|
| 186 |
+
if 40 <= age <= 75:
|
| 187 |
+
recommendations.append({
|
| 188 |
+
**PREVENTIVE_GUIDELINES["cholesterol_screening"][0],
|
| 189 |
+
"applicable": True,
|
| 190 |
+
"rationale": "Age-based cardiovascular risk screening"
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
# Sex-specific screenings
|
| 194 |
+
if sex.lower() == "female":
|
| 195 |
+
if 40 <= age <= 74:
|
| 196 |
+
recommendations.append({
|
| 197 |
+
"title": "Breast Cancer Screening",
|
| 198 |
+
"recommendation": "Biennial screening mammography for women aged 40-74",
|
| 199 |
+
"grade": "B",
|
| 200 |
+
"age_range": "40-74 years",
|
| 201 |
+
"frequency": "Every 2 years",
|
| 202 |
+
"source": "USPSTF",
|
| 203 |
+
"applicable": True,
|
| 204 |
+
"citation": "USPSTF 2024"
|
| 205 |
+
})
|
| 206 |
+
if 21 <= age <= 65:
|
| 207 |
+
recommendations.append({
|
| 208 |
+
"title": "Cervical Cancer Screening",
|
| 209 |
+
"recommendation": "Screening every 3 years with cytology (21-29) or every 5 years with HPV testing (30-65)",
|
| 210 |
+
"grade": "A",
|
| 211 |
+
"age_range": "21-65 years",
|
| 212 |
+
"frequency": "Every 3-5 years based on method",
|
| 213 |
+
"source": "USPSTF",
|
| 214 |
+
"applicable": True,
|
| 215 |
+
"citation": "USPSTF 2018"
|
| 216 |
+
})
|
| 217 |
+
elif sex.lower() == "male":
|
| 218 |
+
if 55 <= age <= 80 and "smoking history" in condition_lower:
|
| 219 |
+
recommendations.append({
|
| 220 |
+
"title": "Lung Cancer Screening",
|
| 221 |
+
"recommendation": "Annual screening with low-dose CT for adults 50-80 with 20 pack-year smoking history",
|
| 222 |
+
"grade": "B",
|
| 223 |
+
"age_range": "50-80 years",
|
| 224 |
+
"frequency": "Annually",
|
| 225 |
+
"source": "USPSTF",
|
| 226 |
+
"applicable": True,
|
| 227 |
+
"conditions": ["20 pack-year smoking history"],
|
| 228 |
+
"citation": "USPSTF 2021"
|
| 229 |
+
})
|
| 230 |
+
|
| 231 |
+
return {
|
| 232 |
+
"patient_age": age,
|
| 233 |
+
"patient_sex": sex,
|
| 234 |
+
"conditions": conditions,
|
| 235 |
+
"recommendations": recommendations,
|
| 236 |
+
"total_recommendations": len(recommendations)
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
@safe_json_return
|
| 240 |
+
async def clinical_get_vaccine_schedule(
|
| 241 |
+
self,
|
| 242 |
+
age: int,
|
| 243 |
+
conditions: List[str] = None
|
| 244 |
+
) -> Dict[str, Any]:
|
| 245 |
+
"""
|
| 246 |
+
Get personalized vaccine recommendations based on CDC guidelines.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
age: Patient age in years
|
| 250 |
+
conditions: List of health conditions
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
Recommended vaccines with timing and priority
|
| 254 |
+
"""
|
| 255 |
+
if conditions is None:
|
| 256 |
+
conditions = []
|
| 257 |
+
|
| 258 |
+
recommended_vaccines = []
|
| 259 |
+
condition_lower = [c.lower() for c in conditions]
|
| 260 |
+
|
| 261 |
+
# Universal vaccines
|
| 262 |
+
recommended_vaccines.append({
|
| 263 |
+
**VACCINE_SCHEDULE["influenza"],
|
| 264 |
+
"due": "Annually, preferably before flu season (Sept-Oct)",
|
| 265 |
+
"priority": "High" if age >= 65 or len(conditions) > 0 else "Standard"
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
recommended_vaccines.append({
|
| 269 |
+
**VACCINE_SCHEDULE["covid19"],
|
| 270 |
+
"due": "Check for updated formulation annually",
|
| 271 |
+
"priority": "High" if age >= 65 else "Standard"
|
| 272 |
+
})
|
| 273 |
+
|
| 274 |
+
# Age-specific vaccines
|
| 275 |
+
if age >= 65:
|
| 276 |
+
recommended_vaccines.append({
|
| 277 |
+
**VACCINE_SCHEDULE["pneumococcal"],
|
| 278 |
+
"due": "If not previously vaccinated",
|
| 279 |
+
"priority": "High"
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
if age >= 50:
|
| 283 |
+
recommended_vaccines.append({
|
| 284 |
+
**VACCINE_SCHEDULE["shingles"],
|
| 285 |
+
"due": "2-dose series if not previously vaccinated",
|
| 286 |
+
"priority": "High" if age >= 60 else "Standard"
|
| 287 |
+
})
|
| 288 |
+
|
| 289 |
+
# Condition-specific
|
| 290 |
+
if any(c in condition_lower for c in ["diabetes", "heart disease", "lung disease", "immunocompromised"]):
|
| 291 |
+
if age >= 19 and age < 65:
|
| 292 |
+
recommended_vaccines.append({
|
| 293 |
+
**VACCINE_SCHEDULE["pneumococcal"],
|
| 294 |
+
"due": "Based on risk factors",
|
| 295 |
+
"priority": "High",
|
| 296 |
+
"rationale": "Chronic medical condition"
|
| 297 |
+
})
|
| 298 |
+
|
| 299 |
+
# Tdap (universal adult)
|
| 300 |
+
recommended_vaccines.append({
|
| 301 |
+
**VACCINE_SCHEDULE["tdap"],
|
| 302 |
+
"due": "If not received in last 10 years",
|
| 303 |
+
"priority": "Standard"
|
| 304 |
+
})
|
| 305 |
+
|
| 306 |
+
# Hepatitis B
|
| 307 |
+
if 19 <= age <= 59:
|
| 308 |
+
recommended_vaccines.append({
|
| 309 |
+
**VACCINE_SCHEDULE["hepatitis_b"],
|
| 310 |
+
"due": "If not previously vaccinated",
|
| 311 |
+
"priority": "Standard"
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
return {
|
| 315 |
+
"patient_age": age,
|
| 316 |
+
"conditions": conditions,
|
| 317 |
+
"recommended_vaccines": recommended_vaccines,
|
| 318 |
+
"total_vaccines": len(recommended_vaccines)
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
@safe_json_return
|
| 322 |
+
async def clinical_search_guidelines(
|
| 323 |
+
self,
|
| 324 |
+
query: str,
|
| 325 |
+
source: str = "All"
|
| 326 |
+
) -> Dict[str, Any]:
|
| 327 |
+
"""
|
| 328 |
+
Search clinical guidelines by condition or screening type.
|
| 329 |
+
|
| 330 |
+
Args:
|
| 331 |
+
query: Search term (condition, screening type, etc.)
|
| 332 |
+
source: Filter by source (USPSTF, CDC, AHRQ, All)
|
| 333 |
+
|
| 334 |
+
Returns:
|
| 335 |
+
Matching guidelines with recommendations
|
| 336 |
+
"""
|
| 337 |
+
query_lower = query.lower()
|
| 338 |
+
results = []
|
| 339 |
+
|
| 340 |
+
# Search preventive guidelines
|
| 341 |
+
for key, guidelines in PREVENTIVE_GUIDELINES.items():
|
| 342 |
+
if query_lower in key:
|
| 343 |
+
results.extend(guidelines)
|
| 344 |
+
else:
|
| 345 |
+
for g in guidelines:
|
| 346 |
+
if query_lower in g.get("title", "").lower():
|
| 347 |
+
results.append(g)
|
| 348 |
+
|
| 349 |
+
# Search vaccines
|
| 350 |
+
for key, vaccine in VACCINE_SCHEDULE.items():
|
| 351 |
+
if query_lower in key or query_lower in vaccine.get("vaccine", "").lower():
|
| 352 |
+
results.append({
|
| 353 |
+
"title": vaccine["vaccine"],
|
| 354 |
+
"recommendation": vaccine["recommendation"],
|
| 355 |
+
"source": vaccine["source"],
|
| 356 |
+
"type": "Immunization",
|
| 357 |
+
"age_groups": vaccine.get("age_groups", []),
|
| 358 |
+
"frequency": vaccine.get("frequency", "")
|
| 359 |
+
})
|
| 360 |
+
|
| 361 |
+
# Filter by source if specified
|
| 362 |
+
if source != "All":
|
| 363 |
+
results = [r for r in results if r.get("source", "").upper() == source.upper()]
|
| 364 |
+
|
| 365 |
+
return {
|
| 366 |
+
"query": query,
|
| 367 |
+
"source_filter": source,
|
| 368 |
+
"results": results,
|
| 369 |
+
"total": len(results)
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
@safe_json_return
|
| 373 |
+
async def clinical_get_lifestyle_recommendations(
|
| 374 |
+
self,
|
| 375 |
+
conditions: List[str],
|
| 376 |
+
age: int
|
| 377 |
+
) -> Dict[str, Any]:
|
| 378 |
+
"""
|
| 379 |
+
Get evidence-based lifestyle and wellness recommendations.
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
conditions: List of health conditions
|
| 383 |
+
age: Patient age
|
| 384 |
+
|
| 385 |
+
Returns:
|
| 386 |
+
Lifestyle modification recommendations
|
| 387 |
+
"""
|
| 388 |
+
if conditions is None:
|
| 389 |
+
conditions = []
|
| 390 |
+
|
| 391 |
+
recommendations = []
|
| 392 |
+
condition_lower = [c.lower() for c in conditions]
|
| 393 |
+
|
| 394 |
+
# Universal recommendations
|
| 395 |
+
recommendations.append({
|
| 396 |
+
"category": "Physical Activity",
|
| 397 |
+
"recommendation": "At least 150 minutes of moderate-intensity aerobic activity per week",
|
| 398 |
+
"source": "CDC Physical Activity Guidelines",
|
| 399 |
+
"evidence_grade": "A"
|
| 400 |
+
})
|
| 401 |
+
|
| 402 |
+
recommendations.append({
|
| 403 |
+
"category": "Nutrition",
|
| 404 |
+
"recommendation": "Mediterranean-style diet rich in fruits, vegetables, whole grains, and healthy fats",
|
| 405 |
+
"source": "AHA Dietary Guidelines",
|
| 406 |
+
"evidence_grade": "A"
|
| 407 |
+
})
|
| 408 |
+
|
| 409 |
+
recommendations.append({
|
| 410 |
+
"category": "Sleep",
|
| 411 |
+
"recommendation": "7-9 hours of sleep per night for adults",
|
| 412 |
+
"source": "CDC Sleep Guidelines",
|
| 413 |
+
"evidence_grade": "A"
|
| 414 |
+
})
|
| 415 |
+
|
| 416 |
+
# Condition-specific
|
| 417 |
+
if "diabetes" in condition_lower:
|
| 418 |
+
recommendations.append({
|
| 419 |
+
"category": "Diabetes Management",
|
| 420 |
+
"recommendation": "Monitor blood glucose regularly, aim for HbA1c <7% for most adults",
|
| 421 |
+
"source": "ADA Standards of Care",
|
| 422 |
+
"evidence_grade": "A",
|
| 423 |
+
"specifics": ["Carbohydrate counting", "Regular foot examinations", "Annual eye exams"]
|
| 424 |
+
})
|
| 425 |
+
|
| 426 |
+
if "hypertension" in condition_lower:
|
| 427 |
+
recommendations.append({
|
| 428 |
+
"category": "Blood Pressure Management",
|
| 429 |
+
"recommendation": "DASH diet, sodium restriction (<2300mg/day), regular BP monitoring",
|
| 430 |
+
"source": "ACC/AHA Hypertension Guidelines",
|
| 431 |
+
"evidence_grade": "A",
|
| 432 |
+
"target": "BP <130/80 mmHg"
|
| 433 |
+
})
|
| 434 |
+
|
| 435 |
+
if any("heart" in c or "cardiac" in c or "cardiovascular" in c for c in condition_lower):
|
| 436 |
+
recommendations.append({
|
| 437 |
+
"category": "Cardiovascular Health",
|
| 438 |
+
"recommendation": "Cardiac rehabilitation if eligible, cholesterol management, smoking cessation",
|
| 439 |
+
"source": "AHA/ACC Guidelines",
|
| 440 |
+
"evidence_grade": "A"
|
| 441 |
+
})
|
| 442 |
+
|
| 443 |
+
if "obesity" in condition_lower or "overweight" in condition_lower:
|
| 444 |
+
recommendations.append({
|
| 445 |
+
"category": "Weight Management",
|
| 446 |
+
"recommendation": "5-10% weight loss through caloric reduction and increased physical activity",
|
| 447 |
+
"source": "USPSTF Obesity Guidelines",
|
| 448 |
+
"evidence_grade": "B"
|
| 449 |
+
})
|
| 450 |
+
|
| 451 |
+
return {
|
| 452 |
+
"conditions": conditions,
|
| 453 |
+
"patient_age": age,
|
| 454 |
+
"recommendations": recommendations,
|
| 455 |
+
"total": len(recommendations)
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
@safe_json_return
|
| 459 |
+
async def clinical_generate_care_plan(
|
| 460 |
+
self,
|
| 461 |
+
age: int,
|
| 462 |
+
sex: str,
|
| 463 |
+
conditions: List[str] = None,
|
| 464 |
+
current_medications: List[str] = None
|
| 465 |
+
) -> Dict[str, Any]:
|
| 466 |
+
"""
|
| 467 |
+
Generate comprehensive 6-month care plan with screening, vaccines, and lifestyle.
|
| 468 |
+
|
| 469 |
+
Args:
|
| 470 |
+
age: Patient age
|
| 471 |
+
sex: Patient sex
|
| 472 |
+
conditions: Existing conditions
|
| 473 |
+
current_medications: Current medication list
|
| 474 |
+
|
| 475 |
+
Returns:
|
| 476 |
+
Structured 6-month care plan
|
| 477 |
+
"""
|
| 478 |
+
if conditions is None:
|
| 479 |
+
conditions = []
|
| 480 |
+
if current_medications is None:
|
| 481 |
+
current_medications = []
|
| 482 |
+
|
| 483 |
+
# Get all recommendation types
|
| 484 |
+
preventive = await self.clinical_get_preventive_recommendations(age, sex, conditions)
|
| 485 |
+
vaccines = await self.clinical_get_vaccine_schedule(age, conditions)
|
| 486 |
+
lifestyle = await self.clinical_get_lifestyle_recommendations(conditions, age)
|
| 487 |
+
|
| 488 |
+
# Build timeline
|
| 489 |
+
care_plan = {
|
| 490 |
+
"patient_profile": {
|
| 491 |
+
"age": age,
|
| 492 |
+
"sex": sex,
|
| 493 |
+
"conditions": conditions,
|
| 494 |
+
"current_medications": current_medications
|
| 495 |
+
},
|
| 496 |
+
"preventive_screenings": preventive.get("recommendations", []),
|
| 497 |
+
"immunizations": vaccines.get("recommended_vaccines", []),
|
| 498 |
+
"lifestyle_modifications": lifestyle.get("recommendations", []),
|
| 499 |
+
"timeline": {
|
| 500 |
+
"month_1": ["Schedule overdue screenings", "Update immunizations"],
|
| 501 |
+
"month_2": ["Follow up on screening results", "Begin lifestyle modifications"],
|
| 502 |
+
"month_3": ["Medication review with provider", "Assess lifestyle progress"],
|
| 503 |
+
"month_6": ["Comprehensive health review", "Update care plan"]
|
| 504 |
+
},
|
| 505 |
+
"follow_up_schedule": {
|
| 506 |
+
"primary_care": "Every 3-6 months" if conditions else "Annually",
|
| 507 |
+
"specialist": "As indicated by conditions" if conditions else "Not required",
|
| 508 |
+
"lab_work": "Per screening recommendations"
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
return care_plan
|
providers/cms_pricing_provider.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CMS Pricing Provider for Medicare fee schedules and cost transparency."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List, Dict, Any, Callable
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from ..core.base_provider import BaseProvider
|
| 8 |
+
from ..core.decorators import safe_json_return
|
| 9 |
+
from . import register_provider
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# Sample Medicare fee schedule data
|
| 14 |
+
MEDICARE_FEE_SCHEDULE = {
|
| 15 |
+
"99213": {
|
| 16 |
+
"description": "Office visit, established patient, level 3",
|
| 17 |
+
"medicare_payment": 93.47,
|
| 18 |
+
"facility_rate": 93.47,
|
| 19 |
+
"non_facility_rate": 131.20,
|
| 20 |
+
"rvu": 1.92
|
| 21 |
+
},
|
| 22 |
+
"99214": {
|
| 23 |
+
"description": "Office visit, established patient, level 4",
|
| 24 |
+
"medicare_payment": 131.20,
|
| 25 |
+
"facility_rate": 131.20,
|
| 26 |
+
"non_facility_rate": 183.19,
|
| 27 |
+
"rvu": 2.80
|
| 28 |
+
},
|
| 29 |
+
"43239": {
|
| 30 |
+
"description": "Upper GI endoscopy with biopsy",
|
| 31 |
+
"medicare_payment": 248.54,
|
| 32 |
+
"facility_rate": 248.54,
|
| 33 |
+
"non_facility_rate": 450.00,
|
| 34 |
+
"rvu": 5.28
|
| 35 |
+
},
|
| 36 |
+
"45378": {
|
| 37 |
+
"description": "Colonoscopy, diagnostic",
|
| 38 |
+
"medicare_payment": 365.00,
|
| 39 |
+
"facility_rate": 365.00,
|
| 40 |
+
"non_facility_rate": 650.00,
|
| 41 |
+
"rvu": 7.75
|
| 42 |
+
},
|
| 43 |
+
"70450": {
|
| 44 |
+
"description": "CT scan, head without contrast",
|
| 45 |
+
"medicare_payment": 178.00,
|
| 46 |
+
"facility_rate": 178.00,
|
| 47 |
+
"non_facility_rate": 320.00,
|
| 48 |
+
"rvu": 3.78
|
| 49 |
+
},
|
| 50 |
+
"70553": {
|
| 51 |
+
"description": "MRI brain with contrast",
|
| 52 |
+
"medicare_payment": 485.00,
|
| 53 |
+
"facility_rate": 485.00,
|
| 54 |
+
"non_facility_rate": 875.00,
|
| 55 |
+
"rvu": 10.30
|
| 56 |
+
},
|
| 57 |
+
"80053": {
|
| 58 |
+
"description": "Comprehensive metabolic panel",
|
| 59 |
+
"medicare_payment": 14.00,
|
| 60 |
+
"facility_rate": 14.00,
|
| 61 |
+
"non_facility_rate": 18.00,
|
| 62 |
+
"rvu": 0.30
|
| 63 |
+
},
|
| 64 |
+
"85025": {
|
| 65 |
+
"description": "Complete blood count with differential",
|
| 66 |
+
"medicare_payment": 11.00,
|
| 67 |
+
"facility_rate": 11.00,
|
| 68 |
+
"non_facility_rate": 14.00,
|
| 69 |
+
"rvu": 0.23
|
| 70 |
+
},
|
| 71 |
+
"71046": {
|
| 72 |
+
"description": "Chest X-ray, 2 views",
|
| 73 |
+
"medicare_payment": 38.00,
|
| 74 |
+
"facility_rate": 38.00,
|
| 75 |
+
"non_facility_rate": 62.00,
|
| 76 |
+
"rvu": 0.81
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# Sample procedure cost estimates
|
| 81 |
+
PROCEDURE_COST_ESTIMATES = {
|
| 82 |
+
"colonoscopy": {
|
| 83 |
+
"procedure": "Diagnostic Colonoscopy",
|
| 84 |
+
"hcpcs_code": "45378",
|
| 85 |
+
"average_cost": 2750,
|
| 86 |
+
"low_estimate": 1500,
|
| 87 |
+
"high_estimate": 4500,
|
| 88 |
+
"medicare_rate": 365,
|
| 89 |
+
"factors": ["Facility type", "Anesthesia", "Biopsy performed", "Location"]
|
| 90 |
+
},
|
| 91 |
+
"mri": {
|
| 92 |
+
"procedure": "MRI Scan",
|
| 93 |
+
"hcpcs_code": "70553",
|
| 94 |
+
"average_cost": 1420,
|
| 95 |
+
"low_estimate": 400,
|
| 96 |
+
"high_estimate": 3500,
|
| 97 |
+
"medicare_rate": 485,
|
| 98 |
+
"factors": ["Body part", "With/without contrast", "Facility type"]
|
| 99 |
+
},
|
| 100 |
+
"ct scan": {
|
| 101 |
+
"procedure": "CT Scan",
|
| 102 |
+
"hcpcs_code": "70450",
|
| 103 |
+
"average_cost": 820,
|
| 104 |
+
"low_estimate": 300,
|
| 105 |
+
"high_estimate": 1800,
|
| 106 |
+
"medicare_rate": 178,
|
| 107 |
+
"factors": ["Body part", "With/without contrast", "Emergency vs scheduled"]
|
| 108 |
+
},
|
| 109 |
+
"blood test": {
|
| 110 |
+
"procedure": "Comprehensive Metabolic Panel",
|
| 111 |
+
"hcpcs_code": "80053",
|
| 112 |
+
"average_cost": 45,
|
| 113 |
+
"low_estimate": 15,
|
| 114 |
+
"high_estimate": 120,
|
| 115 |
+
"medicare_rate": 14,
|
| 116 |
+
"factors": ["Lab facility", "Stat vs routine", "Insurance negotiated rate"]
|
| 117 |
+
},
|
| 118 |
+
"x-ray": {
|
| 119 |
+
"procedure": "Chest X-Ray",
|
| 120 |
+
"hcpcs_code": "71046",
|
| 121 |
+
"average_cost": 125,
|
| 122 |
+
"low_estimate": 50,
|
| 123 |
+
"high_estimate": 300,
|
| 124 |
+
"medicare_rate": 38,
|
| 125 |
+
"factors": ["Number of views", "Facility type", "Interpretation included"]
|
| 126 |
+
},
|
| 127 |
+
"office visit": {
|
| 128 |
+
"procedure": "Office Visit - Established Patient",
|
| 129 |
+
"hcpcs_code": "99213",
|
| 130 |
+
"average_cost": 150,
|
| 131 |
+
"low_estimate": 100,
|
| 132 |
+
"high_estimate": 300,
|
| 133 |
+
"medicare_rate": 93,
|
| 134 |
+
"factors": ["Complexity level", "Time spent", "Geographic location"]
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Sample facility comparison data
|
| 139 |
+
FACILITY_COSTS = {
|
| 140 |
+
"45378": [ # Colonoscopy
|
| 141 |
+
{"facility": "University Hospital", "gross_charge": 4500, "negotiated_rate": 2800, "cash_price": 2500},
|
| 142 |
+
{"facility": "Community Medical Center", "gross_charge": 3200, "negotiated_rate": 2100, "cash_price": 1800},
|
| 143 |
+
{"facility": "Ambulatory Surgery Center", "gross_charge": 2800, "negotiated_rate": 1600, "cash_price": 1500},
|
| 144 |
+
],
|
| 145 |
+
"70450": [ # CT Scan
|
| 146 |
+
{"facility": "University Hospital", "gross_charge": 1800, "negotiated_rate": 820, "cash_price": 600},
|
| 147 |
+
{"facility": "Community Medical Center", "gross_charge": 1200, "negotiated_rate": 650, "cash_price": 500},
|
| 148 |
+
{"facility": "Imaging Center", "gross_charge": 900, "negotiated_rate": 450, "cash_price": 400},
|
| 149 |
+
],
|
| 150 |
+
"70553": [ # MRI
|
| 151 |
+
{"facility": "University Hospital", "gross_charge": 3500, "negotiated_rate": 1800, "cash_price": 1500},
|
| 152 |
+
{"facility": "Community Medical Center", "gross_charge": 2800, "negotiated_rate": 1400, "cash_price": 1200},
|
| 153 |
+
{"facility": "Imaging Center", "gross_charge": 2200, "negotiated_rate": 1000, "cash_price": 900},
|
| 154 |
+
]
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
# Procedure code database
|
| 158 |
+
PROCEDURE_CODES = [
|
| 159 |
+
{"code": "99213", "description": "Office visit, established patient, level 3", "category": "Evaluation & Management"},
|
| 160 |
+
{"code": "99214", "description": "Office visit, established patient, level 4", "category": "Evaluation & Management"},
|
| 161 |
+
{"code": "99215", "description": "Office visit, established patient, level 5", "category": "Evaluation & Management"},
|
| 162 |
+
{"code": "99203", "description": "Office visit, new patient, level 3", "category": "Evaluation & Management"},
|
| 163 |
+
{"code": "45378", "description": "Colonoscopy, diagnostic", "category": "Gastroenterology"},
|
| 164 |
+
{"code": "43239", "description": "Upper GI endoscopy with biopsy", "category": "Gastroenterology"},
|
| 165 |
+
{"code": "70450", "description": "CT scan, head without contrast", "category": "Radiology"},
|
| 166 |
+
{"code": "70553", "description": "MRI brain with contrast", "category": "Radiology"},
|
| 167 |
+
{"code": "71046", "description": "Chest X-ray, 2 views", "category": "Radiology"},
|
| 168 |
+
{"code": "80053", "description": "Comprehensive metabolic panel", "category": "Laboratory"},
|
| 169 |
+
{"code": "85025", "description": "Complete blood count with differential", "category": "Laboratory"},
|
| 170 |
+
{"code": "82947", "description": "Glucose blood test", "category": "Laboratory"},
|
| 171 |
+
{"code": "83036", "description": "Hemoglobin A1C", "category": "Laboratory"},
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@register_provider("cms_pricing")
|
| 176 |
+
class CMSPricingProvider(BaseProvider):
|
| 177 |
+
"""Provider for Medicare pricing and cost transparency data."""
|
| 178 |
+
|
| 179 |
+
def __init__(self, client: httpx.AsyncClient):
|
| 180 |
+
super().__init__("cms_pricing", client)
|
| 181 |
+
|
| 182 |
+
async def initialize(self) -> None:
|
| 183 |
+
"""Initialize CMS pricing provider."""
|
| 184 |
+
logger.info("CMS Pricing provider initialized")
|
| 185 |
+
|
| 186 |
+
def get_tools(self) -> List[Callable]:
|
| 187 |
+
"""Return all CMS pricing tools."""
|
| 188 |
+
return [
|
| 189 |
+
self.cms_get_medicare_fee,
|
| 190 |
+
self.cms_estimate_procedure_cost,
|
| 191 |
+
self.cms_search_procedure_codes,
|
| 192 |
+
self.cms_compare_facility_costs,
|
| 193 |
+
self.cms_estimate_out_of_pocket,
|
| 194 |
+
]
|
| 195 |
+
|
| 196 |
+
@safe_json_return
|
| 197 |
+
async def cms_get_medicare_fee(
|
| 198 |
+
self,
|
| 199 |
+
hcpcs_code: str,
|
| 200 |
+
locality: str = "National"
|
| 201 |
+
) -> Dict[str, Any]:
|
| 202 |
+
"""
|
| 203 |
+
Get Medicare fee schedule pricing for a procedure by HCPCS/CPT code.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
hcpcs_code: HCPCS procedure code (e.g., "99213", "43239")
|
| 207 |
+
locality: Geographic locality (default: "National")
|
| 208 |
+
|
| 209 |
+
Returns:
|
| 210 |
+
Medicare reimbursement rates with facility vs non-facility pricing
|
| 211 |
+
"""
|
| 212 |
+
result = MEDICARE_FEE_SCHEDULE.get(hcpcs_code, {
|
| 213 |
+
"description": f"Procedure {hcpcs_code}",
|
| 214 |
+
"medicare_payment": 150.00,
|
| 215 |
+
"facility_rate": 150.00,
|
| 216 |
+
"non_facility_rate": 200.00,
|
| 217 |
+
"rvu": 3.0,
|
| 218 |
+
"note": "Sample data - actual rates vary by locality and year"
|
| 219 |
+
})
|
| 220 |
+
|
| 221 |
+
result["hcpcs_code"] = hcpcs_code
|
| 222 |
+
result["locality"] = locality
|
| 223 |
+
result["year"] = 2024
|
| 224 |
+
|
| 225 |
+
return result
|
| 226 |
+
|
| 227 |
+
@safe_json_return
|
| 228 |
+
async def cms_estimate_procedure_cost(
|
| 229 |
+
self,
|
| 230 |
+
procedure_name: str,
|
| 231 |
+
region: str = "National",
|
| 232 |
+
insurance_type: str = "Medicare"
|
| 233 |
+
) -> Dict[str, Any]:
|
| 234 |
+
"""
|
| 235 |
+
Estimate typical cost range for a procedure in a geographic region.
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
procedure_name: Common procedure name (e.g., "colonoscopy", "MRI", "blood test")
|
| 239 |
+
region: Geographic region (default: "National")
|
| 240 |
+
insurance_type: Type of insurance (Medicare, Commercial, Cash)
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
Estimated cost range with average, low, and high estimates
|
| 244 |
+
"""
|
| 245 |
+
procedure_key = procedure_name.lower()
|
| 246 |
+
result = PROCEDURE_COST_ESTIMATES.get(procedure_key, {
|
| 247 |
+
"procedure": procedure_name.title(),
|
| 248 |
+
"hcpcs_code": "Unknown",
|
| 249 |
+
"average_cost": 500,
|
| 250 |
+
"low_estimate": 200,
|
| 251 |
+
"high_estimate": 1000,
|
| 252 |
+
"medicare_rate": 150,
|
| 253 |
+
"factors": ["Procedure complexity", "Facility type", "Geographic location"]
|
| 254 |
+
})
|
| 255 |
+
|
| 256 |
+
result["region"] = region
|
| 257 |
+
result["insurance_type"] = insurance_type
|
| 258 |
+
result["note"] = "Estimates based on national averages. Actual costs vary significantly by provider and location."
|
| 259 |
+
|
| 260 |
+
# Adjust for insurance type
|
| 261 |
+
if insurance_type.lower() == "commercial":
|
| 262 |
+
result["estimated_patient_cost"] = result["average_cost"]
|
| 263 |
+
elif insurance_type.lower() == "medicare":
|
| 264 |
+
result["estimated_patient_cost"] = result["medicare_rate"] * 1.2 # 20% coinsurance
|
| 265 |
+
elif insurance_type.lower() == "cash":
|
| 266 |
+
result["estimated_patient_cost"] = result["average_cost"] * 0.7 # Cash discount
|
| 267 |
+
|
| 268 |
+
return result
|
| 269 |
+
|
| 270 |
+
@safe_json_return
|
| 271 |
+
async def cms_search_procedure_codes(
|
| 272 |
+
self,
|
| 273 |
+
query: str,
|
| 274 |
+
limit: int = 10
|
| 275 |
+
) -> Dict[str, Any]:
|
| 276 |
+
"""
|
| 277 |
+
Search for HCPCS/CPT codes by procedure description or category.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
query: Search term (e.g., "office visit", "colonoscopy")
|
| 281 |
+
limit: Maximum number of results (default: 10)
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
List of matching procedures with codes, descriptions, and categories
|
| 285 |
+
"""
|
| 286 |
+
query_lower = query.lower()
|
| 287 |
+
matches = [
|
| 288 |
+
p for p in PROCEDURE_CODES
|
| 289 |
+
if query_lower in p["description"].lower() or query_lower in p["category"].lower()
|
| 290 |
+
]
|
| 291 |
+
|
| 292 |
+
return {
|
| 293 |
+
"query": query,
|
| 294 |
+
"results": matches[:limit],
|
| 295 |
+
"total": len(matches)
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
@safe_json_return
|
| 299 |
+
async def cms_compare_facility_costs(
|
| 300 |
+
self,
|
| 301 |
+
procedure_code: str,
|
| 302 |
+
facilities: List[str] = None
|
| 303 |
+
) -> Dict[str, Any]:
|
| 304 |
+
"""
|
| 305 |
+
Compare procedure costs across different healthcare facilities.
|
| 306 |
+
|
| 307 |
+
Args:
|
| 308 |
+
procedure_code: HCPCS/CPT code
|
| 309 |
+
facilities: List of facility names (uses sample data if not provided)
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
Cost comparison showing gross charges, negotiated rates, and cash prices
|
| 313 |
+
"""
|
| 314 |
+
facilities_data = FACILITY_COSTS.get(procedure_code, [
|
| 315 |
+
{"facility": "Sample Facility A", "gross_charge": 2000, "negotiated_rate": 1200, "cash_price": 1000},
|
| 316 |
+
{"facility": "Sample Facility B", "gross_charge": 1500, "negotiated_rate": 900, "cash_price": 800},
|
| 317 |
+
{"facility": "Sample Facility C", "gross_charge": 1800, "negotiated_rate": 1000, "cash_price": 900},
|
| 318 |
+
])
|
| 319 |
+
|
| 320 |
+
# Calculate savings
|
| 321 |
+
cash_prices = [f["cash_price"] for f in facilities_data]
|
| 322 |
+
lowest_cash = min(cash_prices)
|
| 323 |
+
highest_cash = max(cash_prices)
|
| 324 |
+
potential_savings = highest_cash - lowest_cash
|
| 325 |
+
|
| 326 |
+
return {
|
| 327 |
+
"procedure_code": procedure_code,
|
| 328 |
+
"facilities": facilities_data,
|
| 329 |
+
"comparison": {
|
| 330 |
+
"lowest_cash_price": lowest_cash,
|
| 331 |
+
"highest_cash_price": highest_cash,
|
| 332 |
+
"potential_savings": potential_savings,
|
| 333 |
+
"average_cash_price": sum(cash_prices) / len(cash_prices)
|
| 334 |
+
},
|
| 335 |
+
"note": "Sample data for demonstration. Actual facility prices vary. Patients should request price estimates directly."
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
@safe_json_return
|
| 339 |
+
async def cms_estimate_out_of_pocket(
|
| 340 |
+
self,
|
| 341 |
+
procedure_code: str,
|
| 342 |
+
insurance_scenario: str = "Medicare",
|
| 343 |
+
deductible_met: bool = False
|
| 344 |
+
) -> Dict[str, Any]:
|
| 345 |
+
"""
|
| 346 |
+
Estimate patient out-of-pocket costs for common insurance scenarios.
|
| 347 |
+
|
| 348 |
+
Args:
|
| 349 |
+
procedure_code: HCPCS/CPT code
|
| 350 |
+
insurance_scenario: Insurance type (Medicare, Commercial High Deductible, Commercial PPO, Cash)
|
| 351 |
+
deductible_met: Whether annual deductible has been met
|
| 352 |
+
|
| 353 |
+
Returns:
|
| 354 |
+
Estimated patient financial responsibility
|
| 355 |
+
"""
|
| 356 |
+
# Get base Medicare cost
|
| 357 |
+
medicare_data = await self.cms_get_medicare_fee(procedure_code)
|
| 358 |
+
base_cost = medicare_data.get("medicare_payment", 200)
|
| 359 |
+
|
| 360 |
+
# Define insurance scenarios
|
| 361 |
+
scenarios = {
|
| 362 |
+
"Medicare": {
|
| 363 |
+
"coinsurance": 0.20,
|
| 364 |
+
"copay": 0,
|
| 365 |
+
"description": "Medicare Part B (20% coinsurance after deductible)",
|
| 366 |
+
"deductible": 240 # 2024 Part B deductible
|
| 367 |
+
},
|
| 368 |
+
"Commercial PPO": {
|
| 369 |
+
"coinsurance": 0.20,
|
| 370 |
+
"copay": 0,
|
| 371 |
+
"description": "Commercial PPO (20% coinsurance after deductible)",
|
| 372 |
+
"deductible": 2000,
|
| 373 |
+
"multiplier": 2.5 # Commercial pays ~2.5x Medicare
|
| 374 |
+
},
|
| 375 |
+
"Commercial High Deductible": {
|
| 376 |
+
"coinsurance": 0.30,
|
| 377 |
+
"copay": 0,
|
| 378 |
+
"description": "High Deductible Health Plan (30% coinsurance)",
|
| 379 |
+
"deductible": 5000,
|
| 380 |
+
"multiplier": 2.5
|
| 381 |
+
},
|
| 382 |
+
"Cash": {
|
| 383 |
+
"coinsurance": 0,
|
| 384 |
+
"copay": 0,
|
| 385 |
+
"description": "Cash/Self-pay (negotiated rate)",
|
| 386 |
+
"deductible": 0,
|
| 387 |
+
"multiplier": 1.8 # Cash discount from commercial
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
scenario = scenarios.get(insurance_scenario, scenarios["Medicare"])
|
| 392 |
+
|
| 393 |
+
# Calculate costs
|
| 394 |
+
allowed_amount = base_cost * scenario.get("multiplier", 1.0)
|
| 395 |
+
|
| 396 |
+
if deductible_met:
|
| 397 |
+
patient_responsibility = allowed_amount * scenario["coinsurance"] + scenario["copay"]
|
| 398 |
+
else:
|
| 399 |
+
# Patient pays deductible first, then coinsurance on remainder
|
| 400 |
+
if allowed_amount <= scenario["deductible"]:
|
| 401 |
+
patient_responsibility = allowed_amount
|
| 402 |
+
else:
|
| 403 |
+
remaining = allowed_amount - scenario["deductible"]
|
| 404 |
+
patient_responsibility = scenario["deductible"] + (remaining * scenario["coinsurance"])
|
| 405 |
+
|
| 406 |
+
return {
|
| 407 |
+
"procedure_code": procedure_code,
|
| 408 |
+
"procedure_description": medicare_data.get("description", "Unknown procedure"),
|
| 409 |
+
"insurance_scenario": insurance_scenario,
|
| 410 |
+
"deductible_met": deductible_met,
|
| 411 |
+
"cost_breakdown": {
|
| 412 |
+
"allowed_amount": round(allowed_amount, 2),
|
| 413 |
+
"deductible": scenario["deductible"],
|
| 414 |
+
"coinsurance_rate": f"{int(scenario['coinsurance'] * 100)}%",
|
| 415 |
+
"estimated_patient_responsibility": round(patient_responsibility, 2),
|
| 416 |
+
"insurance_pays": round(allowed_amount - patient_responsibility, 2)
|
| 417 |
+
},
|
| 418 |
+
"scenario_details": scenario["description"],
|
| 419 |
+
"note": "Estimates only. Actual costs depend on specific plan benefits and negotiated rates."
|
| 420 |
+
}
|
providers/openfda_provider.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenFDA API Provider for adverse events, drug labels, recalls, and more."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import logging
|
| 5 |
+
from typing import List, Dict, Any, Callable, Optional
|
| 6 |
+
import httpx
|
| 7 |
+
from functools import lru_cache
|
| 8 |
+
|
| 9 |
+
from ..core.base_provider import BaseProvider
|
| 10 |
+
from ..core.decorators import safe_json_return, with_retry
|
| 11 |
+
from . import register_provider
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# OpenFDA API endpoints
|
| 16 |
+
OPENFDA_DRUG_EVENT = "https://api.fda.gov/drug/event.json"
|
| 17 |
+
OPENFDA_DRUG_LABEL = "https://api.fda.gov/drug/label.json"
|
| 18 |
+
OPENFDA_DRUG_NDC = "https://api.fda.gov/drug/ndc.json"
|
| 19 |
+
OPENFDA_DRUG_ENFORCEMENT = "https://api.fda.gov/drug/enforcement.json"
|
| 20 |
+
OPENFDA_DRUG_DRUGSFDA = "https://api.fda.gov/drug/drugsfda.json"
|
| 21 |
+
OPENFDA_DEVICE_EVENT = "https://api.fda.gov/device/event.json"
|
| 22 |
+
OPENFDA_DEVICE_ENFORCEMENT = "https://api.fda.gov/device/enforcement.json"
|
| 23 |
+
OPENFDA_DEVICE_CLASSIFICATION = "https://api.fda.gov/device/classification.json"
|
| 24 |
+
OPENFDA_DEVICE_510K = "https://api.fda.gov/device/510k.json"
|
| 25 |
+
OPENFDA_DEVICE_PMA = "https://api.fda.gov/device/pma.json"
|
| 26 |
+
OPENFDA_FOOD_ENFORCEMENT = "https://api.fda.gov/food/enforcement.json"
|
| 27 |
+
OPENFDA_FOOD_EVENT = "https://api.fda.gov/food/event.json"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def normalize_drug_name(raw_name: str) -> str:
|
| 31 |
+
"""Extract base drug name from full medication name."""
|
| 32 |
+
if not raw_name:
|
| 33 |
+
return ""
|
| 34 |
+
|
| 35 |
+
cleaned = raw_name
|
| 36 |
+
patterns = [
|
| 37 |
+
r'\s+\d+(?:\.\d+)?\s*(MG|MCG|ML|G|%)',
|
| 38 |
+
r'\s+Oral\s+',
|
| 39 |
+
r'\s+Tablet\s*',
|
| 40 |
+
r'\s+Capsule\s*',
|
| 41 |
+
r'\s+Injectable\s*',
|
| 42 |
+
r'\s+Solution\s*',
|
| 43 |
+
r'\s+Suspension\s*'
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
for pattern in patterns:
|
| 47 |
+
cleaned = re.split(pattern, cleaned, flags=re.IGNORECASE)[0]
|
| 48 |
+
|
| 49 |
+
base_name = cleaned.strip().split()[0] if cleaned.strip() else cleaned.strip()
|
| 50 |
+
return base_name
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@register_provider("openfda")
|
| 54 |
+
class OpenFDAProvider(BaseProvider):
|
| 55 |
+
"""Provider for OpenFDA APIs."""
|
| 56 |
+
|
| 57 |
+
def __init__(self, client: httpx.AsyncClient, api_key: Optional[str] = None):
|
| 58 |
+
super().__init__("openfda", client)
|
| 59 |
+
self.api_key = api_key
|
| 60 |
+
|
| 61 |
+
async def initialize(self) -> None:
|
| 62 |
+
"""Initialize OpenFDA provider."""
|
| 63 |
+
logger.info("OpenFDA provider initialized")
|
| 64 |
+
|
| 65 |
+
def get_tools(self) -> List[Callable]:
|
| 66 |
+
"""Return all OpenFDA tools."""
|
| 67 |
+
return [
|
| 68 |
+
self.openfda_get_adverse_event_summary,
|
| 69 |
+
self.openfda_fetch_adverse_events,
|
| 70 |
+
self.openfda_top_reactions,
|
| 71 |
+
self.openfda_search_drug_labels,
|
| 72 |
+
self.openfda_search_ndc,
|
| 73 |
+
self.openfda_search_drug_recalls,
|
| 74 |
+
self.openfda_search_drugs_fda,
|
| 75 |
+
self.openfda_search_device_events,
|
| 76 |
+
self.openfda_search_device_recalls,
|
| 77 |
+
self.openfda_search_device_classifications,
|
| 78 |
+
self.openfda_search_510k,
|
| 79 |
+
self.openfda_search_pma,
|
| 80 |
+
self.openfda_search_food_recalls,
|
| 81 |
+
self.openfda_search_food_events,
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
def _build_params(self, search: str, limit: int = 10) -> Dict[str, Any]:
|
| 85 |
+
"""Build query parameters with optional API key."""
|
| 86 |
+
params = {"search": search, "limit": min(limit, 100)}
|
| 87 |
+
if self.api_key:
|
| 88 |
+
params["api_key"] = self.api_key
|
| 89 |
+
return params
|
| 90 |
+
|
| 91 |
+
@with_retry
|
| 92 |
+
async def _fetch_fda_data(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
| 93 |
+
"""Fetch data from OpenFDA API with retry logic."""
|
| 94 |
+
response = await self.client.get(url, params=params)
|
| 95 |
+
response.raise_for_status()
|
| 96 |
+
return response.json()
|
| 97 |
+
|
| 98 |
+
# Drug Adverse Events
|
| 99 |
+
@safe_json_return
|
| 100 |
+
async def openfda_get_adverse_event_summary(self, drug_name: str) -> Dict[str, Any]:
|
| 101 |
+
"""
|
| 102 |
+
Get high-level adverse event summary for a medication from FAERS database.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
drug_name: Name of the medication (generic or brand name)
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Summary with total reports, serious reports, and top reactions
|
| 109 |
+
"""
|
| 110 |
+
clean_name = normalize_drug_name(drug_name)
|
| 111 |
+
logger.info(f"FDA adverse event query: '{drug_name}' -> '{clean_name}'")
|
| 112 |
+
|
| 113 |
+
# Query for count and reactions
|
| 114 |
+
search_query = f'patient.drug.medicinalproduct:"{clean_name}"'
|
| 115 |
+
params = self._build_params(search_query, limit=1)
|
| 116 |
+
params["count"] = "patient.reaction.reactionmeddrapt.exact"
|
| 117 |
+
|
| 118 |
+
data = await self._fetch_fda_data(OPENFDA_DRUG_EVENT, params)
|
| 119 |
+
|
| 120 |
+
total_reports = data.get("meta", {}).get("results", {}).get("total", 0)
|
| 121 |
+
reactions = data.get("results", [])[:5]
|
| 122 |
+
|
| 123 |
+
top_reactions = [
|
| 124 |
+
{"reaction": r.get("term", "Unknown"), "count": r.get("count", 0)}
|
| 125 |
+
for r in reactions
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
# Get serious event count
|
| 129 |
+
serious_query = f'{search_query}+AND+serious:1'
|
| 130 |
+
serious_params = self._build_params(serious_query, limit=1)
|
| 131 |
+
try:
|
| 132 |
+
serious_data = await self._fetch_fda_data(OPENFDA_DRUG_EVENT, serious_params)
|
| 133 |
+
serious_reports = serious_data.get("meta", {}).get("results", {}).get("total", 0)
|
| 134 |
+
except Exception:
|
| 135 |
+
serious_reports = 0
|
| 136 |
+
|
| 137 |
+
return {
|
| 138 |
+
"drug": clean_name,
|
| 139 |
+
"total_reports": total_reports,
|
| 140 |
+
"serious_reports": serious_reports,
|
| 141 |
+
"top_reactions": top_reactions
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
@safe_json_return
|
| 145 |
+
async def openfda_fetch_adverse_events(
|
| 146 |
+
self,
|
| 147 |
+
drug_name: str,
|
| 148 |
+
limit: int = 25,
|
| 149 |
+
max_pages: int = 1
|
| 150 |
+
) -> Dict[str, Any]:
|
| 151 |
+
"""
|
| 152 |
+
Fetch raw adverse event reports from OpenFDA with pagination.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
drug_name: Name of the medication
|
| 156 |
+
limit: Number of events per page (max 100)
|
| 157 |
+
max_pages: Number of pages to fetch
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
List of adverse event reports with metadata
|
| 161 |
+
"""
|
| 162 |
+
clean_name = normalize_drug_name(drug_name)
|
| 163 |
+
search_query = f'patient.drug.medicinalproduct:"{clean_name}"'
|
| 164 |
+
|
| 165 |
+
all_events = []
|
| 166 |
+
for page in range(max_pages):
|
| 167 |
+
params = self._build_params(search_query, limit=min(limit, 100))
|
| 168 |
+
params["skip"] = page * limit
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
data = await self._fetch_fda_data(OPENFDA_DRUG_EVENT, params)
|
| 172 |
+
results = data.get("results", [])
|
| 173 |
+
|
| 174 |
+
for result in results:
|
| 175 |
+
reactions = result.get("patient", {}).get("reaction", [])
|
| 176 |
+
reaction_list = [r.get("reactionmeddrapt", "Unknown") for r in reactions]
|
| 177 |
+
|
| 178 |
+
all_events.append({
|
| 179 |
+
"safety_report_id": result.get("safetyreportid", "Unknown"),
|
| 180 |
+
"receive_date": result.get("receivedate", "Unknown"),
|
| 181 |
+
"serious": result.get("serious", 0) == 1,
|
| 182 |
+
"reactions": reaction_list[:5],
|
| 183 |
+
"patient_age": result.get("patient", {}).get("patientonsetage", "Unknown"),
|
| 184 |
+
"patient_sex": result.get("patient", {}).get("patientsex", "Unknown")
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
if len(results) < limit:
|
| 188 |
+
break
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.warning(f"Error fetching page {page}: {e}")
|
| 191 |
+
break
|
| 192 |
+
|
| 193 |
+
return {
|
| 194 |
+
"drug": clean_name,
|
| 195 |
+
"events": all_events,
|
| 196 |
+
"total_fetched": len(all_events)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
@safe_json_return
|
| 200 |
+
async def openfda_top_reactions(self, drug_name: str) -> Dict[str, Any]:
|
| 201 |
+
"""
|
| 202 |
+
Get top 5 most commonly reported adverse reactions for a medication.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
drug_name: Name of the medication
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
Top 5 reactions with counts
|
| 209 |
+
"""
|
| 210 |
+
clean_name = normalize_drug_name(drug_name)
|
| 211 |
+
search_query = f'patient.drug.medicinalproduct:"{clean_name}"'
|
| 212 |
+
|
| 213 |
+
params = self._build_params(search_query, limit=1)
|
| 214 |
+
params["count"] = "patient.reaction.reactionmeddrapt.exact"
|
| 215 |
+
|
| 216 |
+
data = await self._fetch_fda_data(OPENFDA_DRUG_EVENT, params)
|
| 217 |
+
reactions = data.get("results", [])[:5]
|
| 218 |
+
|
| 219 |
+
return {
|
| 220 |
+
"drug": clean_name,
|
| 221 |
+
"top_reactions": [
|
| 222 |
+
{"reaction": r.get("term", "Unknown"), "count": r.get("count", 0)}
|
| 223 |
+
for r in reactions
|
| 224 |
+
]
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
# Drug Labels
|
| 228 |
+
@safe_json_return
|
| 229 |
+
async def openfda_search_drug_labels(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 230 |
+
"""
|
| 231 |
+
Search FDA drug labeling information (warnings, indications, dosage).
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
query: Drug name, active ingredient, or condition
|
| 235 |
+
limit: Maximum results (max 100)
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Drug label information
|
| 239 |
+
"""
|
| 240 |
+
params = self._build_params(query, limit)
|
| 241 |
+
data = await self._fetch_fda_data(OPENFDA_DRUG_LABEL, params)
|
| 242 |
+
|
| 243 |
+
results = data.get("results", [])
|
| 244 |
+
labels = []
|
| 245 |
+
|
| 246 |
+
for result in results:
|
| 247 |
+
openfda = result.get("openfda", {})
|
| 248 |
+
labels.append({
|
| 249 |
+
"brand_name": openfda.get("brand_name", ["Unknown"])[0],
|
| 250 |
+
"generic_name": openfda.get("generic_name", ["Unknown"])[0],
|
| 251 |
+
"manufacturer": openfda.get("manufacturer_name", ["Unknown"])[0],
|
| 252 |
+
"purpose": result.get("purpose", ["Not specified"])[0] if result.get("purpose") else "Not specified",
|
| 253 |
+
"warnings": result.get("warnings", ["Not specified"])[0][:500] if result.get("warnings") else "Not specified",
|
| 254 |
+
"indications_and_usage": result.get("indications_and_usage", ["Not specified"])[0][:500] if result.get("indications_and_usage") else "Not specified"
|
| 255 |
+
})
|
| 256 |
+
|
| 257 |
+
return {"results": labels, "total": len(labels)}
|
| 258 |
+
|
| 259 |
+
# NDC Directory
|
| 260 |
+
@safe_json_return
|
| 261 |
+
async def openfda_search_ndc(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 262 |
+
"""
|
| 263 |
+
Search National Drug Code (NDC) directory for drug product information.
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
query: Brand name, generic name, or NDC number
|
| 267 |
+
limit: Maximum results (max 100)
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
NDC product information
|
| 271 |
+
"""
|
| 272 |
+
params = self._build_params(query, limit)
|
| 273 |
+
data = await self._fetch_fda_data(OPENFDA_DRUG_NDC, params)
|
| 274 |
+
|
| 275 |
+
results = data.get("results", [])
|
| 276 |
+
products = []
|
| 277 |
+
|
| 278 |
+
for result in results:
|
| 279 |
+
products.append({
|
| 280 |
+
"product_ndc": result.get("product_ndc", "Unknown"),
|
| 281 |
+
"brand_name": result.get("brand_name", "Unknown"),
|
| 282 |
+
"generic_name": result.get("generic_name", "Unknown"),
|
| 283 |
+
"manufacturer": result.get("labeler_name", "Unknown"),
|
| 284 |
+
"dosage_form": result.get("dosage_form", "Unknown"),
|
| 285 |
+
"route": result.get("route", ["Unknown"])[0] if result.get("route") else "Unknown",
|
| 286 |
+
"marketing_status": result.get("marketing_status", "Unknown")
|
| 287 |
+
})
|
| 288 |
+
|
| 289 |
+
return {"results": products, "total": len(products)}
|
| 290 |
+
|
| 291 |
+
# Drug Recalls
|
| 292 |
+
@safe_json_return
|
| 293 |
+
async def openfda_search_drug_recalls(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 294 |
+
"""
|
| 295 |
+
Search FDA drug recall and enforcement reports.
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
query: Drug name, manufacturer, or reason for recall
|
| 299 |
+
limit: Maximum results (max 100)
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
Drug recall information
|
| 303 |
+
"""
|
| 304 |
+
params = self._build_params(query, limit)
|
| 305 |
+
data = await self._fetch_fda_data(OPENFDA_DRUG_ENFORCEMENT, params)
|
| 306 |
+
|
| 307 |
+
results = data.get("results", [])
|
| 308 |
+
recalls = []
|
| 309 |
+
|
| 310 |
+
for result in results:
|
| 311 |
+
recalls.append({
|
| 312 |
+
"product_description": result.get("product_description", "Unknown"),
|
| 313 |
+
"reason_for_recall": result.get("reason_for_recall", "Unknown"),
|
| 314 |
+
"classification": result.get("classification", "Unknown"),
|
| 315 |
+
"status": result.get("status", "Unknown"),
|
| 316 |
+
"recall_date": result.get("recall_initiation_date", "Unknown"),
|
| 317 |
+
"recalling_firm": result.get("recalling_firm", "Unknown")
|
| 318 |
+
})
|
| 319 |
+
|
| 320 |
+
return {"results": recalls, "total": len(recalls)}
|
| 321 |
+
|
| 322 |
+
# Drugs@FDA
|
| 323 |
+
@safe_json_return
|
| 324 |
+
async def openfda_search_drugs_fda(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 325 |
+
"""
|
| 326 |
+
Search Drugs@FDA database for approved drug products and applications.
|
| 327 |
+
|
| 328 |
+
Args:
|
| 329 |
+
query: Drug name or active ingredient
|
| 330 |
+
limit: Maximum results (max 100)
|
| 331 |
+
|
| 332 |
+
Returns:
|
| 333 |
+
Approved drug information
|
| 334 |
+
"""
|
| 335 |
+
params = self._build_params(query, limit)
|
| 336 |
+
data = await self._fetch_fda_data(OPENFDA_DRUG_DRUGSFDA, params)
|
| 337 |
+
|
| 338 |
+
results = data.get("results", [])
|
| 339 |
+
drugs = []
|
| 340 |
+
|
| 341 |
+
for result in results:
|
| 342 |
+
products = result.get("products", [])
|
| 343 |
+
for product in products:
|
| 344 |
+
drugs.append({
|
| 345 |
+
"application_number": result.get("application_number", "Unknown"),
|
| 346 |
+
"sponsor_name": result.get("sponsor_name", "Unknown"),
|
| 347 |
+
"brand_name": product.get("brand_name", "Unknown"),
|
| 348 |
+
"active_ingredients": product.get("active_ingredients", []),
|
| 349 |
+
"dosage_form": product.get("dosage_form", "Unknown"),
|
| 350 |
+
"route": product.get("route", "Unknown"),
|
| 351 |
+
"marketing_status": product.get("marketing_status", "Unknown")
|
| 352 |
+
})
|
| 353 |
+
|
| 354 |
+
return {"results": drugs, "total": len(drugs)}
|
| 355 |
+
|
| 356 |
+
# Device Events
|
| 357 |
+
@safe_json_return
|
| 358 |
+
async def openfda_search_device_events(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 359 |
+
"""
|
| 360 |
+
Search medical device adverse event reports.
|
| 361 |
+
|
| 362 |
+
Args:
|
| 363 |
+
query: Device name or brand
|
| 364 |
+
limit: Maximum results (max 100)
|
| 365 |
+
|
| 366 |
+
Returns:
|
| 367 |
+
Device adverse event information
|
| 368 |
+
"""
|
| 369 |
+
params = self._build_params(query, limit)
|
| 370 |
+
data = await self._fetch_fda_data(OPENFDA_DEVICE_EVENT, params)
|
| 371 |
+
|
| 372 |
+
results = data.get("results", [])
|
| 373 |
+
events = []
|
| 374 |
+
|
| 375 |
+
for result in results:
|
| 376 |
+
device = result.get("device", [{}])[0]
|
| 377 |
+
events.append({
|
| 378 |
+
"report_number": result.get("report_number", "Unknown"),
|
| 379 |
+
"date_received": result.get("date_received", "Unknown"),
|
| 380 |
+
"device_name": device.get("brand_name", "Unknown"),
|
| 381 |
+
"manufacturer": device.get("manufacturer_d_name", "Unknown"),
|
| 382 |
+
"event_type": result.get("event_type", "Unknown"),
|
| 383 |
+
"device_problem": device.get("device_problem_codes", ["Unknown"])[0] if device.get("device_problem_codes") else "Unknown"
|
| 384 |
+
})
|
| 385 |
+
|
| 386 |
+
return {"results": events, "total": len(events)}
|
| 387 |
+
|
| 388 |
+
# Device Recalls
|
| 389 |
+
@safe_json_return
|
| 390 |
+
async def openfda_search_device_recalls(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 391 |
+
"""
|
| 392 |
+
Search medical device recall and enforcement reports.
|
| 393 |
+
|
| 394 |
+
Args:
|
| 395 |
+
query: Device name, manufacturer, or reason
|
| 396 |
+
limit: Maximum results (max 100)
|
| 397 |
+
|
| 398 |
+
Returns:
|
| 399 |
+
Device recall information
|
| 400 |
+
"""
|
| 401 |
+
params = self._build_params(query, limit)
|
| 402 |
+
data = await self._fetch_fda_data(OPENFDA_DEVICE_ENFORCEMENT, params)
|
| 403 |
+
|
| 404 |
+
results = data.get("results", [])
|
| 405 |
+
recalls = []
|
| 406 |
+
|
| 407 |
+
for result in results:
|
| 408 |
+
recalls.append({
|
| 409 |
+
"product_description": result.get("product_description", "Unknown"),
|
| 410 |
+
"reason_for_recall": result.get("reason_for_recall", "Unknown"),
|
| 411 |
+
"classification": result.get("classification", "Unknown"),
|
| 412 |
+
"status": result.get("status", "Unknown"),
|
| 413 |
+
"recall_date": result.get("recall_initiation_date", "Unknown"),
|
| 414 |
+
"recalling_firm": result.get("recalling_firm", "Unknown")
|
| 415 |
+
})
|
| 416 |
+
|
| 417 |
+
return {"results": recalls, "total": len(recalls)}
|
| 418 |
+
|
| 419 |
+
# Device Classifications
|
| 420 |
+
@safe_json_return
|
| 421 |
+
async def openfda_search_device_classifications(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 422 |
+
"""
|
| 423 |
+
Search medical device classification database.
|
| 424 |
+
|
| 425 |
+
Args:
|
| 426 |
+
query: Device type or classification
|
| 427 |
+
limit: Maximum results (max 100)
|
| 428 |
+
|
| 429 |
+
Returns:
|
| 430 |
+
Device classification information
|
| 431 |
+
"""
|
| 432 |
+
params = self._build_params(query, limit)
|
| 433 |
+
data = await self._fetch_fda_data(OPENFDA_DEVICE_CLASSIFICATION, params)
|
| 434 |
+
|
| 435 |
+
results = data.get("results", [])
|
| 436 |
+
classifications = []
|
| 437 |
+
|
| 438 |
+
for result in results:
|
| 439 |
+
classifications.append({
|
| 440 |
+
"device_name": result.get("device_name", "Unknown"),
|
| 441 |
+
"device_class": result.get("device_class", "Unknown"),
|
| 442 |
+
"medical_specialty": result.get("medical_specialty_description", "Unknown"),
|
| 443 |
+
"regulation_number": result.get("regulation_number", "Unknown"),
|
| 444 |
+
"product_code": result.get("product_code", "Unknown")
|
| 445 |
+
})
|
| 446 |
+
|
| 447 |
+
return {"results": classifications, "total": len(classifications)}
|
| 448 |
+
|
| 449 |
+
# 510(k) Clearances
|
| 450 |
+
@safe_json_return
|
| 451 |
+
async def openfda_search_510k(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 452 |
+
"""
|
| 453 |
+
Search FDA 510(k) premarket clearance database.
|
| 454 |
+
|
| 455 |
+
Args:
|
| 456 |
+
query: Device name or manufacturer
|
| 457 |
+
limit: Maximum results (max 100)
|
| 458 |
+
|
| 459 |
+
Returns:
|
| 460 |
+
510(k) clearance information
|
| 461 |
+
"""
|
| 462 |
+
params = self._build_params(query, limit)
|
| 463 |
+
data = await self._fetch_fda_data(OPENFDA_DEVICE_510K, params)
|
| 464 |
+
|
| 465 |
+
results = data.get("results", [])
|
| 466 |
+
clearances = []
|
| 467 |
+
|
| 468 |
+
for result in results:
|
| 469 |
+
clearances.append({
|
| 470 |
+
"k_number": result.get("k_number", "Unknown"),
|
| 471 |
+
"device_name": result.get("device_name", "Unknown"),
|
| 472 |
+
"applicant": result.get("applicant", "Unknown"),
|
| 473 |
+
"clearance_date": result.get("date_received", "Unknown"),
|
| 474 |
+
"decision_description": result.get("decision_description", "Unknown"),
|
| 475 |
+
"product_code": result.get("product_code", "Unknown")
|
| 476 |
+
})
|
| 477 |
+
|
| 478 |
+
return {"results": clearances, "total": len(clearances)}
|
| 479 |
+
|
| 480 |
+
# PMA Approvals
|
| 481 |
+
@safe_json_return
|
| 482 |
+
async def openfda_search_pma(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 483 |
+
"""
|
| 484 |
+
Search FDA premarket approval (PMA) database.
|
| 485 |
+
|
| 486 |
+
Args:
|
| 487 |
+
query: Device name or manufacturer
|
| 488 |
+
limit: Maximum results (max 100)
|
| 489 |
+
|
| 490 |
+
Returns:
|
| 491 |
+
PMA approval information
|
| 492 |
+
"""
|
| 493 |
+
params = self._build_params(query, limit)
|
| 494 |
+
data = await self._fetch_fda_data(OPENFDA_DEVICE_PMA, params)
|
| 495 |
+
|
| 496 |
+
results = data.get("results", [])
|
| 497 |
+
approvals = []
|
| 498 |
+
|
| 499 |
+
for result in results:
|
| 500 |
+
approvals.append({
|
| 501 |
+
"pma_number": result.get("pma_number", "Unknown"),
|
| 502 |
+
"device_name": result.get("device_name", "Unknown"),
|
| 503 |
+
"applicant": result.get("applicant", "Unknown"),
|
| 504 |
+
"approval_date": result.get("date_received", "Unknown"),
|
| 505 |
+
"decision_description": result.get("decision_description", "Unknown"),
|
| 506 |
+
"product_code": result.get("product_code", "Unknown")
|
| 507 |
+
})
|
| 508 |
+
|
| 509 |
+
return {"results": approvals, "total": len(approvals)}
|
| 510 |
+
|
| 511 |
+
# Food Recalls
|
| 512 |
+
@safe_json_return
|
| 513 |
+
async def openfda_search_food_recalls(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 514 |
+
"""
|
| 515 |
+
Search FDA food recall and enforcement reports.
|
| 516 |
+
|
| 517 |
+
Args:
|
| 518 |
+
query: Food product or reason for recall
|
| 519 |
+
limit: Maximum results (max 100)
|
| 520 |
+
|
| 521 |
+
Returns:
|
| 522 |
+
Food recall information
|
| 523 |
+
"""
|
| 524 |
+
params = self._build_params(query, limit)
|
| 525 |
+
data = await self._fetch_fda_data(OPENFDA_FOOD_ENFORCEMENT, params)
|
| 526 |
+
|
| 527 |
+
results = data.get("results", [])
|
| 528 |
+
recalls = []
|
| 529 |
+
|
| 530 |
+
for result in results:
|
| 531 |
+
recalls.append({
|
| 532 |
+
"product_description": result.get("product_description", "Unknown"),
|
| 533 |
+
"reason_for_recall": result.get("reason_for_recall", "Unknown"),
|
| 534 |
+
"classification": result.get("classification", "Unknown"),
|
| 535 |
+
"status": result.get("status", "Unknown"),
|
| 536 |
+
"recall_date": result.get("recall_initiation_date", "Unknown"),
|
| 537 |
+
"recalling_firm": result.get("recalling_firm", "Unknown")
|
| 538 |
+
})
|
| 539 |
+
|
| 540 |
+
return {"results": recalls, "total": len(recalls)}
|
| 541 |
+
|
| 542 |
+
# Food Events
|
| 543 |
+
@safe_json_return
|
| 544 |
+
async def openfda_search_food_events(self, query: str, limit: int = 10) -> Dict[str, Any]:
|
| 545 |
+
"""
|
| 546 |
+
Search FDA food adverse event reports.
|
| 547 |
+
|
| 548 |
+
Args:
|
| 549 |
+
query: Food product or reaction
|
| 550 |
+
limit: Maximum results (max 100)
|
| 551 |
+
|
| 552 |
+
Returns:
|
| 553 |
+
Food adverse event information
|
| 554 |
+
"""
|
| 555 |
+
params = self._build_params(query, limit)
|
| 556 |
+
data = await self._fetch_fda_data(OPENFDA_FOOD_EVENT, params)
|
| 557 |
+
|
| 558 |
+
results = data.get("results", [])
|
| 559 |
+
events = []
|
| 560 |
+
|
| 561 |
+
for result in results:
|
| 562 |
+
products = result.get("products", [{}])
|
| 563 |
+
reactions = result.get("reactions", [])
|
| 564 |
+
|
| 565 |
+
events.append({
|
| 566 |
+
"report_number": result.get("report_number", "Unknown"),
|
| 567 |
+
"date_started": result.get("date_started", "Unknown"),
|
| 568 |
+
"products": [p.get("name_brand", "Unknown") for p in products],
|
| 569 |
+
"reactions": [r.get("reaction", "Unknown") for r in reactions][:5],
|
| 570 |
+
"outcomes": result.get("outcomes", ["Unknown"])
|
| 571 |
+
})
|
| 572 |
+
|
| 573 |
+
return {"results": events, "total": len(events)}
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mcp>=1.0.0
|
| 2 |
+
httpx>=0.28.0
|
| 3 |
+
pydantic>=2.11.0
|
| 4 |
+
tenacity>=9.0.0
|
| 5 |
+
fastapi>=0.104.0
|
| 6 |
+
uvicorn[standard]>=0.24.0
|
server.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Healthcare API MCP Server - Public APIs for FDA, Clinical Guidelines, and CMS."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
|
| 7 |
+
from mcp.server import Server
|
| 8 |
+
from mcp.server.stdio import stdio_server
|
| 9 |
+
from mcp.types import Tool, TextContent
|
| 10 |
+
|
| 11 |
+
from .config import MCPConfig
|
| 12 |
+
from .core import create_http_client
|
| 13 |
+
from .providers import get_provider_class
|
| 14 |
+
from .providers.openfda_provider import OpenFDAProvider
|
| 15 |
+
from .providers.clinical_guidelines_provider import ClinicalGuidelinesProvider
|
| 16 |
+
from .providers.cms_pricing_provider import CMSPricingProvider
|
| 17 |
+
|
| 18 |
+
# Setup logging
|
| 19 |
+
logging.basicConfig(
|
| 20 |
+
level=logging.INFO,
|
| 21 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 22 |
+
)
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Initialize MCP server
|
| 26 |
+
app = Server("healthcare-api-mcp")
|
| 27 |
+
|
| 28 |
+
# Global state
|
| 29 |
+
providers = {}
|
| 30 |
+
http_client = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def create_tool_schema(func: callable, provider_name: str) -> Tool:
|
| 34 |
+
"""Create MCP tool schema from provider function."""
|
| 35 |
+
# Get function metadata
|
| 36 |
+
doc = func.__doc__ or ""
|
| 37 |
+
lines = [line.strip() for line in doc.split('\n') if line.strip()]
|
| 38 |
+
|
| 39 |
+
# Extract description (first non-empty line)
|
| 40 |
+
description = lines[0] if lines else func.__name__
|
| 41 |
+
|
| 42 |
+
# Extract parameter info from docstring
|
| 43 |
+
# Parse Args section
|
| 44 |
+
params_start = None
|
| 45 |
+
params_end = None
|
| 46 |
+
for i, line in enumerate(lines):
|
| 47 |
+
if line.startswith('Args:'):
|
| 48 |
+
params_start = i + 1
|
| 49 |
+
elif params_start and line.startswith('Returns:'):
|
| 50 |
+
params_end = i
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
# Build input schema
|
| 54 |
+
properties = {}
|
| 55 |
+
required = []
|
| 56 |
+
|
| 57 |
+
if params_start and params_end:
|
| 58 |
+
for line in lines[params_start:params_end]:
|
| 59 |
+
if ':' in line:
|
| 60 |
+
param_info = line.split(':', 1)
|
| 61 |
+
param_name = param_info[0].strip()
|
| 62 |
+
param_desc = param_info[1].strip() if len(param_info) > 1 else ""
|
| 63 |
+
|
| 64 |
+
# Determine type from function annotations
|
| 65 |
+
func_params = func.__annotations__
|
| 66 |
+
param_type = "string" # default
|
| 67 |
+
|
| 68 |
+
if param_name in func_params:
|
| 69 |
+
annotation = func_params[param_name]
|
| 70 |
+
if annotation == int:
|
| 71 |
+
param_type = "integer"
|
| 72 |
+
elif annotation == bool:
|
| 73 |
+
param_type = "boolean"
|
| 74 |
+
elif annotation == float:
|
| 75 |
+
param_type = "number"
|
| 76 |
+
elif hasattr(annotation, '__origin__'):
|
| 77 |
+
# Handle List[str], etc.
|
| 78 |
+
if annotation.__origin__ == list:
|
| 79 |
+
param_type = "array"
|
| 80 |
+
|
| 81 |
+
properties[param_name] = {
|
| 82 |
+
"type": param_type,
|
| 83 |
+
"description": param_desc
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
# Check if parameter has no default value (is required)
|
| 87 |
+
import inspect
|
| 88 |
+
sig = inspect.signature(func)
|
| 89 |
+
if param_name in sig.parameters:
|
| 90 |
+
param = sig.parameters[param_name]
|
| 91 |
+
if param.default == inspect.Parameter.empty and param_name != 'self':
|
| 92 |
+
required.append(param_name)
|
| 93 |
+
|
| 94 |
+
# Create tool schema
|
| 95 |
+
tool = Tool(
|
| 96 |
+
name=func.__name__,
|
| 97 |
+
description=description,
|
| 98 |
+
inputSchema={
|
| 99 |
+
"type": "object",
|
| 100 |
+
"properties": properties,
|
| 101 |
+
"required": required
|
| 102 |
+
}
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
return tool
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@app.list_tools()
|
| 109 |
+
async def list_tools() -> List[Tool]:
|
| 110 |
+
"""List all available tools from enabled providers."""
|
| 111 |
+
tools = []
|
| 112 |
+
|
| 113 |
+
for provider_name, provider in providers.items():
|
| 114 |
+
provider_tools = provider.get_tools()
|
| 115 |
+
for tool_func in provider_tools:
|
| 116 |
+
tool_schema = create_tool_schema(tool_func, provider_name)
|
| 117 |
+
tools.append(tool_schema)
|
| 118 |
+
|
| 119 |
+
logger.info(f"Listed {len(tools)} tools from {len(providers)} providers")
|
| 120 |
+
return tools
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@app.call_tool()
|
| 124 |
+
async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
|
| 125 |
+
"""Execute a tool from any enabled provider."""
|
| 126 |
+
logger.info(f"Calling tool: {name} with arguments: {arguments}")
|
| 127 |
+
|
| 128 |
+
# Find the tool in providers
|
| 129 |
+
for provider_name, provider in providers.items():
|
| 130 |
+
provider_tools = provider.get_tools()
|
| 131 |
+
for tool_func in provider_tools:
|
| 132 |
+
if tool_func.__name__ == name:
|
| 133 |
+
try:
|
| 134 |
+
# Call the tool function
|
| 135 |
+
result = await tool_func(**arguments)
|
| 136 |
+
|
| 137 |
+
# Return as TextContent
|
| 138 |
+
return [TextContent(
|
| 139 |
+
type="text",
|
| 140 |
+
text=str(result)
|
| 141 |
+
)]
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.exception(f"Error calling tool {name}")
|
| 144 |
+
error_result = f'{{"error": "{str(e)}", "error_type": "{type(e).__name__}"}}'
|
| 145 |
+
return [TextContent(
|
| 146 |
+
type="text",
|
| 147 |
+
text=error_result
|
| 148 |
+
)]
|
| 149 |
+
|
| 150 |
+
# Tool not found
|
| 151 |
+
error_msg = f'{{"error": "Tool not found: {name}"}}'
|
| 152 |
+
return [TextContent(type="text", text=error_msg)]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
async def main():
|
| 156 |
+
"""Main entry point for the MCP server."""
|
| 157 |
+
global providers, http_client
|
| 158 |
+
|
| 159 |
+
# Load configuration
|
| 160 |
+
config = MCPConfig.from_env()
|
| 161 |
+
|
| 162 |
+
# Set logging level
|
| 163 |
+
logging.getLogger().setLevel(config.log_level)
|
| 164 |
+
|
| 165 |
+
logger.info("Starting Healthcare API MCP Server")
|
| 166 |
+
logger.info(f"Enabled providers: {config.enabled_providers}")
|
| 167 |
+
|
| 168 |
+
# Create shared HTTP client
|
| 169 |
+
http_client = create_http_client()
|
| 170 |
+
|
| 171 |
+
# Initialize providers
|
| 172 |
+
provider_classes = {
|
| 173 |
+
"openfda": OpenFDAProvider,
|
| 174 |
+
"clinical_guidelines": ClinicalGuidelinesProvider,
|
| 175 |
+
"cms_pricing": CMSPricingProvider
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
for provider_name in config.enabled_providers:
|
| 179 |
+
if provider_name not in provider_classes:
|
| 180 |
+
logger.warning(f"Unknown provider: {provider_name}")
|
| 181 |
+
continue
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
provider_class = provider_classes[provider_name]
|
| 185 |
+
|
| 186 |
+
# Initialize provider with appropriate parameters
|
| 187 |
+
if provider_name == "openfda":
|
| 188 |
+
provider = provider_class(http_client, api_key=config.openfda_api_key)
|
| 189 |
+
else:
|
| 190 |
+
provider = provider_class(http_client)
|
| 191 |
+
|
| 192 |
+
await provider.initialize()
|
| 193 |
+
providers[provider_name] = provider
|
| 194 |
+
logger.info(f"Initialized provider: {provider_name}")
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(f"Failed to initialize provider {provider_name}: {e}")
|
| 197 |
+
|
| 198 |
+
if not providers:
|
| 199 |
+
logger.error("No providers initialized. Exiting.")
|
| 200 |
+
return
|
| 201 |
+
|
| 202 |
+
logger.info(f"Successfully initialized {len(providers)} provider(s)")
|
| 203 |
+
|
| 204 |
+
# Run the server with stdio transport
|
| 205 |
+
try:
|
| 206 |
+
async with stdio_server() as (read_stream, write_stream):
|
| 207 |
+
logger.info("MCP server running on stdio")
|
| 208 |
+
await app.run(
|
| 209 |
+
read_stream,
|
| 210 |
+
write_stream,
|
| 211 |
+
app.create_initialization_options()
|
| 212 |
+
)
|
| 213 |
+
finally:
|
| 214 |
+
# Cleanup
|
| 215 |
+
logger.info("Shutting down...")
|
| 216 |
+
for provider in providers.values():
|
| 217 |
+
await provider.cleanup()
|
| 218 |
+
if http_client:
|
| 219 |
+
await http_client.aclose()
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
if __name__ == "__main__":
|
| 223 |
+
asyncio.run(main())
|
server_http.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP Streaming MCP Server for Healthcare API MCP - HuggingFace Spaces deployment.
|
| 2 |
+
|
| 3 |
+
Uses HTTP streaming with chunked transfer encoding instead of SSE.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from typing import AsyncGenerator
|
| 10 |
+
|
| 11 |
+
from fastapi import FastAPI, Request
|
| 12 |
+
from fastapi.responses import StreamingResponse
|
| 13 |
+
from starlette.middleware.cors import CORSMiddleware
|
| 14 |
+
|
| 15 |
+
from config import MCPConfig
|
| 16 |
+
from core import create_http_client
|
| 17 |
+
from providers.openfda_provider import OpenFDAProvider
|
| 18 |
+
from providers.clinical_guidelines_provider import ClinicalGuidelinesProvider
|
| 19 |
+
from providers.cms_pricing_provider import CMSPricingProvider
|
| 20 |
+
|
| 21 |
+
logging.basicConfig(level=logging.INFO)
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# Initialize FastAPI app
|
| 25 |
+
app = FastAPI(
|
| 26 |
+
title="Healthcare API MCP",
|
| 27 |
+
description="Model Context Protocol server for public healthcare data APIs",
|
| 28 |
+
version="1.0.0"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Add CORS middleware
|
| 32 |
+
app.add_middleware(
|
| 33 |
+
CORSMiddleware,
|
| 34 |
+
allow_origins=["*"],
|
| 35 |
+
allow_credentials=True,
|
| 36 |
+
allow_methods=["*"],
|
| 37 |
+
allow_headers=["*"],
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Global state
|
| 41 |
+
providers = {}
|
| 42 |
+
http_client = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.on_event("startup")
|
| 46 |
+
async def startup():
|
| 47 |
+
"""Initialize providers on startup."""
|
| 48 |
+
global http_client, providers
|
| 49 |
+
|
| 50 |
+
config = MCPConfig.from_env()
|
| 51 |
+
logger.info(f"Starting Healthcare API MCP with providers: {config.enabled_providers}")
|
| 52 |
+
|
| 53 |
+
http_client = create_http_client()
|
| 54 |
+
|
| 55 |
+
if "openfda" in config.enabled_providers:
|
| 56 |
+
providers["openfda"] = OpenFDAProvider(http_client, config.openfda_api_key)
|
| 57 |
+
await providers["openfda"].initialize()
|
| 58 |
+
logger.info("✅ OpenFDA provider initialized")
|
| 59 |
+
|
| 60 |
+
if "clinical_guidelines" in config.enabled_providers:
|
| 61 |
+
providers["clinical_guidelines"] = ClinicalGuidelinesProvider(http_client)
|
| 62 |
+
await providers["clinical_guidelines"].initialize()
|
| 63 |
+
logger.info("✅ Clinical Guidelines provider initialized")
|
| 64 |
+
|
| 65 |
+
if "cms_pricing" in config.enabled_providers:
|
| 66 |
+
providers["cms_pricing"] = CMSPricingProvider(http_client)
|
| 67 |
+
await providers["cms_pricing"].initialize()
|
| 68 |
+
logger.info("✅ CMS Pricing provider initialized")
|
| 69 |
+
|
| 70 |
+
logger.info(f"Healthcare API MCP ready with {len(providers)} provider(s)")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.on_event("shutdown")
|
| 74 |
+
async def shutdown():
|
| 75 |
+
"""Cleanup on shutdown."""
|
| 76 |
+
global http_client, providers
|
| 77 |
+
|
| 78 |
+
logger.info("Shutting down Healthcare API MCP...")
|
| 79 |
+
|
| 80 |
+
for provider in providers.values():
|
| 81 |
+
await provider.cleanup()
|
| 82 |
+
|
| 83 |
+
if http_client:
|
| 84 |
+
await http_client.aclose()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@app.get("/")
|
| 88 |
+
async def root():
|
| 89 |
+
"""Root endpoint with server info."""
|
| 90 |
+
return {
|
| 91 |
+
"name": "Healthcare API MCP",
|
| 92 |
+
"version": "1.0.0",
|
| 93 |
+
"protocol": "MCP over HTTP Streaming",
|
| 94 |
+
"providers": list(providers.keys()),
|
| 95 |
+
"endpoints": {
|
| 96 |
+
"tools": "/mcp/tools",
|
| 97 |
+
"call": "/mcp/call",
|
| 98 |
+
"stream": "/mcp/stream"
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.get("/health")
|
| 104 |
+
async def health_check():
|
| 105 |
+
"""Health check endpoint."""
|
| 106 |
+
return {
|
| 107 |
+
"status": "healthy",
|
| 108 |
+
"providers": {name: "active" for name in providers.keys()}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@app.get("/mcp/tools")
|
| 113 |
+
async def list_tools():
|
| 114 |
+
"""List all available MCP tools."""
|
| 115 |
+
tools = []
|
| 116 |
+
|
| 117 |
+
for provider_name, provider in providers.items():
|
| 118 |
+
provider_tools = provider.get_tools()
|
| 119 |
+
for tool_func in provider_tools:
|
| 120 |
+
# Extract tool metadata
|
| 121 |
+
doc = tool_func.__doc__ or ""
|
| 122 |
+
description = doc.split("Args:")[0].strip() if "Args:" in doc else doc.strip()
|
| 123 |
+
|
| 124 |
+
# Parse parameters from docstring
|
| 125 |
+
params = {}
|
| 126 |
+
if "Args:" in doc:
|
| 127 |
+
args_section = doc.split("Args:")[1].split("Returns:")[0]
|
| 128 |
+
for line in args_section.split("\n"):
|
| 129 |
+
line = line.strip()
|
| 130 |
+
if ":" in line:
|
| 131 |
+
param_name = line.split(":")[0].strip()
|
| 132 |
+
param_desc = line.split(":", 1)[1].strip()
|
| 133 |
+
params[param_name] = param_desc
|
| 134 |
+
|
| 135 |
+
tools.append({
|
| 136 |
+
"name": tool_func.__name__,
|
| 137 |
+
"provider": provider_name,
|
| 138 |
+
"description": description,
|
| 139 |
+
"parameters": params
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
return {"tools": tools, "total": len(tools)}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.post("/mcp/call")
|
| 146 |
+
async def call_tool(request: Request):
|
| 147 |
+
"""Call an MCP tool and return result."""
|
| 148 |
+
body = await request.json()
|
| 149 |
+
tool_name = body.get("tool")
|
| 150 |
+
arguments = body.get("arguments", {})
|
| 151 |
+
|
| 152 |
+
if not tool_name:
|
| 153 |
+
return {"error": "Missing 'tool' parameter"}
|
| 154 |
+
|
| 155 |
+
logger.info(f"Calling tool: {tool_name} with args: {arguments}")
|
| 156 |
+
|
| 157 |
+
# Find and execute tool
|
| 158 |
+
for provider_name, provider in providers.items():
|
| 159 |
+
provider_tools = provider.get_tools()
|
| 160 |
+
for tool_func in provider_tools:
|
| 161 |
+
if tool_func.__name__ == tool_name:
|
| 162 |
+
try:
|
| 163 |
+
result = await tool_func(**arguments)
|
| 164 |
+
return {
|
| 165 |
+
"success": True,
|
| 166 |
+
"tool": tool_name,
|
| 167 |
+
"result": result
|
| 168 |
+
}
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.exception(f"Error calling tool {tool_name}")
|
| 171 |
+
return {
|
| 172 |
+
"success": False,
|
| 173 |
+
"tool": tool_name,
|
| 174 |
+
"error": str(e),
|
| 175 |
+
"error_type": type(e).__name__
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"success": False,
|
| 180 |
+
"tool": tool_name,
|
| 181 |
+
"error": f"Tool not found: {tool_name}"
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
async def stream_tool_result(tool_name: str, arguments: dict) -> AsyncGenerator[str, None]:
|
| 186 |
+
"""Stream tool execution results as JSON chunks."""
|
| 187 |
+
# Send initial acknowledgment
|
| 188 |
+
yield json.dumps({
|
| 189 |
+
"type": "start",
|
| 190 |
+
"tool": tool_name,
|
| 191 |
+
"arguments": arguments
|
| 192 |
+
}) + "\n"
|
| 193 |
+
|
| 194 |
+
# Find and execute tool
|
| 195 |
+
tool_found = False
|
| 196 |
+
for provider_name, provider in providers.items():
|
| 197 |
+
provider_tools = provider.get_tools()
|
| 198 |
+
for tool_func in provider_tools:
|
| 199 |
+
if tool_func.__name__ == tool_name:
|
| 200 |
+
tool_found = True
|
| 201 |
+
try:
|
| 202 |
+
# Execute tool
|
| 203 |
+
result = await tool_func(**arguments)
|
| 204 |
+
|
| 205 |
+
# Stream result
|
| 206 |
+
yield json.dumps({
|
| 207 |
+
"type": "result",
|
| 208 |
+
"success": True,
|
| 209 |
+
"data": result
|
| 210 |
+
}) + "\n"
|
| 211 |
+
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logger.exception(f"Error executing tool {tool_name}")
|
| 214 |
+
yield json.dumps({
|
| 215 |
+
"type": "error",
|
| 216 |
+
"success": False,
|
| 217 |
+
"error": str(e),
|
| 218 |
+
"error_type": type(e).__name__
|
| 219 |
+
}) + "\n"
|
| 220 |
+
|
| 221 |
+
break
|
| 222 |
+
if tool_found:
|
| 223 |
+
break
|
| 224 |
+
|
| 225 |
+
if not tool_found:
|
| 226 |
+
yield json.dumps({
|
| 227 |
+
"type": "error",
|
| 228 |
+
"success": False,
|
| 229 |
+
"error": f"Tool not found: {tool_name}"
|
| 230 |
+
}) + "\n"
|
| 231 |
+
|
| 232 |
+
# Send completion
|
| 233 |
+
yield json.dumps({"type": "complete"}) + "\n"
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@app.post("/mcp/stream")
|
| 237 |
+
async def stream_tool_call(request: Request):
|
| 238 |
+
"""Stream tool execution results using HTTP chunked transfer encoding."""
|
| 239 |
+
body = await request.json()
|
| 240 |
+
tool_name = body.get("tool")
|
| 241 |
+
arguments = body.get("arguments", {})
|
| 242 |
+
|
| 243 |
+
if not tool_name:
|
| 244 |
+
return {"error": "Missing 'tool' parameter"}
|
| 245 |
+
|
| 246 |
+
logger.info(f"Streaming tool: {tool_name} with args: {arguments}")
|
| 247 |
+
|
| 248 |
+
return StreamingResponse(
|
| 249 |
+
stream_tool_result(tool_name, arguments),
|
| 250 |
+
media_type="application/x-ndjson",
|
| 251 |
+
headers={
|
| 252 |
+
"Cache-Control": "no-cache",
|
| 253 |
+
"X-Accel-Buffering": "no",
|
| 254 |
+
}
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
if __name__ == "__main__":
|
| 259 |
+
import uvicorn
|
| 260 |
+
uvicorn.run(
|
| 261 |
+
app,
|
| 262 |
+
host="0.0.0.0",
|
| 263 |
+
port=7860,
|
| 264 |
+
log_level="info"
|
| 265 |
+
)
|