mishrabp commited on
Commit
3c1a2b8
·
verified ·
1 Parent(s): ee5235e

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,223 +1,26 @@
1
  ---
2
- title: AI Chatbot
3
- emoji: 🤖
4
  colorFrom: pink
5
- colorTo: yellow
6
  sdk: docker
7
- sdk_version: "0.0.1"
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
- tags:
12
- - text-generation
13
- - agentic-ai
14
- - openai-sdk
15
- short_description: An Experimental Agentic Chatbot (uses OpenAI Agent SDK)
16
  ---
17
 
18
- # AI Chatbot
19
 
20
- This is an experimental chatbot for chatting with AI. It is equipped with agents & tools to give you realtime data from the web. It uses **OpenAI - SDK** and **OpenAI - Agents**...
21
 
22
- ## Features
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
- ## Usage
29
- 1. Type a message or select a predefined prompt
30
- 2. Press **Enter** or click **Send**
31
- 3. AI responses appear instantly in the chat interface
32
 
33
- ## Supported APIs
34
- - OpenAI
35
- - Google
36
- - GROQ
37
- - SERPER
38
- - News API
39
 
40
- ## Notes
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 = ["."] # Look in the project root
181
- include = ["common*", "src*"] # Treat 'common' and 'src' folders as packages
182
 
183
 
184
  # ============================================================
185
  # PYTEST SETTINGS
186
  # ============================================================
187
- # Configures the test runner to automatically find code.
188
  [tool.pytest.ini_options]
189
- # Adds 'src' and 'common' to the python path during tests.
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
- "healthcare": {
33
- "path": "src/healthcare-assistant",
34
  "entry": "app.py",
35
- "description": "Healthcare Assistant - Medical information with RAG and web search"
36
  },
37
- "deep-research_v1": {
38
- "path": "src/deep-research_v1",
39
  "entry": "app.py",
40
- "description": "Deep Research AI - Comprehensive research assistant"
41
  },
42
- "deep-research_v2": {
43
- "path": "src/deep-research_v2",
44
  "entry": "app.py",
45
- "description": "Deep Research AI - Comprehensive research assistant"
46
  },
47
- "stock-analyst": {
48
- "path": "src/stock-analyst",
49
  "entry": "app.py",
50
- "description": "Stock Analyst - Financial analysis and stock recommendations"
51
  },
52
- "travel-agent": {
53
- "path": "src/travel-agent",
54
  "entry": "app.py",
55
- "description": "Travel Agent - Trip planning and travel recommendations"
56
  },
57
- "trip-planner": {
58
- "path": "src/trip-planner",
59
  "entry": "main.py",
60
  "type": "fastapi",
61
- "description": "Trip Planner - Detailed trip itinerary planning"
62
  },
63
- "chatbot_v1": {
64
- "path": "src/chatbot_v1",
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 - Decoupled Multi-agent market analysis (Vue.js + FastAPI)"
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 == "market-analyst":
276
- frontend_dir = project_root / "src/market-analyst/frontend"
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