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:
@@ -1,170 +1,140 @@
|
||||
"""工作流图引擎(第一版)。
|
||||
|
||||
对应 dograh 的 pipecat_engine.py,极简实现:
|
||||
- 单个 startCall 入口,开场白来自该节点;
|
||||
- agentNode 用各自的 prompt 驱动多轮对话;
|
||||
- globalNode 不参与连线,按节点开关向会话节点注入统一提示词;
|
||||
- 每轮助手回复后,用一次轻量 LLM「路由」判断是否满足某条出边的 condition,
|
||||
满足则切换当前节点(linear = 单边;branching = 多边按条件分流);
|
||||
- 到达 endCall 播放结束语并停止路由。
|
||||
|
||||
只读图结构,不持有对话状态(当前节点由 pipeline 维护),便于单测。
|
||||
"""
|
||||
"""Pure Workflow v3 graph queries and deterministic edge evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from services.node_specs import normalize_graph
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
def __init__(self, graph: dict[str, Any]):
|
||||
nodes = graph.get("nodes") or []
|
||||
self.nodes: dict[str, dict] = {n["id"]: n for n in nodes if n.get("id")}
|
||||
self.edges: list[dict] = graph.get("edges") or []
|
||||
self.start_id: str | None = next(
|
||||
(nid for nid, n in self.nodes.items() if n.get("type") == "startCall"),
|
||||
None,
|
||||
)
|
||||
self.global_id: str | None = next(
|
||||
(nid for nid, n in self.nodes.items() if n.get("type") == "globalNode"),
|
||||
self.graph = normalize_graph(graph)
|
||||
self.settings = self.graph.get("settings") or {}
|
||||
self.nodes: dict[str, dict] = {
|
||||
str(node["id"]): node
|
||||
for node in self.graph.get("nodes") or []
|
||||
if node.get("id")
|
||||
}
|
||||
self.edges: list[dict] = list(self.graph.get("edges") or [])
|
||||
self.start_id = next(
|
||||
(node_id for node_id, node in self.nodes.items() if node.get("type") == "start"),
|
||||
None,
|
||||
)
|
||||
|
||||
# ---- 结构查询 ----
|
||||
def node_type(self, nid: str | None) -> str | None:
|
||||
return self.nodes.get(nid or "", {}).get("type")
|
||||
def has_graph(self) -> bool:
|
||||
return bool(self.start_id)
|
||||
|
||||
def data(self, nid: str | None) -> dict:
|
||||
return self.nodes.get(nid or "", {}).get("data") or {}
|
||||
def node_type(self, node_id: str | None) -> str | None:
|
||||
return self.nodes.get(node_id or "", {}).get("type")
|
||||
|
||||
def name(self, nid: str | None) -> str:
|
||||
return self.data(nid).get("name") or (self.node_type(nid) or "")
|
||||
def data(self, node_id: str | None) -> dict:
|
||||
return self.nodes.get(node_id or "", {}).get("data") or {}
|
||||
|
||||
def outgoing(self, nid: str | None) -> list[dict]:
|
||||
return [e for e in self.edges if e.get("source") == nid]
|
||||
def name(self, node_id: str | None) -> str:
|
||||
return str(self.data(node_id).get("name") or self.node_type(node_id) or "")
|
||||
|
||||
def outgoing(self, node_id: str | None) -> list[dict]:
|
||||
result = [edge for edge in self.edges if edge.get("source") == node_id]
|
||||
return sorted(result, key=lambda edge: int((edge.get("data") or {}).get("priority", 10)))
|
||||
|
||||
def edge_mode(self, edge: dict) -> str:
|
||||
return str((edge.get("data") or {}).get("mode") or "always")
|
||||
|
||||
def edge_fn_name(self, edge: dict) -> str:
|
||||
"""每条边对应一个 LLM 函数名(稳定、合法标识符)。"""
|
||||
raw = edge.get("id") or f"{edge.get('source')}_{edge.get('target')}"
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", str(raw).lower()).strip("_")
|
||||
return f"goto_{slug or 'next'}"
|
||||
|
||||
def edge_condition(self, edge: dict) -> str:
|
||||
return (edge.get("data") or {}).get("condition") or ""
|
||||
def edge_description(self, edge: dict) -> str:
|
||||
data = edge.get("data") or {}
|
||||
target = self.name(str(edge.get("target") or ""))
|
||||
condition = str(data.get("condition") or "").strip()
|
||||
if condition:
|
||||
return f"当满足以下条件时转到「{target}」:{condition}"
|
||||
return f"当当前阶段任务完成时转到「{target}」。"
|
||||
|
||||
def edge_transition_speech(self, edge: dict | None) -> str:
|
||||
"""命中该边、切换节点瞬间播报的过渡语(可选,掩盖延迟,不写入上下文)。"""
|
||||
if not edge:
|
||||
return ""
|
||||
return (edge.get("data") or {}).get("transition_speech") or ""
|
||||
data = edge.get("data") or {}
|
||||
return str(data.get("transitionSpeech") or data.get("transition_speech") or "")
|
||||
|
||||
def find_edge(self, source: str | None, target: str | None) -> dict | None:
|
||||
for edge in self.edges:
|
||||
if edge.get("source") == source and edge.get("target") == target:
|
||||
return edge
|
||||
return None
|
||||
def global_prompt(self) -> str:
|
||||
return str(self.settings.get("globalPrompt") or "").strip()
|
||||
|
||||
def edge_description(self, edge: dict) -> str:
|
||||
"""作为转移函数的 description 交给 LLM:满足该条件时模型应调用此函数。"""
|
||||
cond = self.edge_condition(edge)
|
||||
target = self.name(edge.get("target"))
|
||||
if cond:
|
||||
return f"当满足以下条件时调用以转到节点「{target}」:{cond}"
|
||||
return f"当当前节点任务完成、应继续推进对话时调用以转到节点「{target}」。"
|
||||
|
||||
def is_end(self, nid: str | None) -> bool:
|
||||
return self.node_type(nid) == "endCall"
|
||||
|
||||
def has_graph(self) -> bool:
|
||||
return self.start_id is not None
|
||||
|
||||
def greeting(self) -> str:
|
||||
return self.data(self.start_id).get("greeting") or ""
|
||||
|
||||
def system_prompt_for(self, nid: str | None) -> str:
|
||||
"""组合当前节点提示与可选的全局提示(开始节点也是会话节点)。"""
|
||||
header = f"[当前节点:{self.name(nid)}]"
|
||||
node_data = self.data(nid)
|
||||
prompt = str(node_data.get("prompt") or "").strip()
|
||||
node_type = self.node_type(nid)
|
||||
default_add_global = node_type in {"startCall", "agentNode"}
|
||||
add_global = bool(node_data.get("addGlobalPrompt", default_add_global))
|
||||
global_prompt = (
|
||||
str(self.data(self.global_id).get("prompt") or "").strip()
|
||||
if add_global and self.global_id
|
||||
else ""
|
||||
)
|
||||
|
||||
sections = [header]
|
||||
if global_prompt:
|
||||
sections.append(f"[全局规则]\n{global_prompt}")
|
||||
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
||||
sections = [f"[当前阶段:{self.name(node_id)}]"]
|
||||
if self.global_prompt():
|
||||
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
|
||||
if prompt:
|
||||
sections.append(f"[当前节点任务]\n{prompt}")
|
||||
sections.append(f"[当前阶段任务]\n{prompt}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
# ---- 路由:决定下一节点 ----
|
||||
async def route(
|
||||
def greeting(self, store: DynamicVariableStore) -> str:
|
||||
return store.render(str(self.data(self.start_id).get("greeting") or ""))
|
||||
|
||||
def expression_matches(self, expression: dict, values: dict[str, Any]) -> bool:
|
||||
results = []
|
||||
for rule in expression.get("rules") or []:
|
||||
name = str(rule.get("variable") or "")
|
||||
operator = str(rule.get("operator") or "")
|
||||
expected = rule.get("value")
|
||||
exists = name in values
|
||||
actual = values.get(name)
|
||||
try:
|
||||
if operator == "exists":
|
||||
matched = exists if expected is not False else not exists
|
||||
elif operator == "eq":
|
||||
matched = actual == expected
|
||||
elif operator == "neq":
|
||||
matched = actual != expected
|
||||
elif operator == "gt":
|
||||
matched = actual > expected
|
||||
elif operator == "gte":
|
||||
matched = actual >= expected
|
||||
elif operator == "lt":
|
||||
matched = actual < expected
|
||||
elif operator == "lte":
|
||||
matched = actual <= expected
|
||||
elif operator == "contains":
|
||||
matched = expected in actual
|
||||
elif operator == "in":
|
||||
matched = actual in expected
|
||||
else:
|
||||
matched = False
|
||||
except (TypeError, ValueError):
|
||||
matched = False
|
||||
results.append(matched)
|
||||
if not results:
|
||||
return False
|
||||
return all(results) if expression.get("combinator", "and") == "and" else any(results)
|
||||
|
||||
def deterministic_edge(
|
||||
self,
|
||||
nid: str | None,
|
||||
history: list[dict],
|
||||
node_id: str,
|
||||
store: DynamicVariableStore,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
) -> str | None:
|
||||
"""根据对话历史判断当前节点是否应转移。返回目标节点 id,或 None 表示停留。"""
|
||||
outs = self.outgoing(nid)
|
||||
if not outs:
|
||||
return None
|
||||
include_default: bool,
|
||||
) -> dict | None:
|
||||
default = None
|
||||
for edge in self.outgoing(node_id):
|
||||
data = edge.get("data") or {}
|
||||
mode = data.get("mode")
|
||||
if mode == "expression" and self.expression_matches(
|
||||
data.get("expression") or {}, store.values
|
||||
):
|
||||
return edge
|
||||
if mode == "always":
|
||||
default = edge
|
||||
return default if include_default else None
|
||||
|
||||
options = []
|
||||
for i, edge in enumerate(outs, 1):
|
||||
edata = edge.get("data") or {}
|
||||
cond = edata.get("condition") or "(无明确条件,作为默认后继)"
|
||||
tgt_name = self.name(edge.get("target"))
|
||||
options.append(f"{i}. 条件:{cond} → 目标节点:{tgt_name}")
|
||||
|
||||
convo = "\n".join(
|
||||
f"{m['role']}: {m['content']}" for m in history[-8:] if m.get("content")
|
||||
)
|
||||
system = (
|
||||
"你是语音对话的流程路由器。根据最近的对话,判断是否已满足某条转移条件。\n"
|
||||
"规则:仅当某条件被明确满足时,返回其编号;若都不满足或不确定,返回 0 "
|
||||
"(停留在当前节点继续对话)。只输出一个数字,不要任何解释。"
|
||||
)
|
||||
user = (
|
||||
"可选转移:\n"
|
||||
+ "\n".join(options)
|
||||
+ f"\n\n对话记录:\n{convo}\n\n请只返回编号(0 表示停留):"
|
||||
)
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url or None)
|
||||
resp = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
temperature=0,
|
||||
max_tokens=5,
|
||||
)
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
except Exception as exc: # noqa: BLE001 - 路由失败不应中断通话
|
||||
logger.warning(f"工作流路由调用失败,停留当前节点: {exc}")
|
||||
return None
|
||||
|
||||
match = re.search(r"\d+", text)
|
||||
if not match:
|
||||
return None
|
||||
idx = int(match.group())
|
||||
if idx < 1 or idx > len(outs):
|
||||
return None
|
||||
target = outs[idx - 1].get("target")
|
||||
logger.info(f"工作流路由: {self.name(nid)} → {self.name(target)} (edge {idx})")
|
||||
return target
|
||||
def llm_edges(self, node_id: str) -> list[dict]:
|
||||
return [
|
||||
edge
|
||||
for edge in self.outgoing(node_id)
|
||||
if self.edge_mode(edge) in {"llm", "always"}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user