Files
AgentLab/backend/tests/test_blueprint_generation.py
T
ThearchyHelios 7739340607 普通用户建应用集成收尾:
- 问数据和个人应用的图放行绑定只读查询等合规工具的调用工具节点,只读和遮罩在这条路径上生效;被治理成写入的工具在只读运行里直接拒;知识库范围在工具层固定(图级默认值、参数名纠错、Agent 都改不了)
- 个人应用的刷新和个人蓝图的试运行只有拥有者能读:运行、事件、证据、工件、文件、运行与审批列表、对比、可验证报告、质询、假设分析、提取工作流一并收紧;个人蓝图的试运行不论谁发起都按个人应用约束
- 共享蓝图只给拥有者、对应应用的管理者、管理员,看不到的一律 404;审核副本归审核人;管理员看别人的个人应用只有元数据、可以归档
- 记录表跨版本汇总(只读挂载旧版本、按当前列 UNION ALL、各版本各自固定高水位,缺的版本照实写进印章);停用前发起的运行可以继续写旧版本;更正沿用原提交人、更正人记审计
- 约定收口:待办种类 review、事件 file.extracted、表单错误 file、模板定义带示例与预置;前端类型一次补齐、去掉临时的局部类型;审核待办卡不再给打不开的链接;读附件事件有中文说法;检查脚本补断言
2026-10-15 13:55:19 +08:00

302 lines
17 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.
"""蓝图和关节的生成落地(docs/design/normal-user-apps.md N2、N3、§3.4)、蓝图的存与留痕、查看口径(look caliber)。
- TemplateApplier 只收 fill_joint,只收这一组里模板声明过的生成关节;改别的位置、别组的关节、用别的操作一律拒绝并照实
交回(自查时交回模型一次、交付时列给人看);
- TemplateChecker:没填的、指标式不合规矩(只许 cell() 取格加算术、行号 0、列要在 SQL 里、追得到原始数据)、SQL 过不了
只读检查;
- BlueprintApplier:助手的操作来源记 assistant,越界的拒;复用口径卡按读好的口径核对并补上它读的列;
- apply_ops_and_save:每个操作写一条留痕;卡片改了,那一组生成关节标回「待填」;助手这一轮的操作和人改过的卡片冲突时
照实交回;
- look caliber:只列最新的已发布且未退役版本里的源头口径卡,给出复用写法;读别的东西的卡照实说不能复用;
verify_reuse 按库里那一版核对。
"""
from __future__ import annotations
import copy
import uuid
import pytest
from app.blueprints.generation import BlueprintApplier, BlueprintChecker, TemplateApplier, TemplateChecker
from app.blueprints.instantiate import generated_groups, instantiate
from app.blueprints.templates import _common as c
from app.blueprints.templates import get_template
from app.db.base import SessionLocal
import app.db.models # noqa: F401 - 建表前要先登记全部模型(conftest 按 Base.metadata 建)
def _board():
extras = get_template("periodic_board")
inst = instantiate(extras.example())
group = generated_groups(inst)["fetch_shop"]
return extras, inst, group
def test_template_applier_refuses_anything_but_declared_joints():
extras, inst, group = _board()
graph = copy.deepcopy(inst.graph)
applier = TemplateApplier(graph, inst.joint_defs, group)
ok = applier.apply({"op": "fill_joint", "joint": "metric_gmv", "value": "cell(nodes.fetch_shop, 0, 'gmv')"})
assert (ok.changed, ok.forward) == (True, True)
assert {n["id"]: n for n in graph["nodes"]}["caliber"]["data"]["config"]["metrics"][0]["expression"] \
== "cell(nodes.fetch_shop, 0, 'gmv')"
before = copy.deepcopy(graph)
for op, words in (
({"op": "fill_joint", "joint": "done.contract", "value": "{}"}, "按卡片确定地填"),
({"op": "fill_joint", "joint": "nope", "value": "x"}, "没有关节"),
({"op": "fill_joint", "joint": "sql_fetch_shop", "value": " "}, "非空"),
({"op": "update_node", "id": "done", "config": {"contract": {}}}, "锁住"),
({"op": "add_node", "node": {"id": "x", "type": "llm"}}, "锁住"),
({"op": "bp_set", "path": "/purpose", "value": "x"}, "fill_joint"),
):
out = applier.apply(op)
assert (out.changed, out.forward) == (False, False) and words in out.problem, op
assert graph == before, "被拒的一处都没落地"
assert [r["joint"] for r in applier.refusals()] == ["done.contract", "nope", "sql_fetch_shop", None, None, None]
assert all(r["code"] == "tpl.joint_refused" for r in applier.refusals())
# 别的组的关节也不收
spec = extras.example()
spec["data"].append({**copy.deepcopy(spec["data"][0]), "id": "visits", "table": c.template_item(
{"source": "crm", "table": "visits"})})
two = instantiate(spec)
groups = generated_groups(two)
applier = TemplateApplier(copy.deepcopy(two.graph), two.joint_defs, groups["fetch_shop"])
assert "不在这一组里" in applier.apply({"op": "fill_joint", "joint": "sql_fetch_crm", "value": "SELECT 1"}).problem
async def test_template_checker_reports_unfilled_expressions_and_sql():
extras, inst, group = _board()
applier = TemplateApplier(copy.deepcopy(inst.graph), inst.joint_defs, group)
checker = TemplateChecker(applier, skeleton=inst.skeleton)
applier.apply({"op": "fill_joint", "joint": "nope", "value": "x"})
first = await checker.issues()
assert {i["code"] for i in first} == {"tpl.joint_refused", "tpl.joint_unfilled"}
assert {i["joint"] for i in first if i["code"] == "tpl.joint_unfilled"} == {j.id for j in group}
assert "tpl.joint_refused" not in {i["code"] for i in await checker.issues()}, "越界的只交回一次"
fill = extras.example_fill()
for jid, value in fill.items():
applier.apply({"op": "fill_joint", "joint": jid, "value": value})
assert await checker.issues() == []
for jid, value, code, words in (
("metric_gmv", "len(nodes.fetch_shop.rows)", "tpl.metric_expr", "不能用 len"),
("metric_gmv", "cell(nodes.caliber, 0, 'gmv')", "tpl.metric_expr", "这一组取数"),
("metric_gmv", "cell(nodes.fetch_shop, 1, 'gmv')", "tpl.metric_expr", "行号写 0"),
("metric_gmv", "cell(nodes.fetch_shop, 0, 'revenue')", "tpl.metric_column", "找不到"),
("metric_gmv", "input.period", "tpl.metric_expr", "cell("),
("metric_gmv", "cell(nodes.fetch_shop, 0, 'gmv') +", "tpl.metric_expr", "写得不对"),
("sql_fetch_shop", "DELETE FROM orders", "tpl.sql_rejected", "只读"),
):
fresh = TemplateApplier(copy.deepcopy(inst.graph), inst.joint_defs, group)
for k, v in {**fill, jid: value}.items():
fresh.apply({"op": "fill_joint", "joint": k, "value": v})
issues = await TemplateChecker(fresh, skeleton=inst.skeleton).issues()
assert any(i["code"] == code and words in i["message"] for i in issues), (value, issues)
assert "fill_joint" in checker.repair_request(first, "看过的表")
def test_blueprint_applier_records_assistant_proposals_and_refusals():
from app.blueprints.spec import empty_blueprint
applier = BlueprintApplier(empty_blueprint())
assert applier.apply({"op": "bp_template", "template": {"id": "periodic_board", "version": 1}}).changed
assert applier.apply({"op": "bp_set", "path": "/purpose", "value": "看销售"}).changed
assert applier.spec["purpose"]["status"] == "proposed"
out = applier.apply({"op": "bp_add", "path": "/access/use", "value": {"kind": "group", "name": "门店"}})
assert (out.changed, out.forward) == (False, False) and "授权" in out.problem
assert not applier.apply({"op": "add_node", "node": {}}).forward
assert applier.apply({"op": "plan", "summary": "整理"}).forward
assert applier.landed() == ["bp_template", "/purpose"]
assert [r["path"] for r in applier.refusals()] == ["/access/use"]
async def test_blueprint_checker_sends_back_refusals_and_mismatches():
from app.blueprints.spec import empty_blueprint
applier = BlueprintApplier(empty_blueprint())
checker = BlueprintChecker(applier)
applier.apply({"op": "bp_template", "template": {"id": "periodic_board", "version": 1}})
applier.apply({"op": "bp_add", "path": "/people", "value": {"id": "s1", "label": "审批"}})
applier.apply({"op": "bp_set", "path": "/access/use", "value": []})
issues = await checker.issues()
assert {i["code"] for i in issues} == {"bp.refused", "bp.template_mismatch"}
# 缺项不交回模型:缺的是只有用户知道的事
assert "bp.missing" not in {i["code"] for i in issues}
assert "bp_ask" in checker.repair_request(issues, "")
async def _blueprint(spec: dict, **extra):
from app.db.models_blueprints import Blueprint
async with SessionLocal() as session:
row = Blueprint(name="测试蓝图", scope="shared", spec=spec, **extra)
session.add(row)
await session.commit()
return row.id
async def test_apply_ops_and_save_writes_events_and_marks_stale_joints():
from sqlalchemy import select
from app.blueprints import store
from app.blueprints.ops import BpRefused
from app.db.models_blueprints import Blueprint, BlueprintEvent
extras = get_template("periodic_board")
spec = extras.example()
inst = instantiate(spec, filled=extras.example_fill())
bp_id = await _blueprint(spec, template_id="periodic_board", template_version=1, joints=inst.joints)
async with SessionLocal() as session:
row = await session.get(Blueprint, bp_id)
saved = await store.apply_ops_and_save(session, row, [
{"op": "bp_set", "path": "/purpose", "value": "每周一看上周"},
{"op": "bp_set", "path": "/data/0/measure", "value": "sum(amount) - sum(refund)"},
], "zhang", True, "user")
assert len(saved.applied) == 2 and saved.refused == []
events = list((await session.execute(select(BlueprintEvent).where(BlueprintEvent.blueprint_id == bp_id)
.order_by(BlueprintEvent.seq))).scalars())
assert [(e.seq, e.op, e.actor, e.verified) for e in events] == [(1, "bp_set", "zhang", True),
(2, "bp_set", "zhang", True)]
assert events[1].data["before"]["value"] == "sum(amount)"
assert events[1].data["after"]["value"] == "sum(amount) - sum(refund)"
assert events[1].data["source"] == "user"
# 「怎么算」改了:这一组的 SQL 和指标式标回「待填」
assert row.joints["sql_fetch_shop"]["status"] == "pending"
assert row.joints["done.contract"]["status"] == "filled"
# 卡片上的修改一个被拒就整串不落
with pytest.raises(BpRefused):
await store.apply_ops_and_save(session, row, [
{"op": "bp_set", "path": "/purpose", "value": "再改"},
{"op": "bp_set", "path": "/people/0/policy", "value": "any"},
], "zhang", True, "user")
await session.refresh(row)
assert row.spec["purpose"]["value"] == "每周一看上周"
# 助手这一轮的:冲突的照实交回,别的照常落
saved = await store.apply_ops_and_save(session, row, [
{"op": "bp_set", "path": "/data/0/label", "value": "成交额"},
{"op": "bp_set", "path": "/data/9/label", "value": "不存在"},
], None, False, "assistant", partial=True)
assert [o["path"] for o in saved.applied] == ["/data/0/label"]
assert "超出了列表的范围" in saved.refused[0]["message"]
out = store.blueprint_out(row)
assert out["spec"]["data"][0]["label"] == {"value": "成交额", "source": "assistant", "status": "proposed",
"why": None}
assert "value" not in out["joints"]["sql_fetch_shop"], "关节只给状态"
codes = {i["code"] for i in out["issues"]}
assert {"bp.unconfirmed", "tpl.joint_unfilled"} <= codes
assert all(i.get("plain") for i in out["issues"] if i["code"].startswith(("bp.", "tpl.")))
await session.delete(row)
await session.commit()
# ---------------------------------------------------------------- 查看口径
async def _published_card(graph: dict) -> tuple[str, str]:
from app.db.models import Workflow, WorkflowVersion
async with SessionLocal() as session:
wf = Workflow(name=f"周销售_{uuid.uuid4().hex[:6]}", graph=graph, status="published", published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph, graph_hash="h" * 64, level="published"))
await session.commit()
return wf.id, wf.name
def _card_graph(expression: str = "cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'order_cnt')") -> dict:
return {"nodes": [
c.node("start", "input", "入口"),
c.node("fetch", "tool", "取数", tool="db_query__shop", args={"sql": "SELECT 1 AS gmv, 2 AS order_cnt"}),
c.node("kpi", "metrics", "周报口径卡", caliber="周报口径", caliber_version="v2", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "aov", "name": "客单价", "unit": "元", "expression": expression}]),
], "edges": [c.edge("start", "fetch"), c.edge("fetch", "kpi")]}
async def test_look_caliber_lists_published_cards_and_how_to_reuse():
from app.db.models import Workflow
from app.look.base import LookBudget
from app.look.calibers import CaliberKind
from app.look.service import LookService
wf_id, wf_name = await _published_card(_card_graph())
bad_id, bad_name = await _published_card(_card_graph("vars.x * 2"))
try:
async with SessionLocal() as session:
kind = await CaliberKind.load(session)
mine = [i for i in kind.items if i.workflow_id == wf_id]
assert [(i.metric_id, i.name, i.version) for i in mine] == [("gmv", "销售额", 1), ("aov", "客单价", 1)]
assert mine[1].reuse() == {"workflow_id": wf_id, "version": 1, "node_id": "kpi", "metric_id": "aov",
"inputs": [{"node": "fetch", "tool": "db_query__shop", "columns": ["gmv", "order_cnt"]}]}
assert any(wf_name in line for line in await kind.overview())
looks = LookService([kind])
result = await looks.look({"op": "look", "need": "客单价", "targets": [
{"kind": "caliber", "source": wf_name, "name": "客单价"}, {"kind": "caliber", "name": "不存在的指标"}]},
LookBudget())
assert result.record["objects"][0]["kind"] == "caliber"
assert result.record["objects"][0]["named"] == [f"{wf_name} v1 · 客单价"]
assert "复用写法" in result.text and '"metric_id": "aov"' in result.text and "fetch(db_query__shop)" in result.text
assert "没找到口径" in result.text
# 读别的东西的卡:照实说不能复用
bad = [i for i in kind.items if i.workflow_id == bad_id]
assert bad and all(i.reuse() is None for i in bad)
result = await looks.look({"op": "look", "need": "x", "targets": [{"kind": "caliber", "source": bad_name}]},
LookBudget())
assert "不能复用" in result.text
# 蓝图助手按读好的口径核对复用、补上读的列
reuse, reason = kind.reuse_for({"workflow_id": wf_id, "version": 1, "node_id": "kpi", "metric_id": "aov"})
assert reason is None and reuse["inputs"][0]["columns"] == ["gmv", "order_cnt"]
assert kind.reuse_for({"workflow_id": wf_id, "version": 7, "node_id": "kpi", "metric_id": "aov"})[0] is None
finally:
async with SessionLocal() as session:
for wid in (wf_id, bad_id):
await session.delete(await session.get(Workflow, wid))
await session.commit()
async def test_saving_a_reuse_checks_the_published_version():
from app.blueprints import store
from app.blueprints.ops import BpRefused
from app.blueprints.spec import empty_blueprint
from app.db.models import Workflow, WorkflowVersion
from app.db.models_blueprints import Blueprint
wf_id, _ = await _published_card(_card_graph())
spec = empty_blueprint()
spec["data"] = [{"id": "aov", "label": c.template_item("客单价"), "unit": c.template_item(None),
"table": c.template_item(None), "measure": c.template_item(None), "date": c.template_item(None),
"filter": c.template_item(None), "grain": c.template_item(None), "reuse": None}]
bp_id = await _blueprint(spec)
try:
async with SessionLocal() as session:
row = await session.get(Blueprint, bp_id)
ref = {"workflow_id": wf_id, "version": 1, "node_id": "kpi", "metric_id": "aov"}
await store.apply_ops_and_save(session, row, [{"op": "bp_set", "path": "/data/0/reuse", "value": ref}],
"zhang", True, "user")
assert row.spec["data"][0]["reuse"]["inputs"] == [
{"node": "fetch", "tool": "db_query__shop", "columns": ["gmv", "order_cnt"]}]
# 退役了的版本不能复用
version = (await session.execute(WorkflowVersion.__table__.select().where(
WorkflowVersion.workflow_id == wf_id))).first()
assert version is not None
from sqlalchemy import update
from app.db.base import utcnow
await session.execute(update(WorkflowVersion).where(WorkflowVersion.workflow_id == wf_id)
.values(retired_at=utcnow()))
await session.commit()
with pytest.raises(BpRefused) as e:
await store.apply_ops_and_save(session, row, [{"op": "bp_set", "path": "/data/0/reuse", "value": ref}],
"zhang", True, "user")
assert e.value.code == "bp.data_unmapped"
await session.delete(row)
await session.commit()
finally:
async with SessionLocal() as session:
await session.delete(await session.get(Workflow, wf_id))
await session.commit()