- Update WorkflowBrain to handle greeting playback more effectively, ensuring that the initial greeting completes before transitioning to the first node. - Introduce new methods for managing greeting states and conditions, enhancing the interaction flow for user turns. - Refactor WorkflowLLMRouter to improve routing logic and ensure proper handling of conditional paths. - Enhance tests to verify the correct behavior of greeting management and routing under various scenarios, including waiting for audio playback to finish. - Update frontend components to reflect changes in edge handling and improve user experience in workflow configurations.
234 lines
8.9 KiB
Python
234 lines
8.9 KiB
Python
"""Pure Workflow v3 graph queries and deterministic edge evaluation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from services.node_specs import normalize_graph
|
|
from services.runtime_variables import DynamicVariableStore
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentStageConfig:
|
|
"""The complete assistant configuration active inside one Agent node."""
|
|
|
|
inherits_global: bool
|
|
llm_resource_id: str
|
|
asr_resource_id: str
|
|
tts_resource_id: str
|
|
tool_ids: tuple[str, ...]
|
|
knowledge_base_id: str | None
|
|
knowledge_mode: str
|
|
knowledge_top_n: int
|
|
knowledge_score_threshold: float
|
|
enable_interrupt: bool
|
|
turn_config: dict[str, Any]
|
|
|
|
|
|
class WorkflowEngine:
|
|
def __init__(self, graph: dict[str, Any]):
|
|
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 has_graph(self) -> bool:
|
|
return bool(self.start_id)
|
|
|
|
def node_type(self, node_id: str | None) -> str | None:
|
|
return self.nodes.get(node_id or "", {}).get("type")
|
|
|
|
def data(self, node_id: str | None) -> dict:
|
|
return self.nodes.get(node_id or "", {}).get("data") or {}
|
|
|
|
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 has_outgoing(self, node_id: str | None) -> bool:
|
|
return any(edge.get("source") == node_id for edge in self.edges)
|
|
|
|
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:
|
|
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_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 ""
|
|
data = edge.get("data") or {}
|
|
return str(
|
|
data.get("transitionSpeech") or data.get("transition_speech") or ""
|
|
)
|
|
|
|
def global_prompt(self) -> str:
|
|
return str(self.settings.get("globalPrompt") or "").strip()
|
|
|
|
def inherits_global_config(self, node_id: str) -> bool:
|
|
"""Return the Agent's explicit configuration scope, defaulting to global."""
|
|
return bool(self.data(node_id).get("inheritGlobalConfig", True))
|
|
|
|
def agent_stage_config(self, node_id: str) -> AgentStageConfig:
|
|
"""Resolve either Workflow defaults or one Agent's complete override."""
|
|
data = self.data(node_id)
|
|
inherits_global = self.inherits_global_config(node_id)
|
|
source = self.settings if inherits_global else data
|
|
llm_key = "defaultLlmResourceId" if inherits_global else "llmResourceId"
|
|
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
|
|
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
|
|
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
|
|
global_turn_config = self.settings.get("turnConfig")
|
|
if not isinstance(global_turn_config, dict):
|
|
global_turn_config = {}
|
|
turn_config = source.get("turnConfig", global_turn_config)
|
|
if not isinstance(turn_config, dict):
|
|
turn_config = global_turn_config
|
|
return AgentStageConfig(
|
|
inherits_global=inherits_global,
|
|
llm_resource_id=str(source.get(llm_key) or ""),
|
|
asr_resource_id=str(source.get(asr_key) or ""),
|
|
tts_resource_id=str(source.get(tts_key) or ""),
|
|
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
|
|
knowledge_base_id=knowledge_base_id or None,
|
|
knowledge_mode=(
|
|
str(source.get("knowledgeMode") or "automatic")
|
|
if knowledge_base_id
|
|
else "disabled"
|
|
),
|
|
knowledge_top_n=int(source.get("knowledgeTopN") or 5),
|
|
knowledge_score_threshold=float(
|
|
source.get("knowledgeScoreThreshold") or 0.0
|
|
),
|
|
enable_interrupt=bool(
|
|
source.get(
|
|
"enableInterrupt",
|
|
self.settings.get("enableInterrupt", True),
|
|
)
|
|
),
|
|
turn_config=dict(turn_config),
|
|
)
|
|
|
|
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
|
"""Build the Agent system prompt according to its inheritance setting."""
|
|
prompt = store.render(str(self.data(node_id).get("prompt") or "").strip())
|
|
sections = [f"[当前阶段:{self.name(node_id)}]"]
|
|
if self.inherits_global_config(node_id) and self.global_prompt():
|
|
sections.append(f"[全局规则]\n{store.render(self.global_prompt())}")
|
|
if prompt:
|
|
sections.append(f"[当前阶段任务]\n{prompt}")
|
|
return "\n\n".join(sections)
|
|
|
|
def greeting(self, store: DynamicVariableStore) -> str:
|
|
return store.render(str(self.data(self.start_id).get("greeting") or ""))
|
|
|
|
def routing_prompt(self, node_id: str, store: DynamicVariableStore) -> str:
|
|
"""Describe the current node to the small LLM edge router."""
|
|
if self.node_type(node_id) == "agent":
|
|
return self.prompt_for(node_id, store)
|
|
data = self.data(node_id)
|
|
details = (
|
|
data.get("greeting")
|
|
or data.get("message")
|
|
or data.get("target")
|
|
or ""
|
|
)
|
|
rendered = store.render(str(details)).strip()
|
|
return f"{self.node_type(node_id) or 'workflow'} 节点:{rendered or self.name(node_id)}"
|
|
|
|
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,
|
|
node_id: str,
|
|
store: DynamicVariableStore,
|
|
*,
|
|
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
|
|
|
|
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"}
|
|
]
|