feat: chatbot

This commit is contained in:
OpenSquared
2026-07-14 17:31:34 +02:00
parent 2dc4d8e91b
commit c4de6957ea
9 changed files with 639 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
"""
Free-form, read-only chat with GPT-4o about the current cockpit state.
Deliberately has NO function-calling/tools wired up — a plain text-completion
call physically cannot trigger any action (no trade, no cycle, no DB write
beyond persisting the conversation itself). The system prompt also tells the
model explicitly not to claim it can act, so it doesn't mislead the user.
"""
import re
import time
from typing import Dict, List, Optional
from services.ai_analyzer import get_client
from services.ai_chat_context import assemble_context, CONTEXT_BLOCKS
SYSTEM_PROMPT_HEADER = """Tu es l'assistant IA integre au cockpit de trading OpenFin Intelligence.
Tu as acces ci-dessous a un instantane en lecture seule de la situation actuelle (portefeuille, risque geopolitique, regime macro, patterns, options/IV, indicateurs techniques, signaux ondelettes, watchlist, calendrier economique, rapports institutionnels, lecons accumulees, VaR/risque).
REGLES IMPORTANTES :
- Tu ne peux declencher AUCUNE action (pas de trade, pas de cycle, pas de modification) - tu es uniquement la pour discuter, expliquer et aider a comprendre la situation ou une idee (ex. un montage d'options, un signal ondelette, pourquoi un pattern a tel score).
- Si on te demande d'agir, rappelle clairement que tu ne peux qu'expliquer/discuter, pas executer.
- Reponds en francais, de facon concise et directe, en t'appuyant sur le contexte fourni. Si une donnee demandee n'est pas dans le contexte ci-dessous, dis-le plutot que d'inventer.
=== CONTEXTE ACTUEL ===
{context}
=== FIN DU CONTEXTE ===
"""
def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max_tokens: int = 1200) -> str:
"""Multi-turn variant of ai_analyzer._chat() — accepts a full message history
instead of a single system+user pair. Same client/retry/backoff logic, kept
independent so it never risks the well-tested cycle-facing _chat()."""
client = get_client()
if not client:
raise RuntimeError("OpenAI API key not configured")
kwargs = {
"model": model,
"messages": [{"role": "system", "content": system}] + messages,
"temperature": 0.4,
"max_tokens": max_tokens,
}
last_exc: Optional[Exception] = None
for attempt in range(4):
try:
resp = client.chat.completions.create(**kwargs)
return resp.choices[0].message.content or ""
except Exception as e:
last_exc = e
err_str = str(e)
if "429" in err_str or "rate_limit" in err_str:
m = re.search(r"try again in ([\d.]+)s", err_str)
wait = float(m.group(1)) + 1.0 if m else 2 ** (attempt + 1) * 5.0
time.sleep(min(wait, 60.0))
continue
raise
raise last_exc # type: ignore[misc]
def send_chat_message(
session_id: str,
message: str,
enabled_blocks: Optional[List[str]] = None,
refresh_context: bool = False,
) -> Dict:
from services.database import save_chat_message, get_chat_messages
enabled = enabled_blocks or CONTEXT_BLOCKS
blocks = assemble_context(enabled, session_id, refresh=refresh_context)
context_text = "\n\n".join(blocks.values())
system = SYSTEM_PROMPT_HEADER.format(context=context_text)
history = get_chat_messages(session_id)
messages = [{"role": h["role"], "content": h["content"]} for h in history]
messages.append({"role": "user", "content": message})
save_chat_message(session_id, "user", message)
reply = _chat_messages(system, messages)
save_chat_message(session_id, "assistant", reply)
return {"reply": reply, "blocks_included": list(blocks.keys())}

View File

@@ -0,0 +1,207 @@
"""
Context assembly for the free-form AI chat widget. Every block below reuses an
existing, independent builder already used by the auto-cycle prompt (see
services/auto_cycle.py Step 1.9/2) — nothing here duplicates that logic, it just
re-packages the same read-only data for an interactive Q&A session instead of a
decision-making cycle. No block writes to the DB or triggers anything.
"""
import time
from typing import Dict, List
CONTEXT_BLOCKS = [
"portfolio", "geo_news", "macro", "patterns", "options_iv", "tech_indicators",
"wavelet_signals", "watchlist_quotes", "economic_calendar", "institutional",
"super_context", "var_risk",
]
_CACHE_TTL_SECONDS = 10 * 60
_context_cache: Dict[str, Dict] = {} # session_id -> {"blocks": {...}, "ts": float}
def _block_portfolio() -> str:
from services.portfolio_context import get_open_trades_with_moves, get_portfolio_concentration, build_portfolio_context_block
trades = get_open_trades_with_moves()
conc = get_portfolio_concentration(trades)
return build_portfolio_context_block(trades, conc)
def _block_geo_news() -> str:
from services.data_fetcher import fetch_geo_news
from services.geo_analyzer import compute_geo_risk_score
news = fetch_geo_news()
score = compute_geo_risk_score(news)
top = sorted(news, key=lambda n: -(n.get("impact_score") or 0))[:12]
lines = [f"## GEOPOLITICAL RISK\nScore: {score['score']}/100 ({score['level']})", "Top news:"]
for n in top:
lines.append(f"- [{round((n.get('impact_score') or 0) * 100)}] {n.get('title')}")
return "\n".join(lines)
def _block_macro() -> str:
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
gauges = get_macro_gauges()
scenarios = score_macro_scenarios(gauges)
lines = [f"## MACRO REGIME\nDominant scenario: {scenarios.get('dominant')}"]
ranked = scenarios.get("ranked") or []
if ranked:
lines.append("Scenario scores: " + ", ".join(f"{k}={v}" for k, v in ranked[:5]))
lines.append("Key gauges:")
for gid, g in list(gauges.items())[:12]:
lines.append(f"- {g.get('label', gid)}: {g.get('value')} {g.get('unit', '')} ({g.get('change_pct')}%)")
return "\n".join(lines)
def _block_patterns() -> str:
from services.database import get_all_pattern_reliability_map
rel = get_all_pattern_reliability_map()
if not rel:
return "## PATTERN RELIABILITY\nNo data yet."
lines = ["## PATTERN RELIABILITY"]
for pid, stats in list(rel.items())[:15]:
lines.append(f"- {stats.get('pattern_id', pid)}: win_rate={stats.get('win_rate')}, trades={stats.get('trade_count')}")
return "\n".join(lines)
def _block_options_iv() -> str:
from services.database import get_instruments_watchlist
from services.iv_engine import get_iv_context_for_prompt
tickers = [w["ticker"] for w in get_instruments_watchlist()]
if not tickers:
return "## OPTIONS / IV\nNo watchlist instruments configured (Config > Watchlist)."
return "## OPTIONS / IV (watchlist)\n" + get_iv_context_for_prompt(tickers)
def _block_tech_indicators() -> str:
from services.database import get_instruments_watchlist
from services.technical_indicators import compute_indicators, format_indicators_for_prompt
tickers = [w["ticker"] for w in get_instruments_watchlist()][:8]
lines = ["## TECHNICAL INDICATORS (watchlist)"]
for t in tickers:
try:
block = format_indicators_for_prompt(compute_indicators(t, horizon_days=45))
except Exception:
block = ""
if block:
lines.append(f"### {t}\n{block}")
return "\n".join(lines) if len(lines) > 1 else "## TECHNICAL INDICATORS\nNo data available."
def _block_wavelet_signals() -> str:
from services.database import get_latest_wavelet_signals
signals = get_latest_wavelet_signals()
if not signals:
return "## WAVELET SIGNALS\nNo wavelet signal detected yet (computed each auto-cycle)."
lines = ["## WAVELET SIGNALS (watchlist, latest cycle scan)"]
for s in signals[:20]:
lines.append(f"- {s['ticker']}: band {s['band_label']} · {s['signal_kind']} · {s['direction']} @ {s.get('price_at_signal')}")
return "\n".join(lines)
def _block_watchlist_quotes() -> str:
from services.database import get_instruments_watchlist
from services.data_fetcher import get_quote
items = get_instruments_watchlist()
if not items:
return "## WATCHLIST\nEmpty — no instruments configured."
lines = ["## WATCHLIST QUOTES"]
for w in items:
q = get_quote(w["ticker"]) or {}
lines.append(f"- {w['ticker']} ({w.get('asset_class')}): {q.get('price')} ({q.get('change_pct')}%)")
return "\n".join(lines)
def _block_economic_calendar() -> str:
from services.ff_calendar import get_calendar
data = get_calendar(period="recent", limit=50)
events = [e for e in data.get("events", []) if e.get("impact") in ("high", "medium")]
if not events:
return "## ECONOMIC CALENDAR\nNo high/medium impact events in range."
lines = ["## ECONOMIC CALENDAR (high/medium impact, recent window)"]
for e in events[:15]:
lines.append(f"- {e['event_date']} {e.get('event_time') or ''} [{e['currency']}] {e['event_name']} ({e['impact']})")
return "\n".join(lines)
def _block_institutional() -> str:
from services.ai_analyzer import build_institutional_block
return "## INSTITUTIONAL REPORTS\n" + build_institutional_block(days=7)
def _block_super_context() -> str:
from services.database import get_latest_portfolio_lessons, get_latest_reasoning_state
lessons = get_latest_portfolio_lessons()
reasoning = get_latest_reasoning_state()
lines = ["## SUPER CONTEXT / LESSONS LEARNED"]
if lessons:
lines.append(f"Headline: {lessons.get('headline', '')}")
key_lessons = lessons.get("key_lessons") or []
if key_lessons:
lines.append("Key lessons: " + "; ".join(str(k) for k in key_lessons[:5]))
if lessons.get("risk_watch"):
lines.append(f"Risk watch: {lessons['risk_watch']}")
if reasoning and reasoning.get("narrative"):
lines.append(f"Reasoning state (v{reasoning.get('version')}): {reasoning['narrative'][:600]}")
if len(lines) == 1:
return "## SUPER CONTEXT\nNo data yet."
return "\n".join(lines)
def _block_var_risk() -> str:
from services.var_service import get_latest_var_snapshot
from services.portfolio_risk import analyze_simulation_portfolio, build_monitor_context
lines = ["## RISK / VaR"]
snap = get_latest_var_snapshot()
if snap:
lines.append(
f"VaR 95% hist: {snap.get('hist_var_1d_pct')}% · CVaR: {snap.get('hist_cvar_pct')}% · "
f"Monte Carlo x1.5: {snap.get('mc_var_1d_pct')}%"
)
else:
lines.append("No VaR snapshot yet.")
try:
risk = analyze_simulation_portfolio()
lines.append(build_monitor_context(risk))
except Exception:
pass
return "\n".join(lines)
_BLOCK_BUILDERS = {
"portfolio": _block_portfolio,
"geo_news": _block_geo_news,
"macro": _block_macro,
"patterns": _block_patterns,
"options_iv": _block_options_iv,
"tech_indicators": _block_tech_indicators,
"wavelet_signals": _block_wavelet_signals,
"watchlist_quotes": _block_watchlist_quotes,
"economic_calendar": _block_economic_calendar,
"institutional": _block_institutional,
"super_context": _block_super_context,
"var_risk": _block_var_risk,
}
def assemble_context(enabled_blocks: List[str], session_id: str, refresh: bool = False) -> Dict[str, str]:
"""Return {block_key: formatted_text} for every requested, known block.
Cached in-memory per session (same TTL-cache idiom as _macro_cache in
routers/market_data.py) so a back-and-forth conversation doesn't re-run every
builder (some hit yfinance) on every single message."""
cached = _context_cache.get(session_id)
if not refresh and cached and (time.time() - cached["ts"]) < _CACHE_TTL_SECONDS:
blocks = cached["blocks"]
else:
blocks = {}
for key in CONTEXT_BLOCKS:
try:
blocks[key] = _BLOCK_BUILDERS[key]()
except Exception as e:
blocks[key] = f"## {key.upper()}\n(unavailable: {e})"
_context_cache[session_id] = {"blocks": blocks, "ts": time.time()}
requested = [b for b in enabled_blocks if b in blocks] or list(blocks.keys())
return {k: blocks[k] for k in requested}
def clear_context_cache(session_id: str) -> None:
_context_cache.pop(session_id, None)

View File

@@ -135,6 +135,14 @@ def init_db():
price_at_signal REAL
)""",
"ALTER TABLE cycle_runs ADD COLUMN wavelet_signals_count INTEGER DEFAULT 0",
# AI Chat widget — persisted conversation turns, one growing thread per session_id
"""CREATE TABLE IF NOT EXISTS ai_chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)""",
]:
try:
c.execute(_sql)
@@ -146,6 +154,11 @@ def init_db():
except Exception:
pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_chat_session_date ON ai_chat_messages(session_id, created_at)")
except Exception:
pass
# Specialist Reports — surprise index + text sentiment columns
try:
c.execute("ALTER TABLE specialist_reports ADD COLUMN consensus_estimate REAL")
@@ -3213,6 +3226,35 @@ def get_wavelet_signals_history(ticker: str, days: int = 30) -> List[Dict]:
return [dict(r) for r in rows]
# ── AI Chat widget — persisted conversation ────────────────────────────────────
def save_chat_message(session_id: str, role: str, content: str) -> None:
conn = get_conn()
conn.execute(
"INSERT INTO ai_chat_messages (session_id, role, content) VALUES (?, ?, ?)",
(session_id, role, content),
)
conn.commit()
conn.close()
def get_chat_messages(session_id: str, limit: int = 100) -> List[Dict]:
conn = get_conn()
rows = conn.execute(
"SELECT role, content, created_at FROM ai_chat_messages WHERE session_id=? ORDER BY id ASC LIMIT ?",
(session_id, limit),
).fetchall()
conn.close()
return [dict(r) for r in rows]
def clear_chat_session(session_id: str) -> None:
conn = get_conn()
conn.execute("DELETE FROM ai_chat_messages WHERE session_id=?", (session_id,))
conn.commit()
conn.close()
# ── System Logs ───────────────────────────────────────────────────────────────
def log_system_event(