deerflow-code/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
2026-09-07 18:24:55 +08:00

552 lines
27 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.

"""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 chains (``scope=mine``默认 / ``public`` / ``pending`` / ``all`` 后两者仅管理员)
POST "" create a chain
GET "/{chain_id}" one chain
PUT "/{chain_id}" partial update (title / description / seats)
DELETE "/{chain_id}" delete
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
(所有账号的全部链条),并可 get / 编辑 / 发布 / 启停 / 删除**任意** owner 的链条;非管理员
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
``enabled`` 默认 True:停用后管理列表仍可见,但选链弹窗 / 公共入口不再选用。
"""
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_agent_store, get_config, get_local_provider
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"])
# Stored business chains support up to 20 seats. The frontend may choose a
# lower deployment-specific cap, but must never exceed this server contract.
MIN_SEATS = 2
MAX_SEATS = 20
# ── schemas ────────────────────────────────────────────────────────────────
class ChainSeatSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
description: str = ""
# 可选「席位模型覆盖」:该席位作为子智能体执行时用的 LLM model name(须是 config
# models[] 里的一个 name)。None/"" = 不覆盖,运行时回退到全局所选模型。整条 seats
# 数组以 JSON 透传持久化(见 persistence/roundtable_chains),故新增此字段无需迁移;
# 仅需在此 schema 显式声明,否则 model_dump() 会把未知字段剥掉。
model: str | None = Field(default=None, max_length=128)
# 该席位「会商技能识别强化」开关(弱模型补偿)。True = 该席位作为会商子智能体执行时,
# 把其配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务。
# 与该席位 agent 自身 config.yaml 的同名开关取 OR。默认 False(关)。整条 seats 数组以
# JSON 透传持久化,故新增此字段无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。
seat_skill_directive: bool = False
# Per-seat human checkpoint. Step 2 pauses after this seat (or its DAG
# stage) completes and reuses the existing resume/intervention controls.
# It is stored in the existing seats JSON, so no database migration is needed.
human_validation: bool = False
# 该席位「推理深度覆盖」(flash/thinking/pro/ultra):覆盖链条级 seat_mode,仅本席位生效。
# None = 跟随链条级默认。「卡顿提速」用:把次要席位降到 flash。整条 seats 数组以 JSON 透传
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
# 被 _SEAT_MODE_PARAMS.get() 安全忽略(回退作业级),故此处只约束长度、不强校验取值。
seat_mode: str | None = Field(default=None, max_length=16)
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
position_id: str | None = Field(default=None, max_length=64)
# 该席位在业务链中的岗位职责。会商智能体配置面板只读展示此字段;
# 保存在既有 seats JSON 中,无需迁移。空/None = 未填写。
job_responsibilities: str | None = Field(default=None, max_length=4000)
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
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。
seat_mode: str | None = None
# 研讨前并行取数开关(逐条 opt-in);True = 选入研讨时研讨前先并行取数。缺省 False。
gather_first: bool = False
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。
enabled: bool = True
# 「待审核」标记:True = owner 已提交审核(等待管理员在「待审核」筛选里发布);
# 管理员发布时随 is_public=True 一起清回 False。缺省 False。
pending_review: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
# 用 LocalAuthProvider 解析;解析失败时为 None,前端回退到短 id 展示。
owner_id: str | None = None
owner_name: str | None = None
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)
seat_mode: str | None = Field(default=None, max_length=16)
gather_first: bool = False
enabled: bool = True
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)
seat_mode: str | None = Field(default=None, max_length=16)
# 研讨前并行取数开关;send 显式 true/false 改,omit 不改。
gather_first: bool | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
# 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。
enabled: bool | None = None
# 「待审核」开关(True = 提交审核,owner 在编辑器发起;False = 清除,管理员发布时
# 随 is_public=True 一起提交)。omit 不改。
pending_review: 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 _is_admin(request: Request) -> bool:
"""当前请求是否为管理员。
认证关闭时 ``request.state.user`` 为 None(单一 default 用户,mine 即 all),
按管理员处理(与 menu_overrides 等放行约定一致,无越权风险)。
"""
user = getattr(request.state, "user", None)
if user is None:
return True
return getattr(user, "system_role", None) == "admin"
async def _enrich_owner_names(rows: list[dict]) -> None:
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
与 knowledge 路由的 ``_enrich_actor_names`` 同思路:批量解析、按 id 缓存、best-effort
(解析失败 / provider 不可用时 ``owner_name`` 留空,前端回退到短 id)。
"""
try:
provider = get_local_provider()
except Exception:
return
cache: dict[str, str | None] = {}
for row in rows:
uid = row.get("owner_id")
if not uid:
row["owner_name"] = None
continue
if uid not in cache:
try:
user = await provider.get_user(uid)
cache[uid] = user.email if user else None
except Exception:
cache[uid] = None
row["owner_name"] = cache[uid]
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
async def _publish_seat_agents(request: Request, row: dict, user_id: str, *, is_admin: bool) -> list[str]:
"""把链条席位里尚未公开的自定义智能体一并发布(best-effort)。
公共链条对其他账号可用的前提是链上每个席位智能体本身已发布(见
business_mapping 的 ``_validate_public_chain_mapping`` 联动校验——只发布
链条、不发布其中的自定义智能体,别的账号会解析不到席位)。故链条处于
公共态时自动补发:
- 内置智能体(``user_id IS NULL``)全局可见,跳过;
- 已公开(``published`` 且非 ``is_private``)跳过;
- 其余置 ``published=True`` 并清 ``is_private=False``(两者互斥,私密
广场智能体要变公共必须同时清私密标记)。
非管理员只能发布本人的智能体(store.update 的 owner 限定);他人的智能体
正常流程不会出现在自己链条里,跳过并记日志。单个失败不阻断链条发布。
返回本次实际发布成功的智能体名列表(仅供日志/排查)。
"""
agent_ids: list[str] = []
for seat in row.get("seats") or []:
if isinstance(seat, dict) and seat.get("agent_id"):
agent_ids.append(str(seat["agent_id"]))
if not agent_ids:
return []
try:
agent_store = get_agent_store(request)
except HTTPException:
return []
published: list[str] = []
for agent_id in dict.fromkeys(agent_ids):
try:
agent = await agent_store.get_any(agent_id)
except Exception:
logger.warning("chain seat agent lookup failed: %s", agent_id, exc_info=True)
continue
if agent is None or agent.get("user_id") is None:
continue # 已删除 / 内置智能体:内置全局可见,无需发布
if agent.get("published") and not agent.get("is_private"):
continue # 已公开
updates = {"published": True, "is_private": False}
try:
updated = await agent_store.update_any(agent_id, updates) if is_admin else await agent_store.update(agent_id, user_id, updates)
except Exception:
logger.warning("chain seat agent publish failed: %s", agent_id, exc_info=True)
continue
if updated is None:
logger.info(
"chain seat agent %s not publishable by user %s (not owner); skipped",
agent_id,
user_id,
)
continue
published.append(agent.get("name") or agent_id)
logger.info(
"auto-published chain seat agent %s (%s) with chain %s",
agent_id,
agent.get("name"),
row.get("id"),
)
return published
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
"""Enforce 2 ≤ N ≤ 20 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} 字)",
)
_VALID_SEAT_MODES = {"flash", "thinking", "pro", "ultra"}
def _validate_seat_mode(seat_mode: str | None) -> None:
"""席位执行模式只能是 flash/thinking/pro/ultra 或 None(未设)。"""
if seat_mode is not None and seat_mode not in _VALID_SEAT_MODES:
raise HTTPException(
status_code=400,
detail=f"非法席位执行模式: {seat_mode}(应为 {'/'.join(sorted(_VALID_SEAT_MODES))} 之一)",
)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
"""列出业务链条。
``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨);
- ``pending``:所有**待审核**(``pending_review=True``)链条(**仅管理员**;
非管理员请求 ``pending`` 退化为 ``mine``);
- ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。
其余值按 ``mine`` 处理。
"""
is_admin = _is_admin(request)
normalized_scope = scope if scope in ("mine", "public", "pending", "all") else "mine"
# 非管理员请求 pending / all → 退化为 mine,绝不越权。
if normalized_scope in ("pending", "all") and not is_admin:
normalized_scope = "mine"
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id, scope=normalized_scope, is_admin=is_admin)
await _enrich_owner_names(rows)
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)
_validate_seat_mode(body.seat_mode)
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,
"seat_mode": body.seat_mode,
"gather_first": body.gather_first,
"enabled": body.enabled,
},
)
await _enrich_owner_names([row])
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, is_admin=_is_admin(request))
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
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)
_validate_seat_mode(body.seat_mode)
store = _get_store(request)
user_id = _current_user_id(request)
is_admin = _is_admin(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, is_admin=is_admin, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
# 发布联动:链条更新后处于公共态时(点发布,或已发布链再改席位),把链上
# 尚未发布的自定义智能体一并发布,保证其他账号解析公共链条时席位可用。
if row.get("is_public"):
await _publish_seat_agents(request, row, user_id, is_admin=is_admin)
await _enrich_owner_names([row])
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, is_admin=_is_admin(request))
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)