Files
2026-06-02 21:15:46 +08:00

163 lines
6.0 KiB
Python
Raw Permalink 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.
"""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")