From 40d6f149012e9da83921055292bb0f03b7f81dd6 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Tue, 30 Jun 2026 09:04:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20causal=20lab=20=E2=80=94=20chip=20filte?= =?UTF-8?q?rs,=20editor=20zoom,=20AI=20modify=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/causal_lab.py | 87 +++++++++ frontend/src/pages/CausalLab.tsx | 313 ++++++++++++++++++++++++++++--- 2 files changed, 369 insertions(+), 31 deletions(-) diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 099209e..d53f119 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -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)) diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 07ecacd..6a634d2 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -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 - 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 = { - 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 = { + 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 = { @@ -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([]) const [selected, setSelected] = useState