Files
AgentLab/backend/app/blueprints/templates/_common.py
T
ThearchyHelios 0b6cde5c0c 自由形应用的两处小问题:入口按钮「发起」后英文名空一格,岗位显示名不再拿岗位名充数
一、入口按钮写成了「发起accept 报销写回应用」:派生(apps/derive)、审批模板、自由形模板都是 f"发起{名字}" 直接拼。
加 apps/derive.start_label,名字以英文字母、数字开头时中间空一格(「发起 accept 报销写回应用」),三处都用它。

二、岗位标签显示原始名 finance:派生和自由形导入的图里只写了岗位名(画布上随手写的 id),应用里岗位的显示名就填成了它,
看着像起好的名字。加 apps/tree.slot_label:按用到这个岗位的节点在图上的名字拼(派给它的写节点名,几处用到用顿号连;
超时升级给它的写「…超时升级」,同蓝图模板 slot_labels 的叫法;动作确认派给它的写「…发送前确认」);节点没起名字、
没有节点用到它的照实用岗位名。派生和蓝图模板(app_base)共用:卡片上起了名字的(审批模板的步骤名)照旧用卡片上的,
卡片上就是岗位名本身的才按节点名拼。应用编辑器的岗位一节里,显示名和岗位名一样时写一句「还没有起显示名,现在用的是图里的
岗位名」(文案在 lib/terms 的 APP_EDITOR_TEXT.slotLabelRaw),不让人误以为那是起好的名字。

测试:test_blueprint_templates.py 的 test_entry_labels_keep_a_space_before_a_latin_name、
test_slot_labels_use_the_node_names_and_say_so_when_there_is_none、test_derived_apps_use_the_same_slot_labels_and_entry_label;
test_app_spec_tools.py::test_derive_a_process_leaves_slots_empty 原来钉的是旧行为(显示名 = finance),改成节点名「财务审批」。
去掉修复回跑四条都失败。
2026-10-17 12:00:58 +08:00

840 lines
44 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.
"""场景模板共用的零件(N3、§3.3–§3.4):画节点、按卡片填确定性关节、按库分组的取数、口径卡、应用骨架、定时任务。
每个模板模块只写自己的骨架长什么样,怎么从卡片换成节点配置都在这里,各模板说法一致(P5):
- 统计期:入口字段 period / period_start / period_end,定时任务的输入模板按「多久更新」卡片取(scheduler 的模板变量);
- 谁来做:人工节点只写岗位(派给谁、超时升级给谁),岗位绑到哪些人由应用的岗位绑定定(bindings),和样例⑩同一个做法;
- 看哪些数:按取数节点分组,同一组一条 SQL(生成关节 sql),口径卡每个数一个指标式(生成关节 metric_expr),复用已发布
口径卡的项落成 caliber_from;
- 应用骨架:apps/derive.py 的派生规则(_results、_entries、岗位、待办外观)加模板自己的排布。
每个模板的登记(TemplateDef)连同示例蓝图(测试和启动核对用)、选模板时的预置、给模型看的每组关节说明、模板自己认的问题
一起写,不另开旁路登记;这里的 prefill / template_problems 按模板 id 取登记来用。
"""
from __future__ import annotations
import copy
import hashlib
import json
import re
from dataclasses import dataclass, field
from typing import Any
from app.blueprints.templates import Joint, get_template
#: 实例化时交给模板的上下文(蓝图 id、工作流 id、定时任务 id、记录表、导入的图):放在 bp 的这个键下,模板读、不写回
CONTEXT_KEY = "_context"
#: 生成关节没填时的占位。SQL 是一句注释(试运行和发布前由 tpl.joint_unfilled 挡住);指标式是空值(照常能过画布校验)
SQL_PLACEHOLDER = "-- 待填:助手还没写这条 SQL"
METRIC_PLACEHOLDER = "None"
#: 口径卡、成果、解析节点的固定 id
CALIBER = "caliber"
DONE = "done"
START = "start"
# ==========================================================================
# 写给模型的文字(每组关节的说明)
# ==========================================================================
SQL_BRIEF_HEAD_PROMPT = ("取数节点 {node}(工具 db_query__{source}):写一条只读 SQL(只能 SELECT,一条语句),结果{shape}。"
"这个工具已经连到数据源「{source}」:表名照下面写的(就是这个数据源里的表名),前面不要加「{source}.」,"
"数据库里没有叫这个名字的库。{period}")
SQL_PERIOD_PROMPT = ("统计期用入口字段:从 '{{ input.period_start }}' 到 '{{ input.period_end }}'(日期,含两端,"
"写在 SQL 里要带单引号)。")
SQL_ONE_ROW_PROMPT = "只出一行,每个要算的数一列(或者算它要用的基础量各一列),列名用英文小写和下划线"
SQL_ITEM_PROMPT = "- {label}(指标 id {id}{unit}):怎么算:{measure};表:{table};业务日期:{date};筛选:{filter};粒度:{grain}"
SQL_REUSED_PROMPT = "复用的已发布口径卡要这条 SQL 交出这几列(列名照抄,别改):{columns}"
METRIC_BRIEF_PROMPT = ("口径卡 {card} 每个数写一个指标式(关节 metric_<指标 id>):{metrics}。只能用 cell(nodes.{node}, 0, '列名') "
"取上面那条 SQL 第一行的格,再做加减乘除、round、abs、min、max、比较和 … if … else …;分母可能为 0 "
"时写成 … if 分母 else None。不能用别的函数,不能引用别的节点。")
METRIC_ITEM_PROMPT = "{id}({label}{unit})"
UNKNOWN_PROMPT = "未写"
# ==========================================================================
# 节点、连线、卡片上的值
# ==========================================================================
def node(node_id: str, node_type: str, label: str, **config: Any) -> dict[str, Any]:
"""一个节点(和 seed.py 的 _n 同一种写法)。"""
return {"id": node_id, "type": node_type, "position": {"x": 0, "y": 0},
"data": {"label": label, "config": config}}
def edge(source: str, target: str, handle: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {"source": source, "target": target}
if handle:
out["sourceHandle"] = handle
return out
def value(item: Any) -> Any:
"""卡片上一项的值({value, source, status} → value)。"""
return item.get("value") if isinstance(item, dict) else None
def context(bp: dict[str, Any]) -> dict[str, Any]:
ctx = bp.get(CONTEXT_KEY)
return ctx if isinstance(ctx, dict) else {}
def with_graph(spec: dict[str, Any], graph: Any) -> dict[str, Any]:
"""只有卡片(Blueprint.spec)、没有实例化上下文的地方,带上蓝图存的图再交给模板的函数:自由形的「谁来做」、要不要
核实人都按导入的图算(freeform.people_rows / needs_reconciler)。"""
return {**spec, CONTEXT_KEY: {**context(spec), "graph": graph}}
def is_freeform(spec: dict[str, Any]) -> bool:
"""卡片上选的是导入图的模板(自由形)。"""
ref = spec.get("template")
template = get_template(str(ref.get("id") or "")) if isinstance(ref, dict) else None
return template is not None and template.imports_graph
def app_name(bp: dict[str, Any], fallback: str) -> str:
"""应用和工作流的名字:实例化时给的名字,没给就用目的的前一段。"""
name = str(context(bp).get("name") or "").strip()
if name:
return name[:200]
purpose = str(value(bp.get("purpose")) or "").strip()
return (re.split(r"[,。;,;\n]", purpose)[0][:40] if purpose else "") or fallback
def safe_id(text: str) -> str:
"""节点 id 用的一段:小写字母、数字、下划线;全是中文的名字换不出来时为空串,由调用方编号。"""
return re.sub(r"_+", "_", re.sub(r"[^a-z0-9_]", "_", text.lower())).strip("_")[:24]
# ==========================================================================
# 统计期与定时任务
# ==========================================================================
#: 统计哪一期 → (说法, 期名、第一天、最后一天各取哪个定时任务模板变量,scheduler/service.VARIABLES)
PERIODS: dict[str, tuple[str, str, str, str]] = {
"today": ("今天", "today", "today", "today"),
"yesterday": ("昨天", "yesterday", "yesterday", "yesterday"),
"this_iso_week": ("本周", "this_week", "this_week_start", "today"),
"last_iso_week": ("上周", "last_week", "last_week_start", "last_week_end"),
"this_month": ("本月", "this_month", "this_month_start", "today"),
"last_month": ("上月", "last_month", "last_month_start", "last_month_end"),
}
#: 没写统计哪一期时按更新频率取:每天看昨天,每周看上周,每月看上月
DEFAULT_PERIOD = {"daily": "yesterday", "weekly": "last_iso_week", "monthly": "last_month"}
PERIOD_FIELDS = ("period", "period_start", "period_end")
def period_fields() -> list[dict[str, Any]]:
"""统计期的入口字段。第一天、最后一天都含在内(SQL 说明里照这个写)。"""
return [
{"name": "period", "label": "统计期", "description": "这一期叫什么,例如 2026-W41、2026-09"},
{"name": "period_start", "label": "第一天", "type": "string", "format": "date", "required": True,
"description": "统计期的第一天(含)"},
{"name": "period_end", "label": "最后一天", "type": "string", "format": "date", "required": True,
"description": "统计期的最后一天(含)"},
]
def period_of(cadence: dict[str, Any] | None) -> str:
if not isinstance(cadence, dict):
return "yesterday"
return cadence.get("period") or DEFAULT_PERIOD.get(cadence.get("preset") or "", "yesterday")
def cron_of(cadence: dict[str, Any]) -> str:
"""更新频率 → 五字段 cron。周几按卡片的 1–7(周一到周日),cron 里周日写 0。"""
hour, minute = (int(x) for x in str(cadence["time"]).split(":"))
if cadence["preset"] == "weekly":
return f"{minute} {hour} * * {int(cadence['weekday']) % 7}"
if cadence["preset"] == "monthly":
return f"{minute} {hour} {int(cadence['day'])} * *"
return f"{minute} {hour} * * *"
def period_template(cadence: dict[str, Any] | None) -> dict[str, str]:
"""定时任务的输入模板:统计期三个字段各取哪个变量。"""
_, name, start, end = PERIODS[period_of(cadence)]
return {"period": f"{{{{ schedule.{name} }}}}", "period_start": f"{{{{ schedule.{start} }}}}",
"period_end": f"{{{{ schedule.{end} }}}}"}
def schedule_of(bp: dict[str, Any], name: str, *, inputs: dict[str, str] | None = None) -> dict[str, Any] | None:
"""定时任务参数(api/schedules 建任务的字段):正式运行、先停用(发布单在应用发布后才启用,N5)。没定更新频率为 None。"""
cadence = value(bp.get("cadence"))
if not isinstance(cadence, dict):
return None
return {"name": f"{name}(定时)", "cron": cron_of(cadence), "timezone": cadence["tz"], "run_class": "formal",
"input_template": period_template(cadence) if inputs is None else inputs, "enabled": False}
# ==========================================================================
# 填什么:入口字段与表单
# ==========================================================================
#: 「填什么」的字段属性里能带进入口节点字段的键(engine/nodes/io.py、apps/forms.input_schema 认的那几样)
_INPUT_KEYS = ("type", "description", "default", "enum", "format", "minimum", "maximum", "x-widget", "x-enum-labels")
def form_fields(bp: dict[str, Any], *, required: bool = True) -> list[dict[str, Any]]:
"""「填什么」→ 入口节点的字段。required=False 时都不在入口节点上必填(同一张图另有不填表单的路径,必填由应用的入口
表单把关)。"""
out: list[dict[str, Any]] = []
for f in bp.get("fields") or []:
prop = value(f.get("spec")) or {}
out.append({"name": f["name"], "label": str(prop.get("title") or f["name"]),
**{k: copy.deepcopy(prop[k]) for k in _INPUT_KEYS if k in prop},
**({"required": True} if required and prop.get("required") else {})})
return out
def entry_form(bp: dict[str, Any], title: str = "") -> dict[str, Any]:
"""入口表单(agentlab.form/1):直接用「填什么」卡片上的表单属性,选项文字、控件这些外观也带上。"""
from app.apps.spec import FORM_FORMAT
props: dict[str, Any] = {}
required: list[str] = []
for f in bp.get("fields") or []:
prop = dict(value(f.get("spec")) or {"type": "string"})
if prop.pop("required", False):
required.append(f["name"])
prop.setdefault("title", f["name"])
props[f["name"]] = prop
return {"format": FORM_FORMAT, "type": "object", **({"title": title} if title else {}), "properties": props,
"required": required, "x-order": list(props)}
def field_ref(name: str) -> str:
return f"{{{{ input.{name} }}}}"
def fields_message(bp: dict[str, Any]) -> str:
"""待办里列出表单内容:每个字段一行「名称:值」。"""
lines = []
for f in bp.get("fields") or []:
title = (value(f.get("spec")) or {}).get("title") or f["name"]
lines.append(f"{title}:{field_ref(f['name'])}")
return "\n".join(lines)
# ==========================================================================
# 谁来做:人工节点的确定性关节
# ==========================================================================
def slot_of(step: dict[str, Any]) -> str:
"""这一步派给哪个岗位:处理人就是一个岗位时用它的名字(同一个岗位可以出现在几步里),否则用步骤 id。"""
who = value(step.get("who")) or []
if len(who) == 1 and who[0].get("kind") == "slot":
return str(who[0]["name"])
return str(step["id"])
def escalate_slot(step: dict[str, Any]) -> str | None:
timeout = value(step.get("on_timeout"))
if not isinstance(timeout, dict) or timeout.get("action") != "escalate":
return None
to = timeout.get("to") or []
if len(to) == 1 and to[0].get("kind") == "slot":
return str(to[0]["name"])
return f"{step['id']}_up"
def assignee_of(step: dict[str, Any]) -> dict[str, Any]:
who = value(step.get("who")) or []
if len(who) == 1 and who[0].get("kind") == "initiator":
return {"kind": "initiator"}
return {"kind": "slot", "slot": slot_of(step)}
def policy_of(step: dict[str, Any]) -> Any:
policy = value(step.get("policy")) or {"kind": "any"}
if policy["kind"] == "quorum":
return {"quorum": int(policy["m"])}
return policy["kind"]
def timeout_of(step: dict[str, Any]) -> dict[str, Any] | None:
timeout = value(step.get("on_timeout"))
if not isinstance(timeout, dict):
return None
out: dict[str, Any] = {"action": timeout["action"]}
if timeout["action"] == "escalate":
out["to_slot"] = escalate_slot(step)
if timeout.get("every"):
out["every"] = timeout["every"]
return out
def human(step: dict[str, Any], index: int, node_id: str, *, title: str, message: str, mode: str = "approve",
form: dict[str, Any] | None = None, **extra: Any) -> tuple[dict[str, Any], list[Joint]]:
"""「谁来做」的一步 → 人工节点和它的确定性关节(派给谁、几个人同意、时限、超时怎么办)。"""
config: dict[str, Any] = {"mode": mode, "title": title, "message": message, "assignee": assignee_of(step)}
policy = policy_of(step)
if mode == "approve" or policy == "any":
config["policy"] = policy
if due := value(step.get("due")):
config["due"] = due
if timeout := timeout_of(step):
config["on_timeout"] = timeout
if form is not None:
config["form"] = form
config.update(extra)
label = str(value(step.get("label")) or step["id"])
base = f"/people/{index}"
joints = [Joint(id=f"{node_id}.assignee", kind="assignee", node_id=node_id, field="/assignee",
from_card=f"{base}/who", label=f"{label}:派给谁"),
Joint(id=f"{node_id}.policy", kind="policy", node_id=node_id, field="/policy",
from_card=f"{base}/policy", label=f"{label}:几个人同意算通过")]
if "due" in config:
joints.append(Joint(id=f"{node_id}.due", kind="due", node_id=node_id, field="/due", from_card=f"{base}/due",
label=f"{label}:时限"))
if "on_timeout" in config:
joints.append(Joint(id=f"{node_id}.on_timeout", kind="on_timeout", node_id=node_id, field="/on_timeout",
from_card=f"{base}/on_timeout", label=f"{label}:超时怎么办"))
return node(node_id, "human", label, **config), joints
def threshold_condition(threshold: dict[str, Any]) -> str:
"""金额线 → 分支条件「input.字段 比较 常量」(只有比较,没有算术,§3.4 threshold)。"""
return f"input.{threshold['field']} {threshold['op']} {json.dumps(threshold['value'], ensure_ascii=False)}"
def title_of(bp: dict[str, Any], label: str) -> str:
"""待办标题带上表单的前两个字段:同一个人的两笔申请,待办列表里一眼分得开(同样例⑩)。"""
refs = [field_ref(f["name"]) for f in (bp.get("fields") or [])[:2]]
return f"{label}:{' · '.join(refs)}" if refs else label
@dataclass
class Chain:
"""一串审批:入口连到 head,每一步通过去下一步(写了条件的先过分支,不满足直接跳过),驳回都到 rejected。"""
head: str
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
joints: list[Joint]
#: 每一步的意见(成果字段)
notes: list[dict[str, Any]]
def approval_chain(bp: dict[str, Any], *, after: str, rejected: str) -> Chain:
"""「谁来做」→ 一串人工节点。从后往前排:每一步通过之后去哪(下一步的分支或人工节点,最后是 after)。"""
steps = bp.get("people") or []
parts: list[tuple[list[dict[str, Any]], list[dict[str, Any]], list[Joint]]] = []
notes: list[dict[str, Any]] = []
for i in range(len(steps) - 1, -1, -1):
step = steps[i]
review_id = f"review_{step['id']}"
label = str(value(step.get("label")) or step["id"])
review, review_joints = human(step, i, review_id, title=title_of(bp, label), message=fields_message(bp))
step_nodes = [review]
step_edges = [edge(review_id, after, "approved"), edge(review_id, rejected, "rejected")]
step_joints = list(review_joints)
head = review_id
threshold = value(step.get("threshold"))
if isinstance(threshold, dict):
gate_id = f"gate_{step['id']}"
step_nodes.insert(0, node(gate_id, "branch", f"是否要{label}", mode="expression", cases=[
{"key": "hit", "condition": threshold_condition(threshold), "label": f"要{label}"}]))
step_edges = [edge(gate_id, review_id, "hit"), edge(gate_id, after, "default"), *step_edges]
step_joints.append(Joint(id=f"{gate_id}.threshold", kind="threshold", node_id=gate_id,
field="/cases/0/condition", from_card=f"/people/{i}/threshold",
label=f"{label}:走这一步的条件"))
head = gate_id
parts.insert(0, (step_nodes, step_edges, step_joints))
notes.insert(0, {"name": f"{label}意见", "value": f"{{{{ nodes.{review_id}.note }}}}"})
after = head
return Chain(head=after, nodes=[n for p in parts for n in p[0]], edges=[e for p in parts for e in p[1]],
joints=[j for p in parts for j in p[2]], notes=notes)
def bindings_of(bp: dict[str, Any]) -> dict[str, Any]:
"""岗位绑定(应用的 draft_bindings 形状 {"slots": {岗位: [{kind: user|group, name}]}}):处理人、超时升级给的人
写的是具体的人或组的,按这一步的岗位绑上;写的是岗位的,岗位绑到谁要在应用里定(发布单的「你来定」)。"""
slots: dict[str, list[dict[str, str]]] = {}
def bind(slot: str | None, principals: list[dict[str, Any]]) -> None:
if not slot:
return
people = [{"kind": p["kind"], "name": p["name"]} for p in principals if p.get("kind") in ("user", "group")]
if people:
have = slots.setdefault(slot, [])
have += [p for p in people if p not in have]
for step in bp.get("people") or []:
bind(slot_of(step), value(step.get("who")) or [])
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}
def slot_labels(bp: dict[str, Any]) -> dict[str, str]:
"""岗位 → 给人看的名字:步骤的名字,升级岗位写「……超时升级」。"""
out: dict[str, str] = {}
for step in bp.get("people") or []:
label = str(value(step.get("label")) or step["id"])
out.setdefault(slot_of(step), slot_of(step) if slot_of(step) != step["id"] else label)
if up := escalate_slot(step):
out.setdefault(up, up if up != f"{step['id']}_up" else f"{label}超时升级")
return out
# ==========================================================================
# 写回哪里:调用工具节点的确定性关节
# ==========================================================================
def write_args(mapping: dict[str, Any], *, metrics: dict[str, str] | None = None,
rows: str | None = None) -> tuple[dict[str, Any], list[str]]:
"""字段对应 → 调用工具节点的 args(模板写法)。metrics:指标 id → 取值链;rows:核对出来的行的取值链。
交回 (args, 对不上的取值)。"""
args: dict[str, Any] = {}
bad: list[str] = []
for param, source in (mapping or {}).items():
if isinstance(source, bool) or isinstance(source, (int, float)):
args[param] = source
elif source == "rows" and rows:
args[param] = f"{{{{ {rows} | compact }}}}"
elif isinstance(source, str) and source.startswith("form."):
args[param] = field_ref(source[len("form."):])
elif isinstance(source, str) and source.startswith("metric.") and source[len("metric."):] in (metrics or {}):
args[param] = f"{{{{ {(metrics or {})[source[len('metric.'):]]} }}}}"
elif isinstance(source, str) and len(source) >= 2 and source[0] == source[-1] == "'":
args[param] = source[1:-1]
else:
bad.append(f"{param}:{source}")
return args, bad
def write_node(bp: dict[str, Any], node_id: str, *, confirm_slot: str | None, metrics: dict[str, str] | None = None,
rows: str | None = None, label: str = "写回") -> tuple[dict[str, Any], list[Joint], list[str]]:
"""「写回哪里」→ 调用工具节点:工具是目标能力,参数按字段对应;要人确认时审批写「每次都审批」并派给 confirm_slot,
不要确认时写「全部自动放行」(能力登记里还要允许无人值守写入才真不问人,nodes/tools._run_write)。"""
wb = bp.get("writeback") or {}
capability = value(wb.get("capability")) or ""
args, bad = write_args(value(wb.get("mapping")) or {}, metrics=metrics, rows=rows)
config: dict[str, Any] = {"tool": capability, "args": args}
if value(wb.get("confirm")) is False:
config["approval"] = "never"
else:
config["approval"] = "always"
if confirm_slot:
config["assignee"] = {"kind": "slot", "slot": confirm_slot}
joints = [Joint(id=f"{node_id}.capability", kind="capability", node_id=node_id, field="/tool",
from_card="/writeback/capability", label=f"{label}:目标能力"),
Joint(id=f"{node_id}.args", kind="capability", node_id=node_id, field="/args",
from_card="/writeback/mapping", label=f"{label}:字段对应")]
return node(node_id, "tool", label, **config), joints, bad
def write_lookup(capability: str) -> bool:
"""写入能力有没有登记查询(结果未知时按幂等键查实际结果):没有的要绑核实岗位(apps/derive.RECONCILER)。"""
from app.capabilities.registry import cached_view
view = cached_view(capability) or {}
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 not None and template.imports_graph:
# 自由形没有「写回哪里」:按导入的图里的写入算(bp 要带着图,with_graph),核实人存在 /writeback/reconciler
# (05-已知遗留 (d))
from app.blueprints.templates import freeform
return freeform.needs_reconciler(bp)
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))
# ==========================================================================
# 看哪些数:按取数节点分组
# ==========================================================================
@dataclass
class Group:
"""一个取数节点和它要算的数。同一组的 SQL 和指标式一起交给模型填(生成关节的 group),不同组并行填。"""
node_id: str
source: str
#: 本地算的数据项(口径卡里各一个指标)
items: list[dict[str, Any]] = field(default_factory=list)
#: 复用已发布口径卡的数据项:它们读这个取数节点(reuse.inputs)
reused: list[dict[str, Any]] = field(default_factory=list)
#: 复用的口径卡要这条 SQL 交出的列
columns: list[str] = field(default_factory=list)
def data_groups(bp: dict[str, Any], *, source_map: dict[str, str] | None = None,
prefix: str = "fetch") -> tuple[list[Group], list[dict[str, Any]]]:
"""「看哪些数」→ 取数分组:同一个库一条 SQL。复用的口径卡读的是原工作流里某个取数节点(cell(nodes.X, …)),新图里
那个库的取数节点就叫 X、交出同样的列,复用才算得出来。交回 (分组, 问题)。source_map 把模板自己的占位库名换成真名
(填报登记的记录表)。"""
from app.tools.datasource import QUERY_PREFIX
groups: dict[str, Group] = {}
by_source: dict[str, str] = {}
problems: list[dict[str, Any]] = []
source_map = source_map or {}
def problem(code: str, i: int, message: str) -> None:
problems.append({"code": code, "level": "error", "path": f"/data/{i}", "message": message})
for i, item in enumerate(bp.get("data") or []):
reuse = item.get("reuse")
if not reuse:
continue
label = value(item.get("label")) or item["id"]
inputs = reuse.get("inputs")
if not inputs:
problem("bp.data_unmapped", i, f"「{label}」复用的口径卡还没核对过它读哪些列:请重新从口径卡里选一次")
continue
for inp in inputs:
tool = str(inp.get("tool") or "")
if not tool.startswith(QUERY_PREFIX):
problem("bp.template_mismatch", i, f"「{label}」复用的口径卡不是从数据库取数的,模板里接不上:请按表重新对")
break
source, node_id = tool[len(QUERY_PREFIX):], str(inp["node"])
if by_source.get(source, node_id) != node_id or (node_id in groups and groups[node_id].source != source):
problem("bp.template_mismatch", i, f"「{label}」复用的口径卡和别的口径卡取数的方式对不上:请只复用其中一张,"
"其余按表重新对")
break
by_source[source] = node_id
group = groups.setdefault(node_id, Group(node_id=node_id, source=source))
if item not in group.reused:
group.reused.append(item)
group.columns += [str(c) for c in inp.get("columns") or [] if str(c) not in group.columns]
used = set(groups)
for item in bp.get("data") or []:
table = value(item.get("table"))
if item.get("reuse") or not isinstance(table, dict):
continue
source = source_map.get(table["source"], table["source"])
node_id = by_source.get(source)
if node_id is None:
base = safe_id(source)
node_id = f"{prefix}_{base}" if base else f"{prefix}_{len(used) + 1}"
while node_id in used:
node_id = f"{node_id}_{len(used) + 1}"
used.add(node_id)
by_source[source] = node_id
groups.setdefault(node_id, Group(node_id=node_id, source=source)).items.append(item)
return list(groups.values()), problems
def fetch_nodes(groups: list[Group]) -> tuple[list[dict[str, Any]], list[Joint]]:
"""每组一个取数节点(调用工具 db_query__库名),SQL 是生成关节。"""
nodes: list[dict[str, Any]] = []
joints: list[Joint] = []
for g in groups:
nodes.append(node(g.node_id, "tool", f"取数({g.source})", tool=f"db_query__{g.source}",
args={"sql": SQL_PLACEHOLDER}))
joints.append(Joint(id=f"sql_{g.node_id}", kind="sql", node_id=g.node_id, field="/args/sql", group=g.node_id,
label=f"取数的 SQL({g.source})"))
return nodes, joints
def caliber_node(bp: dict[str, Any], groups: list[Group], name: str) -> tuple[dict[str, Any] | None, list[Joint]]:
"""本地算的数都进一张口径卡,每个数一个指标式(生成关节)。缺输入记成空值,交给出具契约按必需指标判档。"""
metrics: list[dict[str, Any]] = []
joints: list[Joint] = []
for g in groups:
for item in g.items:
metric: dict[str, Any] = {"id": item["id"], "name": str(value(item.get("label")) or item["id"]),
"expression": METRIC_PLACEHOLDER}
if unit := value(item.get("unit")):
metric["unit"] = unit
joints.append(Joint(id=f"metric_{item['id']}", kind="metric_expr", node_id=CALIBER,
field=f"/metrics/{len(metrics)}/expression", group=g.node_id,
label=f"指标式:{metric['name']}"))
metrics.append(metric)
if not metrics:
return None, []
return node(CALIBER, "metrics", "口径卡", caliber=f"{name}口径", caliber_version="v1", on_missing="null",
metrics=metrics), joints
def reuse_nodes(groups: list[Group]) -> list[tuple[dict[str, Any], dict[str, Any], str]]:
"""复用已发布口径卡的项各一张卡(caliber_from 固定到那一版),交回 (节点, 数据项, 读的取数节点)。"""
out = []
for g in groups:
for item in g.reused:
reuse = item["reuse"]
out.append((node(f"reuse_{item['id']}", "metrics", f"复用口径:{value(item.get('label')) or item['id']}",
caliber_from={"workflow_id": reuse["workflow_id"], "workflow_version": reuse["version"],
"node_id": reuse["node_id"]}), item, g.node_id))
return out
def metric_paths(caliber: dict[str, Any] | None) -> dict[str, str]:
"""口径卡里每个指标的取值链(成果字段、写回参数用):指标 id → nodes.caliber.metrics[i].value。"""
if caliber is None:
return {}
return {m["id"]: f"nodes.{CALIBER}.metrics[{i}].value"
for i, m in enumerate(caliber["data"]["config"].get("metrics") or [])}
def board_output(bp: dict[str, Any], caliber: dict[str, Any] | None,
reused: list[tuple[dict[str, Any], dict[str, Any], str]],
node_id: str = DONE, label: str = "出具") -> tuple[dict[str, Any], list[Joint]]:
"""按指标出具的成果(engine/issuance.metrics_only、governance.board_only):契约只写「指标来自」和必需指标,成果字段
每个本地指标一项(值就是指标值,回指得上)。复用的指标在应用里按指标块看,不进成果字段。"""
paths = metric_paths(caliber)
fields = []
for m in (caliber["data"]["config"]["metrics"] if caliber else []):
unit = f"({m['unit']})" if m.get("unit") else ""
fields.append({"name": f"{m['name']}{unit}", "value": f"{{{{ {paths[m['id']]} }}}}"})
sources = ([CALIBER] if caliber else []) + [n["id"] for n, _, _ in reused]
required = [m["id"] for m in (caliber["data"]["config"]["metrics"] if caliber else [])] + \
[item["reuse"]["metric_id"] for _, item, _ in reused]
contract = {"metrics_from": sources, "required": required, "strict": True}
joints = [Joint(id=f"{node_id}.contract", kind="contract", node_id=node_id, field="/contract", from_card="/data",
label="出具契约:必需指标")]
return node(node_id, "output", label, fields=fields, contract=contract), joints
def data_briefs(bp: dict[str, Any], groups: list[Group], *, shape: str = SQL_ONE_ROW_PROMPT,
period: bool = True) -> dict[str, str]:
"""每组关节给模型的说明:要算哪些数、用哪张表、业务日期、筛选,口径卡的指标式怎么写。"""
out: dict[str, str] = {}
for g in groups:
lines = [SQL_BRIEF_HEAD_PROMPT.format(node=g.node_id, source=g.source, shape=shape,
period=SQL_PERIOD_PROMPT if period else "")]
for item in g.items:
lines.append(item_brief(item))
if g.columns:
lines.append(SQL_REUSED_PROMPT.format(columns="、".join(g.columns)))
if g.items:
metrics = [METRIC_ITEM_PROMPT.format(id=item["id"], label=value(item.get("label")) or item["id"],
unit=f",单位 {value(item.get('unit'))}" if value(item.get("unit")) else "")
for item in g.items]
lines.append(METRIC_BRIEF_PROMPT.format(card=CALIBER, node=g.node_id, metrics="、".join(metrics)))
out[g.node_id] = "\n".join(lines)
return out
def item_brief(item: dict[str, Any]) -> str:
table = value(item.get("table")) or {}
date = value(item.get("date")) or {}
unit = value(item.get("unit"))
def text(key: str) -> str:
return str(value(item.get(key)) or UNKNOWN_PROMPT)
return SQL_ITEM_PROMPT.format(
label=value(item.get("label")) or item["id"], id=item["id"], unit=f",单位 {unit}" if unit else "",
# 只写数据源里的表名(多 schema 的库,目录里的名字本身带 schema 前缀):以前写成「数据源名.表名」,模型照抄进 SQL,
# 数据源名被数据库当成库名,跑起来报库不存在
measure=text("measure"), table=str(table.get("table") or UNKNOWN_PROMPT) if table else UNKNOWN_PROMPT,
date=(f"{date.get('column')}" + (f"({date['rule']})" if date.get("rule") else "")) if date else UNKNOWN_PROMPT,
filter=text("filter"), grain=text("grain"))
def basis_of(brief: str, template_id: str, version: int) -> str:
"""一组生成关节的依据的指纹:说明变了(卡片改了),之前填的就不算数了,要重新填。"""
return hashlib.sha256(f"{template_id}@{version}\n{brief}".encode()).hexdigest()[:16]
# ==========================================================================
# 应用骨架
# ==========================================================================
def app_base(bp: dict[str, Any], graph: dict[str, Any], *, use: str, name: str, description: str = "",
writes: dict[str, str] | None = None) -> dict[str, Any]:
"""按 apps/derive.py 的派生规则出应用骨架的公共部分:钉住项(还没发布,版本为空)、岗位、待办外观、核实岗位。
writes 是写外部系统的调用工具节点 → 能力名(模板知道哪几个是写入,不用查库)。页面、视图、入口由各模板排。"""
from app.apps.derive import PIN, RECONCILER
from app.apps.spec import APP_FORMAT
from app.apps.tree import node_map, slot_label, slot_refs
from app.core.artifact_store import graph_hash
ctx = context(bp)
pin: dict[str, Any] = {"alias": PIN, "version": None, "graph_hash": graph_hash(graph), "level": None, "use": use}
if ctx.get("workflow_id"):
pin["workflow_id"] = ctx["workflow_id"]
else:
# 还没有工作流(个人应用、还没保存的草稿):固定到蓝图上,图在蓝图里
pin["blueprint"] = ctx.get("blueprint_id") or "draft"
spec: dict[str, Any] = {
"format": APP_FORMAT, "name": name, "description": description, "icon": "app", "pins": [pin], "slots": {},
"entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [],
}
# 岗位的显示名:卡片上起了名字的用它(审批模板的步骤名);卡片上就是岗位名本身的(处理人写的是岗位、自由形导入的图)按
# 用到它的节点名拼(apps/tree.slot_label,同派生)
labels = slot_labels(bp)
refs = slot_refs(graph, writes or {})
for ref in refs:
label = labels.get(ref["slot"])
spec["slots"].setdefault(ref["slot"], {"label": label if label and label != ref["slot"]
else slot_label(ref["slot"], refs)})
for nid, n in node_map(graph).items():
if n["type"] == "human":
spec["task_ui"][nid] = {"title": n["title"]}
if writes and not all(write_lookup(cap) for cap in writes.values()):
spec["slots"][RECONCILER] = {"label": "动作核实"}
return spec
def instance_page(graph: dict[str, Any], writes: dict[str, str] | None = None, *,
results: bool = True) -> dict[str, Any]:
"""实例页(同 apps/derive.py):概况、进度、各人工节点的待办表单和会签进度、写入动作的状态、这一次的结果。"""
from app.apps.derive import INSTANCE_VIEW, _results, _safe
from app.apps.tree import node_map
nodes = node_map(graph)
humans = [nid for nid, n in nodes.items() if n["type"] == "human"]
return {"id": "instance", "title": "实例", "body": [
{"id": "inst_header", "type": "instance_header"},
{"id": "inst_timeline", "type": "instance_timeline"},
*[c for nid in humans for c in (
{"id": f"task_{_safe(nid)}", "type": "task_form", "bind": {"task": nid}},
{"id": f"signoffs_{_safe(nid)}", "type": "signoffs", "bind": {"task": nid}})],
*[{"id": f"action_{_safe(nid)}", "type": "action_status", "props": {"label": nodes[nid]["title"][:200]},
"bind": {"action": nid}} for nid in (writes or {})],
*(_results(nodes, INSTANCE_VIEW, "inst") if results else []),
]}
def series_section(graph: dict[str, Any], view: str, prefix: str) -> list[dict[str, Any]]:
"""趋势:口径卡每个本地指标一条(同一来源最近几次封存运行的值,apps/resolve._series)。"""
from app.apps.derive import _safe
from app.apps.tree import node_map
card = node_map(graph).get(CALIBER)
metrics = [m for m in (card or {}).get("config", {}).get("metrics") or [] if isinstance(m, dict) and m.get("id")]
if not metrics:
return []
return [{"id": f"{prefix}_trend", "type": "section", "props": {"title": "趋势"}, "children": [
{"id": f"{prefix}_series_{_safe(str(m['id']))}", "type": "series",
"props": {"label": str(m.get("name") or m["id"])[:200], "chart": "line"},
"bind": {"view": view, "series": f"{CALIBER}.{m['id']}"}} for m in metrics]}]
def reused_section(bp: dict[str, Any], graph: dict[str, Any], view: str, prefix: str) -> list[dict[str, Any]]:
"""复用口径卡的指标:派生规则只认本地写了指标的口径卡,复用的那几个按数据项各放一个指标块。"""
from app.apps.derive import _safe
from app.apps.tree import node_map
nodes = node_map(graph)
blocks = []
for item in bp.get("data") or []:
reuse = item.get("reuse")
if reuse and f"reuse_{item['id']}" in nodes:
blocks.append({"id": f"{prefix}_reuse_{_safe(item['id'])}", "type": "metric",
"props": {"label": str(value(item.get("label")) or reuse["metric_id"])[:200]},
"bind": {"view": view, "metric": f"reuse_{item['id']}.{reuse['metric_id']}"}})
if not blocks:
return []
return [{"id": f"{prefix}_reused", "type": "section", "props": {"title": "复用的口径"},
"children": [{"id": f"{prefix}_reused_cols", "type": "columns", "props": {"count": min(len(blocks), 4)},
"children": blocks}]}]
def latest_view(ctx: dict[str, Any]) -> dict[str, Any]:
"""看板首页的视图:最近封存的正式运行,有定时任务就只看它出的数(派生规则同 apps/derive.py)。"""
from app.apps.derive import PIN
schedule = ctx.get("schedule_id")
return {"source": {"kind": "latest_sealed", "pin": PIN, "from": f"schedule:{schedule}" if schedule else "formal"}}
def entry_and_pages(spec: dict[str, Any], bp: dict[str, Any], graph: dict[str, Any], *, label: str,
writes: dict[str, str] | None = None, home_extra: list[dict[str, Any]] | None = None,
entry_map: dict[str, str] | None = None) -> None:
"""流程类应用(同 apps/derive.py 的流程规则):入口表单就是「填什么」,首页放发起按钮、我发起的、派给我的待办,
每件事的结果在它自己的实例页看。entry_map 另加的入口映射(常量写 'x')。"""
from app.apps.derive import ENTRY, INSTANCE_VIEW, PIN, _entries
spec["forms"][ENTRY] = entry_form(bp)
names = list(spec["forms"][ENTRY]["properties"])
first = next((n for n in names if n in spec["forms"][ENTRY]["required"]), names[0] if names else None)
spec["entries"].append({
"id": ENTRY, "label": label, "pin": PIN, "form": {"$ref": f"#/forms/{ENTRY}"},
"map": {**{n: f"form.{n}" for n in names}, **(entry_map or {})},
**({"instance_title": {"pattern": f"{spec['name']}·{{{first}}}", "fields": [first]}}
if first and re.fullmatch(r"[A-Za-z0-9_㐀-鿿]+", first) else {}),
})
spec["views"][INSTANCE_VIEW] = {"source": {"kind": "instance"}}
spec["pages"] = [
{"id": "home", "title": spec["name"], "body": [
*(home_extra or []), *_entries(spec),
{"id": "my_tasks", "type": "task_list", "props": {"scope": "mine"}}]},
instance_page(graph, writes),
]
def finish_app(spec: dict[str, Any]) -> dict[str, Any]:
"""形状对不上是模板的错,不是用户的:这里直接报出来(同 apps/derive.py)。"""
from app.apps.spec import parse_spec
return parse_spec(spec).model_dump(mode="json", by_alias=True)
# ==========================================================================
# 示例蓝图的写法、预置、模板自己认的问题(各模板写进自己的 TemplateDef)
# ==========================================================================
def user_item(v: Any) -> dict[str, Any]:
"""用户填过、确认过的一项(示例蓝图用);没填的记「缺」。"""
return {"value": v, "source": "user", "status": "confirmed" if v is not None else "missing", "why": None}
def template_item(v: Any) -> dict[str, Any]:
"""模板默认的一项:来源记「模板默认」,状态「待确认」(发布前要人看过,P2)。"""
return {"value": v, "source": "template", "status": "proposed" if v is not None else "missing", "why": None}
def template_problems(template_id: str, spec: dict[str, Any]) -> list[dict[str, Any]]:
"""模板自己认的问题(TemplateDef.problems):卡片内容超出模板能力(只能从一个库查、复用的口径卡接不上……)。"""
template = get_template(template_id)
if template is None or template.problems is None:
return []
return template.problems(spec)
def mismatch(path: str, message: str) -> dict[str, Any]:
return {"code": "bp.template_mismatch", "level": "error", "path": path, "message": message}
def prefill(template_id: str, spec: dict[str, Any]) -> None:
"""选模板时按模板预置还没填的卡片(TemplateDef.prefill,原地改)。已经写了的不动。"""
template = get_template(template_id)
if template is not None and template.prefill is not None:
template.prefill(spec)
def default_cadence(preset: str = "weekly") -> dict[str, Any]:
out: dict[str, Any] = {"preset": preset, "time": "09:00", "tz": "Asia/Shanghai", "period": DEFAULT_PERIOD[preset]}
if preset == "weekly":
out["weekday"] = 1
if preset == "monthly":
out["day"] = 1
return out
def fill_cadence(spec: dict[str, Any], preset: str = "weekly") -> None:
if value(spec.get("cadence")) is None:
spec["cadence"] = template_item(default_cadence(preset))
def step(step_id: str, label: str, *, policy: str = "any", due: str | None = "P2D") -> dict[str, Any]:
"""预置的一步:处理人留空(要人填),几个人同意、时限按模板默认。"""
return {"id": step_id, "label": template_item(label), "who": template_item(None),
"policy": template_item({"kind": policy}), "due": template_item(due), "on_timeout": template_item(None),
"threshold": template_item(None)}