552 lines
27 KiB
Python
552 lines
27 KiB
Python
"""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)
|