deeptutor-visualize 1.7.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.
Files changed (30) hide show
  1. deeptutor/agents/visualize/__init__.py +5 -0
  2. deeptutor/agents/visualize/agents/__init__.py +11 -0
  3. deeptutor/agents/visualize/agents/analysis_agent.py +102 -0
  4. deeptutor/agents/visualize/agents/code_generator_agent.py +117 -0
  5. deeptutor/agents/visualize/agents/review_agent.py +75 -0
  6. deeptutor/agents/visualize/capability.py +571 -0
  7. deeptutor/agents/visualize/models.py +120 -0
  8. deeptutor/agents/visualize/pipeline.py +91 -0
  9. deeptutor/agents/visualize/prompts/en/analysis_agent.yaml +132 -0
  10. deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml +189 -0
  11. deeptutor/agents/visualize/prompts/en/review_agent.yaml +38 -0
  12. deeptutor/agents/visualize/prompts/en/visualize.yaml +14 -0
  13. deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml +123 -0
  14. deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml +161 -0
  15. deeptutor/agents/visualize/prompts/zh/review_agent.yaml +35 -0
  16. deeptutor/agents/visualize/prompts/zh/visualize.yaml +14 -0
  17. deeptutor/agents/visualize/utils.py +210 -0
  18. deeptutor/api/routers/visualizers.py +144 -0
  19. deeptutor/visualizers/README.md +79 -0
  20. deeptutor/visualizers/__init__.py +20 -0
  21. deeptutor/visualizers/builtin.py +369 -0
  22. deeptutor/visualizers/loop_capability.py +125 -0
  23. deeptutor/visualizers/protocol.py +225 -0
  24. deeptutor/visualizers/registry.py +155 -0
  25. deeptutor/visualizers/store.py +260 -0
  26. deeptutor/visualizers/tool.py +150 -0
  27. deeptutor_visualize-1.7.0.dist-info/METADATA +6 -0
  28. deeptutor_visualize-1.7.0.dist-info/RECORD +30 -0
  29. deeptutor_visualize-1.7.0.dist-info/WHEEL +5 -0
  30. deeptutor_visualize-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,571 @@
1
+ """Plugin-driven visualization capability built on the shared chat loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from deeptutor.agents._shared.capability_result import emit_capability_result
10
+ from deeptutor.core.capability_protocol import TurnCapability
11
+ from deeptutor.core.context import UnifiedContext
12
+ from deeptutor.core.trace import merge_trace_metadata
13
+ from deeptutor.i18n import StatusI18n
14
+ from deeptutor.runtime.agentic.usage import UsageTracker
15
+ from deeptutor.runtime.bootstrap.builtin_capabilities import builtin_capability_manifest
16
+ from deeptutor.runtime.request_contracts import (
17
+ VisualizeRequestConfig,
18
+ validate_visualize_request_config,
19
+ )
20
+ from deeptutor.runtime.stream_bus import StreamBus
21
+ from deeptutor.services.config.capabilities_settings import get_visualize_params
22
+ from deeptutor.visualizers.protocol import (
23
+ REQUESTED_VISUALIZER_KEY,
24
+ VISUALIZATION_RESULT_KEY,
25
+ VISUALIZE_MODE_KEY,
26
+ )
27
+ from deeptutor.visualizers.registry import get_visualizer_registry
28
+ from deeptutor.visualizers.store import VisualizerStoreError
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Default (English) copy for the tool-calling diagnosis, kept beside the code
33
+ # that emits it. StatusI18n prefers the localized strings in this capability's
34
+ # prompt files and falls back to these.
35
+ _TOOL_CALLING_DISABLED_WARNING = (
36
+ "Tool calling is disabled for {target}, but a visualization only reaches "
37
+ "the canvas through the submit_visualization tool call. Enable it in "
38
+ "Settings → Models → LLM → Capabilities → Tool calling → Supported."
39
+ )
40
+ _NO_PAYLOAD_TOOL_CALLING_DISABLED = (
41
+ "No visualization was committed: tool calling is disabled for {target}, so "
42
+ "the model could not call submit_visualization — the only way to hand a "
43
+ "payload to the canvas. Local servers (LM Studio, Ollama, vLLM, llama.cpp) "
44
+ "are opted out by default; declare the model as tool-capable in "
45
+ "Settings → Models → LLM → Capabilities → Tool calling → Supported, "
46
+ "then run the visualization again."
47
+ )
48
+ _NO_PAYLOAD = (
49
+ "The visualization agent finished without a valid canvas payload. A "
50
+ "reasoning model can spend most of its output budget on internal "
51
+ "thinking before the submit_visualization call is even written, which "
52
+ "cuts the call off mid-argument. If this repeats, raise Max tokens for "
53
+ "Visualize in Settings → Capabilities → Visualize."
54
+ )
55
+
56
+ # Stages exposed in the manifest. The first three cover the text-emitting
57
+ # path (svg/chartjs/mermaid/html); the rest cover the manim subprocess
58
+ # path. A given turn only streams a subset of these.
59
+ _VISUALIZE_STAGES = [
60
+ "analyzing",
61
+ "generating",
62
+ "reviewing",
63
+ "concept_analysis",
64
+ "concept_design",
65
+ "code_generation",
66
+ "code_retry",
67
+ "summary",
68
+ "render_output",
69
+ ]
70
+
71
+ _MANIM_RENDER_TYPES = {"manim_video", "manim_image"}
72
+
73
+ # Visualizer packages contribute model-facing payload documentation. Keep the
74
+ # reused chat loop's read/grounding surface, but never let package instructions
75
+ # unlock state-changing built-ins such as exec, write_memory, write_note, cron,
76
+ # github or deferred tool loading.
77
+ _VISUALIZE_SAFE_BUILTINS = (
78
+ "rag",
79
+ "kb_files",
80
+ "read_source",
81
+ "read_memory",
82
+ "list_notebook",
83
+ "read_skill",
84
+ "web_fetch",
85
+ "ask_user",
86
+ )
87
+
88
+
89
+ class VisualizeCapability(TurnCapability):
90
+ manifest = builtin_capability_manifest("visualize")
91
+
92
+ async def run(self, context: UnifiedContext, stream: StreamBus) -> None:
93
+ request_config = validate_visualize_request_config(context.config_overrides)
94
+ render_mode = request_config.render_mode
95
+ i18n = StatusI18n(self.name, context.language, module="visualize")
96
+ registry = get_visualizer_registry()
97
+
98
+ history_context = str(context.metadata.get("conversation_context_text", "") or "").strip()
99
+ async with stream.stage("analyzing", source=self.name):
100
+ await stream.progress(
101
+ i18n.t("analyzing", "Analyzing visualization requirements..."),
102
+ source=self.name,
103
+ stage="analyzing",
104
+ )
105
+ if render_mode != "auto" and registry.get(render_mode) is None:
106
+ available = ", ".join(plugin.manifest.id for plugin in registry.installed())
107
+ raise VisualizerStoreError(
108
+ f"visualizer '{render_mode}' is not installed or is disabled. "
109
+ f"Available: {available or 'none'}"
110
+ )
111
+
112
+ # Artifact renderers retain their specialized subprocess pipeline.
113
+ if render_mode in _MANIM_RENDER_TYPES:
114
+ from deeptutor.services.llm.config import get_llm_config
115
+
116
+ llm_config = get_llm_config()
117
+ await self._run_manim_path(
118
+ context=context,
119
+ stream=stream,
120
+ render_type=render_mode,
121
+ visualize_config=request_config,
122
+ history_context=history_context,
123
+ usage=UsageTracker(model=getattr(llm_config, "model", None)),
124
+ i18n=i18n,
125
+ )
126
+ return
127
+
128
+ # The generic capability only marks the turn and owns the final
129
+ # envelope. Generation, retrieval, repair and tool dispatch all run in
130
+ # the shared Chat Engine through VisualizationLoopCapability.
131
+ from deeptutor.agents.chat.agentic_pipeline import AgenticChatPipeline
132
+
133
+ # A visualization only reaches the canvas through the
134
+ # submit_visualization tool call, so a provider that is never handed
135
+ # tool schemas has no way to commit one: it answers in prose, and the
136
+ # loop capability's output policy discards that prose. Local
137
+ # OpenAI-compatible servers (LM Studio, Ollama, vLLM, llama.cpp) are
138
+ # opted out of native tools by default, which used to surface only as
139
+ # a bare "no valid canvas payload" error after a full five-round loop.
140
+ # Say so up front, and keep the finding for the failure message.
141
+ #
142
+ # This deliberately does not abort the turn: the DSML fallback still
143
+ # recovers text-format tool calls from some local DeepSeek
144
+ # deployments, so a run without native tools can still succeed.
145
+ tool_calling_disabled_for = self._tool_calling_disabled_for()
146
+ if tool_calling_disabled_for:
147
+ await stream.progress(
148
+ i18n.t(
149
+ "tool_calling_disabled",
150
+ _TOOL_CALLING_DISABLED_WARNING,
151
+ target=tool_calling_disabled_for,
152
+ ),
153
+ source=self.name,
154
+ stage="generating",
155
+ metadata={"trace_kind": "warning"},
156
+ )
157
+
158
+ context.metadata[VISUALIZE_MODE_KEY] = True
159
+ context.metadata[REQUESTED_VISUALIZER_KEY] = render_mode
160
+ context.metadata.pop(VISUALIZATION_RESULT_KEY, None)
161
+ if context.allowed_builtin_tools is None:
162
+ context.allowed_builtin_tools = list(_VISUALIZE_SAFE_BUILTINS)
163
+ else:
164
+ allowed = set(context.allowed_builtin_tools)
165
+ context.allowed_builtin_tools = [
166
+ name for name in _VISUALIZE_SAFE_BUILTINS if name in allowed
167
+ ]
168
+ # Read the visualize budget instead of stating it here: a reasoning
169
+ # model can spend most of one round on chain-of-thought, which truncated
170
+ # the submit_visualization arguments mid-JSON and left a blank canvas
171
+ # with no error (#1546). The defaults are the values this call used to
172
+ # hardcode, so an untouched install behaves exactly as before.
173
+ try:
174
+ params = get_visualize_params()
175
+ except Exception as exc: # pragma: no cover - defensive config read
176
+ logger.warning("Failed to load visualize params, using defaults: %s", exc)
177
+ params = {}
178
+ pipeline = AgenticChatPipeline(
179
+ language=context.language,
180
+ max_rounds=5,
181
+ temperature=params.get("temperature", 0.15),
182
+ max_tokens=params.get("max_tokens", 16000),
183
+ event_source=self.name,
184
+ event_stage="generating",
185
+ emit_result=False,
186
+ )
187
+ loop_result = await pipeline.run(context, stream)
188
+ envelope = context.metadata.get(VISUALIZATION_RESULT_KEY)
189
+ if not isinstance(envelope, dict):
190
+ if tool_calling_disabled_for:
191
+ raise RuntimeError(
192
+ i18n.t(
193
+ "no_payload_tool_calling_disabled",
194
+ _NO_PAYLOAD_TOOL_CALLING_DISABLED,
195
+ target=tool_calling_disabled_for,
196
+ )
197
+ )
198
+ raise RuntimeError(i18n.t("no_payload", _NO_PAYLOAD))
199
+
200
+ payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
201
+ data = payload.get("data")
202
+ plugin = registry.get(str(envelope.get("render_type") or ""))
203
+ language_tag = plugin.manifest.language_tag if plugin is not None else "text"
204
+ serialized = (
205
+ data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, indent=2)
206
+ )
207
+ content_md = f"```{language_tag}\n{serialized}\n```"
208
+ await stream.content(content_md, source=self.name, stage="reviewing")
209
+
210
+ result = {
211
+ **envelope,
212
+ # Legacy compatibility for existing consumers while they migrate
213
+ # to renderer/payload/presentation.
214
+ "response": content_md,
215
+ "code": {"language": language_tag, "content": serialized},
216
+ "analysis": {
217
+ "render_type": envelope.get("render_type"),
218
+ "description": (envelope.get("presentation") or {}).get("description", ""),
219
+ "engine": "chat_agent_loop",
220
+ "requested_type": render_mode,
221
+ },
222
+ "review": {
223
+ "changed": False,
224
+ "review_notes": "Accepted by the installed visualizer validator.",
225
+ },
226
+ "engine": "chat_agent_loop",
227
+ "loop": loop_result,
228
+ }
229
+ await emit_capability_result(
230
+ stream,
231
+ result,
232
+ source=self.name,
233
+ usage=pipeline.usage,
234
+ )
235
+
236
+ @staticmethod
237
+ def _tool_calling_disabled_for() -> str:
238
+ """``"binding/model"`` when this turn gets no tool schemas, else ``""``.
239
+
240
+ Mirrors the resolution ``AgenticChatPipeline`` performs for the same
241
+ LLM config, so the warning and the failure message describe the loop
242
+ that actually ran. Probing must never be why a visualization fails, so
243
+ an unreadable config reports "enabled" and leaves the generic
244
+ diagnosis in place.
245
+ """
246
+ try:
247
+ from deeptutor.runtime.agentic.client import can_use_native_tool_calling
248
+ from deeptutor.services.llm.config import get_llm_config
249
+
250
+ llm_config = get_llm_config()
251
+ binding = str(getattr(llm_config, "binding", None) or "openai")
252
+ model = str(getattr(llm_config, "model", None) or "")
253
+ if can_use_native_tool_calling(binding=binding, model=model or None):
254
+ return ""
255
+ return f"{binding}/{model}" if model else binding
256
+ except Exception: # pragma: no cover - defensive: never block a turn
257
+ logger.debug("Tool-calling probe unavailable for visualize", exc_info=True)
258
+ return ""
259
+
260
+ async def _run_manim_path(
261
+ self,
262
+ *,
263
+ context: UnifiedContext,
264
+ stream: StreamBus,
265
+ render_type: str,
266
+ visualize_config: VisualizeRequestConfig,
267
+ history_context: str,
268
+ usage: UsageTracker | None = None,
269
+ i18n: StatusI18n | None = None,
270
+ ) -> None:
271
+ """
272
+ Manim sub-pipeline. Mirrors ``MathAnimatorCapability.run`` but emits
273
+ the final result with ``render_type`` as the discriminator so the
274
+ unified frontend dispatcher can route to ``MathAnimatorViewer``.
275
+ """
276
+ import importlib.util
277
+ import time
278
+
279
+ from deeptutor.modules.catalog import require_module
280
+
281
+ require_module("renderer-manim")
282
+ if importlib.util.find_spec("manim") is None:
283
+ raise RuntimeError(
284
+ "Manim rendering requires optional dependencies. "
285
+ "Install with `pip install 'deeptutor[math-animator]'` "
286
+ "or `pip install -r requirements/math-animator.txt`."
287
+ )
288
+
289
+ from deeptutor.agents.math_animator.pipeline import MathAnimatorPipeline
290
+ from deeptutor.agents.math_animator.request_config import MathAnimatorRequestConfig
291
+ from deeptutor.core.trace import build_trace_metadata, new_call_id
292
+ from deeptutor.services.llm.config import get_llm_config
293
+
294
+ if i18n is None:
295
+ i18n = StatusI18n(self.name, context.language, module="visualize")
296
+ output_mode = "image" if render_type == "manim_image" else "video"
297
+ request_config = MathAnimatorRequestConfig(
298
+ output_mode=output_mode, # type: ignore[arg-type]
299
+ quality=visualize_config.quality,
300
+ style_hint=visualize_config.style_hint,
301
+ )
302
+
303
+ llm_config = get_llm_config()
304
+ workspace = context.runtime.workspace
305
+ pipeline = MathAnimatorPipeline(
306
+ api_key=llm_config.api_key,
307
+ base_url=llm_config.base_url,
308
+ api_version=llm_config.api_version,
309
+ language=context.language,
310
+ trace_callback=self._build_trace_bridge(stream, i18n=i18n),
311
+ workspace_output_dir=workspace.output_dir if workspace else None,
312
+ workspace_root=workspace.root if workspace else None,
313
+ workspace_id=workspace.workspace_id if workspace else "",
314
+ )
315
+
316
+ timings: dict[str, float] = {}
317
+ turn_id = str(
318
+ context.metadata.get("turn_id", "") or context.session_id or "visualize-manim"
319
+ )
320
+ render_call_meta = build_trace_metadata(
321
+ call_id=new_call_id("manim-render"),
322
+ phase="render_output",
323
+ label="Render output",
324
+ call_kind="math_render_output",
325
+ trace_role="render",
326
+ trace_kind="progress",
327
+ output_mode=request_config.output_mode,
328
+ quality=request_config.quality,
329
+ )
330
+
331
+ stage_start = time.perf_counter()
332
+ async with stream.stage("concept_analysis", source=self.name):
333
+ analysis = await pipeline.run_analysis(
334
+ user_input=context.user_message,
335
+ history_context=history_context,
336
+ request_config=request_config,
337
+ attachments=context.attachments,
338
+ )
339
+ timings["concept_analysis"] = round(time.perf_counter() - stage_start, 3)
340
+
341
+ stage_start = time.perf_counter()
342
+ async with stream.stage("concept_design", source=self.name):
343
+ design = await pipeline.run_design(
344
+ user_input=context.user_message,
345
+ request_config=request_config,
346
+ analysis=analysis,
347
+ )
348
+ timings["concept_design"] = round(time.perf_counter() - stage_start, 3)
349
+
350
+ stage_start = time.perf_counter()
351
+ async with stream.stage("code_generation", source=self.name):
352
+ generated = await pipeline.run_code_generation(
353
+ user_input=context.user_message,
354
+ request_config=request_config,
355
+ analysis=analysis,
356
+ design=design,
357
+ )
358
+ await stream.progress(
359
+ message=i18n.t("manim_code_prepared", "Manim code prepared."),
360
+ source=self.name,
361
+ stage="code_generation",
362
+ )
363
+ timings["code_generation"] = round(time.perf_counter() - stage_start, 3)
364
+
365
+ async def _on_retry(retry_attempt) -> None:
366
+ await stream.progress(
367
+ message=i18n.t(
368
+ "manim_retry",
369
+ f"Retry {retry_attempt.attempt}: {retry_attempt.error}",
370
+ attempt=retry_attempt.attempt,
371
+ error=retry_attempt.error,
372
+ ),
373
+ source=self.name,
374
+ stage="code_retry",
375
+ metadata={**render_call_meta, "trace_layer": "raw"},
376
+ )
377
+
378
+ async def _on_render_progress(message: str, raw: bool) -> None:
379
+ await stream.progress(
380
+ message=message,
381
+ source=self.name,
382
+ stage="render_output",
383
+ metadata={
384
+ **render_call_meta,
385
+ "trace_layer": "raw" if raw else "summary",
386
+ },
387
+ )
388
+
389
+ async def _on_retry_status(message: str) -> None:
390
+ await stream.progress(
391
+ message=message,
392
+ source=self.name,
393
+ stage="code_retry",
394
+ metadata={"trace_layer": "summary"},
395
+ )
396
+
397
+ stage_start = time.perf_counter()
398
+ async with stream.stage("code_retry", source=self.name):
399
+ await stream.progress(
400
+ message=i18n.t(
401
+ "manim_rendering",
402
+ (
403
+ f"Rendering {request_config.output_mode} "
404
+ f"with quality={request_config.quality}."
405
+ ),
406
+ mode=request_config.output_mode,
407
+ quality=request_config.quality,
408
+ ),
409
+ source=self.name,
410
+ stage="code_retry",
411
+ metadata={**render_call_meta, "call_state": "running"},
412
+ )
413
+ final_code, render_result = await pipeline.run_render(
414
+ turn_id=turn_id,
415
+ user_input=context.user_message,
416
+ request_config=request_config,
417
+ initial_code=generated.code,
418
+ on_retry=_on_retry,
419
+ on_render_progress=_on_render_progress,
420
+ on_retry_status=_on_retry_status,
421
+ )
422
+ timings["code_retry"] = round(time.perf_counter() - stage_start, 3)
423
+
424
+ stage_start = time.perf_counter()
425
+ async with stream.stage("summary", source=self.name):
426
+ summary = await pipeline.run_summary(
427
+ user_input=context.user_message,
428
+ request_config=request_config,
429
+ analysis=analysis,
430
+ design=design,
431
+ render_result=render_result,
432
+ )
433
+ if summary.summary_text:
434
+ await stream.content(summary.summary_text, source=self.name, stage="summary")
435
+ timings["summary"] = round(time.perf_counter() - stage_start, 3)
436
+
437
+ async with stream.stage("render_output", source=self.name):
438
+ artifact_count = len(render_result.artifacts)
439
+ artifact_key = "manim_artifacts_one" if artifact_count == 1 else "manim_artifacts_many"
440
+ await stream.progress(
441
+ message=i18n.t(
442
+ artifact_key,
443
+ (
444
+ f"Prepared {artifact_count} "
445
+ f"{'artifact' if artifact_count == 1 else 'artifacts'}."
446
+ ),
447
+ count=artifact_count,
448
+ ),
449
+ source=self.name,
450
+ stage="render_output",
451
+ metadata={**render_call_meta, "call_state": "complete"},
452
+ )
453
+ timings["render_output"] = 0.0
454
+ visual_review = getattr(render_result, "visual_review", None)
455
+ workspace_items = list(getattr(render_result, "workspace_items", []) or [])
456
+ if workspace_items:
457
+ await stream.sources(
458
+ [{"type": "workspace_item", **item} for item in workspace_items],
459
+ source=self.name,
460
+ stage="render_output",
461
+ )
462
+
463
+ await emit_capability_result(
464
+ stream,
465
+ {
466
+ "response": summary.summary_text,
467
+ "render_type": render_type,
468
+ "summary": summary.model_dump(),
469
+ "code": {
470
+ "language": "python",
471
+ "content": final_code,
472
+ },
473
+ "output_mode": request_config.output_mode,
474
+ "artifacts": [artifact.model_dump() for artifact in render_result.artifacts],
475
+ "workspace_items": workspace_items,
476
+ "timings": timings,
477
+ "render": {
478
+ "quality": request_config.quality,
479
+ "retry_attempts": render_result.retry_attempts,
480
+ "retry_history": [item.model_dump() for item in render_result.retry_history],
481
+ "source_code_path": render_result.source_code_path,
482
+ "visual_review": visual_review.model_dump() if visual_review else None,
483
+ },
484
+ "analysis": analysis.model_dump(),
485
+ "design": design.model_dump(),
486
+ },
487
+ source=self.name,
488
+ usage=usage,
489
+ )
490
+
491
+ def _build_trace_bridge(self, stream: StreamBus, i18n: StatusI18n | None = None):
492
+ async def _trace_bridge(update: dict[str, Any]) -> None:
493
+ event = str(update.get("event", "") or "")
494
+ stage = str(update.get("phase") or update.get("stage") or "analyzing")
495
+ base_metadata = {
496
+ key: value
497
+ for key, value in update.items()
498
+ if key
499
+ not in {"event", "state", "response", "chunk", "result", "tool_name", "tool_args"}
500
+ }
501
+
502
+ if event != "llm_call":
503
+ return
504
+
505
+ state = str(update.get("state", "running"))
506
+ label = str(base_metadata.get("label", "") or stage.replace("_", " ").title())
507
+ if state == "running":
508
+ await stream.progress(
509
+ message=label,
510
+ source=self.name,
511
+ stage=stage,
512
+ metadata=merge_trace_metadata(
513
+ base_metadata,
514
+ {"trace_kind": "call_status", "call_state": "running"},
515
+ ),
516
+ )
517
+ return
518
+ if state == "streaming":
519
+ chunk = str(update.get("chunk", "") or "")
520
+ if chunk:
521
+ await stream.thinking(
522
+ chunk,
523
+ source=self.name,
524
+ stage=stage,
525
+ metadata=merge_trace_metadata(
526
+ base_metadata,
527
+ {"trace_kind": "llm_chunk"},
528
+ ),
529
+ )
530
+ return
531
+ if state == "complete":
532
+ was_streaming = update.get("streaming", False)
533
+ if not was_streaming:
534
+ response = str(update.get("response", "") or "")
535
+ if response:
536
+ await stream.thinking(
537
+ response,
538
+ source=self.name,
539
+ stage=stage,
540
+ metadata=merge_trace_metadata(
541
+ base_metadata,
542
+ {"trace_kind": "llm_output"},
543
+ ),
544
+ )
545
+ await stream.progress(
546
+ message=label,
547
+ source=self.name,
548
+ stage=stage,
549
+ metadata=merge_trace_metadata(
550
+ base_metadata,
551
+ {"trace_kind": "call_status", "call_state": "complete"},
552
+ ),
553
+ )
554
+ return
555
+ if state == "error":
556
+ fallback = (
557
+ i18n.t("llm_call_failed", "LLM call failed.")
558
+ if i18n is not None
559
+ else "LLM call failed."
560
+ )
561
+ await stream.error(
562
+ str(update.get("response", "") or fallback),
563
+ source=self.name,
564
+ stage=stage,
565
+ metadata=merge_trace_metadata(
566
+ base_metadata,
567
+ {"trace_kind": "call_status", "call_state": "error"},
568
+ ),
569
+ )
570
+
571
+ return _trace_bridge
@@ -0,0 +1,120 @@
1
+ """Data models for the visualize pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Literal, get_args
7
+
8
+ from pydantic import BaseModel, Field, model_validator
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ RenderType = Literal[
13
+ "svg",
14
+ "chartjs",
15
+ "mermaid",
16
+ "mindmap",
17
+ "html",
18
+ "manim_video",
19
+ "manim_image",
20
+ ]
21
+
22
+ VisualGenre = Literal[
23
+ "",
24
+ "flowchart",
25
+ "structural",
26
+ "illustrative",
27
+ "chart",
28
+ "stepper",
29
+ "interactive",
30
+ "mockup",
31
+ "art",
32
+ ]
33
+
34
+
35
+ class VisualizationAnalysis(BaseModel):
36
+ """Output of the analysis stage."""
37
+
38
+ render_type: RenderType = Field(
39
+ description=(
40
+ "Render output: raw SVG, a Chart.js configuration, a Mermaid "
41
+ "diagram, a self-contained interactive HTML page, or a Manim "
42
+ "animation (video) / storyboard image."
43
+ ),
44
+ )
45
+ description: str = Field(
46
+ default="",
47
+ description="High-level description of what the visualization should show.",
48
+ )
49
+ data_description: str = Field(
50
+ default="",
51
+ description="Description of the data or elements to be visualized.",
52
+ )
53
+ chart_type: str = Field(
54
+ default="",
55
+ description=(
56
+ "Chart.js chart type (bar, line, pie, doughnut, radar, etc.) when render_type is chartjs, "
57
+ "Mermaid diagram type (flowchart, sequenceDiagram, mindmap, classDiagram, stateDiagram, etc.) "
58
+ "when render_type is mermaid, or a short interaction tag (e.g. 'interactive', 'animation', "
59
+ "'walkthrough') when render_type is html."
60
+ ),
61
+ )
62
+ visual_elements: list[str] = Field(
63
+ default_factory=list,
64
+ description="Key visual elements to include (shapes, labels, axes, colors, etc.).",
65
+ )
66
+ rationale: str = Field(
67
+ default="",
68
+ description="Why this render_type was chosen over the alternative.",
69
+ )
70
+ visual_genre: VisualGenre = Field(
71
+ default="",
72
+ description=(
73
+ "Teaching-oriented sub-type that drives the code-generation style, "
74
+ "routed on the user's intent (the verb), not the topic (the noun): "
75
+ "'flowchart'/'structural' for reference maps, 'illustrative' for "
76
+ "intuition/'how does X work' spatial metaphors, 'stepper' for "
77
+ "cyclic or staged walkthroughs, 'chart' for quantitative data, "
78
+ "'interactive'/'mockup'/'art' for the matching HTML/SVG experiences. "
79
+ "Empty when no sub-type applies."
80
+ ),
81
+ )
82
+
83
+ @model_validator(mode="before")
84
+ @classmethod
85
+ def _drop_off_enum_values(cls, data: Any) -> Any:
86
+ """Degrade an invented enum value instead of failing the whole render.
87
+
88
+ Models regularly answer with a genre or render type that is not in the
89
+ prompt's list ("simulation", "diagram"). Both fields are enums, so
90
+ validation used to abort generation over a label that no downstream
91
+ stage strictly needs — the genre only selects a code-generation style,
92
+ and svg is the universal fallback render.
93
+ """
94
+ if not isinstance(data, dict):
95
+ return data
96
+ genre = data.get("visual_genre")
97
+ if genre is not None and genre not in get_args(VisualGenre):
98
+ logger.warning("Discarding unknown visual_genre %r", genre)
99
+ data = {**data, "visual_genre": ""}
100
+ render_type = data.get("render_type")
101
+ if render_type is not None and render_type not in get_args(RenderType):
102
+ logger.warning("Falling back to svg for unknown render_type %r", render_type)
103
+ data = {**data, "render_type": "svg"}
104
+ return data
105
+
106
+
107
+ class ReviewResult(BaseModel):
108
+ """Output of the review / optimization stage."""
109
+
110
+ optimized_code: str = Field(
111
+ description="The final (potentially optimized) visualization code.",
112
+ )
113
+ changed: bool = Field(
114
+ default=False,
115
+ description="Whether the reviewer made modifications.",
116
+ )
117
+ review_notes: str = Field(
118
+ default="",
119
+ description="Notes on what was checked or changed.",
120
+ )