Files
AgentLab/backend/tests/test_report_masked_cells.py
T
ThearchyHelios 65d6a1e63c M2 复核第 3 轮:报告自查只查引用、别的表里同名的列不算;裁判摘录退回只按数据源设的遮罩列;收遮罩值只收确定取自遮罩列的值;遮罩分析和收到的值按快照工件记在进程里
第 2 轮的按值自查和面板那套宁可多遮的认法带进了误判,正式运行因此失败,代价远大于收益,这一轮回到规格原意:

- 报告自查去掉按值在正文里找遮罩值(engine/evidence.py 的 masked_values、report.py 的收值):两个字的姓名会撞上「销售
  高峰」这类常用词,同名列的值会撞上门店名;照抄成正文的号码本来就会被「数字没有出处」拦下。时间线日志 report_masked_cell
  不再带 written,decode.ts、terms.ts、证据审计的 CSV 回到「引用了遮罩列」一种说法,check-decode 同步
- 自查认引用时从 _mask_sets 的 sure 起,再按 SQL 血缘(sqlglot.lineage,按快照里冻结的表结构)核一遍值取自哪张表的哪一列
  (api/evidence.py 的 _cited_masks / _owned_columns):遮罩列不带表名,同名的列在几张表里都有、其中有的表另有别的遮罩列时
  (customers 有 name 和 phone,stores 只有 name),只认那几张表,门店名不算客户姓名;值根本不取自遮罩列的(region AS
  phone)也不算。认不出出处的照算,没探查过表结构的照列名认。违规说明点名它取自的那一列遮罩列
- 交给裁判的摘录退回 M2 之前的做法,只按数据源设的列名遮(删掉 _judge_masks):并进面板那套宁可多遮的列后,整份遮的快照、
  只数个数的列从摘录里消失,被引用的金额、人数裁判看不到。按需裁判和节点里当场裁判都改回
- 证据面板、推演收「这次运行查到过的遮罩值」只收 sure 的列(_snapshot_secrets,同 masking 收值的口径):SQL 认不准、整份
  遮的快照只遮显示,里面的区域名、金额不再收成遮罩值
- 按快照工件 id 在进程里记下遮罩分析和收到的值(_SnapshotMemo,键里带数据源现在设的遮罩、在不在、类型,设置改了不拿旧的;
  只记读得出来的快照,存取加锁,分析结果冻结、交出去的值另抄一份):之后点开片段、推演都不再把全部快照读盘、复验、分析,
  只读这一步要显示的那一份。30 份 500 行的快照,第一次约 0.7 秒,之后不到 1 毫秒

测试:新增 test_report_masked_owners.py(高峰、门店名写成文字照常出具,照抄号码由数字核对拦下,引用门店名不算、引用客户名算,
同一条查询里两张表的同名列、只设 name、* 展开、WITH、UNION、没有表结构、表值函数)、test_evidence_mask_memo.py(整份遮
的快照不收值、只读一次只分析一次、设置改了重新收、推演共用、再点开片段只读本步快照);裁判摘录改测被引用的金额和计数
都在摘录里;按值自查的用例改成不按值认的回归
2026-10-12 12:33:32 +08:00

268 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.
"""报告自查认得出引用了取自遮罩列的格子:像没有出处的数字一样记违规、交回写作者重写,仍不改的按「违规处置」办。
验收发现:写作要求点名引用 [[v:Q2.r0.tel]](Q2 的 SQL 是 c.phone AS tel,phone 设了遮罩),报告自查判 ok、violations 为空,
正文写出号码,导出的可验证报告也把它带了出去,从发起到导出没有一步提示。写作目录不遮罩是已知设计(写作者要看得到数据),
所以拦在自查:
- 组装文档时(compose_doc 的 masked:{查询快照工件: 取自遮罩列的列}),引用了这些列的格子记一条 masked_cell,列名对上
的、按 SQL 认出改了名字的都算(报告节点按 api/evidence.py 的 _cited_masks 算,和证据面板同一个 _mask_sets 起步)
- 违规里不带那一格的值(不写 text、context):违规会进 report.checked、重写的日志、出具缺口,这些地方不能再露一次号码
- 报告节点把它交回写作者重写(不是只标注的那几类);重写后还在:「违规处置」为失败的判失败,为标注的照常产出,
时间线上另起一行说清(report_masked_cell),节点产出里写一条出具缺口,出口降档
- 没设遮罩、引用的是别的列:一切照旧
真跑的部分用虚构的 SQLite 商城库(号码是编的),写作者是 MockChatModel 照剧本写。
"""
from __future__ import annotations
import asyncio
import json
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 import review as rv
from app.engine.evidence import compose_doc, describe_violations, iter_segments
from app.engine.runner import run_manager
# --------------------------------------------------------------------------
# 组装文档:compose_doc 的 masked
# --------------------------------------------------------------------------
ROWS = [["客户乙", "199-0000-0102", 380.0], ["客户甲", "199-0000-0101", 220.0]]
SECRET = [r[1] for r in ROWS]
async def catalog_of() -> tuple[dict, str]:
art = await artifact_store.put_json({"columns": ["customer", "tel", "spend"], "rows": ROWS, "source": "mall",
"sql": "SELECT c.name AS customer, c.phone AS tel, 1 AS spend FROM customers c",
"mask_columns": ["phone"]}, kind="query")
entry = {"alias": "Q1", "kind": "query", "artifact": art, "columns": ["customer", "tel", "spend"], "rows": 2,
"node_id": "top", "source": "mall"}
return {"Q1": entry}, art
def masked_of(doc: dict) -> list[dict]:
return [v for v in doc["violations"] if v["code"] == "masked_cell"]
async def test_a_cited_masked_cell_is_a_violation_without_its_value():
catalog, art = await catalog_of()
doc = compose_doc("消费最高的是 [[v:Q1.r0.customer]],电话 [[v:Q1.r0.tel]],花了 [[v:Q1.r0.spend]]。", catalog,
node_id="write", run_id="r1", masked={art: {"phone", "tel"}})
[v] = masked_of(doc)
seg = next(s for s in iter_segments(doc) if s.get("ref") == "v:Q1.r0.tel")
assert (v["ref"], v["column"], v["segment"]) == ("v:Q1.r0.tel", "tel", seg["id"]) and v["unit"] and v["span"]
assert "遮罩" in v["message"] and "「tel」" in v["message"] and "删掉" in v["for_model"]
# 违规本身不再写一次号码:它会进事件、日志和出具缺口
assert not [p for p in SECRET if p in json.dumps(v, ensure_ascii=False)]
assert "text" not in v and "context" not in v
assert not [p for p in SECRET if p in describe_violations(masked_of(doc))]
async def test_without_masks_or_for_other_columns_nothing_is_flagged():
catalog, art = await catalog_of()
text = "电话 [[v:Q1.r0.tel]],花了 [[v:Q1.r0.spend]]。"
assert masked_of(compose_doc(text, catalog, node_id="write", run_id="r1")) == []
assert masked_of(compose_doc(text, catalog, node_id="write", run_id="r1", masked={})) == []
assert masked_of(compose_doc("花了 [[v:Q1.r0.spend]]。", catalog, node_id="write", run_id="r1",
masked={art: {"phone", "tel"}})) == []
async def test_a_whole_table_reports_every_masked_cell():
"""整表引用展开成每格一个 [[v:]]:遮罩列的每一格各记一条,写作者看得到要去掉哪一列。"""
catalog, art = await catalog_of()
doc = compose_doc("前两名:\n\n[[table:Q1]]\n", catalog, node_id="write", run_id="r1", masked={art: {"tel"}})
assert [v["ref"] for v in masked_of(doc)] == ["v:Q1.r0.tel", "v:Q1.r1.tel"]
assert all("cols=" in v["for_model"] for v in masked_of(doc))
def test_the_masked_cell_code_is_a_degraded_signal_of_its_own_kind():
"""复核层认得这条日志:报告照常产出、有缺口,按它自己的 kind 记,不落成笼统的 warn。"""
assert rv.WARN_CODES["report_masked_cell"] == ("report_masked_cell", rv.DEGRADED)
warn = {"type": "log", "node_id": "write",
"data": {"level": "warn", "code": "report_masked_cell", "message": "报告引用了取自遮罩列的格子"}}
[signal] = rv.scan([warn], {"报告": "首位客户已联系"})
assert (signal.kind, signal.severity) == ("report_masked_cell", rv.DEGRADED)
# --------------------------------------------------------------------------
# 真跑:报告节点交回重写、按违规处置办、时间线和出具缺口说清
# --------------------------------------------------------------------------
CUSTOMERS = [(1, "客户甲", "199-0000-0101"), (2, "客户乙", "199-0000-0102"), (3, "客户丙", "199-0000-0103")]
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]
SQL_TOP = ("SELECT c.name AS customer, c.phone AS tel, ROUND(SUM(o.amount), 2) AS spend "
"FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY c.id, c.name, c.phone ORDER BY spend DESC")
CITES_TEL = "消费最高的客户是 [[v:Q1.r0.customer]],联系电话 [[v:Q1.r0.tel]],共消费 [[v:Q1.r0.spend]]。"
CLEAN = "消费最高的客户是 [[v:Q1.r0.customer]],共消费 [[v:Q1.r0.spend]]。"
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def set_source(path, options=None):
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 node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(*, sql: str = SQL_TOP, contract: dict | None = None, **report) -> dict:
out = {"fields": [{"name": "报告", "value": "{{ nodes.write.text }}"}]}
if contract is not None:
out["contract"] = contract
nodes = [
node("start", "input"),
node("top", "tool", tool="db_query__mall", args={"sql": sql}),
node("write", "report", instructions="写一句,联系电话写 [[v:Q1.r0.tel]]", **report),
node("out", "output", **out),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def script(monkeypatch, *replies) -> list[list]:
"""写作者依次回这几段,用完了一直回最后一段。返回每次调用收到的消息。"""
from app.providers import mock_model
seen: list[list] = []
def _decide(self, messages):
seen.append(list(messages))
return AIMessage(content=replies[min(len(seen), len(replies)) - 1])
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
return seen
async def finish(spec: dict) -> Run:
run_id = (await run_manager.start(graph=spec, input_payload={})).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, etype: str | None = None) -> list[RunEvent]:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id)
if etype:
q = q.where(RunEvent.type == etype)
return list((await session.execute(q.order_by(RunEvent.seq))).scalars())
def logs(rows: list[RunEvent], code: str) -> list[dict]:
return [e.data for e in rows if e.type == "log" and e.data.get("code") == code]
def leaks(value) -> list[str]:
raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return [p for p in PHONES if p in raw]
async def test_the_writer_is_asked_to_rewrite_and_the_rewrite_passes(monkeypatch, mall):
"""第一稿引用了 tel(phone 改的名字):自查记违规、交回重写,重写稿不引用就通过。重写请求和日志里不再写号码。"""
seen = script(monkeypatch, CITES_TEL, CLEAN)
row = await finish(graph())
assert row.status == "succeeded", row.error
[checked] = await events(row.id, "report.checked")
assert checked.data["ok"] is True and checked.data["repairs"] == 1, checked.data
repair = str(seen[1][-1].content)
assert "tel" in repair and "遮罩" in repair and leaks(repair) == []
[log] = logs(await events(row.id), "report_repair")
assert "v:Q1.r0.tel" in log["message"] and leaks(log) == []
async def test_a_writer_that_insists_is_flagged_and_the_issuance_says_why(monkeypatch, mall):
"""重写后还引用:「违规处置」为标注时照常产出,自查结果、时间线、出具缺口都说清,这几处都不再写一次号码。"""
script(monkeypatch, CITES_TEL)
row = await finish(graph(on_violation="flag", contract={"report_from": "write"}))
assert row.status == "succeeded", row.error
rows = await events(row.id)
[checked] = [e.data for e in rows if e.type == "report.checked"]
assert checked["ok"] is False and checked["repairs"] == 1 and not checked["failed"]
[v] = [v for v in checked["violations"] if v["code"] == "masked_cell"]
assert v["ref"] == "v:Q1.r0.tel" and v["column"] == "tel" and leaks(v) == []
[log] = logs(rows, "report_masked_cell")
assert log["count"] == 1 and log["refs"] == ["v:Q1.r0.tel"] and log["columns"] == ["tel"] and log["level"] == "warn"
assert "重写 1 次" in log["message"] and leaks(log) == []
finished = next(e.data for e in rows if e.type == "node.finished" and e.node_id == "write")
payload = artifact_store.load(finished["artifact"])
[gap] = payload["issuance_gaps"]
assert "遮罩" in gap and "v:Q1.r0.tel" in gap and leaks(gap) == []
issuance = row.output["_issuance"]
assert issuance["tier"] == "degraded" and any(g.startswith("报告撰写「write」:") and "遮罩" in g
for g in issuance["gaps"]), issuance["gaps"]
assert leaks(issuance["gaps"]) == []
async def test_a_writer_that_insists_fails_the_node_when_violations_fail(monkeypatch, mall):
"""「违规处置」为失败:重写后还引用就判失败,原因说是遮罩列,不写号码。"""
script(monkeypatch, CITES_TEL)
row = await finish(graph(on_violation="fail"))
assert row.status == "failed" and "遮罩" in (row.error or "") and "tel" in row.error, row.error
assert leaks(row.error) == []
[checked] = await events(row.id, "report.checked")
assert checked.data["failed"] is True
assert logs(await events(row.id), "report_masked_cell") == [], "节点判失败时不另起「照常产出」的那一行"
async def test_the_masked_column_itself_is_flagged_too(monkeypatch, mall):
"""列名直接就是遮罩列(没起别名)的也算。"""
script(monkeypatch, "首位客户 [[v:Q1.r0.name]],电话 [[v:Q1.r0.phone]]。")
row = await finish(graph(sql="SELECT name, phone FROM customers ORDER BY id", on_violation="flag"))
[checked] = await events(row.id, "report.checked")
assert [v["ref"] for v in checked.data["violations"] if v["code"] == "masked_cell"] == ["v:Q1.r0.phone"]
async def test_without_masks_the_report_is_unchanged(monkeypatch, mall):
await set_source(mall, {})
script(monkeypatch, CITES_TEL)
row = await finish(graph(on_violation="flag", contract={"report_from": "write"}))
assert row.status == "succeeded", row.error
[checked] = await events(row.id, "report.checked")
assert checked.data["ok"] is True and checked.data["repairs"] == 0
assert logs(await events(row.id), "report_masked_cell") == []
finished = next(e.data for e in await events(row.id, "node.finished") if e.node_id == "write")
assert "issuance_gaps" not in artifact_store.load(finished["artifact"])
assert row.output["_issuance"]["tier"] == "formal" or not any("遮罩" in g for g in row.output["_issuance"]["gaps"])