205 lines
8.5 KiB
Python
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="报告结构不存在")
|