Files
AgentLab/backend/tests/test_whatif_masked_values.py
T
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

272 lines
15 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.
"""推演和证据面板不露指标本身的原值:指标的输入取自遮罩格(或者指标的值本身就是遮罩格)时,原值写「已遮罩」。
验收发现:phone_raw = cell(nodes.q_cust, 0, 'phone') 推演时判成 masked,输入里置了空,指标的 old、rendered_old 却还是那串
号码;按位置取的 phone_pos 也一样。同一份答复里输入拿掉了、指标上照给,等于没遮。现在:
- 推演(engine/recompute.py):用到了遮罩列的指标(哪怕只嵌在下标里),old、rendered_old 写「已遮罩」,标 withheld,
原因照旧写在 reason 里;new、rendered_new 本来就是空的(推演不了);报告里引用它的地方 rendered_old 同样写「已遮罩」
- 最外层的输入遮没遮核对不了(快照读不出来)的也一样;只嵌在下标里、遮没遮核对不了的那一格不碍事,原值照给
- 证据面板的指标步骤:value 按同一个判断写「已遮罩」(recompute.hides_value),报告上的字(rendered)照旧
- 没用到遮罩列的指标一字不差
前半是纯函数(手写的虚构卡和快照),后半真跑一遍图(虚构的 SQLite 客户库,号码是编的)。
"""
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 import recompute
from app.engine.evidence import iter_segments
from app.engine.recompute import Changes, Sources, whatif
from app.engine.runner import run_manager
from app.main import app
# --------------------------------------------------------------------------
# 纯函数:手写的卡和快照
# --------------------------------------------------------------------------
SNAP, OTHER = "a1" * 32, "b2" * 32
STORE = {
SNAP: {"columns": ["id", "phone", "amount"], "source": "crm", "sql": "SELECT id, phone, amount FROM members",
"rows": [[1, "19900000201", 120.5], [2, "19900000202", 80.0]]},
OTHER: {"columns": ["n"], "source": "crm", "sql": "SELECT 2 AS n", "rows": [[2]]},
}
PHONE = "cell(nodes.crm, 0, 'phone')"
AMOUNT = "cell(nodes.crm, 0, 'amount')"
def cell(row, column, value, artifact=SNAP):
return {"path": f"cell(nodes.crm, {row}, '{column}')", "value": value, "node_id": "crm", "via": "tool_cell",
"locator": {"row": row, "column": column}, "artifact": artifact, "status": "verified"}
def scalar(path, value):
return {"path": path, "value": value, "node_id": "fetch", "via": "transform", "status": "ok"}
def card(mid, expression, substituted, value, inputs, unit=""):
return {"kind": "metric_set", "caliber": "会员口径", "caliber_version": "v1", "metrics": [
{"id": mid, "name": mid, "unit": unit, "value": value, "format": "plain", "expression": expression,
"substituted": substituted, "inputs": inputs, "status": "ok"}]}
def sources(**kw):
base = {"load": STORE.get, "queries": (SNAP, OTHER), "masked": {SNAP: frozenset({"phone"})}}
return Sources(**{**base, **kw})
def text_of(result) -> str:
return json.dumps(result, ensure_ascii=False)
def test_the_value_of_a_metric_that_is_a_masked_cell_is_withheld():
"""验收的 phone_raw:指标就是遮罩列的那一格。old、rendered_old 都写「已遮罩」,号码不出现在答复的任何地方。"""
result = whatif(card("phone_raw", PHONE, "19900000201", 19900000201, [cell(0, "phone", 19900000201)]),
Changes(), sources())
[m] = result["metrics"]
assert (m["status"], m["old"], m["rendered_old"], m["withheld"]) == ("masked", "已遮罩", "已遮罩", True)
assert [m[k] for k in ("new", "rendered_new", "delta", "rendered_delta", "changed", "substituted")] == [None] * 6
assert "遮罩" in m["reason"] and m["inputs"][0]["masked"] is True and m["inputs"][0]["old"] is None
assert "19900000201" not in text_of(result)
def test_a_metric_computed_from_a_masked_cell_withholds_its_value_too():
"""号码位数这类算出来的数同样不给:改一个别的输入、拿指标的值一减,就能算出遮住的那一格(宁可多遮)。"""
[m] = whatif(card("mix", f"len({PHONE}) + {AMOUNT}", "len('19900000201') + 120.5", 131.5,
[cell(0, "phone", "19900000201"), cell(0, "amount", 120.5)]), Changes(), sources())["metrics"]
assert (m["status"], m["old"], m["rendered_old"]) == ("masked", "已遮罩", "已遮罩")
inputs = {i["path"]: i for i in m["inputs"]}
assert inputs[AMOUNT]["old"] == 120.5 and inputs[PHONE]["old"] is None
def test_a_missing_value_stays_missing():
"""本来就没有值的(缺输入)照旧是空的、显示「—」:缺输入还得看得出来。"""
[m] = whatif(card("gone", PHONE, None, None, [cell(0, "phone", None)]), Changes(), sources())["metrics"]
assert m["status"] == "masked" and m["old"] is None and m["rendered_old"] == "—" and m["withheld"] is True
def test_a_top_level_cell_on_an_unreadable_snapshot_withholds_the_value():
"""那一格所在的快照读不出来:遮没遮核对不了,输入不给值,指标的原值同样不给(和代入式一样)。说法写「无法核对」,
不写「已遮罩」:数据源可能根本没设遮罩,读的人不该以为这里遮着。"""
[m] = whatif(card("east", AMOUNT, "120.5", 120.5, [cell(0, "amount", 120.5)]), Changes(),
sources(load=lambda a: None if a == SNAP else STORE.get(a)))["metrics"]
assert m["status"] == "unsupported" and m["old"] == m["rendered_old"] == "无法核对" and m["withheld"] is True
assert "120.5" not in text_of(m)
def test_a_cell_only_choosing_a_row_on_an_unreadable_snapshot_does_not_hide_the_value():
"""只嵌在下标里、遮没遮核对不了的那一格:它只决定取哪一项,取到的值卡上另记着,原值照给、照样推演。"""
pick = f"vars.kpi.list[{AMOUNT.replace('amount', 'id')}] + vars.kpi.k"
top = f"vars.kpi.list[{AMOUNT.replace('amount', 'id')}]"
metric = card("pick", pick, "20 + 2", 22, [scalar(top, 20), cell(0, "id", 1), scalar("vars.kpi.k", 2)])
[m] = whatif(metric, Changes(), sources(load=lambda a: None if a == SNAP else STORE.get(a)))["metrics"]
assert m["status"] == "unchanged" and m["old"] == 22 and "withheld" not in m
[m] = whatif(metric, Changes(inputs={"vars.kpi.k": 5}), sources(load=lambda a: None if a == SNAP else STORE.get(a)))[
"metrics"]
assert (m["status"], m["old"], m["new"]) == ("ok", 22, 25)
def test_an_unmasked_metric_is_unchanged():
result = whatif(card("east", AMOUNT, "120.5", 120.5, [cell(0, "amount", 120.5)]), Changes(), sources())
[m] = result["metrics"]
assert (m["status"], m["old"], m["rendered_old"]) == ("unchanged", 120.5, "120.5") and "withheld" not in m
def test_report_segments_of_a_withheld_metric_do_not_carry_the_value():
"""报告里引用了原值不给的指标:推演答复里那一处的 rendered_old 同样写「已遮罩」。"""
metric = card("phone_raw", PHONE, "19900000201", 19900000201, [cell(0, "phone", 19900000201)])
result = whatif(metric, Changes(), sources())
entry = {"kind": "metric", "node_id": "card", "artifact": "c" * 64, "locator": {"metric": "phone_raw"}}
doc = {"catalog": {"m:phone_raw": entry}, "blocks": [{"units": [{"id": "u0", "segments": [
{"id": "s0", "kind": "number", "text": "19900000201", "state": "deterministic",
"cite": {"alias": "m:phone_raw", "kind": "metric", "status": "resolved"}}]}]}]}
[seg] = recompute.report_segments([("write", doc)], card_node="card", card_artifact="c" * 64,
results=result["metrics"])
assert (seg["rendered_old"], seg["rendered_new"], seg["changed"]) == ("已遮罩", None, None)
@pytest.mark.parametrize("masked, unverified, expected", [
({PHONE}, set(), "masked"), # 取自遮罩列
({"vars.kpi.i"}, set(), "masked"), # 遮罩列的那一格只嵌在下标里:推演整个按遮罩处理,原值也不给
(set(), {AMOUNT}, "unverified"), # 最外层的输入遮没遮核对不了
(set(), {"vars.kpi.i"}, None), # 只嵌在下标里、遮没遮核对不了:不碍事
(set(), set(), None),
])
def test_withheld_value_is_one_rule_for_the_whatif_and_the_evidence_panel(masked, unverified, expected):
metric = {"expression": f"{AMOUNT} + vars.kpi.list[vars.kpi.i]"}
assert recompute.withheld_value(metric, 1, masked=masked, unverified=unverified) == expected
# 表达式读不懂:有一个遮没遮核对不了的就不给
assert recompute.withheld_value({"expression": "1 +"}, 1, unverified={AMOUNT}) == "unverified"
# --------------------------------------------------------------------------
# 真跑:验收原样的口径卡(单格原值、按位置取、按位置取的位数、编号)
# --------------------------------------------------------------------------
CUSTOMERS = [(1, "客户甲", "19900000301"), (2, "客户乙", "19900000302"), (3, "客户丙", "19900000303")]
PHONES = [c[2] for c in CUSTOMERS]
CARD_METRICS = [
{"id": "phone_raw", "name": "首位客户手机号(单格原值)", "format": "plain",
"expression": "cell(nodes.q_cust, 0, 'phone')"},
{"id": "phone_pos", "name": "首位客户手机号(按位置)", "format": "plain", "expression": "vars.cust.rows[0][2]"},
{"id": "phone_pos_len", "name": "首位客户手机号位数", "unit": "位", "format": "plain",
"expression": "len(str(vars.cust.rows[0][2]))"},
{"id": "id0", "name": "首位客户编号", "format": "plain", "expression": "cell(nodes.q_cust, 0, 'id')"},
]
TEXT = "首位客户编号 [[m:id0]],号码位数 [[m:phone_pos_len]]。"
@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):
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 LIMIT 5"}),
node("parse_cust", "transform", mode="json", template="{{ nodes.q_cust }}", assign_to="cust"),
node("card_c", "metrics", caliber="遮罩取法对照", caliber_version="v1", metrics=CARD_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:])]}
async def run_graph(monkeypatch) -> 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(raw: str) -> list[str]:
return [p for p in PHONES if p in raw]
async def test_the_whatif_answer_carries_no_masked_value(client, monkeypatch, members):
"""验收原样:推演答复里号码一个都没有(以前在 phone_raw、phone_pos 的 old、rendered_old 里)。"""
row = await run_graph(monkeypatch)
res = await client.post(f"/api/runs/{row.id}/evidence/whatif", json={"card": "card_c"})
assert res.status_code == 200, res.text
assert leaks(res.text) == [], leaks(res.text)
metrics = {m["id"]: m for m in res.json()["metrics"]}
for mid in ("phone_raw", "phone_pos", "phone_pos_len"):
m = metrics[mid]
assert (m["status"], m["old"], m["rendered_old"], m["withheld"]) == ("masked", "已遮罩", "已遮罩", True), m
assert m["new"] is None and m["rendered_new"] is None and "遮罩" in m["reason"]
assert (metrics["id0"]["status"], metrics["id0"]["old"]) == ("unchanged", 1) and "withheld" not in metrics["id0"]
# 报告里引用了号码位数:那一处的原值也写「已遮罩」
[seg] = [s for s in res.json()["segments"] if s["metric"] == "phone_pos_len"]
assert seg["rendered_old"] == "已遮罩" and seg["rendered_new"] is None
async def test_the_evidence_panel_withholds_the_metric_value_by_the_same_rule(client, monkeypatch, members):
"""证据面板的指标步骤:用到遮罩列的指标 value 写「已遮罩」;报告撰写节点写作时按同一个判断把它在目录里换成了「已遮罩」,
报告上的字也是「已遮罩」(tests/test_report_masked_metrics.py),渲染一致。没用到的照给。"""
row = await run_graph(monkeypatch)
[report] = (await client.get(f"/api/runs/{row.id}/evidence")).json()["reports"]
doc = artifact_store.load(report["doc_artifact"])
by_ref = {s["ref"]: s["id"] for s in iter_segments(doc) if s.get("ref")}
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{by_ref['m:phone_pos_len']}")).json()
metric = body["chain"][0]
assert (metric["step"], metric["value"], metric["rendered"], metric["render_ok"]) == \
("metric", "已遮罩", "已遮罩", True), metric
assert leaks(json.dumps(body, ensure_ascii=False)) == []
plain = (await client.get(f"/api/runs/{row.id}/evidence/segments/{by_ref['m:id0']}")).json()
assert plain["chain"][0]["value"] == 1