mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:52:01 +00:00
169c500526
- 同名列(回退):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, 覆盖面不小于原来)。
171 lines
11 KiB
Python
171 lines
11 KiB
Python
"""审批质询的遮罩收口,端到端(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
|