Files
董宏凯andClaude Opus 4.8 7748661963 feat(roundtable): 历史卡片显示所用业务链(自由模式) + 诊断只收报错日志
- 历史「分析记录」卡片/侧边栏列表:把「第X步」改为本次会商所用业务链名,
  无链条显示「自由模式」。后端 list 轻量 meta 从 step2 正则抽取 chain_title
  (不整体解析大块 JSON),个人草稿 + task 草稿两个 store 都覆盖。
- 会商诊断改为只收集报错日志:record_diagnostic 写库前丢弃 level==info
  运行日志(begin/consensus/gather/job_started),只留 warning/error。
  单点收口覆盖前台/后台/recorder 全部写路径;诊断页标题改「会商报错日志」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:36:31 +08:00

190 lines
7.4 KiB
Python

"""CRUD API for **task-scoped** roundtable-planning drafts (聊天记录).
This is the dedicated store for roundtable sessions opened via a taskId deep-link
(无界嵌入抽屉). It is physically and semantically separate from the per-user
``/api/roundtable-drafts`` store:
* records are keyed **only by ``task_id``** (one task → many records);
* there is **no user 分权** — any authenticated caller may read/write/delete a
task's records (the deep-link audience is shared); ``created_by`` is recorded
for audit only.
Routes (prefix ``/api/roundtable-task-drafts``):
GET "/by-task/{task_id}" list all drafts for a task (lightweight)
GET "/by-task/{task_id}/latest" newest draft for a task (full)
POST "" create (``task_id`` required)
GET "/{draft_id}" full draft (incl. step1/step2/step3)
PUT "/{draft_id}" partial update
DELETE "/{draft_id}" delete
"""
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-task-drafts", tags=["roundtable-task-drafts"])
# ── schemas ────────────────────────────────────────────────────────────────
class TaskDraftMetaResponse(BaseModel):
id: str
task_id: str
title: str = ""
furthest_step: int = 1
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
chain_title: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
# Latest background-job status for this draft (history-dropdown loading badge).
status: str | None = None
job_id: str | None = None
class TaskDraftListResponse(BaseModel):
drafts: list[TaskDraftMetaResponse]
class TaskDraftResponse(TaskDraftMetaResponse):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class TaskDraftCreateRequest(BaseModel):
id: str | None = Field(default=None, max_length=64)
task_id: str = Field(max_length=128)
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
step3: dict[str, Any] | None = None
class TaskDraftUpdateRequest(BaseModel):
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
step3: dict[str, Any] | None = None
# ── 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_task_draft_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable task draft store not available")
return store
async def _enrich_status(request: Request, rows: list[dict[str, Any]]) -> None:
"""Attach the latest background-job status/jobId per draft (best-effort).
Uses the job store's draft-id keyed lookup (no user filter) — a task's drafts
span users. Failure or a missing job store silently degrades to no status.
"""
job_store = getattr(request.app.state, "roundtable_job_store", None)
if job_store is None or not rows:
return
try:
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
except AttributeError:
status_map = {}
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
logger.warning("roundtable task draft list: job status enrichment failed", exc_info=True)
status_map = {}
for r in rows:
info = status_map.get(r.get("id"))
if info:
r["status"] = info.get("status")
r["job_id"] = info.get("jobId")
# ── routes ───────────────────────────────────────────────────────────────
@router.get("/by-task/{task_id}", response_model=TaskDraftListResponse)
async def list_drafts_by_task(request: Request, task_id: str) -> TaskDraftListResponse:
"""All drafts for a task, newest-first (一个 taskId 多条记录,不分权)。"""
store = _get_store(request)
rows = await store.list_drafts_by_task(task_id)
await _enrich_status(request, rows)
return TaskDraftListResponse(drafts=[TaskDraftMetaResponse(**r) for r in rows])
@router.get("/by-task/{task_id}/latest", response_model=TaskDraftResponse)
async def get_latest_draft_by_task(request: Request, task_id: str) -> TaskDraftResponse:
"""Newest draft for a task; 404 if none. 回显最新一条聊天记录用。"""
store = _get_store(request)
row = await store.get_latest_draft_by_task(task_id)
if row is None:
raise HTTPException(status_code=404, detail="No draft for this task")
return TaskDraftResponse(**row)
@router.post("", response_model=TaskDraftResponse, status_code=201)
async def create_draft(request: Request, body: TaskDraftCreateRequest) -> TaskDraftResponse:
store = _get_store(request)
task_id = (body.task_id or "").strip()
if not task_id:
raise HTTPException(status_code=400, detail="task_id is required")
row = await store.create_draft(
{
"id": (body.id or uuid4().hex),
"task_id": task_id,
"title": body.title.strip(),
"furthest_step": body.furthest_step,
"step1": body.step1,
"step2": body.step2,
"step3": body.step3,
},
created_by=_current_user_id(request),
)
return TaskDraftResponse(**row)
@router.get("/{draft_id}", response_model=TaskDraftResponse)
async def get_draft(request: Request, draft_id: str) -> TaskDraftResponse:
store = _get_store(request)
row = await store.get_draft(draft_id)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return TaskDraftResponse(**row)
@router.put("/{draft_id}", response_model=TaskDraftResponse)
async def update_draft(request: Request, draft_id: str, body: TaskDraftUpdateRequest) -> TaskDraftResponse:
store = _get_store(request)
fields = body.model_dump(exclude_unset=True)
row = await store.update_draft(draft_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return TaskDraftResponse(**row)
@router.delete("/{draft_id}", status_code=204)
async def delete_draft(request: Request, draft_id: str) -> None:
store = _get_store(request)
deleted = await store.delete_draft(draft_id)
if not deleted:
raise HTTPException(status_code=404, detail="Draft not found")