Files
ai-video-fullstack/backend/services/task_backend_resources.py
T

172 lines
8.0 KiB
Python

"""Validate reusable task backends and resolve their server-only snapshots."""
import json
import re
from db.models import Assistant, McpServer, ModelResource, TaskBackendResource, Tool
from models import (
RuntimeModelResource,
RuntimeTaskBackend,
RuntimeTool,
RuntimeToolBinding,
)
from schemas import AssistantTaskBackendBinding
from services.masking import mask_secrets
from services.system_tools import RESERVED_TOOL_NAMES
from services.tool_executor import ToolExecutor
from sqlalchemy import or_, select
from task_backend_schemas import (
ReasonerBackendConfig,
TaskBackendOut,
TaskBackendUpsert,
)
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
SYSTEM_VARIABLES = {
"system__agent_id", "system__conversation_id", "system__time", "system__time_utc",
"system__timezone", "system__agent_turns", "system__conversation_history",
"system__last_action_status", "system__last_action_invocation_id", "system__last_action_duration_ms",
"system__last_action_error", "system__last_action_error_code", "system__handoff_status",
}
def task_backend_to_out(resource: TaskBackendResource) -> TaskBackendOut:
return TaskBackendOut(
id=resource.id, name=resource.name, type=resource.type,
description=resource.description, config=resource.config,
secrets=mask_secrets(resource.secrets or {}), enabled=resource.enabled,
updated_at=resource.updated_at.isoformat() if resource.updated_at else None,
)
def validate_source_variables(source: dict, variables: set[str]) -> None:
names = set()
if source.get("source") == "variable":
names.add(str(source.get("variable_name") or source.get("variableName") or ""))
elif source.get("source") == "template":
names.update(PLACEHOLDER.findall(json.dumps(source.get("value"), ensure_ascii=False)))
unknown = names - variables - SYSTEM_VARIABLES
if unknown:
raise ValueError("任务配置引用了未声明变量: " + ", ".join(sorted(unknown)))
async def validate_backend_resource(
session,
body: TaskBackendUpsert,
*,
variables: set[str] | None = None,
) -> None:
if body.type != "reasoner":
return
config = body.config
assert isinstance(config, ReasonerBackendConfig)
model = await session.get(ModelResource, config.model_resource_id)
if (
not model
or not model.enabled
or model.capability != "LLM"
or model.interface_type not in {"openai-llm", "dashscope-llm"}
):
raise ValueError("Reasoner 必须引用已启用的 OpenAI-compatible LLM 模型")
if (
config.reasoning_effort != "provider_default"
and (model.values or {}).get("supportsReasoningEffort") is not True
):
raise ValueError("该模型未声明支持 reasoning_effort,不能设置推理强度")
names = set()
for binding in config.tool_bindings:
tool = await session.get(Tool, binding.tool_id)
if not tool or tool.status != "active" or tool.type not in {"http", "mcp"}:
raise ValueError("Reasoner 仅允许已启用的 HTTP/MCP 服务端工具: " + binding.tool_id)
if tool.function_name in RESERVED_TOOL_NAMES or tool.function_name in names:
raise ValueError("Reasoner 工具函数名重复或属于平台保留名称: " + tool.function_name)
names.add(tool.function_name)
if tool.type == "mcp":
server = await session.get(McpServer, tool.mcp_server_id) if tool.mcp_server_id else None
if not server or server.status != "active":
raise ValueError("Reasoner MCP 工具的 Server 不存在或未启用")
runtime_tool = RuntimeTool(
id=tool.id,
name=tool.name,
function_name=tool.function_name,
type=tool.type,
definition=tool.definition or {},
)
properties, required = ToolExecutor.input_schema(runtime_tool)
if set(binding.argument_bindings) - set(properties):
raise ValueError("Reasoner 工具绑定引用了未知参数")
if set(required) - set(binding.argument_bindings):
raise ValueError("Reasoner 工具绑定缺少必填参数")
template_inputs = ((tool.definition or {}).get("config") or {}).get("template_inputs") or []
template_names = {item["name"] for item in template_inputs if item.get("name")}
required_templates = {item["name"] for item in template_inputs if item.get("name") and item.get("required", True)}
if set(binding.template_bindings) - template_names or required_templates - set(binding.template_bindings):
raise ValueError("Reasoner 工具模板输入缺失或引用了未知模板输入")
if variables is not None:
for source in [*binding.argument_bindings.values(), *binding.template_bindings.values()]:
validate_source_variables(source.model_dump(), variables)
unknown_targets = set(binding.result_assignments) - variables
if unknown_targets:
raise ValueError("Reasoner 结果映射目标未声明: " + ", ".join(sorted(unknown_targets)))
async def backend_consumers(session, backend_id: str) -> list[Assistant]:
return list((await session.execute(select(Assistant).where(or_(
Assistant.task_backend["backend_id"].astext == backend_id,
Assistant.task_backend["backendId"].astext == backend_id,
)))).scalars().all())
async def validate_backend_binding(
session, assistant, *, resource=None
) -> TaskBackendResource | None:
raw = getattr(assistant, "task_backend", None)
if not raw:
return None
if assistant.type != "prompt" or getattr(assistant, "runtime_mode", "pipeline") != "pipeline":
raise ValueError("任务后端仅支持提示词 Pipeline")
binding = (
raw
if isinstance(raw, AssistantTaskBackendBinding)
else AssistantTaskBackendBinding.model_validate(raw)
)
resource = resource or await session.get(TaskBackendResource, binding.backend_id)
if resource is None or not resource.enabled:
raise ValueError("任务后端不存在或未启用")
variables = set(assistant.dynamic_variable_definitions or {})
validate_source_variables(binding.instruction_source.model_dump(), variables)
validate_source_variables({"source": "template", "value": binding.instructions}, variables)
body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config)
await validate_backend_resource(session, body, variables=variables)
return resource
async def resolve_task_backend(session, assistant) -> RuntimeTaskBackend | None:
resource = await validate_backend_binding(session, assistant)
if resource is None:
return None
binding = AssistantTaskBackendBinding.model_validate(assistant.task_backend)
body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config)
config = body.config.model_dump()
model = None
tool_bindings = []
tools = []
if resource.type == "reasoner":
from services.config_resolver import _tools_for
row = await session.get(ModelResource, config["model_resource_id"])
model = RuntimeModelResource(
id=row.id, name=row.name, capability=row.capability, interface_type=row.interface_type,
values=row.values or {}, secrets=row.secrets or {}, support_image_input=bool(row.support_image_input),
)
tool_bindings = [RuntimeToolBinding(**item) for item in config["tool_bindings"]]
tools = await _tools_for(session, assistant, [item.tool_id for item in tool_bindings])
if len(tools) != len(tool_bindings):
raise ValueError("Reasoner 工具解析失败,请检查工具与 MCP Server")
return RuntimeTaskBackend(
id=resource.id, name=resource.name, type=resource.type, config=config,
secrets=resource.secrets or {}, model=model, tools=tools, tool_bindings=tool_bindings,
instruction_source=binding.instruction_source.model_dump(), instructions=binding.instructions,
)