From dd31f92aaf91ef0ab5a20f78513fb43d2f305f03 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 28 Jun 2026 10:02:28 +0200 Subject: [PATCH] feat: causal lab - bouton Mettre a jour + PATCH endpoint - TabEditor: updateExisting() + bouton bleu visible si templateId set - Bouton Creer renomme en copie pour clarifier - PATCH /api/causal-lab/template/{id} pour graph_json + metadonnees Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/causal_lab.py | 35 +++++++++ frontend/src/pages/CausalLab.tsx | 129 +++++++++++++++++++++++-------- 2 files changed, 133 insertions(+), 31 deletions(-) diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index fbc14bf..a3f1280 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -402,6 +402,41 @@ def create_template(body: CreateTemplateRequest): raise HTTPException(500, str(e)) +@router.patch("/api/causal-lab/template/{template_id}") +def patch_template(template_id: int, body: dict): + """Met à jour un template existant en entier (graph_json, métadonnées, coefficients).""" + try: + from services.database import get_conn + from services.causal_graphs import get_template + conn = get_conn() + tmpl = get_template(conn, template_id) + if not tmpl: + conn.close(); raise HTTPException(404, "Template introuvable") + + sets, params = [], [] + for field in ("name", "category", "sub_type", "description", "ai_rationale"): + if field in body: + sets.append(f"{field}=?"); params.append(body[field]) + if "instruments" in body: + sets.append("instruments=?"); params.append(json.dumps(body["instruments"])) + if "graph_json" in body: + sets.append("graph_json=?"); params.append(json.dumps(body["graph_json"])) + + if not sets: + conn.close(); return {"ok": True} + + sets.append("updated_at=datetime('now')") + params.append(template_id) + conn.execute(f"UPDATE causal_graph_templates SET {', '.join(sets)} WHERE id=?", params) + conn.commit(); conn.close() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] patch_template {template_id}: {e}") + raise HTTPException(500, str(e)) + + @router.delete("/api/causal-lab/template/{template_id}") def delete_template(template_id: int): """Supprime un template utilisateur (les templates system sont protégés).""" diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 56d387d..21a6f15 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -133,7 +133,9 @@ export function GraphSVG({ const W = maxX - minX; const H = maxY - minY const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])) - const NW = compact ? 88 : 112; const NH = compact ? 28 : 38 + // Nodes larger in editor mode to accommodate type badge + const NW = compact ? 92 : editorMode ? 130 : 120 + const NH = compact ? 32 : editorMode ? 50 : 42 const statusColor = (s: string) => s === 'correct' ? '#10b981' : s === 'wrong' ? '#ef4444' : '#64748b' @@ -152,6 +154,24 @@ export function GraphSVG({ return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}` } + // Label position: 1/3 of the way from source (avoids node centers) + function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null { + const s = nodeMap[e.from]; const t = nodeMap[e.to] + if (!s || !t) return null + // Use 30% along the path to stay near source and avoid crowded midpoints + const t0 = 0.3 + 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 + // Cubic bezier at t: C(x1,my), C(x2,my) + 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 + // Offset perpendicular to avoid sitting on the line + const dx = x2 - x1; const dy = y2 - y1 + const len = Math.sqrt(dx*dx + dy*dy) || 1 + return { x: bx + (-dy/len)*10, y: by + (dx/len)*10 } + } + function handleSvgClick(e: React.MouseEvent) { if (e.target === svgRef.current || (e.target as SVGElement).tagName === 'svg') { onBgClick?.(e) @@ -160,7 +180,7 @@ export function GraphSVG({ return ( @@ -172,12 +192,14 @@ export function GraphSVG({ {/* Background grid in editor mode */} - {editorMode && ( - - - - )} - {editorMode && } + {editorMode && <> + + + + + + + } {/* Edges */} {edges.map((e, i) => { @@ -186,20 +208,24 @@ export function GraphSVG({ 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 return ( - {e.label && (() => { - const s2 = nodeMap[e.from]; const t2 = nodeMap[e.to] - if (!s2 || !t2) return null - const mx = (s2.x + t2.x) / 2; const my = (s2.y + t2.y) / 2 - return ( - {e.label} - ) - })()} + {lpos && ( + + {/* Dark bg pill behind label */} + + {labelText} + + )} ) })} @@ -207,33 +233,47 @@ export function GraphSVG({ {/* Nodes */} {nodes.map(n => { const col = getNodeColor(n) - const val = nodeValues?.[n.id] + const val = nodeValues?.[n.id] const isSel = selectedNodeId === n.id + const maxLen = editorMode ? 20 : 19 + const lFS = compact ? 9 : 11 + // vertical layout inside node + const labelY = compact ? 12 : editorMode ? 15 : 14 + const valY = compact ? 22 : 30 + const typeY = NH - 7 // type badge near bottom return ( { e.stopPropagation(); onNodeClick?.(n.id, e) }} style={{ cursor: editorMode ? 'pointer' : 'default' }}> {isSel && ( - + )} - - - {n.label.length > 18 ? n.label.slice(0, 17) + '…' : n.label} + + {/* Label */} + + {n.label.length > maxLen ? n.label.slice(0, maxLen - 1) + '…' : n.label} + {/* Value (analyze mode) */} {val != null && ( - + {val > 0 ? '+' : ''}{val.toFixed(1)}{n.unit ? ` ${n.unit}` : ''} )} + {/* Type badge (editor only) — inside node at bottom */} {editorMode && ( - - {n.type?.replace('_', ' ')} - + <> + + + {n.type?.replace(/_/g, ' ')} + + )} ) @@ -573,6 +613,26 @@ function TabEditor() { } 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, input_mapping: {} }, + 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) return ( @@ -866,10 +926,17 @@ function TabEditor() { {/* Sauvegarde */}
+ {state.templateId && ( + + )} {msg && (