mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:01:57 +00:00
248 lines
15 KiB
Python
248 lines
15 KiB
Python
"""反方审稿的遮罩收口(engine/nodes/challenge.py):
|
||
|
||
- 交给反方 agent 的证据摘录先遮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号),摘录里不给这一列(按 SQL
|
||
认,和工具边界同一套规则);别的列(备注里换了写法的号码)、SQL 里写着的遮罩值换成「已遮罩」。以前摘录只按列名遮,
|
||
四个号码原样交给模型,模型照抄进理由
|
||
- 理由正文按值遮(报告快照里的值、agent 查询时遮掉的值、换了写法的号码),端到端;组装之后还认得出遮罩值的(第二道
|
||
防线漏了),整句降为无法判断,理由不记,同审批质询的最后一道关
|
||
|
||
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额、手机号都是虚构的。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
import uuid
|
||
|
||
import pytest
|
||
|
||
from app.core import artifact_store
|
||
from app.data import introspect
|
||
from app.data.engine import engines
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource
|
||
from app.engine import masking
|
||
from app.engine.nodes import challenge as challenge_mod
|
||
from tests.fixtures.names import letters
|
||
from tests.test_challenge_node import Models, Play, engine_up, finish, graph, output_of # noqa: F401 - engine_up 是自动用上的夹具
|
||
|
||
#: 四笔订单,买家手机号设了遮罩;备注没设遮罩,其中一条写着换了写法的号码
|
||
SCRIPT = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, refunded INTEGER,"
|
||
" note TEXT);"
|
||
"INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0, '客户 139-0000-0002 代下单'),"
|
||
" (2, '华东', '13900000002', 300, 1, ''), (3, '华南', '13900000003', 900, 0, '例行回访'),"
|
||
" (4, '华北', '13900000004', 400, 0, '');")
|
||
SECRETS = ("1390000000", "0000-0002", "139 0000")
|
||
MASKED = "已遮罩"
|
||
|
||
|
||
@pytest.fixture
|
||
async def masked_shop(tmp_path):
|
||
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript(SCRIPT)
|
||
db.commit()
|
||
db.close()
|
||
name = f"shop_{letters(8)}"
|
||
async with SessionLocal() as session:
|
||
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True,
|
||
options={"mask_columns": ["buyer_phone"]})
|
||
session.add(row)
|
||
await session.flush()
|
||
row.schema_cache = await introspect.introspect(row)
|
||
await session.commit()
|
||
source_id = row.id
|
||
yield name
|
||
await engines.invalidate(source_id)
|
||
|
||
|
||
def _clean(text: str) -> bool:
|
||
return not any(s in text for s in SECRETS)
|
||
|
||
|
||
#: 报告取数:手机号改了名字选出来,备注照选,按号码排序时把号码写进了 SQL
|
||
REPORT_SQL = ("SELECT buyer_phone AS 手机号, note AS 备注, amount AS 金额 FROM sales "
|
||
"ORDER BY buyer_phone = '13900000001' DESC, amount DESC")
|
||
WRITER = ("金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]",)
|
||
CLAIM = "金额最高的一笔"
|
||
|
||
|
||
async def test_the_excerpt_in_the_task_is_masked_before_the_agent_sees_it(monkeypatch, masked_shop):
|
||
"""反方 agent 收到的任务里:改了名字的遮罩列(手机号)不给,备注里换了写法的号码、SQL 里写着的号码换成「已遮罩」,
|
||
金额照常。以前「列:金额 | 手机号」「r0:1,200 | 13900000001」…,四个号码都在。"""
|
||
name = masked_shop
|
||
plays = {CLAIM: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "stands", "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
|
||
models = Models(monkeypatch, name, writer=WRITER, plays=plays)
|
||
row = await finish(graph(name, sql=REPORT_SQL), run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
task = models.tasks()[CLAIM]
|
||
assert "金额最高的一笔 1,200。" in task and "【原报告 Q1】" in task
|
||
excerpt = task[task.index("【原报告 Q1】"):]
|
||
assert _clean(excerpt), excerpt
|
||
columns = next(line for line in excerpt.splitlines() if line.startswith("列:"))
|
||
assert "手机号" not in columns and "金额" in columns and "备注" in columns
|
||
assert "客户 已遮罩 代下单" in excerpt and "buyer_phone = '已遮罩'" in excerpt
|
||
assert "(有的列按数据源的设置遮罩了,没有给出)" in excerpt
|
||
|
||
|
||
async def test_masked_values_the_agent_found_itself_are_masked_in_the_rationale(monkeypatch, masked_shop):
|
||
"""端到端:反方 agent 自己查出的号码(查询时在工具边界遮掉、只在内存里)被它换着写法写进理由:审稿记录里的理由正文、
|
||
理由文档都换成「已遮罩」。报告没有选出号码,值只来自 agent 查询时遮掉的那一份。"""
|
||
name = masked_shop
|
||
top = "SELECT buyer_phone AS 电话, amount AS 金额 FROM sales WHERE region = '华南'"
|
||
said = "华南那笔来自 +86 139 0000 0003,金额 [[v:Q1.r0.金额]]。[[see:Q1]]"
|
||
plays = {CLAIM: Play([top], "stands", said)}
|
||
Models(monkeypatch, name, writer=WRITER, plays=plays)
|
||
row = await finish(graph(name, sql="SELECT region AS 区域, amount AS 金额 FROM sales ORDER BY amount DESC"),
|
||
run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
|
||
[claim] = [c for c in record["claims"] if CLAIM in c["text"]]
|
||
assert claim["verdict"] == "stands" and claim["rationale"] == "华南那笔来自 已遮罩,金额 900。"
|
||
assert claim["rationale_doc"]["markdown"] == claim["rationale"]
|
||
assert "0000 0003" not in json.dumps(record, ensure_ascii=False)
|
||
|
||
|
||
async def test_a_rationale_still_showing_a_masked_value_is_not_kept(monkeypatch, masked_shop):
|
||
"""最后一道关:按值遮之后组装出来的理由里还认得出遮罩值(这里让按值换失手),这句降为无法判断,理由不记、不存,
|
||
原值不进审稿记录。同审批质询的回答。"""
|
||
name = masked_shop
|
||
monkeypatch.setattr(masking, "_scrubbed_source", lambda text, secrets: text)
|
||
said = "号码 13900000001 的那笔属实,金额 [[v:Q1.r0.笔数]]。[[see:Q1]]"
|
||
plays = {CLAIM: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "refuted", said)}
|
||
Models(monkeypatch, name, writer=WRITER, plays=plays)
|
||
row = await finish(graph(name, sql=REPORT_SQL), run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
|
||
[claim] = [c for c in record["claims"] if CLAIM in c["text"]]
|
||
assert claim["verdict"] == "inconclusive" and claim["rationale_doc"] is None
|
||
assert claim["rationale"] == challenge_mod.LEAKED
|
||
assert _clean(json.dumps(record, ensure_ascii=False))
|
||
out = await output_of(row.id, "chal")
|
||
assert out["counts"]["refuted"] == 0, "遮不住的理由不能让出具降档"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 摘录分段遮:阈值、没设遮罩的数值格照原样,和遮罩列做等值比较的字面量都换
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
#: 金额、客户名设了遮罩,金额都是五位数;访问次数没设遮罩,第二、三、四单正好等于别的订单的金额
|
||
BIG = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_name TEXT, amount INTEGER, visits INTEGER);"
|
||
"INSERT INTO sales VALUES (1, '华东', '张伟', 10000, 25000), (2, '华东', '李娜', 12000, 20000),"
|
||
" (3, '华南', '王芳', 20000, 30000), (4, '华北', '刘洋', 30000, 50000), (5, '华北', '赵敏', 50000, 9000);")
|
||
|
||
|
||
@pytest.fixture
|
||
async def big_shop(tmp_path):
|
||
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript(BIG)
|
||
db.commit()
|
||
db.close()
|
||
name = f"shop_{letters(8)}"
|
||
async with SessionLocal() as session:
|
||
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True,
|
||
options={"mask_columns": ["amount", "buyer_name"]})
|
||
session.add(row)
|
||
await session.flush()
|
||
row.schema_cache = await introspect.introspect(row)
|
||
await session.commit()
|
||
source_id = row.id
|
||
yield name
|
||
await engines.invalidate(source_id)
|
||
|
||
|
||
def _excerpt_of(task: str) -> str:
|
||
return task[task.index("【原报告 Q1】"):]
|
||
|
||
|
||
async def test_thresholds_and_unmasked_numbers_in_the_excerpt_are_kept(monkeypatch, big_shop):
|
||
"""报告按金额阈值取数(amount >= 20000,正好等于某一笔被遮的金额),访问次数没设遮罩、第二行正好等于另一笔金额:
|
||
摘录里 SQL 的阈值、访问次数都照原样,金额列不给。以前对渲染好的整段按值换,成了「amount >= 已遮罩」「r1:4 | 华北 |
|
||
已遮罩」,agent 看不到报告按什么取数,没设遮罩的数也被改写。"""
|
||
report = "SELECT id, region, amount, visits FROM sales WHERE amount >= 20000 ORDER BY id"
|
||
claim = "大额订单里访问最多的一笔在华南"
|
||
plays = {claim: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "stands", "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
|
||
models = Models(monkeypatch, big_shop, writer=(f"{claim},访问 [[v:Q1.r0.visits]] 次。[[see:Q1]]",),
|
||
plays=plays)
|
||
row = await finish(graph(big_shop, sql=report), run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
excerpt = _excerpt_of(models.tasks()[claim])
|
||
assert f"SQL:{report}" in excerpt, excerpt
|
||
assert "列:visits | id | region" in excerpt
|
||
assert "r1:50,000 | 4 | 华北" in excerpt and MASKED not in excerpt.split("列:", 1)[1].split("(", 1)[0]
|
||
|
||
|
||
async def test_literals_compared_with_a_masked_column_are_masked_even_when_never_collected(monkeypatch, masked_shop):
|
||
"""报告没选出号码,只在条件里排除了两个(一个换了写法):报告快照里收不到这两个值,摘录的 SQL 照样换掉——和遮罩列
|
||
做等值、IN 比较的字面量不论认不认得出都换。以前按值换只认快照里收到的,号码原样交给 agent。"""
|
||
report = ("SELECT region AS 区域, COUNT(*) AS 笔数 FROM sales WHERE buyer_phone NOT IN ('13900000002', "
|
||
"'139-0000-0003') GROUP BY region ORDER BY 笔数 DESC")
|
||
claim = "笔数最多的区域"
|
||
plays = {claim: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "stands", "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
|
||
models = Models(monkeypatch, masked_shop, writer=(f"{claim}有 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]",), plays=plays)
|
||
row = await finish(graph(masked_shop, sql=report), run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
task = models.tasks()[claim]
|
||
assert _clean(task), task
|
||
assert f"buyer_phone NOT IN ('{MASKED}', '{MASKED}')" in _excerpt_of(task)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 最后一道关只看正文和原文:agent 起的列名、照原样留着的阈值不算露了遮罩值
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
AMOUNTS = "SELECT region AS 区域, amount AS 金额 FROM sales ORDER BY id"
|
||
FIRST = "华东首单金额"
|
||
|
||
|
||
@pytest.mark.parametrize("verdict", ["stands", "refuted"])
|
||
async def test_a_threshold_in_an_alias_does_not_void_the_verdict(monkeypatch, big_shop, verdict):
|
||
"""反方 agent 把阈值写进别名(不低于20000的笔数),20000 恰好是某一笔被遮的金额:结论照常采信,理由照常记。以前最后
|
||
一道关把目录里的列名当成露了遮罩值,整句改判无法判断,「被推翻」也不再让出具降档,日志里多一条误报。"""
|
||
top = "SELECT COUNT(*) AS 不低于20000的笔数 FROM sales WHERE amount >= 20000"
|
||
said = "金额不低于两万的订单有 [[v:Q1.r0.不低于20000的笔数]] 笔,与结论" + ("不矛盾" if verdict == "stands" else "矛盾") \
|
||
+ "。[[see:Q1]]"
|
||
Models(monkeypatch, big_shop, writer=(f"{FIRST} [[v:Q1.r0.金额]]。[[see:Q1]]",),
|
||
plays={FIRST: Play([top], verdict, said)})
|
||
row = await finish(graph(big_shop, sql=AMOUNTS), run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
out = await output_of(row.id, "chal")
|
||
record = artifact_store.load(out["challenge_artifact"])
|
||
[claim] = [c for c in record["claims"] if FIRST in c["text"]]
|
||
assert claim["verdict"] == verdict, claim["rationale"]
|
||
assert claim["rationale"].startswith("金额不低于两万的订单有 3 笔")
|
||
assert out["counts"][verdict] == 1
|
||
|
||
|
||
async def test_a_threshold_the_agent_wrote_in_its_sql_stays_in_the_rationale(monkeypatch, big_shop):
|
||
"""理由正文复述 agent 自己 SQL 里照原样留着的阈值(「不低于 20000 元」,恰好等于某一笔被遮的金额):照原样,同审批质询
|
||
的回答;它随 SQL 存进审稿记录、摆在读者眼前。以前换成「已遮罩」,理由和 SQL 对不上。"""
|
||
top = "SELECT COUNT(*) AS 笔数 FROM sales WHERE amount >= 20000"
|
||
said = "金额不低于 20000 元的订单有 [[v:Q1.r0.笔数]] 笔,与结论不矛盾。[[see:Q1]]"
|
||
Models(monkeypatch, big_shop, writer=(f"{FIRST} [[v:Q1.r0.金额]]。[[see:Q1]]",),
|
||
plays={FIRST: Play([top], "stands", said)})
|
||
row = await finish(graph(big_shop, sql=AMOUNTS), run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
|
||
[claim] = [c for c in record["claims"] if FIRST in c["text"]]
|
||
assert (claim["verdict"], claim["rationale"]) == ("stands", "金额不低于 20000 元的订单有 3 笔,与结论不矛盾。")
|
||
|
||
|
||
async def test_an_alias_naming_a_masked_value_is_renamed_like_the_snapshot(monkeypatch, big_shop):
|
||
"""agent 把找的那个值写进了别名(金额为20000的笔数,20000 是一笔被遮的金额,不是阈值):理由文档的目录列名照快照遮好的
|
||
样子换(同质询的 _renamed_entry),引用照样对得上,结论照常采信,存下的理由文档里没有这个值。以前目录原样传进重新
|
||
组装,最后一道关又把目录拿去认,整句改判无法判断。"""
|
||
top = "SELECT COUNT(*) AS 金额为20000的笔数 FROM sales WHERE amount = 20000"
|
||
said = "金额为两万的订单有 [[v:Q1.r0.金额为20000的笔数]] 笔,与结论不矛盾。[[see:Q1]]"
|
||
Models(monkeypatch, big_shop, writer=(f"{FIRST} [[v:Q1.r0.金额]]。[[see:Q1]]",),
|
||
plays={FIRST: Play([top], "stands", said)})
|
||
row = await finish(graph(big_shop, sql=AMOUNTS), run_class="exploratory")
|
||
assert row.status == "succeeded", row.error
|
||
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
|
||
[claim] = [c for c in record["claims"] if FIRST in c["text"]]
|
||
assert (claim["verdict"], claim["rationale"]) == ("stands", "金额为两万的订单有 1 笔,与结论不矛盾。")
|
||
assert "20000" not in json.dumps(claim["rationale_doc"], ensure_ascii=False)
|