Files
AgentLab/backend/app/api/governance.py
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

152 lines
5.5 KiB
Python

from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import get_session
from app.db.models import Run, RunEvent, Workflow
from app.engine.schema import GraphSpec, NodeType
router = APIRouter(prefix="/api/governance", tags=["governance"])
@router.get("/tool-usage")
async def tool_usage(
workflow_id: str,
limit_runs: int = Query(default=100, le=500),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""工具白名单衰减报表。
关节层最可能的退化方式不是模板太死,而是白名单为了长尾一个个放宽,
半年后等于没有白名单。这份报表把"授了权但从来没用过"的工具摆到台面上,
放宽才能被定期回收。
"""
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, "这个工作流不存在,可能已经被删了")
spec = GraphSpec.model_validate(workflow.graph)
# 各节点声明的白名单
granted: dict[str, list[str]] = {}
for node in spec.nodes:
if node.type == NodeType.AGENT:
granted[node.id] = list(node.config.get("tools") or [])
elif node.type == NodeType.TOOL and node.config.get("tool"):
granted[node.id] = [node.config["tool"]]
elif node.type == NodeType.SUPERVISOR:
merged: list[str] = []
for agent in node.config.get("agents") or []:
merged.extend(agent.get("tools") or [])
granted[node.id] = sorted(set(merged))
run_ids = [
rid
for (rid,) in await session.execute(
select(Run.id)
.where(Run.workflow_id == workflow_id)
.order_by(Run.created_at.desc())
.limit(limit_runs)
)
]
usage: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
if run_ids:
rows = await session.execute(
select(RunEvent.node_id, RunEvent.data).where(
RunEvent.run_id.in_(run_ids), RunEvent.type == "tool.start"
)
)
for node_id, data in rows:
tool = (data or {}).get("tool")
if node_id and tool:
# supervisor 内部子 agent 的 node_id 形如 "team:researcher"
usage[node_id.split(":")[0]][tool] += 1
report = []
for node_id, tools in granted.items():
used = usage.get(node_id, {})
report.append(
{
"node_id": node_id,
"granted": tools,
"used": dict(used),
"unused": [t for t in tools if not used.get(t)],
"ungrated_but_used": [t for t in used if t not in tools],
}
)
return {
"workflow_id": workflow_id,
"runs_considered": len(run_ids),
"nodes": report,
"hint": "授权了却很多轮都没用过的工具,是白名单在慢慢放宽的信号,考虑从节点里摘掉",
}
@router.get("/exploratory-clusters")
async def exploratory_clusters(
limit: int = Query(default=200, le=500),
threshold: float = Query(default=0.62, ge=0.3, le=0.95),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""探索性问题聚类:同一个问题被反复问,就该长成模板里的固定一节。
这就是模板生长的触发器。用本地 embedding 做贪心聚类,
出现 ≥3 次的簇标记为晋升候选。
"""
from app.memory.embeddings import cosine, embed_texts
rows = list(
await session.execute(
select(Run.id, Run.input, Run.workflow_name, Run.created_at)
.where(Run.run_class != "formal")
.order_by(Run.created_at.desc())
.limit(limit)
)
)
items = []
for rid, input_payload, wf_name, created in rows:
text = json.dumps(input_payload or {}, ensure_ascii=False, sort_keys=True)
if len(text) <= 4: # 空输入没有聚类意义
continue
items.append({"run_id": rid, "text": text, "workflow": wf_name,
"at": created.isoformat() if created else None})
if not items:
return {"clusters": [], "considered": 0}
vectors = await embed_texts([i["text"] for i in items])
clusters: list[dict[str, Any]] = []
for i, item in enumerate(items):
placed = False
for cluster in clusters:
if cosine(vectors[i], vectors[cluster["seed"]]) >= threshold:
cluster["members"].append(item)
placed = True
break
if not placed:
clusters.append({"seed": i, "members": [item]})
out = [
{
"count": len(c["members"]),
"representative": c["members"][0]["text"][:200],
"workflow": c["members"][0]["workflow"],
"run_ids": [m["run_id"] for m in c["members"]][:20],
"first_at": c["members"][-1]["at"],
"last_at": c["members"][0]["at"],
"promote_candidate": len(c["members"]) >= 3,
}
for c in clusters
if len(c["members"]) >= 2
]
out.sort(key=lambda c: c["count"], reverse=True)
return {
"clusters": out,
"considered": len(items),
"hint": "标为晋升候选的问题反复出现,考虑用「提取模板」把它固定成工作流里的一步",
}