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

171 lines
11 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.
"""审批质询的遮罩收口,端到端(engine/inquiry.py 经核查 agent 的工具边界):
- 分档写在子查询、WITH 里、外层按档位分组:档位列交给模型、存下、渲染进回答都照常,标签不当遮罩值,SQL 照原样封存;
以前回答成了「已遮罩有 3 笔,已遮罩有 2 笔。」
- 交给质询 agent 的证据摘录先遮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号),摘录里不给这一列;别的列、
SQL 里写着的遮罩值(换了写法的号码也算)换成「已遮罩」。以前摘录只按列名遮,号码原样交给模型
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额、手机号都是虚构的。
"""
from __future__ import annotations
import pytest
from app.core import artifact_store
from tests.test_inquiry_api import ( # noqa: F401 - engine_up 是自动用上的夹具,client、big_shop、masked_shop 是夹具
AMOUNTS,
Models,
Play,
ask,
big_shop,
client,
engine_up,
events,
fetching,
masked_shop,
pause,
pending,
report_doc,
unit_of,
)
BAND = "CASE WHEN amount >= 20000 THEN '大单' ELSE '小单' END"
@pytest.mark.parametrize("sql", [
pytest.param(f"SELECT 档位, COUNT(*) AS 笔数 FROM (SELECT *, {BAND} AS 档位 FROM sales) GROUP BY 档位 ORDER BY 档位",
id="子查询里 * 加分档"),
pytest.param(f"WITH b AS (SELECT {BAND} AS 档位 FROM sales) SELECT 档位, COUNT(*) AS 笔数 FROM b GROUP BY 档位 "
"ORDER BY 档位", id="WITH 里只有分档"),
])
async def test_a_band_grouped_outside_its_derived_source_reads_as_written(monkeypatch, big_shop, client, sql):
"""金额设了遮罩,问两万元上下各有几笔:分档写在派生来源里、外层按档位分组。模型看得到档位、回答里引用的档位和它写的
「大单」「小单」都照原样,SQL 照原样封存,质询工件里档位列照常显示。"""
said = "[[v:Q1.r0.档位]]有 [[v:Q1.r0.笔数]] 笔,[[v:Q1.r1.档位]]有 [[v:Q1.r1.笔数]] 笔,大单少于小单。[[see:Q1]]"
models = Models(monkeypatch, big_shop, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]",
plays={"两万元上下": Play([sql], said)})
paused = await pause(big_shop, graph=fetching(big_shop, AMOUNTS))
[approval] = await pending(paused.id)
r = await ask(client, paused.id, report="write", unit=None, question="两万元上下各有几笔?",
approval_id=approval.id)
assert r.status_code == 200, r.text
item = r.json()["inquiry"]
assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "大单有 3 笔,小单有 2 笔,大单少于小单。")
[query] = item["queries"]
assert query["sql"] == sql, "SQL 里的标签不当遮罩值换"
[event] = await events(paused.id, "approval.inquiry")
assert event.data["queries"][0]["sql"] == sql
snapshot = artifact_store.load(query["artifact"])
assert snapshot["rows"] == [["大单", 3], ["小单", 2]] and snapshot["masked_at_query"] == []
seen = [str(m.content) for messages in models.finals for m in messages]
assert any('r0:档位="大单",笔数=3' in text for text in seen), "模型看得到档位"
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}")
assert got.status_code == 200, got.text
assert (got.json()["content"]["rows"], got.json()["masked"]) == ([["大单", 3], ["小单", 2]], [])
#: 报告取数:手机号改了名字选出来,按号码排序时把号码写进了 SQL
RENAMED = "SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY buyer_phone = '13900000001' DESC, amount DESC"
async def test_the_excerpt_in_the_task_is_masked_before_the_agent_sees_it(monkeypatch, masked_shop, client):
"""针对「金额最高的一笔 1,200。」发起质询:任务里这句挂的证据摘录不给改了名字的遮罩列(手机号),SQL 里写着的号码
换成「已遮罩」,金额照常。以前「列:金额 | 手机号」「r0:1,200 | 13900000001」…,四个号码都在。"""
models = Models(monkeypatch, masked_shop, writer="金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]",
plays={"这笔是谁买的": Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")})
paused = await pause(masked_shop, graph=fetching(masked_shop, RENAMED))
[approval] = await pending(paused.id)
doc = await report_doc(paused.id)
r = await ask(client, paused.id, report="write", unit=unit_of(doc, "金额最高的一笔 1,200。"),
question="这笔是谁买的?", approval_id=approval.id)
assert r.status_code == 200, r.text
[task] = models.tasks
excerpt = task[task.index("【原报告 Q1】"):]
assert "1390000000" not in task, excerpt
columns = next(line for line in excerpt.splitlines() if line.startswith("列:"))
assert "手机号" not in columns and "金额" in columns
assert "buyer_phone = '已遮罩'" in excerpt and "(有的列按数据源的设置遮罩了,没有给出)" in excerpt
assert r.json()["inquiry"]["answer"]["ok"] is True
# --------------------------------------------------------------------------
# 摘录分段遮:阈值、没设遮罩的数值格、被问那句里的值照原样
# --------------------------------------------------------------------------
#: 报告按金额阈值取数(12000 正好是某一笔被遮的金额),访问次数没设遮罩
THRESHOLD = ("SELECT id AS 编号, region AS 区域, amount AS 金额, visits AS 访问 FROM sales WHERE amount >= 12000 "
"ORDER BY id")
async def test_the_excerpt_keeps_thresholds_and_unmasked_numbers(monkeypatch, big_shop, client):
"""针对「华南首单访问 30,000 次。」发起质询:摘录里 SQL 的阈值(12000,恰好是一笔被遮的金额)照原样,访问次数(没设
遮罩,20000、30000 恰好等于别的订单的金额)照原样,金额列不给。以前对渲染好的整段按值换:「amount >= 已遮罩」
「r0:已遮罩 | 2 | 华东」,被问的那一格也成了「已遮罩」,任务开头的原句却写着 30,000。"""
models = Models(monkeypatch, big_shop, writer="华南首单访问 [[v:Q1.r1.访问]] 次。[[see:Q1]]",
plays={"访问为什么这么高": Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "共 [[v:Q1.r0.笔数]] 笔。")})
paused = await pause(big_shop, graph=fetching(big_shop, THRESHOLD))
[approval] = await pending(paused.id)
doc = await report_doc(paused.id)
r = await ask(client, paused.id, report="write", unit=unit_of(doc, "华南首单访问 30,000 次。"),
question="访问为什么这么高?", approval_id=approval.id)
assert r.status_code == 200, r.text
[task] = models.tasks
excerpt = task[task.index("【原报告 Q1】"):]
assert f"SQL:{THRESHOLD}" in excerpt, excerpt
assert "列:访问 | 编号 | 区域" in excerpt and "r0:20,000 | 2 | 华东" in excerpt and "r1:30,000 | 3 | 华南" in excerpt
#: 买家姓名设了遮罩;推荐人没设遮罩,写着的恰好是另一位买家的名字
REFERRALS = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_name TEXT, referrer TEXT, amount INTEGER);"
"INSERT INTO sales VALUES (1, '华东', '张伟', '李娜', 1200), (2, '华南', '李娜', '张伟', 900),"
" (3, '华北', '王芳', '刘洋', 400);")
@pytest.fixture
async def referral_shop(tmp_path):
from app.data.engine import engines
from tests.test_inquiry_api import _source
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_name"}, script=REFERRALS)
yield name
await engines.invalidate(source_id)
async def test_values_in_the_asked_sentence_stay_in_the_excerpt_and_the_answer(monkeypatch, referral_shop, client):
"""被问的那句写着「李娜」(推荐人这一格,恰好也是另一笔的买家):它就在任务开头、摆在审批人眼前,摘录里这一格、回答里
复述的都照原样(同反方审稿的 _spared);别的买家名照旧换。以前质询不把这句原文算作已经摆在眼前的,这一格成了
「已遮罩」,和任务开头写着的原句对不上。"""
report = "SELECT buyer_name AS 买家, referrer AS 推荐人, amount AS 金额 FROM sales ORDER BY id"
said = "推荐人是李娜,第二单的推荐人是 [[v:Q1.r1.推荐人]]。[[see:Q1]]"
models = Models(monkeypatch, referral_shop, writer="首单由 [[v:Q1.r0.推荐人]] 推荐。[[see:Q1]]",
plays={"谁推荐的": Play(["SELECT id, referrer AS 推荐人 FROM sales ORDER BY id"], said)})
paused = await pause(referral_shop, graph=fetching(referral_shop, report))
[approval] = await pending(paused.id)
doc = await report_doc(paused.id)
r = await ask(client, paused.id, report="write", unit=unit_of(doc, "首单由 李娜 推荐。"),
question="谁推荐的?", approval_id=approval.id)
assert r.status_code == 200, r.text
[task] = models.tasks
excerpt = task[task.index("【原报告 Q1】"):]
assert "列:推荐人 | 金额" in excerpt and "r0:李娜 | 1,200" in excerpt and "r1:已遮罩 | 900" in excerpt, excerpt
item = r.json()["inquiry"]
assert item["answer"]["text"] == "推荐人是李娜,第二单的推荐人是 已遮罩。"
async def test_literals_compared_with_a_masked_column_are_masked_in_the_excerpt(monkeypatch, masked_shop, client):
"""报告没选出号码,只在条件里排除了两个(一个换了写法):报告快照里收不到这两个值,摘录的 SQL 照样换掉。"""
report = ("SELECT region AS 区域, COUNT(*) AS 笔数 FROM sales WHERE buyer_phone NOT IN ('13900000002', "
"'139-0000-0003') GROUP BY region ORDER BY 笔数 DESC")
models = Models(monkeypatch, masked_shop, writer="笔数最多的区域有 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]",
plays={"按什么排除": Play([], "查不到判断所需的数据。")})
paused = await pause(masked_shop, graph=fetching(masked_shop, report))
[approval] = await pending(paused.id)
doc = await report_doc(paused.id)
r = await ask(client, paused.id, report="write", unit=unit_of(doc, "笔数最多的区域有 1 笔。"),
question="按什么排除的?", approval_id=approval.id)
assert r.status_code == 200, r.text
[task] = models.tasks
assert "13900000002" not in task and "0000-0003" not in task, task
assert "buyer_phone NOT IN ('已遮罩', '已遮罩')" in task