Spaces:
Running
Running
| #!/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))) | |