mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
写回卡片加「结果不明时谁去核实」:写回的目标能力没登记查询时,结果不明(请求发出去了、没拿到答复)平台查不了、也不自动重发,要有人去目标系统里核实;以前发布单报「写入结果不明时没人核实」「有一步还没指定谁来做」却在卡片上找不到地方改。
- 蓝图格式:写回卡片多一项 reconciler(一组人或组),旧蓝图没有这一项照旧可读;只收用户的操作(同「谁能看」),助手整张换写回卡片时留着用户选好的人 - 是否需要(templates/_common.needs_reconciler,和应用骨架加核实岗位同一个判断):模板用到写回卡片、选了目标能力、能力没登记查询;交给前端的蓝图带 reconciler_needed,界面不自己判 - 蓝图检查 bp.reconciler_unbound:试运行只提醒(待办派给试运行的人自己),发布挡住;发布单上应用检查说同一件事的 app.reconciler_unbound 和核实岗位的 app.slot_unbound 不再重复列 - 岗位绑定:选了的人绑到应用的核实岗位;能力补登了查询就不再要、也不再绑,卡片上留着的人照样摆出来、写明用不上 - 卡片:需要时摆这一项,标「缺」、写明为什么要人核实,选人控件只能选人或组;「缺」只挡发布;蓝图助手的提示词写明这一项由用户定 - 示例蓝图(核对写回)补上核实人,审批、核对写回的卡片说明写上这一项;测试和检查脚本跟上
This commit is contained in:
@@ -59,7 +59,7 @@ BLUEPRINT_SYSTEM_PROMPT = """\
|
||||
"sensitive":false};type 只能是 string、number、integer、boolean、array,x-widget 可写 textarea、select、date 这类控件
|
||||
- /writeback 写回哪里:{"capability":"能力登记里的写入能力","mapping":{"参数":"form.字段 | metric.指标 id | rows | '常量'"},
|
||||
"confirm":true}
|
||||
- 谁能看(/access)由用户自己在卡片上定,你不填
|
||||
- 谁能看(/access)、写回的结果不明时谁去核实(/writeback/reconciler)由用户自己在卡片上定,你不填
|
||||
|
||||
规则:
|
||||
- 只提议:你写的每一项都是「待确认」,用户确认了才算数;用户已经确认过的项不要改,除非他这一轮明确要改
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
- /asks:bp_ask 追加一个反问(只有助手问),/asks/<i>/answer 记用户的回答;
|
||||
- 选模板只走 bp_template:记下模板和版本,按模板预置还没填的卡片(来源记「模板默认」、状态「待确认」)。
|
||||
|
||||
「谁能看」(access)只收用户的操作:授权是人的决定,助手提的没有「待确认」可挂(P2)。
|
||||
「谁能看」(access)和写回的「结果不明时谁去核实」(/writeback/reconciler)只收用户的操作:授权、派活给谁是人的决定,
|
||||
助手提的没有「待确认」可挂(P2)。
|
||||
落地前后都按 agentlab.blueprint/1 的形状核一遍(spec.parse_blueprint),不合形状的一律拒。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -45,9 +46,17 @@ POLICY_KINDS = ("any", "all", "quorum")
|
||||
TIMEOUT_ACTIONS = ("remind", "escalate", "reject")
|
||||
#: 金额线只许「字段 比较 常量」
|
||||
THRESHOLD_OPS = (">", ">=", "<", "<=", "==", "!=")
|
||||
#: 「谁能看」的三档和能授权的对象
|
||||
#: 「谁能看」的三档和能授权的对象(写回的核实人也只能是人或组:绑到应用的核实岗位)
|
||||
ACCESS_KINDS = ("use", "observe", "manage")
|
||||
GRANTEE_KINDS = ("user", "group")
|
||||
#: 写回卡片上的项
|
||||
WRITEBACK_KEYS = ("capability", "mapping", "confirm", "reconciler")
|
||||
#: 只收用户操作的位置(JSON 指针的前几段)和助手动它时交回的话(按位置的最后一段认)
|
||||
USER_ONLY = (("access",), ("writeback", "reconciler"))
|
||||
_USER_ONLY_TEXT = {
|
||||
"access": "「谁能看」要由你来定:授权是人的决定,助手不替你填",
|
||||
"reconciler": "「结果不明时谁去核实」要由你来定:派活给谁是人的决定,助手不替你选",
|
||||
}
|
||||
#: 入口字段里模板自己要用的名字(统计期),「填什么」不能占
|
||||
RESERVED_FIELDS = frozenset({"period", "period_start", "period_end", "mode"})
|
||||
#: 列表的上限:再多就不是普通人在卡片上能判断对错的颗粒度了
|
||||
@@ -363,6 +372,18 @@ def _grantees(value: Any, _spec: dict[str, Any]) -> Any:
|
||||
return [_principal(v, GRANTEE_KINDS, "授权的对象") for v in value]
|
||||
|
||||
|
||||
def _reconciler(value: Any, _spec: dict[str, Any]) -> Any:
|
||||
"""结果不明时谁去核实:一组人或组(绑到应用的核实岗位,核实待办派给他们)。"""
|
||||
if not isinstance(value, list) or len(value) > 20:
|
||||
raise BpRefused("核实的人要写成一组人或组(最多 20 个)")
|
||||
out: list[dict[str, str]] = []
|
||||
for v in value:
|
||||
p = _principal(v, GRANTEE_KINDS, "核实的人")
|
||||
if p not in out:
|
||||
out.append(p)
|
||||
return out or None
|
||||
|
||||
|
||||
#: 各项的检查。键是 (卡片, 项);卡片本身就是一项的写 (卡片, None)
|
||||
_CHECKS: dict[tuple[str, str | None], Callable[[Any, dict[str, Any]], Any]] = {
|
||||
("purpose", None): _text(500, "目的"),
|
||||
@@ -383,6 +404,7 @@ _CHECKS: dict[tuple[str, str | None], Callable[[Any, dict[str, Any]], Any]] = {
|
||||
("writeback", "capability"): _capability,
|
||||
("writeback", "mapping"): _mapping,
|
||||
("writeback", "confirm"): _confirm,
|
||||
("writeback", "reconciler"): _reconciler,
|
||||
}
|
||||
|
||||
|
||||
@@ -441,12 +463,22 @@ def _new_entry(card: str, raw: Any, source: str, spec: dict[str, Any]) -> dict[s
|
||||
|
||||
|
||||
def _writeback(raw: Any, source: str, spec: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""整张写回卡片换掉。核实人只由用户定:助手写了要拒;没写的沿用卡片上原来的(助手改目标能力、字段对应时,不能把
|
||||
用户选好的人冲掉)。"""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict) or set(raw) - {"capability", "mapping", "confirm"}:
|
||||
raise BpRefused("「写回哪里」只能写目标能力、字段对应和要不要人确认")
|
||||
return {key: _item(_checked("writeback", key, raw.get(key), spec), source)
|
||||
for key in ("capability", "mapping", "confirm")}
|
||||
if not isinstance(raw, dict) or set(raw) - set(WRITEBACK_KEYS):
|
||||
raise BpRefused("「写回哪里」只能写目标能力、字段对应、要不要人确认和结果不明时谁去核实")
|
||||
if "reconciler" in raw and source != "user":
|
||||
raise BpRefused(_USER_ONLY_TEXT["reconciler"])
|
||||
out = {key: _item(_checked("writeback", key, raw.get(key), spec), source)
|
||||
for key in ("capability", "mapping", "confirm")}
|
||||
if "reconciler" in raw:
|
||||
out["reconciler"] = _item(_checked("writeback", "reconciler", raw["reconciler"], spec), source)
|
||||
else:
|
||||
before = (spec.get("writeback") or {}).get("reconciler")
|
||||
out["reconciler"] = copy.deepcopy(before) if isinstance(before, dict) else _item(None, "template")
|
||||
return out
|
||||
|
||||
|
||||
def _set_item_part(item: dict[str, Any], part: str, value: Any, source: str) -> None:
|
||||
@@ -481,9 +513,9 @@ def _container(spec: dict[str, Any], tokens: list[str]) -> tuple[dict[str, Any],
|
||||
if card == "fields" and len(tokens) == 3 and tokens[2] == "spec":
|
||||
entries = spec.get("fields") or []
|
||||
return entries[_index(tokens[1], len(entries))], "spec"
|
||||
if card == "writeback" and len(tokens) == 2 and tokens[1] in ("capability", "mapping", "confirm"):
|
||||
if card == "writeback" and len(tokens) == 2 and tokens[1] in WRITEBACK_KEYS:
|
||||
if spec.get("writeback") is None:
|
||||
spec["writeback"] = {k: _item(None, "template") for k in ("capability", "mapping", "confirm")}
|
||||
spec["writeback"] = {k: _item(None, "template") for k in WRITEBACK_KEYS}
|
||||
return spec["writeback"], tokens[1]
|
||||
raise BpRefused(f"不能改这个位置:/{'/'.join(tokens)}")
|
||||
|
||||
@@ -642,8 +674,8 @@ def apply_bp_op(spec: dict[str, Any], op: dict[str, Any], *, source: str) -> dic
|
||||
tokens = parse_pointer(parsed.path)
|
||||
if tokens[0] not in _LABEL or tokens[0] == "template":
|
||||
raise BpRefused(f"没有这张卡片:/{tokens[0]}(选模板用 bp_template)")
|
||||
if tokens[0] == "access" and source != "user":
|
||||
raise BpRefused("「谁能看」要由你来定:授权是人的决定,助手不替你填")
|
||||
if source != "user" and (only := next((k for k in USER_ONLY if tuple(tokens[:len(k)]) == k), None)):
|
||||
raise BpRefused(_USER_ONLY_TEXT[only[-1]])
|
||||
if parsed.op == "bp_set":
|
||||
_set(out, tokens, parsed.value, source)
|
||||
elif parsed.op == "bp_add":
|
||||
@@ -720,6 +752,15 @@ def blueprint_issues(spec: dict[str, Any], *, for_: str) -> list[dict[str, Any]]
|
||||
add("bp.missing", "/writeback", "「写回哪里」还没定:要写回哪个系统、哪些字段")
|
||||
elif isinstance(wb, dict) and _value(wb.get("capability")) is not None and _value(wb.get("mapping")) is None:
|
||||
add("bp.missing", "/writeback/mapping", "写回的字段对应还没写:目标系统的每个参数取哪个字段或指标")
|
||||
if "writeback" in used and for_ != "personal" and isinstance(wb, dict) and _value(wb.get("reconciler")) is None:
|
||||
from app.blueprints.templates._common import needs_reconciler
|
||||
|
||||
if needs_reconciler(spec):
|
||||
# 目标能力没登记查询:写回的结果不明时平台查不了、也不自动重发,要有人去目标系统里核实。试运行时写入走测试
|
||||
# 环境或演练、待办派给试运行的人自己,不挡试运行,只挡发布(同 bp.people_unbound)
|
||||
add("bp.reconciler_unbound", "/writeback/reconciler",
|
||||
f"「{_value(wb.get('capability'))}」没登记查询:写回的结果不明时(请求发出去了、没拿到答复)要有人去目标系统里"
|
||||
"核实,还没定是谁", level="error" if publish else "warning")
|
||||
|
||||
for card in ("data", "people", "fields"):
|
||||
if spec.get(card) and card not in used:
|
||||
@@ -818,14 +859,14 @@ def _items(spec: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
||||
out.append((f"/fields/{i}/spec", f["spec"]))
|
||||
wb = spec.get("writeback")
|
||||
if isinstance(wb, dict):
|
||||
out += [(f"/writeback/{k}", wb[k]) for k in ("capability", "mapping", "confirm") if isinstance(wb.get(k), dict)]
|
||||
out += [(f"/writeback/{k}", wb[k]) for k in WRITEBACK_KEYS if isinstance(wb.get(k), dict)]
|
||||
return out
|
||||
|
||||
|
||||
_PART_LABEL = {"label": "名称", "unit": "单位", "table": "对到的表", "measure": "怎么算", "date": "业务日期",
|
||||
"filter": "筛选条件", "grain": "粒度", "who": "处理人", "policy": "几个人同意", "due": "时限",
|
||||
"on_timeout": "超时怎么办", "threshold": "条件", "spec": "字段", "capability": "目标能力",
|
||||
"mapping": "字段对应", "confirm": "要不要人确认"}
|
||||
"mapping": "字段对应", "confirm": "要不要人确认", "reconciler": "结果不明时谁去核实"}
|
||||
|
||||
|
||||
def _where(path: str) -> str:
|
||||
|
||||
@@ -71,6 +71,8 @@ GRAPH_CHANGED = "plan.graph_changed"
|
||||
UNTRIALED = "plan.untrialed"
|
||||
TRIAL_FAILED = "plan.trial_failed"
|
||||
TRIAL_DIFFERS = "plan.trial_differs"
|
||||
#: 蓝图检查:写回的结果不明时没人核实(blueprints/ops.blueprint_issues)
|
||||
RECONCILER_UNBOUND = "bp.reconciler_unbound"
|
||||
ADMIN_REQUIRED = "plan.admin_required"
|
||||
DOWNGRADE_ADMIN = "plan.downgrade_admin"
|
||||
PREFLIGHT = "plan.preflight"
|
||||
@@ -343,6 +345,13 @@ async def _preflight(session: AsyncSession, bp: Blueprint, target: _Target, *, l
|
||||
issues = await lint_app(session, spec, _draft_bindings(bp, target), previous=target.previous,
|
||||
dispositions=options.get("dispositions") or {},
|
||||
pins=await _simulated_pins(session, spec, target, workflow_id=workflow_id, level=level))
|
||||
if any(i.get("code") == RECONCILER_UNBOUND for i in out):
|
||||
# 写回的结果不明时没人核实:蓝图检查已经指到卡片上那一项,应用检查说的是同一件事(核实岗位没绑、写入没人核实),
|
||||
# 不再多列两条——那两条说的岗位在卡片上找不到
|
||||
from app.apps.derive import RECONCILER
|
||||
|
||||
issues = [i for i in issues if i.get("code") != "app.reconciler_unbound"
|
||||
and not (i.get("code") == "app.slot_unbound" and i.get("path") == f"slots.{RECONCILER}")]
|
||||
out += [{**i, "source": "app"} for i in issues]
|
||||
# 授权:给的人和组要在名录里(同授权接口)
|
||||
_, problem = await grants_problem(session, grant_items(bp))
|
||||
|
||||
@@ -78,6 +78,9 @@ class Writeback(_Strict):
|
||||
capability: Item = Field(default_factory=Item)
|
||||
mapping: Item = Field(default_factory=Item) # {参数: "form.x" | "metric.y" | 常量}
|
||||
confirm: Item = Field(default_factory=Item) # 要不要人确认(布尔)
|
||||
# 结果不明时谁去核实([{kind: user|group, name}]):目标能力没登记查询时才要,绑到应用的核实岗位(apps/derive.RECONCILER)。
|
||||
# 只由用户定(同「谁能看」)。旧蓝图没有这一项,读进来是空的
|
||||
reconciler: Item = Field(default_factory=Item)
|
||||
|
||||
|
||||
class Access(_Strict):
|
||||
|
||||
@@ -377,7 +377,10 @@ async def blueprint_view(session: AsyncSession, blueprint: Any) -> dict[str, Any
|
||||
|
||||
def blueprint_out(blueprint: Any, *, issues: bool = True, extra: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
"""一张蓝图交给前端的样子(frontend/src/typesBlueprint.ts 的 Blueprint)。关节只给状态和模板声明的说法、类型、分组
|
||||
(JointState:status、by、note、label、kind、group),不给填的值(值在图上看)。"""
|
||||
(JointState:status、by、note、label、kind、group),不给填的值(值在图上看)。reconciler_needed:写回卡片上要不要
|
||||
摆「结果不明时谁去核实」(目标能力没登记查询,templates/_common.needs_reconciler),界面不自己判。"""
|
||||
from app.blueprints.templates._common import needs_reconciler
|
||||
|
||||
joints = {jid: {k: v for k, v in state.items() if k in ("status", "by", "note", "label", "kind", "group")}
|
||||
for jid, state in (blueprint.joints or {}).items() if isinstance(state, dict)}
|
||||
out = {
|
||||
@@ -386,7 +389,7 @@ def blueprint_out(blueprint: Any, *, issues: bool = True, extra: list[dict[str,
|
||||
"template_version": blueprint.template_version, "source": blueprint.source or {},
|
||||
"conversation_id": blueprint.conversation_id, "workflow_id": blueprint.workflow_id, "app_id": blueprint.app_id,
|
||||
"last_trial_run_id": blueprint.last_trial_run_id, "graph_hash": blueprint.graph_hash, "joints": joints,
|
||||
"copied_from": blueprint.copied_from,
|
||||
"copied_from": blueprint.copied_from, "reconciler_needed": needs_reconciler(blueprint.spec or {}),
|
||||
"created_at": blueprint.created_at.isoformat() if blueprint.created_at else None,
|
||||
"updated_at": blueprint.updated_at.isoformat() if blueprint.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -352,6 +352,12 @@ def bindings_of(bp: dict[str, Any]) -> dict[str, Any]:
|
||||
timeout = value(step.get("on_timeout"))
|
||||
if isinstance(timeout, dict):
|
||||
bind(escalate_slot(step), timeout.get("to") or [])
|
||||
if needs_reconciler(bp):
|
||||
# 写回的结果不明时谁去核实:只在目标能力没登记查询时绑(应用骨架这时才声明核实岗位,app_base)。补登了查询的,
|
||||
# 卡片上留着的人不再绑:平台自己查,用不上
|
||||
from app.apps.derive import RECONCILER
|
||||
|
||||
bind(RECONCILER, value((bp.get("writeback") or {}).get("reconciler")) or [])
|
||||
return {"slots": slots}
|
||||
|
||||
|
||||
@@ -422,6 +428,18 @@ def write_lookup(capability: str) -> bool:
|
||||
return bool(view.get("lookup"))
|
||||
|
||||
|
||||
def needs_reconciler(bp: dict[str, Any]) -> bool:
|
||||
"""「写回哪里」要不要定「结果不明时谁去核实」:模板用到写回卡片、选了目标能力、这项能力没登记查询(和应用骨架加核实
|
||||
岗位同一个判断,app_base)。结果不明时平台不自动重发,查不了就只能派人去目标系统里看。蓝图检查、岗位绑定、交给前端的
|
||||
蓝图(reconciler_needed)共用这一份。"""
|
||||
ref = bp.get("template")
|
||||
template = get_template(str(ref.get("id") or "")) if isinstance(ref, dict) else None
|
||||
if template is None or "writeback" not in template.cards:
|
||||
return False
|
||||
capability = value((bp.get("writeback") or {}).get("capability"))
|
||||
return bool(capability) and not write_lookup(str(capability))
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 看哪些数:按取数节点分组
|
||||
# ==========================================================================
|
||||
|
||||
@@ -105,7 +105,7 @@ TEMPLATE = register(TemplateDef(
|
||||
cards={"purpose": "必填:一句话说清审批什么",
|
||||
"fields": "必填:申请人要填的字段(名称、类型、必填、上下限、选项)",
|
||||
"people": "必填:每一步谁处理、几个人同意算通过、时限、超时给谁;某一步只在金额超过某个数时才走,写条件",
|
||||
"writeback": "可选:通过后写回哪个系统、哪些字段、要不要人确认",
|
||||
"writeback": "可选:通过后写回哪个系统、哪些字段、要不要人确认;能力没登记查询的,还要定结果不明时谁去核实",
|
||||
"access": "可选:谁能发起、谁能看全部、谁能管理"},
|
||||
personal=None,
|
||||
skeleton=skeleton, app_skeleton=app_skeleton, schedule=None, tags=["流程", "审批"],
|
||||
|
||||
@@ -168,7 +168,7 @@ def example() -> dict[str, Any]:
|
||||
"fields": [],
|
||||
"writeback": {"capability": item("fake_ledger_adjust"),
|
||||
"mapping": item({"items": "rows", "total": "metric.diff_amount"}),
|
||||
"confirm": item(True)},
|
||||
"confirm": item(True), "reconciler": item([{"kind": "group", "name": "财务组"}])},
|
||||
"access": {"use": [{"kind": "group", "name": "财务组"}], "observe": [], "manage": []}, "asks": [],
|
||||
}
|
||||
|
||||
@@ -193,7 +193,8 @@ TEMPLATE = register(TemplateDef(
|
||||
"data": "必填:差异的指标(差异笔数、差异金额),对到同一个库里的表",
|
||||
"cadence": "必填:多久核对一次,统计哪一期",
|
||||
"people": "必填:谁复核(只有一步)",
|
||||
"writeback": "必填:写回哪个系统(能力登记里的写入能力)、字段对应(差异明细写 rows)、要不要人确认",
|
||||
"writeback": "必填:写回哪个系统(能力登记里的写入能力)、字段对应(差异明细写 rows)、要不要人确认;"
|
||||
"能力没登记查询的,还要定结果不明时谁去核实",
|
||||
"access": "可选:谁能用、谁能看全部、谁能管理"},
|
||||
personal=None,
|
||||
skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["流程", "定时", "写回"],
|
||||
|
||||
@@ -332,6 +332,9 @@ PLAIN.update({
|
||||
"在「看哪些数」里点「改」重新选表;表刚建的话,先到「数据」页读取一次表结构。"),
|
||||
"bp.people_unbound": Plain(
|
||||
"有一步还没定谁来处理", "流程走到这一步就没人接手。", _Y, "在「谁来做」里选人、组或岗位。"),
|
||||
"bp.reconciler_unbound": Plain(
|
||||
"写回的结果不明时没人核实", "这项写入能力没登记查询:结果不明时平台查不了,也不会自动重发,要有人去目标系统里看写进去没有。",
|
||||
_Y, "在「写回哪里」卡片的「结果不明时谁去核实」里选人或组。"),
|
||||
"bp.template_mismatch": Plain(
|
||||
"卡片上写的超出了这个场景能做的", "超出的那部分不会生效,做出来的应用和你说的对不上。", _Y,
|
||||
"按提示删掉或改小,或者换一个场景。"),
|
||||
|
||||
@@ -187,6 +187,47 @@ def test_writeback_only_takes_write_capabilities(monkeypatch):
|
||||
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": "门店"}})
|
||||
|
||||
@@ -202,6 +202,65 @@ async def test_a_process_with_an_unbound_step_stops_at_preflight(client):
|
||||
assert "沿用" in summary["save_version"] and "流程类" in summary["first_run"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def erp_write():
|
||||
"""一项写入能力:绑定登记端点的 POST,没登记查询(结果未知时平台查不了,要人核实)。"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.capabilities import registry
|
||||
from app.db.models import Capability, CustomTool, Endpoint
|
||||
|
||||
tag = uuid.uuid4().hex[:6]
|
||||
name = f"erp_claim_{tag}"
|
||||
async with SessionLocal() as session:
|
||||
erp = Endpoint(name=f"erp_{tag}", environments={"prod": {"base_url": "https://erp.example.com", "headers": {}}})
|
||||
session.add(erp)
|
||||
await session.flush()
|
||||
session.add(CustomTool(name=name, kind="http", parameters={"type": "object", "properties": {}},
|
||||
config={"endpoint": erp.id, "method": "POST", "path": "/claims"}))
|
||||
await session.commit()
|
||||
await registry.sync_all(session)
|
||||
try:
|
||||
yield name
|
||||
finally:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(CustomTool).where(CustomTool.name == name))
|
||||
await session.execute(delete(Endpoint).where(Endpoint.name == f"erp_{tag}"))
|
||||
await session.execute(delete(Capability).where(Capability.name == name))
|
||||
await session.commit()
|
||||
await registry.refresh_cache(session)
|
||||
|
||||
|
||||
async def test_the_reconciler_comes_from_the_writeback_card(client, erp_write):
|
||||
"""审批通过后写回一项没登记查询的能力:结果不明时要人核实。发布单只列一条、指到「写回哪里」卡片上那一项(应用检查说的
|
||||
核实岗位没绑、写入没人核实是同一件事,不再重复列);在卡片上选了人,绑到应用的核实岗位,这几条都不再有。"""
|
||||
from app.blueprints.templates import get_template
|
||||
|
||||
bp = (await client.post("/api/blueprints", json={"source": {"kind": "template", "id": "approval"},
|
||||
"scope": "shared"})).json()
|
||||
example = get_template("approval").example()
|
||||
await s.edit_spec(bp["id"], **{k: example[k] for k in ("purpose", "people", "fields", "access")},
|
||||
writeback={"capability": s.item(erp_write), "mapping": s.item({"amount": "form.amount"}),
|
||||
"confirm": s.item(True), "reconciler": s.item(None, status="missing")})
|
||||
assert (await client.get(f"/api/blueprints/{bp['id']}")).json()["reconciler_needed"] is True
|
||||
|
||||
def about_reconciler(plan: dict) -> list[str]:
|
||||
return sorted(p["code"] for p in plan["problems"] if p["code"] in ("bp.reconciler_unbound", "app.reconciler_unbound")
|
||||
or (p["code"] == "app.slot_unbound" and p.get("path") == "slots.reconciler"))
|
||||
|
||||
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
||||
assert plan["ok"] is False and about_reconciler(plan) == ["bp.reconciler_unbound"]
|
||||
(problem,) = [p for p in plan["problems"] if p["code"] == "bp.reconciler_unbound"]
|
||||
assert problem["who"] == "you" and problem["path"] == "/writeback/reconciler"
|
||||
assert "结果不明时谁去核实" in problem["plain"]["action"]
|
||||
|
||||
r = await client.patch(f"/api/blueprints/{bp['id']}", json={"ops": [
|
||||
{"op": "bp_set", "path": "/writeback/reconciler", "value": [{"kind": "group", "name": "财务组"}]}]})
|
||||
assert r.status_code == 200, r.text
|
||||
plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json()
|
||||
assert about_reconciler(plan) == [], [p["code"] for p in plan["problems"]]
|
||||
|
||||
|
||||
async def test_a_failed_step_stops_and_resume_does_not_redo_what_is_done(client, tmp_path, monkeypatch):
|
||||
from app.blueprints import publish_plan as plans
|
||||
|
||||
|
||||
@@ -204,6 +204,7 @@ export const ITEM_LABEL: Record<string, string> = {
|
||||
capability: '写到哪个系统',
|
||||
mapping: '字段对应',
|
||||
confirm: '要不要人确认',
|
||||
reconciler: '结果不明时谁去核实',
|
||||
cadence: '多久更新、统计哪一期',
|
||||
use: '可以使用',
|
||||
observe: '可以查看全部',
|
||||
@@ -351,6 +352,10 @@ export const BP_EDIT_TEXT = {
|
||||
addField: '加一个字段',
|
||||
addFieldPrompt: '字段叫什么(例如「金额」)',
|
||||
addWriteback: '要写回系统',
|
||||
/** 写回卡片上「结果不明时谁去核实」的说明:目标能力没登记查询时才摆(服务端 Blueprint.reconciler_needed) */
|
||||
reconcilerHint: '这项写入能力没登记查询:写回的结果不明时(请求发出去了、没拿到答复),平台查不了,也不会自动重发,'
|
||||
+ '要有人去目标系统里看写进去没有。只能由你来选。',
|
||||
reconcilerUnused: '这项写入能力已经登记了查询,结果不明时平台自己去查:这一项用不上了,发布时不会派给他们。',
|
||||
removeTitle: (where: string) => `删掉${where}?`,
|
||||
removeConfirm: '删掉',
|
||||
template: '换一个场景',
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* 条件这些选填的空着也是缺,只看项的状态会把它们都标成缺;「待确认」是助手、模板、目录提的、你还没看过的项。界面不自己判
|
||||
* 哪些项必填(P7)。
|
||||
*
|
||||
* 写回卡片上「结果不明时谁去核实」只在目标能力没登记查询时摆(服务端 Blueprint.reconciler_needed),只能由人选(同「谁能看」)。
|
||||
*
|
||||
* 每一项可以点「修改」:改完发一个 bp_set(值是纯值,服务端记成来源「你」、已确认);待确认的可以直接点「确认」(只改状态,
|
||||
* 来源不变),一张卡片上待确认的不止一项时卡头另有「确认全部」,一次修改带上这几项的状态(服务端整批落或整批不落)。
|
||||
* 加一项是 bp_add(卡片字段写纯值),删一项是 bp_remove。对到数据的项写表、中文名、粒度、业务日期和
|
||||
@@ -29,6 +31,9 @@ import type {
|
||||
import { SELF_RECORDS, ValueEditor, valueText, type EditorKind, type EditorScope } from './editors'
|
||||
import { isItem, missingPaths, visibleCards, type CardKey } from './shared'
|
||||
|
||||
/** 卡片上还没有的一项(旧蓝图的写回卡片没有「结果不明时谁去核实」):按缺摆出来,改了服务端才建 */
|
||||
const UNSET: BpItem = { value: null, source: 'template', status: 'missing' }
|
||||
|
||||
const STATUS_COLOR: Record<ItemStatus, string> = {
|
||||
confirmed: 'var(--st-done)', proposed: 'var(--st-waiting)', missing: 'var(--st-failed)',
|
||||
}
|
||||
@@ -207,8 +212,12 @@ function CardFor({ card, ctx, template }: { card: CardKey; ctx: CardCtx; templat
|
||||
}
|
||||
case 'writeback': {
|
||||
const wb = spec.writeback
|
||||
// 结果不明时谁去核实:目标能力没登记查询时才摆(服务端说要不要);以前选过、后来能力补登了查询的照样摆出来,写明用不上
|
||||
const reconciler: BpItem = wb?.reconciler ?? UNSET
|
||||
const chosen = Array.isArray(reconciler.value) && reconciler.value.length > 0
|
||||
const askReconciler = !!wb && (!!ctx.bp.reconciler_needed || chosen)
|
||||
const items: [string, BpItem][] = wb ? [['/writeback/capability', wb.capability], ['/writeback/mapping', wb.mapping],
|
||||
['/writeback/confirm', wb.confirm]] : []
|
||||
['/writeback/confirm', wb.confirm], ...(askReconciler ? [['/writeback/reconciler', reconciler] as [string, BpItem]] : [])] : []
|
||||
return (
|
||||
<Card card={card} note={note} issues={issues} status={cardStatus(ctx, card, items)}
|
||||
actions={!wb ? (
|
||||
@@ -222,6 +231,15 @@ function CardFor({ card, ctx, template }: { card: CardKey; ctx: CardCtx; templat
|
||||
<ItemRow ctx={ctx} path="/writeback/capability" item={wb.capability} label={ITEM_LABEL.capability} kind="text" />
|
||||
<ItemRow ctx={ctx} path="/writeback/mapping" item={wb.mapping} label={ITEM_LABEL.mapping} kind="mapping" />
|
||||
<ItemRow ctx={ctx} path="/writeback/confirm" item={wb.confirm} label={ITEM_LABEL.confirm} kind="bool" />
|
||||
{askReconciler && (
|
||||
<>
|
||||
<ItemRow ctx={ctx} path="/writeback/reconciler" item={reconciler} label={ITEM_LABEL.reconciler} kind="grantees" />
|
||||
<p className="pl-[7.5rem] text-2xs leading-relaxed text-dim"
|
||||
data-bp-reconciler-hint={ctx.bp.reconciler_needed ? 'needed' : 'unused'}>
|
||||
{ctx.bp.reconciler_needed ? BP_EDIT_TEXT.reconcilerHint : BP_EDIT_TEXT.reconcilerUnused}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</SubBlock>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -25,8 +25,11 @@ import { TrialSection } from './TrialSection'
|
||||
import { blueprintPath, trialed } from './shared'
|
||||
|
||||
const SECTIONS = ['cards', 'trial'] as const
|
||||
/** 只挡发布、不挡试运行的问题:待确认的项、岗位没人(试运行时人工步骤派给试运行的人自己,服务端 blueprints/ops) */
|
||||
const PUBLISH_ONLY = new Set(['bp.unconfirmed', 'bp.people_unbound'])
|
||||
/**
|
||||
* 只挡发布、不挡试运行的问题:待确认的项、岗位没人、写回结果不明时没人核实(试运行时人工步骤派给试运行的人自己,写入走
|
||||
* 测试环境或演练,服务端 blueprints/ops)
|
||||
*/
|
||||
const PUBLISH_ONLY = new Set(['bp.unconfirmed', 'bp.people_unbound', 'bp.reconciler_unbound'])
|
||||
type Section = (typeof SECTIONS)[number]
|
||||
|
||||
/** 模板说明整站一份,进一次蓝图页取一次就够 */
|
||||
|
||||
@@ -65,10 +65,12 @@ export function visibleCards(spec: BlueprintSpec | null | undefined, templateCar
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端蓝图检查(Blueprint.issues)里说「缺」的那几种:缺必填项、没对到表、岗位没人。卡片上的「缺」以它为准——项的状态
|
||||
* 默认就是缺(单位、筛选条件这些可以不填),只看项的状态会把选填的都标成缺
|
||||
* 服务端蓝图检查(Blueprint.issues)里说「缺」的那几种:缺必填项、没对到表、岗位没人、写回结果不明时没人核实。卡片上的
|
||||
* 「缺」以它为准——项的状态默认就是缺(单位、筛选条件这些可以不填),只看项的状态会把选填的都标成缺
|
||||
*/
|
||||
export const MISSING_CODES = new Set(['bp.missing', 'bp.data_unmapped', 'bp.people_unbound', 'tpl.joint_unfilled'])
|
||||
export const MISSING_CODES = new Set([
|
||||
'bp.missing', 'bp.data_unmapped', 'bp.people_unbound', 'bp.reconciler_unbound', 'tpl.joint_unfilled',
|
||||
])
|
||||
|
||||
/** 卡片上标「缺」的位置:服务端给了检查结果时按它;老服务端没给时退回项本身的状态(null) */
|
||||
export function missingPaths(bp: Blueprint | null | undefined): Set<string> | null {
|
||||
|
||||
@@ -49,6 +49,11 @@ export interface BpWriteback {
|
||||
capability: BpItem<string>
|
||||
mapping: BpItem<Record<string, string>>
|
||||
confirm: BpItem<boolean>
|
||||
/**
|
||||
* 结果不明时谁去核实(一组人或组):目标能力没登记查询时才要(Blueprint.reconciler_needed),发布时绑到应用的核实岗位。
|
||||
* 只由用户定,助手的改动服务端不收。旧蓝图没有这一项
|
||||
*/
|
||||
reconciler?: BpItem<{ kind: 'user' | 'group'; name: string }[]>
|
||||
}
|
||||
|
||||
export interface BpAccess {
|
||||
@@ -122,6 +127,11 @@ export interface Blueprint {
|
||||
graph_hash: string | null
|
||||
joints: Record<string, JointState>
|
||||
copied_from: string | null
|
||||
/**
|
||||
* 写回卡片上要不要摆「结果不明时谁去核实」:模板用到写回、选了目标能力、这项能力没登记查询(服务端
|
||||
* templates/_common.needs_reconciler,界面不自己判)。老服务端不给时按不要
|
||||
*/
|
||||
reconciler_needed?: boolean
|
||||
/** 蓝图检查(bp.* / tpl.*),带白话 */
|
||||
issues?: PlainIssue[]
|
||||
created_at: string
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// 应用首页的「新建应用」链到 /apps/create;
|
||||
// - 卡片:卡头标状态(缺按服务端检查、待确认、已确认),对到数据的项写表、中文名、粒度、业务日期、为什么;「修改」发 bp_set
|
||||
// (纯值),确认只改状态,「确认全部」一次带上整张卡片待确认的几项,加一项发 bp_add(纯值);缺项时「用真数试一次」置灰并写清缺什么;
|
||||
// - 写回卡片:目标能力没登记查询时摆「结果不明时谁去核实」(服务端 reconciler_needed),缺时只挡发布,选人发 bp_set;
|
||||
// - 反问:助手的 bp_ask 画成可点的选项,「这一轮在等你回答」常显,输入框提示回答;点选项就是把它作为下一轮发出去(回答记在
|
||||
// 问题上、这一轮落进蓝图对话都是服务端做的,前端不另记);
|
||||
// - 关节:只列生成关节,按卡片填的汇总一句;「让助手填」走 fill-joints-stream,逐组写进度,组之间并行;
|
||||
@@ -74,7 +75,7 @@ const TEMPLATES = [
|
||||
{ id: 'approval', version: 1, label: '审批流程', description: '有人发起、有人审批,可以按金额分级', applies_when: '', tags: [],
|
||||
personal: null,
|
||||
cards: [{ card: 'purpose', note: '必填' }, { card: 'fields', note: '必填:发起时填什么' }, { card: 'people', note: '必填:谁审批' },
|
||||
{ card: 'access', note: '' }] },
|
||||
{ card: 'writeback', note: '可选:通过后写回哪个系统' }, { card: 'access', note: '' }] },
|
||||
{ id: 'freeform', version: 1, label: '自由形', description: '从已有的运行或工作流导入', applies_when: '', tags: [], personal: '只读部分',
|
||||
cards: [{ card: 'purpose', note: '' }, { card: 'cadence', note: '' }, { card: 'access', note: '' }] },
|
||||
]
|
||||
@@ -491,6 +492,64 @@ await section('蓝图页:卡片的状态、对到的数据、改一项、缺
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 写回:结果不明时谁去核实
|
||||
|
||||
/** 审批流程的蓝图:通过后写回一项没登记查询的写入能力(服务端说要定核实人:reconciler_needed) */
|
||||
const CLAIM_SPEC = {
|
||||
...clone(SPEC), template: { id: 'approval', version: 1 }, data: [], cadence: item(null),
|
||||
people: [{ id: 's1', label: item('主管审批', 'user'), who: item([{ kind: 'user', name: 'zhang' }], 'user'),
|
||||
policy: item({ kind: 'any' }, 'user'), due: item(null), on_timeout: item(null), threshold: item(null) }],
|
||||
fields: [{ name: 'amount', spec: item({ type: 'number', title: '金额' }, 'user'), sensitive: false }],
|
||||
writeback: { capability: item('erp_claim', 'user'), mapping: item({ amount: 'form.amount' }, 'user'),
|
||||
confirm: item(true, 'user'), reconciler: item(null) },
|
||||
}
|
||||
const RECONCILER_UNBOUND = { code: 'bp.reconciler_unbound', level: 'error', path: '/writeback/reconciler',
|
||||
message: '「erp_claim」没登记查询:写回的结果不明时要有人去目标系统里核实,还没定是谁',
|
||||
plain: { title: '写回的结果不明时没人核实', why: '平台查不了,也不会自动重发', who: 'you',
|
||||
action: '在「写回哪里」卡片的「结果不明时谁去核实」里选人或组。' } }
|
||||
const CLAIM = (extra = {}) => ({ ...clone(BP), template_id: 'approval', spec: clone(CLAIM_SPEC), joints: {},
|
||||
issues: [RECONCILER_UNBOUND], reconciler_needed: true, ...extra })
|
||||
|
||||
await section('写回:结果不明时谁去核实(能力没登记查询才摆,只能由人选,只挡发布)', async () => {
|
||||
const state = { bp: CLAIM() }
|
||||
const { page } = await open({ state })
|
||||
await page.goto(`${WEB}/blueprints/bp_week`)
|
||||
await page.locator('[data-card="writeback"]').waitFor({ timeout: 15000 })
|
||||
const row = page.locator('[data-bp-item="/writeback/reconciler"]')
|
||||
check('能力没登记查询:摆出「结果不明时谁去核实」,标「缺」,写明为什么要人核实',
|
||||
await row.count() === 1 && await row.getAttribute('data-item-status') === 'missing'
|
||||
&& await page.locator('[data-bp-reconciler-hint="needed"]').count() === 1
|
||||
&& await page.locator('[data-card="writeback"]').getAttribute('data-card-status') === 'missing')
|
||||
check('卡片上照白话写缺什么、怎么改', (await page.locator('[data-card="writeback"] [data-card-issues]').innerText())
|
||||
.includes('写回的结果不明时没人核实'))
|
||||
check('只挡发布:「用真数试一次」照样能点', !(await page.locator('[data-blueprint-trial]').isDisabled()))
|
||||
await row.locator('[data-bp-edit]').click()
|
||||
const editor = row.locator('[data-bp-editor]')
|
||||
await editor.locator('select').first().selectOption('group')
|
||||
await editor.locator('[data-principal-picker] input').fill('财务组')
|
||||
await editor.locator('[data-principal-picker] input').press('Enter')
|
||||
await row.locator('[data-bp-save]').click()
|
||||
check('选人:bp_set /writeback/reconciler,值是一组人或组', await until(() => (state.patches ?? []).length === 1)
|
||||
&& JSON.stringify(state.patches[0].ops) === JSON.stringify([
|
||||
{ op: 'bp_set', path: '/writeback/reconciler', value: [{ kind: 'group', name: '财务组' }] }]),
|
||||
JSON.stringify(state.patches?.[0]))
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
|
||||
// 能力登记了查询:平台自己查,不摆这一项;以前选过的照样摆出来,写明用不上
|
||||
const looked = { bp: CLAIM({ issues: [], reconciler_needed: false }) }
|
||||
const second = await open({ state: looked })
|
||||
await second.page.goto(`${WEB}/blueprints/bp_week`)
|
||||
await second.page.locator('[data-card="writeback"]').waitFor({ timeout: 15000 })
|
||||
check('登记了查询、没选过人:不摆这一项', await second.page.locator('[data-bp-item="/writeback/reconciler"]').count() === 0)
|
||||
const chosen = CLAIM({ issues: [], reconciler_needed: false })
|
||||
chosen.spec.writeback.reconciler = item([{ kind: 'group', name: '财务组' }], 'user')
|
||||
const third = await open({ state: { bp: chosen } })
|
||||
await third.page.goto(`${WEB}/blueprints/bp_week`)
|
||||
await third.page.locator('[data-card="writeback"]').waitFor({ timeout: 15000 })
|
||||
check('登记了查询、以前选过人:照样摆出来,写明用不上', await third.page.locator('[data-bp-item="/writeback/reconciler"]').count() === 1
|
||||
&& await third.page.locator('[data-bp-reconciler-hint="unused"]').count() === 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 反问
|
||||
|
||||
await section('反问:选项可点、「这一轮在等你回答」常显、回答记在问题上', async () => {
|
||||
|
||||
Reference in New Issue
Block a user