mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +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 的合并、升级岗位、只读行、残留绑定提醒。
330 lines
16 KiB
Python
330 lines
16 KiB
Python
"""按模板实例化蓝图(N3、§3.3–§3.4):确定性关节按卡片填,生成关节留给模型(TemplateApplier 的 fill_joint),
|
||
实例化后的图和骨架逐节点比对,只许关节位置不同。模型失败时生成关节留空、标「待填」(P3)。
|
||
|
||
实例化是纯函数:不读库、不落库。读库的那几样由调用方先读好放进上下文(工作流 id、蓝图 id、定时任务 id、记录表、
|
||
自由形导入的图)。图按画布保存时的样子规范化(GraphSpec 解析、自动排版、序列化):图哈希、试运行和发布单按哈希对齐
|
||
(N4)都认这一份。
|
||
|
||
生成关节的值和依据存在蓝图的 joints 上({joint_id: {status, by, note?, value?, basis?}}):卡片改了、这一组关节的
|
||
说明变了(basis 对不上),之前填的就不算数,重新标「待填」(filled_from)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
#: 关节的状态
|
||
JOINT_STATUSES = ("filled", "pending", "refused")
|
||
#: 骨架比对的问题编号
|
||
SKELETON_CHANGED = "tpl.skeleton_changed"
|
||
JOINT_UNFILLED = "tpl.joint_unfilled"
|
||
|
||
|
||
@dataclass
|
||
class Instantiated:
|
||
#: 实例化出来的图(GraphSpec 的 JSON)。生成关节没填的位置是占位
|
||
graph: dict[str, Any]
|
||
#: {joint_id: {"status": filled|pending|refused, "by": template|assistant|user, "note"?: …}}
|
||
joints: dict[str, Any]
|
||
#: 按模板排布的应用草稿(agentlab.app/1),钉住项的 version 为空(还没发布)
|
||
app_spec: dict[str, Any]
|
||
#: 定时任务参数(看板类)或 None
|
||
schedule: dict[str, Any] | None
|
||
problems: list[dict[str, Any]] = field(default_factory=list)
|
||
#: 岗位绑定(应用的 draft_bindings 形状):「谁来做」里写了具体的人或组的那几个岗位
|
||
bindings: dict[str, Any] = field(default_factory=dict)
|
||
#: 模板的骨架(规范化后的、生成关节是占位的那张图):骨架比对、TemplateChecker 用
|
||
skeleton: dict[str, Any] = field(default_factory=dict)
|
||
#: 模板声明的关节(blueprints/templates.Joint)
|
||
joint_defs: list[Any] = field(default_factory=list)
|
||
#: 每组生成关节给模型的说明 {group: 说明},和依据的指纹 {group: basis}
|
||
briefs: dict[str, str] = field(default_factory=dict)
|
||
basis: dict[str, str] = field(default_factory=dict)
|
||
|
||
|
||
# ==========================================================================
|
||
# 图上的位置(关节的 field 是节点配置里的 JSON 指针)
|
||
# ==========================================================================
|
||
|
||
|
||
def _tokens(pointer: str) -> list[str]:
|
||
return [p.replace("~1", "/").replace("~0", "~") for p in pointer.lstrip("/").split("/")] if pointer.strip("/") else []
|
||
|
||
|
||
def read_at(config: Any, pointer: str) -> Any:
|
||
cur = config
|
||
for token in _tokens(pointer):
|
||
if isinstance(cur, list) and token.isdigit() and int(token) < len(cur):
|
||
cur = cur[int(token)]
|
||
elif isinstance(cur, dict):
|
||
cur = cur.get(token)
|
||
else:
|
||
return None
|
||
return cur
|
||
|
||
|
||
def write_at(config: dict[str, Any], pointer: str, value: Any) -> None:
|
||
tokens = _tokens(pointer)
|
||
cur: Any = config
|
||
for token in tokens[:-1]:
|
||
cur = cur[int(token)] if isinstance(cur, list) else cur.setdefault(token, {})
|
||
if isinstance(cur, list):
|
||
cur[int(tokens[-1])] = value
|
||
else:
|
||
cur[tokens[-1]] = value
|
||
|
||
|
||
def _blank_at(config: dict[str, Any], pointer: str) -> None:
|
||
"""骨架比对时把关节位置抹掉:两边都抹,只比别的地方。"""
|
||
tokens = _tokens(pointer)
|
||
cur: Any = config
|
||
for token in tokens[:-1]:
|
||
if isinstance(cur, list) and token.isdigit() and int(token) < len(cur):
|
||
cur = cur[int(token)]
|
||
elif isinstance(cur, dict) and token in cur:
|
||
cur = cur[token]
|
||
else:
|
||
return
|
||
if isinstance(cur, list) and tokens[-1].isdigit() and int(tokens[-1]) < len(cur):
|
||
cur[int(tokens[-1])] = None
|
||
elif isinstance(cur, dict):
|
||
cur.pop(tokens[-1], None)
|
||
|
||
|
||
def node_config(graph: dict[str, Any], node_id: str) -> dict[str, Any] | None:
|
||
for n in graph.get("nodes") or []:
|
||
if isinstance(n, dict) and n.get("id") == node_id:
|
||
data = n.setdefault("data", {})
|
||
if not isinstance(data.get("config"), dict):
|
||
data["config"] = {}
|
||
return data["config"]
|
||
return None
|
||
|
||
|
||
def normalize(graph: dict[str, Any]) -> dict[str, Any]:
|
||
"""按画布保存时的样子规范化:GraphSpec 解析、自动排版、序列化。坐标由结构定,同一张图每次都一样。"""
|
||
from app.engine.layout import auto_layout
|
||
from app.engine.schema import GraphSpec
|
||
|
||
return auto_layout(GraphSpec.model_validate(graph)).model_dump(mode="json")
|
||
|
||
|
||
# ==========================================================================
|
||
# 骨架比对
|
||
# ==========================================================================
|
||
|
||
|
||
def skeleton_changes(template_graph: dict[str, Any], graph: dict[str, Any], joints: list[Any]) -> list[dict[str, Any]]:
|
||
"""骨架比对:除关节位置外的任何差异都交回(tpl.skeleton_changed)。照 engine/autofix.forbidden_changes 的写法逐节点比:
|
||
节点增删、换类型、改标题、配置里关节以外的地方、连线、全图默认。坐标不比(排版的事)。"""
|
||
from app.engine.schema import type_label
|
||
|
||
def nodes_of(g: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||
return {str(n["id"]): n for n in g.get("nodes") or [] if isinstance(n, dict) and n.get("id")}
|
||
|
||
def title(n: dict[str, Any]) -> str:
|
||
return str((n.get("data") or {}).get("label") or n.get("id"))
|
||
|
||
old, new = nodes_of(template_graph), nodes_of(graph)
|
||
at: dict[str, list[str]] = {}
|
||
for j in joints:
|
||
at.setdefault(j.node_id, []).append(j.field)
|
||
out: list[dict[str, Any]] = []
|
||
|
||
def add(message: str, node_id: str | None = None, field: str | None = None) -> None:
|
||
out.append({"code": SKELETON_CHANGED, "level": "error", "node_id": node_id, "field": field,
|
||
"message": f"改动超出了模板的关节:{message}"})
|
||
|
||
for nid, n in old.items():
|
||
if nid not in new:
|
||
add(f"删除了节点「{title(n)}」", nid)
|
||
for nid, n in new.items():
|
||
was = old.get(nid)
|
||
if was is None:
|
||
add(f"多出了节点「{title(n)}」", nid)
|
||
continue
|
||
if was.get("type") != n.get("type"):
|
||
add(f"把「{title(was)}」从{type_label(was.get('type'))}换成了{type_label(n.get('type'))}", nid)
|
||
if title(was) != title(n):
|
||
add(f"改了节点「{title(was)}」的标题", nid)
|
||
before = copy.deepcopy((was.get("data") or {}).get("config") or {})
|
||
after = copy.deepcopy((n.get("data") or {}).get("config") or {})
|
||
for pointer in at.get(nid, []):
|
||
_blank_at(before, pointer)
|
||
_blank_at(after, pointer)
|
||
if before != after:
|
||
keys = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k))
|
||
add(f"改了「{title(n)}」关节以外的配置({'、'.join(keys)})", nid, keys[0] if keys else None)
|
||
|
||
def edges_of(g: dict[str, Any]) -> set[tuple[str, str, str]]:
|
||
return {(str(e.get("source")), str(e.get("target")), str(e.get("sourceHandle") or ""))
|
||
for e in g.get("edges") or [] if isinstance(e, dict)}
|
||
|
||
for s, t, _ in sorted(edges_of(template_graph) - edges_of(graph)):
|
||
add(f"删除了连线「{title(old.get(s) or {'id': s})}」→「{title(old.get(t) or {'id': t})}」")
|
||
for s, t, _ in sorted(edges_of(graph) - edges_of(template_graph)):
|
||
add(f"多出了连线「{title(new.get(s) or {'id': s})}」→「{title(new.get(t) or {'id': t})}」")
|
||
if (template_graph.get("defaults") or {}) != (graph.get("defaults") or {}):
|
||
add("改了全图默认")
|
||
return out
|
||
|
||
|
||
# ==========================================================================
|
||
# 实例化
|
||
# ==========================================================================
|
||
|
||
|
||
def _spec_of(blueprint: Any) -> dict[str, Any]:
|
||
from app.blueprints.spec import parse_blueprint
|
||
|
||
raw = getattr(blueprint, "spec", blueprint)
|
||
return parse_blueprint(raw or {}).model_dump(mode="json", by_alias=True)
|
||
|
||
|
||
def instantiate(blueprint: dict[str, Any], *, filled: dict[str, Any] | None = None, name: str | None = None,
|
||
workflow_id: str | None = None, blueprint_id: str | None = None, schedule_id: str | None = None,
|
||
records: dict[str, str] | None = None, graph: dict[str, Any] | None = None) -> Instantiated:
|
||
"""蓝图 + 已填的生成关节({joint_id: value})→ 图、关节状态、应用草稿、定时任务参数。纯函数,不落库。
|
||
|
||
blueprint 是 agentlab.blueprint/1(或带 spec 的蓝图行)。另几样是调用方读好的上下文:name 是应用和工作流的名字;
|
||
workflow_id 给了,应用草稿的钉住项挂在这个工作流上,没给挂在蓝图上(blueprint_id);schedule_id 是看板视图要绑的
|
||
定时任务;records 是填报登记的记录表 {table, source};graph 是自由形导入的图。"""
|
||
from app.blueprints.ops import blueprint_issues
|
||
from app.blueprints.templates import GENERATED_JOINT_KINDS, get_template
|
||
from app.blueprints.templates import _common as c
|
||
|
||
spec = _spec_of(blueprint)
|
||
ref = spec.get("template") or {}
|
||
template = get_template(str(ref.get("id") or ""))
|
||
problems = [i for i in blueprint_issues(spec, for_="trial", graph=graph) if i["level"] == "error"]
|
||
if template is None:
|
||
from app.apps.spec import empty_spec
|
||
|
||
return Instantiated(graph={"nodes": [], "edges": []}, joints={}, app_spec=empty_spec(name or "应用"),
|
||
schedule=None, problems=problems)
|
||
bp = {**copy.deepcopy(spec), c.CONTEXT_KEY: {
|
||
"name": name, "workflow_id": workflow_id, "blueprint_id": blueprint_id, "schedule_id": schedule_id,
|
||
"records": records, "graph": graph}}
|
||
raw, joint_defs = template.skeleton(bp)
|
||
# 自由形导入的图原样用、不重新排版:它的哈希要和导入的那一版对得上,发布单才按哈希复用那一版
|
||
skeleton = copy.deepcopy(raw) if template.imports_graph else normalize(raw)
|
||
out = copy.deepcopy(skeleton)
|
||
briefs = template.briefs(bp) if template.briefs is not None else {}
|
||
basis = {g: c.basis_of(text, template.id, template.version) for g, text in briefs.items()}
|
||
joints: dict[str, Any] = {}
|
||
filled = filled or {}
|
||
for j in joint_defs:
|
||
# 模板声明的说法、类型、分组随状态一起存(界面写「第几组、填的是什么」,不只写关节 id)
|
||
said = {"label": j.label or j.id, "kind": j.kind, "group": j.group}
|
||
if j.kind not in GENERATED_JOINT_KINDS:
|
||
joints[j.id] = {"status": "filled", "by": "template", **said}
|
||
continue
|
||
value = filled.get(j.id)
|
||
config = node_config(out, j.node_id)
|
||
if isinstance(value, str) and value.strip() and config is not None:
|
||
write_at(config, j.field, value.strip())
|
||
joints[j.id] = {"status": "filled", "by": "assistant", "value": value.strip(), **said,
|
||
**({"basis": basis[j.group]} if j.group in basis else {})}
|
||
else:
|
||
joints[j.id] = {"status": "pending", "by": "template", **said,
|
||
"note": "这一项助手还没填上:可以请助手填,或者换个说法再试"}
|
||
problems.append({"code": JOINT_UNFILLED, "level": "error", "node_id": j.node_id, "field": j.field,
|
||
"joint": j.id, "message": f"「{j.label or j.id}」还没填"})
|
||
problems += skeleton_changes(skeleton, out, joint_defs)
|
||
problems += _graph_errors(out)
|
||
app_spec = template.app_skeleton(bp, out) if template.app_skeleton is not None else {}
|
||
schedule = template.schedule(bp) if template.schedule is not None else None
|
||
return Instantiated(graph=out, joints=joints, app_spec=app_spec, schedule=schedule,
|
||
problems=_dedupe(problems), bindings=c.bindings_of(bp), skeleton=skeleton,
|
||
joint_defs=list(joint_defs), briefs=briefs, basis=basis)
|
||
|
||
|
||
def _graph_errors(graph: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""实例化出来的图过画布校验(engine/schema.validate_graph):error 照实交回。"""
|
||
from app.engine.schema import GraphSpec, validate_graph
|
||
|
||
return [i.model_dump() for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.level == "error"]
|
||
|
||
|
||
def _dedupe(problems: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
seen: set[tuple[Any, ...]] = set()
|
||
out = []
|
||
for p in problems:
|
||
key = (p.get("code"), p.get("path"), p.get("node_id"), p.get("field"), p.get("message"))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def filled_from(blueprint: dict[str, Any], joints: dict[str, Any] | None, *,
|
||
graph: dict[str, Any] | None = None, records: dict[str, str] | None = None) -> dict[str, Any]:
|
||
"""蓝图上存的生成关节里还算数的值:状态是已填、依据(这一组的说明的指纹)没变。卡片改过、说明变了的不算(要重新填)。"""
|
||
probe = instantiate(blueprint, records=records, graph=graph)
|
||
out: dict[str, Any] = {}
|
||
for j in probe.joint_defs:
|
||
state = (joints or {}).get(j.id) or {}
|
||
if state.get("status") == "filled" and isinstance(state.get("value"), str) \
|
||
and state.get("basis") == probe.basis.get(j.group or ""):
|
||
out[j.id] = state["value"]
|
||
return out
|
||
|
||
|
||
def generated_groups(inst: Instantiated) -> dict[str, list[Any]]:
|
||
"""生成关节按组:{group: [Joint]}。不同组的并行填(N11)。"""
|
||
from app.blueprints.templates import GENERATED_JOINT_KINDS
|
||
|
||
out: dict[str, list[Any]] = {}
|
||
for j in inst.joint_defs:
|
||
if j.kind in GENERATED_JOINT_KINDS:
|
||
out.setdefault(j.group or j.id, []).append(j)
|
||
return out
|
||
|
||
|
||
def trial_inputs(blueprint: dict[str, Any], *, at: datetime | None = None) -> dict[str, Any]:
|
||
"""试运行的输入(N4):看板类按「多久更新」算出最近一期(和定时任务到点渲染的是同一份),流程类没有(用户填示例表单)。"""
|
||
from datetime import timezone
|
||
|
||
from app.scheduler import service
|
||
|
||
inst = instantiate(blueprint)
|
||
if inst.schedule is None:
|
||
return {}
|
||
moment = at or datetime.now(timezone.utc)
|
||
values = service.template_values(moment, inst.schedule["timezone"], inst.schedule["cron"])
|
||
return service.render_inputs(inst.schedule["input_template"], values)
|
||
|
||
|
||
# ==========================================================================
|
||
# 模板登记的核对(启动时、测试里)
|
||
# ==========================================================================
|
||
|
||
|
||
def registry_problems() -> list[str]:
|
||
"""模板登记齐不齐(templates.check_registry 调这里):首批都登记了、关节类型都认得、每个模板都给了示例蓝图
|
||
(TemplateDef.example / example_fill),示例实例化出来过得了画布校验、骨架比对没有差异(模板定义、实例化、测试
|
||
三样都要给,缺一样启动即报错,N3)。"""
|
||
from app.blueprints.templates import JOINT_KINDS, TEMPLATE_IDS, get_template, list_templates
|
||
|
||
problems = [f"模板「{t}」没有登记" for t in TEMPLATE_IDS if get_template(t) is None]
|
||
for template in list_templates():
|
||
if template.example is None or template.example_fill is None:
|
||
problems.append(f"模板「{template.id}」没有示例蓝图")
|
||
continue
|
||
try:
|
||
graph = None
|
||
if template.imports_graph:
|
||
from app.blueprints.templates.freeform import example_graph
|
||
|
||
graph = example_graph()
|
||
inst = instantiate(template.example(), filled=template.example_fill(), graph=graph)
|
||
except Exception as e: # noqa: BLE001 - 核对本身出错也照实报,不让启动卡在异常上
|
||
problems.append(f"模板「{template.id}」的示例实例化出错:{e}")
|
||
continue
|
||
problems += [f"模板「{template.id}」的关节类型「{j.kind}」不认得" for j in inst.joint_defs
|
||
if j.kind not in JOINT_KINDS]
|
||
problems += [f"模板「{template.id}」的示例:{p['message']}" for p in inst.problems if p["level"] == "error"]
|
||
return problems
|