Files
AgentLab/backend/tests/test_api_messages.py
T
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

349 lines
14 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.
"""面向用户的报错不露 Python 异常类名和内部符号,并且说得出下一步。
以前界面首行红字是「_make_query_tool.<locals>._run() got an unexpected keyword
argument 'query'」,助手输入坞里是「RuntimeError: …」,校验失败是一大段 pydantic
英文。对问数据的业务用户这些都是天书,而且没有下一步。原始异常不丢:放进 detail,
排查的人照样拿得到。
"""
from __future__ import annotations
import json
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
_INTERNAL = ("Error:", "Error)", "Exception", "Traceback", "<locals>", "Input should be",
"validation error", "errors.pydantic.dev")
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
def _clean(text: str) -> None:
for token in _INTERNAL:
assert token not in text, f"面向用户的报错里露出了 {token!r}:{text}"
# --------------------------------------------------------------------------
# 翻译规则本身
# --------------------------------------------------------------------------
def test_common_failures_are_put_in_plain_words():
import httpx
from app.core.errors import explain, graph_error, raw
reason, hint = explain(httpx.ConnectError("[Errno 61] Connection refused"))
assert "无法连接服务" in reason and hint
req = httpx.Request("GET", "https://x/v1/models")
denied = httpx.HTTPStatusError("401", request=req, response=httpx.Response(401, request=req))
assert "API Key" in explain(denied)[0]
reason, hint = explain(TypeError("_run() got an unexpected keyword argument 'query'"))
_clean(reason)
assert "内部" in reason and hint
assert raw(TypeError("x")) == "TypeError: x", "原始异常要原样留给 detail"
from app.engine.schema import GraphSpec
with pytest.raises(Exception) as e:
GraphSpec.model_validate({"nodes": [{"id": "a", "type": "nope"}]})
text = graph_error(e.value)
_clean(text)
assert "第 1 个节点" in text and "nope" in text
def test_errors_carry_a_category_for_scene_specific_hints():
"""要按场景换说法的调用方(测数据库连接)按类别判断,不比较 explain 的文字。"""
import asyncio
import httpx
from app.core import errors
req = httpx.Request("GET", "https://x/v1/models")
cases = [
(httpx.ConnectError("[Errno 61] Connection refused"), errors.NETWORK),
(ConnectionRefusedError(61, "Connection refused"), errors.NETWORK),
(asyncio.TimeoutError(), errors.TIMEOUT),
(httpx.HTTPStatusError("401", request=req, response=httpx.Response(401, request=req)), errors.AUTH),
(httpx.HTTPStatusError("429", request=req, response=httpx.Response(429, request=req)), errors.RATE_LIMIT),
(httpx.HTTPStatusError("502", request=req, response=httpx.Response(502, request=req)), errors.SERVER),
(TypeError("x() got an unexpected keyword argument 'q'"), errors.BUG),
(RuntimeError("upstream closed"), None),
]
for exc, want in cases:
assert errors.classify(exc) == want, (exc, errors.classify(exc))
# --------------------------------------------------------------------------
# 各接口
# --------------------------------------------------------------------------
async def test_a_failing_provider_test_explains_itself(client, monkeypatch):
import httpx
import app.api.settings as settings_api
class Boom:
async def ainvoke(self, _prompt):
raise httpx.ConnectError("[Errno 61] Connection refused")
monkeypatch.setattr(settings_api, "build_chat_model", lambda *_a, **_k: Boom())
body = (await client.post("/api/providers/test", json={"kind": "mock", "default_model": "m"})).json()
assert body["ok"] is False
_clean(body["error"])
assert "无法连接服务" in body["error"] and body["hint"]
assert "ConnectError" in body["detail"], "技术细节里要留着原始异常"
async def test_copilot_stream_errors_are_readable(client, monkeypatch):
import app.api.copilot as copilot
class Broken:
async def astream(self, _messages):
raise RuntimeError("upstream closed")
yield # pragma: no cover
async def _model(*_a, **_k):
return Broken(), "mock-fast"
monkeypatch.setattr(copilot, "get_chat_model", _model)
events = []
async with client.stream("POST", "/api/copilot/generate-stream", json={"instruction": "x"}) as r:
async for line in r.aiter_lines():
if line.startswith("data: "):
events.append(json.loads(line[6:]))
err = next(e for e in events if e["op"] == "error")
_clean(err["message"])
assert "upstream closed" in err["message"] and "RuntimeError" in err["detail"]
async def test_copilot_generate_errors_are_readable(client, monkeypatch):
import app.api.copilot as copilot
class Garbage:
def with_structured_output(self, _schema):
return self
async def ainvoke(self, _messages):
raise ValueError("not json")
async def _model(*_a, **_k):
return Garbage(), "mock-fast"
monkeypatch.setattr(copilot, "get_chat_model", _model)
r = await client.post("/api/copilot/generate", json={"instruction": "x"})
assert r.status_code == 502
_clean(r.json()["detail"])
async def test_graph_validation_points_at_the_node(client):
body = (await client.post("/api/workflows/validate",
json={"graph": {"nodes": [{"id": "a", "type": "nope"}]}})).json()
message = body["issues"][0]["message"]
_clean(message)
assert "第 1 个节点的类型「nope」无法识别" in message
async def test_publish_level_uses_ui_words(client):
wf = (await client.post("/api/workflows", json={"name": "档位", "graph": {}})).json()
r = await client.post(f"/api/workflows/{wf['id']}/publish", json={"level": "gold"})
assert r.status_code == 400
assert "「已发布」" in r.json()["detail"] and "governed" not in r.json()["detail"]
await client.delete(f"/api/workflows/{wf['id']}")
async def test_a_failing_tool_run_explains_itself(client, monkeypatch):
import app.api.tools as tools_api
async def boom(*_a, **_k):
raise TypeError("_run() got an unexpected keyword argument 'query'")
monkeypatch.setattr(tools_api, "call_tool", boom)
body = (await client.post("/api/tools/file_list/run", json={"args": {}})).json()
assert body["ok"] is False
_clean(body["error"])
assert "TypeError" in body["detail"]
async def test_datasource_introspect_failure_is_readable(client):
r = await client.post("/api/datasources", json={"name": "ghostfile", "kind": "sqlite",
"database": "/no/such/dir/ghost.db"})
assert r.status_code == 201
sid = r.json()["id"]
try:
r = await client.post(f"/api/datasources/{sid}/introspect")
assert r.status_code == 400
_clean(r.json()["detail"])
body = (await client.post(f"/api/datasources/{sid}/test")).json()
assert body["ok"] is False
_clean(body["error"])
assert body["detail"], "原始报错要留在 detail"
finally:
await client.delete(f"/api/datasources/{sid}")
async def test_embedding_probe_failure_is_readable(client):
r = await client.get("/api/kb/embedding/probe", params={"base_url": "http://127.0.0.1:9/v1"})
assert r.status_code == 400
_clean(r.json()["detail"])
def test_a_broken_document_is_explained_without_class_names():
pytest.importorskip("pypdf")
from app.memory.parsing import UnsupportedDocument, extract
with pytest.raises(UnsupportedDocument) as e:
extract(b"%PDF-1.4 definitely not a pdf", "broken.pdf")
_clean(str(e.value))
assert "broken" not in str(e.value) or "PDF" in str(e.value)
async def test_background_processing_failure_is_readable(monkeypatch):
from app.api import knowledge as kb_api
from app.db.base import SessionLocal
from app.db.models import Document
from app.memory import kb
async with SessionLocal() as session:
doc = await kb.create_document(session, collection="errs", title="t", content="正文",
source="t.txt", mime="text/plain", status="processing")
doc_id = doc.id
async def boom(*_a, **_k):
raise AttributeError("'NoneType' object has no attribute 'encode'")
monkeypatch.setattr(kb, "process_document", boom)
await kb_api._process_in_background(doc_id)
async with SessionLocal() as session:
doc = await session.get(Document, doc_id)
assert doc.status == "failed"
_clean(doc.error)
async def test_governance_hints_do_not_quote_field_names(client):
wf = (await client.post("/api/workflows", json={"name": "治理", "graph": {}})).json()
try:
body = (await client.get("/api/governance/tool-usage", params={"workflow_id": wf["id"]})).json()
assert "unused" not in body["hint"]
finally:
await client.delete(f"/api/workflows/{wf['id']}")
body = (await client.get("/api/governance/exploratory-clusters")).json()
assert "promote_candidate" not in body.get("hint", "")
async def test_an_unconfigured_copilot_model_uses_settings_words(client, monkeypatch):
import app.api.copilot as copilot
from app.providers.factory import ProviderNotConfigured
async def _model(*_a, **_k):
raise ProviderNotConfigured("provider 'gw' 需要填 base_url")
monkeypatch.setattr(copilot, "get_chat_model", _model)
r = await client.post("/api/copilot/generate-stream", json={"instruction": "x"})
assert r.status_code == 400
detail = r.json()["detail"]
assert "模型接入「gw」" in detail and "Base URL" in detail and "provider" not in detail, detail
async def test_a_missing_item_says_it_may_have_been_deleted(client):
"""404 多半是别的标签页里删掉了,或者链接过期了。只说「不存在」,人会以为
是自己输错了什么;统一说一句「可能已被删除」,下一步就清楚了。"""
gone = "no-such-id"
calls = [
("get", f"/api/workflows/{gone}", None),
("patch", f"/api/workflows/{gone}", {"name": "x"}),
("delete", f"/api/workflows/{gone}", None),
("post", f"/api/workflows/{gone}/duplicate", None),
("get", f"/api/workflows/{gone}/versions/1", None),
("post", f"/api/workflows/{gone}/versions/1/restore", None),
("post", f"/api/workflows/{gone}/publish", {}),
("get", "/api/governance/tool-usage", None),
("post", "/api/copilot/from-run", {"run_id": gone}),
("post", "/api/copilot/review", {"run_id": gone}),
("get", f"/api/conversations/{gone}", None),
("get", f"/api/kb/documents/{gone}", None),
("delete", f"/api/kb/documents/{gone}", None),
("delete", f"/api/skills/{gone}", None),
("get", f"/api/artifacts/{'0' * 64}", None),
]
for method, url, body in calls:
kwargs = {"json": body} if body is not None else {}
if url.endswith("tool-usage"):
kwargs = {"params": {"workflow_id": gone}}
r = await client.request(method.upper(), url, **kwargs)
assert r.status_code == 404, (method, url, r.status_code, r.text)
detail = r.json()["detail"]
assert "可能已被删除" in detail, (method, url, detail)
assert gone not in detail, f"把 id 原样念给用户听没用:{detail}"
async def test_an_unconfigured_provider_on_the_run_path_uses_settings_words(monkeypatch):
"""运行路径上的「缺少 API Key」「接入已停用」和助手、设置页一样经过统一的翻译:写「模型接入」、
写去「设置 → 模型接入」哪里补,不露 provider、base_url 和英文单引号。以前运行失败原因里原样是
「provider 'x' 缺少 API Key」。"""
import asyncio
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import Provider, Run
from app.engine.runner import run_manager
for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
monkeypatch.delenv(name, raising=False)
def graph(model: str) -> dict:
nodes = [
{"id": "start", "type": "input", "data": {"config": {"fields": [{"name": "q"}]}}},
{"id": "ask", "type": "llm", "data": {"label": "提问", "config": {"model": model, "prompt": "hi"}}},
{"id": "out", "type": "output", "data": {"config": {"fields": [{"name": "结果", "value": "{{ last_message }}"}]}}},
]
return {"nodes": nodes, "edges": [{"source": "start", "target": "ask"}, {"source": "ask", "target": "out"}]}
async def error_of(model: str) -> str:
run = await run_manager.start(graph=graph(model), input_payload={"q": "x"})
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row and row.status in ("failed", "succeeded", "cancelled"):
assert row.status == "failed", row.status
return row.error or ""
await asyncio.sleep(0.05)
raise AssertionError("运行没有按时结束")
async with SessionLocal() as session:
keyless = Provider(name="无钥接入", kind="anthropic", api_key=None, models=[{"id": "claude-nokey"}],
default_model="claude-nokey", enabled=True, extra={})
disabled = Provider(name="停用接入", kind="openai", api_key=encrypt("k"), models=[{"id": "gpt-off"}],
default_model="gpt-off", enabled=False, extra={})
session.add_all([keyless, disabled])
await session.commit()
ids = [keyless.id, disabled.id]
await run_manager.setup()
try:
error = await error_of("claude-nokey")
assert "模型接入「无钥接入」缺少 API Key" in error and "「设置 → 模型接入」" in error, error
error = await error_of("gpt-off")
assert "模型「gpt-off」所属的模型接入「停用接入」已停用" in error, error
assert "「设置 → 模型接入」中启用" in error, error
for text in (error,):
assert "provider" not in text and "'" not in text, text
finally:
await run_manager.shutdown()
async with SessionLocal() as session:
for pid in ids:
row = await session.get(Provider, pid)
if row is not None:
await session.delete(row)
await session.commit()