Meteord commited on
Commit
e501ea5
·
verified ·
1 Parent(s): 5d79d97

Sync from GitHub via hub-sync

Browse files
Files changed (2) hide show
  1. openui_adapter_demo.py +749 -0
  2. train/router/router_mlp.py +98 -0
openui_adapter_demo.py ADDED
@@ -0,0 +1,749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Interactive Gradio playground for the synthetic OpenUI SFT adapter."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import html
8
+ import json
9
+ import re
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ DEFAULT_MODEL = "openbmb/MiniCPM5-1B"
17
+ DEFAULT_ADAPTER = Path("train/openui_lang/outputs/openui-translate-mini-lora")
18
+ DEFAULT_DATASET = Path("train/openui_lang/data/openui_sft_train.jsonl")
19
+ SYSTEM_PROMPT = (
20
+ "You generate OpenUI Lang from a user query and a structured tool result. "
21
+ "Use only the values from the tool result. Do not invent data. "
22
+ "Return only OpenUI Lang assignment statements, without explanations or markdown. "
23
+ "Start with root = Root([...])."
24
+ )
25
+
26
+ MODEL: Any | None = None
27
+ TOKENIZER: Any | None = None
28
+ ACTIVE_MODEL_KEY: tuple[str, str, bool] | None = None
29
+
30
+
31
+ @dataclass
32
+ class DemoExample:
33
+ user_query: str
34
+ tool_result: dict[str, Any]
35
+ expected: str
36
+ label: str
37
+
38
+
39
+ def build_arg_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(description="Launch an interactive OpenUI adapter test app.")
41
+ parser.add_argument("--model-name", default=DEFAULT_MODEL)
42
+ parser.add_argument("--adapter", type=Path, default=DEFAULT_ADAPTER)
43
+ parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
44
+ parser.add_argument("--max-new-tokens", type=int, default=1600)
45
+ parser.add_argument("--temperature", type=float, default=0.0)
46
+ parser.add_argument("--top-p", type=float, default=1.0)
47
+ parser.add_argument("--load-in-4bit", action="store_true", default=True)
48
+ parser.add_argument("--no-load-in-4bit", dest="load_in_4bit", action="store_false")
49
+ parser.add_argument("--server-name", default="127.0.0.1")
50
+ parser.add_argument("--server-port", type=int, default=7861)
51
+ parser.add_argument("--share", action="store_true")
52
+ return parser
53
+
54
+
55
+ def parse_user_content(content: str) -> tuple[str, dict[str, Any]]:
56
+ match = re.search(r"(?:User query:\s*)?(.*?)\n\nTool result:\n(.*)\s*$", content, flags=re.DOTALL)
57
+ if not match:
58
+ raise ValueError("Sample user content does not match the generated dataset format.")
59
+ user_query = match.group(1).strip()
60
+ tool_result = json.loads(match.group(2))
61
+ return user_query, tool_result
62
+
63
+
64
+ def load_examples(path: Path, limit: int = 12) -> list[DemoExample]:
65
+ examples_by_shape: dict[str, DemoExample] = {}
66
+ examples: list[DemoExample] = []
67
+ if not path.exists():
68
+ return examples
69
+
70
+ def iter_rows() -> list[dict[str, Any]]:
71
+ if path.is_dir():
72
+ rows = []
73
+ for sample_path in sorted(path.glob("*.json")):
74
+ if sample_path.name == "manifest.json":
75
+ continue
76
+ rows.append(json.loads(sample_path.read_text(encoding="utf-8")))
77
+ return rows
78
+
79
+ rows = []
80
+ with path.open(encoding="utf-8") as handle:
81
+ for line in handle:
82
+ if line.strip():
83
+ rows.append(json.loads(line))
84
+ return rows
85
+
86
+ preferred_shapes = [
87
+ "scalar",
88
+ "comparison",
89
+ "time_series_daily",
90
+ "time_series_monthly",
91
+ "ranking",
92
+ "threshold",
93
+ "percentage",
94
+ "table",
95
+ "multi_kpi",
96
+ "geo_values",
97
+ ]
98
+ for row in iter_rows():
99
+ try:
100
+ messages = row["messages"]
101
+ user_query, tool_result = parse_user_content(messages[1]["content"])
102
+ expected = messages[2]["content"]
103
+ except Exception:
104
+ continue
105
+
106
+ metadata = row.get("metadata", {})
107
+ data_shape = str(metadata.get("data_shape", "unknown"))
108
+ label = " / ".join(
109
+ str(part)
110
+ for part in [
111
+ metadata.get("domain", "unknown"),
112
+ data_shape,
113
+ metadata.get("component", "unknown"),
114
+ ]
115
+ )
116
+ example = DemoExample(user_query, tool_result, expected, label)
117
+ if data_shape not in examples_by_shape:
118
+ examples_by_shape[data_shape] = example
119
+ if len(examples) < limit:
120
+ examples.append(example)
121
+
122
+ diverse = [
123
+ examples_by_shape[shape]
124
+ for shape in preferred_shapes
125
+ if shape in examples_by_shape
126
+ ]
127
+ if len(diverse) >= min(limit, len(examples_by_shape)):
128
+ seen = {id(example) for example in diverse}
129
+ diverse.extend(
130
+ example
131
+ for example in examples
132
+ if id(example) not in seen
133
+ )
134
+ return diverse[:limit]
135
+
136
+ return examples[:limit]
137
+
138
+
139
+ def make_user_message(user_query: str, tool_result_text: str) -> str:
140
+ parsed = json.loads(tool_result_text)
141
+ return user_query.strip() + "\n\nTool result:\n" + json.dumps(parsed, ensure_ascii=False, indent=2)
142
+
143
+
144
+ def clean_component_output(output: str) -> str:
145
+ output = output.strip()
146
+ fence = re.search(r"```(?:jsx|xml|openui|text)?\s*(.*?)```", output, flags=re.DOTALL | re.IGNORECASE)
147
+ if fence:
148
+ output = fence.group(1).strip()
149
+ root = re.search(r"(?m)^\s*root\s*=\s*Root\s*\(", output)
150
+ if root and root.start() > 0:
151
+ output = output[root.start() :].strip()
152
+ tag = re.search(r"<[A-Za-z][A-Za-z0-9]*(?:\s|>|/)", output)
153
+ if not root and tag and tag.start() > 0:
154
+ output = output[tag.start() :].strip()
155
+ return output
156
+
157
+
158
+ def ensure_adapter_ready(adapter: Path) -> None:
159
+ if not adapter.exists():
160
+ raise FileNotFoundError(
161
+ f"Adapter directory does not exist yet: {adapter}. Wait for training to create it, or pass --adapter."
162
+ )
163
+ if not (adapter / "adapter_config.json").exists():
164
+ checkpoints = sorted(adapter.glob("checkpoint-*/adapter_config.json"))
165
+ if checkpoints:
166
+ return
167
+ raise FileNotFoundError(
168
+ f"No adapter_config.json found in {adapter}. Wait until a checkpoint is saved, or pass a checkpoint path."
169
+ )
170
+
171
+
172
+ def resolve_adapter_path(adapter: Path) -> Path:
173
+ if (adapter / "adapter_config.json").exists():
174
+ return adapter
175
+ checkpoints = sorted(
176
+ [path.parent for path in adapter.glob("checkpoint-*/adapter_config.json")],
177
+ key=lambda path: int(re.search(r"checkpoint-(\d+)$", path.name).group(1)) if re.search(r"checkpoint-(\d+)$", path.name) else -1,
178
+ )
179
+ if checkpoints:
180
+ return checkpoints[-1]
181
+ return adapter
182
+
183
+
184
+ def load_model_once(model_name: str, adapter: Path, load_in_4bit: bool) -> tuple[Any, Any]:
185
+ global ACTIVE_MODEL_KEY, MODEL, TOKENIZER
186
+
187
+ adapter = resolve_adapter_path(adapter)
188
+ key = (model_name, str(adapter.resolve()), load_in_4bit)
189
+ if MODEL is not None and TOKENIZER is not None and ACTIVE_MODEL_KEY == key:
190
+ return MODEL, TOKENIZER
191
+
192
+ ensure_adapter_ready(adapter)
193
+
194
+ import torch
195
+ from peft import PeftModel
196
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
197
+
198
+ tokenizer = AutoTokenizer.from_pretrained(str(adapter) if (adapter / "tokenizer_config.json").exists() else model_name, trust_remote_code=True)
199
+ if tokenizer.pad_token is None:
200
+ tokenizer.pad_token = tokenizer.eos_token
201
+
202
+ quantization_config = None
203
+ if load_in_4bit:
204
+ quantization_config = BitsAndBytesConfig(
205
+ load_in_4bit=True,
206
+ bnb_4bit_quant_type="nf4",
207
+ bnb_4bit_compute_dtype=torch.bfloat16,
208
+ bnb_4bit_use_double_quant=True,
209
+ )
210
+
211
+ base_model = AutoModelForCausalLM.from_pretrained(
212
+ model_name,
213
+ trust_remote_code=True,
214
+ torch_dtype=torch.bfloat16 if load_in_4bit else "auto",
215
+ device_map="auto",
216
+ #quantization_config=quantization_config,
217
+ )
218
+ model = PeftModel.from_pretrained(base_model, adapter)
219
+ model.eval()
220
+
221
+ MODEL = model
222
+ TOKENIZER = tokenizer
223
+ ACTIVE_MODEL_KEY = key
224
+ return model, tokenizer
225
+
226
+
227
+ def generate_component(
228
+ user_query: str,
229
+ tool_result_text: str,
230
+ model_name: str,
231
+ adapter: Path,
232
+ max_new_tokens: int,
233
+ temperature: float,
234
+ top_p: float,
235
+ load_in_4bit: bool,
236
+ ) -> tuple[str, str, str]:
237
+ try:
238
+ user_content = make_user_message(user_query, tool_result_text)
239
+ except Exception as exc:
240
+ message = f"Invalid tool result JSON: {exc}"
241
+ return "", render_error(message), message
242
+
243
+ try:
244
+ model, tokenizer = load_model_once(model_name, adapter, load_in_4bit)
245
+ messages = [
246
+ {"role": "system", "content": SYSTEM_PROMPT},
247
+ {"role": "user", "content": user_content},
248
+ ]
249
+ prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
250
+ inputs = tokenizer(prompt, return_tensors="pt").to(next(model.parameters()).device)
251
+ input_len = inputs["input_ids"].shape[-1]
252
+ generation_kwargs = {
253
+ "max_new_tokens": max_new_tokens,
254
+ "do_sample": temperature > 0,
255
+ "eos_token_id": [tokenizer.eos_token_id, 130073],
256
+ "pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
257
+ }
258
+ if temperature > 0:
259
+ generation_kwargs["temperature"] = temperature
260
+ generation_kwargs["top_p"] = top_p
261
+
262
+ import torch
263
+
264
+ with torch.no_grad():
265
+ generated = model.generate(**inputs, **generation_kwargs)
266
+ output = clean_component_output(tokenizer.decode(generated[0, input_len:], skip_special_tokens=True))
267
+ return output, render_component_preview(output), "OK"
268
+ except Exception as exc:
269
+ message = str(exc)
270
+ return "", render_error(message), message
271
+
272
+
273
+ def extract_prop(component: str, name: str) -> str | None:
274
+ patterns = [
275
+ rf'{name}\s*=\s*"([^"]*)"',
276
+ rf"{name}\s*=\s*'([^']*)'",
277
+ rf"{name}\s*=\s*\{{([^{{}}]+)\}}",
278
+ ]
279
+ for pattern in patterns:
280
+ match = re.search(pattern, component, flags=re.DOTALL)
281
+ if match:
282
+ return match.group(1).strip()
283
+ return None
284
+
285
+
286
+ def extract_component_name(component: str) -> str:
287
+ match = re.search(r"<([A-Za-z][A-Za-z0-9]*)", component)
288
+ return match.group(1) if match else "OpenUI"
289
+
290
+
291
+ def extract_stat_cards(component: str) -> list[dict[str, str]]:
292
+ cards = []
293
+ for match in re.finditer(r"<StatCard\b(.*?)/>", component, flags=re.DOTALL):
294
+ raw = match.group(0)
295
+ cards.append(
296
+ {
297
+ "title": extract_prop(raw, "title") or "Stat",
298
+ "value": extract_prop(raw, "value") or "",
299
+ "unit": extract_prop(raw, "unit") or "",
300
+ }
301
+ )
302
+ return cards
303
+
304
+
305
+ def extract_data_rows(component: str, limit: int = 10) -> list[tuple[str, str]]:
306
+ rows = []
307
+ for label_key in ["label", "district", "month", "date", "office"]:
308
+ pattern = rf'{label_key}\s*:\s*"([^"]+)".*?value\s*:\s*(-?\d+(?:\.\d+)?)'
309
+ for label, value in re.findall(pattern, component, flags=re.DOTALL):
310
+ rows.append((label, value))
311
+ if len(rows) >= limit:
312
+ return rows
313
+ return rows
314
+
315
+
316
+ def extract_table_rows(component: str, limit: int = 8) -> list[dict[str, str]]:
317
+ rows = []
318
+ data_match = re.search(r"data\s*=\s*\{\s*\[(.*?)\]\s*\}", component, flags=re.DOTALL)
319
+ if not data_match:
320
+ return rows
321
+ for row_match in re.finditer(r"\{(.*?)\}", data_match.group(1), flags=re.DOTALL):
322
+ raw = row_match.group(1)
323
+ label = ""
324
+ for key in ["office", "label", "district", "month", "date"]:
325
+ prop = re.search(rf'{key}\s*:\s*"([^"]*)"', raw)
326
+ if prop:
327
+ label = prop.group(1)
328
+ break
329
+ value = re.search(r"value\s*:\s*(-?\d+(?:\.\d+)?)", raw)
330
+ unit = re.search(r'unit\s*:\s*"([^"]*)"', raw)
331
+ if label or value:
332
+ rows.append(
333
+ {
334
+ "label": label or "Row",
335
+ "value": value.group(1) if value else "",
336
+ "unit": unit.group(1) if unit else "",
337
+ }
338
+ )
339
+ if len(rows) >= limit:
340
+ break
341
+ return rows
342
+
343
+
344
+ def split_openui_args(args_text: str) -> list[str]:
345
+ args = []
346
+ start = 0
347
+ depth = 0
348
+ quote = None
349
+ escaped = False
350
+ for index, char in enumerate(args_text):
351
+ if escaped:
352
+ escaped = False
353
+ continue
354
+ if char == "\\" and quote:
355
+ escaped = True
356
+ continue
357
+ if char in {'"', "'"}:
358
+ if quote == char:
359
+ quote = None
360
+ elif quote is None:
361
+ quote = char
362
+ continue
363
+ if quote:
364
+ continue
365
+ if char in "([{":
366
+ depth += 1
367
+ elif char in ")]}":
368
+ depth -= 1
369
+ elif char == "," and depth == 0:
370
+ args.append(args_text[start:index].strip())
371
+ start = index + 1
372
+ tail = args_text[start:].strip()
373
+ if tail:
374
+ args.append(tail)
375
+ return args
376
+
377
+
378
+ def parse_openui_value(value: str) -> Any:
379
+ value = value.strip()
380
+ if value in {"None", "null"}:
381
+ return None
382
+ if value.startswith("[") and value.endswith("]"):
383
+ inner = value[1:-1].strip()
384
+ return [] if not inner else [parse_openui_value(part) for part in split_openui_args(inner)]
385
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value):
386
+ return {"$ref": value}
387
+ return json.loads(value)
388
+
389
+
390
+ def parse_openui_assignments(openui_lang: str) -> dict[str, dict[str, Any]]:
391
+ components: dict[str, dict[str, Any]] = {}
392
+ for raw_line in openui_lang.splitlines():
393
+ line = raw_line.strip()
394
+ if not line or line.startswith("//"):
395
+ continue
396
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([A-Za-z_][A-Za-z0-9_]*)\((.*)\)", line)
397
+ if not match:
398
+ raise ValueError(f"Invalid OpenUI line: {line}")
399
+ identifier, component_type, args_text = match.groups()
400
+ components[identifier] = {
401
+ "type": component_type,
402
+ "args": [parse_openui_value(part) for part in split_openui_args(args_text)],
403
+ }
404
+ if "root" not in components:
405
+ raise ValueError("Missing `root = Root([...])` component.")
406
+ return components
407
+
408
+
409
+ def render_openui_lang_preview(openui_lang: str) -> str:
410
+ components = parse_openui_assignments(openui_lang)
411
+ return render_openui_ref("root", components)
412
+
413
+
414
+ def render_openui_ref(ref: str | dict[str, str], components: dict[str, dict[str, Any]]) -> str:
415
+ if isinstance(ref, dict):
416
+ ref = ref["$ref"]
417
+ component = components.get(ref)
418
+ if not component:
419
+ return f'<div class="missing">Missing component: {escape(str(ref))}</div>'
420
+
421
+ ctype = component["type"]
422
+ args = component["args"]
423
+ if ctype == "Root":
424
+ children = args[0] if args else []
425
+ return f'<section class="preview openui-lang">{"".join(render_openui_ref(child, components) for child in children)}</section>'
426
+ if ctype == "InsightCard":
427
+ title = args[0] if args else "Insight"
428
+ body = args[1] if len(args) > 1 else ""
429
+ return f'<article class="insight"><h2>{escape(title)}</h2><p>{escape(body)}</p></article>'
430
+ if ctype == "Notice":
431
+ message = args[0] if args else ""
432
+ tone = args[1] if len(args) > 1 else "info"
433
+ return f'<article class="alert {escape(tone)}"><p>{escape(message)}</p></article>'
434
+ if ctype == "MetricGrid":
435
+ items = args[0] if args else []
436
+ return f'<div class="grid">{"".join(render_openui_ref(item, components) for item in items)}</div>'
437
+ if ctype == "Metric":
438
+ label = args[0] if args else "Metric"
439
+ value = args[1] if len(args) > 1 else ""
440
+ caption = args[2] if len(args) > 2 else ""
441
+ return (
442
+ '<div class="stat">'
443
+ f"<span>{escape(label)}</span>"
444
+ f"<strong>{escape(value)}</strong>"
445
+ f"<small>{escape(caption)}</small>"
446
+ "</div>"
447
+ )
448
+ if ctype == "DataTable":
449
+ title = args[0] if args else "Table"
450
+ rows = args[1] if len(args) > 1 and isinstance(args[1], list) else []
451
+ return render_openui_data_table(title, rows)
452
+ if ctype == "BarChart":
453
+ title = args[0] if args else "Chart"
454
+ x_column = args[1] if len(args) > 1 else "label"
455
+ y_column = args[2] if len(args) > 2 else "value"
456
+ rows = args[3] if len(args) > 3 and isinstance(args[3], list) else []
457
+ return render_openui_bar_chart(title, x_column, y_column, rows)
458
+ if ctype == "Histogram":
459
+ title = args[0] if args else "Histogram"
460
+ column = args[1] if len(args) > 1 else "value"
461
+ values = args[2] if len(args) > 2 and isinstance(args[2], list) else []
462
+ return render_openui_histogram(title, column, values)
463
+ return f'<div class="unsupported"><strong>{escape(ctype)}</strong><pre>{escape(json.dumps(args, ensure_ascii=False))}</pre></div>'
464
+
465
+
466
+ def render_openui_data_table(title: Any, rows: list[Any]) -> str:
467
+ columns = list(rows[0].keys()) if rows and isinstance(rows[0], dict) else []
468
+ head = "".join(f"<th>{escape(column)}</th>" for column in columns)
469
+ body = []
470
+ for row in rows:
471
+ if not isinstance(row, dict):
472
+ continue
473
+ body.append("<tr>" + "".join(f"<td>{escape(row.get(column, ''))}</td>" for column in columns) + "</tr>")
474
+ return f'<article class="table-preview"><h2>{escape(title)}</h2><table><thead><tr>{head}</tr></thead><tbody>{"".join(body)}</tbody></table></article>'
475
+
476
+
477
+ def render_openui_bar_chart(title: Any, x_column: Any, y_column: Any, rows: list[Any]) -> str:
478
+ pairs = []
479
+ for row in rows:
480
+ if not isinstance(row, dict):
481
+ continue
482
+ raw_value = row.get(str(y_column), 0)
483
+ try:
484
+ numeric = float(raw_value)
485
+ except (TypeError, ValueError):
486
+ numeric = 0.0
487
+ pairs.append((str(row.get(str(x_column), "")), numeric, str(raw_value)))
488
+ max_value = max([abs(value) for _, value, _ in pairs] or [1.0]) or 1.0
489
+ bars = []
490
+ for label, numeric, raw_value in pairs:
491
+ width = max(2.0, abs(numeric) / max_value * 100.0)
492
+ bars.append(
493
+ f"""
494
+ <div class="bar-row">
495
+ <span>{escape(label)}</span>
496
+ <div><i style="width:{width:.1f}%"></i></div>
497
+ <b>{escape(raw_value)}</b>
498
+ </div>
499
+ """
500
+ )
501
+ return f'<article class="chart-preview"><h2>{escape(title)}</h2><div class="bars">{"".join(bars)}</div></article>'
502
+
503
+
504
+ def render_openui_histogram(title: Any, column: Any, values: list[Any]) -> str:
505
+ numeric_values = []
506
+ for value in values:
507
+ try:
508
+ numeric_values.append(float(value))
509
+ except (TypeError, ValueError):
510
+ continue
511
+ if not numeric_values:
512
+ return f'<article class="chart-preview"><h2>{escape(title)}</h2><p>{escape(column)}: no numeric values</p></article>'
513
+ buckets = [0] * min(12, max(1, len(numeric_values)))
514
+ minimum = min(numeric_values)
515
+ span = max(numeric_values) - minimum or 1.0
516
+ for value in numeric_values:
517
+ index = min(len(buckets) - 1, int(((value - minimum) / span) * len(buckets)))
518
+ buckets[index] += 1
519
+ top = max(buckets) or 1
520
+ bars = "".join(
521
+ f'<div class="histogram-bar" title="{escape(count)}" style="height:{max(4.0, count / top * 100.0):.1f}%"></div>'
522
+ for count in buckets
523
+ )
524
+ return f'<article class="chart-preview"><h2>{escape(title)}</h2><p>{escape(column)}</p><div class="histogram">{bars}</div></article>'
525
+
526
+
527
+ def render_component_preview(component: str) -> str:
528
+ if not component.strip():
529
+ return render_error("No model output.")
530
+ if re.search(r"(?m)^\s*root\s*=\s*Root\s*\(", component):
531
+ try:
532
+ return render_openui_lang_preview(component)
533
+ except Exception as exc:
534
+ return render_error(str(exc))
535
+
536
+ component_name = extract_component_name(component)
537
+ title = extract_prop(component, "title") or component_name
538
+ value = extract_prop(component, "value")
539
+ unit = extract_prop(component, "unit") or ""
540
+ severity = extract_prop(component, "severity")
541
+ stat_cards = extract_stat_cards(component)
542
+ rows = extract_data_rows(component)
543
+ table_rows = extract_table_rows(component)
544
+
545
+ if component_name == "DashboardGrid" and stat_cards:
546
+ body = "".join(
547
+ f"""
548
+ <div class="stat">
549
+ <span>{escape(card["title"])}</span>
550
+ <strong>{escape(card["value"])}</strong>
551
+ <small>{escape(card["unit"])}</small>
552
+ </div>
553
+ """
554
+ for card in stat_cards
555
+ )
556
+ return f'<section class="preview"><h2>{escape(title)}</h2><div class="grid">{body}</div></section>'
557
+
558
+ if component_name == "ComparisonCard":
559
+ current_label = extract_prop(component, "currentLabel") or "Current"
560
+ previous_label = extract_prop(component, "previousLabel") or "Previous"
561
+ current_value = extract_prop(component, "currentValue") or ""
562
+ previous_value = extract_prop(component, "previousValue") or ""
563
+ delta_value = extract_prop(component, "deltaValue") or ""
564
+ direction = extract_prop(component, "deltaDirection") or ""
565
+ return f"""
566
+ <section class="preview">
567
+ <h2>{escape(title)}</h2>
568
+ <div class="grid">
569
+ <div class="stat"><span>{escape(current_label)}</span><strong>{escape(current_value)}</strong><small>{escape(unit)}</small></div>
570
+ <div class="stat"><span>{escape(previous_label)}</span><strong>{escape(previous_value)}</strong><small>{escape(unit)}</small></div>
571
+ <div class="stat"><span>Delta {escape(direction)}</span><strong>{escape(delta_value)}</strong><small>{escape(unit)}</small></div>
572
+ </div>
573
+ </section>
574
+ """
575
+
576
+ if component_name in {"LineChartCard", "BarChartCard", "HorizontalBarChartCard", "DistrictMapCard", "DistrictBarChartCard"} and rows:
577
+ max_value = max(abs(float(value)) for _, value in rows) or 1.0
578
+ bars = []
579
+ for label, raw_value in rows:
580
+ value_number = float(raw_value)
581
+ width = max(2.0, abs(value_number) / max_value * 100.0)
582
+ bars.append(
583
+ f"""
584
+ <div class="bar-row">
585
+ <span>{escape(label)}</span>
586
+ <div><i style="width:{width:.1f}%"></i></div>
587
+ <b>{escape(raw_value)} {escape(unit)}</b>
588
+ </div>
589
+ """
590
+ )
591
+ return f'<section class="preview"><h2>{escape(title)}</h2><div class="bars">{"".join(bars)}</div></section>'
592
+
593
+ if component_name == "AlertCard":
594
+ tone = "danger" if severity == "danger" else "warning" if severity == "warning" else "success"
595
+ threshold = extract_prop(component, "threshold")
596
+ description = extract_prop(component, "description") or ""
597
+ return (
598
+ f'<section class="preview alert {tone}"><h2>{escape(title)}</h2>'
599
+ f'<p><strong>{escape(value)}</strong> {escape(unit)} / Grenzwert {escape(threshold)}</p>'
600
+ f'<span>{escape(description)}</span></section>'
601
+ )
602
+
603
+ if component_name == "ProgressCard":
604
+ number = float(value or 0)
605
+ maximum = float(extract_prop(component, "max") or 100)
606
+ width = max(0.0, min(100.0, number / max(1.0, maximum) * 100.0))
607
+ description = extract_prop(component, "description") or ""
608
+ return (
609
+ f'<section class="preview"><h2>{escape(title)}</h2><div class="progress"><i style="width:{width:.1f}%"></i></div>'
610
+ f'<p><strong>{escape(value)}</strong>{escape(unit)} {escape(description)}</p></section>'
611
+ )
612
+
613
+ if component_name == "TableCard":
614
+ body = "".join(
615
+ f"<tr><td>{escape(row['label'])}</td><td>{escape(row['value'])}</td><td>{escape(row['unit'] or unit)}</td></tr>"
616
+ for row in table_rows
617
+ )
618
+ if not body:
619
+ body = '<tr><td colspan="3">Inspect raw component code for columns and rows.</td></tr>'
620
+ return f'<section class="preview"><h2>{escape(title)}</h2><table><tbody>{body}</tbody></table></section>'
621
+
622
+ return (
623
+ f'<section class="preview"><h2>{escape(title)}</h2>'
624
+ f'<p><strong>{escape(value)}</strong> {escape(unit)}</p>'
625
+ f'<small>{escape(component_name)}</small></section>'
626
+ )
627
+
628
+
629
+ def render_error(message: str) -> str:
630
+ return f'<section class="preview error"><h2>Error</h2><pre>{escape(message)}</pre></section>'
631
+
632
+
633
+ def escape(value: Any) -> str:
634
+ return html.escape("" if value is None else str(value))
635
+
636
+
637
+ def sample_to_gradio(example: DemoExample) -> list[str]:
638
+ return [
639
+ example.user_query,
640
+ json.dumps(example.tool_result, ensure_ascii=False, indent=2),
641
+ example.expected,
642
+ render_component_preview(example.expected),
643
+ example.label,
644
+ ]
645
+
646
+
647
+ def build_app(args: argparse.Namespace):
648
+ import gradio as gr
649
+
650
+ examples = load_examples(args.dataset)
651
+ initial = examples[0] if examples else DemoExample(
652
+ "Wie hoch war Sonnenstunden in München im Jahr 2022?",
653
+ {
654
+ "domain": "weather",
655
+ "metric": "Sonnenstunden",
656
+ "location": "München",
657
+ "year": 2022,
658
+ "aggregation": "sum",
659
+ "value": 2000,
660
+ "unit": "h",
661
+ },
662
+ "",
663
+ "manual",
664
+ )
665
+
666
+ css = """
667
+ .preview { border: 1px solid #d7dde8; border-radius: 8px; padding: 14px; background: #fff; color: #111827; }
668
+ .preview, .preview * { color: #111827; }
669
+ .preview h2 { margin: 0 0 10px; font-size: 18px; color: #0f172a; }
670
+ .preview p { color: #334155; }
671
+ .insight, .chart-preview, .table-preview { background: #fff; color: #111827; }
672
+ .insight { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin-bottom: 10px; }
673
+ .insight p, .chart-preview p { color: #334155; margin: 0 0 10px; }
674
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
675
+ .stat { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px; display: grid; gap: 3px; background: #f8fafc; }
676
+ .stat span, .stat small { color: #475569; font-size: 12px; }
677
+ .stat strong { color: #0f172a; font-size: 20px; }
678
+ .bar-row { display: grid; grid-template-columns: minmax(72px, 150px) 1fr minmax(72px, 120px); gap: 8px; align-items: center; margin: 8px 0; }
679
+ .bar-row span, .bar-row b { color: #1f2937; font-size: 12px; overflow-wrap: anywhere; }
680
+ .bar-row div, .progress { height: 14px; background: #e5e7eb; border-radius: 999px; overflow: hidden; }
681
+ .bar-row i, .progress i { display: block; height: 100%; background: #2563eb; border-radius: 999px; }
682
+ .alert.warning { border-color: #f59e0b; background: #fffbeb; }
683
+ .alert.danger { border-color: #ef4444; background: #fef2f2; }
684
+ .alert.success { border-color: #10b981; background: #ecfdf5; }
685
+ .preview table { width: 100%; border-collapse: collapse; }
686
+ .preview th { color: #111827; border-bottom: 1px solid #cbd5e1; padding: 7px 6px; font-size: 13px; text-align: left; }
687
+ .preview td { color: #1f2937; border-top: 1px solid #e5e7eb; padding: 7px 6px; font-size: 13px; }
688
+ .preview td:nth-child(2), .preview td:nth-child(3) { text-align: right; }
689
+ .histogram { display: flex; align-items: end; gap: 4px; height: 160px; padding-top: 8px; }
690
+ .histogram-bar { flex: 1; min-width: 8px; background: #2563eb; border-radius: 4px 4px 0 0; }
691
+ .error { border-color: #ef4444; background: #fef2f2; }
692
+ .error pre { white-space: pre-wrap; }
693
+ """
694
+
695
+ with gr.Blocks(title="OpenUI Adapter Playground") as demo:
696
+ gr.Markdown("# OpenUI Adapter Playground")
697
+ gr.Markdown(f"Adapter: `{args.adapter}`")
698
+ with gr.Row():
699
+ with gr.Column(scale=1):
700
+ query = gr.Textbox(label="User query", value=initial.user_query, lines=3)
701
+ tool_result = gr.Code(
702
+ label="Tool result JSON",
703
+ value=json.dumps(initial.tool_result, ensure_ascii=False, indent=2),
704
+ language="json",
705
+ lines=18,
706
+ )
707
+ with gr.Row():
708
+ max_new_tokens = gr.Slider(128, 3000, value=args.max_new_tokens, step=64, label="Max new tokens")
709
+ temperature = gr.Slider(0.0, 1.0, value=args.temperature, step=0.05, label="Temperature")
710
+ generate = gr.Button("Generate", variant="primary")
711
+ status = gr.Textbox(label="Status", interactive=False)
712
+ with gr.Column(scale=1):
713
+ output = gr.Code(label="Model OpenUI component code", language="javascript", lines=18)
714
+ preview = gr.HTML(label="Preview")
715
+
716
+ if examples:
717
+ gr.Examples(
718
+ examples=[sample_to_gradio(example) for example in examples],
719
+ inputs=[query, tool_result, output, preview, status],
720
+ label="Eval examples",
721
+ )
722
+
723
+ generate.click(
724
+ fn=lambda user_query, tool_json, max_tokens, temp: generate_component(
725
+ user_query=user_query,
726
+ tool_result_text=tool_json,
727
+ model_name=args.model_name,
728
+ adapter=args.adapter,
729
+ max_new_tokens=int(max_tokens),
730
+ temperature=float(temp),
731
+ top_p=args.top_p,
732
+ load_in_4bit=args.load_in_4bit,
733
+ ),
734
+ inputs=[query, tool_result, max_new_tokens, temperature],
735
+ outputs=[output, preview, status],
736
+ )
737
+
738
+ return demo, css
739
+
740
+
741
+ def main() -> int:
742
+ args = build_arg_parser().parse_args()
743
+ app, css = build_app(args)
744
+ app.launch(server_name=args.server_name, server_port=args.server_port, share=args.share, css=css)
745
+ return 0
746
+
747
+
748
+ if __name__ == "__main__":
749
+ raise SystemExit(main())
train/router/router_mlp.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ class RouterMLPConfig:
9
+ def __init__(
10
+ self,
11
+ *,
12
+ vocab_size: int,
13
+ embedding_dim: int,
14
+ hidden_dim: int,
15
+ num_labels: int,
16
+ dropout: float,
17
+ pad_token_id: int,
18
+ labels: list[str] | None = None,
19
+ ) -> None:
20
+ self.vocab_size = vocab_size
21
+ self.embedding_dim = embedding_dim
22
+ self.hidden_dim = hidden_dim
23
+ self.num_labels = num_labels
24
+ self.dropout = dropout
25
+ self.pad_token_id = pad_token_id
26
+ self.labels = labels or ["general_agent", "ckan_retrieval", "openui_translator"]
27
+
28
+ @classmethod
29
+ def from_dict(cls, payload: dict[str, Any]) -> "RouterMLPConfig":
30
+ return cls(
31
+ vocab_size=int(payload["vocab_size"]),
32
+ embedding_dim=int(payload["embedding_dim"]),
33
+ hidden_dim=int(payload["hidden_dim"]),
34
+ num_labels=int(payload["num_labels"]),
35
+ dropout=float(payload["dropout"]),
36
+ pad_token_id=int(payload["pad_token_id"]),
37
+ labels=list(payload.get("labels") or ["general_agent", "ckan_retrieval", "openui_translator"]),
38
+ )
39
+
40
+ @classmethod
41
+ def from_json(cls, path: str | Path) -> "RouterMLPConfig":
42
+ return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "vocab_size": self.vocab_size,
47
+ "embedding_dim": self.embedding_dim,
48
+ "hidden_dim": self.hidden_dim,
49
+ "num_labels": self.num_labels,
50
+ "dropout": self.dropout,
51
+ "pad_token_id": self.pad_token_id,
52
+ "labels": self.labels,
53
+ }
54
+
55
+
56
+ def build_router_mlp(config: RouterMLPConfig):
57
+ from torch import nn
58
+ import torch
59
+
60
+ class RouterMLP(nn.Module):
61
+ def __init__(self, cfg: RouterMLPConfig) -> None:
62
+ super().__init__()
63
+ self.config = cfg
64
+ self.embedding = nn.Embedding(cfg.vocab_size, cfg.embedding_dim, padding_idx=cfg.pad_token_id)
65
+ self.net = nn.Sequential(
66
+ nn.LayerNorm(cfg.embedding_dim),
67
+ nn.Linear(cfg.embedding_dim, cfg.hidden_dim),
68
+ nn.GELU(),
69
+ nn.Dropout(cfg.dropout),
70
+ nn.Linear(cfg.hidden_dim, cfg.num_labels),
71
+ )
72
+
73
+ def forward(self, input_ids, attention_mask=None, labels=None):
74
+ input_ids = input_ids.clamp(min=0, max=self.config.vocab_size - 1)
75
+ embeddings = self.embedding(input_ids)
76
+ if attention_mask is None:
77
+ pooled = embeddings.mean(dim=1)
78
+ else:
79
+ mask = attention_mask.unsqueeze(-1).to(embeddings.dtype)
80
+ pooled = (embeddings * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
81
+ logits = self.net(pooled)
82
+ loss = None
83
+ if labels is not None:
84
+ loss = torch.nn.functional.cross_entropy(logits, labels)
85
+ return {"loss": loss, "logits": logits}
86
+
87
+ return RouterMLP(config)
88
+
89
+
90
+ def load_router_mlp(output_dir: str | Path, *, map_location: str = "cpu"):
91
+ import torch
92
+
93
+ output_dir = Path(output_dir)
94
+ config = RouterMLPConfig.from_json(output_dir / "config.json")
95
+ model = build_router_mlp(config)
96
+ model.load_state_dict(torch.load(output_dir / "router_mlp.pt", map_location=map_location))
97
+ model.eval()
98
+ return model, config