diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index d53f119..9407d3c 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -1849,13 +1849,126 @@ def generate_theory(template_id: int): raise HTTPException(500, str(e)) -# ── AI graph modifier ───────────────────────────────────────────────────────── +# ── AI graph modifier & grammar compiler ───────────────────────────────────── class AiModifyRequest(BaseModel): prompt: str current_graph: dict = {} # client can pass current in-memory state +GRAMMAR_SPEC = """\ +Grammar for causal graph patch operations (one operation per line, # = comment): + +NODE OPERATIONS ++node id= label="" type= [x=] [y=] [instrument=] [formula=] [unit=] +~node id= [label=""] [type=...] [x=] [y=] [instrument=] +-node id= + +EDGE OPERATIONS (sign: + positive - negative = neutral) ++edge from= to= [sign=+|-|=] [s=1|2|3] [style=solid|dashed] [label=""] [lag=] [decay=] +~edge from= to= [sign=...] [s=...] [label=""] [lag=] [decay=] +-edge from= to= + +COEFFICIENT OPERATIONS ++coef key= value= [desc=""] +~coef key= [value=] [desc=""] +-coef key= + +INPUT MAPPING +~input node= source= [key=] +-input node= + +GRAPH METADATA +~instruments EURUSD,SP500,... + +Vocabulary: +- macro_event: source event node (NFP surprise, etc.) +- observable: directly measurable market variable (OIS rate, US 2Y yield, spread) +- latent: hidden variable (risk aversion, growth expectations, sentiment) +- market_asset: traded instrument (EUR/USD, S&P 500 — final output nodes) +- s=1 weak / s=2 medium (default) / s=3 strong causal link +- style=dashed for indirect/uncertain channels +- lag: trading days before effect onset; decay: half-life in days (null = permanent shift) +""" + + +class AiToGrammarRequest(BaseModel): + prompt: str + current_graph: dict = {} + + +@router.post("/api/causal-lab/template/{template_id}/ai-to-grammar") +def ai_to_grammar(template_id: int, body: AiToGrammarRequest): + """ + Convert a natural-language modification request into graph patch grammar. + Returns only the grammar lines — user reviews before applying. + """ + 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é") + + gj = body.current_graph if body.current_graph.get("nodes") else t["graph_json"] + + # Compact node/edge summary for context + node_summary = "\n".join( + f" {n['id']} ({n['type']}) — {n['label']}" + for n in gj.get("nodes", []) + ) + edge_summary = "\n".join( + f" {e['from']} → {e['to']} sign={e.get('sign','?')} lag={e.get('lag_days','?')} [{e.get('label','')}]" + for e in gj.get("edges", []) + ) + + system_prompt = ( + "You are a causal graph patch generator for the GeoOptions macro-finance platform.\n" + "Convert the user's natural language modification request into graph patch operations.\n" + "Return ONLY the grammar lines — no explanation, no markdown, no fences.\n\n" + + GRAMMAR_SPEC + ) + + user_prompt = ( + f'Current graph nodes:\n{node_summary}\n\n' + f'Current graph edges:\n{edge_summary}\n\n' + f'Modification request:\n{body.prompt}\n\n' + "Return ONLY the grammar patch lines, one per line." + ) + + 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}, + ], + temperature=0.15, + max_tokens=800, + ) + grammar = (resp.choices[0].message.content or "").strip() + # Strip any accidental markdown fences + grammar = "\n".join( + l for l in grammar.splitlines() + if not l.strip().startswith("```") + ) + return {"grammar": grammar} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] ai_to_grammar {template_id}: {e}") + raise HTTPException(500, str(e)) + + @router.post("/api/causal-lab/template/{template_id}/ai-modify") def ai_modify_template(template_id: int, body: AiModifyRequest): """ diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 5235eeb..d70a8fa 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -394,6 +394,172 @@ function GraphLegend() { ) } +// ── Graph Patch Grammar ─────────────────────────────────────────────────────── +// Syntax: (+|-|~)(node|edge|coef|input|instruments) key=val key="val with spaces" +// sign shortcuts: + positive - negative = neutral + +interface GrammarOp { + op: '+' | '-' | '~' + entity: 'node' | 'edge' | 'coef' | 'input' | 'instruments' + args: Record + raw: string +} + +function parseGrammarArgs(s: string): Record { + const res: Record = {} + const re = /(\w+)=(?:"([^"]*)"|([\S]*))/g + let m: RegExpExecArray | null + while ((m = re.exec(s)) !== null) res[m[1]] = m[2] !== undefined ? m[2] : m[3] + return res +} + +function parseGrammar(text: string): { ops: GrammarOp[]; errors: string[] } { + const ops: GrammarOp[] = []; const errors: string[] = [] + for (const rawLine of text.split('\n')) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + const m = line.match(/^([+\-~])(node|edge|coef|input|instruments)\s*(.*)/i) + if (!m) { errors.push(`Ligne non reconnue: "${line}"`); continue } + const entity = m[2].toLowerCase() as GrammarOp['entity'] + const args = entity === 'instruments' ? { list: m[3].trim() } : parseGrammarArgs(m[3]) + ops.push({ op: m[1] as '+' | '-' | '~', entity, args, raw: line }) + } + return { ops, errors } +} + +function resolveSign(s?: string): 'positive' | 'negative' | 'neutral' { + if (!s) return 'neutral' + if (s === '+' || s === 'positive') return 'positive' + if (s === '-' || s === 'negative') return 'negative' + return 'neutral' +} + +function applyGrammarOps(state: EditorState, ops: GrammarOp[]): { state: EditorState; errors: string[] } { + let s: EditorState = { + ...state, + nodes: [...state.nodes], edges: [...state.edges], + coefficients: { ...state.coefficients }, inputMapping: { ...state.inputMapping }, + instruments: [...state.instruments], + } + const errors: string[] = [] + + for (const op of ops) { + try { + if (op.entity === 'node') { + const { id, label, type, x, y, formula, unit, instrument } = op.args + if (op.op === '+') { + if (!id) { errors.push('+node: id manquant'); continue } + if (s.nodes.find(n => n.id === id)) { errors.push(`+node: "${id}" existe déjà`); continue } + const autoY = s.nodes.length ? Math.max(...s.nodes.map(n => n.y)) + 120 : 200 + s.nodes = [...s.nodes, { + id, label: label ?? id, + type: (type ?? 'observable') as CausalNode['type'], + x: x ? +x : 300, y: y ? +y : autoY, + ...(formula ? { formula } : {}), ...(unit ? { unit } : {}), ...(instrument ? { instrument } : {}), + }] + } else if (op.op === '-') { + const tid = id ?? Object.values(op.args)[0] + if (!tid) { errors.push('-node: id manquant'); continue } + s.nodes = s.nodes.filter(n => n.id !== tid) + s.edges = s.edges.filter(e => e.from !== tid && e.to !== tid) + const im = { ...s.inputMapping }; delete im[tid]; s.inputMapping = im + } else { + if (!id) { errors.push('~node: id manquant'); continue } + s.nodes = s.nodes.map(n => n.id !== id ? n : { + ...n, + ...(label !== undefined ? { label } : {}), + ...(type !== undefined ? { type: type as CausalNode['type'] } : {}), + ...(x !== undefined ? { x: +x } : {}), + ...(y !== undefined ? { y: +y } : {}), + ...(formula !== undefined ? { formula } : {}), + ...(unit !== undefined ? { unit } : {}), + ...(instrument !== undefined ? { instrument } : {}), + }) + } + } else if (op.entity === 'edge') { + const { from, to, sign, s: str, style, label, lag, decay } = op.args + if (op.op === '+') { + if (!from || !to) { errors.push('+edge: from/to manquants'); continue } + s.edges = [...s.edges, { + from, to, sign: resolveSign(sign), + strength: (str ? +str : 2) as 1|2|3, + style: (style ?? 'solid') as 'solid'|'dashed', + ...(label ? { label } : {}), + ...(lag !== undefined ? { lag_days: +lag } : {}), + ...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}), + }] + } else if (op.op === '-') { + if (!from || !to) { errors.push('-edge: from/to manquants'); continue } + s.edges = s.edges.filter(e => !(e.from === from && e.to === to)) + } else { + if (!from || !to) { errors.push('~edge: from/to manquants'); continue } + s.edges = s.edges.map(e => (e.from !== from || e.to !== to) ? e : { + ...e, + ...(sign !== undefined ? { sign: resolveSign(sign) } : {}), + ...(str !== undefined ? { strength: +str as 1|2|3 } : {}), + ...(style !== undefined ? { style: style as 'solid'|'dashed' } : {}), + ...(label !== undefined ? { label } : {}), + ...(lag !== undefined ? { lag_days: +lag } : {}), + ...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}), + }) + } + } else if (op.entity === 'coef') { + const { key, value, desc } = op.args + if (op.op === '+') { + if (!key || value === undefined) { errors.push('+coef: key/value manquants'); continue } + s.coefficients = { ...s.coefficients, [key]: { value: +value, calibrated: null, description: desc ?? '' } } + } else if (op.op === '-') { + if (!key) { errors.push('-coef: key manquant'); continue } + const cc = { ...s.coefficients }; delete cc[key]; s.coefficients = cc + } else { + if (!key) { errors.push('~coef: key manquant'); continue } + const ex = s.coefficients[key] ?? { value: 0, calibrated: null, description: '' } + s.coefficients = { ...s.coefficients, [key]: { + ...ex, + ...(value !== undefined ? { value: +value } : {}), + ...(desc !== undefined ? { description: desc } : {}), + }} + } + } else if (op.entity === 'input') { + const { node, source, key } = op.args + if (op.op === '-') { + if (!node) { errors.push('-input: node manquant'); continue } + const im = { ...s.inputMapping }; delete im[node]; s.inputMapping = im + } else { + if (!node || !source) { errors.push(`${op.op}input: node/source manquants`); continue } + s.inputMapping = { ...s.inputMapping, [node]: { source: source as InputMapping['source'], ...(key ? { key } : {}) } } + } + } else if (op.entity === 'instruments') { + const list = op.args.list?.split(',').map(x => x.trim()).filter(Boolean) + if (list?.length) s.instruments = list + } + } catch (e) { errors.push(`Erreur "${op.raw}": ${e}`) } + } + return { state: s, errors } +} + +const GRAMMAR_REFERENCE = `# Nœuds ++node id= label="" type= [x=] [y=] [instrument=] +~node id= [label="..."] [type=...] [x=] [y=] [instrument=] +-node id= + +# Arêtes (sign: + positif - négatif = neutre) ++edge from= to= [sign=+|-|=] [s=1|2|3] [style=solid|dashed] [label="..."] [lag=] [decay=] +~edge from= to= [...mêmes champs...] +-edge from= to= + +# Coefficients ++coef key= value= [desc="..."] +~coef key= [value=] [desc="..."] +-coef key= + +# Mapping d'entrée +~input node= source= [key=] +-input node= + +# Instruments de sortie +~instruments EURUSD,SP500,...` + // ── Tab: Bibliothèque ───────────────────────────────────────────────────────── function TabLibrary({ @@ -932,9 +1098,12 @@ function TabEditor({ initialId }: { initialId?: number | null }) { const [saving, setSaving] = useState(false) const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null) const [dataSources, setDS] = useState({ prices: [], macro_series: [], ff_events: [] }) - const [aiPrompt, setAiPrompt] = useState('') - const [aiLoading, setAiLoading] = useState(false) - const [aiError, setAiError] = useState(null) + const [grammarText, setGrammarText] = useState('') + const [grammarErrors, setGrammarErrors] = useState([]) + const [grammarApplied, setGrammarApplied] = useState(0) // count of applied ops + const [aiLoading, setAiLoading] = useState(false) + const [aiError, setAiError] = useState(null) + const [showGrammarRef, setShowGrammarRef] = useState(false) const svgRef = useRef(null) useEffect(() => { @@ -965,24 +1134,18 @@ function TabEditor({ initialId }: { initialId?: number | null }) { if (t) applyTemplateToState(t) } - async function applyAiModify() { - if (!aiPrompt.trim()) return + // Compile natural language → grammar (replaces textarea content) + async function compileNlToGrammar() { + if (!grammarText.trim()) return setAiLoading(true); setAiError(null) try { - const r = await api(`/api/causal-lab/template/${state.templateId ?? 0}/ai-modify`, { + const r = await api(`/api/causal-lab/template/${state.templateId ?? 0}/ai-to-grammar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: aiPrompt, current_graph: graphJson }), + body: JSON.stringify({ prompt: grammarText, 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.' }) + setGrammarText(r.grammar) + setGrammarErrors([]) + setGrammarApplied(0) } catch (e) { setAiError(String(e)) } finally { @@ -990,6 +1153,17 @@ function TabEditor({ initialId }: { initialId?: number | null }) { } } + // Apply grammar patch directly to editor state (no AI) + function applyGrammar() { + const { ops, errors: parseErrors } = parseGrammar(grammarText) + if (parseErrors.length && ops.length === 0) { setGrammarErrors(parseErrors); return } + const { state: newState, errors: applyErrors } = applyGrammarOps(state, ops) + setGrammarErrors([...parseErrors, ...applyErrors]) + setState(newState) + setGrammarApplied(ops.length - applyErrors.length) + if (ops.length > 0) setMsg({ ok: true, text: `${ops.length - applyErrors.length}/${ops.length} opérations appliquées.` }) + } + function resetNew() { setState(BLANK); setSelNode(null) } const graphJson: GraphJson = { @@ -1645,40 +1819,80 @@ function TabEditor({ initialId }: { initialId?: number | null }) { - {/* AI Modification prompt */} -
-

- Modifier via IA -

- {!state.templateId && ( -

- Sauvegardez d'abord le template pour activer la modification IA. -

+ {/* Grammar patch panel */} +
+
+

+ Patch Grammaire +

+ +
+ + {showGrammarRef && ( +
+              {GRAMMAR_REFERENCE}
+            
)} +