Upload folder using huggingface_hub
Browse files- README.md +13 -210
- pyproject.toml +6 -8
- run.py +23 -203
- src/_mcpservers/mcp-finance/server.py +808 -0
- src/_mcpservers/mcp-news/server.py +214 -0
- src/_mcpservers/mcp-web-search/server.py +172 -0
- src/nexus/Dockerfile +35 -0
- src/nexus/README.md +26 -0
- src/nexus/app.py +356 -0
- src/nexus/content_guardrail.py +65 -0
- src/nexus/model_factory.py +190 -0
- src/nexus/orchestrator.py +147 -0
- src/nexus/prompts/economic_news.txt +27 -0
- src/nexus/prompts/entertainment_updates.txt +26 -0
- src/nexus/prompts/india_news.txt +26 -0
- src/nexus/prompts/market_sentiment.txt +34 -0
- src/nexus/prompts/news_headlines.txt +28 -0
- src/nexus/prompts/odia_news.txt +26 -0
- src/nexus/prompts/trade_recommendation.txt +40 -0
- src/nexus/prompts/upcoming_earnings.txt +27 -0
- src/nexus/specialists.py +197 -0
- src/nexus/tracing.py +198 -0
README.md
CHANGED
|
@@ -1,223 +1,26 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: pink
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
sdk_version: "0.0.1"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
| 11 |
-
|
| 12 |
-
- text-generation
|
| 13 |
-
- agentic-ai
|
| 14 |
-
- openai-sdk
|
| 15 |
-
short_description: An Experimental Agentic Chatbot (uses OpenAI Agent SDK)
|
| 16 |
---
|
| 17 |
|
| 18 |
-
#
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
-
##
|
| 23 |
-
- Predefined prompts for quick analysis
|
| 24 |
-
- Chat interface with AI responses
|
| 25 |
-
- Enter key support and responsive design
|
| 26 |
-
- Latest messages appear on top
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
|
| 33 |
-
##
|
| 34 |
-
- OpenAI
|
| 35 |
-
- Google
|
| 36 |
-
- GROQ
|
| 37 |
-
- SERPER
|
| 38 |
-
- News API
|
| 39 |
|
| 40 |
-
|
| 41 |
-
- Make sure your API keys are configured in the Space secrets
|
| 42 |
-
- Built using Streamlit and deployed as a Docker Space
|
| 43 |
-
|
| 44 |
-
## References
|
| 45 |
-
|
| 46 |
-
https://openai.github.io/openai-agents-python/
|
| 47 |
-
|
| 48 |
-
https://github.com/openai/openai-agents-python/tree/main/examples/mcp
|
| 49 |
-
|
| 50 |
-
## Project Folder Structure
|
| 51 |
-
|
| 52 |
-
```
|
| 53 |
-
chatbot/
|
| 54 |
-
├── app.py # Main Streamlit chatbot interface
|
| 55 |
-
├── appagents/
|
| 56 |
-
│ ├── __init__.py # Package initialization
|
| 57 |
-
│ ├── OrchestratorAgent.py # Main orchestrator - coordinates all agents
|
| 58 |
-
│ ├── FinancialAgent.py # Financial data and analysis agent
|
| 59 |
-
│ ├── NewsAgent.py # News retrieval and summarization agent
|
| 60 |
-
│ ├── SearchAgent.py # General web search agent
|
| 61 |
-
│ └── InputValidationAgent.py # Input validation and sanitization agent
|
| 62 |
-
├── core/
|
| 63 |
-
│ ├── __init__.py # Package initialization
|
| 64 |
-
│ └── logger.py # Centralized logging configuration
|
| 65 |
-
├── tools/
|
| 66 |
-
│ ├── __init__.py # Package initialization
|
| 67 |
-
│ ├── google_tools.py # Google search API wrapper
|
| 68 |
-
│ ├── yahoo_tools.py # Yahoo Finance API wrapper
|
| 69 |
-
│ ├── news_tools.py # News API wrapper
|
| 70 |
-
│ └── time_tools.py # Time-related utility functions
|
| 71 |
-
├── prompts/
|
| 72 |
-
│ ├── economic_news.txt # Prompt for economic news analysis
|
| 73 |
-
│ ├── market_sentiment.txt # Prompt for market sentiment analysis
|
| 74 |
-
│ ├── news_headlines.txt # Prompt for news headline summarization
|
| 75 |
-
│ ├── trade_recommendation.txt # Prompt for trade recommendations
|
| 76 |
-
│ └── upcoming_earnings.txt # Prompt for upcoming earnings alerts
|
| 77 |
-
├── Dockerfile # Docker configuration for container deployment
|
| 78 |
-
├── pyproject.toml # Project metadata and dependencies (copied from root)
|
| 79 |
-
├── uv.lock # Locked dependency versions (copied from root)
|
| 80 |
-
├── README.md # Project documentation
|
| 81 |
-
└── run.py # Script to run the application locally
|
| 82 |
-
```
|
| 83 |
-
|
| 84 |
-
## File Descriptions
|
| 85 |
-
|
| 86 |
-
### UI Layer
|
| 87 |
-
- **app.py** - Main Streamlit chatbot interface that provides:
|
| 88 |
-
- Chat message display with user and AI messages
|
| 89 |
-
- Text input for user queries
|
| 90 |
-
- Predefined prompt buttons for quick analysis
|
| 91 |
-
- Real-time AI responses
|
| 92 |
-
- Support for Enter key submission
|
| 93 |
-
- Responsive design with latest messages appearing first
|
| 94 |
-
|
| 95 |
-
### Agents (`appagents/`)
|
| 96 |
-
- **OrchestratorAgent.py** - Main orchestrator that:
|
| 97 |
-
- Coordinates communication between all specialized agents
|
| 98 |
-
- Routes user queries to appropriate agents
|
| 99 |
-
- Manages conversation flow and context
|
| 100 |
-
- Integrates tool responses
|
| 101 |
-
|
| 102 |
-
- **FinancialAgent.py** - Financial data and analysis:
|
| 103 |
-
- Retrieves stock prices and financial metrics
|
| 104 |
-
- Performs financial analysis using Yahoo Finance API
|
| 105 |
-
- Provides market insights and recommendations
|
| 106 |
-
- Integrates with yahoo_tools for data fetching
|
| 107 |
-
|
| 108 |
-
- **NewsAgent.py** - News retrieval and summarization:
|
| 109 |
-
- Fetches latest news articles
|
| 110 |
-
- Summarizes news content
|
| 111 |
-
- Integrates with News API for real-time updates
|
| 112 |
-
- Provides news-based market insights
|
| 113 |
-
|
| 114 |
-
- **SearchAgent.py** - General web search:
|
| 115 |
-
- Performs web searches for general queries
|
| 116 |
-
- Integrates with Google Search / Serper API
|
| 117 |
-
- Returns relevant search results
|
| 118 |
-
- Supports multi-source data gathering
|
| 119 |
-
|
| 120 |
-
- **InputValidationAgent.py** - Input validation:
|
| 121 |
-
- Sanitizes user input
|
| 122 |
-
- Validates query format and content
|
| 123 |
-
- Prevents malicious inputs
|
| 124 |
-
- Ensures appropriate content
|
| 125 |
-
|
| 126 |
-
### Core Utilities (`core/`)
|
| 127 |
-
- **logger.py** - Centralized logging configuration:
|
| 128 |
-
- Provides consistent logging across agents
|
| 129 |
-
- Handles different log levels
|
| 130 |
-
- Formats log messages for clarity
|
| 131 |
-
|
| 132 |
-
### Tools (`tools/`)
|
| 133 |
-
- **google_tools.py** - Google Search API wrapper:
|
| 134 |
-
- Executes web searches via Google Search / Serper API
|
| 135 |
-
- Parses and returns search results
|
| 136 |
-
- Handles API authentication
|
| 137 |
-
|
| 138 |
-
- **yahoo_tools.py** - Yahoo Finance API integration:
|
| 139 |
-
- Retrieves stock price data
|
| 140 |
-
- Fetches financial metrics and indicators
|
| 141 |
-
- Provides historical price data
|
| 142 |
-
- Returns earnings information
|
| 143 |
-
|
| 144 |
-
- **news_tools.py** - News API integration:
|
| 145 |
-
- Fetches latest news articles
|
| 146 |
-
- Filters news by category and keywords
|
| 147 |
-
- Returns news headlines and summaries
|
| 148 |
-
- Provides market-related news feeds
|
| 149 |
-
|
| 150 |
-
- **time_tools.py** - Time utility functions:
|
| 151 |
-
- Provides current time information
|
| 152 |
-
- Formats timestamps
|
| 153 |
-
- Handles timezone conversions
|
| 154 |
-
|
| 155 |
-
### Prompts (`prompts/`)
|
| 156 |
-
Predefined prompts for specialized analysis:
|
| 157 |
-
- **economic_news.txt** - Analyzes economic news and implications
|
| 158 |
-
- **market_sentiment.txt** - Analyzes market sentiment trends
|
| 159 |
-
- **news_headlines.txt** - Summarizes and explains news headlines
|
| 160 |
-
- **trade_recommendation.txt** - Provides trading recommendations
|
| 161 |
-
- **upcoming_earnings.txt** - Alerts about upcoming earnings reports
|
| 162 |
-
|
| 163 |
-
### Configuration Files
|
| 164 |
-
- **Dockerfile** - Container deployment:
|
| 165 |
-
- Builds Docker image with Python 3.12
|
| 166 |
-
- Installs dependencies using `uv`
|
| 167 |
-
- Sets up Streamlit server on port 8501
|
| 168 |
-
- Configures PYTHONPATH for module imports
|
| 169 |
-
|
| 170 |
-
- **pyproject.toml** - Project metadata:
|
| 171 |
-
- Package name: "agents"
|
| 172 |
-
- Python version requirement: 3.12
|
| 173 |
-
- Lists all dependencies (OpenAI, LangChain, Streamlit, etc.)
|
| 174 |
-
|
| 175 |
-
- **uv.lock** - Dependency lock file:
|
| 176 |
-
- Ensures reproducible builds
|
| 177 |
-
- Pins exact versions of all dependencies
|
| 178 |
-
|
| 179 |
-
## Key Technologies
|
| 180 |
-
|
| 181 |
-
| Component | Technology | Purpose |
|
| 182 |
-
|-----------|-----------|---------|
|
| 183 |
-
| LLM Framework | OpenAI Agents | Multi-agent orchestration |
|
| 184 |
-
| Chat Interface | Streamlit | User interaction and display |
|
| 185 |
-
| Web Search | Google Search / Serper API | Web search results |
|
| 186 |
-
| Financial Data | Yahoo Finance API | Stock prices and metrics |
|
| 187 |
-
| News Data | News API | Latest news articles |
|
| 188 |
-
| Async Operations | AsyncIO | Parallel agent execution |
|
| 189 |
-
| Dependencies | UV | Fast Python package management |
|
| 190 |
-
| Containerization | Docker | Cloud deployment |
|
| 191 |
-
|
| 192 |
-
## Predefined Prompts
|
| 193 |
-
|
| 194 |
-
The chatbot includes quick-access buttons for common analysis:
|
| 195 |
-
|
| 196 |
-
1. **Economic News** - Analyzes current economic trends and news
|
| 197 |
-
2. **Market Sentiment** - Provides market sentiment analysis
|
| 198 |
-
3. **News Headlines** - Summarizes latest news headlines
|
| 199 |
-
4. **Trade Recommendation** - Suggests trading strategies
|
| 200 |
-
5. **Upcoming Earnings** - Lists upcoming company earnings
|
| 201 |
-
|
| 202 |
-
## Running Locally
|
| 203 |
-
|
| 204 |
-
```bash
|
| 205 |
-
# Install dependencies
|
| 206 |
-
uv sync
|
| 207 |
-
|
| 208 |
-
# Set environment variables defined in .env.name file
|
| 209 |
-
export OPENAI_API_KEY="your-key"
|
| 210 |
-
export SERPER_API_KEY="your-key"
|
| 211 |
-
export NEWS_API_KEY="your-key"
|
| 212 |
-
|
| 213 |
-
# Run the Streamlit app (from the root)
|
| 214 |
-
python run.py chatbot
|
| 215 |
-
```
|
| 216 |
-
|
| 217 |
-
## Deployment
|
| 218 |
-
|
| 219 |
-
The project is deployed on Hugging Face Spaces as a Docker container:
|
| 220 |
-
- **Space**: https://huggingface.co/spaces/mishrabp/chatbot-app
|
| 221 |
-
- **URL**: https://mishrabp-chatbot-app.hf.space
|
| 222 |
-
- **Trigger**: Automatic deployment on push to `main` branch
|
| 223 |
-
- **Configuration**: `.github/workflows/chatbot-app-hf.yml`
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Nexus
|
| 3 |
+
emoji: 🔮
|
| 4 |
colorFrom: pink
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: Multi-specialist AI orchestrator for web research
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Nexus
|
| 14 |
|
| 15 |
+
Nexus is a multi-specialist AI research orchestrator. It fans out every query to three dedicated agents — a Financial Markets Analyst, a News Intelligence Specialist, and a Web Research Specialist — then synthesises their reports into a single, coherent answer.
|
| 16 |
|
| 17 |
+
## What it does
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
- **Finance** — stock prices, market sentiment, analyst ratings, earnings, IV analysis, sector screening
|
| 20 |
+
- **News** — breaking headlines, topic-specific articles, category filtering
|
| 21 |
+
- **Web Research** — deep-dive fact-finding with cited sources
|
| 22 |
+
- **Orchestration** — parallel specialist calls, automatic web fallback on failure, content guardrails
|
| 23 |
|
| 24 |
+
## Stack
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
Streamlit · OpenAI Agents SDK · MCP (stdio) · Google Gemini / GPT-4o
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pyproject.toml
CHANGED
|
@@ -138,6 +138,7 @@ dependencies = [
|
|
| 138 |
# =======================
|
| 139 |
# OBSERVABILITY
|
| 140 |
# =======================
|
|
|
|
| 141 |
"openinference-instrumentation-autogen>=0.1.0",
|
| 142 |
"openinference-instrumentation-openai>=0.1.15",
|
| 143 |
"opentelemetry-sdk>=1.20.0",
|
|
@@ -160,6 +161,7 @@ dev = [
|
|
| 160 |
"pytest>=9.0.3",
|
| 161 |
"ipykernel>=7.1.0",
|
| 162 |
"pytest-asyncio",
|
|
|
|
| 163 |
]
|
| 164 |
|
| 165 |
# ============================================================
|
|
@@ -175,20 +177,16 @@ build-backend = "setuptools.build_meta"
|
|
| 175 |
# PACKAGING & DISCOVERY
|
| 176 |
# ============================================================
|
| 177 |
# Tells setuptools where to find the source code.
|
| 178 |
-
# This makes 'common' and 'src' importable when installed (pip install -e .).
|
| 179 |
[tool.setuptools.packages.find]
|
| 180 |
-
where = ["."]
|
| 181 |
-
include = ["
|
| 182 |
|
| 183 |
|
| 184 |
# ============================================================
|
| 185 |
# PYTEST SETTINGS
|
| 186 |
# ============================================================
|
| 187 |
-
# Configures the test runner to automatically find code.
|
| 188 |
[tool.pytest.ini_options]
|
| 189 |
-
|
| 190 |
-
# This allows tests to import modules (e.g., 'import travel_agent')
|
| 191 |
-
# just like the apps do locally, preventing ModuleNotFoundError.
|
| 192 |
-
pythonpath = ["src", "common"]
|
| 193 |
testpaths = ["tests"] # Only look for tests in the 'tests' directory
|
| 194 |
addopts = "-q" # Run in quiet mode (less verbose output)
|
|
|
|
|
|
| 138 |
# =======================
|
| 139 |
# OBSERVABILITY
|
| 140 |
# =======================
|
| 141 |
+
"langfuse>=3.0.0",
|
| 142 |
"openinference-instrumentation-autogen>=0.1.0",
|
| 143 |
"openinference-instrumentation-openai>=0.1.15",
|
| 144 |
"opentelemetry-sdk>=1.20.0",
|
|
|
|
| 161 |
"pytest>=9.0.3",
|
| 162 |
"ipykernel>=7.1.0",
|
| 163 |
"pytest-asyncio",
|
| 164 |
+
"pytest-cov>=7.1.0",
|
| 165 |
]
|
| 166 |
|
| 167 |
# ============================================================
|
|
|
|
| 177 |
# PACKAGING & DISCOVERY
|
| 178 |
# ============================================================
|
| 179 |
# Tells setuptools where to find the source code.
|
|
|
|
| 180 |
[tool.setuptools.packages.find]
|
| 181 |
+
where = ["."]
|
| 182 |
+
include = ["src*"]
|
| 183 |
|
| 184 |
|
| 185 |
# ============================================================
|
| 186 |
# PYTEST SETTINGS
|
| 187 |
# ============================================================
|
|
|
|
| 188 |
[tool.pytest.ini_options]
|
| 189 |
+
pythonpath = ["src"]
|
|
|
|
|
|
|
|
|
|
| 190 |
testpaths = ["tests"] # Only look for tests in the 'tests' directory
|
| 191 |
addopts = "-q" # Run in quiet mode (less verbose output)
|
| 192 |
+
asyncio_mode = "auto" # Auto-detect async tests (requires pytest-asyncio)
|
run.py
CHANGED
|
@@ -29,152 +29,42 @@ load_dotenv(override=True)
|
|
| 29 |
|
| 30 |
# App registry - maps app names to their paths and entry points
|
| 31 |
APP_REGISTRY: Dict[str, Dict[str, str]] = {
|
| 32 |
-
"
|
| 33 |
-
"path": "src/
|
| 34 |
"entry": "app.py",
|
| 35 |
-
"description": "
|
| 36 |
},
|
| 37 |
-
"
|
| 38 |
-
"path": "src/
|
| 39 |
"entry": "app.py",
|
| 40 |
-
"description": "Deep Research
|
| 41 |
},
|
| 42 |
-
"
|
| 43 |
-
"path": "src/
|
| 44 |
"entry": "app.py",
|
| 45 |
-
"description": "
|
| 46 |
},
|
| 47 |
-
"
|
| 48 |
-
"path": "src/
|
| 49 |
"entry": "app.py",
|
| 50 |
-
"description": "Stock Analyst -
|
| 51 |
},
|
| 52 |
-
"
|
| 53 |
-
"path": "src/
|
| 54 |
"entry": "app.py",
|
| 55 |
-
"description": "Travel
|
| 56 |
},
|
| 57 |
-
"
|
| 58 |
-
"path": "src/
|
| 59 |
"entry": "main.py",
|
| 60 |
"type": "fastapi",
|
| 61 |
-
"description": "Trip Planner -
|
| 62 |
},
|
| 63 |
-
"
|
| 64 |
-
"path": "src/
|
| 65 |
-
"entry": "app.py",
|
| 66 |
-
"description": "General Chatbot - Multi-purpose conversational AI"
|
| 67 |
-
},
|
| 68 |
-
"chatbot_v2": {
|
| 69 |
-
"path": "src/chatbot_v2",
|
| 70 |
-
"entry": "app.py",
|
| 71 |
-
"description": "Layered Chatbot (ReAct) - Advanced Architecture"
|
| 72 |
-
},
|
| 73 |
-
"accessibility_v2": {
|
| 74 |
-
"path": "src/accessibility_v2",
|
| 75 |
-
"entry": "app.py",
|
| 76 |
-
"description": "Accessibility Auditor V2 - Layered Architecture"
|
| 77 |
-
},
|
| 78 |
-
"accessibility_v1": {
|
| 79 |
-
"path": "src/accessibility_v1",
|
| 80 |
-
"entry": "app.py",
|
| 81 |
-
"description": "Accessibility Tools - Assistive technology applications"
|
| 82 |
-
},
|
| 83 |
-
"literature-review": {
|
| 84 |
-
"path": "src/literature-review",
|
| 85 |
-
"entry": "app.py",
|
| 86 |
-
"description": "Literature Review Assistant - Multi-agent literature review tool"
|
| 87 |
-
},
|
| 88 |
-
"market-analyst": {
|
| 89 |
-
"path": "src/market-analyst",
|
| 90 |
"entry": "backend/main.py",
|
| 91 |
"type": "fastapi",
|
| 92 |
-
"description": "Market Analyst -
|
| 93 |
-
},
|
| 94 |
-
"image": {
|
| 95 |
-
"path": "src/image-generator",
|
| 96 |
-
"entry": "app.py",
|
| 97 |
-
"description": "Image Generator - Multi-agent image generation tool"
|
| 98 |
-
},
|
| 99 |
-
"interview-assistant": {
|
| 100 |
-
"path": "src/interview-assistant",
|
| 101 |
-
"entry": "app.py",
|
| 102 |
-
"description": "Interview Assistant - Multi-agent interview tool"
|
| 103 |
-
},
|
| 104 |
-
"finadvisor-lg": {
|
| 105 |
-
"path": "src/finadvisor",
|
| 106 |
-
"entry": "app-lg.py",
|
| 107 |
-
"description": "Financial Advisor - Multi-agent financial advisor tool using LangGraph"
|
| 108 |
-
},
|
| 109 |
-
"finadvisor-oai": {
|
| 110 |
-
"path": "src/finadvisor",
|
| 111 |
-
"entry": "app-oai.py",
|
| 112 |
-
"description": "Financial Advisor - Multi-agent financial advisor tool using OpenAI"
|
| 113 |
-
},
|
| 114 |
-
"finadvisor-phi": {
|
| 115 |
-
"path": "src/finadvisor",
|
| 116 |
-
"entry": "app-phi.py",
|
| 117 |
-
"description": "Financial Advisor - Multi-agent financial advisor tool using Phidata"
|
| 118 |
-
}
|
| 119 |
-
,
|
| 120 |
-
"finadvisor-ag": {
|
| 121 |
-
"path": "src/finadvisor",
|
| 122 |
-
"entry": "app-ag.py",
|
| 123 |
-
"description": "Financial Advisor - Multi-agent financial advisor tool using Autogen"
|
| 124 |
-
},
|
| 125 |
-
"mcp-trader": {
|
| 126 |
-
"path": "src/mcp-trader",
|
| 127 |
-
"entry": "server.py",
|
| 128 |
-
"type": "script",
|
| 129 |
-
"description": "Strategies MCP Server - FastMCP server for trading strategies"
|
| 130 |
-
},
|
| 131 |
-
"mcp-web": {
|
| 132 |
-
"path": "src/mcp-web",
|
| 133 |
-
"entry": "server.py",
|
| 134 |
-
"type": "script",
|
| 135 |
-
"description": "Web MCP Server - Search, Extract, Wikipedia, Arxiv"
|
| 136 |
-
},
|
| 137 |
-
"mcp-azure-sre": {
|
| 138 |
-
"path": "src/mcp-azure-sre",
|
| 139 |
-
"entry": "server.py",
|
| 140 |
-
"type": "script",
|
| 141 |
-
"description": "Azure SRE MCP Server - Manage Azure Resources & Monitoring"
|
| 142 |
-
},
|
| 143 |
-
"mcp-rag-secure": {
|
| 144 |
-
"path": "src/mcp-rag-secure",
|
| 145 |
-
"entry": "server.py",
|
| 146 |
-
"type": "script",
|
| 147 |
-
"description": "Secure RAG MCP Server - Multi-tenant knowledge base"
|
| 148 |
-
},
|
| 149 |
-
"mcp-trading-research": {
|
| 150 |
-
"path": "src/mcp-trading-research",
|
| 151 |
-
"entry": "server.py",
|
| 152 |
-
"type": "script",
|
| 153 |
-
"description": "Trading Research MCP Server - News, Insider, Analysts"
|
| 154 |
-
},
|
| 155 |
-
"mcp-github": {
|
| 156 |
-
"path": "src/mcp-github",
|
| 157 |
-
"entry": "server.py",
|
| 158 |
-
"type": "script",
|
| 159 |
-
"description": "GitHub MCP Server - Issues, PRs, Alerts"
|
| 160 |
-
},
|
| 161 |
-
"mcp-seo": {
|
| 162 |
-
"path": "src/mcp-seo",
|
| 163 |
-
"entry": "server.py",
|
| 164 |
-
"type": "script",
|
| 165 |
-
"description": "SEO & ADA MCP Server - Website Audits"
|
| 166 |
-
},
|
| 167 |
-
"github-portal": {
|
| 168 |
-
"path": "src/github-portal",
|
| 169 |
-
"entry": "app.py",
|
| 170 |
-
"type": "streamlit",
|
| 171 |
-
"description": "GitHub Portal - Repository health dashboard for issues, security, and pipelines"
|
| 172 |
-
},
|
| 173 |
-
"mcp-hub": {
|
| 174 |
-
"path": "src/mcp-hub",
|
| 175 |
-
"entry": "package.json",
|
| 176 |
-
"type": "npm",
|
| 177 |
-
"description": "MCP HUB - Discovery and monitoring portal (Vue.js)"
|
| 178 |
},
|
| 179 |
"test": {
|
| 180 |
"path": ".",
|
|
@@ -182,12 +72,6 @@ APP_REGISTRY: Dict[str, Dict[str, str]] = {
|
|
| 182 |
"type": "test",
|
| 183 |
"description": "Run Project Tests - Executes pytest suite"
|
| 184 |
},
|
| 185 |
-
"salesdata": {
|
| 186 |
-
"path": "src/salesdata",
|
| 187 |
-
"entry": "app.py",
|
| 188 |
-
"type": "salesdata",
|
| 189 |
-
"description": "Sales Data Agent - Agentic RAG over sales CSV (FastAPI + Streamlit)"
|
| 190 |
-
}
|
| 191 |
}
|
| 192 |
|
| 193 |
|
|
@@ -272,8 +156,8 @@ def launch_app(app_name: str, port: Optional[int] = None):
|
|
| 272 |
env["PYTHONPATH"] = str(project_root) + os.pathsep + env.get("PYTHONPATH", "")
|
| 273 |
|
| 274 |
# Decoupled App Logic: Build frontend if needed
|
| 275 |
-
if app_name == "
|
| 276 |
-
frontend_dir = project_root / "src/
|
| 277 |
dist_dir = frontend_dir / "dist"
|
| 278 |
if not dist_dir.exists():
|
| 279 |
print("\n🛠️ Frontend build missing. Building now...")
|
|
@@ -334,70 +218,6 @@ def launch_app(app_name: str, port: Optional[int] = None):
|
|
| 334 |
# Change to app directory and run
|
| 335 |
os.chdir(app_dir)
|
| 336 |
|
| 337 |
-
# Special case for mcp-hub: launch backend API first
|
| 338 |
-
if app_name == "mcp-hub":
|
| 339 |
-
print("🚀 Starting MCP Hub Backend API on port 8001...")
|
| 340 |
-
api_cmd = [python_exe, "api.py"]
|
| 341 |
-
subprocess.Popen(api_cmd, env=env, shell=is_windows)
|
| 342 |
-
|
| 343 |
-
# Special case for salesdata: launch FastAPI backend + Streamlit frontend
|
| 344 |
-
if app_name == "salesdata":
|
| 345 |
-
api_port = 8080
|
| 346 |
-
ui_port = port if port else 8501
|
| 347 |
-
|
| 348 |
-
# Aggressively kill any stale processes on both ports
|
| 349 |
-
import platform
|
| 350 |
-
if platform.system() != "Windows":
|
| 351 |
-
for p in [api_port, ui_port]:
|
| 352 |
-
# lsof is more reliable than fuser for finding PIDs by port
|
| 353 |
-
result = subprocess.run(
|
| 354 |
-
["lsof", "-ti", f":{p}"],
|
| 355 |
-
capture_output=True, text=True
|
| 356 |
-
)
|
| 357 |
-
pids = result.stdout.strip().split()
|
| 358 |
-
for pid in pids:
|
| 359 |
-
subprocess.run(["kill", "-9", pid],
|
| 360 |
-
stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
| 361 |
-
time.sleep(1) # let OS release ports
|
| 362 |
-
print(f"🧹 Cleaned up ports {api_port} and {ui_port}")
|
| 363 |
-
|
| 364 |
-
print(f"🚀 Starting Sales Agent API on port {api_port}...")
|
| 365 |
-
api_cmd = [
|
| 366 |
-
python_exe, "-m", "uvicorn", "api:app",
|
| 367 |
-
"--host", "0.0.0.0", "--port", str(api_port)
|
| 368 |
-
]
|
| 369 |
-
subprocess.Popen(api_cmd, env=env, shell=is_windows, cwd=app_dir)
|
| 370 |
-
|
| 371 |
-
# Poll /health until agent is ready (instead of a fixed sleep)
|
| 372 |
-
import urllib.request, urllib.error, json as _json
|
| 373 |
-
health_url = f"http://localhost:{api_port}/health"
|
| 374 |
-
print(f"⏳ Waiting for API + agent to be ready", end="", flush=True)
|
| 375 |
-
timeout = 90 # seconds
|
| 376 |
-
ready = False
|
| 377 |
-
for _ in range(timeout):
|
| 378 |
-
time.sleep(1)
|
| 379 |
-
print(".", end="", flush=True)
|
| 380 |
-
try:
|
| 381 |
-
with urllib.request.urlopen(health_url, timeout=2) as r:
|
| 382 |
-
data = _json.loads(r.read())
|
| 383 |
-
if data.get("agent_ready"):
|
| 384 |
-
ready = True
|
| 385 |
-
break
|
| 386 |
-
except Exception:
|
| 387 |
-
pass
|
| 388 |
-
print()
|
| 389 |
-
if not ready:
|
| 390 |
-
print("⚠️ API did not become ready within 90s — launching UI anyway")
|
| 391 |
-
|
| 392 |
-
print(f"🌐 Starting Streamlit UI on port {ui_port}...")
|
| 393 |
-
ui_cmd = [
|
| 394 |
-
python_exe, "-m", "streamlit", "run", "app.py",
|
| 395 |
-
"--server.port", str(ui_port)
|
| 396 |
-
]
|
| 397 |
-
subprocess.run(ui_cmd, env=env, shell=is_windows)
|
| 398 |
-
return
|
| 399 |
-
|
| 400 |
-
|
| 401 |
|
| 402 |
|
| 403 |
subprocess.run(cmd, env=env, shell=is_windows)
|
|
|
|
| 29 |
|
| 30 |
# App registry - maps app names to their paths and entry points
|
| 31 |
APP_REGISTRY: Dict[str, Dict[str, str]] = {
|
| 32 |
+
"nexus": {
|
| 33 |
+
"path": "src/nexus",
|
| 34 |
"entry": "app.py",
|
| 35 |
+
"description": "Nexus - AI Research Assistant - Multi-specialist orchestrator for finance, news, and web research"
|
| 36 |
},
|
| 37 |
+
"athena": {
|
| 38 |
+
"path": "src/athena",
|
| 39 |
"entry": "app.py",
|
| 40 |
+
"description": "Athena - Deep Research Reporter - Plans, searches, and synthesises comprehensive research reports"
|
| 41 |
},
|
| 42 |
+
"remedy": {
|
| 43 |
+
"path": "src/remedy",
|
| 44 |
"entry": "app.py",
|
| 45 |
+
"description": "Remedy - Healthcare RAG Advisor - Medical information retrieval using RAG and web search"
|
| 46 |
},
|
| 47 |
+
"midas": {
|
| 48 |
+
"path": "src/midas",
|
| 49 |
"entry": "app.py",
|
| 50 |
+
"description": "Midas - Stock Investment Analyst - Multi-agent investment team for technical and sentiment analysis"
|
| 51 |
},
|
| 52 |
+
"odyssey": {
|
| 53 |
+
"path": "src/odyssey",
|
| 54 |
"entry": "app.py",
|
| 55 |
+
"description": "Odyssey - Travel Planner - AI-powered trip planning with flight, hotel, and itinerary recommendations"
|
| 56 |
},
|
| 57 |
+
"waypoint": {
|
| 58 |
+
"path": "src/waypoint",
|
| 59 |
"entry": "main.py",
|
| 60 |
"type": "fastapi",
|
| 61 |
+
"description": "Waypoint - Trip Planner API - Phidata-powered trip itinerary planning REST API"
|
| 62 |
},
|
| 63 |
+
"agora": {
|
| 64 |
+
"path": "src/agora",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
"entry": "backend/main.py",
|
| 66 |
"type": "fastapi",
|
| 67 |
+
"description": "Agora - AI Market Analyst - Real-time multi-agent market analysis with streaming (Vue.js + FastAPI)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
},
|
| 69 |
"test": {
|
| 70 |
"path": ".",
|
|
|
|
| 72 |
"type": "test",
|
| 73 |
"description": "Run Project Tests - Executes pytest suite"
|
| 74 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
|
|
|
|
| 156 |
env["PYTHONPATH"] = str(project_root) + os.pathsep + env.get("PYTHONPATH", "")
|
| 157 |
|
| 158 |
# Decoupled App Logic: Build frontend if needed
|
| 159 |
+
if app_name == "agora":
|
| 160 |
+
frontend_dir = project_root / "src/agora/frontend"
|
| 161 |
dist_dir = frontend_dir / "dist"
|
| 162 |
if not dist_dir.exists():
|
| 163 |
print("\n🛠️ Frontend build missing. Building now...")
|
|
|
|
| 218 |
# Change to app directory and run
|
| 219 |
os.chdir(app_dir)
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
subprocess.run(cmd, env=env, shell=is_windows)
|
src/_mcpservers/mcp-finance/server.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP Finance Server — comprehensive financial data and analysis via Yahoo Finance."""
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import yfinance as yf
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
from mcp.server.fastmcp import FastMCP
|
| 10 |
+
|
| 11 |
+
load_dotenv(Path(__file__).resolve().parents[3] / ".env")
|
| 12 |
+
|
| 13 |
+
# Log to stderr — stdout is reserved for the stdio JSON-RPC channel
|
| 14 |
+
logging.basicConfig(
|
| 15 |
+
stream=sys.stderr,
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format="%(asctime)s [%(levelname)s] mcp-finance: %(message)s",
|
| 18 |
+
)
|
| 19 |
+
log = logging.getLogger("mcp-finance")
|
| 20 |
+
|
| 21 |
+
mcp = FastMCP("Finance MCP", host="0.0.0.0", port=8003)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Predefined large-cap universe used by get_upcoming_earnings / screen
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
_LARGE_CAPS = [
|
| 28 |
+
"AAPL", "MSFT", "NVDA", "GOOGL", "GOOG", "META", "AMZN", "TSLA", "AVGO", "ORCL",
|
| 29 |
+
"ADBE", "CRM", "AMD", "INTC", "QCOM",
|
| 30 |
+
"JPM", "BAC", "WFC", "GS", "MS", "BLK", "C", "AXP", "V", "MA",
|
| 31 |
+
"JNJ", "LLY", "UNH", "PFE", "ABBV", "MRK", "TMO", "ABT", "AMGN",
|
| 32 |
+
"XOM", "CVX", "COP", "SLB",
|
| 33 |
+
"WMT", "HD", "MCD", "NKE", "SBUX", "TGT", "COST", "AMZN",
|
| 34 |
+
"CAT", "BA", "GE", "HON", "RTX", "UPS", "DE",
|
| 35 |
+
"T", "VZ", "DIS", "NFLX", "CMCSA",
|
| 36 |
+
"SPY", "QQQ",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Internal helpers
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
def _date_range(period: str) -> tuple[str, str]:
|
| 45 |
+
"""Convert a period string (e.g. '1mo') into (start_date, end_date) strings."""
|
| 46 |
+
end = datetime.today()
|
| 47 |
+
if period.endswith("d"):
|
| 48 |
+
days = int(period[:-1])
|
| 49 |
+
elif period.endswith("mo"):
|
| 50 |
+
days = int(period[:-2]) * 30
|
| 51 |
+
elif period.endswith("y"):
|
| 52 |
+
days = int(period[:-1]) * 365
|
| 53 |
+
else:
|
| 54 |
+
days = 30
|
| 55 |
+
return (end - timedelta(days=days)).strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _history(symbol: str, period: str):
|
| 59 |
+
"""Fetch a Close-price history DataFrame for the given period."""
|
| 60 |
+
ticker = yf.Ticker(symbol)
|
| 61 |
+
start, end = _date_range(period)
|
| 62 |
+
return ticker.history(start=start, end=end)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _fmt_large(value) -> str:
|
| 66 |
+
"""Format large numbers as $1.23T / $456.78B / $789.01M."""
|
| 67 |
+
if value is None:
|
| 68 |
+
return "N/A"
|
| 69 |
+
if value >= 1e12:
|
| 70 |
+
return f"${value/1e12:.2f}T"
|
| 71 |
+
if value >= 1e9:
|
| 72 |
+
return f"${value/1e9:.2f}B"
|
| 73 |
+
if value >= 1e6:
|
| 74 |
+
return f"${value/1e6:.2f}M"
|
| 75 |
+
return f"${value:,.0f}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ===========================================================================
|
| 79 |
+
# Existing tools
|
| 80 |
+
# ===========================================================================
|
| 81 |
+
|
| 82 |
+
@mcp.tool()
|
| 83 |
+
def get_stock_summary(symbol: str, period: str = "1d", interval: str = "1h") -> str:
|
| 84 |
+
"""
|
| 85 |
+
Fetch the latest price summary for a stock or index.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
symbol: Ticker symbol (e.g. 'AAPL', 'GOOG', 'BTC-USD').
|
| 89 |
+
period: Lookback window — '1d', '5d', '1mo', '3mo' (default '1d').
|
| 90 |
+
interval: Data granularity — '1m', '5m', '1h', '1d' (default '1h').
|
| 91 |
+
"""
|
| 92 |
+
try:
|
| 93 |
+
ticker = yf.Ticker(symbol)
|
| 94 |
+
start, end = _date_range(period)
|
| 95 |
+
data = ticker.history(start=start, end=end, interval=interval)
|
| 96 |
+
if data.empty:
|
| 97 |
+
return f"No data found for '{symbol}'."
|
| 98 |
+
latest = data.iloc[-1]
|
| 99 |
+
price = round(latest["Close"], 2)
|
| 100 |
+
open_p = round(latest["Open"], 2)
|
| 101 |
+
change = round(price - open_p, 2)
|
| 102 |
+
pct = round((change / open_p) * 100, 2)
|
| 103 |
+
info = ticker.info
|
| 104 |
+
name = info.get("longName", symbol)
|
| 105 |
+
currency = info.get("currency", "USD")
|
| 106 |
+
return (
|
| 107 |
+
f"📈 {name} ({symbol})\n"
|
| 108 |
+
f"Price: {price} {currency} | Change: {change} ({pct}%)\n"
|
| 109 |
+
f"Open: {open_p} High: {round(latest['High'], 2)} Low: {round(latest['Low'], 2)}\n"
|
| 110 |
+
f"Volume: {int(latest['Volume'])} | Period: {period} @ {interval}"
|
| 111 |
+
)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
return f"Error fetching data for '{symbol}': {e}"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@mcp.tool()
|
| 117 |
+
def get_market_sentiment(symbol: str, period: str = "1mo") -> str:
|
| 118 |
+
"""
|
| 119 |
+
Analyse price movement and return a Bullish / Bearish / Neutral verdict.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
symbol: Ticker symbol (e.g. 'AAPL').
|
| 123 |
+
period: Lookback window — '7d', '1mo', '3mo' (default '1mo').
|
| 124 |
+
"""
|
| 125 |
+
try:
|
| 126 |
+
data = _history(symbol, period)
|
| 127 |
+
if data.empty:
|
| 128 |
+
return f"No data for '{symbol}'."
|
| 129 |
+
pct = (data["Close"].iloc[-1] - data["Close"].iloc[0]) / data["Close"].iloc[0] * 100
|
| 130 |
+
verdict = "Bullish" if pct > 2 else ("Bearish" if pct < -2 else "Neutral")
|
| 131 |
+
return f"{symbol} sentiment over {period}: {verdict} ({pct:.2f}% change)"
|
| 132 |
+
except Exception as e:
|
| 133 |
+
return f"Error fetching sentiment for '{symbol}': {e}"
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@mcp.tool()
|
| 137 |
+
def get_price_history(symbol: str, period: str = "1mo") -> str:
|
| 138 |
+
"""
|
| 139 |
+
Fetch historical OHLCV price data for a ticker (last 5 rows).
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
symbol: Ticker symbol (e.g. 'AAPL').
|
| 143 |
+
period: Lookback window — '1d', '5d', '1mo', '3mo', '1y' (default '1mo').
|
| 144 |
+
"""
|
| 145 |
+
try:
|
| 146 |
+
data = _history(symbol, period)
|
| 147 |
+
if data.empty:
|
| 148 |
+
return f"No historical data for '{symbol}'."
|
| 149 |
+
return f"Price history for {symbol} ({period}):\n{data.tail(5).to_string()}"
|
| 150 |
+
except Exception as e:
|
| 151 |
+
return f"Error fetching history for '{symbol}': {e}"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@mcp.tool()
|
| 155 |
+
def get_analyst_recommendations(symbol: str) -> str:
|
| 156 |
+
"""
|
| 157 |
+
Fetch the most recent analyst Buy/Sell/Hold ratings for a ticker.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
symbol: Ticker symbol (e.g. 'AAPL').
|
| 161 |
+
"""
|
| 162 |
+
try:
|
| 163 |
+
ticker = yf.Ticker(symbol)
|
| 164 |
+
recs = ticker.recommendations
|
| 165 |
+
if recs is None or recs.empty:
|
| 166 |
+
return f"No analyst recommendations found for '{symbol}'."
|
| 167 |
+
return f"Analyst recommendations for {symbol}:\n{recs.tail(5).to_string()}"
|
| 168 |
+
except Exception as e:
|
| 169 |
+
return f"Error fetching recommendations for '{symbol}': {e}"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@mcp.tool()
|
| 173 |
+
def get_earnings_calendar(symbol: str) -> str:
|
| 174 |
+
"""
|
| 175 |
+
Fetch the upcoming earnings date for a ticker.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
symbol: Ticker symbol (e.g. 'AAPL').
|
| 179 |
+
"""
|
| 180 |
+
try:
|
| 181 |
+
ticker = yf.Ticker(symbol)
|
| 182 |
+
calendar = ticker.calendar
|
| 183 |
+
if not calendar:
|
| 184 |
+
return f"No earnings calendar found for '{symbol}'."
|
| 185 |
+
return f"Earnings calendar for {symbol}:\n{calendar}"
|
| 186 |
+
except Exception as e:
|
| 187 |
+
return f"Error fetching earnings calendar for '{symbol}': {e}"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ===========================================================================
|
| 191 |
+
# New tools
|
| 192 |
+
# ===========================================================================
|
| 193 |
+
|
| 194 |
+
@mcp.tool()
|
| 195 |
+
def get_valuation_metrics(symbol: str) -> str:
|
| 196 |
+
"""
|
| 197 |
+
Fetch key valuation and fundamental metrics for a stock.
|
| 198 |
+
|
| 199 |
+
Includes market cap, P/E ratios, EPS, revenue, profit margin,
|
| 200 |
+
dividend yield, beta, and 52-week range.
|
| 201 |
+
|
| 202 |
+
Args:
|
| 203 |
+
symbol: Ticker symbol (e.g. 'AAPL', 'MSFT').
|
| 204 |
+
"""
|
| 205 |
+
try:
|
| 206 |
+
info = yf.Ticker(symbol).info
|
| 207 |
+
name = info.get("longName", symbol)
|
| 208 |
+
rows = [
|
| 209 |
+
("Market Cap", _fmt_large(info.get("marketCap"))),
|
| 210 |
+
("Revenue (TTM)", _fmt_large(info.get("totalRevenue"))),
|
| 211 |
+
("P/E Ratio (TTM)", f"{info['trailingPE']:.2f}" if info.get("trailingPE") else "N/A"),
|
| 212 |
+
("Forward P/E", f"{info['forwardPE']:.2f}" if info.get("forwardPE") else "N/A"),
|
| 213 |
+
("P/B Ratio", f"{info['priceToBook']:.2f}" if info.get("priceToBook") else "N/A"),
|
| 214 |
+
("EPS (TTM)", f"${info['trailingEps']:.2f}" if info.get("trailingEps") else "N/A"),
|
| 215 |
+
("Profit Margin", f"{info['profitMargins']*100:.2f}%" if info.get("profitMargins") else "N/A"),
|
| 216 |
+
("Dividend Yield", f"{info['dividendYield']*100:.2f}%" if info.get("dividendYield") else "N/A"),
|
| 217 |
+
("Beta", f"{info['beta']:.2f}" if info.get("beta") else "N/A"),
|
| 218 |
+
("52-Week High", f"${info['fiftyTwoWeekHigh']:.2f}" if info.get("fiftyTwoWeekHigh") else "N/A"),
|
| 219 |
+
("52-Week Low", f"${info['fiftyTwoWeekLow']:.2f}" if info.get("fiftyTwoWeekLow") else "N/A"),
|
| 220 |
+
]
|
| 221 |
+
lines = [f"📊 Valuation Metrics — {name} ({symbol})\n"]
|
| 222 |
+
width = max(len(k) for k, _ in rows)
|
| 223 |
+
for k, v in rows:
|
| 224 |
+
lines.append(f" {k:<{width}} : {v}")
|
| 225 |
+
return "\n".join(lines)
|
| 226 |
+
except Exception as e:
|
| 227 |
+
return f"Error fetching valuation metrics for '{symbol}': {e}"
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@mcp.tool()
|
| 231 |
+
def get_financial_statements(symbol: str, statement: str = "income") -> str:
|
| 232 |
+
"""
|
| 233 |
+
Fetch annual financial statements for a stock.
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
symbol: Ticker symbol (e.g. 'AAPL').
|
| 237 |
+
statement: Which statement to return —
|
| 238 |
+
'income' → Income Statement (revenue, gross profit, net income, EBITDA)
|
| 239 |
+
'balance' → Balance Sheet (assets, liabilities, equity)
|
| 240 |
+
'cashflow' → Cash Flow Statement (operating, investing, financing)
|
| 241 |
+
"""
|
| 242 |
+
try:
|
| 243 |
+
ticker = yf.Ticker(symbol)
|
| 244 |
+
if statement == "income":
|
| 245 |
+
df = ticker.income_stmt
|
| 246 |
+
label = "Income Statement"
|
| 247 |
+
elif statement == "balance":
|
| 248 |
+
df = ticker.balance_sheet
|
| 249 |
+
label = "Balance Sheet"
|
| 250 |
+
elif statement == "cashflow":
|
| 251 |
+
df = ticker.cashflow
|
| 252 |
+
label = "Cash Flow Statement"
|
| 253 |
+
else:
|
| 254 |
+
return (
|
| 255 |
+
f"Unknown statement type '{statement}'. "
|
| 256 |
+
"Use 'income', 'balance', or 'cashflow'."
|
| 257 |
+
)
|
| 258 |
+
if df is None or df.empty:
|
| 259 |
+
return f"No {label} data available for '{symbol}'."
|
| 260 |
+
return f"📋 {label} — {symbol}\n\n{df.head(12).to_string()}"
|
| 261 |
+
except Exception as e:
|
| 262 |
+
return f"Error fetching {statement} statement for '{symbol}': {e}"
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@mcp.tool()
|
| 266 |
+
def get_dividends(symbol: str, years: int = 3) -> str:
|
| 267 |
+
"""
|
| 268 |
+
Fetch dividend payment history and yield for a stock.
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
symbol: Ticker symbol (e.g. 'JNJ', 'KO').
|
| 272 |
+
years: How many years of history to show (default 3).
|
| 273 |
+
"""
|
| 274 |
+
try:
|
| 275 |
+
ticker = yf.Ticker(symbol)
|
| 276 |
+
divs = ticker.dividends
|
| 277 |
+
if divs is None or divs.empty:
|
| 278 |
+
return f"'{symbol}' pays no dividends (or data is unavailable)."
|
| 279 |
+
|
| 280 |
+
# Normalise timezone before date filtering
|
| 281 |
+
idx = divs.index.tz_localize(None) if divs.index.tz is None else divs.index.tz_convert(None)
|
| 282 |
+
cutoff = datetime.now() - timedelta(days=365 * years)
|
| 283 |
+
recent = divs[idx >= cutoff]
|
| 284 |
+
if recent.empty:
|
| 285 |
+
return f"No dividends paid in the last {years} year(s) for '{symbol}'."
|
| 286 |
+
|
| 287 |
+
annual = recent.resample("YE").sum()
|
| 288 |
+
info = ticker.info
|
| 289 |
+
yield_pct = info.get("dividendYield")
|
| 290 |
+
|
| 291 |
+
lines = [f"💰 Dividend History — {symbol} (last {years} year(s))\n"]
|
| 292 |
+
if yield_pct:
|
| 293 |
+
lines.append(f" Current Yield : {yield_pct * 100:.2f}%")
|
| 294 |
+
lines.append(" Annual totals :")
|
| 295 |
+
for date, amount in annual.items():
|
| 296 |
+
lines.append(f" {date.year} : ${amount:.4f}")
|
| 297 |
+
lines.append(
|
| 298 |
+
f"\n Last payment : ${recent.iloc[-1]:.4f} "
|
| 299 |
+
f"on {idx[-1].strftime('%Y-%m-%d')}"
|
| 300 |
+
)
|
| 301 |
+
return "\n".join(lines)
|
| 302 |
+
except Exception as e:
|
| 303 |
+
return f"Error fetching dividends for '{symbol}': {e}"
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
@mcp.tool()
|
| 307 |
+
def get_technical_indicators(symbol: str, period: str = "6mo") -> str:
|
| 308 |
+
"""
|
| 309 |
+
Compute SMA(20/50/200), RSI(14), and MACD(12,26,9) from price history.
|
| 310 |
+
|
| 311 |
+
Args:
|
| 312 |
+
symbol: Ticker symbol (e.g. 'AAPL').
|
| 313 |
+
period: Lookback window for calculations — '3mo', '6mo', '1y', '2y'
|
| 314 |
+
(default '6mo'; use '2y' to get SMA-200).
|
| 315 |
+
"""
|
| 316 |
+
try:
|
| 317 |
+
data = _history(symbol, period)
|
| 318 |
+
if data.empty or len(data) < 20:
|
| 319 |
+
return (
|
| 320 |
+
f"Insufficient price history for '{symbol}'. "
|
| 321 |
+
"Try a longer period (e.g. '1y')."
|
| 322 |
+
)
|
| 323 |
+
close = data["Close"]
|
| 324 |
+
price = round(close.iloc[-1], 2)
|
| 325 |
+
|
| 326 |
+
def _sma(n):
|
| 327 |
+
return round(close.rolling(n).mean().iloc[-1], 2) if len(close) >= n else None
|
| 328 |
+
|
| 329 |
+
sma20, sma50, sma200 = _sma(20), _sma(50), _sma(200)
|
| 330 |
+
|
| 331 |
+
# RSI-14
|
| 332 |
+
delta = close.diff()
|
| 333 |
+
gain = delta.clip(lower=0).rolling(14).mean()
|
| 334 |
+
loss = (-delta.clip(upper=0)).rolling(14).mean()
|
| 335 |
+
rs = gain / loss
|
| 336 |
+
rsi = round((100 - 100 / (1 + rs)).iloc[-1], 2)
|
| 337 |
+
rsi_label = "Overbought" if rsi > 70 else ("Oversold" if rsi < 30 else "Neutral")
|
| 338 |
+
|
| 339 |
+
# MACD (12, 26, 9)
|
| 340 |
+
ema12 = close.ewm(span=12, adjust=False).mean()
|
| 341 |
+
ema26 = close.ewm(span=26, adjust=False).mean()
|
| 342 |
+
macd = ema12 - ema26
|
| 343 |
+
signal = macd.ewm(span=9, adjust=False).mean()
|
| 344 |
+
hist = macd - signal
|
| 345 |
+
macd_label = "Bullish" if hist.iloc[-1] > 0 else "Bearish"
|
| 346 |
+
|
| 347 |
+
def _sma_line(n, val):
|
| 348 |
+
if val is None:
|
| 349 |
+
return f" SMA({n:3d}) : N/A (need more history)"
|
| 350 |
+
arrow = "↑" if price > val else "↓"
|
| 351 |
+
return f" SMA({n:3d}) : ${val} {arrow}"
|
| 352 |
+
|
| 353 |
+
lines = [
|
| 354 |
+
f"📉 Technical Indicators — {symbol} (period: {period})\n",
|
| 355 |
+
f" Price : ${price}",
|
| 356 |
+
_sma_line(20, sma20),
|
| 357 |
+
_sma_line(50, sma50),
|
| 358 |
+
_sma_line(200, sma200),
|
| 359 |
+
f" RSI(14) : {rsi} → {rsi_label}",
|
| 360 |
+
f" MACD line : {round(macd.iloc[-1], 4)}",
|
| 361 |
+
f" Signal : {round(signal.iloc[-1], 4)}",
|
| 362 |
+
f" Histogram : {round(hist.iloc[-1], 4)} → {macd_label} momentum",
|
| 363 |
+
]
|
| 364 |
+
return "\n".join(lines)
|
| 365 |
+
except Exception as e:
|
| 366 |
+
return f"Error computing technical indicators for '{symbol}': {e}"
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@mcp.tool()
|
| 370 |
+
def compare_stocks(symbols: str, period: str = "1y") -> str:
|
| 371 |
+
"""
|
| 372 |
+
Compare the price performance of multiple stocks side-by-side.
|
| 373 |
+
|
| 374 |
+
Args:
|
| 375 |
+
symbols: Comma-separated ticker symbols, e.g. 'AAPL,MSFT,GOOG' (max 6).
|
| 376 |
+
period: Comparison window — '1mo', '3mo', '6mo', '1y', '3y' (default '1y').
|
| 377 |
+
"""
|
| 378 |
+
try:
|
| 379 |
+
tickers = [s.strip().upper() for s in symbols.split(",") if s.strip()]
|
| 380 |
+
if not tickers:
|
| 381 |
+
return "No valid ticker symbols provided."
|
| 382 |
+
if len(tickers) > 6:
|
| 383 |
+
return "Maximum 6 tickers supported for a single comparison."
|
| 384 |
+
|
| 385 |
+
rows = []
|
| 386 |
+
for sym in tickers:
|
| 387 |
+
try:
|
| 388 |
+
data = _history(sym, period)
|
| 389 |
+
if data.empty:
|
| 390 |
+
rows.append((sym, None, None, None, None))
|
| 391 |
+
continue
|
| 392 |
+
close = data["Close"]
|
| 393 |
+
ret = (close.iloc[-1] - close.iloc[0]) / close.iloc[0] * 100
|
| 394 |
+
rows.append((sym, ret, close.iloc[-1], close.max(), close.min()))
|
| 395 |
+
except Exception:
|
| 396 |
+
rows.append((sym, None, None, None, None))
|
| 397 |
+
|
| 398 |
+
lines = [
|
| 399 |
+
f"📊 Stock Comparison — {period}\n",
|
| 400 |
+
f" {'Symbol':<8} {'Return':>8} {'Price':>9} {'52W High':>10} {'52W Low':>9}",
|
| 401 |
+
f" {'-'*57}",
|
| 402 |
+
]
|
| 403 |
+
for sym, ret, price, high, low in rows:
|
| 404 |
+
if ret is None:
|
| 405 |
+
lines.append(f" {sym:<8} {'N/A':>8} {'N/A':>9} {'N/A':>10} {'N/A':>9}")
|
| 406 |
+
else:
|
| 407 |
+
lines.append(
|
| 408 |
+
f" {sym:<8} {ret:>+7.2f}% ${price:>8.2f} ${high:>9.2f} ${low:>8.2f}"
|
| 409 |
+
)
|
| 410 |
+
return "\n".join(lines)
|
| 411 |
+
except Exception as e:
|
| 412 |
+
return f"Error comparing stocks: {e}"
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
@mcp.tool()
|
| 416 |
+
def get_options_chain(symbol: str, expiry_index: int = 0) -> str:
|
| 417 |
+
"""
|
| 418 |
+
Fetch the top calls and puts for a given options expiry date.
|
| 419 |
+
|
| 420 |
+
Args:
|
| 421 |
+
symbol: Ticker symbol (e.g. 'AAPL', 'SPY').
|
| 422 |
+
expiry_index: Which expiry to use — 0 = nearest, 1 = next, etc. (default 0).
|
| 423 |
+
"""
|
| 424 |
+
try:
|
| 425 |
+
ticker = yf.Ticker(symbol)
|
| 426 |
+
expiry_dates = ticker.options
|
| 427 |
+
if not expiry_dates:
|
| 428 |
+
return f"No options data available for '{symbol}'."
|
| 429 |
+
|
| 430 |
+
idx = min(expiry_index, len(expiry_dates) - 1)
|
| 431 |
+
expiry = expiry_dates[idx]
|
| 432 |
+
chain = ticker.option_chain(expiry)
|
| 433 |
+
|
| 434 |
+
def _fmt_contracts(df, kind):
|
| 435 |
+
top = df.sort_values("openInterest", ascending=False).head(5)
|
| 436 |
+
lines = [f"\n{'📗' if kind == 'Calls' else '📕'} Top {kind} (by open interest):"]
|
| 437 |
+
for _, row in top.iterrows():
|
| 438 |
+
iv = row.get("impliedVolatility", 0) * 100
|
| 439 |
+
oi = int(row.get("openInterest", 0))
|
| 440 |
+
lines.append(
|
| 441 |
+
f" Strike ${row['strike']:.2f} | "
|
| 442 |
+
f"Last ${row['lastPrice']:.2f} | "
|
| 443 |
+
f"IV {iv:.1f}% | OI {oi:,}"
|
| 444 |
+
)
|
| 445 |
+
return "\n".join(lines)
|
| 446 |
+
|
| 447 |
+
header = (
|
| 448 |
+
f"⚙️ Options Chain — {symbol} | Expiry: {expiry}\n"
|
| 449 |
+
f"All expiries: {', '.join(expiry_dates[:6])}"
|
| 450 |
+
f"{'…' if len(expiry_dates) > 6 else ''}"
|
| 451 |
+
)
|
| 452 |
+
return header + _fmt_contracts(chain.calls, "Calls") + _fmt_contracts(chain.puts, "Puts")
|
| 453 |
+
except Exception as e:
|
| 454 |
+
return f"Error fetching options chain for '{symbol}': {e}"
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
@mcp.tool()
|
| 458 |
+
def get_institutional_holdings(symbol: str) -> str:
|
| 459 |
+
"""
|
| 460 |
+
Fetch the top institutional holders and ownership breakdown for a stock.
|
| 461 |
+
|
| 462 |
+
Args:
|
| 463 |
+
symbol: Ticker symbol (e.g. 'AAPL', 'TSLA').
|
| 464 |
+
"""
|
| 465 |
+
try:
|
| 466 |
+
ticker = yf.Ticker(symbol)
|
| 467 |
+
major = ticker.major_holders
|
| 468 |
+
inst = ticker.institutional_holders
|
| 469 |
+
|
| 470 |
+
lines = [f"🏦 Institutional Holdings — {symbol}\n"]
|
| 471 |
+
|
| 472 |
+
if major is not None and not major.empty:
|
| 473 |
+
lines.append(" Ownership breakdown:")
|
| 474 |
+
for _, row in major.iterrows():
|
| 475 |
+
lines.append(f" {row.iloc[1]}: {row.iloc[0]}")
|
| 476 |
+
lines.append("")
|
| 477 |
+
|
| 478 |
+
if inst is not None and not inst.empty:
|
| 479 |
+
lines.append(" Top institutional holders:")
|
| 480 |
+
for _, row in inst.head(8).iterrows():
|
| 481 |
+
name = row.get("Holder", str(row.iloc[0]))
|
| 482 |
+
pct = row.get("pctHeld") or row.get("% Out")
|
| 483 |
+
shares = row.get("Shares")
|
| 484 |
+
pct_str = f" ({pct*100:.2f}%)" if pct else ""
|
| 485 |
+
shares_str = f" {int(shares):,} shares" if shares else ""
|
| 486 |
+
lines.append(f" {name}{pct_str}{shares_str}")
|
| 487 |
+
else:
|
| 488 |
+
lines.append(" No institutional holder data available.")
|
| 489 |
+
|
| 490 |
+
return "\n".join(lines)
|
| 491 |
+
except Exception as e:
|
| 492 |
+
return f"Error fetching institutional holdings for '{symbol}': {e}"
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@mcp.tool()
|
| 496 |
+
def get_stock_news(symbol: str, max_results: int = 5) -> str:
|
| 497 |
+
"""
|
| 498 |
+
Fetch recent news articles about a specific stock from Yahoo Finance.
|
| 499 |
+
|
| 500 |
+
Args:
|
| 501 |
+
symbol: Ticker symbol (e.g. 'AAPL', 'TSLA').
|
| 502 |
+
max_results: Number of articles to return, 1–10 (default 5).
|
| 503 |
+
"""
|
| 504 |
+
try:
|
| 505 |
+
ticker = yf.Ticker(symbol)
|
| 506 |
+
news = ticker.news
|
| 507 |
+
if not news:
|
| 508 |
+
return f"No recent news found for '{symbol}'."
|
| 509 |
+
lines = [f"📰 Recent News — {symbol}\n"]
|
| 510 |
+
for item in news[:min(max_results, 10)]:
|
| 511 |
+
pub_ts = item.get("providerPublishTime", 0)
|
| 512 |
+
pub_date = (
|
| 513 |
+
datetime.fromtimestamp(pub_ts).strftime("%Y-%m-%d %H:%M")
|
| 514 |
+
if pub_ts else "N/A"
|
| 515 |
+
)
|
| 516 |
+
lines.append(
|
| 517 |
+
f" 📌 {item.get('title', 'No title')}\n"
|
| 518 |
+
f" {item.get('publisher', 'Unknown')} | {pub_date}\n"
|
| 519 |
+
f" {item.get('link', '')}\n"
|
| 520 |
+
)
|
| 521 |
+
return "\n".join(lines)
|
| 522 |
+
except Exception as e:
|
| 523 |
+
return f"Error fetching news for '{symbol}': {e}"
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
@mcp.tool()
|
| 527 |
+
def current_datetime(format: str = "natural") -> str:
|
| 528 |
+
"""
|
| 529 |
+
Return the current date and time. Call this first to anchor temporal context.
|
| 530 |
+
|
| 531 |
+
Args:
|
| 532 |
+
format: 'natural' → 'Saturday, June 07, 2025 at 3:59 PM'
|
| 533 |
+
'natural_short' → 'Jun 07, 2025 at 3:59 PM'
|
| 534 |
+
Or any strftime format string (e.g. '%Y-%m-%d').
|
| 535 |
+
"""
|
| 536 |
+
now = datetime.now()
|
| 537 |
+
if format == "natural":
|
| 538 |
+
return now.strftime("%A, %B %d, %Y at %I:%M %p")
|
| 539 |
+
if format == "natural_short":
|
| 540 |
+
return now.strftime("%b %d, %Y at %I:%M %p")
|
| 541 |
+
return now.strftime(format)
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
@mcp.tool()
|
| 545 |
+
def get_earnings_estimates(symbol: str) -> str:
|
| 546 |
+
"""
|
| 547 |
+
Get EPS and revenue consensus estimates plus recent historical actuals for a stock.
|
| 548 |
+
|
| 549 |
+
Returns forward EPS estimate, revenue estimate (low/avg/high), trailing EPS,
|
| 550 |
+
and the last four quarters of reported EPS vs estimate with surprise %.
|
| 551 |
+
|
| 552 |
+
Args:
|
| 553 |
+
symbol: Ticker symbol (e.g. 'AAPL', 'MSFT').
|
| 554 |
+
"""
|
| 555 |
+
try:
|
| 556 |
+
ticker = yf.Ticker(symbol)
|
| 557 |
+
info = ticker.info
|
| 558 |
+
name = info.get("longName", symbol)
|
| 559 |
+
|
| 560 |
+
lines = [f"📊 Earnings Estimates — {name} ({symbol})\n"]
|
| 561 |
+
|
| 562 |
+
# Near-term consensus from calendar
|
| 563 |
+
try:
|
| 564 |
+
cal = ticker.calendar or {}
|
| 565 |
+
if cal:
|
| 566 |
+
lines.append(" ── Next Earnings ──")
|
| 567 |
+
earn_date = cal.get("Earnings Date")
|
| 568 |
+
if earn_date:
|
| 569 |
+
dates = earn_date if isinstance(earn_date, list) else [earn_date]
|
| 570 |
+
lines.append(f" Date : {', '.join(str(d)[:10] for d in dates)}")
|
| 571 |
+
for key, label in [
|
| 572 |
+
("Earnings Average", "EPS Estimate (avg)"),
|
| 573 |
+
("Earnings Low", "EPS Estimate (low)"),
|
| 574 |
+
("Earnings High", "EPS Estimate (high)"),
|
| 575 |
+
("Revenue Average", "Revenue Estimate"),
|
| 576 |
+
("Revenue Low", "Revenue Low"),
|
| 577 |
+
("Revenue High", "Revenue High"),
|
| 578 |
+
]:
|
| 579 |
+
val = cal.get(key)
|
| 580 |
+
if val is not None:
|
| 581 |
+
if "Revenue" in key:
|
| 582 |
+
lines.append(f" {label:<20}: {_fmt_large(val)}")
|
| 583 |
+
else:
|
| 584 |
+
lines.append(f" {label:<20}: ${val:.2f}")
|
| 585 |
+
except Exception as e:
|
| 586 |
+
log.warning("calendar fetch failed for %s: %s", symbol, e)
|
| 587 |
+
|
| 588 |
+
# Forward EPS / PE from info
|
| 589 |
+
lines.append("\n ── Analyst Consensus ──")
|
| 590 |
+
for key, label in [
|
| 591 |
+
("forwardEps", "Forward EPS"),
|
| 592 |
+
("trailingEps", "Trailing EPS (TTM)"),
|
| 593 |
+
("forwardPE", "Forward P/E"),
|
| 594 |
+
("trailingPE", "Trailing P/E"),
|
| 595 |
+
]:
|
| 596 |
+
val = info.get(key)
|
| 597 |
+
lines.append(f" {label:<22}: {'N/A' if val is None else f'${val:.2f}' if 'EPS' in label else f'{val:.2f}'}")
|
| 598 |
+
|
| 599 |
+
# Historical quarterly actuals from earnings_dates
|
| 600 |
+
try:
|
| 601 |
+
edates = ticker.earnings_dates
|
| 602 |
+
if edates is not None and not edates.empty:
|
| 603 |
+
past = edates[edates.index < datetime.now().strftime("%Y-%m-%d")].head(4)
|
| 604 |
+
if not past.empty:
|
| 605 |
+
lines.append("\n ── Recent Quarterly Actuals ──")
|
| 606 |
+
for dt, row in past.iterrows():
|
| 607 |
+
est = row.get("EPS Estimate")
|
| 608 |
+
rep = row.get("Reported EPS")
|
| 609 |
+
sur = row.get("Surprise(%)")
|
| 610 |
+
est_s = f"${est:.2f}" if est is not None and est == est else "N/A"
|
| 611 |
+
rep_s = f"${rep:.2f}" if rep is not None and rep == rep else "N/A"
|
| 612 |
+
sur_s = f"{sur:.1f}%" if sur is not None and sur == sur else "N/A"
|
| 613 |
+
lines.append(
|
| 614 |
+
f" {str(dt)[:10]} Est {est_s:<8} Actual {rep_s:<8} Surprise {sur_s}"
|
| 615 |
+
)
|
| 616 |
+
except Exception as e:
|
| 617 |
+
log.warning("earnings_dates fetch failed for %s: %s", symbol, e)
|
| 618 |
+
|
| 619 |
+
return "\n".join(lines)
|
| 620 |
+
except Exception as e:
|
| 621 |
+
log.error("get_earnings_estimates failed for %s: %s", symbol, e)
|
| 622 |
+
return f"Error fetching earnings estimates for '{symbol}': {e}"
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
@mcp.tool()
|
| 626 |
+
def get_upcoming_earnings(tickers: str = "", days_ahead: int = 14) -> str:
|
| 627 |
+
"""
|
| 628 |
+
List upcoming earnings dates for multiple companies within a date window.
|
| 629 |
+
|
| 630 |
+
If no tickers are provided, scans the built-in large-cap universe (~60 names
|
| 631 |
+
covering tech, finance, healthcare, energy, consumer, and industrial sectors).
|
| 632 |
+
|
| 633 |
+
Args:
|
| 634 |
+
tickers: Comma-separated symbols, e.g. 'AAPL,MSFT,GOOG'. Leave blank to
|
| 635 |
+
scan the default large-cap list.
|
| 636 |
+
days_ahead: How many calendar days ahead to look (default 14).
|
| 637 |
+
"""
|
| 638 |
+
try:
|
| 639 |
+
symbols = (
|
| 640 |
+
[s.strip().upper() for s in tickers.split(",") if s.strip()]
|
| 641 |
+
if tickers.strip()
|
| 642 |
+
else _LARGE_CAPS
|
| 643 |
+
)
|
| 644 |
+
today = datetime.today().date()
|
| 645 |
+
cutoff = today + timedelta(days=days_ahead)
|
| 646 |
+
|
| 647 |
+
hits: list[tuple] = []
|
| 648 |
+
for sym in symbols:
|
| 649 |
+
try:
|
| 650 |
+
cal = yf.Ticker(sym).calendar or {}
|
| 651 |
+
earn_date = cal.get("Earnings Date")
|
| 652 |
+
if not earn_date:
|
| 653 |
+
continue
|
| 654 |
+
dates = earn_date if isinstance(earn_date, list) else [earn_date]
|
| 655 |
+
for d in dates:
|
| 656 |
+
d_date = d.date() if hasattr(d, "date") else d
|
| 657 |
+
if today <= d_date <= cutoff:
|
| 658 |
+
eps_est = cal.get("Earnings Average")
|
| 659 |
+
rev_est = cal.get("Revenue Average")
|
| 660 |
+
hits.append((d_date, sym, eps_est, rev_est))
|
| 661 |
+
break
|
| 662 |
+
except Exception as e:
|
| 663 |
+
log.debug("skip %s in upcoming_earnings: %s", sym, e)
|
| 664 |
+
|
| 665 |
+
if not hits:
|
| 666 |
+
return f"No earnings found in the next {days_ahead} days for the scanned tickers."
|
| 667 |
+
|
| 668 |
+
hits.sort()
|
| 669 |
+
lines = [f"📅 Upcoming Earnings — next {days_ahead} days\n"]
|
| 670 |
+
lines.append(f" {'Date':<12} {'Symbol':<8} {'EPS Est':>9} {'Rev Est':>12}")
|
| 671 |
+
lines.append(f" {'-'*45}")
|
| 672 |
+
for d, sym, eps, rev in hits:
|
| 673 |
+
eps_s = f"${eps:.2f}" if eps is not None else "N/A"
|
| 674 |
+
rev_s = _fmt_large(rev) if rev is not None else "N/A"
|
| 675 |
+
lines.append(f" {str(d):<12} {sym:<8} {eps_s:>9} {rev_s:>12}")
|
| 676 |
+
return "\n".join(lines)
|
| 677 |
+
except Exception as e:
|
| 678 |
+
log.error("get_upcoming_earnings failed: %s", e)
|
| 679 |
+
return f"Error fetching upcoming earnings: {e}"
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
@mcp.tool()
|
| 683 |
+
def get_iv_summary(symbol: str) -> str:
|
| 684 |
+
"""
|
| 685 |
+
Summarise implied volatility (IV) across the nearest option expiry dates.
|
| 686 |
+
|
| 687 |
+
Shows average IV for calls and puts per expiry, plus a trend direction
|
| 688 |
+
(rising / falling / flat) — useful for gauging market uncertainty ahead
|
| 689 |
+
of earnings or macro events.
|
| 690 |
+
|
| 691 |
+
Args:
|
| 692 |
+
symbol: Ticker symbol (e.g. 'AAPL', 'SPY').
|
| 693 |
+
"""
|
| 694 |
+
try:
|
| 695 |
+
ticker = yf.Ticker(symbol)
|
| 696 |
+
expiries = ticker.options
|
| 697 |
+
if not expiries:
|
| 698 |
+
return f"No options data available for '{symbol}'."
|
| 699 |
+
|
| 700 |
+
lines = [f"📊 Implied Volatility Summary — {symbol}\n"]
|
| 701 |
+
lines.append(f" {'Expiry':<14} {'Avg Call IV':>12} {'Avg Put IV':>12}")
|
| 702 |
+
lines.append(f" {'-'*40}")
|
| 703 |
+
|
| 704 |
+
iv_series: list[float] = []
|
| 705 |
+
for exp in expiries[:6]:
|
| 706 |
+
try:
|
| 707 |
+
chain = ticker.option_chain(exp)
|
| 708 |
+
call_iv = chain.calls["impliedVolatility"].dropna().mean()
|
| 709 |
+
put_iv = chain.puts["impliedVolatility"].dropna().mean()
|
| 710 |
+
iv_series.append((call_iv + put_iv) / 2)
|
| 711 |
+
lines.append(
|
| 712 |
+
f" {exp:<14} {call_iv*100:>11.1f}% {put_iv*100:>11.1f}%"
|
| 713 |
+
)
|
| 714 |
+
except Exception as e:
|
| 715 |
+
log.debug("IV fetch failed for %s expiry %s: %s", symbol, exp, e)
|
| 716 |
+
lines.append(f" {exp:<14} {'N/A':>12} {'N/A':>12}")
|
| 717 |
+
|
| 718 |
+
if len(iv_series) >= 2:
|
| 719 |
+
trend = (
|
| 720 |
+
"📈 Rising IV (increasing uncertainty)"
|
| 721 |
+
if iv_series[-1] > iv_series[0] * 1.05
|
| 722 |
+
else "📉 Falling IV (uncertainty decreasing)"
|
| 723 |
+
if iv_series[-1] < iv_series[0] * 0.95
|
| 724 |
+
else "➡️ Flat IV"
|
| 725 |
+
)
|
| 726 |
+
lines.append(f"\n Trend: {trend}")
|
| 727 |
+
|
| 728 |
+
return "\n".join(lines)
|
| 729 |
+
except Exception as e:
|
| 730 |
+
log.error("get_iv_summary failed for %s: %s", symbol, e)
|
| 731 |
+
return f"Error fetching IV summary for '{symbol}': {e}"
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
@mcp.tool()
|
| 735 |
+
def screen_large_caps(
|
| 736 |
+
sector: str = "",
|
| 737 |
+
min_market_cap_b: float = 10.0,
|
| 738 |
+
sort_by: str = "market_cap",
|
| 739 |
+
top_n: int = 10,
|
| 740 |
+
) -> str:
|
| 741 |
+
"""
|
| 742 |
+
Screen the large-cap universe for stocks meeting basic criteria.
|
| 743 |
+
|
| 744 |
+
Useful for finding sector leaders, high-volume names, or high-market-cap
|
| 745 |
+
stocks ahead of earnings season.
|
| 746 |
+
|
| 747 |
+
Args:
|
| 748 |
+
sector: Filter by sector keyword, e.g. 'Technology', 'Healthcare',
|
| 749 |
+
'Financial', 'Energy', 'Consumer'. Leave blank for all sectors.
|
| 750 |
+
min_market_cap_b: Minimum market cap in billions (default 10).
|
| 751 |
+
sort_by: Rank by 'market_cap', 'volume', or 'pe_ratio' (default 'market_cap').
|
| 752 |
+
top_n: Number of results to return (default 10, max 20).
|
| 753 |
+
"""
|
| 754 |
+
try:
|
| 755 |
+
rows: list[dict] = []
|
| 756 |
+
for sym in _LARGE_CAPS:
|
| 757 |
+
try:
|
| 758 |
+
info = yf.Ticker(sym).info
|
| 759 |
+
mcap = info.get("marketCap") or 0
|
| 760 |
+
if mcap < min_market_cap_b * 1e9:
|
| 761 |
+
continue
|
| 762 |
+
sym_sector = info.get("sector", "")
|
| 763 |
+
if sector and sector.lower() not in sym_sector.lower():
|
| 764 |
+
continue
|
| 765 |
+
rows.append({
|
| 766 |
+
"symbol": sym,
|
| 767 |
+
"name": info.get("shortName", sym)[:22],
|
| 768 |
+
"sector": sym_sector[:18],
|
| 769 |
+
"market_cap": mcap,
|
| 770 |
+
"volume": info.get("averageVolume") or 0,
|
| 771 |
+
"pe_ratio": info.get("trailingPE") or 0,
|
| 772 |
+
})
|
| 773 |
+
except Exception as e:
|
| 774 |
+
log.debug("screen skip %s: %s", sym, e)
|
| 775 |
+
|
| 776 |
+
if not rows:
|
| 777 |
+
return "No stocks matched the given criteria."
|
| 778 |
+
|
| 779 |
+
sort_key = {"market_cap": "market_cap", "volume": "volume", "pe_ratio": "pe_ratio"}.get(
|
| 780 |
+
sort_by, "market_cap"
|
| 781 |
+
)
|
| 782 |
+
rows.sort(key=lambda r: r[sort_key], reverse=True)
|
| 783 |
+
rows = rows[: min(top_n, 20)]
|
| 784 |
+
|
| 785 |
+
lines = [
|
| 786 |
+
f"🔍 Large-Cap Screen — sector='{sector or 'All'}' "
|
| 787 |
+
f"min_cap=${min_market_cap_b:.0f}B sort={sort_by}\n"
|
| 788 |
+
]
|
| 789 |
+
lines.append(
|
| 790 |
+
f" {'Symbol':<7} {'Name':<24} {'Sector':<20} {'Mkt Cap':>9} {'Avg Vol':>10} {'P/E':>6}"
|
| 791 |
+
)
|
| 792 |
+
lines.append(f" {'-'*78}")
|
| 793 |
+
for r in rows:
|
| 794 |
+
lines.append(
|
| 795 |
+
f" {r['symbol']:<7} {r['name']:<24} {r['sector']:<20} "
|
| 796 |
+
f"{_fmt_large(r['market_cap']):>9} {r['volume']:>10,} "
|
| 797 |
+
f"{r['pe_ratio']:>6.1f}" if r["pe_ratio"] else
|
| 798 |
+
f" {r['symbol']:<7} {r['name']:<24} {r['sector']:<20} "
|
| 799 |
+
f"{_fmt_large(r['market_cap']):>9} {r['volume']:>10,} {'N/A':>6}"
|
| 800 |
+
)
|
| 801 |
+
return "\n".join(lines)
|
| 802 |
+
except Exception as e:
|
| 803 |
+
log.error("screen_large_caps failed: %s", e)
|
| 804 |
+
return f"Error running large-cap screen: {e}"
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
if __name__ == "__main__":
|
| 808 |
+
mcp.run(transport="stdio")
|
src/_mcpservers/mcp-news/server.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP News Server — top headlines, topic search, and category filtering via NewsAPI.org."""
|
| 2 |
+
import datetime
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from mcp.server.fastmcp import FastMCP
|
| 11 |
+
|
| 12 |
+
load_dotenv(Path(__file__).resolve().parents[3] / ".env")
|
| 13 |
+
|
| 14 |
+
logging.basicConfig(
|
| 15 |
+
stream=sys.stderr,
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format="%(asctime)s [%(levelname)s] mcp-news: %(message)s",
|
| 18 |
+
)
|
| 19 |
+
log = logging.getLogger("mcp-news")
|
| 20 |
+
|
| 21 |
+
mcp = FastMCP("News MCP", host="0.0.0.0", port=8002)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@mcp.tool()
|
| 25 |
+
def current_datetime(format: str = "natural") -> str:
|
| 26 |
+
"""
|
| 27 |
+
Return the current date and time.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
format: 'natural' → 'Saturday, June 07, 2025 at 3:59 PM'
|
| 31 |
+
'natural_short' → 'Jun 07, 2025 at 3:59 PM'
|
| 32 |
+
Or any strftime format string (e.g. '%Y-%m-%d').
|
| 33 |
+
"""
|
| 34 |
+
now = datetime.datetime.now()
|
| 35 |
+
if format == "natural":
|
| 36 |
+
return now.strftime("%A, %B %d, %Y at %I:%M %p")
|
| 37 |
+
if format == "natural_short":
|
| 38 |
+
return now.strftime("%b %d, %Y at %I:%M %p")
|
| 39 |
+
return now.strftime(format)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _call_newsapi(url: str, params: dict) -> tuple[list[dict], str | None]:
|
| 43 |
+
"""Returns (articles, error_message). error_message is None on success."""
|
| 44 |
+
api_key = os.getenv("NEWS_API_KEY", "")
|
| 45 |
+
if not api_key:
|
| 46 |
+
return [], "NEWS_API_KEY is not set — add it to your .env file"
|
| 47 |
+
params["apiKey"] = api_key
|
| 48 |
+
try:
|
| 49 |
+
response = requests.get(url, params=params, timeout=10)
|
| 50 |
+
data = response.json()
|
| 51 |
+
if response.status_code != 200:
|
| 52 |
+
msg = data.get("message", response.text)
|
| 53 |
+
return [], f"NewsAPI error {response.status_code}: {msg}"
|
| 54 |
+
return data.get("articles", []), None
|
| 55 |
+
except requests.exceptions.ConnectionError:
|
| 56 |
+
log.error("NewsAPI connection failed: network unreachable")
|
| 57 |
+
return [], "Could not reach NewsAPI — check your network connection"
|
| 58 |
+
except Exception as exc:
|
| 59 |
+
log.error("NewsAPI unexpected error: %s", exc)
|
| 60 |
+
return [], f"Unexpected error: {exc}"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _format_articles(articles: list[dict], label: str, error: str | None = None) -> str:
|
| 64 |
+
if error:
|
| 65 |
+
return f"Error fetching '{label}': {error}"
|
| 66 |
+
if not articles:
|
| 67 |
+
return f"No results found for: {label}"
|
| 68 |
+
lines = [f"{label}\n"]
|
| 69 |
+
for a in articles:
|
| 70 |
+
lines.append(
|
| 71 |
+
f"📰 {a.get('title')}\n"
|
| 72 |
+
f" Source: {a.get('source', {}).get('name')}\n"
|
| 73 |
+
f" Published: {a.get('publishedAt', 'N/A')}\n"
|
| 74 |
+
f" URL: {a.get('url')}\n"
|
| 75 |
+
)
|
| 76 |
+
return "\n".join(lines)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
_COUNTRY_NAMES = {
|
| 80 |
+
"in": "India", "gb": "United Kingdom", "au": "Australia", "ca": "Canada",
|
| 81 |
+
"de": "Germany", "fr": "France", "jp": "Japan", "cn": "China",
|
| 82 |
+
"br": "Brazil", "mx": "Mexico", "za": "South Africa", "ae": "UAE",
|
| 83 |
+
"sg": "Singapore", "nz": "New Zealand", "ie": "Ireland",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@mcp.tool()
|
| 88 |
+
def get_top_headlines(country: str = "us", num_results: int = 5) -> str:
|
| 89 |
+
"""
|
| 90 |
+
Fetch the latest top headlines for a country.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
country: Two-letter country code (e.g. 'us', 'gb', 'in').
|
| 94 |
+
num_results: Number of articles to return (default 5).
|
| 95 |
+
|
| 96 |
+
Note: If top-headlines returns no results for a country (common with the free API tier),
|
| 97 |
+
this tool automatically falls back to a keyword search using the country name.
|
| 98 |
+
"""
|
| 99 |
+
articles, error = _call_newsapi(
|
| 100 |
+
"https://newsapi.org/v2/top-headlines",
|
| 101 |
+
{"country": country, "pageSize": num_results},
|
| 102 |
+
)
|
| 103 |
+
if error:
|
| 104 |
+
return _format_articles([], f"Top Headlines — {country.upper()}", error=error)
|
| 105 |
+
|
| 106 |
+
# Free-tier may not support all country codes — fall back to keyword search
|
| 107 |
+
if not articles:
|
| 108 |
+
country_name = _COUNTRY_NAMES.get(country.lower(), country.upper())
|
| 109 |
+
from_date = (
|
| 110 |
+
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7)
|
| 111 |
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 112 |
+
articles, error = _call_newsapi(
|
| 113 |
+
"https://newsapi.org/v2/everything",
|
| 114 |
+
{
|
| 115 |
+
"q": f"{country_name} news",
|
| 116 |
+
"pageSize": num_results,
|
| 117 |
+
"sortBy": "publishedAt",
|
| 118 |
+
"language": "en",
|
| 119 |
+
"from": from_date,
|
| 120 |
+
},
|
| 121 |
+
)
|
| 122 |
+
label = f"Top Headlines — {country_name} (via keyword search)"
|
| 123 |
+
return _format_articles(articles, label, error=error)
|
| 124 |
+
|
| 125 |
+
return _format_articles(articles, f"Top Headlines — {country.upper()}")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@mcp.tool()
|
| 129 |
+
def search_news(query: str, num_results: int = 5, days_back: int = 7) -> str:
|
| 130 |
+
"""
|
| 131 |
+
Search for recent news articles on a specific topic.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
query: Keyword or topic (e.g. 'Tesla earnings', 'AI regulation').
|
| 135 |
+
num_results: Number of articles to return (default 5).
|
| 136 |
+
days_back: How many days back to search (default 7).
|
| 137 |
+
"""
|
| 138 |
+
from_date = (
|
| 139 |
+
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=days_back)
|
| 140 |
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 141 |
+
articles, error = _call_newsapi(
|
| 142 |
+
"https://newsapi.org/v2/everything",
|
| 143 |
+
{
|
| 144 |
+
"q": query,
|
| 145 |
+
"pageSize": num_results,
|
| 146 |
+
"sortBy": "publishedAt",
|
| 147 |
+
"language": "en",
|
| 148 |
+
"from": from_date,
|
| 149 |
+
},
|
| 150 |
+
)
|
| 151 |
+
return _format_articles(articles, f"News Search — '{query}' (last {days_back} days)", error=error)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@mcp.tool()
|
| 155 |
+
def get_news_by_category(
|
| 156 |
+
category: str = "business", country: str = "us", num_results: int = 5
|
| 157 |
+
) -> str:
|
| 158 |
+
"""
|
| 159 |
+
Fetch top headlines filtered by category.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
category: One of 'business', 'entertainment', 'general', 'health',
|
| 163 |
+
'science', 'sports', 'technology'.
|
| 164 |
+
country: Two-letter country code (default 'us').
|
| 165 |
+
num_results: Number of articles to return (default 5).
|
| 166 |
+
"""
|
| 167 |
+
articles, error = _call_newsapi(
|
| 168 |
+
"https://newsapi.org/v2/top-headlines",
|
| 169 |
+
{"category": category, "country": country, "pageSize": num_results},
|
| 170 |
+
)
|
| 171 |
+
return _format_articles(
|
| 172 |
+
articles, f"Top {category.capitalize()} Headlines — {country.upper()}", error=error
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@mcp.tool()
|
| 177 |
+
def search_company_news(
|
| 178 |
+
company_name: str,
|
| 179 |
+
num_results: int = 5,
|
| 180 |
+
days_back: int = 30,
|
| 181 |
+
language: str = "en",
|
| 182 |
+
) -> str:
|
| 183 |
+
"""
|
| 184 |
+
Search for news about a company by its full name rather than a ticker symbol.
|
| 185 |
+
|
| 186 |
+
Useful when the caller knows the company name but not the exchange ticker,
|
| 187 |
+
or wants broader coverage (e.g. subsidiary names, product names).
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
company_name: Company or brand name (e.g. 'Apple Inc', 'OpenAI', 'SpaceX').
|
| 191 |
+
num_results: Number of articles to return (default 5).
|
| 192 |
+
days_back: How many days back to search (default 30).
|
| 193 |
+
language: Two-letter language code for results (default 'en').
|
| 194 |
+
"""
|
| 195 |
+
from_date = (
|
| 196 |
+
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=days_back)
|
| 197 |
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 198 |
+
articles, error = _call_newsapi(
|
| 199 |
+
"https://newsapi.org/v2/everything",
|
| 200 |
+
{
|
| 201 |
+
"q": f'"{company_name}"',
|
| 202 |
+
"pageSize": num_results,
|
| 203 |
+
"sortBy": "publishedAt",
|
| 204 |
+
"language": language,
|
| 205 |
+
"from": from_date,
|
| 206 |
+
},
|
| 207 |
+
)
|
| 208 |
+
return _format_articles(
|
| 209 |
+
articles, f"News for '{company_name}' (last {days_back} days)", error=error
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
if __name__ == "__main__":
|
| 214 |
+
mcp.run(transport="stdio")
|
src/_mcpservers/mcp-web-search/server.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP Web Search Server — DuckDuckGo search, page fetching, and current datetime."""
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
import requests
|
| 10 |
+
from bs4 import BeautifulSoup
|
| 11 |
+
from ddgs import DDGS
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
from mcp.server.fastmcp import FastMCP
|
| 14 |
+
|
| 15 |
+
load_dotenv(Path(__file__).resolve().parents[3] / ".env")
|
| 16 |
+
|
| 17 |
+
logging.basicConfig(
|
| 18 |
+
stream=sys.stderr,
|
| 19 |
+
level=logging.INFO,
|
| 20 |
+
format="%(asctime)s [%(levelname)s] mcp-web-search: %(message)s",
|
| 21 |
+
)
|
| 22 |
+
log = logging.getLogger("mcp-web-search")
|
| 23 |
+
|
| 24 |
+
mcp = FastMCP("Web Search MCP", host="0.0.0.0", port=8001)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Datetime
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
@mcp.tool()
|
| 32 |
+
def current_datetime(format: str = "natural") -> str:
|
| 33 |
+
"""
|
| 34 |
+
Return the current date and time.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
format: 'natural' → 'Saturday, June 07, 2025 at 3:59 PM'
|
| 38 |
+
'natural_short' → 'Jun 07, 2025 at 3:59 PM'
|
| 39 |
+
Or any strftime format string (e.g. '%Y-%m-%d').
|
| 40 |
+
"""
|
| 41 |
+
now = datetime.now()
|
| 42 |
+
if format == "natural":
|
| 43 |
+
return now.strftime("%A, %B %d, %Y at %I:%M %p")
|
| 44 |
+
if format == "natural_short":
|
| 45 |
+
return now.strftime("%b %d, %Y at %I:%M %p")
|
| 46 |
+
return now.strftime(format)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Web search
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
@mcp.tool()
|
| 54 |
+
def duckduckgo_search(
|
| 55 |
+
query: str,
|
| 56 |
+
max_results: int = 5,
|
| 57 |
+
search_type: str = "text",
|
| 58 |
+
timelimit: str = "d",
|
| 59 |
+
region: str = "us-en",
|
| 60 |
+
) -> list[dict]:
|
| 61 |
+
"""
|
| 62 |
+
Search the web via DuckDuckGo and return result snippets.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
query: The search query.
|
| 66 |
+
max_results: Number of results to return (default 5).
|
| 67 |
+
search_type: 'text' for general results, 'news' to include publication dates.
|
| 68 |
+
timelimit: Recency filter — 'd' day, 'w' week, 'm' month, 'y' year.
|
| 69 |
+
region: Region code (default 'us-en').
|
| 70 |
+
"""
|
| 71 |
+
results: list[dict] = []
|
| 72 |
+
with DDGS() as ddgs:
|
| 73 |
+
if search_type == "news":
|
| 74 |
+
raw = ddgs.news(query, max_results=max_results, timelimit=timelimit, region=region)
|
| 75 |
+
for r in raw:
|
| 76 |
+
results.append({
|
| 77 |
+
"title": r.get("title", ""),
|
| 78 |
+
"link": r.get("url", ""),
|
| 79 |
+
"snippet": r.get("body", ""),
|
| 80 |
+
"datetime": r.get("date", ""),
|
| 81 |
+
})
|
| 82 |
+
else:
|
| 83 |
+
raw = ddgs.text(query, max_results=max_results, timelimit=timelimit, region=region)
|
| 84 |
+
for r in raw:
|
| 85 |
+
results.append({
|
| 86 |
+
"title": r.get("title", ""),
|
| 87 |
+
"link": r.get("href", ""),
|
| 88 |
+
"snippet": r.get("body", ""),
|
| 89 |
+
})
|
| 90 |
+
return results
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
# Page fetching
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
@mcp.tool()
|
| 98 |
+
def fetch_page_content(url: str, timeout: int = 3) -> str:
|
| 99 |
+
"""
|
| 100 |
+
Download a web page and return its readable text content.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
url: The URL to fetch.
|
| 104 |
+
timeout: Request timeout in seconds (default 3).
|
| 105 |
+
"""
|
| 106 |
+
try:
|
| 107 |
+
headers = {
|
| 108 |
+
"User-Agent": (
|
| 109 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 110 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 111 |
+
"Chrome/91.0.4472.124 Safari/537.36"
|
| 112 |
+
)
|
| 113 |
+
}
|
| 114 |
+
response = requests.get(url, headers=headers, timeout=timeout)
|
| 115 |
+
response.raise_for_status()
|
| 116 |
+
soup = BeautifulSoup(response.content, "html.parser")
|
| 117 |
+
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
| 118 |
+
tag.decompose()
|
| 119 |
+
text = soup.get_text(separator="\n", strip=True)
|
| 120 |
+
lines = (line.strip() for line in text.splitlines())
|
| 121 |
+
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
|
| 122 |
+
return "\n".join(chunk for chunk in chunks if chunk)
|
| 123 |
+
except Exception as e:
|
| 124 |
+
return f"[ERROR] Could not fetch {url}: {e}"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@mcp.tool()
|
| 128 |
+
def extract_tables_from_page(url: str, timeout: int = 5) -> str:
|
| 129 |
+
"""
|
| 130 |
+
Extract all HTML tables from a web page and return them as readable text.
|
| 131 |
+
|
| 132 |
+
Especially useful for financial data pages, Wikipedia articles, and
|
| 133 |
+
data-heavy sites where the information lives inside <table> elements.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
url: The URL to fetch.
|
| 137 |
+
timeout: Request timeout in seconds (default 5).
|
| 138 |
+
"""
|
| 139 |
+
try:
|
| 140 |
+
headers = {
|
| 141 |
+
"User-Agent": (
|
| 142 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 143 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 144 |
+
"Chrome/91.0.4472.124 Safari/537.36"
|
| 145 |
+
)
|
| 146 |
+
}
|
| 147 |
+
response = requests.get(url, headers=headers, timeout=timeout)
|
| 148 |
+
response.raise_for_status()
|
| 149 |
+
soup = BeautifulSoup(response.content, "html.parser")
|
| 150 |
+
tables = soup.find_all("table")
|
| 151 |
+
if not tables:
|
| 152 |
+
return f"No tables found on {url}."
|
| 153 |
+
|
| 154 |
+
results = [f"Found {len(tables)} table(s) on {url}\n"]
|
| 155 |
+
for i, table in enumerate(tables, 1):
|
| 156 |
+
rows = table.find_all("tr")
|
| 157 |
+
parsed = []
|
| 158 |
+
for row in rows:
|
| 159 |
+
cells = [c.get_text(strip=True) for c in row.find_all(["th", "td"])]
|
| 160 |
+
if any(cells):
|
| 161 |
+
parsed.append(" | ".join(cells))
|
| 162 |
+
if parsed:
|
| 163 |
+
results.append(f"--- Table {i} ---")
|
| 164 |
+
results.extend(parsed)
|
| 165 |
+
results.append("")
|
| 166 |
+
return "\n".join(results)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
return f"[ERROR] Could not extract tables from {url}: {e}"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
if __name__ == "__main__":
|
| 172 |
+
mcp.run(transport="stdio")
|
src/nexus/Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
DEBIAN_FRONTEND=noninteractive \
|
| 5 |
+
PYTHONPATH=/app:/app/common:$PYTHONPATH
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# System deps
|
| 10 |
+
RUN apt-get update && apt-get install -y \
|
| 11 |
+
git build-essential curl \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Install uv
|
| 15 |
+
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 16 |
+
ENV PATH="/root/.local/bin:$PATH"
|
| 17 |
+
|
| 18 |
+
# Copy project metadata
|
| 19 |
+
COPY pyproject.toml .
|
| 20 |
+
COPY uv.lock .
|
| 21 |
+
|
| 22 |
+
# Copy required folders
|
| 23 |
+
COPY common/ ./common/
|
| 24 |
+
COPY src/chatbot/ ./src/chatbot/
|
| 25 |
+
|
| 26 |
+
# Install dependencies using uv, then export and install with pip to system
|
| 27 |
+
RUN uv sync --frozen --no-dev && \
|
| 28 |
+
uv pip install -e . --system
|
| 29 |
+
|
| 30 |
+
# Copy entry point
|
| 31 |
+
COPY run.py .
|
| 32 |
+
|
| 33 |
+
EXPOSE 7860
|
| 34 |
+
|
| 35 |
+
CMD ["python", "run.py", "chatbot", "--port", "7860"]
|
src/nexus/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Nexus
|
| 3 |
+
emoji: 🔮
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Multi-specialist AI orchestrator for web research
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Nexus
|
| 14 |
+
|
| 15 |
+
Nexus is a multi-specialist AI research orchestrator. It fans out every query to three dedicated agents — a Financial Markets Analyst, a News Intelligence Specialist, and a Web Research Specialist — then synthesises their reports into a single, coherent answer.
|
| 16 |
+
|
| 17 |
+
## What it does
|
| 18 |
+
|
| 19 |
+
- **Finance** — stock prices, market sentiment, analyst ratings, earnings, IV analysis, sector screening
|
| 20 |
+
- **News** — breaking headlines, topic-specific articles, category filtering
|
| 21 |
+
- **Web Research** — deep-dive fact-finding with cited sources
|
| 22 |
+
- **Orchestration** — parallel specialist calls, automatic web fallback on failure, content guardrails
|
| 23 |
+
|
| 24 |
+
## Stack
|
| 25 |
+
|
| 26 |
+
Streamlit · OpenAI Agents SDK · MCP (stdio) · Google Gemini / GPT-4o
|
src/nexus/app.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import glob
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
load_dotenv() # must run before any module that reads env vars
|
| 9 |
+
|
| 10 |
+
import streamlit as st
|
| 11 |
+
from agents import Runner, SQLiteSession
|
| 12 |
+
from agents.exceptions import InputGuardrailTripwireTriggered
|
| 13 |
+
from agents.mcp import MCPServerManager
|
| 14 |
+
from model_factory import MODEL_LABELS, OLLAMA_MODEL_OPTIONS, provider_ready
|
| 15 |
+
from orchestrator import create_orchestrator
|
| 16 |
+
from tracing import setup_langfuse_tracing
|
| 17 |
+
|
| 18 |
+
setup_langfuse_tracing() # installs Langfuse processor or disables SDK tracing
|
| 19 |
+
|
| 20 |
+
# -----------------------------
|
| 21 |
+
# Configuration & Utils
|
| 22 |
+
# -----------------------------
|
| 23 |
+
st.set_page_config(
|
| 24 |
+
page_title="Nexus",
|
| 25 |
+
layout="wide",
|
| 26 |
+
page_icon="🔗"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
def load_prompts(folder="prompts"):
|
| 30 |
+
prompts = []
|
| 31 |
+
prompt_labels = []
|
| 32 |
+
if os.path.exists(folder):
|
| 33 |
+
for file_path in glob.glob(os.path.join(folder, "*.txt")):
|
| 34 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 35 |
+
content = f.read().strip()
|
| 36 |
+
if content:
|
| 37 |
+
prompts.append(content)
|
| 38 |
+
|
| 39 |
+
prompt_labels.append(os.path.basename(file_path).replace("_", " ").replace(".txt", "").title())
|
| 40 |
+
return prompts, prompt_labels
|
| 41 |
+
|
| 42 |
+
prompts, prompt_labels = load_prompts()
|
| 43 |
+
|
| 44 |
+
# -----------------------------
|
| 45 |
+
# Session State
|
| 46 |
+
# -----------------------------
|
| 47 |
+
if "messages" not in st.session_state:
|
| 48 |
+
st.session_state.messages = []
|
| 49 |
+
|
| 50 |
+
if "ai_session_id" not in st.session_state:
|
| 51 |
+
st.session_state.ai_session_id = str(uuid.uuid4())
|
| 52 |
+
|
| 53 |
+
# Persistent SQLite session
|
| 54 |
+
if "ai_session" not in st.session_state:
|
| 55 |
+
st.session_state.ai_session = SQLiteSession(f"conversation_{st.session_state.ai_session_id}.db")
|
| 56 |
+
|
| 57 |
+
session = st.session_state.ai_session
|
| 58 |
+
|
| 59 |
+
# -----------------------------
|
| 60 |
+
# Premium Styling
|
| 61 |
+
# -----------------------------
|
| 62 |
+
st.markdown("""
|
| 63 |
+
<style>
|
| 64 |
+
/* ---------------------------------------------------------------------
|
| 65 |
+
1. GLOBAL & RESET
|
| 66 |
+
--------------------------------------------------------------------- */
|
| 67 |
+
* {
|
| 68 |
+
box-sizing: border-box;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.stApp, [data-testid="stAppViewContainer"] {
|
| 72 |
+
/* Standard Streamlit background */
|
| 73 |
+
background-color: #f8f9fa;
|
| 74 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
html {
|
| 78 |
+
-webkit-text-size-adjust: 100%; /* Prevent iOS font boosting */
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/* ---------------------------------------------------------------------
|
| 82 |
+
2. LAYOUT & HERO BANNER
|
| 83 |
+
--------------------------------------------------------------------- */
|
| 84 |
+
|
| 85 |
+
/* Mobile font optimization */
|
| 86 |
+
@media (max-width: 768px) {
|
| 87 |
+
/* Target all markdown text specifically */
|
| 88 |
+
.stMarkdown p, .stMarkdown li, .stChatMessage p, .message-content, .stDataFrame, .stTable {
|
| 89 |
+
font-size: 16px !important;
|
| 90 |
+
line-height: 1.6 !important;
|
| 91 |
+
color: #1a1a1a !important;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
h1, h2, h3, h4, h5, h6 {
|
| 95 |
+
color: #1a1a1a !important;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* Desktop Layout */
|
| 100 |
+
@media (min-width: 769px) {
|
| 101 |
+
.block-container {
|
| 102 |
+
padding-top: 0 !important;
|
| 103 |
+
padding-bottom: 2rem !important;
|
| 104 |
+
padding-left: 5rem !important;
|
| 105 |
+
padding-right: 5rem !important;
|
| 106 |
+
max-width: 100% !important;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.hero-container {
|
| 110 |
+
margin-top: -3rem;
|
| 111 |
+
margin-left: -5rem;
|
| 112 |
+
margin-right: -5rem;
|
| 113 |
+
/* Simple negative margins to pull edge-to-edge */
|
| 114 |
+
padding: 2.5rem 1rem 2rem 1rem; /* Compact desktop padding */
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* Mobile Layout */
|
| 119 |
+
@media (max-width: 768px) {
|
| 120 |
+
.block-container {
|
| 121 |
+
padding-left: 1rem !important;
|
| 122 |
+
padding-right: 1rem !important;
|
| 123 |
+
padding-top: 0 !important;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.hero-container {
|
| 127 |
+
margin-top: -2rem;
|
| 128 |
+
margin-left: -1rem;
|
| 129 |
+
margin-right: -1rem;
|
| 130 |
+
/* Break out of the 1rem padding */
|
| 131 |
+
padding: 2rem 1rem 1.5rem 1rem; /* Compact mobile padding */
|
| 132 |
+
border-radius: 0 0 12px 12px;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* Ensure font sizes are standard (Streamlit defaults is ~16px) */
|
| 136 |
+
/* We DO NOT override them to 17px/fixed, allowing system zoom to work. */
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* Hero Styling */
|
| 140 |
+
.hero-container {
|
| 141 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 142 |
+
color: white;
|
| 143 |
+
text-align: center;
|
| 144 |
+
border-radius: 0 0 16px 16px;
|
| 145 |
+
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
| 146 |
+
margin-bottom: 2rem;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.hero-title {
|
| 150 |
+
font-size: 2rem; /* Slightly smaller */
|
| 151 |
+
font-weight: 700;
|
| 152 |
+
margin-bottom: 0.25rem;
|
| 153 |
+
color: white !important;
|
| 154 |
+
}
|
| 155 |
+
.hero-subtitle {
|
| 156 |
+
font-size: 1rem;
|
| 157 |
+
opacity: 0.95;
|
| 158 |
+
font-weight: 400;
|
| 159 |
+
color: rgba(255,255,255,0.95) !important;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* Remove Header Decoration */
|
| 163 |
+
header[data-testid="stHeader"] {
|
| 164 |
+
background-color: transparent !important;
|
| 165 |
+
height: 0 !important;
|
| 166 |
+
z-index: 100;
|
| 167 |
+
}
|
| 168 |
+
div[data-testid="stDecoration"] { display: none; }
|
| 169 |
+
|
| 170 |
+
/* ---------------------------------------------------------------------
|
| 171 |
+
3. COMPONENT STYLING (Healthcare-like)
|
| 172 |
+
--------------------------------------------------------------------- */
|
| 173 |
+
|
| 174 |
+
/* Chat Bubbles - Clean & Readable */
|
| 175 |
+
.stChatMessage {
|
| 176 |
+
background-color: white;
|
| 177 |
+
border-radius: 12px;
|
| 178 |
+
border: 1px solid #e5e5e5;
|
| 179 |
+
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
| 180 |
+
padding: 1rem;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.stChatMessage[data-testid="stChatMessage"]:nth-of-type(odd) {
|
| 184 |
+
background-color: #f8f9fa;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/* Input Fields */
|
| 188 |
+
.stTextInput input {
|
| 189 |
+
border-radius: 20px; /* Matching healthcare-assistant roundness */
|
| 190 |
+
border: 1px solid #ddd;
|
| 191 |
+
padding: 0.75rem 1rem;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* Buttons */
|
| 195 |
+
.stButton button {
|
| 196 |
+
border-radius: 20px; /* Matching healthcare-assistant */
|
| 197 |
+
min-height: 48px;
|
| 198 |
+
font-weight: 500;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/* Sidebar */
|
| 202 |
+
section[data-testid="stSidebar"] {
|
| 203 |
+
background-color: #ffffff;
|
| 204 |
+
border-right: 1px solid #eaeaea;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/* Minimize Sidebar Top Padding */
|
| 208 |
+
section[data-testid="stSidebar"] .block-container {
|
| 209 |
+
padding-top: 0rem !important;
|
| 210 |
+
padding-bottom: 0rem !important;
|
| 211 |
+
}
|
| 212 |
+
</style>
|
| 213 |
+
""", unsafe_allow_html=True)
|
| 214 |
+
|
| 215 |
+
# -----------------------------
|
| 216 |
+
# Cached Orchestrator Factory
|
| 217 |
+
# -----------------------------
|
| 218 |
+
|
| 219 |
+
@st.cache_resource
|
| 220 |
+
def _get_orchestrator(model_label: str, ollama_model: str):
|
| 221 |
+
"""Create and cache an orchestrator for the given model selection."""
|
| 222 |
+
from model_factory import get_model_from_label
|
| 223 |
+
m = get_model_from_label(model_label, ollama_model=ollama_model)
|
| 224 |
+
return create_orchestrator(m)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# -----------------------------
|
| 228 |
+
# Logic
|
| 229 |
+
# -----------------------------
|
| 230 |
+
|
| 231 |
+
async def get_ai_response(prompt: str) -> str:
|
| 232 |
+
model_label = st.session_state.get("model_label", MODEL_LABELS[0])
|
| 233 |
+
ollama_model = st.session_state.get("ollama_model", "llama3.2")
|
| 234 |
+
orchestrator, mcp_servers = _get_orchestrator(model_label, ollama_model)
|
| 235 |
+
try:
|
| 236 |
+
async with MCPServerManager(mcp_servers):
|
| 237 |
+
result = await Runner.run(orchestrator, prompt, session=st.session_state.ai_session)
|
| 238 |
+
return result.final_output
|
| 239 |
+
except InputGuardrailTripwireTriggered as e:
|
| 240 |
+
reasoning = (
|
| 241 |
+
getattr(e, "reasoning", None)
|
| 242 |
+
or getattr(getattr(e, "output", None), "reasoning", None)
|
| 243 |
+
or getattr(getattr(e, "guardrail_output", None), "reasoning", None)
|
| 244 |
+
or "Guardrail triggered, but no reasoning provided."
|
| 245 |
+
)
|
| 246 |
+
return f"⚠️ **Guardrail Blocked Input**\n\n{reasoning}"
|
| 247 |
+
except Exception as e:
|
| 248 |
+
msg = str(e)
|
| 249 |
+
if "401" in msg or "invalid_api_key" in msg or "AuthenticationError" in type(e).__name__:
|
| 250 |
+
from model_factory import MODELS, _BY_LABEL
|
| 251 |
+
cfg = _BY_LABEL.get(model_label)
|
| 252 |
+
key_hint = f" Check `{cfg.key_env}` in your `.env`." if cfg and cfg.key_env else ""
|
| 253 |
+
return f"❌ **Authentication Error** — API key rejected for **{model_label}**.{key_hint}"
|
| 254 |
+
if "Connection" in msg or "ConnectError" in msg or "refused" in msg.lower():
|
| 255 |
+
return f"❌ **Connection Error** — Could not reach the model endpoint for **{model_label}**. Is the service running?"
|
| 256 |
+
return f"❌ **Error**: {msg}"
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# -----------------------------
|
| 260 |
+
# Sidebar
|
| 261 |
+
# -----------------------------
|
| 262 |
+
with st.sidebar:
|
| 263 |
+
# --- Model Selection ---
|
| 264 |
+
st.markdown("### 🧠 Model")
|
| 265 |
+
|
| 266 |
+
selected_label = st.selectbox(
|
| 267 |
+
"Choose a model",
|
| 268 |
+
options=MODEL_LABELS,
|
| 269 |
+
index=MODEL_LABELS.index(st.session_state.get("model_label", MODEL_LABELS[0])),
|
| 270 |
+
key="model_label",
|
| 271 |
+
label_visibility="collapsed",
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# Ollama model name picker
|
| 275 |
+
if selected_label == "Ollama (local)":
|
| 276 |
+
st.selectbox(
|
| 277 |
+
"Ollama model",
|
| 278 |
+
options=OLLAMA_MODEL_OPTIONS,
|
| 279 |
+
key="ollama_model",
|
| 280 |
+
label_visibility="collapsed",
|
| 281 |
+
)
|
| 282 |
+
else:
|
| 283 |
+
st.session_state.setdefault("ollama_model", "llama3.2")
|
| 284 |
+
|
| 285 |
+
# Provider readiness indicator
|
| 286 |
+
_ollama_model = st.session_state.get("ollama_model", "llama3.2")
|
| 287 |
+
ready, warn = provider_ready(selected_label, ollama_model=_ollama_model)
|
| 288 |
+
st.session_state["_provider_ready"] = ready
|
| 289 |
+
if not ready:
|
| 290 |
+
st.warning(warn, icon="⚠️")
|
| 291 |
+
else:
|
| 292 |
+
st.caption("✅ Provider ready")
|
| 293 |
+
|
| 294 |
+
st.markdown("---")
|
| 295 |
+
|
| 296 |
+
# --- Quick Starters ---
|
| 297 |
+
st.markdown("### ⚡ Quick Starters")
|
| 298 |
+
st.markdown("Select a prompt to start:")
|
| 299 |
+
|
| 300 |
+
selected_prompt = None
|
| 301 |
+
for idx, prompt_text in enumerate(prompts):
|
| 302 |
+
label = prompt_labels[idx] if idx < len(prompt_labels) else f"Prompt {idx+1}"
|
| 303 |
+
if st.button(label, key=f"sidebar_btn_{idx}", use_container_width=True):
|
| 304 |
+
st.session_state.messages = []
|
| 305 |
+
st.session_state.ai_session_id = str(uuid.uuid4())
|
| 306 |
+
st.session_state.ai_session = SQLiteSession(
|
| 307 |
+
f"conversation_{st.session_state.ai_session_id}.db"
|
| 308 |
+
)
|
| 309 |
+
selected_prompt = prompt_text
|
| 310 |
+
|
| 311 |
+
st.markdown("---")
|
| 312 |
+
if st.button("🗑️ Clear Conversation", use_container_width=True):
|
| 313 |
+
st.session_state.messages = []
|
| 314 |
+
st.rerun()
|
| 315 |
+
|
| 316 |
+
# -----------------------------
|
| 317 |
+
# Main Content
|
| 318 |
+
# -----------------------------
|
| 319 |
+
|
| 320 |
+
# Hero Banner (Always visible & Sticky)
|
| 321 |
+
st.markdown("""
|
| 322 |
+
<div class="hero-container" role="banner">
|
| 323 |
+
<div class="hero-title">🔗 Nexus</div>
|
| 324 |
+
<div class="hero-subtitle">Your intelligent hub for research, analysis, and more.</div>
|
| 325 |
+
</div>
|
| 326 |
+
""", unsafe_allow_html=True)
|
| 327 |
+
|
| 328 |
+
# Display Chat History
|
| 329 |
+
for message in st.session_state.messages:
|
| 330 |
+
with st.chat_message(message["role"]):
|
| 331 |
+
st.markdown(message["content"], unsafe_allow_html=True)
|
| 332 |
+
|
| 333 |
+
# Chat Input Handling
|
| 334 |
+
_provider_ok = st.session_state.get("_provider_ready", True)
|
| 335 |
+
_chat_placeholder = "Type your message..." if _provider_ok else "⚠️ Provider not ready — check sidebar"
|
| 336 |
+
if prompt := (st.chat_input(_chat_placeholder, disabled=not _provider_ok) or selected_prompt):
|
| 337 |
+
# User Message
|
| 338 |
+
st.session_state.messages.append({"role": "user", "content": prompt})
|
| 339 |
+
with st.chat_message("user"):
|
| 340 |
+
st.markdown(prompt)
|
| 341 |
+
|
| 342 |
+
# Assistant Response
|
| 343 |
+
with st.chat_message("assistant"):
|
| 344 |
+
with st.spinner("Thinking..."):
|
| 345 |
+
response_text = asyncio.run(get_ai_response(prompt))
|
| 346 |
+
st.markdown(response_text, unsafe_allow_html=True)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
st.session_state.messages.append({"role": "assistant", "content": response_text})
|
| 351 |
+
|
| 352 |
+
# If it was a sidebar click, we need to rerun to clear the selection state potentially,
|
| 353 |
+
# but st.chat_input usually handles focus. With buttons, a rerun happens automatically
|
| 354 |
+
# but we want to make sure the input box is cleared (which 'selected_prompt' doesn't use).
|
| 355 |
+
if selected_prompt:
|
| 356 |
+
st.rerun()
|
src/nexus/content_guardrail.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content Policy Guardrail — blocks unparliamentary language before it reaches the orchestrator."""
|
| 2 |
+
from agents import Agent, GuardrailFunctionOutput, Runner, input_guardrail
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
from model_factory import get_model
|
| 6 |
+
|
| 7 |
+
model = get_model("google", "gemini-2.5-flash")
|
| 8 |
+
|
| 9 |
+
_INSTRUCTIONS = """
|
| 10 |
+
You are the **Content Policy Guardrail**. Your sole job is to decide whether a
|
| 11 |
+
user message violates the content policy.
|
| 12 |
+
|
| 13 |
+
## Policy
|
| 14 |
+
Flag the message as invalid (`is_valid: false`) ONLY if it contains:
|
| 15 |
+
- Profanity or unparliamentary / abusive language directed at people.
|
| 16 |
+
|
| 17 |
+
All other messages — questions, commands, opinions, technical requests — are valid.
|
| 18 |
+
|
| 19 |
+
## Output (JSON, mandatory)
|
| 20 |
+
{
|
| 21 |
+
"is_valid": <true|false>,
|
| 22 |
+
"reasoning": "<one sentence explanation>"
|
| 23 |
+
}
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ContentValidationResult(BaseModel):
|
| 28 |
+
is_valid: bool
|
| 29 |
+
reasoning: str
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def create_guardrail(m=None):
|
| 33 |
+
"""Return (policy_agent, guardrail_fn) using the supplied model (default: Gemini)."""
|
| 34 |
+
_model = m or model
|
| 35 |
+
policy_agent = Agent(
|
| 36 |
+
name="Content Policy Guardrail",
|
| 37 |
+
model=_model,
|
| 38 |
+
output_type=ContentValidationResult,
|
| 39 |
+
instructions=_INSTRUCTIONS,
|
| 40 |
+
)
|
| 41 |
+
policy_agent.description = (
|
| 42 |
+
"Screens user input for unparliamentary language and blocks policy-violating messages."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
@input_guardrail
|
| 46 |
+
async def _enforce(ctx, agent, input_data):
|
| 47 |
+
result = await Runner.run(policy_agent, input_data, context=ctx.context)
|
| 48 |
+
raw = result.final_output
|
| 49 |
+
if isinstance(raw, ContentValidationResult):
|
| 50 |
+
validated = raw
|
| 51 |
+
else:
|
| 52 |
+
validated = ContentValidationResult(
|
| 53 |
+
is_valid=False,
|
| 54 |
+
reasoning=f"Unexpected guardrail output type: {type(raw)}",
|
| 55 |
+
)
|
| 56 |
+
return GuardrailFunctionOutput(
|
| 57 |
+
output_info=validated,
|
| 58 |
+
tripwire_triggered=not validated.is_valid,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
return policy_agent, _enforce
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Module-level defaults (backwards compat / tests)
|
| 65 |
+
content_policy_agent, enforce_content_policy = create_guardrail(model)
|
src/nexus/model_factory.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model factory — creates OpenAIChatCompletionsModel instances for multiple providers.
|
| 2 |
+
|
| 3 |
+
Providers supported:
|
| 4 |
+
google — Gemini models via Google's OpenAI-compatible endpoint
|
| 5 |
+
openai — OpenAI models (GPT-4o, etc.)
|
| 6 |
+
ollama — Local Ollama models via its OpenAI-compatible server
|
| 7 |
+
|
| 8 |
+
Langfuse tracing is applied automatically when LANGFUSE_SECRET_KEY and
|
| 9 |
+
LANGFUSE_PUBLIC_KEY are set in the environment.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
from agents import OpenAIChatCompletionsModel
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Model catalog
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class ModelConfig:
|
| 24 |
+
label: str # display name shown in the sidebar
|
| 25 |
+
provider: str # internal routing key
|
| 26 |
+
model_name: str # identifier sent to the API
|
| 27 |
+
key_env: str # env var for the API key (empty → no key needed)
|
| 28 |
+
note: str # short description shown as tooltip / helper text
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
MODELS: list[ModelConfig] = [
|
| 32 |
+
ModelConfig(
|
| 33 |
+
"Gemini 2.5 Flash", "google", "gemini-2.5-flash",
|
| 34 |
+
"GOOGLE_API_KEY", "Fast · free tier available",
|
| 35 |
+
),
|
| 36 |
+
ModelConfig(
|
| 37 |
+
"Gemini 2.5 Pro", "google", "gemini-2.5-pro",
|
| 38 |
+
"GOOGLE_API_KEY", "More capable Google model",
|
| 39 |
+
),
|
| 40 |
+
ModelConfig(
|
| 41 |
+
"GPT-4o", "openai", "gpt-4o",
|
| 42 |
+
"OPENAI_API_KEY", "OpenAI flagship model",
|
| 43 |
+
),
|
| 44 |
+
ModelConfig(
|
| 45 |
+
"GPT-4o Mini", "openai", "gpt-4o-mini",
|
| 46 |
+
"OPENAI_API_KEY", "Faster · cheaper OpenAI model",
|
| 47 |
+
),
|
| 48 |
+
ModelConfig(
|
| 49 |
+
"Ollama (local)", "ollama", "", # model_name chosen at runtime
|
| 50 |
+
"", "Local model served by Ollama",
|
| 51 |
+
),
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
MODEL_LABELS: list[str] = [m.label for m in MODELS]
|
| 55 |
+
_BY_LABEL: dict[str, ModelConfig] = {m.label: m for m in MODELS}
|
| 56 |
+
|
| 57 |
+
OLLAMA_MODEL_OPTIONS: list[str] = [
|
| 58 |
+
"qwen3:8b", "llama3.2", "llama3.1", "mistral", "phi4", "gemma3", "qwen2.5",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
_PROVIDER_BASE_URLS: dict[str, str | None] = {
|
| 62 |
+
"google": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
| 63 |
+
"openai": None,
|
| 64 |
+
"ollama": os.environ.get("OLLAMA_BASE_URL", "http://localhost:30786/v1"),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _wsl2_host_ip() -> str | None:
|
| 69 |
+
"""Return the Windows host IP visible from WSL2, or None if not in WSL2."""
|
| 70 |
+
try:
|
| 71 |
+
with open("/etc/resolv.conf") as f:
|
| 72 |
+
for line in f:
|
| 73 |
+
if line.startswith("nameserver"):
|
| 74 |
+
ip = line.split()[1].strip()
|
| 75 |
+
# WSL2 nameserver is typically in the 172.x.x.x range
|
| 76 |
+
if ip.startswith("172."):
|
| 77 |
+
return ip
|
| 78 |
+
except OSError:
|
| 79 |
+
pass
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Public API
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
def get_model(provider: str, model_name: str) -> OpenAIChatCompletionsModel:
|
| 88 |
+
"""Return a ready-to-use model object for the given provider + model name."""
|
| 89 |
+
from openai import AsyncOpenAI
|
| 90 |
+
|
| 91 |
+
base_url = _PROVIDER_BASE_URLS.get(provider)
|
| 92 |
+
key_env = _BY_LABEL_by_provider(provider)
|
| 93 |
+
api_key = os.environ.get(key_env, "") if key_env else "ollama"
|
| 94 |
+
|
| 95 |
+
kwargs: dict = {"api_key": api_key}
|
| 96 |
+
if base_url:
|
| 97 |
+
kwargs["base_url"] = base_url
|
| 98 |
+
|
| 99 |
+
return OpenAIChatCompletionsModel(
|
| 100 |
+
model=model_name,
|
| 101 |
+
openai_client=AsyncOpenAI(**kwargs),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_model_from_label(label: str, ollama_model: str = "llama3.2") -> OpenAIChatCompletionsModel:
|
| 106 |
+
"""Convenience wrapper used by the Streamlit UI."""
|
| 107 |
+
cfg = _BY_LABEL.get(label)
|
| 108 |
+
if cfg is None:
|
| 109 |
+
raise ValueError(f"Unknown model label: {label!r}")
|
| 110 |
+
model_name = ollama_model if cfg.provider == "ollama" else cfg.model_name
|
| 111 |
+
return get_model(cfg.provider, model_name)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def provider_ready(label: str, ollama_model: str = "") -> tuple[bool, str]:
|
| 115 |
+
"""Return (is_ready, warning_message) for a model label."""
|
| 116 |
+
cfg = _BY_LABEL.get(label)
|
| 117 |
+
if cfg is None:
|
| 118 |
+
return False, f"Unknown model: {label}"
|
| 119 |
+
|
| 120 |
+
if cfg.provider == "ollama":
|
| 121 |
+
return _check_ollama(ollama_model)
|
| 122 |
+
|
| 123 |
+
if not cfg.key_env:
|
| 124 |
+
return True, ""
|
| 125 |
+
key = os.environ.get(cfg.key_env, "")
|
| 126 |
+
if not key:
|
| 127 |
+
return False, f"{cfg.key_env} is not set in environment"
|
| 128 |
+
return True, ""
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _check_ollama(model_name: str = "") -> tuple[bool, str]:
|
| 132 |
+
"""Ping Ollama and optionally verify the requested model is loaded.
|
| 133 |
+
|
| 134 |
+
Tries localhost first; if refused, retries with the WSL2 Windows-host IP
|
| 135 |
+
(read from /etc/resolv.conf) and updates OLLAMA_BASE_URL for the session.
|
| 136 |
+
"""
|
| 137 |
+
import requests as _req
|
| 138 |
+
|
| 139 |
+
def _try(base_url: str) -> tuple[bool, str, bool]:
|
| 140 |
+
"""Returns (reachable, error_msg, model_ok)."""
|
| 141 |
+
tags_url = base_url.rstrip("/").removesuffix("/v1") + "/api/tags"
|
| 142 |
+
try:
|
| 143 |
+
r = _req.get(tags_url, timeout=2)
|
| 144 |
+
r.raise_for_status()
|
| 145 |
+
if model_name:
|
| 146 |
+
available = [m.get("name", "") for m in r.json().get("models", [])]
|
| 147 |
+
if not any(model_name in n for n in available):
|
| 148 |
+
return True, f"Model '{model_name}' not found — run: ollama pull {model_name}", False
|
| 149 |
+
return True, "", True
|
| 150 |
+
except _req.exceptions.ConnectionError:
|
| 151 |
+
return False, "", False
|
| 152 |
+
except Exception as exc:
|
| 153 |
+
return True, str(exc), False
|
| 154 |
+
|
| 155 |
+
# 1. Try whatever is currently configured (default: localhost)
|
| 156 |
+
base = os.environ.get("OLLAMA_BASE_URL", "http://localhost:30786/v1")
|
| 157 |
+
reachable, msg, ok = _try(base)
|
| 158 |
+
if reachable:
|
| 159 |
+
return (True, "") if ok else (False, msg)
|
| 160 |
+
|
| 161 |
+
# 2. Localhost refused — try the WSL2 Windows-host IP
|
| 162 |
+
wsl_ip = _wsl2_host_ip()
|
| 163 |
+
if wsl_ip:
|
| 164 |
+
wsl_base = f"http://{wsl_ip}:30786/v1"
|
| 165 |
+
reachable, msg, ok = _try(wsl_base)
|
| 166 |
+
if reachable:
|
| 167 |
+
# Persist for this session so get_model() also uses it
|
| 168 |
+
os.environ["OLLAMA_BASE_URL"] = wsl_base
|
| 169 |
+
_PROVIDER_BASE_URLS["ollama"] = wsl_base
|
| 170 |
+
return (True, "") if ok else (False, msg)
|
| 171 |
+
|
| 172 |
+
# 3. Both failed
|
| 173 |
+
host = f"localhost or {wsl_ip}" if wsl_ip else "localhost"
|
| 174 |
+
return False, (
|
| 175 |
+
f"Ollama not reachable at {host}:30786. "
|
| 176 |
+
"Check that the Ollama pod is running and the NodePort service is healthy "
|
| 177 |
+
"(`kubectl get svc` / `kubectl get pods`). "
|
| 178 |
+
"Override the URL with OLLAMA_BASE_URL in your .env if the address differs."
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
# Internal helpers
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
|
| 186 |
+
def _BY_LABEL_by_provider(provider: str) -> str:
|
| 187 |
+
for m in MODELS:
|
| 188 |
+
if m.provider == provider and m.key_env:
|
| 189 |
+
return m.key_env
|
| 190 |
+
return ""
|
src/nexus/orchestrator.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Research Orchestrator — fans out to specialist agents and synthesises results."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from agents import Agent, Runner, function_tool
|
| 5 |
+
from agents.mcp import MCPServerStdio
|
| 6 |
+
|
| 7 |
+
from content_guardrail import create_guardrail, enforce_content_policy
|
| 8 |
+
from specialists import (
|
| 9 |
+
create_finance_agent, create_news_agent, create_web_agent,
|
| 10 |
+
finance_agent, news_agent, web_agent,
|
| 11 |
+
)
|
| 12 |
+
from model_factory import get_model
|
| 13 |
+
|
| 14 |
+
_INSTRUCTIONS = """
|
| 15 |
+
You are the **AI Research Orchestrator** — the central intelligence that coordinates
|
| 16 |
+
three specialist agents and delivers polished, multi-source answers.
|
| 17 |
+
|
| 18 |
+
## Specialist Roster
|
| 19 |
+
| Agent | Best for |
|
| 20 |
+
|---|---|
|
| 21 |
+
| Financial Markets Analyst | Stock prices, market sentiment, analyst ratings, earnings, IV, sector screening |
|
| 22 |
+
| News Intelligence Specialist | Breaking news, recent events, headlines by topic or category |
|
| 23 |
+
| Web Research Specialist | General knowledge, history, facts, how-to, research |
|
| 24 |
+
|
| 25 |
+
## Workflow
|
| 26 |
+
1. **Understand** the user's intent.
|
| 27 |
+
2. **Select** only the specialists needed — set unused agents to `false` in
|
| 28 |
+
`broadcast_to_specialists` to reduce latency.
|
| 29 |
+
3. **Call** `broadcast_to_specialists` with the user's query.
|
| 30 |
+
4. **Synthesise** the reports into one clear, structured response.
|
| 31 |
+
|
| 32 |
+
## Response Templates
|
| 33 |
+
- **Finance query** → Financial Snapshot table + Key Developments + Synthesis
|
| 34 |
+
- **News query** → Executive Summary + numbered headlines with sources
|
| 35 |
+
- **Research query** → Answer with cited evidence + Sources section
|
| 36 |
+
- **General chat** → Conversational markdown (bullet points, bold highlights)
|
| 37 |
+
- **Code request** → Fenced code blocks + step-by-step explanation
|
| 38 |
+
|
| 39 |
+
## Constraints
|
| 40 |
+
- For any query that needs live data, ALWAYS call `broadcast_to_specialists`.
|
| 41 |
+
- Finance and News specialists automatically fall back to Web Research if they fail.
|
| 42 |
+
- If all specialist reports return errors, tell the user clearly and suggest retrying.
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _make_broadcast_tool(finance_a, news_a, web_a):
|
| 47 |
+
@function_tool
|
| 48 |
+
async def broadcast_to_specialists(
|
| 49 |
+
query: str,
|
| 50 |
+
include_finance: bool = True,
|
| 51 |
+
include_news: bool = True,
|
| 52 |
+
include_web_search: bool = True,
|
| 53 |
+
) -> str:
|
| 54 |
+
"""
|
| 55 |
+
Fan out the query to selected specialist agents in parallel and merge reports.
|
| 56 |
+
Finance and News specialists automatically fall back to Web Research on failure.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
query: The user's question or research topic.
|
| 60 |
+
include_finance: Query the Financial Markets Analyst (default True).
|
| 61 |
+
include_news: Query the News Intelligence Specialist (default True).
|
| 62 |
+
include_web_search: Query the Web Research Specialist (default True).
|
| 63 |
+
"""
|
| 64 |
+
active: list[tuple[str, object]] = []
|
| 65 |
+
if include_finance:
|
| 66 |
+
active.append(("Financial Markets Analyst", Runner.run(finance_a, query)))
|
| 67 |
+
if include_news:
|
| 68 |
+
active.append(("News Intelligence Specialist", Runner.run(news_a, query)))
|
| 69 |
+
if include_web_search:
|
| 70 |
+
active.append(("Web Research Specialist", Runner.run(web_a, query)))
|
| 71 |
+
|
| 72 |
+
if not active:
|
| 73 |
+
return "No specialist agents were selected for this query."
|
| 74 |
+
|
| 75 |
+
names = [name for name, _ in active]
|
| 76 |
+
coroutines = [coro for _, coro in active]
|
| 77 |
+
results = list(await asyncio.gather(*coroutines, return_exceptions=True))
|
| 78 |
+
|
| 79 |
+
# Web fallback for failed Finance / News specialists
|
| 80 |
+
fallback_indices = [
|
| 81 |
+
i for i, (name, res) in enumerate(zip(names, results))
|
| 82 |
+
if isinstance(res, Exception) and name != "Web Research Specialist"
|
| 83 |
+
]
|
| 84 |
+
if fallback_indices:
|
| 85 |
+
fallbacks = await asyncio.gather(
|
| 86 |
+
*[Runner.run(web_a, query) for _ in fallback_indices],
|
| 87 |
+
return_exceptions=True,
|
| 88 |
+
)
|
| 89 |
+
for i, fb in zip(fallback_indices, fallbacks):
|
| 90 |
+
results[i] = fb
|
| 91 |
+
names[i] = f"{names[i]} → Web Fallback"
|
| 92 |
+
|
| 93 |
+
sections = [
|
| 94 |
+
f"✅ {name} Report:\n{res.final_output}"
|
| 95 |
+
if not isinstance(res, Exception)
|
| 96 |
+
else f"❌ {name} — Error: {res}"
|
| 97 |
+
for name, res in zip(names, results)
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
"\n--- SPECIALIST REPORTS BEGIN ---\n\n"
|
| 102 |
+
+ "\n\n---\n\n".join(sections)
|
| 103 |
+
+ "\n\n--- SPECIALIST REPORTS END ---"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return broadcast_to_specialists
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def create_orchestrator(m=None) -> tuple[Agent, list[MCPServerStdio]]:
|
| 110 |
+
"""Return (orchestrator, [mcp_servers]). Caller manages server lifecycle."""
|
| 111 |
+
_model = m or get_model("google", "gemini-2.5-flash")
|
| 112 |
+
_finance, _finance_mcp = create_finance_agent(_model)
|
| 113 |
+
_news, _news_mcp = create_news_agent(_model)
|
| 114 |
+
_web, _web_mcp = create_web_agent(_model)
|
| 115 |
+
_, _guardrail = create_guardrail(_model)
|
| 116 |
+
|
| 117 |
+
orchestrator = Agent(
|
| 118 |
+
name="AI Research Orchestrator",
|
| 119 |
+
model=_model,
|
| 120 |
+
tools=[_make_broadcast_tool(_finance, _news, _web)],
|
| 121 |
+
input_guardrails=[_guardrail],
|
| 122 |
+
instructions=_INSTRUCTIONS,
|
| 123 |
+
)
|
| 124 |
+
orchestrator.description = (
|
| 125 |
+
"Coordinates the Financial Markets Analyst, News Intelligence Specialist, and "
|
| 126 |
+
"Web Research Specialist to deliver comprehensive, multi-source answers."
|
| 127 |
+
)
|
| 128 |
+
return orchestrator, [_finance_mcp, _news_mcp, _web_mcp]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
# Module-level defaults (backwards compat / tests)
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
broadcast_to_specialists = _make_broadcast_tool(finance_agent, news_agent, web_agent)
|
| 136 |
+
|
| 137 |
+
ai_research_orchestrator = Agent(
|
| 138 |
+
name="AI Research Orchestrator",
|
| 139 |
+
model=get_model("google", "gemini-2.5-flash"),
|
| 140 |
+
tools=[broadcast_to_specialists],
|
| 141 |
+
input_guardrails=[enforce_content_policy],
|
| 142 |
+
instructions=_INSTRUCTIONS,
|
| 143 |
+
)
|
| 144 |
+
ai_research_orchestrator.description = (
|
| 145 |
+
"Coordinates the Financial Markets Analyst, News Intelligence Specialist, and "
|
| 146 |
+
"Web Research Specialist to deliver comprehensive, multi-source answers."
|
| 147 |
+
)
|
src/nexus/prompts/economic_news.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Provide a concise update on **major economic indicators released recently** in USA.
|
| 3 |
+
|
| 4 |
+
###### Include
|
| 5 |
+
- **Interest Rates**: Latest central bank decisions, current policy rates, and forward guidance.
|
| 6 |
+
- **Labor Market**: Unemployment rate, job creation figures, and key labor metrics (if available).
|
| 7 |
+
- **Inflation**: CPI, PCE, or other inflation data with MoM and YoY changes.
|
| 8 |
+
- **Growth Indicators**: GDP, PMIs, or industrial production released recently.
|
| 9 |
+
- **Market Reaction**: Brief impact on equities, bonds, FX, and commodities.
|
| 10 |
+
|
| 11 |
+
###### Guidelines
|
| 12 |
+
- Compare results against forecasts and prior releases
|
| 13 |
+
- Highlight notable surprises and their implications
|
| 14 |
+
- Keep the summary brief, factual, and structured
|
| 15 |
+
- **Always retrieve numerical data from primary or authoritative sources**
|
| 16 |
+
|
| 17 |
+
###### Fallback
|
| 18 |
+
- If no relevant data was released recently, explicitly state **“No major economic indicators were released during this period.”**
|
| 19 |
+
- If data is partially unavailable, summarize what is available and clearly note missing indicators.
|
| 20 |
+
- Do not infer or fabricate numbers under any circumstance.
|
| 21 |
+
|
| 22 |
+
###### Output Style
|
| 23 |
+
- Concise, factual, and well-structured
|
| 24 |
+
- Use clear bullet points or short paragraphs
|
| 25 |
+
- Avoid speculation unless explicitly labeled as interpretation
|
| 26 |
+
- **Cite data sources clearly**
|
| 27 |
+
- Use color and emoji to make it more engaging.
|
src/nexus/prompts/entertainment_updates.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Provide the **top 5 recent movie or series updates** that are trending or newly released in USA.
|
| 3 |
+
|
| 4 |
+
###### For each title, include:
|
| 5 |
+
- **Title in bold** and optionally use **color or emojis** to make it fun (e.g., 🎬, 🍿, 🌟)
|
| 6 |
+
- A **short, 2–3 line snippet** that generates excitement or humor about the plot, cast, or vibe
|
| 7 |
+
- **Platform or source** where it can be watched (Netflix, Prime, Disney+, etc.)
|
| 8 |
+
- **Release date or premiere date**
|
| 9 |
+
|
| 10 |
+
###### Requirements / Guidelines
|
| 11 |
+
- Focus on **recent releases** (last 2–4 weeks) or currently trending content
|
| 12 |
+
- Keep the tone **fun, witty, and engaging**, like a friend recommending a show
|
| 13 |
+
- Use **emojis liberally** to emphasize excitement, genre, or humor
|
| 14 |
+
- Call out the **main actors and actresses** to build the interest
|
| 15 |
+
- Where possible, add a **light humorous quip or pun** about the movie/series
|
| 16 |
+
- If color is supported, use HTML span tags, e.g., `<span style="color:orange">Title</span>` for emphasis
|
| 17 |
+
|
| 18 |
+
###### Fallback
|
| 19 |
+
- If fewer than 5 titles are available, provide what is available and indicate:
|
| 20 |
+
**“Only X recent releases found.”**
|
| 21 |
+
- Do not fabricate platforms or release dates — only use verified sources
|
| 22 |
+
|
| 23 |
+
###### Output Style
|
| 24 |
+
- List format (1–5) sorted by **popularity or release date**
|
| 25 |
+
- **Title + snippet + watch source + release date** per entry
|
| 26 |
+
- Use **color, emojis, and humor** to make the output visually appealing and fun to read
|
src/nexus/prompts/india_news.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Tell me the **top 3 headlines from India**.
|
| 3 |
+
|
| 4 |
+
###### For each headline, provide:
|
| 5 |
+
- **Title in bold**
|
| 6 |
+
- A **3‑line summary**
|
| 7 |
+
- **Publish date and time**
|
| 8 |
+
- A **link to the exact source URL**
|
| 9 |
+
|
| 10 |
+
###### Requirements
|
| 11 |
+
- Use authoritative news sources (e.g., major national/regional news outlets)
|
| 12 |
+
- Headlines should be **recent (last 24 hours)**
|
| 13 |
+
- Provide timestamps in **UTC**
|
| 14 |
+
- If publish date/time is not available, indicate “Date/Time not provided”
|
| 15 |
+
|
| 16 |
+
###### Fallback
|
| 17 |
+
- If fewer than 3 headlines are found, provide what is available and state:
|
| 18 |
+
**“Only X recent headlines found for India.”**
|
| 19 |
+
- Do not fabricate headlines, dates, or URLs
|
| 20 |
+
|
| 21 |
+
###### Output Style
|
| 22 |
+
- Structured list sorted by **most recent first**
|
| 23 |
+
- Clear and concise formatting as requested
|
| 24 |
+
- Use color and emoji to make it more engaging.
|
| 25 |
+
- Use `<span style="color:...">` for coloring the title if the renderer supports it
|
| 26 |
+
- Keep the output **concise, factual, and visually engaging**
|
src/nexus/prompts/market_sentiment.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Act as a **Senior Market Analyst** and provide a concise market sentiment update for the **US Stock Market / S&P 500**.
|
| 3 |
+
|
| 4 |
+
###### Steps
|
| 5 |
+
1. **Data Gathering**: Search for the **top 5 financial news headlines** from the last 24 hours related to the [US Stock Market / S&P 500].
|
| 6 |
+
2. **Market Check**: Retrieve the **current value** and **today’s percentage change** for:
|
| 7 |
+
- **S&P 500 (SPX)**
|
| 8 |
+
- **VIX (Volatility Index)**
|
| 9 |
+
3. **Synthesis**: Based on the **tone of the news headlines** and the **index performance**, determine whether the **current market sentiment** is:
|
| 10 |
+
- **Bullish**
|
| 11 |
+
- **Bearish**
|
| 12 |
+
- **Neutral**
|
| 13 |
+
4. **Output**: Provide a:
|
| 14 |
+
- **Sentiment Score (1–10)**
|
| 15 |
+
- **Top 3 key drivers** influencing this sentiment
|
| 16 |
+
|
| 17 |
+
###### Guidelines
|
| 18 |
+
- Prioritize **reliable financial news sources** (e.g., Bloomberg, Reuters, WSJ, CNBC)
|
| 19 |
+
- Use **accurate, real-time market data** for indices
|
| 20 |
+
- Base sentiment on both **news tone** and **market movement**
|
| 21 |
+
- Avoid subjective or unsupported judgments
|
| 22 |
+
|
| 23 |
+
###### Fallback
|
| 24 |
+
- If no relevant financial headlines are found in the last 24 hours, clearly state:
|
| 25 |
+
**“No significant market news available in the last 24 hours.”**
|
| 26 |
+
- If either index value or change is unavailable, report available data and note missing values explicitly
|
| 27 |
+
- Do not invent or estimate values — only use verified data
|
| 28 |
+
|
| 29 |
+
###### Output Style
|
| 30 |
+
- Concise, factual, and structured
|
| 31 |
+
- Use clear bullet points or short paragraphs
|
| 32 |
+
- Include numerical values and data timestamps
|
| 33 |
+
- Provide **sources for headlines and index data**
|
| 34 |
+
- Use color and emoji to make it more engaging.
|
src/nexus/prompts/news_headlines.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Tell me the **top 3 USA headlines**. Use **emojis** and, where supported, **HTML color tags** to make the output engaging.
|
| 3 |
+
|
| 4 |
+
###### For each headline, provide:
|
| 5 |
+
- **Title in bold** and optionally in color, e.g., `<span style="color:blue">Title</span>`
|
| 6 |
+
- A **3-line summary** with an emoji indicating the type of news:
|
| 7 |
+
- 📰 Politics
|
| 8 |
+
- 💼 Business
|
| 9 |
+
- 🌎 World
|
| 10 |
+
- ⚡ Breaking news
|
| 11 |
+
- **Publish date and time** (UTC)
|
| 12 |
+
- A **link to the exact source URL**
|
| 13 |
+
|
| 14 |
+
###### Requirements
|
| 15 |
+
- Use **credible news sources** (Reuters, AP, BBC, Guardian, etc.)
|
| 16 |
+
- Headlines should be **recent (last 24 hours)**
|
| 17 |
+
- If publish time is unavailable, indicate **“Time not provided”**
|
| 18 |
+
|
| 19 |
+
###### Fallback
|
| 20 |
+
- If fewer than 3 headlines are found, state:
|
| 21 |
+
**“Only X recent headlines found for the USA.”**
|
| 22 |
+
- Do not fabricate headlines, dates, or URLs
|
| 23 |
+
|
| 24 |
+
###### Output Style
|
| 25 |
+
- Structured list sorted by **most recent first**
|
| 26 |
+
- Use emojis consistently to indicate news type
|
| 27 |
+
- Use `<span style="color:...">` for coloring the title if the renderer supports it
|
| 28 |
+
- Keep the output **concise, factual, and visually engaging**
|
src/nexus/prompts/odia_news.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Tell me the **top 3 headlines from Odisha**.
|
| 3 |
+
|
| 4 |
+
###### For each headline, provide:
|
| 5 |
+
- **Title in bold**
|
| 6 |
+
- A **3‑line summary**
|
| 7 |
+
- **Publish date and time**
|
| 8 |
+
- A **link to the exact source URL**
|
| 9 |
+
|
| 10 |
+
###### Requirements
|
| 11 |
+
- Use authoritative news sources (e.g., major national/regional news outlets)
|
| 12 |
+
- Headlines should be **recent (last 24 hours)**
|
| 13 |
+
- Provide timestamps in **UTC**
|
| 14 |
+
- If publish date/time is not available, indicate “Date/Time not provided”
|
| 15 |
+
|
| 16 |
+
###### Fallback
|
| 17 |
+
- If fewer than 3 headlines are found, provide what is available and state:
|
| 18 |
+
**“Only X recent headlines found for Odisha.”**
|
| 19 |
+
- Do not fabricate headlines, dates, or URLs
|
| 20 |
+
|
| 21 |
+
###### Output Style
|
| 22 |
+
- Structured list sorted by **most recent first**
|
| 23 |
+
- Clear and concise formatting as requested
|
| 24 |
+
- Use color and emoji to make it more engaging.
|
| 25 |
+
- Use `<span style="color:...">` for coloring the title if the renderer supports it
|
| 26 |
+
- Keep the output **concise, factual, and visually engaging**
|
src/nexus/prompts/trade_recommendation.txt
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Recommend **three option spreads** with **>80% probability of profit**. Perform a thorough analysis of each underlying’s **3-month price trend** and current **market sentiment** before selecting spreads.
|
| 3 |
+
|
| 4 |
+
###### Steps
|
| 5 |
+
1. **Stock selection & analysis**
|
| 6 |
+
- Analyze the **last 3 months** of price action (trend, volatility, support/resistance).
|
| 7 |
+
- Assess market sentiment from the **last 7 days** of headlines and social/analyst tone.
|
| 8 |
+
2. **Spread construction**
|
| 9 |
+
- For each of the **3 recommended spreads**, specify:
|
| 10 |
+
- **Underlying ticker**
|
| 11 |
+
- **Spread type** (e.g., bull put, bear call, iron condor)
|
| 12 |
+
- **Exact expiry date** (YYYY-MM-DD)
|
| 13 |
+
- **Each leg**: side (sell/buy), option type (put/call), **strike price**
|
| 14 |
+
- **Premium entry**: exact net credit/debit per share (use live bid/ask midpoint)
|
| 15 |
+
- **Position size guidance** (risk per trade as % of portfolio) — optional
|
| 16 |
+
3. **Probability & rationale**
|
| 17 |
+
- Provide a **quantitative probability of profit (%)** (clearly state model/method used).
|
| 18 |
+
- Give a concise **rationale** linking 3-month trend, implied volatility, and sentiment to the spread choice.
|
| 19 |
+
- Show key supporting numbers: current spot, IV30, recent volatility, and relevant news headlines (with timestamps).
|
| 20 |
+
|
| 21 |
+
###### Requirements / Guidelines
|
| 22 |
+
- Target **>80% probability of profit** for each spread. Explain how the probability was computed (IV-based log-normal, normal approximation, or risk-neutral model).
|
| 23 |
+
- **Always** use live option-chain quotes (bid/ask midpoint) and authoritative sources for prices/IV (e.g., exchange data, major market data providers).
|
| 24 |
+
- Compare outcomes **vs. forecasts / recent range** and note any idiosyncratic risk (earnings, events).
|
| 25 |
+
- Include **exact timestamps** (UTC) for all quoted prices.
|
| 26 |
+
- Provide **sources** for price, IV, and headlines.
|
| 27 |
+
|
| 28 |
+
###### Fallback
|
| 29 |
+
- If live option-chain or price data is unavailable, state: **“Live market data unavailable — cannot generate exact strike/premium. Provide analysis based on most recent available snapshot.”**
|
| 30 |
+
- If sentiment or 3-month history is incomplete, present what is available and **explicitly list missing items**.
|
| 31 |
+
- **Do not fabricate** strikes, premiums, probabilities, or news — only use verified data.
|
| 32 |
+
|
| 33 |
+
###### Output Style
|
| 34 |
+
- For each spread, use a compact block with:
|
| 35 |
+
- Ticker — Spread type — Expiry (YYYY-MM-DD) — Net premium — PO P (%)
|
| 36 |
+
- Legs: bullet list of exact leg details (sell/buy, put/call, strike, premium)
|
| 37 |
+
- Rationale: 2–3 short sentences linking trend & sentiment to the trade
|
| 38 |
+
- Sources & timestamps
|
| 39 |
+
- Keep language concise, factual, and machine/agent friendly for downstream parsing.
|
| 40 |
+
- Use color and emoji to make it more engaging.
|
src/nexus/prompts/upcoming_earnings.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##### Task
|
| 2 |
+
Search for **upcoming critical earnings announcements** in the stock market.
|
| 3 |
+
|
| 4 |
+
###### Include
|
| 5 |
+
- **Ticker**
|
| 6 |
+
- **Company name**
|
| 7 |
+
- **Earnings date & time**
|
| 8 |
+
- **Expected EPS & revenue consensus**
|
| 9 |
+
- **Last quarter’s actual EPS & revenue**
|
| 10 |
+
- **Implied volatility trend ahead of earnings**
|
| 11 |
+
|
| 12 |
+
###### Requirements / Guidelines
|
| 13 |
+
- Focus on **high‑impact names** (large cap, high volume, sector leaders)
|
| 14 |
+
- Include **earnings expected within the next 7 calendar days**
|
| 15 |
+
- Use **primary/authoritative sources** for earnings dates and estimates (e.g., exchange calendars, Bloomberg/Refinitiv/Estimize)
|
| 16 |
+
- Show **timestamped data** (UTC)
|
| 17 |
+
|
| 18 |
+
###### Fallback
|
| 19 |
+
- If no critical earnings are found in the next 7 days, state:
|
| 20 |
+
**“No upcoming critical earnings announcements found within the specified period.”**
|
| 21 |
+
- If consensus estimates are unavailable, list the earnings date/time and note missing metrics.
|
| 22 |
+
|
| 23 |
+
###### Output Style
|
| 24 |
+
- Structured list sorted by **earnings date**
|
| 25 |
+
- Use clear bullet points or short paragraphs
|
| 26 |
+
- Provide **sources** for each item
|
| 27 |
+
- Use color and emoji to make it more engaging.
|
src/nexus/specialists.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Specialist agents — Finance, News, and Web Research.
|
| 2 |
+
|
| 3 |
+
Each factory returns (Agent, MCPServerStdio). The caller (orchestrator) is
|
| 4 |
+
responsible for connecting/cleaning up the servers via MCPServerManager.
|
| 5 |
+
"""
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from agents import Agent
|
| 10 |
+
from agents.mcp import MCPServerStdio, MCPServerStdioParams
|
| 11 |
+
|
| 12 |
+
from model_factory import get_model
|
| 13 |
+
|
| 14 |
+
_MCPDIR = Path(__file__).resolve().parent.parent / "_mcpservers"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Financial Markets Analyst
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
_FINANCE_INSTRUCTIONS = """
|
| 22 |
+
You are the **Financial Markets Analyst** — a data-driven equities researcher.
|
| 23 |
+
Provide precise, tool-backed financial analysis. Never speculate without data.
|
| 24 |
+
|
| 25 |
+
## Tool Selection Guide
|
| 26 |
+
- `current_datetime` — call this FIRST on every request to anchor temporal context.
|
| 27 |
+
- `get_stock_summary` — current price, open, high, low, volume.
|
| 28 |
+
- `get_market_sentiment` — Bullish / Bearish / Neutral over a period.
|
| 29 |
+
- `get_price_history` — historical OHLCV data (last 5 rows).
|
| 30 |
+
- `get_analyst_recommendations` — latest Buy/Sell/Hold ratings.
|
| 31 |
+
- `get_earnings_calendar` — upcoming earnings date for a single ticker.
|
| 32 |
+
- `get_earnings_estimates` — EPS & revenue consensus + last 4 quarters actuals vs estimates.
|
| 33 |
+
- `get_upcoming_earnings` — earnings dates for multiple companies in a date window. Scans ~60 large-cap names by default; pass comma-separated tickers to narrow it.
|
| 34 |
+
- `get_iv_summary` — implied volatility trend across the nearest option expiries.
|
| 35 |
+
- `screen_large_caps` — filter large-cap universe by sector, min market cap, sort by market_cap / volume / pe_ratio.
|
| 36 |
+
- `get_valuation_metrics` — P/E, P/B, EPS, market cap, dividend yield, beta.
|
| 37 |
+
- `get_financial_statements` — income statement, balance sheet, or cash flow.
|
| 38 |
+
- `get_dividends` — dividend history and annual totals.
|
| 39 |
+
- `get_technical_indicators` — SMA(20/50/200), RSI(14), MACD.
|
| 40 |
+
- `compare_stocks` — side-by-side return and price comparison (up to 6 symbols).
|
| 41 |
+
- `get_options_chain` — calls and puts for a specific expiry date.
|
| 42 |
+
- `get_institutional_holdings` — major holders and institutional ownership.
|
| 43 |
+
- `get_stock_news` — latest news articles from Yahoo Finance.
|
| 44 |
+
|
| 45 |
+
## Output Format
|
| 46 |
+
**[Company Name] ([SYMBOL]) — Market Analysis**
|
| 47 |
+
|
| 48 |
+
| Metric | Value |
|
| 49 |
+
|---|---|
|
| 50 |
+
| Price | ... |
|
| 51 |
+
| Change | ... |
|
| 52 |
+
| Sentiment | ... |
|
| 53 |
+
| Analyst Consensus | ... |
|
| 54 |
+
| Next Earnings | ... |
|
| 55 |
+
|
| 56 |
+
**Key Takeaways:** [2–3 bullet synthesis]
|
| 57 |
+
|
| 58 |
+
*Disclaimer: For informational purposes only. Not financial advice.*
|
| 59 |
+
|
| 60 |
+
## Rules
|
| 61 |
+
- Every number must come from a tool call — never hallucinate prices.
|
| 62 |
+
- Include the data source and date for all figures.
|
| 63 |
+
- If data is unavailable, state "Data unavailable for [X]" explicitly.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def create_finance_agent(model=None) -> tuple[Agent, MCPServerStdio]:
|
| 68 |
+
mcp = MCPServerStdio(
|
| 69 |
+
params=MCPServerStdioParams(
|
| 70 |
+
command=sys.executable,
|
| 71 |
+
args=[str(_MCPDIR / "mcp-finance" / "server.py")],
|
| 72 |
+
),
|
| 73 |
+
cache_tools_list=True,
|
| 74 |
+
name="finance-mcp",
|
| 75 |
+
)
|
| 76 |
+
agent = Agent(
|
| 77 |
+
name="Financial Markets Analyst",
|
| 78 |
+
model=model or _default_model(),
|
| 79 |
+
mcp_servers=[mcp],
|
| 80 |
+
instructions=_FINANCE_INSTRUCTIONS,
|
| 81 |
+
)
|
| 82 |
+
agent.description = (
|
| 83 |
+
"Delivers stock summaries, sentiment, price history, earnings estimates, "
|
| 84 |
+
"IV analysis, and large-cap screening via the Finance MCP server."
|
| 85 |
+
)
|
| 86 |
+
return agent, mcp
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
# News Intelligence Specialist
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
_NEWS_INSTRUCTIONS = """
|
| 94 |
+
You are the **News Intelligence Specialist** — an objective, source-citing news analyst.
|
| 95 |
+
Retrieve and present the most relevant, up-to-date news on any topic.
|
| 96 |
+
|
| 97 |
+
## Tool Selection Guide
|
| 98 |
+
- `current_datetime` — always call first for temporal grounding.
|
| 99 |
+
- `get_top_headlines` — use for country-level "what's happening today" requests.
|
| 100 |
+
Automatically falls back to keyword search when top-headlines lacks coverage.
|
| 101 |
+
- `search_news` — preferred for specific topics, companies, people, or any country query.
|
| 102 |
+
- `get_news_by_category` — use when the request names a category
|
| 103 |
+
(business, technology, health, sports, science, entertainment, general).
|
| 104 |
+
- `search_company_news` — search news by company name rather than ticker symbol.
|
| 105 |
+
|
| 106 |
+
## Output Format
|
| 107 |
+
Present each article as:
|
| 108 |
+
|
| 109 |
+
**[Headline]**
|
| 110 |
+
- Source: [Name] | Published: [Date]
|
| 111 |
+
- Summary: [1-2 sentence description]
|
| 112 |
+
- Read more: [URL]
|
| 113 |
+
|
| 114 |
+
## Rules
|
| 115 |
+
- Never fabricate headlines or sources.
|
| 116 |
+
- Always include publication dates and URLs.
|
| 117 |
+
- Flag if results may be older than the requested timeframe.
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def create_news_agent(model=None) -> tuple[Agent, MCPServerStdio]:
|
| 122 |
+
mcp = MCPServerStdio(
|
| 123 |
+
params=MCPServerStdioParams(
|
| 124 |
+
command=sys.executable,
|
| 125 |
+
args=[str(_MCPDIR / "mcp-news" / "server.py")],
|
| 126 |
+
),
|
| 127 |
+
cache_tools_list=True,
|
| 128 |
+
name="news-mcp",
|
| 129 |
+
)
|
| 130 |
+
agent = Agent(
|
| 131 |
+
name="News Intelligence Specialist",
|
| 132 |
+
model=model or _default_model(),
|
| 133 |
+
mcp_servers=[mcp],
|
| 134 |
+
instructions=_NEWS_INSTRUCTIONS,
|
| 135 |
+
)
|
| 136 |
+
agent.description = (
|
| 137 |
+
"Retrieves top headlines and topic-specific news articles via the News MCP server."
|
| 138 |
+
)
|
| 139 |
+
return agent, mcp
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ---------------------------------------------------------------------------
|
| 143 |
+
# Web Research Specialist
|
| 144 |
+
# ---------------------------------------------------------------------------
|
| 145 |
+
|
| 146 |
+
_WEB_INSTRUCTIONS = """
|
| 147 |
+
You are the **Web Research Specialist** — a precise, citation-driven researcher.
|
| 148 |
+
Your sole purpose is to retrieve and synthesize factual information from the internet.
|
| 149 |
+
|
| 150 |
+
## Workflow
|
| 151 |
+
1. Call `current_datetime` to establish temporal context.
|
| 152 |
+
2. Construct 1–3 targeted search queries and call `duckduckgo_search`.
|
| 153 |
+
Use `search_type='news'` for current events.
|
| 154 |
+
3. Fetch the full text of the top 3 most relevant results using `fetch_page_content`.
|
| 155 |
+
4. Synthesize the fetched content into a clear, structured answer.
|
| 156 |
+
|
| 157 |
+
## Output Rules
|
| 158 |
+
- Ground every claim in the fetched text — never fabricate.
|
| 159 |
+
- Use headings and bullet points for readability.
|
| 160 |
+
- End with a **Sources** section listing the Title and URL of each page fetched.
|
| 161 |
+
- If no conclusive answer is found, state: "A conclusive answer could not be
|
| 162 |
+
verified by current web sources."
|
| 163 |
+
"""
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def create_web_agent(model=None) -> tuple[Agent, MCPServerStdio]:
|
| 167 |
+
mcp = MCPServerStdio(
|
| 168 |
+
params=MCPServerStdioParams(
|
| 169 |
+
command=sys.executable,
|
| 170 |
+
args=[str(_MCPDIR / "mcp-web-search" / "server.py")],
|
| 171 |
+
),
|
| 172 |
+
cache_tools_list=True,
|
| 173 |
+
name="web-search-mcp",
|
| 174 |
+
)
|
| 175 |
+
agent = Agent(
|
| 176 |
+
name="Web Research Specialist",
|
| 177 |
+
model=model or _default_model(),
|
| 178 |
+
mcp_servers=[mcp],
|
| 179 |
+
instructions=_WEB_INSTRUCTIONS,
|
| 180 |
+
)
|
| 181 |
+
agent.description = (
|
| 182 |
+
"Searches DuckDuckGo, fetches full page content, and returns cited answers."
|
| 183 |
+
)
|
| 184 |
+
return agent, mcp
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
# Module-level defaults — used by orchestrator's backwards-compat instances
|
| 189 |
+
# ---------------------------------------------------------------------------
|
| 190 |
+
|
| 191 |
+
def _default_model():
|
| 192 |
+
return get_model("google", "gemini-2.5-flash")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
finance_agent, _finance_mcp = create_finance_agent()
|
| 196 |
+
news_agent, _news_mcp = create_news_agent()
|
| 197 |
+
web_agent, _web_mcp = create_web_agent()
|
src/nexus/tracing.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Langfuse tracing integration for the OpenAI Agents SDK.
|
| 2 |
+
|
| 3 |
+
When LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY are set, installs a custom
|
| 4 |
+
TracingProcessor that forwards every SDK span to Langfuse, producing a clean
|
| 5 |
+
waterfall view: trace → agent spans → LLM generations → tool calls.
|
| 6 |
+
|
| 7 |
+
Call setup_langfuse_tracing() once at app startup.
|
| 8 |
+
If credentials are absent, tracing is disabled so the SDK doesn't send 401 noise
|
| 9 |
+
to OpenAI's tracing endpoint.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from agents.tracing import TracingProcessor, set_trace_processors, set_tracing_disabled
|
| 19 |
+
|
| 20 |
+
log = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def setup_langfuse_tracing() -> bool:
|
| 24 |
+
"""Install Langfuse as the sole tracing backend. Returns True if installed."""
|
| 25 |
+
if not (os.getenv("LANGFUSE_SECRET_KEY") and os.getenv("LANGFUSE_PUBLIC_KEY")):
|
| 26 |
+
set_tracing_disabled(True)
|
| 27 |
+
return False
|
| 28 |
+
try:
|
| 29 |
+
from langfuse import Langfuse
|
| 30 |
+
lf = Langfuse()
|
| 31 |
+
set_trace_processors([_LangfuseProcessor(lf)])
|
| 32 |
+
log.info("Langfuse tracing → %s", os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com"))
|
| 33 |
+
return True
|
| 34 |
+
except Exception as exc:
|
| 35 |
+
log.warning("Langfuse setup failed (%s) — tracing disabled", exc)
|
| 36 |
+
set_tracing_disabled(True)
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class _LangfuseProcessor(TracingProcessor):
|
| 41 |
+
"""Maps Agents SDK spans → Langfuse observations in a waterfall hierarchy."""
|
| 42 |
+
|
| 43 |
+
def __init__(self, lf: Any) -> None:
|
| 44 |
+
self._lf = lf
|
| 45 |
+
self._traces: dict[str, Any] = {} # trace_id → langfuse trace object
|
| 46 |
+
|
| 47 |
+
# ------------------------------------------------------------------
|
| 48 |
+
# TracingProcessor interface
|
| 49 |
+
# ------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
def on_trace_start(self, trace: Any) -> None:
|
| 52 |
+
try:
|
| 53 |
+
self._traces[trace.trace_id] = self._lf.trace(
|
| 54 |
+
id=trace.trace_id,
|
| 55 |
+
name=trace.name or "agent-run",
|
| 56 |
+
)
|
| 57 |
+
except Exception as exc:
|
| 58 |
+
log.debug("on_trace_start: %s", exc)
|
| 59 |
+
|
| 60 |
+
def on_trace_end(self, trace: Any) -> None:
|
| 61 |
+
try:
|
| 62 |
+
self._traces.pop(trace.trace_id, None)
|
| 63 |
+
self._lf.flush()
|
| 64 |
+
except Exception as exc:
|
| 65 |
+
log.debug("on_trace_end: %s", exc)
|
| 66 |
+
|
| 67 |
+
def on_span_start(self, span: Any) -> None:
|
| 68 |
+
pass # all data available at span_end
|
| 69 |
+
|
| 70 |
+
def on_span_end(self, span: Any) -> None:
|
| 71 |
+
try:
|
| 72 |
+
lf_trace = self._traces.get(span.trace_id)
|
| 73 |
+
if lf_trace is None:
|
| 74 |
+
return
|
| 75 |
+
# span.parent_id == the parent's span_id which we also used as the
|
| 76 |
+
# Langfuse observation id, so it doubles as parent_observation_id.
|
| 77 |
+
self._emit(lf_trace, span, parent_id=span.parent_id)
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
log.debug("on_span_end: %s", exc)
|
| 80 |
+
|
| 81 |
+
def force_flush(self) -> None:
|
| 82 |
+
try:
|
| 83 |
+
self._lf.flush()
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
+
def shutdown(self) -> None:
|
| 88 |
+
try:
|
| 89 |
+
self._lf.flush()
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
# ------------------------------------------------------------------
|
| 94 |
+
# Span → Langfuse observation
|
| 95 |
+
# ------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
def _emit(self, trace: Any, span: Any, parent_id: str | None) -> None:
|
| 98 |
+
from agents.tracing.span_data import (
|
| 99 |
+
AgentSpanData, FunctionSpanData, GenerationSpanData,
|
| 100 |
+
GuardrailSpanData, HandoffSpanData, MCPListToolsSpanData,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
d = span.span_data
|
| 104 |
+
err = str(span.error) if span.error else None
|
| 105 |
+
level = "ERROR" if span.error else "DEFAULT"
|
| 106 |
+
start = _ts(span.started_at)
|
| 107 |
+
end = _ts(span.ended_at)
|
| 108 |
+
sid = span.span_id
|
| 109 |
+
|
| 110 |
+
if isinstance(d, GenerationSpanData):
|
| 111 |
+
usage = None
|
| 112 |
+
if d.usage:
|
| 113 |
+
usage = {
|
| 114 |
+
"input": d.usage.get("input_tokens", 0),
|
| 115 |
+
"output": d.usage.get("output_tokens", 0),
|
| 116 |
+
"total": d.usage.get("total_tokens", 0),
|
| 117 |
+
}
|
| 118 |
+
trace.generation(
|
| 119 |
+
id=sid, parent_observation_id=parent_id,
|
| 120 |
+
name=f"llm · {d.model or 'unknown'}",
|
| 121 |
+
model=d.model, input=d.input, output=d.output,
|
| 122 |
+
usage=usage, start_time=start, end_time=end,
|
| 123 |
+
level=level, status_message=err,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
elif isinstance(d, FunctionSpanData):
|
| 127 |
+
prefix = "mcp" if d.mcp_data else "tool"
|
| 128 |
+
trace.span(
|
| 129 |
+
id=sid, parent_observation_id=parent_id,
|
| 130 |
+
name=f"{prefix} · {d.name or '?'}",
|
| 131 |
+
input=_parse(d.input), output=_parse(d.output),
|
| 132 |
+
start_time=start, end_time=end,
|
| 133 |
+
level=level, status_message=err,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
elif isinstance(d, AgentSpanData):
|
| 137 |
+
trace.span(
|
| 138 |
+
id=sid, parent_observation_id=parent_id,
|
| 139 |
+
name=f"agent · {d.name or '?'}",
|
| 140 |
+
metadata={"tools": d.tools, "handoffs": d.handoffs},
|
| 141 |
+
start_time=start, end_time=end, level=level,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
elif isinstance(d, GuardrailSpanData):
|
| 145 |
+
trace.span(
|
| 146 |
+
id=sid, parent_observation_id=parent_id,
|
| 147 |
+
name=f"guardrail · {d.name or '?'}",
|
| 148 |
+
metadata={"triggered": d.triggered},
|
| 149 |
+
start_time=start, end_time=end, level=level,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
elif isinstance(d, HandoffSpanData):
|
| 153 |
+
trace.span(
|
| 154 |
+
id=sid, parent_observation_id=parent_id,
|
| 155 |
+
name=f"handoff → {d.to_agent or '?'}",
|
| 156 |
+
start_time=start, end_time=end,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
elif isinstance(d, MCPListToolsSpanData):
|
| 160 |
+
trace.span(
|
| 161 |
+
id=sid, parent_observation_id=parent_id,
|
| 162 |
+
name=f"mcp list-tools · {d.server or '?'}",
|
| 163 |
+
output=d.result, start_time=start, end_time=end,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
else:
|
| 167 |
+
trace.span(
|
| 168 |
+
id=sid, parent_observation_id=parent_id,
|
| 169 |
+
name=getattr(d, "name", d.type),
|
| 170 |
+
start_time=start, end_time=end,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
# Helpers
|
| 176 |
+
# ------------------------------------------------------------------
|
| 177 |
+
|
| 178 |
+
def _ts(value: Any) -> datetime | None:
|
| 179 |
+
if value is None:
|
| 180 |
+
return None
|
| 181 |
+
if isinstance(value, datetime):
|
| 182 |
+
return value
|
| 183 |
+
try:
|
| 184 |
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
| 185 |
+
except Exception:
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _parse(value: Any) -> Any:
|
| 190 |
+
if value is None:
|
| 191 |
+
return None
|
| 192 |
+
if isinstance(value, str):
|
| 193 |
+
import json
|
| 194 |
+
try:
|
| 195 |
+
return json.loads(value)
|
| 196 |
+
except Exception:
|
| 197 |
+
return value
|
| 198 |
+
return value
|