"""Temporary compatibility API for the TaskCOP situation-overview frontend. The Vue page at ``/situation-overview/action/sentiment`` currently talks to a legacy Consumer service. These routes retain that service's paths and response envelopes while persisting data in DeerFlow's own database: * ``POST /cop/saveSuperiorTask`` * ``GET /taskAnalyseSearch/cop-task-three-list`` * ``GET /taskAnalyseSearch/cop-my-tasks`` * ``GET /taskAnalyseSearch/cop-task-detail`` The separately deployed legacy UI signs in through DeerFlow before calling these routes. The Gateway authentication middleware therefore supplies the creator identity, which keeps the "my tasks" endpoint correctly scoped. """ from __future__ import annotations from datetime import datetime from typing import Annotated, Any from fastapi import APIRouter, Body, HTTPException, Query, Request from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, field_validator from deerflow.runtime.user_context import DEFAULT_USER_ID, get_effective_user_id router = APIRouter(tags=["taskcop-compat"]) class SaveSuperiorTaskRequest(BaseModel): """Payload posted by the legacy ``AddTaskDialog`` component.""" model_config = ConfigDict(populate_by_name=True) issuing_dept: str = Field(default="", validation_alias=AliasChoices("issuingDept", "issuing_dept")) overview: str = Field(min_length=1, max_length=50_000) task_direction: str = Field( default="W方向", validation_alias=AliasChoices("taskDirection", "task_direction"), max_length=64 ) task_end_date: datetime | None = Field( default=None, validation_alias=AliasChoices("taskEndDate", "task_end_date") ) task_level: str = Field( default="1", validation_alias=AliasChoices("taskLevel", "task_level"), max_length=32 ) task_name: str = Field( min_length=1, max_length=512, validation_alias=AliasChoices("taskName", "task_name") ) task_no: str = Field(default="", validation_alias=AliasChoices("taskNo", "task_no"), max_length=255) task_start_date: datetime | None = Field( default=None, validation_alias=AliasChoices("taskStartDate", "task_start_date") ) task_status: int = Field(default=0, validation_alias=AliasChoices("taskStatus", "task_status")) task_superior_flag: int = Field( default=0, validation_alias=AliasChoices("taskSuperiorFlag", "task_superior_flag") ) task_type: int = Field(default=1, validation_alias=AliasChoices("taskType", "task_type")) parent_task_id: str | None = Field( default=None, validation_alias=AliasChoices("pTaskId", "parent_task_id"), max_length=64 ) class TaskImportRecord(BaseModel): """One task accepted by the JSON import page. Both the legacy ``taskName/taskContent`` keys and the internal snake_case keys are accepted, so exported task-list files can be uploaded unchanged. """ model_config = ConfigDict(populate_by_name=True) id: str = Field(..., validation_alias=AliasChoices("id", "taskId"), max_length=64) task_name: str = Field( ..., validation_alias=AliasChoices("taskName", "task_name"), min_length=1, max_length=512 ) overview: str = Field( default="", validation_alias=AliasChoices("taskContent", "overview"), max_length=50_000 ) issuing_dept: str = Field(default="", validation_alias=AliasChoices("issuingDept", "issuing_dept")) task_direction: str = Field( default="W方向", validation_alias=AliasChoices("taskDirection", "task_direction"), max_length=64 ) task_end_date: datetime | None = Field( default=None, validation_alias=AliasChoices("taskEndDate", "task_end_date") ) task_level: str = Field( default="1", validation_alias=AliasChoices("taskLevel", "task_level"), max_length=32 ) task_no: str = Field(default="", validation_alias=AliasChoices("taskNo", "task_no"), max_length=255) task_start_date: datetime | None = Field( default=None, validation_alias=AliasChoices("taskStartDate", "task_start_date") ) task_status: int = Field(default=0, validation_alias=AliasChoices("taskStatus", "task_status")) task_superior_flag: int = Field( default=0, validation_alias=AliasChoices("taskSuperiorFlag", "task_superior_flag") ) task_type: int = Field(default=1, validation_alias=AliasChoices("taskType", "task_type")) parent_task_id: str | None = Field( default=None, validation_alias=AliasChoices("pTaskId", "parent_task_id"), max_length=64 ) @field_validator("id", "task_name") @classmethod def strip_required_text(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("must not be blank") return value def _store(request: Request): store = getattr(request.app.state, "taskcop_task_store", None) if store is None: raise HTTPException(status_code=503, detail="TaskCOP task store not available") return store def _request_user_id(request: Request) -> str: user = getattr(request.state, "user", None) if user is not None: return str(user.id) try: return get_effective_user_id() except RuntimeError: return DEFAULT_USER_ID def _parse_statuses(task_status: str | None) -> set[int] | None: if not task_status or not task_status.strip(): return None try: return {int(value.strip()) for value in task_status.split(",") if value.strip()} except ValueError: # The legacy service treats malformed filters as no filter. return None def _bounded_page(page_num: int, page_size: int) -> tuple[int, int]: return max(page_num, 1), min(max(page_size, 1), 100) async def _list_tasks( request: Request, *, mine: bool, content: str, page_num: int, page_size: int, task_status: str | None, task_direction: str | None, start_time: datetime | None, end_time: datetime | None, ) -> dict: page_num, page_size = _bounded_page(page_num, page_size) total, records = await _store(request).list_tasks( created_by=_request_user_id(request) if mine else None, keyword=content, task_direction=task_direction, task_statuses=_parse_statuses(task_status), start_time=start_time, end_time=end_time, page_num=page_num, page_size=page_size, ) return {"state": 200, "msg": "查询成功", "data": {"total": total, "records": records}} TaskListQuery = Annotated[str, Query(max_length=512)] @router.post("/cop/saveSuperiorTask", status_code=201) async def save_superior_task(request: Request, body: SaveSuperiorTaskRequest) -> dict: """Create a task using the legacy ``{state, msg, data}`` response shape.""" row = await _store(request).create_task( { **body.model_dump(), "task_name": body.task_name.strip(), "overview": body.overview.strip(), "created_by": _request_user_id(request), } ) return {"state": 201, "msg": "创建任务成功", "data": row} def _import_task_rows(payload: Any) -> list[Any]: """Accept array, ``{tasks: [...]}``, or legacy list-response envelopes.""" if isinstance(payload, list): rows = payload elif isinstance(payload, dict): data = payload.get("tasks", payload.get("records", payload.get("data", payload))) if isinstance(data, dict): data = data.get("records", data.get("tasks", data)) rows = data if isinstance(data, list) else [data] else: raise HTTPException(status_code=422, detail="任务导入文件必须是 JSON 数组或包含 tasks/records 的对象") if not rows or not all(isinstance(row, dict) for row in rows): raise HTTPException(status_code=422, detail="任务导入文件中至少需要一条任务对象") return rows @router.post("/api/taskcop/import/tasks") async def import_tasks(request: Request, payload: Any = Body(...)) -> dict: """Import tasks by id; a duplicate id atomically replaces that task.""" rows = _import_task_rows(payload) created = 0 updated = 0 records: list[dict[str, Any]] = [] for index, raw in enumerate(rows, start=1): try: item = TaskImportRecord.model_validate(raw) except ValidationError as error: raise HTTPException( status_code=422, detail={"message": f"第 {index} 条任务数据格式不正确", "errors": error.errors()}, ) from error record, inserted = await _store(request).upsert_task( { **item.model_dump(), "task_name": item.task_name, "overview": item.overview, "created_by": _request_user_id(request), } ) records.append(record) created += int(inserted) updated += int(not inserted) return { "state": 200, "msg": "任务导入成功", "data": {"created": created, "updated": updated, "records": records}, } @router.delete("/cop/tasks/{task_id}") async def delete_task(request: Request, task_id: str) -> dict: """Delete a TaskCOP task and its shared report document.""" task_id = task_id.strip() if not task_id: raise HTTPException(status_code=422, detail="task id is required") task_store = _store(request) if await task_store.get_task(task_id) is None: raise HTTPException(status_code=404, detail="task not found") if not await task_store.delete_task(task_id): raise HTTPException(status_code=404, detail="task not found") report_store = getattr(request.app.state, "task_report_store", None) if report_store is not None: await report_store.delete_by_task(task_id) return {"state": 200, "msg": "task deleted", "data": {"id": task_id}} @router.get("/taskAnalyseSearch/cop-task-three-list") async def list_task_three( request: Request, content: TaskListQuery = "", pageNum: int = Query(default=1), pageSize: int = Query(default=10), taskStatus: str | None = Query(default=None), taskDirection: str | None = Query(default=None), startTime: datetime | None = Query(default=None), endTime: datetime | None = Query(default=None), ) -> dict: """List all tasks for the sentiment screen's default "全部" tab.""" return await _list_tasks( request, mine=False, content=content, page_num=pageNum, page_size=pageSize, task_status=taskStatus, task_direction=taskDirection, start_time=startTime, end_time=endTime, ) @router.get("/taskAnalyseSearch/cop-my-tasks") async def list_my_tasks( request: Request, content: TaskListQuery = "", pageNum: int = Query(default=1), pageSize: int = Query(default=10), taskStatus: str | None = Query(default=None), taskDirection: str | None = Query(default=None), startTime: datetime | None = Query(default=None), endTime: datetime | None = Query(default=None), ) -> dict: """List the caller's tasks for the sentiment screen's "我的" tab.""" return await _list_tasks( request, mine=True, content=content, page_num=pageNum, page_size=pageSize, task_status=taskStatus, task_direction=taskDirection, start_time=startTime, end_time=endTime, ) @router.get("/taskAnalyseSearch/cop-task-list") async def list_task_analysis( request: Request, content: TaskListQuery = "", pageNum: int = Query(default=1), pageSize: int = Query(default=10), taskStatus: str | None = Query(default=None), taskDirection: str | None = Query(default=None), startTime: datetime | None = Query(default=None), endTime: datetime | None = Query(default=None), ) -> dict: """Compatibility alias used by the neighbouring task-analysis screen.""" return await _list_tasks( request, mine=False, content=content, page_num=pageNum, page_size=pageSize, task_status=taskStatus, task_direction=taskDirection, start_time=startTime, end_time=endTime, ) @router.get("/taskAnalyseSearch/cop-task-detail") async def get_task_detail(request: Request, taskId: str = Query(min_length=1, max_length=64)) -> dict: """Return a task selected in the list or opened via a task link.""" row = await _store(request).get_task(taskId) if row is None: raise HTTPException(status_code=404, detail="任务不存在") return {"state": 200, "msg": "查询成功", "data": row}