RayMelius Claude Sonnet 4.6 commited on
Commit
7c55124
Β·
1 Parent(s): 7a8da51

Fix Generate Now: move LLM call into dashboard.py directly

Browse files

Previously: dashboard β†’ Kafka β†’ ai_analyst β†’ LLM β†’ Kafka β†’ dashboard
Now: dashboard β†’ LLM β†’ SSE broadcast (on-demand path)

- Adds _call_llm(), _build_market_prompt(), _generate_and_broadcast()
directly in dashboard.py; /session/ai_insight spawns a thread
- Surfaces LLM errors in the panel (⚠️ error message via SSE)
- HF model: Qwen/Qwen2.5-3B-Instruct (faster 3B vs 7B)
- Timeout: 90s with Timeout exception caught separately
- Logs every HF status code so HF Spaces logs show exactly what happens
- JS hard cap: 95s (was 120s) matching the 90s request timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

dashboard/dashboard.py CHANGED
@@ -26,6 +26,130 @@ session_state = {"active": False, "start_time": None, "suspended": False, "mode"
26
  SCHEDULE_FILE = os.getenv("SCHEDULE_FILE", "/app/data/market_schedule.txt")
27
  FRONTEND_URL = os.getenv("FRONTEND_URL", "")
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # ── OHLCV History ──────────────────────────────────────────────────────────────
30
  HISTORY_DB = os.getenv("HISTORY_DB", "/app/data/dashboard_history.db")
31
  BUCKET_SIZE = 60 # 1-minute candles
@@ -449,13 +573,8 @@ def session_resume():
449
 
450
  @app.route("/session/ai_insight", methods=["POST"])
451
  def trigger_ai_insight():
452
- try:
453
- p = get_producer()
454
- p.send(Config.CONTROL_TOPIC, {"action": "generate_insight"})
455
- p.flush()
456
- return jsonify({"status": "ok", "message": "Insight generation triggered"})
457
- except Exception as e:
458
- return jsonify({"status": "error", "error": str(e)}), 500
459
 
460
 
461
  @app.route("/session/mode", methods=["POST"])
 
26
  SCHEDULE_FILE = os.getenv("SCHEDULE_FILE", "/app/data/market_schedule.txt")
27
  FRONTEND_URL = os.getenv("FRONTEND_URL", "")
28
 
29
+ # ── AI Analyst (inline LLM for on-demand generation) ───────────────────────────
30
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
31
+ HF_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen2.5-3B-Instruct")
32
+ HF_URL = "https://router.huggingface.co/v1/chat/completions"
33
+ OLLAMA_HOST = os.getenv("OLLAMA_HOST", "")
34
+ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
35
+
36
+
37
+ def _build_market_prompt():
38
+ with lock:
39
+ snaps = dict(bbos)
40
+ recent = list(trades_cache[:30])
41
+ now = datetime.datetime.now().strftime("%H:%M:%S")
42
+ sess = ("ACTIVE" if session_state["active"] and not session_state["suspended"]
43
+ else "SUSPENDED" if session_state["suspended"] else "IDLE")
44
+
45
+ if recent:
46
+ by_sym = {}
47
+ for t in recent:
48
+ by_sym.setdefault(t.get("symbol", "?"), []).append(t)
49
+ trade_lines = []
50
+ for sym, ts in sorted(by_sym.items()):
51
+ prices = [float(t.get("price", 0)) for t in ts]
52
+ vol = sum(int(t.get("quantity") or t.get("qty") or 0) for t in ts)
53
+ trade_lines.append(f" {sym}: {len(ts)} trade(s), range {min(prices):.2f}–{max(prices):.2f}, vol {vol}, last {prices[-1]:.2f}")
54
+ trades_block = "\n".join(trade_lines)
55
+ else:
56
+ trades_block = " No recent trades"
57
+
58
+ if snaps:
59
+ book_lines = []
60
+ for sym, s in sorted(snaps.items()):
61
+ bid, ask = s.get("best_bid"), s.get("best_ask")
62
+ spread = f"{float(ask)-float(bid):.2f}" if bid and ask else "?"
63
+ book_lines.append(f" {sym}: Bid {bid or '-'} / Ask {ask or '-'} (spread {spread})")
64
+ book_block = "\n".join(book_lines)
65
+ else:
66
+ book_block = " No order book data"
67
+
68
+ return (f"You are a concise financial market analyst for a simulated stock exchange.\n"
69
+ f"Time: {now} | Session: {sess}\n\n"
70
+ f"Recent trades:\n{trades_block}\n\n"
71
+ f"Order book:\n{book_block}\n\n"
72
+ f"In 3-4 sentences: activity level, notable moves, market sentiment. "
73
+ f"Plain prose, no headers, no bullet points.")
74
+
75
+
76
+ def _call_llm(prompt):
77
+ """Try Ollama first, then HuggingFace router. Returns (text, source) or (None, error_msg)."""
78
+ # 1. Ollama
79
+ if OLLAMA_HOST:
80
+ try:
81
+ r = requests.post(f"{OLLAMA_HOST}/api/chat",
82
+ json={"model": OLLAMA_MODEL,
83
+ "messages": [{"role": "user", "content": prompt}],
84
+ "stream": False},
85
+ timeout=90)
86
+ if r.status_code == 200:
87
+ text = r.json().get("message", {}).get("content", "").strip()
88
+ if text:
89
+ return text, "Ollama"
90
+ print(f"[Dashboard/LLM] Ollama {r.status_code}: {r.text[:200]}")
91
+ except Exception as e:
92
+ print(f"[Dashboard/LLM] Ollama error: {e}")
93
+
94
+ # 2. HuggingFace router
95
+ if not HF_TOKEN:
96
+ return None, "HF_TOKEN not set"
97
+ print(f"[Dashboard/LLM] Calling HF router ({HF_MODEL})…")
98
+ for attempt in range(3):
99
+ try:
100
+ r = requests.post(HF_URL,
101
+ headers={"Authorization": f"Bearer {HF_TOKEN}",
102
+ "Content-Type": "application/json"},
103
+ json={"model": HF_MODEL,
104
+ "messages": [{"role": "user", "content": prompt}],
105
+ "max_tokens": 180,
106
+ "temperature": 0.7},
107
+ timeout=90)
108
+ print(f"[Dashboard/LLM] HF status {r.status_code} (attempt {attempt+1})")
109
+ if r.status_code == 200:
110
+ text = r.json()["choices"][0]["message"]["content"].strip()
111
+ if text:
112
+ return text, HF_MODEL
113
+ elif r.status_code == 503:
114
+ body = {}
115
+ try: body = r.json()
116
+ except: pass
117
+ wait = min(float(body.get("estimated_time", 20)), 30)
118
+ print(f"[Dashboard/LLM] Model loading, waiting {wait:.0f}s…")
119
+ time.sleep(wait)
120
+ else:
121
+ print(f"[Dashboard/LLM] HF error body: {r.text[:400]}")
122
+ return None, f"HF HTTP {r.status_code}: {r.text[:120]}"
123
+ except requests.exceptions.Timeout:
124
+ print(f"[Dashboard/LLM] HF timeout (attempt {attempt+1})")
125
+ return None, "HF request timed out after 90s"
126
+ except Exception as e:
127
+ print(f"[Dashboard/LLM] HF exception: {e}")
128
+ return None, str(e)
129
+ return None, "HF: max retries exceeded"
130
+
131
+
132
+ def _generate_and_broadcast():
133
+ """Background thread: call LLM, publish result via SSE + Kafka."""
134
+ prompt = _build_market_prompt()
135
+ text, source = _call_llm(prompt)
136
+ if text:
137
+ insight = {"text": text, "source": source, "timestamp": time.time()}
138
+ with lock:
139
+ ai_insights_cache.insert(0, insight)
140
+ ai_insights_cache[:] = ai_insights_cache[:10]
141
+ broadcast_event("ai_insight", insight)
142
+ try:
143
+ get_producer().send(Config.AI_INSIGHTS_TOPIC, insight)
144
+ except Exception:
145
+ pass
146
+ print(f"[Dashboard/LLM] Insight published ({len(text)} chars, src={source})")
147
+ else:
148
+ # Surface the error in the panel
149
+ err_insight = {"text": f"⚠️ LLM error: {source}", "source": "error", "timestamp": time.time()}
150
+ broadcast_event("ai_insight", err_insight)
151
+ print(f"[Dashboard/LLM] LLM failed: {source}")
152
+
153
  # ── OHLCV History ──────────────────────────────────────────────────────────────
154
  HISTORY_DB = os.getenv("HISTORY_DB", "/app/data/dashboard_history.db")
155
  BUCKET_SIZE = 60 # 1-minute candles
 
573
 
574
  @app.route("/session/ai_insight", methods=["POST"])
575
  def trigger_ai_insight():
576
+ threading.Thread(target=_generate_and_broadcast, daemon=True).start()
577
+ return jsonify({"status": "ok", "message": "Insight generation started"})
 
 
 
 
 
578
 
579
 
580
  @app.route("/session/mode", methods=["POST"])
dashboard/templates/index.html CHANGED
@@ -450,13 +450,13 @@
450
  status.textContent = `generating… ${elapsed}s`;
451
  }, 1000);
452
 
453
- // Hard cap: re-enable after 120s with failure message
454
  setTimeout(() => {
455
  clearInterval(tick);
456
  btn.disabled = false;
457
  btn.textContent = "✨ Generate Now";
458
- status.textContent = "no response β€” check logs or try again";
459
- }, 120000);
460
 
461
  // Store tick handle so SSE success can clear it
462
  btn._tick = tick;
 
450
  status.textContent = `generating… ${elapsed}s`;
451
  }, 1000);
452
 
453
+ // Hard cap: re-enable after 95s (matches 90s server timeout + buffer)
454
  setTimeout(() => {
455
  clearInterval(tick);
456
  btn.disabled = false;
457
  btn.textContent = "✨ Generate Now";
458
+ status.textContent = "timed out β€” check HF token / try again";
459
+ }, 95000);
460
 
461
  // Store tick handle so SSE success can clear it
462
  btn._tick = tick;