deerflow-code/offline-backend-20260512/backend/app/gateway/routers/workflow_planning.py
2026-09-07 18:24:55 +08:00

462 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Conversation-driven candidate workflow planning APIs.
The routes in this module intentionally stop before execution. A user can
select and edit a proposed graph here; ``workflow_runs.start_run`` is the only
place that turns a confirmed proposal into a durable run.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from app.gateway.services import format_sse
from app.gateway.workflow_proposal_planner import (
WorkflowPlannerUnavailableError,
build_workflow_proposals,
)
from app.gateway.workflow_resource_checks import build_resource_catalog, make_resource_checker
from deerflow.config.app_config import get_app_config
from deerflow.persistence.workflow_planning import (
WorkflowPlanningConflictError,
WorkflowPlanningProposalNotFoundError,
WorkflowPlanningSessionNotFoundError,
WorkflowPlanningStore,
)
from deerflow.workflows.schemas import WorkflowGraph
from deerflow.workflows.validator import validate_workflow_graph
router = APIRouter(prefix="/api/workflows", tags=["workflow-planning"])
PlanningProgressEmitter = Callable[[dict[str, Any]], Awaitable[None]]
# A small, valid SSE comment after a real frame makes the response observable
# immediately through development proxies as well as nginx. Some proxies hold
# sub-kilobyte chunks until the controller has finished, which makes a genuine
# progress stream appear to be a single JSON response. Comments are ignored
# by SSE consumers and never become business progress in the UI.
_SSE_FLUSH_PADDING = " " * 2_048
_SSE_HEARTBEAT_SECONDS = 1.0
class CreatePlanningSessionBody(BaseModel):
query: str = Field(min_length=1, max_length=12_000)
model_name: str | None = Field(default=None, alias="modelName", max_length=191)
model_config = {"populate_by_name": True}
class UpdateProposalGraphBody(BaseModel):
graph: dict[str, Any]
canvas_schema_json: str | None = Field(default=None, alias="canvasSchemaJson", max_length=5_000_000)
expected_revision: int = Field(alias="expectedRevision", ge=0)
model_config = {"populate_by_name": True}
def _cfg():
return get_app_config().workflows
def _require_enabled() -> None:
if not _cfg().enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
def _selected_model_name(value: str | None) -> str | None:
model_name = str(value or "").strip()
if not model_name:
return None
if get_app_config().get_model_config(model_name) is None:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_MODEL_INVALID", "message": "所选模型不存在或已不可用"},
)
return model_name
def _store(request: Request) -> WorkflowPlanningStore:
store = getattr(request.app.state, "workflow_planning_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow planning store not available")
return store
def _definitions(request: Request):
store = getattr(request.app.state, "workflow_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow store not available")
return store
async def _is_admin(request: Request) -> bool:
user = await get_optional_user_from_request(request)
return user is not None and getattr(user, "system_role", None) == "admin"
async def _owned_session(request: Request, session_id: str) -> dict[str, Any]:
session = await _store(request).get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PLANNING_NOT_FOUND", "message": "规划会话不存在"})
user_id = await get_current_user(request)
if session.get("owner_id") != user_id and not await _is_admin(request):
raise HTTPException(status_code=403, detail={"code": "WORKFLOW_FORBIDDEN", "message": "无权访问该规划会话"})
return session
def _public_proposal(proposal: dict[str, Any]) -> dict[str, Any]:
graph = proposal.get("graph") if isinstance(proposal.get("graph"), dict) else {}
nodes = graph.get("nodes") if isinstance(graph.get("nodes"), list) else []
roles: list[dict[str, str]] = []
steps: list[str] = []
for node in nodes:
if not isinstance(node, dict):
continue
kind = str(node.get("type") or "")
name = str(node.get("name") or kind or "节点").strip()
if kind not in {"start", "output"} and name:
steps.append(name)
if kind not in {"agent", "skill"}:
continue
config = node.get("config") if isinstance(node.get("config"), dict) else {}
roles.append(
{
"nodeId": str(node.get("id") or ""),
"name": name or "智能体",
"kind": kind,
"agentId": str(config.get("agentId") or config.get("agent_id") or "default"),
}
)
return {
"proposalId": proposal["id"],
"position": proposal["position"],
"status": proposal["status"],
"strategy": proposal["strategy"],
"title": proposal["title"],
"summary": proposal.get("summary") or "",
"rationale": proposal.get("rationale") or "",
"estimatedDurationSeconds": proposal.get("estimated_duration_seconds"),
"graph": graph,
"canvasSchemaJson": proposal.get("canvas_schema") or "{}",
"validation": proposal.get("validation") or {},
"graphRevision": int(proposal.get("graph_revision") or 0),
"updatedAt": proposal.get("updated_at"),
# Presentation only: derived afresh from the executable graph so it
# can never become a second, stale planning authority.
"roles": roles,
"steps": steps[:12],
}
def _public_session(session: dict[str, Any]) -> dict[str, Any]:
return {
"planningSessionId": session["id"],
"workflowId": session["workflow_id"],
"query": session["query"],
"status": session["status"],
"sourceDraftRevision": session.get("source_draft_revision"),
"selectedProposalId": session.get("selected_proposal_id"),
"confirmedRunId": session.get("confirmed_run_id"),
"createdAt": session.get("created_at"),
"updatedAt": session.get("updated_at"),
"proposals": [_public_proposal(proposal) for proposal in session.get("proposals") or []],
}
async def _validation_payload(
request: Request,
*,
workflow_id: str,
user_id: str,
raw_graph: dict[str, Any],
) -> tuple[dict[str, Any], WorkflowGraph | None]:
try:
graph = WorkflowGraph.model_validate(raw_graph)
except Exception as exc: # malformed edits are retained with a readable card error
return (
{
"valid": False,
"issues": [
{
"code": "WORKFLOW_SCHEMA_INVALID",
"message": f"执行图不符合 WorkflowGraph v1.0: {exc}",
}
],
},
None,
)
catalog = await build_resource_catalog(request, user_id=user_id)
checker = make_resource_checker(
catalog,
config=_cfg(),
current_workflow_id=workflow_id,
)
issues = validate_workflow_graph(
graph,
resource_checker=checker,
system_max_steps=_cfg().max_steps,
system_max_loop_iterations=_cfg().max_loop_iterations,
system_max_parallelism=_cfg().max_parallelism,
)
return (
{
"valid": not issues,
"issues": [issue.model_dump(by_alias=True) for issue in issues],
},
graph,
)
async def _create_planning_session(
request: Request,
workflow_id: str,
body: CreatePlanningSessionBody,
*,
on_progress: PlanningProgressEmitter | None = None,
) -> dict[str, Any]:
"""Plan 2–3 editable, executable candidate flows from the current draft."""
_require_enabled()
user_id = await get_current_user(request)
definition = await _definitions(request).get_definition(workflow_id, include_draft=True)
if definition is None:
raise HTTPException(status_code=404, detail="工作流不存在")
if definition.get("owner_id") != user_id and not await _is_admin(request):
raise HTTPException(status_code=403, detail={"code": "WORKFLOW_FORBIDDEN", "message": "无权规划该工作流"})
raw_graph = definition.get("draft_graph") or {}
try:
source_graph = WorkflowGraph.model_validate(raw_graph)
except Exception as exc:
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_SCHEMA_INVALID",
"message": f"当前画布没有可规划的执行图:{exc}",
},
) from exc
try:
model_name = _selected_model_name(body.model_name)
proposals = await build_workflow_proposals(
request.app,
source_graph=source_graph,
query=body.query.strip(),
owner_id=user_id,
model_name=model_name,
source_canvas_schema=str(definition.get("draft_canvas_schema") or "{}"),
on_progress=on_progress,
)
except WorkflowPlannerUnavailableError as exc:
raise HTTPException(
status_code=503,
detail={"code": "WORKFLOW_PLANNER_UNAVAILABLE", "message": str(exc)},
) from exc
if not proposals:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_PLANNING_EMPTY", "message": "当前画布无法生成候选流程,请至少保留开始和可执行节点"},
)
if on_progress is not None:
await on_progress(
{
"phase": "validation",
"status": "running",
"message": "正在校验候选流程的节点、连线和权限。",
}
)
for proposal in proposals:
validation, _ = await _validation_payload(
request,
workflow_id=workflow_id,
user_id=user_id,
raw_graph=proposal["graph"],
)
proposal["validation"] = validation
session = await _store(request).create_session(
{
"workflow_id": workflow_id,
"owner_id": user_id,
"query": body.query.strip(),
"source_draft_revision": definition.get("draft_revision"),
"proposals": proposals,
}
)
public = _public_session(session)
if on_progress is not None:
await on_progress(
{
"phase": "validation",
"status": "completed",
"message": "候选流程校验完成。",
}
)
await on_progress(
{
"phase": "complete",
"status": "completed",
"message": f"已生成 {len(public['proposals'])} 条可编辑候选流程。",
}
)
return public
@router.post("/{workflow_id}/planning-sessions/stream")
async def create_planning_session_stream(
request: Request,
workflow_id: str,
body: CreatePlanningSessionBody,
) -> StreamingResponse:
"""Stream the real DeerFlow controller milestones, then one final session."""
def flush_frame(event: str, payload: dict[str, Any]) -> str:
return f"{format_sse(event, payload)}: stream-flush{_SSE_FLUSH_PADDING}\n\n"
def heartbeat_frame() -> str:
return f": keepalive{_SSE_FLUSH_PADDING}\n\n"
async def event_stream():
queue: asyncio.Queue[tuple[str, dict[str, Any] | None]] = asyncio.Queue()
async def emit_progress(payload: dict[str, Any]) -> None:
await queue.put(("progress", payload))
async def create() -> None:
try:
session = await _create_planning_session(
request,
workflow_id,
body,
on_progress=emit_progress,
)
await queue.put(("result", session))
except HTTPException as exc:
detail = exc.detail
if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("detail") or "工作流规划失败")
code = str(detail.get("code") or f"http_{exc.status_code}")
else:
message = str(detail or "工作流规划失败")
code = f"http_{exc.status_code}"
await queue.put(("error", {"code": code, "message": message}))
except Exception: # noqa: BLE001 - SSE requires a terminal error frame
await queue.put(("error", {"code": "WORKFLOW_PLANNING_FAILED", "message": "工作流规划失败,请稍后重试"}))
finally:
await queue.put(("done", None))
try:
# This is a transport milestone, not a fabricated model phase. It
# gives the user immediate confirmation that their request reached
# the server before the first catalog/controller callback arrives.
yield flush_frame(
"progress",
{
"phase": "connection",
"status": "running",
"message": "已接收需求,正在连接工作流总控。",
},
)
task = asyncio.create_task(create())
while True:
try:
event, payload = await asyncio.wait_for(
queue.get(),
timeout=_SSE_HEARTBEAT_SECONDS,
)
except TimeoutError:
# Keep an otherwise quiet model connection visible and
# prevent an intermediary from buffering the next real
# controller milestone.
yield heartbeat_frame()
continue
if event == "done":
break
yield flush_frame(event, payload or {})
finally:
if "task" in locals() and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/{workflow_id}/planning-sessions")
async def create_planning_session(
request: Request,
workflow_id: str,
body: CreatePlanningSessionBody,
) -> dict[str, Any]:
"""Non-streaming compatibility endpoint for existing integrations."""
return await _create_planning_session(request, workflow_id, body)
@router.get("/planning-sessions/{session_id}")
async def get_planning_session(request: Request, session_id: str) -> dict[str, Any]:
_require_enabled()
return _public_session(await _owned_session(request, session_id))
@router.post("/planning-sessions/{session_id}/proposals/{proposal_id}/select")
async def select_planning_proposal(request: Request, session_id: str, proposal_id: str) -> dict[str, Any]:
_require_enabled()
await _owned_session(request, session_id)
try:
session = await _store(request).select_proposal(session_id, proposal_id)
except WorkflowPlanningProposalNotFoundError as exc:
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PROPOSAL_NOT_FOUND", "message": "候选流程不存在"}) from exc
except WorkflowPlanningConflictError as exc:
raise HTTPException(status_code=409, detail={"code": "WORKFLOW_PLANNING_CONFLICT", "message": str(exc)}) from exc
return _public_session(session)
@router.patch("/planning-sessions/{session_id}/proposals/{proposal_id}/graph")
async def update_planning_proposal_graph(
request: Request,
session_id: str,
proposal_id: str,
body: UpdateProposalGraphBody,
) -> dict[str, Any]:
_require_enabled()
session = await _owned_session(request, session_id)
user_id = await get_current_user(request)
validation, _ = await _validation_payload(
request,
workflow_id=session["workflow_id"],
user_id=user_id,
raw_graph=body.graph,
)
try:
updated = await _store(request).update_proposal(
session_id,
proposal_id,
graph=body.graph,
canvas_schema=body.canvas_schema_json,
validation=validation,
expected_revision=body.expected_revision,
)
except WorkflowPlanningSessionNotFoundError as exc:
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PLANNING_NOT_FOUND", "message": "规划会话不存在"}) from exc
except WorkflowPlanningProposalNotFoundError as exc:
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_PROPOSAL_NOT_FOUND", "message": "候选流程不存在"}) from exc
except WorkflowPlanningConflictError as exc:
detail: dict[str, Any] = {"code": "WORKFLOW_PLANNING_CONFLICT", "message": str(exc)}
if exc.current_revision is not None:
detail["currentRevision"] = exc.current_revision
raise HTTPException(status_code=409, detail=detail) from exc
return _public_session(updated)
__all__ = ["router"]