diff --git a/backend/data/geooptions.db-journal b/backend/data/geooptions.db-journal deleted file mode 100644 index cbef509..0000000 Binary files a/backend/data/geooptions.db-journal and /dev/null differ diff --git a/backend/main.py b/backend/main.py index 794e45a..37ea1ad 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,6 +19,7 @@ from routers import causal_lab as causal_lab_router from routers import instrument_models as instrument_models_router from routers import instruments_watchlist as instruments_watchlist_router from routers import wavelet as wavelet_router +from routers import ai_chat as ai_chat_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os import logging @@ -237,6 +238,7 @@ app.include_router(causal_lab_router.router) app.include_router(instrument_models_router.router) app.include_router(instruments_watchlist_router.router) app.include_router(wavelet_router.router) +app.include_router(ai_chat_router.router) @app.get("/") diff --git a/backend/routers/ai_chat.py b/backend/routers/ai_chat.py new file mode 100644 index 0000000..852273f --- /dev/null +++ b/backend/routers/ai_chat.py @@ -0,0 +1,60 @@ +""" +Free-form, read-only chat with GPT-4o about the current cockpit state. +No function-calling — this endpoint can never trigger an action. +""" +from typing import List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from services.ai_chat_context import CONTEXT_BLOCKS + +router = APIRouter(prefix="/api/ai-chat", tags=["ai-chat"]) + + +class ChatMessageBody(BaseModel): + session_id: str + message: str + enabled_blocks: Optional[List[str]] = None + refresh_context: bool = False + + +class ClearBody(BaseModel): + session_id: str + + +@router.get("/blocks") +def list_context_blocks(): + """Static list of context block keys the frontend can offer as toggles.""" + return {"blocks": CONTEXT_BLOCKS} + + +@router.post("/") +def send_message(body: ChatMessageBody): + from services.ai_chat import send_chat_message + if not body.message.strip(): + raise HTTPException(400, "Message vide.") + try: + return send_chat_message( + session_id=body.session_id, + message=body.message.strip(), + enabled_blocks=body.enabled_blocks, + refresh_context=body.refresh_context, + ) + except RuntimeError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.get("/history") +def get_history(session_id: str): + from services.database import get_chat_messages + return {"messages": get_chat_messages(session_id)} + + +@router.post("/clear") +def clear_session(body: ClearBody): + from services.database import clear_chat_session + from services.ai_chat_context import clear_context_cache + clear_chat_session(body.session_id) + clear_context_cache(body.session_id) + return {"cleared": body.session_id} diff --git a/backend/services/ai_chat.py b/backend/services/ai_chat.py new file mode 100644 index 0000000..0de8a06 --- /dev/null +++ b/backend/services/ai_chat.py @@ -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())} diff --git a/backend/services/ai_chat_context.py b/backend/services/ai_chat_context.py new file mode 100644 index 0000000..24e0e3a --- /dev/null +++ b/backend/services/ai_chat_context.py @@ -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) diff --git a/backend/services/database.py b/backend/services/database.py index b38e442..fc6ca0f 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1368c99..0b79dfa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,7 @@ import MacroSeriesPage from './pages/MacroSeriesPage' import EuroSimulator from './pages/EuroSimulator' import CausalLab from './pages/CausalLab' import InstrumentModels from './pages/InstrumentModels' +import ChatWidget from './components/ChatWidget' import { useCycleWatcher } from './hooks/useApi' // ── Keep-alive pages ────────────────────────────────────────────────────────── @@ -157,6 +158,7 @@ function AppInner() { return (