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