Fix Generate Now: move LLM call into dashboard.py directly
Browse filesPreviously: 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 +126 -7
- dashboard/templates/index.html +3 -3
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 |
-
|
| 453 |
-
|
| 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
|
| 454 |
setTimeout(() => {
|
| 455 |
clearInterval(tick);
|
| 456 |
btn.disabled = false;
|
| 457 |
btn.textContent = "β¨ Generate Now";
|
| 458 |
-
status.textContent = "
|
| 459 |
-
},
|
| 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;
|