simit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,614 @@
1
+ """The built-in skill library (17 visualization categories).
2
+
3
+ Each skill mirrors one category of the SIMIT pipeline: a narrow
4
+ specification prompt, a parser, and a deterministic renderer. ``natural``
5
+ uses the model's own (or a tool) image generator instead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import base64
12
+ import io
13
+ import json
14
+ import math
15
+ import re
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from PIL import Image, ImageDraw, ImageFont
21
+
22
+ from ..utils import CJK_RE, png_bytes
23
+ from . import prompts as P
24
+ from .base import Realization, Skill, SpecError, extract_fenced_block
25
+ from .routing import BUILTIN_DEFINITIONS
26
+
27
+
28
+ def _render_file(fn, *args, suffix: str = ".png") -> Image.Image:
29
+ """Run a renderer that writes to a path and load the result."""
30
+ with tempfile.TemporaryDirectory(prefix="simit_render_") as tmp:
31
+ out = str(Path(tmp) / f"out{suffix}")
32
+ try:
33
+ fn(*args, out)
34
+ except SpecError:
35
+ raise
36
+ except Exception as e: # deterministic renderers raise ValueError & co on bad specs
37
+ raise SpecError(str(e) or type(e).__name__) from None
38
+ image = Image.open(out)
39
+ image.load()
40
+ return image
41
+
42
+
43
+ def _png(data: bytes) -> Image.Image:
44
+ image = Image.open(io.BytesIO(data))
45
+ image.load()
46
+ return image
47
+
48
+
49
+ class _Builtin(Skill):
50
+ """Built-in skills take their router definition from the original prompt."""
51
+
52
+ repair_template: str = ""
53
+
54
+ def __init__(self):
55
+ if not self.route_hint:
56
+ self.route_hint = BUILTIN_DEFINITIONS[self.label]
57
+
58
+ def build_repair_prompt(self, request, previous, reason):
59
+ return self.repair_template.format(request=request, reason=reason)
60
+
61
+
62
+ # --------------------------------------------------------------------- natural
63
+ class NaturalSkill(_Builtin):
64
+ """Photos/illustrations of real-world content: native image generation."""
65
+
66
+ name = "natural"
67
+ verify_rubric = "strict"
68
+ # Body-part / botanical descriptions are routed here before asking the
69
+ # model, which often mistakes them for GEOMETRY.
70
+ route_pattern = re.compile(
71
+ r'\b(?:foot|feet|hand|palm|finger|thumb|toe|knee|ankle|elbow|wrist|shoulder|hip'
72
+ r'|eye|nose|mouth|ear|neck|chest|spine|rib|skull|bone|muscle|skin'
73
+ r'|brain|lung|liver|kidney|stomach|intestine|artery|vein|nerve'
74
+ r'|leaf|leaves|petal|sepal|flower|blossom|bloom'
75
+ r'|moss|algae|fern|mushroom|fungi|pollen|spore'
76
+ r'|fur|feather|claw|hoof|paw|snout|beak|gill|whisker)\b',
77
+ re.IGNORECASE,
78
+ )
79
+ route_exclude_pattern = re.compile(
80
+ r'\b(?:diagram|schematic|anatomical|cross.?section|anatomy|flowchart|food\s+chain)\b', re.IGNORECASE)
81
+
82
+ async def realize(self, ctx, request, retries=2):
83
+ image = await ctx.generate_image(request)
84
+ return None if image is None else Realization(image, self.name, spec_text=request)
85
+
86
+
87
+ # ----------------------------------------------------------- DSL-based skills
88
+ class DiagramSkill(_Builtin):
89
+ name = "diagram"
90
+ prompt = P.DIAGRAM_DSL_PROMPT
91
+ repair_template = P.DSL_REPAIR_PROMPT
92
+ retry_temperature = 0.0
93
+
94
+ def parse(self, text):
95
+ from .renderers import parse_dsl
96
+ nodes, edges = parse_dsl(text)
97
+ if not nodes:
98
+ raise SpecError("no NODE: lines found")
99
+ return nodes, edges
100
+
101
+ def render(self, spec):
102
+ from .renderers import render_flowchart
103
+ return _render_file(render_flowchart, *spec)
104
+
105
+
106
+ class GraphSkill(_Builtin):
107
+ name = "graph"
108
+ prompt = P.GRAPH_DSL_PROMPT
109
+ repair_template = P.GRAPH_REPAIR_PROMPT
110
+ retry_temperature = 0.0
111
+
112
+ def parse(self, text):
113
+ from .renderers import parse_graph_dsl
114
+ nodes, edges = parse_graph_dsl(text)
115
+ if not nodes:
116
+ raise SpecError("no NODE: lines found")
117
+ return nodes, edges
118
+
119
+ def render(self, spec):
120
+ from .renderers import render_graph
121
+ return _render_file(render_graph, *spec)
122
+
123
+
124
+ class MoleculeSkill(_Builtin):
125
+ name = "molecule"
126
+ prompt = P.MOLECULE_PROMPT
127
+ max_new_tokens = 128
128
+ retry_temperature = 0.0
129
+
130
+ def shortcut(self, request):
131
+ # The model's chemistry recall is unreliable even for common molecules.
132
+ from .renderers import lookup_common_molecule
133
+ return lookup_common_molecule(request)
134
+
135
+ def parse(self, text):
136
+ from rdkit import Chem, RDLogger
137
+ from .renderers import parse_molecule
138
+ smiles, title = parse_molecule(text)
139
+ if not smiles:
140
+ raise SpecError("missing")
141
+ RDLogger.DisableLog("rdApp.*")
142
+ if Chem.MolFromSmiles(smiles) is None:
143
+ raise SpecError("RDKit could not parse it")
144
+ return smiles, title
145
+
146
+ def build_repair_prompt(self, request, previous, reason):
147
+ from .renderers import parse_molecule
148
+ smiles = parse_molecule(previous)[0] or "(none)"
149
+ return P.MOLECULE_REPAIR_PROMPT.format(request=request, smiles=smiles, reason=reason)
150
+
151
+ def render(self, spec):
152
+ from rdkit import Chem
153
+ from rdkit.Chem import Draw
154
+ smiles, title = spec
155
+ mol = Chem.MolFromSmiles(smiles)
156
+ if mol is None:
157
+ raise SpecError("RDKit could not parse it")
158
+ return Draw.MolToImage(mol, size=(450, 450), legend=title)
159
+
160
+
161
+ class CircuitSkill(_Builtin):
162
+ name = "circuit"
163
+ prompt = P.CIRCUIT_DSL_PROMPT
164
+ repair_template = P.CIRCUIT_REPAIR_PROMPT
165
+ max_new_tokens = 256
166
+ retry_temperature = 0.0
167
+
168
+ def parse(self, text):
169
+ from .renderers import parse_circuit_dsl
170
+ slots = parse_circuit_dsl(text)
171
+ if not slots:
172
+ raise SpecError("no COMPONENT: lines found")
173
+ return slots
174
+
175
+ def render(self, spec):
176
+ from .renderers import render_circuit
177
+ return _render_file(render_circuit, spec)
178
+
179
+
180
+ class VectorSkill(_Builtin):
181
+ name = "vector"
182
+ prompt = P.VECTOR_DSL_PROMPT
183
+ repair_template = P.VECTOR_REPAIR_PROMPT
184
+ retry_temperature = 0.0
185
+
186
+ def parse(self, text):
187
+ from .renderers import parse_vector_dsl
188
+ origin_box, vectors = parse_vector_dsl(text)
189
+ if not vectors:
190
+ raise SpecError("no VECTOR: lines found")
191
+ return origin_box, vectors
192
+
193
+ def render(self, spec):
194
+ from .renderers import render_vectors
195
+ return _render_file(render_vectors, *spec)
196
+
197
+
198
+ class VennSkill(_Builtin):
199
+ name = "venn"
200
+ prompt = P.VENN_DSL_PROMPT
201
+ repair_template = P.VENN_REPAIR_PROMPT
202
+ retry_temperature = 0.0
203
+
204
+ def parse(self, text):
205
+ from .renderers import parse_venn_dsl
206
+ fields = parse_venn_dsl(text)
207
+ if not fields["SET_A"] or not fields["SET_B"]:
208
+ raise SpecError("missing SET_A or SET_B")
209
+ return fields
210
+
211
+ def render(self, spec):
212
+ from .renderers import render_venn
213
+ return _render_file(render_venn, spec)
214
+
215
+
216
+ class TableSkill(_Builtin):
217
+ name = "table"
218
+ prompt = P.TABLE_DSL_PROMPT
219
+ repair_template = P.TABLE_REPAIR_PROMPT
220
+ retry_temperature = 0.0
221
+
222
+ def parse(self, text):
223
+ from .renderers import parse_table_dsl
224
+ title, columns, rows = parse_table_dsl(text)
225
+ if not columns or not rows:
226
+ raise SpecError("missing COLUMNS or ROW lines")
227
+ return title, columns, rows
228
+
229
+ def render(self, spec):
230
+ from .renderers import render_table
231
+ return _render_file(render_table, *spec)
232
+
233
+
234
+ class SceneSkill(_Builtin):
235
+ name = "scene"
236
+ prompt = P.SCENE_DSL_PROMPT
237
+ repair_template = P.SCENE_REPAIR_PROMPT
238
+ retry_temperature = 0.0
239
+
240
+ def parse(self, text):
241
+ from .renderers import parse_scene_dsl
242
+ title, items = parse_scene_dsl(text)
243
+ if not items:
244
+ raise SpecError("no SHAPE: lines found")
245
+ return title, items
246
+
247
+ def render(self, spec):
248
+ from .renderers import render_scene
249
+ return _render_file(render_scene, *spec)
250
+
251
+
252
+ class PuzzleSkill(_Builtin):
253
+ name = "puzzle"
254
+ prompt = P.PUZZLE_DSL_PROMPT
255
+ repair_template = P.PUZZLE_REPAIR_PROMPT
256
+ retry_temperature = 0.0
257
+
258
+ def parse(self, text):
259
+ from .renderers import parse_puzzle_dsl
260
+ spec = parse_puzzle_dsl(text)
261
+ if not spec["layout"] or not spec["panels"]:
262
+ raise SpecError("missing LAYOUT or PANEL lines")
263
+ return spec
264
+
265
+ def render(self, spec):
266
+ from .renderers import render_puzzle
267
+ return _render_file(render_puzzle, spec)
268
+
269
+
270
+ class GeometrySkill(_Builtin):
271
+ """Points and references only; every length/angle/area is computed by the
272
+ renderer from coordinates."""
273
+
274
+ name = "geometry"
275
+ prompt = P.GEOMETRY_DSL_PROMPT
276
+ max_new_tokens = 768
277
+
278
+ def build_repair_prompt(self, request, previous, reason):
279
+ return P.GEOMETRY_REPAIR_PROMPT.format(request=request, previous=previous, reason=reason)
280
+
281
+ async def realize(self, ctx, request, retries=2):
282
+ from .renderers import parse_geometry_dsl
283
+ prompt = self.build_prompt(request)
284
+ for attempt in range(retries + 1):
285
+ temperature = self.first_temperature if attempt == 0 else self.retry_temperature
286
+ cleaned = self.clean(await ctx.generate(prompt, max_new_tokens=self.max_new_tokens,
287
+ temperature=temperature))
288
+ # Corruption often hits only a few lines: drop them and keep the
289
+ # rest, unless so much is lost that a retry is the better bet.
290
+ raw = cleaned.splitlines()
291
+ usable = [ln for ln in raw if not CJK_RE.search(ln)]
292
+ if len(raw) - len(usable) > len(raw) / 3 and attempt < retries:
293
+ prompt = self.build_repair_prompt(request, cleaned, "your answer contained non-English characters")
294
+ continue
295
+ cleaned = "\n".join(usable)
296
+ commands = parse_geometry_dsl(cleaned)
297
+ if not commands:
298
+ prompt = self.build_repair_prompt(request, cleaned, "no structure lines found")
299
+ continue
300
+ try:
301
+ image = await ctx.render(self, commands)
302
+ except SpecError as e:
303
+ prompt = self.build_repair_prompt(request, cleaned, str(e))
304
+ continue
305
+ return Realization(image, self.name, spec_text=cleaned, attempts=attempt + 1)
306
+ return None
307
+
308
+ def render(self, spec):
309
+ from .renderers import render_geometry
310
+ return _render_file(render_geometry, spec)
311
+
312
+
313
+ # ------------------------------------------------------------- code-based skills
314
+ class FigureSkill(_Builtin):
315
+ """Charts and other numeric figures: the model writes matplotlib code."""
316
+
317
+ name = "figure"
318
+ prompt = P.MASTER_PROMPT
319
+ max_new_tokens = 1536
320
+
321
+ async def realize(self, ctx, request, retries=2):
322
+ from .sandbox import extract_code, find_known_api_issues, find_non_ascii_issue, is_safe
323
+ prompt = self.build_prompt(request)
324
+ for attempt in range(retries + 1):
325
+ temperature = self.first_temperature if attempt == 0 else self.retry_temperature
326
+ code = extract_code(self.clean(await ctx.generate(prompt, max_new_tokens=self.max_new_tokens,
327
+ temperature=temperature)))
328
+ if code is None:
329
+ prompt = self.build_prompt(request)
330
+ continue
331
+ if not is_safe(code):
332
+ ctx.log("[figure] generated code failed the safety check; not running it")
333
+ return None
334
+ issue = find_non_ascii_issue(code) or find_known_api_issues(code)
335
+ if issue:
336
+ prompt = P.REPAIR_PROMPT.format(request=request, code=code, error=issue)
337
+ continue
338
+ try:
339
+ image = await ctx.render(self, code)
340
+ except SpecError as e:
341
+ prompt = P.REPAIR_PROMPT.format(request=request, code=code, error=str(e))
342
+ continue
343
+ return Realization(image, self.name, spec_text=code, attempts=attempt + 1)
344
+ return None
345
+
346
+ def render(self, spec):
347
+ from .sandbox import run_script
348
+ image, error = run_script(spec)
349
+ if image is None:
350
+ raise SpecError(error or "unknown error")
351
+ return image
352
+
353
+
354
+ class SvgSkill(_Builtin):
355
+ name = "svg"
356
+ prompt = P.SVG_PROMPT
357
+ repair_template = P.SVG_REPAIR_PROMPT
358
+ max_new_tokens = 1024
359
+
360
+ def parse(self, text):
361
+ code = extract_fenced_block(text)
362
+ if code is None:
363
+ raise SpecError("no SVG code block found in response")
364
+ if "<svg" not in code:
365
+ raise SpecError("no <svg> element found in the code block")
366
+ return code[code.index("<svg"):]
367
+
368
+ def render(self, spec):
369
+ from .web import render_svg
370
+ return _png(render_svg(spec))
371
+
372
+
373
+ class MermaidSkill(_Builtin):
374
+ name = "mermaid"
375
+ prompt = P.MERMAID_PROMPT
376
+ repair_template = P.MERMAID_REPAIR_PROMPT
377
+ max_new_tokens = 768
378
+
379
+ def parse(self, text):
380
+ code = extract_fenced_block(text)
381
+ if code is None:
382
+ raise SpecError("no fenced code block found")
383
+ return code
384
+
385
+ def render(self, spec):
386
+ from .web import render_mermaid
387
+ return _png(render_mermaid(spec))
388
+
389
+
390
+ _VEGALITE_SCHEME_FIXES = {"yelloworange": "yelloworangered", "redyellow": "redyellowblue", "bluered": "redblue"}
391
+ _MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
392
+
393
+
394
+ def _clean_spec_strings(obj):
395
+ if isinstance(obj, str):
396
+ return CJK_RE.sub("", obj).strip().rstrip("((")
397
+ if isinstance(obj, dict):
398
+ return {k: _clean_spec_strings(v) for k, v in obj.items()}
399
+ if isinstance(obj, list):
400
+ return [_clean_spec_strings(v) for v in obj]
401
+ return obj
402
+
403
+
404
+ def _fix_vegalite_spec(spec: dict) -> dict:
405
+ """Patch common model mistakes: invalid scheme names, 'area' used for a
406
+ heatmap, months sorted alphabetically."""
407
+ text = json.dumps(spec)
408
+ for wrong, right in _VEGALITE_SCHEME_FIXES.items():
409
+ text = text.replace(f'"scheme": "{wrong}"', f'"scheme": "{right}"')
410
+ spec = json.loads(text)
411
+ try:
412
+ enc = spec.get("encoding", {})
413
+ if isinstance(enc, dict):
414
+ typ = lambda k: enc.get(k, {}).get("type", "") if isinstance(enc.get(k), dict) else "" # noqa: E731
415
+ if (spec.get("mark") == "area" and typ("x") in ("ordinal", "nominal")
416
+ and typ("y") in ("ordinal", "nominal") and typ("color") == "quantitative"):
417
+ spec["mark"] = "rect"
418
+ data = spec.get("data", {})
419
+ values = data.get("values", []) if isinstance(data, dict) else []
420
+ month_fields = {f for row in values if isinstance(row, dict) for f, v in row.items() if v in _MONTHS}
421
+ if month_fields:
422
+ def add_sort(e):
423
+ for axis in ("x", "y"):
424
+ ax = e.get(axis, {})
425
+ if isinstance(ax, dict) and ax.get("field") in month_fields and "sort" not in ax:
426
+ ax["sort"] = _MONTHS
427
+ if isinstance(spec.get("encoding"), dict):
428
+ add_sort(spec["encoding"])
429
+ for layer in spec.get("layer", []):
430
+ if isinstance(layer, dict) and isinstance(layer.get("encoding"), dict):
431
+ add_sort(layer["encoding"])
432
+ except Exception:
433
+ pass
434
+ return spec
435
+
436
+
437
+ class VegaLiteSkill(_Builtin):
438
+ name = "vegalite"
439
+ prompt = P.VEGALITE_PROMPT
440
+ repair_template = P.VEGALITE_REPAIR_PROMPT
441
+ max_new_tokens = 1536
442
+
443
+ def parse(self, text):
444
+ block = extract_fenced_block(text)
445
+ if block is None:
446
+ raise SpecError("no JSON code block found")
447
+ try:
448
+ spec = json.loads(block)
449
+ except json.JSONDecodeError as e:
450
+ raise SpecError(f"invalid JSON: {e}") from None
451
+ if not isinstance(spec, dict):
452
+ raise SpecError("the top-level JSON must be an object, not an array")
453
+ return _fix_vegalite_spec(_clean_spec_strings(spec))
454
+
455
+ def render(self, spec):
456
+ from .web import render_vegalite
457
+ return _png(render_vegalite(spec))
458
+
459
+
460
+ # A CJK run inside CSS often carries a glued-on English "translation" word
461
+ # (e.g. "#3498db 获取acquire"); drop the run together with that word.
462
+ _CJK_WITH_ORPHAN_RE = re.compile(r"[一-鿿]+[A-Za-z]*")
463
+
464
+
465
+ def _clean_html(html: str) -> str:
466
+ """Strip CJK contamination and stray fence markers from model HTML."""
467
+ lines = html.splitlines()
468
+ while lines and lines[0].strip().startswith("```"):
469
+ lines = lines[1:]
470
+ html = "\n".join(lines)
471
+ html = re.sub(r'style="([^"]*)"', lambda m: f'style="{_CJK_WITH_ORPHAN_RE.sub("", m.group(1))}"', html)
472
+ return CJK_RE.sub("", html)
473
+
474
+
475
+ class HtmlSkill(_Builtin):
476
+ name = "html"
477
+ prompt = P.HTML_PROMPT
478
+ repair_template = P.HTML_REPAIR_PROMPT
479
+ max_new_tokens = 1536
480
+
481
+ def parse(self, text):
482
+ html = extract_fenced_block(text)
483
+ if html is None:
484
+ if "<html" in text.lower() or "<body" in text.lower():
485
+ html = text
486
+ else:
487
+ raise SpecError("no HTML code block found in response")
488
+ html = _clean_html(html)
489
+ if "<!DOCTYPE" not in html and "<html" not in html.lower():
490
+ html = f"<!DOCTYPE html><html><body style='margin:20px;font-family:sans-serif'>{html}</body></html>"
491
+ return html
492
+
493
+ def render(self, spec):
494
+ from .web import render_html
495
+ return _png(render_html(spec))
496
+
497
+
498
+ # ------------------------------------------------------------------ composite
499
+ _PANEL_SIZE = 280
500
+
501
+
502
+ def _gray_panel(label: str, size: int = _PANEL_SIZE) -> bytes:
503
+ img = Image.new("RGB", (size, size), color="#e8e8e8")
504
+ draw = ImageDraw.Draw(img)
505
+ draw.rectangle([2, 2, size - 3, size - 3], outline="#aaaaaa", width=2)
506
+ try:
507
+ font = ImageFont.load_default(size=32)
508
+ except TypeError: # Pillow < 10.1
509
+ font = ImageFont.load_default()
510
+ draw.text((size // 2, size // 2), label or "?", fill="#666666", anchor="mm", font=font)
511
+ return png_bytes(img)
512
+
513
+
514
+ class CompositeSkill(_Builtin):
515
+ """N labeled sub-images in a grid; each panel is realized recursively by
516
+ whichever skill fits it, then composed as an HTML page."""
517
+
518
+ name = "html_composite"
519
+ prompt = P.HTML_COMPOSITE_PROMPT
520
+ repair_template = P.HTML_COMPOSITE_REPAIR_PROMPT
521
+ max_new_tokens = 768
522
+ route_priority = 1 # checked before natural's pattern
523
+ # Multi-panel labeled image grids are routed here before asking the model.
524
+ route_pattern = re.compile(
525
+ r'(?:'
526
+ r'\b(?:image|photo|picture)s?\s+(?:labeled\s+|showing\s+)?[A-Da-d1-5]'
527
+ r'|\bpanels?\s+(?:labeled|showing)\s+(?:[A-Da-d1-5]|different)'
528
+ r'|\bpanel\s+[A-Da-d1-5]\s+(?:shows?|is\b|depicts?|contains?)'
529
+ r'|\b(?:three|four|five|[2-5])\s+(?:image|photo|picture)s?\b'
530
+ r'|labeled\s+[A-Da-d]\s+through\s+[A-Da-d]'
531
+ r'|labeled\s+[A-Da-d][-–][A-Da-d]'
532
+ r'|\b(?:left|right|top|bottom)\s+panel\b.{0,60}labeled\s+[A-Da-d]'
533
+ r')',
534
+ re.IGNORECASE,
535
+ )
536
+
537
+ def parse(self, text):
538
+ title, panels = "", []
539
+ for line in text.splitlines():
540
+ line = line.strip()
541
+ if line.upper().startswith("TITLE:"):
542
+ title = line[6:].strip()
543
+ elif line.upper().startswith("PANEL:"):
544
+ content = line[6:].strip()
545
+ if "|" in content:
546
+ desc, label = content.rsplit("|", 1)
547
+ panels.append((desc.strip(), label.strip()))
548
+ else:
549
+ panels.append((content, ""))
550
+ if not panels:
551
+ raise SpecError("no PANEL: lines found")
552
+ return title, panels
553
+
554
+ async def realize(self, ctx, request, retries=2):
555
+ prompt = self.build_prompt(request)
556
+ layout = None
557
+ for attempt in range(retries + 1):
558
+ temperature = self.first_temperature if attempt == 0 else self.retry_temperature
559
+ text = self.clean(await ctx.generate(prompt, max_new_tokens=self.max_new_tokens,
560
+ temperature=temperature))
561
+ try:
562
+ layout = self.parse(text)
563
+ break
564
+ except SpecError as e:
565
+ prompt = self.build_repair_prompt(request, text, str(e))
566
+ if layout is None:
567
+ return None
568
+ title, panels = layout
569
+ subs = await asyncio.gather(*[
570
+ ctx.realize(desc, retries=1, allow_composite=False, size=_PANEL_SIZE) for desc, _ in panels
571
+ ])
572
+ rendered = []
573
+ for (desc, label), sub in zip(panels, subs):
574
+ rendered.append((png_bytes(sub.image) if sub is not None else _gray_panel(label), label))
575
+ image = await ctx.render(self, {"title": title, "panels": rendered})
576
+ return Realization(image, self.name, spec_text=text, attempts=attempt + 1,
577
+ extra={"panels": [(d, l, s.skill if s else None) for (d, l), s in zip(panels, subs)]})
578
+
579
+ def render(self, spec):
580
+ from .web import render_html
581
+ title, panels = spec["title"], spec["panels"]
582
+ n = len(panels)
583
+ n_cols = min(4, n)
584
+ n_rows = math.ceil(n / n_cols)
585
+ width = n_cols * (_PANEL_SIZE + 30) + 60
586
+ height = 80 + (50 if title else 0) + n_rows * (_PANEL_SIZE + 60) + 40
587
+ divs = ""
588
+ for data, label in panels:
589
+ b64 = base64.b64encode(data).decode()
590
+ label_html = f'<div class="label">{label}</div>' if label else ""
591
+ divs += f'\n<div class="panel"><img src="data:image/png;base64,{b64}" alt="{label}">{label_html}</div>'
592
+ title_html = f'<h2 style="text-align:center;margin:0 0 16px;font-size:20px">{title}</h2>' if title else ""
593
+ html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><style>
594
+ body {{ background:#fff; margin:20px; font-family:Arial,sans-serif; }}
595
+ .grid {{ display:flex; flex-wrap:wrap; gap:20px; justify-content:center; align-items:flex-start; }}
596
+ .panel {{ text-align:center; }}
597
+ .panel img {{ width:{_PANEL_SIZE}px; height:{_PANEL_SIZE}px; object-fit:contain;
598
+ border:1px solid #ccc; background:#fafafa; display:block; }}
599
+ .label {{ font-size:22px; font-weight:bold; margin-top:8px; color:#111; }}
600
+ </style></head><body>{title_html}<div class="grid">{divs}
601
+ </div></body></html>"""
602
+ return _png(render_html(html, width=width, height=height))
603
+
604
+
605
+ BUILTIN_SKILLS = [
606
+ NaturalSkill, DiagramSkill, GraphSkill, MoleculeSkill, CircuitSkill, VectorSkill, VennSkill,
607
+ TableSkill, SceneSkill, PuzzleSkill, GeometrySkill, FigureSkill, MermaidSkill, SvgSkill,
608
+ VegaLiteSkill, HtmlSkill, CompositeSkill,
609
+ ]
610
+
611
+
612
+ def default_skills() -> list[Skill]:
613
+ """Fresh instances of the 17 built-in skills, in router order."""
614
+ return [cls() for cls in BUILTIN_SKILLS]