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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)."""
|
||||
|
||||
@@ -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<SVGSVGElement>) {
|
||||
if (e.target === svgRef.current || (e.target as SVGElement).tagName === 'svg') {
|
||||
onBgClick?.(e)
|
||||
@@ -160,7 +180,7 @@ export function GraphSVG({
|
||||
|
||||
return (
|
||||
<svg ref={svgRef} viewBox={`${minX} ${minY} ${W} ${H}`}
|
||||
style={{ width: '100%', height: compact ? 260 : editorMode ? 500 : 460 }}
|
||||
style={{ width: '100%', height: compact ? 280 : editorMode ? 540 : 480 }}
|
||||
className={clsx('overflow-visible', editorMode && 'cursor-crosshair')}
|
||||
onClick={handleSvgClick}>
|
||||
<defs>
|
||||
@@ -172,12 +192,14 @@ export function GraphSVG({
|
||||
</defs>
|
||||
|
||||
{/* Background grid in editor mode */}
|
||||
{editorMode && (
|
||||
<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>
|
||||
)}
|
||||
{editorMode && <rect x={minX} y={minY} width={W} height={H} fill="url(#grid)" />}
|
||||
{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) => {
|
||||
@@ -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 (
|
||||
<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} />
|
||||
{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 (
|
||||
<text x={mx + 6} y={my} fill={color} fontSize={8} opacity={0.8}>{e.label}</text>
|
||||
)
|
||||
})()}
|
||||
{lpos && (
|
||||
<g>
|
||||
{/* Dark bg pill behind label */}
|
||||
<rect x={lpos.x - 2} y={lpos.y - 9} width={labelW + 4} height={11}
|
||||
rx={3} fill="#0c1220" opacity={0.82} />
|
||||
<text x={lpos.x} y={lpos.y} fill={color} fontSize={8.5} fontWeight="500"
|
||||
letterSpacing="0.2">{labelText}</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
@@ -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 (
|
||||
<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 + 6} height={NH + 6} x={-3} y={-3} rx={8}
|
||||
fill="none" stroke="#facc15" strokeWidth={2} opacity={0.8} />
|
||||
<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={6}
|
||||
fill={col} fillOpacity={isSel ? 0.35 : 0.18}
|
||||
stroke={col} strokeWidth={isSel ? 2.5 : 1.5} />
|
||||
<text x={NW / 2} y={compact ? 11 : 14} textAnchor="middle"
|
||||
fill="#e2e8f0" fontSize={compact ? 9 : 10} fontWeight="600">
|
||||
{n.label.length > 18 ? n.label.slice(0, 17) + '…' : n.label}
|
||||
<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={compact ? 22 : 27} textAnchor="middle"
|
||||
fill={col} fontSize={compact ? 9 : 10} fontWeight="700">
|
||||
<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 && (
|
||||
<text x={NW / 2} y={NH - 4} textAnchor="middle" fill={col} fontSize={7} opacity={0.7}>
|
||||
{n.type?.replace('_', ' ')}
|
||||
</text>
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
@@ -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 */}
|
||||
<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'}
|
||||
{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')}>
|
||||
|
||||
Reference in New Issue
Block a user