Spaces:
Sleeping
Sleeping
Commit ·
82de0b8
1
Parent(s): 5c8029a
Feat: Add Brave, Charts and Email Notifications
Browse files- app.py +38 -33
- requirements.txt +3 -1
- src/agent.py +41 -50
- src/email_utils.py +39 -0
- src/viz.py +34 -0
- test_llm.py +1 -1
app.py
CHANGED
|
@@ -1,57 +1,62 @@
|
|
| 1 |
import chainlit as cl
|
| 2 |
-
from src.agent import app
|
| 3 |
|
| 4 |
@cl.on_chat_start
|
| 5 |
async def start():
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
"""
|
| 9 |
-
await cl.Message(content="👋 **Hello! I am PrimoGreedy.**\n\nI am your skeptical financial analyst.\nGive me a ticker (e.g., AAPL, AMC, PLTR) and I will run it through the Firewall.").send()
|
| 10 |
|
| 11 |
@cl.on_message
|
| 12 |
async def main(message: cl.Message):
|
| 13 |
-
|
| 14 |
-
Runs every time the user types a message.
|
| 15 |
-
"""
|
| 16 |
-
ticker = message.content.upper().strip()
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
await msg.send()
|
| 21 |
|
| 22 |
-
# 2. Run the Agent Logic
|
| 23 |
-
# We use 'invoke' to run the graph we built in agent.py
|
| 24 |
try:
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
final_report = result.get('final_report')
|
| 29 |
status = result.get('status')
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if status == "FAIL":
|
| 34 |
-
#
|
| 35 |
-
reason = financial_data.get('reason', 'Unknown Reason')
|
| 36 |
-
|
| 37 |
response = f"""
|
| 38 |
-
❌ **REJECTED
|
| 39 |
-
|
| 40 |
-
**Ticker:** {ticker}
|
| 41 |
-
**Reason:** {reason}
|
| 42 |
|
| 43 |
-
*
|
| 44 |
"""
|
| 45 |
-
|
| 46 |
-
#
|
| 47 |
response = f"""
|
| 48 |
✅ **PASSED FIREWALL**
|
| 49 |
|
| 50 |
-
{
|
| 51 |
-
"""
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
except Exception as e:
|
| 57 |
await cl.Message(content=f"⚠️ Error: {str(e)}").send()
|
|
|
|
| 1 |
import chainlit as cl
|
| 2 |
+
from src.agent import app
|
| 3 |
|
| 4 |
@cl.on_chat_start
|
| 5 |
async def start():
|
| 6 |
+
# Send a Welcome Message that lists your new powers
|
| 7 |
+
await cl.Message(content="👋 **PrimoGreedy v3.0**\n\n- **Brave Search** Active 🦁\n- **Resend Email** Active 📧\n- **Charts** Active 📈\n\n*Type a ticker (e.g., NVDA) to scout, or just say Hello.*").send()
|
|
|
|
|
|
|
| 8 |
|
| 9 |
@cl.on_message
|
| 10 |
async def main(message: cl.Message):
|
| 11 |
+
user_input = message.content.strip()
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
# Simple check: If it looks like a ticker, say "Scouting", otherwise "Thinking"
|
| 14 |
+
if len(user_input) <= 5 and " " not in user_input:
|
| 15 |
+
msg = cl.Message(content=f"🔍 Scouting **{user_input.upper()}**...")
|
| 16 |
+
else:
|
| 17 |
+
msg = cl.Message(content=f"🤔 Thinking...")
|
| 18 |
await msg.send()
|
| 19 |
|
|
|
|
|
|
|
| 20 |
try:
|
| 21 |
+
# Run the full Agent (Brain + Eyes + Hands)
|
| 22 |
+
result = await app.ainvoke({"ticker": user_input})
|
| 23 |
|
| 24 |
+
# Extract all the new data we added
|
|
|
|
| 25 |
status = result.get('status')
|
| 26 |
+
report = result.get('final_report')
|
| 27 |
+
chart_bytes = result.get('chart_data')
|
| 28 |
+
email_msg = result.get('email_status')
|
| 29 |
+
|
| 30 |
+
# Prepare the Image (if we have one)
|
| 31 |
+
elements = []
|
| 32 |
+
if chart_bytes:
|
| 33 |
+
elements.append(cl.Image(content=chart_bytes, name="chart", display="inline"))
|
| 34 |
+
|
| 35 |
+
# Format the Text Response
|
| 36 |
if status == "FAIL":
|
| 37 |
+
# Rejection (Firewall)
|
|
|
|
|
|
|
| 38 |
response = f"""
|
| 39 |
+
❌ **REJECTED**: {result.get('financial_data', {}).get('reason')}
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
*No email sent. No chart drawn.*
|
| 42 |
"""
|
| 43 |
+
elif status == "PASS":
|
| 44 |
+
# Success (Analysis + Chart + Email)
|
| 45 |
response = f"""
|
| 46 |
✅ **PASSED FIREWALL**
|
| 47 |
|
| 48 |
+
{report}
|
|
|
|
| 49 |
|
| 50 |
+
---
|
| 51 |
+
**System Status:**
|
| 52 |
+
{email_msg if email_msg else "📧 Email not sent (Check keys)"}
|
| 53 |
+
"""
|
| 54 |
+
else:
|
| 55 |
+
# Just Chatting
|
| 56 |
+
response = report
|
| 57 |
+
|
| 58 |
+
# Send everything to the UI
|
| 59 |
+
await cl.Message(content=response, elements=elements).send()
|
| 60 |
|
| 61 |
except Exception as e:
|
| 62 |
await cl.Message(content=f"⚠️ Error: {str(e)}").send()
|
requirements.txt
CHANGED
|
@@ -7,4 +7,6 @@ langgraph
|
|
| 7 |
yfinance
|
| 8 |
duckduckgo-search
|
| 9 |
openai
|
| 10 |
-
requests
|
|
|
|
|
|
|
|
|
| 7 |
yfinance
|
| 8 |
duckduckgo-search
|
| 9 |
openai
|
| 10 |
+
requests
|
| 11 |
+
matplotlib
|
| 12 |
+
resend
|
src/agent.py
CHANGED
|
@@ -1,105 +1,96 @@
|
|
| 1 |
import os
|
| 2 |
-
from typing import TypedDict, Optional
|
| 3 |
from langgraph.graph import StateGraph, END
|
| 4 |
from langchain_core.messages import HumanMessage, SystemMessage
|
| 5 |
import requests
|
| 6 |
from src.llm import get_llm
|
| 7 |
from src.finance_tools import check_financial_health
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
# --- 1. STATE
|
| 10 |
class AgentState(TypedDict):
|
| 11 |
ticker: str
|
| 12 |
status: str
|
| 13 |
financial_data: Optional[dict]
|
| 14 |
final_report: Optional[str]
|
|
|
|
|
|
|
| 15 |
|
| 16 |
llm = get_llm()
|
| 17 |
|
| 18 |
-
# --- 2.
|
| 19 |
def brave_market_search(query: str):
|
| 20 |
-
"""
|
| 21 |
api_key = os.getenv("BRAVE_API_KEY")
|
| 22 |
-
if not api_key:
|
| 23 |
-
return "Brave API Key missing. Skipping web search."
|
| 24 |
|
| 25 |
url = "https://api.search.brave.com/res/v1/web/search"
|
| 26 |
-
headers = {
|
| 27 |
-
|
| 28 |
-
"X-Subscription-Token": api_key
|
| 29 |
-
}
|
| 30 |
-
params = {"q": f"{query} stock market news sentiment", "count": 5}
|
| 31 |
|
| 32 |
try:
|
| 33 |
-
|
| 34 |
-
results =
|
| 35 |
-
|
| 36 |
-
return "\n".join(snippets) if snippets else "No recent news found."
|
| 37 |
except Exception as e:
|
| 38 |
-
return f"Search
|
| 39 |
|
| 40 |
# --- 3. NODES ---
|
| 41 |
-
|
| 42 |
def check_health(state: AgentState):
|
| 43 |
-
"""The Logic Firewall Node."""
|
| 44 |
ticker = state['ticker'].upper()
|
| 45 |
result = check_financial_health(ticker)
|
| 46 |
return {"financial_data": result, "status": result['status']}
|
| 47 |
|
| 48 |
async def analyze_stock(state: AgentState):
|
| 49 |
-
|
| 50 |
-
if state['status'] == "FAIL":
|
| 51 |
-
return state
|
| 52 |
|
| 53 |
ticker = state['ticker'].upper()
|
| 54 |
-
# Use Brave for real-time data
|
| 55 |
-
market_news = brave_market_search(ticker)
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
{
|
|
|
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
async def chat_mode(state: AgentState):
|
| 72 |
-
|
| 73 |
-
prompt = [
|
| 74 |
-
SystemMessage(content="You are PrimoGreedy, a witty and skeptical financial assistant. Keep it brief."),
|
| 75 |
-
HumanMessage(content=state['ticker'])
|
| 76 |
-
]
|
| 77 |
-
response = await llm.ainvoke(prompt)
|
| 78 |
return {"final_report": response.content, "status": "CHAT"}
|
| 79 |
|
| 80 |
-
# --- 4.
|
| 81 |
def route_query(state: AgentState):
|
| 82 |
query = state['ticker'].strip().upper()
|
| 83 |
-
# Simple logic: If 1-5 chars and no spaces, it's likely a ticker
|
| 84 |
if 1 <= len(query) <= 5 and " " not in query:
|
| 85 |
return "financial_health_check"
|
| 86 |
return "chat_mode"
|
| 87 |
|
| 88 |
-
# --- 5.
|
| 89 |
workflow = StateGraph(AgentState)
|
| 90 |
-
|
| 91 |
workflow.add_node("financial_health_check", check_health)
|
| 92 |
workflow.add_node("analyst_research", analyze_stock)
|
| 93 |
workflow.add_node("chat_mode", chat_mode)
|
| 94 |
|
| 95 |
-
workflow.set_conditional_entry_point(
|
| 96 |
-
route_query,
|
| 97 |
-
{
|
| 98 |
-
"financial_health_check": "financial_health_check",
|
| 99 |
-
"chat_mode": "chat_mode"
|
| 100 |
-
}
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
workflow.add_edge("financial_health_check", "analyst_research")
|
| 104 |
workflow.add_edge("analyst_research", END)
|
| 105 |
workflow.add_edge("chat_mode", END)
|
|
|
|
| 1 |
import os
|
| 2 |
+
from typing import TypedDict, Optional, Any
|
| 3 |
from langgraph.graph import StateGraph, END
|
| 4 |
from langchain_core.messages import HumanMessage, SystemMessage
|
| 5 |
import requests
|
| 6 |
from src.llm import get_llm
|
| 7 |
from src.finance_tools import check_financial_health
|
| 8 |
+
from src.viz import get_stock_chart
|
| 9 |
+
from src.email_utils import send_email_report
|
| 10 |
|
| 11 |
+
# --- 1. STATE ---
|
| 12 |
class AgentState(TypedDict):
|
| 13 |
ticker: str
|
| 14 |
status: str
|
| 15 |
financial_data: Optional[dict]
|
| 16 |
final_report: Optional[str]
|
| 17 |
+
chart_data: Optional[Any]
|
| 18 |
+
email_status: Optional[str]
|
| 19 |
|
| 20 |
llm = get_llm()
|
| 21 |
|
| 22 |
+
# --- 2. TOOLS ---
|
| 23 |
def brave_market_search(query: str):
|
| 24 |
+
"""Uses Brave API to find real-time news."""
|
| 25 |
api_key = os.getenv("BRAVE_API_KEY")
|
| 26 |
+
if not api_key: return "⚠️ Brave Key Missing."
|
|
|
|
| 27 |
|
| 28 |
url = "https://api.search.brave.com/res/v1/web/search"
|
| 29 |
+
headers = {"X-Subscription-Token": api_key}
|
| 30 |
+
params = {"q": f"{query} stock news sentiment", "count": 3}
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
try:
|
| 33 |
+
data = requests.get(url, headers=headers, params=params).json()
|
| 34 |
+
results = data.get("web", {}).get("results", [])
|
| 35 |
+
return "\n".join([f"- {r['title']}" for r in results])
|
|
|
|
| 36 |
except Exception as e:
|
| 37 |
+
return f"Search Error: {str(e)}"
|
| 38 |
|
| 39 |
# --- 3. NODES ---
|
|
|
|
| 40 |
def check_health(state: AgentState):
|
|
|
|
| 41 |
ticker = state['ticker'].upper()
|
| 42 |
result = check_financial_health(ticker)
|
| 43 |
return {"financial_data": result, "status": result['status']}
|
| 44 |
|
| 45 |
async def analyze_stock(state: AgentState):
|
| 46 |
+
if state['status'] == "FAIL": return state
|
|
|
|
|
|
|
| 47 |
|
| 48 |
ticker = state['ticker'].upper()
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
# 1. Run Tools
|
| 51 |
+
news = brave_market_search(ticker)
|
| 52 |
+
chart = get_stock_chart(ticker)
|
| 53 |
|
| 54 |
+
# 2. AI Analysis
|
| 55 |
+
prompt = f"""
|
| 56 |
+
Analyze {ticker}.
|
| 57 |
+
Financials: {state['financial_data']['reason']}
|
| 58 |
+
Real-Time News (Brave): {news}
|
| 59 |
|
| 60 |
+
Give a recommendation (BUY/HOLD/SELL) and detailed reasoning.
|
| 61 |
"""
|
| 62 |
+
response = await llm.ainvoke([
|
| 63 |
+
SystemMessage(content="You are PrimoGreedy. Sarcastic, skeptical, data-driven."),
|
| 64 |
+
HumanMessage(content=prompt)
|
| 65 |
+
])
|
| 66 |
+
|
| 67 |
+
# 3. Send Email (Only if it passed the firewall)
|
| 68 |
+
email_result = send_email_report(ticker, response.content)
|
| 69 |
|
| 70 |
+
return {
|
| 71 |
+
"final_report": response.content,
|
| 72 |
+
"chart_data": chart,
|
| 73 |
+
"email_status": email_result
|
| 74 |
+
}
|
| 75 |
|
| 76 |
async def chat_mode(state: AgentState):
|
| 77 |
+
response = await llm.ainvoke([HumanMessage(content=state['ticker'])])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return {"final_report": response.content, "status": "CHAT"}
|
| 79 |
|
| 80 |
+
# --- 4. ROUTER ---
|
| 81 |
def route_query(state: AgentState):
|
| 82 |
query = state['ticker'].strip().upper()
|
|
|
|
| 83 |
if 1 <= len(query) <= 5 and " " not in query:
|
| 84 |
return "financial_health_check"
|
| 85 |
return "chat_mode"
|
| 86 |
|
| 87 |
+
# --- 5. GRAPH ---
|
| 88 |
workflow = StateGraph(AgentState)
|
|
|
|
| 89 |
workflow.add_node("financial_health_check", check_health)
|
| 90 |
workflow.add_node("analyst_research", analyze_stock)
|
| 91 |
workflow.add_node("chat_mode", chat_mode)
|
| 92 |
|
| 93 |
+
workflow.set_conditional_entry_point(route_query, {"financial_health_check": "financial_health_check", "chat_mode": "chat_mode"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
workflow.add_edge("financial_health_check", "analyst_research")
|
| 95 |
workflow.add_edge("analyst_research", END)
|
| 96 |
workflow.add_edge("chat_mode", END)
|
src/email_utils.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import resend
|
| 3 |
+
|
| 4 |
+
def send_email_report(ticker: str, report: str):
|
| 5 |
+
"""
|
| 6 |
+
Sends an email alert using Resend.
|
| 7 |
+
"""
|
| 8 |
+
api_key = os.getenv("RESEND_API_KEY")
|
| 9 |
+
target_email = os.getenv("TARGET_EMAIL") # We will set this in Hugging Face Secrets
|
| 10 |
+
|
| 11 |
+
if not api_key or not target_email:
|
| 12 |
+
return "⚠️ Email skipped: Missing API Key or Target Email."
|
| 13 |
+
|
| 14 |
+
resend.api_key = api_key
|
| 15 |
+
|
| 16 |
+
# Format the email body (Convert newlines to HTML breaks)
|
| 17 |
+
html_body = f"""
|
| 18 |
+
<h1>PrimoGreedy Report: {ticker}</h1>
|
| 19 |
+
<p><strong>Status:</strong> PASSED FIREWALL</p>
|
| 20 |
+
<hr>
|
| 21 |
+
<div style="font-family: monospace; white-space: pre-wrap;">
|
| 22 |
+
{report}
|
| 23 |
+
</div>
|
| 24 |
+
<hr>
|
| 25 |
+
<p><em>Generated by your AI Agent.</em></p>
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
params = {
|
| 29 |
+
"from": "PrimoGreedy <onboarding@resend.dev>", # Default testing domain
|
| 30 |
+
"to": [target_email],
|
| 31 |
+
"subject": f"💰 Trade Alert: {ticker} Analysis",
|
| 32 |
+
"html": html_body
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
email = resend.Emails.send(params)
|
| 37 |
+
return f"📧 Email sent! (ID: {email.get('id')})"
|
| 38 |
+
except Exception as e:
|
| 39 |
+
return f"⚠️ Email failed: {str(e)}"
|
src/viz.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import matplotlib.pyplot as plt
|
| 2 |
+
import yfinance as yf
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
def get_stock_chart(ticker: str):
|
| 6 |
+
"""
|
| 7 |
+
Generates a 6-month price chart and returns the image as bytes.
|
| 8 |
+
"""
|
| 9 |
+
try:
|
| 10 |
+
stock = yf.Ticker(ticker)
|
| 11 |
+
hist = stock.history(period="6mo")
|
| 12 |
+
|
| 13 |
+
if hist.empty: return None
|
| 14 |
+
|
| 15 |
+
plt.figure(figsize=(10, 5))
|
| 16 |
+
plt.plot(hist.index, hist['Close'], label='Price', color='#00ff00', linewidth=2)
|
| 17 |
+
plt.title(f"{ticker} - 6 Month Trend", color='white')
|
| 18 |
+
plt.grid(True, alpha=0.3)
|
| 19 |
+
|
| 20 |
+
# Dark Mode Style
|
| 21 |
+
ax = plt.gca()
|
| 22 |
+
ax.set_facecolor('#0e1117')
|
| 23 |
+
plt.gcf().set_facecolor('#0e1117')
|
| 24 |
+
ax.tick_params(colors='white')
|
| 25 |
+
|
| 26 |
+
# Save to RAM
|
| 27 |
+
buf = io.BytesIO()
|
| 28 |
+
plt.savefig(buf, format='png', bbox_inches='tight')
|
| 29 |
+
plt.close()
|
| 30 |
+
buf.seek(0)
|
| 31 |
+
return buf.getvalue()
|
| 32 |
+
|
| 33 |
+
except:
|
| 34 |
+
return None
|
test_llm.py
CHANGED
|
@@ -4,7 +4,7 @@ print("\n--- 🧠 PRIMOGREEDY BRAIN TEST ---\n")
|
|
| 4 |
|
| 5 |
try:
|
| 6 |
llm = get_llm()
|
| 7 |
-
print("🤖 Asking
|
| 8 |
|
| 9 |
response = llm.invoke("What is the single most dangerous risk for a bank? Answer in 1 sentence.")
|
| 10 |
|
|
|
|
| 4 |
|
| 5 |
try:
|
| 6 |
llm = get_llm()
|
| 7 |
+
print("🤖 Asking Agent: 'What is the most dangerous risk for a bank?'...")
|
| 8 |
|
| 9 |
response = llm.invoke("What is the single most dangerous risk for a bank? Answer in 1 sentence.")
|
| 10 |
|