Files
AgentLab/backend/tests/test_segment_missing_input.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

144 lines
6.5 KiB
Python

"""缺输入的指标也给出链:报告里引用的指标存在、只是这次没有值(missing_input),片段显示「—」。
以前片段接口只给确定性片段出链,这种片段点开只剩一句「缺输入」:说不出是哪个输入空了、
那一格在哪次查询里。现在照样返回指标步骤、它的输入步骤和查询步骤,面板能指着那一格说
「没查到,记为空,没有兜底成 0」。引用本身写错(目录里没有这个指标)的,照旧没有链。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.core.config import settings
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.evidence import iter_segments
from app.engine.runner import run_manager
from app.main import app
SQL = "SELECT SUM(amount) AS gmv, COUNT(*) AS orders, NULL AS refunds FROM orders"
TEXT = "销售额 [[m:gmv]],退款率 [[m:refund_rate]],新客 [[m:nope]]。"
@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
@pytest.fixture(autouse=True)
async def shop(tmp_path):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL);"
"INSERT INTO orders VALUES (1, 100.5), (2, 200.0);")
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
if row is None:
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def weekly() -> dict:
nodes = [
node("start", "input"),
node("fetch", "tool", tool="db_query__shop", args={"sql": SQL}),
# 缺输入记为空值,交给契约判档;不整张卡失败
node("card", "metrics", caliber="周报口径", on_missing="null", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "refund_rate", "name": "退款率", "format": "percent_of_ratio", "decimals": 4,
"expression": "cell(nodes.fetch, 0, 'refunds') / cell(nodes.fetch, 0, 'orders')"}]),
node("write", "report", instructions="写周报", on_violation="flag"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def run_weekly(monkeypatch) -> tuple[Run, dict]:
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=TEXT))
run = await run_manager.start(graph=weekly(), input_payload={})
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:
break
assert row.status == "succeeded", row.error
doc_id = row.output["_evidence"]["doc_artifact"]
doc = json.loads((settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json").read_text("utf-8"))
return row, doc
def seg_by_ref(doc: dict, ref: str) -> dict:
return next(s for s in iter_segments(doc) if s.get("ref") == ref)
async def segment(client, run_id: str, seg_id: str) -> dict:
resp = await client.get(f"/api/runs/{run_id}/evidence/segments/{seg_id}")
assert resp.status_code == 200, resp.text
return resp.json()
async def test_a_metric_missing_its_input_still_opens_its_chain(client, monkeypatch):
row, doc = await run_weekly(monkeypatch)
seg = seg_by_ref(doc, "m:refund_rate")
assert seg["text"] == "—" and seg["state"] != "deterministic" and seg["cite"]["status"] == "unresolved"
body = await segment(client, row.id, seg["id"])
metric, refunds, orders, query = body["chain"]
assert metric["step"] == "metric" and metric["alias"] == "m:refund_rate" and metric["metric"] == "refund_rate"
assert metric["status"] == "missing_input" and metric["value"] is None and metric["rendered"] == "—"
assert metric["eid_ok"] is True and metric["hash_ok"] is True and metric["sealed"] is True
assert refunds["step"] == "input" and refunds["via"] == "tool_cell" and refunds["value"] is None
assert refunds["locator"] == {"row": 0, "column": "refunds"} and refunds["cell"] == "Q1.r0.refunds"
assert orders["value"] == 2
async with SessionLocal() as session:
[end] = (await session.execute(select(RunEvent).where(
RunEvent.run_id == row.id, RunEvent.type == "tool.end", RunEvent.node_id == "fetch"))).scalars()
assert query["step"] == "query" and query["artifact"] == end.data["query_artifact"]
assert query["highlight"]["cells"] == [[0, "refunds"], [0, "orders"]]
assert query["rows"] == [[300.5, 2, None]]
# 片段本身的说法不变:引用无法解析,缺少输入
assert "缺少输入" in body["note"] and body["segment"]["cite"]["status"] == "unresolved"
async def test_a_wrong_reference_still_has_no_chain(client, monkeypatch):
row, doc = await run_weekly(monkeypatch)
body = await segment(client, row.id, seg_by_ref(doc, "m:nope")["id"])
assert body["chain"] == [] and "引用的指标「nope」不存在" in body["note"]
async def test_resolved_metrics_are_unchanged(client, monkeypatch):
row, doc = await run_weekly(monkeypatch)
body = await segment(client, row.id, seg_by_ref(doc, "m:gmv")["id"])
metric, source, query = body["chain"]
assert metric["status"] == "ok" and metric["render_ok"] is True and source["value"] == 300.5
assert query["highlight"]["cells"] == [[0, "gmv"]]