"""Workflow v4 node catalog, normalization, and validation.""" from __future__ import annotations from collections import defaultdict, deque from copy import deepcopy from typing import Any from models import VISION_PROMPT_MODES from services.message_policy import ( MESSAGE_COMPLETION_POLICIES, MESSAGE_CONFIRMATION, ) SPEC_VERSION = "4" NODE_TYPES = { "start", "agent", "message", "action", "update_state", "handoff", "end", } EDGE_MODES = {"llm", "expression", "always"} WORKFLOW_RUNTIME_MODES = {"pipeline", "realtime"} WORKFLOW_LLM_ROUTING_MODES = {"llm_router", "edge_tool"} AGENT_ENTRY_MODES = {"wait_user", "generate"} ACTION_USER_INPUT_POLICIES = {"queue", "block"} AUTOMATIC_NODE_TYPES = { "start", "message", "action", "update_state", "handoff", } EXPRESSION_OPERATORS = { "eq", "neq", "gt", "gte", "lt", "lte", "contains", "in", "exists", } NODE_SPECS: list[dict[str, Any]] = [ { "name": "start", "displayName": "Start", "category": "control_node", "description": "初始化会话、动态变量和全局观察器。", "icon": "Play", "accent": "mint", "addable": False, "constraints": { "minIncoming": 0, "maxIncoming": 0, "minOutgoing": 0, "minInstances": 1, "maxInstances": 1, }, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Start"}, ], }, { "name": "agent", "displayName": "Agent", "category": "conversation_node", "description": "阶段智能体:绑定上下文、工具、知识库、视觉及语音资源。", "icon": "Bot", "accent": "sky", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 0}, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Agent"}, { "key": "prompt", "label": "阶段提示词", "type": "textarea", "required": True, "default": "", }, ], }, { "name": "message", "displayName": "Message", "category": "interaction_node", "description": "固定播报,并可同时显示内置客户端消息、等待用户确认。", "icon": "MessageSquareText", "accent": "lavender", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 0}, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Message"}, ], }, { "name": "action", "displayName": "Action", "category": "execution_node", "description": "确定性执行指定工具,并将结果字段写入会话动态变量。", "icon": "Zap", "accent": "peach", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 0}, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Action"}, ], }, { "name": "update_state", "displayName": "Update State", "category": "execution_node", "description": "原子更新已声明的动态变量,然后使用新状态继续路由。", "icon": "Braces", "accent": "mint", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 0}, "fields": [ { "key": "name", "label": "节点名称", "type": "text", "default": "Update State", }, ], }, { "name": "handoff", "displayName": "Handoff", "category": "execution_node", "description": "转交其他 AI、人工、队列或电话;MVP 发送转交事件后继续路由。", "icon": "PhoneForwarded", "accent": "lavender", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 0}, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Handoff"}, {"key": "target", "label": "转交目标", "type": "text", "default": ""}, {"key": "message", "label": "转交提示", "type": "textarea", "default": ""}, ], }, { "name": "end", "displayName": "End", "category": "control_node", "description": "结束 AI 流程或整个音视频会话。", "icon": "Flag", "accent": "rose", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0}, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "End"}, {"key": "message", "label": "固定结束语", "type": "textarea", "default": ""}, ], }, ] _SPEC_BY_NAME = {spec["name"]: spec for spec in NODE_SPECS} def node_types_response() -> dict[str, Any]: return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS} def copy_graph(graph: dict[str, Any] | None) -> dict[str, Any]: """Return an isolated copy of a current Workflow graph.""" return deepcopy(graph or {}) def _validate_expression(expression: Any) -> list[str]: if not isinstance(expression, dict): return ["表达式条件不能为空"] combinator = expression.get("combinator", "and") if combinator not in {"and", "or"}: return ["表达式组合方式必须是 and 或 or"] rules = expression.get("rules") if not isinstance(rules, list) or not rules: return ["表达式至少需要一条规则"] errors = [] for rule in rules: if not isinstance(rule, dict) or not rule.get("variable"): errors.append("表达式规则缺少变量") elif rule.get("operator") not in EXPRESSION_OPERATORS: errors.append(f"不支持的表达式运算符:{rule.get('operator')}") return errors def validate_graph(graph: dict[str, Any]) -> list[str]: graph = copy_graph(graph) errors: list[str] = [] if str(graph.get("specVersion") or "") != SPEC_VERSION: errors.append(f"工作流只支持 specVersion {SPEC_VERSION}") nodes = graph.get("nodes") edges = graph.get("edges") settings = graph.get("settings") if not isinstance(nodes, list) or not nodes: errors.append("工作流必须包含节点") nodes = [] if not isinstance(edges, list): errors.append("工作流 edges 必须是列表") edges = [] if not isinstance(settings, dict): errors.append("工作流 settings 必须是对象") settings = {} required_settings = { "runtimeMode", "llmRoutingMode", "globalPrompt", "defaultRealtimeResourceId", "defaultLlmResourceId", "defaultAsrResourceId", "defaultTtsResourceId", "visionEnabled", "visionModelResourceId", "visionPromptMode", "visionPrompt", "toolBindings", "builtInTools", "knowledgeBaseId", "knowledgeMode", "knowledgeTopN", "knowledgeScoreThreshold", "enableInterrupt", "turnConfig", } missing_settings = sorted(required_settings - settings.keys()) if missing_settings: errors.append("工作流 settings 缺少字段:" + ", ".join(missing_settings)) runtime_mode = str(settings.get("runtimeMode") or "") llm_routing_mode = str(settings.get("llmRoutingMode") or "") if runtime_mode not in WORKFLOW_RUNTIME_MODES: errors.append(f"工作流运行模式无效:{runtime_mode}") if llm_routing_mode not in WORKFLOW_LLM_ROUTING_MODES: errors.append(f"大模型判断路由模式无效:{llm_routing_mode}") if runtime_mode == "realtime" and llm_routing_mode != "edge_tool": errors.append("Realtime 工作流的大模型判断路由只能使用边工具模式") if runtime_mode == "realtime" and not settings.get( "defaultRealtimeResourceId" ): errors.append("Realtime 工作流必须选择 Realtime 模型") if ( runtime_mode == "realtime" and settings.get("knowledgeBaseId") and settings.get("knowledgeMode") != "on_demand" ): errors.append("Realtime 工作流的知识库只能使用按需模式") node_by_id: dict[str, dict] = {} counts: dict[str, int] = defaultdict(int) for node in nodes: node_id = str(node.get("id") or "") node_type = str(node.get("type") or "") if not node_id: errors.append("存在缺少 id 的节点") continue if node_id in node_by_id: errors.append(f"节点 id 重复:{node_id}") if node_type not in NODE_TYPES: errors.append(f"未知节点类型:{node_type}(节点 {node_id})") node_by_id[node_id] = node counts[node_type] += 1 if node_type == "agent": data = node.get("data") or {} entry_mode = data.get("entryMode", "wait_user") if entry_mode not in AGENT_ENTRY_MODES: errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}") source = settings if data.get("inheritGlobalConfig", True) else data if not isinstance(source.get("toolBindings"), list): errors.append(f"Agent 节点 {node_id} 的工具绑定必须是列表") if not isinstance(source.get("builtInTools"), dict): errors.append(f"Agent 节点 {node_id} 的内建工具配置必须是对象") vision_prompt_mode = str(source.get("visionPromptMode") or "runtime") if vision_prompt_mode not in VISION_PROMPT_MODES: errors.append( f"Agent 节点 {node_id} 的视觉提示词模式无效:{vision_prompt_mode}" ) if ( source.get("visionEnabled") and vision_prompt_mode in {"fixed", "guided"} and not str(source.get("visionPrompt") or "").strip() ): errors.append( f"Agent 节点 {node_id} 的固定或引导模式必须填写视觉提示词" ) if runtime_mode == "realtime": if data.get("contextPolicy", "inherit") != "inherit": errors.append( f"Realtime Agent 节点 {node_id} 只能继承会话上下文" ) if any( data.get(key) for key in ( "llmResourceId", "asrResourceId", "ttsResourceId", ) ) or any(key in data for key in ("enableInterrupt", "turnConfig")): errors.append( f"Realtime Agent 节点 {node_id} 不支持节点级模型、语音或交互策略覆盖" ) if ( source.get("knowledgeBaseId") and source.get("knowledgeMode") != "on_demand" ): errors.append( f"Realtime Agent 节点 {node_id} 的知识库只能使用按需模式" ) elif node_type == "message": data = node.get("data") or {} speech = data.get("speech") completion_policy = data.get("completionPolicy") if not isinstance(speech, str): errors.append(f"Message 节点 {node_id} 的播报内容必须是文本") if ( not isinstance(completion_policy, str) or completion_policy not in MESSAGE_COMPLETION_POLICIES ): errors.append( f"Message 节点 {node_id} 的完成策略无效:{completion_policy}" ) if not str(speech or "").strip(): errors.append(f"Message 节点 {node_id} 必须配置播报内容") if completion_policy == MESSAGE_CONFIRMATION: title = data.get("title") message = data.get("message") confirm_label = data.get("confirmLabel") if ( not isinstance(title, str) or not title.strip() or len(title) > 120 ): errors.append( f"Message 节点 {node_id} 的弹窗标题必须为 1-120 个字符" ) if ( not isinstance(message, str) or not message.strip() or len(message) > 2000 ): errors.append( f"Message 节点 {node_id} 的弹窗消息必须为 1-2000 个字符" ) if ( not isinstance(confirm_label, str) or not confirm_label.strip() or len(confirm_label) > 40 ): errors.append( f"Message 节点 {node_id} 的按钮文字必须为 1-40 个字符" ) elif node_type == "action": data = node.get("data") or {} binding = data.get("toolBinding") if not isinstance(binding, dict) or not binding.get("toolId"): errors.append(f"Action 节点 {node_id} 必须配置工具绑定") input_policy = data.get("userInputPolicy") if input_policy not in ACTION_USER_INPUT_POLICIES: errors.append( f"Action 节点 {node_id} 的用户输入策略无效:{input_policy}" ) elif runtime_mode == "realtime" and input_policy != "block": errors.append( f"Realtime Action 节点 {node_id} 必须阻止执行期间的用户输入" ) elif node_type == "update_state": data = node.get("data") or {} updates = data.get("updates") if not isinstance(updates, list) or not updates: errors.append( f"Update State 节点 {node_id} 必须配置至少一条更新规则" ) if counts["start"] != 1: errors.append("工作流必须有且仅有一个 Start 节点") incoming: dict[str, int] = defaultdict(int) outgoing: dict[str, int] = defaultdict(int) adj: dict[str, list[str]] = defaultdict(list) auto_adj: dict[str, list[str]] = defaultdict(list) priorities: dict[str, set[int]] = defaultdict(set) always_counts: dict[str, int] = defaultdict(int) for edge in edges: edge_id = str(edge.get("id") or "") source_id = str(edge.get("source") or "") target_id = str(edge.get("target") or "") if source_id not in node_by_id: errors.append(f"边 {edge_id} 指向不存在的源节点:{source_id}") continue if target_id not in node_by_id: errors.append(f"边 {edge_id} 指向不存在的目标节点:{target_id}") continue if source_id == target_id and node_by_id[source_id].get("type") != "agent": errors.append(f"自动节点不能自连:{source_id}") data = edge.get("data") or {} mode = data.get("mode") if mode not in EDGE_MODES: errors.append(f"边 {edge_id} 的判断模式无效:{mode}") if mode == "llm" and not str(data.get("condition") or "").strip(): errors.append(f"大模型判断边缺少自然语言条件:{edge_id}") if mode == "expression": expression_errors = _validate_expression(data.get("expression")) errors.extend(f"边 {edge_id}:{item}" for item in expression_errors) if mode == "always": always_counts[source_id] += 1 if always_counts[source_id] > 1: errors.append(f"节点 {source_id} 最多只能有一条默认路径") else: try: priority = int(data.get("priority", 10)) except (TypeError, ValueError): errors.append(f"边 {edge_id} 的优先级必须是整数") priority = 10 if priority in priorities[source_id]: errors.append(f"节点 {source_id} 的条件边优先级不能重复:{priority}") priorities[source_id].add(priority) source_type = node_by_id[source_id].get("type") if llm_routing_mode == "edge_tool" and mode == "llm" and source_type != "agent": errors.append(f"边工具模式的大模型判断边只能从 Agent 发出:{edge_id}") if llm_routing_mode == "edge_tool" and mode == "always" and source_type == "agent": errors.append( f"边工具模式的 Agent 不能使用默认路径:{source_id}" ) incoming[target_id] += 1 outgoing[source_id] += 1 adj[source_id].append(target_id) source_is_automatic = node_by_id[source_id].get("type") != "agent" target_is_automatic = node_by_id[target_id].get("type") != "agent" if source_is_automatic and target_is_automatic: auto_adj[source_id].append(target_id) for node_id, node in node_by_id.items(): spec = _SPEC_BY_NAME.get(str(node.get("type"))) if not spec: continue constraints = spec["constraints"] for actual, suffix, label in ( (incoming[node_id], "Incoming", "入边"), (outgoing[node_id], "Outgoing", "出边"), ): lo = constraints.get(f"min{suffix}") hi = constraints.get(f"max{suffix}") if lo is not None and actual < lo: errors.append(f"节点 {node_id} 的{label}不能少于 {lo}") if hi is not None and actual > hi: errors.append(f"节点 {node_id} 的{label}不能多于 {hi}") if ( node.get("type") == "agent" and outgoing[node_id] == 1 and always_counts[node_id] == 1 ): errors.append( f"Agent 节点 {node_id} 不能只有默认路径;" "请改为条件路径,或删除该路径以持续对话" ) start_id = next( (node_id for node_id, node in node_by_id.items() if node.get("type") == "start"), None, ) if start_id: reached = {start_id} queue = deque([start_id]) while queue: current = queue.popleft() for target in adj[current]: if target not in reached: reached.add(target) queue.append(target) for node_id in node_by_id.keys() - reached: errors.append(f"节点不可从 Start 到达:{node_id}") # Reject cycles made only of instantaneous nodes; Agent cycles are valid waits. visiting: set[str] = set() visited: set[str] = set() def visit(node_id: str) -> bool: if node_id in visiting: return True if node_id in visited: return False visiting.add(node_id) for target in auto_adj[node_id]: if visit(target): return True visiting.remove(node_id) visited.add(node_id) return False automatic_node_ids = ( node_id for node_id, node in node_by_id.items() if node.get("type") != "agent" ) if any(visit(node_id) for node_id in automatic_node_ids): errors.append("自动节点之间不能形成无等待循环") return list(dict.fromkeys(errors)) def graph_references(graph: dict[str, Any]) -> dict[str, set[str]]: """Collect externally referenced IDs for save/runtime validation.""" normalized = copy_graph(graph) settings = normalized.get("settings") or {} resources = { str(value) for value in ( settings.get("defaultLlmResourceId"), settings.get("defaultRealtimeResourceId"), settings.get("defaultAsrResourceId"), settings.get("defaultTtsResourceId"), ( settings.get("visionModelResourceId") if settings.get("visionEnabled") else None ), ) if value } tools: set[str] = { str(binding.get("toolId") or "") for binding in settings.get("toolBindings") or [] if isinstance(binding, dict) and binding.get("toolId") } knowledge: set[str] = ( {str(settings["knowledgeBaseId"])} if settings.get("knowledgeBaseId") else set() ) for node in normalized.get("nodes") or []: data = node.get("data") or {} inherits_global = ( node.get("type") == "agent" and data.get("inheritGlobalConfig", True) ) if not inherits_global: for resource_id in ( data.get("llmResourceId"), data.get("asrResourceId"), data.get("ttsResourceId"), ( data.get("visionModelResourceId") if data.get("visionEnabled") else None ), ): if resource_id: resources.add(str(resource_id)) for binding in data.get("toolBindings") or []: if isinstance(binding, dict) and binding.get("toolId"): tools.add(str(binding["toolId"])) if data.get("knowledgeBaseId"): knowledge.add(str(data["knowledgeBaseId"])) binding = data.get("toolBinding") if isinstance(binding, dict) and binding.get("toolId"): tools.add(str(binding["toolId"])) return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}