- Introduce WorkflowLLMRouter for pre-response LLM routing, allowing agents to determine the appropriate function to call based on user input. - Implement UserTurnRoutingProcessor to manage user turns before reaching the LLM, ensuring proper routing and handling of user messages. - Refactor WorkflowBrain to integrate new routing logic and enhance agent stage configuration, including entry modes and resource management. - Update service factory to support dynamic LLM resource configuration based on workflow settings. - Add tests for new routing functionality and ensure proper handling of user messages in various scenarios.
205 lines
7.8 KiB
Python
205 lines
7.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
|
|
tool_ids: tuple[str, ...]
|
|
knowledge_base_id: str | None
|
|
knowledge_mode: str
|
|
knowledge_top_n: int
|
|
knowledge_score_threshold: float
|
|
|
|
|
|
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 "")
|
|
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
|
|
),
|
|
)
|
|
|
|
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 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"}
|
|
]
|