Files
deerflow-code/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
T
董宏凯andClaude Fable 5 df8bf895ef 圆桌第三步与业务链条增强:主题跟随/插话暂停/提示词优化/结构化流程图
- 结果绘制跟随日/夜间主题(深色按深蓝 UI 规范令牌绘制),提示词注入真实生成时间
- 绘制支持暂停与随时插话(线程存活时免带原料续线程省 token)、失败自动重试一次、草稿主题不匹配提示条
- 总结报告优先执行用户最终总结要求(结构化输出/重点席位可整体替换默认章节)
- 业务链条总控提示词与各阶段目标加「AI 优化」按钮(新接口 POST /api/roundtable-chains/optimize-prompt)
- 总结报告 flow-json 结构化流程图:流式增量绘制、最新连线蚂蚁线动画、拖拽/缩放/复位/全屏、节点详情弹窗、沙箱切换按钮/无沙箱自动打开
- roundtable-report / roundtable-summary 两个内置智能体 SOUL 更新(双主题、多轮修改允许读底稿、KPI 估算标注、流程数据契约)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:37:45 +08:00

370 lines
17 KiB
Python
Raw 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.
"""CRUD API for roundtable-planning business chains (业务链条).
A business chain is a named, reusable, *ordered* list of agents the user
assembles in the chain editor. In Step 2 the coordinator dispatches to the seats
following this order. Bound to the requesting user (not the browser), so it
follows the account across devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/roundtable-chains``):
GET "" list current user's chains (full records, incl. seats)
POST "" create a chain
GET "/{chain_id}" one chain
PUT "/{chain_id}" partial update (title / description / seats)
DELETE "/{chain_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8).
MIN_SEATS = 2
MAX_SEATS = 8
# ── schemas ────────────────────────────────────────────────────────────────
class ChainSeatSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
description: str = ""
class ChainResponse(BaseModel):
id: str
title: str = ""
description: str | None = None
seats: list[ChainSeatSchema] = []
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
stages: list[list[str]] | None = None
# Optional per-stage goals aligned to ``stages`` (dag-only). None = no goals.
stage_goals: list[str | None] | None = None
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
coordinator_prompt: str | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class ChainListResponse(BaseModel):
chains: list[ChainResponse]
class ChainCreateRequest(BaseModel):
# Optional client-provided id so the browser can keep one stable id across
# the create + subsequent updates without an extra round-trip.
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] = []
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
class ChainUpdateRequest(BaseModel):
# All optional → callers send only what changed.
title: str | None = Field(default=None, max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | 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_chain_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable chain store not available")
return store
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
"""Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain."""
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
raise HTTPException(
status_code=400,
detail=f"业务链条席位数须在 {MIN_SEATS}–{MAX_SEATS} 之间(当前 {len(seats)})",
)
seen: set[str] = set()
for s in seats:
if s.agent_id in seen:
raise HTTPException(status_code=400, detail=f"业务链条存在重复智能体: {s.agent_id}")
seen.add(s.agent_id)
def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | None) -> None:
"""Validate the optional layering. ``stages`` must partition the seats.
Rules (None = linear chain, always valid):
- at least one stage, no empty stage;
- every agent_id in a stage must be one of the chain's seats;
- no agent_id appears twice (within or across stages);
- every seat must appear in exactly one stage (full coverage), so the
stored layering and the seat list never drift apart.
"""
if stages is None:
return
if len(stages) == 0:
raise HTTPException(status_code=400, detail="编排分层不能为空(至少一个阶段)")
seat_ids = {s.agent_id for s in seats}
seen: set[str] = set()
for idx, stage in enumerate(stages):
if not stage:
raise HTTPException(status_code=400, detail=f"第 {idx + 1} 阶段为空,请删除或放入席位")
for agent_id in stage:
if agent_id not in seat_ids:
raise HTTPException(
status_code=400,
detail=f"编排分层包含不在席位列表中的智能体: {agent_id}",
)
if agent_id in seen:
raise HTTPException(
status_code=400,
detail=f"智能体 {agent_id} 在编排分层中重复出现,每个席位只能跑一次",
)
seen.add(agent_id)
if seen != seat_ids:
missing = seat_ids - seen
raise HTTPException(
status_code=400,
detail=f"编排分层未覆盖全部席位,缺少: {', '.join(sorted(missing))}",
)
# Per-goal length cap to keep injected prompts bounded.
MAX_STAGE_GOAL_CHARS = 1000
def _validate_stage_goals(
stages: list[list[str]] | None, stage_goals: list[str | None] | None
) -> None:
"""Validate the optional per-stage goals (None = no goals, always valid).
Rules:
- goals only make sense alongside a non-null layering (dag-only): reject
goals sent without ``stages``;
- length must match ``stages`` exactly (positional alignment);
- each goal (when present) must be ≤ ``MAX_STAGE_GOAL_CHARS`` chars.
"""
if stage_goals is None:
return
if stages is None:
raise HTTPException(status_code=400, detail="阶段目标需与编排分层一起提交(线性链不支持阶段目标)")
if len(stage_goals) != len(stages):
raise HTTPException(
status_code=400,
detail=f"阶段目标数量({len(stage_goals)})与编排分层阶段数({len(stages)})不一致",
)
for idx, goal in enumerate(stage_goals):
if goal is not None and len(goal) > MAX_STAGE_GOAL_CHARS:
raise HTTPException(
status_code=400,
detail=f"第 {idx + 1} 阶段目标过长(上限 {MAX_STAGE_GOAL_CHARS} 字)",
)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
"""列出业务链条。
``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
其余值按 ``mine`` 处理。
"""
normalized_scope = scope if scope in ("mine", "public") else "mine"
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id, scope=normalized_scope)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@router.post("", response_model=ChainResponse, status_code=201)
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
_validate_seats(body.seats)
_validate_stages(body.seats, body.stages)
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_chain(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"description": body.description,
"seats": [s.model_dump() for s in body.seats],
"stages": body.stages,
"stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt,
},
)
return ChainResponse(**row)
@router.get("/{chain_id}", response_model=ChainResponse)
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_chain(chain_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
return ChainResponse(**row)
@router.put("/{chain_id}", response_model=ChainResponse)
async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse:
if body.seats is not None:
_validate_seats(body.seats)
# A non-null stages layering can only be validated against seats; the editor
# always saves both together. Reject a layering update that omits seats.
if body.stages is not None:
if body.seats is None:
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
_validate_stages(body.seats, body.stages)
# Goals align to stages; reject goals sent without a layering, enforce length/size.
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
fields = body.model_dump(exclude_unset=True)
row = await store.update_chain(chain_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
return ChainResponse(**row)
@router.delete("/{chain_id}", status_code=204)
async def delete_chain(request: Request, chain_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_chain(chain_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Chain not found")
# ── 提示词优化(总控提示词 / 阶段目标)──────────────────────────────────────
# 链条编辑器的「AI 优化」按钮:把用户当前写的提示词 + 链条结构发来,单次直连
# LLM 重写后返回全文(同 suggestions 路由的直调模式,不走 agent / loopback)。
class OptimizePromptRequest(BaseModel):
prompt_text: str = Field(min_length=1, max_length=8000, description="用户当前写的提示词原文")
kind: str = Field(default="coordinator", description="coordinator=总控提示词 | stage_goal=阶段目标")
context: str = Field(default="", max_length=8000, description="链条结构等上下文(标题 / 各 Stage 席位 / 目标)")
model_name: str | None = Field(default=None, description="可选模型覆盖,缺省用 config models[0]")
class OptimizePromptResponse(BaseModel):
optimized: str
_OPTIMIZE_SYSTEM_COORDINATOR = (
"你是「多智能体业务链条」的提示词优化专家。用户在业务链条编辑器里给总控(协调)智能体写了一段编排提示词——"
"总控会按它逐阶段给各席位智能体派活;第 3 步的方案总结报告也会把它当作最高优先级口径(决定总结的结构与重点)。\n\n"
"请在保留用户原意的前提下重写这段提示词,让它更明确、更可执行:\n"
"1. 明确派活方式:各阶段先后顺序、每阶段的产出目标、对上一阶段结论的承接要求;\n"
"2. 明确交付口径:各席位交付聚焦本职、避免重复劳动,要求显式给出结论而非泛泛而谈;\n"
"3. 若原文流露了对最终总结的期望(输出结构、重点席位、风格详略),把它整理成明确条目;若没有,不要凭空替用户添加;\n"
"4. 语言精炼、命令式、分点表述。\n\n"
"硬性要求:只输出优化后的提示词全文(纯文本,可用换行与编号),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 800 字。"
)
_OPTIMIZE_SYSTEM_STAGE_GOAL = (
"你是「多智能体业务链条」的提示词优化专家。用户在链条编辑器里为某一个 Stage(阶段)写了「本阶段目标」——"
"总控按它给本阶段的席位派活,并要求本阶段交付承接上一阶段产出。\n\n"
"请在保留用户原意的前提下重写这段阶段目标,让它更明确、更可执行:点明本阶段要产出什么(交付物 / 结论形态)、"
"怎么承接上一阶段产出、本阶段各席位分别聚焦什么(如上下文给了席位名单)。语言精炼,两到四句话即可。\n\n"
"硬性要求:只输出优化后的阶段目标全文(纯文本),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 200 字。"
)
def _extract_llm_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts) if parts else ""
if content is None:
return ""
return str(content)
def _strip_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
@router.post("/optimize-prompt", response_model=OptimizePromptResponse)
async def optimize_chain_prompt(
body: OptimizePromptRequest,
config: AppConfig = Depends(get_config),
) -> OptimizePromptResponse:
system = _OPTIMIZE_SYSTEM_STAGE_GOAL if body.kind == "stage_goal" else _OPTIMIZE_SYSTEM_COORDINATOR
user_parts: list[str] = []
if body.context.strip():
user_parts.append(f"【业务链条结构】\n{body.context.strip()}")
user_parts.append(f"【待优化的提示词原文】\n{body.prompt_text.strip()}")
user_parts.append("请输出优化后的提示词全文。")
try:
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[SystemMessage(content=system), HumanMessage(content="\n\n".join(user_parts))],
config={"run_name": "chain_prompt_optimize"},
)
text = _strip_code_fence(_extract_llm_text(response.content))
except Exception as exc: # 模型 / 网络故障 → 502,前端 toast 原样展示
logger.exception("chain prompt optimize failed: %s", exc)
raise HTTPException(status_code=502, detail=f"大模型调用失败:{exc}") from exc
if not text:
raise HTTPException(status_code=502, detail="大模型未返回内容")
return OptimizePromptResponse(optimized=text)