Files
AgentLab/backend/tests/test_masking_excerpts.py
T
ThearchyHelios 169c500526 K3b 复核修正(第 1 轮):数值格按位置认、(表达式).* 带外括号也整份遮、同名 CTE 按作用域认、认不全的写法名单合一、整份遮不再把每一格收成遮罩值、证据摘录分段遮、反方最后一道关只看正文
- 同名列(回退):column_types 按列名记,同名的几列只留得下后一列的类型,文字的备注被当成数值列,号码不再按值换、
  原样交给模型并落库。现在只在列名唯一时按列类型认数值列(_typed_numbers),同名的按格子本身认:整数、小数才算数,
  写成字符串的数按文字照换、记进 masked_cells;列名唯一、列类型是 number 的 NUMERIC / DECIMAL 字符串照原样
- (表达式).* 外面套了括号、起了别名(((p.card).*)、(p.card).* AS x,Postgres 实测照样展开):以前只遮第一列。选出项
  先去掉外括号、别名再认(_unwrapped),(s).*、(s.*) AS x 照旧当 s.*
- WITH 里和数据源里的表同名的一段(WITH sales AS (SELECT * FROM sales WHERE …)):不带 RECURSIVE 的一段看不见它自己和
  排在后面的段(_scoped_cte),同名的按数据源里的表认;以前认成它自己,* 展开什么都不带出,整行引用的号码原样交出
- 认不全的写法:和可验证报告合成一份名单(masking 的 OPAQUE_SQL、INDIRECT_FUNCTIONS、INDIRECT_NAMES,bundle 引用后另加
  系统 schema),补上 MySQL 会执行的注释、U&"…"、pg_stat_get_activity、逻辑解码、MySQL 的 column_statistics /
  general_log / PROCESSLIST / performance_schema、MariaDB 的 column_stats、Oracle 的直方图 / *_tab_columns / V$SQL 一族;
  去掉注释再认一遍(函数名和括号之间夹注释);去掉 DuckDB、SQL Server 的函数(query 误伤同名 CTE、字符串);没有 * 的
  一支选出项和结果列数对不上的、Postgres 解析不了的整份遮
- 整份遮只收确定取自遮罩列的那几列的值(_derived_masks 的 collect,报告快照收值同一规则):以前每一格都收,区域名、
  列名在同一次核查里处处被换掉,封存的 SQL 也被改写。快照另记 masked_whole,格子引用照实说写法认不全
- 证据摘录分段遮(inquiry.ExcerptMask,judge.prepare 的 mask 取代 kinds):SQL 照封存 SQL 的办法换,范围阈值、LIMIT 照
  原样,和遮罩列做等值、IN、LIKE 比较的字面量不论收没收到都换;格子逐格遮,数值格照原样;质询把被问的那句(连同表格)
  当作已经摆在眼前,摘录、查询工具、回答里都不换
- 反方审稿:理由文档的目录列名照快照换(同质询的 _renamed_entry),agent 自己 SQL 里照原样留着的阈值、行数在正文里
  不换(_shown),最后一道关只看正文和原文,不再把 agent 起的列名、阈值当成露了遮罩值整句改判

补测试:同名列一文一数、DECIMAL 字符串(含端到端引用取值),外括号 × 复合类型 / 记录函数 / 匿名 ROW × 最外层 /
UNION 前后支 / 和 * 写在一起,同名 CTE 四种写法和 RECURSIVE 对照,十七种认不全的写法和三种不该误伤的写法,整份遮后的
收值与封存 SQL,Postgres 解析不了的整行引用,摘录的阈值、数值格、IN / NOT IN 字面量、被问那句(反方、质询各端到端),
反方别名里的阈值(经受住、被推翻各一条)、找某个值的别名、正文里的阈值。逐项去掉实现,对应的测试都变红。
改了文件所有权以外的 bundle/build.py:_OPAQUE_SQL、_INDIRECT_READS 改为引用 masking 的同一份名单(另加系统 schema,
覆盖面不小于原来)。
2026-10-07 01:09:36 +08:00

112 lines
6.4 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.
"""交给核查 agent 的证据摘录分段遮(engine/inquiry.py 的 ExcerptMask、_scrub_excerpt_sql,engine/masking.py 的
masked_cell):
- SQL 照质询封存 SQL 的办法换:范围比较的阈值、LIMIT 的行数照原样(恰好等于某个遮罩值也一样),字符串、注释、名字里的
遮罩值换;和遮罩列(含改了名字的)做等值、IN、LIKE 一类比较的字面量不论认不认得出都换(报告没选出遮罩列时快照里收
不到这些值),同一个数写成阈值的跟着换;已经摆在读者眼前的值(问题、被问的那句)照旧不换
- 格子逐格遮:数值格(数、列类型是 number 的)照原样,文字格整格的、夹着的那一截换掉(换了写法的号码也认)
以前对渲染好的整段摘录按值换,分不出格子和 SQL 里的用法:阈值、没设遮罩的访问次数恰好等于某一笔被遮的金额,都成了
「已遮罩」。端到端的用例在 test_challenge_masking、test_inquiry_masking_e2e。号码、姓名、金额都是虚构的。
"""
from __future__ import annotations
import pytest
from app.engine import masking
from app.engine.inquiry import ExcerptMask, _scrub_excerpt_sql
MASKED = "已遮罩"
def secrets_of(*values, protect: tuple[str, ...] = ()) -> masking._Secrets:
"""报告快照里收到的遮罩值(金额、号码),去掉已经摆在读者眼前的(protect)。"""
secrets = masking._Secrets()
secrets.add({"columns": ["v"], "rows": [[v] for v in values]}, {"v"})
secrets.protect(protect)
return secrets
AMOUNTS = secrets_of(20000, 30000, 50000)
@pytest.mark.parametrize("sql", [
"SELECT id, visits FROM sales WHERE amount >= 20000 ORDER BY id",
"SELECT id FROM sales WHERE amount BETWEEN 20000 AND 50000 LIMIT 30000",
"SELECT region, COUNT(*) AS n FROM sales WHERE visits > 30000 GROUP BY region",
])
def test_thresholds_and_limits_stay_as_written(sql):
"""范围比较的边界、LIMIT 的行数是报告自己定的条件,恰好等于某一笔被遮的金额也照原样:agent 要看得到报告按什么取数。"""
assert _scrub_excerpt_sql(sql, AMOUNTS, {"amount"}, "sqlite") == sql
def test_a_value_the_report_looked_for_is_masked():
"""在找某一笔的(等值比较)照旧换:这是在点名一个遮罩值。"""
assert _scrub_excerpt_sql("SELECT id FROM sales WHERE amount = 50000", AMOUNTS, {"amount"}, "sqlite") \
== f"SELECT id FROM sales WHERE amount = {MASKED}"
@pytest.mark.parametrize("sql, expected", [
("SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000002', '139-0000-0003')",
f"SELECT region FROM sales WHERE buyer_phone NOT IN ('{MASKED}', '{MASKED}')"),
("SELECT region FROM sales WHERE buyer_phone = '13900000009' OR buyer_phone LIKE '%0001'",
f"SELECT region FROM sales WHERE buyer_phone = '{MASKED}' OR buyer_phone LIKE '{MASKED}'"),
("SELECT region FROM (SELECT region, buyer_phone AS 手机号 FROM sales) t WHERE 手机号 IN ('13900000002')",
f"SELECT region FROM (SELECT region, buyer_phone AS 手机号 FROM sales) t WHERE 手机号 IN ('{MASKED}')"),
("SELECT region FROM sales WHERE region = '华东'", "SELECT region FROM sales WHERE region = '华东'"),
])
def test_literals_compared_with_a_masked_column_are_masked_even_when_never_collected(sql, expected):
"""和遮罩列(含改了名字的)做等值、IN、LIKE 比较的字面量:报告快照里收不到这些值(报告没选出号码),照样换掉;和没设
遮罩的列比较的照原样。"""
assert _scrub_excerpt_sql(sql, masking._Secrets(), {"buyer_phone", "手机号"}, "sqlite") == expected
def test_a_threshold_equal_to_a_masked_literal_goes_with_it():
"""同一个数既在找某一笔、又写成阈值:阈值留着就把换掉的值露了,跟着换(同封存 SQL 的 _literals)。"""
sql = "SELECT id FROM sales WHERE buyer_phone = 13900000009 OR id >= 13900000009"
assert _scrub_excerpt_sql(sql, masking._Secrets(), {"buyer_phone"}, "sqlite") \
== f"SELECT id FROM sales WHERE buyer_phone = {MASKED} OR id >= {MASKED}"
def test_values_already_in_front_of_the_reader_are_not_masked():
"""提问人、被问的那句里写着的值(protect)照旧不换,换了写法写的也认得出。"""
secrets = secrets_of(protect=("139-0000-0009 这位客户的单",))
sql = "SELECT id FROM sales WHERE buyer_phone = '13900000009' OR buyer_phone = '13900000008'"
assert _scrub_excerpt_sql(sql, secrets, {"buyer_phone"}, "sqlite") \
== f"SELECT id FROM sales WHERE buyer_phone = '13900000009' OR buyer_phone = '{MASKED}'"
def test_strings_and_comments_are_masked_by_value():
"""字符串、注释里写着的遮罩值按值换(换了写法的号码也认),同封存 SQL。"""
secrets = secrets_of("13900000001")
sql = "SELECT id FROM sales /* 客户 139-0000-0001 */ WHERE note LIKE '%13900000001%'"
assert _scrub_excerpt_sql(sql, secrets, set(), "sqlite") \
== f"SELECT id FROM sales /* 客户 {MASKED} */ WHERE note LIKE '%{MASKED}%'"
@pytest.mark.parametrize("value, numeric, expected", [
(20000, False, 20000),
(20000.0, False, 20000.0),
("20000.00", True, "20000.00"),
("20000", False, MASKED),
("上月消费 20000 元", False, f"上月消费 {MASKED} 元"),
("回访:联系 139-0000-0003", False, f"回访:联系 {MASKED}"),
({"phone": "13900000003"}, False, '{"phone": "' + MASKED + '"}'),
("例行回访", False, "例行回访"),
(None, False, None),
])
def test_cells_are_masked_like_the_tool_boundary(value, numeric, expected):
"""格子同工具边界:数(整数、小数,列类型是 number 的字符串)照原样;文字格整格是遮罩值的、夹着的那一截换掉,JSON
按画出来的样子换。"""
secrets = secrets_of(20000, "13900000003")
assert masking.masked_cell(value, secrets, numeric=numeric) == expected
def test_an_excerpt_mask_without_values_only_masks_by_column():
"""没有可按值换的(报告快照里没收到值):格子、文字照原样,SQL 里和遮罩列做等值比较的照样换。"""
mask = ExcerptMask({"shop": "sqlite"})
assert mask.cell("13900000001", numeric=False) == "13900000001"
assert mask.text("客户 13900000001") == "客户 13900000001"
assert mask.sql("SELECT 1 FROM sales WHERE buyer_phone = '13900000001'", {"source": "shop"}, {"buyer_phone"}) \
== f"SELECT 1 FROM sales WHERE buyer_phone = '{MASKED}'"