Files
董宏凯andClaude Opus 4.8 b1b1ff98f5 feat(roundtable): 深链接业务链抽取规范 + 总结报告文件干净/对话强制核对
深链接会商(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>
2026-06-20 18:13:03 +08:00

411 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""圆桌后台作业控制 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)