Files
AgentLab/backend/tests/test_masked_metric_values.py
T
ThearchyHelios ce4be9cc8f M2 复核第 2 轮:报告自查按值认出正文里写着的遮罩值,只数个数的列不再判违规;遮没遮核对不了的输入写「无法核对」;整句面板挂的依据按证据图的标签写
- 自查按值认(engine/evidence.py 的 masked_values,report.py 的 _MaskedCells):写作目录里确定取自遮罩列的值出现在正文里——照抄成文字(姓名这类文字列数字核对管不到)、写成数字(换了写法的长号码组装时被拆成几个数字片段,按整句认)、经指标带出来([[m:phone_raw]] 的值就是号码)——记一条 masked_cell(by_value),不带值,交回写作者时附上值换成「已遮罩」的那一句。只收确定取自遮罩列的列、不收分档标签;运行输入里写着的值是发起运行的人自己填的,不算。只读数据源现在设了遮罩的快照,认引用时读过的不再读,没设遮罩的运行一份都不读
- 时间线:日志 report_masked_cell 多带 written(其中按值认出的几处),标题分「引用了…格子」「写进了…值」「引用、写进了…」;缺口、日志按两种分别说;违规清单、CSV 的叫法改成「写进了遮罩列的值」
- 只数个数的列(COUNT(*) FILTER (WHERE phone IS NULL)、SUM(phone IS NULL)、AVG(phone IS NOT NULL)、SUM(amount) FILTER (WHERE phone IS NOT NULL)):认遮罩列的那一套把 FILTER 条件、汇总里的真假条件当成值的来历,数据质量报告引用「手机号缺失的人数」在正式运行里判失败。自查认之前先改写(_counted:FILTER 同 WHERE 去掉,汇总里的真假条件改成 CASE WHEN … THEN 1 ELSE 0 END),不判违规;证据面板、推演照可验证报告的认法多遮(只影响展示),根上的改法在 masking 里
- 证据面板:遮没遮核对不了的输入(快照、取值链上的产出读不出来),值和代入式里写「无法核对」,不写「已遮罩」,数据源可能根本没设遮罩
- 整句面板「挂的依据」不再直接读文档目录里的「名称 = 渲染值」:按证据图的标签写(服务端已把用到遮罩列的指标值换成「已遮罩」),证据图没回来时指标只写名字
- on_demand:当场裁判过、文档里却压根没有这句的判定时,不说「原因见判定」
- 测试:合并查询自查的用例补上拼接、upper、WITH;推演对整行、下标不是字面量的一格说「用到了遮罩列」(顺着取值链认出来的,不是按值猜的);计数指标 cnt 的原值照给;SQL 认不准、只数个数的反例
2026-10-12 10:48:19 +08:00

214 lines
12 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.
"""用到遮罩列的指标:推演和证据面板对「哪些输入算遮罩、指标的原值给不给」是同一个判断,证据图的标签也照这个判断。
复核发现:
- 整份结果当输入(len(vars.cust.rows)):推演只要结果里有一列遮着就把原值写成「已遮罩」,同一屏的指标步骤照写 3、报告
正文也写 3 位;str(vars.cust.rows) 反过来,推演遮了、指标步骤把整份结果连号码给了出来
- 认不出的取值写法照样给出号码:整行 vars.cust.rows[0]、下标不是字面量的 vars.cust.rows[len(…) - 1][2](指标遮了、同一
路径的输入照给)、数据整形把号码取出来再赋给变量(vars.first_phone)
- 证据图的 evidence[].label(「名称 = 渲染值」)照给用到遮罩列的指标的值,报告没引用它也一样
现在推演(engine/recompute.py)和证据面板(api/evidence.py 的 _metric_masks)共用一套输入分类和一个判断
(recompute.withheld_value):取自遮罩列的一格、一行的不给;整份结果当输入、认不出出处的输入,按值认(这次运行查到过的
遮罩值),值里有遮罩值才不给——数行数的照给。证据图的标签按同一个判断换成「已遮罩」。
真跑一遍图(虚构的 SQLite 会员库,号码是编的),报告由 MockChatModel 照剧本写。
"""
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 import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run
from app.engine.evidence import iter_segments
from app.engine.runner import run_manager
from app.main import app
CUSTOMERS = [(1, "客户甲", "19900000501"), (2, "客户乙", "19900000502"), (3, "客户丙", "19900000503")]
PHONES = [c[2] for c in CUSTOMERS]
METRICS = [
{"id": "n_cust", "name": "客户数", "unit": "位", "format": "thousands", "expression": "len(vars.cust.rows)"},
{"id": "dump", "name": "客户清单", "format": "plain", "expression": "str(vars.cust.rows)"},
{"id": "row0", "name": "首位客户", "format": "plain", "expression": "str(vars.cust.rows[0])"},
{"id": "last", "name": "末位号码", "format": "plain", "expression": "vars.cust.rows[len(vars.cust.rows) - 1][2]"},
{"id": "phone_var", "name": "整形取出的号码", "format": "plain", "expression": "vars.first_phone"},
{"id": "phone_raw", "name": "首位客户手机号", "format": "plain", "expression": "cell(nodes.q_cust, 0, 'phone')"},
{"id": "id0", "name": "首位客户编号", "format": "plain", "expression": "cell(nodes.q_cust, 0, 'id')"},
]
#: 报告只写客户数和编号,别的指标只挂作依据:正文里没有号码
TEXT = "共有 [[m:n_cust]] 客户,首位编号 [[m:id0]]。[[see:m:phone_raw]]首位客户的联系方式已登记。"
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client(engine_up):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def members(tmp_path, engine_up):
path = tmp_path / "members.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);")
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", CUSTOMERS)
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "members"))).scalar_one_or_none()
if row is None:
row = DataSource(name="members", 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 = {"mask_columns": ["phone"]}
await session.commit()
source_id = row.id
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() -> dict:
nodes = [
node("start", "input"),
node("q_cust", "tool", tool="db_query__members", args={"sql": "SELECT id, name, phone FROM customers ORDER BY id"}),
node("parse", "transform", mode="json", template="{{ nodes.q_cust }}", assign_to="cust"),
# 数据整形把号码取出来再赋给变量:卡上的 vars.first_phone 顺着取值链认不出是哪一列
node("pick", "transform", mode="expression", expression="vars.cust.rows[0][2]", assign_to="first_phone"),
node("card", "metrics", caliber="遮罩对照", caliber_version="v1", metrics=METRICS),
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:])]}
@pytest.fixture
async def run(monkeypatch, members) -> 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=graph(), 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:
assert row.status == "succeeded", row.error
return row
raise AssertionError(f"运行 {run_id} 没跑完:{row.status}")
def leaks(value) -> list[str]:
"""写着的号码,按千分位写的(19,900,000,101)也算。"""
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 or p in raw.replace(",", "")]
async def whatif(client, run_id: str) -> dict[str, dict]:
res = await client.post(f"/api/runs/{run_id}/evidence/whatif", json={"card": "card"})
assert res.status_code == 200, res.text
assert leaks(res.json()) == [], "推演答复里不出现号码"
return {m["id"]: m for m in res.json()["metrics"]}
async def metric_step(client, run_id: str, metric: str) -> dict:
"""指标步骤:报告引用了的按片段取;只挂作依据、没写进正文的,借一个引用它的片段造一个(同一张卡、同一个台账)。"""
from app.api.evidence import _Masks, _metric_chain, _sealed
sealed = await _sealed(run_id)
[report] = (await client.get(f"/api/runs/{run_id}/evidence")).json()["reports"]
doc = artifact_store.load(report["doc_artifact"])
entry = {**doc["catalog"]["m:id0"], "locator": {"metric": metric}}
cite = {"alias": f"m:{metric}", "kind": "metric", "status": "resolved", "locator": {"metric": metric}}
chain = await _metric_chain({"text": "—"}, cite, entry, doc, sealed, _Masks(sealed))
return {"metric": chain[0], "inputs": [s for s in chain if s["step"] == "input"], "chain": chain}
# --------------------------------------------------------------------------
# 推演和指标步骤:同一个判断
# --------------------------------------------------------------------------
async def test_a_row_count_over_a_masked_result_is_shown_in_both_places(client, run):
"""len(vars.cust.rows):整份结果有一列遮着,推演不了(勾行能反推那一格),可行数不是遮罩值:推演的原值、指标步骤的值
都给 3。整份结果那个输入照旧不给(推演、指标步骤都写已遮罩)。"""
m = (await whatif(client, run.id))["n_cust"]
assert m["status"] == "masked" and m["old"] == 3 and m["rendered_old"] == "3位" and not m.get("withheld"), m
[rows] = m["inputs"]
assert rows["path"] == "vars.cust.rows" and rows["masked"] is True and rows["old"] is None
step = await metric_step(client, run.id, "n_cust")
assert step["metric"]["value"] == 3, step["metric"]
[chip] = step["inputs"]
assert chip["value"] == "已遮罩" and chip["masked"] == ["phone"]
# 代入式里整份结果那一处只留路径,不写成「已遮罩」
assert step["metric"]["substituted"] == "len(vars.cust.rows)"
@pytest.mark.parametrize("metric", ["dump", "row0", "last", "phone_var", "phone_raw"])
async def test_a_value_carrying_a_masked_value_is_withheld_in_both_places(client, run, metric):
"""值里就是号码的指标:整份结果转成文字、整行、下标不是字面量的一格、数据整形取出来的号码、cell() 取的号码。推演的原值、
指标步骤的值都写「已遮罩」,输入也不给值,号码一个都不交出来。"""
m = (await whatif(client, run.id))[metric]
assert m["status"] == "masked" and m["old"] == m["rendered_old"] == "已遮罩" and m["withheld"] is True, m
assert all(i["old"] is None for i in m["inputs"]), m["inputs"]
if metric in ("row0", "last"):
# 整行、下标不是字面量的一格顺着取值链认得出取自遮罩列(不是按值猜的):说用到了遮罩列
from app.engine.recompute import MASKED_METRIC
assert m["reason"] == MASKED_METRIC, m["reason"]
step = await metric_step(client, run.id, metric)
assert step["metric"]["value"] == "已遮罩", step["metric"]
assert all(i["value"] == "已遮罩" for i in step["inputs"]), step["inputs"]
assert leaks(step["chain"]) == []
async def test_a_value_from_an_unmasked_cell_is_given_in_both_places(client, run):
m = (await whatif(client, run.id))["id0"]
assert (m["status"], m["old"]) == ("unchanged", 1) and not m.get("withheld")
assert (await metric_step(client, run.id, "id0"))["metric"]["value"] == 1
async def test_a_scalar_reshaped_into_a_variable_is_recognised_by_value(client, run):
"""vars.first_phone 认不出出处,值却就是号码:按值认出来,推演说清是值与遮罩列中的值相同,指标步骤的输入说明同样。"""
m = (await whatif(client, run.id))["phone_var"]
assert "与设置了遮罩的列中的值相同" in m["reason"], m["reason"]
[chip] = (await metric_step(client, run.id, "phone_var"))["inputs"]
assert chip["mask_unverified"] == "值与设置了遮罩的列中的值相同" and "masked" not in chip, chip
res = await client.post(f"/api/runs/{run.id}/evidence/whatif",
json={"card": "card", "overrides": {"inputs": {"vars.first_phone": 1}}})
assert res.status_code == 422 and "与设置了遮罩的列中的值相同" in res.json()["detail"]
# --------------------------------------------------------------------------
# 证据图的标签
# --------------------------------------------------------------------------
async def test_the_evidence_graph_labels_follow_the_same_rule(client, run):
"""证据图的 evidence[].label:用到遮罩列、值里有遮罩值的指标写「名称 = 已遮罩」,客户数、编号照给;整份答复里没有号码
(报告只把 phone_raw 挂作依据,正文里没有号码)。"""
body = (await client.get(f"/api/runs/{run.id}/evidence")).json()
labels = {e["alias"]: e["label"] for e in body["evidence"] if e["kind"] == "metric"}
assert labels["m:phone_raw"] == "首位客户手机号 = 已遮罩" and labels["m:phone_var"] == "整形取出的号码 = 已遮罩"
assert labels["m:dump"] == "客户清单 = 已遮罩" and labels["m:row0"] == "首位客户 = 已遮罩"
assert labels["m:n_cust"] == "客户数 = 3位" and labels["m:id0"] == "首位客户编号 = 1"
assert leaks(body) == []