PublicAlpha / app.py
GabrielSalem's picture
Update app.py
daa0e59 verified
raw
history blame
18.9 kB
#!/usr/bin/env python3
"""
AURA Chat — Gradio Space (single-file)
- Fixed model, tokens, and scrape delay (not editable in UI).
- User supplies data prompts (one per line) and presses Analyze.
- App scrapes via SCRAPER_API_URL, runs LLM analysis, returns a polished "Top picks" analysis
with Investment Duration (When to Invest / When to Sell) for each stock.
- The analysis seeds a chat conversation; the user can then ask follow-ups referencing the analysis.
- Robust lifecycle: creates/ closes OpenAI client per-call and tries to avoid asyncio fd shutdown warnings.
"""
import os
import sys
import time
import asyncio
import requests
import atexit
import traceback
import gc
import socket
from datetime import datetime
from typing import List
import gradio as gr
# Defensive: make a fresh event loop early to avoid fd race during interpreter shutdown
if sys.platform != "win32":
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
except Exception:
traceback.print_exc()
# -----------------------
# Fixed configuration (locked)
# -----------------------
SCRAPER_API_URL = os.getenv("SCRAPER_API_URL", "https://deep-scraper-96.created.app/api/deep-scrape")
SCRAPER_HEADERS = {"User-Agent": "Mozilla/5.0", "Content-Type": "application/json"}
# Locked model & tokens & delay (not editable from UI)
LLM_MODEL = os.getenv("LLM_MODEL", "openai/gpt-oss-20b:free")
MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "3000"))
SCRAPE_DELAY = float(os.getenv("SCRAPE_DELAY", "1.0"))
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://openrouter.ai/api/v1")
# Attempt to import OpenAI client class (SDK must be installed)
try:
from openai import OpenAI
except Exception:
OpenAI = None
# -----------------------
# System prompt (locked)
# -----------------------
PROMPT_TEMPLATE = f"""
You are AURA, a concise, professional hedge-fund research assistant.
Task:
- Given scraped data below, produce a clear, readable analysis that:
1) Lists the top 5 stock picks (or fewer if not enough data).
2) For each stock provide: Ticker / Company name, 2 short rationale bullets,
and an explicit **Investment Duration** entry: one-line "When to Invest" and one-line "When to Sell".
3) Provide a 2–3 sentence summary conclusion at the top.
4) After the list, include a concise "Assumptions & Risks" section (2–3 bullets).
5) Use clean, scannable formatting (numbered list, bold headers). No JSON. Human-readable.
Model: {LLM_MODEL}
Max tokens: {MAX_TOKENS}
"""
# -----------------------
# Scraping helpers
# -----------------------
def deep_scrape(query: str, retries: int = 3, timeout: int = 40) -> str:
payload = {"query": query}
last_err = None
for attempt in range(1, retries + 1):
try:
resp = requests.post(SCRAPER_API_URL, headers=SCRAPER_HEADERS, json=payload, timeout=timeout)
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
pieces = []
for k, v in data.items():
pieces.append(f"{k.upper()}:\n{v}\n")
return "\n".join(pieces)
return str(data)
except Exception as e:
last_err = e
if attempt < retries:
time.sleep(1.0)
else:
return f"ERROR: Scraper failed: {e}"
return f"ERROR: {last_err}"
def multi_scrape(queries: List[str], delay: float = SCRAPE_DELAY) -> str:
aggregated = []
for q in queries:
q = q.strip()
if not q:
continue
aggregated.append(f"\n=== QUERY: {q} ===\n")
scraped = deep_scrape(q)
aggregated.append(scraped)
time.sleep(delay)
return "\n".join(aggregated)
# -----------------------
# LLM call (safe create/close per-call)
# -----------------------
def run_llm_system_and_user(system_prompt: str, user_text: str, model: str = LLM_MODEL, max_tokens: int = MAX_TOKENS) -> str:
if OpenAI is None:
return "ERROR: `openai` package not installed (see requirements)."
if not OPENAI_API_KEY:
return "ERROR: OPENAI_API_KEY not set in environment."
client = None
try:
client = OpenAI(base_url=OPENAI_BASE_URL, api_key=OPENAI_API_KEY)
completion = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
max_tokens=max_tokens,
)
# Guarded extraction
if hasattr(completion, "choices") and len(completion.choices) > 0:
try:
return completion.choices[0].message.content
except Exception:
return str(completion.choices[0])
return str(completion)
except Exception as e:
return f"ERROR: LLM call failed: {e}"
finally:
try:
if client is not None:
try:
client.close()
except Exception:
try:
asyncio.get_event_loop().run_until_complete(client.aclose())
except Exception:
pass
except Exception:
pass
# -----------------------
# Pipeline functions (Gradio-friendly: use message dicts)
# -----------------------
def analyze_and_seed_chat(prompts_text: str):
"""
Returns: analysis_text (string), initial_chat (list of message dicts)
message dicts: {"role": "user"|"assistant", "content": "..."}
"""
if not prompts_text or not prompts_text.strip():
return "Please enter at least one prompt (query) describing what data to gather.", []
queries = [line.strip() for line in prompts_text.splitlines() if line.strip()]
scraped = multi_scrape(queries, delay=SCRAPE_DELAY)
if scraped.startswith("ERROR"):
return scraped, []
user_payload = f"SCRAPED DATA:\n\n{scraped}\n\nPlease produce the analysis as instructed in the system prompt."
analysis = run_llm_system_and_user(PROMPT_TEMPLATE, user_payload)
if analysis.startswith("ERROR"):
return analysis, []
initial_chat = [
{"role": "user", "content": f"Analyze the data provided (prompts: {', '.join(queries)})"},
{"role": "assistant", "content": analysis},
]
return analysis, initial_chat
def continue_chat(chat_messages: List[dict], user_message: str, analysis_text: str) -> List[dict]:
"""
Appends user message and assistant response, returns updated list of message dicts.
"""
if chat_messages is None:
chat_messages = []
if not user_message or not user_message.strip():
return chat_messages
# Append user message
chat_messages.append({"role": "user", "content": user_message})
followup_system = (
"You are AURA, a helpful analyst. Use the provided analysis as the authoritative context. "
"Answer follow-up questions about the analysis, explain rationale, and be concise and actionable."
)
user_payload = f"REFERENCE ANALYSIS:\n\n{analysis_text}\n\nUSER QUESTION: {user_message}\n\nAnswer concisely."
assistant_reply = run_llm_system_and_user(followup_system, user_payload)
chat_messages.append({"role": "assistant", "content": assistant_reply})
return chat_messages
# -----------------------
# Aggressive cleanup to reduce 'Invalid file descriptor: -1' noise at shutdown
# -----------------------
def _aggressive_cleanup():
try:
gc.collect()
except Exception:
pass
try:
loop = asyncio.get_event_loop()
if loop.is_running():
try:
loop.stop()
except Exception:
pass
if not loop.is_closed():
try:
loop.close()
except Exception:
pass
except Exception:
pass
# Close any lingering sockets found via GC (best-effort)
try:
for obj in gc.get_objects():
try:
if isinstance(obj, socket.socket):
try:
obj.close()
except Exception:
pass
except Exception:
pass
# -----------------------
# Beautiful responsive UI (single build function)
# -----------------------
def build_demo():
with gr.Blocks(title="AURA Chat — Hedge Fund Picks") as demo:
# Inject responsive CSS & fonts
gr.HTML("""
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0f1724;
--card:#0b1220;
--muted:#9aa4b2;
--accent:#6ee7b7;
--glass: rgba(255,255,255,0.03);
}
body, .gradio-container { font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; background: linear-gradient(180deg,#071028 0%, #071831 100%); color: #e6eef6; }
.container { max-width:1200px; margin:18px auto; padding:18px; }
.topbar { display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom:12px; }
.brand { display:flex; gap:12px; align-items:center; }
.logo { width:48px; height:48px; border-radius:10px; background:linear-gradient(135deg,#10b981,#06b6d4); display:flex; align-items:center; justify-content:center; font-weight:700; color:#021028; font-size:18px; box-shadow:0 8px 30px rgba(2,16,40,0.6); }
.title { font-size:20px; font-weight:700; margin:0; }
.subtitle { color:var(--muted); font-size:13px; margin-top:2px; }
.panel { background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); border-radius:12px; padding:14px; box-shadow: 0 6px 30px rgba(2,6,23,0.7); border:1px solid rgba(255,255,255,0.03); }
.left { min-width: 300px; max-width: 520px; }
.right { flex:1; }
.analysis-card { background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); padding:14px; border-radius:10px; min-height:220px; overflow:auto; }
.muted { color:var(--muted); font-size:13px; }
.small { font-size:12px; color:var(--muted); }
.button-row { display:flex; gap:10px; margin-top:10px; }
.pill { display:inline-block; background: rgba(255,255,255,0.03); padding:6px 10px; border-radius:999px; color:var(--muted); font-size:13px; }
.chat-container { height:420px; overflow:auto; border-radius:10px; padding:8px; background: linear-gradient(180deg, rgba(255,255,255,0.01), rgba(255,255,255,0.005)); border:1px solid rgba(255,255,255,0.03); }
/* Responsive */
@media (max-width: 880px){
.topbar { flex-direction:column; align-items:flex-start; gap:6px; }
.layout-row { flex-direction:column; gap:12px; }
}
</style>
""")
# Top bar / header
with gr.Row(elem_id="top-row"):
with gr.Column(scale=1):
gr.HTML(
"""
<div class="container">
<div class="topbar">
<div class="brand">
<div class="logo">A</div>
<div>
<div class="title">AURA — Hedge Fund Picks</div>
<div class="subtitle">Scrape • Synthesize • Serve concise investment durations</div>
</div>
</div>
<div class="small">Model locked • Max tokens locked • Delay locked</div>
</div>
</div>
"""
)
# Main layout
with gr.Row(elem_classes="layout-row", visible=True):
# Left column: inputs
with gr.Column(scale=1, min_width=320, elem_classes="left"):
with gr.Group(elem_classes="panel"):
gr.Markdown("### Data prompts")
prompts = gr.Textbox(lines=6, placeholder="SEC insider transactions october 2025\n13F filings Q3 2025\ncompany: ACME corp insider buys", label=None)
gr.Markdown("**Only provide prompts**. Model, tokens and scrape delay are fixed.")
with gr.Row():
analyze_btn = gr.Button("Analyze", variant="primary")
clear_btn = gr.Button("Clear", variant="secondary")
gr.Markdown("**Status**")
status = gr.Markdown("Idle", elem_id="status-box")
gr.Markdown("**Settings**")
gr.HTML(f"<div class='pill'>Model: {LLM_MODEL}</div> <div class='pill'>Max tokens: {MAX_TOKENS}</div> <div class='pill'>Delay: {SCRAPE_DELAY}s</div>")
# Right column: analysis + chat
with gr.Column(scale=2, min_width=420, elem_classes="right"):
with gr.Group(elem_classes="panel"):
gr.Markdown("### Generated Analysis")
analysis_html = gr.HTML("<div class='analysis-card muted'>No analysis yet. Enter prompts and press <strong>Analyze</strong>.</div>")
gr.Markdown("### Chat (ask follow-ups about the analysis)")
chatbot = gr.Chatbot(elem_classes="chat-container", label=None)
with gr.Row():
user_input = gr.Textbox(placeholder="Ask a follow-up question about the analysis...", label=None)
send_btn = gr.Button("Send", variant="primary")
# Hidden states
analysis_state = gr.State("") # string
chat_state = gr.State([]) # list of message dicts
# ---- Handler functions (defined in scope) ----
def set_status(text: str):
# small helper to update status markdown box
return gr.update(value=f"**{text}**")
def on_clear():
return "", gr.update(value="<div class='analysis-card muted'>No analysis yet. Enter prompts and press <strong>Analyze</strong>.</div>"), [], gr.update(value=[]), set_status("Cleared")
def on_analyze(prompts_text: str):
# Start
status_msg = "Scraping..."
analysis_preview = "<div class='analysis-card muted'>Working... scraping data and calling model. This may take a few seconds.</div>"
# Immediately return a quick UI update while heavy work runs (Gradio will process synchronously).
# Now run real pipeline:
try:
status_msg = "Scraping..."
# update status for UI
# Collect queries
queries = [line.strip() for line in (prompts_text or "").splitlines() if line.strip()]
if not queries:
return "", gr.update(value="<div class='analysis-card muted'>Please provide at least one data prompt.</div>"), [], [], set_status("Idle")
scraped = multi_scrape(queries, delay=SCRAPE_DELAY)
if scraped.startswith("ERROR"):
return "", gr.update(value=f"<div class='analysis-card muted'><strong>Error:</strong> {scraped}</div>"), [], [], set_status("Scrape error")
status_msg = "Generating analysis (LLM)..."
user_payload = f"SCRAPED DATA:\n\n{scraped}\n\nPlease produce the analysis as instructed in the system prompt."
analysis_text = run_llm_system_and_user(PROMPT_TEMPLATE, user_payload)
if analysis_text.startswith("ERROR"):
return "", gr.update(value=f"<div class='analysis-card muted'><strong>Error:</strong> {analysis_text}</div>"), [], [], set_status("LLM error")
# Build nicely formatted HTML preview (we display the raw LLM text wrapped in <pre> for readability)
safe_html = "<div class='analysis-card'><pre style='white-space:pre-wrap; font-family:Inter, monospace; font-size:14px; color:#dfeefc;'>" + \
gr.escape(analysis_text) + "</pre></div>"
# Seed chat messages: user + assistant
initial_chat = [
{"role": "user", "content": f"Analyze the data provided (prompts: {', '.join(queries)})"},
{"role": "assistant", "content": analysis_text},
]
return analysis_text, gr.update(value=safe_html), initial_chat, initial_chat, set_status("Done")
except Exception as e:
tb = traceback.format_exc()
return "", gr.update(value=f"<div class='analysis-card muted'><strong>Unexpected error:</strong> {e}</div>"), [], [], set_status("Error")
def on_send(chat_messages: List[dict], user_msg: str, analysis_text: str):
if not user_msg or not user_msg.strip():
return chat_messages or [], ""
# Append and get updated messages
updated = continue_chat(chat_messages or [], user_msg, analysis_text or "")
return updated, ""
def render_chat(chat_messages: List[dict]):
"""
Gradio Chatbot in some versions accepts list of dicts {"role","content"}.
We will return list of dicts unchanged where possible. If Gradio fails,
it will raise — but previously we fixed to produce dicts.
"""
if not chat_messages:
return []
# Return as-is
return chat_messages
# ---- Wire up events ----
analyze_btn.click(
fn=on_analyze,
inputs=[prompts],
outputs=[analysis_state, analysis_html, chat_state, chatbot, status],
)
clear_btn.click(
fn=on_clear,
inputs=[],
outputs=[prompts, analysis_html, chat_state, chatbot, status],
)
send_btn.click(
fn=on_send,
inputs=[chat_state, user_input, analysis_state],
outputs=[chat_state, user_input],
)
user_input.submit(
fn=on_send,
inputs=[chat_state, user_input, analysis_state],
outputs=[chat_state, user_input],
)
# Keep chatbot UI updated
chat_state.change(fn=render_chat, inputs=[chat_state], outputs=[chatbot])
return demo
# -----------------------
# Run
# -----------------------
if __name__ == "__main__":
demo = build_demo()
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))