Files
OpenFin/frontend/src/pages/CausalLab.tsx
OpenSquared f9dddb7184 feat: graph patch grammar DSL for causal editor
New grammar panel replaces free-text AI prompt with a two-step workflow:
1. NL to Grammar: GPT-4o converts description to patch ops (new endpoint ai-to-grammar)
2. Apply: frontend parser applies grammar locally without AI

Grammar syntax: (+|-|~)(node|edge|coef|input|instruments) key=val key=val
Sign shortcuts: + positive, - negative, = neutral
Auto-positions new nodes when x/y omitted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 09:30:17 +02:00

2327 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
FlaskConical, Library, Zap, BarChart3, RefreshCw, Edit3,
Sliders, Brain, Search, CheckCircle, XCircle, Minus, AlertCircle,
Plus, Trash2, Save, Copy, Info, Wand2, ExternalLink, Filter, X,
} from 'lucide-react'
import clsx from 'clsx'
// ── Types ─────────────────────────────────────────────────────────────────────
export interface CausalNode {
id: string; label: string
type: 'macro_event' | 'observable' | 'latent' | 'market_asset' | 'input' | 'intermediate' | 'output'
x: number; y: number
formula?: string; unit?: string; instrument?: string; description?: string
}
export interface CausalEdge {
from: string; to: string
style: 'solid' | 'dashed'
type?: string
strength?: 1 | 2 | 3
sign?: 'positive' | 'negative' | 'neutral'
label?: string
lag_min?: number // délai avant onset (minutes) — mode intraday 5m
lag_days?: number // délai avant onset (jours) — mode journalier
diffusion_min?: number // durée jusqu'à absorption complète (minutes)
decay_days?: number | null // demi-vie (jours), null = shift permanent
}
interface Coefficient { value: number; calibrated: number | null; description: string }
interface InputMapping {
source: 'user_input' | 'market_watchlist' | 'economic_events' | 'ff_calendar' | 'surprise' | 'actual_value' | 'impact_score_scaled' | 'impact_score_pct'
key?: string
field?: string
unit?: string
range?: number[]
}
interface DataSources {
prices: { key: string; label: string }[]
macro_series: { key: string; label: string }[]
ff_events: { key: string; label: string }[]
}
export interface GraphJson {
nodes: CausalNode[]; edges: CausalEdge[]
coefficients: Record<string, Coefficient>
instruments: string[]
input_mapping: Record<string, InputMapping>
}
interface Template {
id: number; name: string; category: string; sub_type: string
instruments: string[]; description: string; graph_json: GraphJson
ai_rationale: string; calibration_json: Record<string, unknown>
heuristic_ver: number; created_by?: string; created_at?: string
}
interface MarketEvent {
id: number; name: string; category: string; sub_type: string
start_date: string; end_date: string | null; level: string; created_at: string | null
impact_score: number; market_impact: string; affected_assets: string
actual_value: number | null; expected_value: number | null; surprise_pct: number | null
analysis_id: number | null; template_id: number | null; activation_score: number | null
}
interface NodeResult { pred: number | null; act: number | null; status: string; label: string }
interface AnalysisResult {
event: MarketEvent; template_id: number; template_name: string; instrument: string
inputs: Record<string, number>; node_values: Record<string, number>
actual_moves: Record<string, number>; yields: Record<string, number | null>
activation: { score: number | null; nodes: Record<string, NodeResult>; correct: number; total: number }
drift: { pre_pips: number | null; post_pips: number | null; drift_ratio: number | null; leak: string }
prices_mode: string; analyzed_at: string; effective_lag_min?: number
}
interface Recommendation {
template_id: number | null; template_name: string; confidence: number
rationale: string; coefficient_suggestions: Record<string, number>
instrument_focus: string[]; notes: string; error?: string
}
interface CalibRow {
id: number; name: string; category: string
n_analyses: number; avg_activation: number | null
calibration: Record<string, { n?: number; avg_pred?: number; avg_actual?: number; coef_ratio?: number }>
}
// ── API ───────────────────────────────────────────────────────────────────────
const api = async (path: string, opts?: RequestInit) => {
const r = await fetch(path, opts)
if (!r.ok) { const t = await r.text(); throw new Error(t) }
return r.json()
}
// ── Visuels ───────────────────────────────────────────────────────────────────
/** Couleur de nœud selon son type sémantique */
const nodeColor = (type: string): string => ({
macro_event: '#3b82f6', // blue — événements source (NFP, CPI, FOMC)
observable: '#06b6d4', // cyan — variables marché observables (OIS, 2Y, spreads)
latent: '#8b5cf6', // violet — variables latentes (anticipations, sentiment)
market_asset: '#10b981', // green — instruments tradés (EUR/USD, S&P)
// compat backward
input: '#3b82f6',
intermediate: '#8b5cf6',
output: '#10b981',
}[type] ?? '#64748b')
/** Couleur de l'arête selon son signe */
const edgeColor = (sign?: string): string => ({
positive: '#34d399', // teal
negative: '#f87171', // rouge-rose
neutral: '#475569', // gris ardoise
}[sign ?? 'neutral'] ?? '#475569')
/** Épaisseur selon strength */
const edgeWidth = (s?: number) => s === 1 ? 1 : s === 3 ? 3.5 : 2
/** Format lag minutes → "+5m" / "+2h" */
function fmtLag(min: number): string {
if (min < 60) return `+${min}m`
return `+${(min / 60).toFixed(0)}h`
}
/** Format decay days → "↩3j" */
const fmtDecay = (d: number) => `${d}j`
const CAT_LABELS: Record<string, string> = {
// Template categories
macro_us: 'Macro US',
macro_eu: 'Macro EU',
geopolitical: 'Géopolitique',
commodity: 'Matières 1ères',
report: 'Report',
sentiment: 'Sentiment',
// Market event categories (aligned)
event_calendar: 'Calendrier',
calendar_event: 'Calendrier',
fundamental: 'Fondamental',
}
// Chip color per category (inline styles to survive Tailwind purge)
const CAT_CHIP: Record<string, { bg: string; color: string; border: string }> = {
macro_us: { bg: '#1e3a5f', color: '#93c5fd', border: '#3b82f6' },
macro_eu: { bg: '#2d1b69', color: '#c4b5fd', border: '#8b5cf6' },
event_calendar: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' },
calendar_event: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' },
geopolitical: { bg: '#422006', color: '#fcd34d', border: '#d97706' },
commodity: { bg: '#14352a', color: '#6ee7b7', border: '#10b981' },
report: { bg: '#1e293b', color: '#94a3b8', border: '#475569' },
sentiment: { bg: '#4a1942', color: '#f9a8d4', border: '#ec4899' },
fundamental: { bg: '#1e1b4b', color: '#a5b4fc', border: '#6366f1' },
}
const chipStyle = (cat: string, active: boolean) => {
const c = CAT_CHIP[cat] ?? { bg: '#1e293b', color: '#94a3b8', border: '#475569' }
return {
background: active ? c.bg : 'transparent',
color: active ? c.color : '#64748b',
border: `1px solid ${active ? c.border : '#334155'}`,
}
}
const NODE_TYPES = ['macro_event', 'observable', 'latent', 'market_asset'] as const
const NODE_TYPE_LABELS: Record<string, string> = {
macro_event: 'Macro event', observable: 'Observable', latent: 'Latent', market_asset: 'Market asset',
}
const fmtPips = (v: number | null | undefined) =>
v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(0)} pip`
// ── SVG Graph renderer (partagé) ──────────────────────────────────────────────
export function GraphSVG({
graph, nodeValues, activationNodes, compact = false,
selectedNodeId, onNodeClick, onBgClick, editorMode = false,
}: {
graph: GraphJson
nodeValues?: Record<string, number>
activationNodes?: Record<string, NodeResult>
compact?: boolean
selectedNodeId?: string | null
onNodeClick?: (id: string, e: React.MouseEvent) => void
onBgClick?: (e: React.MouseEvent<SVGSVGElement>) => void
editorMode?: boolean
}) {
const svgRef = useRef<SVGSVGElement>(null)
const nodes = graph.nodes || []
const edges = graph.edges || []
// Scale positions to reduce overlap between nodes and edge labels
const POS_SCALE = compact ? 1 : 1.55
const scaledNodes = nodes.map(n => ({ ...n, x: n.x * POS_SCALE, y: n.y * POS_SCALE }))
const nodeMap = Object.fromEntries(scaledNodes.map(n => [n.id, n]))
const xs = scaledNodes.map(n => n.x)
const ys = scaledNodes.map(n => n.y)
const minX = (xs.length ? Math.min(...xs) : 0) - 80
const minY = (ys.length ? Math.min(...ys) : 0) - 50
const maxX = (xs.length ? Math.max(...xs) : 400) + 160
const maxY = (ys.length ? Math.max(...ys) : 400) + 90
const W = maxX - minX; const H = maxY - minY
// Nodes larger in editor mode to accommodate type badge
const NW = compact ? 92 : editorMode ? 140 : 130
const NH = compact ? 32 : editorMode ? 54 : 48
const statusColor = (s: string) =>
s === 'correct' ? '#10b981' : s === 'wrong' ? '#ef4444' : '#64748b'
function getNodeColor(n: CausalNode) {
if (activationNodes?.[n.id]) return statusColor(activationNodes[n.id].status)
return nodeColor(n.type)
}
function edgePath(e: CausalEdge) {
const s = nodeMap[e.from]; const t = nodeMap[e.to]
if (!s || !t) return ''
const x1 = s.x; const y1 = s.y + NH / 2
const x2 = t.x; const y2 = t.y - NH / 2
const my = (y1 + y2) / 2
return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`
}
function bezierPt(e: CausalEdge, t0: number): { x: number; y: number } | null {
const s = nodeMap[e.from]; const t = nodeMap[e.to]
if (!s || !t) return null
const x1 = s.x; const y1 = s.y + NH / 2
const x2 = t.x; const y2 = t.y - NH / 2
const my = (y1 + y2) / 2
const dx = x2 - x1; const dy = y2 - y1; const len = Math.sqrt(dx*dx + dy*dy) || 1
const bx = (1-t0)**3*x1 + 3*(1-t0)**2*t0*x1 + 3*(1-t0)*t0**2*x2 + t0**3*x2
const by = (1-t0)**3*y1 + 3*(1-t0)**2*t0*my + 3*(1-t0)*t0**2*my + t0**3*y2
return { x: bx + (-dy/len)*16, y: by + (dx/len)*16 }
}
// Label position along bezier path with perpendicular offset to avoid the line
function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null {
return bezierPt(e, 0.42)
}
function handleSvgClick(e: React.MouseEvent<SVGSVGElement>) {
if (e.target === svgRef.current || (e.target as SVGElement).tagName === 'svg') {
onBgClick?.(e)
}
}
return (
<svg ref={svgRef} viewBox={`${minX} ${minY} ${W} ${H}`}
style={{ width: '100%', height: compact ? 280 : editorMode ? 620 : 560 }}
className={clsx('overflow-visible', editorMode && 'cursor-crosshair')}
onClick={handleSvgClick}>
<defs>
{(['positive', 'negative', 'neutral'] as const).map(sign => (
<marker key={sign} id={`arrow-${sign}`} markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill={edgeColor(sign)} />
</marker>
))}
</defs>
{/* Background grid in editor mode */}
{editorMode && <>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" strokeWidth="0.5" />
</pattern>
</defs>
<rect x={minX} y={minY} width={W} height={H} fill="url(#grid)" />
</>}
{/* Edges */}
{edges.map((e, i) => {
const s = nodeMap[e.from]; const t = nodeMap[e.to]
if (!s || !t) return null
const sign = e.sign ?? 'neutral'
const color = edgeColor(sign)
const sw = edgeWidth(e.strength)
const lpos = e.label ? edgeLabelPos(e) : null
const labelText = e.label ?? ''
const labelW = labelText.length * 5.8
const lagTxtMin = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null
const lagTxtDay = (!compact && e.lag_days) ? `+${e.lag_days}j` : null
const lagTxt = lagTxtMin && lagTxtDay ? `${lagTxtMin}/${lagTxtDay}` : (lagTxtMin ?? lagTxtDay)
const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null
const lagPos = lagTxt ? bezierPt(e, 0.13) : null
const decayPos = decayTxt ? bezierPt(e, 0.84) : null
return (
<g key={i}>
<path d={edgePath(e)} fill="none"
stroke={color} strokeWidth={sw}
strokeDasharray={e.style === 'dashed' ? '6,3' : undefined}
markerEnd={`url(#arrow-${sign})`} opacity={0.85} />
{lpos && (
<g>
<rect x={lpos.x - 3} y={lpos.y - 10} width={labelW + 6} height={13}
rx={3} fill="#0c1220" opacity={0.88} />
<text x={lpos.x} y={lpos.y} fill={color} fontSize={9.5} fontWeight="500"
letterSpacing="0.2">{labelText}</text>
</g>
)}
{lagPos && lagTxt && (
<g>
<rect x={lagPos.x - 3} y={lagPos.y - 10} width={lagTxt.length * 5.8 + 6} height={13}
rx={3} fill="#0c1220" opacity={0.88} />
<text x={lagPos.x} y={lagPos.y} fill="#fbbf24" fontSize={9} fontWeight="600">{lagTxt}</text>
</g>
)}
{decayPos && decayTxt && (
<g>
<rect x={decayPos.x - 3} y={decayPos.y - 10} width={decayTxt.length * 5.8 + 6} height={13}
rx={3} fill="#0c1220" opacity={0.88} />
<text x={decayPos.x} y={decayPos.y} fill="#94a3b8" fontSize={9} fontWeight="600">{decayTxt}</text>
</g>
)}
</g>
)
})}
{/* Nodes */}
{scaledNodes.map(n => {
const col = getNodeColor(n)
const val = nodeValues?.[n.id]
const isSel = selectedNodeId === n.id
const maxLen = editorMode ? 22 : 21
const lFS = compact ? 9 : 12
// vertical layout inside node
const labelY = compact ? 12 : editorMode ? 17 : 16
const valY = compact ? 22 : 34
const typeY = NH - 7 // type badge near bottom
return (
<g key={n.id} transform={`translate(${n.x - NW / 2},${n.y - NH / 2})`}
onClick={e => { e.stopPropagation(); onNodeClick?.(n.id, e) }}
style={{ cursor: editorMode ? 'pointer' : 'default' }}>
{isSel && (
<rect width={NW + 8} height={NH + 8} x={-4} y={-4} rx={9}
fill="none" stroke="#facc15" strokeWidth={2} opacity={0.85} />
)}
<rect width={NW} height={NH} rx={7}
fill={col} fillOpacity={isSel ? 0.32 : 0.16}
stroke={col} strokeWidth={isSel ? 2 : 1.5} />
{/* Label */}
<text x={NW / 2} y={labelY} textAnchor="middle"
fill="#e2e8f0" fontSize={lFS} fontWeight="600">
{n.label.length > maxLen ? n.label.slice(0, maxLen - 1) + '…' : n.label}
</text>
{/* Value (analyze mode) */}
{val != null && (
<text x={NW / 2} y={valY} textAnchor="middle"
fill={col} fontSize={lFS} fontWeight="700">
{val > 0 ? '+' : ''}{val.toFixed(1)}{n.unit ? ` ${n.unit}` : ''}
</text>
)}
{/* Type badge (editor only) — inside node at bottom */}
{editorMode && (
<>
<rect x={4} y={typeY - 8} width={NW - 8} height={10} rx={3}
fill={col} fillOpacity={0.18} />
<text x={NW / 2} y={typeY} textAnchor="middle"
fill={col} fontSize={8} fontWeight="500" opacity={0.9}>
{n.type?.replace(/_/g, ' ')}
</text>
</>
)}
</g>
)
})}
</svg>
)
}
// ── Légende des conventions ───────────────────────────────────────────────────
function GraphLegend() {
return (
<div className="bg-dark-800/60 border border-slate-700/30 rounded-lg p-3 text-xs space-y-2">
<div className="text-slate-400 font-semibold uppercase tracking-wider text-xs mb-2">Conventions</div>
<div className="space-y-1.5">
{[
{ color: nodeColor('macro_event'), label: 'Macro event (source)' },
{ color: nodeColor('observable'), label: 'Observable (OIS, taux…)' },
{ color: nodeColor('latent'), label: 'Latent (anticipations…)' },
{ color: nodeColor('market_asset'), label: 'Market asset (FX, S&P…)' },
].map(({ color, label }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm flex-shrink-0" style={{ background: color, opacity: 0.7 }} />
<span className="text-slate-400">{label}</span>
</div>
))}
</div>
<div className="border-t border-slate-700/30 pt-2 space-y-1">
{[
{ style: 'solid 3px', color: '#34d399', label: 'Causalité directe forte +' },
{ style: 'solid 1.5px', color: '#f87171', label: 'Effet négatif direct' },
{ style: 'dashed 2px', color: '#94a3b8', label: 'Transmission indirecte' },
].map(({ style, color, label }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-8 h-0 flex-shrink-0" style={{ borderTop: `${style} ${color}` }} />
<span className="text-slate-400">{label}</span>
</div>
))}
</div>
</div>
)
}
// ── Graph Patch Grammar ───────────────────────────────────────────────────────
// Syntax: (+|-|~)(node|edge|coef|input|instruments) key=val key="val with spaces"
// sign shortcuts: + positive - negative = neutral
interface GrammarOp {
op: '+' | '-' | '~'
entity: 'node' | 'edge' | 'coef' | 'input' | 'instruments'
args: Record<string, string>
raw: string
}
function parseGrammarArgs(s: string): Record<string, string> {
const res: Record<string, string> = {}
const re = /(\w+)=(?:"([^"]*)"|([\S]*))/g
let m: RegExpExecArray | null
while ((m = re.exec(s)) !== null) res[m[1]] = m[2] !== undefined ? m[2] : m[3]
return res
}
function parseGrammar(text: string): { ops: GrammarOp[]; errors: string[] } {
const ops: GrammarOp[] = []; const errors: string[] = []
for (const rawLine of text.split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = line.match(/^([+\-~])(node|edge|coef|input|instruments)\s*(.*)/i)
if (!m) { errors.push(`Ligne non reconnue: "${line}"`); continue }
const entity = m[2].toLowerCase() as GrammarOp['entity']
const args = entity === 'instruments' ? { list: m[3].trim() } : parseGrammarArgs(m[3])
ops.push({ op: m[1] as '+' | '-' | '~', entity, args, raw: line })
}
return { ops, errors }
}
function resolveSign(s?: string): 'positive' | 'negative' | 'neutral' {
if (!s) return 'neutral'
if (s === '+' || s === 'positive') return 'positive'
if (s === '-' || s === 'negative') return 'negative'
return 'neutral'
}
function applyGrammarOps(state: EditorState, ops: GrammarOp[]): { state: EditorState; errors: string[] } {
let s: EditorState = {
...state,
nodes: [...state.nodes], edges: [...state.edges],
coefficients: { ...state.coefficients }, inputMapping: { ...state.inputMapping },
instruments: [...state.instruments],
}
const errors: string[] = []
for (const op of ops) {
try {
if (op.entity === 'node') {
const { id, label, type, x, y, formula, unit, instrument } = op.args
if (op.op === '+') {
if (!id) { errors.push('+node: id manquant'); continue }
if (s.nodes.find(n => n.id === id)) { errors.push(`+node: "${id}" existe déjà`); continue }
const autoY = s.nodes.length ? Math.max(...s.nodes.map(n => n.y)) + 120 : 200
s.nodes = [...s.nodes, {
id, label: label ?? id,
type: (type ?? 'observable') as CausalNode['type'],
x: x ? +x : 300, y: y ? +y : autoY,
...(formula ? { formula } : {}), ...(unit ? { unit } : {}), ...(instrument ? { instrument } : {}),
}]
} else if (op.op === '-') {
const tid = id ?? Object.values(op.args)[0]
if (!tid) { errors.push('-node: id manquant'); continue }
s.nodes = s.nodes.filter(n => n.id !== tid)
s.edges = s.edges.filter(e => e.from !== tid && e.to !== tid)
const im = { ...s.inputMapping }; delete im[tid]; s.inputMapping = im
} else {
if (!id) { errors.push('~node: id manquant'); continue }
s.nodes = s.nodes.map(n => n.id !== id ? n : {
...n,
...(label !== undefined ? { label } : {}),
...(type !== undefined ? { type: type as CausalNode['type'] } : {}),
...(x !== undefined ? { x: +x } : {}),
...(y !== undefined ? { y: +y } : {}),
...(formula !== undefined ? { formula } : {}),
...(unit !== undefined ? { unit } : {}),
...(instrument !== undefined ? { instrument } : {}),
})
}
} else if (op.entity === 'edge') {
const { from, to, sign, s: str, style, label, lag, decay } = op.args
if (op.op === '+') {
if (!from || !to) { errors.push('+edge: from/to manquants'); continue }
s.edges = [...s.edges, {
from, to, sign: resolveSign(sign),
strength: (str ? +str : 2) as 1|2|3,
style: (style ?? 'solid') as 'solid'|'dashed',
...(label ? { label } : {}),
...(lag !== undefined ? { lag_days: +lag } : {}),
...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}),
}]
} else if (op.op === '-') {
if (!from || !to) { errors.push('-edge: from/to manquants'); continue }
s.edges = s.edges.filter(e => !(e.from === from && e.to === to))
} else {
if (!from || !to) { errors.push('~edge: from/to manquants'); continue }
s.edges = s.edges.map(e => (e.from !== from || e.to !== to) ? e : {
...e,
...(sign !== undefined ? { sign: resolveSign(sign) } : {}),
...(str !== undefined ? { strength: +str as 1|2|3 } : {}),
...(style !== undefined ? { style: style as 'solid'|'dashed' } : {}),
...(label !== undefined ? { label } : {}),
...(lag !== undefined ? { lag_days: +lag } : {}),
...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}),
})
}
} else if (op.entity === 'coef') {
const { key, value, desc } = op.args
if (op.op === '+') {
if (!key || value === undefined) { errors.push('+coef: key/value manquants'); continue }
s.coefficients = { ...s.coefficients, [key]: { value: +value, calibrated: null, description: desc ?? '' } }
} else if (op.op === '-') {
if (!key) { errors.push('-coef: key manquant'); continue }
const cc = { ...s.coefficients }; delete cc[key]; s.coefficients = cc
} else {
if (!key) { errors.push('~coef: key manquant'); continue }
const ex = s.coefficients[key] ?? { value: 0, calibrated: null, description: '' }
s.coefficients = { ...s.coefficients, [key]: {
...ex,
...(value !== undefined ? { value: +value } : {}),
...(desc !== undefined ? { description: desc } : {}),
}}
}
} else if (op.entity === 'input') {
const { node, source, key } = op.args
if (op.op === '-') {
if (!node) { errors.push('-input: node manquant'); continue }
const im = { ...s.inputMapping }; delete im[node]; s.inputMapping = im
} else {
if (!node || !source) { errors.push(`${op.op}input: node/source manquants`); continue }
s.inputMapping = { ...s.inputMapping, [node]: { source: source as InputMapping['source'], ...(key ? { key } : {}) } }
}
} else if (op.entity === 'instruments') {
const list = op.args.list?.split(',').map(x => x.trim()).filter(Boolean)
if (list?.length) s.instruments = list
}
} catch (e) { errors.push(`Erreur "${op.raw}": ${e}`) }
}
return { state: s, errors }
}
const GRAMMAR_REFERENCE = `# Nœuds
+node id=<id> label="<texte>" type=<macro_event|observable|latent|market_asset> [x=<n>] [y=<n>] [instrument=<str>]
~node id=<id> [label="..."] [type=...] [x=<n>] [y=<n>] [instrument=<str>]
-node id=<id>
# Arêtes (sign: + positif - négatif = neutre)
+edge from=<id> to=<id> [sign=+|-|=] [s=1|2|3] [style=solid|dashed] [label="..."] [lag=<j>] [decay=<j|null>]
~edge from=<id> to=<id> [...mêmes champs...]
-edge from=<id> to=<id>
# Coefficients
+coef key=<str> value=<float> [desc="..."]
~coef key=<str> [value=<float>] [desc="..."]
-coef key=<str>
# Mapping d'entrée
~input node=<id> source=<surprise|actual_value|user_input|impact_score_scaled> [key=<str>]
-input node=<id>
# Instruments de sortie
~instruments EURUSD,SP500,...`
// ── Tab: Bibliothèque ─────────────────────────────────────────────────────────
function TabLibrary({
initialTemplateId,
onOpenEditor,
}: {
initialTemplateId?: number | null
onOpenEditor: (id: number) => void
}) {
const [templates, setTemplates] = useState<Template[]>([])
const [selected, setSelected] = useState<Template | null>(null)
const [catFilter, setCatFilter] = useState('')
const [subFilter, setSubFilter] = useState('')
const [instFilter, setInstFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [search, setSearch] = useState('')
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [savingLag, setSavingLag] = useState(false)
const [savingTheory, setSavingTheory] = useState(false)
const [genTheory, setGenTheory] = useState(false)
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
const [editEdgesLag, setEdgesLag] = useState<CausalEdge[]>([])
const [lagDirty, setLagDirty] = useState(false)
const [loading, setLoading] = useState(true)
const [apiError, setApiError] = useState<string | null>(null)
const [debugInfo, setDebugInfo] = useState<Record<string, unknown> | null>(null)
useEffect(() => {
setLoading(true); setApiError(null)
api('/api/causal-lab/templates')
.then(data => {
setTemplates(data)
if (data.length === 0) api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {})
})
.catch(e => setApiError(String(e)))
.finally(() => setLoading(false))
}, [])
// Auto-select template when arriving from a deep-link (?template=ID)
useEffect(() => {
if (!initialTemplateId || !templates.length || selected) return
const t = templates.find(t => t.id === initialTemplateId)
if (t) selectTemplate(t)
}, [templates, initialTemplateId])
function selectTemplate(t: Template) {
setSelected(t)
setEditCoefs(Object.fromEntries(
Object.entries(t.graph_json?.coefficients || {}).map(([k, v]) => [k, v.value])
))
setEdgesLag(JSON.parse(JSON.stringify(t.graph_json?.edges || [])))
setLagDirty(false)
}
async function saveCoefs() {
if (!selected) return
setSaving(true)
try {
await api(`/api/causal-lab/template/${selected.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coefficients: editCoefs }),
})
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
setSelected(fresh)
} finally { setSaving(false) }
}
async function saveTemporalParams() {
if (!selected) return
setSavingLag(true)
try {
const updatedGraph = { ...selected.graph_json, edges: editEdgesLag }
await api(`/api/causal-lab/template/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph_json: updatedGraph }),
})
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
setSelected(fresh)
setEdgesLag(JSON.parse(JSON.stringify(fresh.graph_json?.edges || [])))
setLagDirty(false)
} finally { setSavingLag(false) }
}
async function generateTheory() {
if (!selected) return
setGenTheory(true)
try {
await api(`/api/causal-lab/template/${selected.id}/generate-theory`, { method: 'POST' })
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
setSelected(fresh)
} finally { setGenTheory(false) }
}
async function saveTheoryParams(updates: Record<string, unknown>) {
if (!selected) return
setSavingTheory(true)
try {
const newCalib = { ...(selected.calibration_json || {}), ...updates }
await api(`/api/causal-lab/template/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calibration_json: newCalib }),
})
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
setSelected(fresh)
} finally { setSavingTheory(false) }
}
function updateEdgeLag(i: number, field: 'lag_min' | 'lag_days' | 'diffusion_min' | 'decay_days', val: string) {
setEdgesLag(prev => prev.map((e, idx) => idx !== i ? e : {
...e,
[field]: val === '' ? (field === 'decay_days' ? null : undefined)
: (field === 'decay_days' ? parseFloat(val) : parseInt(val)),
}))
setLagDirty(true)
}
async function deleteTemplate() {
if (!selected) return
if (!window.confirm(`Supprimer "${selected.name}" ? Cette action est irréversible.`)) return
setDeleting(true)
try {
await api(`/api/causal-lab/template/${selected.id}`, { method: 'DELETE' })
setSelected(null)
const data = await api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
setTemplates(data)
} catch (e: unknown) {
alert(String(e))
} finally { setDeleting(false) }
}
// Derive filter options from loaded templates
const cats = [...new Set(templates.map(t => t.category))].sort()
const subs = catFilter
? [...new Set(templates.filter(t => t.category === catFilter).map(t => t.sub_type).filter(Boolean))].sort()
: []
const insts = [...new Set(templates.flatMap(t => t.instruments))].sort()
// Client-side filtered list
const visible = templates.filter(t => {
if (catFilter && t.category !== catFilter) return false
if (subFilter && t.sub_type !== subFilter) return false
if (instFilter && !t.instruments.includes(instFilter)) return false
if (dateFrom && t.created_at && t.created_at < dateFrom) return false
if (dateTo && t.created_at && t.created_at > dateTo + 'T23:59') return false
if (search && !t.name.toLowerCase().includes(search.toLowerCase())
&& !(t.description ?? '').toLowerCase().includes(search.toLowerCase())) return false
return true
})
return (
<div className="flex flex-col gap-3">
{/* ── Filter bar ── */}
<div className="bg-dark-700/60 rounded-lg border border-slate-700/30 p-3 space-y-2">
{/* Category chips */}
<div className="flex flex-wrap gap-1.5 items-center">
<Filter className="w-3 h-3 text-slate-500 shrink-0" />
<button
onClick={() => { setCatFilter(''); setSubFilter('') }}
className="text-xs px-2.5 py-1 rounded-full border transition-all"
style={chipStyle('', catFilter === '')}>
Tout
</button>
{cats.map(c => (
<button key={c}
onClick={() => { setCatFilter(catFilter === c ? '' : c); setSubFilter('') }}
className="text-xs px-2.5 py-1 rounded-full border transition-all"
style={chipStyle(c, catFilter === c)}>
{CAT_LABELS[c] ?? c}
</button>
))}
</div>
{/* Sub-type chips — only if category selected and has sub-types */}
{subs.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center pl-4">
<span className="text-xs text-slate-600"></span>
<button
onClick={() => setSubFilter('')}
className="text-xs px-2 py-0.5 rounded-full border transition-all"
style={{ background: subFilter === '' ? '#1e293b' : 'transparent', color: subFilter === '' ? '#94a3b8' : '#475569', border: '1px solid ' + (subFilter === '' ? '#475569' : '#1e293b') }}>
Tous
</button>
{subs.map(s => (
<button key={s}
onClick={() => setSubFilter(subFilter === s ? '' : s)}
className="text-xs px-2 py-0.5 rounded-full border transition-all"
style={{ background: subFilter === s ? '#1e293b' : 'transparent', color: subFilter === s ? '#e2e8f0' : '#475569', border: '1px solid ' + (subFilter === s ? '#64748b' : '#1e293b') }}>
{s}
</button>
))}
</div>
)}
{/* Instrument + date row */}
<div className="flex flex-wrap gap-2 items-center">
{/* Instrument chips */}
<div className="flex flex-wrap gap-1 items-center">
<span className="text-xs text-slate-600">Inst:</span>
{insts.map(i => (
<button key={i}
onClick={() => setInstFilter(instFilter === i ? '' : i)}
className="text-xs px-2 py-0.5 rounded border transition-all"
style={{ background: instFilter === i ? '#14352a' : 'transparent', color: instFilter === i ? '#6ee7b7' : '#475569', border: '1px solid ' + (instFilter === i ? '#10b981' : '#1e293b') }}>
{i}
</button>
))}
</div>
{/* Date range */}
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-xs text-slate-600">Créé:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="bg-dark-800 border border-slate-700 rounded px-1.5 py-0.5 text-xs text-slate-400 w-32" />
<span className="text-slate-600 text-xs"></span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="bg-dark-800 border border-slate-700 rounded px-1.5 py-0.5 text-xs text-slate-400 w-32" />
{(dateFrom || dateTo) && (
<button onClick={() => { setDateFrom(''); setDateTo('') }}
className="text-slate-600 hover:text-slate-400">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="w-3 h-3 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-600" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Rechercher par nom / description…"
className="w-full bg-dark-800 border border-slate-700 rounded pl-7 pr-3 py-1.5 text-xs text-slate-300 placeholder:text-slate-600" />
{search && (
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* ── Main content: list + detail ── */}
<div className="flex gap-4">
<div className="w-64 flex-shrink-0">
{apiError && (
<div className="mb-2 p-3 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300 break-all">
<div className="font-bold mb-1">Erreur API</div>{apiError}
</div>
)}
{debugInfo && (
<div className="mb-2 p-3 bg-yellow-900/20 border border-yellow-700/40 rounded text-xs text-yellow-200">
<div className="font-bold mb-1">Diagnostic</div>
<pre className="whitespace-pre-wrap">{JSON.stringify(debugInfo, null, 2)}</pre>
</div>
)}
{loading
? <div className="text-slate-500 text-xs p-4">Chargement</div>
: <>
<div className="text-xs text-slate-600 mb-1.5">{visible.length} graphe{visible.length !== 1 ? 's' : ''}</div>
<div className="space-y-1 overflow-y-auto max-h-[calc(100vh-400px)]">
{visible.map(t => (
<button key={t.id} onClick={() => selectTemplate(t)}
className={clsx('w-full text-left px-3 py-2.5 rounded border text-xs transition-all',
selected?.id === t.id
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
<div className="font-medium truncate">{t.name}</div>
<div className="text-slate-500 mt-0.5 flex items-center gap-1.5 flex-wrap">
<span className="px-1.5 py-px rounded-sm text-[10px]"
style={chipStyle(t.category, true)}>
{CAT_LABELS[t.category] ?? t.category}
</span>
{t.sub_type && <span className="text-slate-600 text-[10px]">{t.sub_type}</span>}
<span className="text-slate-600">{t.instruments.join(', ')}</span>
</div>
</button>
))}
</div>
</>
}
</div>
{selected ? (
<div className="flex-1 overflow-y-auto space-y-4 min-w-0 max-h-[calc(100vh-380px)]">
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold">{selected.name}</h3>
<p className="text-slate-400 text-xs mt-1">{selected.description}</p>
{selected.created_at && (
<p className="text-slate-600 text-xs mt-1">
Créé le {new Date(selected.created_at).toLocaleDateString('fr-FR')}
{selected.created_by && ` · par ${selected.created_by}`}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
<button
onClick={() => onOpenEditor(selected.id)}
title="Modifier dans l'éditeur"
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-violet-800/40 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 text-xs font-medium transition-all">
<ExternalLink className="w-3 h-3" /> Éditer
</button>
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
v{selected.heuristic_ver}
</span>
<button
onClick={deleteTemplate}
disabled={deleting}
title="Supprimer ce template"
className="p-1 rounded transition-colors text-slate-500 hover:bg-red-900/30 hover:text-red-400 disabled:opacity-40">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{selected.ai_rationale && (
<div className="mt-3 p-3 bg-blue-900/10 border border-blue-800/30 rounded text-xs text-blue-200">
<Brain className="w-3 h-3 inline mr-1.5 text-blue-400" />
{selected.ai_rationale}
</div>
)}
<div className="mt-3 flex gap-2 flex-wrap items-center">
<span className="text-xs px-2 py-0.5 rounded border" style={chipStyle(selected.category, true)}>
{CAT_LABELS[selected.category] ?? selected.category}
</span>
{selected.sub_type && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-700/40 text-slate-400 border border-slate-600/40">
{selected.sub_type}
</span>
)}
{selected.instruments.map(i => (
<span key={i} className="text-xs px-2 py-0.5 rounded bg-emerald-900/20 text-emerald-400 border border-emerald-700/30">{i}</span>
))}
</div>
</div>
<div className="grid grid-cols-[1fr_200px] gap-4">
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider">Graphe causal</h4>
<GraphSVG graph={selected.graph_json} />
</div>
<GraphLegend />
</div>
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
<Sliders className="w-3.5 h-3.5" /> Coefficients
</h4>
<div className="space-y-2">
{Object.entries(selected.graph_json?.coefficients || {}).map(([k, c]) => (
<div key={k} className="grid grid-cols-[1fr_auto] gap-3 items-center">
<div>
<div className="text-xs text-slate-300 font-medium">{k}</div>
<div className="text-xs text-slate-500">{c.description}</div>
</div>
<input type="number" step="0.001"
value={editCoefs[k] ?? c.value}
onChange={e => setEditCoefs(p => ({ ...p, [k]: parseFloat(e.target.value) }))}
className="w-28 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 text-right" />
</div>
))}
</div>
<button onClick={saveCoefs} disabled={saving}
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-xs font-medium text-white">
{saving ? 'Sauvegarde…' : 'Sauvegarder les coefficients'}
</button>
</div>
{/* Paramètres temporels */}
{editEdgesLag.some(e => e.from && e.to) && (
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-yellow-400" /> Paramètres temporels
</h4>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_52px_52px_52px_52px] gap-1.5 text-xs text-slate-500 pb-1 border-b border-slate-700/30">
<span>Arête</span>
<span className="text-center" title="Délai intraday (mode 5m)">Lag 5m</span>
<span className="text-center" title="Délai journalier (mode daily)">Lag j</span>
<span className="text-center">Diff. m</span>
<span className="text-center">Decay j</span>
</div>
{editEdgesLag.map((e, i) => (
<div key={i} className="grid grid-cols-[1fr_52px_52px_52px_52px] gap-1.5 items-center">
<span className="text-xs font-mono text-slate-400 truncate">{e.from} {e.to}</span>
<input type="number" min={0} step={5}
value={e.lag_min ?? ''}
onChange={ev => updateEdgeLag(i, 'lag_min', ev.target.value)}
placeholder="0"
title="Délai en minutes (intraday 5m)"
className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" />
<input type="number" min={0} step={1}
value={e.lag_days ?? ''}
onChange={ev => updateEdgeLag(i, 'lag_days', ev.target.value)}
placeholder="0"
title="Délai en jours (mode journalier)"
className="w-full bg-dark-800 border border-amber-600/40 rounded px-1 py-1 text-xs text-amber-200 text-center" />
<input type="number" min={0} step={15}
value={e.diffusion_min ?? ''}
onChange={ev => updateEdgeLag(i, 'diffusion_min', ev.target.value)}
placeholder="60"
className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" />
<input type="number" min={0} step={1}
value={e.decay_days ?? ''}
onChange={ev => updateEdgeLag(i, 'decay_days', ev.target.value)}
placeholder="∞"
className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" />
</div>
))}
</div>
<button onClick={saveTemporalParams} disabled={savingLag}
className={clsx(
'mt-4 px-4 py-2 rounded text-xs font-medium text-white disabled:opacity-50 transition-all',
lagDirty ? 'bg-amber-600 hover:bg-amber-500 ring-2 ring-amber-400/50 animate-pulse'
: 'bg-slate-700 hover:bg-slate-600'
)}>
{savingLag ? 'Sauvegarde…' : lagDirty ? '⚠ Sauvegarder les paramètres temporels' : 'Paramètres temporels sauvegardés'}
</button>
</div>
)}
{/* Paramètres théoriques */}
<div className="bg-dark-700 rounded-lg p-4 border border-violet-800/30">
<div className="flex items-center gap-2 mb-3">
<h4 className="text-slate-300 text-xs font-semibold uppercase tracking-wider flex items-center gap-2 flex-1">
<span className="text-violet-400"></span> Paramètres théoriques
</h4>
<button
onClick={generateTheory}
disabled={genTheory}
className="px-3 py-1 bg-violet-700 hover:bg-violet-600 disabled:opacity-50 rounded text-xs font-medium text-white flex items-center gap-1.5"
>
{genTheory ? (
<><span className="animate-spin inline-block"></span> Génération</>
) : (
<><span></span> Générer IA</>
)}
</button>
</div>
{(() => {
const calib = selected.calibration_json || {}
const absorption = calib.absorption_days as number | undefined
const decay = calib.decay_type as string | undefined
const conf = calib.confidence as number | undefined
const rationale = calib.theory_rationale as string | undefined
const genAt = calib.theory_generated_at as string | undefined
return (
<div className="space-y-3">
{rationale && (
<p className="text-xs text-violet-300 italic border-l-2 border-violet-700/60 pl-2">{rationale}</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-1">Durée absorption (j)</div>
<input
type="number" min={1} max={60} step={1}
value={absorption ?? ''}
placeholder="—"
onChange={e => saveTheoryParams({ absorption_days: parseInt(e.target.value) || 7 })}
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 text-center"
/>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Type de décroissance</div>
<select
value={decay ?? 'exp'}
onChange={e => saveTheoryParams({ decay_type: e.target.value })}
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200"
>
<option value="step">step tout ou rien</option>
<option value="linear">linear déclin linéaire</option>
<option value="exp">exp déclin exponentiel</option>
</select>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{conf !== undefined && (
<span className="flex items-center gap-1">
Confiance IA :
<span className={`font-mono font-semibold ${conf >= 0.7 ? 'text-emerald-400' : conf >= 0.4 ? 'text-yellow-400' : 'text-red-400'}`}>
{Math.round(conf * 100)}%
</span>
</span>
)}
{genAt && (
<span className="ml-auto opacity-60">
{new Date(genAt).toLocaleDateString('fr-FR')}
</span>
)}
{savingTheory && <span className="text-violet-400 animate-pulse">Sauvegarde</span>}
</div>
</div>
)
})()}
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm">
Sélectionnez un template dans la liste
</div>
)}
</div>{/* end flex list+detail */}
</div>
)
}
// ── Tab: Editeur de graphe ────────────────────────────────────────────────────
interface EditorState {
templateId: number | null; name: string; category: string; subType: string
description: string; instruments: string[]; aiRationale: string
nodes: CausalNode[]; edges: CausalEdge[]
coefficients: Record<string, Coefficient>
inputMapping: Record<string, InputMapping>
}
const BLANK: EditorState = {
templateId: null, name: 'Nouveau template', category: 'macro_us', subType: '',
description: '', instruments: ['EURUSD'], aiRationale: '',
nodes: [], edges: [], coefficients: {}, inputMapping: {},
}
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
function TabEditor({ initialId }: { initialId?: number | null }) {
const [templates, setTemplates] = useState<Template[]>([])
const [state, setState] = useState<EditorState>(BLANK)
const [selNode, setSelNode] = useState<string | null>(null)
const [editEdgeIdx, setEditEdge] = useState<number | null>(null)
const [addNodeOpen, setAddNode] = useState(false)
const [addEdgeOpen, setAddEdge] = useState(false)
const [newNode, setNewNode] = useState<Partial<CausalNode>>({ type: 'observable', x: 200, y: 200 })
const [newEdge, setNewEdge] = useState<Partial<CausalEdge>>({ style: 'solid', strength: 2, sign: 'positive' })
const [editEdge, setEditEdgeVal] = useState<Partial<CausalEdge>>({})
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
const [dataSources, setDS] = useState<DataSources>({ prices: [], macro_series: [], ff_events: [] })
const [grammarText, setGrammarText] = useState('')
const [grammarErrors, setGrammarErrors] = useState<string[]>([])
const [grammarApplied, setGrammarApplied] = useState(0) // count of applied ops
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState<string | null>(null)
const [showGrammarRef, setShowGrammarRef] = useState(false)
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
api('/api/causal-lab/templates').then(setTemplates)
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
}, [])
// Load template when arriving from Library "Éditer" button
useEffect(() => {
if (!initialId) return
api(`/api/causal-lab/template/${initialId}`).then(applyTemplateToState).catch(() => {})
}, [initialId])
function applyTemplateToState(t: Template) {
setState({
templateId: t.id, name: t.name, category: t.category, subType: t.sub_type,
description: t.description, instruments: t.instruments, aiRationale: t.ai_rationale,
nodes: JSON.parse(JSON.stringify(t.graph_json.nodes || [])),
edges: JSON.parse(JSON.stringify(t.graph_json.edges || [])),
coefficients: JSON.parse(JSON.stringify(t.graph_json.coefficients || {})),
inputMapping: JSON.parse(JSON.stringify(t.graph_json.input_mapping || {})),
})
setSelNode(null)
}
function loadTemplate(id: number) {
const t = templates.find(x => x.id === id)
if (t) applyTemplateToState(t)
}
// Compile natural language → grammar (replaces textarea content)
async function compileNlToGrammar() {
if (!grammarText.trim()) return
setAiLoading(true); setAiError(null)
try {
const r = await api(`/api/causal-lab/template/${state.templateId ?? 0}/ai-to-grammar`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: grammarText, current_graph: graphJson }),
})
setGrammarText(r.grammar)
setGrammarErrors([])
setGrammarApplied(0)
} catch (e) {
setAiError(String(e))
} finally {
setAiLoading(false)
}
}
// Apply grammar patch directly to editor state (no AI)
function applyGrammar() {
const { ops, errors: parseErrors } = parseGrammar(grammarText)
if (parseErrors.length && ops.length === 0) { setGrammarErrors(parseErrors); return }
const { state: newState, errors: applyErrors } = applyGrammarOps(state, ops)
setGrammarErrors([...parseErrors, ...applyErrors])
setState(newState)
setGrammarApplied(ops.length - applyErrors.length)
if (ops.length > 0) setMsg({ ok: true, text: `${ops.length - applyErrors.length}/${ops.length} opérations appliquées.` })
}
function resetNew() { setState(BLANK); setSelNode(null) }
const graphJson: GraphJson = {
nodes: state.nodes, edges: state.edges,
coefficients: state.coefficients,
instruments: state.instruments,
input_mapping: state.inputMapping,
}
// Click SVG background → reposition selected node
function handleBgClick(e: React.MouseEvent<SVGSVGElement>) {
if (!selNode) return
const svg = (e.currentTarget as SVGSVGElement)
const pt = svg.createSVGPoint()
pt.x = e.clientX; pt.y = e.clientY
const sp = pt.matrixTransform(svg.getScreenCTM()!.inverse())
setState(s => ({
...s, nodes: s.nodes.map(n =>
n.id === selNode ? { ...n, x: Math.round(sp.x), y: Math.round(sp.y) } : n
),
}))
}
function addNode() {
if (!newNode.id || !newNode.label) return
const n: CausalNode = {
id: newNode.id!, label: newNode.label!, type: (newNode.type ?? 'observable') as CausalNode['type'],
x: newNode.x ?? 200, y: newNode.y ?? 200,
...(newNode.formula ? { formula: newNode.formula } : {}),
...(newNode.unit ? { unit: newNode.unit } : {}),
...(newNode.instrument ? { instrument: newNode.instrument } : {}),
}
setState(s => ({ ...s, nodes: [...s.nodes, n] }))
setNewNode({ type: 'observable', x: 200, y: 200 })
setAddNode(false)
autoDetectCoefs([...state.nodes, n], state.coefficients)
}
function updateNode(id: string, changes: Partial<CausalNode>) {
setState(s => ({ ...s, nodes: s.nodes.map(n => n.id === id ? { ...n, ...changes } : n) }))
}
function removeNode(id: string) {
setState(s => {
const im = { ...s.inputMapping }; delete im[id]
return {
...s,
nodes: s.nodes.filter(n => n.id !== id),
edges: s.edges.filter(e => e.from !== id && e.to !== id),
inputMapping: im,
}
})
if (selNode === id) setSelNode(null)
}
function addEdge() {
if (!newEdge.from || !newEdge.to) return
const e: CausalEdge = {
from: newEdge.from!, to: newEdge.to!,
style: newEdge.style ?? 'solid',
strength: newEdge.strength ?? 2,
sign: newEdge.sign ?? 'neutral',
...(newEdge.label ? { label: newEdge.label } : {}),
}
setState(s => ({ ...s, edges: [...s.edges, e] }))
setNewEdge({ style: 'solid', strength: 2, sign: 'positive' })
setAddEdge(false)
}
function removeEdge(i: number) {
setState(s => ({ ...s, edges: s.edges.filter((_, idx) => idx !== i) }))
if (editEdgeIdx === i) setEditEdge(null)
}
function startEditEdge(i: number) {
const e = state.edges[i]
setEditEdgeVal({ ...e })
setEditEdge(i)
}
function applyEdgeEdit() {
if (editEdgeIdx === null) return
setState(s => ({
...s,
edges: s.edges.map((e, i) => i === editEdgeIdx ? { ...e, ...editEdge } : e),
}))
setEditEdge(null)
}
function autoDetectCoefs(nodes: CausalNode[], existing: Record<string, Coefficient>) {
const pattern = /\{\{(\w+)\}\}/g
const found = new Set<string>()
nodes.forEach(n => { let m; while ((m = pattern.exec(n.formula ?? '')) !== null) found.add(m[1]) })
const newCoefs = { ...existing }
found.forEach(k => { if (!(k in newCoefs)) newCoefs[k] = { value: 1.0, calibrated: null, description: '' } })
setState(s => ({ ...s, coefficients: newCoefs }))
}
async function saveAsNew() {
setSaving(true); setMsg(null)
try {
const body = {
name: state.name, category: state.category, sub_type: state.subType,
description: state.description, instruments: state.instruments,
graph_json: graphJson,
ai_rationale: state.aiRationale,
}
const r = await api('/api/causal-lab/template', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
setMsg({ ok: true, text: `Template créé (id=${r.id})` })
setState(s => ({ ...s, templateId: r.id }))
api('/api/causal-lab/templates').then(setTemplates)
} catch (e: unknown) {
setMsg({ ok: false, text: String(e) })
} finally { setSaving(false) }
}
async function updateExisting() {
if (!state.templateId) return
setSaving(true); setMsg(null)
try {
await api(`/api/causal-lab/template/${state.templateId}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: state.name, category: state.category, sub_type: state.subType,
description: state.description, instruments: state.instruments,
graph_json: graphJson,
ai_rationale: state.aiRationale,
}),
})
setMsg({ ok: true, text: 'Template mis à jour ✓' })
api('/api/causal-lab/templates').then(setTemplates)
} catch (e: unknown) {
setMsg({ ok: false, text: String(e) })
} finally { setSaving(false) }
}
const nodeIds = state.nodes.map(n => n.id)
const selectedNodeObj = selNode ? state.nodes.find(n => n.id === selNode) : null
return (
<div className="flex gap-4">
{/* Panneau gauche — formulaires */}
<div className="w-80 flex-shrink-0 space-y-3 overflow-y-auto max-h-[calc(100vh-200px)]">
{/* Charger existant */}
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40 space-y-2">
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Base de départ</div>
<div className="flex gap-2">
<select onChange={e => loadTemplate(Number(e.target.value))} defaultValue=""
className="flex-1 bg-dark-800 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-300">
<option value="">Charger un template existant</option>
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<button onClick={resetNew} title="Nouveau vide"
className="shrink-0 px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs text-slate-300 whitespace-nowrap">
Nouveau
</button>
</div>
</div>
{/* Métadonnées */}
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40 space-y-2">
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Métadonnées</div>
{[
{ label: 'Nom', key: 'name' as const },
{ label: 'Description', key: 'description' as const },
].map(({ label, key }) => (
<div key={key}>
<label className="text-xs text-slate-500">{label}</label>
<input value={state[key] as string}
onChange={e => setState(s => ({ ...s, [key]: e.target.value }))}
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 mt-0.5" />
</div>
))}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-slate-500">Catégorie</label>
<select value={state.category}
onChange={e => setState(s => ({ ...s, category: e.target.value }))}
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300 mt-0.5">
{Object.entries(CAT_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Sub-type</label>
<input value={state.subType}
onChange={e => setState(s => ({ ...s, subType: e.target.value }))}
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 mt-0.5" />
</div>
</div>
<div>
<label className="text-xs text-slate-500">Instruments</label>
<div className="flex flex-wrap gap-1 mt-1">
{INSTRUMENTS_LIST.map(i => (
<button key={i} onClick={() => setState(s => ({
...s, instruments: s.instruments.includes(i)
? s.instruments.filter(x => x !== i)
: [...s.instruments, i],
}))}
className={clsx('text-xs px-2 py-0.5 rounded border',
state.instruments.includes(i)
? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/40'
: 'bg-dark-800 text-slate-500 border-slate-700')}>
{i}
</button>
))}
</div>
</div>
</div>
{/* Nœuds */}
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider">
Nœuds ({state.nodes.length})
</span>
<button onClick={() => setAddNode(v => !v)}
className="flex items-center gap-1 text-xs px-2 py-1 bg-blue-700/40 hover:bg-blue-700 rounded text-blue-300 border border-blue-700/40">
<Plus className="w-3 h-3" /> Ajouter
</button>
</div>
{addNodeOpen && (
<div className="mb-2 p-2 bg-dark-800 rounded border border-slate-600 space-y-1.5 text-xs">
<div className="grid grid-cols-2 gap-1">
<div>
<label className="text-slate-500">ID</label>
<input placeholder="ex: ois_pricing"
value={newNode.id ?? ''} onChange={e => setNewNode(p => ({ ...p, id: e.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">Type</label>
<select value={newNode.type} onChange={e => setNewNode(p => ({ ...p, type: e.target.value as CausalNode['type'] }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
{NODE_TYPES.map(t => <option key={t} value={t}>{NODE_TYPE_LABELS[t]}</option>)}
</select>
</div>
</div>
<div>
<label className="text-slate-500">Label</label>
<input placeholder="ex: Fed OIS / probabilités"
value={newNode.label ?? ''} onChange={e => setNewNode(p => ({ ...p, label: e.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">Formule (optionnel)</label>
<input placeholder="ex: input_a * {{coef_x}}"
value={newNode.formula ?? ''} onChange={e => setNewNode(p => ({ ...p, formula: e.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5 font-mono" />
</div>
<div className="grid grid-cols-3 gap-1">
<div>
<label className="text-slate-500">Unité</label>
<input value={newNode.unit ?? ''} onChange={e => setNewNode(p => ({ ...p, unit: e.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">X</label>
<input type="number" value={newNode.x ?? 200} onChange={e => setNewNode(p => ({ ...p, x: parseInt(e.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">Y</label>
<input type="number" value={newNode.y ?? 200} onChange={e => setNewNode(p => ({ ...p, y: parseInt(e.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
</div>
<button onClick={addNode}
className="w-full py-1 bg-blue-600 hover:bg-blue-500 rounded text-xs text-white">
+ Ajouter le nœud
</button>
</div>
)}
<div className="space-y-1 max-h-52 overflow-y-auto">
{state.nodes.map(n => (
<div key={n.id}
className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs cursor-pointer',
selNode === n.id
? 'border-yellow-500/60 bg-yellow-900/10 text-yellow-200'
: 'border-slate-700/40 bg-dark-800/50 text-slate-300 hover:border-slate-500')}
onClick={() => setSelNode(selNode === n.id ? null : n.id)}>
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: nodeColor(n.type) }} />
<span className="font-mono flex-1 truncate">{n.id}</span>
<span className="text-slate-500 truncate max-w-[80px]">{n.label}</span>
<button onClick={e => { e.stopPropagation(); removeNode(n.id) }}
className="text-slate-600 hover:text-red-400 flex-shrink-0">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
{state.nodes.length === 0 && (
<div className="text-slate-600 text-xs text-center py-3">Aucun nœud cliquez "+ Ajouter"</div>
)}
</div>
{selNode && (
<div className="mt-2 p-2 bg-yellow-900/10 border border-yellow-700/30 rounded text-xs text-yellow-300">
<Info className="w-3 h-3 inline mr-1" />
Nœud <strong>{selNode}</strong> sélectionné cliquez sur le graphe (droite) pour le repositionner
</div>
)}
</div>
{/* Mapping observable */}
{selectedNodeObj?.type === 'observable' && selNode && (() => {
const mapping: InputMapping = state.inputMapping[selNode] || { source: 'user_input' }
const updateMapping = (changes: Partial<InputMapping>) =>
setState(s => ({
...s,
inputMapping: { ...s.inputMapping, [selNode]: { ...s.inputMapping[selNode], ...changes } },
}))
const clearMapping = () =>
setState(s => { const m = { ...s.inputMapping }; delete m[selNode]; return { ...s, inputMapping: m } })
const fieldOptions: Record<string, { key: string; label: string }[]> = {
market_watchlist: [{ key: 'close', label: 'Prix (close)' }, { key: 'change_pct', label: 'Variation %' }],
economic_events: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise (actualforecast)' }],
ff_calendar: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise' }],
}
const dsKeys = mapping.source === 'market_watchlist' ? dataSources.prices
: mapping.source === 'economic_events' ? dataSources.macro_series
: mapping.source === 'ff_calendar' ? dataSources.ff_events : []
return (
<div className="bg-dark-700 rounded-lg p-3 border border-cyan-700/40 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-cyan-400 font-semibold uppercase tracking-wider">
Mapping {selNode}
</span>
{mapping.source && mapping.source !== 'user_input' && (
<button onClick={clearMapping} className="text-xs text-slate-500 hover:text-red-400">Effacer</button>
)}
</div>
<div>
<label className="text-xs text-slate-500">Source</label>
<select value={mapping.source || 'user_input'}
onChange={e => updateMapping({ source: e.target.value as InputMapping['source'], key: '', field: 'close' })}
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
<option value="user_input">Manuel (saisie Analyser)</option>
<option value="market_watchlist">Prix marché yfinance</option>
<option value="economic_events">Série macro BD</option>
<option value="ff_calendar">Calendrier Forex Factory</option>
</select>
</div>
{mapping.source && mapping.source !== 'user_input' && (<>
<div>
<label className="text-xs text-slate-500">Clé</label>
<input list={`ds-${selNode}`}
value={mapping.key || ''}
onChange={e => updateMapping({ key: e.target.value })}
placeholder={
mapping.source === 'market_watchlist' ? 'ex: EURUSD' :
mapping.source === 'economic_events' ? 'ex: NFP_US' : 'ex: Non-Farm Payrolls'
}
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
<datalist id={`ds-${selNode}`}>
{dsKeys.map(ds => <option key={ds.key} value={ds.key}>{ds.label}</option>)}
</datalist>
</div>
<div>
<label className="text-xs text-slate-500">Champ</label>
<select value={mapping.field || (mapping.source === 'market_watchlist' ? 'close' : 'actual')}
onChange={e => updateMapping({ field: e.target.value })}
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
{(fieldOptions[mapping.source] || []).map(f => (
<option key={f.key} value={f.key}>{f.label}</option>
))}
</select>
</div>
</>)}
</div>
)
})()}
{/* Arêtes */}
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider">
Arêtes ({state.edges.length})
</span>
<button onClick={() => setAddEdge(v => !v)} disabled={state.nodes.length < 2}
className="flex items-center gap-1 text-xs px-2 py-1 bg-purple-700/40 hover:bg-purple-700 disabled:opacity-40 rounded text-purple-300 border border-purple-700/40">
<Plus className="w-3 h-3" /> Ajouter
</button>
</div>
{addEdgeOpen && (
<div className="mb-2 p-2 bg-dark-800 rounded border border-slate-600 space-y-1.5 text-xs">
<div className="grid grid-cols-2 gap-1">
<div>
<label className="text-slate-500">De</label>
<select value={newEdge.from ?? ''} onChange={e => setNewEdge(p => ({ ...p, from: e.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value=""> source </option>
{nodeIds.map(id => <option key={id} value={id}>{id}</option>)}
</select>
</div>
<div>
<label className="text-slate-500">Vers</label>
<select value={newEdge.to ?? ''} onChange={e => setNewEdge(p => ({ ...p, to: e.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value=""> cible </option>
{nodeIds.map(id => <option key={id} value={id}>{id}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-1">
<div>
<label className="text-slate-500">Style</label>
<select value={newEdge.style} onChange={e => setNewEdge(p => ({ ...p, style: e.target.value as 'solid' | 'dashed' }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value="solid">Direct </option>
<option value="dashed">Indirect </option>
</select>
</div>
<div>
<label className="text-slate-500">Force</label>
<select value={newEdge.strength} onChange={e => setNewEdge(p => ({ ...p, strength: parseInt(e.target.value) as 1|2|3 }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value={1}>1 fin</option>
<option value={2}>2 normal</option>
<option value={3}>3 fort</option>
</select>
</div>
<div>
<label className="text-slate-500">Signe</label>
<select value={newEdge.sign} onChange={e => setNewEdge(p => ({ ...p, sign: e.target.value as 'positive'|'negative'|'neutral' }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value="positive">+ positif</option>
<option value="negative"> négatif</option>
<option value="neutral"> neutre</option>
</select>
</div>
</div>
<div>
<label className="text-slate-500">Label</label>
<input placeholder="ex: canal court terme"
value={newEdge.label ?? ''} onChange={e => setNewEdge(p => ({ ...p, label: e.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div className="grid grid-cols-4 gap-1">
<div>
<label className="text-slate-500" title="Délai en minutes (intraday 5m)">Lag (min)</label>
<input type="number" min={0} step={5} placeholder="0"
value={newEdge.lag_min ?? ''}
onChange={e => setNewEdge(p => ({ ...p, lag_min: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-amber-400" title="Délai en jours (mode journalier)">Lag (j)</label>
<input type="number" min={0} step={1} placeholder="0"
value={newEdge.lag_days ?? ''}
onChange={e => setNewEdge(p => ({ ...p, lag_days: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
className="w-full bg-dark-700 border border-amber-600/40 rounded px-1.5 py-1 text-amber-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">Diff. (min)</label>
<input type="number" min={0} step={15} placeholder="60"
value={newEdge.diffusion_min ?? ''}
onChange={e => setNewEdge(p => ({ ...p, diffusion_min: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">Decay (j)</label>
<input type="number" min={0} step={1} placeholder="∞"
value={newEdge.decay_days ?? ''}
onChange={e => setNewEdge(p => ({ ...p, decay_days: e.target.value === '' ? null : parseFloat(e.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
</div>
<button onClick={addEdge}
className="w-full py-1 bg-purple-600 hover:bg-purple-500 rounded text-xs text-white">
+ Ajouter l'arête
</button>
</div>
)}
<div className="space-y-1 max-h-52 overflow-y-auto">
{state.edges.map((e, i) => (
<div key={i}>
<div className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs cursor-pointer',
editEdgeIdx === i
? 'border-yellow-500/60 bg-yellow-900/10'
: 'border-slate-700/40 bg-dark-800/50 hover:border-slate-500')}
onClick={() => editEdgeIdx === i ? setEditEdge(null) : startEditEdge(i)}>
<div className="w-8 h-0 flex-shrink-0"
style={{ borderTop: `${edgeWidth(e.strength)}px ${e.style === 'dashed' ? 'dashed' : 'solid'} ${edgeColor(e.sign)}` }} />
<span className="font-mono text-slate-400 truncate flex-1">
{e.from} → {e.to}
</span>
{e.label && <span className="text-slate-500 truncate max-w-[60px]">{e.label}</span>}
<button onClick={ev => { ev.stopPropagation(); removeEdge(i) }}
className="text-slate-600 hover:text-red-400 flex-shrink-0">
<Trash2 className="w-3 h-3" />
</button>
</div>
{editEdgeIdx === i && (
<div className="mt-1 p-2 bg-dark-800 rounded border border-yellow-700/30 space-y-1.5 text-xs">
<div className="grid grid-cols-3 gap-1">
<div>
<label className="text-slate-500">Style</label>
<select value={editEdge.style ?? e.style}
onChange={ev => setEditEdgeVal(p => ({ ...p, style: ev.target.value as 'solid'|'dashed' }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value="solid">Direct ──</option>
<option value="dashed">Indirect ╌╌</option>
</select>
</div>
<div>
<label className="text-slate-500">Force</label>
<select value={editEdge.strength ?? e.strength}
onChange={ev => setEditEdgeVal(p => ({ ...p, strength: parseInt(ev.target.value) as 1|2|3 }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value={1}>1 — fin</option>
<option value={2}>2 — normal</option>
<option value={3}>3 — fort</option>
</select>
</div>
<div>
<label className="text-slate-500">Signe</label>
<select value={editEdge.sign ?? e.sign}
onChange={ev => setEditEdgeVal(p => ({ ...p, sign: ev.target.value as 'positive'|'negative'|'neutral' }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
<option value="positive">+ positif</option>
<option value="negative"> négatif</option>
<option value="neutral">∅ neutre</option>
</select>
</div>
</div>
<div>
<label className="text-slate-500">Label</label>
<input value={editEdge.label ?? e.label ?? ''}
onChange={ev => setEditEdgeVal(p => ({ ...p, label: ev.target.value }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div className="grid grid-cols-4 gap-1">
<div>
<label className="text-slate-500" title="Délai en minutes (intraday 5m)">Lag (min)</label>
<input type="number" min={0} step={5}
value={editEdge.lag_min ?? e.lag_min ?? ''}
onChange={ev => setEditEdgeVal(p => ({ ...p, lag_min: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-amber-400" title="Délai en jours (mode journalier)">Lag (j)</label>
<input type="number" min={0} step={1}
value={editEdge.lag_days ?? e.lag_days ?? ''}
onChange={ev => setEditEdgeVal(p => ({ ...p, lag_days: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
className="w-full bg-dark-700 border border-amber-600/40 rounded px-1.5 py-1 text-amber-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">Diff. (min)</label>
<input type="number" min={0} step={15}
value={editEdge.diffusion_min ?? e.diffusion_min ?? ''}
onChange={ev => setEditEdgeVal(p => ({ ...p, diffusion_min: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
<div>
<label className="text-slate-500">Decay (j)</label>
<input type="number" min={0} step={1}
value={editEdge.decay_days ?? e.decay_days ?? ''}
onChange={ev => setEditEdgeVal(p => ({ ...p, decay_days: ev.target.value === '' ? null : parseFloat(ev.target.value) }))}
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
</div>
</div>
<button onClick={applyEdgeEdit}
className="w-full py-1 bg-yellow-700 hover:bg-yellow-600 rounded text-xs text-white">
Appliquer
</button>
</div>
)}
</div>
))}
{state.edges.length === 0 && (
<div className="text-slate-600 text-xs text-center py-3">Aucune arête</div>
)}
</div>
</div>
{/* Coefficients */}
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider">
Coefficients ({Object.keys(state.coefficients).length})
</span>
<button onClick={() => autoDetectCoefs(state.nodes, state.coefficients)}
className="text-xs px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-slate-300">
Détecter auto
</button>
</div>
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{Object.entries(state.coefficients).map(([k, c]) => (
<div key={k} className="flex gap-2 items-center">
<span className="text-xs text-slate-400 font-mono flex-1 truncate">{k}</span>
<input type="number" step="0.001" value={c.value}
onChange={e => setState(s => ({
...s, coefficients: { ...s.coefficients, [k]: { ...c, value: parseFloat(e.target.value) || 0 } }
}))}
className="w-20 bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-right" />
<button onClick={() => setState(s => {
const cc = { ...s.coefficients }; delete cc[k]; return { ...s, coefficients: cc }
})} className="text-slate-600 hover:text-red-400">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
{/* Sauvegarde */}
<div className="space-y-2">
{state.templateId && (
<button onClick={updateExisting} disabled={saving || !state.name}
className="w-full flex items-center justify-center gap-2 py-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-40 rounded text-xs font-medium text-white">
<Save className="w-3.5 h-3.5" />
{saving ? 'Sauvegarde' : 'Mettre à jour le template'}
</button>
)}
<button onClick={saveAsNew} disabled={saving || !state.name}
className="w-full flex items-center justify-center gap-2 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded text-xs font-medium text-white">
<Save className="w-3.5 h-3.5" />
{saving ? 'Sauvegarde' : 'Créer ce template (copie)'}
</button>
{msg && (
<div className={clsx('p-2 rounded text-xs', msg.ok ? 'bg-emerald-900/20 text-emerald-300' : 'bg-red-900/20 text-red-300')}>
{msg.text}
</div>
)}
</div>
</div>
{/* Panneau droit — SVG live */}
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3 mb-1">
<span className="text-xs text-slate-500">
{selNode
? <span className="text-yellow-400">Cliquez sur le graphe pour repositionner <strong>{selNode}</strong></span>
: 'Cliquez sur un nœud dans le graphe pour le sélectionner et le repositionner'
}
</span>
</div>
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
<GraphSVG
graph={graphJson}
editorMode={true}
selectedNodeId={selNode}
onNodeClick={(id) => setSelNode(selNode === id ? null : id)}
onBgClick={handleBgClick}
/>
</div>
<GraphLegend />
{/* Grammar patch panel */}
<div className="bg-dark-700 rounded-lg p-4 border border-violet-900/40 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-slate-300 text-xs font-semibold uppercase tracking-wider flex items-center gap-2">
<Wand2 className="w-3.5 h-3.5 text-violet-400" /> Patch Grammaire
</h4>
<button onClick={() => setShowGrammarRef(v => !v)}
className="text-xs text-slate-600 hover:text-slate-400 flex items-center gap-1">
<Info className="w-3 h-3" /> {showGrammarRef ? 'Masquer' : 'Référence'}
</button>
</div>
{showGrammarRef && (
<pre className="text-[10px] font-mono text-slate-500 bg-dark-900/60 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre">
{GRAMMAR_REFERENCE}
</pre>
)}
<textarea
value={grammarText}
onChange={e => { setGrammarText(e.target.value); setGrammarErrors([]); setGrammarApplied(0) }}
disabled={aiLoading}
placeholder={
"Écrivez la grammaire directement, ou décrivez en français puis cliquez « Compiler »\n\n" +
"+node id=risk_aversion label=\"Risk Aversion\" type=latent x=450 y=300\n" +
"+edge from=us_10y to=risk_aversion sign=- lag=2 decay=10\n" +
"~edge from=ois_pricing to=us_2y lag=3\n" +
"-edge from=rate_diff to=eurusd_pip"
}
rows={7}
className="w-full bg-dark-900/70 border border-slate-700 rounded px-3 py-2 text-xs font-mono text-slate-200 placeholder:text-slate-600 resize-none focus:border-violet-600 focus:outline-none"
/>
<div className="flex items-center gap-2 flex-wrap">
{/* Compile NL → grammar */}
<button
onClick={compileNlToGrammar}
disabled={aiLoading || !grammarText.trim() || !state.templateId}
title={!state.templateId ? "Sauvegardez d'abord le template" : undefined}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 disabled:opacity-40 rounded text-xs text-slate-300 border border-slate-600 transition-all">
{aiLoading
? <><RefreshCw className="w-3 h-3 animate-spin" /> Compilation…</>
: <><Brain className="w-3 h-3 text-violet-400" /> NL → Grammaire</>}
</button>
{/* Apply grammar locally */}
<button
onClick={applyGrammar}
disabled={!grammarText.trim() || aiLoading}
className="flex items-center gap-1.5 px-3 py-1.5 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded text-xs font-medium text-white transition-all">
<Zap className="w-3 h-3" /> Appliquer
</button>
{/* Live parse count */}
{grammarText.trim() && (() => {
const { ops } = parseGrammar(grammarText)
return ops.length > 0 ? (
<span className="text-xs text-slate-500">{ops.length} opération{ops.length > 1 ? 's' : ''} détectée{ops.length > 1 ? 's' : ''}</span>
) : null
})()}
{grammarApplied > 0 && (
<span className="text-xs text-emerald-500">✓ {grammarApplied} appliquée{grammarApplied > 1 ? 's' : ''}</span>
)}
</div>
{/* Errors */}
{grammarErrors.length > 0 && (
<div className="p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300 space-y-0.5">
{grammarErrors.map((e, i) => <div key={i}>⚠ {e}</div>)}
</div>
)}
{aiError && (
<div className="p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">
{aiError}
</div>
)}
</div>
</div>
</div>
)
}
// ── Tab: Analyser ─────────────────────────────────────────────────────────────
function TabAnalyze({ initialEventId }: { initialEventId?: number | null }) {
const [events, setEvents] = useState<MarketEvent[]>([])
const [templates, setTemplates] = useState<Template[]>([])
const [selEvent, setSelEvent] = useState<MarketEvent | null>(null)
const [selTmpl, setSelTmpl] = useState<number>(0)
const [instrument, setInstr] = useState('EURUSD')
const [manualInputs, setManual] = useState<Record<string, number>>({})
const [result, setResult] = useState<AnalysisResult | null>(null)
const [rec, setRec] = useState<Recommendation | null>(null)
const [loadingRec, setLoadRec] = useState(false)
const [loadingAn, setLoadAn] = useState(false)
const [q, setQ] = useState('')
const [catF, setCatF] = useState('')
const [error, setError] = useState('')
useEffect(() => {
Promise.all([
api(`/api/causal-lab/market-events?limit=200${catF ? `&category=${catF}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}`),
api('/api/causal-lab/templates'),
]).then(([evs, tmps]) => { setEvents(evs); setTemplates(tmps) })
}, [catF, q])
useEffect(() => {
if (!initialEventId || !events.length || selEvent) return
const found = events.find(e => e.id === initialEventId)
if (found) setSelEvent(found)
}, [events, initialEventId])
const currentTemplate = templates.find(t => t.id === selTmpl)
const manualNodes = currentTemplate?.graph_json?.input_mapping
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
!c.source || c.source === 'user_input')
: []
const autoNodes = currentTemplate?.graph_json?.input_mapping
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
c.source && c.source !== 'user_input')
: []
async function recommend() {
if (!selEvent) return
setLoadRec(true); setRec(null); setError('')
try {
const r = await api('/api/causal-lab/recommend', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: selEvent.id }),
})
setRec(r.recommendation)
if (r.recommendation?.template_id) setSelTmpl(r.recommendation.template_id)
} catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) }
finally { setLoadRec(false) }
}
async function analyze() {
if (!selEvent || !selTmpl) return
setLoadAn(true); setResult(null); setError('')
try {
const r = await api('/api/causal-lab/analyze', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: selEvent.id, template_id: selTmpl, instrument, inputs: manualInputs, coef_overrides: {} }),
})
setResult(r)
} catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) }
finally { setLoadAn(false) }
}
return (
<div className="flex gap-4">
<div className="w-72 flex-shrink-0 space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-2 top-2 text-slate-500" />
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Rechercher"
className="w-full bg-dark-700 border border-slate-700 rounded pl-7 pr-2 py-1.5 text-xs text-slate-300" />
</div>
<select value={catF} onChange={e => setCatF(e.target.value)}
className="bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300">
<option value="">Tous</option>
<option value="event_calendar">Calendrier éco</option>
<option value="fundamental">Fondamental</option>
<option value="geopolitical">Géopolitique</option>
<option value="report">Report</option>
<option value="sentiment">Sentiment</option>
<option value="technical">Technique</option>
</select>
</div>
<div className="space-y-1 overflow-y-auto max-h-60 border border-slate-700/30 rounded-lg p-1">
{events.length === 0
? <div className="text-slate-600 text-xs p-3 text-center">Aucun événement</div>
: events.map(ev => (
<button key={ev.id} onClick={() => { setSelEvent(ev); setResult(null); setRec(null) }}
className={clsx('w-full text-left px-3 py-2 rounded border text-xs transition-all',
selEvent?.id === ev.id
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
<div className="font-medium truncate">{ev.name}</div>
<div className="text-slate-500 flex justify-between">
<span>{ev.start_date?.slice(0, 10)}</span>
{ev.activation_score != null && (
<span className={clsx('font-semibold',
ev.activation_score >= 0.7 ? 'text-emerald-400' :
ev.activation_score >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
{(ev.activation_score * 100).toFixed(0)}%
</span>
)}
</div>
</button>
))}
</div>
<select value={selTmpl} onChange={e => setSelTmpl(Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-2 text-xs text-slate-300">
<option value={0}>— Choisir un template —</option>
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<select value={instrument} onChange={e => setInstr(e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-2 text-xs text-slate-300">
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT'].map(i => <option key={i} value={i}>{i}</option>)}
</select>
{autoNodes.length > 0 && (
<div className="bg-dark-800/60 rounded p-3 space-y-1.5 border border-cyan-700/20">
<div className="text-xs text-cyan-500 font-semibold flex items-center gap-1.5">
<Zap className="w-3 h-3" /> Auto-récupéré depuis BD
</div>
{autoNodes.map(([id, cfg]) => (
<div key={id} className="flex items-center gap-2 text-xs">
<span className="text-slate-500 flex-1 capitalize">{id.replace(/_/g, ' ')}</span>
<span className="text-cyan-400/70 text-xs font-mono">{cfg.key ?? cfg.source}</span>
</div>
))}
</div>
)}
{manualNodes.length > 0 && (
<div className="bg-dark-800 rounded p-3 space-y-2 border border-slate-700/30">
<div className="text-xs text-slate-400 font-semibold">Inputs manuels</div>
{manualNodes.map(([id, cfg]) => {
const range = cfg.range as [number, number] | undefined
return (
<div key={id} className="flex items-center gap-2">
<span className="text-xs text-slate-400 flex-1 capitalize">{id.replace(/_/g, ' ')}</span>
<input type="range" min={range?.[0] ?? -3} max={range?.[1] ?? 3} step={0.5}
value={manualInputs[id] ?? 0}
onChange={e => setManual(p => ({ ...p, [id]: parseFloat(e.target.value) }))}
className="w-24" />
<span className="text-xs text-slate-300 w-8 text-right">
{(manualInputs[id] ?? 0) > 0 ? '+' : ''}{manualInputs[id] ?? 0}
</span>
</div>
)
})}
</div>
)}
<button onClick={recommend} disabled={!selEvent || loadingRec}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-purple-700/50 hover:bg-purple-700 disabled:opacity-40 rounded text-xs font-medium text-purple-200 border border-purple-700/40">
<Brain className="w-3.5 h-3.5" />
{loadingRec ? 'GPT-4o analyse…' : 'Recommandation GPT-4o'}
</button>
<button onClick={analyze} disabled={!selEvent || !selTmpl || loadingAn}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 rounded text-xs font-medium text-white">
<Zap className="w-3.5 h-3.5" />
{loadingAn ? 'Analyse…' : 'Lancer l\'analyse'}
</button>
{error && <div className="p-3 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">{error}</div>}
</div>
<div className="flex-1 space-y-4 min-w-0">
{rec && (
<div className="bg-purple-900/10 border border-purple-700/30 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-200">Recommandation GPT-4o</span>
{rec.confidence != null && (
<span className="ml-auto text-xs text-purple-400 font-bold">
{(rec.confidence * 100).toFixed(0)}% confiance
</span>
)}
</div>
{rec.error
? <div className="text-red-400 text-xs">{rec.error}</div>
: <>
<div className="text-sm text-white font-medium mb-1">Template : {rec.template_name}</div>
<p className="text-xs text-purple-200/80">{rec.rationale}</p>
{rec.notes && <p className="text-xs text-slate-400 mt-1">{rec.notes}</p>}
{Object.keys(rec.coefficient_suggestions || {}).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{Object.entries(rec.coefficient_suggestions).map(([k, v]) => (
<span key={k} className="text-xs px-2 py-0.5 bg-purple-900/30 text-purple-300 rounded border border-purple-700/30">
{k} = {v}
</span>
))}
</div>
)}
</>
}
</div>
)}
{result && (
<>
{Object.keys(result.inputs).length > 0 && (
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
<div className="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-2">Inputs utilisés</div>
<div className="flex flex-wrap gap-2">
{Object.entries(result.inputs).map(([k, v]) => {
const isAuto = autoNodes.some(([id]) => id === k)
return (
<span key={k} className={clsx('text-xs px-2 py-0.5 rounded border font-mono',
isAuto
? 'bg-cyan-900/20 text-cyan-300 border-cyan-700/30'
: 'bg-slate-800 text-slate-300 border-slate-700/40')}>
{k}: {typeof v === 'number' ? (v > 0 ? '+' : '') + v.toFixed(3) : String(v)}
{isAuto && <span className="ml-1 text-cyan-600 text-xs">auto</span>}
</span>
)
})}
</div>
</div>
)}
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<div className="flex items-center justify-between mb-3">
<h3 className="text-white font-semibold text-sm">{result.event.name}</h3>
<span className={clsx('text-sm font-bold px-3 py-1 rounded',
result.activation.score == null ? 'text-slate-400 bg-slate-800' :
result.activation.score >= 0.7 ? 'text-emerald-400 bg-emerald-900/30' :
result.activation.score >= 0.4 ? 'text-yellow-400 bg-yellow-900/30' :
'text-red-400 bg-red-900/30')}>
{result.activation.score != null ? `${(result.activation.score * 100).toFixed(0)}% activation` : 'N/A'}
</span>
</div>
<div className="grid grid-cols-4 gap-3 mb-3">
{[
{ label: 'Pré-drift', val: fmtPips(result.drift?.pre_pips) },
{ label: 'Réel post', val: fmtPips(result.drift?.post_pips) },
{ label: 'Mode prix', val: result.prices_mode },
{ label: 'Fuite info', val: result.drift?.leak ?? '—' },
{ label: 'Lag mesuré', val: result.effective_lag_min != null ? `${result.effective_lag_min}min` : '0min' },
].map(m => (
<div key={m.label} className="bg-dark-800 rounded p-3 text-center">
<div className="text-xs text-slate-500">{m.label}</div>
<div className="text-sm font-bold text-slate-200 mt-0.5">{m.val}</div>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(result.activation.nodes).map(([id, node]) => {
const Icon = node.status === 'correct' ? CheckCircle : node.status === 'wrong' ? XCircle : Minus
const col =
node.status === 'correct' ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/10' :
node.status === 'wrong' ? 'text-red-400 border-red-700/40 bg-red-900/10' :
'text-slate-500 border-slate-700/40 bg-slate-800/30'
return (
<div key={id} className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded border text-xs', col)}>
<Icon className="w-3.5 h-3.5" />
<span>{node.label}</span>
{node.pred != null && (
<span className="opacity-70">
P:{node.pred > 0 ? '+' : ''}{node.pred.toFixed(1)} / A:{node.act != null ? (node.act > 0 ? '+' : '') + node.act.toFixed(1) : '—'}
</span>
)}
</div>
)
})}
</div>
</div>
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider">Graphe — valeurs calculées</h4>
{currentTemplate && (
<GraphSVG graph={currentTemplate.graph_json}
nodeValues={result.node_values}
activationNodes={result.activation.nodes} />
)}
</div>
</>
)}
{(loadingAn || loadingRec) && (
<div className="flex items-center justify-center h-48 text-slate-500 gap-3">
<RefreshCw className="w-5 h-5 animate-spin" />
<span className="text-sm">{loadingAn ? 'Récupération des prix (yfinance)…' : 'GPT-4o en cours…'}</span>
</div>
)}
{!result && !loadingAn && !loadingRec && !rec && (
<div className="flex flex-col items-center justify-center h-64 text-slate-600 space-y-3">
<FlaskConical className="w-12 h-12 opacity-30" />
<div className="text-sm text-center">Sélectionnez un événement, choisissez un template et lancez l'analyse</div>
</div>
)}
</div>
</div>
)
}
// ── Tab: Calibration ──────────────────────────────────────────────────────────
function TabCalibration() {
const [data, setData] = useState<CalibRow[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api('/api/causal-lab/calibration').then(setData).finally(() => setLoading(false))
}, [])
if (loading) return <div className="text-slate-500 text-sm p-6">Chargement…</div>
return (
<div className="space-y-3">
<div className="text-xs text-slate-500 flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
Statistiques agrégées par template. Le ratio pred/réel converge vers 1.0 quand les coefficients sont bien calibrés.
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700/50">
{['Template', 'Catégorie', 'Nb analyses', 'Activation moy.', 'Pred. moy.', 'Réel moy.', 'Ratio'].map(h => (
<th key={h} className="px-3 py-2 text-left text-slate-500 uppercase tracking-wider font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{data.map(row => {
const calib = row.calibration || {}
const instKey = Object.keys(calib).find(k => !['n_events', 'avg_activation', 'last_analyzed'].includes(k))
const instData = instKey ? calib[instKey] : null
return (
<tr key={row.id} className="border-b border-slate-800/50 hover:bg-dark-700/30 transition-colors">
<td className="px-3 py-2.5 text-slate-200 font-medium">{row.name}</td>
<td className="px-3 py-2.5 text-slate-400">{CAT_LABELS[row.category] ?? row.category}</td>
<td className="px-3 py-2.5 text-slate-300">{row.n_analyses}</td>
<td className="px-3 py-2.5">
{row.avg_activation != null
? <span className={clsx('font-bold',
row.avg_activation >= 0.7 ? 'text-emerald-400' :
row.avg_activation >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
{(row.avg_activation * 100).toFixed(0)}%
</span>
: <span className="text-slate-600">—</span>}
</td>
<td className="px-3 py-2.5 text-slate-300">{instData?.avg_pred?.toFixed(1) ?? '—'}</td>
<td className="px-3 py-2.5 text-slate-300">{instData?.avg_actual?.toFixed(1) ?? '—'}</td>
<td className="px-3 py-2.5">
{instData?.coef_ratio != null
? <span className={clsx('font-bold',
Math.abs(instData.coef_ratio - 1) < 0.2 ? 'text-emerald-400' :
Math.abs(instData.coef_ratio - 1) < 0.5 ? 'text-yellow-400' : 'text-red-400')}>
{instData.coef_ratio.toFixed(2)}x
</span>
: <span className="text-slate-600">—</span>}
</td>
</tr>
)
})}
</tbody>
</table>
{data.length === 0 && (
<div className="text-center py-12 text-slate-600 text-sm">
Aucune analyse effectuée. Lancez des analyses dans l'onglet "Analyser".
</div>
)}
</div>
</div>
)
}
// ── Page principale ───────────────────────────────────────────────────────────
const TABS = [
{ id: 'library', label: 'Bibliothèque', icon: Library },
{ id: 'editor', label: 'Editeur', icon: Edit3 },
{ id: 'analyze', label: 'Analyser', icon: FlaskConical },
{ id: 'calib', label: 'Calibration', icon: BarChart3 },
]
export default function CausalLab() {
const [searchParams] = useSearchParams()
const initialTemplateId = searchParams.get('template') ? parseInt(searchParams.get('template')!) : null
const initialEventId = searchParams.get('event') ? parseInt(searchParams.get('event')!) : null
const [tab, setTab] = useState(initialEventId ? 'analyze' : 'library')
const [editorInitId, setEditorInitId] = useState<number | null>(null)
function openInEditor(id: number) {
setEditorInitId(id)
setTab('editor')
}
return (
<div className="p-6 min-h-screen">
<div className="flex items-center gap-3 mb-6">
<FlaskConical className="w-6 h-6 text-blue-400" />
<div>
<h1 className="text-xl font-bold text-white">Lab Causal</h1>
<p className="text-slate-400 text-xs mt-0.5">
Bibliothèque · Editeur visuel · Analyse empirique · Calibration IA
</p>
</div>
</div>
<div className="flex gap-1 mb-6 bg-dark-800 p-1 rounded-lg w-fit border border-slate-700/30">
{TABS.map(({ id, label, icon: Icon }) => (
<button key={id} onClick={() => setTab(id)}
className={clsx('flex items-center gap-2 px-4 py-2 rounded text-sm font-medium transition-all',
tab === id ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} onOpenEditor={openInEditor} />}
{tab === 'editor' && <TabEditor initialId={editorInitId} />}
{tab === 'analyze' && <TabAnalyze initialEventId={initialEventId} />}
{tab === 'calib' && <TabCalibration />}
</div>
</div>
)
}