业务链条
This commit is contained in:
1 parent
30410b0a93
commit
9ae6184dc1
20 files changed
+2440
-24
No files matched your search
@@ -42,6 +42,7 @@ from app.gateway.routers import (
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_drafts,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
@@ -853,6 +854,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
|
||||
app.include_router(roundtable_drafts.router)
|
||||
|
||||
# Roundtable-planning business chains at /api/roundtable-chains
|
||||
app.include_router(roundtable_chains.router)
|
||||
|
||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router)
|
||||
|
||||
|
||||
@@ -156,6 +156,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||
|
||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||
|
||||
# Run event store (has its own factory with config-driven backend selection)
|
||||
run_events_config = getattr(config, "run_events", None)
|
||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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")
|
||||
+2
@@ -22,6 +22,7 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
from deerflow.persistence.roundtable_drafts.model import (
|
||||
RoundtableDraftRow,
|
||||
RoundtableRecommendHistoryRow,
|
||||
@@ -49,6 +50,7 @@ __all__ = [
|
||||
"LlmCallMetricRow",
|
||||
"NotificationRow",
|
||||
"RecommendedQuestionRow",
|
||||
"RoundtableChainRow",
|
||||
"RoundtableDraftRow",
|
||||
"RoundtableRecommendHistoryRow",
|
||||
"RunEventRow",
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Roundtable-planning business-chain persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
from deerflow.persistence.roundtable_chains.sql import RoundtableChainRepository
|
||||
|
||||
__all__ = [
|
||||
"RoundtableChainRepository",
|
||||
"RoundtableChainRow",
|
||||
"make_roundtable_chain_store",
|
||||
]
|
||||
|
||||
|
||||
def make_roundtable_chain_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return RoundtableChainRepository(session_factory)
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""ORM model 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 (see ``multi-agent-business-chain-dev.md``).
|
||||
|
||||
Persisted per user (like ``roundtable_drafts``) so it follows the account across
|
||||
devices/sessions. ``seats`` is stored as a JSON array serialized into
|
||||
``PortableLongText`` (MySQL ``LONGTEXT``) rather than ``PortableJSON`` for
|
||||
headroom, mirroring the sibling roundtable persistence modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class RoundtableChainRow(Base):
|
||||
__tablename__ = "roundtable_chains"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
# Human-readable chain title shown on the picker card.
|
||||
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
# Optional free-text description.
|
||||
description: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
# JSON: ordered [{"agent_id": str, "name": str, "description": str}, ...].
|
||||
# seats[0] is the first seat to speak. Stored as LONGTEXT-backed JSON string.
|
||||
seats: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"""SQLAlchemy-backed roundtable business-chain store.
|
||||
|
||||
The ``seats`` JSON column is stored as a serialized string in
|
||||
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
|
||||
— same approach as ``roundtable_drafts`` / ``ai_writing_sessions``.
|
||||
|
||||
All write/read paths are scoped by ``user_id`` for ownership isolation; a row
|
||||
that belongs to another user is treated as not found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
class RoundtableChainRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"description": row.description,
|
||||
"seats": _loads(row.seats) or [],
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(RoundtableChainRow)
|
||||
.where(RoundtableChainRow.user_id == user_id)
|
||||
.order_by(RoundtableChainRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return self._to_dict(row)
|
||||
|
||||
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableChainRow(
|
||||
id=data["id"],
|
||||
user_id=user_id,
|
||||
title=(data.get("title") or "").strip(),
|
||||
description=(data.get("description") or None),
|
||||
seats=_dumps(data.get("seats") or []),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def update_chain(
|
||||
self,
|
||||
chain_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
title=_UNSET,
|
||||
description=_UNSET,
|
||||
seats=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
if title is not _UNSET and title is not None:
|
||||
row.title = str(title).strip()
|
||||
if description is not _UNSET:
|
||||
row.description = description or None
|
||||
if seats is not _UNSET:
|
||||
row.seats = _dumps(seats)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
Reference in new issue
Block a user