Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion deeptutor/agents/visualize/agents/analysis_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,14 @@ async def process(
trace_kind="llm_output",
),
)
result = VisualizationAnalysis.model_validate(extract_json_object(raw))
data = extract_json_object(raw)
try:
result = VisualizationAnalysis.model_validate(data)
except Exception:
# LLM may produce an illegal visual_genre (e.g. "simulation").
# Fall back to empty string so generation doesn't hard-fail.
data["visual_genre"] = ""
result = VisualizationAnalysis.model_validate(data)
if render_mode in ("svg", "chartjs", "mermaid", "html"):
result.render_type = render_mode # type: ignore[assignment]
elif render_mode == "figure" and result.render_type not in (
Expand Down
2 changes: 1 addition & 1 deletion deeptutor/agents/visualize/prompts/en/analysis_agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ user_template_fixed: |
Return JSON:
{{
"render_type": "{render_type}",
"visual_genre": "the genre that best fits this {render_type} and the user's intent, or empty string",
"visual_genre": "the genre that best fits this {render_type} and the user's intent. Must be one of: flowchart | structural | illustrative | chart | stepper | interactive | mockup | art, or empty string if none fit",
"description": "High-level description of what the visualization should show",
"data_description": "Description of the data or elements to be visualized",
"chart_type": "Chart.js chart type if chartjs; Mermaid diagram type if mermaid; an interaction tag (interactive / animation / walkthrough / quiz) if html; otherwise empty string",
Expand Down
2 changes: 1 addition & 1 deletion deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ user_template_fixed: |
返回 JSON:
{{
"render_type": "{render_type}",
"visual_genre": "最贴合这个 {render_type} 和用户意图的 genre,或空字符串",
"visual_genre": "最贴合这个 {render_type} 和用户意图的 genre。必须是以下之一:flowchart / structural / illustrative / chart / stepper / interactive / mockup / art,没有合适的就留空 """,
"description": "可视化应展示的内容的高层描述",
"data_description": "要可视化的数据或元素的描述",
"chart_type": "如果是 chartjs 填 Chart.js 图表类型;如果是 mermaid 填 Mermaid 图表类型;如果是 html 填交互形式(interactive / animation / walkthrough / quiz);否则为空字符串",
Expand Down
145 changes: 145 additions & 0 deletions tests/agents/visualize/test_analysis_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""The analysis agent must not crash when the LLM returns an illegal
visual_genre — it should degrade to "" and recover gracefully."""

from __future__ import annotations

import json
from typing import Any

import pytest

from deeptutor.agents.base_agent import BaseAgent
from deeptutor.agents.visualize.agents.analysis_agent import AnalysisAgent
from deeptutor.agents.visualize.models import VisualizationAnalysis


def _llm_reply(visual_genre: str) -> str:
"""A well-formed analysis JSON with a caller-controlled visual_genre."""
return json.dumps(
{
"render_type": "svg",
"description": "a diagram",
"data_description": "",
"chart_type": "",
"visual_elements": ["box"],
"rationale": "test",
"visual_genre": visual_genre,
}
)


# Config-free prompt stubs — just enough for get_prompt() to return truthy values.
_FIXED_PROMPTS = {
"system_fixed": "You are a visualization analyst.",
"user_template_fixed": "Return JSON: {user_input} {history_context} {render_type}",
}

_AUTO_PROMPTS = {
"system": "You are a visualization analyst.",
"user_template": "Return JSON: {user_input} {history_context}",
}


def _install_agent_stubs(
monkeypatch: pytest.MonkeyPatch,
prompts: dict[str, str],
llm_reply: str,
) -> dict[str, Any]:
"""Mock the three things ``AnalysisAgent`` needs for a local test run:

1. ``get_agent_params`` — returns a dummy dict (avoids agents.yaml).
2. ``self.prompts`` — pre-populated after ``__init__`` finishes.
3. ``BaseAgent.call_llm`` — returns *llm_reply*, captures kwargs.
"""
# (1) Avoid FileNotFoundError in BaseAgent.__init__
monkeypatch.setattr(
"deeptutor.agents.base_agent.get_agent_params",
lambda _module_name: {},
)

# (2) After real __init__ runs, overwrite prompts with our stubs.
real_init = AnalysisAgent.__init__

def _patched_init(self: AnalysisAgent, **kwargs: Any) -> None:
real_init(self, **kwargs)
self.prompts = dict(prompts)

monkeypatch.setattr(AnalysisAgent, "__init__", _patched_init)

# (3) Replace the LLM call.
captured: dict[str, Any] = {}

async def _fake_call(self: BaseAgent, **kwargs: Any) -> str:
captured.update(kwargs)
return llm_reply

monkeypatch.setattr(BaseAgent, "call_llm", _fake_call)
return captured


# ── tests ───────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_invalid_visual_genre_falls_back_to_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The agent should return visual_genre="" instead of crashing."""
_install_agent_stubs(monkeypatch, _FIXED_PROMPTS, _llm_reply("simulation"))

result = await AnalysisAgent().process(
user_input="explain how a CPU works",
history_context="",
render_mode="svg",
)

assert isinstance(result, VisualizationAnalysis)
assert result.visual_genre == ""


@pytest.mark.asyncio
async def test_valid_visual_genre_passes_through(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Valid values should not be touched by the fallback."""
_install_agent_stubs(monkeypatch, _FIXED_PROMPTS, _llm_reply("interactive"))

result = await AnalysisAgent().process(
user_input="interactive demo of a pendulum",
history_context="",
render_mode="html",
)

assert result.visual_genre == "interactive"


@pytest.mark.asyncio
async def test_fixed_render_mode_preserves_render_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When visual_genre is illegal, fixed render_type is not affected."""
_install_agent_stubs(monkeypatch, _FIXED_PROMPTS, _llm_reply("simulation"))

result = await AnalysisAgent().process(
user_input="draw a flowchart",
history_context="",
render_mode="svg",
)

assert result.render_type == "svg"


@pytest.mark.asyncio
async def test_auto_mode_survives_bad_genre(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The fallback works in auto mode (the default) too."""
_install_agent_stubs(monkeypatch, _AUTO_PROMPTS, _llm_reply("simulation"))

result = await AnalysisAgent().process(
user_input="help me understand neural networks",
history_context="",
)

assert isinstance(result, VisualizationAnalysis)
assert result.visual_genre == ""