Refactor workflow routing and greeting management in Brain classes

- 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.
This commit is contained in:
Xin Wang
2026-07-17 22:01:42 +08:00
parent 34c0d12d2a
commit 162a3d8bec
15 changed files with 826 additions and 147 deletions

View File

@@ -28,6 +28,7 @@ from services.brains.dify_llm import (
)
from services.brains.workflow_brain import WorkflowBrain
from services.runtime_variables import prepare_dynamic_config
from services.workflow_router import STAY_ON_CURRENT_NODE
class FakeLLM:
@@ -460,6 +461,86 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_initial_fixed_speech_waits_for_start_greeting_to_finish(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{
"id": "start",
"type": "start",
"data": {"greeting": "欢迎使用"},
},
{
"id": "agent",
"type": "agent",
"data": {
"prompt": "收集用户信息",
"entryMode": "fixed_speech",
"entrySpeech": "请问您怎么称呼?",
},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
}
)
queued = []
async def queue_frame(frame):
queued.append(frame)
class FakeManager:
def __init__(self):
self.current_node = None
async def initialize(self, config):
self.current_node = config["name"]
async def set_node_from_config(self, config):
self.current_node = config["name"]
for action in config.get("pre_actions", []):
await action["handler"](action, self)
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
)
brain._manager = FakeManager()
await brain.on_connected(greeting_pending=True)
self.assertEqual(brain._manager.current_node, "start")
self.assertFalse(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
await brain.on_greeting_finished()
self.assertEqual(brain._manager.current_node, "agent")
fixed_speech_frames = [
frame for frame in queued if isinstance(frame, TTSSpeakFrame)
]
self.assertEqual(len(fixed_speech_frames), 1)
self.assertEqual(fixed_speech_frames[0].text, "请问您怎么称呼?")
# Playback notifications may be duplicated by a transport reconnect;
# the initial entry behavior must still run only once.
await brain.on_greeting_finished()
self.assertEqual(
len([frame for frame in queued if isinstance(frame, TTSSpeakFrame)]),
1,
)
async def test_action_publishes_updated_session_variables(self):
tool = RuntimeTool(
id="lookup",
@@ -652,6 +733,261 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
)
)
async def test_start_llm_conditions_wait_for_and_route_first_user_turn(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{
"id": "start",
"type": "start",
"data": {"name": "Start", "greeting": "你想做什么?"},
},
{
"id": "eat",
"type": "agent",
"data": {
"name": "点饭",
"prompt": "帮助用户点饭",
"contextPolicy": "fresh",
"entryMode": "wait_user",
},
},
{"id": "drink", "type": "agent", "data": {}},
{"id": "run", "type": "agent", "data": {}},
],
"edges": [
{
"id": "eat",
"source": "start",
"target": "eat",
"data": {
"mode": "llm",
"priority": 10,
"condition": "用户想吃饭",
},
},
{
"id": "drink",
"source": "start",
"target": "drink",
"data": {
"mode": "llm",
"priority": 20,
"condition": "用户想喝水",
},
},
{
"id": "run",
"source": "start",
"target": "run",
"data": {
"mode": "llm",
"priority": 30,
"condition": "用户想跑步",
},
},
],
}
)
queued = []
async def queue_frame(frame):
queued.append(frame)
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
)
class FakeManager:
def __init__(self):
self.current_node = None
self.config = None
async def initialize(self, config):
self.current_node = config["name"]
self.config = config
async def set_node_from_config(self, config):
self.current_node = config["name"]
self.config = config
class FakeRouter:
def __init__(self):
self.calls = 0
async def select_edge(self, **_kwargs):
self.calls += 1
return "goto_eat"
manager = FakeManager()
router = FakeRouter()
brain._manager = manager
brain._router = router
await brain.on_connected()
self.assertEqual(manager.current_node, "start")
self.assertEqual(router.calls, 0)
handled = await brain.on_user_turn_end("我想吃饭")
self.assertTrue(handled)
self.assertEqual(router.calls, 1)
self.assertEqual(manager.current_node, "eat")
self.assertIn(
{"role": "user", "content": "我想吃饭"},
manager.config["task_messages"],
)
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
self.assertIn("我想吃饭", brain._store.values["system__conversation_history"])
async def test_automatic_node_can_follow_llm_condition(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {"globalPrompt": "全局规则"},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "handoff",
"type": "handoff",
"data": {"name": "人工转接", "targetType": "human"},
},
{
"id": "agent",
"type": "agent",
"data": {"name": "继续服务", "prompt": "继续处理"},
},
],
"edges": [
{
"id": "to-agent",
"source": "handoff",
"target": "agent",
"data": {
"mode": "llm",
"priority": 10,
"condition": "转接后仍需 AI 继续服务",
},
}
],
}
)
queued = []
async def queue_frame(frame):
queued.append(frame)
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
)
class FakeRouter:
async def select_edge(self, **kwargs):
self.node_name = kwargs["node_name"]
return "goto_to_agent"
router = FakeRouter()
brain._router = router
config = await brain._resolve_path("handoff")
self.assertEqual(config["name"], "agent")
self.assertEqual(router.node_name, "人工转接")
async def test_mixed_edge_conditions_follow_priority(self):
brain = WorkflowBrain(
{
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "agent", "type": "agent", "data": {}},
{"id": "llm-target", "type": "end", "data": {}},
{"id": "expression-target", "type": "end", "data": {}},
{"id": "default-target", "type": "end", "data": {}},
],
"edges": [
{
"id": "llm",
"source": "agent",
"target": "llm-target",
"data": {
"mode": "llm",
"priority": 10,
"condition": "大模型条件成立",
},
},
{
"id": "expression",
"source": "agent",
"target": "expression-target",
"data": {
"mode": "expression",
"priority": 20,
"expression": {
"combinator": "and",
"rules": [
{
"variable": "route",
"operator": "eq",
"value": "expression",
}
],
},
},
},
{
"id": "default",
"source": "agent",
"target": "default-target",
"data": {"mode": "always", "priority": 30},
},
],
}
)
brain._store.values["route"] = "expression"
class FakeRouter:
def __init__(self):
self.result = STAY_ON_CURRENT_NODE
self.edge_ids = []
async def select_edge(self, **kwargs):
self.edge_ids = [edge["id"] for edge in kwargs["edges"]]
return self.result
router = FakeRouter()
brain._router = router
selected = await brain._select_edge("agent")
self.assertEqual(router.edge_ids, ["llm"])
self.assertEqual(selected["id"], "expression")
router.result = "goto_llm"
selected = await brain._select_edge("agent")
self.assertEqual(selected["id"], "llm")
expression_edge = next(
edge for edge in brain._engine.edges if edge["id"] == "expression"
)
expression_edge["data"]["priority"] = 5
router.edge_ids = []
selected = await brain._select_edge("agent")
self.assertEqual(selected["id"], "expression")
self.assertEqual(router.edge_ids, [])
async def test_transition_and_end_are_owned_by_workflow_brain(self):
graph = {
"specVersion": 3,