"""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="报告结构不存在")