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:
|
except Exception as e:
|
||||||
logger.error(f"[causal_lab] generate_theory {template_id}: {e}")
|
logger.error(f"[causal_lab] generate_theory {template_id}: {e}")
|
||||||
raise HTTPException(500, str(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 {
|
import {
|
||||||
FlaskConical, Library, Zap, BarChart3, RefreshCw, Edit3,
|
FlaskConical, Library, Zap, BarChart3, RefreshCw, Edit3,
|
||||||
Sliders, Brain, Search, CheckCircle, XCircle, Minus, AlertCircle,
|
Sliders, Brain, Search, CheckCircle, XCircle, Minus, AlertCircle,
|
||||||
Plus, Trash2, Save, Copy, Info,
|
Plus, Trash2, Save, Copy, Info, Wand2, ExternalLink, Filter, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ interface Template {
|
|||||||
id: number; name: string; category: string; sub_type: string
|
id: number; name: string; category: string; sub_type: string
|
||||||
instruments: string[]; description: string; graph_json: GraphJson
|
instruments: string[]; description: string; graph_json: GraphJson
|
||||||
ai_rationale: string; calibration_json: Record<string, unknown>
|
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 {
|
interface MarketEvent {
|
||||||
id: number; name: string; category: string; sub_type: string
|
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 fmtDecay = (d: number) => `↩${d}j`
|
||||||
|
|
||||||
const CAT_LABELS: Record<string, string> = {
|
const CAT_LABELS: Record<string, string> = {
|
||||||
macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique',
|
// Template categories
|
||||||
commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment',
|
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_TYPES = ['macro_event', 'observable', 'latent', 'market_asset'] as const
|
||||||
const NODE_TYPE_LABELS: Record<string, string> = {
|
const NODE_TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -366,10 +396,21 @@ function GraphLegend() {
|
|||||||
|
|
||||||
// ── Tab: Bibliothèque ─────────────────────────────────────────────────────────
|
// ── 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 [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [selected, setSelected] = useState<Template | null>(null)
|
const [selected, setSelected] = useState<Template | null>(null)
|
||||||
const [catFilter, setCatFilter] = useState('')
|
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 [saving, setSaving] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [savingLag, setSavingLag] = useState(false)
|
const [savingLag, setSavingLag] = useState(false)
|
||||||
@@ -384,14 +425,14 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true); setApiError(null)
|
setLoading(true); setApiError(null)
|
||||||
api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
|
api('/api/causal-lab/templates')
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setTemplates(data)
|
setTemplates(data)
|
||||||
if (data.length === 0) api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {})
|
if (data.length === 0) api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {})
|
||||||
})
|
})
|
||||||
.catch(e => setApiError(String(e)))
|
.catch(e => setApiError(String(e)))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [catFilter])
|
}, [])
|
||||||
|
|
||||||
// Auto-select template when arriving from a deep-link (?template=ID)
|
// Auto-select template when arriving from a deep-link (?template=ID)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -485,17 +526,117 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
|||||||
} finally { setDeleting(false) }
|
} 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 (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="w-72 flex-shrink-0">
|
{/* ── Filter bar ── */}
|
||||||
<select value={catFilter} onChange={e => setCatFilter(e.target.value)}
|
<div className="bg-dark-700/60 rounded-lg border border-slate-700/30 p-3 space-y-2">
|
||||||
className="w-full mb-3 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300">
|
{/* Category chips */}
|
||||||
<option value="">Toutes les catégories</option>
|
<div className="flex flex-wrap gap-1.5 items-center">
|
||||||
{cats.map(c => <option key={c} value={c}>{CAT_LABELS[c] ?? c}</option>)}
|
<Filter className="w-3 h-3 text-slate-500 shrink-0" />
|
||||||
</select>
|
<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 && (
|
{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="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 className="font-bold mb-1">Erreur API</div>{apiError}
|
||||||
@@ -510,33 +651,52 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
|||||||
|
|
||||||
{loading
|
{loading
|
||||||
? <div className="text-slate-500 text-xs p-4">Chargement…</div>
|
? <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)}
|
<button key={t.id} onClick={() => selectTemplate(t)}
|
||||||
className={clsx('w-full text-left px-3 py-2.5 rounded border text-xs transition-all',
|
className={clsx('w-full text-left px-3 py-2.5 rounded border text-xs transition-all',
|
||||||
selected?.id === t.id
|
selected?.id === t.id
|
||||||
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
|
? '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')}>
|
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
|
||||||
<div className="font-medium truncate">{t.name}</div>
|
<div className="font-medium truncate">{t.name}</div>
|
||||||
<div className="text-slate-500 mt-0.5 flex items-center gap-2">
|
<div className="text-slate-500 mt-0.5 flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="px-1 rounded bg-slate-700/50">{CAT_LABELS[t.category] ?? t.category}</span>
|
<span className="px-1.5 py-px rounded-sm text-[10px]"
|
||||||
<span>{t.instruments.join(', ')}</span>
|
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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected ? (
|
{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="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<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>
|
<h3 className="text-white font-semibold">{selected.name}</h3>
|
||||||
<p className="text-slate-400 text-xs mt-1">{selected.description}</p>
|
<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>
|
||||||
<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">
|
<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}
|
v{selected.heuristic_ver}
|
||||||
</span>
|
</span>
|
||||||
@@ -555,7 +715,15 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
|||||||
{selected.ai_rationale}
|
{selected.ai_rationale}
|
||||||
</div>
|
</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 => (
|
{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>
|
<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
|
Sélectionnez un template dans la liste
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>{/* end flex list+detail */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -750,7 +919,7 @@ const BLANK: EditorState = {
|
|||||||
|
|
||||||
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
||||||
|
|
||||||
function TabEditor() {
|
function TabEditor({ initialId }: { initialId?: number | null }) {
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [state, setState] = useState<EditorState>(BLANK)
|
const [state, setState] = useState<EditorState>(BLANK)
|
||||||
const [selNode, setSelNode] = useState<string | null>(null)
|
const [selNode, setSelNode] = useState<string | null>(null)
|
||||||
@@ -763,6 +932,9 @@ function TabEditor() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||||
const [dataSources, setDS] = useState<DataSources>({ prices: [], macro_series: [], ff_events: [] })
|
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)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -770,9 +942,13 @@ function TabEditor() {
|
|||||||
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
|
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function loadTemplate(id: number) {
|
// Load template when arriving from Library "Éditer" button
|
||||||
const t = templates.find(x => x.id === id)
|
useEffect(() => {
|
||||||
if (!t) return
|
if (!initialId) return
|
||||||
|
api(`/api/causal-lab/template/${initialId}`).then(applyTemplateToState).catch(() => {})
|
||||||
|
}, [initialId])
|
||||||
|
|
||||||
|
function applyTemplateToState(t: Template) {
|
||||||
setState({
|
setState({
|
||||||
templateId: t.id, name: t.name, category: t.category, subType: t.sub_type,
|
templateId: t.id, name: t.name, category: t.category, subType: t.sub_type,
|
||||||
description: t.description, instruments: t.instruments, aiRationale: t.ai_rationale,
|
description: t.description, instruments: t.instruments, aiRationale: t.ai_rationale,
|
||||||
@@ -784,6 +960,36 @@ function TabEditor() {
|
|||||||
setSelNode(null)
|
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) }
|
function resetNew() { setState(BLANK); setSelNode(null) }
|
||||||
|
|
||||||
const graphJson: GraphJson = {
|
const graphJson: GraphJson = {
|
||||||
@@ -1438,6 +1644,45 @@ function TabEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<GraphLegend />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1826,6 +2071,12 @@ export default function CausalLab() {
|
|||||||
const initialTemplateId = searchParams.get('template') ? parseInt(searchParams.get('template')!) : null
|
const initialTemplateId = searchParams.get('template') ? parseInt(searchParams.get('template')!) : null
|
||||||
const initialEventId = searchParams.get('event') ? parseInt(searchParams.get('event')!) : null
|
const initialEventId = searchParams.get('event') ? parseInt(searchParams.get('event')!) : null
|
||||||
const [tab, setTab] = useState(initialEventId ? 'analyze' : 'library')
|
const [tab, setTab] = useState(initialEventId ? 'analyze' : 'library')
|
||||||
|
const [editorInitId, setEditorInitId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
function openInEditor(id: number) {
|
||||||
|
setEditorInitId(id)
|
||||||
|
setTab('editor')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 min-h-screen">
|
<div className="p-6 min-h-screen">
|
||||||
@@ -1851,8 +2102,8 @@ export default function CausalLab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
|
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
|
||||||
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} />}
|
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} onOpenEditor={openInEditor} />}
|
||||||
{tab === 'editor' && <TabEditor />}
|
{tab === 'editor' && <TabEditor initialId={editorInitId} />}
|
||||||
{tab === 'analyze' && <TabAnalyze initialEventId={initialEventId} />}
|
{tab === 'analyze' && <TabAnalyze initialEventId={initialEventId} />}
|
||||||
{tab === 'calib' && <TabCalibration />}
|
{tab === 'calib' && <TabCalibration />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user