whitelove7255 commited on
Commit
76b7608
·
verified ·
1 Parent(s): 0f2ff69

feat: uploaded the first version of excali-draw

Browse files
Files changed (6) hide show
  1. app.py +218 -0
  2. convert_dsl_to_excalidraw.py +679 -0
  3. dsl_schema.json +269 -0
  4. packages.txt +1 -0
  5. requirements.txt +2 -0
  6. validate_dsl.py +757 -0
app.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio front-end for the Excalidraw-DSL pipeline (the HF Space).
3
+
4
+ Flow per request:
5
+ prompt --> Modal GPU endpoint --> DSL string
6
+ --> strip code fences --> validate_dsl (one retry if invalid)
7
+ --> Converter --> Excalidraw scene JSON
8
+ --> rendered live in an embedded (read-only) Excalidraw canvas
9
+ --> downloadable .excalidraw file
10
+
11
+ The GPU work (generation) is remote on Modal; everything here is free CPU work. The validator
12
+ and converter are the SAME modules used in training/eval — imported, not reimplemented.
13
+
14
+ Files this Space needs (upload alongside this one):
15
+ app.py requirements.txt packages.txt
16
+ convert_dsl_to_excalidraw.py validate_dsl.py dsl_schema.json
17
+
18
+ Space env vars (Settings -> Variables and secrets):
19
+ MODEL_ENDPOINT the deployed Modal URL (…-model-generate.modal.run) [required]
20
+ MODEL_ENDPOINT_KEY Modal-Key — only if you enabled proxy auth [optional]
21
+ MODEL_ENDPOINT_SECRET Modal-Secret— only if you enabled proxy auth [optional]
22
+ """
23
+
24
+ import html
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+ import tempfile
30
+ import pathlib
31
+
32
+ import requests
33
+ import gradio as gr
34
+
35
+ # Import the canonical validator + converter. They sit next to this file on the Space; one dir
36
+ # up in the local repo. Add both so it works in either layout.
37
+ ROOT = pathlib.Path(__file__).resolve().parent
38
+ for p in (ROOT, ROOT.parent):
39
+ if str(p) not in sys.path:
40
+ sys.path.insert(0, str(p))
41
+
42
+ from validate_dsl import load_schema, validate_dsl_text # noqa: E402
43
+ from convert_dsl_to_excalidraw import Converter # noqa: E402
44
+
45
+ SCHEMA = load_schema()
46
+ ENDPOINT = os.environ.get("MODEL_ENDPOINT", "").strip()
47
+ ENDPOINT_KEY = os.environ.get("MODEL_ENDPOINT_KEY", "").strip()
48
+ ENDPOINT_SECRET = os.environ.get("MODEL_ENDPOINT_SECRET", "").strip()
49
+
50
+ EXCALIDRAW_VERSION = "0.17.6" # UMD build pinned for the embedded read-only canvas
51
+
52
+
53
+ # --------------------------------------------------------------------------- model call
54
+ def call_model(prompt: str, temperature: float = 0.0) -> str:
55
+ if not ENDPOINT:
56
+ raise RuntimeError(
57
+ "MODEL_ENDPOINT is not set. Deploy serve/modal_infer.py and put its URL in the "
58
+ "Space's environment variables."
59
+ )
60
+ headers = {"content-type": "application/json"}
61
+ if ENDPOINT_KEY and ENDPOINT_SECRET: # only when proxy auth is enabled
62
+ headers["Modal-Key"] = ENDPOINT_KEY
63
+ headers["Modal-Secret"] = ENDPOINT_SECRET
64
+ r = requests.post(
65
+ ENDPOINT,
66
+ headers=headers,
67
+ json={"prompt": prompt, "temperature": temperature},
68
+ timeout=180, # generous: covers a cold start of the scaled-to-zero container
69
+ )
70
+ r.raise_for_status()
71
+ body = r.json()
72
+ if "error" in body:
73
+ raise RuntimeError(f"model endpoint error: {body['error']}")
74
+ return body["dsl"]
75
+
76
+
77
+ # --------------------------------------------------------------------------- DSL cleanup
78
+ _FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
79
+
80
+
81
+ def strip_fences(text: str) -> str:
82
+ """Models occasionally wrap output in ```json fences despite the system prompt."""
83
+ text = _FENCE.sub("", text.strip())
84
+ # Keep only the outermost JSON object if there is trailing prose.
85
+ start, end = text.find("{"), text.rfind("}")
86
+ if start != -1 and end != -1 and end > start:
87
+ text = text[start:end + 1]
88
+ return text.strip()
89
+
90
+
91
+ def get_valid_dsl(prompt: str):
92
+ """Returns (dsl_obj, dsl_text, status_md). Retries once with sampling if the first
93
+ (greedy) attempt fails validation."""
94
+ attempts = [("greedy", 0.0), ("retry (sampled)", 0.4)]
95
+ last_err = ""
96
+ for label, temp in attempts:
97
+ raw = call_model(prompt, temperature=temp)
98
+ text = strip_fences(raw)
99
+ res = validate_dsl_text(text, SCHEMA)
100
+ if res.ok:
101
+ warn = (f" · {len(res.warnings)} warning(s)" if res.warnings else "")
102
+ return json.loads(text), text, f"✅ valid DSL ({label}){warn}"
103
+ last_err = ", ".join(c for c, _ in res.errors) or "unparseable"
104
+ # Both attempts failed — surface the raw text so the user can see what happened.
105
+ return None, text, f"❌ DSL failed validation: {last_err}"
106
+
107
+
108
+ # --------------------------------------------------------------------------- render
109
+ def render_scene_html(scene: dict) -> str:
110
+ """An <iframe> hosting a read-only Excalidraw canvas initialised with `scene`."""
111
+ scene_json = json.dumps(scene)
112
+ doc = f"""<!DOCTYPE html>
113
+ <html>
114
+ <head>
115
+ <meta charset="utf-8"/>
116
+ <style>html,body,#root{{margin:0;padding:0;height:100%;width:100%}}</style>
117
+ <script>window.EXCALIDRAW_ASSET_PATH="https://unpkg.com/@excalidraw/excalidraw@{EXCALIDRAW_VERSION}/dist/";</script>
118
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
119
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
120
+ <script crossorigin src="https://unpkg.com/@excalidraw/excalidraw@{EXCALIDRAW_VERSION}/dist/excalidraw.production.min.js"></script>
121
+ </head>
122
+ <body>
123
+ <div id="root"></div>
124
+ <script>
125
+ const scene = {scene_json};
126
+ const App = () => React.createElement(ExcalidrawLib.Excalidraw, {{
127
+ initialData: {{
128
+ elements: scene.elements || [],
129
+ appState: Object.assign({{}}, scene.appState, {{viewModeEnabled: true}}),
130
+ files: scene.files || {{}},
131
+ scrollToContent: true,
132
+ }},
133
+ viewModeEnabled: true,
134
+ UIOptions: {{canvasActions: {{export: false, saveToActiveFile: false}}}},
135
+ }});
136
+ ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(App));
137
+ </script>
138
+ </body>
139
+ </html>"""
140
+ srcdoc = html.escape(doc, quote=True)
141
+ return (
142
+ f'<iframe title="excalidraw preview" '
143
+ f'style="width:100%;height:600px;border:1px solid #ddd;border-radius:8px" '
144
+ f'srcdoc="{srcdoc}"></iframe>'
145
+ )
146
+
147
+
148
+ def write_excalidraw_file(scene: dict) -> str:
149
+ tmp = tempfile.NamedTemporaryFile(
150
+ mode="w", suffix=".excalidraw", delete=False, encoding="utf-8"
151
+ )
152
+ json.dump(scene, tmp, ensure_ascii=False, indent=2)
153
+ tmp.close()
154
+ return tmp.name
155
+
156
+
157
+ # --------------------------------------------------------------------------- main handler
158
+ EMPTY_PREVIEW = '<div style="height:600px;display:flex;align-items:center;justify-content:center;color:#999;border:1px dashed #ccc;border-radius:8px">Your diagram preview will appear here</div>'
159
+
160
+
161
+ def generate(prompt: str):
162
+ prompt = (prompt or "").strip()
163
+ if not prompt:
164
+ return EMPTY_PREVIEW, "", None, "Enter a prompt to start."
165
+ try:
166
+ obj, dsl_text, status = get_valid_dsl(prompt)
167
+ except Exception as e: # noqa: BLE001 — show the user what broke
168
+ return EMPTY_PREVIEW, "", None, f"❌ {type(e).__name__}: {e}"
169
+
170
+ if obj is None:
171
+ return EMPTY_PREVIEW, dsl_text, None, status
172
+
173
+ try:
174
+ scene = Converter(obj).build()
175
+ except Exception as e: # noqa: BLE001
176
+ return EMPTY_PREVIEW, dsl_text, None, f"{status}, but converter failed: {e}"
177
+
178
+ html_preview = render_scene_html(scene)
179
+ file_path = write_excalidraw_file(scene)
180
+ return html_preview, dsl_text, file_path, f"{status} · {len(scene.get('elements', []))} elements"
181
+
182
+
183
+ # --------------------------------------------------------------------------- UI
184
+ EXAMPLES = [
185
+ "Left-to-right architecture for a food delivery app: customer app, API gateway, order service, payment service, Redis cache, Postgres, delivery worker.",
186
+ "Flowchart for a user login flow with validation and a retry on failure.",
187
+ "ER diagram for a blog: users, posts, comments, tags.",
188
+ "Sequence diagram for a checkout: client, API, payment gateway, database.",
189
+ "Data pipeline: ingest events from Kafka, transform in Spark, load into a warehouse, dashboard on top.",
190
+ ]
191
+
192
+ with gr.Blocks(title="Excalidraw DSL Generator") as demo:
193
+ gr.Markdown(
194
+ "# 🖍️ Excalidraw Diagram Generator\n"
195
+ "Describe a diagram → fine-tuned model emits compact DSL → deterministic converter "
196
+ "produces a real `.excalidraw` file. Preview is live and editable after download."
197
+ )
198
+ with gr.Row():
199
+ with gr.Column(scale=2):
200
+ prompt = gr.Textbox(
201
+ label="Describe your diagram",
202
+ placeholder="e.g. architecture for a URL shortener with cache and worker",
203
+ lines=3,
204
+ )
205
+ go = gr.Button("Generate", variant="primary")
206
+ status = gr.Markdown("")
207
+ gr.Examples(examples=EXAMPLES, inputs=prompt)
208
+ with gr.Column(scale=3):
209
+ preview = gr.HTML(EMPTY_PREVIEW)
210
+ download = gr.DownloadButton("⬇️ Download .excalidraw", interactive=True)
211
+ with gr.Accordion("Generated DSL (JSON)", open=False):
212
+ dsl_view = gr.Code(language="json", label="DSL")
213
+
214
+ go.click(generate, inputs=prompt, outputs=[preview, dsl_view, download, status])
215
+ prompt.submit(generate, inputs=prompt, outputs=[preview, dsl_view, download, status])
216
+
217
+ if __name__ == "__main__":
218
+ demo.launch()
convert_dsl_to_excalidraw.py ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ convert_dsl_to_excalidraw.py
4
+
5
+ Deterministic converter: Diagram DSL (see dsl.schema.json / DSL_SPEC.md) -> .excalidraw JSON.
6
+
7
+ Design:
8
+ - Graph family (system_architecture, flowchart, data_pipeline, er_diagram) is laid out by
9
+ Graphviz `dot` (called as a subprocess with -Tjson). Only dependency: the `dot` binary.
10
+ - sequence_diagram, timeline, mind_map, mobile_wireframe use hand-rolled layouts.
11
+ - A shared emit layer builds valid Excalidraw elements (shapes, bound text, bound arrows),
12
+ handles z-ordering, and self-validates the output (no dangling refs, overlap warnings).
13
+
14
+ The DSL carries ONLY semantic decisions. Everything geometric/stylistic is computed here.
15
+
16
+ Usage:
17
+ python3 convert_dsl_to_excalidraw.py input.dsl.json -o output.excalidraw
18
+ cat input.dsl.json | python3 convert_dsl_to_excalidraw.py - > output.excalidraw
19
+ """
20
+
21
+ import argparse
22
+ import hashlib
23
+ import json
24
+ import math
25
+ import subprocess
26
+ import sys
27
+
28
+ # --------------------------------------------------------------------------------------
29
+ # Constants
30
+ # --------------------------------------------------------------------------------------
31
+
32
+ FONT = 20
33
+ TITLE_FONT = 28
34
+ FONT_FAMILY = 1 # 1 = Excalidraw hand-drawn (Virgil)
35
+ LINE_HEIGHT = 1.25
36
+ CHAR_W = 0.60 # avg glyph width as a fraction of font size (approx for Virgil)
37
+ PAD_X = 22
38
+ PAD_Y = 16
39
+ NODE_MIN_W = 120
40
+ NODE_MAX_W = 320
41
+ NODE_MIN_H = 56
42
+ INK = "#1e1e1e"
43
+ TRANSPARENT = "transparent"
44
+ WHITE = "#ffffff"
45
+
46
+ # role -> (shape, fill, stroke, rounded, dashed)
47
+ # shape in {"rectangle","ellipse","diamond"}. Mirrors the table in DSL_SPEC.md.
48
+ ROLE_STYLE = {
49
+ "actor": ("ellipse", "#e9ecef", "#343a40", False, False),
50
+ "client": ("rectangle", "#a5d8ff", "#1971c2", False, False),
51
+ "service": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
52
+ "gateway": ("diamond", "#d0bfff", "#7048e8", False, False),
53
+ "database": ("rectangle", "#a5d8ff", "#1971c2", False, False),
54
+ "cache": ("rectangle", "#ffd8a8", "#e8590c", True, False),
55
+ "queue": ("rectangle", "#ffec99", "#f08c00", False, False),
56
+ "worker": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
57
+ "storage": ("rectangle", "#e9ecef", "#495057", False, False),
58
+ "external": ("rectangle", "#f1f3f5", "#868e96", False, True),
59
+ "monitoring": ("rectangle", "#ffec99", "#f08c00", False, False),
60
+ "auth": ("rectangle", "#d0bfff", "#7048e8", False, False),
61
+ "start": ("ellipse", "#b2f2bb", "#2f9e44", False, False),
62
+ "end": ("ellipse", "#ffec99", "#e8590c", False, False),
63
+ "process": ("rectangle", "#a5d8ff", "#1971c2", True, False),
64
+ "decision": ("diamond", "#ffec99", "#f08c00", False, False),
65
+ "io": ("rectangle", "#e9ecef", "#495057", False, False),
66
+ "source": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
67
+ "transform": ("rectangle", "#a5d8ff", "#1971c2", True, False),
68
+ "sink": ("rectangle", "#d0bfff", "#7048e8", True, False),
69
+ "entity": ("rectangle", WHITE, "#1e1e1e", False, False),
70
+ "participant": ("rectangle", "#a5d8ff", "#1971c2", False, False),
71
+ "milestone": ("ellipse", "#a5d8ff", "#1971c2", False, False),
72
+ "root": ("rectangle", "#d0bfff", "#7048e8", True, False),
73
+ "branch": ("rectangle", "#a5d8ff", "#1971c2", True, False),
74
+ "leaf": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
75
+ "screen": ("rectangle", TRANSPARENT, "#343a40", True, False),
76
+ "ui_element": ("rectangle", "#e9ecef", "#495057", False, False),
77
+ "component": ("rectangle", "#e9ecef", "#495057", True, False),
78
+ "generic": ("rectangle", "#e9ecef", "#495057", False, False),
79
+ }
80
+
81
+ COLOR_OVERRIDE = {
82
+ "red": ("#ffc9c9", "#e03131"),
83
+ "orange": ("#ffd8a8", "#e8590c"),
84
+ "yellow": ("#ffec99", "#f08c00"),
85
+ "green": ("#b2f2bb", "#2f9e44"),
86
+ "blue": ("#a5d8ff", "#1971c2"),
87
+ "violet": ("#d0bfff", "#7048e8"),
88
+ "gray": ("#e9ecef", "#495057"),
89
+ "black": ("#e9ecef", "#1e1e1e"),
90
+ }
91
+
92
+ STROKE_STYLE = {"solid": "solid", "dashed": "dashed", "dotted": "dotted"}
93
+
94
+ DEFAULT_DIRECTION = {
95
+ "system_architecture": "LR",
96
+ "flowchart": "TB",
97
+ "data_pipeline": "LR",
98
+ "er_diagram": "LR",
99
+ "sequence_diagram": "LR",
100
+ "timeline": "LR",
101
+ "mind_map": "radial",
102
+ "mobile_wireframe": "TB",
103
+ }
104
+
105
+ GRAPH_FAMILY = {"system_architecture", "flowchart", "data_pipeline", "er_diagram"}
106
+
107
+ # --------------------------------------------------------------------------------------
108
+ # Text measurement / sizing
109
+ # --------------------------------------------------------------------------------------
110
+
111
+ def text_dims(text, font=FONT):
112
+ lines = str(text).split("\n")
113
+ w = max((len(l) for l in lines), default=1) * font * CHAR_W
114
+ h = len(lines) * font * LINE_HEIGHT
115
+ return max(w, font * CHAR_W), max(h, font * LINE_HEIGHT)
116
+
117
+
118
+ def node_size(label):
119
+ tw, th = text_dims(label, FONT)
120
+ w = min(max(tw + 2 * PAD_X, NODE_MIN_W), NODE_MAX_W)
121
+ h = max(th + 2 * PAD_Y, NODE_MIN_H)
122
+ return w, h
123
+
124
+
125
+ # --------------------------------------------------------------------------------------
126
+ # Converter
127
+ # --------------------------------------------------------------------------------------
128
+
129
+ class Converter:
130
+ def __init__(self, dsl, seed=None):
131
+ self.dsl = dsl
132
+ self.diagram = dsl["diagram"]
133
+ self.title = dsl["title"]
134
+ self.direction = dsl.get("direction") or DEFAULT_DIRECTION[self.diagram]
135
+ self.nodes = dsl.get("nodes", [])
136
+ self.edges = dsl.get("edges", [])
137
+ self.groups = dsl.get("groups", [])
138
+ self.node_by_id = {n["id"]: n for n in self.nodes}
139
+
140
+ if seed is None:
141
+ h = hashlib.md5((self.diagram + "|" + self.title).encode()).hexdigest()
142
+ seed = int(h[:8], 16)
143
+ self._seed = seed
144
+ self._rng_state = seed & 0x7FFFFFFF
145
+
146
+ self.elements = [] # accumulates element dicts (with temporary "_z")
147
+ self.box_elem = {} # node_id -> shape element dict (to append boundElements)
148
+ self.boxes = {} # node_id -> (x, y, w, h)
149
+ self._idc = 0
150
+
151
+ # ---- deterministic helpers -------------------------------------------------------
152
+
153
+ def _rand(self):
154
+ # simple LCG so output is reproducible without Date.now()/random module state
155
+ self._rng_state = (self._rng_state * 1103515245 + 12345) & 0x7FFFFFFF
156
+ return self._rng_state
157
+
158
+ def _new_id(self, prefix):
159
+ self._idc += 1
160
+ return f"{prefix}_{self._idc}"
161
+
162
+ def _base(self, typ, x, y, w, h, z):
163
+ return {
164
+ "id": None, "type": typ,
165
+ "x": round(x, 2), "y": round(y, 2),
166
+ "width": round(w, 2), "height": round(h, 2),
167
+ "angle": 0,
168
+ "strokeColor": INK, "backgroundColor": TRANSPARENT,
169
+ "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
170
+ "roughness": 1, "opacity": 100,
171
+ "groupIds": [], "frameId": None, "roundness": None,
172
+ "seed": self._rand(), "version": 1, "versionNonce": self._rand(),
173
+ "isDeleted": False, "boundElements": [], "updated": 1,
174
+ "link": None, "locked": False,
175
+ "_z": z,
176
+ }
177
+
178
+ def _add(self, el):
179
+ self.elements.append(el)
180
+ return el
181
+
182
+ # ---- element builders ------------------------------------------------------------
183
+
184
+ def add_box(self, node_id, x, y, w, h, role, color_override=None, z=2):
185
+ shape, fill, stroke, rounded, dashed = ROLE_STYLE.get(role, ROLE_STYLE["generic"])
186
+ if color_override and color_override in COLOR_OVERRIDE:
187
+ fill, stroke = COLOR_OVERRIDE[color_override]
188
+ el = self._base(shape, x, y, w, h, z)
189
+ el["id"] = self._new_id("sh")
190
+ el["backgroundColor"] = fill
191
+ el["strokeColor"] = stroke
192
+ if rounded and shape == "rectangle":
193
+ el["roundness"] = {"type": 3}
194
+ if dashed:
195
+ el["strokeStyle"] = "dashed"
196
+ self._add(el)
197
+ if node_id is not None:
198
+ self.box_elem[node_id] = el
199
+ self.boxes[node_id] = (x, y, w, h)
200
+ return el
201
+
202
+ def add_label(self, container_el, text, align="center", valign="middle",
203
+ font=FONT, color=INK, z=3, cx=None, cy=None):
204
+ tw, th = text_dims(text, font)
205
+ bx, by = container_el["x"], container_el["y"]
206
+ bw, bh = container_el["width"], container_el["height"]
207
+ if cx is None:
208
+ if align == "left":
209
+ tx = bx + PAD_X
210
+ else:
211
+ tx = bx + (bw - tw) / 2
212
+ else:
213
+ tx = cx - tw / 2
214
+ if cy is None:
215
+ if valign == "top":
216
+ ty = by + PAD_Y
217
+ else:
218
+ ty = by + (bh - th) / 2
219
+ else:
220
+ ty = cy - th / 2
221
+ el = self._base("text", tx, ty, tw, th, z)
222
+ el["id"] = self._new_id("tx")
223
+ el["strokeColor"] = color
224
+ el.update({
225
+ "fontSize": font, "fontFamily": FONT_FAMILY,
226
+ "text": str(text), "originalText": str(text),
227
+ "textAlign": align, "verticalAlign": valign,
228
+ "containerId": container_el["id"], "lineHeight": LINE_HEIGHT,
229
+ "autoResize": True,
230
+ })
231
+ container_el["boundElements"].append({"type": "text", "id": el["id"]})
232
+ self._add(el)
233
+ return el
234
+
235
+ def add_free_text(self, text, cx, top, font=FONT, color=INK, z=4):
236
+ tw, th = text_dims(text, font)
237
+ el = self._base("text", cx - tw / 2, top, tw, th, z)
238
+ el["id"] = self._new_id("tx")
239
+ el["strokeColor"] = color
240
+ el.update({
241
+ "fontSize": font, "fontFamily": FONT_FAMILY,
242
+ "text": str(text), "originalText": str(text),
243
+ "textAlign": "center", "verticalAlign": "top",
244
+ "containerId": None, "lineHeight": LINE_HEIGHT, "autoResize": True,
245
+ })
246
+ self._add(el)
247
+ return el
248
+
249
+ def _linear(self, typ, pts, style="solid", arrowhead=True,
250
+ stroke=INK, src_id=None, dst_id=None, z=1):
251
+ x0, y0 = pts[0]
252
+ rel = [[round(px - x0, 2), round(py - y0, 2)] for px, py in pts]
253
+ xs = [p[0] for p in rel]
254
+ ys = [p[1] for p in rel]
255
+ el = self._base(typ, x0, y0, max(xs) - min(xs), max(ys) - min(ys), z)
256
+ el["id"] = self._new_id("ln" if typ == "line" else "ar")
257
+ el["strokeColor"] = stroke
258
+ el["strokeStyle"] = STROKE_STYLE.get(style, "solid")
259
+ el["roundness"] = {"type": 2}
260
+ el["points"] = rel
261
+ el["lastCommittedPoint"] = None
262
+ el["startArrowhead"] = None
263
+ el["endArrowhead"] = "arrow" if (arrowhead and typ == "arrow") else None
264
+ el["startBinding"] = None
265
+ el["endBinding"] = None
266
+ if src_id is not None and src_id in self.box_elem:
267
+ el["startBinding"] = {"elementId": self.box_elem[src_id]["id"], "focus": 0, "gap": 4}
268
+ self.box_elem[src_id]["boundElements"].append({"type": "arrow", "id": el["id"]})
269
+ if dst_id is not None and dst_id in self.box_elem:
270
+ el["endBinding"] = {"elementId": self.box_elem[dst_id]["id"], "focus": 0, "gap": 4}
271
+ self.box_elem[dst_id]["boundElements"].append({"type": "arrow", "id": el["id"]})
272
+ self._add(el)
273
+ return el
274
+
275
+ def add_arrow(self, pts, label=None, style="solid", src_id=None, dst_id=None,
276
+ arrowhead=True, stroke=INK):
277
+ el = self._linear("arrow", pts, style, arrowhead, stroke, src_id, dst_id)
278
+ if label:
279
+ mid = pts[len(pts) // 2]
280
+ self.add_label(el, label, cx=mid[0], cy=mid[1], font=16)
281
+ return el
282
+
283
+ def add_line(self, pts, style="solid", stroke=INK):
284
+ return self._linear("line", pts, style, arrowhead=False, stroke=stroke)
285
+
286
+ def add_frame(self, x, y, w, h, label=None, stroke="#868e96"):
287
+ el = self._base("rectangle", x, y, w, h, z=0)
288
+ el["id"] = self._new_id("fr")
289
+ el["strokeColor"] = stroke
290
+ el["strokeStyle"] = "dashed"
291
+ el["roundness"] = {"type": 3}
292
+ self._add(el)
293
+ if label:
294
+ self.add_free_text(label, x + 70, y + 8, font=16, color=stroke, z=1)
295
+ return el
296
+
297
+ # ---- geometry --------------------------------------------------------------------
298
+
299
+ @staticmethod
300
+ def _clip(box, toward):
301
+ """Point on box border in the direction of `toward` (center of another box)."""
302
+ x, y, w, h = box
303
+ cx, cy = x + w / 2, y + h / 2
304
+ dx, dy = toward[0] - cx, toward[1] - cy
305
+ if dx == 0 and dy == 0:
306
+ return (cx, cy)
307
+ hw, hh = w / 2, h / 2
308
+ scale = 1.0 / max(abs(dx) / hw if hw else 1e9, abs(dy) / hh if hh else 1e9)
309
+ return (cx + dx * scale, cy + dy * scale)
310
+
311
+ def _center(self, node_id):
312
+ x, y, w, h = self.boxes[node_id]
313
+ return (x + w / 2, y + h / 2)
314
+
315
+ # ----------------------------------------------------------------------------------
316
+ # Graphviz-backed layout (graph family)
317
+ # ----------------------------------------------------------------------------------
318
+
319
+ def build_graph_family(self):
320
+ OFFX, OFFY = 80, 120
321
+ rankdir = self.direction if self.direction in ("LR", "RL", "TB", "BT") else "TB"
322
+
323
+ # precompute node render sizes
324
+ sizes = {}
325
+ labels = {}
326
+ for n in self.nodes:
327
+ if self.diagram == "er_diagram":
328
+ label = self._er_label(n)
329
+ else:
330
+ label = n["label"]
331
+ labels[n["id"]] = label
332
+ sizes[n["id"]] = node_size(label) if self.diagram != "er_diagram" \
333
+ else self._er_size(label)
334
+
335
+ # build dot source
336
+ src = ["digraph G {",
337
+ f' graph [rankdir={rankdir}, nodesep=0.5, ranksep=0.9, splines=true];',
338
+ ' node [shape=box, fixedsize=true];']
339
+ group_members = {g["id"]: [] for g in self.groups}
340
+ for n in self.nodes:
341
+ if n.get("group") in group_members:
342
+ group_members[n["group"]].append(n["id"])
343
+ grouped = set()
344
+ for g in self.groups:
345
+ members = group_members.get(g["id"], [])
346
+ if not members:
347
+ continue
348
+ src.append(f' subgraph cluster_{g["id"]} {{')
349
+ src.append(f' label="{self._esc(g["label"])}"; style=dashed; color="#868e96";')
350
+ for nid in members:
351
+ w, h = sizes[nid]
352
+ src.append(f' "{nid}" [width={w/72:.3f}, height={h/72:.3f}, label=""];')
353
+ grouped.add(nid)
354
+ src.append(" }")
355
+ for n in self.nodes:
356
+ if n["id"] in grouped:
357
+ continue
358
+ w, h = sizes[n["id"]]
359
+ src.append(f' "{n["id"]}" [width={w/72:.3f}, height={h/72:.3f}, label=""];')
360
+ for e in self.edges:
361
+ src.append(f' "{e["from"]}" -> "{e["to"]}";')
362
+ src.append("}")
363
+ dot_src = "\n".join(src)
364
+
365
+ data = self._run_dot(dot_src)
366
+ bb = [float(v) for v in data["bb"].split(",")]
367
+ H = bb[3]
368
+
369
+ def flip(x, y):
370
+ return (x + OFFX, (H - y) + OFFY)
371
+
372
+ # nodes + clusters
373
+ gvid_name = {}
374
+ for o in data.get("objects", []):
375
+ name = o.get("name", "")
376
+ gvid_name[o.get("_gvid")] = name
377
+ if name.startswith("cluster_") and "bb" in o:
378
+ x0, y0, x1, y1 = [float(v) for v in o["bb"].split(",")]
379
+ fx0, fy1 = flip(x0, y0)
380
+ fx1, fy0 = flip(x1, y1)
381
+ glabel = o.get("label") or ""
382
+ self.add_frame(min(fx0, fx1), min(fy0, fy1),
383
+ abs(fx1 - fx0), abs(fy1 - fy0), label=glabel)
384
+ elif name in self.node_by_id and "pos" in o:
385
+ px, py = [float(v) for v in o["pos"].split(",")]
386
+ cx, cy = flip(px, py)
387
+ w, h = sizes[name]
388
+ node = self.node_by_id[name]
389
+ box = self.add_box(name, cx - w / 2, cy - h / 2, w, h,
390
+ node["role"], node.get("color"))
391
+ if self.diagram == "er_diagram":
392
+ self.add_label(box, labels[name], align="left", valign="top")
393
+ else:
394
+ self.add_label(box, labels[name])
395
+
396
+ # edges (match output edges back to DSL edges for label/style)
397
+ pending = {}
398
+ for e in self.edges:
399
+ pending.setdefault((e["from"], e["to"]), []).append(e)
400
+ for oe in data.get("edges", []):
401
+ frm = gvid_name.get(oe.get("tail"))
402
+ to = gvid_name.get(oe.get("head"))
403
+ attrs = {}
404
+ q = pending.get((frm, to))
405
+ if q:
406
+ attrs = q.pop(0)
407
+ pts = self._spline(oe.get("pos", ""), flip)
408
+ if len(pts) < 2:
409
+ pts = [self._center(frm), self._center(to)]
410
+ label = attrs.get("label")
411
+ if self.diagram == "er_diagram" and attrs.get("meta", {}).get("cardinality"):
412
+ label = attrs["meta"]["cardinality"] + (f" {label}" if label else "")
413
+ self.add_arrow(pts, label=label, style=attrs.get("style", "solid"),
414
+ src_id=frm, dst_id=to)
415
+
416
+ @staticmethod
417
+ def _spline(pos, flip):
418
+ start = end = None
419
+ mids = []
420
+ for tok in pos.split():
421
+ if tok.startswith("e,"):
422
+ x, y = tok[2:].split(",")
423
+ end = flip(float(x), float(y))
424
+ elif tok.startswith("s,"):
425
+ x, y = tok[2:].split(",")
426
+ start = flip(float(x), float(y))
427
+ elif "," in tok:
428
+ x, y = tok.split(",")
429
+ mids.append(flip(float(x), float(y)))
430
+ poly = []
431
+ if start:
432
+ poly.append(start)
433
+ poly.extend(mids)
434
+ if end:
435
+ poly.append(end)
436
+ return poly
437
+
438
+ def _er_label(self, node):
439
+ lines = [node["label"]]
440
+ for f in node.get("meta", {}).get("fields", []):
441
+ key = f.get("key", "none")
442
+ tag = {"pk": "🔑 ", "fk": "↗ "}.get(key, "")
443
+ t = f.get("type", "")
444
+ lines.append(f"{tag}{f['name']}" + (f": {t}" if t else ""))
445
+ return "\n".join(lines)
446
+
447
+ @staticmethod
448
+ def _er_size(label):
449
+ tw, th = text_dims(label, FONT)
450
+ return min(max(tw + 2 * PAD_X, 160), 360), th + 2 * PAD_Y
451
+
452
+ @staticmethod
453
+ def _esc(s):
454
+ return str(s).replace("\\", "\\\\").replace('"', '\\"')
455
+
456
+ @staticmethod
457
+ def _run_dot(src):
458
+ try:
459
+ p = subprocess.run(["dot", "-Tjson"], input=src.encode(),
460
+ capture_output=True, timeout=30)
461
+ except FileNotFoundError:
462
+ raise SystemExit("ERROR: graphviz `dot` not found. Install graphviz.")
463
+ if p.returncode != 0:
464
+ raise SystemExit("ERROR: dot failed:\n" + p.stderr.decode())
465
+ return json.loads(p.stdout.decode())
466
+
467
+ # ----------------------------------------------------------------------------------
468
+ # Hand-rolled layouts
469
+ # ----------------------------------------------------------------------------------
470
+
471
+ def build_sequence(self):
472
+ MX, TOP = 100, 100
473
+ gap = max((node_size(n["label"])[0] for n in self.nodes), default=160) + 80
474
+ part_x = {}
475
+ box_h = 50
476
+ for i, n in enumerate(self.nodes):
477
+ w, _ = node_size(n["label"])
478
+ cx = MX + i * gap
479
+ part_x[n["id"]] = cx
480
+ box = self.add_box(n["id"], cx - w / 2, TOP, w, box_h, n["role"], n.get("color"))
481
+ self.add_label(box, n["label"])
482
+ n_msgs = max(len(self.edges), 1)
483
+ life_bottom = TOP + box_h + 60 + n_msgs * 55 + 40
484
+ for nid, cx in part_x.items():
485
+ self.add_line([(cx, TOP + box_h), (cx, life_bottom)], style="dashed", stroke="#adb5bd")
486
+ y = TOP + box_h + 60
487
+ for e in self.edges:
488
+ x1, x2 = part_x[e["from"]], part_x[e["to"]]
489
+ kind = e.get("meta", {}).get("kind", "sync")
490
+ style = "dashed" if kind in ("async", "return") else "solid"
491
+ self.add_arrow([(x1, y), (x2, y)], label=e.get("label"), style=style)
492
+ y += 55
493
+
494
+ def build_timeline(self):
495
+ nodes = sorted(self.nodes, key=lambda n: str(n.get("meta", {}).get("date", "")))
496
+ MX, AXIS = 120, 320
497
+ gap = max((node_size(n["label"])[0] for n in nodes), default=160) + 60
498
+ xs = [MX + i * gap for i in range(len(nodes))]
499
+ if xs:
500
+ self.add_arrow([(MX - 40, AXIS), (xs[-1] + 60, AXIS)], stroke="#495057")
501
+ for i, n in enumerate(nodes):
502
+ cx = xs[i]
503
+ marker = self.add_box(n["id"], cx - 9, AXIS - 9, 18, 18, "milestone", n.get("color"))
504
+ above = (i % 2 == 0)
505
+ w, h = node_size(n["label"])
506
+ by = AXIS - 70 - h if above else AXIS + 70
507
+ box = self.add_box(None, cx - w / 2, by, w, h, "generic", n.get("color"))
508
+ self.add_label(box, n["label"])
509
+ date = n.get("meta", {}).get("date", "")
510
+ if date:
511
+ self.add_free_text(str(date), cx, AXIS + (-95 if not above else 75), font=14,
512
+ color="#868e96")
513
+ connector_y = (by + h, AXIS) if above else (by, AXIS)
514
+ self.add_line([(cx, connector_y[0]), (cx, connector_y[1])], stroke="#adb5bd")
515
+
516
+ def build_mind_map(self):
517
+ roots = [n for n in self.nodes if n["role"] == "root"] or self.nodes[:1]
518
+ root = roots[0]
519
+ adj = {n["id"]: [] for n in self.nodes}
520
+ for e in self.edges:
521
+ adj[e["from"]].append(e["to"])
522
+ adj[e["to"]].append(e["from"])
523
+ CX, CY = 700, 450
524
+ placed = {root["id"]: (CX, CY)}
525
+ order = [(root["id"], 0.0, 2 * math.pi, 0)]
526
+ visited = {root["id"]}
527
+ edges_to_draw = []
528
+ while order:
529
+ nid, a0, a1, depth = order.pop(0)
530
+ children = [c for c in adj[nid] if c not in visited]
531
+ if not children:
532
+ continue
533
+ span = (a1 - a0) / len(children)
534
+ r = (depth + 1) * 230
535
+ for i, c in enumerate(children):
536
+ visited.add(c)
537
+ ang = a0 + span * (i + 0.5)
538
+ cx, cy = CX + r * math.cos(ang), CY + r * math.sin(ang)
539
+ placed[c] = (cx, cy)
540
+ edges_to_draw.append((nid, c))
541
+ order.append((c, a0 + span * i, a0 + span * (i + 1), depth + 1))
542
+ for n in self.nodes:
543
+ cx, cy = placed.get(n["id"], (CX, CY))
544
+ w, h = node_size(n["label"])
545
+ role = "root" if n["id"] == root["id"] else n["role"]
546
+ box = self.add_box(n["id"], cx - w / 2, cy - h / 2, w, h, role, n.get("color"))
547
+ self.add_label(box, n["label"])
548
+ for frm, to in edges_to_draw:
549
+ p1 = self._clip(self.boxes[frm], self._center(to))
550
+ p2 = self._clip(self.boxes[to], self._center(frm))
551
+ self.add_arrow([p1, p2], arrowhead=False, src_id=frm, dst_id=to, stroke="#868e96")
552
+
553
+ def build_wireframe(self):
554
+ screens = self.groups or [{"id": "__all__", "label": self.dsl.get("title", "Screen")}]
555
+ members = {g["id"]: [] for g in screens}
556
+ for n in self.nodes:
557
+ g = n.get("group", "__all__")
558
+ members.setdefault(g, []).append(n)
559
+ FW, FH, MX, TOP, GAP = 300, 600, 100, 120, 60
560
+ kind_h = {"header": 56, "nav": 48, "tab": 44, "footer": 56, "button": 50,
561
+ "input": 50, "text": 40, "image": 140, "list": 200, "card": 120}
562
+ for si, sc in enumerate(screens):
563
+ fx = MX + si * (FW + GAP)
564
+ self.add_frame(fx, TOP, FW, FH, label=sc["label"], stroke="#343a40")
565
+ y = TOP + 20
566
+ for n in members.get(sc["id"], []):
567
+ kind = n.get("meta", {}).get("kind", "text")
568
+ h = kind_h.get(kind, 50)
569
+ box = self.add_box(n["id"], fx + 16, y, FW - 32, h, "ui_element", n.get("color"))
570
+ self.add_label(box, n["label"])
571
+ y += h + 14
572
+
573
+ # ----------------------------------------------------------------------------------
574
+ # Finalize
575
+ # ----------------------------------------------------------------------------------
576
+
577
+ def _content_bbox(self):
578
+ xs0, ys0, xs1, ys1 = [], [], [], []
579
+ for el in self.elements:
580
+ xs0.append(el["x"]); ys0.append(el["y"])
581
+ xs1.append(el["x"] + el["width"]); ys1.append(el["y"] + el["height"])
582
+ if not xs0:
583
+ return (0, 0, 200, 200)
584
+ return (min(xs0), min(ys0), max(xs1), max(ys1))
585
+
586
+ def build(self):
587
+ if self.diagram in GRAPH_FAMILY:
588
+ self.build_graph_family()
589
+ elif self.diagram == "sequence_diagram":
590
+ self.build_sequence()
591
+ elif self.diagram == "timeline":
592
+ self.build_timeline()
593
+ elif self.diagram == "mind_map":
594
+ self.build_mind_map()
595
+ elif self.diagram == "mobile_wireframe":
596
+ self.build_wireframe()
597
+ else:
598
+ raise SystemExit(f"Unsupported diagram type: {self.diagram}")
599
+
600
+ x0, y0, x1, _ = self._content_bbox()
601
+ self.add_free_text(self.title, (x0 + x1) / 2, y0 - 60, font=TITLE_FONT)
602
+
603
+ self._assign_z_and_index()
604
+ self._self_validate()
605
+ return {
606
+ "type": "excalidraw",
607
+ "version": 2,
608
+ "source": "convert_dsl_to_excalidraw.py",
609
+ "elements": [self._strip(e) for e in self.elements],
610
+ "appState": {"viewBackgroundColor": WHITE, "gridSize": None},
611
+ "files": {},
612
+ }
613
+
614
+ def _assign_z_and_index(self):
615
+ # stable sort by z layer, then assign fractional index in that order
616
+ order = sorted(range(len(self.elements)), key=lambda i: (self.elements[i]["_z"], i))
617
+ self.elements = [self.elements[i] for i in order]
618
+ for i, el in enumerate(self.elements):
619
+ el["index"] = f"a{i:07d}"
620
+
621
+ @staticmethod
622
+ def _strip(el):
623
+ el = dict(el)
624
+ el.pop("_z", None)
625
+ return el
626
+
627
+ def _self_validate(self):
628
+ ids = {el["id"] for el in self.elements}
629
+ warns = []
630
+ for el in self.elements:
631
+ for be in el.get("boundElements", []):
632
+ if be["id"] not in ids:
633
+ warns.append(f"dangling boundElement {be['id']} on {el['id']}")
634
+ if el.get("containerId") and el["containerId"] not in ids:
635
+ warns.append(f"dangling containerId {el['containerId']} on {el['id']}")
636
+ for b in ("startBinding", "endBinding"):
637
+ if el.get(b) and el[b]["elementId"] not in ids:
638
+ warns.append(f"dangling {b} on {el['id']}")
639
+ # overlap warning between node boxes
640
+ items = list(self.boxes.items())
641
+ for i in range(len(items)):
642
+ for j in range(i + 1, len(items)):
643
+ a, b = items[i][1], items[j][1]
644
+ ox = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
645
+ oy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
646
+ if ox > 5 and oy > 5:
647
+ warns.append(f"overlap: {items[i][0]} & {items[j][0]}")
648
+ if warns:
649
+ sys.stderr.write("[self-validate] " + str(len(warns)) + " warning(s):\n")
650
+ for w in warns[:20]:
651
+ sys.stderr.write(" - " + w + "\n")
652
+
653
+
654
+ # --------------------------------------------------------------------------------------
655
+ # CLI
656
+ # --------------------------------------------------------------------------------------
657
+
658
+ def main():
659
+ ap = argparse.ArgumentParser(description="Convert Diagram DSL JSON to .excalidraw")
660
+ ap.add_argument("input", help="DSL json file, or '-' for stdin")
661
+ ap.add_argument("-o", "--output", help="output .excalidraw (default: stdout)")
662
+ ap.add_argument("--seed", type=int, default=None, help="override deterministic seed")
663
+ args = ap.parse_args()
664
+
665
+ raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
666
+ dsl = json.loads(raw)
667
+
668
+ out = Converter(dsl, seed=args.seed).build()
669
+ text = json.dumps(out, indent=2, ensure_ascii=False)
670
+ if args.output:
671
+ with open(args.output, "w", encoding="utf-8") as f:
672
+ f.write(text)
673
+ sys.stderr.write(f"wrote {args.output} ({len(out['elements'])} elements)\n")
674
+ else:
675
+ sys.stdout.write(text)
676
+
677
+
678
+ if __name__ == "__main__":
679
+ main()
dsl_schema.json ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://excali-ft/dsl.schema.json",
4
+ "title": "Excalidraw Diagram DSL",
5
+ "description": "Compact, semantic-only description of a diagram. Carries planning decisions (what exists, what connects, what type). All geometry, sizing, IDs, colors, and Excalidraw format fields are computed deterministically by the converter and MUST NOT appear here.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["diagram", "title", "nodes"],
9
+ "properties": {
10
+ "diagram": {
11
+ "description": "Selects the converter strategy and default layout.",
12
+ "type": "string",
13
+ "enum": [
14
+ "system_architecture",
15
+ "flowchart",
16
+ "er_diagram",
17
+ "sequence_diagram",
18
+ "data_pipeline",
19
+ "timeline",
20
+ "mind_map",
21
+ "mobile_wireframe"
22
+ ]
23
+ },
24
+ "title": {
25
+ "description": "Human-readable diagram title, rendered as a heading text element.",
26
+ "type": "string",
27
+ "minLength": 1,
28
+ "maxLength": 80
29
+ },
30
+ "direction": {
31
+ "description": "Primary flow/layout direction. Optional; the converter applies a per-diagram default when omitted (e.g. TB for flowchart, LR for architecture, radial for mind_map).",
32
+ "type": "string",
33
+ "enum": ["LR", "RL", "TB", "BT", "radial"]
34
+ },
35
+ "nodes": {
36
+ "description": "The boxes/entities/participants/milestones. Internal `id`s are reference handles only and are never rendered.",
37
+ "type": "array",
38
+ "minItems": 1,
39
+ "maxItems": 40,
40
+ "items": { "$ref": "#/$defs/node" }
41
+ },
42
+ "edges": {
43
+ "description": "Connections between nodes. For sequence_diagram, array order is message order. May be empty (e.g. some timelines/mind maps without explicit links).",
44
+ "type": "array",
45
+ "maxItems": 80,
46
+ "items": { "$ref": "#/$defs/edge" },
47
+ "default": []
48
+ },
49
+ "groups": {
50
+ "description": "Optional clusters/swimlanes/screens. A node joins a group via node.group = group.id.",
51
+ "type": "array",
52
+ "maxItems": 12,
53
+ "items": { "$ref": "#/$defs/group" },
54
+ "default": []
55
+ },
56
+ "meta": {
57
+ "description": "Diagram-level escape hatch for type-specific knobs. Keep empty unless a type below defines a key.",
58
+ "type": "object",
59
+ "additionalProperties": true,
60
+ "default": {}
61
+ }
62
+ },
63
+
64
+ "$defs": {
65
+ "id": {
66
+ "description": "Stable snake_case identifier, unique within its collection. Reference handle only — not rendered.",
67
+ "type": "string",
68
+ "pattern": "^[a-z][a-z0-9_]*$",
69
+ "minLength": 1,
70
+ "maxLength": 40
71
+ },
72
+ "label": {
73
+ "description": "Visible text. Non-empty, single line preferred.",
74
+ "type": "string",
75
+ "minLength": 1,
76
+ "maxLength": 60
77
+ },
78
+ "role": {
79
+ "description": "Semantic kind of the node. The converter maps role -> {shape, fill color, stroke color}. The model never picks shapes or colors directly.",
80
+ "type": "string",
81
+ "enum": [
82
+ "actor",
83
+ "client",
84
+ "service",
85
+ "gateway",
86
+ "database",
87
+ "cache",
88
+ "queue",
89
+ "worker",
90
+ "storage",
91
+ "external",
92
+ "monitoring",
93
+ "auth",
94
+ "start",
95
+ "end",
96
+ "process",
97
+ "decision",
98
+ "io",
99
+ "source",
100
+ "transform",
101
+ "sink",
102
+ "entity",
103
+ "participant",
104
+ "milestone",
105
+ "root",
106
+ "branch",
107
+ "leaf",
108
+ "screen",
109
+ "ui_element",
110
+ "component",
111
+ "generic"
112
+ ]
113
+ },
114
+ "node": {
115
+ "type": "object",
116
+ "additionalProperties": false,
117
+ "required": ["id", "label", "role"],
118
+ "properties": {
119
+ "id": { "$ref": "#/$defs/id" },
120
+ "label": { "$ref": "#/$defs/label" },
121
+ "role": { "$ref": "#/$defs/role" },
122
+ "group": {
123
+ "description": "Optional group membership; must equal some groups[].id.",
124
+ "$ref": "#/$defs/id"
125
+ },
126
+ "color": {
127
+ "description": "OPTIONAL explicit override only when a human would intentionally choose a color (e.g. emphasis). Otherwise omit and let the converter derive color from role.",
128
+ "type": "string",
129
+ "enum": ["red", "orange", "yellow", "green", "blue", "violet", "gray", "black"]
130
+ },
131
+ "meta": {
132
+ "description": "Type-specific node data. See per-diagram rules in DSL_SPEC.md. Examples: er_diagram fields, timeline date, wireframe ui kind.",
133
+ "type": "object",
134
+ "additionalProperties": false,
135
+ "properties": {
136
+ "fields": {
137
+ "description": "er_diagram only: entity columns.",
138
+ "type": "array",
139
+ "maxItems": 20,
140
+ "items": {
141
+ "type": "object",
142
+ "additionalProperties": false,
143
+ "required": ["name"],
144
+ "properties": {
145
+ "name": { "type": "string", "minLength": 1, "maxLength": 40 },
146
+ "type": { "type": "string", "maxLength": 30 },
147
+ "key": { "type": "string", "enum": ["pk", "fk", "none"], "default": "none" }
148
+ }
149
+ }
150
+ },
151
+ "date": {
152
+ "description": "timeline only: ISO-ish date or short label used for ordering and display.",
153
+ "type": "string",
154
+ "maxLength": 30
155
+ },
156
+ "kind": {
157
+ "description": "mobile_wireframe only: the UI element kind.",
158
+ "type": "string",
159
+ "enum": ["header", "nav", "text", "input", "button", "list", "image", "card", "tab", "footer"]
160
+ }
161
+ }
162
+ }
163
+ }
164
+ },
165
+ "edge": {
166
+ "type": "object",
167
+ "additionalProperties": false,
168
+ "required": ["from", "to"],
169
+ "properties": {
170
+ "from": {
171
+ "description": "Source node id. Must reference an existing node.",
172
+ "$ref": "#/$defs/id"
173
+ },
174
+ "to": {
175
+ "description": "Target node id. Must reference an existing node.",
176
+ "$ref": "#/$defs/id"
177
+ },
178
+ "label": {
179
+ "description": "Optional edge label. For flowchart decision branches use 'yes'/'no'.",
180
+ "type": "string",
181
+ "maxLength": 40
182
+ },
183
+ "style": {
184
+ "description": "Line style. Default solid.",
185
+ "type": "string",
186
+ "enum": ["solid", "dashed", "dotted"],
187
+ "default": "solid"
188
+ },
189
+ "meta": {
190
+ "type": "object",
191
+ "additionalProperties": false,
192
+ "properties": {
193
+ "cardinality": {
194
+ "description": "er_diagram only: relationship cardinality.",
195
+ "type": "string",
196
+ "enum": ["1:1", "1:N", "N:1", "N:M"]
197
+ },
198
+ "kind": {
199
+ "description": "sequence_diagram only: message kind.",
200
+ "type": "string",
201
+ "enum": ["sync", "async", "return"],
202
+ "default": "sync"
203
+ }
204
+ }
205
+ }
206
+ }
207
+ },
208
+ "group": {
209
+ "type": "object",
210
+ "additionalProperties": false,
211
+ "required": ["id", "label"],
212
+ "properties": {
213
+ "id": { "$ref": "#/$defs/id" },
214
+ "label": { "$ref": "#/$defs/label" }
215
+ }
216
+ }
217
+ },
218
+
219
+ "allOf": [
220
+ {
221
+ "description": "ER diagrams: relationships should carry cardinality.",
222
+ "if": { "properties": { "diagram": { "const": "er_diagram" } } },
223
+ "then": {
224
+ "properties": {
225
+ "edges": {
226
+ "items": {
227
+ "properties": {
228
+ "meta": { "required": ["cardinality"] }
229
+ },
230
+ "required": ["meta"]
231
+ }
232
+ }
233
+ }
234
+ }
235
+ },
236
+ {
237
+ "description": "Timelines: every milestone node should carry a date.",
238
+ "if": { "properties": { "diagram": { "const": "timeline" } } },
239
+ "then": {
240
+ "properties": {
241
+ "nodes": {
242
+ "items": {
243
+ "properties": {
244
+ "meta": { "required": ["date"] }
245
+ },
246
+ "required": ["meta"]
247
+ }
248
+ }
249
+ }
250
+ }
251
+ },
252
+ {
253
+ "description": "Mobile wireframes: every node needs a UI kind.",
254
+ "if": { "properties": { "diagram": { "const": "mobile_wireframe" } } },
255
+ "then": {
256
+ "properties": {
257
+ "nodes": {
258
+ "items": {
259
+ "properties": {
260
+ "meta": { "required": ["kind"] }
261
+ },
262
+ "required": ["meta"]
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ ]
269
+ }
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ graphviz
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=4.44,<6
2
+ requests>=2.31
validate_dsl.py ADDED
@@ -0,0 +1,757 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ validate_dsl.py
4
+
5
+ The Phase 2 accept/reject gate for Diagram DSL.
6
+
7
+ Two layers, both enforced here (zero pip dependencies — pure stdlib):
8
+
9
+ 1. STRUCTURAL — a small JSON-Schema validator that reads `dsl_schema.json` and checks
10
+ types, enums, required keys, additionalProperties, lengths, patterns, and the
11
+ diagram-specific allOf/if-then conditionals. The schema file stays the single source
12
+ of truth; this validator just interprets it.
13
+
14
+ 2. SEMANTIC — the rules JSON Schema cannot express (DSL_SPEC.md section 2): unique ids,
15
+ edge endpoints resolve, no isolated nodes, flowchart-has-one-start, pipeline-is-acyclic,
16
+ mind_map-is-a-tree, etc. These are the rules that actually keep the dataset clean.
17
+
18
+ A DSL is ACCEPTED iff it produces zero errors. Warnings never reject — they flag rows worth
19
+ a human glance (e.g. very dense graphs, heavy color overrides).
20
+
21
+ Usage:
22
+ # validate a single DSL file (or '-' for stdin)
23
+ python3 validate_dsl.py diagram.dsl.json
24
+ cat diagram.dsl.json | python3 validate_dsl.py -
25
+
26
+ # validate every assistant DSL in a training file (the dataset gate)
27
+ python3 validate_dsl.py --jsonl train.jsonl
28
+ python3 validate_dsl.py --jsonl train.jsonl --max-show 20 --warnings
29
+
30
+ Exit code is 0 when everything passed, 1 otherwise — usable in CI / generation loops.
31
+
32
+ As a library:
33
+ from validate_dsl import validate_dsl, validate_dsl_text, Result
34
+ res = validate_dsl(dsl_dict)
35
+ if res.ok: ...
36
+ """
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import re
42
+ import sys
43
+
44
+ SCHEMA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dsl_schema.json")
45
+
46
+ # Diagram types whose every node must touch at least one edge (DSL_SPEC U7).
47
+ CONNECTED_TYPES = {"system_architecture", "flowchart", "data_pipeline", "mind_map"}
48
+
49
+
50
+ # --------------------------------------------------------------------------------------
51
+ # Result accumulator
52
+ # --------------------------------------------------------------------------------------
53
+
54
+ class Result:
55
+ """Collects validation findings. `ok` is True iff there are no errors."""
56
+
57
+ def __init__(self):
58
+ self.errors = [] # list of (code, message)
59
+ self.warnings = [] # list of (code, message)
60
+
61
+ def error(self, code, message):
62
+ self.errors.append((code, message))
63
+
64
+ def warn(self, code, message):
65
+ self.warnings.append((code, message))
66
+
67
+ @property
68
+ def ok(self):
69
+ return not self.errors
70
+
71
+ def extend(self, other):
72
+ self.errors.extend(other.errors)
73
+ self.warnings.extend(other.warnings)
74
+
75
+ def __str__(self):
76
+ lines = []
77
+ for code, msg in self.errors:
78
+ lines.append(f" ERROR [{code}] {msg}")
79
+ for code, msg in self.warnings:
80
+ lines.append(f" WARN [{code}] {msg}")
81
+ if not lines:
82
+ lines.append(" OK")
83
+ return "\n".join(lines)
84
+
85
+
86
+ # --------------------------------------------------------------------------------------
87
+ # Layer 1: structural validation (a minimal JSON-Schema interpreter for our schema)
88
+ # --------------------------------------------------------------------------------------
89
+
90
+ _JSON_TYPES = {
91
+ "object": dict,
92
+ "array": list,
93
+ "string": str,
94
+ "number": (int, float),
95
+ "integer": int,
96
+ "boolean": bool,
97
+ "null": type(None),
98
+ }
99
+
100
+
101
+ def _type_matches(value, type_name):
102
+ # bool is a subclass of int in Python; keep number/integer from swallowing booleans.
103
+ if type_name in ("number", "integer") and isinstance(value, bool):
104
+ return False
105
+ if type_name == "boolean":
106
+ return isinstance(value, bool)
107
+ return isinstance(value, _JSON_TYPES[type_name])
108
+
109
+
110
+ class SchemaValidator:
111
+ """Interprets the subset of JSON Schema 2020-12 that dsl_schema.json actually uses:
112
+ $ref/$defs, type, enum, const, required, properties, additionalProperties, items,
113
+ minItems/maxItems, minLength/maxLength, pattern, allOf, and if/then/else."""
114
+
115
+ def __init__(self, schema):
116
+ self.root = schema
117
+
118
+ def validate(self, instance):
119
+ errors = []
120
+ self._check(instance, self.root, "$", errors)
121
+ return errors
122
+
123
+ # -- internals --------------------------------------------------------------------
124
+
125
+ def _resolve(self, schema):
126
+ # Follow a $ref chain ("#/$defs/node"). Sibling keywords in this schema are only
127
+ # `description` (non-validating), so replacing the node with its target is safe.
128
+ seen = 0
129
+ while isinstance(schema, dict) and "$ref" in schema:
130
+ ref = schema["$ref"]
131
+ parts = [p for p in ref.lstrip("#/").split("/") if p]
132
+ target = self.root
133
+ for p in parts:
134
+ target = target[p]
135
+ schema = target
136
+ seen += 1
137
+ if seen > 50:
138
+ break
139
+ return schema
140
+
141
+ def _matches(self, instance, schema):
142
+ """True iff `instance` validates against `schema` (used for if/then)."""
143
+ tmp = []
144
+ self._check(instance, schema, "$", tmp)
145
+ return not tmp
146
+
147
+ def _check(self, instance, schema, path, errors):
148
+ schema = self._resolve(schema)
149
+ if not isinstance(schema, dict):
150
+ return
151
+
152
+ # allOf
153
+ for sub in schema.get("allOf", []):
154
+ self._check(instance, sub, path, errors)
155
+
156
+ # if / then / else
157
+ if "if" in schema:
158
+ if self._matches(instance, schema["if"]):
159
+ if "then" in schema:
160
+ self._check(instance, schema["then"], path, errors)
161
+ elif "else" in schema:
162
+ self._check(instance, schema["else"], path, errors)
163
+
164
+ # type
165
+ if "type" in schema:
166
+ t = schema["type"]
167
+ types = t if isinstance(t, list) else [t]
168
+ if not any(_type_matches(instance, tn) for tn in types):
169
+ errors.append(("schema", f"{path}: expected type {t}, got {_pytype(instance)}"))
170
+ return # deeper keyword checks would just produce noise
171
+
172
+ # enum / const
173
+ if "enum" in schema and instance not in schema["enum"]:
174
+ errors.append(("schema", f"{path}: {instance!r} is not one of {schema['enum']}"))
175
+ if "const" in schema and instance != schema["const"]:
176
+ errors.append(("schema", f"{path}: expected const {schema['const']!r}"))
177
+
178
+ if isinstance(instance, str):
179
+ self._check_string(instance, schema, path, errors)
180
+ elif isinstance(instance, list):
181
+ self._check_array(instance, schema, path, errors)
182
+ elif isinstance(instance, dict):
183
+ self._check_object(instance, schema, path, errors)
184
+
185
+ def _check_string(self, instance, schema, path, errors):
186
+ if "minLength" in schema and len(instance) < schema["minLength"]:
187
+ errors.append(("schema", f"{path}: shorter than minLength {schema['minLength']}"))
188
+ if "maxLength" in schema and len(instance) > schema["maxLength"]:
189
+ errors.append(("schema", f"{path}: longer than maxLength {schema['maxLength']}"))
190
+ if "pattern" in schema and not re.search(schema["pattern"], instance):
191
+ errors.append(("schema", f"{path}: {instance!r} does not match pattern {schema['pattern']}"))
192
+
193
+ def _check_array(self, instance, schema, path, errors):
194
+ if "minItems" in schema and len(instance) < schema["minItems"]:
195
+ errors.append(("schema", f"{path}: fewer than minItems {schema['minItems']}"))
196
+ if "maxItems" in schema and len(instance) > schema["maxItems"]:
197
+ errors.append(("schema", f"{path}: more than maxItems {schema['maxItems']}"))
198
+ items = schema.get("items")
199
+ if items is not None:
200
+ for i, el in enumerate(instance):
201
+ self._check(el, items, f"{path}[{i}]", errors)
202
+
203
+ def _check_object(self, instance, schema, path, errors):
204
+ props = schema.get("properties", {})
205
+ for req in schema.get("required", []):
206
+ if req not in instance:
207
+ errors.append(("schema", f"{path}: missing required property '{req}'"))
208
+ additional = schema.get("additionalProperties", True)
209
+ for key, val in instance.items():
210
+ if key in props:
211
+ self._check(val, props[key], f"{path}.{key}", errors)
212
+ elif additional is False:
213
+ errors.append(("schema", f"{path}: unexpected property '{key}' (additionalProperties=false)"))
214
+ elif isinstance(additional, dict):
215
+ self._check(val, additional, f"{path}.{key}", errors)
216
+
217
+
218
+ def _pytype(v):
219
+ if isinstance(v, bool):
220
+ return "boolean"
221
+ if isinstance(v, dict):
222
+ return "object"
223
+ if isinstance(v, list):
224
+ return "array"
225
+ if isinstance(v, str):
226
+ return "string"
227
+ if isinstance(v, (int, float)):
228
+ return "number"
229
+ if v is None:
230
+ return "null"
231
+ return type(v).__name__
232
+
233
+
234
+ # --------------------------------------------------------------------------------------
235
+ # Layer 2: semantic validation (DSL_SPEC.md section 2)
236
+ # --------------------------------------------------------------------------------------
237
+
238
+ def _semantic(dsl, res):
239
+ diagram = dsl["diagram"]
240
+ nodes = dsl["nodes"]
241
+ edges = dsl.get("edges", []) or []
242
+ groups = dsl.get("groups", []) or []
243
+
244
+ node_ids = [n["id"] for n in nodes]
245
+ id_set = set(node_ids)
246
+ by_id = {n["id"]: n for n in nodes}
247
+ group_ids = [g["id"] for g in groups]
248
+
249
+ _universal(dsl, diagram, nodes, edges, groups, node_ids, id_set, group_ids, res)
250
+
251
+ # Edges that actually resolve — later graph checks must not crash on dangling refs.
252
+ valid_edges = [e for e in edges if e["from"] in id_set and e["to"] in id_set]
253
+
254
+ dispatch = {
255
+ "flowchart": _flowchart,
256
+ "data_pipeline": _data_pipeline,
257
+ "sequence_diagram": _sequence,
258
+ "er_diagram": _er,
259
+ "mind_map": _mind_map,
260
+ "timeline": _timeline,
261
+ "mobile_wireframe": _wireframe,
262
+ }
263
+ fn = dispatch.get(diagram)
264
+ if fn:
265
+ fn(nodes, valid_edges, edges, by_id, id_set, groups, res)
266
+
267
+ _size_guards(nodes, edges, res)
268
+
269
+
270
+ def _roles(nodes, role):
271
+ return [n for n in nodes if n.get("role") == role]
272
+
273
+
274
+ def _universal(dsl, diagram, nodes, edges, groups, node_ids, id_set, group_ids, res):
275
+ # U1 — unique node ids
276
+ for dup in _dups(node_ids):
277
+ res.error("U1", f"duplicate node id '{dup}'")
278
+ # U2 — unique group ids
279
+ for dup in _dups(group_ids):
280
+ res.error("U2", f"duplicate group id '{dup}'")
281
+ group_set = set(group_ids)
282
+
283
+ # U3 — edge endpoints resolve
284
+ for i, e in enumerate(edges):
285
+ if e["from"] not in id_set:
286
+ res.error("U3", f"edges[{i}].from '{e['from']}' is not a node id")
287
+ if e["to"] not in id_set:
288
+ res.error("U3", f"edges[{i}].to '{e['to']}' is not a node id")
289
+
290
+ # U4 — group membership resolves
291
+ for n in nodes:
292
+ g = n.get("group")
293
+ if g is not None and g not in group_set:
294
+ res.error("U4", f"node '{n['id']}' references unknown group '{g}'")
295
+
296
+ # U5 — no self loops
297
+ for i, e in enumerate(edges):
298
+ if e["from"] == e["to"]:
299
+ res.error("U5", f"edges[{i}] is a self-loop on '{e['from']}'")
300
+
301
+ # U6 — no duplicate edges (sequence_diagram may legitimately repeat a pair)
302
+ if diagram != "sequence_diagram":
303
+ seen = set()
304
+ for i, e in enumerate(edges):
305
+ key = (e["from"], e["to"], e.get("label", ""))
306
+ if key in seen:
307
+ res.error("U6", f"edges[{i}] duplicates edge {e['from']}->{e['to']} (label {e.get('label','')!r})")
308
+ seen.add(key)
309
+
310
+ # U7 — no isolated nodes for connected types
311
+ if diagram in CONNECTED_TYPES:
312
+ touched = set()
313
+ for e in edges:
314
+ touched.add(e["from"])
315
+ touched.add(e["to"])
316
+ for n in nodes:
317
+ if n["id"] not in touched:
318
+ res.error("U7", f"node '{n['id']}' is isolated (no edge) in a connected diagram")
319
+
320
+ # U8 — color override sanity (warn only)
321
+ overrides = sum(1 for n in nodes if n.get("color"))
322
+ if nodes and overrides / len(nodes) > 0.30:
323
+ res.warn("U8", f"{overrides}/{len(nodes)} nodes override color (>30%) — styling should come from role")
324
+
325
+ # U9 — label hygiene
326
+ _label_hygiene("title", dsl.get("title", ""), res)
327
+ for n in nodes:
328
+ _label_hygiene(f"node '{n['id']}' label", n.get("label", ""), res)
329
+ for g in groups:
330
+ _label_hygiene(f"group '{g['id']}' label", g.get("label", ""), res)
331
+ for i, e in enumerate(edges):
332
+ if "label" in e:
333
+ _label_hygiene(f"edges[{i}] label", e["label"], res)
334
+
335
+
336
+ def _label_hygiene(where, text, res):
337
+ if "\n" in text or "\r" in text:
338
+ res.error("U9", f"{where} contains a newline (converter handles wrapping)")
339
+ elif text != text.strip():
340
+ res.warn("U9", f"{where} has leading/trailing whitespace")
341
+
342
+
343
+ def _flowchart(nodes, edges, raw_edges, by_id, id_set, groups, res):
344
+ starts = _roles(nodes, "start")
345
+ # F1 — exactly one start
346
+ if len(starts) != 1:
347
+ res.error("F1", f"flowchart needs exactly one 'start' node, found {len(starts)}")
348
+ # F2 — at least one end
349
+ if not _roles(nodes, "end"):
350
+ res.error("F2", "flowchart needs at least one 'end' node")
351
+
352
+ out = _out_adj(edges)
353
+ # F3 — decision branches labeled, >= 2 outgoing
354
+ for n in nodes:
355
+ if n.get("role") == "decision":
356
+ outs = [e for e in edges if e["from"] == n["id"]]
357
+ if len(outs) < 2:
358
+ res.error("F3", f"decision node '{n['id']}' has {len(outs)} outgoing edges (need >= 2)")
359
+ for e in outs:
360
+ if not e.get("label", "").strip():
361
+ res.error("F3", f"decision node '{n['id']}' has an unlabeled branch to '{e['to']}'")
362
+
363
+ # F4 — reachability from the (first) start
364
+ if starts:
365
+ reachable = _bfs(starts[0]["id"], out)
366
+ for n in nodes:
367
+ if n["id"] not in reachable:
368
+ res.error("F4", f"node '{n['id']}' is not reachable from start '{starts[0]['id']}'")
369
+
370
+
371
+ def _data_pipeline(nodes, edges, raw_edges, by_id, id_set, groups, res):
372
+ out = _out_adj(edges)
373
+ indeg = _indegree(nodes, edges)
374
+ outdeg = _outdegree(nodes, edges)
375
+
376
+ # P1 — acyclic
377
+ cycle = _find_cycle(nodes, out)
378
+ if cycle:
379
+ res.error("P1", f"data_pipeline must be acyclic; cycle through {' -> '.join(cycle)}")
380
+
381
+ # P2 — has >=1 source and >=1 sink (by role)
382
+ sources = _roles(nodes, "source")
383
+ sinks = _roles(nodes, "sink")
384
+ if not sources:
385
+ res.error("P2", "data_pipeline needs at least one 'source' node")
386
+ if not sinks:
387
+ res.error("P2", "data_pipeline needs at least one 'sink' node")
388
+
389
+ # P3 — sources have no incoming, sinks have no outgoing
390
+ for n in sources:
391
+ if indeg.get(n["id"], 0) > 0:
392
+ res.error("P3", f"source '{n['id']}' has incoming edges")
393
+ for n in sinks:
394
+ if outdeg.get(n["id"], 0) > 0:
395
+ res.error("P3", f"sink '{n['id']}' has outgoing edges")
396
+
397
+
398
+ def _sequence(nodes, edges, raw_edges, by_id, id_set, groups, res):
399
+ # S1 — all nodes are participants (or actors)
400
+ for n in nodes:
401
+ if n.get("role") not in ("participant", "actor"):
402
+ res.error("S1", f"sequence_diagram node '{n['id']}' has role '{n.get('role')}' (expected participant/actor)")
403
+
404
+ # S4 — return messages should point back toward an earlier sender (warn only).
405
+ prior_pairs = set()
406
+ for i, e in enumerate(raw_edges):
407
+ kind = (e.get("meta") or {}).get("kind")
408
+ if kind == "return":
409
+ if (e["to"], e["from"]) not in prior_pairs:
410
+ res.warn("S4", f"edges[{i}] is a 'return' to '{e['to']}' with no preceding message from it")
411
+ prior_pairs.add((e["from"], e["to"]))
412
+
413
+
414
+ def _er(nodes, edges, raw_edges, by_id, id_set, groups, res):
415
+ # E1 — nodes are entities
416
+ for n in nodes:
417
+ if n.get("role") != "entity":
418
+ res.error("E1", f"er_diagram node '{n['id']}' has role '{n.get('role')}' (expected entity)")
419
+ # E2 — each entity declares >=1 field; at most one pk
420
+ for n in nodes:
421
+ fields = (n.get("meta") or {}).get("fields")
422
+ if not fields:
423
+ res.error("E2", f"entity '{n['id']}' declares no fields")
424
+ continue
425
+ pks = [f for f in fields if f.get("key") == "pk"]
426
+ if len(pks) > 1:
427
+ res.error("E2", f"entity '{n['id']}' has {len(pks)} primary keys (max 1)")
428
+ # E3 — relationships carry cardinality (also schema-enforced; restated)
429
+ for i, e in enumerate(raw_edges):
430
+ if not (e.get("meta") or {}).get("cardinality"):
431
+ res.error("E3", f"edges[{i}] ({e['from']}->{e['to']}) is missing meta.cardinality")
432
+
433
+
434
+ def _mind_map(nodes, edges, raw_edges, by_id, id_set, groups, res):
435
+ roots = _roles(nodes, "root")
436
+ # M1 — exactly one root
437
+ if len(roots) != 1:
438
+ res.error("M1", f"mind_map needs exactly one 'root' node, found {len(roots)}")
439
+
440
+ n = len(nodes)
441
+ # M2 — tree shape: undirected connected & acyclic, every non-root has one parent.
442
+ if len(edges) != n - 1:
443
+ res.error("M2", f"mind_map is not a tree: {n} nodes need exactly {n-1} edges, found {len(edges)}")
444
+ undirected = _undirected_adj(edges)
445
+ if roots:
446
+ seen = _bfs(roots[0]["id"], undirected)
447
+ if len(seen) != n:
448
+ res.error("M2", f"mind_map is not connected: {len(seen)}/{n} nodes reachable from root (undirected)")
449
+ # directed parent check: root in-degree 0, others exactly 1
450
+ indeg = _indegree(nodes, edges)
451
+ for nd in nodes:
452
+ d = indeg.get(nd["id"], 0)
453
+ if nd["id"] == roots[0]["id"]:
454
+ if d != 0:
455
+ res.error("M2", f"root '{nd['id']}' has {d} incoming edges (expected 0)")
456
+ elif d != 1:
457
+ res.error("M2", f"node '{nd['id']}' has {d} parents (expected exactly 1)")
458
+
459
+ # M3 — root reaches all (directed)
460
+ out = _out_adj(edges)
461
+ reach = _bfs(roots[0]["id"], out)
462
+ for nd in nodes:
463
+ if nd["id"] not in reach:
464
+ res.error("M3", f"node '{nd['id']}' is not reachable from root (directed)")
465
+
466
+
467
+ def _timeline(nodes, edges, raw_edges, by_id, id_set, groups, res):
468
+ # T1 — every node has meta.date (schema-enforced; restated). T2 — sortable.
469
+ for n in nodes:
470
+ date = (n.get("meta") or {}).get("date")
471
+ if not date:
472
+ res.error("T1", f"timeline node '{n['id']}' is missing meta.date")
473
+ elif _date_sort_key(date) is None:
474
+ res.warn("T2", f"timeline node '{n['id']}' date {date!r} is not obviously sortable")
475
+
476
+
477
+ def _wireframe(nodes, edges, raw_edges, by_id, id_set, groups, res):
478
+ # W1 — every node has meta.kind (schema-enforced; restated)
479
+ for n in nodes:
480
+ if not (n.get("meta") or {}).get("kind"):
481
+ res.error("W1", f"wireframe node '{n['id']}' is missing meta.kind")
482
+ # W2 — every node belongs to a screen group
483
+ if not groups:
484
+ res.error("W2", "mobile_wireframe has no groups (screens)")
485
+ for n in nodes:
486
+ if not n.get("group"):
487
+ res.error("W2", f"wireframe node '{n['id']}' is not assigned to a screen group")
488
+
489
+
490
+ def _size_guards(nodes, edges, res):
491
+ # G1 / G3 — schema already bounds counts; these are quality warnings.
492
+ if len(nodes) > 25:
493
+ res.warn("G1", f"{len(nodes)} nodes — layout quality tends to degrade past ~25")
494
+ if nodes and len(edges) > len(nodes) * 3:
495
+ res.warn("G3", f"{len(edges)} edges for {len(nodes)} nodes (>3x) — likely too dense")
496
+
497
+
498
+ # --------------------------------------------------------------------------------------
499
+ # Graph helpers
500
+ # --------------------------------------------------------------------------------------
501
+
502
+ def _dups(seq):
503
+ seen, dups = set(), []
504
+ for x in seq:
505
+ if x in seen and x not in dups:
506
+ dups.append(x)
507
+ seen.add(x)
508
+ return dups
509
+
510
+
511
+ def _out_adj(edges):
512
+ adj = {}
513
+ for e in edges:
514
+ adj.setdefault(e["from"], []).append(e["to"])
515
+ return adj
516
+
517
+
518
+ def _undirected_adj(edges):
519
+ adj = {}
520
+ for e in edges:
521
+ adj.setdefault(e["from"], []).append(e["to"])
522
+ adj.setdefault(e["to"], []).append(e["from"])
523
+ return adj
524
+
525
+
526
+ def _indegree(nodes, edges):
527
+ deg = {n["id"]: 0 for n in nodes}
528
+ for e in edges:
529
+ deg[e["to"]] = deg.get(e["to"], 0) + 1
530
+ return deg
531
+
532
+
533
+ def _outdegree(nodes, edges):
534
+ deg = {n["id"]: 0 for n in nodes}
535
+ for e in edges:
536
+ deg[e["from"]] = deg.get(e["from"], 0) + 1
537
+ return deg
538
+
539
+
540
+ def _bfs(start, adj):
541
+ seen, stack = set(), [start]
542
+ while stack:
543
+ cur = stack.pop()
544
+ if cur in seen:
545
+ continue
546
+ seen.add(cur)
547
+ for nxt in adj.get(cur, []):
548
+ if nxt not in seen:
549
+ stack.append(nxt)
550
+ return seen
551
+
552
+
553
+ def _find_cycle(nodes, adj):
554
+ """Return a node sequence describing a directed cycle, or None. DFS with colors."""
555
+ WHITE, GRAY, BLACK = 0, 1, 2
556
+ color = {n["id"]: WHITE for n in nodes}
557
+ parent = {}
558
+
559
+ def visit(u):
560
+ color[u] = GRAY
561
+ for v in adj.get(u, []):
562
+ if v not in color: # endpoint outside node set; skip defensively
563
+ continue
564
+ if color[v] == WHITE:
565
+ parent[v] = u
566
+ r = visit(v)
567
+ if r:
568
+ return r
569
+ elif color[v] == GRAY: # back edge u->v closes a cycle
570
+ path = [u] # walk parents from u up to the ancestor v
571
+ x = u
572
+ while x != v and x in parent:
573
+ x = parent[x]
574
+ path.append(x)
575
+ path.reverse() # now v ... u
576
+ return path + [v] # close the loop: v ... u -> v
577
+ color[u] = BLACK
578
+ return None
579
+
580
+ sys.setrecursionlimit(10000)
581
+ for n in nodes:
582
+ if color[n["id"]] == WHITE:
583
+ r = visit(n["id"])
584
+ if r:
585
+ return r
586
+ return None
587
+
588
+
589
+ _DATE_RE = re.compile(r"^\d{4}(-\d{2}(-\d{2})?)?$")
590
+
591
+
592
+ def _date_sort_key(date):
593
+ """Return a sortable key for common date forms, or None if not obviously sortable.
594
+ Accepts ISO-ish (YYYY, YYYY-MM, YYYY-MM-DD) and 'Q<n> YYYY' style labels."""
595
+ date = date.strip()
596
+ if _DATE_RE.match(date):
597
+ return date
598
+ m = re.match(r"^Q([1-4])\s+(\d{4})$", date)
599
+ if m:
600
+ return f"{m.group(2)}-Q{m.group(1)}"
601
+ return None
602
+
603
+
604
+ # --------------------------------------------------------------------------------------
605
+ # Public API
606
+ # --------------------------------------------------------------------------------------
607
+
608
+ _SCHEMA_CACHE = None
609
+
610
+
611
+ def load_schema(path=SCHEMA_FILE):
612
+ global _SCHEMA_CACHE
613
+ if _SCHEMA_CACHE is None:
614
+ with open(path, "r", encoding="utf-8") as f:
615
+ _SCHEMA_CACHE = json.load(f)
616
+ return _SCHEMA_CACHE
617
+
618
+
619
+ def validate_dsl(dsl, schema=None):
620
+ """Validate a parsed DSL object. Returns a Result (res.ok == accepted)."""
621
+ res = Result()
622
+ schema = schema if schema is not None else load_schema()
623
+
624
+ # Layer 1 — structural. If it fails, semantic checks would just crash on the same
625
+ # malformed data, so we stop here and report the structural errors.
626
+ structural_errors = SchemaValidator(schema).validate(dsl)
627
+ if structural_errors:
628
+ for code, msg in structural_errors:
629
+ res.error(code, msg)
630
+ return res
631
+
632
+ # Layer 2 — semantic.
633
+ _semantic(dsl, res)
634
+ return res
635
+
636
+
637
+ def validate_dsl_text(text, schema=None):
638
+ """Validate DSL given as a JSON string (e.g. a model's assistant output)."""
639
+ res = Result()
640
+ try:
641
+ dsl = json.loads(text)
642
+ except json.JSONDecodeError as e:
643
+ res.error("json", f"not valid JSON: {e}")
644
+ return res
645
+ if not isinstance(dsl, dict):
646
+ res.error("json", f"top-level DSL must be an object, got {_pytype(dsl)}")
647
+ return res
648
+ return validate_dsl(dsl, schema)
649
+
650
+
651
+ def _assistant_content(row):
652
+ """Pull the assistant DSL string out of a chat-format training row."""
653
+ msgs = row.get("messages")
654
+ if not isinstance(msgs, list):
655
+ return None
656
+ for m in reversed(msgs):
657
+ if isinstance(m, dict) and m.get("role") == "assistant":
658
+ return m.get("content")
659
+ return None
660
+
661
+
662
+ # --------------------------------------------------------------------------------------
663
+ # CLI
664
+ # --------------------------------------------------------------------------------------
665
+
666
+ def _read(path):
667
+ if path == "-":
668
+ return sys.stdin.read()
669
+ with open(path, "r", encoding="utf-8") as f:
670
+ return f.read()
671
+
672
+
673
+ def _run_single(path, show_warnings):
674
+ res = validate_dsl_text(_read(path))
675
+ status = "PASS" if res.ok else "FAIL"
676
+ print(f"{status}: {path}")
677
+ for code, msg in res.errors:
678
+ print(f" ERROR [{code}] {msg}")
679
+ if show_warnings:
680
+ for code, msg in res.warnings:
681
+ print(f" WARN [{code}] {msg}")
682
+ return 0 if res.ok else 1
683
+
684
+
685
+ def _run_jsonl(path, show_warnings, max_show):
686
+ text = _read(path)
687
+ total = passed = failed = warned = 0
688
+ shown = 0
689
+ error_codes = {}
690
+ for lineno, line in enumerate(text.splitlines(), 1):
691
+ line = line.strip()
692
+ if not line:
693
+ continue
694
+ total += 1
695
+ try:
696
+ row = json.loads(line)
697
+ except json.JSONDecodeError as e:
698
+ failed += 1
699
+ error_codes["json"] = error_codes.get("json", 0) + 1
700
+ if shown < max_show:
701
+ print(f"FAIL line {lineno}: row is not valid JSON: {e}")
702
+ shown += 1
703
+ continue
704
+
705
+ content = _assistant_content(row) if isinstance(row, dict) else None
706
+ # Accept either a chat row or a bare DSL object on the line.
707
+ res = validate_dsl_text(content) if content is not None else validate_dsl(row) \
708
+ if isinstance(row, dict) else Result()
709
+ if content is None and not isinstance(row, dict):
710
+ res = Result()
711
+ res.error("json", "line is neither a chat row nor a DSL object")
712
+
713
+ if res.warnings:
714
+ warned += 1
715
+ for code, _ in res.errors:
716
+ error_codes[code] = error_codes.get(code, 0) + 1
717
+
718
+ if res.ok:
719
+ passed += 1
720
+ if show_warnings and res.warnings and shown < max_show:
721
+ print(f"WARN line {lineno}:")
722
+ for code, msg in res.warnings:
723
+ print(f" WARN [{code}] {msg}")
724
+ shown += 1
725
+ else:
726
+ failed += 1
727
+ if shown < max_show:
728
+ print(f"FAIL line {lineno}:")
729
+ for code, msg in res.errors:
730
+ print(f" ERROR [{code}] {msg}")
731
+ shown += 1
732
+
733
+ print("-" * 60)
734
+ print(f"total={total} passed={passed} failed={failed} with_warnings={warned}")
735
+ if error_codes:
736
+ breakdown = " ".join(f"{c}={n}" for c, n in sorted(error_codes.items(), key=lambda x: -x[1]))
737
+ print(f"error breakdown: {breakdown}")
738
+ return 0 if failed == 0 else 1
739
+
740
+
741
+ def main(argv=None):
742
+ ap = argparse.ArgumentParser(description="Validate Diagram DSL (structural + semantic gate).")
743
+ ap.add_argument("input", help="DSL JSON file, JSONL dataset, or '-' for stdin")
744
+ ap.add_argument("--jsonl", action="store_true",
745
+ help="treat input as a JSONL dataset; validate each row's assistant DSL")
746
+ ap.add_argument("--warnings", action="store_true", help="also show warnings")
747
+ ap.add_argument("--max-show", type=int, default=20,
748
+ help="max failing/warning rows to print in --jsonl mode (default 20)")
749
+ args = ap.parse_args(argv)
750
+
751
+ if args.jsonl:
752
+ return _run_jsonl(args.input, args.warnings, args.max_show)
753
+ return _run_single(args.input, args.warnings)
754
+
755
+
756
+ if __name__ == "__main__":
757
+ sys.exit(main())