562 lines
22 KiB
Python
562 lines
22 KiB
Python
"""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}
|