Files
AgentLab/backend/tests/test_validation_text.py
ThearchyHelios 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 并注明理由
2026-09-30 08:05:15 +08:00

209 lines
9.0 KiB
Python
Raw Permalink 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.
"""校验和报错写给人看:用界面上的叫法,说清原因和下一步,不露 Python 内部名字。
- 分支 case 的 key 用了保留名 default:和兜底出口撞成同一个 id,跑完两条一起点亮,
看不出实际走了哪条。重复 key、空 key 更糟——那个 case 根本路由不到。
- 门禁文案写「受管模板不允许 supervisor 节点」「approval=never」「该是个 llm 节点」,
界面上它们叫「多 Agent 协作」「全部自动放行」「模型调用」,报错引用了用户从没见过的词。
- 运行失败的首行写着「工具 db_query__shop 执行失败:_make_query_tool.<locals>._run()
got an unexpected keyword argument 'query'」,恢复失败写「当前状态是 succeeded」。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine import compiler
from app.engine.governance import lint_for_publish
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, NodeType, validate_graph
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 _branch(cases: list[dict]) -> GraphSpec:
return GraphSpec.model_validate({
"nodes": [
node("start", "input"),
node("br", "branch", label="走哪条", mode="expression", cases=cases),
node("a", "output"), node("b", "output"),
],
"edges": [
{"source": "start", "target": "br"},
{"source": "br", "target": "a", "sourceHandle": "fast"},
{"source": "br", "target": "b", "sourceHandle": "default"},
],
})
def _issues(spec: GraphSpec, level: str) -> list[str]:
return [i.message for i in validate_graph(spec).issues if i.level == level and i.node_id == "br"]
def test_case_key_default_is_flagged_as_the_reserved_fallback():
spec = _branch([{"key": "fast", "condition": "true"},
{"key": "default", "condition": "true", "label": "协作模式"}])
warnings = _issues(spec, "warning")
assert any("default" in m and "保留" in m for m in warnings), warnings
# 现存的 ⑥ 模板就是这么写的:条件为 true 的 default 等价于兜底,运行语义自洽。
# 升成 error 会让它和所有从它复制出来的图直接不能跑
assert validate_graph(spec).ok
def test_duplicate_case_keys_are_an_error():
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": "fast", "condition": "vars.b"}])
errors = _issues(spec, "error")
assert any("fast" in m and "重复" in m for m in errors), errors
assert not validate_graph(spec).ok
def test_blank_case_key_is_an_error():
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": " ", "condition": "vars.b"}])
assert any("标识" in m for m in _issues(spec, "error")), _issues(spec, "error")
def test_validation_uses_the_names_on_the_canvas():
spec = GraphSpec.model_validate({
"nodes": [node("start", "input"), node("sub", "subgraph"), node("t", "tool"),
node("v", "validate"), node("out", "output")],
"edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "t"},
{"source": "t", "target": "v"}, {"source": "v", "target": "out"}],
})
messages = " ".join(i.message for i in validate_graph(spec).issues)
assert "子工作流" in messages and "子图节点" not in messages
assert "「调用工具」" in messages
assert "结构校验" in messages
def _governed(*nodes) -> str:
spec = GraphSpec.model_validate({
"nodes": [node("start", "input"), *nodes],
"edges": [{"source": "start", "target": n["id"]} for n in nodes],
})
return " ".join(i.message for i in lint_for_publish(spec, level="governed").issues)
def test_governance_speaks_the_ui_vocabulary():
text = _governed(
node("team", "supervisor", label="协作团队"),
node("ag", "agent", label="取数员", tools=["web_search"], approval="never"),
node("bare", "agent", label="空手的"),
node("c", "code", label="算一下", network=True),
)
for internal in ("supervisor", "approval=never", "llm 节点", "contract"):
assert internal not in text, f"门禁文案里还有内部词 {internal!r}:{text}"
assert "「协作团队」" in text and "多 Agent 协作" in text
assert "审批策略" in text and "全部无需审批" in text
assert "模型调用" in text
assert "出具契约" in text
# --------------------------------------------------------------------------
# 运行期报错
# --------------------------------------------------------------------------
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def _finish(run_id: str) -> Run:
for _ in range(300):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled", "interrupted"):
return row
await asyncio.sleep(0.03)
raise AssertionError("等超时了")
RAW = "_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'"
async def test_a_python_error_reads_like_a_sentence(engine_up, monkeypatch):
async def broken(state, ctx):
raise TypeError(RAW)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, broken)
graph = {
"nodes": [node("start", "input"), node("shape", "transform", label="整理数据",
mode="template", template="x")],
"edges": [{"source": "start", "target": "shape"}],
}
run = await run_manager.start(graph=graph, input_payload={})
row = await _finish(run.id)
assert row.status == "failed"
for text in (row.error or "",):
assert "TypeError" not in text and "<locals>" not in text, text
assert "query" in text, "原因要说到点子上:哪个参数对不上"
assert row.error_node_id == "shape"
async with SessionLocal() as session:
events = list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
)).scalars())
failed_node = next(e for e in events if e.type == "node.failed")
assert "TypeError" not in failed_node.data["error"]
assert "TypeError" in failed_node.data["detail"], "原始异常要留着,给排查的人看"
failed_run = next(e for e in events if e.type == "run.failed")
assert failed_run.data["node_id"] == "shape"
assert failed_run.data["label"] == "整理数据"
assert "TypeError" not in failed_run.data["error"]
async def test_resume_refusal_does_not_leak_the_status_enum(engine_up):
async with SessionLocal() as session:
run = Run(workflow_name="x", status="succeeded", graph={"nodes": [], "edges": []}, input={})
session.add(run)
await session.commit()
with pytest.raises(ValueError) as caught:
await run_manager.resume(run.id, {"approved": True})
assert "succeeded" not in str(caught.value)
assert "已完成" in str(caught.value)
def test_exception_wording_helper():
from app.core.errors import describe_exception
assert "query" in describe_exception(TypeError(RAW))
assert "TypeError" not in describe_exception(TypeError(RAW))
timeout = describe_exception(TimeoutError())
assert timeout and "Timeout" not in timeout
generic = describe_exception(RuntimeError("Integer exceeds 64-bit range"))
assert "RuntimeError" not in generic and "64-bit" in generic
nested = describe_exception(RuntimeError("连接失败:ConnectError: All connection attempts failed"))
assert "Error" not in nested and "All connection attempts failed" in nested
async def test_issuance_event_says_why_it_was_downgraded(engine_up):
"""只有 gaps 时判成降档,事件里却不带 gaps:时间线上只剩一个光秃秃的「降档出具」,
横幅还说「请对照下方声明」,下方什么都没有。"""
graph = {
"nodes": [
node("start", "input"),
node("out", "output", fields=[{"name": "结论", "value": "本周 12 单"}],
contract={"metrics_from": "没有这个口径卡", "narrative": "{{ output.结论 }}"}),
],
"edges": [{"source": "start", "target": "out"}],
}
run = await run_manager.start(graph=graph, input_payload={})
row = await _finish(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
issuance = (await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "issuance")
)).scalars().one()
assert issuance.data["tier"] == "degraded"
assert any("没有这个口径卡" in g for g in issuance.data["gaps"]), issuance.data
assert issuance.data["metrics_checked"] == 0