253 lines
9.8 KiB
Python
253 lines
9.8 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
|
|
vision_enabled: bool
|
|
vision_model_resource_id: str | None
|
|
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 ""),
|
|
vision_enabled=bool(source.get("visionEnabled", False)),
|
|
vision_model_resource_id=(
|
|
str(source.get("visionModelResourceId") or "") or None
|
|
),
|
|
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 uses_vision(self) -> bool:
|
|
"""Return whether any effective Agent stage needs a camera stream."""
|
|
return any(
|
|
self.agent_stage_config(node_id).vision_enabled
|
|
for node_id, node in self.nodes.items()
|
|
if node.get("type") == "agent"
|
|
)
|
|
|
|
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 not exists:
|
|
# Missing values are never equal, unequal, greater or
|
|
# contained. Use the explicit ``exists`` operator when the
|
|
# distinction between missing and present is important.
|
|
matched = False
|
|
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"}
|
|
]
|