Files
ThearchyHelios 0b6cde5c0c 自由形应用的两处小问题:入口按钮「发起」后英文名空一格,岗位显示名不再拿岗位名充数
一、入口按钮写成了「发起accept 报销写回应用」:派生(apps/derive)、审批模板、自由形模板都是 f"发起{名字}" 直接拼。
加 apps/derive.start_label,名字以英文字母、数字开头时中间空一格(「发起 accept 报销写回应用」),三处都用它。

二、岗位标签显示原始名 finance:派生和自由形导入的图里只写了岗位名(画布上随手写的 id),应用里岗位的显示名就填成了它,
看着像起好的名字。加 apps/tree.slot_label:按用到这个岗位的节点在图上的名字拼(派给它的写节点名,几处用到用顿号连;
超时升级给它的写「…超时升级」,同蓝图模板 slot_labels 的叫法;动作确认派给它的写「…发送前确认」);节点没起名字、
没有节点用到它的照实用岗位名。派生和蓝图模板(app_base)共用:卡片上起了名字的(审批模板的步骤名)照旧用卡片上的,
卡片上就是岗位名本身的才按节点名拼。应用编辑器的岗位一节里,显示名和岗位名一样时写一句「还没有起显示名,现在用的是图里的
岗位名」(文案在 lib/terms 的 APP_EDITOR_TEXT.slotLabelRaw),不让人误以为那是起好的名字。

测试:test_blueprint_templates.py 的 test_entry_labels_keep_a_space_before_a_latin_name、
test_slot_labels_use_the_node_names_and_say_so_when_there_is_none、test_derived_apps_use_the_same_slot_labels_and_entry_label;
test_app_spec_tools.py::test_derive_a_process_leaves_slots_empty 原来钉的是旧行为(显示名 = finance),改成节点名「财务审批」。
去掉修复回跑四条都失败。
2026-10-17 12:00:58 +08:00

147 lines
7.5 KiB
Python
Raw Permalink 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, Iterable, 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
#: 按用到它的节点给岗位起显示名(slot_label):派给它的写节点名,超时升级给它的写「…超时升级」(同蓝图模板 slot_labels 的
#: 叫法),动作确认派给它的写「…发送前确认」
_SLOT_USE_LABEL = {"assignee": "{title}", "escalation": "{title}超时升级", "confirm": "{title}发送前确认"}
def slot_label(slot: str, refs: Iterable[dict[str, str]]) -> str:
"""岗位给人看的名字(应用里岗位的显示名)。图里只写了岗位名——画布上随手写的 id,常是英文(finance):以前显示名就是
它,看着像起好的名字。现在按用到它的节点在图上的名字拼(refs 是 slot_refs 的结果,只看这个岗位的那几条,几处用到用顿号
连);节点没起名字(名字就是节点 id)、或者没有节点用到它的,照实用岗位名——界面上显示名和岗位名一样时提示还没起名字。"""
names = [_SLOT_USE_LABEL[r["role"]].format(title=r["title"]) for r in refs
if r.get("slot") == slot and r.get("role") in _SLOT_USE_LABEL and r.get("title")
and r["title"] != r.get("node_id")]
return "、".join(dict.fromkeys(names)) or slot
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