feat: graph patch grammar DSL for causal editor

New grammar panel replaces free-text AI prompt with a two-step workflow:
1. NL to Grammar: GPT-4o converts description to patch ops (new endpoint ai-to-grammar)
2. Apply: frontend parser applies grammar locally without AI

Grammar syntax: (+|-|~)(node|edge|coef|input|instruments) key=val key=val
Sign shortcuts: + positive, - negative, = neutral
Auto-positions new nodes when x/y omitted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-30 09:30:17 +02:00
parent 9a1e943be0
commit f9dddb7184
2 changed files with 368 additions and 41 deletions

View File

@@ -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=<id> label="<text>" type=<macro_event|observable|latent|market_asset> [x=<int>] [y=<int>] [instrument=<str>] [formula=<expr>] [unit=<str>]
~node id=<id> [label="<text>"] [type=...] [x=<int>] [y=<int>] [instrument=<str>]
-node id=<id>
EDGE OPERATIONS (sign: + positive - negative = neutral)
+edge from=<id> to=<id> [sign=+|-|=] [s=1|2|3] [style=solid|dashed] [label="<text>"] [lag=<int>] [decay=<float|null>]
~edge from=<id> to=<id> [sign=...] [s=...] [label="<text>"] [lag=<int>] [decay=<float|null>]
-edge from=<id> to=<id>
COEFFICIENT OPERATIONS
+coef key=<str> value=<float> [desc="<text>"]
~coef key=<str> [value=<float>] [desc="<text>"]
-coef key=<str>
INPUT MAPPING
~input node=<id> source=<surprise|actual_value|user_input|impact_score_scaled> [key=<str>]
-input node=<id>
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):
"""

View File

@@ -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<string, string>
raw: string
}
function parseGrammarArgs(s: string): Record<string, string> {
const res: Record<string, string> = {}
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=<id> label="<texte>" type=<macro_event|observable|latent|market_asset> [x=<n>] [y=<n>] [instrument=<str>]
~node id=<id> [label="..."] [type=...] [x=<n>] [y=<n>] [instrument=<str>]
-node id=<id>
# Arêtes (sign: + positif - négatif = neutre)
+edge from=<id> to=<id> [sign=+|-|=] [s=1|2|3] [style=solid|dashed] [label="..."] [lag=<j>] [decay=<j|null>]
~edge from=<id> to=<id> [...mêmes champs...]
-edge from=<id> to=<id>
# Coefficients
+coef key=<str> value=<float> [desc="..."]
~coef key=<str> [value=<float>] [desc="..."]
-coef key=<str>
# Mapping d'entrée
~input node=<id> source=<surprise|actual_value|user_input|impact_score_scaled> [key=<str>]
-input node=<id>
# 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<DataSources>({ prices: [], macro_series: [], ff_events: [] })
const [aiPrompt, setAiPrompt] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState<string | null>(null)
const [grammarText, setGrammarText] = useState('')
const [grammarErrors, setGrammarErrors] = useState<string[]>([])
const [grammarApplied, setGrammarApplied] = useState(0) // count of applied ops
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState<string | null>(null)
const [showGrammarRef, setShowGrammarRef] = useState(false)
const svgRef = useRef<SVGSVGElement>(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 }) {
</div>
<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>
{/* Grammar patch panel */}
<div className="bg-dark-700 rounded-lg p-4 border border-violet-900/40 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-slate-300 text-xs font-semibold uppercase tracking-wider flex items-center gap-2">
<Wand2 className="w-3.5 h-3.5 text-violet-400" /> Patch Grammaire
</h4>
<button onClick={() => setShowGrammarRef(v => !v)}
className="text-xs text-slate-600 hover:text-slate-400 flex items-center gap-1">
<Info className="w-3 h-3" /> {showGrammarRef ? 'Masquer' : 'Référence'}
</button>
</div>
{showGrammarRef && (
<pre className="text-[10px] font-mono text-slate-500 bg-dark-900/60 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre">
{GRAMMAR_REFERENCE}
</pre>
)}
<textarea
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
value={grammarText}
onChange={e => { setGrammarText(e.target.value); setGrammarErrors([]); setGrammarApplied(0) }}
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."
crivez la grammaire directement, ou décrivez en français puis cliquez « Compiler »\n\n" +
"+node id=risk_aversion label=\"Risk Aversion\" type=latent x=450 y=300\n" +
"+edge from=us_10y to=risk_aversion sign=- lag=2 decay=10\n" +
"~edge from=ois_pricing to=us_2y lag=3\n" +
"-edge from=rate_diff to=eurusd_pip"
}
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"
rows={7}
className="w-full bg-dark-900/70 border border-slate-700 rounded px-3 py-2 text-xs font-mono 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">
<div className="flex items-center gap-2 flex-wrap">
{/* Compile NL → grammar */}
<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">
onClick={compileNlToGrammar}
disabled={aiLoading || !grammarText.trim() || !state.templateId}
title={!state.templateId ? "Sauvegardez d'abord le template" : undefined}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 disabled:opacity-40 rounded text-xs text-slate-300 border border-slate-600 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</>}
? <><RefreshCw className="w-3 h-3 animate-spin" /> Compilation…</>
: <><Brain className="w-3 h-3 text-violet-400" /> NL → Grammaire</>}
</button>
{aiLoading && <span className="text-xs text-violet-400 animate-pulse">GPT-4o réécrit le graphe</span>}
{/* Apply grammar locally */}
<button
onClick={applyGrammar}
disabled={!grammarText.trim() || aiLoading}
className="flex items-center gap-1.5 px-3 py-1.5 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded text-xs font-medium text-white transition-all">
<Zap className="w-3 h-3" /> Appliquer
</button>
{/* Live parse count */}
{grammarText.trim() && (() => {
const { ops } = parseGrammar(grammarText)
return ops.length > 0 ? (
<span className="text-xs text-slate-500">{ops.length} opération{ops.length > 1 ? 's' : ''} détectée{ops.length > 1 ? 's' : ''}</span>
) : null
})()}
{grammarApplied > 0 && (
<span className="text-xs text-emerald-500">✓ {grammarApplied} appliquée{grammarApplied > 1 ? 's' : ''}</span>
)}
</div>
{/* Errors */}
{grammarErrors.length > 0 && (
<div className="p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300 space-y-0.5">
{grammarErrors.map((e, i) => <div key={i}>⚠ {e}</div>)}
</div>
)}
{aiError && (
<div className="mt-2 p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">
<div className="p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">
{aiError}
</div>
)}