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

361 lines
18 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.
"""出口契约(引用模式)按报告节点的 claims 和可疑实体判档。
- claims: require_citation:没挂依据的结论句计入缺口,按 decide_tier 的 claims_policy 降档(不是不予出具,
strict 也一样);数的是出口重新核对出来的(verify_doc 的 uncited),不信文档里记的 cites。off(默认)
照旧不管。升级前发起的运行不管图里写没写,一律按 off
- 可疑实体(unknown_entity):只在受管级别的正式运行里按「有缺口」降档,不拦截;探索运行、已发布级别
只在 _issuance.entities 里标注。表结构快照不全时核对不了的名字(unverified_entity)在哪都只标注
- 契约自己写 claims: require_citation 的同样生效;写 judge 的仍是后续版本,照实记缺口
- 契约只能收紧、不能放松:报告节点要求了,契约写 ignore / off 照旧降档,写 withhold 收紧成不予出具;
受管级别的正式运行里 ignore 一律当 degrade(没有哪道门禁查契约里的 claims)
"""
from __future__ import annotations
import asyncio
import sqlite3
import uuid
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent, Workflow, WorkflowVersion
from app.engine import runner as runner_mod
from app.engine.runner import run_manager
from app.main import app
SOURCE = "warehouse"
TOOL = f"db_query__{SOURCE}"
SQL = "SELECT region, SUM(amount) AS gmv, COUNT(*) AS order_cnt FROM orders GROUP BY region ORDER BY gmv DESC"
CLEAN = "东区销售额 [[m:gmv]],来自 `orders`。[[see:m:gmv]]"
UNCITED = CLEAN + "增长主要来自新客。"
SUSPICIOUS = "东区销售额 [[m:gmv]],来自 `orders`,`refund_log` 另算。[[see:m:gmv]]"
@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
async def _warehouse(tmp_path, *, truncated: bool = False):
from app.data.introspect import introspect
path = tmp_path / "warehouse.db"
db = sqlite3.connect(path)
db.executescript(
"CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, amount REAL);"
"CREATE TABLE refunds (id INTEGER PRIMARY KEY, order_id INTEGER, amount REAL);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "east", 100.5), (2, "west", 200.0), (3, "east", 300.0)])
db.commit()
db.close()
probe = SimpleNamespace(id=f"probe-{path.name}", name=SOURCE, kind="sqlite", database=str(path), host=None,
port=None, username=None, password=None, options={}, readonly=True, schema_cache={})
cache = await introspect(probe)
if truncated:
# 库里的表太多、探查只存了一部分:快照不全,找不到的名字只能说核对不了
cache.update(truncated=True, total=500)
await engines.invalidate(probe.id)
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none()
if row is None:
row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.schema_cache = str(path), "sqlite", True, True, cache
row.options = {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
return source_id
@pytest.fixture
async def warehouse(tmp_path):
source_id = await _warehouse(tmp_path)
yield source_id
await engines.invalidate(source_id)
@pytest.fixture
async def partial_warehouse(tmp_path):
source_id = await _warehouse(tmp_path, truncated=True)
yield source_id
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(*, report=None, contract=None):
nodes = [
node("start", "input"),
node("fetch", "tool", tool=TOOL, args={"sql": SQL}),
node("card", "metrics", caliber="周报口径", caliber_version="v2", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands",
"expression": "cell(nodes.fetch, 0, 'gmv')"}]),
node("write", "report", instructions="写周报", on_violation="flag", max_repairs=0, **(report or {})),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True,
**(contract or {})}),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def script(monkeypatch, text):
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
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 explore(graph) -> dict:
run = await run_manager.start(graph=graph, input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
return row.output["_issuance"]
async def formal(client, graph, *, status: str) -> Run:
"""把这张图发布成 status 级别,再从发布版本发起正式运行。"""
async with SessionLocal() as session:
wf = Workflow(name=f"claims-{status}-{uuid.uuid4().hex[:6]}", graph=graph, status=status, published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph))
await session.commit()
wf_id = wf.id
res = await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"})
assert res.status_code == 201, res.text
row = await wait(res.json()["id"])
assert row.status == "succeeded", row.error
return row
async def issuance_event(run_id: str) -> dict:
async with SessionLocal() as session:
[event] = (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "issuance"))).scalars()
return event.data
# --------------------------------------------------------------------------
# claims
# --------------------------------------------------------------------------
async def test_require_citation_degrades_uncited_claims(warehouse, monkeypatch):
script(monkeypatch, UNCITED)
issuance = await explore(weekly(report={"claims": "require_citation"}))
assert issuance["tier"] == "degraded", "strict 下也只是降档:结论句没挂依据不是数没有出处"
claims = issuance["claims"]
assert claims["policy"] == "require_citation" and claims["uncited_claims"] == 1
[item] = claims["uncited"]
assert item["text"] == "增长主要来自新客。" and item["unit"] and len(item["span"]) == 2
assert issuance["unmatched_numbers"] == [] and issuance["unresolved"] == []
assert not any("结论句" in g for g in issuance["gaps"]), "判了就不是「没核」"
async def test_require_citation_with_every_claim_cited_is_formal(warehouse, monkeypatch):
script(monkeypatch, CLEAN)
issuance = await explore(weekly(report={"claims": "require_citation"}))
assert issuance["tier"] == "formal"
assert issuance["claims"] == {"policy": "require_citation", "uncited_claims": 0, "uncited": []}
async def test_claims_off_changes_nothing(warehouse, monkeypatch):
script(monkeypatch, UNCITED)
issuance = await explore(weekly())
assert issuance["tier"] == "formal" and "claims" not in issuance
async def test_an_auto_linked_name_does_not_count_as_a_citation(warehouse, monkeypatch):
"""只提到表名的结论句仍算没挂依据:自动链接的名字是系统加的标注,不是写作者给的出处。"""
script(monkeypatch, CLEAN + "增长主要来自 `orders` 里的新客。")
issuance = await explore(weekly(report={"claims": "require_citation"}))
assert issuance["tier"] == "degraded" and issuance["claims"]["uncited_claims"] == 1
async def test_runs_from_before_the_upgrade_ignore_claims(warehouse, monkeypatch):
async def no_snapshot(session):
return None
monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot)
script(monkeypatch, UNCITED)
issuance = await explore(weekly(report={"claims": "require_citation"}))
assert issuance["tier"] == "formal" and "claims" not in issuance and "entities" not in issuance
async def test_the_contract_can_ask_for_citations_too(warehouse, monkeypatch):
script(monkeypatch, UNCITED)
issuance = await explore(weekly(contract={"claims": {"policy": "require_citation", "on_uncited": "withhold"}}))
assert issuance["tier"] == "withheld" and issuance["claims"]["uncited_claims"] == 1
script(monkeypatch, CLEAN)
issuance = await explore(weekly(contract={"claims": "require_citation"}))
assert issuance["tier"] == "formal" and not any("结论句" in g for g in issuance["gaps"])
LOOSE = {"claims": {"policy": "require_citation", "on_uncited": "ignore"}}
async def test_the_contract_can_tighten_the_report_policy_but_never_loosen_it(warehouse, monkeypatch):
"""报告节点写了 require_citation:契约写 withhold 能收紧成不予出具;写 ignore、写 off 都放不松,照旧降档。"""
script(monkeypatch, UNCITED)
asks = {"claims": "require_citation"}
issuance = await explore(weekly(report=asks, contract=LOOSE))
assert issuance["tier"] == "degraded", "契约的 ignore 不能把报告节点要求的缺口抹掉"
assert issuance["claims"]["uncited_claims"] == 1 and "on_uncited" not in issuance["claims"]
issuance = await explore(weekly(report=asks, contract={"claims": "off"}))
assert issuance["tier"] == "degraded" and issuance["claims"]["uncited_claims"] == 1
issuance = await explore(weekly(report=asks, contract={"claims": {"policy": "require_citation",
"on_uncited": "withhold"}}))
assert issuance["tier"] == "withheld" and issuance["claims"]["on_uncited"] == "withhold"
async def test_ignore_cannot_lower_the_governed_floor(client, warehouse, monkeypatch):
"""受管级别的正式运行:契约写 on_uncited: ignore 不算数(没有哪道门禁查契约里的 claims),没挂依据的结论句照样
计入缺口、降一档。"""
script(monkeypatch, UNCITED)
row = await formal(client, weekly(report={"claims": "require_citation"}, contract=LOOSE), status="governed")
issuance = row.output["_issuance"]
assert issuance["tier"] == "degraded" and issuance["gaps"] == [], issuance
assert issuance["claims"]["uncited_claims"] == 1 and "on_uncited" not in issuance["claims"]
assert (await issuance_event(row.id))["claims"] == {"policy": "require_citation", "uncited_claims": 1}
# 报告节点没写(门禁之前发布的版本)、只有契约写了 ignore:受管级别同样不认 ignore
row = await formal(client, weekly(contract=LOOSE), status="governed")
assert row.output["_issuance"]["tier"] == "degraded"
async def test_outside_governed_a_contract_may_ignore_its_own_policy(client, warehouse, monkeypatch):
"""报告节点没要求时,契约自己写的 ignore 在探索运行、已发布级别照旧生效:不计入缺口,照实记下 ignore。"""
script(monkeypatch, UNCITED)
issuance = await explore(weekly(contract=LOOSE))
assert issuance["tier"] == "formal" and issuance["claims"]["on_uncited"] == "ignore"
row = await formal(client, weekly(contract=LOOSE), status="published")
assert row.output["_issuance"]["tier"] == "formal"
assert (await issuance_event(row.id))["claims"] == {"policy": "require_citation", "uncited_claims": 1,
"on_uncited": "ignore"}
@pytest.mark.parametrize(("written", "declared", "governed", "effect"), [
("require_citation", None, False, "degrade"),
("require_citation", "off", False, "degrade"),
("require_citation", {"policy": "require_citation", "on_uncited": "ignore"}, False, "degrade"),
("require_citation", {"policy": "require_citation", "on_uncited": "degrade"}, False, "degrade"),
("require_citation", {"policy": "require_citation", "on_uncited": "withhold"}, False, "withhold"),
("require_citation", {"policy": "require_citation", "on_uncited": "bogus"}, False, "degrade"),
("off", "require_citation", False, "degrade"),
("off", {"policy": "require_citation", "on_uncited": "withhold"}, True, "withhold"),
("off", {"policy": "require_citation", "on_uncited": "ignore"}, False, "ignore"),
("off", {"policy": "require_citation", "on_uncited": "ignore"}, True, "degrade"),
("off", {"policy": "require_citation", "on_uncited": "bogus"}, False, "degrade"),
("off", None, True, None),
("off", "off", True, None),
])
def test_the_effective_claims_policy_is_the_stricter_one(written, declared, governed, effect):
"""报告节点记下的策略和契约写的取更严的那个;受管级别 ignore 当 degrade;认不出的写法不放松(当 degrade)。"""
from app.engine.issuance import decide_tier
from app.engine.nodes.io import _claims
out = {"gaps": [], "claims_policy": None, "uncited": None, "claims": None}
uncited = [{"unit": "u1", "span": [0, 4], "text": "增长很快"}]
_claims(out, {"claims": declared} if declared is not None else {}, {"text": "x", "claims": written},
evidence_on=True, governed=governed, uncited=uncited)
assert out["gaps"] == []
if effect is None:
assert out["claims_policy"] is None and out["claims"] is None
return
assert out["claims"]["uncited_claims"] == 1 and out["claims"].get("on_uncited", "degrade") == effect
tier = decide_tier(missing_required=[], missing_expected=[], unmatched=[], strict=True, gaps=[],
uncited_claims=out["uncited"], claims_policy=out["claims_policy"])
assert tier == {"degrade": "degraded", "withhold": "withheld", "ignore": "formal"}[effect]
def test_a_run_in_flight_across_the_upgrade_keeps_the_old_gap():
"""升级前写的报告(产出里没有 claims)碰上写了 require_citation 的契约:照旧只记一条缺口,不判档。"""
from app.engine.nodes.io import _claims
out = {"gaps": [], "claims_policy": None, "uncited": None, "claims": None}
_claims(out, {"claims": "require_citation"}, {"text": "x"}, evidence_on=True, governed=False,
uncited=[{"unit": "u1", "span": [0, 4], "text": "增长很快"}])
assert out["claims_policy"] is None and out["claims"] is None
assert len(out["gaps"]) == 1 and "生成于系统升级前" in out["gaps"][0]
# --------------------------------------------------------------------------
# 可疑实体
# --------------------------------------------------------------------------
async def test_suspicious_names_are_only_annotated_in_exploratory_runs(warehouse, monkeypatch):
script(monkeypatch, SUSPICIOUS)
issuance = await explore(weekly())
assert issuance["tier"] == "formal" and issuance["gaps"] == []
[fake] = issuance["entities"]["unknown"]
assert fake["name"] == "refund_log" and fake["segment"] and fake["unit"]
assert issuance["entities"]["unverified"] == [] and issuance["entities"]["counted"] is False
async def test_suspicious_names_degrade_governed_formal_runs(client, warehouse, monkeypatch):
script(monkeypatch, SUSPICIOUS)
row = await formal(client, weekly(), status="governed")
issuance = row.output["_issuance"]
assert issuance["tier"] == "degraded", "按缺口降档,不是不予出具(契约是 strict 的)"
assert any("refund_log" in g and "可疑" in g for g in issuance["gaps"]), issuance["gaps"]
assert issuance["entities"]["counted"] is True and issuance["entities"]["unknown"][0]["name"] == "refund_log"
event = await issuance_event(row.id)
assert event["tier"] == "degraded" and event["entities"] == {"unknown": 1, "unverified": 0, "counted": True}
async def test_published_formal_runs_only_annotate(client, warehouse, monkeypatch):
script(monkeypatch, SUSPICIOUS)
issuance = (await formal(client, weekly(), status="published")).output["_issuance"]
assert issuance["tier"] == "formal" and issuance["gaps"] == []
assert issuance["entities"]["unknown"][0]["name"] == "refund_log" and issuance["entities"]["counted"] is False
async def test_names_that_cannot_be_checked_are_only_annotated_even_when_governed(client, partial_warehouse,
monkeypatch):
script(monkeypatch, SUSPICIOUS)
issuance = (await formal(client, weekly(), status="governed")).output["_issuance"]
assert issuance["tier"] == "formal" and issuance["gaps"] == []
assert issuance["entities"]["unknown"] == []
assert [u["name"] for u in issuance["entities"]["unverified"]] == ["refund_log"]
async def test_the_exit_says_the_report_turned_entities_off(warehouse, monkeypatch):
"""entities: off 的报告写了 [[t:]]:出口复核和报告节点一样照实说是这个节点关掉的,不说成「后续版本支持」。"""
from app.engine.evidence import ENTITIES_OFF_REASON, LATER_REASON
script(monkeypatch, "东区销售额 [[m:gmv]],见 [[t:orders]]。[[see:m:gmv]]")
issuance = await explore(weekly(report={"entities": "off"}))
[unresolved] = issuance["unresolved"]
assert unresolved["ref"] == "t:orders" and ENTITIES_OFF_REASON in unresolved["message"]
assert LATER_REASON not in unresolved["message"] and "entities" not in issuance
async def test_clean_reports_carry_no_entity_annotation(warehouse, monkeypatch):
script(monkeypatch, CLEAN)
issuance = await explore(weekly())
assert issuance["tier"] == "formal" and "entities" not in issuance