"""Workflow v3 node catalog, v2 compatibility normalization, and validation.""" from __future__ import annotations from collections import defaultdict, deque from copy import deepcopy from typing import Any SPEC_VERSION = "3" NODE_TYPES = {"start", "agent", "action", "handoff", "end"} EDGE_MODES = {"llm", "expression", "always"} 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": 1, "minInstances": 1, "maxInstances": 1, }, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Start"}, {"key": "greeting", "label": "固定开场白", "type": "textarea", "default": ""}, ], }, { "name": "agent", "displayName": "Agent", "category": "conversation_node", "description": "阶段智能体:绑定上下文、工具、知识库及 ASR/TTS 资源。", "icon": "Bot", "accent": "sky", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 1}, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Agent"}, { "key": "prompt", "label": "阶段提示词", "type": "textarea", "required": True, "default": "", }, ], }, { "name": "action", "displayName": "Action", "category": "execution_node", "description": "确定性执行指定工具,并将结果字段写入会话动态变量。", "icon": "Zap", "accent": "peach", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 1}, "fields": [ {"key": "name", "label": "节点名称", "type": "text", "default": "Action"}, ], }, { "name": "handoff", "displayName": "Handoff", "category": "execution_node", "description": "转交其他 AI、人工、队列或电话;MVP 发送转交事件后继续路由。", "icon": "PhoneForwarded", "accent": "lavender", "addable": True, "constraints": {"minIncoming": 1, "minOutgoing": 1}, "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 _edge_data_v3(edge: dict, source_type: str) -> dict: data = deepcopy(edge.get("data") or {}) if data.get("mode") in EDGE_MODES: data.setdefault("priority", 10) return data condition = str(data.pop("condition", "") or "").strip() transition = data.pop("transition_speech", data.get("transitionSpeech", "")) data.update( { "mode": "llm" if condition and source_type == "agent" else "always", "priority": 10, "condition": condition, "transitionSpeech": transition, } ) return data def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]: """Return a deep-copied v3 graph; preserve v3 IDs and migrate v2 semantics.""" source = deepcopy(graph or {}) if str(source.get("specVersion") or "") == SPEC_VERSION: source.setdefault("settings", {}) source.setdefault("nodes", []) source.setdefault("edges", []) return source nodes = source.get("nodes") or [] edges = source.get("edges") or [] global_prompt = "" mapped_nodes: list[dict] = [] type_by_id: dict[str, str] = {} start_prompt_nodes: dict[str, str] = {} type_map = { "startCall": "start", "agentNode": "agent", "endCall": "end", "start": "start", "agent": "agent", "action": "action", "handoff": "handoff", "end": "end", } for node in nodes: old_type = str(node.get("type") or "") data = deepcopy(node.get("data") or {}) if old_type == "globalNode": global_prompt = str(data.get("prompt") or "") continue new_type = type_map.get(old_type, old_type) migrated = deepcopy(node) migrated["type"] = new_type if new_type == "end": data["message"] = data.pop("message", data.pop("prompt", "")) data.setdefault("scope", "session") elif new_type == "agent": data.setdefault("contextPolicy", "inherit") data.setdefault("toolIds", []) data.setdefault("knowledgeMode", "disabled") elif new_type == "start": prompt = str(data.pop("prompt", "") or "").strip() if prompt: start_prompt_nodes[str(node.get("id"))] = prompt for key in ("allowInterrupt", "addGlobalPrompt"): data.pop(key, None) migrated["data"] = data mapped_nodes.append(migrated) if migrated.get("id"): type_by_id[str(migrated["id"])] = new_type mapped_edges: list[dict] = [] for edge in edges: migrated = deepcopy(edge) migrated["data"] = _edge_data_v3( migrated, type_by_id.get(str(migrated.get("source")), "") ) mapped_edges.append(migrated) # A v2 Start was conversational. Insert a synthetic Agent so its prompt remains active. for start_id, prompt in start_prompt_nodes.items(): synthetic_id = f"{start_id}-migrated-agent" start_node = next((n for n in mapped_nodes if n.get("id") == start_id), None) position = (start_node or {}).get("position") or {"x": 100, "y": 120} mapped_nodes.append( { "id": synthetic_id, "type": "agent", "position": {"x": position.get("x", 100) + 300, "y": position.get("y", 120)}, "data": { "name": "迁移的开场 Agent", "prompt": prompt, "contextPolicy": "inherit", "toolIds": [], "knowledgeMode": "disabled", }, } ) for edge in mapped_edges: if edge.get("source") == start_id: edge["source"] = synthetic_id edge["data"] = _edge_data_v3(edge, "agent") if str(edge["data"].get("condition") or "").strip(): edge["data"]["mode"] = "llm" mapped_edges.append( { "id": f"e-{start_id}-{synthetic_id}", "source": start_id, "target": synthetic_id, "data": {"mode": "always", "priority": 0, "transitionSpeech": ""}, } ) settings = deepcopy(source.get("settings") or {}) settings.setdefault("globalPrompt", global_prompt) settings.setdefault("defaultAsrResourceId", "") settings.setdefault("defaultTtsResourceId", "") return { "specVersion": 3, "settings": settings, "nodes": mapped_nodes, "edges": mapped_edges, **({"viewport": deepcopy(source["viewport"])} if source.get("viewport") else {}), } 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 = normalize_graph(graph) nodes = graph.get("nodes") or [] edges = graph.get("edges") or [] if not nodes: return [] errors: list[str] = [] 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 counts["start"] != 1: errors.append("工作流必须有且仅有一个 Start 节点") if counts["end"] < 1: errors.append("工作流至少需要一个 End 节点") 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 node_by_id[source_id].get("type") != "agent": errors.append(f"LLM 判断边只能从 Agent 发出:{edge_id}") if mode == "llm" and not str(data.get("condition") or "").strip(): errors.append(f"LLM 判断边缺少自然语言条件:{edge_id}") if mode == "expression": errors.extend(f"边 {edge_id}:{item}" for item in _validate_expression(data.get("expression"))) 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) if mode == "always": always_counts[source_id] += 1 if always_counts[source_id] > 1: errors.append(f"节点 {source_id} 最多只能有一条默认边") incoming[target_id] += 1 outgoing[source_id] += 1 adj[source_id].append(target_id) if node_by_id[source_id].get("type") != "agent" and node_by_id[target_id].get("type") != "agent": 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") in {"start", "action", "handoff"} and always_counts[node_id] != 1: errors.append(f"自动节点 {node_id} 必须有且仅有一条默认边") start_id = next((nid for nid, n in node_by_id.items() if n.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 if any(visit(node_id) for node_id, node in node_by_id.items() if node.get("type") != "agent"): errors.append("Start/Action/Handoff/End 之间不能形成无等待循环") 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 = normalize_graph(graph) settings = normalized.get("settings") or {} resources = { str(value) for value in ( settings.get("defaultAsrResourceId"), settings.get("defaultTtsResourceId"), ) if value } tools: set[str] = set() knowledge: set[str] = set() for node in normalized.get("nodes") or []: data = node.get("data") or {} for resource_id in (data.get("asrResourceId"), data.get("ttsResourceId")): if resource_id: resources.add(str(resource_id)) for tool_id in data.get("toolIds") or []: tools.add(str(tool_id)) if data.get("toolId"): tools.add(str(data["toolId"])) if data.get("knowledgeBaseId"): knowledge.add(str(data["knowledgeBaseId"])) return {"model_resources": resources, "tools": tools, "knowledge_bases": knowledge}