Add workflow support and enhance runtime configuration in models and services

- Introduce RuntimeModelResource and RuntimeKnowledgeBase classes to manage workflow resources.
- Update AssistantConfig to include workflow_model_resources and workflow_knowledge_bases for better integration.
- Refactor validation and processing logic in routes and services to accommodate workflow types.
- Implement dynamic variable support for workflow assistants and enhance graph normalization.
- Add ToolExecutor for reusable tool execution across different assistant types.
- Update various services to ensure compatibility with new workflow features and improve error handling.
This commit is contained in:
Xin Wang
2026-07-13 16:13:27 +08:00
parent 6108b00007
commit 32aef14ddb
27 changed files with 2563 additions and 910 deletions

View File

@@ -1,132 +1,108 @@
"""工作流节点规格 + 图校验(对齐 dograh 的 node-spec / GraphConstraints 思路)。
当前实现 4 个核心节点:开始(startCall)/智能体(agentNode)/结束(endCall)/全局(globalNode)。
本模块是「节点类型」的唯一事实源:
- /api/node-types 接口直接吐这里的规格;
- 助手保存时用这里的约束校验 workflow 图。
新增节点类型只需在 NODE_SPECS 里加一条并补充约束。前端 specs.ts 与此保持一致。
"""
"""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
# 规格版本号:节点定义有破坏性变更时 +1,前端可据此判断是否需要刷新缓存。
SPEC_VERSION = "2"
# 每个节点的图约束。None 表示不限制。
# min_incoming / max_incoming:入边数量
# min_outgoing / max_outgoing:出边数量
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": "startCall",
"displayName": "开始",
"category": "call_node",
"description": "工作流入口,每个流程有且仅有一个。播放开场白,并用自己的提示词进行多轮对话,满足出边条件后流转",
"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": "开始"},
{"key": "greeting", "label": "开场白", "type": "textarea", "default": ""},
{"key": "prompt", "label": "节点提示词", "type": "textarea", "default": ""},
{
"key": "allowInterrupt",
"label": "允许用户打断",
"type": "switch",
"default": True,
},
{
"key": "addGlobalPrompt",
"label": "应用全局提示词",
"type": "switch",
"default": True,
},
{"key": "name", "label": "节点名称", "type": "text", "default": "Start"},
{"key": "greeting", "label": "固定开场白", "type": "textarea", "default": ""},
],
},
{
"name": "agentNode",
"displayName": "智能体节点",
"category": "call_node",
"description": "对话处理单元。按提示词与用户多轮交互,可有多个并通过条件边流转",
"name": "agent",
"displayName": "Agent",
"category": "conversation_node",
"description": "阶段智能体:绑定上下文、工具、知识库及 ASR/TTS 资源",
"icon": "Bot",
"accent": "sky",
"addable": True,
"constraints": {"minIncoming": 1},
"constraints": {"minIncoming": 1, "minOutgoing": 1},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "智能体节点"},
{"key": "name", "label": "节点名称", "type": "text", "default": "Agent"},
{
"key": "prompt",
"label": "节点提示词",
"label": "阶段提示词",
"type": "textarea",
"required": True,
"default": "",
},
{
"key": "allowInterrupt",
"label": "允许用户打断",
"type": "switch",
"default": True,
},
{
"key": "addGlobalPrompt",
"label": "应用全局提示词",
"type": "switch",
"default": True,
},
],
},
{
"name": "endCall",
"displayName": "结束",
"category": "call_node",
"description": "终止节点,礼貌结束对话。可有多个,均无出边",
"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": "结束"},
{"key": "prompt", "label": "结束语提示词", "type": "textarea", "default": ""},
{
"key": "addGlobalPrompt",
"label": "应用全局提示词",
"type": "switch",
"default": False,
},
],
},
{
"name": "globalNode",
"displayName": "全局节点",
"category": "global_node",
"description": "为整个工作流提供统一的人设、语气和公共规则。无需连线,每个流程最多一个。",
"icon": "Globe2",
"accent": "lavender",
"addable": True,
"constraints": {
"minIncoming": 0,
"maxIncoming": 0,
"minOutgoing": 0,
"maxOutgoing": 0,
"maxInstances": 1,
},
"fields": [
{"key": "name", "label": "节点名称", "type": "text", "default": "全局设定"},
{
"key": "prompt",
"label": "全局提示词",
"type": "textarea",
"required": True,
"default": "你是一个友好、专业的语音助手。请使用简短、自然、适合口语表达的句子。",
},
{"key": "name", "label": "节点名称", "type": "text", "default": "End"},
{"key": "message", "label": "固定结束语", "type": "textarea", "default": ""},
],
},
]
@@ -135,108 +111,303 @@ _SPEC_BY_NAME = {spec["name"]: spec for spec in NODE_SPECS}
def node_types_response() -> dict[str, Any]:
"""/api/node-types 的响应体(camelCase,直接喂前端)。"""
return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS}
def validate_graph(graph: dict[str, Any]) -> list[str]:
"""校验 workflow 图,返回错误信息列表(空列表 = 通过)。
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
基础规则(对齐 dograh 的核心不变量):
1. 节点类型必须是已注册类型;
2. 有且仅有一个 startCall;
3. 至少有一个 endCall,全局节点最多一个;
4. 边的 source/target 必须指向存在的节点;
5. 入边/出边数量满足各节点类型的约束。
空图(无节点)视为草稿,直接放行,方便先存后编排。
"""
errors: list[str] = []
nodes = graph.get("nodes") or []
edges = graph.get("edges") or []
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
if not nodes:
return errors # 草稿:放行
node_ids: set[str] = set()
type_counts: dict[str, int] = {}
node_type_by_id: dict[str, str] = {}
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:
node_id = node.get("id")
node_type = node.get("type")
if not node_id:
errors.append("存在缺少 id 的节点")
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
if node_id in node_ids:
errors.append(f"节点 id 重复:{node_id}")
node_ids.add(node_id)
if node_type not in _SPEC_BY_NAME:
errors.append(f"未知节点类型:{node_type}(节点 {node_id})")
continue
node_type_by_id[node_id] = node_type
type_counts[node_type] = type_counts.get(node_type, 0) + 1
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
start_count = type_counts.get("startCall", 0)
if start_count == 0:
errors.append("工作流必须有一个「开始」节点")
elif start_count > 1:
errors.append("工作流只能有一个「开始」节点")
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)
if type_counts.get("endCall", 0) == 0:
errors.append("工作流至少需要一个「结束」节点")
for node_type, spec in _SPEC_BY_NAME.items():
# 开始节点上方已有更明确的中文错误提示,避免重复报错。
if node_type == "startCall":
continue
constraints = spec["constraints"]
count = type_counts.get(node_type, 0)
_check_count(
errors,
count,
constraints,
"Instances",
node_type,
"实例",
# 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": ""},
}
)
# 统计入边/出边
incoming: dict[str, int] = {nid: 0 for nid in node_ids}
outgoing: dict[str, int] = {nid: 0 for nid in node_ids}
for edge in edges:
source = edge.get("source")
target = edge.get("target")
if source not in node_ids:
errors.append(f"连线指向了不存在的源节点:{source}")
continue
if target not in node_ids:
errors.append(f"连线指向了不存在的目标节点:{target}")
continue
outgoing[source] += 1
incoming[target] += 1
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 {}),
}
for node_id, node_type in node_type_by_id.items():
constraints = _SPEC_BY_NAME[node_type]["constraints"]
name = node_type
_check_count(errors, incoming[node_id], constraints, "Incoming", name, "入边")
_check_count(errors, outgoing[node_id], constraints, "Outgoing", name, "出边")
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 _check_count(
errors: list[str],
actual: int,
constraints: dict[str, int],
suffix: str,
node_type: str,
label: str,
) -> None:
lo = constraints.get(f"min{suffix}")
hi = constraints.get(f"max{suffix}")
display = _SPEC_BY_NAME[node_type]["displayName"]
if lo is not None and actual < lo:
errors.append(f"{display}」节点{label}数量不能少于 {lo}(当前 {actual})")
if hi is not None and actual > hi:
errors.append(f"{display}」节点{label}数量不能多于 {hi}(当前 {actual})")
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}