- 新增会商全流程诊断日志:新表 roundtable_diagnostics + 仓储 + admin API (/api/roundtable-diagnostics) + 管理员诊断页(会商页右上角设置按钮入口), 覆盖第一/二/三步及后台执行器/进程内网关各处报错与关键事件,支持按 范围/阶段/级别/事件/关键词筛选 + 分页。诊断写入全程 best-effort,绝不打断会商。 - 修复后台挂起「执行完第一个席位后永久卡死」:进程内网关每轮 agent run 改为 idle(无活动)超时(默认 20min)——只要还在持续产出 token/工具事件就一直等, 仅当连续无任何输出才判卡死并取消+报错+落 seat_turn_timeout 诊断;慢但活着的 生成绝不打断。可经 ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS 调整。 - 一并提交进行中的会商 seat-skill 注入 / 业务链条编辑器相关改动。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
"""圆桌会商诊断日志查询 API(管理员)。
|
||
|
||
会商全流程(第一/二/三步 + 后台执行器 + 进程内网关)各处的报错与关键事件落在
|
||
``roundtable_diagnostics`` 表,本路由暴露**管理员**筛选查询 + facets + 清理。
|
||
|
||
Routes(prefix ``/api/roundtable-diagnostics``,全部 admin-gated)::
|
||
GET "" 筛选 + 分页列出(scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until)
|
||
GET "/facets" 各筛选维度的 distinct 取值(下拉用)
|
||
POST "/admin/cleanup" 按保留天数清理旧日志
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, HTTPException, Query, Request
|
||
from pydantic import BaseModel, Field
|
||
|
||
router = APIRouter(prefix="/api/roundtable-diagnostics", tags=["roundtable-diagnostics"])
|
||
|
||
|
||
def _get_store(request: Request):
|
||
store = getattr(request.app.state, "roundtable_diagnostic_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="Roundtable diagnostic store not available")
|
||
return store
|
||
|
||
|
||
async def _require_admin(request: Request) -> None:
|
||
"""放行 admin / auth-disabled,其余 403(与 roundtable_jobs / task_buttons 同套语义)。"""
|
||
from app.gateway.deps import get_optional_user_from_request
|
||
|
||
user = await get_optional_user_from_request(request)
|
||
if user is None:
|
||
return
|
||
if getattr(user, "system_role", None) != "admin":
|
||
raise HTTPException(status_code=403, detail="会商诊断日志仅限管理员查看")
|
||
|
||
|
||
class DiagnosticItem(BaseModel):
|
||
id: str
|
||
scope: str
|
||
stage: str
|
||
level: str
|
||
event: str
|
||
message: str | None = None
|
||
detail: Any = None
|
||
jobId: str | None = None
|
||
draftId: str | None = None
|
||
taskId: str | None = None
|
||
userId: str | None = None
|
||
agentId: str | None = None
|
||
agentName: str | None = None
|
||
cycle: int | None = None
|
||
createdAt: str | None = None
|
||
|
||
|
||
class DiagnosticListResponse(BaseModel):
|
||
items: list[DiagnosticItem]
|
||
total: int
|
||
limit: int
|
||
offset: int
|
||
|
||
|
||
class FacetsResponse(BaseModel):
|
||
scope: list[str] = Field(default_factory=list)
|
||
stage: list[str] = Field(default_factory=list)
|
||
level: list[str] = Field(default_factory=list)
|
||
event: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class CleanupRequest(BaseModel):
|
||
retention_days: int = Field(default=30, ge=0)
|
||
batch_size: int = Field(default=1000, ge=1, le=10000)
|
||
|
||
|
||
class CleanupResponse(BaseModel):
|
||
deleted: int
|
||
elapsed_seconds: float
|
||
|
||
|
||
def _to_item(row: dict[str, Any]) -> DiagnosticItem:
|
||
return DiagnosticItem(
|
||
id=row["id"],
|
||
scope=row.get("scope") or "",
|
||
stage=row.get("stage") or "",
|
||
level=row.get("level") or "",
|
||
event=row.get("event") or "",
|
||
message=row.get("message"),
|
||
detail=row.get("detail"),
|
||
jobId=row.get("job_id"),
|
||
draftId=row.get("draft_id"),
|
||
taskId=row.get("task_id"),
|
||
userId=row.get("user_id"),
|
||
agentId=row.get("agent_id"),
|
||
agentName=row.get("agent_name"),
|
||
cycle=row.get("cycle"),
|
||
createdAt=row.get("created_at"),
|
||
)
|
||
|
||
|
||
def _parse_dt(raw: str | None) -> datetime | None:
|
||
if not raw:
|
||
return None
|
||
try:
|
||
# 容忍前端传的 ``...Z`` 后缀(datetime.fromisoformat 在 3.11+ 接受,旧版不接受)。
|
||
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
@router.get("", response_model=DiagnosticListResponse)
|
||
async def list_diagnostics(
|
||
request: Request,
|
||
scope: str | None = None,
|
||
stage: str | None = None,
|
||
level: str | None = None,
|
||
event: str | None = None,
|
||
job_id: str | None = None,
|
||
draft_id: str | None = None,
|
||
task_id: str | None = None,
|
||
user_id: str | None = None,
|
||
q: str | None = None,
|
||
since: str | None = None,
|
||
until: str | None = None,
|
||
limit: int = Query(default=100, ge=1, le=500),
|
||
offset: int = Query(default=0, ge=0),
|
||
) -> DiagnosticListResponse:
|
||
await _require_admin(request)
|
||
store = _get_store(request)
|
||
items, total = await store.list_logs(
|
||
scope=scope or None,
|
||
stage=stage or None,
|
||
level=level or None,
|
||
event=event or None,
|
||
job_id=job_id or None,
|
||
draft_id=draft_id or None,
|
||
task_id=task_id or None,
|
||
user_id=user_id or None,
|
||
q=q or None,
|
||
since=_parse_dt(since),
|
||
until=_parse_dt(until),
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
return DiagnosticListResponse(items=[_to_item(r) for r in items], total=total, limit=limit, offset=offset)
|
||
|
||
|
||
@router.get("/facets", response_model=FacetsResponse)
|
||
async def get_facets(request: Request) -> FacetsResponse:
|
||
await _require_admin(request)
|
||
store = _get_store(request)
|
||
facets = await store.facets()
|
||
return FacetsResponse(**facets)
|
||
|
||
|
||
@router.post("/admin/cleanup", response_model=CleanupResponse)
|
||
async def cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
|
||
await _require_admin(request)
|
||
store = _get_store(request)
|
||
cutoff = datetime.now(UTC) - timedelta(days=body.retention_days)
|
||
t0 = time.perf_counter()
|
||
deleted = 0
|
||
while True:
|
||
ids = await store.list_older_than(cutoff, limit=body.batch_size)
|
||
if not ids:
|
||
break
|
||
deleted += await store.delete_by_ids(ids)
|
||
if len(ids) < body.batch_size:
|
||
break
|
||
return CleanupResponse(deleted=deleted, elapsed_seconds=round(time.perf_counter() - t0, 3))
|