Files
ai-video-fullstack/backend/tests/test_workflow_v4.py
T

836 lines
31 KiB
Python

from __future__ import annotations
import asyncio
import unittest
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock
from models import AssistantConfig, RuntimeModelResource
from fastapi import HTTPException
from routes.assistants import (
_validate_update_state_config,
_validate_workflow,
_validate_workflow_references,
)
from schemas import AssistantUpsert
from services.pipecat.service_factory import config_with_resource
from services.node_specs import graph_references, validate_graph
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
from services.vision import config_with_vision_resource
from services.workflow_engine import WorkflowEngine
def valid_settings():
return {
"runtimeMode": "pipeline",
"llmRoutingMode": "llm_router",
"globalPrompt": "服务 {{customer}}",
"defaultRealtimeResourceId": "",
"defaultLlmResourceId": "",
"defaultAsrResourceId": "",
"defaultTtsResourceId": "",
"visionEnabled": False,
"visionModelResourceId": "",
"visionPromptMode": "runtime",
"visionPrompt": "",
"toolBindings": [],
"builtInTools": {},
"knowledgeBaseId": "",
"knowledgeMode": "disabled",
"knowledgeTopN": 5,
"knowledgeScoreThreshold": 0.0,
"enableInterrupt": True,
"turnConfig": {
"bargeIn": {"strategy": "vad"},
"turnDetection": {"strategy": "smart_turn"},
},
}
def valid_graph():
return {
"specVersion": 4,
"settings": valid_settings(),
"nodes": [
{"id": "start", "type": "start", "data": {"name": "Start"}},
{
"id": "agent",
"type": "agent",
"data": {
"name": "Agent",
"prompt": "处理订单",
"contextPolicy": "fresh",
"inheritGlobalConfig": True,
"entryMode": "wait_user",
},
},
{"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_rejects_non_current_workflow_schema(self):
graph = valid_graph()
graph["specVersion"] = 3
graph["settings"].pop("runtimeMode")
errors = validate_graph(graph)
self.assertIn("工作流只支持 specVersion 4", errors)
self.assertTrue(any("缺少字段:runtimeMode" in error for error in errors))
def test_revision_pins_the_normalized_workflow_snapshot(self):
first = WorkflowEngine(valid_graph())
same_graph = deepcopy(valid_graph())
same_graph["settings"] = dict(reversed(list(same_graph["settings"].items())))
second = WorkflowEngine(same_graph)
self.assertEqual(first.revision, second.revision)
self.assertTrue(first.revision.startswith("sha256:"))
metadata = first.session_metadata()
first.graph["settings"]["globalPrompt"] = "运行时外部修改"
self.assertEqual(
metadata["workflow"]["snapshot"]["settings"]["globalPrompt"],
"服务 {{customer}}",
)
changed = valid_graph()
changed["settings"]["globalPrompt"] = "另一份配置"
self.assertNotEqual(first.revision, WorkflowEngine(changed).revision)
def test_workflow_graph_owns_flat_vision_session_flag(self):
graph = valid_graph()
graph["settings"].update(
{
"defaultLlmResourceId": "llm_global",
"visionEnabled": True,
"visionModelResourceId": "vision_global",
}
)
body = AssistantUpsert(
name="视觉工作流",
type="workflow",
graph=graph,
visionEnabled=False,
visionModelResourceId="ignored_top_level_vision",
)
_validate_workflow(body)
self.assertTrue(body.vision_enabled)
self.assertIsNone(body.vision_model_resource_id)
def test_agent_entry_modes_only_control_conversation_start(self):
graph = valid_graph()
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
self.assertEqual(agent["data"]["entryMode"], "wait_user")
self.assertTrue(agent["data"]["inheritGlobalConfig"])
self.assertEqual(agent["data"]["contextPolicy"], "fresh")
agent["data"]["entryMode"] = "generate"
self.assertEqual(validate_graph(graph), [])
agent["data"]["entryMode"] = "fixed_speech"
self.assertTrue(any("进入模式无效" in error for error in validate_graph(graph)))
def test_action_advanced_settings_are_validated(self):
graph = valid_graph()
graph["nodes"].append(
{
"id": "action",
"type": "action",
"data": {
"toolBinding": {},
"userInputPolicy": "cancel",
},
}
)
errors = validate_graph(graph)
self.assertTrue(any("必须配置工具绑定" in error for error in errors))
self.assertTrue(any("用户输入策略无效" in error for error in errors))
def test_message_contract_and_validation(self):
graph = valid_graph()
graph["nodes"].append(
{
"id": "message",
"type": "message",
"data": {
"speech": "请确认重要信息",
"completionPolicy": "playback",
},
}
)
graph["edges"][0]["target"] = "message"
graph["edges"].insert(
1,
{
"id": "after-message",
"source": "message",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
)
message = next(
node for node in graph["nodes"] if node["type"] == "message"
)
self.assertEqual(validate_graph(graph), [])
message["data"].update(
{
"speech": "",
"title": "重要提示",
"message": "",
"confirmLabel": "确认",
"completionPolicy": "confirmation",
}
)
errors = validate_graph(graph)
self.assertTrue(any("弹窗消息必须为" in error for error in errors))
self.assertTrue(any("必须配置播报内容" in error for error in errors))
message["data"].update(
{"speech": "", "completionPolicy": "playback"}
)
errors = validate_graph(graph)
self.assertTrue(any("必须配置播报内容" in error for error in errors))
message["data"]["completionPolicy"] = "unknown"
errors = validate_graph(graph)
self.assertTrue(any("完成策略无效" in error for error in errors))
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")
llm = RuntimeModelResource(
id="llm_1",
capability="LLM",
interface_type="openai-llm",
values={"modelId": "deepseek-chat", "apiUrl": "https://llm.test/v1"},
secrets={"apiKey": "llm-secret"},
)
llm_resolved = config_with_resource(base, llm)
self.assertEqual(llm_resolved.model, "deepseek-chat")
self.assertEqual(llm_resolved.llm_api_key, "llm-secret")
def test_global_and_custom_agent_references_are_preserved(self):
graph = valid_graph()
graph["settings"].update(
{
"defaultLlmResourceId": "llm_global",
"defaultAsrResourceId": "asr_global",
"defaultTtsResourceId": "tts_global",
"visionEnabled": True,
"visionModelResourceId": "vision_global",
"toolBindings": [{"toolId": "tool_global"}],
"knowledgeBaseId": "kb_global",
}
)
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
agent["data"].update(
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"asrResourceId": "asr_agent",
"ttsResourceId": "tts_agent",
"visionEnabled": True,
"visionModelResourceId": "vision_agent",
"toolBindings": [{"toolId": "tool_agent"}],
"knowledgeBaseId": "kb_agent",
}
)
refs = graph_references(graph)
self.assertEqual(
refs["model_resources"],
{
"llm_global",
"asr_global",
"tts_global",
"vision_global",
"llm_agent",
"asr_agent",
"tts_agent",
"vision_agent",
},
)
self.assertEqual(refs["tools"], {"tool_global", "tool_agent"})
self.assertEqual(refs["knowledge_bases"], {"kb_global", "kb_agent"})
def test_inherited_agent_ignores_stale_custom_references(self):
graph = valid_graph()
agent = next(node for node in graph["nodes"] if node["type"] == "agent")
agent["data"].update(
{
"inheritGlobalConfig": True,
"llmResourceId": "stale_llm",
"asrResourceId": "stale_asr",
"ttsResourceId": "stale_tts",
"toolBindings": [{"toolId": "stale_tool"}],
"knowledgeBaseId": "stale_kb",
}
)
refs = graph_references(graph)
self.assertNotIn("stale_llm", refs["model_resources"])
self.assertNotIn("stale_tool", refs["tools"])
self.assertNotIn("stale_kb", refs["knowledge_bases"])
def test_agent_effective_config_inherits_then_switches_to_override(self):
graph = valid_graph()
agent = next(node for node in graph["nodes"] if node["id"] == "agent")
graph["settings"].update(
{
"defaultLlmResourceId": "llm_global",
"defaultAsrResourceId": "asr_global",
"defaultTtsResourceId": "tts_global",
"visionEnabled": True,
"visionModelResourceId": "vision_global",
"toolBindings": [{"toolId": "tool_global"}],
"builtInTools": {
"updateState": {"description": "更新", "updates": []},
"skipTurn": {"description": "跳过"},
},
"knowledgeBaseId": "kb_global",
"knowledgeMode": "on_demand",
"knowledgeTopN": 8,
"knowledgeScoreThreshold": 0.4,
"enableInterrupt": False,
"turnConfig": {
"bargeIn": {"strategy": "transcription"},
"turnDetection": {"strategy": "silence", "silenceTimeoutSecs": 1.2},
},
}
)
engine = WorkflowEngine(graph)
inherited = engine.agent_stage_config("agent")
self.assertEqual(inherited.llm_resource_id, "llm_global")
self.assertTrue(inherited.vision_enabled)
self.assertEqual(
inherited.vision_model_resource_id,
"vision_global",
)
self.assertTrue(engine.uses_vision())
self.assertEqual(
[binding["toolId"] for binding in inherited.tool_bindings],
["tool_global"],
)
self.assertIn("updateState", inherited.built_in_tools)
self.assertEqual(inherited.knowledge_mode, "on_demand")
self.assertFalse(inherited.enable_interrupt)
self.assertEqual(
inherited.turn_config["bargeIn"]["strategy"],
"transcription",
)
engine.data("agent").update(
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"toolBindings": [{"toolId": "tool_agent"}],
"builtInTools": {"skipTurn": {"description": "跳过"}},
"knowledgeBaseId": "",
"visionEnabled": False,
"visionModelResourceId": "",
"enableInterrupt": True,
"turnConfig": {
"bargeIn": {"strategy": "vad"},
"turnDetection": {"strategy": "smart_turn"},
},
}
)
custom = engine.agent_stage_config("agent")
self.assertEqual(custom.llm_resource_id, "llm_agent")
self.assertFalse(custom.vision_enabled)
self.assertIsNone(custom.vision_model_resource_id)
self.assertFalse(engine.uses_vision())
self.assertEqual(
[binding["toolId"] for binding in custom.tool_bindings],
["tool_agent"],
)
self.assertEqual(custom.knowledge_mode, "disabled")
self.assertTrue(custom.enable_interrupt)
self.assertEqual(
custom.turn_config["turnDetection"]["strategy"],
"smart_turn",
)
def test_update_state_node_and_agent_state_scope_are_validated(self):
graph = valid_graph()
graph["nodes"].insert(
1,
{
"id": "set_state",
"type": "update_state",
"data": {
"name": "记录状态",
"updates": [
{
"variableName": "confirmed",
"expression": {"type": "constant", "value": True},
}
],
},
},
)
graph["edges"][0]["target"] = "set_state"
graph["edges"].insert(
1,
{
"id": "after_state",
"source": "set_state",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
)
body = AssistantUpsert(
name="状态工作流",
type="workflow",
dynamicVariableDefinitions={
"confirmed": {
"type": "boolean",
"required": False,
"default": False,
},
"customer": {
"type": "string",
"required": False,
"default": None,
},
},
graph=graph,
)
_validate_workflow(body)
_validate_update_state_config(body)
normalized_state = next(
node for node in body.graph["nodes"] if node["type"] == "update_state"
)
self.assertEqual(
normalized_state["data"]["updates"][0]["variableName"],
"confirmed",
)
normalized_state["data"]["updates"] = [
{
"variableName": "missing",
"expression": {"type": "constant", "value": "x"},
}
]
with self.assertRaisesRegex(HTTPException, "目标未声明"):
_validate_update_state_config(body)
def test_vision_resource_creates_isolated_runtime_config(self):
base = AssistantConfig(type="workflow", model="text-only")
resource = RuntimeModelResource(
id="vision_1",
capability="LLM",
interface_type="openai-llm",
values={
"modelId": "vision-model",
"apiUrl": "https://vision.test/v1",
},
secrets={"apiKey": "vision-secret"},
support_image_input=True,
)
resolved = config_with_vision_resource(base, resource)
self.assertEqual(resolved.vision_model, "vision-model")
self.assertEqual(resolved.vision_llm_api_key, "vision-secret")
self.assertEqual(resolved.vision_llm_base_url, "https://vision.test/v1")
self.assertEqual(base.model, "text-only")
def test_start_agent_action_and_handoff_may_have_no_outgoing_edge(self):
terminal_graphs = [
{
"specVersion": 4,
"settings": valid_settings(),
"nodes": [{"id": "start", "type": "start", "data": {}}],
"edges": [],
},
{
"specVersion": 4,
"settings": valid_settings(),
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {
"prompt": "持续处理用户问题",
"inheritGlobalConfig": True,
"entryMode": "wait_user",
},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
},
{
"specVersion": 4,
"settings": valid_settings(),
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "handoff", "type": "handoff", "data": {}},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "handoff",
"data": {"mode": "always", "priority": 0},
}
],
},
]
for graph in terminal_graphs:
with self.subTest(node=graph["nodes"][-1]["type"]):
self.assertEqual(validate_graph(graph), [])
action_without_exit = {
"specVersion": 4,
"settings": valid_settings(),
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "action",
"type": "action",
"data": {
"toolBinding": {"toolId": "tool"},
"userInputPolicy": "queue",
},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "action",
"data": {"mode": "always", "priority": 0},
}
],
}
self.assertEqual(validate_graph(action_without_exit), [])
def test_agent_cannot_have_only_one_default_path(self):
graph = {
"specVersion": 4,
"settings": valid_settings(),
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {
"inheritGlobalConfig": True,
"entryMode": "wait_user",
},
},
{"id": "end", "type": "end", "data": {}},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
},
{
"id": "leave",
"source": "agent",
"target": "end",
"data": {"mode": "always", "priority": 0},
},
],
}
self.assertTrue(
any(
"Agent 节点 agent 不能只有默认路径" in error
for error in validate_graph(graph)
)
)
graph["edges"][1]["data"] = {
"mode": "llm",
"priority": 10,
"condition": "当前阶段已经完成",
}
self.assertEqual(validate_graph(graph), [])
def test_all_source_nodes_allow_multiple_conditional_paths(self):
expression = {
"combinator": "and",
"rules": [{"variable": "route", "operator": "eq", "value": "yes"}],
}
cases = {
"start": {
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "agent", "type": "agent", "data": {}},
{"id": "action", "type": "action", "data": {"toolBinding": {"toolId": "tool"}}},
{"id": "end", "type": "end", "data": {}},
],
"edges": [
{"id": "default", "source": "start", "target": "agent", "data": {"mode": "always", "priority": 10}},
{"id": "llm", "source": "start", "target": "end", "data": {"mode": "llm", "priority": 20, "condition": "应当结束"}},
{"id": "expr", "source": "start", "target": "action", "data": {"mode": "expression", "priority": 30, "expression": expression}},
{"id": "action-end", "source": "action", "target": "end", "data": {"mode": "always", "priority": 10}},
],
},
"agent": {
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "agent", "type": "agent", "data": {}},
{"id": "action", "type": "action", "data": {"toolBinding": {"toolId": "tool"}}},
{"id": "handoff", "type": "handoff", "data": {}},
{"id": "end", "type": "end", "data": {}},
],
"edges": [
{"id": "begin", "source": "start", "target": "agent", "data": {"mode": "always", "priority": 10}},
{"id": "default", "source": "agent", "target": "end", "data": {"mode": "always", "priority": 10}},
{"id": "llm", "source": "agent", "target": "action", "data": {"mode": "llm", "priority": 20, "condition": "执行操作"}},
{"id": "expr", "source": "agent", "target": "handoff", "data": {"mode": "expression", "priority": 30, "expression": expression}},
{"id": "action-end", "source": "action", "target": "end", "data": {"mode": "always", "priority": 10}},
],
},
"action": {
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "action", "type": "action", "data": {"toolBinding": {"toolId": "tool"}}},
{"id": "agent", "type": "agent", "data": {}},
{"id": "handoff", "type": "handoff", "data": {}},
{"id": "end", "type": "end", "data": {}},
],
"edges": [
{"id": "begin", "source": "start", "target": "action", "data": {"mode": "always", "priority": 10}},
{"id": "default", "source": "action", "target": "agent", "data": {"mode": "always", "priority": 10}},
{"id": "llm", "source": "action", "target": "end", "data": {"mode": "llm", "priority": 20, "condition": "执行失败"}},
{"id": "expr", "source": "action", "target": "handoff", "data": {"mode": "expression", "priority": 30, "expression": expression}},
],
},
"handoff": {
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "handoff", "type": "handoff", "data": {}},
{"id": "agent", "type": "agent", "data": {}},
{"id": "action", "type": "action", "data": {"toolBinding": {"toolId": "tool"}}},
{"id": "end", "type": "end", "data": {}},
],
"edges": [
{"id": "begin", "source": "start", "target": "handoff", "data": {"mode": "always", "priority": 10}},
{"id": "default", "source": "handoff", "target": "agent", "data": {"mode": "always", "priority": 10}},
{"id": "llm", "source": "handoff", "target": "end", "data": {"mode": "llm", "priority": 20, "condition": "转接完成"}},
{"id": "expr", "source": "handoff", "target": "action", "data": {"mode": "expression", "priority": 30, "expression": expression}},
{"id": "action-end", "source": "action", "target": "end", "data": {"mode": "always", "priority": 10}},
],
},
}
for source_type, parts in cases.items():
for node in parts["nodes"]:
if node["type"] == "agent":
node["data"].update(
{
"inheritGlobalConfig": True,
"entryMode": "wait_user",
}
)
elif node["type"] == "action":
node["data"]["userInputPolicy"] = "queue"
graph = {"specVersion": 4, "settings": valid_settings(), **parts}
with self.subTest(source_type=source_type):
self.assertEqual(validate_graph(graph), [])
graph["edges"].append(
{
"id": "duplicate-default",
"source": source_type,
"target": "end",
"data": {"mode": "always", "priority": 40},
}
)
self.assertTrue(
any("最多只能有一条默认路径" in error for error in validate_graph(graph))
)
def test_rejects_automatic_cycle_and_duplicate_priorities(self):
graph = valid_graph()
graph["nodes"].insert(
1,
{
"id": "action",
"type": "action",
"data": {
"toolBinding": {"toolId": "tool"},
"userInputPolicy": "queue",
},
},
)
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))
inherited_prompt = engine.prompt_for("agent", store)
self.assertIn("服务 王先生", inherited_prompt)
self.assertIn("处理订单", inherited_prompt)
engine.data("agent")["inheritGlobalConfig"] = False
custom_prompt = engine.prompt_for("agent", store)
self.assertNotIn("服务 王先生", custom_prompt)
self.assertIn("处理订单", custom_prompt)
def test_missing_variable_only_matches_explicit_exists_rule(self):
engine = WorkflowEngine(valid_graph())
values = {}
self.assertFalse(
engine.expression_matches(
{
"combinator": "and",
"rules": [
{
"variable": "order_status",
"operator": "neq",
"value": "paid",
}
],
},
values,
)
)
self.assertTrue(
engine.expression_matches(
{
"combinator": "and",
"rules": [
{
"variable": "order_status",
"operator": "exists",
"value": False,
}
],
},
values,
)
)
class WorkflowVisionReferenceTests(unittest.IsolatedAsyncioTestCase):
async def test_visual_model_must_support_image_input(self):
graph = valid_graph()
graph["settings"].update(
{
"defaultLlmResourceId": "llm_global",
"visionEnabled": True,
"visionModelResourceId": "vision_global",
}
)
body = AssistantUpsert(
name="视觉工作流",
type="workflow",
graph=graph,
)
_validate_workflow(body)
resources = {
"llm_global": SimpleNamespace(
enabled=True,
capability="LLM",
support_image_input=False,
),
"vision_global": SimpleNamespace(
enabled=True,
capability="LLM",
support_image_input=False,
),
}
class FakeSession:
async def get(self, _model, resource_id):
return resources.get(resource_id)
with self.assertRaisesRegex(HTTPException, "必须支持图片输入"):
await _validate_workflow_references(FakeSession(), body)
resources["vision_global"].support_image_input = True
await _validate_workflow_references(FakeSession(), body)
if __name__ == "__main__":
unittest.main()