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.
- simit/__init__.py +39 -0
- simit/api.py +258 -0
- simit/backends/__init__.py +112 -0
- simit/backends/bagel.py +359 -0
- simit/backends/base.py +114 -0
- simit/backends/hf.py +336 -0
- simit/backends/imagegen.py +136 -0
- simit/backends/lance.py +338 -0
- simit/backends/mot/engine.py +844 -0
- simit/backends/mot/kernels.py +130 -0
- simit/backends/mot/modeling.py +514 -0
- simit/backends/mot/wan_vae.py +872 -0
- simit/backends/vllm.py +204 -0
- simit/config.py +112 -0
- simit/metrics.py +113 -0
- simit/pipeline/core.py +586 -0
- simit/pipeline/prompts.py +167 -0
- simit/skills/__init__.py +6 -0
- simit/skills/assets/MERMAID_LICENSE +21 -0
- simit/skills/assets/mermaid.min.js +3587 -0
- simit/skills/base.py +172 -0
- simit/skills/builtin.py +614 -0
- simit/skills/helpers.py +197 -0
- simit/skills/pool.py +262 -0
- simit/skills/prompts.py +2374 -0
- simit/skills/renderers.py +1292 -0
- simit/skills/routing.py +89 -0
- simit/skills/sandbox.py +188 -0
- simit/skills/web.py +253 -0
- simit/tune.py +323 -0
- simit/types.py +96 -0
- simit/utils.py +67 -0
- simit-0.1.0.dist-info/METADATA +388 -0
- simit-0.1.0.dist-info/RECORD +38 -0
- simit-0.1.0.dist-info/WHEEL +5 -0
- simit-0.1.0.dist-info/licenses/LICENSE +202 -0
- simit-0.1.0.dist-info/licenses/src/simit/skills/assets/MERMAID_LICENSE +21 -0
- simit-0.1.0.dist-info/top_level.txt +1 -0
simit/skills/builtin.py
ADDED
|
@@ -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]
|