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

192 lines
8.2 KiB
Python

"""报告节点的结论句策略 claims:off(默认,和以前一样)/ require_citation(没挂依据的结论句计入缺口)。
这里测确定性的一半(模型裁判 judge 见 test_report_judge.py):
- require_citation 时写作提示明确要求每句结论挂 [[see:…]];没挂的照常产出、计数,
出口按出具档位降档(io.py),报告节点自己不为它失败、不为它重写
- report.checked 事件和节点产出都带上 claims,出口据此判档
- 写了不认识的值节点直接报错,不能悄悄当成 off
- 升级前发起的运行一个字都不变
"""
from __future__ import annotations
import asyncio
import pytest
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
from app.engine import runner as runner_mod
from app.engine.evidence import CLAIMS_RULE, build_catalog, compose_doc, uncited_claims
from app.engine.runner import run_manager
from app.engine.schema import CLAIMS_VALUES, claims_problem
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
METRICS = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}]
#: 第一句挂了依据,第二句是没挂依据的结论(有方向词),第三句是连接性的话
TEXT = "本周销售额 [[m:gmv]]。[[see:m:gmv]]增长主要来自新客。下面看细节。"
def weekly(**report):
nodes = [
node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]),
node("fetch", "transform", mode="expression", expression="{'gmv': 45678.5}", assign_to="kpi"),
node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=METRICS),
node("write", "report", instructions="写周报", **report),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
]
ids = [n["id"] for n in nodes]
return {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]}
def script(monkeypatch, reply=TEXT) -> list:
from app.providers import mock_model
seen: list = []
def _decide(self, messages):
seen.append(list(messages))
return AIMessage(content=reply)
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
return seen
def lenient(monkeypatch):
"""装作画布校验没拦住(校验以后放宽、或者绕过校验发起的运行):节点执行时自己也要守住这一关,
报的是同一句话。"""
original = runner_mod.validate_graph
def permissive(spec):
report = original(spec)
report.issues = [i for i in report.issues if i.field != "claims"]
report.ok = not any(i.level == "error" for i in report.issues)
return report
monkeypatch.setattr(runner_mod, "validate_graph", permissive)
async def finish(graph) -> Run:
run = await run_manager.start(graph=graph, 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:
return row
raise AssertionError(f"run {run.id} 没跑完:{row.status}")
async def events(run_id: str, etype: str) -> list[RunEvent]:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype).order_by(RunEvent.seq)
return list((await session.execute(q)).scalars())
async def checked_and_output(run_id: str) -> tuple[dict, dict]:
[checked] = await events(run_id, "report.checked")
finished = [e for e in await events(run_id, "node.finished") if e.node_id == "write"][0]
return checked.data, artifact_store.load(finished.data["artifact"])
def prompt_of(seen: list) -> str:
return "\n".join(str(m.content) for m in seen[0])
async def test_default_is_off_and_changes_nothing(monkeypatch):
seen = script(monkeypatch)
row = await finish(weekly())
assert row.status == "succeeded", row.error
checked, output = await checked_and_output(row.id)
assert checked["claims"] == "off" and output["claims"] == "off"
assert CLAIMS_RULE not in prompt_of(seen)
assert checked["stats"]["uncited_claims"] == 1
async def test_require_citation_asks_for_support_but_does_not_block(monkeypatch):
seen = script(monkeypatch)
row = await finish(weekly(claims="require_citation", on_violation="fail"))
assert row.status == "succeeded", row.error
assert len(seen) == 1, "没挂依据的结论句不触发重写"
assert CLAIMS_RULE in prompt_of(seen)
checked, output = await checked_and_output(row.id)
assert checked["claims"] == "require_citation" and output["claims"] == "require_citation"
assert checked["ok"] is True and checked["violations"] == []
assert checked["stats"]["uncited_claims"] == output["stats"]["uncited_claims"] == 1
doc = artifact_store.load(checked["doc_artifact"])
[flagged] = uncited_claims(doc)
assert flagged["text"] == "增长主要来自新客。" and doc["markdown"][slice(*flagged["span"])] == flagged["text"]
async def test_a_bad_value_is_refused_before_any_model_call(monkeypatch):
seen = script(monkeypatch)
with pytest.raises(ValueError) as info: # 画布校验先拦
await run_manager.start(graph=weekly(claims="sometimes"), input_payload={})
assert claims_problem("sometimes") in str(info.value)
lenient(monkeypatch)
row = await finish(weekly(claims="sometimes"))
assert row.status == "failed" and claims_problem("sometimes") in (row.error or ""), row.error
assert seen == [], "不支持的策略不该先花一次模型调用"
@pytest.mark.parametrize("value", ["sometimes", 1, ["require_citation"]])
async def test_other_values_are_refused(monkeypatch, value):
script(monkeypatch)
lenient(monkeypatch)
row = await finish(weekly(claims=value))
assert row.status == "failed", row.error
assert claims_problem(value) in row.error and "「计入缺口」" in row.error
async def test_runs_from_before_the_upgrade_are_untouched(monkeypatch):
async def no_snapshot(session):
return None
monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot)
seen = script(monkeypatch)
row = await finish(weekly())
assert row.status == "succeeded", row.error
checked, output = await checked_and_output(row.id)
assert "claims" not in checked and "claims" not in output
assert CLAIMS_RULE not in prompt_of(seen)
# --------------------------------------------------------------------------
# 出口和界面共用的纯函数
# --------------------------------------------------------------------------
def test_the_supported_policies_match_the_validator():
from app.engine.nodes.report import CLAIMS, report_claims
from app.engine.schema import GraphSpec
assert CLAIMS == CLAIMS_VALUES == ("off", "require_citation", "judge")
spec = GraphSpec.model_validate(weekly(claims="require_citation"))
assert report_claims(spec, spec.node_map()["write"]) == "require_citation"
spec = GraphSpec.model_validate(weekly())
assert report_claims(spec, spec.node_map()["write"]) == "off"
spec.defaults["claims"] = "require_citation" # 图级 defaults 同样生效,和执行时一个规则
assert report_claims(spec, spec.node_map()["write"]) == "require_citation"
def test_uncited_claims_lists_claim_units_without_support():
catalog = build_catalog(nodes={"caliber": {"kind": "metric_set", "caliber": "c", "caliber_version": "v1",
"artifact": "f" * 64,
"metrics": [{"id": "gmv", "name": "销售额", "value": 1, "unit": ""}]}})
doc = compose_doc("销售额 [[m:gmv]]。环比下降。[[see:m:gmv]]主要来自东区。连接的话。", catalog)
assert [u["text"] for u in uncited_claims(doc)] == ["主要来自东区。"]
assert all(set(u) == {"unit", "span", "text"} for u in uncited_claims(doc))