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:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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