From 1a8818d44017f54f6efc7e22f8cb7cc089c09948 Mon Sep 17 00:00:00 2001 From: Mathew Date: Tue, 23 Dec 2025 01:26:57 -0800 Subject: [PATCH 1/3] feat(agents): Add AG-UI decision logic with router and rich UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement intelligent routing between chat and rich UI components: - Add router.py with heuristic + LLM-based routing decisions - Add DataTableCapture and ProcessMapBuilder frontend components - Add request_data_table and request_process_map tools - Integrate router state tracking in design assistant - Add confidence thresholds for tool vs clarify vs chat decisions - Support structured UI submissions with JSON markers - Add infer_selection_from_assistant_output for numbered lists πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/ag-ui-decision-logic-spec.md | 225 +++++ src/backend/clara/agents/design_assistant.py | 314 +++++- .../agents/prompts/interview_orchestrator.txt | 16 + src/backend/clara/agents/router.py | 936 ++++++++++++++++++ src/backend/clara/agents/simulation_agent.py | 127 ++- src/backend/clara/agents/tools.py | 265 ++++- src/backend/clara/config.py | 1 + src/frontend/src/api/simulation-sessions.ts | 2 + .../design-assistant/ChatMessage.tsx | 56 +- .../design-assistant/DataTableCapture.tsx | 262 +++++ .../design-assistant/OptionCards.tsx | 74 +- .../design-assistant/ProcessMapBuilder.tsx | 186 ++++ src/frontend/src/hooks/useDesignSession.ts | 1 + .../src/pages/AutomatedSimulationPage.tsx | 22 + .../src/pages/DesignAssistantPage.tsx | 17 + src/frontend/src/pages/SimulationPage.tsx | 204 ++-- src/frontend/src/types/design-session.ts | 58 +- 17 files changed, 2615 insertions(+), 151 deletions(-) create mode 100644 docs/ag-ui-decision-logic-spec.md create mode 100644 src/backend/clara/agents/router.py create mode 100644 src/frontend/src/components/design-assistant/DataTableCapture.tsx create mode 100644 src/frontend/src/components/design-assistant/ProcessMapBuilder.tsx diff --git a/docs/ag-ui-decision-logic-spec.md b/docs/ag-ui-decision-logic-spec.md new file mode 100644 index 0000000..fa95753 --- /dev/null +++ b/docs/ag-ui-decision-logic-spec.md @@ -0,0 +1,225 @@ +AG-UI Decision Logic Spec (Tight Loop) v1 +======================================== + +Status: Draft +Owner: Clara Design Assistant +Scope: Decision logic for switching from chat to rich UI (tables, process maps) + +This spec is standalone. Do not rely on other design artifacts. + +--- + +1. Goals +-------- +- Decide when to switch from chat to a rich UI based on user input and session state. +- Minimize user typing for structured data capture. +- Prevent tool thrash and ambiguous loops. +- Provide deterministic, testable decision rules. +- Use small models for routing and larger models for content. + +2. Non-Goals +------------ +- UI styling, layout, or frontend implementation details. +- Domain-specific ontology or schema design beyond table/map scaffolds. +- File upload and ingestion pipelines. + +3. Key Patterns Adopted (from claude-code analysis) +--------------------------------------------------- +- Self-refinement loop with completion promise and max iterations (Ralph loop). +- Multi-phase gating with explicit approvals (feature-dev flow). +- Confidence thresholding to reduce noise (code-review plugin). +- Stateful checkpointing for resumption (advanced workflows). +- User-defined guardrails derived from friction (hookify patterns). +- Decision-point user contribution requests (learning output style). + +These patterns are explicitly incorporated in Sections 6-9. + +4. Architecture Overview +------------------------ +Components: +- Router (small model): decides tool vs chat and generates tool parameters. +- Orchestrator (Sonnet): handles natural language responses, tool explanations, and follow-up. +- Tool Menu: enumerated UI tools with strict schema. +- UI State Store: tracks tool status, completion criteria, and checkpoints. + +Data flow: +User Input -> Router -> (Tool Call or Chat) -> UI -> Tool Result -> Orchestrator + +5. Router Inputs and Outputs +---------------------------- +Inputs: +- user_message (string) +- session_state (see Section 7) +- last_tool (name or null) +- last_tool_status (open, completed, canceled) +- user_preferences (opt-out rules, preferred UI) + +Output JSON: +{ + "action": "tool" | "chat" | "clarify", + "tool_name": "request_data_table" | "request_process_map" | null, + "confidence": 0.0-1.0, + "params": { ...tool schema... } | null, + "rationale": "short string for logs" +} + +Routing thresholds: +- confidence >= 0.75: execute tool call immediately +- 0.45 <= confidence < 0.75: ask one clarifying question, then re-route +- confidence < 0.45: forward to Orchestrator for standard chat + +6. Tool Menu (v1) +----------------- +6.1 request_data_table +Use when the user needs to provide a list of items, structured data, or bulk entries. +Do NOT use for single items. + +Parameters: +{ + "title": "string", + "columns": [ + { "name": "string", "type": "text|number|enum|date|url", "required": true|false } + ], + "min_rows": number, + "starter_rows": number, + "input_modes": ["paste", "inline", "import"], + "summary_prompt": "string" +} + +6.2 request_process_map +Use when the user describes a workflow, sequence of steps, timeline, or migration path. +Capture "Step A -> Step B" relationships. + +Parameters: +{ + "title": "string", + "required_fields": ["step_name", "owner", "outcome"], + "edge_types": ["sequence", "approval", "parallel"], + "min_steps": number, + "seed_nodes": ["string"] +} + +7. Session State (UI Checkpoints) +--------------------------------- +Persisted fields: +{ + "last_tool": "string|null", + "last_tool_status": "open|completed|canceled", + "ui_checkpoint": { + "tool": "string", + "payload": { ...tool params... }, + "opened_at": "timestamp", + "completion_criteria": { ... }, + "iteration_count": number, + "max_iterations": number + }, + "clarifying_question_pending": boolean, + "user_opt_out": { + "all_tools": boolean, + "tools": ["request_data_table", "request_process_map"], + "expires_at": "timestamp|null" + } +} + +Checkpointing rule: +- When a tool is opened, create a ui_checkpoint. +- If the session resumes and a checkpoint is open, re-open that UI without re-asking. + +8. Decision Rules (Tight Loop) +------------------------------ +Hard triggers (no clarifying question): +- List size >= 3 (explicit or inferred: "we have 12 stakeholders"). +- User mentions bulk entry, spreadsheet, or "paste a list". +- Workflow markers: "first/then/after/before/next", "approval process", "pipeline", "migration steps". + +Soft triggers (ask once, then tool): +- Mentions "process" without steps -> ask: "Want to map the steps now?" +- Mentions "stakeholders/risks/issues" without quantity -> ask for count. +- Mentions multiple roles + any process language -> prefer process map. + +Anti-thrash rules: +- If last_tool_status == "open", do not call another tool. +- If user_opt_out.all_tools is true, do not call tools unless user re-enables. +- If user cancels a tool twice in a row, switch to chat for the next turn. + +Chat-only cases: +- Greetings, explanations, clarifications, or coaching. +- Single-item edits or short statements. + +9. Completion Logic and Self-Refinement Loop +-------------------------------------------- +Completion criteria: +- Data table: min_rows satisfied AND required columns filled. +- Process map: min_steps satisfied AND required_fields filled. + +Self-refinement loop (Ralph pattern): +- After tool submission, run validation checks. +- If criteria not met, ask a targeted fix question and re-open tool. +- Max iterations: 2 (configurable per tool). +- Never loop without explicit user feedback. + +10. Validation and Confidence Gating +------------------------------------ +Run parallel validations (code-review pattern): +- missing_required_fields +- duplicate_entries +- contradictory_sequences (for process map) +- low_coverage (too few rows/steps vs stated scope) + +Each validation returns a confidence score. Only surface issues with +confidence >= 0.80 to avoid noisy warnings. + +11. User Guardrails (Hookify pattern) +------------------------------------- +Allow users to set local rules: +- "Never show tables for stakeholder lists" +- "Always map approval processes" + +Rule format: +{ + "intent_pattern": "regex", + "action": "force_tool|suppress_tool", + "tool": "request_data_table|request_process_map" +} + +Rules are applied before Router decisions. + +12. Examples +------------ +Example A: +User: "We have 20 stakeholders across finance, ops, and IT." +Router: action=tool, tool=request_data_table, confidence=0.86 +Params: columns=[Name, Role, Influence], min_rows=20, input_modes=["paste","inline"] + +Example B: +User: "First finance reviews the invoice, then IT signs off, then CFO approves." +Router: action=tool, tool=request_process_map, confidence=0.91 +Params: required_fields=[step_name, owner, outcome], min_steps=3 + +Example C: +User: "We have some risks." +Router: action=clarify, confidence=0.58 +Clarify: "How many risks are we capturing?" +Then: tool if count >=3, else chat. + +13. Model Routing +----------------- +Router model options: +- Claude 3.5 Haiku (default): fast, strong tool selection. +- Llama 3.1 8B fine-tuned: edge router with 500+ tool-call examples. + +Fallback: +- If Router output is invalid or confidence < 0.45, send to Sonnet. +- Orchestrator always validates tool parameters before execution. + +14. Telemetry +------------- +Log events (privacy-safe): +- router_decision (action, tool, confidence) +- tool_opened, tool_submitted, tool_canceled +- validation_warning_shown (type, confidence) +- user_opt_out_changed + +--- + +End of spec. diff --git a/src/backend/clara/agents/design_assistant.py b/src/backend/clara/agents/design_assistant.py index 8a553f6..6ae0c1d 100644 --- a/src/backend/clara/agents/design_assistant.py +++ b/src/backend/clara/agents/design_assistant.py @@ -14,11 +14,23 @@ from claude_agent_sdk import AgentDefinition, ClaudeAgentOptions, ClaudeSDKClient, HookMatcher +from clara.agents.router import ( + RouterState, + UIRouter, + build_ui_component, + infer_selection_from_assistant_output, + is_cancel_intent, + is_tool_reply, + parse_ui_submission, + summarize_ui_submission, +) from clara.agents.tools import ( CLARA_TOOL_NAMES, clear_session_state, create_clara_tools, + ensure_other_option, get_session_state, + sanitize_ask_options, ) logger = logging.getLogger(__name__) @@ -101,6 +113,8 @@ def __init__(self, session_id: str, project_id: str): self._running = False self._restored = False # True if this session was restored from DB self._first_message_sent = False # Track if we've sent the first message after restoration + self.router_state = RouterState() + self.router = UIRouter() def _sync_state_from_tools(self) -> None: """Sync session state from tool state. @@ -195,6 +209,31 @@ def _build_restoration_context(self) -> str: return "\n".join(parts) + def _build_state_snapshot_event(self) -> AGUIEvent: + """Build a STATE_SNAPSHOT event from current session state.""" + return AGUIEvent( + type="STATE_SNAPSHOT", + data={ + "phase": self.state.phase.value, + "preview": { + "project_name": self.state.blueprint_preview.project_name, + "project_type": self.state.blueprint_preview.project_type, + "entity_types": self.state.blueprint_preview.entity_types, + "agent_count": self.state.blueprint_preview.agent_count, + "topics": self.state.blueprint_preview.topics, + }, + "inferred_domain": self.state.inferred_domain, + "debug": { + "thinking": None, + "approach": None, + "turn_count": self.state.turn_count, + "message_count": self.state.message_count, + "domain_confidence": self.state.domain_confidence, + "discussed_topics": self.state.discussed_topics, + } + } + ) + def _create_subagents(self) -> dict[str, AgentDefinition]: """Define phase-based subagents. @@ -243,8 +282,17 @@ def _create_subagents(self) -> dict[str, AgentDefinition]: "It will explore the 5 key dimensions and use mcp__clara__ask for structured choices. " "After completing, call mcp__clara__hydrate_phase2 with the goal summary." ), - tools=["mcp__clara__ask", "mcp__clara__project", "mcp__clara__save_goal_summary", - "mcp__clara__hydrate_phase2", "mcp__clara__phase", "mcp__clara__get_prompt"], + tools=[ + "mcp__clara__ask", + "mcp__clara__request_selection_list", + "mcp__clara__request_data_table", + "mcp__clara__request_process_map", + "mcp__clara__project", + "mcp__clara__save_goal_summary", + "mcp__clara__hydrate_phase2", + "mcp__clara__phase", + "mcp__clara__get_prompt", + ], prompt=phase1_prompt, model="sonnet" ), @@ -254,8 +302,15 @@ def _create_subagents(self) -> dict[str, AgentDefinition]: "First call mcp__clara__get_prompt to get hydrated instructions with the goal. " "Then configure the specialist agent and call mcp__clara__hydrate_phase3." ), - tools=["mcp__clara__agent_summary", "mcp__clara__phase", "mcp__clara__get_prompt", - "mcp__clara__hydrate_phase3"], + tools=[ + "mcp__clara__agent_summary", + "mcp__clara__phase", + "mcp__clara__get_prompt", + "mcp__clara__hydrate_phase3", + "mcp__clara__request_selection_list", + "mcp__clara__request_data_table", + "mcp__clara__request_process_map", + ], prompt=phase2_prompt, model="sonnet" ), @@ -268,10 +323,20 @@ def _create_subagents(self) -> dict[str, AgentDefinition]: "Use mcp__clara__prompt_editor to show generated prompts for user editing. " "Use mcp__clara__get_agent_context to access uploaded context files." ), - tools=["mcp__clara__project", "mcp__clara__entity", "mcp__clara__agent", - "mcp__clara__ask", "mcp__clara__preview", "mcp__clara__phase", - "mcp__clara__get_prompt", "mcp__clara__prompt_editor", - "mcp__clara__get_agent_context"], + tools=[ + "mcp__clara__project", + "mcp__clara__entity", + "mcp__clara__agent", + "mcp__clara__ask", + "mcp__clara__request_selection_list", + "mcp__clara__request_data_table", + "mcp__clara__request_process_map", + "mcp__clara__preview", + "mcp__clara__phase", + "mcp__clara__get_prompt", + "mcp__clara__prompt_editor", + "mcp__clara__get_agent_context", + ], prompt=phase3_prompt, model="sonnet" ), @@ -298,12 +363,23 @@ async def pre_tool_hook( data={"tool": tool_name, "input": tool_input} )) + if tool_name in { + "mcp__clara__request_data_table", + "mcp__clara__request_process_map", + "mcp__clara__request_selection_list", + }: + normalized_tool = tool_name.replace("mcp__clara__", "") + self.router_state.pending_tool = normalized_tool + self.router_state.last_tool = normalized_tool + self.router_state.last_tool_status = "open" + # Special handling for ask tool - emit CUSTOM event with UI component if tool_name == "mcp__clara__ask": + options = sanitize_ask_options(tool_input.get("options", [])) ui_component = { "type": "user_input_required", "question": tool_input.get("question", ""), - "options": tool_input.get("options", []), + "options": options, "multi_select": tool_input.get("multi_select", False), } # Emit as CUSTOM AG-UI event for reliable rendering @@ -313,6 +389,20 @@ async def pre_tool_hook( )) logger.info(f"[{self.session_id}] Emitted CUSTOM event clara:ask") + if tool_name == "mcp__clara__request_selection_list": + options = ensure_other_option(sanitize_ask_options(tool_input.get("options", []))) + ui_component = { + "type": "user_input_required", + "question": tool_input.get("question", ""), + "options": options, + "multi_select": tool_input.get("multi_select", False), + } + await response_queue.put(AGUIEvent( + type="CUSTOM", + data={"name": "clara:ask", "value": ui_component} + )) + logger.info(f"[{self.session_id}] Emitted CUSTOM event clara:ask (selection list)") + # Special handling for prompt_editor tool - emit CUSTOM event for editable prompt if tool_name == "mcp__clara__prompt_editor": ui_component = { @@ -328,6 +418,37 @@ async def pre_tool_hook( )) logger.info(f"[{self.session_id}] Emitted CUSTOM event clara:prompt_editor") + if tool_name == "mcp__clara__request_data_table": + ui_component = { + "type": "data_table", + "title": tool_input.get("title", "Data Table"), + "columns": tool_input.get("columns", []), + "min_rows": tool_input.get("min_rows", 3), + "starter_rows": tool_input.get("starter_rows", 3), + "input_modes": tool_input.get("input_modes", ["paste", "inline"]), + "summary_prompt": tool_input.get("summary_prompt", ""), + } + await response_queue.put(AGUIEvent( + type="CUSTOM", + data={"name": "clara:data_table", "value": ui_component} + )) + logger.info(f"[{self.session_id}] Emitted CUSTOM event clara:data_table") + + if tool_name == "mcp__clara__request_process_map": + ui_component = { + "type": "process_map", + "title": tool_input.get("title", "Process Map"), + "required_fields": tool_input.get("required_fields", []), + "edge_types": tool_input.get("edge_types", []), + "min_steps": tool_input.get("min_steps", 3), + "seed_nodes": tool_input.get("seed_nodes", []), + } + await response_queue.put(AGUIEvent( + type="CUSTOM", + data={"name": "clara:process_map", "value": ui_component} + )) + logger.info(f"[{self.session_id}] Emitted CUSTOM event clara:process_map") + # Note: agent_summary tool stores config in state but no longer emits UI card return {} # No modifications to tool behavior @@ -412,6 +533,32 @@ async def send_message(self, message: str) -> AsyncIterator[AGUIEvent]: # Sync state from tools before emitting snapshot self._sync_state_from_tools() + submission = parse_ui_submission(message) + if submission: + self.router_state.last_tool = submission.kind + self.router_state.last_tool_status = "completed" + self.router_state.pending_tool = None + self.router_state.pending_payload = None + message = summarize_ui_submission(submission) + + if ( + not submission + and self.router_state.pending_tool == "request_selection_list" + and is_tool_reply(message) + ): + self.router_state.last_tool_status = "completed" + self.router_state.pending_tool = None + self.router_state.pending_payload = None + + if ( + not submission + and self.router_state.pending_tool + and is_cancel_intent(message) + ): + self.router_state.last_tool_status = "canceled" + self.router_state.pending_tool = None + self.router_state.pending_payload = None + # For restored sessions, prepend context on first message actual_message = message if self._restored and not self._first_message_sent: @@ -421,28 +568,83 @@ async def send_message(self, message: str) -> AsyncIterator[AGUIEvent]: logger.info(f"[{self.session_id}] Prepending restoration context to first message") # Emit state snapshot at start of turn - yield AGUIEvent( - type="STATE_SNAPSHOT", - data={ - "phase": self.state.phase.value, - "preview": { - "project_name": self.state.blueprint_preview.project_name, - "project_type": self.state.blueprint_preview.project_type, - "entity_types": self.state.blueprint_preview.entity_types, - "agent_count": self.state.blueprint_preview.agent_count, - "topics": self.state.blueprint_preview.topics, - }, - "inferred_domain": self.state.inferred_domain, - "debug": { - "thinking": None, - "approach": None, - "turn_count": self.state.turn_count, - "message_count": self.state.message_count, - "domain_confidence": self.state.domain_confidence, - "discussed_topics": self.state.discussed_topics, - } - } - ) + yield self._build_state_snapshot_event() + + if not submission: + decision = await self.router.decide( + message=message, + state=self.router_state, + phase=self.state.phase.value, + flow="design_assistant", + allow_selection=False, + ) + + if decision.action in {"tool", "clarify"}: + preamble = "" + if decision.action == "tool": + ui_component = build_ui_component(decision) + if not ui_component: + decision = None + else: + tool_name_map = { + "request_data_table": "mcp__clara__request_data_table", + "request_process_map": "mcp__clara__request_process_map", + "request_selection_list": "mcp__clara__request_selection_list", + } + tool_name = tool_name_map.get( + decision.tool_name, "mcp__clara__request_data_table" + ) + if decision.tool_name == "request_data_table": + preamble = "Let's capture that in a table so you can paste rows quickly." + elif decision.tool_name == "request_process_map": + preamble = "Let's map the steps so we capture the workflow accurately." + elif decision.tool_name == "request_selection_list": + preamble = "Pick the options that apply." + tool_state = get_session_state(self.session_id) + tool_state["pending_ui_component"] = ui_component + self.router_state.pending_tool = decision.tool_name + self.router_state.pending_payload = decision.params + self.router_state.last_tool = decision.tool_name + self.router_state.last_tool_status = "open" + + yield AGUIEvent( + type="TOOL_CALL_START", + data={"tool": tool_name, "input": decision.params or {}} + ) + yield AGUIEvent( + type="TEXT_MESSAGE_CONTENT", + data={"delta": preamble} + ) + yield AGUIEvent( + type="CUSTOM", + data={ + "name": ( + "clara:data_table" + if decision.tool_name == "request_data_table" + else "clara:process_map" + if decision.tool_name == "request_process_map" + else "clara:ask" + ), + "value": ui_component, + } + ) + yield AGUIEvent( + type="TOOL_CALL_END", + data={"tool": tool_name} + ) + yield AGUIEvent(type="TEXT_MESSAGE_END", data={}) + yield self._build_state_snapshot_event() + return + + if decision and decision.action == "clarify": + self.router_state.last_clarify = decision.clarifying_question + yield AGUIEvent( + type="TEXT_MESSAGE_CONTENT", + data={"delta": decision.clarifying_question or "Can you clarify?"} + ) + yield AGUIEvent(type="TEXT_MESSAGE_END", data={}) + yield self._build_state_snapshot_event() + return # Send message to agent (uses actual_message which may include restoration context) await self.client.query(prompt=actual_message) @@ -539,32 +741,38 @@ async def drain_queue(): async for event in drain_queue(): yield event + if current_text and not self.router_state.pending_tool: + selection_decision = infer_selection_from_assistant_output(current_text) + if selection_decision: + ui_component = build_ui_component(selection_decision) + if ui_component: + tool_state = get_session_state(self.session_id) + tool_state["pending_ui_component"] = ui_component + self.router_state.pending_tool = selection_decision.tool_name + self.router_state.pending_payload = selection_decision.params + self.router_state.last_tool = selection_decision.tool_name + self.router_state.last_tool_status = "open" + yield AGUIEvent( + type="TOOL_CALL_START", + data={ + "tool": "mcp__clara__request_selection_list", + "input": selection_decision.params or {}, + } + ) + yield AGUIEvent( + type="CUSTOM", + data={"name": "clara:ask", "value": ui_component} + ) + yield AGUIEvent( + type="TOOL_CALL_END", + data={"tool": "mcp__clara__request_selection_list"} + ) + # Sync state from tools after all tool calls complete self._sync_state_from_tools() # Emit final state snapshot with any changes from this turn - yield AGUIEvent( - type="STATE_SNAPSHOT", - data={ - "phase": self.state.phase.value, - "preview": { - "project_name": self.state.blueprint_preview.project_name, - "project_type": self.state.blueprint_preview.project_type, - "entity_types": self.state.blueprint_preview.entity_types, - "agent_count": self.state.blueprint_preview.agent_count, - "topics": self.state.blueprint_preview.topics, - }, - "inferred_domain": self.state.inferred_domain, - "debug": { - "thinking": None, - "approach": None, - "turn_count": self.state.turn_count, - "message_count": self.state.message_count, - "domain_confidence": self.state.domain_confidence, - "discussed_topics": self.state.discussed_topics, - } - } - ) + yield self._build_state_snapshot_event() # Emit end of message yield AGUIEvent( diff --git a/src/backend/clara/agents/prompts/interview_orchestrator.txt b/src/backend/clara/agents/prompts/interview_orchestrator.txt index d885e94..c5429e1 100644 --- a/src/backend/clara/agents/prompts/interview_orchestrator.txt +++ b/src/backend/clara/agents/prompts/interview_orchestrator.txt @@ -239,6 +239,22 @@ Example opening: ## Key Principles +### Rich UI Submissions + +Users may submit structured data via rich UI components. These messages arrive as: +- `[UI_DATA_TABLE]{json}[/UI_DATA_TABLE]` +- `[UI_PROCESS_MAP]{json}[/UI_PROCESS_MAP]` + +When you see these markers: +1. Extract the JSON payload. +2. Treat it as authoritative user input. +3. Use it to continue the conversation and populate tools. + +### Selection Lists + +You can request a short list selection with `mcp__clara__request_selection_list`. +Use it when the user is choosing from a handful of options (2-7 items). + 1. **Be conversational, not interrogative** - Have a natural dialogue 2. **Listen actively** - Build on what users share 3. **One question at a time** - Don't overwhelm with multiple questions diff --git a/src/backend/clara/agents/router.py b/src/backend/clara/agents/router.py new file mode 100644 index 0000000..ab392f6 --- /dev/null +++ b/src/backend/clara/agents/router.py @@ -0,0 +1,936 @@ +"""AG-UI router for switching between chat and rich UI tools.""" + +from __future__ import annotations + +import json +import logging +import os +import re +from dataclasses import dataclass +from typing import Any, Literal + +import anthropic + +from clara.config import settings +from clara.security import InputSanitizer + +logger = logging.getLogger(__name__) + +DATA_TABLE_MARKER_START = "[DATA_TABLE_SUBMIT]" +DATA_TABLE_MARKER_END = "[/DATA_TABLE_SUBMIT]" +PROCESS_MAP_MARKER_START = "[PROCESS_MAP_SUBMIT]" +PROCESS_MAP_MARKER_END = "[/PROCESS_MAP_SUBMIT]" + +TOOL_CONFIDENCE_THRESHOLD = 0.75 +CLARIFY_CONFIDENCE_THRESHOLD = 0.45 + +ROUTER_MODEL_MAP = { + "haiku": "claude-3-5-haiku-20241022", + "sonnet": "claude-sonnet-4-20250514", + "opus": "claude-opus-4-20250514", +} + +DATA_TABLE_COLUMN_TYPES = {"text", "number", "enum", "date", "url"} + +LIST_KEYWORDS = { + "stakeholder", + "stakeholders", + "list", + "table", + "spreadsheet", + "excel", + "bulk", + "batch", + "inventory", + "items", + "risks", + "issues", + "dependencies", + "systems", + "users", + "customers", + "vendors", +} + +PROCESS_KEYWORDS = { + "process", + "workflow", + "steps", + "step", + "approval", + "approvals", + "pipeline", + "migration", + "sequence", + "timeline", + "handoff", + "hand-off", + "handover", + "stage", + "stages", + "phase", + "phases", +} + +SEQUENCE_MARKERS = {"first", "then", "next", "after", "before", "finally", "last"} + +SELECTION_KEYWORDS = { + "choose", + "pick", + "select", + "which", + "options", + "choices", + "prefer", +} + +SELECTION_MULTI_PATTERNS = [ + r"select all", + r"choose all", + r"pick all", + r"all that apply", + r"which ones", + r"which of these apply", +] + +SELECTION_SINGLE_PATTERNS = [ + r"choose one", + r"pick one", + r"which one", + r"either", + r"choose between", + r"pick between", +] + + +@dataclass +class RouterDecision: + """Router decision output.""" + action: Literal["tool", "chat", "clarify"] + tool_name: str | None = None + confidence: float = 0.0 + params: dict[str, Any] | None = None + rationale: str | None = None + clarifying_question: str | None = None + + +@dataclass +class RouterState: + """Per-session router state.""" + last_tool: str | None = None + last_tool_status: Literal["open", "completed", "canceled"] | None = None + pending_tool: str | None = None + pending_payload: dict[str, Any] | None = None + last_clarify: str | None = None + + +@dataclass +class UISubmission: + """Structured payload submitted via a rich UI component.""" + kind: Literal["data_table", "process_map"] + payload: dict[str, Any] + + +def parse_ui_submission(message: str) -> UISubmission | None: + """Extract structured UI submissions from a message.""" + if not message: + return None + + table_match = re.search( + re.escape(DATA_TABLE_MARKER_START) + r"(.*?)" + re.escape(DATA_TABLE_MARKER_END), + message, + flags=re.DOTALL, + ) + if table_match: + payload = _load_json_payload(table_match.group(1)) + if payload is not None: + return UISubmission(kind="data_table", payload=payload) + + process_match = re.search( + re.escape(PROCESS_MAP_MARKER_START) + r"(.*?)" + re.escape(PROCESS_MAP_MARKER_END), + message, + flags=re.DOTALL, + ) + if process_match: + payload = _load_json_payload(process_match.group(1)) + if payload is not None: + return UISubmission(kind="process_map", payload=payload) + + return None + + +def summarize_ui_submission(submission: UISubmission) -> str: + """Create a compact structured summary for the main model.""" + if submission.kind == "data_table": + payload = _normalize_table_payload(submission.payload) + summary = { + "type": "data_table", + "title": payload.get("title"), + "columns": payload.get("columns", []), + "row_count": len(payload.get("rows", [])), + "rows": payload.get("rows", []), + } + return f"[UI_DATA_TABLE]{json.dumps(summary, ensure_ascii=True)}[/UI_DATA_TABLE]" + + payload = _normalize_process_payload(submission.payload) + summary = { + "type": "process_map", + "title": payload.get("title"), + "step_count": len(payload.get("steps", [])), + "steps": payload.get("steps", []), + } + return f"[UI_PROCESS_MAP]{json.dumps(summary, ensure_ascii=True)}[/UI_PROCESS_MAP]" + + +def is_cancel_intent(message: str) -> bool: + """Detect user intent to cancel a pending UI flow.""" + normalized = message.lower().strip() + cancel_terms = {"cancel", "skip", "not now", "back to chat", "no thanks"} + return any(term in normalized for term in cancel_terms) + + +def is_tool_reply(message: str) -> bool: + """Detect a user reply that came from a UI selection.""" + return _is_tool_reply(message) + + +class UIRouter: + """Router for deciding when to render rich UI components.""" + + def __init__(self, model: str | None = None) -> None: + self.model = model or settings.router_model + self._client: anthropic.AsyncAnthropic | None = None + + async def decide( + self, + message: str, + state: RouterState, + phase: str | None = None, + flow: str | None = None, + allow_selection: bool = True, + ) -> RouterDecision: + """Return a routing decision for a user message.""" + message = InputSanitizer.sanitize_message(message) + if not message: + return RouterDecision(action="chat", confidence=0.0, rationale="empty_message") + + if state.pending_tool and state.last_tool_status == "open": + return RouterDecision(action="chat", confidence=0.2, rationale="pending_tool_open") + + if _is_tool_reply(message): + return RouterDecision(action="chat", confidence=0.1, rationale="tool_reply") + + if parse_ui_submission(message): + return RouterDecision(action="chat", confidence=0.1, rationale="ui_submission") + + if self._use_llm_router(): + decision = await self._llm_decide(message=message, phase=phase, flow=flow) + if decision: + decision = _normalize_selection_decision(message, decision) + if ( + not allow_selection + and decision.action == "tool" + and decision.tool_name == "request_selection_list" + ): + return RouterDecision( + action="chat", + confidence=decision.confidence, + rationale="selection_disabled_on_input", + ) + return _apply_thresholds(decision) + + decision = _heuristic_decide(message=message, allow_selection=allow_selection) + if allow_selection: + decision = _normalize_selection_decision(message, decision) + elif decision.action == "tool" and decision.tool_name == "request_selection_list": + decision = RouterDecision( + action="chat", + confidence=decision.confidence, + rationale="selection_disabled_on_input", + ) + return _apply_thresholds(decision) + + def _use_llm_router(self) -> bool: + if self.model == "heuristic": + return False + if settings.anthropic_api_key: + return True + return bool(os.getenv("ANTHROPIC_API_KEY")) + + async def _llm_decide( + self, + message: str, + phase: str | None, + flow: str | None, + ) -> RouterDecision | None: + """Call a small model to decide routing, fallback to heuristics on error.""" + model_id = ROUTER_MODEL_MAP.get(self.model, self.model) + + try: + if not self._client: + if settings.anthropic_api_key: + self._client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) + else: + self._client = anthropic.AsyncAnthropic() + + system_prompt = _router_system_prompt() + user_prompt = _router_user_prompt(message=message, phase=phase, flow=flow) + + response = await self._client.messages.create( + model=model_id, + max_tokens=512, + system=system_prompt, + messages=[{"role": "user", "content": user_prompt}], + ) + + text = "" + for block in response.content: + if hasattr(block, "text"): + text += block.text + + decision = _parse_router_json(text) + if decision: + return decision + except Exception as exc: # pragma: no cover - network failure fallback + logger.warning("Router model failed, using heuristics: %s", exc) + + return None + + +def _load_json_payload(raw: str) -> dict[str, Any] | None: + """Parse JSON payload safely.""" + try: + return json.loads(raw.strip()) + except json.JSONDecodeError: + return None + + +def _normalize_table_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Normalize data table submissions for downstream use.""" + title = InputSanitizer.sanitize_name(payload.get("title", "")) or "Data Table" + columns = payload.get("columns") or [] + rows = payload.get("rows") or [] + + sanitized_columns = [] + for col in columns: + name = InputSanitizer.sanitize_name(col.get("name")) or "Item" + col_type = col.get("type", "text") + if col_type not in DATA_TABLE_COLUMN_TYPES: + col_type = "text" + sanitized_columns.append( + { + "name": name, + "type": col_type, + "required": bool(col.get("required")), + "options": InputSanitizer.sanitize_array(col.get("options")) or None, + } + ) + + normalized_rows: list[dict[str, str]] = [] + for row in rows: + if not isinstance(row, dict): + continue + cleaned = {} + for key, value in row.items(): + cleaned[str(key)] = str(value).strip() + if any(cleaned.values()): + normalized_rows.append(cleaned) + + return { + "title": title, + "columns": sanitized_columns, + "rows": normalized_rows, + } + + +def _normalize_process_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Normalize process map submissions for downstream use.""" + title = InputSanitizer.sanitize_name(payload.get("title", "")) or "Process Map" + steps = payload.get("steps") or [] + + normalized_steps = [] + for step in steps: + if not isinstance(step, dict): + continue + normalized_steps.append( + { + "step_name": InputSanitizer.sanitize_name(step.get("step_name")), + "owner": InputSanitizer.sanitize_name(step.get("owner")), + "outcome": InputSanitizer.sanitize_description(step.get("outcome")), + "edge_type": InputSanitizer.sanitize_name(step.get("edge_type")), + } + ) + + return {"title": title, "steps": normalized_steps} + + +def _router_system_prompt() -> str: + return ( + "You are a router that decides when to switch from chat to a rich UI.\n" + "Return ONLY valid JSON with keys:\n" + "- action: tool|chat|clarify\n" + "- tool_name: request_data_table|request_process_map|request_selection_list|null\n" + "- confidence: number 0-1\n" + "- params: object|null\n" + "- rationale: short string\n" + "- clarifying_question: string|null\n" + "Follow these rules:\n" + "1) Use request_data_table for lists or bulk structured data.\n" + "2) Use request_process_map for workflows or step sequences.\n" + "3) Use request_selection_list when the user is choosing from 2-7 options.\n" + "3) If unclear, action=clarify with a short question.\n" + "4) Otherwise action=chat.\n" + ) + + +def _router_user_prompt(message: str, phase: str | None, flow: str | None) -> str: + context_parts = [] + if phase: + context_parts.append(f"phase={phase}") + if flow: + context_parts.append(f"flow={flow}") + context = f"Context: {', '.join(context_parts)}" if context_parts else "Context: none" + return f"{context}\nUser message: {message}" + + +def _parse_router_json(text: str) -> RouterDecision | None: + """Parse router JSON output with fallback to substring extraction.""" + if not text: + return None + + match = re.search(r"\{.*\}", text, flags=re.DOTALL) + if not match: + return None + + try: + data = json.loads(match.group(0)) + except json.JSONDecodeError: + return None + + action = data.get("action") + if action not in {"tool", "chat", "clarify"}: + return None + + return RouterDecision( + action=action, + tool_name=data.get("tool_name"), + confidence=float(data.get("confidence", 0.0) or 0.0), + params=data.get("params"), + rationale=data.get("rationale"), + clarifying_question=data.get("clarifying_question"), + ) + + +def _heuristic_decide(message: str, allow_selection: bool = True) -> RouterDecision: + """Heuristic router used when LLM router is unavailable.""" + normalized = message.lower() + + if allow_selection: + selection_items = _extract_selection_items(message) + if selection_items: + multi_select = _selection_is_multi(normalized) + params = _build_selection_params(message, selection_items, multi_select) + confidence = 0.82 + return RouterDecision( + action="tool", + tool_name="request_selection_list", + confidence=confidence, + params=params, + rationale="selection_list_detected", + ) + + count = _extract_explicit_count(normalized) + list_score = sum(1 for key in LIST_KEYWORDS if key in normalized) + process_score = sum(1 for key in PROCESS_KEYWORDS if key in normalized) + sequence_hits = sum(1 for key in SEQUENCE_MARKERS if key in normalized) + + list_force = count is not None and count >= 3 + list_force = list_force or any( + term in normalized for term in ["spreadsheet", "excel", "bulk", "paste", "table"] + ) + + process_force = ( + "->" in normalized + or sequence_hits >= 2 + or ("process" in normalized and sequence_hits >= 1) + ) + + if process_force or (process_score >= 2 and process_score >= list_score): + params = _build_process_map_params(message=message, min_steps=count) + confidence = _confidence_from_score(process_score + sequence_hits, base=0.6) + if process_force: + confidence = max(confidence, 0.82) + return RouterDecision( + action="tool", + tool_name="request_process_map", + confidence=confidence, + params=params, + rationale="process_markers_detected", + ) + + if list_force or list_score >= 2: + params = _build_data_table_params(message=message, min_rows=count) + confidence = _confidence_from_score(list_score, base=0.6) + if list_force: + confidence = max(confidence, 0.82) + return RouterDecision( + action="tool", + tool_name="request_data_table", + confidence=confidence, + params=params, + rationale="list_markers_detected", + ) + + if process_score == 1 or sequence_hits == 1: + return RouterDecision( + action="clarify", + confidence=0.55, + clarifying_question="Want to map the steps in a process map?", + rationale="weak_process_signal", + ) + + if list_score == 1: + return RouterDecision( + action="clarify", + confidence=0.5, + clarifying_question="How many items are you capturing?", + rationale="weak_list_signal", + ) + + return RouterDecision(action="chat", confidence=0.2, rationale="no_signal") + + +def _extract_explicit_count(text: str) -> int | None: + match = re.search(r"\b(\d{1,3})\b", text) + if not match: + return None + try: + value = int(match.group(1)) + except ValueError: + return None + return value if value > 0 else None + + +def _confidence_from_score(score: int, base: float = 0.5) -> float: + return min(0.95, base + (score * 0.08)) + + +def _build_data_table_params(message: str, min_rows: int | None = None) -> dict[str, Any]: + normalized = message.lower() + title = "Data Table" + columns = _default_columns() + + if "stakeholder" in normalized: + title = "Stakeholder List" + columns = [ + {"name": "Name", "type": "text", "required": True}, + {"name": "Role", "type": "text", "required": True}, + {"name": "Influence Level", "type": "enum", "required": False, + "options": ["Low", "Medium", "High"]}, + ] + elif "risk" in normalized: + title = "Risk Register" + columns = [ + {"name": "Risk", "type": "text", "required": True}, + {"name": "Severity", "type": "enum", "required": False, + "options": ["Low", "Medium", "High"]}, + {"name": "Owner", "type": "text", "required": False}, + ] + elif "system" in normalized: + title = "System Inventory" + columns = [ + {"name": "System", "type": "text", "required": True}, + {"name": "Owner", "type": "text", "required": False}, + {"name": "Criticality", "type": "enum", "required": False, + "options": ["Low", "Medium", "High"]}, + ] + + max_rows = 50 + min_rows = min_rows or 3 + min_rows = min(max_rows, min_rows) + starter_rows = min(5, min_rows) + + return { + "title": title, + "columns": columns, + "min_rows": min_rows, + "starter_rows": starter_rows, + "input_modes": ["paste", "inline"], + "summary_prompt": "Capture the list as structured rows.", + } + + +def _default_columns() -> list[dict[str, Any]]: + return [ + {"name": "Item", "type": "text", "required": True}, + {"name": "Description", "type": "text", "required": False}, + {"name": "Owner", "type": "text", "required": False}, + ] + + +def _sanitize_columns(columns: Any) -> list[dict[str, Any]]: + if not isinstance(columns, list): + return _default_columns() + + sanitized = [] + for column in columns: + if not isinstance(column, dict): + continue + name = InputSanitizer.sanitize_name(column.get("name")) + if not name: + continue + col_type = column.get("type", "text") + if col_type not in DATA_TABLE_COLUMN_TYPES: + col_type = "text" + options = InputSanitizer.sanitize_array(column.get("options")) or None + sanitized.append( + { + "name": name, + "type": col_type, + "required": bool(column.get("required")), + "options": options, + } + ) + + return sanitized or _default_columns() + + +def _build_process_map_params(message: str, min_steps: int | None = None) -> dict[str, Any]: + normalized = message.lower() + title = "Process Map" + if "approval" in normalized: + title = "Approval Process" + elif "migration" in normalized: + title = "Migration Workflow" + + min_steps = min_steps or 3 + min_steps = min(min_steps, 20) + return { + "title": title, + "required_fields": ["step_name", "owner", "outcome"], + "edge_types": ["sequence", "approval", "parallel"], + "min_steps": min_steps, + "seed_nodes": [], + } + + +def _build_selection_params( + message: str, + items: list[str], + multi_select: bool, +) -> dict[str, Any]: + question = _extract_question(message) + if not question: + question = "Select the options that apply." if multi_select else "Choose one option." + + options = [] + for item in items: + label = InputSanitizer.sanitize_name(item) + if not label: + continue + option_id = _slugify(label) + options.append({"id": option_id, "label": label}) + + options = _ensure_other_option(options) + + return { + "question": question, + "options": options, + "multi_select": multi_select, + } + + +def _is_tool_reply(message: str) -> bool: + normalized = message.lower().strip() + if normalized.startswith("i chose:"): + return True + if normalized.startswith("[prompt_saved]"): + return True + return False + + +def _extract_selection_items(message: str) -> list[str]: + normalized = message.lower() + if not any(keyword in normalized for keyword in SELECTION_KEYWORDS): + return [] + + patterns = [ + r"(?:options|choices)\s*[:\-]\s*(.+)", + r"(?:choose between|pick between|select from|choose from|pick from)\s+(.+)", + ] + + segment = None + for pattern in patterns: + match = re.search(pattern, message, flags=re.IGNORECASE) + if match: + segment = match.group(1) + break + + if segment is None: + if any(sep in message for sep in [",", "/", "|", " or "]): + segment = message + else: + return [] + + segment = re.split(r"[?.!]", segment)[0] + items = _split_list_items(segment) + + cleaned = [] + for item in items: + value = item.strip().strip('"').strip("'") + if len(value) < 2: + continue + cleaned.append(value) + + if 2 <= len(cleaned) <= 7: + return cleaned + return [] + + +def _split_list_items(segment: str) -> list[str]: + if "," in segment or ";" in segment: + return re.split(r"\s*[,;]\s*", segment) + if "/" in segment or "|" in segment: + return re.split(r"\s*[\/|]\s*", segment) + if re.search(r"\bor\b", segment, flags=re.IGNORECASE): + return re.split(r"\s+\bor\b\s+", segment, flags=re.IGNORECASE) + return re.split(r"\s+\band\b\s+", segment, flags=re.IGNORECASE) + + +def _clean_list_item(item: str) -> str: + cleaned = re.sub(r"[*_`]+", "", item) + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned.strip(" \t-–—:;.,") + + +def _extract_numbered_items(message: str) -> list[str]: + pattern = re.compile( + r"(?:^|\s)(?:\d+)[\.\)]\s*([^\n]+?)(?=\s*\d+[\.\)]|$)", + flags=re.DOTALL, + ) + items: list[str] = [] + for match in pattern.finditer(message): + value = _clean_list_item(match.group(1)) + if value: + items.append(value) + return items + + +def _extract_bulleted_items(message: str) -> list[str]: + items: list[str] = [] + for line in message.splitlines(): + match = re.match(r"\s*(?:[-*β€’])\s+(.+)", line) + if not match: + continue + value = _clean_list_item(match.group(1)) + if value: + items.append(value) + return items + + +def infer_selection_from_assistant_output(message: str) -> RouterDecision | None: + """Infer a selection list from assistant output.""" + sanitized = InputSanitizer.sanitize_message(message) + if not sanitized: + return None + + selection_items = _extract_selection_items(sanitized) + if not selection_items: + selection_items = _extract_numbered_items(sanitized) + if not selection_items: + selection_items = _extract_bulleted_items(sanitized) + + if not (2 <= len(selection_items) <= 7): + return None + + multi_select = _selection_is_multi(sanitized.lower()) + params = _build_selection_params(sanitized, selection_items, multi_select) + return RouterDecision( + action="tool", + tool_name="request_selection_list", + confidence=0.7, + params=params, + rationale="assistant_output_list", + ) + + +def _selection_is_multi(normalized: str) -> bool: + for pattern in SELECTION_MULTI_PATTERNS: + if re.search(pattern, normalized, flags=re.IGNORECASE): + return True + for pattern in SELECTION_SINGLE_PATTERNS: + if re.search(pattern, normalized, flags=re.IGNORECASE): + return False + return False + + +def _selection_is_explicit(normalized: str) -> bool: + return any(keyword in normalized for keyword in {"options", "choices", "choose", "select", "pick"}) + + +def _extract_question(message: str) -> str | None: + if "?" in message: + question = message.split("?", 1)[0].strip() + if question: + return f"{question}?" + return None + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") + return slug or "option" + + +def _ensure_other_option(options: list[dict[str, Any]]) -> list[dict[str, Any]]: + for option in options: + label = str(option.get("label", "")).lower().strip() + if label.startswith("other") or label in {"something else"}: + option["requires_input"] = True + return options + + other_id = "other" + existing_ids = {str(option.get("id")) for option in options} + if other_id in existing_ids: + suffix = 2 + while f"other_{suffix}" in existing_ids: + suffix += 1 + other_id = f"other_{suffix}" + + return [ + *options, + { + "id": other_id, + "label": "Other", + "description": "Something else", + "requires_input": True, + }, + ] + + +def _normalize_selection_decision(message: str, decision: RouterDecision) -> RouterDecision: + if decision.tool_name != "request_selection_list": + return decision + + params = dict(decision.params or {}) + params["multi_select"] = _selection_is_multi(message.lower()) + if "options" in params and isinstance(params["options"], list): + params["options"] = _ensure_other_option(params["options"]) + decision.params = params + return decision + + +def build_ui_component(decision: RouterDecision) -> dict[str, Any] | None: + """Convert router params into UI component payloads.""" + if decision.action != "tool" or not decision.tool_name: + return None + + params = decision.params or {} + + if decision.tool_name == "request_data_table": + min_rows = _safe_int(params.get("min_rows"), 3) + min_rows = min(min_rows, 50) + starter_rows = _safe_int(params.get("starter_rows"), min_rows) + starter_rows = min(starter_rows, min_rows) + input_modes = InputSanitizer.sanitize_array(params.get("input_modes")) + title = InputSanitizer.sanitize_name(params.get("title")) or "Data Table" + return { + "type": "data_table", + "title": title, + "columns": _sanitize_columns(params.get("columns")), + "min_rows": min_rows, + "starter_rows": starter_rows, + "input_modes": input_modes or ["paste", "inline"], + "summary_prompt": params.get("summary_prompt", ""), + } + + if decision.tool_name == "request_process_map": + min_steps = _safe_int(params.get("min_steps"), 3) + min_steps = min(min_steps, 20) + required_fields = InputSanitizer.sanitize_array(params.get("required_fields")) + edge_types = InputSanitizer.sanitize_array(params.get("edge_types")) + seed_nodes = InputSanitizer.sanitize_array(params.get("seed_nodes")) + title = InputSanitizer.sanitize_name(params.get("title")) or "Process Map" + return { + "type": "process_map", + "title": title, + "required_fields": required_fields or ["step_name", "owner", "outcome"], + "edge_types": edge_types or ["sequence", "approval", "parallel"], + "min_steps": min_steps, + "seed_nodes": seed_nodes, + } + + if decision.tool_name == "request_selection_list": + question = InputSanitizer.sanitize_description(params.get("question")) + options = params.get("options") or [] + sanitized_options = [] + for option in options: + if not isinstance(option, dict): + continue + label = InputSanitizer.sanitize_name(option.get("label")) + if not label: + continue + option_id = option.get("id") or _slugify(label) + requires_input = bool(option.get("requires_input")) + if label.strip().lower().startswith("other"): + requires_input = True + sanitized_options.append( + { + "id": InputSanitizer.sanitize_name(option_id), + "label": label, + "description": InputSanitizer.sanitize_description(option.get("description")), + "requires_input": requires_input, + } + ) + + return { + "type": "user_input_required", + "question": question or "Choose an option.", + "options": sanitized_options, + "multi_select": bool(params.get("multi_select")), + } + + return None + + +def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _apply_thresholds(decision: RouterDecision) -> RouterDecision: + """Apply confidence thresholds to router decisions.""" + if decision.action == "tool" and decision.confidence < TOOL_CONFIDENCE_THRESHOLD: + if decision.confidence >= CLARIFY_CONFIDENCE_THRESHOLD: + return RouterDecision( + action="clarify", + confidence=decision.confidence, + clarifying_question=decision.clarifying_question + or _clarify_question_for_tool(decision.tool_name), + rationale="threshold_downgrade_to_clarify", + ) + return RouterDecision( + action="chat", + confidence=decision.confidence, + rationale="threshold_downgrade_to_chat", + ) + + if decision.action == "clarify" and decision.confidence < CLARIFY_CONFIDENCE_THRESHOLD: + return RouterDecision( + action="chat", + confidence=decision.confidence, + rationale="clarify_below_threshold", + ) + + return decision + + +def _clarify_question_for_tool(tool_name: str | None) -> str: + if tool_name == "request_data_table": + return "How many items are you capturing?" + if tool_name == "request_process_map": + return "Want to map the steps in a process map?" + if tool_name == "request_selection_list": + return "Do you want to pick from a short list?" + return "Can you clarify what you need captured?" diff --git a/src/backend/clara/agents/simulation_agent.py b/src/backend/clara/agents/simulation_agent.py index b670025..2ef4a9c 100644 --- a/src/backend/clara/agents/simulation_agent.py +++ b/src/backend/clara/agents/simulation_agent.py @@ -18,6 +18,16 @@ import httpx from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient +from clara.agents.router import ( + RouterState, + UIRouter, + build_ui_component, + infer_selection_from_assistant_output, + is_cancel_intent, + is_tool_reply, + parse_ui_submission, + summarize_ui_submission, +) from clara.config import settings logger = logging.getLogger(__name__) @@ -71,6 +81,8 @@ class SimulationSession: model: str = field(default_factory=lambda: settings.simulation_interviewer_model) persona: PersonaConfig | None = None # For auto-simulation mode messages: list[dict] = field(default_factory=list) + router_state: RouterState = field(default_factory=RouterState) + router: UIRouter = field(default_factory=UIRouter) # Timestamps for TTL cleanup created_at: datetime = field(default_factory=datetime.now) @@ -221,7 +233,11 @@ async def get_introduction(self) -> AsyncGenerator[AGUIEvent, None]: yield AGUIEvent(type="TEXT_MESSAGE_END", data={"role": "assistant"}) - async def send_user_message(self, user_message: str) -> AsyncGenerator[AGUIEvent, None]: + async def send_user_message( + self, + user_message: str, + apply_router: bool = True + ) -> AsyncGenerator[AGUIEvent, None]: """Send a message from the user (human or simulated) and get the interviewer's response.""" if not self._running or not self._interviewer_client: raise RuntimeError("Session not started") @@ -229,6 +245,32 @@ async def send_user_message(self, user_message: str) -> AsyncGenerator[AGUIEvent # Update last activity self.last_activity = datetime.now() + submission = parse_ui_submission(user_message) + if submission: + self.router_state.last_tool = submission.kind + self.router_state.last_tool_status = "completed" + self.router_state.pending_tool = None + self.router_state.pending_payload = None + user_message = summarize_ui_submission(submission) + + if ( + not submission + and self.router_state.pending_tool == "request_selection_list" + and is_tool_reply(user_message) + ): + self.router_state.last_tool_status = "completed" + self.router_state.pending_tool = None + self.router_state.pending_payload = None + + if ( + not submission + and self.router_state.pending_tool + and is_cancel_intent(user_message) + ): + self.router_state.last_tool_status = "canceled" + self.router_state.pending_tool = None + self.router_state.pending_payload = None + # Store user message self.messages.append({"role": "user", "content": user_message}) @@ -236,6 +278,74 @@ async def send_user_message(self, user_message: str) -> AsyncGenerator[AGUIEvent if len(self.messages) > MAX_MESSAGE_HISTORY: self.messages = self.messages[-MAX_MESSAGE_HISTORY:] + if apply_router and not submission: + decision = await self.router.decide( + message=user_message, + state=self.router_state, + phase="interview", + flow="simulation", + allow_selection=False, + ) + + if decision.action in {"tool", "clarify"}: + preamble = "" + if decision.action == "tool": + ui_component = build_ui_component(decision) + if ui_component: + if decision.tool_name == "request_data_table": + preamble = ( + "Let's capture that in a table so you can paste rows quickly." + ) + elif decision.tool_name == "request_process_map": + preamble = ( + "Let's map the steps so we capture the workflow accurately." + ) + elif decision.tool_name == "request_selection_list": + preamble = "Pick the options that apply." + + self.router_state.pending_tool = decision.tool_name + self.router_state.pending_payload = decision.params + self.router_state.last_tool = decision.tool_name + self.router_state.last_tool_status = "open" + + self.messages.append({"role": "assistant", "content": preamble}) + if len(self.messages) > MAX_MESSAGE_HISTORY: + self.messages = self.messages[-MAX_MESSAGE_HISTORY:] + + yield AGUIEvent( + type="TEXT_MESSAGE_CONTENT", + data={"delta": preamble, "role": "assistant"} + ) + yield AGUIEvent( + type="CUSTOM", + data={ + "name": ( + "clara:data_table" + if decision.tool_name == "request_data_table" + else "clara:process_map" + if decision.tool_name == "request_process_map" + else "clara:ask" + ), + "value": ui_component, + }, + ) + yield AGUIEvent(type="TEXT_MESSAGE_END", data={"role": "assistant"}) + return + + if decision.action == "clarify": + question = decision.clarifying_question or "Can you clarify?" + self.router_state.last_clarify = question + self.messages.append({"role": "assistant", "content": question}) + if len(self.messages) > MAX_MESSAGE_HISTORY: + self.messages = self.messages[-MAX_MESSAGE_HISTORY:] + + yield AGUIEvent( + type="TEXT_MESSAGE_CONTENT", + data={"delta": question, "role": "assistant"} + ) + yield AGUIEvent(type="TEXT_MESSAGE_END", data={"role": "assistant"}) + return + try: await self._interviewer_client.query(prompt=user_message) @@ -264,6 +374,20 @@ async def send_user_message(self, user_message: str) -> AsyncGenerator[AGUIEvent if current_text: self.messages.append({"role": "assistant", "content": current_text}) + if current_text and not self.router_state.pending_tool: + selection_decision = infer_selection_from_assistant_output(current_text) + if selection_decision: + ui_component = build_ui_component(selection_decision) + if ui_component: + self.router_state.pending_tool = selection_decision.tool_name + self.router_state.pending_payload = selection_decision.params + self.router_state.last_tool = selection_decision.tool_name + self.router_state.last_tool_status = "open" + yield AGUIEvent( + type="CUSTOM", + data={"name": "clara:ask", "value": ui_component} + ) + yield AGUIEvent(type="TEXT_MESSAGE_END", data={"role": "assistant"}) except Exception as e: @@ -371,6 +495,7 @@ def reset(self): """Reset conversation history.""" self.messages = [] self._introduction_sent = False + self.router_state = RouterState() def is_safe_url(url: str) -> bool: diff --git a/src/backend/clara/agents/tools.py b/src/backend/clara/agents/tools.py index 2a10bab..e27bd80 100644 --- a/src/backend/clara/agents/tools.py +++ b/src/backend/clara/agents/tools.py @@ -110,6 +110,69 @@ def cleanup_stale_sessions() -> int: return len(stale_ids) +def _safe_int(value: Any, default: int) -> int: + """Safely convert values to int with a fallback.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def sanitize_ask_options(options: list[dict[str, Any]] | Any) -> list[dict[str, Any]]: + """Normalize option payloads for selection-style UIs.""" + if not isinstance(options, list): + return [] + sanitized: list[dict[str, Any]] = [] + for option in options: + if not isinstance(option, dict): + continue + label = InputSanitizer.sanitize_name(option.get("label", "")) + if not label: + continue + option_id = InputSanitizer.sanitize_name(option.get("id") or label) or "option" + description = InputSanitizer.sanitize_description(option.get("description")) + requires_input = bool(option.get("requires_input")) + if label.strip().lower().startswith("other"): + requires_input = True + entry: dict[str, Any] = { + "id": option_id, + "label": label, + } + if description: + entry["description"] = description + if requires_input: + entry["requires_input"] = True + sanitized.append(entry) + return sanitized + + +def ensure_other_option(options: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Append an Other option if missing, and mark it as requiring input.""" + for option in options: + label = str(option.get("label", "")).strip().lower() + if label.startswith("other"): + option["requires_input"] = True + return options + + other_id = "other" + existing_ids = {str(option.get("id")) for option in options} + if other_id in existing_ids: + suffix = 2 + while f"other_{suffix}" in existing_ids: + suffix += 1 + other_id = f"other_{suffix}" + + return [ + *options, + { + "id": other_id, + "label": "Other", + "description": "Something else", + "requires_input": True, + }, + ] + + # Tool input schemas as dicts (for the SDK) ProjectSchema = { "type": "object", @@ -170,6 +233,36 @@ def cleanup_stale_sessions() -> int: "id": {"type": "string"}, "label": {"type": "string"}, "description": {"type": "string"}, + "requires_input": { + "type": "boolean", + "description": "If true, require a free-text input when selected (e.g., Other).", + }, + }, + "required": ["id", "label"], + }, + "description": "Options to present to the user", + }, + "multi_select": {"type": "boolean", "description": "Allow multiple selections"}, + }, + "required": ["question", "options"], +} + +SelectionListSchema = { + "type": "object", + "properties": { + "question": {"type": "string", "description": "Question to ask the user"}, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "label": {"type": "string"}, + "description": {"type": "string"}, + "requires_input": { + "type": "boolean", + "description": "If true, require a free-text input when selected (e.g., Other).", + }, }, "required": ["id", "label"], }, @@ -312,6 +405,65 @@ def cleanup_stale_sessions() -> int: "required": ["title", "prompt"], } +DataTableSchema = { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Title for the data table"}, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "type": { + "type": "string", + "enum": ["text", "number", "enum", "date", "url"], + }, + "required": {"type": "boolean"}, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": "Allowed values if type is enum", + }, + }, + "required": ["name", "type"], + }, + }, + "min_rows": {"type": "integer", "description": "Minimum number of rows"}, + "starter_rows": {"type": "integer", "description": "Rows to prefill"}, + "input_modes": { + "type": "array", + "items": {"type": "string", "enum": ["paste", "inline", "import"]}, + }, + "summary_prompt": {"type": "string", "description": "Short prompt for summarizing the data"}, + }, + "required": ["title", "columns"], +} + +ProcessMapSchema = { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Title for the process map"}, + "required_fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Required fields per step", + }, + "edge_types": { + "type": "array", + "items": {"type": "string"}, + "description": "Allowed edge types between steps", + }, + "min_steps": {"type": "integer", "description": "Minimum number of steps"}, + "seed_nodes": { + "type": "array", + "items": {"type": "string"}, + "description": "Seed step labels", + }, + }, + "required": ["title"], +} + GetAgentContextSchema = { "type": "object", "properties": { @@ -438,10 +590,11 @@ async def ask_tool(args: dict) -> dict[str, Any]: logger.info(f"[{session_id}] Asking user: {args['question']}") # Store UI component in session state for frontend access state = get_session_state(session_id) + options = sanitize_ask_options(args.get("options", [])) ui_component = { "type": "user_input_required", "question": args["question"], - "options": args["options"], + "options": options, "multi_select": args.get("multi_select", False), } state["pending_ui_component"] = ui_component @@ -453,6 +606,110 @@ async def ask_tool(args: dict) -> dict[str, Any]: }] } + @tool("request_selection_list", "Present a checkbox/radio list for selection", SelectionListSchema) + async def request_selection_list_tool(args: dict) -> dict[str, Any]: + """Show a selection list UI to user.""" + logger.info(f"[{session_id}] Requesting selection list: {args['question']}") + state = get_session_state(session_id) + options = ensure_other_option(sanitize_ask_options(args.get("options", []))) + ui_component = { + "type": "user_input_required", + "question": args["question"], + "options": options, + "multi_select": args.get("multi_select", False), + } + state["pending_ui_component"] = ui_component + return { + "content": [{ + "type": "text", + "text": f"Presenting selection list: {args['question']}" + }] + } + + @tool("request_data_table", "Request structured data table input", DataTableSchema) + async def request_data_table_tool(args: dict) -> dict[str, Any]: + """Show a data table UI to capture structured lists.""" + state = get_session_state(session_id) + + title = InputSanitizer.sanitize_name(args.get("title", "")) or "Data Table" + columns = [] + for column in args.get("columns", []): + name = InputSanitizer.sanitize_name(column.get("name", "")) or "Item" + col_type = column.get("type", "text") + if col_type not in {"text", "number", "enum", "date", "url"}: + col_type = "text" + options = InputSanitizer.sanitize_array(column.get("options")) + columns.append( + { + "name": name, + "type": col_type, + "required": bool(column.get("required")), + "options": options or None, + } + ) + + max_rows = 50 + min_rows = _safe_int(args.get("min_rows"), 1) + min_rows = min(max_rows, max(1, min_rows)) + starter_rows = _safe_int(args.get("starter_rows"), min_rows) + starter_rows = min(max_rows, max(1, starter_rows), min_rows) + input_modes = InputSanitizer.sanitize_array(args.get("input_modes", ["paste", "inline"])) + + ui_component = { + "type": "data_table", + "title": title, + "columns": columns, + "min_rows": min_rows, + "starter_rows": starter_rows, + "input_modes": input_modes or ["paste", "inline"], + "summary_prompt": InputSanitizer.sanitize_description(args.get("summary_prompt")), + } + + state["pending_ui_component"] = ui_component + + logger.info(f"[{session_id}] Requesting data table: {title}") + return { + "content": [{ + "type": "text", + "text": f"Requesting data table: {title}" + }] + } + + @tool("request_process_map", "Request a process map input", ProcessMapSchema) + async def request_process_map_tool(args: dict) -> dict[str, Any]: + """Show a process map UI to capture workflows.""" + state = get_session_state(session_id) + + title = InputSanitizer.sanitize_name(args.get("title", "")) or "Process Map" + required_fields = InputSanitizer.sanitize_array( + args.get("required_fields", ["step_name", "owner", "outcome"]) + ) + edge_types = InputSanitizer.sanitize_array( + args.get("edge_types", ["sequence", "approval", "parallel"]) + ) + seed_nodes = InputSanitizer.sanitize_array(args.get("seed_nodes", [])) + min_steps = _safe_int(args.get("min_steps"), 1) + min_steps = min(min_steps, 20) + + ui_component = { + "type": "process_map", + "title": title, + "required_fields": required_fields or ["step_name", "owner", "outcome"], + "edge_types": edge_types or ["sequence", "approval", "parallel"], + "min_steps": min_steps, + "seed_nodes": seed_nodes, + } + + state["pending_ui_component"] = ui_component + + logger.info(f"[{session_id}] Requesting process map: {title}") + return { + "content": [{ + "type": "text", + "text": f"Requesting process map: {title}" + }] + } + @tool("phase", "Transition to a different design phase", PhaseSchema) async def phase_tool(args: dict) -> dict[str, Any]: """Change the current design phase. @@ -937,6 +1194,9 @@ async def get_agent_context_tool(args: dict) -> dict[str, Any]: entity_tool, agent_tool, ask_tool, + request_selection_list_tool, + request_data_table_tool, + request_process_map_tool, phase_tool, preview_tool, agent_summary_tool, @@ -957,6 +1217,9 @@ async def get_agent_context_tool(args: dict) -> dict[str, Any]: "mcp__clara__entity", "mcp__clara__agent", "mcp__clara__ask", + "mcp__clara__request_selection_list", + "mcp__clara__request_data_table", + "mcp__clara__request_process_map", "mcp__clara__phase", "mcp__clara__preview", "mcp__clara__agent_summary", diff --git a/src/backend/clara/config.py b/src/backend/clara/config.py index b035d70..c145582 100644 --- a/src/backend/clara/config.py +++ b/src/backend/clara/config.py @@ -28,6 +28,7 @@ class Settings(BaseSettings): simulation_interviewer_model: str = "sonnet" simulation_user_model: str = "haiku" web_search_model: str = "claude-sonnet-4-20250514" + router_model: str = "haiku" # Anthropic API (uses ANTHROPIC_API_KEY env var by default) anthropic_api_key: str | None = None diff --git a/src/frontend/src/api/simulation-sessions.ts b/src/frontend/src/api/simulation-sessions.ts index 9d0f61e..dfc387f 100644 --- a/src/frontend/src/api/simulation-sessions.ts +++ b/src/frontend/src/api/simulation-sessions.ts @@ -207,6 +207,8 @@ export interface AutoSimulationEvent { message?: string; role?: string; turns?: number; + name?: string; + value?: unknown; } /** diff --git a/src/frontend/src/components/design-assistant/ChatMessage.tsx b/src/frontend/src/components/design-assistant/ChatMessage.tsx index df72c5a..e90c427 100644 --- a/src/frontend/src/components/design-assistant/ChatMessage.tsx +++ b/src/frontend/src/components/design-assistant/ChatMessage.tsx @@ -4,22 +4,45 @@ import { useMemo } from 'react'; import clsx from 'clsx'; -import type { ChatMessage as ChatMessageType, AskUIComponent, AgentConfiguredUIComponent, PromptEditorUIComponent, UIComponent } from '../../types/design-session'; +import type { + ChatMessage as ChatMessageType, + AskUIComponent, + AgentConfiguredUIComponent, + PromptEditorUIComponent, + DataTableUIComponent, + ProcessMapUIComponent, + DataTableSubmission, + ProcessMapSubmission, + UIComponent, +} from '../../types/design-session'; import { parseUIComponent, stripUIComponentMarkers } from '../../types/design-session'; import { OptionCards } from './OptionCards'; import { AgentConfiguredCard } from './AgentConfiguredCard'; import { PromptEditor } from './PromptEditor'; +import { DataTableCapture } from './DataTableCapture'; +import { ProcessMapBuilder } from './ProcessMapBuilder'; interface ChatMessageProps { message: ChatMessageType; onOptionSelect?: (optionId: string) => void; /** Callback when user saves an edited prompt */ onPromptSave?: (editedPrompt: string) => void; + /** Callback when user submits a data table */ + onTableSubmit?: (payload: DataTableSubmission) => void; + /** Callback when user submits a process map */ + onProcessMapSubmit?: (payload: ProcessMapSubmission) => void; /** UI component from CUSTOM event - takes precedence over text parsing */ externalUIComponent?: UIComponent | null; } -export function ChatMessage({ message, onOptionSelect, onPromptSave, externalUIComponent }: ChatMessageProps) { +export function ChatMessage({ + message, + onOptionSelect, + onPromptSave, + onTableSubmit, + onProcessMapSubmit, + externalUIComponent, +}: ChatMessageProps) { const isUser = message.role === 'user'; // Parse any UI components from the message @@ -128,6 +151,35 @@ export function ChatMessage({ message, onOptionSelect, onPromptSave, externalUIC /> )} + + {/* Data table capture (bulk entry) */} + {uiComponent && uiComponent.type === 'data_table' && onTableSubmit && ( +
+ +
+ )} + + {/* Process map builder */} + {uiComponent && uiComponent.type === 'process_map' && onProcessMapSubmit && ( +
+ +
+ )} ); diff --git a/src/frontend/src/components/design-assistant/DataTableCapture.tsx b/src/frontend/src/components/design-assistant/DataTableCapture.tsx new file mode 100644 index 0000000..7d99f02 --- /dev/null +++ b/src/frontend/src/components/design-assistant/DataTableCapture.tsx @@ -0,0 +1,262 @@ +/** + * Data table capture component for bulk structured input. + */ + +import { useMemo, useState } from 'react'; +import type { + DataTableColumn, + DataTableRow, + DataTableSubmission, +} from '../../types/design-session'; + +interface DataTableCaptureProps { + title: string; + columns: DataTableColumn[]; + minRows?: number; + starterRows?: number; + inputModes?: Array<'paste' | 'inline' | 'import'>; + summaryPrompt?: string; + onSubmit: (payload: DataTableSubmission) => void; +} + +const MAX_ROWS = 50; + +function buildEmptyRow(columns: DataTableColumn[]): DataTableRow { + return columns.reduce((row, column) => { + row[column.name] = ''; + return row; + }, {}); +} + +function normalizeRows(rows: DataTableRow[], columns: DataTableColumn[]): DataTableRow[] { + return rows.filter((row) => + columns.some((column) => (row[column.name] || '').trim().length > 0) + ); +} + +function parsePastedRows(text: string, columns: DataTableColumn[]): DataTableRow[] { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (!lines.length) return []; + + const delimiter = text.includes('\t') ? '\t' : ','; + const parsed = lines.map((line) => line.split(delimiter).map((cell) => cell.trim())); + + return parsed.slice(0, MAX_ROWS).map((cells) => { + const row = buildEmptyRow(columns); + columns.forEach((column, idx) => { + row[column.name] = cells[idx] ?? ''; + }); + return row; + }); +} + +export function DataTableCapture({ + title, + columns, + minRows = 1, + starterRows = 1, + inputModes = ['paste', 'inline'], + summaryPrompt, + onSubmit, +}: DataTableCaptureProps) { + const initialRows = useMemo( + () => + Array.from({ length: Math.min(MAX_ROWS, Math.max(1, starterRows)) }).map(() => + buildEmptyRow(columns) + ), + [columns, starterRows] + ); + + const [rows, setRows] = useState(initialRows); + const [pasteValue, setPasteValue] = useState(''); + const [error, setError] = useState(null); + + const handleCellChange = (rowIndex: number, columnName: string, value: string) => { + setRows((prev) => + prev.map((row, idx) => + idx === rowIndex ? { ...row, [columnName]: value } : row + ) + ); + }; + + const handleAddRow = () => { + setRows((prev) => + prev.length >= MAX_ROWS ? prev : [...prev, buildEmptyRow(columns)] + ); + }; + + const handleRemoveRow = (rowIndex: number) => { + setRows((prev) => prev.filter((_, idx) => idx !== rowIndex)); + }; + + const handlePasteParse = () => { + const parsedRows = parsePastedRows(pasteValue, columns); + if (!parsedRows.length) { + setError('Paste data with one row per line.'); + return; + } + setError(null); + setRows(parsedRows); + }; + + const handleSubmit = () => { + const cleanedRows = normalizeRows(rows, columns); + if (cleanedRows.length < minRows) { + setError(`Add at least ${minRows} rows.`); + return; + } + + const missingRequired = columns + .filter((column) => column.required) + .some((column) => + cleanedRows.some((row) => !(row[column.name] || '').trim()) + ); + + if (missingRequired) { + setError('Fill all required fields before submitting.'); + return; + } + + setError(null); + onSubmit({ + title, + columns, + rows: cleanedRows, + }); + }; + + return ( +
+
+

{title}

+ {summaryPrompt && ( +

{summaryPrompt}

+ )} +
+ + {inputModes.includes('paste') && ( +
+ +