深链接会商(taskId+goPath,businessCode)下按所选业务链完整层级强制校正 结构化抽取(flow-json) 与 方案总结报告: - 新增 harness 层 extraction_spec(app+harness 共用,单份领域知识两角色用法): build_structure_directive / build_summary_directive 按 businessCode 取 · rwfx(6BF):线性 任务→目的→行为体→预期行为→驱动因素→叙事;预期行为↔驱动因素 1:1、余 1:N · xdfx(7BF):分叉汇聚 DAG,根=行动;行动→受众目标/关键传播节点(1:N)→脆弱点(1:N)→策略(多对一) · 其余业务通用规范(强约束完整性+命名+取数、不套固定模板);business_code 空→零副作用 - 结构化抽取:flow_extract._hierarchy_hint + _validate_messages 用 structure_directive 权威覆盖默认层级(根治"无论什么业务都按目的→行为体抽/停在行为体");前端 streamFlowExtract 传 business_code(源 ingestBusinessCode) - 方案总结报告:前台 multi_agent._special_run(summary) + 后台 build_summary_prompt 注入 business 完整性核对(StartJobRequest/StartParams 透传) - 报告产物策略:.md 报告文件保持完整、干净、无需修改(不写 待补充/不足/⚠️); 「核对说明」(意图对齐+业务链完整性+不足/可补充)改为只在对话回复正文、且每次必出 - 种子 roundtable-structure SOUL 层级顺序改 spec-driven、去 rwfx 写死示例 Tests: test_roundtable_extraction_spec / test_roundtable_summary_prompt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
411 lines
17 KiB
Python
411 lines
17 KiB
Python
"""圆桌后台作业控制 API(Phase 3a)。
|
||
|
||
把 Step 2「后台挂起」的任务交给后端编排引擎跑,并暴露进度订阅 / 续跑 / 取消。
|
||
|
||
Routes(prefix ``/api/roundtable-jobs``)::
|
||
POST "" 启动一个后台作业(创建 job 行 + 拉起执行器任务)
|
||
GET ""?draft_id= 查某草稿最近一个作业
|
||
GET "/{job_id}" 作业快照
|
||
GET "/{job_id}/stream" SSE 进度流(轮询 job 表,多 worker 安全)
|
||
POST "/{job_id}/resume" 回答澄清后续跑
|
||
POST "/{job_id}/cancel" 取消
|
||
|
||
进度数据源是 ``roundtable_jobs`` 表(Phase 1),SSE 走 DB 轮询而非内存 pub/sub —— 这样
|
||
刷新 / 换 worker / 重连都能继续收到进度,规避 ai_writing 踩过的「per-worker 内存 dict
|
||
丢会话」坑。响应统一 camelCase(对齐前端 §2.5)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import time
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.gateway.roundtable_job_executor import StartParams
|
||
from deerflow.agents.roundtable_orchestrator import SeatRef
|
||
from deerflow.runtime.user_context import get_effective_user_id
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/roundtable-jobs", tags=["roundtable-jobs"])
|
||
|
||
_TERMINAL = {"done", "error", "cancelled"}
|
||
# SSE 轮询间隔与最长存活(对齐 ai_writing:长连接最多挂 30 分钟)。
|
||
_POLL_INTERVAL_S = 0.7
|
||
_MAX_STREAM_TICKS = int(30 * 60 / _POLL_INTERVAL_S)
|
||
|
||
# 续跑澄清时拼给 leader 的 prompt 前缀。
|
||
_RESUME_PREFIX = "用户已回复你刚才的澄清问题,回答如下,请据此继续派活或给出结论:\n"
|
||
|
||
|
||
# ── schemas ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class JobAgent(BaseModel):
|
||
agentId: str
|
||
name: str = ""
|
||
|
||
|
||
class StartJobRequest(BaseModel):
|
||
draftId: str | None = Field(default=None, max_length=64)
|
||
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
|
||
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
|
||
taskId: str | None = Field(default=None, max_length=128)
|
||
intent: str = ""
|
||
agents: list[JobAgent] = Field(default_factory=list)
|
||
model: str | None = None
|
||
orchestrationMode: str = "recommend"
|
||
chain: dict[str, Any] | None = None
|
||
# dag 模式分层编排计划(前端 OrchestrationPlan:{mode,stages,finalSynthesis,coordinatorPrompt?})。
|
||
# 仅 orchestrationMode=="dag" 时携带;驱动后端 _run_dag。缺失 → 引擎回退 recommend。
|
||
orchestrationPlan: dict[str, Any] | None = None
|
||
# 第一轮 leader prompt;缺省用 intent。
|
||
seedMessage: str | None = None
|
||
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
|
||
threadIds: dict[str, str] | None = None
|
||
coordinatorName: str = "roundtable-coordinator"
|
||
# 席位执行模式(快速/思考/专业问答/多智能体)派生参数,由前端 toStartBody 下发。
|
||
# 仅 special 席位用:覆盖 roundtable_run_policy("seat") 的 thinking/reasoning;
|
||
# None/缺省 → 后端按 policy 默认(≈pro),向后兼容旧前端。subagent 仅 ultra 档为真。
|
||
thinking_enabled: bool | None = None
|
||
reasoning_effort: str | None = None
|
||
subagent_enabled: bool = False
|
||
# 每席位「技能识别强化」开关映射 {agent_id: bool}(前端读所选业务链条里**每个席位**的
|
||
# 同名配置后下发)。后台席位 run 据此(与各席位 agent 自身 config.yaml 的开关取 OR)决定
|
||
# 是否注入。缺省空 dict(全部按 False/关,仅各 agent 自身开关生效)。
|
||
seat_skill_directives: dict[str, bool] = Field(default_factory=dict)
|
||
# 每席位「推理深度覆盖」映射 {agent_id: SeatMode}(前端读所选业务链条里**每个席位**单独配的
|
||
# 推理深度后下发;值为 flash/thinking/pro/ultra)。后台席位 run 按 agent_id 查表:命中则用该档
|
||
# 派生的 thinking/reasoning/subagent 覆盖作业级 seatMode;未命中 → 跟随作业级。缺省空 dict。
|
||
seat_modes: dict[str, str] = Field(default_factory=dict)
|
||
# 是否研讨前先并行『取数』(业务链条逐条 opt-in;前端读所选链条的开关后下发)。默认 False。
|
||
gather_first: bool = False
|
||
# 深链接业务码(rwfx→6BF 等)。透传到后台总结报告的「业务链完整性核对」。普通会商为空。
|
||
business_code: str | None = None
|
||
|
||
|
||
class ResumeJobRequest(BaseModel):
|
||
answer: str = ""
|
||
|
||
|
||
class CleanupRequest(BaseModel):
|
||
retention_days: int = Field(default=7, ge=0, description="保留多少天内的作业;0 = 不保留")
|
||
only_finished: bool = Field(default=True, description="仅清终态(done/error/cancelled),保护在跑作业")
|
||
batch_size: int = Field(default=500, ge=1, le=5000)
|
||
|
||
|
||
class CleanupResponse(BaseModel):
|
||
deleted: int
|
||
elapsed_seconds: float
|
||
params: dict[str, Any]
|
||
|
||
|
||
class RoundtableJobResponse(BaseModel):
|
||
id: str
|
||
draftId: str | None = None
|
||
status: str = "queued"
|
||
phase: str = "initializing"
|
||
cycle: int = 0
|
||
dispatchChain: list[dict[str, Any]] = Field(default_factory=list)
|
||
activeAgentId: str | None = None
|
||
consensusPercentage: int = 0
|
||
pendingClarification: str | None = None
|
||
orchestrationMode: str = "recommend"
|
||
chain: dict[str, Any] | None = None
|
||
orchestrationPlan: dict[str, Any] | None = None
|
||
error: str | None = None
|
||
dialogues: list[dict[str, Any]] = Field(default_factory=list)
|
||
step3: dict[str, Any] | None = None
|
||
createdAt: datetime | str | None = None
|
||
updatedAt: datetime | str | 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_job_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="Roundtable job store not available")
|
||
return store
|
||
|
||
|
||
def _get_executor(request: Request):
|
||
executor = getattr(request.app.state, "roundtable_job_executor", None)
|
||
if executor is None:
|
||
raise HTTPException(status_code=503, detail="Roundtable job executor not available")
|
||
return executor
|
||
|
||
|
||
def _pending_nodes(agents: list[JobAgent]) -> list[dict[str, Any]]:
|
||
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
|
||
return [
|
||
{"agentId": a.agentId, "name": a.name or a.agentId, "order": i + 1, "state": "pending"}
|
||
for i, a in enumerate(agents)
|
||
]
|
||
|
||
|
||
def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
|
||
return RoundtableJobResponse(
|
||
id=row["id"],
|
||
draftId=row.get("draft_id"),
|
||
status=row.get("status") or "queued",
|
||
phase=row.get("phase") or "initializing",
|
||
cycle=int(row.get("cycle") or 0),
|
||
dispatchChain=row.get("dispatch_chain") or [],
|
||
activeAgentId=row.get("active_agent_id"),
|
||
consensusPercentage=int(row.get("consensus_percentage") or 0),
|
||
pendingClarification=row.get("pending_clarification"),
|
||
orchestrationMode=row.get("orchestration_mode") or "recommend",
|
||
chain=row.get("chain"),
|
||
orchestrationPlan=row.get("orchestration_plan"),
|
||
error=row.get("error"),
|
||
dialogues=row.get("dialogues") or [],
|
||
step3=row.get("step3"),
|
||
createdAt=row.get("created_at"),
|
||
updatedAt=row.get("updated_at"),
|
||
)
|
||
|
||
|
||
def _seat_refs_from_nodes(nodes: list[dict[str, Any]] | None) -> list[SeatRef]:
|
||
out: list[SeatRef] = []
|
||
for n in nodes or []:
|
||
aid = str(n.get("agentId") or "")
|
||
if aid:
|
||
out.append(SeatRef(aid, str(n.get("name") or aid)))
|
||
return out
|
||
|
||
|
||
# ── routes ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post("", response_model=RoundtableJobResponse, status_code=201)
|
||
async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobResponse:
|
||
store = _get_store(request)
|
||
executor = _get_executor(request)
|
||
user_id = _current_user_id(request)
|
||
|
||
if not body.agents:
|
||
raise HTTPException(status_code=400, detail="agents is required")
|
||
|
||
# 防重复挂起(服务端幂等闸门):同一草稿已有未终态作业(queued/running/awaiting_input)
|
||
# 时直接返回它、不再新建。前端的「手动挂起按钮 / 离开页面自动挂起 / beforeunload 刷新
|
||
# 兜底」可能对同一研讨多次触发本端点 —— 客户端去重只能挡住同页内的重复,跨触发源 /
|
||
# 跨标签页的并发只能靠这里兜住,否则同一研讨会被双跑。
|
||
task_id = (body.taskId or "").strip() or None
|
||
if body.draftId:
|
||
# task 作业按 task_id 去重(跨用户共享);个人作业按 user 去重。
|
||
existing = await store.get_by_draft(body.draftId, user_id=user_id, task_id=task_id)
|
||
if existing is not None and (existing.get("status") or "") not in _TERMINAL:
|
||
return _to_response(existing)
|
||
|
||
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
|
||
job_id = uuid4().hex
|
||
row = await store.create(
|
||
id=job_id,
|
||
draft_id=body.draftId,
|
||
user_id=user_id,
|
||
task_id=task_id,
|
||
orchestration_mode=body.orchestrationMode,
|
||
coordinator_name=body.coordinatorName,
|
||
thread_ids=body.threadIds,
|
||
chain=body.chain,
|
||
orchestration_plan=body.orchestrationPlan,
|
||
dispatch_chain=_pending_nodes(body.agents),
|
||
status="queued",
|
||
)
|
||
|
||
executor.start_job(
|
||
StartParams(
|
||
job_id=job_id,
|
||
user_id=user_id,
|
||
agents=agents,
|
||
seed_message=(body.seedMessage or body.intent or "").strip(),
|
||
model=body.model,
|
||
mode=body.orchestrationMode,
|
||
intent_text=body.intent,
|
||
thread_ids=body.threadIds,
|
||
coordinator_name=body.coordinatorName,
|
||
draft_id=body.draftId,
|
||
task_id=task_id,
|
||
chain=body.chain,
|
||
orchestration_plan=body.orchestrationPlan,
|
||
seat_thinking_enabled=body.thinking_enabled,
|
||
seat_reasoning_effort=body.reasoning_effort,
|
||
seat_subagent_enabled=body.subagent_enabled,
|
||
seat_skill_directives=dict(body.seat_skill_directives or {}),
|
||
seat_modes=dict(body.seat_modes or {}),
|
||
gather_first=bool(body.gather_first),
|
||
business_code=(body.business_code or None),
|
||
)
|
||
)
|
||
return _to_response(row)
|
||
|
||
|
||
@router.get("", response_model=RoundtableJobResponse | None)
|
||
async def get_job_by_draft(request: Request, draft_id: str, task_id: str | None = None) -> RoundtableJobResponse | None:
|
||
# task_id 非空 = task 作业查询(按 task 共享、不分权);否则按 user 分权。
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
row = await store.get_by_draft(draft_id, user_id=user_id, task_id=tid)
|
||
return _to_response(row) if row is not None else None
|
||
|
||
|
||
@router.get("/{job_id}", response_model=RoundtableJobResponse)
|
||
async def get_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
return _to_response(row)
|
||
|
||
|
||
@router.get("/{job_id}/stream")
|
||
async def stream_job(request: Request, job_id: str, task_id: str | None = None) -> StreamingResponse:
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
|
||
def _frame(payload: dict[str, Any]) -> str:
|
||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||
|
||
async def gen():
|
||
for _ in range(_MAX_STREAM_TICKS):
|
||
if await request.is_disconnected():
|
||
return
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
yield _frame({"event": "error", "detail": "Job not found"})
|
||
return
|
||
yield _frame({"event": "progress", **_to_response(row).model_dump(mode="json")})
|
||
if (row.get("status") or "") in _TERMINAL:
|
||
return
|
||
await asyncio.sleep(_POLL_INTERVAL_S)
|
||
|
||
return StreamingResponse(
|
||
gen(),
|
||
media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
||
)
|
||
|
||
|
||
@router.post("/{job_id}/resume", response_model=RoundtableJobResponse)
|
||
async def resume_job(
|
||
request: Request, job_id: str, body: ResumeJobRequest, task_id: str | None = None
|
||
) -> RoundtableJobResponse:
|
||
store = _get_store(request)
|
||
executor = _get_executor(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
if (row.get("status") or "") != "awaiting_input":
|
||
raise HTTPException(status_code=409, detail="Job is not awaiting input")
|
||
|
||
answer = body.answer.strip()
|
||
if not answer:
|
||
raise HTTPException(status_code=400, detail="answer is required")
|
||
|
||
# task 作业不分权:放开写入归属校验(user_id=None);并以**原作业所有者**身份续跑,
|
||
# 保证编排上下文 / 写回 task-draft 用一致的 user 上下文。
|
||
write_user_id = None if tid else user_id
|
||
run_user_id = row.get("user_id") or user_id
|
||
updated = await store.update_progress(
|
||
job_id, user_id=write_user_id, status="running", pending_clarification=None
|
||
)
|
||
|
||
executor.start_job(
|
||
StartParams(
|
||
job_id=job_id,
|
||
user_id=run_user_id,
|
||
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
|
||
seed_message=_RESUME_PREFIX + answer,
|
||
model=None,
|
||
mode=row.get("orchestration_mode") or "recommend",
|
||
intent_text=answer,
|
||
thread_ids=row.get("thread_ids"),
|
||
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
|
||
draft_id=row.get("draft_id"),
|
||
task_id=tid or row.get("task_id"),
|
||
is_resume=True,
|
||
chain=row.get("chain"),
|
||
orchestration_plan=row.get("orchestration_plan"),
|
||
)
|
||
)
|
||
return _to_response(updated or row)
|
||
|
||
|
||
async def _require_admin(request: Request) -> None:
|
||
"""放行 admin / auth-disabled,其余 403(与 ai_writing / scheduled_tasks 同套语义)。"""
|
||
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="圆桌作业清理管理接口仅限管理员")
|
||
|
||
|
||
@router.post("/admin/cleanup", response_model=CleanupResponse)
|
||
async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
|
||
"""清理过期作业行(活跃作业受 only_finished 保护,永不误删)。
|
||
|
||
分批 ``list_older_than`` → ``delete_by_ids``,避免大事务。``retention_days=0``
|
||
一刷清空所有终态作业(调试用)。
|
||
"""
|
||
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, only_finished=body.only_finished, 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),
|
||
params={"retention_days": body.retention_days, "only_finished": body.only_finished, "batch_size": body.batch_size},
|
||
)
|
||
|
||
|
||
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
|
||
async def cancel_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
|
||
store = _get_store(request)
|
||
executor = _get_executor(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
executor.cancel(job_id)
|
||
# task 作业不分权:放开写入归属校验。
|
||
updated = await store.update_progress(job_id, user_id=(None if tid else user_id), status="cancelled")
|
||
return _to_response(updated or row)
|