Files
AgentLab/backend/tests/test_blueprint_templates.py
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

351 lines
22 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.
"""场景模板与实例化(docs/design/normal-user-apps.md N3、§3.3、§3.4)。
每个模板用一张完整的示例蓝图实例化:
- 生成关节没填时:画布校验(engine/schema.validate_graph)过得了,关节标「待填」,问题里是 tpl.joint_unfilled(P3);
- 骨架比对没有差异;生成关节都填上之后,发布前检查(governance.publish_issues,已发布级)没有 error;
- 应用草稿合 agentlab.app/1 的形状,固定版本还没有版本号;定时任务、岗位绑定按卡片来。
另外守:骨架比对认得出关节以外的改动;卡片改了,那一组生成关节的依据变了,之前填的就不算数;模板登记齐全。
"""
from __future__ import annotations
import copy
import pytest
from app.blueprints.instantiate import filled_from, generated_groups, instantiate, registry_problems, skeleton_changes
from app.blueprints.templates import GENERATED_JOINT_KINDS, TEMPLATE_IDS, check_registry, get_template
from app.blueprints.templates import _common as c
from app.engine.governance import publish_issues
from app.engine.schema import GraphSpec, validate_graph
def _graph_for(template_id: str):
if get_template(template_id).imports_graph:
from app.blueprints.templates.freeform import example_graph
return example_graph()
return None
def test_registry_is_complete():
assert registry_problems() == []
assert check_registry() == [], "启动核对(check_registry)走的就是 registry_problems 这一份"
assert all(get_template(t) is not None for t in TEMPLATE_IDS)
for t in TEMPLATE_IDS:
template = get_template(t)
assert template.applies_when and template.cards and template.skeleton
assert all(note.startswith(("必填", "可选")) for note in template.cards.values()), t
# 示例蓝图、预置、每组关节的说明、模板自己认的问题都登记在 TemplateDef 上,没有旁路登记
assert callable(template.example) and callable(template.example_fill), t
assert template.imports_graph == (t == "freeform")
assert not hasattr(c, "EXTRAS")
def test_a_template_without_an_example_is_reported(monkeypatch):
"""模板定义、实例化、测试三样缺一样启动即报错:少了示例蓝图的模板,check_registry 照实报出来。"""
import dataclasses
from app.blueprints.templates import _REGISTRY
monkeypatch.setitem(_REGISTRY, "approval", dataclasses.replace(get_template("approval"), example=None))
assert check_registry() == ["模板「approval」没有示例蓝图"]
@pytest.mark.parametrize("template_id", TEMPLATE_IDS)
def test_every_template_instantiates_validates_and_publishes(template_id):
extras = get_template(template_id)
example = extras.example()
graph = _graph_for(template_id)
pending = instantiate(example, graph=graph, workflow_id="wf_1", schedule_id="sch_1")
assert not [i for i in validate_graph(GraphSpec.model_validate(pending.graph)).issues if i.level == "error"]
generated = [j for j in pending.joint_defs if j.kind in GENERATED_JOINT_KINDS]
assert {p["joint"] for p in pending.problems if p["code"] == "tpl.joint_unfilled"} == {j.id for j in generated}
assert all(pending.joints[j.id]["status"] == "pending" for j in generated)
assert skeleton_changes(pending.skeleton, pending.graph, pending.joint_defs) == []
full = instantiate(example, filled=extras.example_fill(), graph=graph, workflow_id="wf_1", schedule_id="sch_1")
assert full.problems == [], full.problems
assert all(state["status"] == "filled" for state in full.joints.values())
assert set(extras.example_fill()) == {j.id for j in generated}, "示例把每个生成关节都填上了"
assert skeleton_changes(full.skeleton, full.graph, full.joint_defs) == []
errors = [(i.code, i.message) for i in publish_issues(GraphSpec.model_validate(full.graph), level="published")
if i.level == "error"]
assert errors == []
# 应用草稿:固定在这个工作流上,还没有版本号(发布单发布工作流之后换成那一版)
pin = full.app_spec["pins"][0]
assert pin["workflow_id"] == "wf_1" and pin["version"] is None
from app.core.artifact_store import graph_hash
assert pin["graph_hash"] == graph_hash(full.graph)
assert full.app_spec["pages"] and full.app_spec["pages"][0]["id"] == "home"
# 同一张蓝图实例化两次,图一模一样(图哈希、试运行对齐都靠它)
assert instantiate(example, filled=extras.example_fill(), graph=graph, workflow_id="wf_1",
schedule_id="sch_1").graph == full.graph
def test_board_details():
extras = get_template("periodic_board")
inst = instantiate(extras.example(), filled=extras.example_fill(), schedule_id="sch_1", blueprint_id="bp_1")
nodes = {n["id"]: n for n in inst.graph["nodes"]}
assert nodes["fetch_shop"]["data"]["config"]["tool"] == "db_query__shop"
contract = nodes["done"]["data"]["config"]["contract"]
assert contract == {"metrics_from": ["caliber"], "required": ["gmv", "orders", "refund_rate"], "strict": True}
from app.engine.governance import board_only
assert board_only(contract, GraphSpec.model_validate(inst.graph), "done"), "按指标出具"
assert inst.schedule["cron"] == "0 9 * * 1" and inst.schedule["enabled"] is False
assert inst.schedule["input_template"]["period_start"] == "{{ schedule.last_week_start }}"
# 没有工作流时固定在蓝图上;首页看定时任务出的数,按条件查看是按需视图
assert inst.app_spec["pins"][0]["blueprint"] == "bp_1"
assert inst.app_spec["views"]["latest"]["source"] == {"kind": "latest_sealed", "pin": "main", "from": "schedule:sch_1",
"params": None, "ttl_s": None, "auto": False}
assert inst.app_spec["views"]["custom"]["source"]["kind"] == "on_demand"
home = [comp["type"] for comp in inst.app_spec["pages"][0]["body"]]
assert home[0] == "stamp" and "section" in home and "table" in home
groups = generated_groups(inst)
assert list(groups) == ["fetch_shop"]
assert "'{{ input.period_start }}'" in inst.briefs["fetch_shop"] and "销售额" in inst.briefs["fetch_shop"]
def test_board_with_reused_caliber_and_two_sources():
spec = get_template("periodic_board").example()
spec["data"].append({"id": "visits", "label": {"value": "访客数", "source": "user", "status": "confirmed", "why": None},
"unit": {"value": "人", "source": "user", "status": "confirmed", "why": None},
"table": {"value": {"source": "crm", "table": "visits"}, "source": "user",
"status": "confirmed", "why": None},
"measure": {"value": "count(distinct uid)", "source": "user", "status": "confirmed", "why": None},
"date": {"value": None, "source": "user", "status": "missing", "why": None},
"filter": {"value": None, "source": "user", "status": "missing", "why": None},
"grain": {"value": None, "source": "user", "status": "missing", "why": None}, "reuse": None})
spec["data"].append({**copy.deepcopy(spec["data"][0]), "id": "aov", "table": {
"value": None, "source": "user", "status": "missing", "why": None},
"reuse": {"workflow_id": "wf_old", "version": 2, "node_id": "kpi", "metric_id": "aov",
"inputs": [{"node": "fetch", "tool": "db_query__shop", "columns": ["gmv", "order_cnt"]}]}})
inst = instantiate(spec)
groups = generated_groups(inst)
# 复用的口径卡读原工作流的 fetch:新图里 shop 的取数节点就叫 fetch,本地算 shop 上的数也在这一组
assert sorted(groups) == ["fetch", "fetch_crm"]
assert {j.id for j in groups["fetch"]} == {"sql_fetch", "metric_gmv", "metric_orders", "metric_refund_rate"}
nodes = {n["id"]: n for n in inst.graph["nodes"]}
assert nodes["reuse_aov"]["data"]["config"]["caliber_from"] == {"workflow_id": "wf_old", "workflow_version": 2,
"node_id": "kpi"}
assert "aov" in nodes["done"]["data"]["config"]["contract"]["required"]
assert "gmv、order_cnt" in inst.briefs["fetch"]
assert any(comp["id"] == "home_reused" for comp in inst.app_spec["pages"][0]["body"])
# 复用的口径卡没核对过读哪些列:照实说对不上
spec["data"][-1]["reuse"].pop("inputs")
assert any(p["code"] == "bp.data_unmapped" for p in instantiate(spec).problems)
def test_approval_details():
inst = instantiate(get_template("approval").example())
nodes = {n["id"]: n for n in inst.graph["nodes"]}
finance = nodes["review_finance"]["data"]["config"]
assert finance["assignee"] == {"kind": "slot", "slot": "finance"} and finance["policy"] == "all"
assert finance["due"] == "PT48H" and finance["on_timeout"] == {"action": "escalate", "to_slot": "finance_up"}
assert nodes["gate_finance"]["data"]["config"]["cases"][0]["condition"] == "input.amount > 5000"
edges = {(e["source"], e["target"], e.get("sourceHandle")) for e in inst.graph["edges"]}
# 金额线以下的直接跳过财务会签
assert ("gate_finance", "approved", "default") in edges and ("gate_finance", "review_finance", "hit") in edges
assert ("review_lead", "rejected", "rejected") in edges
assert inst.bindings == {"slots": {"lead": [{"kind": "user", "name": "zhang"}],
"finance": [{"kind": "group", "name": "财务组"}],
"finance_up": [{"kind": "user", "name": "director"}]}}
assert set(inst.app_spec["slots"]) == {"lead", "finance", "finance_up"}
assert inst.app_spec["forms"]["start"]["required"] == ["amount", "reason"]
assert inst.app_spec["entries"][0]["map"] == {"amount": "form.amount", "reason": "form.reason"}
assert inst.schedule is None
# 确定性关节按卡片换:改了金额线,分支跟着变;骨架比对只许关节位置不同
other = get_template("approval").example()
other["people"][1]["threshold"]["value"]["value"] = 8000
changed = instantiate(other)
assert {n["id"]: n for n in changed.graph["nodes"]}["gate_finance"]["data"]["config"]["cases"][0]["condition"] \
== "input.amount > 8000"
def test_approval_writeback_confirms_with_last_step(monkeypatch):
spec = get_template("approval").example()
item = c.template_item
spec["writeback"] = {"capability": item("erp_reimburse"), "mapping": item({"amount": "form.amount", "kind": "'报销'"}),
"confirm": item(True)}
inst = instantiate(spec)
write = {n["id"]: n for n in inst.graph["nodes"]}["writeback"]["data"]["config"]
assert write == {"tool": "erp_reimburse", "args": {"amount": "{{ input.amount }}", "kind": "报销"},
"approval": "always", "assignee": {"kind": "slot", "slot": "finance"}}
edges = {(e["source"], e["target"], e.get("sourceHandle")) for e in inst.graph["edges"]}
assert ("review_finance", "writeback", "approved") in edges and ("writeback", "approved", None) in edges
# 写入能力没登记查询:要有人核实结果未知的写入
assert "reconciler" in inst.app_spec["slots"]
spec["writeback"]["mapping"] = item({"amount": "metric.total"})
assert any(p["code"] == "bp.template_mismatch" for p in instantiate(spec).problems)
def test_records_routes_submit_and_summary():
from app.records import RECORDS_APPEND
extras = get_template("records")
inst = instantiate(extras.example(), records={"table": "loss", "source": "records_loss"}, blueprint_id="bp_9")
nodes = {n["id"]: n for n in inst.graph["nodes"]}
append = nodes["append"]["data"]["config"]
assert append["tool"] == RECORDS_APPEND and append["approval"] == "never"
assert append["args"] == {"table": "loss", "values": {"store": "{{ input.store }}", "amount": "{{ input.amount }}",
"reason": "{{ input.reason }}"}}
assert nodes["summary_records_loss"]["data"]["config"]["tool"] == "db_query__records_loss"
# 入口节点上表单字段都不是必填(汇总那条路不带表单),必填由应用的入口表单把关
fields = {f["name"]: f for f in nodes["start"]["data"]["config"]["fields"]}
assert not any(f.get("required") for f in fields.values())
assert inst.app_spec["forms"]["start"]["required"] == ["store", "amount"]
assert inst.app_spec["entries"][0]["map"]["mode"] == "'submit'"
assert inst.schedule["input_template"]["mode"] == "summary"
assert inst.app_spec["pages"][0]["body"][0]["type"] == "records_list"
# 汇总看板不放逐行表格(会把每个人的记录列给所有人)
assert "table" not in [comp["type"] for comp in inst.app_spec["pages"][0]["body"]]
def test_exception_and_reconcile_details():
inst = instantiate(get_template("exception_handling").example())
nodes = {n["id"]: n for n in inst.graph["nodes"]}
assert nodes["handle"]["data"]["config"]["mode"] == "input"
assert nodes["handle"]["data"]["config"]["assignee"] == {"kind": "slot", "slot": "owner"}
assert nodes["review"]["data"]["config"]["assignee"] == {"kind": "slot", "slot": "区域经理"}
assert nodes["gate"]["data"]["config"]["cases"][0]["condition"] == "nodes.parse.row_count > 0"
# 只看清单(个人应用能用的那一部分):没有人工节点
spec = get_template("exception_handling").example()
spec["people"] = []
assert not [n for n in instantiate(spec).graph["nodes"] if n["type"] == "human"]
inst = instantiate(get_template("reconcile_writeback").example())
write = {n["id"]: n for n in inst.graph["nodes"]}["writeback"]["data"]["config"]
assert write["args"] == {"items": "{{ nodes.parse.rows | compact }}", "total": "{{ nodes.caliber.metrics[1].value }}"}
assert write["assignee"] == {"kind": "slot", "slot": "check"}
assert [j.id for j in generated_groups(inst)["fetch_shop"]] == \
["sql_fetch_shop", "metric_diff_count", "metric_diff_amount", "sql_detail"]
def test_skeleton_changes_catch_anything_but_joints():
extras = get_template("periodic_board")
inst = instantiate(extras.example(), filled=extras.example_fill())
joints = inst.joint_defs
graph = copy.deepcopy(inst.graph)
nodes = {n["id"]: n for n in graph["nodes"]}
nodes["fetch_shop"]["data"]["config"]["args"]["sql"] = "SELECT 1 AS gmv" # 关节位置:不算
assert skeleton_changes(inst.skeleton, graph, joints) == []
nodes["fetch_shop"]["data"]["config"]["tool"] = "db_query__other"
nodes["done"]["data"]["config"]["contract"]["strict"] = False # 出具契约是确定性关节:模型改了也算越界
graph["nodes"].append(c.node("extra", "llm", "多出来的"))
graph["edges"] = [e for e in graph["edges"] if e["target"] != "done"]
messages = [p["message"] for p in skeleton_changes(inst.skeleton, graph, joints)]
assert any("tool" in m for m in messages) and any("多出了节点" in m for m in messages)
assert any("删除了连线" in m for m in messages)
assert all(p["code"] == "tpl.skeleton_changed" for p in skeleton_changes(inst.skeleton, graph, joints))
def test_filled_values_go_stale_when_the_cards_change():
extras = get_template("periodic_board")
spec = extras.example()
inst = instantiate(spec, filled=extras.example_fill())
joints = inst.joints
assert filled_from(spec, joints) == extras.example_fill()
# 改了「怎么算」:这一组的说明变了,之前填的 SQL 和指标式都不算数
spec["data"][0]["measure"]["value"] = "sum(amount) - sum(refund)"
assert filled_from(spec, joints) == {}
# 只改目的:说明没变,照旧算数
spec = extras.example()
spec["purpose"]["value"] = "换个说法"
assert filled_from(spec, joints) == extras.example_fill()
def test_trial_inputs_follow_the_cadence():
from datetime import datetime, timezone
from app.blueprints.instantiate import trial_inputs
inputs = trial_inputs(get_template("periodic_board").example(), at=datetime(2026, 10, 15, 3, tzinfo=timezone.utc))
assert inputs == {"period": "2026-W41", "period_start": "2026-10-05", "period_end": "2026-10-11"}
assert trial_inputs(get_template("approval").example()) == {}
def test_freeform_people_rows_follow_the_imported_graph():
"""自由形的「谁来做」按导入的图列(05-已知遗留 (d)):同一个岗位用在几处合成一行、说明写全;发起人、表达式派单的步骤是
只读行;没有人工节点和写入的图一行都没有。卡片上留着、图里已经没有的岗位照实提醒,不静默删掉。"""
from app.blueprints.ops import blueprint_issues
from app.blueprints.templates import freeform
graph = {"nodes": [
c.node("start", "input", "发起", fields=[{"name": "q"}]),
c.node("a", "human", "初审", mode="approve", assignee={"kind": "slot", "slot": "finance"},
on_timeout={"action": "escalate", "to_slot": "director"}, due="PT1H"),
c.node("b", "human", "复审", mode="approve", assignee={"kind": "slot", "slot": "finance"}),
c.node("me", "human", "本人确认", mode="approve", assignee={"kind": "initiator"}),
c.node("calc", "human", "按金额派", mode="approve", assignee={"kind": "expression", "expression": "input.q"}),
c.node("done", "output", "完成", fields=[])],
"edges": [c.edge("start", "a"), c.edge("a", "b"), c.edge("b", "me"), c.edge("me", "calc"), c.edge("calc", "done")]}
spec = {**get_template("freeform").example(), "people": [
{"id": "finance", "who": c.user_item([{"kind": "user", "name": "zhang"}])},
{"id": "gone", "who": c.user_item([{"kind": "user", "name": "li"}])}]}
rows = freeform.people_rows(c.with_graph(spec, graph))
assert [(r["kind"], r["slot"], r["path"]) for r in rows] == [
("slot", "finance", "/people/0/who"), ("slot", "director", "/people/1/who"),
("initiator", None, None), ("expression", None, None)]
assert rows[0]["label"] == "「初审」、「复审」" and rows[0]["detail"] == "「初审」处理;「复审」处理"
assert rows[1]["detail"] == "「初审」超时升级" and rows[0]["who"] == [{"kind": "user", "name": "zhang"}]
issues = blueprint_issues(spec, for_="publish", graph=graph)
unbound = [i for i in issues if i["code"] == "bp.people_unbound"]
assert [(i["path"], i["slot"], i["level"]) for i in unbound] == [("/people/1/who", "director", "error")]
assert [i["level"] for i in blueprint_issues(spec, for_="trial", graph=graph) if i["code"] == "bp.people_unbound"] \
== ["warning"], "试运行时待办派给试运行的人自己,不挡"
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 报销"