mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:11:59 +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 并注明理由
261 lines
12 KiB
Python
261 lines
12 KiB
Python
"""口径卡钉住另一个已发布工作流某个版本里的口径卡(caliber_from),版本纳入升版处置。
|
||
|
||
用户拍板:口径卡的版本和子工作流一样纳入升版处置,不新造实体。
|
||
|
||
- caliber_from: {workflow_id, workflow_version, node_id}:运行时从那个 WorkflowVersion 的图里
|
||
取出口径卡的 caliber、caliber_version、metrics 定义,metric_set 里记下来源
|
||
- 引用的工作流、版本或节点不存在:节点失败,报错写清楚
|
||
- 上游有更新的版本时,正式运行前必须声明 upgrade_policy:没声明就挡住,声明了记
|
||
caliber.upgrade 事件(node_id、pinned、latest、policy、policy_label)
|
||
- 证据面板的指标步骤带上来源工作流和版本,以及升版处置
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from langchain_core.messages import AIMessage
|
||
from sqlalchemy import select
|
||
|
||
from app.core import artifact_store
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run, RunEvent, Workflow, WorkflowVersion
|
||
from app.engine.evidence import iter_segments
|
||
from app.engine.runner import run_manager
|
||
from app.main import app
|
||
|
||
KPI = "{'gmv': 45678.5, 'orders': 1234}"
|
||
STANDARD = [
|
||
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands", "expression": "vars.kpi.gmv"},
|
||
{"id": "orders", "name": "订单数", "unit": "单", "format": "thousands", "expression": "vars.kpi.orders"},
|
||
]
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def engine_up():
|
||
await run_manager.setup()
|
||
yield
|
||
await run_manager.shutdown()
|
||
|
||
|
||
@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": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
|
||
|
||
|
||
def chain(*nodes):
|
||
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
|
||
|
||
def library(metrics=STANDARD, version="v3"):
|
||
"""上游:一张只放口径卡的工作流(口径库)。"""
|
||
return chain(node("start", "input"),
|
||
node("caliber", "metrics", "周报口径卡", caliber="周报口径", caliber_version=version, metrics=metrics),
|
||
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.caliber.text }}"}]))
|
||
|
||
|
||
async def publish(graph, *, name="口径库", versions=1, status="published", later=None) -> str:
|
||
async with SessionLocal() as session:
|
||
wf = Workflow(name=name, graph=graph, status=status, published_version=versions, version=versions)
|
||
session.add(wf)
|
||
await session.flush()
|
||
for v in range(1, versions + 1):
|
||
session.add(WorkflowVersion(workflow_id=wf.id, version=v, graph=later if later and v > 1 else graph))
|
||
await session.commit()
|
||
return wf.id
|
||
|
||
|
||
def weekly(pinned: dict, *, report=False, **card):
|
||
nodes = [
|
||
node("start", "input"),
|
||
node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"),
|
||
node("card", "metrics", "本地口径卡", caliber_from=pinned, **card),
|
||
]
|
||
if report:
|
||
nodes.append(node("write", "report", instructions="写周报", on_violation="flag"))
|
||
nodes.append(node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]))
|
||
else:
|
||
nodes.append(node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
|
||
return chain(*nodes)
|
||
|
||
|
||
async def wait(run_id: str) -> Run:
|
||
for _ in range(400):
|
||
await asyncio.sleep(0.05)
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run_id)
|
||
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
|
||
return row
|
||
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
|
||
|
||
|
||
async def events(run_id: str, etype: str, node_id: str | None = None) -> list[RunEvent]:
|
||
async with SessionLocal() as session:
|
||
q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype)
|
||
if node_id:
|
||
q = q.where(RunEvent.node_id == node_id)
|
||
return list((await session.execute(q.order_by(RunEvent.seq))).scalars())
|
||
|
||
|
||
async def explore(graph) -> Run:
|
||
return await wait((await run_manager.start(graph=graph, input_payload={})).id)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 取定义、记来源
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_definitions_come_from_the_pinned_version():
|
||
wf_id = await publish(library())
|
||
pinned = {"workflow_id": wf_id, "workflow_version": 1, "node_id": "caliber"}
|
||
row = await explore(weekly(pinned))
|
||
assert row.status == "succeeded", row.error
|
||
|
||
[finished] = await events(row.id, "node.finished", "card")
|
||
card = artifact_store.load(finished.data["artifact"])
|
||
assert (card["caliber"], card["caliber_version"]) == ("周报口径", "v3")
|
||
assert [(m["id"], m["value"], m["rendered"]) for m in card["metrics"]] == [
|
||
("gmv", 45678.5, "45,678.5元"), ("orders", 1234, "1,234单")]
|
||
assert card["source"] == pinned
|
||
# 落进工件库的指标集里也记着来源:证据接口从这里取
|
||
[entry] = finished.data["evidence"]
|
||
stored = artifact_store.load(entry["artifact"])
|
||
assert stored["source"] == pinned and entry["source"] == pinned
|
||
|
||
|
||
async def test_later_versions_do_not_leak_into_a_pinned_card():
|
||
"""钉住 v1 就用 v1 的定义:上游 v2 改了算法,这次运行照旧按 v1 算。"""
|
||
changed = [{**STANDARD[0], "expression": "vars.kpi.gmv * 2"}, STANDARD[1]]
|
||
wf_id = await publish(library(), versions=2, later=library(changed, version="v4"))
|
||
row = await explore(weekly({"workflow_id": wf_id, "workflow_version": 1, "node_id": "caliber"}))
|
||
[finished] = await events(row.id, "node.finished", "card")
|
||
card = artifact_store.load(finished.data["artifact"])
|
||
assert card["caliber_version"] == "v3" and card["metrics"][0]["value"] == 45678.5
|
||
|
||
|
||
async def test_graphs_without_caliber_from_are_unchanged():
|
||
row = await explore(chain(node("start", "input"),
|
||
node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"),
|
||
node("card", "metrics", caliber="周报口径", metrics=STANDARD),
|
||
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}])))
|
||
[finished] = await events(row.id, "node.finished", "card")
|
||
card = artifact_store.load(finished.data["artifact"])
|
||
assert "source" not in card and "source" not in finished.data["evidence"][0]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 引用不存在
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("pin, words", [
|
||
(lambda wf: {"workflow_id": "no-such-workflow", "workflow_version": 1, "node_id": "caliber"},
|
||
["no-such-workflow", "不存在"]),
|
||
(lambda wf: {"workflow_id": wf, "workflow_version": 7, "node_id": "caliber"}, ["口径库", "v7"]),
|
||
(lambda wf: {"workflow_id": wf, "workflow_version": 1, "node_id": "nope"}, ["口径库", "v1", "nope"]),
|
||
(lambda wf: {"workflow_id": wf, "workflow_version": 1, "node_id": "out"}, ["不是口径卡"]),
|
||
])
|
||
async def test_missing_references_fail_the_node_clearly(pin, words):
|
||
wf_id = await publish(library())
|
||
row = await explore(weekly(pin(wf_id)))
|
||
assert row.status == "failed"
|
||
[failed] = await events(row.id, "node.failed", "card")
|
||
assert all(w in failed.data["error"] for w in words), failed.data["error"]
|
||
|
||
|
||
async def test_pinning_a_card_that_is_itself_pinned_is_refused():
|
||
base = await publish(library(), name="源头口径")
|
||
middle = await publish(chain(node("start", "input"),
|
||
node("caliber", "metrics", caliber_from={"workflow_id": base, "workflow_version": 1,
|
||
"node_id": "caliber"}),
|
||
node("out", "output", fields=[{"name": "r", "value": "x"}])), name="转手口径")
|
||
row = await explore(weekly({"workflow_id": middle, "workflow_version": 1, "node_id": "caliber"}))
|
||
[failed] = await events(row.id, "node.failed", "card")
|
||
assert "源头" in failed.data["error"] and "源头口径" in failed.data["error"], failed.data["error"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 升版处置:正式运行
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def formal(client, graph):
|
||
wf_id = await publish(graph, name="周报")
|
||
return await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"})
|
||
|
||
|
||
async def test_a_newer_upstream_blocks_formal_runs_without_a_policy(client):
|
||
lib = await publish(library(), versions=2, later=library(version="v4"))
|
||
res = await formal(client, weekly({"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"}))
|
||
assert res.status_code == 409, res.text
|
||
detail = res.json()["detail"]
|
||
assert "「本地口径卡」" in detail and "v1" in detail and "v2" in detail and "「上游发布新版本时」" in detail
|
||
|
||
|
||
async def test_a_declared_policy_is_recorded(client):
|
||
lib = await publish(library(), versions=2, later=library(version="v4"))
|
||
res = await formal(client, weekly({"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"},
|
||
upgrade_policy="dual"))
|
||
assert res.status_code == 201, res.text
|
||
row = await wait(res.json()["id"])
|
||
assert row.status == "succeeded", row.error
|
||
[event] = await events(row.id, "caliber.upgrade")
|
||
assert event.node_id == "card"
|
||
assert {k: event.data.get(k) for k in ("workflow_id", "pinned", "latest", "policy", "policy_label",
|
||
"caliber_node")} == {
|
||
"workflow_id": lib, "pinned": 1, "latest": 2, "policy": "dual", "policy_label": "新旧口径并列展示",
|
||
"caliber_node": "caliber"}
|
||
|
||
|
||
async def test_pinned_to_the_latest_needs_no_policy(client):
|
||
lib = await publish(library())
|
||
res = await formal(client, weekly({"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"}))
|
||
assert res.status_code == 201, res.text
|
||
row = await wait(res.json()["id"])
|
||
assert await events(row.id, "caliber.upgrade") == []
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 证据面板的指标步骤
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_metric_step_shows_the_source_and_the_upgrade(client, monkeypatch):
|
||
from app.providers import mock_model
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide",
|
||
lambda self, messages: AIMessage(content="销售额 [[m:gmv]]。"))
|
||
lib = await publish(library(), versions=2, later=library(version="v4"))
|
||
pinned = {"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"}
|
||
res = await formal(client, weekly(pinned, report=True, upgrade_policy="incomparable"))
|
||
row = await wait(res.json()["id"])
|
||
assert row.status == "succeeded", row.error
|
||
doc_id = row.output["_evidence"]["doc_artifact"]
|
||
doc = artifact_store.load(doc_id)
|
||
seg = next(s for s in iter_segments(doc) if s["text"] == "45,678.5元")
|
||
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}")).json()
|
||
metric = body["chain"][0]
|
||
assert metric["source"] == {**pinned, "workflow_name": "口径库"}
|
||
assert (metric["caliber"], metric["version"]) == ("周报口径", "v3")
|
||
assert metric["caliber_upgrade"] == {"workflow_id": lib, "caliber_node": "caliber", "pinned": 1, "latest": 2,
|
||
"policy": "incomparable", "policy_label": "标注与历史不可比"}
|
||
assert json.dumps(metric, ensure_ascii=False)
|
||
|
||
|
||
async def test_subgraphs_keep_the_same_upgrade_rule(client):
|
||
"""子工作流的升版处置原样不变:口径卡是并进同一套规则,不是另起一套。"""
|
||
lib = await publish(library(), versions=2, later=library(version="v4"))
|
||
graph = chain(node("start", "input"),
|
||
node("sub", "subgraph", "方法卡", workflow_id=lib, workflow_version=1),
|
||
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.text }}"}]))
|
||
res = await formal(client, graph)
|
||
assert res.status_code == 409 and "「方法卡」(子工作流)固定在 v1,但上游已有 v2" in res.json()["detail"]
|