mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:42:00 +00:00
自由形应用的两处小问题:入口按钮「发起」后英文名空一格,岗位显示名不再拿岗位名充数
一、入口按钮写成了「发起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),改成节点名「财务审批」。
去掉修复回跑四条都失败。
This commit is contained in:
@@ -17,7 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.apps.forms import input_schema
|
||||
from app.apps.spec import APP_FORMAT, parse_spec
|
||||
from app.apps.tree import input_fields, node_map, slot_refs
|
||||
from app.apps.tree import input_fields, node_map, slot_label, slot_refs
|
||||
|
||||
PIN = "main"
|
||||
VIEW = "latest"
|
||||
@@ -39,6 +39,13 @@ class DeriveRefused(Exception):
|
||||
self.code = code
|
||||
|
||||
|
||||
def start_label(name: str) -> str:
|
||||
"""流程类应用入口按钮的说法「发起…」。名字以英文字母、数字开头时中间空一格(「发起 accept 报销写回」),和界面上中英文
|
||||
之间留空格的写法一致;以前直接拼成「发起accept 报销写回」。派生、蓝图模板(审批、自由形)都用它。"""
|
||||
text = str(name or "").strip()
|
||||
return f"发起 {text}" if text[:1].isascii() and text[:1].isalnum() else f"发起{text}"
|
||||
|
||||
|
||||
def _safe(text: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]", "_", text)
|
||||
|
||||
@@ -102,8 +109,9 @@ async def _derive(session: AsyncSession, graph: dict[str, Any], *, title: str, n
|
||||
"pins": [{**pin, "use": "process" if process else "view"}],
|
||||
"slots": {}, "entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [],
|
||||
}
|
||||
for ref in slot_refs(graph, writes):
|
||||
spec["slots"].setdefault(ref["slot"], {"label": ref["slot"]})
|
||||
refs = slot_refs(graph, writes)
|
||||
for ref in refs:
|
||||
spec["slots"].setdefault(ref["slot"], {"label": slot_label(ref["slot"], refs)})
|
||||
for nid, node in humans.items():
|
||||
spec["task_ui"][nid] = {"title": node["title"]}
|
||||
if writes and not lookups:
|
||||
@@ -113,7 +121,7 @@ async def _derive(session: AsyncSession, graph: dict[str, Any], *, title: str, n
|
||||
names = list(spec["forms"][ENTRY]["properties"])
|
||||
first = next((n for n in names if n in spec["forms"][ENTRY]["required"]), names[0] if names else None)
|
||||
spec["entries"].append({
|
||||
"id": ENTRY, "label": f"发起{title}" if process else "按条件运行", "pin": PIN,
|
||||
"id": ENTRY, "label": start_label(title) if process else "按条件运行", "pin": PIN,
|
||||
"form": {"$ref": f"#/forms/{ENTRY}"}, "map": {n: f"form.{n}" for n in names},
|
||||
**({"instance_title": {"pattern": f"{title}·{{{first}}}", "fields": [first]}}
|
||||
if first and re.fullmatch(r"[A-Za-z0-9_㐀-鿿]+", first) else {}),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Collection, Iterator
|
||||
from collections.abc import Collection, Iterable, Iterator
|
||||
from typing import Any
|
||||
|
||||
from app.core.artifact_store import canonical_json
|
||||
@@ -121,6 +121,21 @@ def slot_refs(graph: Any, writes: Collection[str] = ()) -> list[dict[str, str]]:
|
||||
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]] = []
|
||||
|
||||
@@ -659,7 +659,7 @@ def app_base(bp: dict[str, Any], graph: dict[str, Any], *, use: str, name: str,
|
||||
writes 是写外部系统的调用工具节点 → 能力名(模板知道哪几个是写入,不用查库)。页面、视图、入口由各模板排。"""
|
||||
from app.apps.derive import PIN, RECONCILER
|
||||
from app.apps.spec import APP_FORMAT
|
||||
from app.apps.tree import node_map, slot_refs
|
||||
from app.apps.tree import node_map, slot_label, slot_refs
|
||||
from app.core.artifact_store import graph_hash
|
||||
|
||||
ctx = context(bp)
|
||||
@@ -673,9 +673,14 @@ def app_base(bp: dict[str, Any], graph: dict[str, Any], *, use: str, name: str,
|
||||
"format": APP_FORMAT, "name": name, "description": description, "icon": "app", "pins": [pin], "slots": {},
|
||||
"entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [],
|
||||
}
|
||||
# 岗位的显示名:卡片上起了名字的用它(审批模板的步骤名);卡片上就是岗位名本身的(处理人写的是岗位、自由形导入的图)按
|
||||
# 用到它的节点名拼(apps/tree.slot_label,同派生)
|
||||
labels = slot_labels(bp)
|
||||
for ref in slot_refs(graph, writes or {}):
|
||||
spec["slots"].setdefault(ref["slot"], {"label": labels.get(ref["slot"], ref["slot"])})
|
||||
refs = slot_refs(graph, writes or {})
|
||||
for ref in refs:
|
||||
label = labels.get(ref["slot"])
|
||||
spec["slots"].setdefault(ref["slot"], {"label": label if label and label != ref["slot"]
|
||||
else slot_label(ref["slot"], refs)})
|
||||
for nid, n in node_map(graph).items():
|
||||
if n["type"] == "human":
|
||||
spec["task_ui"][nid] = {"title": n["title"]}
|
||||
|
||||
@@ -50,7 +50,9 @@ def app_skeleton(bp: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]:
|
||||
writes = {WRITE: capability} if capability else {}
|
||||
spec = c.app_base(bp, graph, use="process", name=name, description=str(c.value(bp.get("purpose")) or ""),
|
||||
writes=writes)
|
||||
c.entry_and_pages(spec, bp, graph, label=f"发起{name}", writes=writes)
|
||||
from app.apps.derive import start_label
|
||||
|
||||
c.entry_and_pages(spec, bp, graph, label=start_label(name), writes=writes)
|
||||
return c.finish_app(spec)
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def needs_reconciler(bp: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def app_skeleton(bp: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]:
|
||||
from app.apps.derive import ENTRY, INSTANCE_VIEW, PIN, VIEW, _entries, _results
|
||||
from app.apps.derive import ENTRY, INSTANCE_VIEW, PIN, VIEW, _entries, _results, start_label
|
||||
from app.apps.forms import input_schema
|
||||
from app.apps.tree import input_fields, node_map
|
||||
|
||||
@@ -118,7 +118,7 @@ def app_skeleton(bp: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]:
|
||||
if fields:
|
||||
spec["forms"][ENTRY] = input_schema(fields)
|
||||
names = list(spec["forms"][ENTRY]["properties"])
|
||||
spec["entries"].append({"id": ENTRY, "label": f"发起{name}" if process else "按条件运行", "pin": PIN,
|
||||
spec["entries"].append({"id": ENTRY, "label": start_label(name) if process else "按条件运行", "pin": PIN,
|
||||
"form": {"$ref": f"#/forms/{ENTRY}"}, "map": {n: f"form.{n}" for n in names}})
|
||||
spec["views"][INSTANCE_VIEW] = {"source": {"kind": "instance"}}
|
||||
if not process:
|
||||
|
||||
@@ -344,7 +344,8 @@ async def test_derive_a_process_leaves_slots_empty(monkeypatch):
|
||||
async with SessionLocal() as session:
|
||||
spec = await derive_app(session, wf, 1, name="报销审批")
|
||||
assert spec["name"] == "报销审批" and spec["pins"][0]["use"] == "process"
|
||||
assert spec["slots"] == {"finance": {"label": "finance"}, "reconciler": {"label": "动作核实"}}
|
||||
# 岗位的显示名按用到它的节点名起(apps/tree.slot_label),不再拿岗位名 finance 充当显示名
|
||||
assert spec["slots"] == {"finance": {"label": "财务审批"}, "reconciler": {"label": "动作核实"}}
|
||||
# 流程的首页只放发起和我发起的:结果在实例页看,不派生「最近封存运行」(派生出来只会挂一块永远空着的印章)
|
||||
assert "latest" not in spec["views"] and spec["views"]["inst"]["source"]["kind"] == "instance"
|
||||
home = next(p for p in spec["pages"] if p["id"] == "home")
|
||||
|
||||
@@ -299,3 +299,52 @@ def test_freeform_people_rows_follow_the_imported_graph():
|
||||
stale = [i for i in issues if i["code"] == "bp.template_mismatch" and "gone" in i["message"]]
|
||||
assert stale and stale[0]["level"] == "warning"
|
||||
assert freeform.people_rows(c.with_graph(get_template("freeform").example(), freeform.example_graph())) == []
|
||||
|
||||
|
||||
def _people_graph() -> dict:
|
||||
"""两步审核派给岗位 finance(第一步超时升级给 boss),还有一步派给没起名字的节点用的岗位 ops。名字都是虚构的。"""
|
||||
return {"nodes": [
|
||||
c.node("start", "input", "发起", fields=[{"name": "amount", "type": "number"}]),
|
||||
c.node("a", "human", "初审", mode="approve", assignee={"kind": "slot", "slot": "finance"},
|
||||
on_timeout={"action": "escalate", "to_slot": "boss"}, due="PT1H"),
|
||||
c.node("b", "human", "复审", mode="approve", assignee={"kind": "slot", "slot": "finance"}),
|
||||
{"id": "h3", "type": "human", "position": {"x": 0, "y": 0},
|
||||
"data": {"config": {"mode": "approve", "assignee": {"kind": "slot", "slot": "ops"}}}},
|
||||
c.node("done", "output", "完成", fields=[])],
|
||||
"edges": [c.edge("start", "a"), c.edge("a", "b"), c.edge("b", "h3"), c.edge("h3", "done")]}
|
||||
|
||||
|
||||
def test_entry_labels_keep_a_space_before_a_latin_name():
|
||||
"""入口按钮「发起…」:名字以英文开头时中间空一格(验收时写成了「发起accept 报销写回应用」)。"""
|
||||
from app.apps.derive import start_label
|
||||
|
||||
assert start_label("accept 报销写回应用") == "发起 accept 报销写回应用"
|
||||
assert start_label("报销") == "发起报销" and start_label("2026 年报") == "发起 2026 年报"
|
||||
graph = _people_graph()
|
||||
inst = instantiate({**get_template("freeform").example()}, name="accept 报销写回应用", graph=graph)
|
||||
assert inst.app_spec["entries"][0]["label"] == "发起 accept 报销写回应用", inst.app_spec["entries"]
|
||||
approval = instantiate(get_template("approval").example(), name="erp 报销")
|
||||
assert approval.app_spec["entries"][0]["label"] == "发起 erp 报销"
|
||||
|
||||
|
||||
def test_slot_labels_use_the_node_names_and_say_so_when_there_is_none():
|
||||
"""应用里岗位的显示名:图里只写了岗位名(画布上随手写的 id,常是英文 finance),以前显示名就是它,看着像起好的名字。
|
||||
现在按用到它的节点在图上的名字拼(几处用到用顿号连、超时升级的写「…超时升级」);节点没起名字的照实用岗位名。蓝图卡片
|
||||
上起了名字的(审批模板的步骤名)照旧用它。"""
|
||||
inst = instantiate({**get_template("freeform").example()}, name="报销", graph=_people_graph())
|
||||
slots = inst.app_spec["slots"]
|
||||
assert slots["finance"]["label"] == "初审、复审", slots
|
||||
assert slots["boss"]["label"] == "初审超时升级" and slots["ops"]["label"] == "ops", slots
|
||||
approval = instantiate(get_template("approval").example())
|
||||
labels = {k: v["label"] for k, v in approval.app_spec["slots"].items()}
|
||||
assert all(labels[k] != k for k in ("lead", "finance", "finance_up")), labels
|
||||
|
||||
|
||||
async def test_derived_apps_use_the_same_slot_labels_and_entry_label():
|
||||
from app.apps.derive import derive_from_graph
|
||||
from app.db.base import SessionLocal
|
||||
|
||||
async with SessionLocal() as session:
|
||||
spec = await derive_from_graph(session, _people_graph(), name="accept 报销", blueprint_id="bp1")
|
||||
assert {k: v["label"] for k, v in spec["slots"].items()} == {"finance": "初审、复审", "boss": "初审超时升级", "ops": "ops"}
|
||||
assert spec["entries"][0]["label"] == "发起 accept 报销"
|
||||
|
||||
@@ -6089,6 +6089,8 @@ export const APP_EDITOR_TEXT = {
|
||||
slotAdd: '新增岗位',
|
||||
slotName: '岗位名',
|
||||
slotLabel: '显示名',
|
||||
/** 显示名和岗位名一样:派生时没有节点名可用(服务端 apps/tree.slot_label),照实用的是岗位名,不是起好的名字 */
|
||||
slotLabelRaw: '还没有起显示名,现在用的是图里的岗位名,可以改成一个好认的名字',
|
||||
slotUnbound: '未绑定:发布检查会拦下流程应用',
|
||||
slotRemove: (name: string) => `删除岗位「${name}」`,
|
||||
grants: '授权',
|
||||
|
||||
@@ -60,6 +60,7 @@ export function AccessSection() {
|
||||
setBindings((b) => { const next = { ...b.slots }; delete next[name]; return { ...b, slots: next } })
|
||||
}} />
|
||||
</div>
|
||||
{slot.label.trim() === name && <p className="text-2xs text-faint" data-app-slot-raw="">{T.slotLabelRaw}</p>}
|
||||
<PrincipalPicker label={T.slots} value={bound as Picked[]}
|
||||
onChange={(v) => setBindings((b) => ({
|
||||
...b, slots: { ...b.slots, [name]: v.filter((x) => x.kind !== 'everyone') as { kind: 'user' | 'group'; name: string }[] },
|
||||
|
||||
Reference in New Issue
Block a user