From 97706dea7bee2b8b5fa815cd57bcc642787a3e95 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 25 Jun 2026 23:08:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20Desks=20=E2=80=94=20configurable?= =?UTF-8?q?=20agent=20system=20for=20news/technical/eco=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ai_desks table with CRUD (get_all/by_type/upsert/delete) - ai_desks router: REST API + GET /signal-catalog (7 extensible signals) - News Desk: semantic dedup via AI (±N days window, system_prompt hint) - Technical Desk: 4 signal detectors driven by desk config (ma_cross, rsi_extreme, bb_squeeze, new_52w_extreme) - 3 more signals in catalog ready to enable: price_gap, volume_spike, macd_crossover - market_event_detector.py loads desk configs at runtime, falls back to legacy params - AIDesks.tsx: full editor UI with signal toggles, param sliders, instrument multi-select - Sidebar: Bot icon + /ai-desks route Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 2 + backend/routers/ai_desks.py | 148 +++++ backend/services/database.py | 171 ++++++ backend/services/market_event_detector.py | 560 ++++++++++++++----- frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/pages/AIDesks.tsx | 599 +++++++++++++++++++++ 7 files changed, 1350 insertions(+), 135 deletions(-) create mode 100644 backend/routers/ai_desks.py create mode 100644 frontend/src/pages/AIDesks.tsx diff --git a/backend/main.py b/backend/main.py index 76be3ef..02b8785 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,7 @@ from routers import instruments as instruments_router from routers import impact as impact_router from routers import cycle_actions as cycle_actions_router from routers import market_events as market_events_router +from routers import ai_desks as ai_desks_router from routers import logs as logs_router from routers import var as var_router from routers import reports as reports_router @@ -132,6 +133,7 @@ app.include_router(instruments_router.router) app.include_router(impact_router.router) app.include_router(cycle_actions_router.router) app.include_router(market_events_router.router) +app.include_router(ai_desks_router.router) @app.get("/") diff --git a/backend/routers/ai_desks.py b/backend/routers/ai_desks.py new file mode 100644 index 0000000..fb46307 --- /dev/null +++ b/backend/routers/ai_desks.py @@ -0,0 +1,148 @@ +""" +AI Desks — CRUD + signal catalog. +Prefix: /api/ai-desks +""" +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ai-desks", tags=["AI Desks"]) + + +# ── Signal catalog (extensible) ─────────────────────────────────────────────── + +SIGNAL_CATALOG: List[Dict[str, Any]] = [ + { + "id": "ma_cross", + "label": "Croisement de moyennes mobiles", + "description": "Détecte les croisements bullish/bearish entre deux MAs", + "params": { + "pairs": { + "type": "pairs", "label": "Paires MA", + "default": [["MA50", "MA200"], ["MA50", "MA100"]], + "options": ["MA20", "MA50", "MA100", "MA200"], + }, + }, + }, + { + "id": "rsi_extreme", + "label": "RSI extrême", + "description": "Signal quand le RSI dépasse les seuils de surachat/survente", + "params": { + "period": {"type": "int", "label": "Période", "default": 14, "min": 5, "max": 50}, + "oversold": {"type": "int", "label": "Survente", "default": 30, "min": 10, "max": 45}, + "overbought": {"type": "int", "label": "Surachat", "default": 70, "min": 55, "max": 90}, + }, + }, + { + "id": "bb_squeeze", + "label": "Squeeze Bollinger Bands", + "description": "Détecte quand les bandes BB se resserrent en dessous d'un seuil", + "params": { + "period": {"type": "int", "label": "Période", "default": 20, "min": 10, "max": 50}, + "std": {"type": "float", "label": "Std dev", "default": 2.0, "min": 1.0, "max": 3.0}, + "width_threshold": {"type": "float", "label": "Seuil width", "default": 0.05, "min": 0.01, "max": 0.2}, + }, + }, + { + "id": "new_52w_extreme", + "label": "Nouveau 52 semaines extrême", + "description": "Nouveau plus haut ou plus bas sur 52 semaines (avec buffer)", + "params": { + "buffer_pct": {"type": "float", "label": "Buffer %", "default": 0.5, "min": 0.0, "max": 5.0}, + }, + }, + { + "id": "price_gap", + "label": "Gap de prix", + "description": "Gap d'ouverture significatif par rapport à la clôture précédente", + "params": { + "min_gap_pct": {"type": "float", "label": "Gap min %", "default": 1.5, "min": 0.5, "max": 10.0}, + }, + }, + { + "id": "volume_spike", + "label": "Spike de volume", + "description": "Volume anormalement élevé par rapport à la moyenne mobile", + "params": { + "ma_period": {"type": "int", "label": "Période MA vol", "default": 20, "min": 5, "max": 50}, + "spike_factor": {"type": "float", "label": "Facteur spike", "default": 2.5, "min": 1.5, "max": 10.0}, + }, + }, + { + "id": "macd_crossover", + "label": "Croisement MACD", + "description": "Croisement de la ligne MACD avec la ligne signal", + "params": { + "fast": {"type": "int", "label": "EMA rapide", "default": 12, "min": 5, "max": 30}, + "slow": {"type": "int", "label": "EMA lente", "default": 26, "min": 15, "max": 60}, + "signal": {"type": "int", "label": "Signal", "default": 9, "min": 3, "max": 20}, + }, + }, +] + + +# ── Schemas ─────────────────────────────────────────────────────────────────── + +class AIDeskUpsert(BaseModel): + name: str + type: str + active: bool = True + system_prompt: str = "" + instruments: List[str] = [] + config: Dict[str, Any] = {} + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.get("") +def list_desks() -> List[Dict[str, Any]]: + from services.database import get_all_ai_desks + return get_all_ai_desks() + + +@router.get("/signal-catalog") +def get_signal_catalog() -> List[Dict[str, Any]]: + return SIGNAL_CATALOG + + +@router.get("/{desk_id}") +def get_desk(desk_id: int) -> Dict[str, Any]: + from services.database import get_all_ai_desks + desks = get_all_ai_desks() + desk = next((d for d in desks if d["id"] == desk_id), None) + if not desk: + raise HTTPException(404, f"Desk {desk_id} not found") + return desk + + +@router.post("", status_code=201) +def create_desk(body: AIDeskUpsert) -> Dict[str, Any]: + from services.database import upsert_ai_desk + new_id = upsert_ai_desk(body.dict()) + return {"id": new_id, "status": "created"} + + +@router.put("/{desk_id}") +def update_desk(desk_id: int, body: AIDeskUpsert) -> Dict[str, Any]: + from services.database import get_all_ai_desks, upsert_ai_desk + desks = get_all_ai_desks() + desk = next((d for d in desks if d["id"] == desk_id), None) + if not desk: + raise HTTPException(404, f"Desk {desk_id} not found") + upsert_ai_desk({**body.dict(), "name": desk["name"]}) + return {"status": "updated"} + + +@router.delete("/{desk_id}") +def delete_desk(desk_id: int) -> Dict[str, Any]: + from services.database import get_all_ai_desks, delete_ai_desk + desks = get_all_ai_desks() + desk = next((d for d in desks if d["id"] == desk_id), None) + if not desk: + raise HTTPException(404, f"Desk {desk_id} not found") + delete_ai_desk(desk["name"]) + return {"status": "deleted"} diff --git a/backend/services/database.py b/backend/services/database.py index 9f30a7b..ab11f33 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -961,6 +961,87 @@ def init_db(): except Exception: pass + # ── AI Desks ────────────────────────────────────────────────────────────── + c.execute("""CREATE TABLE IF NOT EXISTS ai_desks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + active INTEGER DEFAULT 1, + system_prompt TEXT DEFAULT '', + instruments TEXT DEFAULT '[]', + config TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )""") + + # Seed default desks (idempotent) + _AI_DESK_DEFAULTS = [ + { + "name": "News Desk — Géopolitique", + "type": "news", + "active": 1, + "system_prompt": ( + "Tu es un analyste géopolitique et macro senior. Tu évalues si une news représente " + "un événement marché STRUCTURANT qui mérite un enregistrement permanent.\n" + "Sois exigeant : préfère ignorer une news douteuse plutôt qu'enregistrer du bruit.\n" + "Points d'attention :\n" + "- Al Jazeera, RT et certains médias régionaux publient souvent plusieurs articles " + "redondants sur le même fait — vérifie toujours si un événement similaire existe déjà.\n" + "- Une rumeur ou spéculation sans source officielle ne qualifie pas.\n" + "- Privilégie les faits avérés avec impact macro ou géopolitique mesurable." + ), + "instruments": json.dumps(["SPY","GLD","USO","TLT","VXX","EURUSD=X","BTC-USD","XOM"]), + "config": json.dumps({ + "min_impact": 0.55, + "lookback_hours": 48, + "max_evaluate": 15, + "dedup_enabled": True, + "dedup_lookback_days": 2, + "dedup_categories": ["geopolitical","fundamental","report"], + }), + }, + { + "name": "Technical Desk", + "type": "technical", + "active": 1, + "system_prompt": ( + "Tu détectes des signaux techniques structurants sur les marchés financiers. " + "Concentre-toi sur les signaux qui ont une signification macro claire." + ), + "instruments": json.dumps([ + "SPY","QQQ","IWM","EEM","GLD","USO","TLT", + "EURUSD=X","VXX","BTC-USD","NVDA","XOM","HYG" + ]), + "config": json.dumps({ + "lookback_days": 7, + "signals": { + "ma_cross": {"enabled": True, "pairs": [["MA50","MA200"],["MA50","MA100"]]}, + "rsi_extreme": {"enabled": True, "period": 14, "oversold": 30, "overbought": 70}, + "bb_squeeze": {"enabled": True, "period": 20, "std": 2.0, "width_threshold": 0.05}, + "new_52w_extreme":{"enabled": True, "buffer_pct": 0.5}, + }, + }), + }, + { + "name": "Eco Desk — FRED", + "type": "eco", + "active": 1, + "system_prompt": "Tu analyses les surprises économiques des données macro US (FRED).", + "instruments": json.dumps(["SPY","TLT","GLD","EURUSD=X","USO","HYG"]), + "config": json.dumps({"z_threshold": 1.5, "days": 7}), + }, + ] + for _desk in _AI_DESK_DEFAULTS: + try: + c.execute( + "INSERT OR IGNORE INTO ai_desks (name, type, active, system_prompt, instruments, config) " + "VALUES (?,?,?,?,?,?)", + (_desk["name"], _desk["type"], _desk["active"], + _desk["system_prompt"], _desk["instruments"], _desk["config"]) + ) + except Exception: + pass + conn.commit() conn.close() @@ -4850,3 +4931,93 @@ def get_weekly_impact_sources(days: int = 7, min_score: float = 0.3) -> List[Dic return result finally: conn.close() + + +# ── AI Desks ────────────────────────────────────────────────────────────────── + +def get_all_ai_desks() -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute("SELECT * FROM ai_desks ORDER BY type, name").fetchall() + result = [] + for r in rows: + d = dict(r) + for f in ("instruments", "config"): + try: + d[f] = json.loads(d.get(f) or "[]" if f == "instruments" else "{}") + except Exception: + d[f] = [] if f == "instruments" else {} + result.append(d) + return result + finally: + conn.close() + + +def get_ai_desk_by_type(desk_type: str) -> Optional[Dict[str, Any]]: + """Return the first active desk of given type, or None.""" + desks = get_all_ai_desks() + return next((d for d in desks if d["type"] == desk_type and d.get("active")), None) + + +def upsert_ai_desk(desk: Dict[str, Any]) -> int: + conn = get_conn() + try: + instr = desk.get("instruments", []) + cfg = desk.get("config", {}) + if isinstance(instr, list): + instr = json.dumps(instr) + if isinstance(cfg, dict): + cfg = json.dumps(cfg) + conn.execute(""" + INSERT INTO ai_desks (name, type, active, system_prompt, instruments, config, updated_at) + VALUES (?,?,?,?,?,?, datetime('now')) + ON CONFLICT(name) DO UPDATE SET + type=excluded.type, active=excluded.active, + system_prompt=excluded.system_prompt, + instruments=excluded.instruments, config=excluded.config, + updated_at=datetime('now') + """, (desk["name"], desk["type"], int(desk.get("active", 1)), + desk.get("system_prompt", ""), instr, cfg)) + row = conn.execute("SELECT id FROM ai_desks WHERE name=?", (desk["name"],)).fetchone() + conn.commit() + return row["id"] if row else -1 + finally: + conn.close() + + +def delete_ai_desk(name: str) -> bool: + conn = get_conn() + try: + conn.execute("DELETE FROM ai_desks WHERE name=?", (name,)) + conn.commit() + return True + finally: + conn.close() + + +def get_market_events_near_date(date_str: str, days: int = 2, + categories: Optional[List[str]] = None) -> List[Dict[str, Any]]: + """Fetch market_events within ±days of date_str, optionally filtered by category.""" + conn = get_conn() + try: + from datetime import datetime, timedelta + dt = datetime.fromisoformat(date_str[:10]) + d_from = (dt - timedelta(days=days)).strftime("%Y-%m-%d") + d_to = (dt + timedelta(days=days)).strftime("%Y-%m-%d") + if categories: + placeholders = ",".join("?" * len(categories)) + rows = conn.execute( + f"SELECT id, name, start_date, category, description FROM market_events " + f"WHERE start_date BETWEEN ? AND ? AND category IN ({placeholders}) " + f"ORDER BY start_date DESC LIMIT 30", + [d_from, d_to] + list(categories) + ).fetchall() + else: + rows = conn.execute( + "SELECT id, name, start_date, category, description FROM market_events " + "WHERE start_date BETWEEN ? AND ? ORDER BY start_date DESC LIMIT 30", + (d_from, d_to) + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() diff --git a/backend/services/market_event_detector.py b/backend/services/market_event_detector.py index 8d9a958..8cd2ea4 100644 --- a/backend/services/market_event_detector.py +++ b/backend/services/market_event_detector.py @@ -4,11 +4,10 @@ Isolated cycle action: Check New Market Events. Scans 4 sources and creates market_events for significant findings: - news : geopolitical/macro news (RSS feeds, rule-scored) - eco : FRED economic releases with high surprise z-score - - technical: MA50/MA100/MA200 crossovers on key instruments + - technical: configurable signal catalog driven by Technical Desk - reports : institutional reports (COT, EIA) with high importance -After each event is created, instrument impacts are evaluated immediately -via the AI (impact_service.evaluate_event_impacts). +Desk configs are loaded from ai_desks table at runtime. """ import json import logging @@ -69,7 +68,7 @@ def _parse_date(raw: str) -> str: def _save_and_evaluate(ev: Dict, existing: set) -> Optional[Dict]: - """Save a market_event and immediately evaluate instrument impacts. Returns created dict or None.""" + """Save a market_event and immediately evaluate instrument impacts.""" from services.database import save_market_event try: event_id = save_market_event(ev) @@ -79,7 +78,6 @@ def _save_and_evaluate(ev: Dict, existing: set) -> Optional[Dict]: logger.error(f"[check_events] save failed for '{ev['name']}': {e}") return None - # Evaluate instrument impacts immediately try: from services.impact_service import evaluate_event_impacts evaluate_event_impacts(event_id, force=False) @@ -89,15 +87,82 @@ def _save_and_evaluate(ev: Dict, existing: set) -> Optional[Dict]: return {"name": ev["name"], "category": ev.get("category", ""), "date": ev.get("start_date", ""), "event_id": event_id} +# ── Semantic deduplication ──────────────────────────────────────────────────── + +def _semantic_dedup( + title: str, + source: str, + date_str: str, + summary: str, + category: str, + client: Any, + dedup_lookback_days: int = 2, + system_prompt_hint: str = "", +) -> bool: + """ + Ask the AI whether this news already exists in recent market_events. + Returns True if it's a duplicate (should be skipped). + """ + from services.database import get_market_events_near_date + dedup_categories = ["geopolitical", "fundamental", "report"] + if category and category not in dedup_categories: + dedup_categories.append(category) + + recent = get_market_events_near_date(date_str, days=dedup_lookback_days, categories=dedup_categories) + if not recent: + return False + + recent_block = "\n".join( + f" [{r['start_date']}] {r['name']} — {(r.get('description') or '')[:80]}" + for r in recent[:15] + ) + + hint = f"\nNote du desk: {system_prompt_hint[:200]}" if system_prompt_hint else "" + + prompt = f"""Tu es un éditeur de base de données d'événements marchés.{hint} + +NOUVELLE NEWS À VÉRIFIER: +- Titre: {title} +- Source: {source} +- Date: {date_str} +- Résumé: {summary[:300]} + +ÉVÉNEMENTS EXISTANTS (±{dedup_lookback_days} jours): +{recent_block} + +Cette news représente-t-elle le même fait qu'un événement déjà enregistré ? +Réponds JSON: {{"is_duplicate": true/false, "reason": "courte phrase"}}""" + + try: + resp = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + temperature=0.0, + max_tokens=100, + ) + parsed = json.loads(resp.choices[0].message.content) + is_dup = bool(parsed.get("is_duplicate", False)) + if is_dup: + logger.debug(f"[dedup] Skipping duplicate: '{title[:40]}' — {parsed.get('reason','')}") + return is_dup + except Exception as e: + logger.debug(f"[dedup] AI check failed for '{title[:40]}': {e}") + return False + + # ── Source 1: Geopolitical / macro news ────────────────────────────────────── -def _check_news( - min_impact: float = 0.55, - lookback_hours: int = 48, - max_to_evaluate: int = 15, -) -> List[Dict[str, Any]]: +def _check_news(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: from services.data_fetcher import fetch_geo_news + min_impact = float(desk_cfg.get("min_impact", 0.55)) + lookback_hours = int(desk_cfg.get("lookback_hours", 48)) + max_evaluate = int(desk_cfg.get("max_evaluate", 15)) + dedup_enabled = bool(desk_cfg.get("dedup_enabled", True)) + dedup_days = int(desk_cfg.get("dedup_lookback_days", 2)) + system_prompt = desk_cfg.get("_system_prompt", "") + api_key = _get_api_key() if not api_key: logger.warning("[check_events/news] no OpenAI key — skipping") @@ -122,13 +187,10 @@ def _check_news( pass candidates.append(n) - candidates = candidates[:max_to_evaluate] + candidates = candidates[:max_evaluate] if not candidates: return [] - existing = _existing_event_keys() - created: List[Dict] = [] - try: from openai import OpenAI client = OpenAI(api_key=api_key) @@ -136,17 +198,35 @@ def _check_news( logger.warning(f"[check_events/news] OpenAI init failed: {e}") return [] + existing = _existing_event_keys() + created: List[Dict] = [] + for n in candidates: title = n.get("title", "") if not title or _is_dup(title, existing): continue + pub_date = _parse_date(n.get("date", "")) + news_summary = str(n.get("summary", ""))[:400] + source = n.get("source", "") + + # Semantic dedup before expensive classification call + if dedup_enabled: + if _semantic_dedup( + title, source, pub_date, news_summary, + category="geopolitical", + client=client, + dedup_lookback_days=dedup_days, + system_prompt_hint=system_prompt, + ): + continue + prompt = f"""Tu es un analyste macro. Cette news représente-t-elle un événement marché structurant qui mérite un enregistrement permanent ? TITRE: {title} -SOURCE: {n.get('source', '')} +SOURCE: {source} DATE: {n.get('date', '')} -RÉSUMÉ: {str(n.get('summary', ''))[:400]} +RÉSUMÉ: {news_summary} SCORE IMPACT (règle): {n.get('impact_score', 0):.2f} Réponds OUI seulement si c'est un fait avéré, pas une rumeur ou une opinion, et qu'il a un impact macro ou géopolitique mesurable. @@ -185,25 +265,25 @@ FORMAT JSON STRICT: continue source_ref = { - "title": title, - "source": n.get("source", ""), - "url": n.get("url") or n.get("link", ""), - "date": _parse_date(n.get("date", "")), + "title": title, + "source": source, + "url": n.get("url") or n.get("link", ""), + "date": pub_date, "original_score": round(float(n.get("impact_score", 0)), 3), } ev = { - "name": ev_name, - "start_date": _parse_date(n.get("date", "")), - "level": parsed.get("level", "short"), - "category": parsed.get("category", "geopolitical"), - "sub_type": parsed.get("sub_type", ""), - "description": parsed.get("description", title), - "market_impact": "", + "name": ev_name, + "start_date": pub_date, + "level": parsed.get("level", "short"), + "category": parsed.get("category", "geopolitical"), + "sub_type": parsed.get("sub_type", ""), + "description": parsed.get("description", title), + "market_impact": "", "affected_assets": parsed.get("affected_assets", []), - "impact_score": float(parsed.get("impact_score", 0.6)), - "source_refs": [source_ref], - "origin": "detector_news", + "impact_score": float(parsed.get("impact_score", 0.6)), + "source_refs": [source_ref], + "origin": "detector_news", } result = _save_and_evaluate(ev, existing) if result: @@ -215,9 +295,12 @@ FORMAT JSON STRICT: # ── Source 2: Eco calendar — FRED surprises ─────────────────────────────────── -def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: +def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: from services.database import get_recent_economic_surprises + z_threshold = float(desk_cfg.get("z_threshold", 1.5)) + days = int(desk_cfg.get("days", 7)) + try: releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold) except Exception as e: @@ -251,33 +334,33 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: assets = [] source_ref = { - "title": f"FRED release: {ev_name_base} ({ev_date})", - "source": "FRED", - "url": f"https://fred.stlouisfed.org/series/{s_id}" if s_id else "", - "date": ev_date, + "title": f"FRED release: {ev_name_base} ({ev_date})", + "source": "FRED", + "url": f"https://fred.stlouisfed.org/series/{s_id}" if s_id else "", + "date": ev_date, "original_score": round(min(0.95, 0.35 + z * 0.15), 3), } ev = { - "name": ev_name, - "start_date": ev_date, - "level": level, - "category": "event_calendar", - "sub_type": sub_type, - "description": ( + "name": ev_name, + "start_date": ev_date, + "level": level, + "category": "event_calendar", + "sub_type": sub_type, + "description": ( f"Surprise {direction} {sign}{s_pct:.1f}% vs baseline " f"(z-score: {z:.1f}σ). " f"Réel: {rel.get('actual_value', '?')} {rel.get('actual_unit', '')} " f"/ Prévision: {rel.get('forecast_value', '?')}." ), - "market_impact": "", + "market_impact": "", "affected_assets": assets, - "impact_score": min(0.95, 0.35 + z * 0.15), - "actual_value": str(rel.get("actual_value", "")), - "expected_value": str(rel.get("forecast_value", "")), - "surprise_pct": float(s_pct), - "source_refs": [source_ref], - "origin": "detector_eco", + "impact_score": min(0.95, 0.35 + z * 0.15), + "actual_value": str(rel.get("actual_value", "")), + "expected_value": str(rel.get("forecast_value", "")), + "surprise_pct": float(s_pct), + "source_refs": [source_ref], + "origin": "detector_eco", } result = _save_and_evaluate(ev, existing) if result: @@ -287,9 +370,192 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: return created -# ── Source 3: MA crossovers (technical) ────────────────────────────────────── +# ── Technical signal detectors ──────────────────────────────────────────────── -def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> List[Dict[str, Any]]: +def _detect_ma_cross(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]: + """Golden/Death cross detector for configured MA pairs.""" + import pandas as pd + events = [] + pairs_cfg = params.get("pairs", [["MA50", "MA200"], ["MA50", "MA100"]]) + ma_map = {"MA20": 20, "MA50": 50, "MA100": 100, "MA200": 200} + + close = df["Close"].squeeze() + + # Pre-compute all required MAs + needed: set = set() + for pair in pairs_cfg: + needed.update(pair) + ma_series: Dict[str, Any] = {} + for lbl in needed: + period = ma_map.get(lbl) + if period and len(df) >= period: + ma_series[lbl] = close.rolling(period).mean() + + recent = df.tail(4) + for i in range(1, len(recent)): + date_str = str(recent.index[i])[:10] + if date_str < cutoff: + continue + for fast_lbl, slow_lbl in pairs_cfg: + if fast_lbl not in ma_series or slow_lbl not in ma_series: + continue + fp = ma_series[fast_lbl].iloc[-(len(recent) - i + 1)] + fc = ma_series[fast_lbl].iloc[-(len(recent) - i)] + sp = ma_series[slow_lbl].iloc[-(len(recent) - i + 1)] + sc = ma_series[slow_lbl].iloc[-(len(recent) - i)] + if any(pd.isna(v) for v in [fp, fc, sp, sc]): + continue + if fp < sp and fc >= sc: + kind = "golden" + elif fp > sp and fc <= sc: + kind = "death" + else: + continue + cross_label = "Golden Cross" if kind == "golden" else "Death Cross" + events.append({ + "name": f"{ticker} {fast_lbl}/{slow_lbl} {cross_label} ({date_str[:7]})", + "date": date_str, + "direction": "bullish" if kind == "golden" else "bearish", + "sub_type": f"{fast_lbl}/{slow_lbl} Cross", + "score": 0.65 if "MA200" in (fast_lbl, slow_lbl) else 0.45, + "level": "medium" if "MA200" in (fast_lbl, slow_lbl) else "short", + "desc": f"{cross_label}: {fast_lbl} {'au-dessus' if kind=='golden' else 'en-dessous'} de {slow_lbl} sur {ticker}.", + }) + return events + + +def _detect_rsi_extreme(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]: + """RSI oversold/overbought signal.""" + import pandas as pd + period = int(params.get("period", 14)) + oversold = float(params.get("oversold", 30)) + overbought = float(params.get("overbought", 70)) + + close = df["Close"].squeeze() + if len(close) < period + 2: + return [] + + delta = close.diff() + gain = delta.clip(lower=0).rolling(period).mean() + loss = (-delta.clip(upper=0)).rolling(period).mean() + rs = gain / loss.replace(0, float("nan")) + rsi = 100 - (100 / (1 + rs)) + + events = [] + recent = rsi.tail(3) + for i in range(len(recent)): + date_str = str(recent.index[i])[:10] + if date_str < cutoff: + continue + val = recent.iloc[i] + if pd.isna(val): + continue + if val <= oversold: + direction, label = "bullish", "Oversold" + elif val >= overbought: + direction, label = "bearish", "Overbought" + else: + continue + events.append({ + "name": f"{ticker} RSI {label} ({date_str[:7]})", + "date": date_str, + "direction": direction, + "sub_type": f"RSI {label}", + "score": 0.50 if abs(val - 50) > 30 else 0.40, + "level": "short", + "desc": f"RSI({period}) à {val:.1f} sur {ticker} — signal {label.lower()} ({direction}).", + }) + return events + + +def _detect_bb_squeeze(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]: + """Bollinger Band squeeze detector.""" + import pandas as pd + period = int(params.get("period", 20)) + std_mult = float(params.get("std", 2.0)) + width_threshold = float(params.get("width_threshold", 0.05)) + + close = df["Close"].squeeze() + if len(close) < period + 2: + return [] + + mid = close.rolling(period).mean() + std = close.rolling(period).std() + upper = mid + std_mult * std + lower = mid - std_mult * std + width = (upper - lower) / mid + + events = [] + recent = width.tail(3) + for i in range(len(recent)): + date_str = str(recent.index[i])[:10] + if date_str < cutoff: + continue + w = recent.iloc[i] + if pd.isna(w): + continue + if w <= width_threshold: + events.append({ + "name": f"{ticker} BB Squeeze ({date_str[:7]})", + "date": date_str, + "direction": "neutral", + "sub_type": "BB Squeeze", + "score": 0.45, + "level": "short", + "desc": f"Bandes de Bollinger({period},{std_mult}) très resserrées sur {ticker} — width={w:.3f}. Explosion de volatilité imminente.", + }) + return events + + +def _detect_52w_extreme(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]: + """New 52-week high/low detector.""" + import pandas as pd + buffer_pct = float(params.get("buffer_pct", 0.5)) / 100 + + close = df["Close"].squeeze() + if len(close) < 252: + return [] + + high_52 = close.rolling(252).max() + low_52 = close.rolling(252).min() + + events = [] + recent_close = close.tail(3) + for i in range(len(recent_close)): + date_str = str(recent_close.index[i])[:10] + if date_str < cutoff: + continue + c = recent_close.iloc[i] + h52 = high_52.iloc[-(3 - i)] + l52 = low_52.iloc[-(3 - i)] + if pd.isna(c) or pd.isna(h52) or pd.isna(l52): + continue + if c >= h52 * (1 - buffer_pct): + events.append({ + "name": f"{ticker} Nouveau 52W High ({date_str[:7]})", + "date": date_str, + "direction": "bullish", + "sub_type": "52W High", + "score": 0.60, + "level": "medium", + "desc": f"{ticker} atteint un nouveau plus haut 52 semaines à {c:.2f} (précédent: {h52:.2f}).", + }) + elif c <= l52 * (1 + buffer_pct): + events.append({ + "name": f"{ticker} Nouveau 52W Low ({date_str[:7]})", + "date": date_str, + "direction": "bearish", + "sub_type": "52W Low", + "score": 0.60, + "level": "medium", + "desc": f"{ticker} atteint un nouveau plus bas 52 semaines à {c:.2f} (précédent: {l52:.2f}).", + }) + return events + + +# ── Source 3: Technical signals ─────────────────────────────────────────────── + +def _check_technical(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: try: import yfinance as yf import pandas as pd @@ -297,93 +563,78 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L logger.warning("[check_events/technical] yfinance/pandas not available") return [] - if instruments is None: - instruments = WATCH_INSTRUMENTS + instruments = desk_cfg.get("_instruments") or WATCH_INSTRUMENTS + lookback_days = int(desk_cfg.get("lookback_days", 7)) + signals_config = desk_cfg.get("signals", {}) - existing = _existing_event_keys() + # Determine which signals are active + def sig_cfg(sig_id: str) -> Optional[Dict]: + c = signals_config.get(sig_id, {}) + return c if c.get("enabled", False) else None + + ma_cross_cfg = sig_cfg("ma_cross") + rsi_cfg = sig_cfg("rsi_extreme") + bb_cfg = sig_cfg("bb_squeeze") + extreme_52w = sig_cfg("new_52w_extreme") + + if not any([ma_cross_cfg, rsi_cfg, bb_cfg, extreme_52w]): + logger.info("[check_events/technical] no active signals in desk config") + return [] + + existing = _existing_event_keys() created: List[Dict] = [] - cutoff = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d") + cutoff = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d") for ticker in instruments: try: - df = yf.download(ticker, period="1y", interval="1d", progress=False, auto_adjust=True) - if df is None or len(df) < 210: + df = yf.download(ticker, period="2y", interval="1d", progress=False, auto_adjust=True) + if df is None or len(df) < 20: continue - close = df["Close"].squeeze() - df["ma50"] = close.rolling(50).mean() - df["ma100"] = close.rolling(100).mean() - df["ma200"] = close.rolling(200).mean() + # Flatten MultiIndex if needed (yfinance ≥ 0.2 returns MultiIndex columns) + if hasattr(df.columns, "levels"): + df.columns = df.columns.get_level_values(0) - recent = df.tail(lookback_days + 2) + detected: List[Dict] = [] + if ma_cross_cfg and len(df) >= 210: + detected += _detect_ma_cross(ticker, df, ma_cross_cfg, cutoff) + if rsi_cfg: + detected += _detect_rsi_extreme(ticker, df, rsi_cfg, cutoff) + if bb_cfg: + detected += _detect_bb_squeeze(ticker, df, bb_cfg, cutoff) + if extreme_52w and len(df) >= 252: + detected += _detect_52w_extreme(ticker, df, extreme_52w, cutoff) - for i in range(1, len(recent)): - date_str = str(recent.index[i])[:10] - if date_str < cutoff: + for sig in detected: + ev_name = sig["name"] + if _is_dup(ev_name, existing): continue - prev = recent.iloc[i - 1] - curr = recent.iloc[i] + source_ref = { + "title": f"Technical signal: {ev_name}", + "source": "yfinance/computed", + "url": f"https://finance.yahoo.com/quote/{ticker}", + "date": sig["date"], + "original_score": sig["score"], + } - def cross(fp, fc, sp, sc): - if any(pd.isna(v) for v in [fp, fc, sp, sc]): - return None - if fp < sp and fc >= sc: - return "golden" - if fp > sp and fc <= sc: - return "death" - return None - - pairs = [ - ("MA50", "MA200", prev["ma50"], curr["ma50"], prev["ma200"], curr["ma200"]), - ("MA50", "MA100", prev["ma50"], curr["ma50"], prev["ma100"], curr["ma100"]), - ] - - for fast_lbl, slow_lbl, fp, fc, sp, sc in pairs: - kind = cross(fp, fc, sp, sc) - if kind is None: - continue - - cross_label = "Golden Cross" if kind == "golden" else "Death Cross" - ev_name = f"{ticker} {fast_lbl}/{slow_lbl} {cross_label} ({date_str[:7]})" - - if _is_dup(ev_name, existing): - continue - - direction = "bullish" if kind == "golden" else "bearish" - level = "medium" if slow_lbl == "MA200" else "short" - - source_ref = { - "title": f"Technical signal: {ev_name}", - "source": "yfinance/computed", - "url": f"https://finance.yahoo.com/quote/{ticker}", - "date": date_str, - "original_score": 0.65 if slow_lbl == "MA200" else 0.45, - } - - ev = { - "name": ev_name, - "start_date": date_str, - "level": level, - "category": "technical", - "sub_type": f"{fast_lbl}/{slow_lbl} Cross", - "description": ( - f"{cross_label} : {fast_lbl} passe " - f"{'au-dessus' if kind == 'golden' else 'en-dessous'} " - f"de la {slow_lbl} sur {ticker}. " - f"Signal {direction} de tendance " - f"{'long terme' if slow_lbl == 'MA200' else 'moyen terme'}." - ), - "market_impact": f"Signal {direction} sur {ticker}", - "affected_assets": [ticker], - "impact_score": 0.65 if slow_lbl == "MA200" else 0.45, - "source_refs": [source_ref], - "origin": "detector_technical", - } - result = _save_and_evaluate(ev, existing) - if result: - result["source"] = "technical" - created.append(result) + ev = { + "name": ev_name, + "start_date": sig["date"], + "level": sig["level"], + "category": "technical", + "sub_type": sig["sub_type"], + "description": sig["desc"], + "market_impact": f"Signal {sig['direction']} sur {ticker}", + "affected_assets": [ticker], + "impact_score": sig["score"], + "source_refs": [source_ref], + "origin": "detector_technical", + } + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "technical" + created.append(result) except Exception as e: logger.debug(f"[check_events/technical] {ticker} failed: {e}") @@ -443,10 +694,10 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any assets.extend(asset_list) source_ref = { - "title": title, - "source": rpt.get("source", rpt_type), - "url": "", - "date": rpt_date, + "title": title, + "source": rpt.get("source", rpt_type), + "url": "", + "date": rpt_date, "original_score": round(min(0.9, 0.3 + rpt.get("importance", 2) * 0.12), 3), } @@ -475,6 +726,7 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any def check_new_market_events( sources: Optional[List[str]] = None, + # Legacy overrides (used when called from cycle_actions without a desk) news_impact_min: float = 0.55, news_lookback_hours: int = 48, eco_z_threshold: float = 1.5, @@ -484,12 +736,52 @@ def check_new_market_events( report_min_importance: int = 3, ) -> Dict[str, Any]: """ - Isolated cycle action — scans all (or selected) sources, creates - market_events with source_refs, and immediately evaluates instrument impacts. + Scans all (or selected) sources, creates market_events with source_refs, + and immediately evaluates instrument impacts. + Desk configs from ai_desks table override legacy params when available. """ if sources is None: sources = ["news", "eco", "technical", "reports"] + # Load desk configs (fall back to legacy params if no active desk found) + try: + from services.database import get_ai_desk_by_type + news_desk = get_ai_desk_by_type("news") + tech_desk = get_ai_desk_by_type("technical") + eco_desk = get_ai_desk_by_type("eco") + except Exception as e: + logger.warning(f"[check_events] Could not load desk configs: {e}") + news_desk = tech_desk = eco_desk = None + + def _desk_cfg(desk: Optional[Dict], fallback: Dict) -> Dict: + if not desk: + return fallback + cfg = dict(desk.get("config") or {}) + cfg["_instruments"] = desk.get("instruments") or None + cfg["_system_prompt"] = desk.get("system_prompt") or "" + return cfg + + news_cfg = _desk_cfg(news_desk, { + "min_impact": news_impact_min, + "lookback_hours": news_lookback_hours, + "max_evaluate": 15, + "dedup_enabled": False, + "dedup_lookback_days": 2, + }) + eco_cfg = _desk_cfg(eco_desk, { + "z_threshold": eco_z_threshold, + "days": eco_days, + }) + tech_cfg = _desk_cfg(tech_desk, { + "lookback_days": technical_lookback_days, + "signals": { + "ma_cross": {"enabled": True, "pairs": [["MA50", "MA200"], ["MA50", "MA100"]]}, + "rsi_extreme": {"enabled": True, "period": 14, "oversold": 30, "overbought": 70}, + "bb_squeeze": {"enabled": True, "period": 20, "std": 2.0, "width_threshold": 0.05}, + "new_52w_extreme": {"enabled": True, "buffer_pct": 0.5}, + }, + }) + results: Dict[str, Any] = { "news": [], "eco": [], "technical": [], "reports": [], "total_created": 0, @@ -498,19 +790,19 @@ def check_new_market_events( if "news" in sources: try: - results["news"] = _check_news(min_impact=news_impact_min, lookback_hours=news_lookback_hours) + results["news"] = _check_news(news_cfg) except Exception as e: logger.error(f"[check_events] news source error: {e}") if "eco" in sources: try: - results["eco"] = _check_eco(z_threshold=eco_z_threshold, days=eco_days) + results["eco"] = _check_eco(eco_cfg) except Exception as e: logger.error(f"[check_events] eco source error: {e}") if "technical" in sources: try: - results["technical"] = _check_technical(lookback_days=technical_lookback_days) + results["technical"] = _check_technical(tech_cfg) except Exception as e: logger.error(f"[check_events] technical source error: {e}") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3db465..ed15b8b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import ExternalSnapshot from './pages/ExternalSnapshot' import InstrumentDashboard from './pages/InstrumentDashboard' import CycleActions from './pages/CycleActions' import MarketEvents from './pages/MarketEvents' +import AIDesks from './pages/AIDesks' import { Navigate } from 'react-router-dom' import { useCycleWatcher } from './hooks/useApi' @@ -74,6 +75,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index dd8d647..cc761ca 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -29,6 +29,7 @@ const nav = [ { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, { to: '/instruments', icon: CandlestickChart, label: 'Instrument Analysis' }, { to: '/market-events', icon: Radio, label: 'Market Events' }, + { to: '/ai-desks', icon: Bot, label: 'AI Desks' }, { to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' }, { to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' }, { to: '/logs', icon: ScrollText, label: 'System Logs' }, diff --git a/frontend/src/pages/AIDesks.tsx b/frontend/src/pages/AIDesks.tsx new file mode 100644 index 0000000..b79bfa8 --- /dev/null +++ b/frontend/src/pages/AIDesks.tsx @@ -0,0 +1,599 @@ +import { useState, useEffect, useCallback } from 'react' +import { Bot, Plus, Trash2, Save, ChevronDown, ChevronRight, ToggleLeft, ToggleRight } from 'lucide-react' +import clsx from 'clsx' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface SignalParam { + type: 'int' | 'float' | 'pairs' + label: string + default: number | number[][] + min?: number + max?: number + options?: string[] +} + +interface SignalDef { + id: string + label: string + description: string + params: Record +} + +interface AIDesk { + id?: number + name: string + type: 'news' | 'technical' | 'eco' | 'report' + active: boolean + system_prompt: string + instruments: string[] + config: Record +} + +const ALL_INSTRUMENTS = [ + 'SPY','QQQ','IWM','EEM','EFA', + 'GLD','SLV','USO','UNG','TLT', + 'HYG','EURUSD=X','USDJPY=X','GBPUSD=X', + 'VXX','AAPL','NVDA','GS','XOM','BTC-USD', +] + +const TYPE_LABELS: Record = { + news: 'News', + technical: 'Technical', + eco: 'Économique', + report: 'Report', +} + +const TYPE_COLORS: Record = { + news: 'text-blue-400 bg-blue-900/20 border-blue-700/40', + technical: 'text-cyan-400 bg-cyan-900/20 border-cyan-700/40', + eco: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40', + report: 'text-violet-400 bg-violet-900/20 border-violet-700/40', +} + +// ── Subcomponents ───────────────────────────────────────────────────────────── + +function InstrumentSelector({ + selected, + onChange, +}: { + selected: string[] + onChange: (v: string[]) => void +}) { + const toggle = (t: string) => { + onChange(selected.includes(t) ? selected.filter(x => x !== t) : [...selected, t]) + } + const all = selected.length === ALL_INSTRUMENTS.length + return ( +
+
+ + +
+
+ {ALL_INSTRUMENTS.map(t => ( + + ))} +
+
+ ) +} + + +function SignalToggle({ + signal, + value, + onChange, +}: { + signal: SignalDef + value: { enabled: boolean; [k: string]: any } + onChange: (v: { enabled: boolean; [k: string]: any }) => void +}) { + const [open, setOpen] = useState(false) + + const update = (key: string, val: any) => { + onChange({ ...value, [key]: val }) + } + + return ( +
+
+ +
+
+ {signal.label} +
+
{signal.description}
+
+ {value.enabled && Object.keys(signal.params).length > 0 && ( + + )} +
+ + {open && value.enabled && ( +
+ {Object.entries(signal.params).map(([key, p]) => { + if (p.type === 'pairs') { + return ( +
+
{p.label}
+
+ {JSON.stringify(value[key] ?? p.default)} +
+
+ ) + } + return ( +
+ + update(key, p.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value))} + className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white" + /> +
+ ) + })} +
+ )} +
+ ) +} + + +function NewsConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const set = (k: string, v: any) => onChange({ ...config, [k]: v }) + return ( +
+
+
+ + set('min_impact', parseFloat(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ + set('max_evaluate', parseInt(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ +
+ + Déduplication sémantique + {config.dedup_enabled && ( +
+ Fenêtre ± jours + set('dedup_lookback_days', parseInt(e.target.value))} + className="w-14 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white" + /> +
+ )} +
+
+ ) +} + + +function EcoConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const set = (k: string, v: any) => onChange({ ...config, [k]: v }) + return ( +
+
+ + set('z_threshold', parseFloat(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ + set('days', parseInt(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ ) +} + + +// ── Desk editor ─────────────────────────────────────────────────────────────── + +function DeskEditor({ + desk, + catalog, + onSave, + onDelete, +}: { + desk: AIDesk + catalog: SignalDef[] + onSave: (d: AIDesk) => void + onDelete: () => void +}) { + const [d, setD] = useState(desk) + const set = (k: keyof AIDesk, v: any) => setD(prev => ({ ...prev, [k]: v })) + const dirty = JSON.stringify(d) !== JSON.stringify(desk) + + // Ensure signals config is initialized from catalog for technical desks + useEffect(() => { + if (d.type !== 'technical' || !catalog.length) return + const signals = d.config.signals ?? {} + let changed = false + const next = { ...signals } + for (const sig of catalog) { + if (!next[sig.id]) { + const defaults: Record = { enabled: false } + for (const [k, p] of Object.entries(sig.params)) { + defaults[k] = p.default + } + next[sig.id] = defaults + changed = true + } + } + if (changed) setD(prev => ({ ...prev, config: { ...prev.config, signals: next } })) + }, [catalog, d.type]) + + const updateSignal = (sigId: string, val: any) => { + setD(prev => ({ + ...prev, + config: { ...prev.config, signals: { ...(prev.config.signals ?? {}), [sigId]: val } }, + })) + } + + return ( +
+ {/* Header controls */} +
+
+ set('name', e.target.value)} + placeholder="Nom du desk" + className="w-full bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white" + /> +
+ + +
+ + {/* System prompt */} +
+ +