From 7ff8fcc8f3d154681b74bc3805056927eeb4e8bb Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sun, 24 May 2026 23:02:20 +0700 Subject: [PATCH] refactor: type visual tool requirements - add typed visual tool capability and requirement contracts for visual intent decisions - route direct and Code Studio tool pruning through the shared requirement contract - cover chart, app, artifact, Mermaid, text, and web-search precedence cases --- .../app/engine/multi_agent/tool_collection.py | 155 ++++++++-------- .../multi_agent/visual_intent_resolver.py | 134 +++++++++++--- .../unit/test_tool_collection_analytical.py | 8 + .../unit/test_tool_collection_host_ui.py | 4 + ...est_tool_collection_visual_requirements.py | 79 +++++++++ .../tests/unit/test_visual_intent_resolver.py | 166 +++++++++++++++++- 6 files changed, 441 insertions(+), 105 deletions(-) create mode 100644 maritime-ai-service/tests/unit/test_tool_collection_visual_requirements.py diff --git a/maritime-ai-service/app/engine/multi_agent/tool_collection.py b/maritime-ai-service/app/engine/multi_agent/tool_collection.py index 67941275..80a810fa 100644 --- a/maritime-ai-service/app/engine/multi_agent/tool_collection.py +++ b/maritime-ai-service/app/engine/multi_agent/tool_collection.py @@ -141,6 +141,30 @@ def filter_tools_for_visual_intent(tools, visual_decision, *, structured_visuals ) +def build_visual_tool_requirement(visual_decision, *, structured_visuals_enabled: bool): + return _load_attr( + "app.engine.multi_agent.visual_intent_resolver", + "build_visual_tool_requirement", + )( + visual_decision, + structured_visuals_enabled=structured_visuals_enabled, + ) + + +def required_visual_tool_names(visual_decision): + return _load_attr( + "app.engine.multi_agent.visual_intent_resolver", + "required_visual_tool_names", + )(visual_decision) + + +def visual_tool_capability_names(*, include_legacy: bool = True): + return _load_attr( + "app.engine.multi_agent.visual_intent_resolver", + "visual_tool_capability_names", + )(include_legacy=include_legacy) + + def detect_visual_patch_request(query: str) -> bool: return _load_attr( "app.engine.multi_agent.visual_intent_resolver", @@ -192,6 +216,28 @@ def _tool_name(tool: Any) -> str: return str(getattr(tool, "name", "") or getattr(tool, "__name__", "") or "").strip() +def _strip_visual_tool_capabilities( + tools: list[Any], + *, + keep_names: tuple[str, ...] = (), +) -> list[Any]: + """Drop known visual tools while preserving non-visual tool capabilities.""" + + visual_names = set(visual_tool_capability_names(include_legacy=True)) + keep_name_set = set(keep_names) + return [ + tool for tool in tools + if _tool_name(tool) not in visual_names or _tool_name(tool) in keep_name_set + ] + + +def _tools_matching_visual_requirement(tools: list[Any], visual_requirement: Any) -> list[Any]: + required_names = set(getattr(visual_requirement, "required_tool_names", ()) or ()) + if not required_names: + return [] + return [tool for tool in tools if _tool_name(tool) in required_names] + + def _is_host_ui_navigation_route(state: Optional[AgentState]) -> bool: if not isinstance(state, dict): return False @@ -702,6 +748,11 @@ def _collect_direct_tools(query: str, user_role: str = "student", state: Optiona logger.debug("[DIRECT] Visual tools unavailable: %s", _e) visual_decision = resolve_visual_intent(query) + structured_visuals_enabled = getattr(settings, "enable_structured_visuals", False) + visual_requirement = build_visual_tool_requirement( + visual_decision, + structured_visuals_enabled=structured_visuals_enabled, + ) thinking_mode = _infer_direct_thinking_mode(query, state, []) normalized_query = _normalize_for_intent(query) _prefers_code_execution_lane = any( @@ -724,57 +775,29 @@ def _collect_direct_tools(query: str, user_role: str = "student", state: Optiona _direct_tools = filter_tools_for_visual_intent( _direct_tools, visual_decision, - structured_visuals_enabled=getattr(settings, "enable_structured_visuals", False), + structured_visuals_enabled=structured_visuals_enabled, ) if web_search_forced: # Explicit @web-search is a stronger user contract than visual intent. # Research prompts often mention charts, pipelines, or summaries; those # words must not narrow the tool bundle to visual generation. - _direct_tools = [ - tool - for tool in _direct_tools - if str(getattr(tool, "name", "") or getattr(tool, "__name__", "") or "") - not in { - "tool_create_visual_code", - "tool_generate_visual", - "tool_generate_mermaid", - "tool_generate_interactive_chart", - } - ] + _direct_tools = _strip_visual_tool_capabilities(_direct_tools) if _should_strip_visual_tools_from_direct(query, visual_decision): - _direct_tools = [ - tool for tool in _direct_tools - if str(getattr(tool, "name", "") or getattr(tool, "__name__", "") or "") - not in { - "tool_create_visual_code", - "tool_generate_visual", - "tool_generate_mermaid", - "tool_generate_interactive_chart", - } - ] + _direct_tools = _strip_visual_tool_capabilities(_direct_tools) if _should_strip_visual_tools_for_analytical_text_turn( query, visual_decision, thinking_mode=thinking_mode, ): - _direct_tools = [ - tool for tool in _direct_tools - if str(getattr(tool, "name", "") or getattr(tool, "__name__", "") or "") - not in { - "tool_create_visual_code", - "tool_generate_visual", - "tool_generate_mermaid", - "tool_generate_interactive_chart", - } - ] + _direct_tools = _strip_visual_tool_capabilities(_direct_tools) # Clear inline article/chart requests should stay tightly on the visual lane. # If there is no competing web/legal/news/datetime/LMS intent, bind only the # preferred visual tool so the first tool call is deterministic and the # direct lane does not waste latency on unrelated tool options. if ( - visual_decision.force_tool - and visual_decision.preferred_tool - and visual_decision.presentation_intent in {"article_figure", "chart_runtime"} + visual_requirement.force_tool + and visual_requirement.required_tool_names + and visual_requirement.presentation_intent in {"article_figure", "chart_runtime"} and not ( _needs_web_search(query) or _needs_datetime(query) @@ -784,21 +807,16 @@ def _collect_direct_tools(query: str, user_role: str = "student", state: Optiona or web_search_forced ) ): - preferred_name = visual_decision.preferred_tool - preferred_tools = [ - tool - for tool in _direct_tools - if str(getattr(tool, "name", "") or getattr(tool, "__name__", "") or "") == preferred_name - ] + preferred_tools = _tools_matching_visual_requirement(_direct_tools, visual_requirement) if preferred_tools: _direct_tools = preferred_tools _needs_visual_tool = ( not _prefers_code_execution_lane and - visual_decision.force_tool - and visual_decision.mode in {"template", "inline_html", "app", "mermaid"} + visual_requirement.force_tool + and visual_requirement.mode in {"template", "inline_html", "app", "mermaid"} and ( - visual_decision.presentation_intent in {"article_figure", "chart_runtime"} + visual_requirement.presentation_intent in {"article_figure", "chart_runtime"} or not _needs_analysis_tool(query) ) ) @@ -899,11 +917,16 @@ def _collect_code_studio_tools(query: str, user_role: str = "student"): logger.debug("[CODE_STUDIO] Browser sandbox tools unavailable: %s", _e) visual_decision = resolve_visual_intent(query) + structured_visuals_enabled = getattr(settings, "enable_structured_visuals", False) + visual_requirement = build_visual_tool_requirement( + visual_decision, + structured_visuals_enabled=structured_visuals_enabled, + ) _tools = filter_tools_for_role(_tools, user_role) _tools = filter_tools_for_visual_intent( _tools, visual_decision, - structured_visuals_enabled=getattr(settings, "enable_structured_visuals", False), + structured_visuals_enabled=structured_visuals_enabled, ) # Clear app/artifact requests should not drift across a broad tool bundle. @@ -911,16 +934,11 @@ def _collect_code_studio_tools(query: str, user_role: str = "student"): # narrow the bound tools to that target so the first tool call is # deterministic and faster to emit in streaming. if ( - visual_decision.force_tool - and visual_decision.preferred_tool - and visual_decision.presentation_intent in {"code_studio_app", "artifact"} + visual_requirement.force_tool + and visual_requirement.required_tool_names + and visual_requirement.presentation_intent in {"code_studio_app", "artifact"} ): - preferred_name = visual_decision.preferred_tool - preferred_tools = [ - tool - for tool in _tools - if str(getattr(tool, "name", "") or getattr(tool, "__name__", "") or "") == preferred_name - ] + preferred_tools = _tools_matching_visual_requirement(_tools, visual_requirement) if preferred_tools: _tools = preferred_tools @@ -988,14 +1006,7 @@ def _direct_required_tool_names(query: str, user_role: str = "student") -> list[ # These capabilities now live exclusively in code_studio_agent. if visual_decision.force_tool and not _needs_analysis_tool(query): - _structured = getattr(settings, "enable_structured_visuals", False) - if visual_decision.mode == "mermaid" and _structured: - required.append("tool_generate_mermaid") - elif visual_decision.preferred_tool: - required.append(visual_decision.preferred_tool) - elif _structured: - # Structured mode: ALL visual intents → multi-figure tool - required.append("tool_generate_visual") + required.extend(required_visual_tool_names(visual_decision)) deduped: list[str] = [] for tool_name in required: @@ -1032,26 +1043,8 @@ def _code_studio_required_tool_names(query: str, user_role: str = "student") -> ): required.append("tool_browser_snapshot_url") - if visual_decision.force_tool and visual_decision.preferred_tool: - required.append(visual_decision.preferred_tool) - deduped: list[str] = [] - for tool_name in required: - if tool_name not in deduped: - deduped.append(tool_name) - return deduped - if visual_decision.force_tool: - _structured = getattr(settings, "enable_structured_visuals", False) - _llm_code_gen = getattr(settings, "enable_llm_code_gen_visuals", False) - if visual_decision.mode == "mermaid" and _structured: - required.append("tool_generate_mermaid") - elif _structured and _llm_code_gen: - if visual_decision.presentation_intent in {"article_figure", "chart_runtime"}: - required.append("tool_generate_visual") - else: - required.append("tool_create_visual_code") - elif _structured: - required.append("tool_generate_visual") + required.extend(required_visual_tool_names(visual_decision)) deduped: list[str] = [] for tool_name in required: diff --git a/maritime-ai-service/app/engine/multi_agent/visual_intent_resolver.py b/maritime-ai-service/app/engine/multi_agent/visual_intent_resolver.py index b8a808f6..bf8e0a43 100644 --- a/maritime-ai-service/app/engine/multi_agent/visual_intent_resolver.py +++ b/maritime-ai-service/app/engine/multi_agent/visual_intent_resolver.py @@ -16,13 +16,10 @@ CODE_WIDGET_CUES as _CODE_WIDGET_CUES, DASHBOARD_APP_CUES as _DASHBOARD_APP_CUES, INTERACTIVE_TABLE_CUES as _INTERACTIVE_TABLE_CUES, - LEGACY_VISUAL_TOOL_NAMES as _LEGACY_VISUAL_TOOL_NAMES, MINI_TOOL_CUES as _MINI_TOOL_CUES, QUIZ_WIDGET_CUES as _QUIZ_WIDGET_CUES, - SCENE_SIMULATION_CUES as _SCENE_SIMULATION_CUES, SEARCH_WIDGET_CUES as _SEARCH_WIDGET_CUES, SIMULATION_APP_CUES as _SIMULATION_APP_CUES, - SIMULATION_PATCH_CUES as _SIMULATION_PATCH_CUES, contains_any_impl, detect_visual_patch_request_impl, infer_figure_budget_impl, @@ -59,7 +56,73 @@ "code_widget", "artifact", ] - +VisualToolCapabilityLane = Literal["structured_visual", "code_studio", "mermaid", "legacy_chart"] + + +@dataclass(frozen=True, slots=True) +class VisualToolCapability: + """Runtime capability advertised by a visual generation tool.""" + + name: str + lane: VisualToolCapabilityLane + presentation_intents: tuple[PresentationIntent, ...] + legacy: bool = False + + +@dataclass(frozen=True, slots=True) +class VisualToolRequirement: + """Auditable tool-binding requirement derived from one visual intent decision.""" + + force_tool: bool + mode: str + presentation_intent: str + required_tool_names: tuple[str, ...] + required_capabilities: tuple[VisualToolCapability, ...] + visual_tool_names: frozenset[str] + strip_unrequired_visual_tools: bool + + def should_keep_tool_name(self, tool_name: str) -> bool: + """Return whether a bound tool should survive visual-intent narrowing.""" + + if not self.strip_unrequired_visual_tools: + return True + if tool_name in self.required_tool_names: + return True + if tool_name in self.visual_tool_names: + return False + return True + + +VISUAL_TOOL_CAPABILITIES: dict[str, VisualToolCapability] = { + "tool_generate_visual": VisualToolCapability( + name="tool_generate_visual", + lane="structured_visual", + presentation_intents=("article_figure", "chart_runtime"), + ), + "tool_create_visual_code": VisualToolCapability( + name="tool_create_visual_code", + lane="code_studio", + presentation_intents=("code_studio_app", "artifact"), + ), + "tool_generate_mermaid": VisualToolCapability( + name="tool_generate_mermaid", + lane="mermaid", + presentation_intents=("article_figure",), + ), + "tool_generate_chart": VisualToolCapability( + name="tool_generate_chart", + lane="legacy_chart", + presentation_intents=("chart_runtime",), + legacy=True, + ), + "tool_generate_interactive_chart": VisualToolCapability( + name="tool_generate_interactive_chart", + lane="legacy_chart", + presentation_intents=("chart_runtime",), + legacy=True, + ), +} +VISUAL_TOOL_CAPABILITY_NAMES = frozenset(VISUAL_TOOL_CAPABILITIES) @dataclass(frozen=True) @@ -216,11 +279,13 @@ def recommended_visual_thinking_effort( def _resolve_preferred_tool( visual_decision: VisualIntentDecision, ) -> str | None: - if visual_decision.preferred_tool: - return visual_decision.preferred_tool - if visual_decision.mode in {"template", "inline_html", "app"}: + preferred_tool = getattr(visual_decision, "preferred_tool", None) + if preferred_tool: + return preferred_tool + mode = getattr(visual_decision, "mode", "") + if mode in {"template", "inline_html", "app"}: return preferred_visual_tool_name() - if visual_decision.mode == "mermaid": + if mode == "mermaid": return "tool_generate_mermaid" return None @@ -229,13 +294,48 @@ def required_visual_tool_names( visual_decision: VisualIntentDecision, ) -> tuple[str, ...]: """Return the visual tool names that should remain available for an explicit intent.""" - if not visual_decision.force_tool: + if not bool(getattr(visual_decision, "force_tool", False)): return () tool_name = _resolve_preferred_tool(visual_decision) return (tool_name,) if tool_name else () +def visual_tool_capability_names(*, include_legacy: bool = True) -> frozenset[str]: + """Return known visual tool names for deterministic pruning.""" + + if include_legacy: + return VISUAL_TOOL_CAPABILITY_NAMES + return frozenset( + name for name, capability in VISUAL_TOOL_CAPABILITIES.items() + if not capability.legacy + ) + + +def build_visual_tool_requirement( + visual_decision: VisualIntentDecision, + *, + structured_visuals_enabled: bool, +) -> VisualToolRequirement: + """Build the typed visual tool requirement consumed by tool collection.""" + + required_tool_names = required_visual_tool_names(visual_decision) + required_capabilities = tuple( + VISUAL_TOOL_CAPABILITIES[tool_name] + for tool_name in required_tool_names + if tool_name in VISUAL_TOOL_CAPABILITIES + ) + return VisualToolRequirement( + force_tool=bool(getattr(visual_decision, "force_tool", False)), + mode=str(getattr(visual_decision, "mode", "") or ""), + presentation_intent=str(getattr(visual_decision, "presentation_intent", "text") or "text"), + required_tool_names=required_tool_names, + required_capabilities=required_capabilities, + visual_tool_names=VISUAL_TOOL_CAPABILITY_NAMES, + strip_unrequired_visual_tools=structured_visuals_enabled and bool(required_tool_names), + ) + + def filter_tools_for_visual_intent( tools: list[Any], visual_decision: VisualIntentDecision, @@ -243,24 +343,18 @@ def filter_tools_for_visual_intent( structured_visuals_enabled: bool, ) -> list[Any]: """Reduce drift toward legacy visual tools when structured intent is explicit.""" - if not structured_visuals_enabled: - return tools - - allowed_names = set( - required_visual_tool_names(visual_decision) + requirement = build_visual_tool_requirement( + visual_decision, + structured_visuals_enabled=structured_visuals_enabled, ) - if not allowed_names: + if not requirement.strip_unrequired_visual_tools: return tools filtered: list[Any] = [] for tool in tools: tool_name = str(getattr(tool, "name", "") or getattr(tool, "__name__", "") or "") - if tool_name in allowed_names: + if requirement.should_keep_tool_name(tool_name): filtered.append(tool) - continue - if tool_name in _LEGACY_VISUAL_TOOL_NAMES: - continue - filtered.append(tool) return filtered diff --git a/maritime-ai-service/tests/unit/test_tool_collection_analytical.py b/maritime-ai-service/tests/unit/test_tool_collection_analytical.py index afc0c5a3..704499e3 100644 --- a/maritime-ai-service/tests/unit/test_tool_collection_analytical.py +++ b/maritime-ai-service/tests/unit/test_tool_collection_analytical.py @@ -8,6 +8,11 @@ def __init__(self, name: str): def test_collect_direct_tools_strips_visual_tools_for_analytical_text_turn(monkeypatch): from app.engine.multi_agent import tool_collection as module + from app.engine.multi_agent.visual_intent_resolver import ( + build_visual_tool_requirement, + required_visual_tool_names, + visual_tool_capability_names, + ) monkeypatch.setattr(module.settings, "enable_character_tools", False, raising=False) monkeypatch.setattr(module.settings, "enable_lms_integration", False, raising=False) @@ -36,6 +41,9 @@ def fake_load_attr(_module_name: str, attr_name: str): preferred_tool=None, visual_type=None, ), + "build_visual_tool_requirement": build_visual_tool_requirement, + "required_visual_tool_names": required_visual_tool_names, + "visual_tool_capability_names": visual_tool_capability_names, "filter_tools_for_visual_intent": lambda tools, _decision, structured_visuals_enabled: tools, "_should_strip_visual_tools_from_direct": lambda _query, _decision: False, "_normalize_for_intent": lambda text: text.lower(), diff --git a/maritime-ai-service/tests/unit/test_tool_collection_host_ui.py b/maritime-ai-service/tests/unit/test_tool_collection_host_ui.py index f2a8907c..6d6cf484 100644 --- a/maritime-ai-service/tests/unit/test_tool_collection_host_ui.py +++ b/maritime-ai-service/tests/unit/test_tool_collection_host_ui.py @@ -427,6 +427,7 @@ def test_host_action_tools_pointy_only_when_no_host_actions_present(): def test_off_topic_direct_prose_does_not_bind_heavy_tools(monkeypatch): from app.engine.multi_agent import tool_collection as module + from app.engine.multi_agent.visual_intent_resolver import build_visual_tool_requirement monkeypatch.setattr(module.settings, "enable_character_tools", False, raising=False) monkeypatch.setattr(module.settings, "enable_lms_integration", False, raising=False) @@ -453,6 +454,7 @@ def test_off_topic_direct_prose_does_not_bind_heavy_tools(monkeypatch): ) monkeypatch.setattr(module, "filter_tools_for_role", lambda tools, _role: tools) monkeypatch.setattr(module, "filter_tools_for_visual_intent", lambda tools, *_args, **_kwargs: tools) + monkeypatch.setattr(module, "build_visual_tool_requirement", build_visual_tool_requirement) def fake_load_attr(_module_name: str, attr_name: str): if attr_name == "_normalize_for_intent": @@ -480,6 +482,7 @@ def fake_load_attr(_module_name: str, attr_name: str): def test_reasoning_safety_meta_direct_prose_overrides_false_visual_force(monkeypatch): from app.engine.multi_agent import tool_collection as module + from app.engine.multi_agent.visual_intent_resolver import build_visual_tool_requirement monkeypatch.setattr(module.settings, "enable_character_tools", False, raising=False) monkeypatch.setattr(module.settings, "enable_lms_integration", False, raising=False) @@ -507,6 +510,7 @@ def test_reasoning_safety_meta_direct_prose_overrides_false_visual_force(monkeyp ) monkeypatch.setattr(module, "filter_tools_for_role", lambda tools, _role: tools) monkeypatch.setattr(module, "filter_tools_for_visual_intent", lambda tools, *_args, **_kwargs: tools) + monkeypatch.setattr(module, "build_visual_tool_requirement", build_visual_tool_requirement) def fake_load_attr(_module_name: str, attr_name: str): if attr_name == "_normalize_for_intent": diff --git a/maritime-ai-service/tests/unit/test_tool_collection_visual_requirements.py b/maritime-ai-service/tests/unit/test_tool_collection_visual_requirements.py new file mode 100644 index 00000000..5b0f4727 --- /dev/null +++ b/maritime-ai-service/tests/unit/test_tool_collection_visual_requirements.py @@ -0,0 +1,79 @@ +from types import SimpleNamespace + + +def _tool(name: str) -> SimpleNamespace: + return SimpleNamespace(name=name) + + +def _tool_names(tools): + return [getattr(tool, "name", getattr(tool, "__name__", "")) for tool in tools] + + +def test_collect_direct_tools_web_search_force_strips_visual_capabilities(monkeypatch): + from app.engine.multi_agent import tool_collection as module + from app.engine.tools import agent_tools + from app.engine.tools import chart_tools + from app.engine.tools import utility_tools + from app.engine.tools import visual_tools + from app.engine.tools import web_fetch_tool + from app.engine.tools import web_search_tools + + monkeypatch.setattr( + module, + "settings", + SimpleNamespace( + enable_agent_handoffs=False, + enable_character_tools=False, + enable_code_execution=False, + enable_host_actions=False, + enable_lms_integration=False, + enable_structured_visuals=True, + enable_browser_agent=False, + enable_privileged_sandbox=False, + sandbox_provider="disabled", + sandbox_allow_browser_workloads=False, + ), + ) + monkeypatch.setattr(utility_tools, "tool_current_datetime", _tool("tool_current_datetime")) + monkeypatch.setattr(web_search_tools, "tool_web_search", _tool("tool_web_search")) + monkeypatch.setattr(web_search_tools, "tool_search_news", _tool("tool_search_news")) + monkeypatch.setattr(web_search_tools, "tool_search_legal", _tool("tool_search_legal")) + monkeypatch.setattr(web_search_tools, "tool_search_maritime", _tool("tool_search_maritime")) + monkeypatch.setattr(web_fetch_tool, "tool_fetch_url", _tool("tool_fetch_url")) + monkeypatch.setattr(agent_tools, "RAG_KNOWLEDGE_TOOL", _tool("tool_rag_knowledge")) + monkeypatch.setattr( + chart_tools, + "get_chart_tools", + lambda: [ + _tool("tool_generate_mermaid"), + _tool("tool_generate_chart"), + _tool("tool_generate_interactive_chart"), + ], + ) + monkeypatch.setattr( + visual_tools, + "get_visual_tools", + lambda: [ + _tool("tool_generate_visual"), + _tool("tool_create_visual_code"), + ], + ) + + tools, force_tools = module._collect_direct_tools( + "Search the web and make a chart about current oil prices.", + user_role="student", + state={ + "context": {"force_skills": ["web-search"]}, + "routing_metadata": {"intent": "general"}, + }, + ) + + names = _tool_names(tools) + assert force_tools is True + assert "tool_web_search" in names + assert "tool_fetch_url" in names + assert "tool_generate_visual" not in names + assert "tool_create_visual_code" not in names + assert "tool_generate_mermaid" not in names + assert "tool_generate_chart" not in names + assert "tool_generate_interactive_chart" not in names diff --git a/maritime-ai-service/tests/unit/test_visual_intent_resolver.py b/maritime-ai-service/tests/unit/test_visual_intent_resolver.py index be9219e6..03d90c7a 100644 --- a/maritime-ai-service/tests/unit/test_visual_intent_resolver.py +++ b/maritime-ai-service/tests/unit/test_visual_intent_resolver.py @@ -1,14 +1,26 @@ from app.engine.multi_agent.visual_intent_resolver import ( + build_visual_tool_requirement, detect_visual_patch_request, filter_tools_for_visual_intent, merge_quality_profile, recommended_visual_thinking_effort, preferred_visual_tool_name, + required_visual_tool_names, resolve_visual_intent, + visual_tool_capability_names, ) from app.engine.tools.code_studio_app_intent_contract import infer_code_studio_app_category +class _Tool: + def __init__(self, name: str): + self.name = name + + +def _tool_names(tools): + return [tool.name for tool in tools] + + def test_resolves_comparison_visual(): decision = resolve_visual_intent("So sanh softmax attention voi linear attention") assert decision.mode == "inline_html" @@ -314,6 +326,61 @@ def test_preferred_visual_tool_name_always_returns_structured(): assert preferred_visual_tool_name() == "tool_generate_visual" +def test_visual_tool_requirement_chart_uses_structured_visual_tool(): + decision = resolve_visual_intent("Vẽ biểu đồ KPI theo tháng") + requirement = build_visual_tool_requirement( + decision, + structured_visuals_enabled=True, + ) + + assert required_visual_tool_names(decision) == ("tool_generate_visual",) + assert requirement.required_tool_names == ("tool_generate_visual",) + assert [capability.lane for capability in requirement.required_capabilities] == [ + "structured_visual", + ] + + +def test_visual_tool_requirement_simulation_requires_code_studio_tool(): + decision = resolve_visual_intent("Hãy mô phỏng vật lý con lắc có kéo thả chuột") + requirement = build_visual_tool_requirement( + decision, + structured_visuals_enabled=True, + ) + + assert required_visual_tool_names(decision) == ("tool_create_visual_code",) + assert requirement.required_tool_names == ("tool_create_visual_code",) + assert [capability.lane for capability in requirement.required_capabilities] == [ + "code_studio", + ] + + +def test_visual_tool_requirement_artifact_requires_code_studio_tool(): + decision = resolve_visual_intent("Tạo một mini app HTML để nhúng vào LMS") + requirement = build_visual_tool_requirement( + decision, + structured_visuals_enabled=True, + ) + + assert decision.presentation_intent == "artifact" + assert required_visual_tool_names(decision) == ("tool_create_visual_code",) + assert requirement.required_tool_names == ("tool_create_visual_code",) + + +def test_visual_tool_capability_inventory_includes_modern_and_legacy_tools(): + assert visual_tool_capability_names() == frozenset({ + "tool_generate_visual", + "tool_create_visual_code", + "tool_generate_mermaid", + "tool_generate_chart", + "tool_generate_interactive_chart", + }) + assert visual_tool_capability_names(include_legacy=False) == frozenset({ + "tool_generate_visual", + "tool_create_visual_code", + "tool_generate_mermaid", + }) + + def test_merge_quality_profile_prefers_higher_bar(): assert merge_quality_profile("standard", "premium") == "premium" assert merge_quality_profile("draft", None) == "draft" @@ -345,10 +412,6 @@ def test_detects_visual_patch_followup_with_unicode_vietnamese(): def test_filter_tools_for_visual_intent_drops_legacy_visual_tools(): - class _Tool: - def __init__(self, name: str): - self.name = name - decision = resolve_visual_intent("Explain Kimi linear attention in charts") tools = [ _Tool("tool_generate_interactive_chart"), @@ -364,3 +427,98 @@ def __init__(self, name: str): ) assert [tool.name for tool in filtered] == ["tool_generate_visual", "tool_web_search"] + + +def test_filter_tools_for_visual_intent_keeps_chart_lane_only(): + decision = resolve_visual_intent("Vẽ biểu đồ so sánh tốc độ các loại tàu container") + tools = [ + _Tool("tool_create_visual_code"), + _Tool("tool_generate_visual"), + _Tool("tool_generate_mermaid"), + _Tool("tool_generate_interactive_chart"), + _Tool("tool_generate_chart"), + _Tool("tool_web_search"), + ] + + filtered = filter_tools_for_visual_intent( + tools, + decision, + structured_visuals_enabled=True, + ) + + assert _tool_names(filtered) == ["tool_generate_visual", "tool_web_search"] + + +def test_filter_tools_for_visual_intent_keeps_app_lane_only(): + decision = resolve_visual_intent("Hãy mô phỏng vật lý con lắc có kéo thả chuột") + tools = [ + _Tool("tool_generate_visual"), + _Tool("tool_create_visual_code"), + _Tool("tool_generate_mermaid"), + _Tool("tool_web_search"), + ] + + filtered = filter_tools_for_visual_intent( + tools, + decision, + structured_visuals_enabled=True, + ) + + assert _tool_names(filtered) == ["tool_create_visual_code", "tool_web_search"] + + +def test_filter_tools_for_visual_intent_keeps_artifact_lane_only(): + decision = resolve_visual_intent("Tạo một mini app HTML để nhúng vào LMS") + tools = [ + _Tool("tool_generate_visual"), + _Tool("tool_create_visual_code"), + _Tool("tool_generate_mermaid"), + _Tool("tool_generate_interactive_chart"), + _Tool("tool_web_search"), + ] + + filtered = filter_tools_for_visual_intent( + tools, + decision, + structured_visuals_enabled=True, + ) + + assert _tool_names(filtered) == ["tool_create_visual_code", "tool_web_search"] + + +def test_filter_tools_for_visual_intent_keeps_mermaid_lane_only(): + decision = resolve_visual_intent("Vẽ flowchart quy trình onboarding") + tools = [ + _Tool("tool_generate_visual"), + _Tool("tool_create_visual_code"), + _Tool("tool_generate_interactive_chart"), + _Tool("tool_generate_chart"), + _Tool("tool_generate_mermaid"), + _Tool("tool_web_search"), + ] + + filtered = filter_tools_for_visual_intent( + tools, + decision, + structured_visuals_enabled=True, + ) + + assert _tool_names(filtered) == ["tool_generate_mermaid", "tool_web_search"] + + +def test_filter_tools_for_visual_intent_leaves_text_turn_tools_alone(): + decision = resolve_visual_intent("Visual Studio Code khác Visual Basic thế nào?") + tools = [ + _Tool("tool_generate_visual"), + _Tool("tool_create_visual_code"), + _Tool("tool_web_search"), + _Tool("tool_knowledge_search"), + ] + + filtered = filter_tools_for_visual_intent( + tools, + decision, + structured_visuals_enabled=True, + ) + + assert _tool_names(filtered) == _tool_names(tools)