Files
ai-video-fullstack/backend/services/workflow_engine.py
Xin Wang 72856bf3a7 Enhance workflow routing and agent configuration management
- 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.
2026-07-14 09:36:28 +08:00

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"}
]