feat: Phase 2 + context log — FRED releases, cycle context snapshot, onglet Contexte IA

Phase 2 — Données macro FRED :
- fred_fetcher.py (nouveau) : 7 séries FRED (CPI, NFP, UNRATE, FEDFUNDS, GDP, ICSA,
  spread 10Y-2Y) avec détection direction bullish/bearish et block prompt formaté
- ai_analyzer.py : param fred_block dans suggest + score, injecté dans les deux prompts
- auto_cycle.py : fetch FRED non-bloquant avant la suggestion

Context log — Snapshot du contexte complet :
- database.py : table cycle_context_snapshots + save/get/list fonctions
- auto_cycle.py : sauvegarde le snapshot (meta, news partitionnées, FRED, tech, IV, quotes)
- cycle.py : GET /api/cycle/contexts + GET /api/cycle/contexts/{run_id}
- useApi.ts : hooks useCycleContextSnapshots + useCycleContextSnapshot
- SystemLogs.tsx : onglet "Contexte IA" avec liste de cycles et visualiseur JSON
  par section (cycle_meta, macro, news, FRED, tech) avec accordéon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 16:51:02 +02:00
parent 50ba75e468
commit 9c0ebbd138
7 changed files with 451 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from services.database import get_cycle_runs, get_cycle_run, set_config, get_config
from services.database import get_cycle_runs, get_cycle_run, set_config, get_config, list_cycle_context_snapshots, get_cycle_context_snapshot
from services.auto_cycle import get_status, trigger_manual, restart_scheduler
router = APIRouter(prefix="/api/cycle", tags=["cycle"])
@@ -82,3 +82,19 @@ def update_cycle_config(req: CycleConfigRequest):
restart_scheduler()
return get_status()
@router.get("/contexts")
def list_context_snapshots(limit: int = 30):
"""List cycle context snapshots (most recent first)."""
return {"snapshots": list_cycle_context_snapshots(limit=limit)}
@router.get("/contexts/{run_id}")
def get_context_snapshot(run_id: str):
"""Return the full context snapshot for a given cycle run_id."""
snap = get_cycle_context_snapshot(run_id)
if not snap:
raise HTTPException(404, "Snapshot non trouvé pour ce cycle")
return snap

View File

@@ -354,6 +354,7 @@ def score_patterns_with_context(
risk_context: str = "",
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
fred_block: str = "",
) -> List[Dict[str, Any]]:
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
if not get_client():
@@ -573,12 +574,14 @@ Instructions de notation:
)
_tech_sc_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
_fred_sc_section = f"\n{fred_block}\n" if fred_block else ""
user = f"""CONTEXTE GLOBAL:
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
- Top risques: {geo_score.get('top_risks', [])}
{temporal_section_sc}
{macro_section}
{_fred_sc_section}
{_tech_sc_section}
TEMPLATE DE NOTATION:
{scoring_template}
@@ -969,6 +972,7 @@ def suggest_patterns_from_market_context(
iv_context: str = "",
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
fred_block: str = "",
) -> List[Dict]:
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
_cycle_meta = cycle_meta or {}
@@ -1115,12 +1119,14 @@ Règles supplémentaires:
)
tech_block_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
fred_section = f"\n{fred_block}\n" if fred_block else ""
user = f"""Tu es un stratège géopolitique et financier senior, expert en options.
{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block}
{temporal_news_block}
## Prix des marchés (variation J-1)
{market_block}
{fred_section}
{tech_block_section}
## Calendrier économique à venir
{cal_block}

View File

@@ -174,7 +174,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
save_pattern_scores, log_macro_regime, log_geo_alert, log_trade_entries,
add_cycle_run, update_cycle_run, save_reasoning_trace,
get_latest_portfolio_lessons, log_system_event,
get_last_completed_cycle_ts,
get_last_completed_cycle_ts, save_cycle_context_snapshot,
)
from services.data_fetcher import fetch_geo_news, get_all_quotes, get_macro_gauges, score_macro_scenarios
from services.geo_analyzer import compute_geo_risk_score
@@ -370,6 +370,18 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
logger.info(f"[Cycle {run_id[:16]}] Reliability map: {len(_reliability_map)} patterns")
except Exception as _re:
logger.warning(f"[Cycle] Reliability map failed (non-blocking): {_re}")
# ── Phase 2: FRED recent macro releases ──────────────────────────────
_fred_releases = []
_fred_block = ""
try:
from services.fred_fetcher import get_fred_recent_releases, build_fred_context_block
_fred_releases = get_fred_recent_releases()
_fred_block = build_fred_context_block(_fred_releases)
if _fred_releases:
logger.info(f"[Cycle {run_id[:16]}] FRED: {len(_fred_releases)} releases fetched")
except Exception as _fe:
logger.warning(f"[Cycle] FRED fetch failed (non-blocking): {_fe}")
try:
from services.data_fetcher import get_economic_calendar
calendar = get_economic_calendar()
@@ -406,6 +418,34 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
except Exception as _te:
logger.warning(f"[Cycle] Tech indicators failed (non-blocking): {_te}")
# ── Save full context snapshot ─────────────────────────────────
try:
from services.ai_analyzer import apply_news_decay as _apply_decay2, partition_news_by_age as _part
_news_snap = _part(_apply_decay2(news), cycle_meta.get("delta_minutes", 180))
_context_snapshot = {
"cycle_meta": cycle_meta,
"macro_regime": {
"dominant": (macro_regime or {}).get("scenarios", {}).get("dominant"),
"scores": (macro_regime or {}).get("scenarios", {}).get("scores"),
"gauges_summary": {k: v.get("value") for k, v in ((macro_regime or {}).get("gauges", {})).items()},
},
"geo_score": geo_score_obj,
"news_partitioned": {
"inter_cycle": [{"title": n.get("title"), "source": n.get("source"), "decayed_score": n.get("decayed_score"), "age_hours": n.get("age_hours")} for n in _news_snap["inter_cycle"][:10]],
"recent_24h": [{"title": n.get("title"), "source": n.get("source"), "decayed_score": n.get("decayed_score")} for n in _news_snap["recent_24h"][:10]],
"older": [{"title": n.get("title"), "source": n.get("source")} for n in _news_snap["older"][:5]],
},
"fred_releases": _fred_releases,
"tech_indicators_block": _tech_block,
"iv_context_preview": iv_context[:500] if iv_context else "",
"calendar": calendar[:8] if calendar else [],
"quotes_summary": {cls: [{"symbol": q.get("symbol"), "price": q.get("price"), "change_pct": q.get("change_pct")} for q in qs[:3]] for cls, qs in quotes.items()},
}
save_cycle_context_snapshot(run_id, _context_snapshot)
logger.info(f"[Cycle {run_id[:16]}] Context snapshot saved")
except Exception as _snap_e:
logger.warning(f"[Cycle] Context snapshot save failed (non-blocking): {_snap_e}")
suggestions = suggest_patterns_from_market_context(
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
portfolio_lessons=portfolio_lessons,
@@ -413,6 +453,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
iv_context=iv_context,
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
fred_block=_fred_block,
)
except Exception as e:
logger.warning(f"[Cycle] Suggestion step failed: {e}")
@@ -537,6 +578,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
risk_context=risk_cluster_context,
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
fred_block=_fred_block,
)
scored_with_id = [s for s in scored if s.get("pattern_id")]
scored_without_id = [s for s in scored if not s.get("pattern_id")]

View File

@@ -379,6 +379,12 @@ def init_db():
details TEXT
)""")
c.execute("""CREATE TABLE IF NOT EXISTS cycle_context_snapshots (
run_id TEXT PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (datetime('now')),
context_json TEXT NOT NULL
)""")
c.execute("""CREATE TABLE IF NOT EXISTS skipped_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT,
@@ -2090,6 +2096,40 @@ def clear_system_logs(older_than_days: int = 30) -> int:
return deleted
# ── Cycle Context Snapshots ───────────────────────────────────────────────────
def save_cycle_context_snapshot(run_id: str, context: dict) -> None:
import json as _json
conn = get_conn()
conn.execute(
"INSERT OR REPLACE INTO cycle_context_snapshots (run_id, context_json) VALUES (?, ?)",
(run_id, _json.dumps(context, ensure_ascii=False, default=str)),
)
conn.commit()
conn.close()
def get_cycle_context_snapshot(run_id: str) -> Optional[dict]:
import json as _json
conn = get_conn()
row = conn.execute(
"SELECT run_id, ts, context_json FROM cycle_context_snapshots WHERE run_id = ?", (run_id,)
).fetchone()
conn.close()
if not row:
return None
return {"run_id": row["run_id"], "ts": row["ts"], "context": _json.loads(row["context_json"])}
def list_cycle_context_snapshots(limit: int = 30) -> list:
conn = get_conn()
rows = conn.execute(
"SELECT run_id, ts FROM cycle_context_snapshots ORDER BY ts DESC LIMIT ?", (limit,)
).fetchall()
conn.close()
return [{"run_id": r["run_id"], "ts": r["ts"]} for r in rows]
# ── Knowledge Base Decay ──────────────────────────────────────────────────────
def decay_kb_confidence() -> int:

View File

@@ -0,0 +1,176 @@
"""
FRED API fetcher — récupère les dernières releases macro US.
Séries suivies :
CPIAUCSL → CPI mensuel (YoY calculé)
PAYEMS → Non-Farm Payrolls (variation mensuelle k emplois)
UNRATE → Taux de chômage
FEDFUNDS → Fed Funds Rate
GDP → PIB US trimestriel (croissance %)
ICSA → Initial Jobless Claims (hebdo)
T10Y2Y → Spread 10Y-2Y (courbe des taux)
DEXUSEU → EUR/USD (proxy macro)
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
logger = logging.getLogger("fred_fetcher")
# Series config : (id, label, unit, asset_impact, direction_interpretation)
FRED_SERIES = [
("CPIAUCSL", "US CPI (inflation)", "index", ["metals", "rates", "forex"], "higher_bearish"),
("PAYEMS", "US Non-Farm Payrolls", "k jobs", ["indices", "forex", "rates"], "higher_bullish"),
("UNRATE", "US Unemployment Rate", "%", ["indices", "forex"], "higher_bearish"),
("FEDFUNDS", "Fed Funds Rate", "%", ["bonds", "forex", "indices"], "higher_bearish_growth"),
("GDP", "US GDP Growth", "bn$", ["indices", "forex"], "higher_bullish"),
("ICSA", "US Initial Jobless Claims","k claims",["indices", "forex"], "higher_bearish"),
("T10Y2Y", "10Y-2Y Spread (courbe)", "%", ["bonds", "indices"], "positive_bullish"),
]
def _get_fred_key() -> Optional[str]:
try:
from services.database import get_config
return get_config("fred_api_key") or None
except Exception:
return None
def _fetch_series_observations(series_id: str, api_key: str, count: int = 3) -> List[Dict]:
"""Fetch last N observations from FRED for a given series."""
import urllib.request
import urllib.parse
import json
params = urllib.parse.urlencode({
"series_id": series_id,
"api_key": api_key,
"file_type": "json",
"sort_order": "desc",
"limit": count,
"observation_start": (datetime.utcnow() - timedelta(days=730)).strftime("%Y-%m-%d"),
})
url = f"https://api.stlouisfed.org/fred/series/observations?{params}"
try:
with urllib.request.urlopen(url, timeout=8) as resp:
data = json.loads(resp.read())
obs = data.get("observations", [])
return [{"date": o["date"], "value": o["value"]} for o in obs if o.get("value") not in (".", None)]
except Exception as e:
logger.warning(f"[FRED] {series_id} fetch failed: {e}")
return []
def _parse_value(v: str) -> Optional[float]:
try:
return float(v)
except (ValueError, TypeError):
return None
def _surprise_direction(series_id: str, current: float, previous: float, direction_hint: str) -> str:
"""Returns 'bullish', 'bearish', or 'neutral' based on change direction."""
delta = current - previous
if abs(delta) < 0.01:
return "neutral"
if direction_hint == "higher_bullish":
return "bullish" if delta > 0 else "bearish"
elif direction_hint in ("higher_bearish", "higher_bearish_growth"):
return "bearish" if delta > 0 else "bullish"
elif direction_hint == "positive_bullish":
return "bullish" if current > 0 else "bearish"
return "neutral"
def _yoy_change(obs: List[Dict]) -> Optional[float]:
"""For monthly series, compute rough YoY % if we have ≥12 month history."""
if len(obs) < 2:
return None
v_latest = _parse_value(obs[0]["value"])
v_prev = _parse_value(obs[-1]["value"])
if v_latest is None or v_prev is None or v_prev == 0:
return None
return round((v_latest - v_prev) / abs(v_prev) * 100, 2)
def get_fred_recent_releases() -> List[Dict[str, Any]]:
"""
Fetch latest FRED data for key macro series.
Returns a list of dicts, one per series, with:
series_id, label, unit, latest_date, latest_value,
previous_value, change, change_pct, direction, asset_impact
"""
api_key = _get_fred_key()
if not api_key:
return []
results = []
for series_id, label, unit, asset_impact, direction_hint in FRED_SERIES:
obs = _fetch_series_observations(series_id, api_key, count=13) # 13 for YoY
if not obs:
continue
latest = obs[0]
previous = obs[1] if len(obs) > 1 else None
v_latest = _parse_value(latest["value"])
v_prev = _parse_value(previous["value"]) if previous else None
if v_latest is None:
continue
change = round(v_latest - v_prev, 4) if v_prev is not None else None
change_pct = round((v_latest - v_prev) / abs(v_prev) * 100, 2) if v_prev and v_prev != 0 else None
direction = _surprise_direction(series_id, v_latest, v_prev, direction_hint) if v_prev is not None else "neutral"
# For CPI, compute YoY
display_value = v_latest
display_unit = unit
if series_id == "CPIAUCSL" and len(obs) >= 13:
yoy = _yoy_change(obs[:13])
if yoy is not None:
display_value = yoy
display_unit = "% YoY"
# Re-compute change vs previous month's YoY
if len(obs) >= 14:
yoy_prev = _yoy_change(obs[1:14])
if yoy_prev is not None:
change = round(yoy - yoy_prev, 3)
direction = "bearish" if change > 0 else "bullish" # higher CPI = bearish markets
entry: Dict[str, Any] = {
"series_id": series_id,
"label": label,
"unit": display_unit,
"latest_date": latest["date"],
"latest_value": display_value,
"previous_value": v_prev,
"change": change,
"direction": direction,
"asset_impact": asset_impact,
}
results.append(entry)
return results
def build_fred_context_block(releases: List[Dict]) -> str:
"""Format FRED releases as a prompt-ready string."""
if not releases:
return ""
lines = ["## 📈 DONNÉES MACRO RÉCENTES (FRED — dernières releases)"]
for r in releases:
val_str = f"{r['latest_value']:.2f}{r['unit']}" if isinstance(r.get("latest_value"), float) else str(r.get("latest_value", "N/A"))
chg_str = ""
if r.get("change") is not None:
arrow = "" if r["change"] > 0 else ""
chg_str = f" ({arrow}{abs(r['change']):.3f} vs release précédente)"
dir_emoji = {"bullish": "🟢", "bearish": "🔴", "neutral": ""}.get(r.get("direction", "neutral"), "")
lines.append(
f" {dir_emoji} {r['label']} [{r['latest_date']}] : {val_str}{chg_str}{r['direction'].upper()}"
)
lines.append("⚠️ Compare ces chiffres au consensus attendu pour évaluer si le marché a déjà intégré la surprise.")
return "\n".join(lines)

View File

@@ -867,6 +867,21 @@ export const useClearLogs = () =>
mutationFn: (days: number) => api.delete('/logs/clear', { params: { older_than_days: days } }).then(r => r.data),
})
export const useCycleContextSnapshots = (limit = 30) =>
useQuery({
queryKey: ['cycle-context-snapshots'],
queryFn: () => api.get('/cycle/contexts', { params: { limit } }).then(r => r.data),
staleTime: 30_000,
})
export const useCycleContextSnapshot = (runId: string | null) =>
useQuery({
queryKey: ['cycle-context-snapshot', runId],
queryFn: () => api.get(`/cycle/contexts/${runId}`).then(r => r.data),
enabled: !!runId,
staleTime: 300_000,
})
// ── IV Watchlist Management ───────────────────────────────────────────────────
export const useWatchlistTickers = () =>

View File

@@ -1,9 +1,9 @@
import { useState, useMemo } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight } from 'lucide-react'
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain } from 'lucide-react'
import clsx from 'clsx'
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, type LogFilters } from '../hooks/useApi'
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, type LogFilters } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
@@ -76,8 +76,124 @@ function LogRow({ log }: { log: any }) {
)
}
function ContextTab() {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30)
const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId)
const snapshots: any[] = snapshotsData?.snapshots ?? []
const fmtTs = (ts: string) => {
try { return format(new Date(ts), 'dd/MM HH:mm:ss', { locale: fr }) } catch { return ts }
}
return (
<div className="grid grid-cols-[280px_1fr] gap-4 min-h-[500px]">
{/* Left: list of snapshots */}
<div className="card overflow-y-auto max-h-[700px]">
{loadingList ? (
<div className="p-4 text-xs text-slate-500">Chargement</div>
) : snapshots.length === 0 ? (
<div className="p-4 text-xs text-slate-600">
Aucun snapshot les contextes seront sauvegardés lors du prochain cycle.
</div>
) : (
<div className="divide-y divide-slate-800">
{snapshots.map(s => (
<button
key={s.run_id}
onClick={() => setSelectedRunId(s.run_id)}
className={clsx(
'w-full text-left px-3 py-2.5 hover:bg-slate-800/60 transition-colors',
selectedRunId === s.run_id && 'bg-purple-900/30 border-l-2 border-purple-500'
)}
>
<div className="text-xs font-mono text-slate-300">{fmtTs(s.ts)}</div>
<div className="text-[10px] text-slate-600 font-mono mt-0.5">{s.run_id.slice(0, 20)}</div>
</button>
))}
</div>
)}
</div>
{/* Right: context JSON viewer */}
<div className="card overflow-hidden">
{!selectedRunId ? (
<div className="p-8 text-center text-slate-600 text-sm">
<Brain className="w-8 h-8 mx-auto mb-2 opacity-30" />
Sélectionne un cycle à gauche pour voir le contexte complet envoyé à l'IA.
</div>
) : loadingSnap ? (
<div className="p-8 text-center text-slate-500 text-sm">Chargement du contexte…</div>
) : !snapData ? (
<div className="p-8 text-center text-slate-500 text-sm">Erreur lors du chargement.</div>
) : (
<div className="flex flex-col h-full">
{/* Header strip with meta */}
<div className="flex items-center gap-4 px-4 py-2 border-b border-slate-800 bg-slate-900/60">
<span className="text-[10px] font-mono text-slate-400">{snapData.run_id}</span>
<span className="text-[10px] text-slate-500">{fmtTs(snapData.ts)}</span>
{snapData.context?.cycle_meta && (
<>
<span className="text-[10px] text-purple-400">
Δ {snapData.context.cycle_meta.delta_minutes?.toFixed(0)}min
</span>
<span className="text-[10px] text-slate-400">{snapData.context.cycle_meta.calibration_label}</span>
</>
)}
</div>
{/* Sections accordion */}
<div className="overflow-y-auto flex-1 p-3 space-y-2">
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
<ContextSection key={key} label={key} value={val} />
))}
</div>
</div>
)}
</div>
</div>
)
}
function ContextSection({ label, value }: { label: string; value: any }) {
const [open, setOpen] = useState(['cycle_meta', 'news_partitioned', 'fred_releases'].includes(label))
const json = JSON.stringify(value, null, 2)
const lineCount = json.split('\n').length
const labelColor: Record<string, string> = {
cycle_meta: 'text-purple-400',
macro_regime: 'text-blue-400',
geo_score: 'text-orange-400',
news_partitioned: 'text-yellow-400',
fred_releases: 'text-green-400',
tech_indicators_block: 'text-cyan-400',
iv_context_preview: 'text-pink-400',
calendar: 'text-slate-300',
quotes_summary: 'text-slate-300',
}
return (
<div className="border border-slate-800 rounded overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 bg-slate-900/60 hover:bg-slate-800/60 text-left"
>
{open ? <ChevronDown className="w-3 h-3 text-slate-500 shrink-0" /> : <ChevronRight className="w-3 h-3 text-slate-500 shrink-0" />}
<span className={clsx('text-xs font-mono font-semibold', labelColor[label] ?? 'text-slate-300')}>{label}</span>
<span className="text-[10px] text-slate-600 ml-auto">{lineCount} lignes</span>
</button>
{open && (
<pre className="text-[10px] font-mono text-slate-400 bg-slate-950 p-3 overflow-x-auto max-h-80 leading-relaxed">
{json}
</pre>
)}
</div>
)
}
export default function SystemLogs() {
const qc = useQueryClient()
const [activeTab, setActiveTab] = useState<'logs' | 'context'>('logs')
const [filters, setFilters] = useState<LogFilters>({ limit: 300 })
const [tickerInput, setTickerInput] = useState('')
const [cycleInput, setCycleInput] = useState('')
@@ -114,7 +230,7 @@ export default function SystemLogs() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">Logs Système</h1>
<p className="text-xs text-slate-500 mt-0.5">Erreurs, avertissements et événements des cycles IA</p>
<p className="text-xs text-slate-500 mt-0.5">Erreurs, avertissements et contextes complets des cycles IA</p>
</div>
<div className="flex items-center gap-2">
<button
@@ -124,17 +240,43 @@ export default function SystemLogs() {
<RefreshCw className={clsx('w-3.5 h-3.5', (isLoading || isRefetching) && 'animate-spin')} />
Actualiser
</button>
<button
onClick={handleClear}
disabled={clearing}
className="btn-secondary flex items-center gap-1.5 text-xs px-3 py-1.5 text-red-400 border-red-800/40 hover:bg-red-900/20"
>
<Trash2 className="w-3.5 h-3.5" />
Purger &gt;30j
</button>
{activeTab === 'logs' && (
<button
onClick={handleClear}
disabled={clearing}
className="btn-secondary flex items-center gap-1.5 text-xs px-3 py-1.5 text-red-400 border-red-800/40 hover:bg-red-900/20"
>
<Trash2 className="w-3.5 h-3.5" />
Purger &gt;30j
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-800">
{([
{ key: 'logs', label: 'Logs système', icon: <Info className="w-3.5 h-3.5" /> },
{ key: 'context', label: 'Contexte IA', icon: <Brain className="w-3.5 h-3.5" /> },
] as const).map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
activeTab === tab.key
? 'border-purple-500 text-purple-300'
: 'border-transparent text-slate-500 hover:text-slate-300'
)}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{activeTab === 'context' && <ContextTab />}
{activeTab === 'logs' && <>
{/* Summary chips */}
<div className="flex items-center gap-3 flex-wrap">
{[
@@ -251,6 +393,7 @@ export default function SystemLogs() {
</div>
)}
</div>
</>}
</div>
)
}