- 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.
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""Assistant CRUD backed by capability-to-model-resource bindings."""
|
|
|
|
import uuid
|
|
|
|
from db.models import (
|
|
Assistant,
|
|
AssistantModelBinding,
|
|
AssistantToolBinding,
|
|
KnowledgeBase,
|
|
ModelResource,
|
|
Tool,
|
|
)
|
|
from db.session import get_session
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from schemas import AssistantOut, AssistantUpsert
|
|
from services.auth import require_admin
|
|
from services.masking import mask, resolve_incoming_key
|
|
from services.node_specs import graph_references, normalize_graph, validate_graph
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
router = APIRouter(
|
|
prefix="/api/assistants",
|
|
tags=["assistants"],
|
|
dependencies=[Depends(require_admin)],
|
|
)
|
|
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent")
|
|
|
|
|
|
def _validate_workflow(body: AssistantUpsert) -> None:
|
|
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
|
|
if body.type != "workflow":
|
|
return
|
|
body.graph = normalize_graph(body.graph or {})
|
|
errors = validate_graph(body.graph)
|
|
if errors:
|
|
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
|
|
refs = graph_references(body.graph)
|
|
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
|
|
|
|
|
|
async def _validate_workflow_references(
|
|
session: AsyncSession, body: AssistantUpsert
|
|
) -> None:
|
|
if body.type != "workflow" or not body.graph.get("nodes"):
|
|
return
|
|
graph = body.graph
|
|
settings = graph.get("settings") or {}
|
|
resource_expectations: dict[str, str] = {}
|
|
for key, capability in (
|
|
("defaultLlmResourceId", "LLM"),
|
|
("defaultAsrResourceId", "ASR"),
|
|
("defaultTtsResourceId", "TTS"),
|
|
):
|
|
if settings.get(key):
|
|
resource_expectations[str(settings[key])] = capability
|
|
knowledge_ids: set[str] = (
|
|
{str(settings["knowledgeBaseId"])}
|
|
if settings.get("knowledgeBaseId")
|
|
else set()
|
|
)
|
|
for node in graph.get("nodes") or []:
|
|
data = node.get("data") or {}
|
|
if node.get("type") == "agent" and data.get("inheritGlobalConfig", True):
|
|
continue
|
|
if data.get("llmResourceId"):
|
|
resource_expectations[str(data["llmResourceId"])] = "LLM"
|
|
if data.get("asrResourceId"):
|
|
resource_expectations[str(data["asrResourceId"])] = "ASR"
|
|
if data.get("ttsResourceId"):
|
|
resource_expectations[str(data["ttsResourceId"])] = "TTS"
|
|
if data.get("knowledgeBaseId"):
|
|
knowledge_ids.add(str(data["knowledgeBaseId"]))
|
|
for resource_id, capability in resource_expectations.items():
|
|
resource = await session.get(ModelResource, resource_id)
|
|
if not resource or not resource.enabled or resource.capability != capability:
|
|
raise HTTPException(400, f"Workflow 引用了无效的 {capability} 资源:{resource_id}")
|
|
for knowledge_id in knowledge_ids:
|
|
knowledge = await session.get(KnowledgeBase, knowledge_id)
|
|
if not knowledge or knowledge.status != "active":
|
|
raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}")
|
|
|
|
|
|
async def _validate_vision_model(
|
|
session: AsyncSession, body: AssistantUpsert
|
|
) -> None:
|
|
if body.vision_enabled:
|
|
if body.vision_model_resource_id:
|
|
resource = await session.get(ModelResource, body.vision_model_resource_id)
|
|
else:
|
|
resource_id = body.model_resource_ids.get("LLM")
|
|
resource = (
|
|
await session.get(ModelResource, resource_id) if resource_id else None
|
|
)
|
|
if not resource or resource.capability != "LLM":
|
|
raise HTTPException(400, "视觉模型必须引用 LLM 模型资源")
|
|
if not resource.support_image_input:
|
|
raise HTTPException(400, "视觉模型必须支持图片输入")
|
|
|
|
|
|
async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert) -> None:
|
|
if body.runtime_mode != "pipeline" or body.type not in {"prompt", "workflow"}:
|
|
body.knowledge_base_id = None
|
|
return
|
|
if body.knowledge_base_id and not await session.get(KnowledgeBase, body.knowledge_base_id):
|
|
raise HTTPException(400, "知识库不存在")
|
|
|
|
|
|
async def _sync_bindings(
|
|
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
|
|
) -> None:
|
|
for capability in CAPABILITIES:
|
|
resource_id = resource_ids.get(capability)
|
|
binding = await session.get(AssistantModelBinding, (assistant_id, capability))
|
|
if not resource_id:
|
|
if binding:
|
|
await session.delete(binding)
|
|
continue
|
|
resource = await session.get(ModelResource, resource_id)
|
|
if not resource or resource.capability != capability:
|
|
raise HTTPException(400, f"{capability} 绑定必须引用同能力的模型资源")
|
|
if binding:
|
|
binding.model_resource_id = resource_id
|
|
else:
|
|
session.add(
|
|
AssistantModelBinding(
|
|
assistant_id=assistant_id,
|
|
capability=capability,
|
|
model_resource_id=resource_id,
|
|
config={},
|
|
)
|
|
)
|
|
|
|
|
|
async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, str]:
|
|
bindings = (
|
|
await session.execute(
|
|
select(AssistantModelBinding).where(
|
|
AssistantModelBinding.assistant_id == assistant_id
|
|
)
|
|
)
|
|
).scalars().all()
|
|
return {binding.capability: binding.model_resource_id for binding in bindings}
|
|
|
|
|
|
async def _sync_tool_bindings(
|
|
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
|
|
) -> None:
|
|
requested = (
|
|
list(dict.fromkeys(tool_ids))
|
|
if assistant_type in {"prompt", "workflow"}
|
|
else []
|
|
)
|
|
if requested:
|
|
tools = (
|
|
await session.execute(select(Tool).where(Tool.id.in_(requested)))
|
|
).scalars().all()
|
|
found = {tool.id for tool in tools if tool.status == "active"}
|
|
missing = [tool_id for tool_id in requested if tool_id not in found]
|
|
if missing:
|
|
raise HTTPException(400, f"工具不存在或未启用: {', '.join(missing)}")
|
|
|
|
existing = (
|
|
await session.execute(
|
|
select(AssistantToolBinding).where(
|
|
AssistantToolBinding.assistant_id == assistant_id
|
|
)
|
|
)
|
|
).scalars().all()
|
|
existing_by_id = {binding.tool_id: binding for binding in existing}
|
|
for tool_id, binding in existing_by_id.items():
|
|
if tool_id not in requested:
|
|
await session.delete(binding)
|
|
for tool_id in requested:
|
|
if tool_id not in existing_by_id:
|
|
session.add(AssistantToolBinding(assistant_id=assistant_id, tool_id=tool_id))
|
|
|
|
|
|
async def _tool_ids(session: AsyncSession, assistant_id: str) -> list[str]:
|
|
rows = (
|
|
await session.execute(
|
|
select(AssistantToolBinding.tool_id)
|
|
.where(AssistantToolBinding.assistant_id == assistant_id)
|
|
.order_by(AssistantToolBinding.created_at)
|
|
)
|
|
).scalars().all()
|
|
return list(rows)
|
|
|
|
|
|
async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
|
return AssistantOut(
|
|
id=assistant.id,
|
|
name=assistant.name,
|
|
type=assistant.type, # type: ignore[arg-type]
|
|
runtime_mode=assistant.runtime_mode, # type: ignore[arg-type]
|
|
greeting=assistant.greeting,
|
|
enable_interrupt=assistant.enable_interrupt,
|
|
turn_config=assistant.turn_config or {},
|
|
vision_enabled=assistant.vision_enabled,
|
|
vision_model_resource_id=assistant.vision_model_resource_id,
|
|
model_resource_ids=await _resource_ids(session, assistant.id),
|
|
knowledge_base_id=assistant.knowledge_base_id,
|
|
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
|
tool_ids=await _tool_ids(session, assistant.id),
|
|
prompt=assistant.prompt,
|
|
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
|
api_url=assistant.api_url,
|
|
api_key=mask(assistant.api_key),
|
|
app_id=assistant.app_id,
|
|
graph=(
|
|
normalize_graph(assistant.graph or {})
|
|
if assistant.type == "workflow"
|
|
else {}
|
|
),
|
|
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[AssistantOut])
|
|
async def list_assistants(session: AsyncSession = Depends(get_session)):
|
|
rows = (
|
|
await session.execute(select(Assistant).order_by(Assistant.updated_at.desc()))
|
|
).scalars().all()
|
|
return [await _to_out(session, assistant) for assistant in rows]
|
|
|
|
|
|
@router.post("", response_model=AssistantOut)
|
|
async def create_assistant(
|
|
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
|
):
|
|
_validate_workflow(body)
|
|
await _validate_workflow_references(session, body)
|
|
await _validate_vision_model(session, body)
|
|
await _validate_knowledge_base(session, body)
|
|
data = body.model_dump()
|
|
resource_ids = data.pop("model_resource_ids")
|
|
tool_ids = data.pop("tool_ids")
|
|
assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data)
|
|
session.add(assistant)
|
|
await session.flush()
|
|
await _sync_bindings(session, assistant.id, resource_ids)
|
|
await _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids)
|
|
await session.commit()
|
|
await session.refresh(assistant)
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.get("/{assistant_id}", response_model=AssistantOut)
|
|
async def get_assistant(
|
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
assistant = await session.get(Assistant, assistant_id)
|
|
if not assistant:
|
|
raise HTTPException(404, "助手不存在")
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.post("/{assistant_id}/duplicate", response_model=AssistantOut)
|
|
async def duplicate_assistant(
|
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
source = await session.get(Assistant, assistant_id)
|
|
if not source:
|
|
raise HTTPException(404, "助手不存在")
|
|
assistant = Assistant(
|
|
id=f"asst_{uuid.uuid4().hex[:12]}",
|
|
name=f"{source.name} 副本",
|
|
type=source.type,
|
|
runtime_mode=source.runtime_mode,
|
|
greeting=source.greeting,
|
|
enable_interrupt=source.enable_interrupt,
|
|
turn_config=dict(source.turn_config or {}),
|
|
vision_enabled=source.vision_enabled,
|
|
vision_model_resource_id=source.vision_model_resource_id,
|
|
knowledge_base_id=source.knowledge_base_id,
|
|
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
|
prompt=source.prompt,
|
|
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
|
api_url=source.api_url,
|
|
api_key=source.api_key,
|
|
app_id=source.app_id,
|
|
graph=dict(source.graph or {}),
|
|
)
|
|
session.add(assistant)
|
|
await session.flush()
|
|
await _sync_bindings(session, assistant.id, await _resource_ids(session, source.id))
|
|
await _sync_tool_bindings(
|
|
session, assistant.id, assistant.type, await _tool_ids(session, source.id)
|
|
)
|
|
await session.commit()
|
|
await session.refresh(assistant)
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.put("/{assistant_id}", response_model=AssistantOut)
|
|
async def update_assistant(
|
|
assistant_id: str,
|
|
body: AssistantUpsert,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
assistant = await session.get(Assistant, assistant_id)
|
|
if not assistant:
|
|
raise HTTPException(404, "助手不存在")
|
|
_validate_workflow(body)
|
|
await _validate_workflow_references(session, body)
|
|
await _validate_vision_model(session, body)
|
|
await _validate_knowledge_base(session, body)
|
|
data = body.model_dump()
|
|
resource_ids = data.pop("model_resource_ids")
|
|
tool_ids = data.pop("tool_ids")
|
|
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
|
|
for key, value in data.items():
|
|
setattr(assistant, key, value)
|
|
await _sync_bindings(session, assistant.id, resource_ids)
|
|
await _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids)
|
|
await session.commit()
|
|
await session.refresh(assistant)
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.delete("/{assistant_id}")
|
|
async def delete_assistant(
|
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
assistant = await session.get(Assistant, assistant_id)
|
|
if not assistant:
|
|
raise HTTPException(404, "助手不存在")
|
|
await session.delete(assistant)
|
|
await session.commit()
|
|
return {"ok": True}
|