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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user