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.
- deeptutor/agents/visualize/__init__.py +5 -0
- deeptutor/agents/visualize/agents/__init__.py +11 -0
- deeptutor/agents/visualize/agents/analysis_agent.py +102 -0
- deeptutor/agents/visualize/agents/code_generator_agent.py +117 -0
- deeptutor/agents/visualize/agents/review_agent.py +75 -0
- deeptutor/agents/visualize/capability.py +571 -0
- deeptutor/agents/visualize/models.py +120 -0
- deeptutor/agents/visualize/pipeline.py +91 -0
- deeptutor/agents/visualize/prompts/en/analysis_agent.yaml +132 -0
- deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml +189 -0
- deeptutor/agents/visualize/prompts/en/review_agent.yaml +38 -0
- deeptutor/agents/visualize/prompts/en/visualize.yaml +14 -0
- deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml +123 -0
- deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml +161 -0
- deeptutor/agents/visualize/prompts/zh/review_agent.yaml +35 -0
- deeptutor/agents/visualize/prompts/zh/visualize.yaml +14 -0
- deeptutor/agents/visualize/utils.py +210 -0
- deeptutor/api/routers/visualizers.py +144 -0
- deeptutor/visualizers/README.md +79 -0
- deeptutor/visualizers/__init__.py +20 -0
- deeptutor/visualizers/builtin.py +369 -0
- deeptutor/visualizers/loop_capability.py +125 -0
- deeptutor/visualizers/protocol.py +225 -0
- deeptutor/visualizers/registry.py +155 -0
- deeptutor/visualizers/store.py +260 -0
- deeptutor/visualizers/tool.py +150 -0
- deeptutor_visualize-1.7.0.dist-info/METADATA +6 -0
- deeptutor_visualize-1.7.0.dist-info/RECORD +30 -0
- deeptutor_visualize-1.7.0.dist-info/WHEEL +5 -0
- 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
|
+
)
|