"""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)