Spaces:
Sleeping
Sleeping
Commit
·
cfd07be
0
Parent(s):
Clean commit for HF push
Browse files- .gitignore +5 -0
- .python-version +1 -0
- README.md +69 -0
- main.py +6 -0
- pyproject.toml +22 -0
- src/credentialwatch_agent/__init__.py +0 -0
- src/credentialwatch_agent/__pycache__/__init__.cpython-310.pyc +0 -0
- src/credentialwatch_agent/__pycache__/__init__.cpython-313.pyc +0 -0
- src/credentialwatch_agent/__pycache__/main.cpython-313.pyc +0 -0
- src/credentialwatch_agent/__pycache__/mcp_client.cpython-310.pyc +0 -0
- src/credentialwatch_agent/__pycache__/mcp_client.cpython-313.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/common.cpython-310.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/common.cpython-313.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/expiry_sweep.cpython-310.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/expiry_sweep.cpython-313.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-310.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-313.pyc +0 -0
- src/credentialwatch_agent/agents/common.py +22 -0
- src/credentialwatch_agent/agents/expiry_sweep.py +93 -0
- src/credentialwatch_agent/agents/interactive_query.py +101 -0
- src/credentialwatch_agent/main.py +112 -0
- src/credentialwatch_agent/mcp_client.py +99 -0
- uv.lock +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env.*
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.DS_Store
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.13
|
README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CredentialWatch Agent
|
| 2 |
+
|
| 3 |
+
MCP-powered agent system for monitoring healthcare provider credentials. This system uses LangGraph to orchestrate workflows for checking credential expiry and answering interactive queries, leveraging Model Context Protocol (MCP) to connect to external data sources.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Interactive Query Agent**: Ask natural language questions about provider credentials.
|
| 8 |
+
- **Expiry Sweep Agent**: Automated workflow to check for expiring credentials and generate alerts.
|
| 9 |
+
- **MCP Integration**: Connects to NPI Registry, Credential Database, and Alerting systems via MCP.
|
| 10 |
+
- **Gradio UI**: User-friendly interface for interaction.
|
| 11 |
+
|
| 12 |
+
## Prerequisites
|
| 13 |
+
|
| 14 |
+
- Python 3.11 or higher
|
| 15 |
+
- [uv](https://github.com/astral-sh/uv) (recommended)
|
| 16 |
+
|
| 17 |
+
## Installation
|
| 18 |
+
|
| 19 |
+
1. **Clone the repository:**
|
| 20 |
+
```bash
|
| 21 |
+
git clone <repository_url>
|
| 22 |
+
cd credential_watch
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
2. **Install dependencies:**
|
| 26 |
+
```bash
|
| 27 |
+
uv sync
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Configuration
|
| 31 |
+
|
| 32 |
+
Create a `.env` file in the root directory (or copy `.env.local`) and configure your environment variables:
|
| 33 |
+
|
| 34 |
+
```env
|
| 35 |
+
OPENAI_API_KEY=your_openai_api_key
|
| 36 |
+
# MCP Server URLs (defaults shown)
|
| 37 |
+
NPI_MCP_URL=http://localhost:8001/sse
|
| 38 |
+
CRED_DB_MCP_URL=http://localhost:8002/sse
|
| 39 |
+
ALERT_MCP_URL=http://localhost:8003/sse
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## Running the Application
|
| 43 |
+
|
| 44 |
+
To run the agent system with the Gradio UI using `uv`:
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
uv run python src/credentialwatch_agent/main.py
|
| 48 |
+
```
|
| 49 |
+
OR
|
| 50 |
+
```bash
|
| 51 |
+
uv run -m credentialwatch_agent.main
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
The UI will be available at `http://localhost:7860`.
|
| 55 |
+
|
| 56 |
+
## Architecture
|
| 57 |
+
|
| 58 |
+
- **`src/credentialwatch_agent/agents/`**: Contains LangGraph workflow definitions.
|
| 59 |
+
- **`src/credentialwatch_agent/mcp_client.py`**: Handles connections to MCP servers.
|
| 60 |
+
- **`src/credentialwatch_agent/main.py`**: Entry point and Gradio UI.
|
| 61 |
+
|
| 62 |
+
## MCP Servers
|
| 63 |
+
|
| 64 |
+
The agent expects the following MCP servers to be running:
|
| 65 |
+
1. **NPI Server** (Port 8001)
|
| 66 |
+
2. **Credential DB Server** (Port 8002)
|
| 67 |
+
3. **Alert Server** (Port 8003)
|
| 68 |
+
|
| 69 |
+
If these servers are not reachable, the client will fall back to using mock data for demonstration purposes.
|
main.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def main():
|
| 2 |
+
print("Hello from credential-watch!")
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
main()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "credentialwatch_agent"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "MCP-powered agent system for monitoring healthcare provider credentials"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"langgraph>=0.0.10",
|
| 9 |
+
"langchain-openai>=0.0.5",
|
| 10 |
+
"pydantic>=2.0.0",
|
| 11 |
+
"mcp>=0.1.0",
|
| 12 |
+
"gradio>=4.0.0",
|
| 13 |
+
"python-dotenv>=1.0.0",
|
| 14 |
+
"httpx>=0.25.0"
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
[build-system]
|
| 18 |
+
requires = ["hatchling"]
|
| 19 |
+
build-backend = "hatchling.build"
|
| 20 |
+
|
| 21 |
+
[tool.hatch.build.targets.wheel]
|
| 22 |
+
packages = ["src/credentialwatch_agent"]
|
src/credentialwatch_agent/__init__.py
ADDED
|
File without changes
|
src/credentialwatch_agent/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (186 Bytes). View file
|
|
|
src/credentialwatch_agent/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (196 Bytes). View file
|
|
|
src/credentialwatch_agent/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (4.9 kB). View file
|
|
|
src/credentialwatch_agent/__pycache__/mcp_client.cpython-310.pyc
ADDED
|
Binary file (3.63 kB). View file
|
|
|
src/credentialwatch_agent/__pycache__/mcp_client.cpython-313.pyc
ADDED
|
Binary file (6.3 kB). View file
|
|
|
src/credentialwatch_agent/agents/__pycache__/common.cpython-310.pyc
ADDED
|
Binary file (1.13 kB). View file
|
|
|
src/credentialwatch_agent/agents/__pycache__/common.cpython-313.pyc
ADDED
|
Binary file (1.36 kB). View file
|
|
|
src/credentialwatch_agent/agents/__pycache__/expiry_sweep.cpython-310.pyc
ADDED
|
Binary file (2.54 kB). View file
|
|
|
src/credentialwatch_agent/agents/__pycache__/expiry_sweep.cpython-313.pyc
ADDED
|
Binary file (4.07 kB). View file
|
|
|
src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-310.pyc
ADDED
|
Binary file (3.05 kB). View file
|
|
|
src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-313.pyc
ADDED
|
Binary file (4.24 kB). View file
|
|
|
src/credentialwatch_agent/agents/common.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import TypedDict, List, Optional, Any, Dict, Annotated
|
| 2 |
+
import operator
|
| 3 |
+
|
| 4 |
+
class AgentState(TypedDict):
|
| 5 |
+
"""
|
| 6 |
+
Common state for agents.
|
| 7 |
+
"""
|
| 8 |
+
messages: List[Dict[str, Any]]
|
| 9 |
+
# Add other common fields if needed
|
| 10 |
+
|
| 11 |
+
class ExpirySweepState(TypedDict):
|
| 12 |
+
"""
|
| 13 |
+
State for the expiry sweep graph.
|
| 14 |
+
"""
|
| 15 |
+
providers: List[Dict[str, Any]]
|
| 16 |
+
alerts_created: int
|
| 17 |
+
errors: List[str]
|
| 18 |
+
summary: str
|
| 19 |
+
window_days: int
|
| 20 |
+
|
| 21 |
+
def merge_dicts(a: Dict, b: Dict) -> Dict:
|
| 22 |
+
return {**a, **b}
|
src/credentialwatch_agent/agents/expiry_sweep.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any, List
|
| 2 |
+
from langgraph.graph import StateGraph, END
|
| 3 |
+
from credentialwatch_agent.agents.common import ExpirySweepState
|
| 4 |
+
from credentialwatch_agent.mcp_client import mcp_client
|
| 5 |
+
|
| 6 |
+
async def fetch_expiring_credentials(state: ExpirySweepState) -> Dict[str, Any]:
|
| 7 |
+
"""
|
| 8 |
+
Fetches expiring credentials from the Credential DB MCP.
|
| 9 |
+
"""
|
| 10 |
+
print("Fetching expiring credentials...")
|
| 11 |
+
# We check for a window defined in state or default to 90.
|
| 12 |
+
window_days = state.get("window_days", 90)
|
| 13 |
+
result = await mcp_client.call_tool(
|
| 14 |
+
"cred_db",
|
| 15 |
+
"list_expiring_credentials",
|
| 16 |
+
{"window_days": window_days}
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# Handle mock/real response structure
|
| 20 |
+
expiring_items = result.get("expiring", []) if isinstance(result, dict) else []
|
| 21 |
+
|
| 22 |
+
return {"providers": expiring_items}
|
| 23 |
+
|
| 24 |
+
async def create_alerts(state: ExpirySweepState) -> Dict[str, Any]:
|
| 25 |
+
"""
|
| 26 |
+
Creates alerts for the expiring credentials found.
|
| 27 |
+
"""
|
| 28 |
+
expiring_items = state.get("providers", [])
|
| 29 |
+
alerts_count = 0
|
| 30 |
+
errors = []
|
| 31 |
+
|
| 32 |
+
print(f"Found {len(expiring_items)} expiring items. Creating alerts...")
|
| 33 |
+
|
| 34 |
+
for item in expiring_items:
|
| 35 |
+
try:
|
| 36 |
+
# Determine severity based on days_remaining
|
| 37 |
+
days = item.get("days_remaining", 90)
|
| 38 |
+
severity = "low"
|
| 39 |
+
if days <= 30:
|
| 40 |
+
severity = "critical"
|
| 41 |
+
elif days <= 60:
|
| 42 |
+
severity = "high"
|
| 43 |
+
elif days <= 90:
|
| 44 |
+
severity = "medium"
|
| 45 |
+
|
| 46 |
+
provider_id = item.get("provider_id")
|
| 47 |
+
credential_id = item.get("credential_id", "unknown") # Fallback if not provided in list
|
| 48 |
+
message = f"Credential {item.get('credential')} for {item.get('name')} expires in {days} days."
|
| 49 |
+
|
| 50 |
+
await mcp_client.call_tool(
|
| 51 |
+
"alert",
|
| 52 |
+
"log_alert",
|
| 53 |
+
{
|
| 54 |
+
"provider_id": provider_id,
|
| 55 |
+
"credential_id": credential_id,
|
| 56 |
+
"severity": severity,
|
| 57 |
+
"message": message
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
alerts_count += 1
|
| 61 |
+
except Exception as e:
|
| 62 |
+
errors.append(f"Failed to create alert for {item}: {e}")
|
| 63 |
+
|
| 64 |
+
return {"alerts_created": alerts_count, "errors": errors}
|
| 65 |
+
|
| 66 |
+
async def summarize_sweep(state: ExpirySweepState) -> Dict[str, Any]:
|
| 67 |
+
"""
|
| 68 |
+
Summarizes the sweep results.
|
| 69 |
+
"""
|
| 70 |
+
count = len(state.get("providers", []))
|
| 71 |
+
alerts = state.get("alerts_created", 0)
|
| 72 |
+
errors = state.get("errors", [])
|
| 73 |
+
|
| 74 |
+
summary = f"Sweep completed. Scanned {count} expiring items. Created {alerts} alerts."
|
| 75 |
+
if errors:
|
| 76 |
+
summary += f" Encountered {len(errors)} errors."
|
| 77 |
+
|
| 78 |
+
return {"summary": summary}
|
| 79 |
+
|
| 80 |
+
# Build the graph
|
| 81 |
+
workflow = StateGraph(ExpirySweepState)
|
| 82 |
+
|
| 83 |
+
workflow.add_node("fetch_expiring_credentials", fetch_expiring_credentials)
|
| 84 |
+
workflow.add_node("create_alerts", create_alerts)
|
| 85 |
+
workflow.add_node("summarize_sweep", summarize_sweep)
|
| 86 |
+
|
| 87 |
+
workflow.set_entry_point("fetch_expiring_credentials")
|
| 88 |
+
|
| 89 |
+
workflow.add_edge("fetch_expiring_credentials", "create_alerts")
|
| 90 |
+
workflow.add_edge("create_alerts", "summarize_sweep")
|
| 91 |
+
workflow.add_edge("summarize_sweep", END)
|
| 92 |
+
|
| 93 |
+
expiry_sweep_graph = workflow.compile()
|
src/credentialwatch_agent/agents/interactive_query.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Annotated, Literal, TypedDict, List
|
| 2 |
+
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from langchain_openai import ChatOpenAI
|
| 5 |
+
from langgraph.graph import StateGraph, END
|
| 6 |
+
from langgraph.prebuilt import ToolNode
|
| 7 |
+
from credentialwatch_agent.mcp_client import mcp_client
|
| 8 |
+
from credentialwatch_agent.agents.common import AgentState
|
| 9 |
+
|
| 10 |
+
# --- Tool Definitions ---
|
| 11 |
+
|
| 12 |
+
@tool
|
| 13 |
+
async def search_providers(query: str, state: str = None, taxonomy: str = None):
|
| 14 |
+
"""
|
| 15 |
+
Search for healthcare providers by name, state, or taxonomy.
|
| 16 |
+
Useful for finding a provider's NPI or internal ID.
|
| 17 |
+
"""
|
| 18 |
+
return await mcp_client.call_tool("npi", "search_providers", {"query": query, "state": state, "taxonomy": taxonomy})
|
| 19 |
+
|
| 20 |
+
@tool
|
| 21 |
+
async def get_provider_by_npi(npi: str):
|
| 22 |
+
"""
|
| 23 |
+
Get provider details using their NPI number.
|
| 24 |
+
"""
|
| 25 |
+
return await mcp_client.call_tool("npi", "get_provider_by_npi", {"npi": npi})
|
| 26 |
+
|
| 27 |
+
@tool
|
| 28 |
+
async def list_expiring_credentials(window_days: int = 90):
|
| 29 |
+
"""
|
| 30 |
+
List credentials expiring within the specified number of days.
|
| 31 |
+
"""
|
| 32 |
+
return await mcp_client.call_tool("cred_db", "list_expiring_credentials", {"window_days": window_days})
|
| 33 |
+
|
| 34 |
+
@tool
|
| 35 |
+
async def get_provider_snapshot(provider_id: int = None, npi: str = None):
|
| 36 |
+
"""
|
| 37 |
+
Get a comprehensive snapshot of a provider's credentials and status.
|
| 38 |
+
Provide either provider_id or npi.
|
| 39 |
+
"""
|
| 40 |
+
return await mcp_client.call_tool("cred_db", "get_provider_snapshot", {"provider_id": provider_id, "npi": npi})
|
| 41 |
+
|
| 42 |
+
@tool
|
| 43 |
+
async def get_open_alerts():
|
| 44 |
+
"""
|
| 45 |
+
Get a list of all currently open alerts.
|
| 46 |
+
"""
|
| 47 |
+
return await mcp_client.call_tool("alert", "get_open_alerts", {})
|
| 48 |
+
|
| 49 |
+
tools = [
|
| 50 |
+
search_providers,
|
| 51 |
+
get_provider_by_npi,
|
| 52 |
+
list_expiring_credentials,
|
| 53 |
+
get_provider_snapshot,
|
| 54 |
+
get_open_alerts
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
# --- Graph Definition ---
|
| 58 |
+
|
| 59 |
+
# We can use the prebuilt AgentState or our custom one.
|
| 60 |
+
# For simplicity, we'll use a state compatible with ToolNode (requires 'messages').
|
| 61 |
+
|
| 62 |
+
async def agent_node(state: AgentState):
|
| 63 |
+
"""
|
| 64 |
+
Invokes the LLM to decide the next step.
|
| 65 |
+
"""
|
| 66 |
+
messages = state["messages"]
|
| 67 |
+
model = ChatOpenAI(model="gpt-5.1", temperature=0) # Using gpt-5.1 as requested
|
| 68 |
+
# Note: User requested GPT-5.1. I should probably use the model name string they asked for if it's supported,
|
| 69 |
+
# or fallback to a standard one. I'll use "gpt-4o" as a safe high-quality default for now,
|
| 70 |
+
# or "gpt-5.1-preview" if I want to be cheeky, but let's stick to "gpt-4o" to ensure it works.
|
| 71 |
+
# Actually, the user said "LLM: OpenAI GPT-5.1". I should try to respect that string if possible,
|
| 72 |
+
# but I'll use "gpt-4o" and add a comment.
|
| 73 |
+
|
| 74 |
+
model_with_tools = model.bind_tools(tools)
|
| 75 |
+
response = await model_with_tools.ainvoke(messages)
|
| 76 |
+
return {"messages": [response]}
|
| 77 |
+
|
| 78 |
+
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
|
| 79 |
+
"""
|
| 80 |
+
Determines if the agent should continue to tools or end.
|
| 81 |
+
"""
|
| 82 |
+
messages = state["messages"]
|
| 83 |
+
last_message = messages[-1]
|
| 84 |
+
if isinstance(last_message, AIMessage) and last_message.tool_calls:
|
| 85 |
+
return "tools"
|
| 86 |
+
return "__end__"
|
| 87 |
+
|
| 88 |
+
workflow = StateGraph(AgentState)
|
| 89 |
+
|
| 90 |
+
workflow.add_node("agent", agent_node)
|
| 91 |
+
workflow.add_node("tools", ToolNode(tools))
|
| 92 |
+
|
| 93 |
+
workflow.set_entry_point("agent")
|
| 94 |
+
|
| 95 |
+
workflow.add_conditional_edges(
|
| 96 |
+
"agent",
|
| 97 |
+
should_continue,
|
| 98 |
+
)
|
| 99 |
+
workflow.add_edge("tools", "agent")
|
| 100 |
+
|
| 101 |
+
interactive_query_graph = workflow.compile()
|
src/credentialwatch_agent/main.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
from typing import Dict, Any, List
|
| 4 |
+
import gradio as gr
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 7 |
+
|
| 8 |
+
from credentialwatch_agent.mcp_client import mcp_client
|
| 9 |
+
from credentialwatch_agent.agents.expiry_sweep import expiry_sweep_graph
|
| 10 |
+
from credentialwatch_agent.agents.interactive_query import interactive_query_graph
|
| 11 |
+
|
| 12 |
+
# Load environment variables
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
async def run_expiry_sweep(window_days: int = 90) -> Dict[str, Any]:
|
| 16 |
+
"""
|
| 17 |
+
Runs the expiry sweep workflow.
|
| 18 |
+
"""
|
| 19 |
+
print(f"Starting expiry sweep for {window_days} days...")
|
| 20 |
+
# Initialize state
|
| 21 |
+
initial_state = {
|
| 22 |
+
"providers": [],
|
| 23 |
+
"alerts_created": 0,
|
| 24 |
+
"errors": [],
|
| 25 |
+
"summary": "",
|
| 26 |
+
"window_days": window_days
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# Run the graph
|
| 30 |
+
# Note: The graph expects to fetch data itself, so initial state can be minimal.
|
| 31 |
+
# We might want to pass window_days if the graph supported dynamic config in state,
|
| 32 |
+
# but for now the graph hardcodes 90 or uses tool defaults.
|
| 33 |
+
# To make it dynamic, we'd need to update the graph to read from state.
|
| 34 |
+
# For this hackathon, we'll assume the graph handles it or we pass it via a modified state if needed.
|
| 35 |
+
# The current implementation of fetch_expiring_credentials uses a hardcoded 90 or tool default.
|
| 36 |
+
|
| 37 |
+
final_state = await expiry_sweep_graph.ainvoke(initial_state)
|
| 38 |
+
return {
|
| 39 |
+
"summary": final_state.get("summary"),
|
| 40 |
+
"alerts_created": final_state.get("alerts_created"),
|
| 41 |
+
"errors": final_state.get("errors")
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
async def run_chat_turn(message: str, history: List[List[str]]) -> str:
|
| 45 |
+
"""
|
| 46 |
+
Runs a turn of the interactive query agent.
|
| 47 |
+
"""
|
| 48 |
+
# Convert history to LangChain format
|
| 49 |
+
messages = []
|
| 50 |
+
for human, ai in history:
|
| 51 |
+
messages.append(HumanMessage(content=human))
|
| 52 |
+
messages.append(AIMessage(content=ai))
|
| 53 |
+
messages.append(HumanMessage(content=message))
|
| 54 |
+
|
| 55 |
+
initial_state = {"messages": messages}
|
| 56 |
+
|
| 57 |
+
# Run the graph
|
| 58 |
+
final_state = await interactive_query_graph.ainvoke(initial_state)
|
| 59 |
+
|
| 60 |
+
# Extract the last message
|
| 61 |
+
last_message = final_state["messages"][-1]
|
| 62 |
+
return last_message.content
|
| 63 |
+
|
| 64 |
+
# --- Gradio UI ---
|
| 65 |
+
|
| 66 |
+
async def start_app():
|
| 67 |
+
"""Initializes the app and connects to MCP servers."""
|
| 68 |
+
print("Connecting to MCP servers...")
|
| 69 |
+
await mcp_client.connect()
|
| 70 |
+
|
| 71 |
+
async def stop_app():
|
| 72 |
+
"""Closes connections."""
|
| 73 |
+
print("Closing MCP connections...")
|
| 74 |
+
await mcp_client.close()
|
| 75 |
+
|
| 76 |
+
with gr.Blocks(title="CredentialWatch") as demo:
|
| 77 |
+
gr.Markdown("# CredentialWatch Agent System")
|
| 78 |
+
|
| 79 |
+
with gr.Tab("Interactive Query"):
|
| 80 |
+
gr.Markdown("Ask questions about provider credentials, e.g., 'Who has expiring licenses?'")
|
| 81 |
+
chat_interface = gr.ChatInterface(fn=run_chat_turn)
|
| 82 |
+
|
| 83 |
+
with gr.Tab("Expiry Sweep"):
|
| 84 |
+
gr.Markdown("Run a batch sweep to check for expiring credentials and create alerts.")
|
| 85 |
+
with gr.Row():
|
| 86 |
+
sweep_btn = gr.Button("Run Sweep", variant="primary")
|
| 87 |
+
|
| 88 |
+
sweep_output = gr.JSON(label="Sweep Results")
|
| 89 |
+
|
| 90 |
+
sweep_btn.click(fn=run_expiry_sweep, inputs=[], outputs=[sweep_output])
|
| 91 |
+
|
| 92 |
+
# Startup/Shutdown hooks
|
| 93 |
+
# Gradio doesn't have native async startup hooks easily exposed in Blocks without mounting to FastAPI.
|
| 94 |
+
# But we can run the connect logic when the script starts if we run it via `uv run`.
|
| 95 |
+
# For a proper app, we'd use lifespan events in FastAPI.
|
| 96 |
+
# Here, we will just connect globally on import or first use if possible,
|
| 97 |
+
# or use a startup event if we were using `gr.mount_gradio_app`.
|
| 98 |
+
# For simplicity in this script, we'll rely on the global mcp_client.connect() being called
|
| 99 |
+
# or we can wrap the demo launch.
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
# Simple async wrapper to run the app
|
| 103 |
+
loop = asyncio.new_event_loop()
|
| 104 |
+
asyncio.set_event_loop(loop)
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
loop.run_until_complete(mcp_client.connect())
|
| 108 |
+
demo.launch(server_name="0.0.0.0", server_port=7860)
|
| 109 |
+
except KeyboardInterrupt:
|
| 110 |
+
pass
|
| 111 |
+
finally:
|
| 112 |
+
loop.run_until_complete(mcp_client.close())
|
src/credentialwatch_agent/mcp_client.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
from contextlib import AsyncExitStack
|
| 5 |
+
|
| 6 |
+
from mcp import ClientSession, StdioServerParameters
|
| 7 |
+
from mcp.client.sse import sse_client
|
| 8 |
+
from mcp.client.stdio import stdio_client
|
| 9 |
+
|
| 10 |
+
class MCPClient:
|
| 11 |
+
"""
|
| 12 |
+
Abstraction for calling MCP tools from multiple servers.
|
| 13 |
+
Manages connections to NPI, Credential DB, and Alert MCP servers.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.npi_url = os.getenv("NPI_MCP_URL", "http://localhost:8001/sse")
|
| 18 |
+
self.cred_db_url = os.getenv("CRED_DB_MCP_URL", "http://localhost:8002/sse")
|
| 19 |
+
self.alert_url = os.getenv("ALERT_MCP_URL", "http://localhost:8003/sse")
|
| 20 |
+
|
| 21 |
+
self._exit_stack = AsyncExitStack()
|
| 22 |
+
self._sessions: Dict[str, ClientSession] = {}
|
| 23 |
+
|
| 24 |
+
async def connect(self):
|
| 25 |
+
"""Establishes connections to all MCP servers."""
|
| 26 |
+
# In a real app, we might want to connect lazily or in parallel.
|
| 27 |
+
# For simplicity, we'll try to connect to all.
|
| 28 |
+
|
| 29 |
+
# Connect to NPI MCP
|
| 30 |
+
try:
|
| 31 |
+
# Note: mcp.client.sse.sse_client is a context manager that yields (read_stream, write_stream)
|
| 32 |
+
# We need to keep the context open.
|
| 33 |
+
npi_transport = await self._exit_stack.enter_async_context(sse_client(self.npi_url))
|
| 34 |
+
self._sessions["npi"] = await self._exit_stack.enter_async_context(
|
| 35 |
+
ClientSession(npi_transport[0], npi_transport[1])
|
| 36 |
+
)
|
| 37 |
+
await self._sessions["npi"].initialize()
|
| 38 |
+
except Exception as e:
|
| 39 |
+
print(f"Failed to connect to NPI MCP at {self.npi_url}: {e}")
|
| 40 |
+
|
| 41 |
+
# Connect to Cred DB MCP
|
| 42 |
+
try:
|
| 43 |
+
cred_transport = await self._exit_stack.enter_async_context(sse_client(self.cred_db_url))
|
| 44 |
+
self._sessions["cred_db"] = await self._exit_stack.enter_async_context(
|
| 45 |
+
ClientSession(cred_transport[0], cred_transport[1])
|
| 46 |
+
)
|
| 47 |
+
await self._sessions["cred_db"].initialize()
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"Failed to connect to Cred DB MCP at {self.cred_db_url}: {e}")
|
| 50 |
+
|
| 51 |
+
# Connect to Alert MCP
|
| 52 |
+
try:
|
| 53 |
+
alert_transport = await self._exit_stack.enter_async_context(sse_client(self.alert_url))
|
| 54 |
+
self._sessions["alert"] = await self._exit_stack.enter_async_context(
|
| 55 |
+
ClientSession(alert_transport[0], alert_transport[1])
|
| 56 |
+
)
|
| 57 |
+
await self._sessions["alert"].initialize()
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"Failed to connect to Alert MCP at {self.alert_url}: {e}")
|
| 60 |
+
|
| 61 |
+
async def close(self):
|
| 62 |
+
"""Closes all connections."""
|
| 63 |
+
await self._exit_stack.aclose()
|
| 64 |
+
|
| 65 |
+
async def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
| 66 |
+
"""Calls a tool on a specific MCP server."""
|
| 67 |
+
session = self._sessions.get(server_name)
|
| 68 |
+
if not session:
|
| 69 |
+
# Fallback for testing/mocking if connection failed
|
| 70 |
+
print(f"Warning: No active session for {server_name}. Returning mock data.")
|
| 71 |
+
return self._get_mock_response(server_name, tool_name, arguments)
|
| 72 |
+
|
| 73 |
+
result = await session.call_tool(tool_name, arguments)
|
| 74 |
+
return result
|
| 75 |
+
|
| 76 |
+
def _get_mock_response(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
| 77 |
+
"""Returns mock data when MCP server is unavailable."""
|
| 78 |
+
if server_name == "npi":
|
| 79 |
+
if tool_name == "search_providers":
|
| 80 |
+
return {"providers": [{"npi": "1234567890", "name": "Dr. Jane Doe", "taxonomy": "Cardiology"}]}
|
| 81 |
+
if tool_name == "get_provider_by_npi":
|
| 82 |
+
return {"npi": arguments.get("npi"), "name": "Dr. Jane Doe", "licenses": []}
|
| 83 |
+
|
| 84 |
+
if server_name == "cred_db":
|
| 85 |
+
if tool_name == "list_expiring_credentials":
|
| 86 |
+
return {"expiring": [{"provider_id": 1, "name": "Dr. Jane Doe", "credential": "Medical License", "days_remaining": 25}]}
|
| 87 |
+
if tool_name == "get_provider_snapshot":
|
| 88 |
+
return {"name": "Dr. Jane Doe", "status": "Active", "credentials": []}
|
| 89 |
+
|
| 90 |
+
if server_name == "alert":
|
| 91 |
+
if tool_name == "log_alert":
|
| 92 |
+
return {"success": True, "alert_id": 101}
|
| 93 |
+
if tool_name == "get_open_alerts":
|
| 94 |
+
return {"alerts": []}
|
| 95 |
+
|
| 96 |
+
return {"error": "Mock data not found for this tool"}
|
| 97 |
+
|
| 98 |
+
# Global instance
|
| 99 |
+
mcp_client = MCPClient()
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|