Add workflow support and enhance runtime configuration in models and services
- Introduce RuntimeModelResource and RuntimeKnowledgeBase classes to manage workflow resources. - Update AssistantConfig to include workflow_model_resources and workflow_knowledge_bases for better integration. - Refactor validation and processing logic in routes and services to accommodate workflow types. - Implement dynamic variable support for workflow assistants and enhance graph normalization. - Add ToolExecutor for reusable tool execution across different assistant types. - Update various services to ensure compatibility with new workflow features and improve error handling.
This commit is contained in:
@@ -111,6 +111,19 @@ class BrainRegistryTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("user_name", assistant.dynamic_variable_definitions)
|
||||
|
||||
def test_workflow_keeps_dynamic_variables_and_tool_bindings(self):
|
||||
assistant = AssistantUpsert(
|
||||
name="workflow",
|
||||
type="workflow",
|
||||
toolIds=["tool_a"],
|
||||
dynamicVariableDefinitions={
|
||||
"customer": {"type": "string", "required": False, "default": "王先生"}
|
||||
},
|
||||
graph={},
|
||||
)
|
||||
self.assertEqual(assistant.tool_ids, ["tool_a"])
|
||||
self.assertIn("customer", assistant.dynamic_variable_definitions)
|
||||
|
||||
|
||||
class DifyHelpersTests(unittest.TestCase):
|
||||
def test_normalize_api_base(self):
|
||||
@@ -363,7 +376,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
params = FakeFunctionParams(
|
||||
{"order_id": "A/1", "Authorization": "attacker-value"}
|
||||
)
|
||||
with patch("services.brains.prompt_brain.httpx.AsyncClient", FakeClient):
|
||||
with patch("services.tool_executor.httpx.AsyncClient", FakeClient):
|
||||
await llm.functions["lookup_order"](params)
|
||||
|
||||
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
|
||||
@@ -377,35 +390,91 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "全局规则"},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startCall",
|
||||
"data": {"name": "开始", "prompt": "收集需求"},
|
||||
"type": "start",
|
||||
"data": {"name": "Start"},
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {
|
||||
"name": "收集需求",
|
||||
"prompt": "服务 {{user_name}}",
|
||||
"contextPolicy": "fresh",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "endCall",
|
||||
"data": {"name": "结束", "prompt": "礼貌结束"},
|
||||
"type": "end",
|
||||
"data": {"name": "End", "message": "感谢来电", "scope": "session"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "finish",
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "finish",
|
||||
"source": "agent",
|
||||
"target": "end",
|
||||
"data": {"condition": "需求已收集"},
|
||||
"data": {
|
||||
"mode": "llm",
|
||||
"priority": 10,
|
||||
"condition": "需求已收集",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
brain = WorkflowBrain(graph)
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph=graph,
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True}
|
||||
},
|
||||
),
|
||||
{"user_name": "王先生"},
|
||||
assistant_id="asst_workflow",
|
||||
)
|
||||
brain = WorkflowBrain(cfg)
|
||||
llm = FakeLLM()
|
||||
context = LLMContext(messages=[])
|
||||
queued = []
|
||||
prompts = []
|
||||
visible_tools = []
|
||||
call_end = FakeCallEnd()
|
||||
|
||||
class FakeWorker:
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
self.handlers = {}
|
||||
|
||||
def set_reached_downstream_filter(self, *_args):
|
||||
pass
|
||||
|
||||
def event_handler(self, name):
|
||||
def decorator(fn):
|
||||
self.handlers[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
async def queue_frame(self, frame):
|
||||
self.frames.append(frame)
|
||||
|
||||
async def queue_frames(self, frames):
|
||||
self.frames.extend(frames)
|
||||
|
||||
worker = FakeWorker()
|
||||
pair = SimpleNamespace(
|
||||
user=lambda: SimpleNamespace(_context=context),
|
||||
assistant=lambda: SimpleNamespace(has_function_calls_in_progress=False),
|
||||
)
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
@@ -413,30 +482,34 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
context=context,
|
||||
llm=llm,
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=prompts.append,
|
||||
set_tools=lambda tools: visible_tools.append(tools or []),
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=call_end,
|
||||
worker=worker,
|
||||
context_aggregator=pair,
|
||||
)
|
||||
await brain.setup(AssistantConfig(type="workflow", graph=graph), runtime)
|
||||
|
||||
self.assertIn("goto_finish", llm.functions)
|
||||
self.assertIn("收集需求", prompts[-1])
|
||||
self.assertEqual(visible_tools[-1][0].name, "goto_finish")
|
||||
|
||||
params = FakeFunctionParams()
|
||||
await llm.functions["goto_finish"](params)
|
||||
self.assertEqual(params.result, {"status": "ok"})
|
||||
self.assertIn("礼貌结束", prompts[-1])
|
||||
self.assertEqual(visible_tools[-1], [])
|
||||
|
||||
await brain.on_assistant_text_start("closing-turn")
|
||||
await brain.on_assistant_text_end(
|
||||
"closing-turn",
|
||||
"感谢来电,再见。",
|
||||
False,
|
||||
await brain.setup(cfg, runtime)
|
||||
await brain.on_connected()
|
||||
self.assertEqual(brain._manager.current_node, "agent")
|
||||
agent_config = brain._agent_config("agent")
|
||||
self.assertIn("王先生", agent_config["role_message"])
|
||||
self.assertIn("完成当前阶段任务", agent_config["role_message"])
|
||||
self.assertEqual(agent_config["task_messages"], [])
|
||||
self.assertEqual(
|
||||
agent_config["context_strategy"].strategy.value,
|
||||
"reset",
|
||||
)
|
||||
|
||||
edge_function = next(
|
||||
function
|
||||
for function in brain._agent_config("agent")["functions"]
|
||||
if function.name == "goto_finish"
|
||||
)
|
||||
_, terminal = await edge_function.handler({}, brain._manager)
|
||||
self.assertEqual(terminal["name"], "end")
|
||||
self.assertTrue(call_end.ending)
|
||||
self.assertTrue(call_end.armed)
|
||||
self.assertTrue(any(getattr(frame, "text", "") == "感谢来电" for frame in queued))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.pipecat.pipeline import _knowledge_tool_description
|
||||
from services.pipecat.pipeline import (
|
||||
KNOWLEDGE_CONTEXT_MARKER,
|
||||
KnowledgeRetrievalProcessor,
|
||||
_knowledge_tool_description,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||
@@ -33,6 +37,25 @@ class KnowledgeToolDescriptionTest(unittest.TestCase):
|
||||
self.assertNotIn("\n ", description)
|
||||
self.assertLess(len(description), 1000)
|
||||
|
||||
def test_workflow_knowledge_uses_system_role(self):
|
||||
processor = KnowledgeRetrievalProcessor(None)
|
||||
messages = [
|
||||
{"role": "assistant", "content": "你好"},
|
||||
{
|
||||
"role": "developer",
|
||||
"content": f"{KNOWLEDGE_CONTEXT_MARKER}\n旧检索结果",
|
||||
},
|
||||
]
|
||||
|
||||
processor._set_context(
|
||||
messages,
|
||||
f"{KNOWLEDGE_CONTEXT_MARKER}\n新检索结果",
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["role"], "system")
|
||||
self.assertIn("新检索结果", messages[0]["content"])
|
||||
self.assertFalse(any(message["role"] == "developer" for message in messages))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
118
backend/tests/test_workflow_v3.py
Normal file
118
backend/tests/test_workflow_v3.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig, RuntimeModelResource
|
||||
from services.pipecat.service_factory import config_with_resource
|
||||
from services.node_specs import normalize_graph, validate_graph
|
||||
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
def valid_graph():
|
||||
return {
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "服务 {{customer}}"},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {"name": "Start"}},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {"name": "Agent", "prompt": "处理订单", "contextPolicy": "fresh"},
|
||||
},
|
||||
{"id": "end", "type": "end", "data": {"name": "End", "scope": "flow"}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "begin",
|
||||
"source": "start",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always", "priority": 0},
|
||||
},
|
||||
{
|
||||
"id": "paid",
|
||||
"source": "agent",
|
||||
"target": "end",
|
||||
"data": {
|
||||
"mode": "expression",
|
||||
"priority": 10,
|
||||
"expression": {
|
||||
"combinator": "and",
|
||||
"rules": [
|
||||
{"variable": "order_status", "operator": "eq", "value": "paid"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class WorkflowGraphTests(unittest.TestCase):
|
||||
def test_voice_resource_creates_isolated_runtime_config(self):
|
||||
base = AssistantConfig(type="workflow", asr="default", voice="default")
|
||||
asr = RuntimeModelResource(
|
||||
id="asr_1",
|
||||
capability="ASR",
|
||||
interface_type="openai-asr",
|
||||
values={"modelId": "sensevoice", "language": "zh", "apiUrl": "https://asr.test"},
|
||||
secrets={"apiKey": "secret"},
|
||||
)
|
||||
resolved = config_with_resource(base, asr)
|
||||
self.assertEqual(resolved.asr, "sensevoice")
|
||||
self.assertEqual(resolved.stt_api_key, "secret")
|
||||
self.assertEqual(base.asr, "default")
|
||||
|
||||
def test_v2_start_prompt_is_preserved_in_synthetic_agent(self):
|
||||
graph = normalize_graph(
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "s", "type": "startCall", "data": {"prompt": "询问需求"}},
|
||||
{"id": "e", "type": "endCall", "data": {"prompt": "再见"}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "done", "source": "s", "target": "e", "data": {"condition": "完成"}}
|
||||
],
|
||||
}
|
||||
)
|
||||
migrated = next(node for node in graph["nodes"] if node["type"] == "agent")
|
||||
self.assertEqual(migrated["data"]["prompt"], "询问需求")
|
||||
self.assertEqual(validate_graph(graph), [])
|
||||
|
||||
def test_rejects_automatic_cycle_and_duplicate_priorities(self):
|
||||
graph = valid_graph()
|
||||
graph["nodes"].insert(1, {"id": "action", "type": "action", "data": {}})
|
||||
graph["edges"] = [
|
||||
{"id": "a", "source": "start", "target": "action", "data": {"mode": "always", "priority": 0}},
|
||||
{"id": "b", "source": "action", "target": "start", "data": {"mode": "always", "priority": 0}},
|
||||
{"id": "c", "source": "agent", "target": "end", "data": {"mode": "always", "priority": 10}},
|
||||
]
|
||||
errors = validate_graph(graph)
|
||||
self.assertTrue(any("无等待循环" in error for error in errors))
|
||||
|
||||
def test_expression_routes_using_session_variables(self):
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph=valid_graph(),
|
||||
dynamic_variable_definitions={
|
||||
"customer": {"type": "string", "default": "王先生"},
|
||||
"order_status": {"type": "string", "default": "pending"},
|
||||
},
|
||||
),
|
||||
{},
|
||||
assistant_id="asst_test",
|
||||
)
|
||||
store = DynamicVariableStore.from_config(cfg)
|
||||
engine = WorkflowEngine(cfg.graph)
|
||||
self.assertIsNone(engine.deterministic_edge("agent", store, include_default=False))
|
||||
store.assign("order_status", "paid")
|
||||
self.assertEqual(
|
||||
engine.deterministic_edge("agent", store, include_default=False)["target"],
|
||||
"end",
|
||||
)
|
||||
self.assertIn("王先生", engine.prompt_for("agent", store))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user