163 lines
6.0 KiB
Python
163 lines
6.0 KiB
Python
"""CRUD API for roundtable-planning business chains (业务链条).
|
||
|
||
A business chain is a named, reusable, *ordered* list of agents the user
|
||
assembles in the chain editor. In Step 2 the coordinator dispatches to the seats
|
||
following this order. Bound to the requesting user (not the browser), so it
|
||
follows the account across devices/sessions — same model as roundtable drafts.
|
||
|
||
Routes (prefix ``/api/roundtable-chains``):
|
||
GET "" list current user's chains (full records, incl. seats)
|
||
POST "" create a chain
|
||
GET "/{chain_id}" one chain
|
||
PUT "/{chain_id}" partial update (title / description / seats)
|
||
DELETE "/{chain_id}" delete
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from uuid import uuid4
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from pydantic import BaseModel, Field
|
||
|
||
from deerflow.runtime.user_context import get_effective_user_id
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
|
||
|
||
# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8).
|
||
MIN_SEATS = 2
|
||
MAX_SEATS = 8
|
||
|
||
|
||
# ── schemas ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class ChainSeatSchema(BaseModel):
|
||
agent_id: str = Field(min_length=1, max_length=128)
|
||
name: str = ""
|
||
description: str = ""
|
||
|
||
|
||
class ChainResponse(BaseModel):
|
||
id: str
|
||
title: str = ""
|
||
description: str | None = None
|
||
seats: list[ChainSeatSchema] = []
|
||
created_at: datetime | str | None = None
|
||
updated_at: datetime | str | None = None
|
||
|
||
|
||
class ChainListResponse(BaseModel):
|
||
chains: list[ChainResponse]
|
||
|
||
|
||
class ChainCreateRequest(BaseModel):
|
||
# Optional client-provided id so the browser can keep one stable id across
|
||
# the create + subsequent updates without an extra round-trip.
|
||
id: str | None = Field(default=None, max_length=64)
|
||
title: str = Field(default="", max_length=512)
|
||
description: str | None = Field(default=None, max_length=2048)
|
||
seats: list[ChainSeatSchema] = []
|
||
|
||
|
||
class ChainUpdateRequest(BaseModel):
|
||
# All optional → callers send only what changed.
|
||
title: str | None = Field(default=None, max_length=512)
|
||
description: str | None = Field(default=None, max_length=2048)
|
||
seats: list[ChainSeatSchema] | None = None
|
||
|
||
|
||
# ── helpers ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _current_user_id(request: Request) -> str:
|
||
user = getattr(request.state, "user", None)
|
||
if user is not None:
|
||
return str(user.id)
|
||
return get_effective_user_id()
|
||
|
||
|
||
def _get_store(request: Request):
|
||
store = getattr(request.app.state, "roundtable_chain_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="Roundtable chain store not available")
|
||
return store
|
||
|
||
|
||
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
|
||
"""Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain."""
|
||
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"业务链条席位数须在 {MIN_SEATS}–{MAX_SEATS} 之间(当前 {len(seats)})",
|
||
)
|
||
seen: set[str] = set()
|
||
for s in seats:
|
||
if s.agent_id in seen:
|
||
raise HTTPException(status_code=400, detail=f"业务链条存在重复智能体: {s.agent_id}")
|
||
seen.add(s.agent_id)
|
||
|
||
|
||
# ── routes ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.get("", response_model=ChainListResponse)
|
||
async def list_chains(request: Request) -> ChainListResponse:
|
||
store = _get_store(request)
|
||
user_id = _current_user_id(request)
|
||
rows = await store.list_chains(user_id)
|
||
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
|
||
|
||
|
||
@router.post("", response_model=ChainResponse, status_code=201)
|
||
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
|
||
_validate_seats(body.seats)
|
||
store = _get_store(request)
|
||
user_id = _current_user_id(request)
|
||
row = await store.create_chain(
|
||
user_id,
|
||
{
|
||
"id": (body.id or uuid4().hex),
|
||
"title": body.title.strip(),
|
||
"description": body.description,
|
||
"seats": [s.model_dump() for s in body.seats],
|
||
},
|
||
)
|
||
return ChainResponse(**row)
|
||
|
||
|
||
@router.get("/{chain_id}", response_model=ChainResponse)
|
||
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
|
||
store = _get_store(request)
|
||
user_id = _current_user_id(request)
|
||
row = await store.get_chain(chain_id, user_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Chain not found")
|
||
return ChainResponse(**row)
|
||
|
||
|
||
@router.put("/{chain_id}", response_model=ChainResponse)
|
||
async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse:
|
||
if body.seats is not None:
|
||
_validate_seats(body.seats)
|
||
store = _get_store(request)
|
||
user_id = _current_user_id(request)
|
||
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
||
fields = body.model_dump(exclude_unset=True)
|
||
row = await store.update_chain(chain_id, user_id, **fields)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Chain not found")
|
||
return ChainResponse(**row)
|
||
|
||
|
||
@router.delete("/{chain_id}", status_code=204)
|
||
async def delete_chain(request: Request, chain_id: str) -> None:
|
||
store = _get_store(request)
|
||
user_id = _current_user_id(request)
|
||
deleted = await store.delete_chain(chain_id, user_id)
|
||
if not deleted:
|
||
raise HTTPException(status_code=404, detail="Chain not found")
|