Files
AgentLab/backend/app/blueprints/ops.py
T
ThearchyHelios d2b398563b 写回卡片加「结果不明时谁去核实」:写回的目标能力没登记查询时,结果不明(请求发出去了、没拿到答复)平台查不了、也不自动重发,要有人去目标系统里核实;以前发布单报「写入结果不明时没人核实」「有一步还没指定谁来做」却在卡片上找不到地方改。
- 蓝图格式:写回卡片多一项 reconciler(一组人或组),旧蓝图没有这一项照旧可读;只收用户的操作(同「谁能看」),助手整张换写回卡片时留着用户选好的人
- 是否需要(templates/_common.needs_reconciler,和应用骨架加核实岗位同一个判断):模板用到写回卡片、选了目标能力、能力没登记查询;交给前端的蓝图带 reconciler_needed,界面不自己判
- 蓝图检查 bp.reconciler_unbound:试运行只提醒(待办派给试运行的人自己),发布挡住;发布单上应用检查说同一件事的 app.reconciler_unbound 和核实岗位的 app.slot_unbound 不再重复列
- 岗位绑定:选了的人绑到应用的核实岗位;能力补登了查询就不再要、也不再绑,卡片上留着的人照样摆出来、写明用不上
- 卡片:需要时摆这一项,标「缺」、写明为什么要人核实,选人控件只能选人或组;「缺」只挡发布;蓝图助手的提示词写明这一项由用户定
- 示例蓝图(核对写回)补上核实人,审批、核对写回的卡片说明写上这一项;测试和检查脚本跟上
2026-10-15 17:31:20 +08:00

880 lines
46 KiB
Python
Raw 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.
"""bp_* 操作的确定性落地(N2、§3.2)。模型(BlueprintApplier)和用户(PATCH /api/blueprints/{id})用同一组操作,
只是来源记成 assistant 或 user。每张卡片有白名单(例如 people[].policy.kind 只能是 any / all / quorum),越界的拒绝并照实交回。
路径是 JSON 指针,能写的位置逐项列在 _SETTERS 里:
- 卡片上的一项(Item):写值(/purpose、/data/0/table、/people/1/due……),来源记成这次操作的来源,用户写的记「已确认」,
助手写的记「待确认」,写空记「缺」;/…/why 写一句「为什么」;/…/status 只收用户的「确认」(值不变、来源不变);
- 列表(/data、/people、/fields、/access/use……):bp_add 追加(/data、/data/- )或插到某个位置(/data/2),bp_remove 删一项;
新加的一项里卡片字段写纯值,来源和状态按这次操作记;
- /asks:bp_ask 追加一个反问(只有助手问),/asks/<i>/answer 记用户的回答;
- 选模板只走 bp_template:记下模板和版本,按模板预置还没填的卡片(来源记「模板默认」、状态「待确认」)。
「谁能看」(access)和写回的「结果不明时谁去核实」(/writeback/reconciler)只收用户的操作:授权、派活给谁是人的决定,
助手提的没有「待确认」可挂(P2)。
落地前后都按 agentlab.blueprint/1 的形状核一遍(spec.parse_blueprint),不合形状的一律拒。
"""
from __future__ import annotations
import copy
import keyword
import re
from collections.abc import Callable
from typing import Any
from pydantic import ValidationError
from app.blueprints.spec import BP_OPS, BpOp, parse_blueprint
class BpRefused(Exception):
"""操作被拒:message 是写给人看的一句话,code 是 bp.* 机器码。"""
def __init__(self, message: str, code: str = "bp.refused") -> None:
super().__init__(message)
self.message = message
self.code = code
#: 操作的来源:用户在卡片上改的、助手提的
BP_SOURCES = ("user", "assistant")
#: 统计哪一期(入口字段和定时任务的输入模板按它取,blueprints/templates/_common.PERIODS)
PERIODS = ("today", "yesterday", "this_iso_week", "last_iso_week", "this_month", "last_month")
CADENCE_PRESETS = ("daily", "weekly", "monthly")
#: 「谁来做」里一个处理人的种类:人、组、岗位、发起人
WHO_KINDS = ("user", "group", "slot", "initiator")
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"})
#: 列表的上限:再多就不是普通人在卡片上能判断对错的颗粒度了
LIST_LIMITS = {"data": 20, "people": 6, "fields": 40, "use": 50, "observe": 50, "manage": 50}
#: 数据项、步骤的 id 进节点 id 和指标 id:只用小写字母、数字、下划线
_ID = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
_TIME = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
#: 卡片上的时限说法:「48 小时」「2 天」「1 周」「30 分钟」
_PLAIN_DURATION = re.compile(r"^\s*(\d{1,4})\s*(分钟|小时|个小时|天|日|周|星期)\s*$")
_DURATION_UNIT = {"分钟": ("T", "M"), "小时": ("T", "H"), "个小时": ("T", "H"), "天": ("", "D"), "日": ("", "D"),
"周": ("", "W"), "星期": ("", "W")}
#: 写回的参数映射:入口表单字段、指标、核对出来的那几行(核对写回)
_MAP_SOURCE = re.compile(r"^(form|metric)\.([^\s.]{1,64})$")
_MAP_ROWS = "rows"
_ITEM_FIELDS = {
"data": ("label", "unit", "table", "measure", "date", "filter", "grain"),
"people": ("label", "who", "policy", "due", "on_timeout", "threshold"),
}
_LABEL = {"purpose": "目的", "template": "场景", "data": "看哪些数", "cadence": "多久更新", "people": "谁来做",
"fields": "填什么", "writeback": "写回哪里", "access": "谁能看", "asks": "反问"}
# ==========================================================================
# JSON 指针
# ==========================================================================
def parse_pointer(path: Any) -> list[str]:
"""/data/0/table → ["data", "0", "table"]。不是 JSON 指针的抛 BpRefused。"""
if not isinstance(path, str) or not path.startswith("/") or path == "/":
raise BpRefused(f"位置要写成 /purpose、/data/0/table 这样的路径,当前为「{path}」")
return [p.replace("~1", "/").replace("~0", "~") for p in path[1:].split("/")]
def _index(token: str, size: int, *, allow_end: bool = False) -> int:
if allow_end and token == "-":
return size
if not token.isdigit():
raise BpRefused(f"列表的位置要写成数字,当前为「{token}」")
at = int(token)
if at > size or (at == size and not allow_end):
raise BpRefused(f"位置 {at} 超出了列表的范围(现有 {size} 项)")
return at
# ==========================================================================
# 各项的白名单(值 → 规范化后的值;不合规抛 BpRefused)
# ==========================================================================
def _text(limit: int, what: str) -> Callable[[Any, dict[str, Any]], Any]:
def check(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, str) or not value.strip():
raise BpRefused(f"「{what}」要写一段文字")
if len(value.strip()) > limit:
raise BpRefused(f"「{what}」最多 {limit} 个字")
return value.strip()
return check
def _identifier(name: Any) -> bool:
return isinstance(name, str) and 0 < len(name) <= 40 and name.isidentifier() and not keyword.iskeyword(name)
def _table(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, dict) or set(value) - {"source", "table"}:
raise BpRefused("对到的表要写明数据源和表名两项")
out = {k: str(value.get(k) or "").strip() for k in ("source", "table")}
if out["source"] == "@records":
# 本应用的记录表(填报登记,blueprints/store.SELF_RECORDS)只有一张,表名固定,用户和模型都不用写
out["table"] = "records"
if not all(out.values()) or any(len(v) > 200 for v in out.values()):
raise BpRefused("对到的表要写明数据源和表名")
return out
def _date(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, dict) or set(value) - {"column", "rule"} or not str(value.get("column") or "").strip():
raise BpRefused("业务日期要写明按哪一列算,可以加一句规则(例如按支付日)")
out = {"column": str(value["column"]).strip()[:200]}
if value.get("rule") not in (None, ""):
out["rule"] = _text(60, "业务日期的规则")(value["rule"], _spec)
return out
def _cadence(value: Any, _spec: dict[str, Any]) -> Any:
from app.scheduler.cron import TimezoneError, timezone_name
if not isinstance(value, dict) or set(value) - {"preset", "weekday", "day", "time", "tz", "period"}:
raise BpRefused("「多久更新」要写明每天、每周还是每月,几点,哪个时区,统计哪一期")
preset = value.get("preset")
if preset not in CADENCE_PRESETS:
raise BpRefused("「多久更新」只能是每天、每周或每月")
time = str(value.get("time") or "")
if not _TIME.match(time):
raise BpRefused(f"更新的时间要写成 09:00 这样的 24 小时制,当前为「{time}」")
try:
tz = timezone_name(str(value.get("tz") or ""))
except TimezoneError as e:
raise BpRefused(str(e)) from e
out: dict[str, Any] = {"preset": preset, "time": time, "tz": tz}
if preset == "weekly":
weekday = value.get("weekday")
if isinstance(weekday, bool) or not isinstance(weekday, int) or not 1 <= weekday <= 7:
raise BpRefused("每周更新要写明周几(1 是周一,7 是周日)")
out["weekday"] = weekday
if preset == "monthly":
day = value.get("day")
# 只收 1–28:29 日以后不是每个月都有,那几个月就不出数了
if isinstance(day, bool) or not isinstance(day, int) or not 1 <= day <= 28:
raise BpRefused("每月更新要写明几号(1 到 28 号,29 号以后不是每个月都有)")
out["day"] = day
period = value.get("period")
if period not in (None, ""):
if period not in PERIODS:
raise BpRefused("统计哪一期只能是今天、昨天、本周、上周、本月或上月")
out["period"] = period
return out
def _principal(value: Any, kinds: tuple[str, ...], what: str) -> dict[str, str]:
if not isinstance(value, dict) or set(value) - {"kind", "name"} or value.get("kind") not in kinds:
raise BpRefused(f"{what}要写明种类和名字,种类只能是{_kinds_text(kinds)}")
name = str(value.get("name") or "").strip()
if value["kind"] != "initiator" and not name:
raise BpRefused(f"{what}要写明是谁")
if len(name) > 100:
raise BpRefused(f"{what}的名字最多 100 个字")
return {"kind": value["kind"], "name": name}
_KIND_WORDS = {"user": "人", "group": "组", "slot": "岗位", "initiator": "发起人"}
def _kinds_text(kinds: tuple[str, ...]) -> str:
words = [_KIND_WORDS[k] for k in kinds]
return "、".join(words[:-1]) + f"或{words[-1]}" if len(words) > 1 else words[0]
def _who(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, list) or len(value) > 20:
raise BpRefused("处理人要写成一组人、组或岗位(最多 20 个)")
out = [_principal(v, WHO_KINDS, "处理人") for v in value]
if any(p["kind"] == "initiator" for p in out) and len(out) > 1:
raise BpRefused("处理人选了发起人时,不能再加别的人")
# 岗位是一个整体(绑到哪些人在应用里定):和具体的人、组混在一起,就说不清这一步派给岗位还是派给这几个人
if any(p["kind"] == "slot" for p in out) and len(out) > 1:
raise BpRefused("处理人选了岗位时,不能再加别的人、组或岗位")
return out or None
def _policy(value: Any, _spec: dict[str, Any]) -> Any:
if isinstance(value, str):
value = {"kind": value}
if not isinstance(value, dict) or set(value) - {"kind", "m"} or value.get("kind") not in POLICY_KINDS:
raise BpRefused("几个人同意算通过,只能是任一人、全体同意或其中若干人同意")
if value["kind"] != "quorum":
if "m" in value:
raise BpRefused("只有「若干人同意」要写人数")
return {"kind": value["kind"]}
m = value.get("m")
if isinstance(m, bool) or not isinstance(m, int) or not 1 <= m <= 50:
raise BpRefused("「若干人同意」要写明几个人(1 到 50)")
return {"kind": "quorum", "m": m}
def duration_of(value: Any) -> str | None:
"""卡片上的时限 → ISO 8601 时长:「48 小时」→ PT48H,「2 天」→ P2D,ISO 写法原样(大写)。换算不了返回 None。"""
from app.engine.lint_process import parse_duration
if not isinstance(value, str) or not value.strip():
return None
text = value.strip()
if m := _PLAIN_DURATION.match(text):
prefix, unit = _DURATION_UNIT[m.group(2)]
text = f"P{prefix}{int(m.group(1))}{unit}"
text = text.upper()
return text if parse_duration(text) is not None else None
def _due(value: Any, _spec: dict[str, Any]) -> Any:
iso = duration_of(value)
if iso is None:
raise BpRefused(f"时限要写成「48 小时」「2 天」「1 周」这样的时长,当前为「{value}」(工作日换算不了,请写成天数)")
return iso
def _on_timeout(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, dict) or set(value) - {"action", "to", "every"} or value.get("action") not in TIMEOUT_ACTIONS:
raise BpRefused("超时以后只能提醒、升级给别人或驳回(不会自动同意)")
out: dict[str, Any] = {"action": value["action"]}
if value["action"] == "escalate":
to = value.get("to")
if not isinstance(to, list) or not to:
raise BpRefused("超时升级要写明升级给谁")
out["to"] = [_principal(v, ("user", "group", "slot"), "升级给的人") for v in to[:20]]
elif value.get("to"):
raise BpRefused("只有超时升级要写升级给谁")
if value.get("every") not in (None, ""):
if value["action"] != "remind":
raise BpRefused("只有超时提醒能写提醒间隔")
every = duration_of(value["every"])
if every is None:
raise BpRefused(f"提醒间隔要写成「1 小时」这样的时长,当前为「{value['every']}」")
out["every"] = every
return out
def _threshold(value: Any, spec: dict[str, Any]) -> Any:
if not isinstance(value, dict) or set(value) != {"field", "op", "value"}:
raise BpRefused("走这一步的条件只能写成「字段 比较 常量」,例如金额大于 5000")
field, op, const = value["field"], value["op"], value["value"]
if not _identifier(field):
raise BpRefused(f"条件里的字段要写字段名(不能写算式),当前为「{field}」")
if op not in THRESHOLD_OPS:
raise BpRefused(f"条件只能用这几种比较:{'、'.join(THRESHOLD_OPS)}")
if isinstance(const, bool) or not isinstance(const, (int, float, str)) or (isinstance(const, str) and len(const) > 100):
raise BpRefused("条件里比较的只能是一个数或一段文字,不能写算式")
if isinstance(const, str) and op not in ("==", "!="):
raise BpRefused("和文字只能比较相等或不相等")
names = [f.get("name") for f in spec.get("fields") or [] if isinstance(f, dict)]
if names and field not in names:
raise BpRefused(f"条件用到的字段「{field}」不在「填什么」里")
return {"field": field, "op": op, "value": const}
def _field_spec(value: Any, spec: dict[str, Any], name: str = "x") -> Any:
"""「填什么」的一个字段:agentlab.form/1 的一个属性,另可写 required(是否必填)。按表单同一份规则核(P5)。"""
from app.apps.forms import validate_form_schema
if not isinstance(value, dict):
raise BpRefused("字段要写明类型、名称这些表单属性")
prop = {k: v for k, v in value.items() if k != "required"}
if "required" in value and not isinstance(value["required"], bool):
raise BpRefused("「必填」只能是是或否")
problems = validate_form_schema({"type": "object", "properties": {name: prop}})
if problems:
raise BpRefused(f"字段不符合表单的写法:{problems[0]}")
return copy.deepcopy(value)
def _capability(value: Any, _spec: dict[str, Any]) -> Any:
"""写回的目标:能力登记里效果为写入的能力(写外部系统只经调用工具节点,总方案 A5)。读进程内快照,不查库。"""
from app.capabilities.registry import cached_view, writes_externally
name = str(value or "").strip()
if not name:
raise BpRefused("写回要选一项能力")
view = cached_view(name)
if view is None or view.get("missing"):
raise BpRefused(f"能力登记里没有「{name}」,请从能力登记里选一项写入能力")
if not writes_externally(name, view.get("effect"), view.get("endpoint_id")) and view.get("effect") != "write":
raise BpRefused(f"「{name}」不是写入能力(它是只读的或在登记里没有定为写入),不能用来写回")
return name
def _mapping(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, dict) or not value or len(value) > 40:
raise BpRefused("字段对应要写成「参数:取值」的一组")
out: dict[str, Any] = {}
for param, source in value.items():
if not isinstance(param, str) or not param.strip() or len(param) > 64:
raise BpRefused("字段对应里的参数名要写清楚")
if isinstance(source, bool) or isinstance(source, (int, float)):
out[param] = source
elif isinstance(source, str) and (_MAP_SOURCE.match(source) or source == _MAP_ROWS):
out[param] = source
elif isinstance(source, str) and len(source) >= 2 and source[0] == source[-1] == "'":
out[param] = source
else:
raise BpRefused(f"参数「{param}」的取值只能是表单字段、指标、核对出来的行或一个常量(文字要加单引号),"
f"当前为「{source}」")
return out
def _confirm(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, bool):
raise BpRefused("要不要人确认只能是是或否")
return value
def _reuse(value: Any, _spec: dict[str, Any]) -> Any:
"""复用已发布的口径卡:{workflow_id, version, node_id, metric_id},另可带 inputs(那张卡读哪几个取数节点的哪几列,
look/calibers 认出来写上,蓝图存的时候按库里那一版再核一遍)。"""
if value is None:
return None
keys = {"workflow_id", "version", "node_id", "metric_id"}
if not isinstance(value, dict) or not keys <= set(value) or set(value) - keys - {"inputs"}:
raise BpRefused("复用口径卡要写明工作流、版本、口径卡节点和指标")
version = value["version"]
if isinstance(version, bool) or not isinstance(version, int) or version < 1:
raise BpRefused("复用口径卡要写明具体的版本号(1、2…)")
out = {k: str(value[k]).strip() for k in ("workflow_id", "node_id", "metric_id")}
if not all(out.values()):
raise BpRefused("复用口径卡要写明工作流、版本、口径卡节点和指标")
out["version"] = version
inputs = value.get("inputs")
if inputs is not None:
if not isinstance(inputs, list) or not all(
isinstance(i, dict) and isinstance(i.get("node"), str) and isinstance(i.get("tool"), str)
and isinstance(i.get("columns"), list) for i in inputs):
raise BpRefused("复用口径卡的输入要逐项写明节点、工具和列")
out["inputs"] = copy.deepcopy(inputs)
return out
def _grantees(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, list):
raise BpRefused("「谁能看」要写成一组人或组")
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, "目的"),
("cadence", None): _cadence,
("data", "label"): _text(60, "名称"),
("data", "unit"): _text(20, "单位"),
("data", "table"): _table,
("data", "measure"): _text(300, "怎么算"),
("data", "date"): _date,
("data", "filter"): _text(300, "筛选条件"),
("data", "grain"): _text(60, "粒度"),
("people", "label"): _text(60, "这一步叫什么"),
("people", "who"): _who,
("people", "policy"): _policy,
("people", "due"): _due,
("people", "on_timeout"): _on_timeout,
("people", "threshold"): _threshold,
("writeback", "capability"): _capability,
("writeback", "mapping"): _mapping,
("writeback", "confirm"): _confirm,
("writeback", "reconciler"): _reconciler,
}
# ==========================================================================
# 落地
# ==========================================================================
def _item(value: Any, source: str, *, why: Any = None) -> dict[str, Any]:
"""卡片上的一项:写空的记「缺」,用户写的记「已确认」,助手写的记「待确认」。"""
if value is None:
return {"value": None, "source": source, "status": "missing", "why": why}
return {"value": value, "source": source, "status": "confirmed" if source == "user" else "proposed", "why": why}
def _checked(card: str, key: str | None, value: Any, spec: dict[str, Any]) -> Any:
if value is None or value == "" or value == []:
return None
return _CHECKS[(card, key)](value, spec)
def _new_entry(card: str, raw: Any, source: str, spec: dict[str, Any]) -> dict[str, Any]:
"""bp_add 加的一项:数据项、步骤、字段。卡片字段写纯值,来源和状态按这次操作记。"""
if not isinstance(raw, dict):
raise BpRefused(f"「{_LABEL[card]}」加的一项要写成一个对象")
existing = spec.get(card) or []
if card == "fields":
name = raw.get("name")
if not _identifier(name):
raise BpRefused(f"字段名要写成字母、汉字、数字和下划线(不以数字开头),当前为「{name}」")
if name in RESERVED_FIELDS:
raise BpRefused(f"字段名「{name}」留给统计期用了,请换一个")
if any(f.get("name") == name for f in existing):
raise BpRefused(f"已经有一个叫「{name}」的字段了")
if set(raw) - {"name", "spec", "sensitive"}:
raise BpRefused("字段只能写名字、表单属性和是否敏感")
sensitive = raw.get("sensitive", False)
if not isinstance(sensitive, bool):
raise BpRefused("「敏感」只能是是或否")
prop = None if raw.get("spec") is None else _field_spec(raw["spec"], spec, name)
return {"name": name, "spec": _item(prop, source), "sensitive": sensitive}
entry_id = raw.get("id")
if not isinstance(entry_id, str) or not _ID.match(entry_id):
raise BpRefused(f"每一项要有一个 id(小写字母开头,只用小写字母、数字、下划线),当前为「{entry_id}」")
if any(e.get("id") == entry_id for e in existing):
raise BpRefused(f"「{_LABEL[card]}」里已经有 id 为「{entry_id}」的一项了")
allowed = {"id", *(_ITEM_FIELDS[card]), *(("reuse",) if card == "data" else ())}
if extra := sorted(set(raw) - allowed):
raise BpRefused(f"「{_LABEL[card]}」的一项不能写这些:{'、'.join(extra)}")
out: dict[str, Any] = {"id": entry_id}
for key in _ITEM_FIELDS[card]:
out[key] = _item(_checked(card, key, raw.get(key), spec), source)
if card == "data":
out["reuse"] = _reuse(raw.get("reuse"), spec)
return out
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) - 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:
"""一项里的 status(只收用户的确认)、why(一句为什么)。"""
if part == "why":
if value is not None and (not isinstance(value, str) or len(value) > 300):
raise BpRefused("「为什么」要写一句话(最多 300 字)")
item["why"] = value.strip() if isinstance(value, str) and value.strip() else None
return
if part != "status":
raise BpRefused(f"卡片上的一项只能改值、确认或写一句为什么,不能改「{part}」")
if source != "user":
raise BpRefused("确认只能由你来做:助手提的项要你看过才算数")
if value != "confirmed":
raise BpRefused("只能把一项标成已确认;要改就直接改它的值")
if item.get("value") is None:
raise BpRefused("这一项还没有值,没法确认")
item["status"] = "confirmed"
def _container(spec: dict[str, Any], tokens: list[str]) -> tuple[dict[str, Any], str]:
"""卡片上的一项所在的对象和键:/purpose → (spec, "purpose"),/data/0/table → (spec.data[0], "table")。"""
card = tokens[0]
if card in ("purpose", "cadence") and len(tokens) == 1:
return spec, card
if card in ("data", "people") and len(tokens) == 3:
entries = spec.get(card) or []
at = _index(tokens[1], len(entries))
if tokens[2] not in _ITEM_FIELDS[card]:
raise BpRefused(f"「{_LABEL[card]}」的一项没有「{tokens[2]}」")
return entries[at], tokens[2]
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 WRITEBACK_KEYS:
if spec.get("writeback") is None:
spec["writeback"] = {k: _item(None, "template") for k in WRITEBACK_KEYS}
return spec["writeback"], tokens[1]
raise BpRefused(f"不能改这个位置:/{'/'.join(tokens)}")
def _set(spec: dict[str, Any], tokens: list[str], value: Any, source: str) -> None:
card = tokens[0]
if card == "access":
if len(tokens) != 2 or tokens[1] not in ACCESS_KINDS:
raise BpRefused("「谁能看」只有使用、查看全部、管理三档")
grantees = _grantees(value if value is not None else [], spec)
if len(grantees) > LIST_LIMITS[tokens[1]]:
raise BpRefused(f"每一档最多授权 {LIST_LIMITS[tokens[1]]} 个人或组")
spec["access"][tokens[1]] = grantees
return
if card == "asks":
if len(tokens) != 3 or tokens[2] != "answer":
raise BpRefused("反问只能记下回答")
asks = spec.get("asks") or []
ask = asks[_index(tokens[1], len(asks))]
if source != "user":
raise BpRefused("反问要由你来回答")
if not isinstance(value, str) or not value.strip() or len(value) > 500:
raise BpRefused("回答要写一句话(最多 500 字)")
ask["answer"] = value.strip()
return
if card == "writeback" and len(tokens) == 1:
spec["writeback"] = _writeback(value, source, spec)
return
if card == "data" and len(tokens) == 3 and tokens[2] == "reuse":
entries = spec.get("data") or []
entries[_index(tokens[1], len(entries))]["reuse"] = _reuse(value, spec)
return
if card == "fields" and len(tokens) == 3 and tokens[2] in ("name", "sensitive"):
entries = spec.get("fields") or []
entry = entries[_index(tokens[1], len(entries))]
if tokens[2] == "sensitive":
if not isinstance(value, bool):
raise BpRefused("「敏感」只能是是或否")
entry["sensitive"] = value
return
if not _identifier(value) or value in RESERVED_FIELDS:
raise BpRefused(f"字段名「{value}」不能用:要写成字母、汉字、数字和下划线,也不能和统计期重名")
if any(f is not entry and f.get("name") == value for f in entries):
raise BpRefused(f"已经有一个叫「{value}」的字段了")
entry["name"] = value
return
if tokens[-1] in ("status", "why") and len(tokens) >= 2:
holder, key = _container(spec, tokens[:-1])
_set_item_part(holder[key], tokens[-1], value, source)
return
holder, key = _container(spec, tokens)
if card == "fields":
checked = None if value is None else _field_spec(value, spec, holder.get("name") or "x")
else:
checked = _checked(card, None if card in ("purpose", "cadence") else key, value, spec)
why = (holder.get(key) or {}).get("why") if checked is not None else None
holder[key] = _item(checked, source, why=why)
def _add(spec: dict[str, Any], tokens: list[str], value: Any, source: str) -> None:
card = tokens[0]
if card == "access":
if len(tokens) not in (2, 3) or tokens[1] not in ACCESS_KINDS:
raise BpRefused("「谁能看」只有使用、查看全部、管理三档")
grantees = spec["access"][tokens[1]]
at = _index(tokens[2], len(grantees), allow_end=True) if len(tokens) == 3 else len(grantees)
grantee = _principal(value, GRANTEE_KINDS, "授权的对象")
if grantee in grantees:
raise BpRefused(f"「{grantee['name']}」已经在这一档里了")
if len(grantees) >= LIST_LIMITS[tokens[1]]:
raise BpRefused(f"每一档最多授权 {LIST_LIMITS[tokens[1]]} 个人或组")
grantees.insert(at, grantee)
return
if card not in ("data", "people", "fields") or len(tokens) > 2:
raise BpRefused(f"只能往「看哪些数」「谁来做」「填什么」「谁能看」里加,不能加到 /{'/'.join(tokens)}")
entries = spec.setdefault(card, [])
at = _index(tokens[1], len(entries), allow_end=True) if len(tokens) == 2 else len(entries)
if len(entries) >= LIST_LIMITS[card]:
raise BpRefused(f"「{_LABEL[card]}」最多 {LIST_LIMITS[card]} 项")
entries.insert(at, _new_entry(card, value, source, spec))
def _remove(spec: dict[str, Any], tokens: list[str], source: str) -> None:
card = tokens[0]
if card == "access":
if len(tokens) != 3 or tokens[1] not in ACCESS_KINDS:
raise BpRefused("「谁能看」要写明删哪一档的第几个")
grantees = spec["access"][tokens[1]]
del grantees[_index(tokens[2], len(grantees))]
return
if card in ("data", "people", "fields") and len(tokens) == 2:
entries = spec.get(card) or []
del entries[_index(tokens[1], len(entries))]
return
if card == "writeback" and len(tokens) == 1:
spec["writeback"] = None
return
if card == "data" and len(tokens) == 3 and tokens[2] == "reuse":
entries = spec.get("data") or []
entries[_index(tokens[1], len(entries))]["reuse"] = None
return
# 卡片上的一项:清空等于记成「缺」
_set(spec, tokens, None, source)
def _ask(spec: dict[str, Any], op: BpOp, source: str) -> None:
if source != "assistant":
raise BpRefused("反问是助手问你的,你直接回答就好")
question = (op.question or "").strip()
if not question or len(question) > 300:
raise BpRefused("反问要写一个问题(最多 300 字)")
options = [o.strip() for o in op.options if isinstance(o, str) and o.strip()]
if len(options) > 6 or any(len(o) > 100 for o in options):
raise BpRefused("反问最多给 6 个候选,每个最多 100 字")
if op.path is not None:
parse_pointer(op.path)
asks = spec.setdefault("asks", [])
asks.append({"id": f"q{len(asks) + 1}", "question": question, "options": options, "path": op.path,
"answer": None})
def _template(spec: dict[str, Any], op: BpOp, source: str) -> None:
from app.blueprints.templates import get_template
ref = op.template
if ref is None:
raise BpRefused("选模板要写明是哪一个")
template = get_template(ref.id)
if template is None:
raise BpRefused(f"没有叫「{ref.id}」的场景模板", code="bp.template_mismatch")
if ref.version != template.version:
raise BpRefused(f"场景模板「{template.label}」现在是第 {template.version} 版,请按这一版选",
code="bp.template_mismatch")
spec["template"] = {"id": template.id, "version": template.version}
from app.blueprints.templates._common import prefill
prefill(template.id, spec)
def apply_bp_op(spec: dict[str, Any], op: dict[str, Any], *, source: str) -> dict[str, Any]:
"""把一个 bp_* 操作落到蓝图上,交回新的蓝图(不改入参)。source 是 user / assistant。不合规时抛 BpRefused。"""
if source not in BP_SOURCES:
raise BpRefused(f"操作的来源只能是用户或助手,当前为「{source}」")
if not isinstance(op, dict) or op.get("op") not in BP_OPS:
raise BpRefused(f"只能用这几种操作:{'、'.join(BP_OPS)}")
try:
parsed = BpOp.model_validate(op)
out = parse_blueprint(copy.deepcopy(spec)).model_dump(mode="json", by_alias=True)
except ValidationError as e:
raise BpRefused(f"操作的写法不对:{_first(e)}") from e
if parsed.op == "bp_ask":
_ask(out, parsed, source)
elif parsed.op == "bp_template":
_template(out, parsed, source)
else:
tokens = parse_pointer(parsed.path)
if tokens[0] not in _LABEL or tokens[0] == "template":
raise BpRefused(f"没有这张卡片:/{tokens[0]}(选模板用 bp_template)")
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":
_add(out, tokens, parsed.value, source)
else:
_remove(out, tokens, source)
try:
return parse_blueprint(out).model_dump(mode="json", by_alias=True)
except ValidationError as e:
raise BpRefused(f"改完的蓝图不合格式:{_first(e)}") from e
def _first(e: ValidationError) -> str:
err = e.errors()[0] if e.errors() else {}
where = ".".join(str(x) for x in err.get("loc") or ())
return f"{where} {err.get('msg', '')}".strip()
# ==========================================================================
# 蓝图检查(§3.13 bp.*)
# ==========================================================================
#: 检查的用途:试运行(缺项挡)、发布(再加待确认项挡)、个人应用(缺项挡,另查模板的个人应用部分)
ISSUE_PURPOSES = ("trial", "publish", "personal")
#: 卡片约束说明以这两个词开头:模板登记里 cards 的写法(blueprints/templates)
REQUIRED_MARK = "必填"
def _value(item: Any) -> Any:
return item.get("value") if isinstance(item, dict) else None
def blueprint_issues(spec: dict[str, Any], *, for_: str) -> list[dict[str, Any]]:
"""蓝图检查(§3.13 bp.*):for_ = trial(缺项挡试运行)/ publish(待确认项挡发布)/ personal。
每条 {code, level, path, message}。"""
from app.blueprints.templates import get_template
if for_ not in ISSUE_PURPOSES:
raise ValueError(for_)
out: list[dict[str, Any]] = []
def add(code: str, path: str, message: str, level: str = "error") -> None:
out.append({"code": code, "level": level, "path": path, "message": message})
ref = spec.get("template") or None
template = get_template(ref["id"]) if isinstance(ref, dict) else None
if not isinstance(ref, dict):
add("bp.missing", "/template", "还没有选场景:先说说要做什么,或者从场景模板开始")
return out
if template is None:
add("bp.template_mismatch", "/template", f"场景模板「{ref.get('id')}」已经不在了,请换一个场景")
return out
if ref.get("version") != template.version:
add("bp.template_mismatch", "/template",
f"这张蓝图按场景模板「{template.label}」第 {ref.get('version')} 版做的,模板现在是第 {template.version} 版:"
"请重新选一次模板,卡片会按新版本核一遍", level="warning")
used = set(template.cards)
required = {c for c, note in template.cards.items() if note.startswith(REQUIRED_MARK)}
publish = for_ == "publish"
if "purpose" in required and _value(spec.get("purpose")) is None:
add("bp.missing", "/purpose", "「目的」还没写:一句话说清这个应用解决什么问题、给谁用")
# 模板用不到的卡片只报一条 bp.template_mismatch(见下),里面的项不再逐条查
if "data" in used:
_data_issues(spec, "data" in required, add)
if "cadence" in required and _value(spec.get("cadence")) is None:
add("bp.missing", "/cadence", "「多久更新」还没定:每天、每周还是每月,几点,统计哪一期")
if "people" in used:
_people_issues(spec, "people" in required, publish, add)
if "fields" in used:
_field_issues(spec, "fields" in required, add)
wb = spec.get("writeback")
if "writeback" in required and (not isinstance(wb, dict) or _value(wb.get("capability")) is None):
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:
add("bp.template_mismatch", f"/{card}",
f"场景「{template.label}」用不到「{_LABEL[card]}」:这张卡片上写的不会生效。请删掉,或者换一个场景")
if isinstance(wb, dict) and _value(wb.get("capability")) is not None and "writeback" not in used:
add("bp.template_mismatch", "/writeback",
f"场景「{template.label}」不写回别的系统:「写回哪里」不会生效。请删掉,或者换成核对写回、审批流程")
# 模板自己认的:卡片内容超出模板能力(只能从一个库查、复用的口径卡接不上、写回取不到的字段……)
from app.blueprints.templates._common import template_problems
out += template_problems(template.id, spec)
if for_ == "personal":
if template.personal is None:
add("bp.template_mismatch", "/template", f"场景「{template.label}」不能做成个人应用:它要有人审批或写回别的系统")
if spec.get("people"):
add("bp.template_mismatch", "/people", "个人应用不能有审批步骤:想给别人用,请提交给搭建者审核发布")
if isinstance(wb, dict) and _value(wb.get("capability")) is not None:
add("bp.template_mismatch", "/writeback", "个人应用只读,不能写回别的系统")
if publish:
for path, item in _items(spec):
if item.get("status") == "proposed" and item.get("value") is not None:
add("bp.unconfirmed", path, f"「{_where(path)}」是{_SOURCE_WORD.get(item.get('source'), '')}提的,"
"还没确认:发布前要你看过")
return out
_SOURCE_WORD = {"assistant": "助手", "template": "模板默认", "catalog": "按数据目录对到", "caliber": "按口径卡对到"}
def _data_issues(spec: dict[str, Any], required: bool, add: Callable[..., None]) -> None:
data = spec.get("data") or []
if required and not data:
add("bp.missing", "/data", "「看哪些数」还没写:至少要有一项,比如销售额、订单数")
seen: set[str] = set()
for i, item in enumerate(data):
if _value(item.get("label")) is None:
add("bp.missing", f"/data/{i}/label", f"第 {i + 1} 项数还没有名字")
reuse = item.get("reuse")
if _value(item.get("table")) is None and not reuse:
add("bp.data_unmapped", f"/data/{i}/table",
f"「{_value(item.get('label')) or item.get('id')}」还没对到表或已发布的口径卡")
if reuse and reuse.get("metric_id") in seen:
add("bp.template_mismatch", f"/data/{i}/reuse", f"复用的指标「{reuse['metric_id']}」和别的项重名,请只留一项")
seen.add(str(reuse.get("metric_id") if reuse else item.get("id")))
def _people_issues(spec: dict[str, Any], required: bool, publish: bool, add: Callable[..., None]) -> None:
people = spec.get("people") or []
if required and not people:
add("bp.missing", "/people", "「谁来做」还没写:至少要有一步,写明谁处理")
numeric = {f.get("name") for f in spec.get("fields") or []
if (_value(f.get("spec")) or {}).get("type") in ("number", "integer")}
names = {f.get("name") for f in spec.get("fields") or []}
for i, step in enumerate(people):
label = _value(step.get("label")) or step.get("id")
if _value(step.get("who")) is None:
# 试运行时人工步骤派给试运行的人自己,岗位没人不挡试运行,只挡发布
add("bp.people_unbound", f"/people/{i}/who", f"「{label}」还没定谁来处理",
level="error" if publish else "warning")
if _value(step.get("policy")) is None:
add("bp.missing", f"/people/{i}/policy", f"「{label}」还没定几个人同意算通过")
timeout = _value(step.get("on_timeout"))
if timeout and _value(step.get("due")) is None:
add("bp.missing", f"/people/{i}/due", f"「{label}」写了超时怎么办,还要写时限")
threshold = _value(step.get("threshold"))
if threshold:
if threshold["field"] not in names:
add("bp.template_mismatch", f"/people/{i}/threshold",
f"「{label}」的条件用到的字段「{threshold['field']}」不在「填什么」里")
elif threshold["op"] not in ("==", "!=") and threshold["field"] not in numeric:
add("bp.template_mismatch", f"/people/{i}/threshold",
f"「{label}」的条件拿「{threshold['field']}」比大小,它要是数字字段")
def _field_issues(spec: dict[str, Any], required: bool, add: Callable[..., None]) -> None:
fields = spec.get("fields") or []
if required and not fields:
add("bp.missing", "/fields", "「填什么」还没写:至少要有一个字段")
for i, f in enumerate(fields):
if _value(f.get("spec")) is None:
add("bp.missing", f"/fields/{i}/spec", f"字段「{f.get('name')}」还没定类型")
def _items(spec: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
"""蓝图里所有带状态的项:(路径, 项)。"""
out: list[tuple[str, dict[str, Any]]] = []
for card in ("purpose", "cadence"):
if isinstance(spec.get(card), dict):
out.append((f"/{card}", spec[card]))
for card in ("data", "people"):
for i, entry in enumerate(spec.get(card) or []):
out += [(f"/{card}/{i}/{k}", entry[k]) for k in _ITEM_FIELDS[card] if isinstance(entry.get(k), dict)]
for i, f in enumerate(spec.get("fields") or []):
if isinstance(f.get("spec"), dict):
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 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": "要不要人确认", "reconciler": "结果不明时谁去核实"}
def _where(path: str) -> str:
tokens = parse_pointer(path)
card = _LABEL.get(tokens[0], tokens[0])
if len(tokens) == 1:
return card
if len(tokens) == 3:
return f"{card}·第 {int(tokens[1]) + 1} 项·{_PART_LABEL.get(tokens[2], tokens[2])}"
return f"{card}·{_PART_LABEL.get(tokens[-1], tokens[-1])}"