GabrielSalem commited on
Commit
e5a4541
·
verified ·
1 Parent(s): ccf5a94

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +221 -288
app.py CHANGED
@@ -1,29 +1,30 @@
1
- #!/usr/bin/env python3
2
  """
3
- AURA Chat — Gradio Space (single-file)
4
- - Fixed model, tokens, and scrape delay (not editable in UI).
5
- - User supplies data prompts (one per line) and presses Analyze.
6
- - App scrapes via SCRAPER_API_URL, runs LLM analysis, returns a polished "Top picks" analysis
7
- with Investment Duration (When to Invest / When to Sell) for each stock.
8
- - The analysis seeds a chat conversation; the user can then ask follow-ups referencing the analysis.
9
- - Robust lifecycle: creates/ closes OpenAI client per-call and tries to avoid asyncio fd shutdown warnings.
 
 
 
 
10
  """
11
 
12
  import os
13
- import sys
14
  import time
 
15
  import asyncio
16
  import requests
17
  import atexit
18
  import traceback
19
- import gc
20
- import socket
21
  from datetime import datetime
22
  from typing import List
23
-
24
  import gradio as gr
25
 
26
- # Defensive: make a fresh event loop early to avoid fd race during interpreter shutdown
 
27
  if sys.platform != "win32":
28
  try:
29
  loop = asyncio.new_event_loop()
@@ -31,13 +32,17 @@ if sys.platform != "win32":
31
  except Exception:
32
  traceback.print_exc()
33
 
34
- # -----------------------
35
- # Fixed configuration (locked)
36
- # -----------------------
 
37
  SCRAPER_API_URL = os.getenv("SCRAPER_API_URL", "https://deep-scraper-96.created.app/api/deep-scrape")
38
- SCRAPER_HEADERS = {"User-Agent": "Mozilla/5.0", "Content-Type": "application/json"}
 
 
 
39
 
40
- # Locked model & tokens & delay (not editable from UI)
41
  LLM_MODEL = os.getenv("LLM_MODEL", "openai/gpt-oss-20b:free")
42
  MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "3000"))
43
  SCRAPE_DELAY = float(os.getenv("SCRAPE_DELAY", "1.0"))
@@ -45,57 +50,68 @@ SCRAPE_DELAY = float(os.getenv("SCRAPE_DELAY", "1.0"))
45
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
46
  OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://openrouter.ai/api/v1")
47
 
48
- # Attempt to import OpenAI client class (SDK must be installed)
49
- try:
50
- from openai import OpenAI
51
- except Exception:
52
- OpenAI = None
53
 
54
- # -----------------------
55
- # System prompt (locked)
56
- # -----------------------
57
- PROMPT_TEMPLATE = f"""
58
- You are AURA, a concise, professional hedge-fund research assistant.
59
 
60
  Task:
61
  - Given scraped data below, produce a clear, readable analysis that:
62
  1) Lists the top 5 stock picks (or fewer if not enough data).
63
- 2) For each stock provide: Ticker / Company name, 2 short rationale bullets,
64
- and an explicit **Investment Duration** entry: one-line "When to Invest" and one-line "When to Sell".
65
- 3) Provide a 2–3 sentence summary conclusion at the top.
66
- 4) After the list, include a concise "Assumptions & Risks" section (2–3 bullets).
67
- 5) Use clean, scannable formatting (numbered list, bold headers). No JSON. Human-readable.
68
-
69
- Model: {LLM_MODEL}
70
- Max tokens: {MAX_TOKENS}
71
- """
72
-
73
- # -----------------------
74
- # Scraping helpers
75
- # -----------------------
 
 
 
 
 
 
 
76
  def deep_scrape(query: str, retries: int = 3, timeout: int = 40) -> str:
 
77
  payload = {"query": query}
78
  last_err = None
 
79
  for attempt in range(1, retries + 1):
80
  try:
81
- resp = requests.post(SCRAPER_API_URL, headers=SCRAPER_HEADERS, json=payload, timeout=timeout)
 
 
 
 
 
82
  resp.raise_for_status()
83
  data = resp.json()
 
 
84
  if isinstance(data, dict):
85
- pieces = []
86
- for k, v in data.items():
87
- pieces.append(f"{k.upper()}:\n{v}\n")
88
- return "\n".join(pieces)
89
- return str(data)
90
  except Exception as e:
91
  last_err = e
92
  if attempt < retries:
93
  time.sleep(1.0)
94
- else:
95
- return f"ERROR: Scraper failed: {e}"
96
- return f"ERROR: {last_err}"
97
 
98
  def multi_scrape(queries: List[str], delay: float = SCRAPE_DELAY) -> str:
 
99
  aggregated = []
100
  for q in queries:
101
  q = q.strip()
@@ -107,15 +123,29 @@ def multi_scrape(queries: List[str], delay: float = SCRAPE_DELAY) -> str:
107
  time.sleep(delay)
108
  return "\n".join(aggregated)
109
 
110
- # -----------------------
111
- # LLM call (safe create/close per-call)
112
- # -----------------------
113
- def run_llm_system_and_user(system_prompt: str, user_text: str, model: str = LLM_MODEL, max_tokens: int = MAX_TOKENS) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  if OpenAI is None:
115
- return "ERROR: `openai` package not installed (see requirements)."
 
116
  if not OPENAI_API_KEY:
117
- return "ERROR: OPENAI_API_KEY not set in environment."
118
-
119
  client = None
120
  try:
121
  client = OpenAI(base_url=OPENAI_BASE_URL, api_key=OPENAI_API_KEY)
@@ -127,16 +157,19 @@ def run_llm_system_and_user(system_prompt: str, user_text: str, model: str = LLM
127
  ],
128
  max_tokens=max_tokens,
129
  )
130
- # Guarded extraction
 
131
  if hasattr(completion, "choices") and len(completion.choices) > 0:
132
  try:
133
  return completion.choices[0].message.content
134
  except Exception:
135
  return str(completion.choices[0])
136
  return str(completion)
 
137
  except Exception as e:
138
  return f"ERROR: LLM call failed: {e}"
139
  finally:
 
140
  try:
141
  if client is not None:
142
  try:
@@ -149,284 +182,184 @@ def run_llm_system_and_user(system_prompt: str, user_text: str, model: str = LLM
149
  except Exception:
150
  pass
151
 
152
- # -----------------------
153
- # Pipeline functions (Gradio-friendly: use message dicts)
154
- # -----------------------
 
155
  def analyze_and_seed_chat(prompts_text: str):
156
- """
157
- Returns: analysis_text (string), initial_chat (list of message dicts)
158
- message dicts: {"role": "user"|"assistant", "content": "..."}
159
- """
160
- if not prompts_text or not prompts_text.strip():
161
  return "Please enter at least one prompt (query) describing what data to gather.", []
162
-
163
  queries = [line.strip() for line in prompts_text.splitlines() if line.strip()]
164
  scraped = multi_scrape(queries, delay=SCRAPE_DELAY)
 
165
  if scraped.startswith("ERROR"):
166
  return scraped, []
167
-
168
- user_payload = f"SCRAPED DATA:\n\n{scraped}\n\nPlease produce the analysis as instructed in the system prompt."
 
169
  analysis = run_llm_system_and_user(PROMPT_TEMPLATE, user_payload)
 
170
  if analysis.startswith("ERROR"):
171
  return analysis, []
172
-
 
173
  initial_chat = [
174
- {"role": "user", "content": f"Analyze the data provided (prompts: {', '.join(queries)})"},
175
- {"role": "assistant", "content": analysis},
176
  ]
177
  return analysis, initial_chat
178
 
179
- def continue_chat(chat_messages: List[dict], user_message: str, analysis_text: str) -> List[dict]:
180
- """
181
- Appends user message and assistant response, returns updated list of message dicts.
182
- """
183
  if chat_messages is None:
184
  chat_messages = []
185
-
186
  if not user_message or not user_message.strip():
187
  return chat_messages
188
-
189
- # Append user message
190
  chat_messages.append({"role": "user", "content": user_message})
191
-
 
192
  followup_system = (
193
- "You are AURA, a helpful analyst. Use the provided analysis as the authoritative context. "
194
- "Answer follow-up questions about the analysis, explain rationale, and be concise and actionable."
 
 
195
  )
196
- user_payload = f"REFERENCE ANALYSIS:\n\n{analysis_text}\n\nUSER QUESTION: {user_message}\n\nAnswer concisely."
197
-
198
  assistant_reply = run_llm_system_and_user(followup_system, user_payload)
 
 
 
 
199
  chat_messages.append({"role": "assistant", "content": assistant_reply})
200
  return chat_messages
201
 
202
- # -----------------------
203
- # Aggressive cleanup to reduce 'Invalid file descriptor: -1' noise at shutdown
204
- # -----------------------
205
- def _aggressive_cleanup():
206
- try:
207
- gc.collect()
208
- except Exception:
209
- pass
210
- try:
211
- loop = asyncio.get_event_loop()
212
- if loop.is_running():
213
- try:
214
- loop.stop()
215
- except Exception:
216
- pass
217
- if not loop.is_closed():
218
- try:
219
- loop.close()
220
- except Exception:
221
- pass
222
- except Exception:
223
- pass
224
 
225
- # Close any lingering sockets found via GC (best-effort)
226
- try:
227
- for obj in gc.get_objects():
228
- try:
229
- if isinstance(obj, socket.socket):
230
- try:
231
- obj.close()
232
- except Exception:
233
- pass
234
- except Exception:
235
- pass
236
-
237
- atexit.register(_aggressive_cleanup)
238
-
239
- # -----------------------
240
- # Beautiful responsive UI (single build function)
241
- # -----------------------
242
  def build_demo():
243
  with gr.Blocks(title="AURA Chat — Hedge Fund Picks") as demo:
244
- # Inject responsive CSS & fonts
245
  gr.HTML("""
246
- <link rel="preconnect" href="https://fonts.googleapis.com">
247
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;800&display=swap" rel="stylesheet">
248
  <style>
249
- :root{
250
- --bg:#0f1724;
251
- --card:#0b1220;
252
- --muted:#9aa4b2;
253
- --accent:#6ee7b7;
254
- --glass: rgba(255,255,255,0.03);
255
- }
256
- 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; }
257
- .container { max-width:1200px; margin:18px auto; padding:18px; }
258
- .topbar { display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom:12px; }
259
- .brand { display:flex; gap:12px; align-items:center; }
260
- .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); }
261
- .title { font-size:20px; font-weight:700; margin:0; }
262
- .subtitle { color:var(--muted); font-size:13px; margin-top:2px; }
263
- .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); }
264
- .left { min-width: 300px; max-width: 520px; }
265
- .right { flex:1; }
266
- .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; }
267
- .muted { color:var(--muted); font-size:13px; }
268
- .small { font-size:12px; color:var(--muted); }
269
- .button-row { display:flex; gap:10px; margin-top:10px; }
270
- .pill { display:inline-block; background: rgba(255,255,255,0.03); padding:6px 10px; border-radius:999px; color:var(--muted); font-size:13px; }
271
- .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); }
272
- /* Responsive */
273
- @media (max-width: 880px){
274
- .topbar { flex-direction:column; align-items:flex-start; gap:6px; }
275
- .layout-row { flex-direction:column; gap:12px; }
276
- }
277
  </style>
278
  """)
279
-
280
- # Top bar / header
281
- with gr.Row(elem_id="top-row"):
 
 
 
 
 
 
282
  with gr.Column(scale=1):
283
- gr.HTML(
284
- """
285
- <div class="container">
286
- <div class="topbar">
287
- <div class="brand">
288
- <div class="logo">A</div>
289
- <div>
290
- <div class="title">AURA — Hedge Fund Picks</div>
291
- <div class="subtitle">Scrape • Synthesize • Serve concise investment durations</div>
292
- </div>
293
- </div>
294
- <div class="small">Model locked • Max tokens locked • Delay locked</div>
295
- </div>
296
- </div>
297
- """
298
  )
299
-
300
- # Main layout
301
- with gr.Row(elem_classes="layout-row", visible=True):
302
- # Left column: inputs
303
- with gr.Column(scale=1, min_width=320, elem_classes="left"):
304
- with gr.Group(elem_classes="panel"):
305
- gr.Markdown("### Data prompts")
306
- prompts = gr.Textbox(lines=6, placeholder="SEC insider transactions october 2025\n13F filings Q3 2025\ncompany: ACME corp insider buys", label=None)
307
- gr.Markdown("**Only provide prompts**. Model, tokens and scrape delay are fixed.")
308
- with gr.Row():
309
- analyze_btn = gr.Button("Analyze", variant="primary")
310
- clear_btn = gr.Button("Clear", variant="secondary")
311
- gr.Markdown("**Status**")
312
- status = gr.Markdown("Idle", elem_id="status-box")
313
- gr.Markdown("**Settings**")
314
- 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>")
315
-
316
- # Right column: analysis + chat
317
- with gr.Column(scale=2, min_width=420, elem_classes="right"):
318
- with gr.Group(elem_classes="panel"):
319
- gr.Markdown("### Generated Analysis")
320
- analysis_html = gr.HTML("<div class='analysis-card muted'>No analysis yet. Enter prompts and press <strong>Analyze</strong>.</div>")
321
- gr.Markdown("### Chat (ask follow-ups about the analysis)")
322
- chatbot = gr.Chatbot(elem_classes="chat-container", label=None)
323
- with gr.Row():
324
- user_input = gr.Textbox(placeholder="Ask a follow-up question about the analysis...", label=None)
325
- send_btn = gr.Button("Send", variant="primary")
326
-
327
- # Hidden states
328
- analysis_state = gr.State("") # string
329
- chat_state = gr.State([]) # list of message dicts
330
-
331
- # ---- Handler functions (defined in scope) ----
332
- def set_status(text: str):
333
- # small helper to update status markdown box
334
- return gr.update(value=f"**{text}**")
335
-
336
- def on_clear():
337
- 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")
338
-
339
- def on_analyze(prompts_text: str):
340
- # Start
341
- status_msg = "Scraping..."
342
- analysis_preview = "<div class='analysis-card muted'>Working... scraping data and calling model. This may take a few seconds.</div>"
343
- # Immediately return a quick UI update while heavy work runs (Gradio will process synchronously).
344
- # Now run real pipeline:
345
- try:
346
- status_msg = "Scraping..."
347
- # update status for UI
348
- # Collect queries
349
- queries = [line.strip() for line in (prompts_text or "").splitlines() if line.strip()]
350
- if not queries:
351
- return "", gr.update(value="<div class='analysis-card muted'>Please provide at least one data prompt.</div>"), [], [], set_status("Idle")
352
-
353
- scraped = multi_scrape(queries, delay=SCRAPE_DELAY)
354
- if scraped.startswith("ERROR"):
355
- return "", gr.update(value=f"<div class='analysis-card muted'><strong>Error:</strong> {scraped}</div>"), [], [], set_status("Scrape error")
356
-
357
- status_msg = "Generating analysis (LLM)..."
358
-
359
- user_payload = f"SCRAPED DATA:\n\n{scraped}\n\nPlease produce the analysis as instructed in the system prompt."
360
- analysis_text = run_llm_system_and_user(PROMPT_TEMPLATE, user_payload)
361
-
362
- if analysis_text.startswith("ERROR"):
363
- return "", gr.update(value=f"<div class='analysis-card muted'><strong>Error:</strong> {analysis_text}</div>"), [], [], set_status("LLM error")
364
-
365
- # Build nicely formatted HTML preview (we display the raw LLM text wrapped in <pre> for readability)
366
- safe_html = "<div class='analysis-card'><pre style='white-space:pre-wrap; font-family:Inter, monospace; font-size:14px; color:#dfeefc;'>" + \
367
- gr.escape(analysis_text) + "</pre></div>"
368
-
369
- # Seed chat messages: user + assistant
370
- initial_chat = [
371
- {"role": "user", "content": f"Analyze the data provided (prompts: {', '.join(queries)})"},
372
- {"role": "assistant", "content": analysis_text},
373
- ]
374
-
375
- return analysis_text, gr.update(value=safe_html), initial_chat, initial_chat, set_status("Done")
376
- except Exception as e:
377
- tb = traceback.format_exc()
378
- return "", gr.update(value=f"<div class='analysis-card muted'><strong>Unexpected error:</strong> {e}</div>"), [], [], set_status("Error")
379
-
380
- def on_send(chat_messages: List[dict], user_msg: str, analysis_text: str):
381
  if not user_msg or not user_msg.strip():
382
- return chat_messages or [], ""
383
- # Append and get updated messages
384
- updated = continue_chat(chat_messages or [], user_msg, analysis_text or "")
385
- return updated, ""
386
-
387
- def render_chat(chat_messages: List[dict]):
388
- """
389
- Gradio Chatbot in some versions accepts list of dicts {"role","content"}.
390
- We will return list of dicts unchanged where possible. If Gradio fails,
391
- it will raise — but previously we fixed to produce dicts.
392
- """
393
- if not chat_messages:
394
- return []
395
- # Return as-is
396
- return chat_messages
397
-
398
- # ---- Wire up events ----
399
  analyze_btn.click(
400
  fn=on_analyze,
401
  inputs=[prompts],
402
- outputs=[analysis_state, analysis_html, chat_state, chatbot, status],
403
- )
404
-
405
- clear_btn.click(
406
- fn=on_clear,
407
- inputs=[],
408
- outputs=[prompts, analysis_html, chat_state, chatbot, status],
409
  )
410
-
411
  send_btn.click(
412
  fn=on_send,
413
  inputs=[chat_state, user_input, analysis_state],
414
- outputs=[chat_state, user_input],
415
  )
416
  user_input.submit(
417
  fn=on_send,
418
  inputs=[chat_state, user_input, analysis_state],
419
- outputs=[chat_state, user_input],
 
 
 
 
 
420
  )
 
 
421
 
422
- # Keep chatbot UI updated
423
- chat_state.change(fn=render_chat, inputs=[chat_state], outputs=[chatbot])
424
 
425
- return demo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
 
427
- # -----------------------
428
- # Run
429
- # -----------------------
 
430
  if __name__ == "__main__":
431
  demo = build_demo()
432
- demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))
 
 
 
 
 
1
  """
2
+ AURA Chat — Gradio Space
3
+ Single-file Gradio app that:
4
+ - Accepts newline-separated prompts (data queries) from the user.
5
+ - On "Analyze" scrapes those queries, sends the aggregated text to a locked LLM,
6
+ and returns a polished analysis with a ranked list of best stocks and an
7
+ "Investment Duration" (when to enter / when to exit) for each stock.
8
+ - Seeds a chat component with the generated analysis; user can then chat about it.
9
+
10
+ Notes:
11
+ - Model, max tokens, and delay between scrapes are fixed and cannot be changed via UI.
12
+ - Set OPENAI_API_KEY in environment (Space Secrets).
13
  """
14
 
15
  import os
 
16
  import time
17
+ import sys
18
  import asyncio
19
  import requests
20
  import atexit
21
  import traceback
 
 
22
  from datetime import datetime
23
  from typing import List
 
24
  import gradio as gr
25
 
26
+
27
+ # Defensive: ensure a fresh event loop early to avoid fd race on shutdown.
28
  if sys.platform != "win32":
29
  try:
30
  loop = asyncio.new_event_loop()
 
32
  except Exception:
33
  traceback.print_exc()
34
 
35
+
36
+ # =============================================================================
37
+ # CONFIGURATION (fixed)
38
+ # =============================================================================
39
  SCRAPER_API_URL = os.getenv("SCRAPER_API_URL", "https://deep-scraper-96.created.app/api/deep-scrape")
40
+ SCRAPER_HEADERS = {
41
+ "User-Agent": "Mozilla/5.0",
42
+ "Content-Type": "application/json"
43
+ }
44
 
45
+ # FIXED model & tokens (cannot be changed from UI)
46
  LLM_MODEL = os.getenv("LLM_MODEL", "openai/gpt-oss-20b:free")
47
  MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "3000"))
48
  SCRAPE_DELAY = float(os.getenv("SCRAPE_DELAY", "1.0"))
 
50
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
51
  OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://openrouter.ai/api/v1")
52
 
 
 
 
 
 
53
 
54
+ # =============================================================================
55
+ # PROMPT ENGINEERING (fixed)
56
+ # =============================================================================
57
+ PROMPT_TEMPLATE = f"""You are AURA, a concise, professional hedge-fund research assistant.
 
58
 
59
  Task:
60
  - Given scraped data below, produce a clear, readable analysis that:
61
  1) Lists the top 5 stock picks (or fewer if not enough data).
62
+ 2) For each stock provide: Ticker / Company name, short rationale (2-3 bullets),
63
+ and an explicit **Investment Duration** entry: a one-line "When to Invest"
64
+ and a one-line "When to Sell" instruction (these two lines are mandatory
65
+ for each stock).
66
+ 3) Keep each stock entry short and scannable. Use a bullet list or numbered list.
67
+ 4) At the top, provide a 2-3 sentence summary conclusion (market context +
68
+ highest conviction pick).
69
+ 5) Output in plain text, clean formatting, easy for humans to read. No JSON.
70
+ 6) After the list, include a concise "Assumptions & Risks" section (2-3 bullet points).
71
+
72
+ Important: Be decisive. If data is insufficient, state that clearly and provide
73
+ the best-available picks with lower confidence.
74
+
75
+ Max tokens for the LLM response: {MAX_TOKENS}
76
+ Model: {LLM_MODEL}"""
77
+
78
+
79
+ # =============================================================================
80
+ # SCRAPING HELPERS
81
+ # =============================================================================
82
  def deep_scrape(query: str, retries: int = 3, timeout: int = 40) -> str:
83
+ """Post a query to SCRAPER_API_URL and return a readable aggregation (or an error string)."""
84
  payload = {"query": query}
85
  last_err = None
86
+
87
  for attempt in range(1, retries + 1):
88
  try:
89
+ resp = requests.post(
90
+ SCRAPER_API_URL,
91
+ headers=SCRAPER_HEADERS,
92
+ json=payload,
93
+ timeout=timeout
94
+ )
95
  resp.raise_for_status()
96
  data = resp.json()
97
+
98
+ # Format into readable text
99
  if isinstance(data, dict):
100
+ parts = [f"{k.upper()}:\n{v}\n" for k, v in data.items()]
101
+ return "\n".join(parts)
102
+ else:
103
+ return str(data)
104
+
105
  except Exception as e:
106
  last_err = e
107
  if attempt < retries:
108
  time.sleep(1.0)
109
+
110
+ return f"ERROR: Scraper failed: {last_err}"
111
+
112
 
113
  def multi_scrape(queries: List[str], delay: float = SCRAPE_DELAY) -> str:
114
+ """Scrape multiple queries and join results into one large string."""
115
  aggregated = []
116
  for q in queries:
117
  q = q.strip()
 
123
  time.sleep(delay)
124
  return "\n".join(aggregated)
125
 
126
+
127
+ # =============================================================================
128
+ # LLM INTERACTION
129
+ # =============================================================================
130
+ try:
131
+ from openai import OpenAI
132
+ except Exception:
133
+ OpenAI = None
134
+
135
+
136
+ def run_llm_system_and_user(
137
+ system_prompt: str,
138
+ user_text: str,
139
+ model: str = LLM_MODEL,
140
+ max_tokens: int = MAX_TOKENS
141
+ ) -> str:
142
+ """Create the OpenAI client lazily, call the chat completions endpoint, then close."""
143
  if OpenAI is None:
144
+ return "ERROR: openai package not installed or available. See requirements."
145
+
146
  if not OPENAI_API_KEY:
147
+ return "ERROR: OPENAI_API_KEY not set in environment. Please add it to Space Secrets."
148
+
149
  client = None
150
  try:
151
  client = OpenAI(base_url=OPENAI_BASE_URL, api_key=OPENAI_API_KEY)
 
157
  ],
158
  max_tokens=max_tokens,
159
  )
160
+
161
+ # Extract content robustly
162
  if hasattr(completion, "choices") and len(completion.choices) > 0:
163
  try:
164
  return completion.choices[0].message.content
165
  except Exception:
166
  return str(completion.choices[0])
167
  return str(completion)
168
+
169
  except Exception as e:
170
  return f"ERROR: LLM call failed: {e}"
171
  finally:
172
+ # Try to close client transport
173
  try:
174
  if client is not None:
175
  try:
 
182
  except Exception:
183
  pass
184
 
185
+
186
+ # =============================================================================
187
+ # MAIN PIPELINE
188
+ # =============================================================================
189
  def analyze_and_seed_chat(prompts_text: str):
190
+ """Called when user clicks Analyze. Returns: (analysis_text, initial_chat_messages_list)"""
191
+ if not prompts_text.strip():
 
 
 
192
  return "Please enter at least one prompt (query) describing what data to gather.", []
193
+
194
  queries = [line.strip() for line in prompts_text.splitlines() if line.strip()]
195
  scraped = multi_scrape(queries, delay=SCRAPE_DELAY)
196
+
197
  if scraped.startswith("ERROR"):
198
  return scraped, []
199
+
200
+ # Compose user payload for LLM
201
+ user_payload = f"SCRAPED DATA:\n\n{scraped}\n\nPlease follow the system instructions and output the analysis."
202
  analysis = run_llm_system_and_user(PROMPT_TEMPLATE, user_payload)
203
+
204
  if analysis.startswith("ERROR"):
205
  return analysis, []
206
+
207
+ # Seed chat with user request and assistant analysis
208
  initial_chat = [
209
+ {"role": "user", "content": f"Analyze the data I provided (prompts: {', '.join(queries)})"},
210
+ {"role": "assistant", "content": analysis}
211
  ]
212
  return analysis, initial_chat
213
 
214
+
215
+ def continue_chat(chat_messages, user_message: str, analysis_text: str):
216
+ """Handle chat follow-ups. Returns updated list of message dicts."""
 
217
  if chat_messages is None:
218
  chat_messages = []
 
219
  if not user_message or not user_message.strip():
220
  return chat_messages
221
+
222
+ # Append user's new message
223
  chat_messages.append({"role": "user", "content": user_message})
224
+
225
+ # Build LLM input using analysis as reference context
226
  followup_system = (
227
+ "You are AURA, a helpful analyst. The conversation context includes a recently "
228
+ "generated analysis from scraped data. Use that analysis as ground truth context; "
229
+ "answer follow-up questions, explain rationale, and provide clarifications. "
230
+ "Be concise and actionable."
231
  )
232
+ user_payload = f"REFERENCE ANALYSIS:\n\n{analysis_text}\n\nUSER QUESTION: {user_message}\n\nRespond concisely and reference lines from the analysis where appropriate."
233
+
234
  assistant_reply = run_llm_system_and_user(followup_system, user_payload)
235
+ if assistant_reply.startswith("ERROR"):
236
+ assistant_reply = assistant_reply
237
+
238
+ # Append assistant reply
239
  chat_messages.append({"role": "assistant", "content": assistant_reply})
240
  return chat_messages
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
+ # =============================================================================
244
+ # GRADIO UI
245
+ # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  def build_demo():
247
  with gr.Blocks(title="AURA Chat — Hedge Fund Picks") as demo:
248
+ # Custom CSS
249
  gr.HTML("""
 
 
250
  <style>
251
+ .gradio-container { max-width: 1100px; margin: 18px auto; }
252
+ .header { text-align: left; margin-bottom: 6px; }
253
+ .muted { color: #7d8590; font-size: 14px; }
254
+ .analysis-box { background: #ffffff; border-radius: 8px; padding: 12px; box-shadow: 0 4px 14px rgba(0,0,0,0.06); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  </style>
256
  """)
257
+
258
+ gr.Markdown("# AURA Chat Hedge Fund Picks")
259
+ gr.Markdown(
260
+ "**Enter one or more data prompts (one per line)** — e.g. SEC insider transactions october 2025 company XYZ.\n\n"
261
+ "Only input prompts; model, tokens and timing are fixed. Press **Analyze** to fetch & generate the picks. "
262
+ "After analysis you can chat with the assistant about the results."
263
+ )
264
+
265
+ with gr.Row():
266
  with gr.Column(scale=1):
267
+ prompts = gr.Textbox(
268
+ lines=6,
269
+ label="Data Prompts (one per line)",
270
+ placeholder="SEC insider transactions october 2025\n13F filings Q3 2025\ncompany: ACME corp insider buys"
 
 
 
 
 
 
 
 
 
 
 
271
  )
272
+ analyze_btn = gr.Button("Analyze", variant="primary")
273
+ error_box = gr.Markdown("", visible=False)
274
+ gr.Markdown(f"**Fixed settings:** Model = {LLM_MODEL} • Max tokens = {MAX_TOKENS} • Scrape delay = {SCRAPE_DELAY}s")
275
+ gr.Markdown("**Important:** Add your OPENAI_API_KEY to Space Secrets before running.")
276
+
277
+ with gr.Column(scale=1):
278
+ analysis_out = gr.Textbox(
279
+ label="Generated Analysis (Top picks with Investment Duration)",
280
+ lines=18,
281
+ interactive=False
282
+ )
283
+ gr.Markdown("**Chat with AURA about this analysis**")
284
+ chatbot = gr.Chatbot(label="AURA Chat", height=420)
285
+ user_input = gr.Textbox(
286
+ placeholder="Ask a follow-up question about the analysis...",
287
+ label="Your question"
288
+ )
289
+ send_btn = gr.Button("Send")
290
+
291
+ # States
292
+ analysis_state = gr.State("")
293
+ chat_state = gr.State([])
294
+
295
+ # Handler functions
296
+ def on_analyze(prompts_text):
297
+ analysis_text, initial_chat = analyze_and_seed_chat(prompts_text)
298
+ if analysis_text.startswith("ERROR"):
299
+ return "", f"**Error:** {analysis_text}", "", []
300
+ return analysis_text, "", analysis_text, initial_chat
301
+
302
+ def on_send(chat_state_list, user_msg, analysis_text):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  if not user_msg or not user_msg.strip():
304
+ return chat_state_list or [], ""
305
+ updated_history = continue_chat(chat_state_list or [], user_msg, analysis_text)
306
+ return updated_history, ""
307
+
308
+ def render_chat(chat_messages):
309
+ return chat_messages or []
310
+
311
+ # Wire handlers
 
 
 
 
 
 
 
 
 
312
  analyze_btn.click(
313
  fn=on_analyze,
314
  inputs=[prompts],
315
+ outputs=[analysis_out, error_box, analysis_state, chat_state]
 
 
 
 
 
 
316
  )
 
317
  send_btn.click(
318
  fn=on_send,
319
  inputs=[chat_state, user_input, analysis_state],
320
+ outputs=[chat_state, user_input]
321
  )
322
  user_input.submit(
323
  fn=on_send,
324
  inputs=[chat_state, user_input, analysis_state],
325
+ outputs=[chat_state, user_input]
326
+ )
327
+ chat_state.change(
328
+ fn=render_chat,
329
+ inputs=[chat_state],
330
+ outputs=[chatbot]
331
  )
332
+
333
+ return demo
334
 
 
 
335
 
336
+ # =============================================================================
337
+ # CLEAN SHUTDOWN
338
+ # =============================================================================
339
+ def _cleanup_on_exit():
340
+ try:
341
+ loop = asyncio.get_event_loop()
342
+ if loop and not loop.is_closed():
343
+ try:
344
+ loop.stop()
345
+ except Exception:
346
+ pass
347
+ try:
348
+ loop.close()
349
+ except Exception:
350
+ pass
351
+ except Exception:
352
+ pass
353
+
354
+ atexit.register(_cleanup_on_exit)
355
 
356
+
357
+ # =============================================================================
358
+ # RUN
359
+ # =============================================================================
360
  if __name__ == "__main__":
361
  demo = build_demo()
362
+ demo.launch(
363
+ server_name="0.0.0.0",
364
+ server_port=int(os.environ.get("PORT", 7860))
365
+ )