mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:11:59 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
267 lines
14 KiB
Python
267 lines
14 KiB
Python
"""能力登记与端点(BE-1):/api/capabilities、/api/endpoints。设计文档 §2.6、§3.9、§3.10。
|
|
|
|
权限(§3.12「MCP stdio、端点和凭据、能力治理、模型接入」那一行):搭建者只读,改动只给管理员。main.py 给整组挂
|
|
require_role("builder"),写接口在里面再要 admin。
|
|
|
|
- 能力列表每次都先同步一遍(sync_all 只改变了的行,几条查询):刚建的数据源、刚加的自定义工具马上看得到;
|
|
- 治理带 version 乐观锁:读到的是 v3、提交时已经是 v4 的回 409,界面重新读一遍再改;
|
|
- 字段语义沿用数据目录「项」的状态和审阅(data/capability_review.py):GET /capabilities/review-queue 列推断的项,
|
|
POST /capabilities/review/batch 批量确认、驳回、撤销审阅(逐个能力按 version 乐观锁,写审计);
|
|
- 端点的认证头只回掩码,提交掩码值表示保持原值;头的名字和值不合法(空名字、带空格、换行、中文)的 422 说清是哪一项;
|
|
allow_intranet 只有管理员能开(写接口本身只给管理员)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import current_principal, ensure_role
|
|
from app.auth.audit import record
|
|
from app.capabilities import endpoints as ep
|
|
from app.capabilities import registry
|
|
from app.data import capability_review
|
|
from app.db.base import get_session
|
|
from app.db.models import Capability, CustomTool, Endpoint, McpServer
|
|
|
|
router = APIRouter(prefix="/api", tags=["capabilities"])
|
|
|
|
|
|
def capability_out(row: Capability) -> dict[str, Any]:
|
|
return {"name": row.name, "system": row.system, "title": row.title, "description": row.description,
|
|
"params_schema": row.params_schema or {}, "returns_schema": row.returns_schema, "tabular": row.tabular,
|
|
"effect_system": row.effect_system, "effect": row.effect, "approval": row.approval,
|
|
"unattended_write": row.unattended_write, "fields": row.fields or {}, "masked": list(row.masked or []),
|
|
"credential_mode": row.credential_mode, "endpoint_id": row.endpoint_id, "lookup": row.lookup,
|
|
"version": row.version, "missing": row.missing,
|
|
"synced_at": row.synced_at.isoformat() if row.synced_at else None, "updated_by": row.updated_by}
|
|
|
|
|
|
@router.get("/capabilities")
|
|
async def list_capabilities(system: str | None = None, effect: str | None = None, q: str | None = None,
|
|
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
|
await registry.sync_all(session)
|
|
rows = list((await session.execute(select(Capability).order_by(Capability.system, Capability.name))).scalars())
|
|
if system:
|
|
rows = [r for r in rows if r.system == system]
|
|
if effect:
|
|
rows = [r for r in rows if registry.effective_effect(r) == effect]
|
|
if q:
|
|
needle = q.strip().lower()
|
|
rows = [r for r in rows if needle in r.name.lower() or needle in (r.title or "").lower()
|
|
or needle in (r.description or "").lower()]
|
|
return {"items": [capability_out(r) for r in rows]}
|
|
|
|
|
|
@router.post("/capabilities/sync")
|
|
async def sync_capabilities(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
|
return {"changed": await registry.sync_all(session)}
|
|
|
|
|
|
# ---- 字段语义的审阅(data/capability_review.py)。要登记在 /capabilities/{name:path} 前面:不然 review-queue 会被当成能力名
|
|
|
|
|
|
@router.get("/capabilities/review-queue")
|
|
async def capability_review_queue(limit: int = Query(50, ge=1, le=capability_review.QUEUE_LIMIT_MAX),
|
|
offset: int = Query(0, ge=0),
|
|
kind: Literal["all", "facts", "names"] = Query("all"),
|
|
capability: str | None = Query(None, max_length=200),
|
|
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
|
"""待审阅的能力字段:只列推断的项,按用到它的运行次数、是不是写入能力、项的风险、来源排好。kind 筛影响数字的项
|
|
(facts:度量类型)或名称和说明(names),capability 只看一项能力。搭建者能看,审阅只给管理员。只读:不顺带同步,
|
|
来源删掉了的(missing)按上一次同步的结果不列——能力页先读列表(会同步),再读队列。"""
|
|
return await capability_review.review_queue(session, kind=kind, capability=capability or None, limit=limit,
|
|
offset=offset)
|
|
|
|
|
|
class CapabilityReviewItem(BaseModel):
|
|
capability: str = Field(min_length=1, max_length=200)
|
|
pointer: str = Field(min_length=1, max_length=500)
|
|
field: Literal["label", "meaning", "unit", "measure", "codes"]
|
|
action: Literal["confirm", "reject", "reset"]
|
|
if_version: int = Field(ge=0)
|
|
|
|
|
|
class CapabilityReviewBatchIn(BaseModel):
|
|
items: list[CapabilityReviewItem] = Field(min_length=1, max_length=capability_review.BATCH_MAX)
|
|
|
|
|
|
@router.post("/capabilities/review/batch")
|
|
async def review_capability_batch(payload: CapabilityReviewBatchIn, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
"""批量审阅能力字段:逐条结果和请求一一对应。逐个能力按 if_version(能力的 version)乐观锁,同一个能力的几条一次写入、
|
|
version 加一、写审计;一个能力冲突、位置不对、能力不存在只记在那几条上(ok 为假,code 是 capability_conflict /
|
|
capability_path_invalid / capability_missing),不影响别的。能力治理只给管理员(§3.12),审阅同样。"""
|
|
ensure_role("admin")
|
|
principal = current_principal()
|
|
items = [capability_review.BatchItem(i.capability, i.pointer, i.field, i.action, i.if_version)
|
|
for i in payload.items]
|
|
return {"results": await capability_review.review_batch(session, items, actor=principal.username,
|
|
verified=principal.verified)}
|
|
|
|
|
|
@router.get("/capabilities/{name:path}")
|
|
async def get_capability(name: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
|
await registry.ensure_synced(session, [name])
|
|
row = await session.get(Capability, name)
|
|
if row is None:
|
|
raise HTTPException(404, f"能力「{name}」不存在")
|
|
return capability_out(row)
|
|
|
|
|
|
class GovernanceIn(BaseModel):
|
|
version: int
|
|
model_config = {"extra": "allow"}
|
|
|
|
|
|
@router.put("/capabilities/{name:path}/governance")
|
|
async def govern_capability(name: str, payload: GovernanceIn, session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""改治理字段。只认 registry.GOVERNED_FIELDS 里的键,带上读到的 version;改一次 version 加一,写审计。"""
|
|
ensure_role("admin")
|
|
changes = {k: v for k, v in (payload.model_extra or {}).items() if k in registry.GOVERNED_FIELDS}
|
|
if not changes:
|
|
raise HTTPException(422, "没有可修改的治理字段")
|
|
principal = current_principal()
|
|
try:
|
|
row = await registry.govern(session, name, changes, version=payload.version, actor=principal.username,
|
|
verified=principal.verified)
|
|
except LookupError as e:
|
|
raise HTTPException(404, f"能力「{name}」不存在") from e
|
|
except registry.GovernanceError as e:
|
|
raise HTTPException(422, str(e)) from e
|
|
except registry.VersionConflict as e:
|
|
raise HTTPException(409, "这项能力的治理设置已被其他人修改,请刷新后再改") from e
|
|
return capability_out(row)
|
|
|
|
|
|
# ---------------------------------------------------------------- 端点
|
|
|
|
|
|
@router.get("/endpoints")
|
|
async def list_endpoints(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
|
rows = (await session.execute(select(Endpoint).order_by(Endpoint.name))).scalars()
|
|
return {"items": [ep.endpoint_out(r) for r in rows]}
|
|
|
|
|
|
class EndpointIn(BaseModel):
|
|
name: str = Field(min_length=1, max_length=100, pattern=r"^[^\s/]+$")
|
|
description: str = ""
|
|
environments: dict[str, Any] = Field(default_factory=dict)
|
|
idempotency_header: str = Field(default="Idempotency-Key", min_length=1, max_length=100)
|
|
timeout_s: int = Field(default=20, ge=1, le=300)
|
|
|
|
|
|
class EndpointPatch(BaseModel):
|
|
version: int
|
|
name: str | None = Field(default=None, min_length=1, max_length=100, pattern=r"^[^\s/]+$")
|
|
description: str | None = None
|
|
environments: dict[str, Any] | None = None
|
|
idempotency_header: str | None = Field(default=None, min_length=1, max_length=100)
|
|
timeout_s: int | None = Field(default=None, ge=1, le=300)
|
|
|
|
|
|
def _audit_view(envs: dict[str, Any]) -> dict[str, Any]:
|
|
"""审计里记的端点环境:地址和开关照记,认证头只记名字。"""
|
|
return {k: {"base_url": v.get("base_url"), "headers": sorted((v.get("headers") or {})),
|
|
"allow_intranet": v.get("allow_intranet"), "verify_tls": v.get("verify_tls")}
|
|
for k, v in (envs or {}).items()}
|
|
|
|
|
|
@router.post("/endpoints", status_code=201)
|
|
async def create_endpoint(payload: EndpointIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
|
ensure_role("admin")
|
|
if (await session.execute(select(Endpoint.id).where(Endpoint.name == payload.name))).first():
|
|
raise HTTPException(409, f"已存在名为「{payload.name}」的端点,请换一个名称")
|
|
if problem := ep.idempotency_header_problem(payload.idempotency_header):
|
|
raise HTTPException(422, problem)
|
|
try:
|
|
envs = ep.seal_environments(payload.environments, None)
|
|
except ep.EndpointError as e:
|
|
raise HTTPException(422, str(e)) from e
|
|
row = Endpoint(name=payload.name, description=payload.description, environments=envs,
|
|
idempotency_header=payload.idempotency_header, timeout_s=payload.timeout_s,
|
|
updated_by=current_principal().username)
|
|
session.add(row)
|
|
await session.flush()
|
|
await record(session, "endpoint.create", "endpoint", row.id, data={"name": row.name, "environments": _audit_view(envs)})
|
|
await session.commit()
|
|
await registry.refresh_cache(session)
|
|
return ep.endpoint_out(row)
|
|
|
|
|
|
@router.patch("/endpoints/{endpoint_id}")
|
|
async def update_endpoint(endpoint_id: str, payload: EndpointPatch, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
ensure_role("admin")
|
|
row = await session.get(Endpoint, endpoint_id)
|
|
if row is None:
|
|
raise HTTPException(404, "端点不存在,可能已被删除")
|
|
values: dict[str, Any] = {}
|
|
if payload.name is not None and payload.name != row.name:
|
|
if (await session.execute(select(Endpoint.id).where(Endpoint.name == payload.name))).first():
|
|
raise HTTPException(409, f"已存在名为「{payload.name}」的端点,请换一个名称")
|
|
values["name"] = payload.name
|
|
if payload.idempotency_header is not None and (problem := ep.idempotency_header_problem(payload.idempotency_header)):
|
|
raise HTTPException(422, problem)
|
|
if payload.environments is not None:
|
|
try:
|
|
values["environments"] = ep.seal_environments(payload.environments, row.environments)
|
|
except ep.EndpointError as e:
|
|
raise HTTPException(422, str(e)) from e
|
|
for key in ("description", "idempotency_header", "timeout_s"):
|
|
if (value := getattr(payload, key)) is not None:
|
|
values[key] = value
|
|
before = {"name": row.name, "environments": _audit_view(row.environments)}
|
|
done = await session.execute(update(Endpoint).where(Endpoint.id == endpoint_id, Endpoint.version == payload.version)
|
|
.values(**values, version=payload.version + 1, updated_by=current_principal().username))
|
|
if done.rowcount == 0:
|
|
await session.rollback()
|
|
raise HTTPException(409, "这个端点已被其他人修改,请刷新后再改")
|
|
await record(session, "endpoint.update", "endpoint", endpoint_id, data={
|
|
"before": before, "after": {"name": values.get("name", row.name),
|
|
"environments": _audit_view(values.get("environments", row.environments))}})
|
|
await session.commit()
|
|
session.expire_all()
|
|
row = await session.get(Endpoint, endpoint_id)
|
|
assert row is not None
|
|
# 端点改了名字,绑定它的能力的「所属系统」跟着变;绑定它的 MCP 服务按新的地址、认证头重连
|
|
await registry.sync_all(session)
|
|
from app.tools.mcp_manager import mcp_manager
|
|
|
|
mcp_manager.invalidate(*(await session.execute(
|
|
select(McpServer.name).where(McpServer.endpoint_id == endpoint_id))).scalars())
|
|
return ep.endpoint_out(row)
|
|
|
|
|
|
@router.delete("/endpoints/{endpoint_id}", status_code=204)
|
|
async def delete_endpoint(endpoint_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
|
ensure_role("admin")
|
|
row = await session.get(Endpoint, endpoint_id)
|
|
if row is None:
|
|
raise HTTPException(404, "端点不存在,可能已被删除")
|
|
tools = [t.name for t in (await session.execute(select(CustomTool))).scalars()
|
|
if ep.bound_endpoint_ref(t) in (row.id, row.name)]
|
|
servers = list((await session.execute(select(McpServer.name).where(McpServer.endpoint_id == row.id))).scalars())
|
|
if users := tools + [f"MCP 服务「{s}」" for s in servers]:
|
|
raise HTTPException(409, f"端点仍被使用:{'、'.join(users)}。请先解除绑定再删除")
|
|
await record(session, "endpoint.delete", "endpoint", row.id, data={"name": row.name})
|
|
await session.delete(row)
|
|
await session.commit()
|
|
await registry.refresh_cache(session)
|
|
|
|
|
|
@router.post("/endpoints/{endpoint_id}/test")
|
|
async def test_endpoint(endpoint_id: str, env: str = "prod", session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
ensure_role("admin")
|
|
row = await session.get(Endpoint, endpoint_id)
|
|
if row is None:
|
|
raise HTTPException(404, "端点不存在,可能已被删除")
|
|
if env not in ep.ENVIRONMENTS:
|
|
raise HTTPException(422, "环境只能是测试或生产")
|
|
return await ep.probe(row, env)
|