Spaces:
Runtime error
Runtime error
Upload 18 files
Browse files- app.py +29 -0
- emailer.py +128 -0
app.py
CHANGED
|
@@ -17,6 +17,7 @@ import gradio as gr
|
|
| 17 |
import pandas as pd
|
| 18 |
|
| 19 |
import automation
|
|
|
|
| 20 |
import finetune_data
|
| 21 |
import llm_local
|
| 22 |
import news_watch
|
|
@@ -211,6 +212,11 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 211 |
explain_btn = gr.Button("🤖 AI summary (local LLM)", scale=1)
|
| 212 |
detail_box = gr.Markdown(label="Raw read")
|
| 213 |
explain_box = gr.Markdown(elem_classes=["ai-panel"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
with gr.Tab("🔄 Sector Rotation"):
|
| 216 |
gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
|
|
@@ -226,6 +232,11 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 226 |
rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
|
| 227 |
rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
|
| 228 |
rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["ai-panel"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
with gr.Tab("📰 Watchlist News"):
|
| 231 |
gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
|
|
@@ -238,6 +249,11 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 238 |
news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
|
| 239 |
hold_status = gr.Markdown()
|
| 240 |
news_out = gr.Markdown(elem_classes=["ai-panel"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
with gr.Tab("🧪 Auto Research"):
|
| 243 |
gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
|
|
@@ -251,6 +267,11 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 251 |
res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
|
| 252 |
res_progress = gr.Markdown()
|
| 253 |
res_out = gr.Markdown(elem_classes=["ai-panel"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
|
| 255 |
elem_classes=["s2-footnote"])
|
| 256 |
with gr.Row():
|
|
@@ -322,6 +343,14 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 322 |
save_btn.click(ui_save_holdings, hold_in, hold_status)
|
| 323 |
news_btn.click(ui_check_news, None, news_out)
|
| 324 |
res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
rep_open.click(ui_open_report, rep_pick, rep_view)
|
| 326 |
auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
|
| 327 |
load_btn.click(ui_load_model, model_pick, model_status)
|
|
|
|
| 17 |
import pandas as pd
|
| 18 |
|
| 19 |
import automation
|
| 20 |
+
import emailer
|
| 21 |
import finetune_data
|
| 22 |
import llm_local
|
| 23 |
import news_watch
|
|
|
|
| 212 |
explain_btn = gr.Button("🤖 AI summary (local LLM)", scale=1)
|
| 213 |
detail_box = gr.Markdown(label="Raw read")
|
| 214 |
explain_box = gr.Markdown(elem_classes=["ai-panel"])
|
| 215 |
+
with gr.Row():
|
| 216 |
+
sig_email = gr.Textbox(label="Email this summary to", scale=4,
|
| 217 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 218 |
+
sig_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 219 |
+
sig_email_status = gr.Markdown()
|
| 220 |
|
| 221 |
with gr.Tab("🔄 Sector Rotation"):
|
| 222 |
gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
|
|
|
|
| 232 |
rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
|
| 233 |
rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
|
| 234 |
rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["ai-panel"])
|
| 235 |
+
with gr.Row():
|
| 236 |
+
rot_email = gr.Textbox(label="Email this narrative to", scale=4,
|
| 237 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 238 |
+
rot_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 239 |
+
rot_email_status = gr.Markdown()
|
| 240 |
|
| 241 |
with gr.Tab("📰 Watchlist News"):
|
| 242 |
gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
|
|
|
|
| 249 |
news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
|
| 250 |
hold_status = gr.Markdown()
|
| 251 |
news_out = gr.Markdown(elem_classes=["ai-panel"])
|
| 252 |
+
with gr.Row():
|
| 253 |
+
news_email = gr.Textbox(label="Email this news brief to", scale=4,
|
| 254 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 255 |
+
news_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 256 |
+
news_email_status = gr.Markdown()
|
| 257 |
|
| 258 |
with gr.Tab("🧪 Auto Research"):
|
| 259 |
gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
|
|
|
|
| 267 |
res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
|
| 268 |
res_progress = gr.Markdown()
|
| 269 |
res_out = gr.Markdown(elem_classes=["ai-panel"])
|
| 270 |
+
with gr.Row():
|
| 271 |
+
res_email = gr.Textbox(label="Email this report to", scale=4,
|
| 272 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 273 |
+
res_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 274 |
+
res_email_status = gr.Markdown()
|
| 275 |
gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
|
| 276 |
elem_classes=["s2-footnote"])
|
| 277 |
with gr.Row():
|
|
|
|
| 343 |
save_btn.click(ui_save_holdings, hold_in, hold_status)
|
| 344 |
news_btn.click(ui_check_news, None, news_out)
|
| 345 |
res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
|
| 346 |
+
sig_email_btn.click(lambda body, to: emailer.send_result(body, to, "Signals summary"),
|
| 347 |
+
[explain_box, sig_email], sig_email_status)
|
| 348 |
+
rot_email_btn.click(lambda body, to: emailer.send_result(body, to, "Sector rotation"),
|
| 349 |
+
[rot_ai, rot_email], rot_email_status)
|
| 350 |
+
news_email_btn.click(lambda body, to: emailer.send_result(body, to, "Watchlist news"),
|
| 351 |
+
[news_out, news_email], news_email_status)
|
| 352 |
+
res_email_btn.click(lambda body, to: emailer.send_result(body, to, "Research report"),
|
| 353 |
+
[res_out, res_email], res_email_status)
|
| 354 |
rep_open.click(ui_open_report, rep_pick, rep_view)
|
| 355 |
auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
|
| 356 |
load_btn.click(ui_load_model, model_pick, model_status)
|
emailer.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
emailer.py — send a tab's AI result to any address (English UI + content).
|
| 3 |
+
|
| 4 |
+
Adapted from the user's send_predict_email.py: same multi-domain SMTP
|
| 5 |
+
auto-detection, plain+HTML multipart, but English subjects/labels and a generic
|
| 6 |
+
`send_result()` the Gradio tabs call.
|
| 7 |
+
|
| 8 |
+
Sender credentials live here (a Gmail App Password). To change the sender, edit
|
| 9 |
+
EMAIL_SENDER / EMAIL_PASSWORD. You can also override them with the environment
|
| 10 |
+
variables CHAN_EMAIL_SENDER / CHAN_EMAIL_PASSWORD on the Space (recommended:
|
| 11 |
+
store the App Password as a Space *secret* rather than in code).
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import html
|
| 17 |
+
import logging
|
| 18 |
+
import smtplib
|
| 19 |
+
import re
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
from email.mime.text import MIMEText
|
| 22 |
+
from email.mime.multipart import MIMEMultipart
|
| 23 |
+
from email.header import Header
|
| 24 |
+
from email.utils import formataddr
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger("chan_emailer")
|
| 27 |
+
|
| 28 |
+
# ── sender account (override via Space secrets if you prefer) ──
|
| 29 |
+
EMAIL_SENDER = os.environ.get("CHAN_EMAIL_SENDER", "cz78illinoisedu@gmail.com")
|
| 30 |
+
EMAIL_PASSWORD = os.environ.get("CHAN_EMAIL_PASSWORD", "tknl qpmb gzye zlva")
|
| 31 |
+
|
| 32 |
+
SMTP_CONFIGS = {
|
| 33 |
+
"gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True},
|
| 34 |
+
"qq.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
|
| 35 |
+
"163.com": {"server": "smtp.163.com", "port": 465, "ssl": True},
|
| 36 |
+
"126.com": {"server": "smtp.126.com", "port": 465, "ssl": True},
|
| 37 |
+
"outlook.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
|
| 38 |
+
"hotmail.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
|
| 39 |
+
"foxmail.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
|
| 40 |
+
"sina.com": {"server": "smtp.sina.com", "port": 465, "ssl": True},
|
| 41 |
+
"aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "ssl": True},
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _sender_address(sender: str) -> str:
|
| 48 |
+
return formataddr((str(Header("Chan Compass", "utf-8")), sender))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _md_to_html(text: str) -> str:
|
| 52 |
+
"""Wrap the result (markdown-ish text) in a clean monospace HTML block."""
|
| 53 |
+
safe = html.escape(text)
|
| 54 |
+
return (
|
| 55 |
+
'<html><body style="margin:0;padding:16px;background:#f6f7f9;">'
|
| 56 |
+
'<div style="font-family:Menlo,Consolas,monospace;font-size:13px;'
|
| 57 |
+
'line-height:1.55;white-space:pre-wrap;word-wrap:break-word;color:#1a1a1a;'
|
| 58 |
+
'background:#ffffff;padding:18px 22px;border-radius:10px;'
|
| 59 |
+
'border:1px solid #e3e6ea;max-width:760px;">'
|
| 60 |
+
f'{safe}'
|
| 61 |
+
'</div>'
|
| 62 |
+
'<p style="font-family:sans-serif;font-size:11px;color:#8a8a8a;'
|
| 63 |
+
'margin:10px 0 0;">Sent by Chan Compass · educational tool, not '
|
| 64 |
+
'investment advice.</p>'
|
| 65 |
+
'</body></html>'
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _parse_recipients(raw: str) -> list:
|
| 70 |
+
parts = re.split(r"[,;\s]+", (raw or "").strip())
|
| 71 |
+
return [p for p in parts if _EMAIL_RE.match(p)]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _close(server):
|
| 75 |
+
if server is not None:
|
| 76 |
+
try:
|
| 77 |
+
server.quit()
|
| 78 |
+
except Exception:
|
| 79 |
+
try:
|
| 80 |
+
server.close()
|
| 81 |
+
except Exception:
|
| 82 |
+
pass
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def send_result(content: str, recipients_raw: str, subject_tag: str) -> str:
|
| 86 |
+
"""Send `content` to the address(es) in `recipients_raw`. Returns a status
|
| 87 |
+
string for the UI. Plain English throughout."""
|
| 88 |
+
if not content or not content.strip():
|
| 89 |
+
return "⚠️ Nothing to send yet — generate a result first."
|
| 90 |
+
if content.strip().startswith(("⏳", "🤖 _", "Run ", "Enter ", "Select ")):
|
| 91 |
+
return "⚠️ Wait for the AI result to finish, then send."
|
| 92 |
+
recipients = _parse_recipients(recipients_raw)
|
| 93 |
+
if not recipients:
|
| 94 |
+
return "⚠️ Enter a valid email address (e.g. name@example.com)."
|
| 95 |
+
if not EMAIL_SENDER or not EMAIL_PASSWORD:
|
| 96 |
+
return "⚠️ Sender email is not configured on the server."
|
| 97 |
+
|
| 98 |
+
subject = (f"Chan Compass · {subject_tag} · "
|
| 99 |
+
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
| 100 |
+
msg = MIMEMultipart("alternative")
|
| 101 |
+
msg["Subject"] = Header(subject, "utf-8")
|
| 102 |
+
msg["From"] = _sender_address(EMAIL_SENDER)
|
| 103 |
+
msg["To"] = ", ".join(recipients)
|
| 104 |
+
msg.attach(MIMEText(content, "plain", "utf-8"))
|
| 105 |
+
msg.attach(MIMEText(_md_to_html(content), "html", "utf-8"))
|
| 106 |
+
|
| 107 |
+
domain = EMAIL_SENDER.split("@")[-1].lower()
|
| 108 |
+
sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True})
|
| 109 |
+
server = None
|
| 110 |
+
try:
|
| 111 |
+
if sc["ssl"]:
|
| 112 |
+
server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=30)
|
| 113 |
+
else:
|
| 114 |
+
server = smtplib.SMTP(sc["server"], sc["port"], timeout=30)
|
| 115 |
+
server.starttls()
|
| 116 |
+
server.login(EMAIL_SENDER, EMAIL_PASSWORD)
|
| 117 |
+
server.send_message(msg)
|
| 118 |
+
logger.info("email sent to %s", recipients)
|
| 119 |
+
return f"✅ Sent to {', '.join(recipients)}."
|
| 120 |
+
except smtplib.SMTPAuthenticationError:
|
| 121 |
+
return ("❌ Authentication failed — check the sender account / App "
|
| 122 |
+
"Password (Gmail needs a 16-char App Password).")
|
| 123 |
+
except smtplib.SMTPConnectError as e:
|
| 124 |
+
return f"❌ Could not connect to the mail server: {e}"
|
| 125 |
+
except Exception as e:
|
| 126 |
+
return f"❌ Send failed: {e}"
|
| 127 |
+
finally:
|
| 128 |
+
_close(server)
|