Files
ai-video-fullstack/backend/services/node_specs.py
T

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}