feat: AI Desks — configurable agent system for news/technical/eco processing
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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("/")
|
||||
|
||||
148
backend/routers/ai_desks.py
Normal file
148
backend/routers/ai_desks.py
Normal file
@@ -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"}
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
|
||||
<Route path="/market-events" element={<MarketEvents />} />
|
||||
<Route path="/cycle-actions" element={<CycleActions />} />
|
||||
<Route path="/ai-desks" element={<AIDesks />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
599
frontend/src/pages/AIDesks.tsx
Normal file
599
frontend/src/pages/AIDesks.tsx
Normal file
@@ -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<string, SignalParam>
|
||||
}
|
||||
|
||||
interface AIDesk {
|
||||
id?: number
|
||||
name: string
|
||||
type: 'news' | 'technical' | 'eco' | 'report'
|
||||
active: boolean
|
||||
system_prompt: string
|
||||
instruments: string[]
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
news: 'News',
|
||||
technical: 'Technical',
|
||||
eco: 'Économique',
|
||||
report: 'Report',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-slate-400">Instruments surveillés</label>
|
||||
<button
|
||||
onClick={() => onChange(all ? [] : [...ALL_INSTRUMENTS])}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{all ? 'Tout désélectionner' : 'Tout sélectionner'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_INSTRUMENTS.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => toggle(t)}
|
||||
className={clsx(
|
||||
'px-2 py-0.5 rounded text-xs border transition-colors',
|
||||
selected.includes(t)
|
||||
? 'bg-blue-900/40 border-blue-600/60 text-blue-300'
|
||||
: 'bg-dark-800 border-slate-700/40 text-slate-500 hover:border-slate-600',
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
<div className={clsx(
|
||||
'rounded-lg border transition-colors',
|
||||
value.enabled
|
||||
? 'border-cyan-700/40 bg-cyan-900/10'
|
||||
: 'border-slate-700/30 bg-dark-800/60',
|
||||
)}>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<button
|
||||
onClick={() => onChange({ ...value, enabled: !value.enabled })}
|
||||
className="shrink-0"
|
||||
>
|
||||
{value.enabled
|
||||
? <ToggleRight className="w-5 h-5 text-cyan-400" />
|
||||
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={clsx('text-sm font-medium', value.enabled ? 'text-white' : 'text-slate-500')}>
|
||||
{signal.label}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 truncate">{signal.description}</div>
|
||||
</div>
|
||||
{value.enabled && Object.keys(signal.params).length > 0 && (
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
{open
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && value.enabled && (
|
||||
<div className="px-4 pb-3 space-y-2 border-t border-slate-700/20 pt-2">
|
||||
{Object.entries(signal.params).map(([key, p]) => {
|
||||
if (p.type === 'pairs') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="text-xs text-slate-400 mb-1">{p.label}</div>
|
||||
<div className="text-xs text-slate-500 italic">
|
||||
{JSON.stringify(value[key] ?? p.default)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<label className="text-xs text-slate-400 w-28 shrink-0">{p.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={p.min}
|
||||
max={p.max}
|
||||
step={p.type === 'float' ? 0.01 : 1}
|
||||
value={value[key] ?? p.default}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function NewsConfig({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, any>
|
||||
onChange: (c: Record<string, any>) => void
|
||||
}) {
|
||||
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Score min d'impact</label>
|
||||
<input
|
||||
type="number" min={0} max={1} step={0.05}
|
||||
value={config.min_impact ?? 0.55}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Max events à évaluer</label>
|
||||
<input
|
||||
type="number" min={1} max={50}
|
||||
value={config.max_evaluate ?? 15}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => set('dedup_enabled', !config.dedup_enabled)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{config.dedup_enabled
|
||||
? <ToggleRight className="w-5 h-5 text-blue-400" />
|
||||
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
|
||||
</button>
|
||||
<span className="text-sm text-slate-300">Déduplication sémantique</span>
|
||||
{config.dedup_enabled && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-xs text-slate-500">Fenêtre ± jours</span>
|
||||
<input
|
||||
type="number" min={1} max={7}
|
||||
value={config.dedup_lookback_days ?? 2}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function EcoConfig({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, any>
|
||||
onChange: (c: Record<string, any>) => void
|
||||
}) {
|
||||
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Seuil z-score</label>
|
||||
<input
|
||||
type="number" min={0.5} max={5} step={0.1}
|
||||
value={config.z_threshold ?? 1.5}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Jours lookback</label>
|
||||
<input
|
||||
type="number" min={1} max={30}
|
||||
value={config.days ?? 7}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Desk editor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DeskEditor({
|
||||
desk,
|
||||
catalog,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
desk: AIDesk
|
||||
catalog: SignalDef[]
|
||||
onSave: (d: AIDesk) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [d, setD] = useState<AIDesk>(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<string, any> = { 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 (
|
||||
<div className="space-y-5">
|
||||
{/* Header controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
value={d.name}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={d.type}
|
||||
onChange={e => set('type', e.target.value as AIDesk['type'])}
|
||||
className="bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white"
|
||||
>
|
||||
{Object.entries(TYPE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => set('active', !d.active)}
|
||||
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded border text-xs font-medium transition-colors',
|
||||
d.active
|
||||
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-400'
|
||||
: 'bg-dark-800 border-slate-700/30 text-slate-500',
|
||||
)}
|
||||
>
|
||||
{d.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||
{d.active ? 'Actif' : 'Inactif'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* System prompt */}
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">System prompt</label>
|
||||
<textarea
|
||||
value={d.system_prompt}
|
||||
onChange={e => set('system_prompt', e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Instructions spécifiques pour ce desk AI..."
|
||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white resize-y font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Instruments */}
|
||||
<InstrumentSelector
|
||||
selected={d.instruments}
|
||||
onChange={v => set('instruments', v)}
|
||||
/>
|
||||
|
||||
{/* Type-specific config */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 mb-2">Configuration</div>
|
||||
{d.type === 'news' && (
|
||||
<NewsConfig
|
||||
config={d.config}
|
||||
onChange={c => set('config', c)}
|
||||
/>
|
||||
)}
|
||||
{d.type === 'eco' && (
|
||||
<EcoConfig
|
||||
config={d.config}
|
||||
onChange={c => set('config', c)}
|
||||
/>
|
||||
)}
|
||||
{d.type === 'technical' && catalog.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{catalog.map(sig => (
|
||||
<SignalToggle
|
||||
key={sig.id}
|
||||
signal={sig}
|
||||
value={d.config.signals?.[sig.id] ?? { enabled: false }}
|
||||
onChange={v => updateSignal(sig.id, v)}
|
||||
/>
|
||||
))}
|
||||
<div className="pt-1">
|
||||
<label className="text-xs text-slate-400 block mb-1">Lookback jours</label>
|
||||
<input
|
||||
type="number" min={1} max={30}
|
||||
value={d.config.lookback_days ?? 7}
|
||||
onChange={e => set('config', { ...d.config, lookback_days: parseInt(e.target.value) })}
|
||||
className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700/30">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs text-red-400 hover:bg-red-900/20 border border-transparent hover:border-red-800/40 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(d)}
|
||||
disabled={!dirty}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-4 py-1.5 rounded text-sm font-medium transition-colors',
|
||||
dirty
|
||||
? 'bg-blue-600 hover:bg-blue-500 text-white'
|
||||
: 'bg-dark-700 text-slate-600 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AIDesks() {
|
||||
const [desks, setDesks] = useState<AIDesk[]>([])
|
||||
const [catalog, setCatalog] = useState<SignalDef[]>([])
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const [dRes, cRes] = await Promise.all([
|
||||
fetch('/api/ai-desks'),
|
||||
fetch('/api/ai-desks/signal-catalog'),
|
||||
])
|
||||
const dData = await dRes.json()
|
||||
const cData = await cRes.json()
|
||||
setDesks(dData)
|
||||
setCatalog(cData)
|
||||
if (dData.length > 0 && selected === null) setSelected(0)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const showMsg = (ok: boolean, text: string) => {
|
||||
setMsg({ ok, text })
|
||||
setTimeout(() => setMsg(null), 3000)
|
||||
}
|
||||
|
||||
const handleSave = async (d: AIDesk) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const method = d.id ? 'PUT' : 'POST'
|
||||
const url = d.id ? `/api/ai-desks/${d.id}` : '/api/ai-desks'
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(d),
|
||||
})
|
||||
if (r.ok) {
|
||||
showMsg(true, 'Desk enregistré')
|
||||
await load()
|
||||
} else {
|
||||
const err = await r.json()
|
||||
showMsg(false, err.detail ?? 'Erreur sauvegarde')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (d: AIDesk) => {
|
||||
if (!d.id) return
|
||||
if (!confirm(`Supprimer le desk "${d.name}" ?`)) return
|
||||
const r = await fetch(`/api/ai-desks/${d.id}`, { method: 'DELETE' })
|
||||
if (r.ok) {
|
||||
showMsg(true, 'Desk supprimé')
|
||||
setSelected(null)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNew = () => {
|
||||
const newDesk: AIDesk = {
|
||||
name: 'Nouveau Desk',
|
||||
type: 'news',
|
||||
active: true,
|
||||
system_prompt: '',
|
||||
instruments: [],
|
||||
config: {},
|
||||
}
|
||||
setDesks(prev => [...prev, newDesk])
|
||||
setSelected(desks.length)
|
||||
}
|
||||
|
||||
const currentDesk = selected !== null ? desks[selected] : null
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bot className="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">AI Desks</h1>
|
||||
<p className="text-sm text-slate-500">Configurer les agents IA spécialisés</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Nouveau desk
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={clsx(
|
||||
'px-4 py-2 rounded-lg text-sm border',
|
||||
msg.ok
|
||||
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-300'
|
||||
: 'bg-red-900/30 border-red-700/40 text-red-300',
|
||||
)}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-slate-500 text-sm py-8 text-center">Chargement...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[240px_1fr] gap-4">
|
||||
{/* Desk list */}
|
||||
<div className="space-y-1">
|
||||
{desks.map((d, i) => (
|
||||
<button
|
||||
key={d.id ?? `new-${i}`}
|
||||
onClick={() => setSelected(i)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2.5 rounded-lg border transition-colors',
|
||||
selected === i
|
||||
? 'bg-blue-900/30 border-blue-700/40 text-white'
|
||||
: 'bg-dark-800/60 border-slate-700/30 text-slate-400 hover:text-white hover:border-slate-600/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
'inline-block w-1.5 h-1.5 rounded-full',
|
||||
d.active ? 'bg-emerald-400' : 'bg-slate-600',
|
||||
)} />
|
||||
<span className="text-sm font-medium truncate">{d.name}</span>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'inline-block mt-1 px-1.5 py-0.5 rounded text-xs border',
|
||||
TYPE_COLORS[d.type] ?? 'text-slate-400 bg-dark-700 border-slate-700/30',
|
||||
)}>
|
||||
{TYPE_LABELS[d.type] ?? d.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{currentDesk ? (
|
||||
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-5">
|
||||
<DeskEditor
|
||||
key={currentDesk.id ?? `new-${selected}`}
|
||||
desk={currentDesk}
|
||||
catalog={catalog}
|
||||
onSave={handleSave}
|
||||
onDelete={() => handleDelete(currentDesk)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 flex items-center justify-center text-slate-600 text-sm">
|
||||
Sélectionner un desk ou en créer un nouveau
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user