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