GabrielSalem commited on
Commit
2d5d541
·
verified ·
1 Parent(s): 6a49078

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +322 -0
app.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ """
3
+ AURA Chat — Gradio Space
4
+ Single-file Gradio app that:
5
+ - Accepts newline-separated prompts (data queries) from the user.
6
+ - On "Analyze" scrapes those queries, sends the aggregated text to a locked LLM,
7
+ and returns a polished analysis with a ranked list of best stocks and an
8
+ "Investment Duration" (when to enter / when to exit) for each stock.
9
+ - Seeds a chat component with the generated analysis; user can then chat about it.
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
+
25
+ import gradio as gr
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()
31
+ asyncio.set_event_loop(loop)
32
+ except Exception:
33
+ traceback.print_exc()
34
+
35
+ # -----------------------
36
+ # Configuration (fixed)
37
+ # -----------------------
38
+ SCRAPER_API_URL = os.getenv("SCRAPER_API_URL", "https://deep-scraper-96.created.app/api/deep-scrape")
39
+ SCRAPER_HEADERS = {"User-Agent": "Mozilla/5.0", "Content-Type": "application/json"}
40
+
41
+ # FIXED model & tokens (cannot be changed from UI)
42
+ LLM_MODEL = os.getenv("LLM_MODEL", "openai/gpt-oss-20b:free") # locked model id
43
+ MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "3000")) # locked max tokens
44
+ SCRAPE_DELAY = float(os.getenv("SCRAPE_DELAY", "1.0")) # locked delay between scrapes (seconds)
45
+
46
+ # OpenAI key is read from env (set in HF Space Secrets). We create the client lazily per-call.
47
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
48
+ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://openrouter.ai/api/v1")
49
+
50
+ # If OPENAI_API_KEY is missing, UI will show a clear error on Analyze click.
51
+ # -----------------------
52
+ # Prompt engineering (fixed) — instruct the model to produce consistent output.
53
+ # -----------------------
54
+ PROMPT_TEMPLATE = f"""
55
+ You are AURA, a concise, professional hedge-fund research assistant.
56
+
57
+ Task:
58
+ - Given scraped data below, produce a clear, readable analysis that:
59
+ 1) Lists the top 5 stock picks (or fewer if not enough data).
60
+ 2) For each stock provide: Ticker / Company name, short rationale (2-3 bullets),
61
+ and an explicit **Investment Duration** entry: a one-line "When to Invest" and
62
+ a one-line "When to Sell" instruction (these two lines are mandatory for each stock).
63
+ 3) Keep each stock entry short and scannable. Use a bullet list or numbered list.
64
+ 4) At the top, provide a 2-3 sentence summary conclusion (market context + highest conviction pick).
65
+ 5) Output in plain text, clean formatting, easy for humans to read. No JSON.
66
+ 6) After the list, include a concise "Assumptions & Risks" section (2-3 bullet points).
67
+
68
+ Important: Be decisive. If data is insufficient, state that clearly and provide the best-available picks with lower confidence.
69
+ Max tokens for the LLM response: {MAX_TOKENS}
70
+ Model: {LLM_MODEL}
71
+ """
72
+
73
+ # -----------------------
74
+ # Helper: scraping
75
+ # -----------------------
76
+ def deep_scrape(query: str, retries: int = 3, timeout: int = 40) -> str:
77
+ """Post a query to SCRAPER_API_URL and return a readable aggregation (or an error string)."""
78
+ payload = {"query": query}
79
+ last_err = None
80
+ for attempt in range(1, retries + 1):
81
+ try:
82
+ resp = requests.post(SCRAPER_API_URL, headers=SCRAPER_HEADERS, json=payload, timeout=timeout)
83
+ resp.raise_for_status()
84
+ data = resp.json()
85
+ # Format into readable text
86
+ if isinstance(data, dict):
87
+ parts = []
88
+ for k, v in data.items():
89
+ parts.append(f"{k.upper()}:\n{v}\n")
90
+ return "\n".join(parts)
91
+ else:
92
+ return str(data)
93
+ except Exception as e:
94
+ last_err = e
95
+ if attempt < retries:
96
+ time.sleep(1.0)
97
+ else:
98
+ return f"ERROR: Scraper failed: {e}"
99
+ return f"ERROR: {last_err}"
100
+
101
+ def multi_scrape(queries: List[str], delay: float = SCRAPE_DELAY) -> str:
102
+ """Scrape multiple queries and join results into one large string."""
103
+ aggregated = []
104
+ for q in queries:
105
+ q = q.strip()
106
+ if not q:
107
+ continue
108
+ aggregated.append(f"\n=== QUERY: {q} ===\n")
109
+ scraped = deep_scrape(q)
110
+ aggregated.append(scraped)
111
+ time.sleep(delay)
112
+ return "\n".join(aggregated)
113
+
114
+ # -----------------------
115
+ # LLM interaction (safe: create+close per call)
116
+ # -----------------------
117
+ # Using the 'openai' SDK style that provides OpenAI class available in some providers.
118
+ # If your environment uses a different SDK, adjust accordingly.
119
+ try:
120
+ from openai import OpenAI # keep import local; if package missing, we'll error nicely at runtime
121
+ except Exception:
122
+ OpenAI = None
123
+
124
+ def run_llm_system_and_user(system_prompt: str, user_text: str, model: str = LLM_MODEL, max_tokens: int = MAX_TOKENS) -> str:
125
+ """Create the OpenAI client lazily, call the chat completions endpoint, then close."""
126
+ if OpenAI is None:
127
+ return "ERROR: `openai` package not installed or available. See requirements."
128
+ if not OPENAI_API_KEY:
129
+ return "ERROR: OPENAI_API_KEY not set in environment. Please add it to Space Secrets."
130
+
131
+ client = None
132
+ try:
133
+ client = OpenAI(base_url=OPENAI_BASE_URL, api_key=OPENAI_API_KEY)
134
+ completion = client.chat.completions.create(
135
+ model=model,
136
+ messages=[
137
+ {"role": "system", "content": system_prompt},
138
+ {"role": "user", "content": user_text},
139
+ ],
140
+ max_tokens=max_tokens,
141
+ )
142
+ # Extract content robustly
143
+ if hasattr(completion, "choices") and len(completion.choices) > 0:
144
+ try:
145
+ return completion.choices[0].message.content
146
+ except Exception:
147
+ return str(completion.choices[0])
148
+ return str(completion)
149
+ except Exception as e:
150
+ return f"ERROR: LLM call failed: {e}"
151
+ finally:
152
+ # try to close client transport
153
+ try:
154
+ if client is not None:
155
+ try:
156
+ client.close()
157
+ except Exception:
158
+ # try async close if available
159
+ try:
160
+ asyncio.get_event_loop().run_until_complete(client.aclose())
161
+ except Exception:
162
+ pass
163
+ except Exception:
164
+ pass
165
+
166
+ # -----------------------
167
+ # Pipeline: analyze -> produce analysis, seed chat
168
+ # -----------------------
169
+ def analyze_and_seed_chat(prompts_text: str):
170
+ """
171
+ Called when user clicks Analyze.
172
+ - prompts_text: newline-separated queries provided by user.
173
+ Returns: (analysis_text, initial_chat_history)
174
+ Where initial_chat_history is a list of tuples for gr.Chatbot: [(user_msg, assistant_msg), ...].
175
+ """
176
+ if not prompts_text.strip():
177
+ return "Please enter at least one prompt (query) describing what data to gather.", []
178
+
179
+ # Prepare queries
180
+ queries = [line.strip() for line in prompts_text.splitlines() if line.strip()]
181
+ scraped = multi_scrape(queries, delay=SCRAPE_DELAY)
182
+ if scraped.startswith("ERROR"):
183
+ return scraped, []
184
+
185
+ # Compose user payload for LLM: scraped data + instruction to format picks only
186
+ user_payload = f"SCRAPED DATA:\n\n{scraped}\n\nPlease follow the system instructions and output the analysis."
187
+
188
+ analysis = run_llm_system_and_user(PROMPT_TEMPLATE, user_payload)
189
+ # short validation
190
+ if analysis.startswith("ERROR"):
191
+ return analysis, []
192
+
193
+ # Prepare initial chat history: seed assistant with analysis (the assistant message)
194
+ # Put the user's original request as the first user message in chat history for context.
195
+ initial_chat = []
196
+ initial_chat.append((f"Analyze the data I provided (prompts: {', '.join(queries)})", analysis))
197
+ return analysis, initial_chat
198
+
199
+ # -----------------------
200
+ # Chat interaction after analysis
201
+ # -----------------------
202
+ def continue_chat(chat_history, user_message, analysis_text):
203
+ """
204
+ chat_history: list of (user, assistant) tuples (existing)
205
+ user_message: new user message (string)
206
+ analysis_text: the original analysis text produced (we keep as long-term context)
207
+ Returns: updated chat history including the assistant reply.
208
+ """
209
+ # Build LLM input: system prompt + context (analysis_text) + prior chat + new query
210
+ if not analysis_text:
211
+ return chat_history + [(user_message, "No analysis available. Please click Analyze first.")]
212
+
213
+ # Compose a concise system message instructing the assistant to be consistent with the analysis
214
+ followup_system = (
215
+ "You are AURA, a helpful analyst. The conversation context includes a recently generated analysis "
216
+ "from scraped data. Use that analysis as ground truth context; answer follow-up questions "
217
+ "about the analysis, explain rationale, and provide clarifications. Be concise and actionable."
218
+ )
219
+
220
+ # Build the user text: include the analysis as a reference block plus the user's question
221
+ 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."
222
+
223
+ # Call LLM
224
+ assistant_reply = run_llm_system_and_user(followup_system, user_payload)
225
+ if assistant_reply.startswith("ERROR"):
226
+ assistant_reply = assistant_reply
227
+
228
+ # Append to local chat history
229
+ chat_history = list(chat_history) # ensure mutable copy
230
+ chat_history.append((user_message, assistant_reply))
231
+ return chat_history
232
+
233
+ # -----------------------
234
+ # Gradio UI
235
+ # -----------------------
236
+ def build_demo():
237
+ with gr.Blocks(title="AURA Chat — Hedge Fund Picks", css="""
238
+ .gradio-container { max-width: 1100px; margin: 18px auto; }
239
+ .header {text-align: left; margin-bottom: 6px;}
240
+ .muted {color: #7d8590; font-size: 14px;}
241
+ .analysis-box { background: #ffffff; border-radius: 8px; padding: 12px; box-shadow: 0 4px 14px rgba(0,0,0,0.06);}
242
+ """) as demo:
243
+ gr.Markdown("# AURA Chat — Hedge Fund Picks")
244
+ gr.Markdown("**Enter one or more data prompts (one per line)** — e.g. `SEC insider transactions october 2025 company XYZ`.\n\nOnly input prompts; model, tokens and timing are fixed. Press **Analyze** to fetch & generate the picks. After analysis you can chat with the assistant about the results.")
245
+
246
+ with gr.Row():
247
+ with gr.Column(scale=1):
248
+ prompts = gr.Textbox(lines=6, label="Data Prompts (one per line)", placeholder="SEC insider transactions october 2025\n13F filings Q3 2025\ncompany: ACME corp insider buys")
249
+ analyze_btn = gr.Button("Analyze", variant="primary")
250
+ error_box = gr.Markdown("", visible=False)
251
+ gr.Markdown(f"**Fixed settings:** Model = `{LLM_MODEL}` • Max tokens = `{MAX_TOKENS}` • Scrape delay = `{SCRAPE_DELAY}s`")
252
+ gr.Markdown("**Important:** Add your `OPENAI_API_KEY` to Space Secrets before running.")
253
+ with gr.Column(scale=1):
254
+ analysis_out = gr.Textbox(label="Generated Analysis (Top picks with Investment Duration)", lines=18, interactive=False)
255
+ gr.Markdown("**Chat with AURA about this analysis**")
256
+ chatbot = gr.Chatbot(label="AURA Chat", height=420)
257
+ user_input = gr.Textbox(placeholder="Ask a follow-up question about the analysis...", label="Your question")
258
+ send_btn = gr.Button("Send")
259
+
260
+ # State to hold the analysis text and chat history
261
+ analysis_state = gr.State("") # holds the analysis text (string)
262
+ chat_state = gr.State([]) # holds list of (user, assistant) tuples
263
+
264
+ # Hook: Analyze button
265
+ def on_analyze(prompts_text):
266
+ analysis_text, initial_chat = analyze_and_seed_chat(prompts_text)
267
+ if analysis_text.startswith("ERROR"):
268
+ return gr.update(value=analysis_text), gr.update(visible=True, value=f"**Error:** {analysis_text}"), "", []
269
+ # Set outputs: analysis_out, clear chatbot and seed it
270
+ # analysis_text displayed in the big box; the chat seeded with the pair
271
+ return analysis_text, gr.update(visible=False, value=""), analysis_text, initial_chat
272
+
273
+ analyze_btn.click(fn=on_analyze, inputs=[prompts], outputs=[analysis_out, error_box, analysis_state, chat_state])
274
+
275
+ # Hook: Send follow-up chat message
276
+ def on_send(chat_state_list, user_msg, analysis_text):
277
+ if not user_msg.strip():
278
+ return chat_state_list, ""
279
+ # chat_state_list is list of tuples
280
+ updated_history = continue_chat(chat_state_list or [], user_msg, analysis_text)
281
+ # Clear user input after sending
282
+ return updated_history, ""
283
+
284
+ send_btn.click(fn=on_send, inputs=[chat_state, user_input, analysis_state], outputs=[chat_state, user_input])
285
+ # Also allow pressing Enter in the user_input box
286
+ user_input.submit(fn=on_send, inputs=[chat_state, user_input, analysis_state], outputs=[chat_state, user_input])
287
+
288
+ # Render chat_state into the Chatbot UI whenever it updates
289
+ def render_chat(chat_state_list):
290
+ # gr.Chatbot expects a list of (user, assistant) pairs
291
+ return chat_state_list or []
292
+
293
+ chat_state.change(fn=render_chat, inputs=[chat_state], outputs=[chatbot])
294
+
295
+ return demo
296
+
297
+ # -----------------------
298
+ # Clean shutdown helper
299
+ # -----------------------
300
+ def _cleanup_on_exit():
301
+ try:
302
+ loop = asyncio.get_event_loop()
303
+ if loop and not loop.is_closed():
304
+ try:
305
+ loop.stop()
306
+ except Exception:
307
+ pass
308
+ try:
309
+ loop.close()
310
+ except Exception:
311
+ pass
312
+ except Exception:
313
+ pass
314
+
315
+ atexit.register(_cleanup_on_exit)
316
+
317
+ # -----------------------
318
+ # Run
319
+ # -----------------------
320
+ if __name__ == "__main__":
321
+ demo = build_demo()
322
+ demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))