Files
AgentLab/backend/app/apps/tree.py
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

132 lines
6.3 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.
"""spec 的读法:页面树的遍历、表单引用、钉住项。检查、操作、派生、解析共用,说法只有一处。"""
from __future__ import annotations
import hashlib
from collections.abc import Collection, Iterator
from typing import Any
from app.core.artifact_store import canonical_json
#: 表单引用的写法:{"$ref": "#/forms/<名字>"}
FORM_REF_PREFIX = "#/forms/"
def components(spec: dict[str, Any]) -> Iterator[tuple[dict[str, Any], dict[str, Any], str]]:
"""每个组件:(组件, 所在页面, 在 spec 里的位置 pages[0].body[2].children[1]),深度优先、按出现顺序。"""
for pi, page in enumerate(spec.get("pages") or []):
if not isinstance(page, dict):
continue
yield from _walk(page.get("body") or [], page, f"pages[{pi}].body")
def _walk(items: list[Any], page: dict[str, Any], at: str) -> Iterator[tuple[dict[str, Any], dict[str, Any], str]]:
for i, comp in enumerate(items):
if not isinstance(comp, dict):
continue
here = f"{at}[{i}]"
yield comp, page, here
yield from _walk(comp.get("children") or [], page, f"{here}.children")
def page_of(spec: dict[str, Any], page_id: str) -> dict[str, Any] | None:
return next((p for p in spec.get("pages") or [] if isinstance(p, dict) and p.get("id") == page_id), None)
def resolve_form(spec: dict[str, Any], form: Any) -> dict[str, Any] | None:
"""内联的表单原样交回;{"$ref": "#/forms/x"} 换成 spec.forms.x;引用不到的是 None。"""
if not isinstance(form, dict):
return None
ref = form.get("$ref")
if ref is None:
return form
if isinstance(ref, str) and ref.startswith(FORM_REF_PREFIX):
found = (spec.get("forms") or {}).get(ref[len(FORM_REF_PREFIX):])
return found if isinstance(found, dict) else None
return None
def pins_by_alias(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {p["alias"]: p for p in spec.get("pins") or [] if isinstance(p, dict) and p.get("alias")}
def entry_of(spec: dict[str, Any], entry_id: str) -> dict[str, Any] | None:
return next((e for e in spec.get("entries") or [] if isinstance(e, dict) and e.get("id") == entry_id), None)
def spec_hash(spec: dict[str, Any]) -> str:
return hashlib.sha256(canonical_json(spec).encode()).hexdigest()
def app_level(pins: list[dict[str, Any]]) -> str:
"""应用的级别取各钉住项里最低的那一级:全部是受管级,应用才算受管级(A1)。没有钉住项的算已发布级。"""
levels = [p.get("level") for p in pins]
return "governed" if levels and all(lv == "governed" for lv in levels) else "published"
def bind_kind(bind: Any) -> str | None:
"""绑定的种类:bind 里出现哪个键就是哪种(spec.BINDING_KINDS)。"""
from app.apps.spec import BINDING_KINDS
if not isinstance(bind, dict):
return None
return next((k for k in BINDING_KINDS if k in bind), None)
def node_map(graph: Any) -> dict[str, dict[str, Any]]:
"""图里的节点:id → {type, title, config}。图的原始 JSON 形状(data.label / data.config)。"""
out: dict[str, dict[str, Any]] = {}
for n in (graph or {}).get("nodes") or []:
if isinstance(n, dict) and n.get("id"):
data = n.get("data") or {}
out[str(n["id"])] = {"type": n.get("type"), "title": data.get("label") or n["id"],
"config": data.get("config") or {}}
return out
#: 图里引用岗位的几处:人工节点派给谁、人工节点超时升级给谁、调用工具节点(写入动作)的发送确认派给谁
SLOT_ROLES = ("assignee", "escalation", "confirm")
def slot_refs(graph: Any, writes: Collection[str] = ()) -> list[dict[str, str]]:
"""图里引用到的岗位:[{slot, node_id, title, role}],role 见 SLOT_ROLES,按节点出现的顺序。派生(列出岗位)和检查
(岗位没声明、没绑定)共用这一份:以前两边都只认人工节点的派给谁,超时升级的岗位、动作确认的岗位一个也没列,发布后
升级、确认悄悄派给了任何人。
writes 是写外部系统的调用工具节点(pins.write_nodes,和运行时同一个认法):只有它们开发送确认卡,派给谁才算用到岗位。
只读的调用工具节点从不开确认卡,它写的(或从图级 defaults 继承来的)派给谁不算——不然派生多出用不到的岗位,检查说
「调用工具节点「取数」的发送确认派给它」,可取数从不要人确认。
取值规则和运行时一样(NodeContext.cfg):节点上没写的取图级 defaults。核实岗位(reconciler)不在图里——写入能力没登记
查询时才要,派生和检查按能力另算。"""
defaults = (graph or {}).get("defaults") or {}
out: list[dict[str, str]] = []
for nid, node in node_map(graph).items():
if node["type"] != "human" and not (node["type"] == "tool" and nid in writes):
continue
cfg = node["config"]
def pick(key: str) -> Any:
value = cfg.get(key)
return defaults.get(key) if value in (None, "") else value
assignee = pick("assignee")
if isinstance(assignee, dict) and assignee.get("kind") == "slot" and str(assignee.get("slot") or "").strip():
out.append({"slot": str(assignee["slot"]).strip(), "node_id": nid, "title": node["title"],
"role": "assignee" if node["type"] == "human" else "confirm"})
timeout = pick("on_timeout") if node["type"] == "human" else None
if isinstance(timeout, dict) and timeout.get("action") == "escalate" \
and str(timeout.get("to_slot") or "").strip():
out.append({"slot": str(timeout["to_slot"]).strip(), "node_id": nid, "title": node["title"],
"role": "escalation"})
return out
def input_fields(graph: Any) -> list[dict[str, Any]]:
"""入口节点声明的字段(原样,含新增的 type / enum / format / description)。"""
out: list[dict[str, Any]] = []
for node in node_map(graph).values():
if node["type"] == "input":
out += [f for f in node["config"].get("fields") or []
if isinstance(f, dict) and isinstance(f.get("name"), str) and f["name"].strip()]
return out