Files
AgentLab/backend/tests/test_report_masked_values.py
ThearchyHelios abb2707904 期 5 验收修复 N1:取自遮罩格的口径卡指标不进报告文档和可验证报告;显示文字核对堵住紧挨着改字的绕过
验收第二轮发现:口径卡 phone_raw = cell(q_cust, 0, 'phone') 被 [[m:phone_raw]] 引用时自查不拦,号码写进正文;写作目录、
文档的证据目录收着上游口径卡的全部指标和原值;可验证报告里口径卡的节点完成事件(预览逐条写着「指标(名称)= 值」)一律
附规范串,没有报告的运行也照样带出号码;另外只改紧挨着被引用数字的普通正文(前面补一位数、加负号、后面接「0 万」)、重算
全部哈希,读者看到的是另一个数,核对页只标无法核对,总览仍是绿的。

报告撰写节点
- 写作之前按证据面板指标步骤的那一套认出值取自遮罩格的指标(api/evidence.py 的 withheld_metrics → _metric_masks,
  recompute.withheld_value):cell() 取的遮罩格、改了名字的遮罩列、按位置取的一格一行、整份结果里带着遮罩值的。写作
  目录、存进文档的证据目录里这些指标的值、渲染、标签都写「已遮罩」并记 masked(evidence.withhold_metrics,没被引用的
  也一样),写作目录另说不要引用
- 只按出处认(by_value=False):认不出出处、值碰巧等于遮罩列某一格的普通指标(写死的目标 20000)不算,不然它在报告里
  成了「已遮罩」、引用它的正式运行判失败、写作者也改不掉(自查第 3 轮去掉按值认是同一个道理);证据面板展示上照旧多遮
- 引用这类指标照样解析、正文写「已遮罩」(换算不再算),自查记 masked_cell(evidence.masked_metrics),和引用遮罩格同一
  套处置:交回重写,仍不改按「违规处置」办;缺口、日志、判失败的下一步说「指标」
- 写成「已遮罩」的别名记进节点产出(masked_metrics),出口契约复核按同一份重建目录(report_catalog),正文里的「已遮罩」
  对得上,不说文档被改过;写作时按上游状态建目录,循环里重跑不沿用上一次记下的那份
- 证据面板点开这一处:值、渲染都是「已遮罩」,按文档写的比渲染,不报渲染不一致

可验证报告(bundle/build.py、verifier.js)
- 口径卡的节点完成事件:卡只给 id(有输入取自遮罩列,或者有指标的值不给)的只给哈希,和报告引不引用无关;没有台账的
  老运行按预览里的 artifact、节点产出工件认,都读不出来的按运行里有没有带遮罩的快照
- 卡只给 id 的判断另按指标认(_Store.metric_word,和写作目录同一个判断):整行、下标不是字面量的一格、整份结果转成的
  文字,以前只认字面量下标的一格,卡原样嵌了进去
- 证据目录里还写着这类原值的老文档(升级前生成的,或者生成之后数据源才设的遮罩)整份只给哈希、不附带它引用的工件,
  报告节点的完成事件也只给哈希;快照读不出来时按它的数据源现在设的遮罩认,卡读不出来时按这次运行查到过的遮罩值认。
  核对页照实说明原因(中性色),这份报告算未能核对,总览不是绿的;文档里写成「已遮罩」的指标所在的卡一律只给 id
- 被引用的数字紧挨着(中间没有空白、标点)数字、负号、小数点判不通过,点名「正文里紧挨着这个数的字改变了读者看到的
  数」;排在看不见、改变方向的字前面判,同一句里再放一个这种字也降不成无法核对。服务端组装的文档两个数之间补了空格,
  原样导出照旧全绿(开发数据里 39 份报告、505 处引用,没有一处紧挨着)

测试
- 新增 tests/test_report_masked_metrics.py(目录、自查、重写、标注 / 失败、出口契约、证据面板、循环)、
  tests/test_bundle_masked_metrics.py(没有报告的运行、新文档、老文档连同核对页、普通卡、和写作目录同一个判断、手搭的
  几条分支);样本页 masked-card 改成现在的报告撰写节点写的文档,新增 masked-old-doc;check-bundle 补数字前加数字、加
  负号、后面接「0 万」三种篡改,各自变红、点名,原有的紧挨着补位、连字符两条改成不通过,报告文档标成遮罩那条改成总览
  不是绿的
- test_report_masked_values 的 [[m:phone_raw]] 改成按出处拦、写死的热线号码照常引用;几处按旧的报告正文(「13位」
  「11位」「26位」)找片段的测试改成按引用找(test_evidence_metric_masks、test_evidence_masked_aliases、
  test_whatif_masked_values),断言照新的正文写
2026-10-12 17:23:27 +08:00

270 lines
14 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.
"""报告自查:只数个数的列不算取自遮罩列;正文里写着的值不按值认。
复核发现:
- 只数个数、只决定算不算进来的写法——COUNT(*) FILTER (WHERE phone IS NULL)、SUM(phone IS NULL)、AVG(phone IS NOT NULL)、
SUM(amount) FILTER (WHERE phone IS NOT NULL)——值里没有号码,可认遮罩列的那一套(masking._value_columns)把 FILTER 的
条件、汇总里的真假条件当成值的来历,自查跟着判 masked_cell,正式运行(默认违规即失败)因此判失败。等价的
SUM(CASE WHEN phone IS NULL THEN 1 ELSE 0 END) 本来就不算。现在自查认之前先把这几种写法改写成认得出的样子
(api/evidence.py 的 _counted),证据面板、推演照可验证报告的认法多遮(展示上多遮无妨)
- 第 2 轮另按值认过正文里写着的遮罩值(照抄的号码、姓名,经指标带出来的值),第 3 轮去掉了:两个字的姓名会撞上「销售
高峰」这类常用词,同名列的值会撞上门店名,正式运行因此判失败,代价远大于收益;照抄的号码本来就会被「数字没有出处」
拦下(test_report_masked_owners)。自查查 [[v:]] 引用,和值取自遮罩格的指标的 [[m:]] 引用(按出处认,和证据面板指标
步骤的值同一个判断),这里留着不按值认的回归
真跑的部分用虚构的 SQLite 商城库(号码、姓名是编的),写作者是 MockChatModel 照剧本写。
"""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.core import artifact_store
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 compose_doc
from app.engine.runner import run_manager
CUSTOMERS = [(1, "陈思远", "19900000701"), (2, "林若溪", "19900000702"), (3, "周以宁", "19900000703")]
ORDERS = [(1, 1, 120.5), (2, 2, 300.0), (3, 2, 80.0), (4, 3, 210.25), (5, 1, 99.5)]
PHONES = [c[2] for c in CUSTOMERS]
NAMES = [c[1] for c in CUSTOMERS]
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def set_source(path, options=None) -> str:
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "mall"))).scalar_one_or_none()
if row is None:
row = DataSource(name="mall", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
row.options = dict(options or {})
await session.commit()
return row.id
@pytest.fixture
async def mall(tmp_path, engine_up):
path = tmp_path / "mall.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);"
"CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount REAL);")
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", CUSTOMERS)
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", ORDERS)
db.commit()
db.close()
source_id = await set_source(path, {"mask_columns": ["phone"]})
await engines.invalidate(source_id)
yield path
await engines.invalidate(source_id)
def masked_of(violations: list[dict]) -> list[dict]:
return [v for v in violations if v["code"] == "masked_cell"]
# --------------------------------------------------------------------------
# 只数个数的列
# --------------------------------------------------------------------------
#: 值里没有号码的写法:条件只决定这一行算不算进来,汇总里的真假条件只数个数
COUNTING = [
"SELECT COUNT(*) FILTER (WHERE phone IS NULL) AS missing FROM customers",
"SELECT SUM(phone IS NULL) AS missing FROM customers",
"SELECT AVG(phone IS NOT NULL) AS missing FROM customers",
"SELECT SUM(LENGTH(phone) = 11) AS missing FROM customers",
"SELECT MAX((phone LIKE '199%')) AS missing FROM customers",
"SELECT SUM(o.amount) FILTER (WHERE c.phone IS NOT NULL) AS missing FROM orders o "
"JOIN customers c ON c.id = o.customer_id",
"SELECT COUNT(*) FILTER (WHERE phone IS NULL) OVER () AS missing FROM customers",
]
@pytest.mark.parametrize("sql", COUNTING)
async def test_counting_columns_are_not_surely_masked(engine_up, mall, sql):
"""自查不为它们判违规(sure 里没有);证据面板照可验证报告的认法多遮(hidden 里有,只影响展示)。"""
from app.api.evidence import _mask_sets, _Masks
sets = await _mask_sets({"sql": sql, "columns": ["missing"], "rows": [[1]], "source": "mall"}, "mall", _Masks())
assert sets.sure == set() and "missing" in sets.hidden, (sets.sure, sets.hidden)
@pytest.mark.parametrize("sql", [
# 值本身取自遮罩列的汇总、逐行的函数照旧算
"SELECT MAX(phone) AS missing FROM customers",
"SELECT SUBSTR(phone, 1, 3) AS missing FROM customers",
"SELECT MAX(phone) FILTER (WHERE id > 1) AS missing FROM customers",
# 逐行的真假条件(不汇总)照旧算:每一行是真是假本身就是这一行的信息,同逐行的 CASE
"SELECT phone IS NULL AS missing FROM customers",
])
async def test_values_taken_from_the_masked_column_stay_surely_masked(engine_up, mall, sql):
from app.api.evidence import _mask_sets, _Masks
sets = await _mask_sets({"sql": sql, "columns": ["missing"], "rows": [[1]], "source": "mall"}, "mall", _Masks())
assert sets.sure == {"missing"}, sets.sure
@pytest.mark.parametrize("sql, columns", [
# SQL 解析不了:认不出哪一列取自哪一列,面板整份遮,自查一列都不算(列名就是遮罩列的除外)
("SELECT name, phone AS p FROM customers WHERE ((", ["name", "p"]),
# 派生来源里没起别名的表达式:认遮罩列的那一套整份算,自查同样一列都不算
("SELECT name, amount FROM (SELECT c.name, UPPER(c.phone), o.amount FROM customers c "
"JOIN orders o ON o.customer_id = c.id) t", ["name", "amount"]),
])
async def test_columns_only_counted_because_the_sql_is_unclear_are_not_surely_masked(engine_up, mall, sql, columns):
from app.api.evidence import _mask_sets, _Masks
sets = await _mask_sets({"sql": sql, "columns": columns, "rows": [], "source": "mall"}, "mall", _Masks())
assert sets.sure == set() and set(columns) <= sets.hidden, (sets.sure, sets.hidden)
direct = await _mask_sets({"sql": "SELECT name, phone FROM customers WHERE ((", "columns": ["name", "phone"],
"rows": [], "source": "mall"}, "mall", _Masks())
assert direct.sure == {"phone"}, direct.sure
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes) -> dict:
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def finish(monkeypatch, spec: dict, text: str, payload: dict | None = None) -> Run:
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
run_id = (await run_manager.start(graph=spec, input_payload=payload or {})).id
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_id} 没跑完:{row.status}")
async def events(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
return list((await session.execute(q)).scalars())
def one_query(sql: str, **report) -> dict:
return chain(node("start", "input"), node("top", "tool", tool="db_query__mall", args={"sql": sql}),
node("write", "report", instructions="写一句", **report),
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]))
@pytest.mark.parametrize("sql", COUNTING[:2])
async def test_a_formal_report_citing_a_count_of_missing_phones_succeeds(monkeypatch, mall, sql):
"""违规即失败(正式运行的默认值):数据质量报告引用「手机号缺失的人数」照常出具,不判违规。"""
row = await finish(monkeypatch, one_query(sql, on_violation="fail"), "手机号缺失的客户有 [[v:Q1.r0.missing]] 位。")
assert row.status == "succeeded", row.error
checked = next(e.data for e in await events(row.id) if e.type == "report.checked")
assert masked_of(checked["violations"]) == [] and checked["ok"] is True, checked["violations"]
# --------------------------------------------------------------------------
# 正文里写着的值不按值认
# --------------------------------------------------------------------------
async def masked_catalog(sql: str, columns: list[str], rows: list[list]) -> dict:
art = await artifact_store.put_json({"columns": columns, "rows": rows, "source": "mall", "sql": sql}, kind="query")
return {"Q1": {"alias": "Q1", "kind": "query", "artifact": art, "columns": columns, "rows": len(rows),
"node_id": "top", "source": "mall"}}
SQL_TOP = "SELECT c.name AS customer, c.phone AS tel, 1 AS spend FROM customers c"
TOP_ROWS = [[NAMES[1], PHONES[1], 380.0], [NAMES[0], PHONES[0], 220.0]]
@pytest.mark.parametrize("text", [
f"消费最高的客户电话是 {PHONES[1]}。",
"消费最高的客户电话是 199-0000-0702。",
f"消费最高的客户电话是 +86 {PHONES[1][:3]} {PHONES[1][3:7]} {PHONES[1][7:]}。",
])
async def test_a_value_copied_into_the_text_is_not_a_masked_cell(engine_up, mall, text):
"""写作者把号码照抄成正文(没写成引用):自查不按值认,不记 masked_cell;裸数字由数字核对记 uncited_number。"""
from app.engine.nodes.report import _MaskedCells
catalog = await masked_catalog(SQL_TOP, ["customer", "tel", "spend"], TOP_ROWS)
doc = await _MaskedCells(catalog)(compose_doc(text, catalog, node_id="write", run_id="r1"))
assert masked_of(doc["violations"]) == [] and any(v["code"] == "uncited_number" for v in doc["violations"])
async def test_a_name_copied_as_text_is_not_flagged(engine_up, mall):
"""文字类的遮罩列(姓名)照抄成正文:同样不按值认(两个字的姓名会撞上常用词)。"""
from app.engine.nodes.report import _MaskedCells
await set_source(mall, {"mask_columns": ["name"]})
catalog = await masked_catalog(SQL_TOP, ["customer", "tel", "spend"], TOP_ROWS)
doc = await _MaskedCells(catalog)(compose_doc(f"消费最高的是{NAMES[1]},花了 [[v:Q1.r0.spend]]。", catalog,
node_id="write", run_id="r1"))
assert masked_of(doc["violations"]) == [], doc["violations"]
async def test_a_cited_masked_cell_is_flagged_once(engine_up, mall):
"""[[v:Q1.r0.tel]]:按引用记一条,没有按值另记的那一类(by_value)。"""
from app.engine.nodes.report import _MaskedCells
catalog = await masked_catalog(SQL_TOP, ["customer", "tel", "spend"], TOP_ROWS)
doc = await _MaskedCells(catalog)(compose_doc("电话 [[v:Q1.r0.tel]]。", catalog, node_id="write", run_id="r1"))
[v] = masked_of(doc["violations"])
assert v["ref"] == "v:Q1.r0.tel" and "by_value" not in v, v
async def test_no_snapshot_is_read_without_a_cited_cell(engine_up, mall, monkeypatch):
"""正文没引用格子:数据源设了遮罩也一份快照都不读(以前按值认要把设了遮罩的快照都读一遍)。"""
from app.core import artifact_store as store
from app.engine.nodes.report import _MaskedCells
catalog = await masked_catalog(SQL_TOP, ["customer", "tel", "spend"], TOP_ROWS)
read: list[str] = []
real = store.load
monkeypatch.setattr(store, "load", lambda a: read.append(a) or real(a))
await _MaskedCells(catalog)(compose_doc(f"一共三位客户,电话 {PHONES[0]}。", catalog, node_id="write", run_id="r1"))
assert read == [], read
METRICS = [
{"id": "phone_raw", "name": "首位客户手机号", "format": "plain", "expression": "cell(nodes.top, 0, 'tel')"},
{"id": "id3", "name": "第三位客户编号", "format": "plain", "expression": "cell(nodes.top, 2, 'id')"},
# 写死在口径卡上的热线号码,碰巧和第三位客户的号码一样:不取自查询结果
{"id": "hotline", "name": "服务热线", "format": "plain", "expression": f"'{PHONES[2]}'"},
]
def card_graph(**report) -> dict:
return chain(node("start", "input"),
node("top", "tool", tool="db_query__mall",
args={"sql": "SELECT id, name AS customer, phone AS tel FROM customers ORDER BY id"}),
node("card", "metrics", caliber="遮罩对照", caliber_version="v1", metrics=METRICS),
node("write", "report", instructions="写一句", **report),
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]))
async def test_a_metric_reference_is_judged_by_where_its_value_comes_from(monkeypatch, mall):
"""[[m:phone_raw]] 的值取自改了名字的遮罩列(phone AS tel):和证据面板指标步骤的值同一个判断,写作目录、证据目录里写
「已遮罩」,引用了记 masked_cell,违规即失败的运行判失败(tests/test_report_masked_metrics.py)。这是按出处认,不是按值:
写死在卡上的热线号码碰巧和客户的号码一样,不取自查询结果,照常引用,运行照常成功、时间线上没有「引用了遮罩列」那一行。"""
row = await finish(monkeypatch, card_graph(on_violation="fail"), "第三位客户编号 [[m:id3]],首位客户电话 [[m:phone_raw]]。")
assert row.status == "failed" and "m:phone_raw" in (row.error or ""), row.error
row = await finish(monkeypatch, card_graph(on_violation="fail"), "第三位客户编号 [[m:id3]],服务热线 [[m:hotline]]。")
assert row.status == "succeeded", row.error
rows = await events(row.id)
checked = next(e.data for e in rows if e.type == "report.checked")
assert masked_of(checked["violations"]) == [], checked["violations"]
assert not [e for e in rows if e.type == "log" and e.data.get("code") == "report_masked_cell"]