mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
d2b398563b
- 蓝图格式:写回卡片多一项 reconciler(一组人或组),旧蓝图没有这一项照旧可读;只收用户的操作(同「谁能看」),助手整张换写回卡片时留着用户选好的人 - 是否需要(templates/_common.needs_reconciler,和应用骨架加核实岗位同一个判断):模板用到写回卡片、选了目标能力、能力没登记查询;交给前端的蓝图带 reconciler_needed,界面不自己判 - 蓝图检查 bp.reconciler_unbound:试运行只提醒(待办派给试运行的人自己),发布挡住;发布单上应用检查说同一件事的 app.reconciler_unbound 和核实岗位的 app.slot_unbound 不再重复列 - 岗位绑定:选了的人绑到应用的核实岗位;能力补登了查询就不再要、也不再绑,卡片上留着的人照样摆出来、写明用不上 - 卡片:需要时摆这一项,标「缺」、写明为什么要人核实,选人控件只能选人或组;「缺」只挡发布;蓝图助手的提示词写明这一项由用户定 - 示例蓝图(核对写回)补上核实人,审批、核对写回的卡片说明写上这一项;测试和检查脚本跟上
280 lines
20 KiB
Python
280 lines
20 KiB
Python
"""蓝图的 bp_* 操作和卡片白名单(docs/design/normal-user-apps.md N2、§3.2、§3.13 bp.*)。
|
||
|
||
守的是:
|
||
- 每一项带来源和状态:用户写的「已确认」、助手写的「待确认」、写空的「缺」;确认只能由用户来做,值和来源不变;
|
||
- 各卡片的白名单:几个人同意只能是 any / all / quorum(quorum 要写人数);时限只收能换算成 ISO 8601 时长的说法;
|
||
金额线只许「字段 比较 常量」;写回只能选能力登记里的写入能力;字段对应只能取表单字段、指标、核对的行或常量;
|
||
「谁能看」只收用户的;反问只能助手问、用户答;
|
||
- bp_template 按模板预置还没填的卡片(来源「模板默认」);
|
||
- 不改入参;不合规的抛 BpRefused,带 bp.* 机器码;
|
||
- blueprint_issues:缺项、没对到数据、没人处理、超出模板能力、发布前没确认的项。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
|
||
import pytest
|
||
|
||
from app.blueprints.ops import BpRefused, apply_bp_op, blueprint_issues, duration_of
|
||
from app.blueprints.spec import empty_blueprint
|
||
|
||
|
||
def board() -> dict:
|
||
spec = apply_bp_op(empty_blueprint(), {"op": "bp_template", "template": {"id": "periodic_board", "version": 1}},
|
||
source="user")
|
||
return spec
|
||
|
||
|
||
def refused(spec: dict, op: dict, *, source: str = "assistant") -> BpRefused:
|
||
with pytest.raises(BpRefused) as e:
|
||
apply_bp_op(spec, op, source=source)
|
||
return e.value
|
||
|
||
|
||
def test_items_carry_source_and_status_and_input_is_not_mutated():
|
||
spec = empty_blueprint()
|
||
before = copy.deepcopy(spec)
|
||
out = apply_bp_op(spec, {"op": "bp_set", "path": "/purpose", "value": " 每周看销售额 "}, source="assistant")
|
||
assert spec == before
|
||
assert out["purpose"] == {"value": "每周看销售额", "source": "assistant", "status": "proposed", "why": None}
|
||
out = apply_bp_op(out, {"op": "bp_set", "path": "/purpose/why", "value": "用户第一句话说的"}, source="assistant")
|
||
assert out["purpose"]["why"] == "用户第一句话说的"
|
||
# 确认只能由用户来做:值和来源不变,只是状态变成已确认
|
||
assert "确认只能由你来做" in refused(out, {"op": "bp_set", "path": "/purpose/status", "value": "confirmed"}).message
|
||
confirmed = apply_bp_op(out, {"op": "bp_set", "path": "/purpose/status", "value": "confirmed"}, source="user")
|
||
assert confirmed["purpose"] == {**out["purpose"], "status": "confirmed"}
|
||
# 用户直接改:已确认,来源记用户;写空:缺
|
||
mine = apply_bp_op(out, {"op": "bp_set", "path": "/purpose", "value": "每天看"}, source="user")
|
||
assert (mine["purpose"]["source"], mine["purpose"]["status"]) == ("user", "confirmed")
|
||
cleared = apply_bp_op(mine, {"op": "bp_remove", "path": "/purpose"}, source="user")
|
||
assert (cleared["purpose"]["value"], cleared["purpose"]["status"]) == (None, "missing")
|
||
assert "没有值" in refused(cleared, {"op": "bp_set", "path": "/purpose/status", "value": "confirmed"},
|
||
source="user").message
|
||
|
||
|
||
def test_template_prefills_with_template_source():
|
||
spec = board()
|
||
assert spec["template"] == {"id": "periodic_board", "version": 1}
|
||
assert spec["cadence"]["source"] == "template" and spec["cadence"]["status"] == "proposed"
|
||
assert spec["cadence"]["value"]["preset"] == "weekly"
|
||
approval = apply_bp_op(empty_blueprint(), {"op": "bp_template", "template": {"id": "approval", "version": 1}},
|
||
source="assistant")
|
||
assert approval["people"][0]["policy"]["value"] == {"kind": "any"}
|
||
assert approval["people"][0]["who"]["status"] == "missing", "处理人要人来填,模板不替人定"
|
||
# 已经写了的不动
|
||
mine = apply_bp_op(empty_blueprint(), {"op": "bp_set", "path": "/cadence", "value": {
|
||
"preset": "daily", "time": "08:30", "tz": "Asia/Shanghai"}}, source="user")
|
||
again = apply_bp_op(mine, {"op": "bp_template", "template": {"id": "periodic_board", "version": 1}}, source="user")
|
||
assert again["cadence"]["value"]["time"] == "08:30"
|
||
e = refused(empty_blueprint(), {"op": "bp_template", "template": {"id": "nope", "version": 1}})
|
||
assert e.code == "bp.template_mismatch"
|
||
assert "第 1 版" in refused(empty_blueprint(), {"op": "bp_template", "template": {"id": "approval", "version": 9}}).message
|
||
|
||
|
||
def test_data_items_and_reuse_shape():
|
||
spec = apply_bp_op(board(), {"op": "bp_add", "path": "/data", "value": {
|
||
"id": "gmv", "label": "销售额", "unit": "元", "table": {"source": "shop", "table": "orders"},
|
||
"date": {"column": "paid_at", "rule": "按支付日"}}}, source="assistant")
|
||
item = spec["data"][0]
|
||
assert item["table"]["value"] == {"source": "shop", "table": "orders"} and item["table"]["status"] == "proposed"
|
||
assert item["measure"]["status"] == "missing"
|
||
assert "已经有 id" in refused(spec, {"op": "bp_add", "path": "/data", "value": {"id": "gmv"}}).message
|
||
assert "小写字母" in refused(spec, {"op": "bp_add", "path": "/data", "value": {"id": "GMV!"}}).message
|
||
assert "不能写这些" in refused(spec, {"op": "bp_add", "path": "/data", "value": {"id": "x", "sql": "select"}}).message
|
||
assert "数据源和表名" in refused(spec, {"op": "bp_set", "path": "/data/0/table", "value": {"source": "shop"}}).message
|
||
reuse = {"workflow_id": "wf", "version": 3, "node_id": "kpi", "metric_id": "gmv"}
|
||
spec = apply_bp_op(spec, {"op": "bp_set", "path": "/data/0/reuse", "value": reuse}, source="user")
|
||
assert spec["data"][0]["reuse"] == reuse
|
||
assert "版本号" in refused(spec, {"op": "bp_set", "path": "/data/0/reuse", "value": {**reuse, "version": "v3"}}).message
|
||
spec = apply_bp_op(spec, {"op": "bp_remove", "path": "/data/0/reuse"}, source="user")
|
||
assert spec["data"][0]["reuse"] is None
|
||
# 插到某个位置、删一项
|
||
spec = apply_bp_op(spec, {"op": "bp_add", "path": "/data/0", "value": {"id": "orders", "label": "订单数"}},
|
||
source="user")
|
||
assert [d["id"] for d in spec["data"]] == ["orders", "gmv"]
|
||
spec = apply_bp_op(spec, {"op": "bp_remove", "path": "/data/1"}, source="user")
|
||
assert [d["id"] for d in spec["data"]] == ["orders"]
|
||
assert "超出了列表的范围" in refused(spec, {"op": "bp_remove", "path": "/data/5"}).message
|
||
|
||
|
||
def test_cadence_whitelist():
|
||
spec = empty_blueprint()
|
||
ok = apply_bp_op(spec, {"op": "bp_set", "path": "/cadence", "value": {
|
||
"preset": "monthly", "day": 1, "time": "07:00", "tz": "asia/shanghai", "period": "last_month"}}, source="user")
|
||
assert ok["cadence"]["value"]["tz"] == "Asia/Shanghai"
|
||
for bad, words in (({"preset": "hourly", "time": "09:00", "tz": "Asia/Shanghai"}, "每天、每周或每月"),
|
||
({"preset": "weekly", "time": "09:00", "tz": "Asia/Shanghai"}, "周几"),
|
||
({"preset": "monthly", "day": 31, "time": "09:00", "tz": "Asia/Shanghai"}, "28"),
|
||
({"preset": "daily", "time": "9点", "tz": "Asia/Shanghai"}, "24 小时制"),
|
||
({"preset": "daily", "time": "09:00", "tz": "火星"}, "时区"),
|
||
({"preset": "daily", "time": "09:00", "tz": "UTC", "period": "last_year"}, "哪一期")):
|
||
assert words in refused(spec, {"op": "bp_set", "path": "/cadence", "value": bad}).message, bad
|
||
|
||
|
||
def test_people_whitelist():
|
||
spec = apply_bp_op(empty_blueprint(), {"op": "bp_add", "path": "/fields", "value": {
|
||
"name": "amount", "spec": {"type": "number", "title": "金额", "required": True}}}, source="user")
|
||
spec = apply_bp_op(spec, {"op": "bp_add", "path": "/people", "value": {
|
||
"id": "finance", "label": "财务", "who": [{"kind": "group", "name": "财务组"}], "policy": "all", "due": "48 小时",
|
||
"on_timeout": {"action": "escalate", "to": [{"kind": "user", "name": "director"}]},
|
||
"threshold": {"field": "amount", "op": ">", "value": 5000}}}, source="assistant")
|
||
step = spec["people"][0]
|
||
assert step["policy"]["value"] == {"kind": "all"}
|
||
assert step["due"]["value"] == "PT48H"
|
||
assert step["on_timeout"]["value"]["to"] == [{"kind": "user", "name": "director"}]
|
||
# 几个人同意:只能是 any / all / quorum,quorum 要写人数
|
||
assert apply_bp_op(spec, {"op": "bp_set", "path": "/people/0/policy", "value": {"kind": "quorum", "m": 2}},
|
||
source="user")["people"][0]["policy"]["value"] == {"kind": "quorum", "m": 2}
|
||
for bad in ({"kind": "majority"}, {"kind": "quorum"}, {"kind": "any", "m": 2}, {"kind": "quorum", "m": 0}):
|
||
assert "同意" in refused(spec, {"op": "bp_set", "path": "/people/0/policy", "value": bad}).message, bad
|
||
# 时限:能换算成 ISO 8601 时长的才收
|
||
assert [duration_of(v) for v in ("2 天", "1周", "30 分钟", "P2D", "pt4h", "3 个工作日", "明天")] == \
|
||
["P2D", "P1W", "PT30M", "P2D", "PT4H", None, None]
|
||
assert "时长" in refused(spec, {"op": "bp_set", "path": "/people/0/due", "value": "3 个工作日"}).message
|
||
# 超时:只能提醒、升级、驳回,升级要写给谁;没有「自动同意」
|
||
assert "升级给谁" in refused(spec, {"op": "bp_set", "path": "/people/0/on_timeout",
|
||
"value": {"action": "escalate"}}).message
|
||
assert "不会自动同意" in refused(spec, {"op": "bp_set", "path": "/people/0/on_timeout",
|
||
"value": {"action": "approve"}}).message
|
||
# 金额线:只许「字段 比较 常量」
|
||
for bad, words in (({"field": "amount * 2", "op": ">", "value": 1}, "字段名"),
|
||
({"field": "amount", "op": "in", "value": 1}, "比较"),
|
||
({"field": "amount", "op": ">", "value": {"x": 1}}, "不能写算式"),
|
||
({"field": "amount", "op": ">", "value": "5000"}, "文字只能比较相等"),
|
||
({"field": "kind", "op": "==", "value": "差旅"}, "不在「填什么」里"),
|
||
({"field": "amount", "op": ">", "value": 1, "and": "x"}, "字段 比较 常量")):
|
||
assert words in refused(spec, {"op": "bp_set", "path": "/people/0/threshold", "value": bad}).message, bad
|
||
# 处理人:岗位不和别的混着写,发起人不和别的混着写
|
||
assert "岗位" in refused(spec, {"op": "bp_set", "path": "/people/0/who", "value": [
|
||
{"kind": "slot", "name": "财务"}, {"kind": "user", "name": "zhang"}]}).message
|
||
assert "种类" in refused(spec, {"op": "bp_set", "path": "/people/0/who", "value": [{"kind": "role", "name": "x"}]}).message
|
||
|
||
|
||
def test_fields_follow_the_form_rules():
|
||
spec = apply_bp_op(empty_blueprint(), {"op": "bp_add", "path": "/fields", "value": {
|
||
"name": "kind", "spec": {"type": "string", "title": "类别", "enum": ["a", "b"], "x-enum-labels": ["甲", "乙"]}}},
|
||
source="assistant")
|
||
assert spec["fields"][0]["spec"]["status"] == "proposed"
|
||
assert "正则" in refused(spec, {"op": "bp_set", "path": "/fields/0/spec",
|
||
"value": {"type": "string", "pattern": "^a"}}).message
|
||
assert "统计期" in refused(spec, {"op": "bp_add", "path": "/fields", "value": {"name": "period_start"}}).message
|
||
assert "已经有一个" in refused(spec, {"op": "bp_add", "path": "/fields", "value": {"name": "kind"}}).message
|
||
assert "必填" in refused(spec, {"op": "bp_set", "path": "/fields/0/spec",
|
||
"value": {"type": "string", "required": "yes"}}).message
|
||
renamed = apply_bp_op(spec, {"op": "bp_set", "path": "/fields/0/name", "value": "类别"}, source="user")
|
||
assert renamed["fields"][0]["name"] == "类别"
|
||
assert apply_bp_op(spec, {"op": "bp_set", "path": "/fields/0/sensitive", "value": True},
|
||
source="user")["fields"][0]["sensitive"] is True
|
||
|
||
|
||
def test_writeback_only_takes_write_capabilities(monkeypatch):
|
||
from app.capabilities import registry
|
||
|
||
monkeypatch.setitem(registry._view, "bp_test_erp_post", {"effect": "write", "endpoint_id": "e1", "missing": False})
|
||
monkeypatch.setitem(registry._view, "bp_test_erp_get", {"effect": "read", "endpoint_id": "e1", "missing": False})
|
||
spec = apply_bp_op(empty_blueprint(), {"op": "bp_set", "path": "/writeback", "value": {
|
||
"capability": "bp_test_erp_post", "mapping": {"amount": "form.amount", "kind": "'报销'", "n": 1, "all": "rows"},
|
||
"confirm": True}}, source="assistant")
|
||
assert spec["writeback"]["capability"]["value"] == "bp_test_erp_post"
|
||
assert spec["writeback"]["mapping"]["status"] == "proposed"
|
||
assert "不是写入能力" in refused(spec, {"op": "bp_set", "path": "/writeback/capability",
|
||
"value": "bp_test_erp_get"}).message
|
||
assert "能力登记里没有" in refused(spec, {"op": "bp_set", "path": "/writeback/capability",
|
||
"value": "bp_test_nope"}).message
|
||
assert "取值只能是" in refused(spec, {"op": "bp_set", "path": "/writeback/mapping",
|
||
"value": {"amount": "input.amount + 1"}}).message
|
||
assert "是或否" in refused(spec, {"op": "bp_set", "path": "/writeback/confirm", "value": "yes"}).message
|
||
assert apply_bp_op(spec, {"op": "bp_remove", "path": "/writeback"}, source="user")["writeback"] is None
|
||
|
||
|
||
def test_the_reconciler_is_chosen_by_people_and_only_when_the_write_cannot_be_looked_up(monkeypatch):
|
||
"""写回的「结果不明时谁去核实」:目标能力没登记查询时才要(试运行只提醒,发布挡住);只收用户的、只能是人或组;
|
||
助手换写回卡片时留着用户选好的人;岗位绑定按它绑到应用的核实岗位,能力补登了查询就不再要、也不再绑。"""
|
||
from app.blueprints.templates._common import bindings_of, needs_reconciler
|
||
from app.capabilities import registry
|
||
|
||
monkeypatch.setitem(registry._view, "bp_test_erp_post", {"effect": "write", "endpoint_id": "e1", "missing": False})
|
||
spec = apply_bp_op(empty_blueprint(), {"op": "bp_template", "template": {"id": "approval", "version": 1}},
|
||
source="user")
|
||
spec = apply_bp_op(spec, {"op": "bp_set", "path": "/writeback", "value": {
|
||
"capability": "bp_test_erp_post", "mapping": {"amount": "form.reason"}, "confirm": True}}, source="assistant")
|
||
assert needs_reconciler(spec)
|
||
found = {i["code"]: i for i in blueprint_issues(spec, for_="publish")}
|
||
assert (found["bp.reconciler_unbound"]["level"], found["bp.reconciler_unbound"]["path"]) == (
|
||
"error", "/writeback/reconciler")
|
||
trial = {i["code"]: i for i in blueprint_issues(spec, for_="trial")}
|
||
assert trial["bp.reconciler_unbound"]["level"] == "warning", "试运行的待办派给试运行的人自己,不挡试运行"
|
||
|
||
finance = [{"kind": "group", "name": "财务组"}]
|
||
assert "要由你来定" in refused(spec, {"op": "bp_set", "path": "/writeback/reconciler", "value": finance}).message
|
||
assert "要由你来定" in refused(spec, {"op": "bp_set", "path": "/writeback", "value": {
|
||
"capability": "bp_test_erp_post", "reconciler": finance}}).message
|
||
assert "人或组" in refused(spec, {"op": "bp_set", "path": "/writeback/reconciler",
|
||
"value": [{"kind": "slot", "name": "财务"}]}, source="user").message
|
||
chosen = apply_bp_op(spec, {"op": "bp_set", "path": "/writeback/reconciler", "value": [*finance, *finance]},
|
||
source="user")
|
||
assert chosen["writeback"]["reconciler"] == {"value": finance, "source": "user", "status": "confirmed", "why": None}
|
||
assert not [i for i in blueprint_issues(chosen, for_="publish") if i["code"] == "bp.reconciler_unbound"]
|
||
assert bindings_of(chosen)["slots"]["reconciler"] == finance
|
||
# 助手换目标能力、字段对应:用户选好的人留着
|
||
kept = apply_bp_op(chosen, {"op": "bp_set", "path": "/writeback", "value": {
|
||
"capability": "bp_test_erp_post", "mapping": {"amount": "form.reason"}, "confirm": False}}, source="assistant")
|
||
assert kept["writeback"]["reconciler"]["value"] == finance and kept["writeback"]["confirm"]["value"] is False
|
||
# 补登了查询:平台自己查,不再要人核实,卡片上留着的人也不绑
|
||
monkeypatch.setitem(registry._view, "bp_test_erp_post", {"effect": "write", "endpoint_id": "e1", "missing": False,
|
||
"lookup": "bp_test_erp_get"})
|
||
assert not needs_reconciler(spec)
|
||
assert not [i for i in blueprint_issues(spec, for_="publish") if i["code"] == "bp.reconciler_unbound"]
|
||
assert "reconciler" not in bindings_of(chosen)["slots"]
|
||
|
||
|
||
def test_access_is_for_people_and_asks_go_one_way():
|
||
spec = empty_blueprint()
|
||
e = refused(spec, {"op": "bp_add", "path": "/access/use", "value": {"kind": "group", "name": "门店"}})
|
||
assert "授权是人的决定" in e.message
|
||
spec = apply_bp_op(spec, {"op": "bp_add", "path": "/access/use", "value": {"kind": "group", "name": "门店"}},
|
||
source="user")
|
||
assert spec["access"]["use"] == [{"kind": "group", "name": "门店"}]
|
||
assert "已经在这一档" in refused(spec, {"op": "bp_add", "path": "/access/use",
|
||
"value": {"kind": "group", "name": "门店"}}, source="user").message
|
||
assert "人或组" in refused(spec, {"op": "bp_add", "path": "/access/observe",
|
||
"value": {"kind": "slot", "name": "财务"}}, source="user").message
|
||
spec = apply_bp_op(spec, {"op": "bp_ask", "question": "销售额按下单日还是支付日算?", "options": ["按下单日", "按支付日"],
|
||
"path": "/data/0/date"}, source="assistant")
|
||
assert spec["asks"][0] == {"id": "q1", "question": "销售额按下单日还是支付日算?", "options": ["按下单日", "按支付日"],
|
||
"path": "/data/0/date", "answer": None}
|
||
assert "直接回答" in refused(spec, {"op": "bp_ask", "question": "?"}, source="user").message
|
||
assert "由你来回答" in refused(spec, {"op": "bp_set", "path": "/asks/0/answer", "value": "按支付日"}).message
|
||
spec = apply_bp_op(spec, {"op": "bp_set", "path": "/asks/0/answer", "value": "按支付日"}, source="user")
|
||
assert spec["asks"][0]["answer"] == "按支付日"
|
||
assert "bp_template" in refused(spec, {"op": "bp_set", "path": "/template", "value": {}}).message
|
||
assert "路径" in refused(spec, {"op": "bp_set", "path": "purpose", "value": "x"}).message
|
||
assert "只能用这几种操作" in refused(spec, {"op": "add_node"}).message
|
||
|
||
|
||
def test_blueprint_issues():
|
||
assert [i["code"] for i in blueprint_issues(empty_blueprint(), for_="trial")] == ["bp.missing"]
|
||
spec = board()
|
||
codes = {(i["code"], i["path"]) for i in blueprint_issues(spec, for_="trial")}
|
||
assert ("bp.missing", "/purpose") in codes and ("bp.missing", "/data") in codes
|
||
spec = apply_bp_op(spec, {"op": "bp_set", "path": "/purpose", "value": "看销售"}, source="assistant")
|
||
spec = apply_bp_op(spec, {"op": "bp_add", "path": "/data", "value": {"id": "gmv", "label": "销售额"}},
|
||
source="assistant")
|
||
trial = blueprint_issues(spec, for_="trial")
|
||
assert [(i["code"], i["path"]) for i in trial] == [("bp.data_unmapped", "/data/0/table")]
|
||
# 发布:助手和模板提的、还没确认的都要确认
|
||
publish = {(i["code"], i["path"]) for i in blueprint_issues(spec, for_="publish")}
|
||
assert {("bp.unconfirmed", "/purpose"), ("bp.unconfirmed", "/cadence"), ("bp.unconfirmed", "/data/0/label")} <= publish
|
||
# 模板用不到的卡片:照实说不会生效
|
||
spec = apply_bp_op(spec, {"op": "bp_add", "path": "/people", "value": {"id": "s1", "label": "审批"}}, source="user")
|
||
assert ("bp.template_mismatch", "/people") in {(i["code"], i["path"]) for i in blueprint_issues(spec, for_="trial")}
|
||
# 审批:处理人没定,试运行只提醒(派给试运行的人自己),发布要挡
|
||
approval = apply_bp_op(empty_blueprint(), {"op": "bp_template", "template": {"id": "approval", "version": 1}},
|
||
source="user")
|
||
trial = {i["code"]: i["level"] for i in blueprint_issues(approval, for_="trial") if i["code"] == "bp.people_unbound"}
|
||
publish = {i["code"]: i["level"] for i in blueprint_issues(approval, for_="publish") if i["code"] == "bp.people_unbound"}
|
||
assert trial == {"bp.people_unbound": "warning"} and publish == {"bp.people_unbound": "error"}
|
||
# 个人应用:模板不支持、或者有审批步骤的都不行
|
||
personal = {i["code"] for i in blueprint_issues(approval, for_="personal")}
|
||
assert "bp.template_mismatch" in personal
|