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

205 lines
8.5 KiB
Python

"""CRUD API for deep-research report structures (报告结构 / 工作流配置).
The deep-research workbench's 高级配置 → 工作流配置 dialog renders a card per
row; picking a card applies ``content`` to the research config's
``custom_outline``. Global / shared — every account sees the same cards.
Routes (prefix ``/api/report-structures``):
GET "" list (optional ``enabled`` / ``title`` filters)
POST "" create (title / name / content / structure_mode /
retrieval_directions / retrieval_skills;
enabled forced True)
GET "/{id}" get one
PUT "/{id}" update title / name / content / structure_mode /
retrieval_directions / retrieval_skills
PUT "/{id}/enabled" toggle 启用/停用
DELETE "/{id}" delete
The first list call seeds the two built-in outlines (通用分析结构 /
递进式论证结构) when the table has never held a row — see
``deerflow.persistence.report_structures.defaults``.
"""
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.persistence.report_structures import ReportStructureStore
from deerflow.persistence.report_structures.directions import normalize_retrieval_directions
from deerflow.persistence.report_structures.skills import normalize_retrieval_skills
from deerflow.persistence.report_structures.modes import (
DEFAULT_STRUCTURE_MODE,
StructureMode,
normalize_structure_mode,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/report-structures", tags=["report-structures"])
# ── schemas ──────────────────────────────────────────────────────────────
class ReportStructureResponse(BaseModel):
id: str
title: str
name: str
content: str
structure_mode: StructureMode = DEFAULT_STRUCTURE_MODE
retrieval_directions: list[str] = []
retrieval_skills: list[str] = []
enabled: bool = True
created_by: str | None = None
created_at: str | None = None
updated_at: str | None = None
class ReportStructureListResponse(BaseModel):
structures: list[ReportStructureResponse]
class ReportStructureCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
name: str = Field(default="", max_length=256)
content: str = Field(..., min_length=1, max_length=20_000)
structure_mode: StructureMode = DEFAULT_STRUCTURE_MODE
retrieval_directions: list[str] = Field(default_factory=list)
retrieval_skills: list[str] = Field(default_factory=list)
class ReportStructureUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=256)
name: str | None = Field(default=None, max_length=256)
content: str | None = Field(default=None, min_length=1, max_length=20_000)
structure_mode: StructureMode | None = None
retrieval_directions: list[str] | None = None
retrieval_skills: list[str] | None = None
class ReportStructureEnabledRequest(BaseModel):
enabled: bool
# ── helpers ──────────────────────────────────────────────────────────────
def _to_response(row: dict) -> ReportStructureResponse:
data = dict(row)
data["structure_mode"] = normalize_structure_mode(data.get("structure_mode"))
data["retrieval_directions"] = normalize_retrieval_directions(data.get("retrieval_directions"))
data["retrieval_skills"] = normalize_retrieval_skills(data.get("retrieval_skills"))
return ReportStructureResponse(**data)
def _get_store(request: Request) -> ReportStructureStore:
store = getattr(request.app.state, "report_structure_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Report structure store not available")
return store
async def _current_username(request: Request) -> str | None:
"""创建用户名:邮箱(本部署的账号即邮箱),无认证时为 None。"""
user = await get_optional_user_from_request(request)
if user is None:
return None
return str(user.email) or str(user.id)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ReportStructureListResponse)
async def list_report_structures(
request: Request,
enabled: bool | None = Query(default=None, description="启用/停用筛选;缺省=全部"),
title: str | None = Query(default=None, max_length=256, description="标题模糊筛选(大小写不敏感)"),
) -> ReportStructureListResponse:
store = _get_store(request)
await store.ensure_defaults()
rows = await store.list_structures()
if enabled is not None:
rows = [r for r in rows if bool(r.get("enabled", True)) is enabled]
if title:
needle = title.strip().lower()
if needle:
rows = [r for r in rows if needle in str(r.get("title", "")).lower()]
return ReportStructureListResponse(structures=[_to_response(r) for r in rows])
@router.post("", response_model=ReportStructureResponse, status_code=201)
async def create_report_structure(request: Request, body: ReportStructureCreateRequest) -> ReportStructureResponse:
store = _get_store(request)
row = await store.create_structure(
{
"id": str(uuid4()),
"title": body.title.strip(),
"name": body.name.strip(),
"content": body.content.strip(),
"structure_mode": normalize_structure_mode(body.structure_mode),
"retrieval_directions": normalize_retrieval_directions(body.retrieval_directions),
"retrieval_skills": normalize_retrieval_skills(body.retrieval_skills),
"created_by": await _current_username(request),
}
)
return _to_response(row)
@router.get("/{structure_id}", response_model=ReportStructureResponse)
async def get_report_structure(request: Request, structure_id: str) -> ReportStructureResponse:
store = _get_store(request)
row = await store.get_structure(structure_id)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return _to_response(row)
@router.put("/{structure_id}", response_model=ReportStructureResponse)
async def update_report_structure(
request: Request, structure_id: str, body: ReportStructureUpdateRequest
) -> ReportStructureResponse:
store = _get_store(request)
data: dict = {}
if body.title is not None:
data["title"] = body.title.strip()
if body.name is not None:
data["name"] = body.name.strip()
if body.content is not None:
data["content"] = body.content.strip()
if body.structure_mode is not None:
data["structure_mode"] = normalize_structure_mode(body.structure_mode)
if body.retrieval_directions is not None:
data["retrieval_directions"] = normalize_retrieval_directions(body.retrieval_directions)
if body.retrieval_skills is not None:
data["retrieval_skills"] = normalize_retrieval_skills(body.retrieval_skills)
if not data:
raise HTTPException(status_code=400, detail="没有需要更新的字段")
row = await store.update_structure(structure_id, data)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return _to_response(row)
@router.put("/{structure_id}/enabled", response_model=ReportStructureResponse)
async def set_report_structure_enabled(
request: Request, structure_id: str, body: ReportStructureEnabledRequest
) -> ReportStructureResponse:
store = _get_store(request)
row = await store.set_enabled(structure_id, body.enabled)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return _to_response(row)
@router.delete("/{structure_id}", status_code=204)
async def delete_report_structure(request: Request, structure_id: str) -> None:
store = _get_store(request)
deleted = await store.delete_structure(structure_id)
if not deleted:
raise HTTPException(status_code=404, detail="报告结构不存在")