"""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 # 功能型内置智能体不可作业务链席位(Step 1 意图理解 / 推荐器 / 总控 / 报告)。 # 与前端 `api/recommend.ts` 的 `filterRecommendCandidates` 排除集逐字对齐: # 席位富化(_enrich_chain_seats)把这些 id 一律标为 available=False,保证 # 「前端不再实时查 /api/agents」后「功能型智能体不可入席」的语义不变。 _FUNCTIONAL_AGENT_IDS = frozenset( { "roundtable-intent", "roundtable-recommender", "roundtable-coordinator", "roundtable-report", } ) _FUNCTIONAL_AGENT_PREFIX = "roundtable-coordinator-" def _is_functional_agent(agent_id: str) -> bool: lowered = agent_id.lower() return lowered in _FUNCTIONAL_AGENT_IDS or lowered.startswith(_FUNCTIONAL_AGENT_PREFIX) # ── 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) # 该席位提交过程产物时传给外部任务系统的 position 值(8BF 会商页用)。 # 缺省 None = 提交时回退 0。保存在既有 seats JSON 中,无需迁移;显式声明 # 可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。 artifact_position: int | None = None # 该席位在业务链中的岗位职责。会商智能体配置面板只读展示此字段; # 保存在既有 seats JSON 中,无需迁移。空/None = 未填写。 job_responsibilities: str | None = Field(default=None, max_length=4000) class ChainSeatResponseSchema(ChainSeatSchema): """席位响应模型:在落库字段之外附带两个**读时计算**的富化字段。 前端选链(弹窗 / 左栏点选 / 深链自动选链)从此不再实时拉 `/api/agents`, 所需的「席位智能体是否仍可用」与「最新名称」由后端在返回链条时用一次批量 查询(`agent_store.list_by_ids`,与 `/api/agents` 同一套可见性过滤)算好: - ``available``:该席位智能体当前对请求用户可见(本人 / 内置 / 已发布、 非私享、非管理员隐藏)且不是功能型内置 → True;否则 False。 - ``live_name``:智能体当前名称;查不到时为 None(前端回退 seat.name 快照)。 仅用于响应序列化,**绝不持久化**:create/update 请求沿用 ``ChainSeatSchema``, 富化字段不会进 seats JSON(存量数据零迁移)。 """ available: bool | None = None live_name: str | None = None class ChainResponse(BaseModel): id: str title: str = "" description: str | None = None seats: list[ChainSeatResponseSchema] = [] # 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 # 管理员目录开关:True = 从所有普通列表(我的/公共/待审核/选链弹窗)隐藏,仅管理员 # 管理视图(admin_view=true)可见。纯目录开关:get_chain 单条读取不受影响,已配置的 # 业务映射 / 进行中的研讨继续可用。缺省 False。 hidden: 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 _apply_seat_enrichment(chains: list[dict], live_records: list[dict]) -> None: """把批量查到的实时智能体信息写进每条链每个 seat(就地修改响应 dict)。 纯函数(不触网),便于单测:``live_records`` 是 ``agent_store.list_by_ids`` 的返回(dict 列表,含 id/name)。规则: - 功能型内置智能体(_is_functional_agent)与查不到的 id → available=False、 live_name=None(对齐前端 ``filterRecommendCandidates`` 的排除语义); - 其余 → available=True、live_name=实时 name(回退 None 由前端用快照名)。 """ live: dict[str, dict] = {} for record in live_records: rid = str(record.get("id") or "").lower() if rid: live[rid] = record for chain in chains: for seat in chain.get("seats") or []: aid = str(seat.get("agent_id") or "").lower() record = live.get(aid) if record is None or _is_functional_agent(aid): seat["available"] = False seat["live_name"] = None else: seat["available"] = True seat["live_name"] = str(record.get("name") or "") or None async def _enrich_chain_seats(request: Request, rows: list[dict]) -> None: """席位富化入口:一次批量 SQL 算好所有 seat 的 available / live_name。 best-effort:agent store 不可用 / 查询异常时不写任何字段(响应缺省 → 前端 按存活处理),与旧前端「agents 列表拉取失败不判删」的降级语义一致。 仅修改响应 dict,落库的 seats JSON 不受影响。 """ agent_ids = { str(seat.get("agent_id") or "").lower() for row in rows for seat in row.get("seats") or [] if seat.get("agent_id") } if not agent_ids: return try: store = get_agent_store(request) except Exception: logger.warning("Seat enrichment skipped: agent store unavailable") return try: records = await store.list_by_ids(sorted(agent_ids), _current_user_id(request)) except Exception: logger.warning("Seat enrichment skipped: batch agent lookup failed", exc_info=True) return _apply_seat_enrichment(rows, records) 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", admin_view: bool = False, hidden: bool | None = None, ) -> ChainListResponse: """列出业务链条。 ``scope`` 查询参数(筛选): - ``mine``(默认):仅当前用户自己的链条; - ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨); - ``pending``:所有**待审核**(``pending_review=True``)链条(**仅管理员**; 非管理员请求 ``pending`` 退化为 ``mine``); - ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。 其余值按 ``mine`` 处理。 隐藏开关(管理员目录):默认(含管理员的普通视图,如选链弹窗)隐藏链条 (``hidden=True``)从所有 scope 排除。管理员传 ``admin_view=true`` 切到管理视图: 不排除隐藏链条,并可用 ``hidden=true/false`` 筛选只看隐藏 / 只看显示中(配置页专用)。 非管理员传 ``admin_view`` 一律忽略,绝不越权。 """ 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, include_hidden=admin_view and is_admin, hidden=hidden if admin_view and is_admin else None, ) await _enrich_owner_names(rows) await _enrich_chain_seats(request, 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]) await _enrich_chain_seats(request, [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]) await _enrich_chain_seats(request, [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]) await _enrich_chain_seats(request, [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") class ChainHiddenUpdateRequest(BaseModel): """Admin-only request body for hiding a chain from every non-admin listing.""" hidden: bool = Field(description="Set true to hide the chain from all non-admin listings (我的/公共/待审核/选链弹窗); false restores it. Purely a catalog switch — get_chain / 业务映射 / 进行中的研讨不受影响。") @router.put("/{chain_id}/hidden", response_model=ChainResponse) async def set_chain_hidden( request: Request, chain_id: str, body: ChainHiddenUpdateRequest ) -> ChainResponse: """隐藏 / 取消隐藏一条业务链条(**仅管理员**)。 隐藏后链条从所有普通列表消失(连 owner 的「我的链条」也不显示),但单条读取不过滤: 已配置的业务映射、进行中的研讨、编辑器直接打开(chainId 深链)都继续可用。管理员 管理视图(``admin_view=true``)始终可见,可随时取消隐藏。 """ if not _is_admin(request): raise HTTPException(status_code=403, detail="Only system admins can hide or restore chains") store = _get_store(request) user_id = _current_user_id(request) row = await store.update_chain(chain_id, user_id, hidden=body.hidden, is_admin=True) if row is None: raise HTTPException(status_code=404, detail="Chain not found") logger.info("Set chain '%s' hidden=%s by admin", chain_id, body.hidden) await _enrich_owner_names([row]) await _enrich_chain_seats(request, [row]) return ChainResponse(**row) # ── 提示词优化(总控提示词 / 阶段目标)────────────────────────────────────── # 链条编辑器的「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)