Files
AgentLab/backend/app/api/blueprints.py
T

484 lines
24 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.
"""蓝图、试运行、发布单的接口(docs/design/normal-user-apps.md §3.5、§3.6、§3.10)。
权限在接口里判:共享蓝图要 builder(受管级发布单再要 admin),而且只给拥有者、它发布出来的应用的管理者和管理员
(blueprints/store.can_access),别的搭建者列表里看不到、直接访问 404;个人蓝图要拥有者且能用个人应用
(blueprints/personal.allowed:开了登录的搭建者及以上随时能,使用者和关登录时看管理员开没开 apps.user_personal_apps)。
看不到的蓝图一律当不存在(404)。
- 起点四个,都落到同一个蓝图(N1):conversation(空蓝图,另建一段蓝图对话)、template(按模板预置卡片)、run(问数据
的一轮回答:按 /copilot/from-run 同一个提取规则取出执行路径,模板记「自由形」)、workflow_version(已发布的版本:
图原样导入,模板记「自由形」,蓝图就挂在那个工作流上)。
- 卡片上的修改(PATCH)走 bp_* 操作(blueprints/store.apply_ops_and_save),来源记成 user。
- 实例化、试运行、预览调 blueprints/trial;发布单调 blueprints/publish_plan。
"""
from __future__ import annotations
import dataclasses
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import Principal, actor_of, current_principal
from app.db.base import get_session
from app.db.models_blueprints import Blueprint
router = APIRouter(prefix="/api", tags=["blueprints"])
#: 机器码
NO_ACCESS = "blueprint_forbidden"
BAD_SOURCE = "blueprint_source_invalid"
ADMIN_ONLY = "admin_only"
#: 没选模板、从已有的东西导入时记的模板
FREEFORM = "freeform"
#: 蓝图对话(Conversation.kind)
CONVERSATION_KIND = "blueprint"
DEFAULT_NAME = "新应用"
#: 蓝图列表最多列几张
_LIST_MAX = 200
# --------------------------------------------------------------------------
# 身份、取蓝图、判权限
# --------------------------------------------------------------------------
def _principal(x_actor: str | None) -> Principal:
"""这次请求的身份。关登录时 username 换成自报的署名(X-Actor),蓝图的拥有者、留痕都按它记(同 api/apps.py)。"""
principal = current_principal()
if principal.mode == "off" and principal.username is None:
return dataclasses.replace(principal, username=actor_of(x_actor))
return principal
def _require_builder(principal: Principal, what: str = "共享蓝图") -> None:
if not principal.at_least("builder"):
raise CodedHTTPException(403, f"{what}需要搭建者权限", NO_ACCESS)
async def _personal_allowed(session: AsyncSession, principal: Principal) -> None:
from app.blueprints.personal import PersonalRefused, ensure_allowed
try:
await ensure_allowed(session, principal)
except PersonalRefused as e:
raise CodedHTTPException(e.status_code, e.message, e.code or NO_ACCESS) from e
async def _blueprint(session: AsyncSession, blueprint_id: str) -> Blueprint:
"""取蓝图并判权限(blueprints/store.load,蓝图接口和蓝图助手同一条规则):共享的要搭建者,且是拥有者、它发布出来的
应用的管理者或管理员;个人的要拥有者且能用个人应用。看不到的当不存在(404)。"""
from app.blueprints.store import load
return await load(session, blueprint_id)
async def blueprint_out(session: AsyncSession, bp: Blueprint) -> dict[str, Any]:
"""接口形状(frontend/src/typesBlueprint.ts 的 Blueprint,blueprints/store.blueprint_view):图本身不下发(只给哈希),
关节只给状态,问题带白话,含要查库的检查(对到的数据源和表在不在)。"""
from app.blueprints.store import blueprint_view
return await blueprint_view(session, bp)
def _refused(e: Any) -> HTTPException:
"""各服务的拒绝(status_code / message / code)→ HTTP(带 code 的回 {detail, code})。"""
if getattr(e, "code", None):
return CodedHTTPException(e.status_code, e.message, e.code)
return HTTPException(e.status_code, e.message)
def _blocked(e: Any) -> JSONResponse:
"""被检查问题挡住的拒绝(试运行遇到缺项、待填的关节):{detail, code, issues},问题带白话,界面逐条列出来。"""
return JSONResponse(status_code=e.status_code, content={
"detail": e.message, **({"code": e.code} if getattr(e, "code", None) else {}),
"issues": getattr(e, "issues", None) or []})
# --------------------------------------------------------------------------
# 起点
# --------------------------------------------------------------------------
class SourceIn(BaseModel):
kind: Literal["conversation", "template", "run", "workflow_version"]
id: str | None = None
version: int | None = None
class BlueprintIn(BaseModel):
source: SourceIn
scope: Literal["shared", "personal"] = "shared"
name: str | None = Field(default=None, max_length=200)
#: 从模板开始时的模板 id(也可以写在 source.id)
template: str | None = None
def _freeform_ref() -> dict[str, Any]:
from app.blueprints.templates import get_template
template = get_template(FREEFORM)
return {"id": FREEFORM, "version": template.version if template is not None else 1}
def _proposed(value: Any) -> dict[str, Any]:
"""起点带来的一项(运行时的问题、工作流的说明):是从已有的东西里取的,没经用户确认,记「待确认」。"""
return {"value": value, "source": "assistant", "status": "proposed", "why": None}
@router.post("/blueprints", status_code=201)
async def create_blueprint(payload: BlueprintIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""起点:{source: {kind: conversation|template|run|workflow_version, id?, version?}, scope: shared|personal, name?}"""
from app.blueprints.spec import empty_blueprint
from app.blueprints.trial import record_event
from app.core.artifact_store import graph_hash
principal = _principal(x_actor)
if payload.scope == "personal":
await _personal_allowed(session, principal)
else:
_require_builder(principal)
spec = empty_blueprint()
bp = Blueprint(name=(payload.name or "").strip(), owner=principal.username, scope=payload.scope,
status="drafting", source={"kind": payload.source.kind}, updated_by=principal.username)
kind = payload.source.kind
if kind == "conversation":
from app.db.models import Conversation
bp.name = bp.name or DEFAULT_NAME
conversation = Conversation(kind=CONVERSATION_KIND, created_by=principal.username, title=f"「{bp.name}」的蓝图")
session.add(conversation)
await session.flush()
bp.conversation_id = conversation.id
elif kind == "template":
spec = _template_spec(spec, payload.template or payload.source.id)
bp.name = bp.name or DEFAULT_NAME
bp.source = {"kind": kind, "id": spec["template"]["id"]}
elif kind == "run":
spec, graph = await _from_run(session, spec, payload.source.id, principal)
bp.graph, bp.graph_hash = graph, graph_hash(graph)
bp.name = bp.name or str((spec.get("purpose") or {}).get("value") or DEFAULT_NAME)[:40]
bp.source = {"kind": kind, "id": payload.source.id}
else:
_require_builder(principal, "从已发布的工作流开始")
spec, workflow, row = await _from_version(session, spec, payload.source.id, payload.source.version)
bp.graph, bp.graph_hash = row.graph or {}, row.graph_hash or graph_hash(row.graph or {})
bp.workflow_id = workflow.id
bp.name = bp.name or workflow.name
bp.source = {"kind": kind, "id": workflow.id, "version": row.version}
ref = spec.get("template")
if isinstance(ref, dict):
bp.template_id, bp.template_version = ref.get("id"), ref.get("version")
bp.spec = spec
session.add(bp)
await session.flush()
await record_event(session, bp, "create", {"source": bp.source, "scope": bp.scope}, principal=principal)
await session.commit()
await session.refresh(bp)
return await blueprint_out(session, bp)
def _template_spec(spec: dict[str, Any], template_id: str | None) -> dict[str, Any]:
"""从模板开始:按模板预置卡片(选模板走 bp_template,来源记成用户:是用户挑的模板)。"""
from app.blueprints.ops import BpRefused, apply_bp_op
from app.blueprints.templates import get_template
template = get_template(template_id or "")
if template is None:
raise CodedHTTPException(422, f"没有叫「{template_id or ''}」的场景模板", BAD_SOURCE, field="template")
try:
return apply_bp_op(spec, {"op": "bp_template", "template": {"id": template.id, "version": template.version}},
source="user")
except BpRefused as e:
raise CodedHTTPException(422, e.message, e.code) from e
async def _from_run(session: AsyncSession, spec: dict[str, Any], run_id: str | None,
principal: Principal) -> tuple[dict[str, Any], dict[str, Any]]:
"""从问数据的一轮回答开始:按「提取成工作流」同一个规则取出这次运行实际走过的路径(api/copilot.extract_run_path),
不建工作流——图存在蓝图上,发布单保存版本时才建。读不到这次运行的人当它不存在。"""
from app.api.copilot import PathRefused, extract_run_path
from app.api.deps import can_read_run
from app.db.models import Run
if not run_id:
raise CodedHTTPException(422, "从问数据的回答开始,需要说明是哪一次运行", BAD_SOURCE, field="source")
run = await session.get(Run, run_id)
if run is None or not await can_read_run(principal, run):
raise HTTPException(404, "运行记录不存在,可能已被删除")
try:
path = await extract_run_path(session, run_id)
except PathRefused as e:
raise HTTPException(e.status_code, e.message) from e
question = (run.input or {}).get("question")
spec = {**spec, "template": _freeform_ref()}
if isinstance(question, str) and question.strip():
spec["purpose"] = _proposed(question.strip()[:500])
return spec, path.graph
async def _from_version(session: AsyncSession, spec: dict[str, Any], workflow_id: str | None,
version: int | None) -> tuple[dict[str, Any], Any, Any]:
"""从已发布的工作流版本开始:图原样导入,模板记「自由形」,蓝图挂在这个工作流上(发布单按图哈希复用这一版)。"""
from app.apps.versions import is_published, version_row
from app.db.models import Workflow
workflow = await session.get(Workflow, workflow_id or "")
if workflow is None:
raise HTTPException(404, "工作流不存在,可能已被删除")
wanted = version or workflow.published_version
row = await version_row(session, workflow.id, wanted) if wanted else None
if row is None or not is_published(row, current=workflow.published_version):
raise CodedHTTPException(409, f"「{workflow.name}」{f'v{wanted} ' if wanted else ''}不是在用的发布版本:"
"只能从已发布、没退役的版本开始", BAD_SOURCE, field="source")
spec = {**spec, "template": _freeform_ref()}
if (workflow.description or "").strip():
spec["purpose"] = _proposed(workflow.description.strip()[:500])
return spec, workflow, row
# --------------------------------------------------------------------------
# 列表、详情、卡片上的修改
# --------------------------------------------------------------------------
@router.get("/blueprints")
async def list_blueprints(scope: Literal["shared", "personal"] | None = None,
session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""我能读写的共享蓝图(搭建者及以上:自己的、自己管理的应用出自的;管理员全部,blueprints/store.can_access)和我的
个人蓝图(能用个人应用时),按最近改动排。别的搭建者的共享蓝图不列。"""
from app.blueprints.personal import allowed
from app.blueprints.store import shared_listed
principal = _principal(x_actor)
rows: list[Blueprint] = []
if scope in (None, "shared") and principal.at_least("builder"):
query = select(Blueprint).where(Blueprint.scope == "shared")
if principal.mode != "off" and principal.role != "admin":
# 候选只有自己的、发布成了应用的(要不要列看应用的管理权,shared_listed 逐个应用核对)
query = query.where(or_(Blueprint.owner == (principal.username or ""), Blueprint.app_id.is_not(None)))
else:
query = query.order_by(Blueprint.updated_at.desc()).limit(_LIST_MAX)
rows += await shared_listed(session, principal, list((await session.execute(query)).scalars()))
if scope in (None, "personal") and await allowed(session, principal):
mine = Blueprint.scope == "personal"
if principal.mode != "off":
mine = mine & (Blueprint.owner == (principal.username or ""))
rows += list((await session.execute(select(Blueprint).where(mine).order_by(Blueprint.updated_at.desc())
.limit(_LIST_MAX))).scalars())
rows.sort(key=lambda bp: bp.updated_at or bp.created_at, reverse=True)
return {"items": [await blueprint_out(session, bp) for bp in rows[:_LIST_MAX]]}
@router.get("/blueprints/{blueprint_id}")
async def get_blueprint(blueprint_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
from app.blueprints.trial import settle
bp = await _blueprint(session, blueprint_id)
return await blueprint_out(session, await settle(session, bp))
@router.get("/blueprints/{blueprint_id}/conversation")
async def blueprint_conversation(blueprint_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""蓝图对话的历史(ConversationDetail):权限按蓝图判(同读蓝图),不经问数据的路由——没开问数据的使用者也读得到
自己个人蓝图的对话。蓝图没有对话(从模板、运行开始的)回 404。"""
from app.api.conversations import conversation_detail
from app.db.models import Conversation
bp = await _blueprint(session, blueprint_id)
row = await session.get(Conversation, bp.conversation_id) if bp.conversation_id else None
if row is None:
raise HTTPException(404, "这张蓝图没有对话")
return await conversation_detail(session, row)
class BlueprintPatch(BaseModel):
ops: list[dict[str, Any]] = Field(default_factory=list)
name: str | None = Field(default=None, min_length=1, max_length=200)
@router.patch("/blueprints/{blueprint_id}")
async def patch_blueprint(blueprint_id: str, payload: BlueprintPatch, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""{ops: [bp_* 操作], name?}:用户在卡片上的修改,来源记成 user。一个操作被拒整批不落(422,带 code)。"""
from app.blueprints.ops import BpRefused
principal = _principal(x_actor)
bp = await _blueprint(session, blueprint_id)
if payload.name is not None and payload.name.strip() != (bp.name or ""):
# 改名也留痕(P2):名字进工作流和应用的名字、口径卡的标题
from app.blueprints.trial import record_event
before, bp.name = bp.name, payload.name.strip()
bp.updated_by = principal.username
await record_event(session, bp, "rename", {"source": "user", "before": before, "after": bp.name},
principal=principal)
if payload.ops:
from app.blueprints.store import apply_ops_and_save
try:
await apply_ops_and_save(session, bp, payload.ops, principal.username, principal.verified, "user")
except BpRefused as e:
await session.rollback()
raise CodedHTTPException(422, e.message, e.code) from e
await session.commit()
await session.refresh(bp)
return await blueprint_out(session, bp)
# --------------------------------------------------------------------------
# 实例化、试运行、预览
# --------------------------------------------------------------------------
@router.post("/blueprints/{blueprint_id}/instantiate")
async def instantiate_blueprint(blueprint_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""按模板实例化:图、关节存到蓝图上,交回应用草稿和问题(缺项、待填的关节,带白话)。不发起运行。"""
from app.blueprints.trial import TrialRefused, materialize
principal = _principal(x_actor)
bp = await _blueprint(session, blueprint_id)
try:
result = await materialize(session, bp, principal=principal)
except TrialRefused as e:
raise _refused(e) from e
return {"blueprint": await blueprint_out(session, bp), "app_spec": result.app_spec, "problems": result.problems}
class TrialIn(BaseModel):
inputs: dict[str, Any] = Field(default_factory=dict)
@router.post("/blueprints/{blueprint_id}/trial")
async def trial(blueprint_id: str, payload: TrialIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""用真数试运行:实例化 → 保存图 → 发起探索运行,交回 {run_id, blueprint, inputs}(inputs 是这次实际用的输入:
看板类补齐了统计期,流程类是示例表单按入口映射换成的图输入)。缺项、待填的关节挡住时回 409 和问题。"""
from app.blueprints.trial import TrialRefused, start_trial
from app.engine.launch import LaunchRefused
principal = _principal(x_actor)
bp = await _blueprint(session, blueprint_id)
try:
run, _ = await start_trial(session, bp, inputs=payload.inputs, principal=principal, actor=principal.username)
except TrialRefused as e:
if e.issues:
return _blocked(e)
raise _refused(e) from e
except LaunchRefused as e:
raise _refused(e) from e
return {"run_id": run.id, "blueprint": await blueprint_out(session, bp), "inputs": dict(run.input or {})}
@router.get("/blueprints/{blueprint_id}/preview")
async def preview(blueprint_id: str, page: str | None = None, run: str | None = None,
session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""按这次试运行解析应用草稿的一页(agentlab.view/1):印章写「试运行」,流程组件对着这次运行。"""
from app.apps.resolve import ViewRefused
from app.blueprints.trial import TrialRefused, preview_page
principal = _principal(x_actor)
bp = await _blueprint(session, blueprint_id)
try:
return await preview_page(session, bp, page_id=page, run_id=run, principal=principal)
except (TrialRefused, ViewRefused) as e:
raise _refused(e) from e
# --------------------------------------------------------------------------
# 发布单
# --------------------------------------------------------------------------
class PlanIn(BaseModel):
level: Literal["published", "governed"] = "published"
#: 跑第一次(不给时看板类为真、流程类为假)
first_run: bool | None = None
#: 钉住项升版时的处置 {别名: recompute|dual|incomparable}
dispositions: dict[str, Literal["recompute", "dual", "incomparable"]] = Field(default_factory=dict)
async def _shared_for_plan(session: AsyncSession, blueprint_id: str, principal: Principal) -> Blueprint:
bp = await _blueprint(session, blueprint_id)
if bp.scope != "shared":
raise CodedHTTPException(409, "个人蓝图不走发布单:存成你的应用自己用,想给别人用请提交审核", "plan_state")
_require_builder(principal, "发布")
return bp
@router.post("/blueprints/{blueprint_id}/publish-plan")
async def publish_plan(blueprint_id: str, payload: PlanIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""出发布单(只读预检):{id, steps, summary, problems(带白话、谁来改), ok}。受管级由搭建者出单时,问题里写明要管理员。"""
from app.blueprints import publish_plan as plans
from app.blueprints.trial import settle
principal = _principal(x_actor)
bp = await settle(session, await _shared_for_plan(session, blueprint_id, principal))
try:
row = await plans.plan(session, bp, level=payload.level, principal=principal,
options={"dispositions": payload.dispositions,
**({"first_run": payload.first_run} if payload.first_run is not None else {})})
except plans.PlanRefused as e:
raise _refused(e) from e
return plans.plan_out(row)
async def _plan_for(session: AsyncSession, plan_id: str, principal: Principal) -> Any:
from app.blueprints import publish_plan as plans
try:
row = await plans.get_plan(session, plan_id)
except plans.PlanRefused as e:
raise _refused(e) from e
await _shared_for_plan(session, row.blueprint_id, principal)
return row
@router.get("/publish-plans/{plan_id}")
async def get_plan(plan_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
from app.blueprints.publish_plan import plan_out
return plan_out(await _plan_for(session, plan_id, _principal(x_actor)))
async def _drive(plan_id: str, x_actor: str | None, session: AsyncSession, how: str) -> dict[str, Any]:
from app.blueprints import publish_plan as plans
principal = _principal(x_actor)
row = await _plan_for(session, plan_id, principal)
if row.level == "governed" and not principal.at_least("admin"):
# 受管级只有管理员能发(Q8,同工作流发布接口):搭建者出的单可以看,执行要管理员
raise CodedHTTPException(403, "以受管级别发布需要管理员权限", ADMIN_ONLY)
try:
done = await (plans.run if how == "run" else plans.resume)(session, plan_id, principal=principal)
except plans.PlanRefused as e:
raise _refused(e) from e
return plans.plan_out(done)
@router.post("/publish-plans/{plan_id}/run")
async def run_plan(plan_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""确认后按序执行。某步停下时 status=stopped,那一步写明原因(白话);修好后用「接着做」。"""
return await _drive(plan_id, x_actor, session, "run")
@router.post("/publish-plans/{plan_id}/resume")
async def resume_plan(plan_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""从停下的那一步接着做:已完成的按结果里的 id 核对,不重做。"""
return await _drive(plan_id, x_actor, session, "resume")