feat: chatbot
This commit is contained in:
Binary file not shown.
@@ -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 instrument_models as instrument_models_router
|
||||||
from routers import instruments_watchlist as instruments_watchlist_router
|
from routers import instruments_watchlist as instruments_watchlist_router
|
||||||
from routers import wavelet as wavelet_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
|
from services.database import init_db, get_config, cleanup_stale_running_cycles
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -237,6 +238,7 @@ app.include_router(causal_lab_router.router)
|
|||||||
app.include_router(instrument_models_router.router)
|
app.include_router(instrument_models_router.router)
|
||||||
app.include_router(instruments_watchlist_router.router)
|
app.include_router(instruments_watchlist_router.router)
|
||||||
app.include_router(wavelet_router.router)
|
app.include_router(wavelet_router.router)
|
||||||
|
app.include_router(ai_chat_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
60
backend/routers/ai_chat.py
Normal file
60
backend/routers/ai_chat.py
Normal file
@@ -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}
|
||||||
83
backend/services/ai_chat.py
Normal file
83
backend/services/ai_chat.py
Normal 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())}
|
||||||
207
backend/services/ai_chat_context.py
Normal file
207
backend/services/ai_chat_context.py
Normal 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)
|
||||||
@@ -135,6 +135,14 @@ def init_db():
|
|||||||
price_at_signal REAL
|
price_at_signal REAL
|
||||||
)""",
|
)""",
|
||||||
"ALTER TABLE cycle_runs ADD COLUMN wavelet_signals_count INTEGER DEFAULT 0",
|
"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:
|
try:
|
||||||
c.execute(_sql)
|
c.execute(_sql)
|
||||||
@@ -146,6 +154,11 @@ def init_db():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Specialist Reports — surprise index + text sentiment columns
|
||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE specialist_reports ADD COLUMN consensus_estimate REAL")
|
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]
|
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 ───────────────────────────────────────────────────────────────
|
# ── System Logs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def log_system_event(
|
def log_system_event(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import MacroSeriesPage from './pages/MacroSeriesPage'
|
|||||||
import EuroSimulator from './pages/EuroSimulator'
|
import EuroSimulator from './pages/EuroSimulator'
|
||||||
import CausalLab from './pages/CausalLab'
|
import CausalLab from './pages/CausalLab'
|
||||||
import InstrumentModels from './pages/InstrumentModels'
|
import InstrumentModels from './pages/InstrumentModels'
|
||||||
|
import ChatWidget from './components/ChatWidget'
|
||||||
import { useCycleWatcher } from './hooks/useApi'
|
import { useCycleWatcher } from './hooks/useApi'
|
||||||
|
|
||||||
// ── Keep-alive pages ──────────────────────────────────────────────────────────
|
// ── Keep-alive pages ──────────────────────────────────────────────────────────
|
||||||
@@ -157,6 +158,7 @@ function AppInner() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
<GlobalWatcher />
|
<GlobalWatcher />
|
||||||
|
<ChatWidget />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|||||||
213
frontend/src/components/ChatWidget.tsx
Normal file
213
frontend/src/components/ChatWidget.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Bot, X, Send, RefreshCw, Trash2, Settings2 } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {
|
||||||
|
useChatContextBlocks, useChatHistory, useSendChatMessage, useClearChatSession, type ChatMessage,
|
||||||
|
} from '../hooks/useApi'
|
||||||
|
|
||||||
|
const BLOCK_LABELS: Record<string, string> = {
|
||||||
|
portfolio: 'Portefeuille',
|
||||||
|
geo_news: 'Risque géopolitique',
|
||||||
|
macro: 'Régime macro',
|
||||||
|
patterns: 'Patterns',
|
||||||
|
options_iv: 'Options / IV',
|
||||||
|
tech_indicators: 'Indicateurs techniques',
|
||||||
|
wavelet_signals: 'Signaux ondelettes',
|
||||||
|
watchlist_quotes: 'Watchlist',
|
||||||
|
economic_calendar: 'Calendrier économique',
|
||||||
|
institutional: 'Rapports institutionnels',
|
||||||
|
super_context: 'Super Contexte',
|
||||||
|
var_risk: 'VaR / Risque',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionId(): string {
|
||||||
|
let id = localStorage.getItem('ai_chat_session_id')
|
||||||
|
if (!id) {
|
||||||
|
id = (crypto as any).randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
localStorage.setItem('ai_chat_session_id', id)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnabledBlocks(allBlocks: string[]): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('ai_chat_enabled_blocks')
|
||||||
|
if (raw) return new Set(JSON.parse(raw))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return new Set(allBlocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatWidget() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [pendingRefresh, setPendingRefresh] = useState(false)
|
||||||
|
const sessionId = useRef(getSessionId()).current
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { data: allBlocks } = useChatContextBlocks()
|
||||||
|
const [enabledBlocks, setEnabledBlocks] = useState<Set<string>>(new Set())
|
||||||
|
useEffect(() => {
|
||||||
|
if (allBlocks) setEnabledBlocks(getEnabledBlocks(allBlocks))
|
||||||
|
}, [allBlocks])
|
||||||
|
|
||||||
|
const { data: history } = useChatHistory(sessionId)
|
||||||
|
useEffect(() => {
|
||||||
|
if (history) setMessages(history)
|
||||||
|
}, [history])
|
||||||
|
|
||||||
|
const { mutateAsync: sendMessage, isPending } = useSendChatMessage()
|
||||||
|
const { mutateAsync: clearSession } = useClearChatSession()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
|
||||||
|
}, [messages, isPending])
|
||||||
|
|
||||||
|
const toggleBlock = (key: string) => {
|
||||||
|
const next = new Set(enabledBlocks)
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key)
|
||||||
|
setEnabledBlocks(next)
|
||||||
|
localStorage.setItem('ai_chat_enabled_blocks', JSON.stringify([...next]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || isPending) return
|
||||||
|
setInput('')
|
||||||
|
setMessages(prev => [...prev, { role: 'user', content: text }])
|
||||||
|
try {
|
||||||
|
const res = await sendMessage({
|
||||||
|
session_id: sessionId,
|
||||||
|
message: text,
|
||||||
|
enabled_blocks: [...enabledBlocks],
|
||||||
|
refresh_context: pendingRefresh,
|
||||||
|
})
|
||||||
|
setPendingRefresh(false)
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: res.reply }])
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.detail ?? 'Erreur — vérifie la clé API OpenAI dans Configuration.'
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `⚠️ ${msg}` }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
await clearSession(sessionId)
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Fixed toggle button — mounted once at the app root, survives all route changes */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
title="Assistant IA"
|
||||||
|
className="fixed top-4 right-5 z-50 w-11 h-11 rounded-full bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/40 flex items-center justify-center transition-all hover:scale-105"
|
||||||
|
>
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'fixed inset-0 bg-black/40 z-40 transition-opacity duration-200',
|
||||||
|
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none',
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'fixed right-0 top-0 h-full w-[420px] max-w-[92vw] bg-dark-800 border-l border-slate-700/40 z-50 flex flex-col shadow-2xl transition-transform duration-200 ease-out',
|
||||||
|
open ? 'translate-x-0' : 'translate-x-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-700/40 shrink-0">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-blue-900/40 flex items-center justify-center">
|
||||||
|
<Bot className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">Assistant IA</div>
|
||||||
|
<div className="text-[10px] text-slate-500">Discussion seule — aucune action déclenchée</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button onClick={() => setShowSettings(s => !s)} title="Contexte transmis"
|
||||||
|
className={clsx('p-1.5 rounded hover:bg-dark-700 transition-colors', showSettings ? 'text-blue-400' : 'text-slate-500')}>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setOpen(false)} className="p-1.5 rounded hover:bg-dark-700 text-slate-500 transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context settings */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="px-4 py-3 border-b border-slate-700/40 shrink-0 max-h-52 overflow-y-auto">
|
||||||
|
<div className="text-[10px] text-slate-500 mb-2">Blocs de contexte transmis à l'IA (tout par défaut) :</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{(allBlocks ?? []).map(b => (
|
||||||
|
<label key={b} className="flex items-center gap-1.5 text-[10px] text-slate-300 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={enabledBlocks.has(b)} onChange={() => toggleBlock(b)} />
|
||||||
|
{BLOCK_LABELS[b] ?? b}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-xs text-slate-600 text-center mt-8">
|
||||||
|
Pose une question sur ta situation actuelle — portefeuille, risque géopolitique, régime macro,
|
||||||
|
Options Lab, signaux ondelettes... L'IA a accès à un instantané complet, en lecture seule.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||||
|
<div className={clsx(
|
||||||
|
'max-w-[85%] rounded-lg px-3 py-2 text-xs whitespace-pre-wrap leading-relaxed',
|
||||||
|
m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-dark-700/80 border border-slate-700/40 text-slate-200',
|
||||||
|
)}>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isPending && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-dark-700/80 border border-slate-700/40 rounded-lg px-3 py-2 text-xs text-slate-500 italic">
|
||||||
|
🧠 Réflexion…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-1.5 border-t border-slate-700/30 shrink-0 text-[10px]">
|
||||||
|
<button onClick={() => setPendingRefresh(true)}
|
||||||
|
className={clsx('flex items-center gap-1 transition-colors', pendingRefresh ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300')}>
|
||||||
|
<RefreshCw className="w-3 h-3" /> {pendingRefresh ? 'Contexte sera rafraîchi' : 'Rafraîchir le contexte'}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleClear} className="ml-auto flex items-center gap-1 text-slate-500 hover:text-red-400 transition-colors">
|
||||||
|
<Trash2 className="w-3 h-3" /> Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="flex items-center gap-2 p-3 border-t border-slate-700/40 shrink-0">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
|
||||||
|
placeholder="Écris ta question…"
|
||||||
|
className="flex-1 bg-dark-900 border border-slate-700/40 rounded-lg px-3 py-2 text-xs text-white placeholder:text-slate-600 focus:outline-none focus:border-blue-500/50"
|
||||||
|
/>
|
||||||
|
<button onClick={send} disabled={isPending || !input.trim()}
|
||||||
|
className="w-8 h-8 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white flex items-center justify-center shrink-0 transition-colors">
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1426,3 +1426,33 @@ export function useScoreText() {
|
|||||||
api.post('/specialist-desks/score-text', body).then(r => r.data) as Promise<TextSentimentResult>,
|
api.post('/specialist-desks/score-text', body).then(r => r.data) as Promise<TextSentimentResult>,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI Chat widget — free-form, read-only, context-aware conversation ────────
|
||||||
|
|
||||||
|
export interface ChatMessage { role: 'user' | 'assistant'; content: string; created_at?: string }
|
||||||
|
|
||||||
|
export const useChatContextBlocks = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['ai-chat-blocks'],
|
||||||
|
queryFn: () => api.get('/ai-chat/blocks').then(r => r.data.blocks as string[]),
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useChatHistory = (sessionId: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['ai-chat-history', sessionId],
|
||||||
|
queryFn: () => api.get('/ai-chat/history', { params: { session_id: sessionId } }).then(r => r.data.messages as ChatMessage[]),
|
||||||
|
enabled: !!sessionId,
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useSendChatMessage = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (body: { session_id: string; message: string; enabled_blocks?: string[]; refresh_context?: boolean }) =>
|
||||||
|
api.post('/ai-chat/', body).then(r => r.data as { reply: string; blocks_included: string[] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useClearChatSession = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (sessionId: string) => api.post('/ai-chat/clear', { session_id: sessionId }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user