feat: causal lab — chip filters, editor zoom, AI modify prompt
Library:
- Color chip filters: category (with per-category colors), sub_type, instrument
- Date range filter (created_at) + text search
- Category labels unified to cover event_calendar, fundamental, calendar_event etc.
- Editer button on selected template → opens in editor tab
- Show created_at + created_by in template detail
Editor:
- Accepts initialId prop so Library can pre-load a template
- AI Modify zone: textarea prompt + button calls /api/causal-lab/template/{id}/ai-modify
- Returns modified graph_json from GPT-4o, applied live — user reviews then saves
Backend:
- New POST /api/causal-lab/template/{id}/ai-modify endpoint
- Sends current graph_json + user prompt to GPT-4o
- Returns validated modified graph_json (nodes/edges required)
- Does not auto-save (user controls save)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1847,3 +1847,90 @@ def generate_theory(template_id: int):
|
||||
except Exception as e:
|
||||
logger.error(f"[causal_lab] generate_theory {template_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ── AI graph modifier ─────────────────────────────────────────────────────────
|
||||
|
||||
class AiModifyRequest(BaseModel):
|
||||
prompt: str
|
||||
current_graph: dict = {} # client can pass current in-memory state
|
||||
|
||||
|
||||
@router.post("/api/causal-lab/template/{template_id}/ai-modify")
|
||||
def ai_modify_template(template_id: int, body: AiModifyRequest):
|
||||
"""
|
||||
GPT-4o modifie un graph_json selon un prompt utilisateur.
|
||||
Retourne le graph_json modifié sans sauvegarder — l'utilisateur valide puis sauvegarde.
|
||||
"""
|
||||
try:
|
||||
from services.database import get_conn, get_config
|
||||
from services.causal_graphs import get_template
|
||||
import openai
|
||||
|
||||
key = get_config("openai_api_key") or ""
|
||||
if not key:
|
||||
raise HTTPException(400, "Clé OpenAI manquante dans la configuration")
|
||||
|
||||
conn = get_conn()
|
||||
t = get_template(conn, template_id)
|
||||
conn.close()
|
||||
if not t:
|
||||
raise HTTPException(404, f"Template {template_id} non trouvé")
|
||||
|
||||
# Use the graph sent by client (latest in-memory edits) or fall back to DB
|
||||
gj = body.current_graph if body.current_graph.get("nodes") else t["graph_json"]
|
||||
|
||||
system_prompt = (
|
||||
"You are a causal graph editor for the GeoOptions macro-finance platform.\n"
|
||||
"You receive a causal graph in JSON and a modification request in natural language.\n"
|
||||
"Return ONLY the complete modified graph_json as a valid JSON object — no explanation, no markdown.\n\n"
|
||||
"Graph format:\n"
|
||||
"{\n"
|
||||
' "nodes": [{"id": str, "label": str, "type": "macro_event|observable|latent|market_asset",\n'
|
||||
' "x": int, "y": int, "formula": str_opt, "unit": str_opt, "instrument": str_opt}],\n'
|
||||
' "edges": [{"from": str, "to": str, "style": "solid|dashed", "strength": 1|2|3,\n'
|
||||
' "sign": "positive|negative|neutral", "label": str_opt,\n'
|
||||
' "lag_days": int_opt, "decay_days": float_opt}],\n'
|
||||
' "coefficients": {"key": {"value": float, "calibrated": null, "description": str}},\n'
|
||||
' "instruments": [str],\n'
|
||||
' "input_mapping": {"node_id": {"source": "user_input|surprise|actual_value|impact_score_scaled",\n'
|
||||
' "key": str_opt, "field": str_opt}}\n'
|
||||
"}\n\n"
|
||||
"Rules:\n"
|
||||
"- Preserve existing node x/y positions unless layout is clearly broken\n"
|
||||
"- All edge from/to must reference valid node ids\n"
|
||||
"- instruments list must include instrument field of all market_asset nodes\n"
|
||||
"- Return the COMPLETE graph_json (not just the changed parts)"
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f'Current graph — template "{t["name"]}" ({t["category"]}, sub_type: {t.get("sub_type","")}):\n'
|
||||
f"{json.dumps(gj, ensure_ascii=False, indent=2)}\n\n"
|
||||
f"Modification request:\n{body.prompt}\n\n"
|
||||
"Return the complete modified graph_json as JSON only."
|
||||
)
|
||||
|
||||
client = openai.OpenAI(api_key=key)
|
||||
resp = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.25,
|
||||
max_tokens=3000,
|
||||
)
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
modified = json.loads(raw)
|
||||
|
||||
if "nodes" not in modified or "edges" not in modified:
|
||||
raise ValueError("L'IA n'a pas retourné un graph_json valide (nodes/edges manquants)")
|
||||
|
||||
return {"graph_json": modified, "template_name": t["name"]}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[causal_lab] ai_modify_template {template_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@@ -3,7 +3,7 @@ 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,
|
||||
Plus, Trash2, Save, Copy, Info, Wand2, ExternalLink, Filter, X,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -50,7 +50,7 @@ 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
|
||||
heuristic_ver: number; created_by?: string; created_at?: string
|
||||
}
|
||||
interface MarketEvent {
|
||||
id: number; name: string; category: string; sub_type: string
|
||||
@@ -120,8 +120,38 @@ function fmtLag(min: number): string {
|
||||
const fmtDecay = (d: number) => `↩${d}j`
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique',
|
||||
commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment',
|
||||
// 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> = {
|
||||
@@ -366,10 +396,21 @@ function GraphLegend() {
|
||||
|
||||
// ── Tab: Bibliothèque ─────────────────────────────────────────────────────────
|
||||
|
||||
function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }) {
|
||||
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)
|
||||
@@ -384,14 +425,14 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true); setApiError(null)
|
||||
api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
|
||||
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))
|
||||
}, [catFilter])
|
||||
}, [])
|
||||
|
||||
// Auto-select template when arriving from a deep-link (?template=ID)
|
||||
useEffect(() => {
|
||||
@@ -485,17 +526,117 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
const cats = [...new Set(templates.map(t => t.category))]
|
||||
// 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 gap-4">
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<select value={catFilter} onChange={e => setCatFilter(e.target.value)}
|
||||
className="w-full mb-3 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300">
|
||||
<option value="">Toutes les catégories</option>
|
||||
{cats.map(c => <option key={c} value={c}>{CAT_LABELS[c] ?? c}</option>)}
|
||||
</select>
|
||||
<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}
|
||||
@@ -510,33 +651,52 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
||||
|
||||
{loading
|
||||
? <div className="text-slate-500 text-xs p-4">Chargement…</div>
|
||||
: <div className="space-y-1 overflow-y-auto max-h-[calc(100vh-230px)]">
|
||||
{templates.map(t => (
|
||||
: <>
|
||||
<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-2">
|
||||
<span className="px-1 rounded bg-slate-700/50">{CAT_LABELS[t.category] ?? t.category}</span>
|
||||
<span>{t.instruments.join(', ')}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div className="flex-1 overflow-y-auto space-y-4 min-w-0 max-h-[calc(100vh-230px)]">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -555,7 +715,15 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
||||
{selected.ai_rationale}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2 flex-wrap">
|
||||
<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>
|
||||
))}
|
||||
@@ -728,6 +896,7 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
||||
Sélectionnez un template dans la liste
|
||||
</div>
|
||||
)}
|
||||
</div>{/* end flex list+detail */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -750,7 +919,7 @@ const BLANK: EditorState = {
|
||||
|
||||
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
||||
|
||||
function TabEditor() {
|
||||
function TabEditor({ initialId }: { initialId?: number | null }) {
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [state, setState] = useState<EditorState>(BLANK)
|
||||
const [selNode, setSelNode] = useState<string | null>(null)
|
||||
@@ -763,6 +932,9 @@ function TabEditor() {
|
||||
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 [aiPrompt, setAiPrompt] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiError, setAiError] = useState<string | null>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -770,9 +942,13 @@ function TabEditor() {
|
||||
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function loadTemplate(id: number) {
|
||||
const t = templates.find(x => x.id === id)
|
||||
if (!t) return
|
||||
// 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,
|
||||
@@ -784,6 +960,36 @@ function TabEditor() {
|
||||
setSelNode(null)
|
||||
}
|
||||
|
||||
function loadTemplate(id: number) {
|
||||
const t = templates.find(x => x.id === id)
|
||||
if (t) applyTemplateToState(t)
|
||||
}
|
||||
|
||||
async function applyAiModify() {
|
||||
if (!aiPrompt.trim()) return
|
||||
setAiLoading(true); setAiError(null)
|
||||
try {
|
||||
const r = await api(`/api/causal-lab/template/${state.templateId ?? 0}/ai-modify`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: aiPrompt, current_graph: graphJson }),
|
||||
})
|
||||
const gj: GraphJson = r.graph_json
|
||||
setState(s => ({
|
||||
...s,
|
||||
nodes: gj.nodes || s.nodes,
|
||||
edges: gj.edges || s.edges,
|
||||
coefficients: gj.coefficients || s.coefficients,
|
||||
inputMapping: gj.input_mapping || s.inputMapping,
|
||||
instruments: gj.instruments || s.instruments,
|
||||
}))
|
||||
setMsg({ ok: true, text: 'Graphe modifié par l\'IA — vérifiez puis sauvegardez.' })
|
||||
} catch (e) {
|
||||
setAiError(String(e))
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetNew() { setState(BLANK); setSelNode(null) }
|
||||
|
||||
const graphJson: GraphJson = {
|
||||
@@ -1438,6 +1644,45 @@ function TabEditor() {
|
||||
/>
|
||||
</div>
|
||||
<GraphLegend />
|
||||
|
||||
{/* AI Modification prompt */}
|
||||
<div className="bg-dark-700 rounded-lg p-4 border border-violet-900/40">
|
||||
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||||
<Wand2 className="w-3.5 h-3.5 text-violet-400" /> Modifier via IA
|
||||
</h4>
|
||||
{!state.templateId && (
|
||||
<p className="text-xs text-slate-500 mb-2 italic">
|
||||
Sauvegardez d'abord le template pour activer la modification IA.
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={aiPrompt}
|
||||
onChange={e => setAiPrompt(e.target.value)}
|
||||
disabled={aiLoading}
|
||||
placeholder={
|
||||
"Décrivez les modifications souhaitées...\n" +
|
||||
"Ex: Ajoute un nœud latent 'risk_aversion' entre Fed OIS et EUR/USD, avec un lien négatif et lag 2j."
|
||||
}
|
||||
rows={5}
|
||||
className="w-full bg-dark-800 border border-slate-600 rounded px-3 py-2 text-xs text-slate-200 placeholder:text-slate-600 resize-none focus:border-violet-600 focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
onClick={applyAiModify}
|
||||
disabled={aiLoading || !aiPrompt.trim() || !state.templateId}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded text-xs font-medium text-white transition-all">
|
||||
{aiLoading
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Génération…</>
|
||||
: <><Wand2 className="w-3.5 h-3.5" /> Appliquer</>}
|
||||
</button>
|
||||
{aiLoading && <span className="text-xs text-violet-400 animate-pulse">GPT-4o réécrit le graphe…</span>}
|
||||
</div>
|
||||
{aiError && (
|
||||
<div className="mt-2 p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">
|
||||
{aiError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1826,6 +2071,12 @@ export default function CausalLab() {
|
||||
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">
|
||||
@@ -1851,8 +2102,8 @@ export default function CausalLab() {
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
|
||||
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} />}
|
||||
{tab === 'editor' && <TabEditor />}
|
||||
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} onOpenEditor={openInEditor} />}
|
||||
{tab === 'editor' && <TabEditor initialId={editorInitId} />}
|
||||
{tab === 'analyze' && <TabAnalyze initialEventId={initialEventId} />}
|
||||
{tab === 'calib' && <TabCalibration />}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user