mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
03c18c4d78
一、文案(前端约 1600 处,后端传到界面的约 880 处) - 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」; 总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别 - 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法 - 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」); 报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供; 发布弹窗里结论句的选项不再是 off / require_citation - 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份 - 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例 二、说错的提示(都有测试证明) - 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀 - 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页 - 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填 - 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因 - 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头 - 导出的证据 CSV 里两列同名,第三列改为「片段编号」 三、前后端对齐 - 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对 - 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上 四、检查 - 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证 - 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、 界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
259 lines
14 KiB
Python
259 lines
14 KiB
Python
"""发布前检查(publish-check)和自动修复(autofix)两个接口。
|
|
|
|
- publish-check 的问题和真正发布时拦下的完全一致(同一套 validate + 门禁)
|
|
- 两个接口都只读:调用前后库里的工作流、版本、状态一个字都不变
|
|
- autofix 返回的图(首批里能 auto 的那几类)真的能过发布检查,存下来也真能发布
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Workflow, WorkflowVersion
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
def node(nid, ntype, label=None, **config):
|
|
return {"id": nid, "type": ntype, "position": {"x": 40, "y": 80}, "data": {"label": label or nid, "config": config}}
|
|
|
|
|
|
def weekly(**contract_overrides) -> dict:
|
|
contract = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}
|
|
contract.update(contract_overrides)
|
|
nodes = [
|
|
node("start", "input", "周期"),
|
|
node("fetch", "tool", "取数", tool="db_query__shop",
|
|
args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}),
|
|
node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[
|
|
{"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"},
|
|
{"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"}]),
|
|
node("write", "report", "报告撰写", instructions="写周报",
|
|
numbers="strict", on_violation="fail", claims="require_citation"),
|
|
node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
|
|
contract={k: v for k, v in contract.items() if v is not None}),
|
|
]
|
|
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
|
|
|
|
|
def broken(lib_id: str | None = None) -> dict:
|
|
"""首批里 auto 能修的几类问题各来一处。"""
|
|
graph = weekly(metrics_from=None, report_from="fetch", strict=False)
|
|
graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="never",
|
|
prompt="用 db_query__shop 查"))
|
|
graph["edges"] += [{"source": "start", "target": "ask"}, {"source": "ask", "target": "card"}]
|
|
if lib_id:
|
|
graph["nodes"].insert(1, node("lib", "subgraph", "方法卡", workflow_id=lib_id))
|
|
graph["edges"] += [{"source": "start", "target": "lib"}, {"source": "lib", "target": "fetch"}]
|
|
return graph
|
|
|
|
|
|
async def create(client, graph: dict, name: str = "周报-发布检查") -> str:
|
|
r = await client.post("/api/workflows", json={"name": name, "graph": graph})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()["id"]
|
|
|
|
|
|
async def library(client) -> str:
|
|
"""一张已发布过 v1、之后又存了 v2 草稿的上游:当前的发布版本是 v1。"""
|
|
lib = await create(client, weekly(), name="方法卡库")
|
|
assert (await client.post(f"/api/workflows/{lib}/publish", json={"level": "published"})).json()["ok"]
|
|
changed = weekly()
|
|
changed["nodes"][3]["data"]["label"] = "改过的报告"
|
|
await client.patch(f"/api/workflows/{lib}", json={"graph": changed})
|
|
return lib
|
|
|
|
|
|
async def state(*workflow_ids: str) -> list:
|
|
"""库里这些工作流和它们全部版本的每一列。"""
|
|
async with SessionLocal() as session:
|
|
rows = []
|
|
for wid in workflow_ids:
|
|
wf = await session.get(Workflow, wid)
|
|
rows.append({c.name: getattr(wf, c.name) for c in Workflow.__table__.columns})
|
|
versions = (await session.execute(select(WorkflowVersion).where(WorkflowVersion.workflow_id == wid)
|
|
.order_by(WorkflowVersion.version))).scalars()
|
|
rows += [{c.name: getattr(v, c.name) for c in WorkflowVersion.__table__.columns} for v in versions]
|
|
count = len((await session.execute(select(WorkflowVersion.id))).all())
|
|
return [rows, count]
|
|
|
|
|
|
def shape(issues: list[dict]) -> list[tuple]:
|
|
return [(i["level"], i.get("code"), i.get("node_id"), i.get("field"), i["message"]) for i in issues]
|
|
|
|
|
|
@pytest.mark.parametrize("level", ["governed", "published"])
|
|
async def test_publish_check_matches_what_publishing_blocks(client, level):
|
|
lib = await library(client)
|
|
wf = await create(client, broken(lib))
|
|
checked = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": level})).json()
|
|
published = (await client.post(f"/api/workflows/{wf}/publish", json={"level": level})).json()
|
|
assert checked["level"] == level and checked["ok"] is published["ok"] is False
|
|
assert shape(checked["issues"]) == shape(published["issues"])
|
|
assert all("fix" in i for i in checked["issues"])
|
|
|
|
|
|
async def test_publish_check_reports_codes_and_fixes(client):
|
|
lib = await library(client)
|
|
wf = await create(client, broken(lib))
|
|
body = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()
|
|
fixes = {f["id"]: f for f in body["fixes"]}
|
|
assert {"governed.agent_approval_never:ask", "governed.subgraph_unpinned:lib", "contract.metrics_from_missing:done",
|
|
"contract.report_from_invalid:done", "contract.strict_off:done"} <= set(fixes)
|
|
# 钉的是当前的发布版本 v1,不是最新存的草稿 v2
|
|
assert fixes["governed.subgraph_unpinned:lib"]["preview"]["after"] == 1
|
|
assert all(not k.startswith("_") for f in body["fixes"] for k in f)
|
|
by_code = {i["code"]: i for i in body["issues"] if i.get("code")}
|
|
assert by_code["governed.agent_approval_never"]["fix"] == "governed.agent_approval_never:ask"
|
|
# 没有修复的问题 fix 是 null(老前端不认这两个字段也不影响)
|
|
assert all(i["fix"] is None for i in body["issues"] if not i.get("code"))
|
|
|
|
|
|
async def test_publish_check_takes_an_unsaved_graph(client):
|
|
wf = await create(client, weekly())
|
|
assert (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()["ok"] is True
|
|
body = (await client.post(f"/api/workflows/{wf}/publish-check",
|
|
json={"level": "governed", "graph": weekly(required=None)})).json()
|
|
assert body["ok"] is False and body["fixes"][0]["id"] == "contract.required_missing:done"
|
|
|
|
|
|
async def test_both_endpoints_leave_the_database_alone(client):
|
|
lib = await library(client)
|
|
wf = await create(client, broken(lib))
|
|
before = await state(wf, lib)
|
|
await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})
|
|
await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": weekly()})
|
|
fixes = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()["fixes"]
|
|
out = (await client.post(f"/api/workflows/{wf}/autofix", json={
|
|
"level": "governed", "apply": [f["id"] for f in fixes if f["kind"] == "auto"]})).json()
|
|
assert out["applied"]
|
|
await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "graph": weekly(required=None),
|
|
"apply": ["contract.required_missing:done"],
|
|
"choices": {"contract.required_missing:done": ["gmv"]}})
|
|
assert await state(wf, lib) == before
|
|
|
|
|
|
async def test_autofixed_graph_passes_the_check_and_really_publishes(client):
|
|
lib = await library(client)
|
|
graph = broken(lib)
|
|
wf = await create(client, graph)
|
|
fixes = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()["fixes"]
|
|
autos = [f["id"] for f in fixes if f["kind"] == "auto"]
|
|
assert len(autos) == 5, autos
|
|
out = (await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": autos})).json()
|
|
assert sorted(out["applied"]) == sorted(autos) and out["rejected"] == [] and out["ok"] is True
|
|
assert [i for i in out["remaining"] if i["level"] == "error"] == []
|
|
assert {c["fix_id"] for c in out["changes"]} == set(autos)
|
|
assert all(op["op"] == "update_node" for op in out["ops"])
|
|
# 预览只改了修复涉及的字段
|
|
fixed = out["graph"]
|
|
assert [n["position"] for n in fixed["nodes"]] == [n["position"] for n in graph["nodes"]]
|
|
checked = (await client.post(f"/api/workflows/{wf}/publish-check",
|
|
json={"level": "governed", "graph": fixed})).json()
|
|
assert checked["ok"] is True, [i for i in checked["issues"] if i["level"] == "error"]
|
|
# 人确认后前端走现有的保存接口,再发布
|
|
await client.patch(f"/api/workflows/{wf}", json={"graph": fixed, "note": "发布前自动修复"})
|
|
published = (await client.post(f"/api/workflows/{wf}/publish", json={"level": "governed"})).json()
|
|
assert published["ok"] is True, published["issues"]
|
|
|
|
|
|
async def test_autofix_with_a_choice_and_without_it(client):
|
|
wf = await create(client, weekly(required=None))
|
|
fid = "contract.required_missing:done"
|
|
out = (await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": [fid]})).json()
|
|
assert out["applied"] == [] and out["rejected"][0]["fix_id"] == fid and out["ok"] is False
|
|
assert [i["fix"] for i in out["remaining"] if i.get("code") == "contract.required_missing"] == [fid]
|
|
out = (await client.post(f"/api/workflows/{wf}/autofix", json={
|
|
"level": "governed", "apply": [fid], "choices": {fid: ["orders", "gmv"]}})).json()
|
|
assert out["ok"] is True
|
|
contract = next(n for n in out["graph"]["nodes"] if n["id"] == "done")["data"]["config"]["contract"]
|
|
assert contract["required"] == ["gmv", "orders"]
|
|
|
|
|
|
async def test_required_candidates_come_from_the_pinned_card(client):
|
|
lib = await create(client, weekly(), name="口径库")
|
|
local = weekly(required=None)
|
|
card = local["nodes"][2]["data"]["config"]
|
|
card.pop("metrics")
|
|
card["caliber_from"] = {"workflow_id": lib, "workflow_version": 1, "node_id": "card"}
|
|
wf = await create(client, local)
|
|
body = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()
|
|
[fix] = [f for f in body["fixes"] if f["id"] == "contract.required_missing:done"]
|
|
assert fix["kind"] == "choice" and [o["value"] for o in fix["options"]] == ["gmv", "orders"]
|
|
|
|
|
|
async def test_bad_requests(client):
|
|
wf = await create(client, weekly())
|
|
r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "gold"})
|
|
assert r.status_code == 400 and "「已发布」" in r.json()["detail"]
|
|
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "gold", "apply": []})
|
|
assert r.status_code == 400
|
|
r = await client.post("/api/workflows/nope/publish-check", json={"level": "governed"})
|
|
assert r.status_code == 404
|
|
r = await client.post("/api/workflows/nope/autofix", json={"level": "governed", "apply": []})
|
|
assert r.status_code == 404
|
|
body = (await client.post(f"/api/workflows/{wf}/publish-check",
|
|
json={"level": "governed", "graph": {"nodes": [{"id": "a", "type": "nope"}]}})).json()
|
|
assert body["ok"] is False and body["fixes"] == [] and "无法解析" in body["issues"][0]["message"]
|
|
body = (await client.post(f"/api/workflows/{wf}/autofix", json={
|
|
"level": "governed", "graph": {"nodes": [{"id": "a", "type": "nope"}]}, "apply": ["x:y"]})).json()
|
|
assert body["ok"] is False and body["applied"] == [] and "无法解析" in body["remaining"][0]["message"]
|
|
|
|
|
|
async def test_the_draft_in_the_database_is_the_default(client):
|
|
wf = await create(client, weekly(strict=False))
|
|
graph = copy.deepcopy(weekly(strict=False))
|
|
out = (await client.post(f"/api/workflows/{wf}/autofix",
|
|
json={"level": "governed", "apply": ["contract.strict_off:done"]})).json()
|
|
assert out["applied"] == ["contract.strict_off:done"]
|
|
contract = next(n for n in out["graph"]["nodes"] if n["id"] == "done")["data"]["config"]["contract"]
|
|
assert contract["strict"] is True
|
|
assert (await client.get(f"/api/workflows/{wf}")).json()["graph"] == graph
|
|
|
|
|
|
@pytest.mark.parametrize("contract", ["abc", ["gmv"], 5])
|
|
def test_a_contract_that_is_not_an_object_is_a_validation_error(contract):
|
|
"""非空、却不是对象的契约:validate 报一条 error(两档发布、发起运行都挡住),不在 .get 上崩。"""
|
|
from app.engine.governance import publish_issues
|
|
from app.engine.schema import GraphSpec, validate_graph
|
|
|
|
graph = weekly()
|
|
graph["nodes"][4]["data"]["config"]["contract"] = contract
|
|
spec = GraphSpec.model_validate(graph)
|
|
[issue] = [i for i in validate_graph(spec).issues if i.code == "contract.not_object"]
|
|
assert issue.level == "error" and issue.node_id == "done" and issue.field == "contract"
|
|
assert "JSON 对象" in issue.message
|
|
for level in ("published", "governed"):
|
|
assert any(i.code == "contract.not_object" and i.level == "error" for i in publish_issues(spec, level=level))
|
|
|
|
|
|
@pytest.mark.parametrize("level", ["governed", "published"])
|
|
async def test_a_string_contract_is_reported_not_a_crash(client, level):
|
|
graph = weekly()
|
|
graph["nodes"][4]["data"]["config"]["contract"] = "report_from=write"
|
|
wf = await create(client, graph)
|
|
before = await state(wf)
|
|
r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": level})
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert [(i["code"], i["node_id"]) for i in body["issues"] if i["level"] == "error"][0] == (
|
|
"contract.not_object", "done")
|
|
# 修不了确定性修复:交给 Copilot
|
|
[fix] = [f for f in body["fixes"] if f["code"] == "contract.not_object"]
|
|
assert fix["kind"] == "assist" and fix["node_id"] == "done"
|
|
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": level, "apply": [fix["id"]]})
|
|
assert r.status_code == 200 and r.json()["ok"] is False and r.json()["applied"] == []
|
|
r = await client.post(f"/api/workflows/{wf}/publish", json={"level": level})
|
|
assert r.status_code == 200 and r.json()["ok"] is False
|
|
assert await state(wf) == before
|