mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:51:58 +00:00
3293ac0b58
为什么:自由形的骨架就是导入的图,图里派给岗位的人工节点、没登记查询的写入在应用里都要绑人,可蓝图上没有「谁来做」卡片。 第一次发布挡在 app.slot_unbound / app.reconciler_unbound 上,白话叫人「在「谁来做」里给这一步选人」,卡片却不存在;这时 应用还没建,也没法去应用编辑器绑,只能绕开蓝图(05-已知遗留 (d))。 做了什么: - freeform 的 cards 加 people(「可选:只定每一步由谁做;会签、时限、超时在画布上改」)。freeform.people_rows 按 tree.slot_refs 列岗位行(同一个岗位用在几处合成一行,label 用节点标题拼、detail 写全每一处用途,path 是 /people/<i>/who);写入能力没登记 查询时加一行「写入结果不明时谁去核实」(path /writeback/reconciler);发起人、表达式、外部系统回调的步骤列成只读行。 blueprint_out 给出 derived_people,界面照这一份画。 - 存储复用现有路径:岗位绑定存在 spec.people[](id 就是岗位名、只写 who),_common.slot_of / bindings_of 原样生效;核实人存在 /writeback/reconciler。needs_reconciler 对自由形按图里的写入算(with_graph 把蓝图存的图带给模板函数)。 - bp_set 对自由形的 people 只收 who(只收人和组,只由用户定),改会签、时限、超时、加减步骤报 bp.template_mismatch「在画布上 改」。apply_bp_op / blueprint_issues 新增 graph 参数,卡片接口、蓝图检查、试运行、实例化、蓝图助手都把蓝图存的图传下去。 - 检查:派生岗位、核实人没绑报 bp.people_unbound(试运行 warning、发布 error),路径就是那一行,带 slot;图里已经没有的岗位、 卡片上还留着的绑定报 warning,不静默删除。发布单照核实人的办法,去掉同一件事的 app.slot_unbound(slots.<slot>)和 app.reconciler_unbound。 偏离:岗位行不让助手代填(同「结果不明时谁去核实」,派活给谁是人的决定)。 测试:test_publish_plan 新增「财务审批(岗位)+ 写回(没登记查询)」建自由形蓝图一条(两行、不选人时只有 bp.people_unbound、 改会签被拒、选好人后绑到 finance 和核实岗位、发布单不再挡);老的两条按新说法改;test_blueprint_templates 新增 people_rows 的合并、升级岗位、只读行、残留绑定提醒。
956 lines
50 KiB
Python
956 lines
50 KiB
Python
"""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 is_freeform(spec: dict[str, Any]) -> bool:
|
||
from app.blueprints.templates._common import is_freeform as imports_graph
|
||
|
||
return imports_graph(spec)
|
||
|
||
|
||
#: 自由形的「谁来做」只定由谁做(05-已知遗留 (d)、遗留-Q5)
|
||
FREEFORM_PEOPLE_ONLY_WHO = "自由形蓝图的「谁来做」只定每一步由谁做;会签、时限、超时、加减步骤在画布上改"
|
||
FREEFORM_PEOPLE_USER = "「谁来做」要由你来定:派活给谁是人的决定,助手不替你选"
|
||
|
||
|
||
def _freeform_who(spec: dict[str, Any], tokens: list[str], value: Any, source: str, graph: Any) -> None:
|
||
"""自由形的 /people/<i>/who:i 是按导入的图列出的第几个岗位(freeform.people_rows 的岗位行,界面照那一份的 path 写),
|
||
绑定存进 spec.people[](id 就是岗位名、只有 who)。只收人和组:岗位、发起人在图上已经写定了。"""
|
||
from app.blueprints.templates import freeform
|
||
from app.blueprints.templates._common import with_graph
|
||
|
||
if len(tokens) != 3 or tokens[2] != "who":
|
||
raise BpRefused(FREEFORM_PEOPLE_ONLY_WHO, code="bp.template_mismatch")
|
||
if source != "user":
|
||
raise BpRefused(FREEFORM_PEOPLE_USER)
|
||
if graph is None:
|
||
raise BpRefused("这张蓝图还没有导入的图,没法按图定谁来做")
|
||
rows = [r for r in freeform.people_rows(with_graph(spec, graph)) if r["kind"] == "slot"]
|
||
slot = rows[_index(tokens[1], len(rows))]["slot"]
|
||
checked = _checked("people", "who", value, spec)
|
||
if checked and any(p["kind"] not in ("user", "group") for p in checked):
|
||
raise BpRefused("这里只选人或组:岗位、发起人在图上已经定了")
|
||
entries = spec.setdefault("people", [])
|
||
entry = next((e for e in entries if e.get("id") == slot), None)
|
||
if entry is None:
|
||
entry = {"id": slot}
|
||
entries.append(entry)
|
||
entry["who"] = _item(checked, source)
|
||
|
||
|
||
def _set(spec: dict[str, Any], tokens: list[str], value: Any, source: str, graph: Any = None) -> None:
|
||
card = tokens[0]
|
||
if card == "people" and is_freeform(spec):
|
||
_freeform_who(spec, tokens, value, source, graph)
|
||
return
|
||
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 == "people" and is_freeform(spec):
|
||
raise BpRefused(FREEFORM_PEOPLE_ONLY_WHO, code="bp.template_mismatch")
|
||
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, graph: Any = None) -> None:
|
||
card = tokens[0]
|
||
if card == "people" and is_freeform(spec):
|
||
if len(tokens) != 3:
|
||
raise BpRefused(FREEFORM_PEOPLE_ONLY_WHO, code="bp.template_mismatch")
|
||
_freeform_who(spec, tokens, None, source, graph)
|
||
return
|
||
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, graph: Any = None) -> dict[str, Any]:
|
||
"""把一个 bp_* 操作落到蓝图上,交回新的蓝图(不改入参)。source 是 user / assistant。不合规时抛 BpRefused。
|
||
graph 是蓝图存的图:自由形的「谁来做」按它列岗位(没给时那张卡片改不了)。"""
|
||
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, graph)
|
||
elif parsed.op == "bp_add":
|
||
_add(out, tokens, parsed.value, source)
|
||
else:
|
||
_remove(out, tokens, source, graph)
|
||
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, graph: Any = None) -> list[dict[str, Any]]:
|
||
"""蓝图检查(§3.13 bp.*):for_ = trial(缺项挡试运行)/ publish(待确认项挡发布)/ personal。
|
||
每条 {code, level, path, message}。graph 是蓝图存的图:自由形的「谁来做」按它查(没给时不查这一张)。"""
|
||
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 and template.imports_graph:
|
||
if graph is not None and for_ != "personal":
|
||
out += _freeform_people_issues(spec, graph, publish)
|
||
elif "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 _freeform_people_issues(spec: dict[str, Any], graph: Any, publish: bool) -> list[dict[str, Any]]:
|
||
"""自由形的「谁来做」(freeform.people_rows):岗位、核实人没绑的报 bp.people_unbound(试运行 warning:待办派给试运行的
|
||
人自己;发布 error),路径就是那一行的 path,带上 slot(发布单据此去掉同一件事的 app.slot_unbound / app.reconciler_unbound)。
|
||
图里已经没有的岗位、卡片上还留着绑定的,照实提醒(warning),不静默删掉。"""
|
||
from app.blueprints.templates import freeform
|
||
from app.blueprints.templates._common import with_graph
|
||
|
||
out: list[dict[str, Any]] = []
|
||
level = "error" if publish else "warning"
|
||
rows = freeform.people_rows(with_graph(spec, graph))
|
||
for row in rows:
|
||
if row["kind"] not in ("slot", "reconciler") or row["who"]:
|
||
continue
|
||
message = (f"{row['label']}还没定:{row['detail']},写入外部系统的结果不明时要有人去目标系统里核实"
|
||
if row["kind"] == "reconciler" else f"{row['label']}还没定谁来处理({row['detail']})")
|
||
out.append({"code": "bp.people_unbound", "level": level, "path": row["path"], "message": message,
|
||
"slot": row["slot"]})
|
||
known = {r["slot"] for r in rows if r["kind"] == "slot"}
|
||
for step in spec.get("people") or []:
|
||
slot = str(step.get("id") or "")
|
||
if slot and slot not in known and _value(step.get("who")):
|
||
out.append({"code": "bp.template_mismatch", "level": "warning", "path": "/people",
|
||
"message": f"岗位「{slot}」在导入的图里已经没有了:卡片上留着的这条绑定不会生效"})
|
||
return out
|
||
|
||
|
||
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])}"
|