新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。 Co-authored-by: Cursor <cursoragent@cursor.com>
224 lines
8.1 KiB
Python
224 lines
8.1 KiB
Python
"""CRUD API for roundtable-planning drafts + recommendation history.
|
|
|
|
A draft is one full roundtable session (Step 1 intent + Step 2 multi-agent
|
|
roundtable). Bound to the requesting user (not the browser), so it follows the
|
|
account across devices/sessions — replacing the old ``localStorage`` storage.
|
|
|
|
Recommendation history is bound to a draft: the recommend dialog shows "last
|
|
recommendation for this session" on open, while still letting the user
|
|
re-analyze (which appends a new history row).
|
|
|
|
Routes (prefix ``/api/roundtable-drafts``):
|
|
GET "" list current user's drafts (lightweight)
|
|
POST "" create a draft
|
|
GET "/{draft_id}" full draft (incl. step1 / step2 blobs)
|
|
PUT "/{draft_id}" partial update (title / step / snapshots)
|
|
DELETE "/{draft_id}" delete
|
|
GET "/{draft_id}/recommendations" list recommendation history
|
|
POST "/{draft_id}/recommendations" append one recommendation run
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
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-drafts", tags=["roundtable-drafts"])
|
|
|
|
|
|
# ── schemas ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class DraftMetaResponse(BaseModel):
|
|
id: str
|
|
title: str = ""
|
|
furthest_step: int = 1
|
|
created_at: datetime | str | None = None
|
|
updated_at: datetime | str | None = None
|
|
|
|
|
|
class DraftListResponse(BaseModel):
|
|
drafts: list[DraftMetaResponse]
|
|
|
|
|
|
class DraftResponse(DraftMetaResponse):
|
|
step1: dict[str, Any] | None = None
|
|
step2: dict[str, Any] | None = None
|
|
|
|
|
|
class DraftCreateRequest(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)
|
|
furthest_step: int = Field(default=1, ge=1, le=3)
|
|
step1: dict[str, Any] | None = None
|
|
step2: dict[str, Any] | None = None
|
|
|
|
|
|
class DraftUpdateRequest(BaseModel):
|
|
# All optional → callers send only what changed. Auto-save omits ``title``
|
|
# so it never clobbers a manual rename.
|
|
title: str | None = Field(default=None, max_length=512)
|
|
furthest_step: int | None = Field(default=None, ge=1, le=3)
|
|
step1: dict[str, Any] | None = None
|
|
step2: dict[str, Any] | None = None
|
|
|
|
|
|
class RecommendPickSchema(BaseModel):
|
|
agent_id: str
|
|
reason: str = ""
|
|
|
|
|
|
class RecommendCandidateSchema(BaseModel):
|
|
agent_id: str
|
|
name: str = ""
|
|
description: str = ""
|
|
|
|
|
|
class RecommendHistoryResponse(BaseModel):
|
|
id: str
|
|
draft_id: str
|
|
objective: str = ""
|
|
status: str = "done"
|
|
model: str | None = None
|
|
rationale: str | None = None
|
|
picks: list[RecommendPickSchema] = []
|
|
candidates: list[RecommendCandidateSchema] = []
|
|
created_at: datetime | str | None = None
|
|
|
|
|
|
class RecommendHistoryListResponse(BaseModel):
|
|
history: list[RecommendHistoryResponse]
|
|
|
|
|
|
class RecommendHistoryCreateRequest(BaseModel):
|
|
objective: str = Field(default="", max_length=512)
|
|
status: str = Field(default="done", max_length=32)
|
|
model: str | None = None
|
|
rationale: str | None = None
|
|
picks: list[RecommendPickSchema] = []
|
|
candidates: list[RecommendCandidateSchema] = []
|
|
|
|
|
|
# ── 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_draft_store", None)
|
|
if store is None:
|
|
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
|
|
return store
|
|
|
|
|
|
# ── draft routes ───────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("", response_model=DraftListResponse)
|
|
async def list_drafts(request: Request) -> DraftListResponse:
|
|
store = _get_store(request)
|
|
user_id = _current_user_id(request)
|
|
rows = await store.list_drafts(user_id)
|
|
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
|
|
|
|
|
|
@router.post("", response_model=DraftResponse, status_code=201)
|
|
async def create_draft(request: Request, body: DraftCreateRequest) -> DraftResponse:
|
|
store = _get_store(request)
|
|
user_id = _current_user_id(request)
|
|
row = await store.create_draft(
|
|
user_id,
|
|
{
|
|
"id": (body.id or uuid4().hex),
|
|
"title": body.title.strip(),
|
|
"furthest_step": body.furthest_step,
|
|
"step1": body.step1,
|
|
"step2": body.step2,
|
|
},
|
|
)
|
|
return DraftResponse(**row)
|
|
|
|
|
|
@router.get("/{draft_id}", response_model=DraftResponse)
|
|
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
|
|
store = _get_store(request)
|
|
user_id = _current_user_id(request)
|
|
row = await store.get_draft(draft_id, user_id)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Draft not found")
|
|
return DraftResponse(**row)
|
|
|
|
|
|
@router.put("/{draft_id}", response_model=DraftResponse)
|
|
async def update_draft(request: Request, draft_id: str, body: DraftUpdateRequest) -> DraftResponse:
|
|
store = _get_store(request)
|
|
user_id = _current_user_id(request)
|
|
# Build kwargs that distinguish "omitted" from "explicitly None". The store
|
|
# uses a sentinel; here we pass only the fields the client actually sent.
|
|
fields = body.model_dump(exclude_unset=True)
|
|
row = await store.update_draft(draft_id, user_id, **fields)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Draft not found")
|
|
return DraftResponse(**row)
|
|
|
|
|
|
@router.delete("/{draft_id}", status_code=204)
|
|
async def delete_draft(request: Request, draft_id: str) -> None:
|
|
store = _get_store(request)
|
|
user_id = _current_user_id(request)
|
|
deleted = await store.delete_draft(draft_id, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Draft not found")
|
|
|
|
|
|
# ── recommendation history routes ────────────────────────────────────────
|
|
|
|
|
|
@router.get("/{draft_id}/recommendations", response_model=RecommendHistoryListResponse)
|
|
async def list_recommendations(request: Request, draft_id: str) -> RecommendHistoryListResponse:
|
|
store = _get_store(request)
|
|
user_id = _current_user_id(request)
|
|
rows = await store.list_recommendations(draft_id, user_id)
|
|
return RecommendHistoryListResponse(history=[RecommendHistoryResponse(**r) for r in rows])
|
|
|
|
|
|
@router.post("/{draft_id}/recommendations", response_model=RecommendHistoryResponse, status_code=201)
|
|
async def add_recommendation(
|
|
request: Request, draft_id: str, body: RecommendHistoryCreateRequest
|
|
) -> RecommendHistoryResponse:
|
|
store = _get_store(request)
|
|
user_id = _current_user_id(request)
|
|
# Ownership: the draft must exist and belong to the caller.
|
|
draft = await store.get_draft(draft_id, user_id)
|
|
if draft is None:
|
|
raise HTTPException(status_code=404, detail="Draft not found")
|
|
row = await store.add_recommendation(
|
|
user_id,
|
|
draft_id,
|
|
{
|
|
"id": uuid4().hex,
|
|
"objective": body.objective,
|
|
"status": body.status,
|
|
"model": body.model,
|
|
"rationale": body.rationale,
|
|
"picks": [p.model_dump() for p in body.picks],
|
|
"candidates": [c.model_dump() for c in body.candidates],
|
|
},
|
|
)
|
|
return RecommendHistoryResponse(**row)
|