mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
合入期 5 前置修复 K3:核查 agent 的查询结果在工具边界先遮——取自遮罩列的结果列(别名、函数、子查询、WITH、*、UNION、表值函数、整行引用都认)在交给模型的内容和落库的快照里都换成「已遮罩」,模型看不到遮罩值;按值遮降为第二道防线;换了写法的长号码(空格、连字符、点、括号、全角、国家码)在回答、理由、工件和最后一道关里都认得出;认遮罩列的共用部分挪到 engine/masking.py
This commit is contained in:
+210
-990
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@
|
||||
取自的查询算,合并查询按它合并的那几个源算);这句没挂依据,就在这份报告引用的数据源里查。任务里给这句结论
|
||||
和它挂的证据摘录(judge.prepare,数据源遮罩的列照旧不给;摘录里原报告的编号都标成「原报告 Qn」,和 agent 自己
|
||||
的 Q1 分开),要它先想清楚「如果这句不成立,数据里会看到什么」,写只读查询去找,最后交结论(经受住 / 被削弱 /
|
||||
被推翻 / 无法判断)和理由。理由里的数字只能引用它自己查询的格子,裸数字由 compose_doc 标出来
|
||||
被推翻 / 无法判断)和理由。理由里的数字只能引用它自己查询的格子,裸数字由 compose_doc 标出来。
|
||||
遮罩:agent 的查询结果在工具边界就遮好了;这份报告引用的查询快照里的遮罩值(按 SQL 认出改了名字的遮罩列,同审批
|
||||
质询)反查之前就交给查询工具,agent 查到写着同一个号码的别的表时也看不到。摘录按列名遮,改了名字的遮罩列原值
|
||||
agent 看得到,会照着写进理由:理由正文和理由文档按值换成「已遮罩」(换了写法的号码也认),这句结论原文里写着的
|
||||
不换(_masked_rationale)
|
||||
3. 出处:一条查询都没执行成功的,判什么都记为无法判断;判「被推翻」的,理由里还得引用到自己的查询结果——它要让
|
||||
正式出具降档,审稿记录里得查得到凭什么。不采信时系统写明原因,反方原本的结论和理由照录在后面(纯文字:解析
|
||||
不了的引用写成「(引用无效)」,裸数字注明「(无出处)」)。这句该查的数据源都不存在或已停用、核查模型本身用
|
||||
@@ -44,8 +48,10 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Callable, Collection, Iterable
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from langgraph.func import task
|
||||
@@ -372,6 +378,54 @@ def _claim_of(cand: judging.Candidate, outcome: VerifyOutcome) -> tuple[dict[str
|
||||
return _claim(cand, verdict, rationale, doc=outcome.rationale, queries=outcome.queries, usage=outcome.usage), None
|
||||
|
||||
|
||||
def _spared(seed: Any, claim: str) -> Any:
|
||||
"""报告快照里的遮罩值(seed)去掉这句结论原文里写着的,交给这一句的反查。seed 本身不动(下一句另算)。"""
|
||||
known = seed.copy()
|
||||
known.protect([claim])
|
||||
return known
|
||||
|
||||
|
||||
def _masked_rationale(outcome: VerifyOutcome, secrets: Any, *, given: Iterable[str | None]) -> VerifyOutcome:
|
||||
"""反方的理由里的遮罩值换成「已遮罩」,换了写法的号码也认(199-1474-2785、+86 …)。值是这份报告引用的查询快照里的
|
||||
(secrets,反查之前就交给了查询工具),加上 agent 查询时在工具边界遮掉的(outcome.masked_values);被审的那句结论
|
||||
原文里写着的(given)已经摆在读者眼前,不换。反方 agent 的查询结果在工具边界就遮好了,可证据摘录按列名遮,改了名字
|
||||
的遮罩列(buyer_phone AS 手机号)的原值它看得到,会写进理由。
|
||||
换的办法同审批质询的回答(_scrubbed_source):原文里的值先换掉,再照原来的目录重新组装理由文档;引用到的格子按
|
||||
遮好的快照渲染(文字格里夹着的遮罩值换掉那一截)。理由里认不出遮罩值的原样交回。"""
|
||||
from app.engine.evidence import compose_doc
|
||||
from app.engine.masking import _masked_view, _scrubbed_source, _Secrets
|
||||
|
||||
doc, result = outcome.rationale, outcome.result
|
||||
# agent 查询时遮掉的值先收(masked_values 里已经排在报告的值前面):报告的值收满了上限 SECRETS_MAX,也不丢 agent 查到的
|
||||
pool = _Secrets()
|
||||
pool.merge(outcome.masked_values)
|
||||
pool.merge(secrets)
|
||||
pool.protect(given)
|
||||
if result is None or not isinstance(doc, dict) or not pool.strong:
|
||||
return outcome
|
||||
pool.prepare()
|
||||
source = str(doc.get("source") or "")
|
||||
if not (pool.spans(str(doc.get("markdown") or ""), prose=True) or pool.spans(source, prose=True)):
|
||||
return outcome
|
||||
views: dict[str, Any] = {}
|
||||
fetch = _fetcher()
|
||||
for query in outcome.queries:
|
||||
snapshot = fetch(query.get("artifact"))
|
||||
if isinstance(snapshot, dict) and isinstance(snapshot.get("rows"), list):
|
||||
views[str(query["artifact"])] = _masked_view(snapshot, set(), pool, embedded=True)[0]
|
||||
def load(artifact: str) -> Any:
|
||||
return views[artifact] if artifact in views else artifact_store.load(artifact)
|
||||
|
||||
try:
|
||||
again = compose_doc(_scrubbed_source(source, pool), doc.get("catalog") or {},
|
||||
node_id=str(doc.get("node_id") or ""), run_id=doc.get("run_id"), loader=load)
|
||||
except Exception: # noqa: BLE001 - 重新组装出了意外,值也不能留着
|
||||
# 正文按值换掉,理由文档不要了(界面照正文显示;判「被推翻」的因此查不到出处,按无法判断记)
|
||||
said = pool.scrub(str(doc.get("markdown") or ""), prose=True)
|
||||
return replace(outcome, rationale=None, result={**result, RATIONALE: said})
|
||||
return replace(outcome, rationale=again, result={**result, RATIONALE: again["markdown"]})
|
||||
|
||||
|
||||
def _cites(doc: dict[str, Any] | None) -> bool:
|
||||
"""理由文档里有没有一处引用解析到了 agent 自己的查询结果(compose_doc 只把解析成功的引用记进 unit.cites)。"""
|
||||
return isinstance(doc, dict) and any(unit.get("cites") for _, unit in iter_units(doc))
|
||||
@@ -566,6 +620,9 @@ class _Review:
|
||||
# 这句没挂依据时在整份报告引用的数据源里查:写作者看得到的就是这几个库
|
||||
everywhere = claim_sources(self.catalog, self.catalog, self.fetch)
|
||||
usable = await _usable_sources(everywhere)
|
||||
# 报告引用的查询快照里遮罩列的值:反查之前交给查询工具(agent 查到写着同一个号码的别的表时看不到),反查之后
|
||||
# 据此换掉理由里的遮罩值(_masked_rationale)
|
||||
seed = await self._seed()
|
||||
claims: list[dict[str, Any]] = []
|
||||
usage = _zero_usage()
|
||||
model: str | None = None
|
||||
@@ -629,11 +686,14 @@ class _Review:
|
||||
if allot.tightened:
|
||||
self.run_seconds = allot.seconds
|
||||
# 收紧过的:核查 agent 到点自己发的 verify_limit 改说运行时长上限将至(deadline.relabel),不写收紧后的秒数
|
||||
# 这句结论原文里写着的值已经摆在读者眼前,不当遮罩值。上万个值过一遍要几十毫秒,放进线程
|
||||
known = await asyncio.to_thread(_spared, seed, cand.text)
|
||||
outcome = await run_verify_agent(
|
||||
spec=self.spec, system=CHALLENGE_SYSTEM, task=task_text, sources=live,
|
||||
output_schema=CHALLENGE_SCHEMA, max_queries=self.max_queries, timeout_s=allot.timeout_s,
|
||||
daily_max_usd=self.daily_max_usd, emit=deadline.relabel(ctx.emit, allot), run_id=ctx.run.run_id,
|
||||
node_id=ctx.node.id, data_versions=run_versions(ctx.run))
|
||||
node_id=ctx.node.id, data_versions=run_versions(ctx.run), masked_values=known)
|
||||
outcome = await asyncio.to_thread(_masked_rationale, outcome, known, given=[cand.text])
|
||||
if outcome.error and not outcome.model:
|
||||
broken = outcome.error # 连模型都没建成(没配好、接入出错):换一句也一样
|
||||
model = model or outcome.model
|
||||
@@ -709,6 +769,14 @@ class _Review:
|
||||
return {"artifact": artifact, "counts": counts, "usage": usage, "unreviewed": missed,
|
||||
"claims": [{k: c[k] for k in ("unit", "text", "verdict")} for c in claims]}
|
||||
|
||||
async def _seed(self) -> Any:
|
||||
"""这份报告目录里的查询快照里遮罩列的值(engine/masking 的 _Secrets)。收法同审批质询收报告快照
|
||||
(inquiry.snapshot_secrets:按 SQL 认出改了名字的遮罩列,查询当时记下的遮罩加上数据源现在设的)。"""
|
||||
from app.engine.inquiry import snapshot_secrets # inquiry 导入了这个模块,用到时再导入
|
||||
|
||||
return await snapshot_secrets(self.fetch(entry.get("artifact")) for entry in self.catalog.values()
|
||||
if isinstance(entry, dict) and entry.get("kind") == "query")
|
||||
|
||||
async def _store(self, record: dict[str, Any]) -> str | None:
|
||||
try:
|
||||
return await artifact_store.put_json(record, kind="challenge", run_id=self.ctx.run.run_id,
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
连取值也不给;遮罩列不给。计算式只有一项来自查询、或者写了口径说明时不给列名;几项都来自查询又没写口径说明的,列名
|
||||
里看不出日期、口径的取舍时才给,好让它分得清哪一项是哪一项。
|
||||
|
||||
**遮罩列上的输入不复算。** cell() 取自遮罩列的输入不能单独复算;别的输入(计算式里写成当时代进去的值)里按位置取自
|
||||
**遮罩列上的输入不复算。** cell() 取自遮罩列的输入不能单独复算,改了名字、套了函数或汇总的遮罩列(SUM(amount) AS
|
||||
gmv,amount 设了遮罩)按 SQL 认出来也一样(engine/masking.derived_columns):盲算的查询在工具边界按同一套认法遮掉,
|
||||
派出去也取不到数,不白花一次盲算。别的输入(计算式里写成当时代进去的值)里按位置取自
|
||||
查询结果的一格(vars.res.rows[0][2]),顺着取值链认出它取自哪份快照的哪一列(recompute.input_origin,证据面板、推演、
|
||||
可验证报告同一个认法),按需算那份快照要遮的列(和证据面板同一套,见 _Sources.hidden)——它不一定是 cell() 用到的
|
||||
那几份。取自遮罩列、遮没遮核对不了的,整个指标按无法复算记:遮罩值不能当字面量写进交给盲算的计算式。伸进查询结果的行、
|
||||
@@ -75,6 +77,7 @@ from app.engine.expressions import ExpressionError, _cell_call, _chain_root, cel
|
||||
parse_expression
|
||||
from app.engine.replay import _in_graph, ask, once
|
||||
from app.engine.labels import DUAL_CHECK_FIELD_LABEL, DUAL_CHECK_ON_REJECT_LABEL
|
||||
from app.engine.masking import derived_columns
|
||||
from app.engine.schema import DUAL_CHECK_ON_REJECT, NodeType, dual_check_card
|
||||
from app.engine.state import GraphState
|
||||
|
||||
@@ -613,9 +616,15 @@ async def _query_input(item: dict[str, Any], sources: _Sources, aliases: dict[st
|
||||
value = cell_value(item.get("value"))
|
||||
if not _number(value):
|
||||
return {}, f"输入 {item['path']} 不是数值"
|
||||
if column.lower() in await sources.hidden(artifact):
|
||||
hidden = await sources.hidden(artifact)
|
||||
if column.lower() in hidden:
|
||||
return {}, f"输入 {item['path']} 取自设置了遮罩的列,不能用于复算"
|
||||
found = await sources.source(source)
|
||||
derived = derived_columns(snap, set(hidden), found.kind if found is not None else None) if hidden else None
|
||||
if derived and column.lower() in derived:
|
||||
# 改了名字、套了函数或汇总的遮罩列(SUM(amount) AS gmv):盲算的查询在工具边界按同一套认法遮掉(engine/masking),
|
||||
# 交回的格子注定取不到,不白派一次盲算。SQL 解析不了的认不准,照常派
|
||||
return {}, f"输入 {item['path']} 由设置了遮罩的列算出(经改名、函数或汇总),盲算时会被遮罩,不能用于复算"
|
||||
if found is None or not found.enabled:
|
||||
return {}, f"数据源「{source}」不存在或已停用"
|
||||
return {"path": item["path"], "key": f"{artifact}|{row}|{column}", "value": value,
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
**只读。** 工具只建给定数据源的 db_schema / db_query,建的时候 force_readonly:可写源上的写语句也被守卫拒绝
|
||||
(tools/datasource.py)。这一路没有审批关卡,不能指望人在半路拦下一条 DELETE。
|
||||
|
||||
**先遮再交。** 建查询工具时还打开遮罩开关(tools/datasource 的 mask_results):数据源设了遮罩时,取自遮罩列的格子
|
||||
(按 SQL 认,起别名、套函数、经子查询或 WITH 转一道、SELECT *(展开的列换了名字的也算)、表值函数拆开的都算)在交给
|
||||
模型、落进查询快照之前就换成「已遮罩」,别的格子按值换:这份结果里的遮罩值、前面的查询遮掉的、调用方交进来的
|
||||
(masked_values:审批质询、反方审稿收的报告快照里的值),文字格里夹着的换那一截(engine/masking.py 的
|
||||
mask_query_result);查询失败时报错原话里的值也换。模型看不到遮罩值,就不会把它写进回答、理由、SQL;以前是事后
|
||||
按值认,每一轮复核都找到新的漏法。快照里的 masked_at_query 记下查询时就遮了的列,格子引用指到这几列、指到按值换掉
|
||||
的格子的都不采信。遮掉的值只在内存里交回调用方(VerifyOutcome.masked_values,审批质询、反方审稿按值遮的第二道防线
|
||||
用),不进快照、不交给模型、不进 as_dict。调用方交进来的和查询时遮掉的分开记(masking.SeenValues),合起来时 agent
|
||||
查到的在前:合起来到了上限 SECRETS_MAX,挤掉的是报告里的值。
|
||||
|
||||
**数从快照取。** 每次成功的查询是一份查询快照(工具自己落的 put_json),按这个 agent 自己的顺序编 Q1、Q2…。
|
||||
最后交结果时,调用方结构里的数值字段一律改成格子引用 {alias, row, column},由代码按引用从快照里取值:模型自己
|
||||
写的数字不采信,取不到就是 None,不兜底成 0。数值字段的各种写法都认(type 列表、anyOf 里的数、$ref 引用进来的、
|
||||
@@ -45,7 +55,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
@@ -59,13 +69,14 @@ from app.engine import spend
|
||||
from app.engine.evidence import build_catalog, cell_eid, compose_doc, query_entry_fields, schema_ledger_entry
|
||||
from app.engine.expressions import CellError, column_kind, locate_cell, numeric_text
|
||||
from app.engine.judge import FALLBACK_MAX_TOKENS, MAX_TOKENS, _json_in, _tokens, _usd
|
||||
from app.engine.masking import SeenValues, _masked_word, _Secrets
|
||||
from app.engine.nodes.llm import _DIGEST_BUDGET, _digest, _split_structured, _typed, explain_model_error
|
||||
from app.engine.state import message_text
|
||||
from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded
|
||||
from app.providers import catalog as pricing
|
||||
from app.providers.budget import BudgetFallback, budget_refused, cut_off, cut_words, error_usage, truncated_error
|
||||
from app.providers.factory import ModelSpec, ProviderNotConfigured, bind_tools_safely, get_chat_model
|
||||
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX, build_datasource_tools
|
||||
from app.tools.datasource import MASKED_VALUES_KEY, QUERY_PREFIX, SCHEMA_PREFIX, build_datasource_tools
|
||||
from app.tools.registry import ToolArgsError, ToolContext, Versions, args_model_of, prepare_args
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -103,6 +114,8 @@ VERIFY_SYSTEM = (
|
||||
"- 先用 db_schema 工具看表结构,确认表名、字段名,再用 db_query 工具写只读 SQL。不能改数据,写语句会被拒绝\n"
|
||||
"- 每次成功的查询前面都标着编号【证据 Qn】,最后交结果时用它说明每个数取自哪次查询的哪一格\n"
|
||||
"- {limit}先想清楚要查什么,不要重复同样的查询\n"
|
||||
"- 数据源设了遮罩的列(手机号、姓名这类),查询结果里显示为「已遮罩」:不要设法绕过,交结果、写理由时也不要引用"
|
||||
"这些格子\n"
|
||||
"- 不要编造数字,查不到就照实说"
|
||||
)
|
||||
VERIFY_FINAL_PROMPT = (
|
||||
@@ -149,6 +162,9 @@ class VerifyOutcome:
|
||||
output_tokens, total_tokens, cost_usd, calls, unpriced_calls}。limit:触顶原因(LIMITS 之一),没触顶为 None。
|
||||
answered:模型是不是自己停下来的(没被上限掐断,也没因为输出到了上限被截断)。被截断的一律没有结果,error 是
|
||||
TRUNCATED_ERROR。
|
||||
masked_values:查询时在工具边界遮掉的值(engine/masking 的 _Secrets),连同调用方交进来的:agent 查到的在前,合起来
|
||||
到了上限 SECRETS_MAX 挤掉的是调用方的(SeenValues.pooled)。只在内存里交给调用方按值遮(审批质询的回答、反方审稿的
|
||||
理由),不进 as_dict、不往外交。
|
||||
"""
|
||||
|
||||
result: dict[str, Any] | None
|
||||
@@ -162,9 +178,12 @@ class VerifyOutcome:
|
||||
answered: bool = False
|
||||
notes: list[str] = field(default_factory=list)
|
||||
duration_ms: int = 0
|
||||
masked_values: Any = field(default=None, repr=False, compare=False)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
out = asdict(replace(self, masked_values=None))
|
||||
out.pop("masked_values", None)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -407,6 +426,18 @@ class _Cells:
|
||||
self.cells.append({"path": path, "status": "unresolved", "alias": alias, "artifact": query["artifact"],
|
||||
"reason": f"{alias} {e}"})
|
||||
return None
|
||||
if str(name).lower() in _masked_at_query(content):
|
||||
# 查询时就遮了的列:格子里是「已遮罩」,不是数据源里的值。照实说是遮罩列,不说成「不是数值」
|
||||
self.cells.append({"path": path, "status": "unresolved", "ref": f"{alias}.r{row}.{name}", "alias": alias,
|
||||
"row": row, "column": name, "artifact": query["artifact"],
|
||||
"reason": f"{alias} 的「{name}」取自设置了遮罩的列,查询时已遮罩,不采信"})
|
||||
return None
|
||||
if raw == _masked_word() and isinstance(content, dict) and "masked_at_query" in content:
|
||||
# 这一格查询时按值换掉了(和别的查询、报告里的遮罩值相同):同样照实说
|
||||
self.cells.append({"path": path, "status": "unresolved", "ref": f"{alias}.r{row}.{name}", "alias": alias,
|
||||
"row": row, "column": name, "artifact": query["artifact"],
|
||||
"reason": f"{alias} 第 {row} 行「{name}」与设置了遮罩的值相同,查询时已遮罩,不采信"})
|
||||
return None
|
||||
truth = _typed(raw, {"type": sorted(types)}, column_kind(content, name))
|
||||
cited = {"ref": f"{alias}.r{row}.{name}", "alias": alias, "row": row, "column": name,
|
||||
"artifact": query["artifact"], "eid": cell_eid(query["artifact"], row, name), "value": truth}
|
||||
@@ -502,6 +533,12 @@ class _Cells:
|
||||
return None
|
||||
|
||||
|
||||
def _masked_at_query(content: Any) -> set[str]:
|
||||
"""查询快照里查询时就整列遮掉的列(小写,tools/datasource 记下的 masked_at_query)。"""
|
||||
listed = content.get("masked_at_query") if isinstance(content, dict) else None
|
||||
return {c.lower() for c in listed if isinstance(c, str)} if isinstance(listed, list) else set()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 花费
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -636,6 +673,9 @@ class _State:
|
||||
queries: list[dict[str, Any]] = field(default_factory=list)
|
||||
ledger: list[dict[str, Any]] = field(default_factory=list)
|
||||
model: str | None = None
|
||||
#: 认出的遮罩值(只在内存里):调用方交进来的,加上查询工具在工具边界遮掉的(经 ToolContext.extra 并进来)。后面的
|
||||
#: 查询按它们按值遮,最后合起来交回调用方(run_verify_agent),agent 查到的在前
|
||||
seen: SeenValues = field(default_factory=SeenValues)
|
||||
|
||||
def outcome(self, **fields: Any) -> VerifyOutcome:
|
||||
usage = self.usage
|
||||
@@ -665,6 +705,7 @@ async def run_verify_agent(
|
||||
run_id: str = "",
|
||||
node_id: str = "",
|
||||
data_versions: dict[str, Any] | Versions | None = Versions.UNSET,
|
||||
masked_values: Any = None,
|
||||
) -> VerifyOutcome:
|
||||
"""让一个只读 agent 查库,按 output_schema 交回有出处的结果。永远不抛。
|
||||
|
||||
@@ -679,16 +720,27 @@ async def run_verify_agent(
|
||||
tool.end(role: verify,查询带 query_artifact);不在节点里(质询接口)就不传,什么都不发。
|
||||
- run_id / node_id:查询快照、表结构快照记在谁名下。data_versions:上传表格用哪一版(运行固定的那份
|
||||
data_versions;不在运行里传 Versions.CURRENT;缺省 UNSET 时上传源一律报错,不拿当前版本顶上)。
|
||||
- masked_values:调用方已经认出的遮罩值(engine/masking 的 _Secrets,审批质询、反方审稿收的报告快照里的值)。
|
||||
查询工具从第一条查询起就按它们按值遮:agent 查到的备注、别的表里写着同一个号码时,模型看不到。随查询时遮掉的值
|
||||
一起交回(VerifyOutcome.masked_values)。不改它。
|
||||
"""
|
||||
state = _State(started=time.monotonic())
|
||||
state = _State(started=time.monotonic(), seen=SeenValues(
|
||||
given=masked_values if masked_values is not None else _Secrets()))
|
||||
try:
|
||||
return await _verify(state, spec=spec, system=system, task=task, sources=sources,
|
||||
output_schema=output_schema, max_queries=max_queries, timeout_s=timeout_s,
|
||||
max_cost_usd=max_cost_usd, daily_max_usd=daily_max_usd, max_steps=max_steps, emit=emit,
|
||||
run_id=run_id, node_id=node_id, data_versions=data_versions)
|
||||
out = await _verify(state, spec=spec, system=system, task=task, sources=sources,
|
||||
output_schema=output_schema, max_queries=max_queries, timeout_s=timeout_s,
|
||||
max_cost_usd=max_cost_usd, daily_max_usd=daily_max_usd, max_steps=max_steps, emit=emit,
|
||||
run_id=run_id, node_id=node_id, data_versions=data_versions)
|
||||
except Exception as e: # noqa: BLE001 - 永远不抛:库写不进去、建工具出错这类意外也落在返回值里
|
||||
logger.warning("核查出错", exc_info=True)
|
||||
return state.outcome(error=f"核查出错({describe_exception(e)})")
|
||||
out = state.outcome(error=f"核查出错({describe_exception(e)})")
|
||||
try:
|
||||
# agent 查到的在前、调用方交进来的在后:上万个值合一遍要几十毫秒,放进线程,别挡住同一进程里别的运行
|
||||
pooled = await asyncio.to_thread(state.seen.pooled)
|
||||
except Exception: # noqa: BLE001 - 永远不抛:合不起来就只交调用方交进来的(agent 的查询结果在工具边界已经遮过)
|
||||
logger.warning("核查遮掉的值合并出错", exc_info=True)
|
||||
pooled = state.seen.given
|
||||
return replace(out, masked_values=pooled)
|
||||
|
||||
|
||||
async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sources: Iterable[str],
|
||||
@@ -728,12 +780,13 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou
|
||||
notes.append(note)
|
||||
await _announce(emit, EventType.LOG, level="warn", code="verify_unpriced", message=note)
|
||||
|
||||
# ---- 工具:只读的 db_schema / db_query
|
||||
# ---- 工具:只读的 db_schema / db_query,查询结果先遮再交给模型(遮掉的值并进 state.seen)
|
||||
wanted = list(dict.fromkeys(s for s in sources if isinstance(s, str) and s))
|
||||
ctx = ToolContext(run_id=run_id, node_id=node_id, data_versions=data_versions, emit=emit)
|
||||
ctx = ToolContext(run_id=run_id, node_id=node_id, data_versions=data_versions, emit=emit,
|
||||
extra={MASKED_VALUES_KEY: state.seen})
|
||||
async with SessionLocal() as session:
|
||||
tools = await build_datasource_tools([f"{p}{s}" for s in wanted for p in (SCHEMA_PREFIX, QUERY_PREFIX)], ctx,
|
||||
session, force_readonly=True)
|
||||
session, force_readonly=True, mask_results=True)
|
||||
tool_map = {t.name: t for t in tools}
|
||||
if lost := [s for s in wanted if f"{QUERY_PREFIX}{s}" not in tool_map]:
|
||||
notes.append("数据源" + "、".join(f"「{s}」" for s in lost) + "不存在或已停用,核查没有查它")
|
||||
|
||||
@@ -53,6 +53,10 @@ RUN_VERSIONS_KEY = "data_versions"
|
||||
DATA_UNAVAILABLE = "查询失败(数据不可用):"
|
||||
#: 运行中途补固定一个源时发的那条日志事件的 code(_pin_late)
|
||||
LATE_PIN_CODE = "data_version_pinned"
|
||||
#: ToolContext.extra 里放「认出的遮罩值」的键(engine/masking 的 SeenValues:调用方交进来的,加上查询时遮掉的):核查
|
||||
#: agent(engine/verify_agent.py)放进去,打开了遮罩开关的查询工具按它们按值遮,再把每次遮掉的值并进去。只在内存里给
|
||||
#: 调用方按值遮用,不进快照、不交给模型
|
||||
MASKED_VALUES_KEY = "masked_values"
|
||||
|
||||
#: 每次查询之后基于数据目录的 SQL 检查最多等这么久。正常一条 SQL 几毫秒;等不到就放弃这一次检查,
|
||||
#: 查询结果照常交回——检查是附加的,不能拖慢查询
|
||||
@@ -199,7 +203,8 @@ def _query_description(source: Any, *, readonly: bool | None = None) -> str:
|
||||
|
||||
|
||||
async def build_datasource_tools(
|
||||
names: list[str], ctx: ToolContext, session: AsyncSession, *, force_readonly: bool = False
|
||||
names: list[str], ctx: ToolContext, session: AsyncSession, *, force_readonly: bool = False,
|
||||
mask_results: bool = False,
|
||||
) -> list[StructuredTool]:
|
||||
"""按工具名反查数据源并构造工具。build_tools 的第四个来源。
|
||||
|
||||
@@ -209,6 +214,9 @@ async def build_datasource_tools(
|
||||
|
||||
force_readonly:查询工具一律按只读守卫检查,可写源上的写语句也拒绝(见 _make_query_tool)。
|
||||
核查(engine/verify_agent.py)建工具时传它:那一路没有审批关卡,绝不能写。
|
||||
|
||||
mask_results:查询结果交给模型、落进快照之前先遮(见 _make_query_tool)。核查建工具时也传它:模型看不到遮罩列的
|
||||
值,就不会把它们写进回答、理由。缺省关着:Agent 节点、调用工具节点的查询照旧原样。
|
||||
"""
|
||||
wanted: dict[str, list[str]] = {}
|
||||
for name in names:
|
||||
@@ -238,7 +246,8 @@ async def build_datasource_tools(
|
||||
entries = await _catalog_of(row)
|
||||
for prefix in prefixes:
|
||||
if prefix == QUERY_PREFIX:
|
||||
tools.append(_make_query_tool(source, ctx, fixed=fixed, catalog=entries, force_readonly=force_readonly))
|
||||
tools.append(_make_query_tool(source, ctx, fixed=fixed, catalog=entries, force_readonly=force_readonly,
|
||||
mask_results=mask_results))
|
||||
else:
|
||||
tools.append(_make_schema_tool(source, fixed=fixed, catalog=entries))
|
||||
return tools
|
||||
@@ -450,7 +459,7 @@ def _unavailable_tool(row: DataSource, prefix: str, reason: str) -> StructuredTo
|
||||
|
||||
def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None,
|
||||
force_readonly: bool = False) -> StructuredTool:
|
||||
force_readonly: bool = False, mask_results: bool = False) -> StructuredTool:
|
||||
"""source 是手工源的 DataSource,或上传源绑定快照的 SourceView(字段同名)。
|
||||
|
||||
fixed:上传源的版本是这次运行固定的(找不到时的说法不同)。
|
||||
@@ -458,6 +467,10 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
force_readonly:不管源可不可写,一律按只读守卫检查,写语句交回 FORCED_READONLY、不执行。只靠守卫这一道:
|
||||
连接池按源缓存、不分只读与否(engine.cache_key),可写源借不到连接层只读的连接——别为此另起一个只读池,
|
||||
缓存键相同,会把这个源平时用的可写连接也换成只读的。
|
||||
mask_results:数据源设了遮罩时,取自遮罩列的格子(按 SQL 认,改了名字的也算)在交给模型、落进快照之前就换成
|
||||
「已遮罩」,见 engine/masking.py 的 mask_query_result;这一次遮掉的值并进 ctx.extra[MASKED_VALUES_KEY](有的话)。
|
||||
那里已经有的值(这个 agent 前面的查询遮掉的、调用方交进来的)这一次也按值换,数据源没设遮罩也一样:先查号码、
|
||||
再查写着号码的备注或别的库,后一份同样遮好。查询失败时报错原话里的值也换(masking.mask_error_text)。
|
||||
"""
|
||||
# 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句,
|
||||
# 引擎按它告诉界面上限是多少(metadata["timeout_s"])
|
||||
@@ -485,6 +498,12 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
if is_write(statement, dialect=(source.kind or "").lower()):
|
||||
return f"SQL 被拒绝:{FORCED_READONLY}"
|
||||
limits = QueryLimits(timeout_seconds=seconds, **({"max_rows": int(limit)} if limit else {}))
|
||||
mask = masked_columns(source.options)
|
||||
# 此前认出的遮罩值合起来的另一份(agent 前面的查询遮掉的在前,调用方交进来的在后,见 masking.SeenValues):线程里
|
||||
# 读的时候这边不会再往里并。上万个值合一遍要几十毫秒,放进线程
|
||||
sink = ctx.extra.get(MASKED_VALUES_KEY) if mask_results else None
|
||||
known = await asyncio.to_thread(sink.pooled) if sink is not None else None
|
||||
known = known if known is not None and known.strong else None
|
||||
try:
|
||||
result = await run_query(source, sql, limits=limits)
|
||||
except SqlRejected as e:
|
||||
@@ -501,10 +520,29 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
|
||||
# 驱动的原话留着("no such table: x" 正是模型改写 SQL 要的线索),
|
||||
# 类名和 SQLAlchemy 的包装前缀去掉:这一行也会原样出现在运行面板上
|
||||
return f"查询失败:{first_line(e) or explain(e)[0]}"
|
||||
said = first_line(e) or explain(e)[0]
|
||||
if mask_results and (mask or known is not None):
|
||||
from app.engine.masking import mask_error_text
|
||||
|
||||
# 数据库会把转换失败的值写进报错(invalid input syntax for type bigint: "139-0000-0001")
|
||||
said = mask_error_text(said, sql, known)
|
||||
return f"查询失败:{said}"
|
||||
|
||||
payload = result.to_payload()
|
||||
payload["source"] = source.name
|
||||
# 查询时就整列遮掉的结果列;没开遮罩开关、数据源没设遮罩又没有可按值换的时为 None(快照和原来一字不差)
|
||||
masked_now: list[str] | None = None
|
||||
if mask_results and (mask or known is not None):
|
||||
from app.engine.masking import mask_query_result
|
||||
|
||||
# 按 SQL 认列、逐格比对是纯计算,放进线程(同 _sql_checks)
|
||||
done = await asyncio.to_thread(mask_query_result, payload, mask, source.kind, known,
|
||||
schema=source.schema_cache)
|
||||
if mask or done.payload is not payload:
|
||||
payload, masked_now = done.payload, done.columns
|
||||
if sink is not None:
|
||||
# 并进 agent 查到的那一份(不和调用方交进来的混在一起,上限收满了也不丢),也放进线程
|
||||
await asyncio.to_thread(sink.add, done.secrets)
|
||||
# 对照数据目录检查这条 SQL(一对多关联后重复计算、存量跨期求和……)。结果交给模型(Agent 据此改写),
|
||||
# 也随查询快照存下:证据面板看得到,口径卡据此给指标标「存疑」、出具降档。没查出问题不加这个键,
|
||||
# 快照和以前一字不差
|
||||
@@ -518,14 +556,19 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
try:
|
||||
from app.core.artifact_store import put_json
|
||||
|
||||
# 只进快照、不进交给模型的结果的两样(模型看到多余的字段只会误会:以为数据被遮了,
|
||||
# 只进快照、不进交给模型的结果的几样(模型看到多余的字段只会误会:以为数据被遮了,
|
||||
# 或者把一串十六进制抄进报告):
|
||||
# - 查询当时数据源设的遮罩列:数据源事后改名、删掉,证据面板仍按当时的遮;
|
||||
# - 查询当时数据源设的遮罩列:数据源事后改名、删掉,证据面板仍按当时的遮。只记数据源设的列名——证据面板把
|
||||
# 同源各份快照记的这一项并起来,从表的字段清单里去掉,混进结果列的别名会把同名的真实字段也去掉;
|
||||
# - masked_at_query:开了遮罩开关时,查询时就整列遮掉的结果列(这几列的格子是「已遮罩」,不是数据源里的值,
|
||||
# 改了名字的也在这里)。有这一项的快照,别的格子整格是「已遮罩」的是查询时按值换掉的;
|
||||
# - data_version:上传表格查的是哪个快照,事后据此认得出这个数出自哪一版数据。
|
||||
# 手工源、没设遮罩的,快照和原来一字不差
|
||||
extra: dict[str, Any] = {}
|
||||
if mask := masked_columns(source.options):
|
||||
if mask:
|
||||
extra["mask_columns"] = mask
|
||||
if masked_now is not None:
|
||||
extra["masked_at_query"] = masked_now
|
||||
if version := getattr(source, "snapshot_id", None):
|
||||
extra["data_version"] = version
|
||||
# 工件引用的 meta 记下源和 SQL 里用到的表:数据目录按它数每张表被运行查询过几次
|
||||
|
||||
@@ -1102,6 +1102,114 @@ async def test_the_task_gives_the_claim_its_evidence_and_only_its_sources(monkey
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_the_challenge_agent_never_sees_masked_values(monkeypatch, tmp_path):
|
||||
"""反查 agent 自己的查询选出了遮罩列(起了别名):交给它的工具结果、它的查询快照里都是「已遮罩」,理由里引用到那一格
|
||||
渲染出来也是「已遮罩」;审稿记录、运行事件里都没有原值。以前查询结果原样交给模型、原样存下,理由也原样渲染。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": ["buyer_phone"]})
|
||||
try:
|
||||
top = "SELECT buyer_phone AS 电话, amount AS 金额 FROM sales WHERE region = '华东' ORDER BY amount DESC"
|
||||
plays = {"居各区域之首": Play([top], "stands", "华东最大一笔来自 [[v:Q1.r0.电话]],金额 [[v:Q1.r0.金额]]。[[see:Q1]]")}
|
||||
models = Models(monkeypatch, name, plays=plays)
|
||||
row = await finish(graph(name), run_class="exploratory")
|
||||
assert row.status == "succeeded", row.error
|
||||
seen = [str(m.content) for _, key, messages in models.calls if key == "居各区域之首"
|
||||
for m in messages if isinstance(m, ToolMessage)]
|
||||
assert seen and any("已遮罩" in s for s in seen) and all("1390000000" not in s for s in seen)
|
||||
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
|
||||
[claim] = [c for c in record["claims"] if "居各区域之首" in c["text"]]
|
||||
assert claim["verdict"] == "stands" and claim["rationale"] == "华东最大一笔来自 已遮罩,金额 1,200。"
|
||||
snapshot = artifact_store.load(claim["queries"][0]["artifact"])
|
||||
assert {r[0] for r in snapshot["rows"]} == {"已遮罩"} and snapshot["masked_at_query"] == ["电话"]
|
||||
assert "1390000000" not in json.dumps(record, ensure_ascii=False)
|
||||
async with SessionLocal() as session:
|
||||
evs = (await session.execute(select(RunEvent).where(RunEvent.run_id == row.id))).scalars().all()
|
||||
assert evs and all("1390000000" not in json.dumps(e.data, ensure_ascii=False) for e in evs)
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_masked_values_written_into_the_rationale_are_masked_in_any_shape(monkeypatch, tmp_path):
|
||||
"""复核第 1 轮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号),摘录按列名遮不住,反方 agent 看得到号码,
|
||||
把它换个写法写进理由(139-0000-0001、+86 139 0000 0002)。审稿记录里的理由正文、理由文档都换成「已遮罩」:值从这句
|
||||
挂的报告快照里按 SQL 认出来收(和审批质询收报告快照的办法一样),连同 agent 查询时在工具边界遮掉的值;同时报告
|
||||
快照里的值在反查之前就交给查询工具,agent 查到写着同一个号码的别的表时也看不到。以前理由原样存下、随运行封存。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": ["buyer_phone"]})
|
||||
try:
|
||||
sql = "SELECT region AS 区域, buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY id"
|
||||
said = "来自 139-0000-0001 号码的那笔属实,另有 +86 139 0000 0002 的一笔。"
|
||||
plays = {"华东有一笔": Play(["SELECT COUNT(*) AS 笔数 FROM sales WHERE region = '华东'"], "stands", said)}
|
||||
Models(monkeypatch, name, writer=("华东有一笔 [[v:Q1.r0.金额]] 的订单。[[see:Q1]]",), plays=plays)
|
||||
row = await finish(graph(name, sql=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 "华东有一笔" in c["text"]]
|
||||
assert claim["verdict"] == "stands"
|
||||
assert claim["rationale"] == "来自 已遮罩 号码的那笔属实,另有 已遮罩 的一笔。"
|
||||
assert claim["rationale_doc"]["markdown"] == claim["rationale"]
|
||||
assert claim["rationale_doc"]["source"] == claim["rationale"], "存下的原文也换掉"
|
||||
shown = json.dumps(record, ensure_ascii=False)
|
||||
assert "0000-0001" not in shown and "0000 0002" not in shown
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_the_challenge_agent_does_not_see_report_values_in_other_tables(monkeypatch, tmp_path):
|
||||
"""报告取数选了手机号(遮罩列);反方 agent 去查回访记录,备注里写着同一个号码(备注没设遮罩)。报告快照里的遮罩值
|
||||
在反查之前就交给了查询工具:交给模型的备注里那一截已经是「已遮罩」,它的查询快照里也是。以前原样交给模型。"""
|
||||
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript(
|
||||
"CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, refunded INTEGER);"
|
||||
"INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0), (2, '华南', '13900000003', 900, 0);"
|
||||
"CREATE TABLE callbacks (id INTEGER PRIMARY KEY, region TEXT, note TEXT);"
|
||||
"INSERT INTO callbacks VALUES (1, '华东', '客户 139-0000-0001 来电改址'), (2, '华南', '例行回访');")
|
||||
db.commit()
|
||||
db.close()
|
||||
name = f"shop_{uuid.uuid4().hex[: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
|
||||
try:
|
||||
sql = "SELECT region AS 区域, buyer_phone, amount AS 金额 FROM sales ORDER BY id"
|
||||
notes = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id"
|
||||
plays = {"华东有一笔": Play([notes], "stands", "回访记录里有 [[v:Q1.r0.备注]]。[[see:Q1]]")}
|
||||
models = Models(monkeypatch, name, writer=("华东有一笔 [[v:Q1.r0.金额]] 的订单。[[see:Q1]]",), plays=plays)
|
||||
row = await finish(graph(name, sql=sql), run_class="exploratory")
|
||||
assert row.status == "succeeded", row.error
|
||||
seen = [str(m.content) for _, key, messages in models.calls if key == "华东有一笔"
|
||||
for m in messages if isinstance(m, ToolMessage)]
|
||||
assert any("客户 已遮罩 来电改址" in s for s in seen) and all("0000-0001" not in s for s in seen)
|
||||
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
|
||||
[claim] = [c for c in record["claims"] if "华东有一笔" in c["text"]]
|
||||
assert claim["rationale"] == "回访记录里有 客户 已遮罩 来电改址。"
|
||||
snapshot = artifact_store.load(claim["queries"][0]["artifact"])
|
||||
assert snapshot["rows"] == [["华东", "客户 已遮罩 来电改址"], ["华南", "例行回访"]]
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
def test_values_written_in_the_claim_itself_are_kept_in_the_rationale():
|
||||
"""被审的那句结论原文里写着的号码已经摆在读者眼前:理由里复述它不换,别的号码照换。"""
|
||||
from app.engine.evidence import compose_doc
|
||||
from app.engine.masking import _Secrets
|
||||
from app.engine.verify_agent import VerifyOutcome
|
||||
|
||||
secrets = _Secrets()
|
||||
secrets.add({"columns": ["p"], "rows": [["13900000001"], ["13900000003"]]}, {"p"})
|
||||
doc = compose_doc("号码 13900000003 的客户在华南,139-0000-0001 不在。", {}, node_id="chal", run_id="r1")
|
||||
outcome = VerifyOutcome(result={"verdict": "stands", "rationale": doc["markdown"]}, cells=[], queries=[],
|
||||
rationale=doc, usage={})
|
||||
masked = challenge_mod._masked_rationale(outcome, secrets, given=["号码 13900000003 的客户在华南。"])
|
||||
assert masked.result["rationale"] == "号码 13900000003 的客户在华南,已遮罩 不在。"
|
||||
assert masked.rationale["markdown"] == masked.result["rationale"] and masked.rationale["node_id"] == "chal"
|
||||
assert challenge_mod._masked_rationale(outcome, _Secrets(), given=[]) is outcome, "没有遮罩值时原样"
|
||||
|
||||
|
||||
async def test_a_metric_excerpt_marks_the_report_query_numbers_too(monkeypatch, shop):
|
||||
"""句子挂的是口径卡指标(正式报告的主路径「取数 → 口径卡 → 报告」):摘录正文里「取自查询 Q1」「查询 Q1 的 SQL」
|
||||
也标成原报告的编号,和反查 agent 自己的 Q1 分开;工具按指标的输入取自的查询找源。"""
|
||||
@@ -1297,3 +1405,26 @@ async def test_the_node_refuses_the_same_bad_settings_at_run_time():
|
||||
with pytest.raises(NodeError) as caught:
|
||||
await challenge_mod.run_challenge({"nodes": {}}, ctx)
|
||||
assert str(caught.value) == message
|
||||
|
||||
|
||||
def test_values_the_agent_found_are_masked_in_the_rationale_when_the_report_fills_the_cap(monkeypatch):
|
||||
"""复核第 2 轮:报告快照里的遮罩值收满了上限 SECRETS_MAX,反方 agent 查询时遮掉的号码照样认得出(agent 查到的先收)。
|
||||
以前先收报告的值、满了就停,agent 查到的号码写进理由原样存下。"""
|
||||
from app.engine import masking
|
||||
from app.engine.evidence import compose_doc
|
||||
from app.engine.verify_agent import VerifyOutcome
|
||||
|
||||
monkeypatch.setattr(masking, "SECRETS_MAX", 40)
|
||||
secrets = masking._Secrets()
|
||||
secrets.add({"columns": ["p"], "rows": [[str(13800000000 + i)] for i in range(40)]}, {"p"})
|
||||
assert len(secrets.weak) == masking.SECRETS_MAX
|
||||
found = masking._Secrets()
|
||||
found.add({"columns": ["p"], "rows": [["19914742785"]]}, {"p"})
|
||||
# 和 run_verify_agent 交回的一样:agent 查到的在前,调用方交进去的在后
|
||||
seen = masking.SeenValues(given=secrets)
|
||||
seen.add(found)
|
||||
doc = compose_doc("号码 199-1474-2785 的客户在华南。", {}, node_id="chal", run_id="r1")
|
||||
outcome = VerifyOutcome(result={"verdict": "stands", "rationale": doc["markdown"]}, cells=[], queries=[],
|
||||
rationale=doc, usage={}, masked_values=seen.pooled())
|
||||
masked = challenge_mod._masked_rationale(outcome, secrets, given=[])
|
||||
assert masked.result["rationale"] == "号码 已遮罩 的客户在华南。"
|
||||
|
||||
@@ -1034,6 +1034,59 @@ async def test_a_blind_cell_on_a_masked_column_is_not_used(monkeypatch, masked_s
|
||||
assert "30.25" not in json.dumps(out, ensure_ascii=False)
|
||||
|
||||
|
||||
async def test_a_renamed_masked_column_in_the_blind_query_is_masked_before_the_blind_agent_sees_it(
|
||||
monkeypatch, masked_shop):
|
||||
"""盲算的查询选出了改了名字的遮罩列(region AS 区域):交给盲算模型、落进盲算快照之前就是「已遮罩」,快照记下查询
|
||||
时遮了哪一列;没碰遮罩列的那一列(销售额)照常取值、照常比对。"""
|
||||
name, _ = masked_shop
|
||||
east = {**GMV, "id": "east", "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
|
||||
renamed = (f"SELECT region AS 区域, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {WEEK} "
|
||||
"GROUP BY region ORDER BY region")
|
||||
Blind(monkeypatch, name, {"华东销售额": (renamed, "gmv")})
|
||||
run = await start(graph(name, BY_REGION, metrics=[east], dual={"definitions": {}}))
|
||||
assert run.status == "succeeded", run.error
|
||||
evs = await events(run.id)
|
||||
out = dual_output(run, evs)
|
||||
assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed"
|
||||
[end] = [e.data for e in evs if e.type == "tool.end" and e.node_id == "dual" and e.data.get("query_artifact")]
|
||||
snapshot = artifact_store.load(end["query_artifact"])
|
||||
assert {row[0] for row in snapshot["rows"]} == {"已遮罩"} and snapshot["masked_at_query"] == ["区域"]
|
||||
shown = json.loads(end["preview"].split("\n", 1)[1])
|
||||
assert {row[0] for row in shown["rows"]} == {"已遮罩"}, "交给盲算模型的结果里也是「已遮罩」"
|
||||
|
||||
|
||||
async def test_a_metric_summed_over_a_masked_column_is_not_blind_computed(monkeypatch, tmp_path):
|
||||
"""复核第 1 轮:金额这一列设了遮罩,指标是 SUM(amount)。核查 agent 的查询结果在工具边界遮好,汇总遮罩列的值也遮
|
||||
(只有一行的汇总就是那一行的原值),盲算交回的格子注定取不到。所以派出盲算之前按 SQL 认出这一列由遮罩列算出,
|
||||
直接按无法复算记、说清原因,不白花一次盲算的钱。以前只按列名认,gmv 认不出,盲算照样派出去,最后才在格子引用上
|
||||
被拒,原因说成「盲算交回的格子引用不可用」。"""
|
||||
name, source_id = await add_source(build(tmp_path / "amount_masked.db"), options={"mask_columns": "amount"})
|
||||
try:
|
||||
blind = Blind(monkeypatch, name, {"销售额": (BLIND_TOTAL, "blind_total")})
|
||||
run = await start(graph(name, RIGHT))
|
||||
evs = await events(run.id)
|
||||
out = dual_output(run, evs)
|
||||
[row] = out["metrics"]
|
||||
assert row["status"] == "unchecked" and blind.runs == 0, "没派盲算"
|
||||
assert row["reason"] == ("输入 cell(nodes.fetch, 0, 'gmv') 由设置了遮罩的列算出(经改名、函数或汇总),"
|
||||
"盲算时会被遮罩,不能用于复算")
|
||||
assert not [e for e in evs if e.type == "tool.end" and e.node_id == "dual"]
|
||||
finally:
|
||||
await drop_source(source_id)
|
||||
|
||||
|
||||
async def test_a_metric_filtered_by_a_masked_column_inside_its_sum_is_still_blind_computed(monkeypatch, masked_shop):
|
||||
"""遮罩列(区域)只在汇总的 CASE 条件里(SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END)):同写在 WHERE 里,
|
||||
值全部来自金额,原 SQL 和盲算的查询都不遮,照常比对。"""
|
||||
name, _ = masked_shop
|
||||
east = (f"SELECT SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END) AS gmv FROM orders "
|
||||
f"WHERE status <> 'refunded' AND paid_date {WEEK}")
|
||||
blind = Blind(monkeypatch, name, {"销售额": (east, "gmv")})
|
||||
run = await start(graph(name, east))
|
||||
out = dual_output(run, await events(run.id))
|
||||
assert blind.runs == 1 and out["metrics"][0]["status"] == "agreed", out["metrics"][0]
|
||||
|
||||
|
||||
async def test_masked_group_columns_are_not_shown_to_the_blind_agent(monkeypatch, masked_shop):
|
||||
name, _ = masked_shop
|
||||
first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
|
||||
|
||||
@@ -904,6 +904,78 @@ async def test_resuming_after_an_inquiry_does_not_repeat_the_nodes(monkeypatch,
|
||||
ALIASED = "SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY amount DESC LIMIT 1"
|
||||
|
||||
|
||||
async def test_the_inquiry_agent_sees_and_stores_its_queries_masked(monkeypatch, masked_shop, client):
|
||||
"""质询 agent 的查询结果在交给模型之前就遮好了(核查 agent 建查询工具时打开遮罩开关):模型看到的工具结果、落库的
|
||||
查询快照、运行详情「工件」页签走的通用工件接口里,遮罩列(起了别名的也算)都是「已遮罩」。以前快照原样存着,模型
|
||||
看得到原值,通用接口也原样给出。回答照常按值遮(查询时遮掉的值只在内存里交回来)。"""
|
||||
plays = {"买得最多": Play([ALIASED], "这一笔来自 [[v:Q1.r0.手机号]](13900000001),金额 [[v:Q1.r0.金额]]。[[see:Q1]]")}
|
||||
Models(monkeypatch, masked_shop, plays=plays)
|
||||
seen: list[str] = []
|
||||
answering = MockChatModel._decide
|
||||
|
||||
def spy(model, messages):
|
||||
seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage))
|
||||
return answering(model, messages)
|
||||
|
||||
monkeypatch.setattr(MockChatModel, "_decide", spy)
|
||||
paused = await pause(masked_shop)
|
||||
[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
|
||||
assert r.json()["inquiry"]["answer"]["text"] == "这一笔来自 已遮罩(已遮罩),金额 1,200。"
|
||||
[query] = r.json()["inquiry"]["queries"]
|
||||
assert seen and any("已遮罩" in s for s in seen) and all("1390000000" not in s for s in seen)
|
||||
got = await client.get(f"/api/artifacts/{query['artifact']}")
|
||||
assert got.status_code == 200 and "1390000000" not in got.text
|
||||
content = got.json()["content"]
|
||||
assert content["rows"] == [["已遮罩", 1200]] and content["masked_at_query"] == ["手机号"]
|
||||
assert content == artifact_store.load(query["artifact"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, rows", [
|
||||
pytest.param("SELECT '合计' AS 区域, SUM(amount) AS 金额 FROM sales UNION ALL SELECT * FROM "
|
||||
"(SELECT buyer_phone, amount FROM sales ORDER BY amount DESC LIMIT 2)",
|
||||
[["已遮罩", 2800], ["已遮罩", 1200], ["已遮罩", 900]], id="UNION 后一支的 *"),
|
||||
pytest.param("WITH t(a, b, c, d, e) AS (SELECT * FROM sales) SELECT c AS 区域, d AS 金额 FROM t "
|
||||
"ORDER BY d DESC LIMIT 2", [["已遮罩", 1200], ["已遮罩", 900]], id="CTE 列名表套 *"),
|
||||
])
|
||||
async def test_a_star_renaming_the_masked_column_never_shows(monkeypatch, tmp_path, client, sql, rows):
|
||||
"""复核第 2 轮的 blocker:* 把手机号带进了别的列名(UNION 后一支的 *,结果的列名取第一支的「区域」;CTE 的列名表把
|
||||
SELECT * 展开的列换了名字)。以前交给模型的工具结果里就有原值,回答「金额最大的一笔来自 13900000001」照常交出,事件、
|
||||
质询工件、通用工件接口都给原值。现在 * 展开落到的那几列查询时就遮掉:只展开一个子查询的按位置只遮号码那一列;展开
|
||||
数据源里的表的按查询当时的表结构数出号码排在第几,也只遮那一列。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=SALES)
|
||||
plays = {"最大": Play([sql], "金额最大的一笔来自 [[v:Q1.r1.区域]]。[[see:Q1]]")}
|
||||
Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays)
|
||||
seen: list[str] = []
|
||||
answering = MockChatModel._decide
|
||||
|
||||
def spy(model, messages):
|
||||
seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage))
|
||||
return answering(model, messages)
|
||||
|
||||
monkeypatch.setattr(MockChatModel, "_decide", spy)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, QUERY))
|
||||
[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, "金额最大的一笔来自 已遮罩。")
|
||||
assert seen and all("1390000000" not in s for s in seen), "交给模型的查询结果里就是遮好的"
|
||||
[query] = item["queries"]
|
||||
for url in (f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}",
|
||||
f"/api/artifacts/{query['artifact']}"):
|
||||
got = await client.get(url)
|
||||
assert got.status_code == 200 and got.json()["content"]["rows"] == rows and "1390000000" not in got.text
|
||||
[event] = await events(paused.id, "approval.inquiry")
|
||||
assert "1390000000" not in json.dumps(event.data, ensure_ascii=False)
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_a_masked_column_under_another_name_is_masked_everywhere(monkeypatch, masked_shop, client):
|
||||
"""遮罩列起了中文别名(本仓库查询的惯例):回答里引用到的格子、事件、回答文档、质询工件里都不露出原值。
|
||||
报告自己的查询没选手机号,原值哪里都认不出来,只能按 SQL 认出「手机号」就是 buyer_phone。"""
|
||||
@@ -1168,6 +1240,48 @@ async def test_a_masked_column_after_a_label_in_a_union_keeps_its_values(monkeyp
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
_BIG_ONE = "CASE WHEN amount > 1000 THEN 1 ELSE 0 END"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shadowed", [
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, {_BIG_ONE} AS vip_level FROM sales) t", id="* 在前"),
|
||||
pytest.param(f"SELECT x.vip_level AS 档 FROM (SELECT * FROM sales s JOIN (SELECT id AS bid, {_BIG_ONE} AS vip_level "
|
||||
"FROM sales) b ON s.id = b.bid) x", id="JOIN 子查询 * 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {_BIG_ONE} AS vip_level FROM sales) t",
|
||||
id="(col) 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {_BIG_ONE} AS vip_level FROM sales) t",
|
||||
id="COLLATE 在前"),
|
||||
])
|
||||
async def test_a_masked_column_shadowing_a_label_keeps_its_values(monkeypatch, tmp_path, client, shadowed):
|
||||
"""第 3 轮复验(J2 留下的缺口):派生来源里 * 带出的等级、套了括号或 COLLATE 的等级排在同名的标签前面,SQLite 交回
|
||||
的是真实等级。以前这一列被当成只装着标签,等级 1 撞上标签 1 不收,之后 WHERE vip_level = 1 在查询清单、事件、质询
|
||||
工件里都照原样。现在不算只装着标签的列,两条都遮。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "vip_level"}, script=LEVELS)
|
||||
first, second = (f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}" for level in (1, 2))
|
||||
plays = {"各等级": Play([shadowed, first, second],
|
||||
"问到的两个等级各有 [[v:Q2.r0.人数]] 人、[[v:Q3.r0.人数]] 人。[[see:Q2]]")}
|
||||
Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, 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, "问到的两个等级各有 2 人、1 人。")
|
||||
sealed = [shadowed, *["SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = 已遮罩"] * 2]
|
||||
assert [q["sql"] for q in item["queries"]] == sealed
|
||||
[event] = await events(paused.id, "approval.inquiry")
|
||||
assert [q["sql"] for q in event.data["queries"]] == sealed
|
||||
for query, sql in zip(item["queries"], sealed):
|
||||
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}")
|
||||
assert got.status_code == 200 and got.json()["content"]["sql"] == sql, "质询工件的 SQL 和事件里的一字不差"
|
||||
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{item['queries'][0]['artifact']}")
|
||||
assert {cell for [cell] in got.json()["content"]["rows"]} == {"已遮罩"} and got.json()["masked"] == ["档"]
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
STAR_TAIL = "SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1"
|
||||
|
||||
|
||||
@@ -1403,14 +1517,22 @@ CALLBACKS = SALES + ("CREATE TABLE callbacks (id INTEGER PRIMARY KEY, region TEX
|
||||
LEAKED = "回答中含有设了遮罩的值,无法遮住,已不予展示,请换个问法重新提问"
|
||||
|
||||
|
||||
async def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact(monkeypatch, tmp_path, client):
|
||||
"""备注里写着手机号(备注没设遮罩,手机号设了遮罩、被报告取数选中):agent 引用这一格,回答照旧整份不交;
|
||||
质询工件里这份快照照样记着、点得开,那一格里的号码换成「已遮罩」,别的字、别的格子照原样,不然「不予展示」
|
||||
旁边一点就能看到原值。"""
|
||||
async def test_a_masked_value_inside_a_text_cell_is_cut_out_before_the_agent_sees_it(monkeypatch, tmp_path, client):
|
||||
"""备注里写着手机号(备注没设遮罩,手机号设了遮罩、被报告取数选中):报告快照里的遮罩值在开始回答之前就交给核查
|
||||
agent 的查询工具,agent 查备注时那一截就换成「已遮罩」,模型看不到;它引用这一格,回答照常给出,渲染出来也是遮好的。
|
||||
质询工件、通用工件接口里这份快照同样遮好,别的字、别的格子照原样。以前模型看得到原值,回答整份不交。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS)
|
||||
notes = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id"
|
||||
plays = {"回访": Play([notes], "华东的回访备注是 [[v:Q1.r0.备注]]。[[see:Q1]]")}
|
||||
Models(monkeypatch, name, writer="金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
|
||||
seen: list[str] = []
|
||||
answering = MockChatModel._decide
|
||||
|
||||
def spy(model, messages):
|
||||
seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage))
|
||||
return answering(model, messages)
|
||||
|
||||
monkeypatch.setattr(MockChatModel, "_decide", spy)
|
||||
try:
|
||||
report_sql = "SELECT buyer_phone, amount AS 金额 FROM sales ORDER BY amount DESC"
|
||||
paused = await pause(name, graph=fetching(name, report_sql))
|
||||
@@ -1420,20 +1542,148 @@ async def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact(monk
|
||||
assert r.status_code == 200, r.text
|
||||
assert "1390000000" not in r.text
|
||||
item = r.json()["inquiry"]
|
||||
assert (item["answer"]["ok"], item["answer"]["text"], item["answer"]["doc_artifact"]) == (False, "", None)
|
||||
assert item["answer"]["error"] == LEAKED
|
||||
assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "华东的回访备注是 客户已遮罩来电改址。")
|
||||
[query] = item["queries"]
|
||||
assert query["sql"] == notes
|
||||
[event] = await events(paused.id, "approval.inquiry")
|
||||
assert event.data["inquiry_artifacts"] == [query["artifact"]]
|
||||
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"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]]
|
||||
assert got.json()["masked"] == [] and "1390000000" not in got.text
|
||||
assert any("客户已遮罩来电改址" in s for s in seen) and all("1390000000" not in s for s in seen), \
|
||||
"交给模型的查询结果里就是遮好的"
|
||||
for url in (f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}",
|
||||
f"/api/artifacts/{query['artifact']}"):
|
||||
got = await client.get(url)
|
||||
assert got.status_code == 200, got.text
|
||||
assert got.json()["content"]["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]]
|
||||
assert "1390000000" not in got.text
|
||||
assert got.json().get("masked", []) == [], "备注只换了一截,不画成遮罩列"
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
#: 质询 agent 的两条查询:一条选出手机号(查询时遮掉),一条查回访备注(备注没设遮罩,里面写着手机号)
|
||||
PHONES_EAST = "SELECT buyer_phone AS 号码, amount AS 金额 FROM sales WHERE region = '华东' ORDER BY amount DESC"
|
||||
NOTES = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id"
|
||||
|
||||
|
||||
async def test_a_number_from_the_agents_other_query_is_masked_when_the_number_came_first(monkeypatch, tmp_path, client):
|
||||
"""复核第 1 轮(对基线回退):报告取数没选手机号,号码只出现在 agent 自己的另一次查询里。先查号码、后查备注:号码
|
||||
查询时就遮掉、值并进内存,后面那次查询的备注在交给模型、落库之前就按值换掉,回答照常给出;质询工件、通用工件接口
|
||||
给的都是遮好的。以前事后取质询工件时收不到这个号码,备注原样给出。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS)
|
||||
plays = {"回访": Play([PHONES_EAST, NOTES], "华东的回访备注是 [[v:Q2.r0.备注]]。[[see:Q2]]")}
|
||||
Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, QUERY))
|
||||
[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, "华东的回访备注是 客户已遮罩来电改址。")
|
||||
phones, notes = item["queries"]
|
||||
for url in (f"/api/runs/{paused.id}/inquiries/artifacts/{notes['artifact']}",
|
||||
f"/api/artifacts/{notes['artifact']}"):
|
||||
got = await client.get(url)
|
||||
assert got.status_code == 200, got.text
|
||||
assert got.json()["content"]["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]]
|
||||
assert "1390000000" not in got.text
|
||||
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{phones['artifact']}")
|
||||
assert got.json()["content"]["rows"] == [["已遮罩", 1200], ["已遮罩", 300]] and got.json()["masked"] == ["号码"]
|
||||
[event] = await events(paused.id, "approval.inquiry")
|
||||
assert "1390000000" not in json.dumps(event.data, ensure_ascii=False)
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_a_number_the_agent_found_is_masked_even_when_report_values_fill_the_cap(monkeypatch, tmp_path, client):
|
||||
"""复核第 2 轮:报告快照里的遮罩值收满了上限(这里把 SECRETS_MAX 调成报告正好收满的大小)。agent 先查出华东的号码
|
||||
(查询时遮掉),再查回访备注:备注里的号码照样在交给模型之前换掉,回答、事件、质询工件里都没有。以前开始回答时先并
|
||||
报告的值、收满就停,agent 查到的号码并不进去:备注原样交给模型、原样落库,回答引用这一格时第二道防线也认不出,原值
|
||||
写进了运行记录。"""
|
||||
from app.engine import masking
|
||||
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS)
|
||||
report_sql = "SELECT buyer_phone, amount AS 金额 FROM sales WHERE region <> '华东' ORDER BY id"
|
||||
plays = {"回访": Play([PHONES_EAST, NOTES], "华东的回访备注是 [[v:Q2.r0.备注]]。[[see:Q2]]")}
|
||||
Models(monkeypatch, name, writer="华南一笔金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, report_sql))
|
||||
[approval] = await pending(paused.id)
|
||||
report = [artifact_store.load(e.data["query_artifact"]) for e in await events(paused.id, "tool.end")
|
||||
if e.data.get("query_artifact")]
|
||||
full = masking._Secrets()
|
||||
full.add(report[0], {"buyer_phone"})
|
||||
monkeypatch.setattr(masking, "SECRETS_MAX", len(full.weak))
|
||||
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, "华东的回访备注是 客户已遮罩来电改址。")
|
||||
phones, notes = item["queries"]
|
||||
assert artifact_store.load(notes["artifact"])["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]]
|
||||
[event] = await events(paused.id, "approval.inquiry")
|
||||
assert "1390000000" not in json.dumps(event.data, ensure_ascii=False)
|
||||
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{notes['artifact']}")
|
||||
assert got.status_code == 200 and "1390000000" not in got.text
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_a_number_from_the_agents_other_query_is_masked_when_the_number_came_last(monkeypatch, tmp_path, client):
|
||||
"""先查备注、后查号码:查备注时还不知道里面写着遮罩值,原样交给模型、原样存下;回答引用这一格,渲染出来才露,整份
|
||||
不交。回答时按内存里的值换掉的位置记进事件(不含值),事后取质询工件照着再换一遍:质询工件里那一截是「已遮罩」,
|
||||
和回答时一样。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS)
|
||||
plays = {"回访": Play([NOTES, PHONES_EAST], "华东的回访备注是 [[v:Q1.r0.备注]]。[[see:Q1]]")}
|
||||
Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, QUERY))
|
||||
[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
|
||||
assert "1390000000" not in r.text
|
||||
item = r.json()["inquiry"]
|
||||
assert (item["answer"]["ok"], item["answer"]["error"]) == (False, LEAKED)
|
||||
notes, phones = item["queries"]
|
||||
[event] = await events(paused.id, "approval.inquiry")
|
||||
assert "1390000000" not in json.dumps(event.data, ensure_ascii=False)
|
||||
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{notes['artifact']}")
|
||||
assert got.status_code == 200, got.text
|
||||
assert got.json()["content"]["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]]
|
||||
assert "1390000000" not in got.text
|
||||
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{phones['artifact']}")
|
||||
assert got.json()["content"]["rows"] == [["已遮罩", 1200], ["已遮罩", 300]]
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_a_number_written_in_another_table_is_masked_whichever_query_came_first(monkeypatch, tmp_path, client):
|
||||
"""另一张表(联系方式)的号码列没设遮罩,存的是同一批号码:先查手机号的,查询时就按值换;后查的,事后取质询工件
|
||||
照回答时记下的位置换。两种先后都整列画成遮罩列。"""
|
||||
phones = "SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY id"
|
||||
visits = "SELECT phone_num AS 号码, visits AS 次数 FROM contacts ORDER BY id"
|
||||
for order in ([phones, visits], [visits, phones]):
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CONTACTS)
|
||||
at = order.index(visits) + 1
|
||||
plays = {"常来": Play(order, f"最常来的客户来过 [[v:Q{at}.r0.次数]] 次。[[see:Q{at}]]")}
|
||||
Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, QUERY))
|
||||
[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
|
||||
assert r.json()["inquiry"]["answer"]["text"] == "最常来的客户来过 5 次。"
|
||||
query = r.json()["inquiry"]["queries"][at - 1]
|
||||
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"] == [["已遮罩", 5], ["已遮罩", 2]]
|
||||
assert got.json()["masked"] == ["号码"] and "1390000000" not in got.text
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql(monkeypatch, masked_shop, client):
|
||||
"""agent 的查询带着 SQL 检查结果(对照数据目录查出的问题,片段摘自这条 SQL):质询工件里片段照查询清单的 SQL
|
||||
换掉遮罩值,给模型的那句改法照抄了片段,不交出去(同证据面板)。"""
|
||||
@@ -1609,6 +1859,78 @@ async def test_a_customer_looked_up_in_any_shape_never_shows(monkeypatch, tmp_pa
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
#: 同一个号码(13900000001)换了写法:带空格、连字符、点、括号、全角数字或全角标点、+86 / 0086 / 86 前缀
|
||||
REWRITTEN = [
|
||||
pytest.param("139-0000-0001", id="连字符"), pytest.param("139 0000 0001", id="空格"),
|
||||
pytest.param("139.0000.0001", id="点"), pytest.param("(139)0000-0001", id="括号"),
|
||||
pytest.param("+8613900000001", id="+86"), pytest.param("+86 139 0000 0001", id="+86 加空格"),
|
||||
pytest.param("0086 139 0000 0001", id="0086"), pytest.param("86-139-0000-0001", id="86"),
|
||||
pytest.param("13900000001", id="全角数字"), pytest.param("139-0000-0001", id="全角连字符"),
|
||||
pytest.param("(139)0000 0001", id="全角括号和空格"),
|
||||
]
|
||||
|
||||
|
||||
def _shown_anywhere(*texts: str) -> str:
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", REWRITTEN)
|
||||
async def test_a_masked_number_written_in_another_shape_never_shows(monkeypatch, tmp_path, client, written):
|
||||
"""验收准备时复现过:模型把号码换个写法写进回答(139-0000-0001、+86 …、全角数字),回答正文、事件、回答文档原样
|
||||
带着,最后一道关也拦不住,正式运行里永久封存。现在正文里按纯数字写法认出来,换成「已遮罩」。报告取数选了手机号,
|
||||
号码是从报告自己的查询快照里认出来的。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=SALES)
|
||||
asked = "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001'"
|
||||
plays = {"这位客户": Play([asked], f"客户 {written} 共下了 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
|
||||
Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, WITH_PHONE))
|
||||
[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, "客户 已遮罩 共下了 1 笔。")
|
||||
[event] = await events(paused.id, "approval.inquiry")
|
||||
doc = artifact_store.load(item["answer"]["doc_artifact"])
|
||||
shown = _shown_anywhere(json.dumps(r.json(), ensure_ascii=False), json.dumps(event.data, ensure_ascii=False),
|
||||
json.dumps(doc, ensure_ascii=False))
|
||||
assert written not in shown and "1390000000" not in shown
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
#: 回访记录的备注里写着换了写法的号码(备注没设遮罩)
|
||||
REWRITTEN_CALLBACKS = SALES + ("CREATE TABLE callbacks (id INTEGER PRIMARY KEY, region TEXT, note TEXT);"
|
||||
"INSERT INTO callbacks VALUES (1, '华东', '客户 (139)0000-0001 来电改址'), "
|
||||
"(2, '华南', '例行回访');")
|
||||
|
||||
|
||||
async def test_a_reformatted_number_inside_a_text_cell_is_cut_out_before_the_agent_sees_it(monkeypatch, tmp_path,
|
||||
client):
|
||||
"""备注里夹着换了写法的号码(报告取数选了手机号):开始回答之前就认得出,agent 查备注时那一截已经换掉,引用这一格
|
||||
回答照常给出;质询工件里同样只换掉那一截。"""
|
||||
name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=REWRITTEN_CALLBACKS)
|
||||
notes = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id"
|
||||
plays = {"回访": Play([notes], "华东的回访备注是 [[v:Q1.r0.备注]]。[[see:Q1]]")}
|
||||
Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
|
||||
try:
|
||||
paused = await pause(name, graph=fetching(name, WITH_PHONE))
|
||||
[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, "华东的回访备注是 客户 已遮罩 来电改址。")
|
||||
[query] = item["queries"]
|
||||
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"] == [["华东", "客户 已遮罩 来电改址"], ["华南", "例行回访"]]
|
||||
assert "0000-0001" not in got.text and "0000-0001" not in r.text
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 接线:两道审批的竞态、上传表格的版本、设置、开放之后状态又变了、张冠李戴的报告文档
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -349,6 +349,119 @@ def test_label_columns_are_traced_to_where_they_are_written(sql, columns, texts)
|
||||
assert inquiry_mod._label_columns(sql, "sqlite", columns) == {"档位": texts}
|
||||
|
||||
|
||||
#: 派生来源里排在标签前面、和标签同名的列:SQLite 让同名的几列都留着,外层按名字取的是排在前面的那一列(真实等级)
|
||||
SHADOWED = [
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, {BIG_ONE} AS vip_level FROM sales) t", id="* 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT s.*, {BIG_ONE} AS vip_level FROM sales s) t", id="t.* 在前"),
|
||||
pytest.param(f"WITH t AS (SELECT *, {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 档 FROM t",
|
||||
id="CTE 里 * 在前"),
|
||||
pytest.param(f"SELECT x.vip_level AS 档 FROM (SELECT * FROM sales s JOIN (SELECT id AS bid, {BIG_ONE} AS vip_level "
|
||||
"FROM sales) b ON s.id = b.bid) x", id="JOIN 子查询 * 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, b.lbl AS vip_level FROM sales s JOIN (SELECT id AS bid, "
|
||||
f"{BIG_ONE} AS lbl FROM sales) b ON s.id = b.bid) t", id="JOIN 之后 * 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {BIG_ONE} AS vip_level FROM sales) t",
|
||||
id="(col) 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT ((vip_level)), {BIG_ONE} AS vip_level FROM sales) t",
|
||||
id="((col)) 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {BIG_ONE} AS vip_level FROM sales) t",
|
||||
id="COLLATE 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level) COLLATE BINARY, {BIG_ONE} AS vip_level "
|
||||
"FROM sales) t", id="(col) COLLATE 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {BIG_ONE} AS vip_level FROM sales UNION ALL "
|
||||
f"SELECT vip_level, {BIG_ONE} FROM sales) t", id="多支的第一支 (col) 在前"),
|
||||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {BIG_ONE} AS vip_level FROM sales "
|
||||
f"UNION ALL SELECT vip_level, {BIG_ONE} FROM sales) t", id="多支的第一支 COLLATE 在前"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", SHADOWED)
|
||||
def test_a_masked_column_shadowing_a_label_in_a_derived_source_is_not_a_label_column(sql):
|
||||
"""第 3 轮复验(J2 留下的缺口):派生来源里 * 带出的遮罩列、没起别名但套了括号或 COLLATE 的遮罩列排在同名的标签
|
||||
前面,SQLite 交回的是真实等级(1、2、3),以前却按标签认、短码值不收,之后 WHERE vip_level = 1 照原样封存。现在
|
||||
第一个同名项前面有 * 带出这一列(或说不清带不带)、有去掉括号和 COLLATE 后同名的项,都不算只装着标签的列。"""
|
||||
plan = plan_of({"source": "shop", "sql": sql, "columns": ["档"], "rows": [[1], [2], [3]]}, {"vip_level"})
|
||||
assert inquiry_mod._label_columns(sql, "sqlite", ["档"]) == {}
|
||||
assert {"1", "2", "3"} <= plan.secrets.weak
|
||||
for level in (1, 2, 3):
|
||||
assert inquiry_mod._scrub_sql(f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}", plan.secrets,
|
||||
plan.tainted["a1"], "sqlite") == (
|
||||
"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = 已遮罩")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
# 标签排在 * 前面:SQLite 取的是排在前面的标签
|
||||
f"SELECT vip_level AS 档 FROM (SELECT {BIG_ONE} AS vip_level, * FROM sales) t",
|
||||
# * 只带出另一个派生来源(写了表名):那里只有标签
|
||||
f"SELECT x.vip_level AS 档 FROM (SELECT b.* FROM sales s JOIN (SELECT id AS bid, {BIG_ONE} AS vip_level "
|
||||
"FROM sales) b ON s.id = b.bid) x",
|
||||
# * 只展开 CTE 里的标签
|
||||
f"WITH b AS (SELECT {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 档 FROM (SELECT * FROM b) t",
|
||||
])
|
||||
def test_a_label_ahead_of_a_star_or_alone_behind_one_is_still_a_label_column(sql):
|
||||
"""收紧之后,标签排在前面、* 只带出派生来源里的标签的,照旧算只装着标签的列。"""
|
||||
assert inquiry_mod._label_columns(sql, "sqlite", ["档"]) == {"档": {"0", "1"}}
|
||||
|
||||
|
||||
#: 没写表名的 * 连着数据源里的表和装着标签的派生来源
|
||||
STAR_JOIN = ("SELECT * FROM sales s JOIN (SELECT id AS bid, CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档 "
|
||||
"FROM sales) b ON s.id = b.bid")
|
||||
STAR_COLUMNS = ["id", "region", "amount", "bid", "档"]
|
||||
|
||||
|
||||
def test_a_star_over_a_table_known_to_lack_the_column_keeps_the_label():
|
||||
"""复核第 1 轮(收紧之后的多遮):没写表名的 * 展开数据源里的表,以前一律算「说不清带不带这一列」,标签照收成
|
||||
遮罩值,回答里的「大单」、SQL 里的 '大单' 都换成「已遮罩」。冻结的表结构里查得到这张表、又没有同名的列时,照旧
|
||||
按派生来源认标签;表里有同名的列、查不到表结构的,照旧不算(宁可多遮)。"""
|
||||
from app.engine import masking
|
||||
|
||||
lacking = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"}, {"name": "amount"}]}}}
|
||||
holding = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "档"}, {"name": "amount"}]}}}
|
||||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(lacking)) == {
|
||||
"档": {"大单", "小单"}}
|
||||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(holding)) == {}
|
||||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS) == {}
|
||||
truncated = {"tables": {"orders": {"columns": [{"name": "id"}]}}, "truncated": True}
|
||||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(truncated)) == {}
|
||||
# 写了表名的 s.* 展开的是数据源里的表:同样按表结构认它带不带这一列
|
||||
nested = ("SELECT x.档 FROM (SELECT s.*, b.* FROM sales s JOIN (SELECT id AS bid, CASE WHEN amount > 1000 "
|
||||
"THEN '大单' ELSE '小单' END AS 档 FROM sales) b ON s.id = b.bid) x")
|
||||
assert masking._label_columns(nested, "sqlite", ["档"], masking._schema_tables(lacking)) == {"档": {"大单", "小单"}}
|
||||
assert masking._label_columns(nested, "sqlite", ["档"], masking._schema_tables(holding)) == {}
|
||||
assert masking._label_columns(nested, "sqlite", ["档"]) == {}
|
||||
|
||||
|
||||
async def test_a_snapshot_reads_labels_with_its_frozen_schema():
|
||||
"""快照记着查询当时冻结的表结构(schema_artifact):按它认,* 连着的表没有同名的列时标签不当遮罩值,回答正文、
|
||||
SQL 里的档名照原样;表结构取不到的照旧不算。"""
|
||||
from app.core import artifact_store
|
||||
from app.engine import masking
|
||||
|
||||
lacking = {"source": "shop", "tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"},
|
||||
{"name": "amount"}]}}}
|
||||
schema = await artifact_store.put_json(lacking, kind="schema_snapshot", run_id="r-labels", node_id="n1")
|
||||
snapshot = {"source": "shop", "sql": STAR_JOIN, "columns": STAR_COLUMNS, "schema_artifact": schema,
|
||||
"rows": [[1, "华东", 1200, 1, "大单"], [2, "华南", 300, 2, "小单"]]}
|
||||
assert masking._snapshot_labels(snapshot, "sqlite") == {"档": {"大单", "小单"}}
|
||||
plan = plan_of(snapshot, {"amount"})
|
||||
assert plan.secrets.scrub("这一笔属于大单") == "这一笔属于大单"
|
||||
sql = "SELECT COUNT(*) FROM (SELECT CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档 FROM sales) WHERE 档 = '大单'"
|
||||
assert inquiry_mod._scrub_sql(sql, plan.secrets, plan.tainted["a1"], "sqlite") == sql
|
||||
assert masking._snapshot_labels({**snapshot, "schema_artifact": "missing"}, "sqlite") == {}
|
||||
|
||||
|
||||
def test_the_tool_boundary_reads_labels_with_the_sources_frozen_schema():
|
||||
"""工具边界按数据源的表结构认标签:* 连着的表没有同名的列,标签不收成遮罩值,同一份结果里别的文字格里的「大单」照常。"""
|
||||
from app.engine import masking
|
||||
|
||||
payload = {"sql": STAR_JOIN, "columns": [*STAR_COLUMNS, "备注"],
|
||||
"rows": [[1, "华东", 1200, 1, "大单", "大单客户"], [2, "华南", 300, 2, "小单", "例行回访"]]}
|
||||
schema = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"}, {"name": "amount"},
|
||||
{"name": "备注"}]}}}
|
||||
done = masking.mask_query_result(payload, ["amount"], "sqlite", schema=schema)
|
||||
assert done.columns == ["amount", "档"] and not {"大单", "小单"} & done.secrets.strong
|
||||
assert [row[-1] for row in done.payload["rows"]] == ["大单客户", "例行回访"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 按值认:门槛、边界
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -1144,6 +1257,27 @@ def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql():
|
||||
"sql_excerpt": "SUM(balance)"}]
|
||||
|
||||
|
||||
def test_check_excerpts_follow_the_sql_recorded_at_answer_time():
|
||||
"""agent 自己的查询快照查询时就遮好了,事后取质询工件时收不到遮罩列里的值:片段、说明照事件里记下的那条 SQL(回答时
|
||||
按内存里的遮罩值换过)换——那条里整个换成「已遮罩」的字面量、换掉的那一截(LIKE 的 %…%),片段、说明里一样换。"""
|
||||
sql = ("SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001' AND age = 25 "
|
||||
"AND note LIKE '%13900000002%'")
|
||||
recorded = "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩' AND age = 已遮罩 AND note LIKE '%已遮罩%'"
|
||||
assert inquiry_mod._recorded_swaps(sql, recorded, "sqlite") == ({"13900000001", "25"}, {"13900000002"})
|
||||
assert inquiry_mod._recorded_swaps(sql, sql, "sqlite") == (set(), set())
|
||||
assert inquiry_mod._recorded_swaps(sql, recorded + " LIMIT 5", "sqlite") == (set(), set()), "对不上就不认"
|
||||
checks = [{"code": "c1", "level": "warning", "table": "sales", "message": "没有码值「25」",
|
||||
"sql_excerpt": "buyer_phone = '13900000001' AND age = 25"},
|
||||
{"code": "c2", "level": "info", "table": "sales", "message": "按备注筛选",
|
||||
"sql_excerpt": "note LIKE '%13900000002%'"}]
|
||||
whole, pieces = inquiry_mod._recorded_swaps(sql, recorded, "sqlite")
|
||||
assert inquiry_mod._masked_checks(checks, sql, inquiry_mod._Secrets(), set(), "sqlite", swapped=whole,
|
||||
pieces=pieces) == [
|
||||
{"code": "c1", "level": "warning", "table": "sales", "message": "没有码值「已遮罩」",
|
||||
"sql_excerpt": "buyer_phone = '已遮罩' AND age = 已遮罩"},
|
||||
{"code": "c2", "level": "info", "table": "sales", "message": "按备注筛选", "sql_excerpt": "note LIKE '%已遮罩%'"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, excerpt, shown", [
|
||||
# 片段只是一段表达式,单独拿出来认不出在找哪一个值:按整条 SQL 里换掉的字面量换
|
||||
("SELECT SUM(CASE age WHEN 25 THEN balance END) AS 余额 FROM accounts",
|
||||
@@ -1299,3 +1433,56 @@ def test_hundreds_of_band_edges_are_read_within_a_budget():
|
||||
assert "已遮罩" in inquiry_mod._scrub_sql(bins(600), every, {"age"}, "sqlite")
|
||||
elapsed = time.perf_counter() - started
|
||||
assert elapsed < 5, f"用了 {elapsed:.2f} 秒"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 回答时才认得出的值:记下位置,事后取质询工件时照着再换一遍
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_values_known_only_at_answer_time_are_recorded_as_positions_and_replayed():
|
||||
"""agent 先查了备注(那时还不知道备注里的号码是遮罩值,查询时原样交回、原样存下),后来又查出了手机号(查询时
|
||||
遮掉,值只在内存里)。回答时按内存里的值换掉的地方记成位置(不含值);事后取质询工件时内存里的值已经不在,照着
|
||||
位置再换一遍,和回答时看到的一样。"""
|
||||
from app.engine import masking
|
||||
|
||||
note = "客户13900000001来电改址,备用 139-0000-0001"
|
||||
snapshot = {"columns": ["区域", "备注", "号码", "13900000001 的单"],
|
||||
"rows": [["华东", note, "13900000001", 1], ["华南", "例行回访", "13800000000", 2]]}
|
||||
seen = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"})
|
||||
edits = masking.view_edits(snapshot, set(), seen, inquiry_mod._Secrets())
|
||||
first, second = note.index("13900000001"), note.index("139-0000-0001")
|
||||
assert edits == {"cells": [[0, 2]], "spans": [[0, 1, first, first + 11], [0, 1, second, second + 13]],
|
||||
"names": [[3, 0, 11]], "masked": ["号码"]}
|
||||
assert "1390000000" not in json.dumps(edits, ensure_ascii=False), "只记位置,不记值"
|
||||
replayed = masking.replay_edits(snapshot, [edits])
|
||||
assert replayed["columns"] == ["区域", "备注", "号码", f"{MASKED} 的单"]
|
||||
assert replayed["rows"] == [["华东", f"客户{MASKED}来电改址,备用 {MASKED}", MASKED, 1],
|
||||
["华南", "例行回访", "13800000000", 2]]
|
||||
# 不靠内存里的值也认得出的(报告快照里就有),事后照样认得出,不记
|
||||
assert masking.view_edits(snapshot, set(), seen, seen) is None
|
||||
# 记下的位置对不上(越界、坏形状)的跳过,不出错
|
||||
broken = {"cells": [[9, 9], "x"], "spans": [[0, 1, 50, 60], [0, 9, 0, 1]], "names": [[7, 0, 1]], "masked": [3]}
|
||||
assert masking.replay_edits(snapshot, [broken, None, "x"]) == snapshot
|
||||
|
||||
|
||||
def test_the_plan_records_what_only_the_values_seen_by_the_agent_masked():
|
||||
"""_settle_plan 带上 agent 查询时遮掉的值(seen)时,另记下只靠它们才换掉的位置(replay);不带 seen 的(事后取
|
||||
质询工件)不记。"""
|
||||
notes = {"source": "shop", "sql": "SELECT region AS 区域, note AS 备注 FROM callbacks", "columns": ["区域", "备注"],
|
||||
"rows": [["华东", "客户13900000001来电改址"], ["华南", "例行回访"]]}
|
||||
seen = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"})
|
||||
plan = inquiry_mod._settle_plan({"a1": notes}, {"a1": ({"buyer_phone"}, [])}, [], {"shop": "sqlite"}, [], [], {},
|
||||
frozenset(), seen)
|
||||
assert plan.replay == {"a1": {"cells": [], "spans": [[0, 1, 2, 13]], "names": [], "masked": []}}
|
||||
later = inquiry_mod._settle_plan({"a1": notes}, {"a1": ({"buyer_phone"}, [])}, [], {"shop": "sqlite"}, [], [], {},
|
||||
frozenset({"a1"}))
|
||||
assert later.replay == {} and later.views["a1"]["rows"][0][1] == "客户13900000001来电改址"
|
||||
|
||||
|
||||
def test_values_the_agent_saw_survive_a_report_that_fills_the_cap():
|
||||
"""报告快照里的遮罩值收满了上限(SECRETS_MAX):agent 查询时遮掉的值先收,照样认得出(以前先收报告的,满了就丢)。"""
|
||||
report = {"columns": ["buyer_phone"], "rows": [[str(13800000000 + i)] for i in range(inquiry_mod.SECRETS_MAX)]}
|
||||
seen = secrets_of({"columns": ["phone"], "rows": [["19914742785"]]}, {"phone"})
|
||||
plan = inquiry_mod._settle_plan({}, {}, [(report, {"buyer_phone"})], {}, [], [], {}, frozenset(), seen)
|
||||
assert plan.secrets.hit("19914742785")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""换了写法的号码(engine/masking.py 的 _Secrets):按值认得出的长数字(SECRET_DIGITS 位以上)被写成带空格、连字符、
|
||||
点、括号、全角数字或全角标点、带 +86 / 0086 / 86 前缀的样子,模型写的正文(审批质询的回答)里照样认得出,换成
|
||||
「已遮罩」;最后一道关(inquiry._leaked)、质询工件里文字格夹着的号码同样认得出。
|
||||
|
||||
验收准备时复现过:模型把号码写成 199-1474-2785、199 1474 2785、199.1474.2785、(199)1474-2785、+8619914742785、
|
||||
全角数字,回答正文、事件、回答文档原样带着,最后一道关也拦不住,正式运行里永久封存。
|
||||
|
||||
认法是在正文里找允许夹分隔符的数字串,规范化(全角转半角、去分隔符、去国家码)后在「遮罩值的纯数字写法」集合里
|
||||
查——集合查找,不现编正则。日期、小数、更长的数里的一截、连字符连着的编号照旧不动。
|
||||
|
||||
数据都是虚构的:手机号、金额。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import inquiry as inquiry_mod
|
||||
from app.engine.masking import _masked_view, _Secrets
|
||||
|
||||
MASKED = "已遮罩"
|
||||
PHONE = "19914742785"
|
||||
|
||||
#: 同一个号码的各种写法(换了写法的号码)
|
||||
REFORMATTED = [
|
||||
pytest.param("199-1474-2785", id="连字符"),
|
||||
pytest.param("199 1474 2785", id="空格"),
|
||||
pytest.param("199.1474.2785", id="点"),
|
||||
pytest.param("(199)1474-2785", id="括号"),
|
||||
pytest.param("(199) 1474 2785", id="括号加空格"),
|
||||
pytest.param("+8619914742785", id="+86"),
|
||||
pytest.param("+86 199 1474 2785", id="+86 加空格"),
|
||||
pytest.param("(+86)19914742785", id="(+86)"),
|
||||
pytest.param("0086 199 1474 2785", id="0086"),
|
||||
pytest.param("0086-19914742785", id="0086 连字符"),
|
||||
pytest.param("86-199-1474-2785", id="86"),
|
||||
pytest.param("8619914742785", id="86 连写"),
|
||||
pytest.param("19914742785", id="全角数字"),
|
||||
pytest.param("199-1474-2785", id="全角数字和连字符"),
|
||||
pytest.param("199 1474 2785", id="全角空格"),
|
||||
pytest.param("199.1474.2785", id="全角点"),
|
||||
pytest.param("(199)1474-2785", id="全角括号"),
|
||||
pytest.param("+86 199 1474 2785", id="全角加号"),
|
||||
]
|
||||
|
||||
|
||||
def secrets_of(values: list, *, hidden: str = "phone") -> _Secrets:
|
||||
secrets = _Secrets()
|
||||
secrets.add({"columns": [hidden], "rows": [[v] for v in values]}, {hidden})
|
||||
return secrets
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", REFORMATTED)
|
||||
@pytest.mark.parametrize("stored", [PHONE, int(PHONE), "199-1474-2785"], ids=["文本", "整数", "存的就带连字符"])
|
||||
def test_a_long_number_in_another_shape_is_masked_in_prose(stored, written):
|
||||
secrets = secrets_of([stored])
|
||||
assert secrets.scrub(f"金额最高的一笔来自 {written},共 3 笔", prose=True) == f"金额最高的一笔来自 {MASKED},共 3 笔"
|
||||
assert secrets.scrub(f"客户{written}来电", prose=True) == f"客户{MASKED}来电"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, shown", [
|
||||
("来自(19914742785),共 3 笔", f"来自({MASKED}),共 3 笔"),
|
||||
("来自(+86 199 1474 2785),共 3 笔", f"来自({MASKED}),共 3 笔"),
|
||||
("电话(+86)199 1474 2785。", f"电话{MASKED}。"),
|
||||
("(电话 199-1474-2785)", f"(电话 {MASKED})"),
|
||||
])
|
||||
def test_brackets_around_a_number_are_kept_and_brackets_inside_it_are_replaced(text, shown):
|
||||
"""整个号码外面套着的一对括号是标点,留着;号码里的括号(区号、国家码)和号码一起换掉。"""
|
||||
assert secrets_of([PHONE]).scrub(text, prose=True) == shown
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
# 日期、时间、小数、千分位、更长的数里的一截、连字符连着的编号、别的号码
|
||||
"2026-10-06 19:14 下单", "金额 19914.74 元", "共 1,991,474 笔", "号码 1991474278512", "批次 19914742785-2",
|
||||
"199-1474-2786", "单号 2026199-1474-2785", "共 199 笔 1474 元 2785 次",
|
||||
])
|
||||
def test_other_numbers_and_number_like_text_are_left_alone(text):
|
||||
secrets = secrets_of([PHONE])
|
||||
assert secrets.scrub(text, prose=True) == text
|
||||
|
||||
|
||||
def test_reformatted_numbers_are_only_read_in_prose_and_text_cells():
|
||||
"""SQL 里 199-1474-2785 是一个算式,不当成号码换(SQL 里的号码按字面量的用法换,见 inquiry._scrub_sql)。"""
|
||||
secrets = secrets_of([PHONE])
|
||||
assert secrets.scrub("SELECT 199-1474-2785 AS x") == "SELECT 199-1474-2785 AS x"
|
||||
assert secrets.scrub("备注 199-1474-2785", loose=True) == f"备注 {MASKED}"
|
||||
|
||||
|
||||
def test_a_text_cell_holding_a_reformatted_number_is_masked_whole_or_in_part():
|
||||
"""快照里别的列(没设遮罩)写着换了写法的号码:整格就是号码的整格遮;质询工件里夹在一段文字里的只换那一截。"""
|
||||
secrets = secrets_of([PHONE])
|
||||
snapshot = {"columns": ["联系方式", "备注"], "rows": [["199-1474-2785", "客户 (199)1474-2785 来电改址"],
|
||||
["138-0000-0000", "例行回访"]]}
|
||||
view, masked = _masked_view(snapshot, set(), secrets)
|
||||
assert view["rows"] == [[MASKED, "客户 (199)1474-2785 来电改址"], ["138-0000-0000", "例行回访"]]
|
||||
assert masked == ["联系方式"]
|
||||
view, masked = _masked_view(snapshot, set(), secrets, embedded=True)
|
||||
assert view["rows"] == [[MASKED, f"客户 {MASKED} 来电改址"], ["138-0000-0000", "例行回访"]]
|
||||
assert masked == ["联系方式"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", REFORMATTED)
|
||||
def test_the_last_gate_stops_an_answer_still_showing_a_reformatted_number(written):
|
||||
"""组装之后还认得出(渲染进单元格引用的一格文字里夹着换了写法的号码):不交出去。正文里的在组装之前就换掉了。"""
|
||||
secrets = secrets_of([PHONE])
|
||||
entry = {"alias": "Q1", "kind": "query", "artifact": "a1", "source": "shop", "columns": ["备注"], "rows": 1,
|
||||
"truncated": False}
|
||||
views = {"a1": _masked_view({"columns": ["备注"], "rows": [[f"客户{written}来电改址"]]}, set(), secrets)[0]}
|
||||
with pytest.raises(inquiry_mod._Leak):
|
||||
inquiry_mod._compose("回访备注是 [[v:Q1.r0.备注]]。", {"Q1": entry}, run_id="r1", views=views, secrets=secrets,
|
||||
allow=[])
|
||||
doc = inquiry_mod._compose(f"来电的是 {written}。", {}, run_id="r1", views={}, secrets=secrets, allow=[])
|
||||
assert doc["markdown"] == f"来电的是 {MASKED}。"
|
||||
assert inquiry_mod._leaked({"markdown": f"来电的是 {written}。", "blocks": []}, secrets)
|
||||
|
||||
|
||||
def test_a_number_the_asker_wrote_in_another_shape_is_left_alone():
|
||||
"""提问人自己写的号码(换了写法也一样)已经摆在读者眼前:回答里照原样,别的号码照遮。"""
|
||||
secrets = secrets_of([PHONE, "13800000000"])
|
||||
secrets.protect(["199-1474-2785 这位客户还买过别的吗?"])
|
||||
assert secrets.scrub("199 1474 2785 另有 138-0000-0000 的单", prose=True) == f"199 1474 2785 另有 {MASKED} 的单"
|
||||
|
||||
|
||||
def test_a_threshold_left_in_the_sql_is_shown_in_the_prose_whatever_the_shape():
|
||||
"""SQL 里照原样留着的阈值,回答正文里复述不遮,换了写法也一样(只免正文)。"""
|
||||
secrets = secrets_of([20000, 35000], hidden="amount")
|
||||
secrets.show({"20000"})
|
||||
assert secrets.scrub("金额不低于 20 000 元,最高一笔 35 000 元", prose=True) == f"金额不低于 20 000 元,最高一笔 {MASKED} 元"
|
||||
|
||||
|
||||
def test_many_masked_numbers_are_matched_without_stalling():
|
||||
"""遮罩值上万个:集合查找,不现编上万个分支的正则。"""
|
||||
secrets = secrets_of([str(19900000000 + i) for i in range(20_000)])
|
||||
text = ";".join(f"客户 199-0000-{i:04d} 来电" for i in range(0, 4000, 7)) * 3
|
||||
began = time.perf_counter()
|
||||
out = secrets.scrub(text, prose=True)
|
||||
assert time.perf_counter() - began < 2.0
|
||||
assert "199-0000" not in out and out.count(MASKED) == text.count("199-0000")
|
||||
|
||||
|
||||
#: 复核第 1 轮:别的空白、减号作分隔符的写法(模型输出里不换行空格、窄空格并不少见)
|
||||
OTHER_SEPARATORS = [
|
||||
pytest.param("199 1474 2785", id="不换行空格"),
|
||||
pytest.param("199 1474 2785", id="窄不换行空格"),
|
||||
pytest.param("199 1474 2785", id="细空格"),
|
||||
pytest.param("199 1474 2785", id="发丝空格"),
|
||||
pytest.param("199\t1474\t2785", id="制表符"),
|
||||
pytest.param("199−1474−2785", id="减号"),
|
||||
pytest.param("199 1474 2785", id="四个空格"),
|
||||
pytest.param("199 - 1474 - 2785", id="连字符两边带空格"),
|
||||
pytest.param("+86 199 1474 2785", id="+86 加不换行空格"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", OTHER_SEPARATORS)
|
||||
def test_numbers_split_by_other_spaces_and_minus_signs_are_masked_too(written):
|
||||
secrets = secrets_of([PHONE])
|
||||
assert secrets.scrub(f"客户 {written} 来电", prose=True) == f"客户 {MASKED} 来电"
|
||||
assert inquiry_mod._leaked({"markdown": f"来电的是 {written}。", "blocks": []}, secrets)
|
||||
assert secrets.hit(written), "整格就是这个号码的,格子按值认"
|
||||
|
||||
|
||||
def test_a_line_break_still_ends_a_number():
|
||||
"""不跨行:两行各写一截的不拼成一个号码。"""
|
||||
secrets = secrets_of([PHONE])
|
||||
assert secrets.scrub("编号 199\n1474 2785", prose=True) == "编号 199\n1474 2785"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, sealed", [
|
||||
pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note LIKE '%199-1474-2785%'",
|
||||
f"SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note LIKE '%{MASKED}%'", id="LIKE 里的一截"),
|
||||
pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note = '客户 199 1474 2785 来电'",
|
||||
f"SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note = '客户 {MASKED} 来电'", id="字符串里夹着"),
|
||||
pytest.param('SELECT note AS "199-1474-2785 的备注" FROM callbacks',
|
||||
f'SELECT note AS "{MASKED} 的备注" FROM callbacks', id="带引号的列名"),
|
||||
pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks /* 客户 199-1474-2785 */",
|
||||
f"SELECT COUNT(*) AS 笔数 FROM callbacks /* 客户 {MASKED} */", id="块注释"),
|
||||
pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks -- 客户 199 1474 2785\n WHERE id = 1",
|
||||
f"SELECT COUNT(*) AS 笔数 FROM callbacks -- 客户 {MASKED}\n WHERE id = 1", id="行注释"),
|
||||
])
|
||||
def test_a_reformatted_number_in_sql_strings_names_and_comments_is_masked(sql, sealed):
|
||||
"""SQL 里字符串、带引号的名字、注释里换了写法的号码也认(以前只有整个字面量就是号码的才换)。"""
|
||||
assert inquiry_mod._scrub_sql(sql, secrets_of([PHONE]), set(), "sqlite") == sealed
|
||||
|
||||
|
||||
def test_a_bare_number_expression_in_sql_is_still_left_alone():
|
||||
"""不带引号的 199-1474-2785 在 SQL 里是一个算式(三个数相减),照旧不按号码认。"""
|
||||
sql = "SELECT 199-1474-2785 AS x FROM callbacks"
|
||||
assert inquiry_mod._scrub_sql(sql, secrets_of([PHONE]), set(), "sqlite") == sql
|
||||
|
||||
|
||||
def test_a_threshold_matching_a_number_swapped_in_a_string_is_not_kept():
|
||||
"""字符串里按换了写法认出、换掉的号码,同一条 SQL 里写成阈值的同一个数也不留(不然阈值把它露了)。"""
|
||||
sql = f"SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note LIKE '%199-1474-2785%' AND phone_no >= {PHONE}"
|
||||
sealed = inquiry_mod._scrub_sql(sql, secrets_of([PHONE]), set(), "sqlite")
|
||||
assert PHONE not in sealed and "1474" not in sealed
|
||||
|
||||
|
||||
def test_a_reformatted_number_in_a_column_name_is_masked_like_the_sql():
|
||||
"""agent 把换了写法的号码写进列名(AS "139-0000-0001 的单"):质询工件的列名、回答里引用这一列的标记照 SQL 里带引号
|
||||
的名字一样换,三处对得上。"""
|
||||
secrets = secrets_of(["13900000001"])
|
||||
snapshot = {"columns": ["139-0000-0001 的单"], "rows": [[3]]}
|
||||
view, _ = _masked_view(snapshot, set(), secrets)
|
||||
assert view["columns"] == [f"{MASKED} 的单"]
|
||||
assert inquiry_mod._scrub_sql('SELECT COUNT(*) AS "139-0000-0001 的单" FROM sales', secrets, set(), "sqlite") == (
|
||||
f'SELECT COUNT(*) AS "{MASKED} 的单" FROM sales')
|
||||
assert inquiry_mod._scrubbed_source("共 [[v:Q1.r0.139-0000-0001 的单]] 单。", secrets) == f"共 [[v:Q1.r0.{MASKED} 的单]] 单。"
|
||||
@@ -0,0 +1,378 @@
|
||||
"""按 SQL 认取自遮罩列的结果列(engine/masking.py 的 _derived_hidden):* 把遮罩列带进了别的列名(复核第 2 轮的 blocker)。
|
||||
|
||||
* 展开的列按位置落进结果:WITH t(a, b, …) AS (SELECT * FROM sales) 的列名表、FROM 里子查询的列名表会给它们换上别的
|
||||
名字,UNION 后一支的 * 落进第一支起的列名里,标量子查询里的 * 则把值交给外层的表达式。以前 * 本身不算取自哪一列,这些
|
||||
写法的原值原样交给核查 agent、落进快照,审批质询按值遮的第二道防线也认不出。现在 * 带出的值取自遮罩列时,它展开落到的
|
||||
那几列都算:只展开一个来源、那里的每一列都数得清的(子查询按选出项,数据源里的表按查询当时的表结构),按位置只算取自
|
||||
遮罩列的那几列;数不清的(没有表结构、列数对不上)整段都算(多遮)。不改名的 *(单独一条 SELECT 的 *、UNION 第一支的 *)
|
||||
照旧按名字认;* 只带出没碰遮罩列的值、EXISTS 里的 * 都不算。
|
||||
|
||||
查询都在内存里的 SQLite 上真跑一遍,看遮完的结果里还有没有原值。数据都是虚构的:区域、手机号、姓名、金额。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import models # noqa: F401 - 建表(conftest)之前先登记模型:表结构快照要存进工件库
|
||||
from app.engine import masking
|
||||
|
||||
MASKED_WORD = "已遮罩"
|
||||
#: 设了遮罩的列
|
||||
MASKED = ["buyer_phone", "buyer_name"]
|
||||
#: 遮罩列里的值:遮完的结果里不能出现
|
||||
SECRETS = ("1390000000", "张伟", "李娜", "王芳")
|
||||
SCRIPT = ("CREATE TABLE sales (id INTEGER, region TEXT, buyer_phone TEXT, buyer_name TEXT, amount INTEGER, note TEXT);"
|
||||
"INSERT INTO sales VALUES (1, '华东', '13900000001', '张伟', 1200, '改址'),"
|
||||
" (2, '华南', '13900000002', '李娜', 300, '回访'), (3, '华北', '13900000003', '王芳', 900, '');"
|
||||
# 只有一列号码的表:标量子查询、UNION 后一支的 * 直接展开它
|
||||
"CREATE TABLE phones (buyer_phone TEXT);"
|
||||
"INSERT INTO phones VALUES ('13900000009');")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.executescript(SCRIPT)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def masked_run(db, sql: str) -> masking.QueryMask:
|
||||
"""真跑一遍 SQL,按工具边界的办法遮(mask_query_result)。"""
|
||||
cursor = db.execute(sql)
|
||||
columns = [d[0] for d in cursor.description]
|
||||
rows = [list(r) for r in cursor.fetchall()]
|
||||
payload = {"columns": columns, "rows": rows, "row_count": len(rows), "truncated": False, "sql": sql}
|
||||
return masking.mask_query_result(payload, MASKED, "sqlite")
|
||||
|
||||
|
||||
def _leaks(done: masking.QueryMask) -> bool:
|
||||
text = json.dumps(done.payload["rows"], ensure_ascii=False)
|
||||
return any(s in text for s in SECRETS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, hidden, shown", [
|
||||
pytest.param("WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT * FROM t", {"c", "d"}, set(),
|
||||
id="CTE 列名表套 * 展开表"),
|
||||
pytest.param("WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT c AS 号码, e AS 金额 FROM t "
|
||||
"ORDER BY e DESC LIMIT 1", {"号码"}, set(), id="CTE 列名表套 * 再改名"),
|
||||
pytest.param("SELECT 0 AS a, '' AS b, '' AS c, '' AS d, 0 AS e, '' AS f UNION ALL SELECT * FROM sales",
|
||||
{"c", "d"}, set(), id="UNION 后一支的 * 展开表"),
|
||||
pytest.param("SELECT region FROM sales UNION ALL SELECT * FROM phones", {"region"}, set(),
|
||||
id="UNION 后一支的 * 展开只有号码的表"),
|
||||
pytest.param("WITH a AS (SELECT buyer_phone FROM sales), b(x) AS (SELECT * FROM a) SELECT x FROM b", {"x"},
|
||||
set(), id="列名表套 * 展开另一段 CTE"),
|
||||
pytest.param("SELECT r FROM (SELECT region AS r FROM sales UNION ALL "
|
||||
"SELECT * FROM (SELECT buyer_phone FROM sales))", {"r"}, set(), id="子查询里 UNION 后一支的 *"),
|
||||
pytest.param("SELECT * FROM (SELECT region FROM sales UNION ALL SELECT * FROM (SELECT buyer_phone FROM sales))",
|
||||
{"region"}, set(), id="外层 * 取子查询里 UNION 后一支的 *"),
|
||||
pytest.param("WITH t(k) AS (SELECT * FROM (SELECT buyer_name FROM sales)) SELECT k AS 名 FROM t", {"名"}, set(),
|
||||
id="列名表套 * 展开子查询"),
|
||||
pytest.param("SELECT region FROM sales UNION ALL SELECT s.* FROM (SELECT buyer_name FROM sales) s", {"region"},
|
||||
set(), id="UNION 后一支的 t.*"),
|
||||
pytest.param("SELECT region FROM sales UNION SELECT * FROM (SELECT buyer_phone FROM sales)", {"region"}, set(),
|
||||
id="UNION 去重"),
|
||||
# 只展开一个派生来源、每一列都数得清:按位置只遮取自遮罩列的那一列,金额照常
|
||||
pytest.param("SELECT '合计' AS 区域, SUM(amount) AS 金额 FROM sales UNION ALL SELECT * FROM "
|
||||
"(SELECT buyer_phone, amount FROM sales ORDER BY amount DESC LIMIT 2)", {"区域"}, {"金额"},
|
||||
id="合计行 UNION 后一支的 * 按位置"),
|
||||
pytest.param("SELECT region AS 区域, amount AS 金额 FROM sales UNION ALL SELECT * FROM "
|
||||
"(SELECT * FROM (SELECT buyer_name, amount FROM sales))", {"区域"}, {"金额"},
|
||||
id="套了两层的 * 按位置"),
|
||||
pytest.param("WITH t(r, x) AS (SELECT * FROM (SELECT buyer_phone, amount FROM sales)) SELECT r AS 号码, x AS 金额 "
|
||||
"FROM t", {"号码"}, {"金额"}, id="列名表套 * 按位置"),
|
||||
pytest.param("SELECT (SELECT * FROM (SELECT buyer_phone FROM sales) LIMIT 1) AS x", {"x"}, set(),
|
||||
id="标量子查询里的 * 展开子查询"),
|
||||
pytest.param("SELECT region, (SELECT * FROM (SELECT s2.buyer_name FROM sales s2 WHERE s2.id = s.id)) AS 名 "
|
||||
"FROM sales s", {"名"}, {"region"}, id="相关子查询里的 *"),
|
||||
pytest.param("SELECT (SELECT * FROM phones LIMIT 1) AS x", {"x"}, set(), id="标量子查询里的 * 展开表"),
|
||||
])
|
||||
def test_a_star_that_renames_a_masked_column_is_masked(db, sql, hidden, shown):
|
||||
"""* 把遮罩列的值带进了别的列名:这几列整列遮,原值不交出去。按位置认得出的,别的列照常。"""
|
||||
done = masked_run(db, sql)
|
||||
columns = {c.lower() for c in done.columns}
|
||||
assert hidden <= columns, done.columns
|
||||
assert not shown & columns, done.columns
|
||||
assert not _leaks(done), done.payload["rows"]
|
||||
assert done.secrets.strong, "遮掉的值交回调用方(后面的查询、回答里按值遮)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
"SELECT * FROM (SELECT region, amount FROM sales) t",
|
||||
"WITH t AS (SELECT * FROM sales) SELECT region, SUM(amount) AS 合计 FROM t GROUP BY region",
|
||||
"SELECT region AS 区域 FROM sales UNION ALL SELECT * FROM (SELECT region FROM sales)",
|
||||
"WITH t(a, b) AS (SELECT * FROM (SELECT region, amount FROM sales)) SELECT a, SUM(b) AS 合计 FROM t GROUP BY a",
|
||||
"SELECT region, EXISTS(SELECT * FROM sales s2 WHERE s2.id = s.id AND s2.amount > 500) AS 大单 FROM sales s",
|
||||
"SELECT '合计' AS 区域, SUM(amount) AS 金额 FROM sales UNION ALL SELECT * FROM "
|
||||
"(SELECT region, SUM(amount) FROM sales GROUP BY region)",
|
||||
])
|
||||
def test_a_star_that_brings_no_masked_values_stays_visible(db, sql):
|
||||
"""* 只带出没碰遮罩列的值、EXISTS 里的 *(只说有没有):一列都不遮,结果原样。"""
|
||||
done = masked_run(db, sql)
|
||||
assert done.columns == [] and MASKED_WORD not in json.dumps(done.payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_a_plain_star_still_masks_by_name(db):
|
||||
"""不改名的 *(单独一条 SELECT)照旧只遮名字对上遮罩列的那几列。"""
|
||||
done = masked_run(db, "SELECT * FROM sales")
|
||||
assert done.columns == ["buyer_phone", "buyer_name"] and not _leaks(done)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, columns, hidden", [
|
||||
pytest.param("SELECT x FROM (SELECT * FROM (SELECT buyer_phone FROM sales) s) AS q(x)", ["x"], {"x"},
|
||||
id="子查询列名表套 *"),
|
||||
pytest.param("SELECT l.x FROM sales s CROSS JOIN LATERAL (SELECT * FROM (SELECT s.buyer_phone) z) AS l(x)", ["x"],
|
||||
{"x"}, id="LATERAL 列名表套 *"),
|
||||
pytest.param("SELECT ARRAY(SELECT * FROM (SELECT buyer_phone FROM sales) s) AS arr", ["arr"], {"arr"},
|
||||
id="ARRAY 子查询里的 *"),
|
||||
])
|
||||
def test_a_star_renaming_in_postgres_is_masked(sql, columns, hidden):
|
||||
snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []}
|
||||
assert hidden <= masking._derived_hidden(snapshot, set(MASKED), "postgres")[0]
|
||||
|
||||
|
||||
def test_a_metric_summed_over_a_star_renamed_masked_column_is_not_sent_to_blind_check():
|
||||
"""双盲复算按同一套认法挡下取自遮罩列的指标(derived_columns):遮罩的金额经 CTE 列名表套 * 改名之后汇总,照样认得出,
|
||||
区域照常。"""
|
||||
sql = "WITH q(r, x) AS (SELECT * FROM (SELECT region, amount FROM sales)) SELECT r AS 区域, SUM(x) AS gmv FROM q " \
|
||||
"GROUP BY r"
|
||||
snapshot = {"source": "shop", "sql": sql, "columns": ["区域", "gmv"], "rows": []}
|
||||
assert masking.derived_columns(snapshot, {"amount"}, "sqlite") == {"gmv"}
|
||||
|
||||
|
||||
def test_a_recursive_cte_with_a_star_is_read_without_looping(db):
|
||||
"""递归的 CTE 里 * 展开它自己:照样认得出、不会绕不出来。"""
|
||||
sql = ("WITH RECURSIVE r(n, p) AS (SELECT 1, buyer_phone FROM sales WHERE id = 1 UNION ALL "
|
||||
"SELECT n + 1, p FROM r WHERE n < 2) SELECT * FROM (SELECT * FROM r) t")
|
||||
done = masked_run(db, sql)
|
||||
assert "p" in {c.lower() for c in done.columns} and not _leaks(done)
|
||||
loop = "WITH RECURSIVE r AS (SELECT buyer_phone AS p FROM sales UNION ALL SELECT * FROM r) SELECT p AS 号 FROM r"
|
||||
snapshot = {"source": "shop", "sql": loop, "columns": ["号"], "rows": []}
|
||||
assert "号" in masking._derived_hidden(snapshot, set(MASKED), "sqlite")[0]
|
||||
|
||||
|
||||
def test_a_star_with_a_replace_is_not_counted_by_position():
|
||||
"""* 带着 REPLACE / EXCLUDE 这类改动(DuckDB 一类的写法):展开成的列不再是来源那几列的原样,不按位置数,整段都算。
|
||||
这里 REPLACE 把手机号换进了「amount」那一列。"""
|
||||
sql = ("SELECT region, amount FROM sales UNION ALL SELECT * REPLACE (buyer_phone AS amount) FROM "
|
||||
"(SELECT buyer_phone, amount FROM sales) t")
|
||||
snapshot = {"source": "shop", "sql": sql, "columns": ["region", "amount"], "rows": []}
|
||||
assert {"region", "amount"} <= masking._derived_hidden(snapshot, set(MASKED), "duckdb")[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# * 展开数据源里的表:按查询当时的表结构数位置
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
#: 两个月的订单表列一样、次序一样(上传表格里的月度工作表常这样);三月那张的号码和区域换了次序
|
||||
MONTHS = ("CREATE TABLE jan (id INTEGER, region TEXT, buyer_phone TEXT, amount INTEGER);"
|
||||
"CREATE TABLE feb (id INTEGER, region TEXT, buyer_phone TEXT, amount INTEGER);"
|
||||
"CREATE TABLE mar (id INTEGER, buyer_phone TEXT, region TEXT, amount INTEGER);"
|
||||
"INSERT INTO jan VALUES (1, '华东', '13900000001', 1200), (2, '华南', '13900000002', 300);"
|
||||
"INSERT INTO feb VALUES (3, '华东', '13900000003', 900);"
|
||||
"INSERT INTO mar VALUES (4, '13900000004', '华北', 400);")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def months():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.executescript(MONTHS)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def schema_of(conn) -> dict:
|
||||
"""数据源的表结构(schema_cache 的样子):每张表的列按表里的次序。"""
|
||||
names = [name for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")]
|
||||
return {"tables": {name: {"columns": [{"name": row[1]} for row in conn.execute(f"PRAGMA table_info({name})")]}
|
||||
for name in names}}
|
||||
|
||||
|
||||
def run_months(conn, sql: str, schema: dict | None) -> masking.QueryMask:
|
||||
cursor = conn.execute(sql)
|
||||
payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql}
|
||||
return masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=schema)
|
||||
|
||||
|
||||
def test_a_union_of_tables_with_the_same_layout_is_masked_by_name_with_the_schema(months):
|
||||
"""UNION 的几支都是 SELECT * FROM 表、表的列一样:按表结构数得出号码落在哪一列,只遮那一列,汇总照常。没有表结构时
|
||||
说不清号码排在第几,* 展开落到的那一段整段遮(多遮)。"""
|
||||
schema = schema_of(months)
|
||||
total = "SELECT region, SUM(amount) AS 合计 FROM (SELECT * FROM jan UNION ALL SELECT * FROM feb) t GROUP BY region"
|
||||
done = run_months(months, total, schema)
|
||||
assert done.columns == [] and done.payload["rows"] == [["华东", 2100], ["华南", 300]]
|
||||
assert {"region", "合计"} <= set(run_months(months, total, None).columns)
|
||||
both = run_months(months, "SELECT * FROM jan UNION ALL SELECT * FROM feb", schema)
|
||||
assert both.columns == ["buyer_phone"] and not _leaks(both)
|
||||
|
||||
|
||||
def test_a_union_of_tables_in_another_order_masks_where_the_values_land(months):
|
||||
"""三月那张表号码排在第二列:UNION 之后它落进第一支的「region」,按表结构认得出,这一列遮;金额照常。"""
|
||||
schema = schema_of(months)
|
||||
both = run_months(months, "SELECT * FROM jan UNION ALL SELECT * FROM mar", schema)
|
||||
assert {"region", "buyer_phone"} <= set(both.columns) and "amount" not in both.columns and not _leaks(both)
|
||||
total = "SELECT region AS 区域, SUM(amount) AS 合计 FROM (SELECT * FROM jan UNION ALL SELECT * FROM mar) t " \
|
||||
"GROUP BY region"
|
||||
done = run_months(months, total, schema)
|
||||
assert done.columns == ["区域"] and not _leaks(done)
|
||||
|
||||
|
||||
def test_a_column_list_over_a_table_is_masked_by_position_with_the_schema(months):
|
||||
"""列名表套 * 展开数据源里的表:按表结构只遮号码换了名字的那一列。"""
|
||||
done = run_months(months, "WITH t(a, b, c, d) AS (SELECT * FROM jan) SELECT * FROM t", schema_of(months))
|
||||
assert done.columns == ["c"] and [row[3] for row in done.payload["rows"]] == [1200, 300]
|
||||
|
||||
|
||||
def test_a_schema_that_does_not_match_the_table_masks_the_whole_stretch(months):
|
||||
"""表结构里没有这张表,或者列数和实际展开的对不上(表结构过期了):照旧整段遮。"""
|
||||
stale = schema_of(months)
|
||||
stale["tables"]["feb"]["columns"] = stale["tables"]["feb"]["columns"][:3]
|
||||
for schema in (stale, {"tables": {"jan": schema_of(months)["tables"]["jan"]}}):
|
||||
both = run_months(months, "SELECT * FROM jan UNION ALL SELECT * FROM feb", schema)
|
||||
assert set(both.columns) == {"id", "region", "buyer_phone", "amount"} and not _leaks(both)
|
||||
|
||||
|
||||
async def test_a_snapshot_is_read_with_its_frozen_schema(months):
|
||||
"""事后按快照认(双盲复算挡指标、审批质询收值)用快照记着的表结构快照(schema_artifact),和查询当时工具边界的认法
|
||||
一样:几张同样的月度表 UNION 起来汇总的金额不算取自遮罩列。"""
|
||||
from app.core import artifact_store
|
||||
|
||||
sql = "SELECT region, SUM(amount) AS 合计 FROM (SELECT * FROM jan UNION ALL SELECT * FROM feb) t GROUP BY region"
|
||||
schema = await artifact_store.put_json({"source": "shop", **schema_of(months)}, kind="schema_snapshot",
|
||||
run_id="r-star", node_id="n1")
|
||||
snapshot = {"source": "shop", "sql": sql, "columns": ["region", "合计"], "rows": [], "schema_artifact": schema}
|
||||
assert masking.derived_columns(snapshot, {"buyer_phone"}, "sqlite") == set()
|
||||
assert masking._derived_hidden(snapshot, {"buyer_phone"}, "sqlite")[0] == {"buyer_phone"}
|
||||
assert masking.derived_columns({**snapshot, "schema_artifact": "missing"}, {"buyer_phone"}, "sqlite") == {
|
||||
"region", "合计"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 写了库名的表、过期的表结构(复核第 3 轮)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
#: 同名的两张表:主库的 sales 和另一个库(archive)里的 sales,号码、区域换了次序。表结构探查的是主库
|
||||
TWO_SALES = ("CREATE TABLE sales (id INTEGER, region TEXT, buyer_phone TEXT, buyer_name TEXT, amount INTEGER, "
|
||||
"note TEXT);"
|
||||
"INSERT INTO sales VALUES (1, '华东', '13900000001', '张伟', 1200, '改址');"
|
||||
"CREATE TABLE archive.sales (id INTEGER, buyer_phone TEXT, region TEXT, buyer_name TEXT, amount INTEGER, "
|
||||
"note TEXT);"
|
||||
"INSERT INTO archive.sales VALUES (7, '13900000007', '华北', '王芳', 500, '回访');")
|
||||
SALES_LAYOUT = ["id", "region", "buyer_phone", "buyer_name", "amount", "note"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_sales():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute("ATTACH DATABASE ':memory:' AS archive")
|
||||
conn.executescript(TWO_SALES)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def probed(schema: str | None = None) -> dict:
|
||||
"""探查主库得到的表结构(schema_cache 的样子):没设 schema 的探查当前库,qualified 就是表名。"""
|
||||
meta = {"qualified": f"{schema}.sales" if schema else "sales", "schema": schema,
|
||||
"columns": [{"name": c} for c in SALES_LAYOUT]}
|
||||
return {"tables": {"sales": meta}, "schema": schema}
|
||||
|
||||
|
||||
def run_two(conn, sql: str, schema: dict | None) -> masking.QueryMask:
|
||||
cursor = conn.execute(sql)
|
||||
payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql}
|
||||
return masking.mask_query_result(payload, MASKED, "sqlite", schema=schema)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
pytest.param("WITH t(a, b, c, d, e, f) AS (SELECT * FROM archive.sales) SELECT b AS j, a AS n FROM t",
|
||||
id="列名表套 * 展开另一个库里的同名表"),
|
||||
pytest.param("SELECT 0 AS a, '' AS b, '' AS c, '' AS d, 0 AS e, '' AS f WHERE 0 UNION ALL "
|
||||
"SELECT * FROM archive.sales", id="UNION 后一支的 * 展开另一个库里的同名表"),
|
||||
pytest.param("WITH t AS (SELECT * FROM sales UNION ALL SELECT * FROM archive.sales) SELECT region AS 区域 FROM t",
|
||||
id="派生来源里 UNION 后一支展开另一个库里的同名表"),
|
||||
])
|
||||
def test_a_table_in_another_schema_is_not_counted_by_the_probed_one(two_sales, sql):
|
||||
"""表结构探查的是主库:查询写了别的库名的同名表(archive.sales,号码排在第二列),不能按主库那张表的列序数位置,
|
||||
按数不清处理,* 展开落到的那一段整段遮。以前按表名查表结构,号码落进的那一列被当成区域,原样交给模型、落库。"""
|
||||
done = run_two(two_sales, sql, probed())
|
||||
assert not _leaks(done), done.payload["rows"]
|
||||
|
||||
|
||||
def test_a_table_named_with_its_probed_schema_is_still_counted(two_sales):
|
||||
"""库名和表结构记下的对得上(探查时设了 schema、qualified 带着它):照样按表结构数位置,只遮号码那一列。没写库名的
|
||||
照旧按表名查。库名对不上表结构记下的(这里探查时没设 schema,查询写了 main.)按数不清处理,整段遮(多遮)。"""
|
||||
precise = "WITH t(a, b, c, d, e, f) AS (SELECT * FROM {}) SELECT c AS j, a AS n FROM t"
|
||||
assert run_two(two_sales, precise.format("main.sales"), probed("main")).columns == ["j"]
|
||||
assert run_two(two_sales, precise.format("sales"), probed()).columns == ["j"]
|
||||
assert set(run_two(two_sales, precise.format("main.sales"), probed()).columns) == {"j", "n"}
|
||||
|
||||
|
||||
def test_schema_columns_are_also_keyed_by_the_probed_schema():
|
||||
"""表结构按「库名.表名」另记一份(库名取 schema、qualified 的前缀);写了别的库名的查不到。"""
|
||||
tables = masking._schema_columns(probed("public"))
|
||||
assert tables["sales"] == tables["public.sales"] == SALES_LAYOUT and "archive.sales" not in tables
|
||||
assert "public.sales" not in masking._schema_columns(probed())
|
||||
assert "archive.sales" not in (masking._schema_tables(probed("public")) or {})
|
||||
|
||||
|
||||
#: 一月的表结构是早先探查的(那时还没有 extra 这一列),表里现在多了一列,排在第二;二月那张号码排在第二列
|
||||
STALE = ("CREATE TABLE jan (id INTEGER, extra TEXT, region TEXT, buyer_phone TEXT, amount INTEGER);"
|
||||
"CREATE TABLE feb (id INTEGER, buyer_phone TEXT, region TEXT, amount INTEGER, extra TEXT);"
|
||||
"INSERT INTO jan VALUES (1, '加急', '华东', '13900000001', 1200);"
|
||||
"INSERT INTO feb VALUES (2, '13900000002', '华南', 300, '普通');")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stale():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.executescript(STALE)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def layout(**tables: list[str]) -> dict:
|
||||
return {"tables": {name: {"qualified": name, "columns": [{"name": c} for c in columns]}
|
||||
for name, columns in tables.items()}}
|
||||
|
||||
|
||||
#: 过期的表结构:一月那张少了 extra(列数和表里实际的不一样)
|
||||
OLD_JAN = layout(jan=["id", "region", "buyer_phone", "amount"], feb=["id", "buyer_phone", "region", "amount", "extra"])
|
||||
#: 准的表结构
|
||||
NOW = layout(jan=["id", "extra", "region", "buyer_phone", "amount"],
|
||||
feb=["id", "buyer_phone", "region", "amount", "extra"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
pytest.param("WITH t AS (SELECT * FROM jan UNION ALL SELECT id, buyer_phone, region, amount, extra FROM feb) "
|
||||
"SELECT extra AS 备注 FROM t", id="后一支明写各列"),
|
||||
pytest.param("WITH t AS (SELECT * FROM jan UNION ALL SELECT * FROM feb) SELECT extra AS 备注 FROM t",
|
||||
id="后一支也是 *"),
|
||||
])
|
||||
def test_a_first_branch_counted_by_a_stale_schema_is_not_trusted(stale, sql):
|
||||
"""UNION 第一支是 *、按表结构数出的列名(表结构过期了,少一列)和后一支的列数对不上:不按它对列名,* 所在的位置按认不出
|
||||
算,整份遮。以前照过期的列名表对,二月的号码落进的「extra」没认出来,原样交回。"""
|
||||
cursor = stale.execute(sql)
|
||||
payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql}
|
||||
for schema in (OLD_JAN, None, NOW):
|
||||
done = masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=schema)
|
||||
assert done.columns == ["备注"] and not _leaks(done), (schema, done.payload["rows"])
|
||||
|
||||
|
||||
def test_a_first_branch_counted_by_an_accurate_schema_still_places_the_values(stale):
|
||||
"""表结构准、每一支数得出的列数都对得上:照样按第一支的列名对上位置,只遮号码落进的那一列,金额照常。"""
|
||||
sql = ("WITH t AS (SELECT * FROM jan UNION ALL SELECT id, buyer_phone, region, buyer_phone, amount FROM feb) "
|
||||
"SELECT region AS 区域, amount AS 金额 FROM t")
|
||||
cursor = stale.execute(sql)
|
||||
payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql}
|
||||
assert masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=NOW).columns == []
|
||||
moved = sql.replace("SELECT id, buyer_phone, region,", "SELECT id, region, buyer_phone,")
|
||||
cursor = stale.execute(moved)
|
||||
payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()],
|
||||
"sql": moved}
|
||||
done = masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=NOW)
|
||||
assert done.columns == ["区域"] and [row[1] for row in done.payload["rows"]] == [1200, 300]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""按 SQL 认取自遮罩列的结果列(engine/masking.py 的 _derived_hidden):FROM、JOIN、LATERAL 里的表值函数,汇总里的 CASE。
|
||||
|
||||
核查 agent 的查询在工具边界按这套认法整列遮掉(mask_query_result),审批质询、双盲复算也用它:
|
||||
|
||||
- 表值函数(SQLite 的 json_each / json_tree,Postgres 的 unnest、jsonb_each_text、regexp_split_to_table、LATERAL
|
||||
VALUES,MySQL 的 JSON_TABLE)把遮罩列拆开时,它出的列都算取自遮罩列:遮罩列是 JSON 时,用 json_each 拆开是最自然的
|
||||
写法,以前只追 SELECT 项、子查询和 *,这些写法的原值原样交给模型、落进快照。出列名认得全的(常见的几个函数、写了
|
||||
列名表的)按名字认,认不全的整份遮
|
||||
- 汇总里的 CASE / IIF 只在条件里用到遮罩列的(SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END)),同
|
||||
写在 WHERE 里,不算;结果的某一支取的是遮罩列、不汇总的逐行 CASE 照旧算
|
||||
|
||||
数据都是虚构的:手机号、区域、金额。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import masking
|
||||
|
||||
#: 设了遮罩的列:手机号,和装着手机号、姓名的 JSON 列
|
||||
MASKED = {"buyer_phone", "profile"}
|
||||
SALES = ["id", "region", "buyer_phone", "amount", "profile"]
|
||||
#: SQLite 的 json_each / json_tree 出的列
|
||||
JSON_EACH = ["key", "value", "type", "atom", "id", "parent", "fullkey", "path"]
|
||||
|
||||
|
||||
def hidden_of(sql: str, columns: list[str], kind: str = "sqlite", masked: set[str] = MASKED) -> set[str]:
|
||||
snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []}
|
||||
return masking._derived_hidden(snapshot, set(masked), kind)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind, sql, columns, shown", [
|
||||
pytest.param("sqlite", "SELECT j.key, j.value FROM sales, json_each(sales.profile) AS j", ["key", "value"],
|
||||
["key", "value"], id="json_each 拆遮罩的 JSON 列"),
|
||||
pytest.param("sqlite", "SELECT t.atom AS 值 FROM sales, json_tree(sales.profile) AS t WHERE t.type = 'text'",
|
||||
["值"], ["值"], id="json_tree"),
|
||||
pytest.param("sqlite", "SELECT value FROM sales, json_each(json_array(buyer_phone))", ["value"], ["value"],
|
||||
id="json_each 套 json_array"),
|
||||
pytest.param("sqlite", "SELECT r.value AS 号码 FROM sales s JOIN json_each(s.profile) r ON 1 = 1", ["号码"],
|
||||
["号码"], id="JOIN 里的 json_each"),
|
||||
pytest.param("sqlite", "SELECT value FROM json_each((SELECT json_group_array(buyer_phone) FROM sales))",
|
||||
["value"], ["value"], id="参数是子查询"),
|
||||
pytest.param("sqlite", "SELECT c.value AS 号码 FROM (SELECT json_group_array(buyer_phone) AS arr FROM sales) t, "
|
||||
"json_each(t.arr) c", ["号码"], ["号码"], id="参数取自派生来源里改了名字的遮罩列"),
|
||||
pytest.param("sqlite", "SELECT * FROM sales, json_each(sales.profile)", [*SALES, *JSON_EACH],
|
||||
["buyer_phone", "profile", "key", "value", "atom", "fullkey", "path"], id="* 带出 json_each 的列"),
|
||||
pytest.param("sqlite", "SELECT x FROM (SELECT j.value AS x FROM sales, json_each(sales.profile) j) t", ["x"],
|
||||
["x"], id="子查询里拆开再改名"),
|
||||
pytest.param("postgres", "SELECT u FROM sales, unnest(ARRAY[buyer_phone]) AS u", ["u"], ["u"], id="unnest"),
|
||||
pytest.param("postgres", "SELECT x.p FROM sales s CROSS JOIN LATERAL unnest(ARRAY[s.buyer_phone]) AS x(p)",
|
||||
["p"], ["p"], id="LATERAL unnest 带列名表"),
|
||||
pytest.param("postgres", "SELECT x FROM sales, LATERAL unnest(ARRAY[buyer_phone]) AS x", ["x"], ["x"],
|
||||
id="LATERAL unnest"),
|
||||
pytest.param("postgres", "SELECT u FROM sales CROSS JOIN LATERAL (VALUES (buyer_phone)) AS v(u)", ["u"], ["u"],
|
||||
id="LATERAL VALUES"),
|
||||
pytest.param("postgres", "SELECT d FROM sales, regexp_split_to_table(buyer_phone, '-') AS d", ["d"], ["d"],
|
||||
id="regexp_split_to_table"),
|
||||
pytest.param("postgres", "SELECT e.key, e.value FROM sales, jsonb_each_text(profile) AS e", ["key", "value"],
|
||||
["key", "value"], id="jsonb_each_text"),
|
||||
pytest.param("postgres", "SELECT x FROM sales, unnest(string_to_array(buyer_phone, ',')) AS x", ["x"], ["x"],
|
||||
id="unnest 套 string_to_array"),
|
||||
pytest.param("postgres", "SELECT * FROM sales, unnest(ARRAY[buyer_phone])", [*SALES, "unnest"],
|
||||
["buyer_phone", "profile", "unnest"], id="没起别名的 unnest 按函数名出列"),
|
||||
pytest.param("mysql", "SELECT j.p FROM sales s, JSON_TABLE(JSON_ARRAY(s.buyer_phone), '$[*]' "
|
||||
"COLUMNS (p VARCHAR(20) PATH '$')) j", ["p"], ["p"], id="JSON_TABLE"),
|
||||
])
|
||||
def test_a_table_function_over_a_masked_column_hides_what_it_returns(kind, sql, columns, shown):
|
||||
assert {c.lower() for c in shown} <= hidden_of(sql, columns, kind)
|
||||
|
||||
|
||||
def test_columns_a_table_function_does_not_return_stay_visible():
|
||||
"""拆的是遮罩列,别的列(区域、金额)照常;拆的不是遮罩列的,一列都不遮。"""
|
||||
hidden = hidden_of("SELECT s.region, s.amount, j.value AS 值 FROM sales s, json_each(s.profile) j",
|
||||
["region", "amount", "值"])
|
||||
assert "值" in hidden and not {"region", "amount"} & hidden
|
||||
assert hidden_of("SELECT s.region, j.value AS 标签 FROM sales s, json_each(s.tags) j", ["region", "标签"]) == MASKED
|
||||
|
||||
|
||||
def test_a_table_function_that_cannot_be_read_hides_the_whole_snapshot():
|
||||
"""不认得的函数拆遮罩列、又没写列名表:说不清出哪几列,整份快照的列都遮(同 SQL 解析不了)。"""
|
||||
columns = ["region", "piece"]
|
||||
assert hidden_of("SELECT s.region, t.piece FROM sales s, my_split(s.buyer_phone) t", columns, "postgres") == {
|
||||
*MASKED, "region", "piece"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
"SELECT SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END) AS total FROM sales",
|
||||
"SELECT SUM(CASE buyer_phone WHEN '13900000001' THEN amount END) AS total FROM sales",
|
||||
"SELECT AVG(IIF(buyer_phone LIKE '139%', amount, NULL)) AS total FROM sales",
|
||||
"SELECT MAX(CASE WHEN buyer_phone IS NULL THEN 0 ELSE amount END) AS total FROM sales",
|
||||
"SELECT SUM(CASE WHEN buyer_phone IS NULL THEN 0 ELSE CASE WHEN region = '华东' THEN amount END END) AS total "
|
||||
"FROM sales",
|
||||
])
|
||||
def test_a_masked_column_only_in_the_condition_of_an_aggregated_case_is_like_a_filter(sql):
|
||||
"""值全部来自金额,手机号只决定这一行算不算进来:同写在 WHERE 里,不算取自遮罩列。"""
|
||||
assert hidden_of(sql, ["total"]) == MASKED
|
||||
assert hidden_of(sql.replace("CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END", "amount")
|
||||
+ " WHERE buyer_phone IS NOT NULL", ["total"]) == MASKED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
# 结果的某一支取的就是遮罩列
|
||||
"SELECT SUM(CASE WHEN region = '华东' THEN buyer_phone END) AS total FROM sales",
|
||||
"SELECT MAX(IIF(amount > 1000, buyer_phone, NULL)) AS total FROM sales",
|
||||
# 不汇总的逐行 CASE:每一行各自落在哪一支
|
||||
"SELECT CASE WHEN buyer_phone IS NULL THEN 0 ELSE amount END AS total FROM sales",
|
||||
])
|
||||
def test_a_case_returning_the_masked_value_or_not_aggregated_is_still_derived(sql):
|
||||
assert "total" in hidden_of(sql, ["total"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 函数参数里的 JSON 箭头(复核第 2 轮)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind, sql, columns, shown", [
|
||||
pytest.param("sqlite", "SELECT j.value AS v FROM sales, json_each(profile->'$.tels') AS j", ["v"], ["v"],
|
||||
id="SQLite json_each 参数带箭头"),
|
||||
pytest.param("postgres", "SELECT e AS 号 FROM sales, json_array_elements_text(profile->'tels') AS e", ["号"],
|
||||
["号"], id="Postgres json_array_elements_text"),
|
||||
pytest.param("postgres", "SELECT e.value AS 号 FROM sales, jsonb_array_elements(profile->'tels') AS e", ["号"],
|
||||
["号"], id="Postgres jsonb_array_elements"),
|
||||
pytest.param("postgres", "SELECT t.tel FROM sales, jsonb_to_recordset(profile->'contacts') AS t(tel text)",
|
||||
["tel"], ["tel"], id="Postgres jsonb_to_recordset 列名表"),
|
||||
pytest.param("postgres", "SELECT k, v FROM sales, jsonb_each_text(profile->'contact') AS x(k, v)", ["k", "v"],
|
||||
["k", "v"], id="Postgres jsonb_each_text 列名表"),
|
||||
pytest.param("postgres", "SELECT jsonb_array_elements_text(profile->'tels') AS 号 FROM sales", ["号"], ["号"],
|
||||
id="Postgres 选出项里拆开"),
|
||||
pytest.param("postgres", "SELECT jsonb_array_elements_text(profile -> 'contact' -> 'tels') AS 号 FROM sales",
|
||||
["号"], ["号"], id="Postgres 连着两个箭头"),
|
||||
pytest.param("mysql", "SELECT JSON_UNQUOTE(profile->'$.tel') AS 电话 FROM sales", ["电话"], ["电话"],
|
||||
id="MySQL JSON_UNQUOTE"),
|
||||
pytest.param("mysql", "SELECT CONCAT(profile->'$.tel', '') AS 电话 FROM sales", ["电话"], ["电话"],
|
||||
id="MySQL CONCAT"),
|
||||
])
|
||||
def test_a_json_arrow_inside_function_arguments_is_read_as_the_masked_column(kind, sql, columns, shown):
|
||||
"""函数参数里没写表名的 col->'path' 被解析器当成以 col 为参数的 lambda(不是一列):以前认不出遮罩列,拆开、取出来的
|
||||
值原样交给模型、落库。遮罩列是 JSON 时这几种是最自然的写法。写了表名、用 ->> 的本来就认得出。"""
|
||||
assert {c.lower() for c in shown} <= hidden_of(sql, columns, kind)
|
||||
|
||||
|
||||
def test_a_json_arrow_over_an_unmasked_column_stays_visible():
|
||||
"""箭头取的不是遮罩列:一列都不遮。真的 lambda 的参数照列名收,只有和遮罩列同名时才多遮。"""
|
||||
assert hidden_of("SELECT j.value AS 标签 FROM sales, json_each(tags->'$.list') AS j", ["标签"]) == MASKED
|
||||
assert hidden_of("SELECT transform(scores, x -> x + 1) AS 分 FROM sales", ["分"], "postgres") == MASKED
|
||||
|
||||
|
||||
def test_a_json_arrow_inside_json_each_is_masked_when_really_run():
|
||||
"""SQLite 里真跑一遍:json_each(profile->'$.tels') 拆出来的号码在交给模型之前就遮掉。"""
|
||||
import sqlite3
|
||||
|
||||
db = sqlite3.connect(":memory:")
|
||||
db.executescript("CREATE TABLE sales (id INTEGER, profile TEXT);"
|
||||
"INSERT INTO sales VALUES (1, '{\"tels\": [\"13900000001\", \"13900000002\"]}');")
|
||||
sql = "SELECT j.value AS v FROM sales, json_each(profile->'$.tels') AS j"
|
||||
cursor = db.execute(sql)
|
||||
payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql}
|
||||
db.close()
|
||||
done = masking.mask_query_result(payload, ["profile"], "sqlite")
|
||||
assert done.columns == ["v"] and done.payload["rows"] == [["已遮罩"], ["已遮罩"]]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""按 SQL 认取自遮罩列的结果列(engine/masking.py 的 _derived_hidden):整行、函数参数里的 *、列名表把遮罩列带进别的列名
|
||||
(复核第 3 轮)。
|
||||
|
||||
Postgres 里不带表名、名字是 FROM 里一个来源的列引用指它的整行(row_to_json(s)、json_agg(t)、SELECT s、s::text),函数
|
||||
参数里的 s.*(to_json(s.*)、concat_ws(',', s.*)、ROW(s.*))也把整行交给函数;数据源里的表可以带列名表(FROM sales AS
|
||||
s(i, r, p),只改前几列的名字),TABLE sales 等于 SELECT * FROM sales;Oracle 的 JSON_OBJECT(*) 把整行拼成 JSON,UNPIVOT、
|
||||
PIVOT、MATCH_RECOGNIZE 把遮罩列的值转进它们自己起名的列。这些写法以前都认不出:遮罩列的值落进了别的列名,原样交给核查
|
||||
agent、落进快照。现在整行、函数参数里的 * 按 * 展开的来源认(数据源里的表一律算带出了遮罩列),列名表按位置对上(表结构
|
||||
查得到这张表的只遮号码改了名字的那一列),TABLE x 当 SELECT * FROM x,PIVOT 这类读到遮罩列的整份遮。
|
||||
|
||||
这里没有真的 Postgres、Oracle:结果按它们交回的样子造(行转成 JSON、记录转成文字),只看遮完的结果里还有没有原值。数据
|
||||
都是虚构的:区域、手机号、姓名、金额。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import masking
|
||||
|
||||
MASKED_WORD = "已遮罩"
|
||||
MASKED = ["buyer_phone", "buyer_name"]
|
||||
SECRETS = ("1390000000", "张伟", "李娜")
|
||||
LAYOUT = ["id", "region", "buyer_phone", "buyer_name", "amount", "note"]
|
||||
#: 探查得到的表结构(没设 schema,探查的是当前库)
|
||||
SCHEMA = {"tables": {"sales": {"qualified": "sales", "schema": None, "columns": [{"name": c} for c in LAYOUT]}}}
|
||||
ROWS = [[1, "华东", "13900000001", "张伟", 1200, "改址"], [2, "华南", "13900000002", "李娜", 300, "回访"]]
|
||||
|
||||
|
||||
def as_json(row: list) -> str:
|
||||
return json.dumps(dict(zip(LAYOUT, row)), ensure_ascii=False)
|
||||
|
||||
|
||||
def as_record(row: list) -> str:
|
||||
return "(" + ",".join(str(v) for v in row) + ")"
|
||||
|
||||
|
||||
def hidden_of(sql: str, columns: list[str], kind: str = "postgres", schema: dict | None = None) -> set[str]:
|
||||
snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []}
|
||||
return masking._derived_hidden(snapshot, set(MASKED), kind, masking._schema_columns(schema))[0]
|
||||
|
||||
|
||||
def masked(sql: str, columns: list[str], rows: list[list], kind: str = "postgres",
|
||||
schema: dict | None = SCHEMA) -> masking.QueryMask:
|
||||
payload = {"columns": columns, "rows": rows, "sql": sql}
|
||||
return masking.mask_query_result(payload, MASKED, kind, schema=schema)
|
||||
|
||||
|
||||
def leaks(done: masking.QueryMask) -> bool:
|
||||
text = json.dumps(done.payload["rows"], ensure_ascii=False)
|
||||
return any(s in text for s in SECRETS)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 整行引用、函数参数里的 *
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, cells", [
|
||||
pytest.param("SELECT row_to_json(s) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS], id="row_to_json(s)"),
|
||||
pytest.param("SELECT to_jsonb(s) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS], id="to_jsonb(s)"),
|
||||
pytest.param("SELECT s AS j, s.id AS n FROM sales s", [as_record(r) for r in ROWS], id="SELECT s"),
|
||||
pytest.param("SELECT s::text AS j, s.id AS n FROM sales s", [as_record(r) for r in ROWS], id="s::text"),
|
||||
pytest.param("SELECT to_json(s.*) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS], id="to_json(s.*)"),
|
||||
pytest.param("SELECT concat_ws(',', s.*) AS j, s.id AS n FROM sales s",
|
||||
[",".join(str(v) for v in r) for r in ROWS], id="concat_ws(',', s.*)"),
|
||||
pytest.param("SELECT ROW(s.*) AS j, s.id AS n FROM sales s", [as_record(r) for r in ROWS], id="ROW(s.*)"),
|
||||
pytest.param("SELECT (s).buyer_phone AS j, s.id AS n FROM sales s", [r[2] for r in ROWS], id="(s).buyer_phone"),
|
||||
pytest.param("SELECT (s.*).buyer_name AS j, s.id AS n FROM sales s", [r[3] for r in ROWS], id="(s.*).buyer_name"),
|
||||
pytest.param("SELECT (SELECT row_to_json(s)) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS],
|
||||
id="相关子查询里的整行"),
|
||||
pytest.param("SELECT kv.value AS j, s.id AS n FROM sales s, LATERAL jsonb_each_text(to_jsonb(s)) AS kv "
|
||||
"WHERE kv.key = 'buyer_phone'", [r[2] for r in ROWS], id="LATERAL 拆开整行"),
|
||||
])
|
||||
def test_a_whole_row_carrying_masked_columns_is_masked(sql, cells):
|
||||
"""整行、函数参数里的 s.* 把遮罩列的值交给了别的列:这一列遮,编号照常。"""
|
||||
done = masked(sql, ["j", "n"], [[cell, row[0]] for cell, row in zip(cells, ROWS)])
|
||||
assert done.columns == ["j"] and not leaks(done), done.payload["rows"]
|
||||
assert [row[1] for row in done.payload["rows"]] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
"SELECT json_agg(t) AS j FROM (SELECT id, buyer_phone FROM sales) t",
|
||||
"SELECT row_to_json(t) AS j FROM (SELECT buyer_name AS 名 FROM sales) t",
|
||||
"WITH t AS (SELECT id, buyer_phone AS p FROM sales) SELECT json_agg(t) AS j FROM t",
|
||||
])
|
||||
def test_a_whole_row_of_a_derived_source_is_read_through_its_columns(sql):
|
||||
"""整行取自子查询、CTE:按它选出的列认,里面有遮罩列(改了名字的也算)就算。"""
|
||||
assert "j" in hidden_of(sql, ["j"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
"SELECT (SELECT j FROM (SELECT row_to_json(s) AS j) z) AS j FROM sales s",
|
||||
"SELECT (SELECT j FROM (SELECT json_agg(t) AS j) z) AS j FROM (SELECT id, buyer_phone FROM sales) t",
|
||||
"SELECT (WITH w AS (SELECT row_to_json(s) AS j) SELECT j FROM w) AS j FROM sales s",
|
||||
"SELECT (SELECT j FROM (SELECT to_json(s.*) AS j) z) AS j FROM sales s",
|
||||
])
|
||||
def test_a_whole_row_of_an_outer_query_is_found_from_a_nested_derived_table(sql):
|
||||
"""FROM 里的子查询、CTE 看不见同一层 FROM 里的别的来源,却看得见再外层的查询:写在里面的整行引用、s.* 指外层的来源,
|
||||
照样认得出。"""
|
||||
assert "j" in hidden_of(sql, ["j"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
"SELECT json_agg(t) AS j FROM (SELECT id, region, amount FROM sales) t",
|
||||
"SELECT count(s.*) AS j FROM sales s",
|
||||
"SELECT COUNT(*) AS j FROM sales s",
|
||||
"SELECT region AS j, SUM(amount) AS total FROM sales s GROUP BY region",
|
||||
"SELECT EXISTS(SELECT * FROM sales s2 WHERE s2.id = s.id) AS j FROM sales s",
|
||||
])
|
||||
def test_rows_and_stars_that_bring_no_masked_values_stay_visible(sql):
|
||||
"""整行取自没有遮罩列的子查询、count(s.*) 只数个数:不算。"""
|
||||
assert hidden_of(sql, ["j", "total"]) == set(MASKED)
|
||||
|
||||
|
||||
def test_a_column_named_like_its_table_is_a_column_where_the_schema_says_so():
|
||||
"""Postgres 先按列认:表里有一列和表同名(visits 表的 visits 列),SELECT visits 取的是这一列,不是整行。表结构里查得到
|
||||
这一列就不按整行算;SQLite、MySQL 没有整行引用,不看表结构也不按整行算。"""
|
||||
schema = {"tables": {"visits": {"columns": [{"name": c} for c in ("id", "visits", "buyer_phone")]}}}
|
||||
sql = "SELECT visits AS j FROM visits"
|
||||
assert hidden_of(sql, ["j"], schema=schema) == set(MASKED)
|
||||
assert hidden_of(sql, ["j"], kind="sqlite") == set(MASKED)
|
||||
assert "j" in hidden_of(sql, ["j"]), "表结构里查不到这一列:可能是整行,按整行算(多遮)"
|
||||
|
||||
|
||||
def test_a_star_inside_a_function_reads_every_source_it_expands():
|
||||
"""Oracle 的 JSON_OBJECT(*) 把整行拼成 JSON:同 to_json(s.*)。"""
|
||||
done = masked("SELECT JSON_OBJECT(*) AS j FROM sales", ["j"], [[as_json(r)] for r in ROWS], kind="oracle")
|
||||
assert done.columns == ["j"] and not leaks(done)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 列名表、TABLE x
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_table_with_a_column_list_masks_the_renamed_masked_column():
|
||||
"""数据源里的表带列名表:号码改叫 p。列名表写全了的,按表结构对上位置,只遮 p 和姓名改叫的 q,编号照常;Postgres 的
|
||||
列名表只写前几列(后面的照原名)时,列名表和表的列数对不上,改了名的几列整段遮(多遮),照原名的姓名按名字遮。"""
|
||||
full = "SELECT p AS j, i AS n FROM sales AS s(i, r, p, q, a, o)"
|
||||
done = masked(full, ["j", "n"], [[r[2], r[0]] for r in ROWS])
|
||||
assert done.columns == ["j"] and not leaks(done)
|
||||
assert set(masked(full, ["j", "n"], [[r[2], r[0]] for r in ROWS], schema=None).columns) == {"j", "n"}, \
|
||||
"表结构里查不到这张表:说不清哪几列改了名,都遮"
|
||||
short = masked("SELECT p AS j, i AS n FROM sales AS s(i, r, p)", ["j", "n"], [[r[2], r[0]] for r in ROWS])
|
||||
assert set(short.columns) == {"j", "n"} and not leaks(short)
|
||||
star = masked("SELECT * FROM sales AS s(i, r, p)", ["i", "r", "p", *LAYOUT[3:]], [list(r) for r in ROWS])
|
||||
assert star.columns == ["i", "r", "p", "buyer_name"] and not leaks(star)
|
||||
|
||||
|
||||
def test_a_column_list_on_a_cte_reference_renames_it_too():
|
||||
"""引用 CTE 时再写列名表(FROM t AS x(p, a)):同样按位置对上。"""
|
||||
sql = "WITH t AS (SELECT buyer_phone, amount FROM sales) SELECT p AS j, a AS total FROM t AS x(p, a)"
|
||||
assert hidden_of(sql, ["j", "total"]) >= {"j"}
|
||||
assert "total" not in hidden_of(sql, ["j", "total"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, columns, shown", [
|
||||
pytest.param("WITH t(a, b, c, d, e, f) AS (TABLE sales) SELECT c AS j, a AS n FROM t", ["j", "n"], {"n"},
|
||||
id="CTE 里的 TABLE"),
|
||||
pytest.param("SELECT * FROM (TABLE sales) AS t(a, b, c, d, e, f)", ["a", "b", "c", "d", "e", "f"], {"a", "b", "e"},
|
||||
id="子查询里的 TABLE"),
|
||||
pytest.param("WITH t(a, b, c) AS (TABLE sales) SELECT c AS j FROM t", ["j"], set(), id="列名表只写前几列"),
|
||||
])
|
||||
def test_a_table_statement_is_read_as_select_star(sql, columns, shown):
|
||||
"""TABLE sales(Postgres、MySQL)就是 SELECT * FROM sales:列名表照样按位置对上,号码改叫 c 的那一列遮。"""
|
||||
hidden = hidden_of(sql, columns, schema=SCHEMA)
|
||||
assert {"j", "c"} & hidden and not shown & hidden, hidden
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
"SELECT 0 AS a, '' AS b, '' AS c, '' AS d, 0 AS e, '' AS f WHERE false UNION ALL TABLE sales",
|
||||
"WITH t(a, b, c) AS (TABLE archive.sales) SELECT c AS j FROM t",
|
||||
"WITH t(a, b, c) AS (TABLE sales ORDER BY id LIMIT 1) SELECT c AS j FROM t",
|
||||
"WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT c AS j FROM t WHERE (",
|
||||
])
|
||||
def test_sql_that_cannot_be_read_but_has_a_star_or_a_table_statement_hides_everything(sql):
|
||||
"""解析器认不出的写法(UNION 后一支的 TABLE、带库名或 ORDER BY 的 TABLE):SQL 里没写遮罩列,可 TABLE 语句、* 会把它
|
||||
带进别的列名,整份快照的列都算,同解析不了又写着遮罩列的。"""
|
||||
columns = ["a", "b", "c", "j"]
|
||||
assert hidden_of(sql, columns) >= set(columns)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", [
|
||||
"SELECT COUNT(*) AS j FROM sales WHERE (",
|
||||
"SELECT amount * 2 AS j FROM sales WHERE (",
|
||||
])
|
||||
def test_sql_that_cannot_be_read_with_only_a_count_or_a_product_shows_the_rest(sql):
|
||||
"""只有 COUNT(*)、乘号:不会把遮罩列带出来,照旧只遮遮罩列本身。"""
|
||||
assert hidden_of(sql, ["j"]) == set(MASKED)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# UNPIVOT、PIVOT、MATCH_RECOGNIZE
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, columns", [
|
||||
pytest.param("SELECT id, val FROM sales UNPIVOT (val FOR col IN (buyer_phone, region))", ["id", "val"],
|
||||
id="UNPIVOT"),
|
||||
pytest.param("SELECT * FROM (SELECT region, buyer_phone FROM sales) PIVOT (MAX(buyer_phone) FOR region IN "
|
||||
"('华东' AS e, '华南' AS s))", ["e", "s"], id="PIVOT"),
|
||||
pytest.param("SELECT * FROM sales MATCH_RECOGNIZE (PARTITION BY region ORDER BY id MEASURES buyer_phone AS x "
|
||||
"ONE ROW PER MATCH PATTERN (a) DEFINE a AS amount > 0)", ["region", "x"], id="MATCH_RECOGNIZE"),
|
||||
])
|
||||
def test_pivots_reading_masked_columns_hide_everything(sql, columns):
|
||||
"""UNPIVOT 的值列、PIVOT 按 IN 里的值起的列、MATCH_RECOGNIZE 的 MEASURES 装着遮罩列的值:出的是哪几列不好认,整份遮。"""
|
||||
assert hidden_of(sql, columns, kind="oracle") >= {c.lower() for c in columns}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, columns", [
|
||||
("SELECT id, val FROM sales UNPIVOT (val FOR col IN (region, note))", ["id", "val"]),
|
||||
("SELECT * FROM (SELECT region, buyer_phone FROM sales) PIVOT (COUNT(buyer_phone) FOR region IN "
|
||||
"('华东' AS e, '华南' AS s))", ["e", "s"]),
|
||||
])
|
||||
def test_pivots_that_bring_no_masked_values_stay_visible(sql, columns):
|
||||
assert hidden_of(sql, columns, kind="oracle") == set(MASKED)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 选出项套了括号的 *
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_star_in_parentheses_is_counted_as_a_star():
|
||||
"""Postgres 里选出项 (s.*)、(s).* 照样展开成好几列:后面没起别名的表达式按结果末尾倒着数,不按一列数位置。"""
|
||||
columns = [*LAYOUT, "upper"]
|
||||
for item in ("(s.*)", "(s).*"):
|
||||
sql = f"SELECT {item}, UPPER(s.buyer_name) FROM sales s"
|
||||
assert hidden_of(sql, columns) == {*MASKED, "upper"}, item
|
||||
|
||||
|
||||
def test_sqlite_runs_of_the_same_shapes_are_unchanged():
|
||||
"""SQLite 没有整行引用、列名表:同样的写法照旧(表和列同名时按列认,不多遮)。"""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.executescript("CREATE TABLE region (id INTEGER, region TEXT, buyer_phone TEXT);"
|
||||
"INSERT INTO region VALUES (1, '华东', '13900000001');")
|
||||
sql = "SELECT region AS 区域, COUNT(*) AS n FROM region GROUP BY region"
|
||||
cursor = conn.execute(sql)
|
||||
payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql}
|
||||
assert masking.mask_query_result(payload, MASKED, "sqlite").payload is payload
|
||||
conn.close()
|
||||
@@ -0,0 +1,625 @@
|
||||
"""核查 agent 看到的查询结果先遮、存下的快照也遮(engine/verify_agent.py 建查询工具时打开 tools/datasource 的遮罩开关)。
|
||||
|
||||
双盲复算、反方审稿、审批质询共用的核查 agent 以前把查询结果原样交给模型,遮罩是事后按值做的(审批质询的 _Secrets),
|
||||
每一轮复核都找到新的漏法。现在在工具边界先遮:数据源设了遮罩时,按 SQL 认出取自遮罩列的结果列(别名、函数、子查询、
|
||||
WITH、SELECT *),连同列名直接对上的,这些格子在交给模型的结果、落库的快照里都是「已遮罩」,快照记下哪些列在查询时
|
||||
就遮了;别的文字格里夹着这份结果里遮罩值的那一截同样换掉(换了写法的号码也认)。运行详情「工件」页签走的通用接口
|
||||
GET /api/artifacts/{id} 给的也就是遮好的。没碰遮罩列的聚合查询原样。遮掉的值只在内存里交回调用方(审批质询按值遮的
|
||||
第二道防线用),不进快照、不交给模型。遮罩开关缺省关着,别的调用方(Agent 节点、调用工具节点)照旧。
|
||||
|
||||
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,手机号、姓名、金额都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.core import artifact_store
|
||||
from app.core.events import EventType
|
||||
from app.data import introspect
|
||||
from app.data.engine import engines
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import DataSource, Setting
|
||||
from app.engine import masking, spend
|
||||
from app.engine.verify_agent import run_verify_agent
|
||||
from app.main import app
|
||||
from app.tools.datasource import build_datasource_tools
|
||||
from app.tools.registry import ToolContext, Versions
|
||||
from tests.test_verify_agent import SPEC, Script, q
|
||||
|
||||
MASKED = "已遮罩"
|
||||
#: 遮罩列里的值(和备注里换了写法的号码):交给模型的、存下的、接口给的都不能出现
|
||||
SECRETS = ("1390000000", "张伟", "李娜", "王芳", "刘洋", "139-0000-0003", "139 0000 0003")
|
||||
SALES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, buyer_name TEXT, amount INTEGER,"
|
||||
" note TEXT);"
|
||||
"INSERT INTO sales VALUES (1, '华东', '13900000001', '张伟', 1200, '客户13900000001来电改址'),"
|
||||
" (2, '华东', '13900000002', '李娜', 300, '例行回访'), (3, '华南', '13900000003', '王芳', 900, '回访:联系 139-0000-0003'),"
|
||||
" (4, '华北', '13900000004', '刘洋', 400, '');"
|
||||
# 联系方式表的号码列、客户资料的 JSON 列都没设遮罩(json_shop 另设了 profile):同一批号码、姓名写在这里
|
||||
"CREATE TABLE contacts (id INTEGER PRIMARY KEY, phone_num TEXT, visits INTEGER);"
|
||||
"INSERT INTO contacts VALUES (1, '13900000001', 5), (2, '139 0000 0003', 2), (3, '13800000000', 1);"
|
||||
"CREATE TABLE customers (id INTEGER PRIMARY KEY, region TEXT, profile TEXT);"
|
||||
"INSERT INTO customers VALUES (1, '华东', '{\"phone\": \"13900000001\", \"name\": \"张伟\"}'),"
|
||||
" (2, '华南', '{\"phone\": \"13900000002\", \"name\": \"李娜\"}');")
|
||||
VALUE_SCHEMA = {"type": "object", "properties": {"value": {"type": "number", "description": "要核对的数"}},
|
||||
"required": ["value"]}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean_spend():
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(Setting).where(Setting.key.in_([spend.SPEND_KEY, "verify"])))
|
||||
await session.commit()
|
||||
yield
|
||||
|
||||
|
||||
async def _source(tmp_path, options: dict | None, script: str = SALES) -> tuple[str, int]:
|
||||
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript(script)
|
||||
db.commit()
|
||||
db.close()
|
||||
name = f"shop_{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options=options or {})
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
row.schema_cache = await introspect.introspect(row)
|
||||
await session.commit()
|
||||
return name, row.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def masked_shop(tmp_path):
|
||||
"""四笔订单,买家手机号、姓名两列设了遮罩;备注没设遮罩,其中两条写着手机号(一条换了写法)。返回源名。"""
|
||||
name, source_id = await _source(tmp_path, {"mask_columns": "buyer_phone, buyer_name"})
|
||||
yield name
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def json_shop(tmp_path):
|
||||
"""同一个库,客户资料(JSON:手机号、姓名)这一列设了遮罩。返回源名。"""
|
||||
name, source_id = await _source(tmp_path, {"mask_columns": "profile"})
|
||||
yield name
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def plain_shop(tmp_path):
|
||||
name, source_id = await _source(tmp_path, None)
|
||||
yield name
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def ask(name: str, **kwargs):
|
||||
"""按剧本跑一次核查 agent(剧本由各个测试先用 Script 装好),交回结果和发出的事件。"""
|
||||
events: list[tuple] = []
|
||||
out = await run_verify_agent(spec=SPEC, system="核对一个数。", task="查一下。", sources=[name],
|
||||
output_schema=VALUE_SCHEMA, data_versions=Versions.CURRENT, run_id="run-mask",
|
||||
node_id="check", emit=lambda kind, **data: events.append((kind, data)), **kwargs)
|
||||
return out, events
|
||||
|
||||
|
||||
def _clean(text: str) -> bool:
|
||||
return not any(s in text for s in SECRETS)
|
||||
|
||||
|
||||
def _cells(snapshot: dict, column: str) -> list:
|
||||
i = snapshot["columns"].index(column)
|
||||
return [row[i] for row in snapshot["rows"]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, masked", [
|
||||
pytest.param("SELECT buyer_phone, amount FROM sales ORDER BY id", ["buyer_phone"], id="直接选出"),
|
||||
pytest.param("SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY id", ["手机号"], id="起别名"),
|
||||
pytest.param("SELECT UPPER(buyer_name) AS 姓名, amount FROM sales ORDER BY id", ["姓名"], id="套函数"),
|
||||
pytest.param("SELECT t.p AS 号码, t.amount FROM (SELECT id, buyer_phone AS p, amount FROM sales) t ORDER BY t.id",
|
||||
["号码"], id="子查询"),
|
||||
pytest.param("WITH t AS (SELECT id, buyer_phone AS p, amount FROM sales) SELECT p, amount FROM t ORDER BY id",
|
||||
["p"], id="WITH"),
|
||||
pytest.param("SELECT * FROM sales ORDER BY id", ["buyer_phone", "buyer_name"], id="SELECT *"),
|
||||
])
|
||||
async def test_masked_columns_are_masked_before_the_model_sees_them(monkeypatch, masked_shop, sql, masked):
|
||||
name = masked_shop
|
||||
amount = "amount" if "金额" not in sql else "金额"
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": amount}, "rationale": "见 [[see:Q1]]。"})
|
||||
out, events = await ask(name)
|
||||
assert out.error is None and out.result["value"] == 1200, "没遮的列照常取值"
|
||||
|
||||
# 交给模型的工具结果:遮罩列是「已遮罩」,别的列照原样
|
||||
seen = script.tool_results()["c1"]
|
||||
assert seen.startswith("【证据 Q1】") and MASKED in seen and _clean(seen), seen
|
||||
payload = json.loads(seen.split("\n", 1)[1])
|
||||
for column in masked:
|
||||
assert set(_cells(payload, column)) == {MASKED}
|
||||
assert _cells(payload, amount) == [1200, 300, 900, 400]
|
||||
assert "mask_columns" not in payload and "masked_at_query" not in payload, "交给模型的结果不多出字段"
|
||||
|
||||
# 落库的快照:同样遮好,记下查询时就遮了的列;数据源设的遮罩列照记,换了名字的结果列也记进去(证据面板据此画成遮罩)
|
||||
[query] = out.queries
|
||||
snapshot = artifact_store.load(query["artifact"])
|
||||
assert _clean(json.dumps(snapshot, ensure_ascii=False))
|
||||
for column in masked:
|
||||
assert set(_cells(snapshot, column)) == {MASKED}
|
||||
assert snapshot.get("column_types", {}).get(column, "text") == "text"
|
||||
assert _cells(snapshot, amount) == [1200, 300, 900, 400]
|
||||
assert snapshot["masked_at_query"] == masked
|
||||
# mask_columns 照记查询当时数据源设的遮罩列(证据面板按它从表的字段清单里去掉列),改了名字的结果列只记在
|
||||
# masked_at_query:混进去的话,同源别的表里同名的真实字段(name、region)会从字段清单里消失
|
||||
assert snapshot["mask_columns"] == ["buyer_phone", "buyer_name"]
|
||||
|
||||
# 运行详情「工件」页签走的通用接口:给的就是遮好的
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
got = await client.get(f"/api/artifacts/{query['artifact']}")
|
||||
assert got.status_code == 200 and got.json()["content"] == snapshot and _clean(got.text)
|
||||
|
||||
# 事件(tool.end 的预览)、收尾时交给模型的查询结果里也没有
|
||||
[end] = [d for k, d in events if k == EventType.TOOL_END]
|
||||
assert MASKED in end["preview"] and _clean(end["preview"])
|
||||
final = "\n".join(str(m.content) for m in script.calls[-1][1])
|
||||
assert MASKED in final and _clean(final)
|
||||
|
||||
# 遮掉的值只在内存里交回调用方,不跟着结果往外交
|
||||
assert out.masked_values is not None and out.masked_values.strong
|
||||
assert "masked_values" not in out.as_dict()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, value", [
|
||||
("SELECT SUM(amount) AS total FROM sales", 2800),
|
||||
("SELECT COUNT(DISTINCT buyer_phone) AS total FROM sales", 4),
|
||||
("SELECT SUM(amount) AS total FROM sales WHERE buyer_phone = '13900000001'", 1200),
|
||||
("SELECT region, SUM(amount) AS total FROM sales GROUP BY region ORDER BY total DESC", 1500),
|
||||
("SELECT region, note, amount AS total FROM sales ORDER BY id", 1200),
|
||||
# 汇总里的 CASE 只在条件里用到手机号:同写在 WHERE 里,值全部来自金额
|
||||
("SELECT SUM(CASE WHEN buyer_phone LIKE '139%' THEN amount ELSE 0 END) AS total FROM sales", 2800),
|
||||
])
|
||||
async def test_queries_that_do_not_read_masked_values_are_untouched(monkeypatch, masked_shop, sql, value):
|
||||
"""没碰遮罩列的值(汇总、只数个数、只在条件里用到):结果原样,取值照常,快照记下查询时一列都没遮。只遮这份结果
|
||||
里的遮罩值:备注里写着手机号、这份结果却没有选出手机号的,按值认不出(那是数据源的遮罩没设全)。"""
|
||||
name = masked_shop
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "见 [[see:Q1]]。"})
|
||||
out, _ = await ask(name)
|
||||
assert out.error is None and out.result["value"] == value
|
||||
seen = script.tool_results()["c1"]
|
||||
assert MASKED not in seen
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["masked_at_query"] == [] and MASKED not in json.dumps(snapshot, ensure_ascii=False)
|
||||
assert [c.lower() for c in snapshot["mask_columns"]] == ["buyer_phone", "buyer_name"]
|
||||
|
||||
|
||||
async def test_masked_values_inside_other_text_cells_are_cut_out(monkeypatch, masked_shop):
|
||||
"""同一份结果里选出了手机号:备注里夹着的同一个号码(换了写法的也算)换成「已遮罩」,备注别的字照原样。"""
|
||||
name = masked_shop
|
||||
sql = "SELECT buyer_phone, note, amount AS total FROM sales ORDER BY id"
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "见 [[see:Q1]]。"})
|
||||
out, _ = await ask(name)
|
||||
seen = script.tool_results()["c1"]
|
||||
assert _clean(seen)
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert _cells(snapshot, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""]
|
||||
assert snapshot["masked_at_query"] == ["buyer_phone"], "备注只换了一截,不算整列遮"
|
||||
|
||||
|
||||
async def test_a_cell_reference_into_a_masked_column_is_not_taken(monkeypatch, masked_shop):
|
||||
"""模型交的格子引用指到查询时就遮了的那一列:不采信,照实说是遮罩列,不说成「不是数值」。"""
|
||||
name = masked_shop
|
||||
sql = "SELECT buyer_phone AS 手机号, amount FROM sales ORDER BY id"
|
||||
Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "手机号"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
assert out.result["value"] is None
|
||||
[cell] = out.cells
|
||||
assert cell["status"] == "unresolved" and "1390000000" not in json.dumps(cell)
|
||||
assert cell["reason"] == "Q1 的「手机号」取自设置了遮罩的列,查询时已遮罩,不采信"
|
||||
|
||||
|
||||
async def test_sql_that_cannot_be_read_but_names_a_masked_column_masks_the_whole_result(monkeypatch, masked_shop):
|
||||
"""SQL 认不出哪一列取自遮罩列、里面又写着遮罩列:整份结果的列都遮(和双盲复算、审批质询同一个保守规则)。"""
|
||||
name = masked_shop
|
||||
monkeypatch.setattr(masking, "_tainted", lambda *args, **kwargs: None)
|
||||
sql = "SELECT buyer_phone, amount FROM sales ORDER BY id"
|
||||
Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "amount"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["masked_at_query"] == ["buyer_phone", "amount"]
|
||||
assert set(_cells(snapshot, "amount")) == {MASKED} and out.result["value"] is None
|
||||
|
||||
|
||||
async def test_the_switch_is_off_by_default_and_a_source_without_masks_is_untouched(masked_shop, plain_shop):
|
||||
"""遮罩开关缺省关着:Agent 节点、调用工具节点建的查询工具照旧交回原样的结果。没设遮罩的数据源开着开关也一字不差
|
||||
(快照里不多出键)。"""
|
||||
sql = "SELECT buyer_phone, amount FROM sales ORDER BY id"
|
||||
ctx = ToolContext(run_id="run-switch", node_id="n1", data_versions=Versions.CURRENT)
|
||||
async with SessionLocal() as session:
|
||||
[plain_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session)
|
||||
[masking_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session, mask_results=True)
|
||||
[unmasked_tool] = await build_datasource_tools([f"db_query__{plain_shop}"], ctx, session, mask_results=True)
|
||||
raw = json.loads(await plain_tool.ainvoke({"sql": sql}))
|
||||
assert _cells(raw, "buyer_phone")[0] == "13900000001"
|
||||
stored = artifact_store.load(raw["artifact"])
|
||||
assert _cells(stored, "buyer_phone")[0] == "13900000001" and "masked_at_query" not in stored
|
||||
shown = json.loads(await masking_tool.ainvoke({"sql": sql}))
|
||||
assert set(_cells(shown, "buyer_phone")) == {MASKED}
|
||||
untouched = json.loads(await unmasked_tool.ainvoke({"sql": sql}))
|
||||
stored = artifact_store.load(untouched["artifact"])
|
||||
assert _cells(stored, "buyer_phone")[0] == "13900000001"
|
||||
assert "masked_at_query" not in stored and "mask_columns" not in stored
|
||||
|
||||
|
||||
async def test_column_names_the_model_wrote_are_kept(monkeypatch, masked_shop):
|
||||
"""列名是模型自己写在 SQL 里的(哪怕写着一个号码):工具边界只遮格子、不改列名,它引用的列名才对得上。"""
|
||||
name = masked_shop
|
||||
sql = 'SELECT buyer_phone, amount AS "13900000001 的金额" FROM sales ORDER BY id'
|
||||
Script(monkeypatch, [q(name, sql, "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": "13900000001 的金额"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["columns"] == ["buyer_phone", "13900000001 的金额"]
|
||||
assert set(_cells(snapshot, "buyer_phone")) == {MASKED} and out.result["value"] == 1200
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 别的查询里遮掉的值、调用方给的值:后面的查询按值遮(复核第 1 轮)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_values_masked_in_an_earlier_query_are_masked_in_later_ones(monkeypatch, masked_shop):
|
||||
"""Q1 选出手机号(查询时整列遮掉,值并进内存);之后的查询里,没设遮罩的联系方式表、备注写着同一批号码(换了写法的
|
||||
也算):整格是号码的整格换,夹在文字里的换那一截。交给模型的、落库的、通用接口给的都是遮好的。以前只按这一份结果
|
||||
里的遮罩值换,Q2 原样交给模型、原样落库,质询工件事后也收不到 Q1 的值。"""
|
||||
name = masked_shop
|
||||
steps = [q(name, "SELECT buyer_phone, amount AS total FROM sales ORDER BY id", "c1"),
|
||||
q(name, "SELECT phone_num, visits FROM contacts ORDER BY id", "c2"),
|
||||
q(name, "SELECT id, note FROM sales ORDER BY id", "c3")]
|
||||
script = Script(monkeypatch, steps, {"value": {"alias": "Q2", "row": 0, "column": "visits"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
assert out.error is None and out.result["value"] == 5
|
||||
seen = script.tool_results()
|
||||
assert all(_clean(seen[c]) for c in ("c1", "c2", "c3")), seen
|
||||
contacts = artifact_store.load(out.queries[1]["artifact"])
|
||||
assert contacts["rows"] == [[MASKED, 5], [MASKED, 2], ["13800000000", 1]]
|
||||
assert contacts["masked_at_query"] == [], "按值换的不算整列遮:没对上的号码照常显示"
|
||||
notes = artifact_store.load(out.queries[2]["artifact"])
|
||||
assert _cells(notes, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""]
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
for query in out.queries:
|
||||
got = await client.get(f"/api/artifacts/{query['artifact']}")
|
||||
assert got.status_code == 200 and _clean(got.text)
|
||||
|
||||
|
||||
async def test_values_given_by_the_caller_are_masked_before_the_model_sees_them(monkeypatch, masked_shop):
|
||||
"""调用方交进来的遮罩值(审批质询、反方审稿收的报告快照里的值):第一条查询就按它遮,模型看不到。"""
|
||||
name = masked_shop
|
||||
given = masking._Secrets()
|
||||
given.add({"columns": ["buyer_phone"], "rows": [["13900000001"], ["13900000003"]]}, {"buyer_phone"})
|
||||
script = Script(monkeypatch, [q(name, "SELECT id, note, amount AS total FROM sales ORDER BY id", "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "x"})
|
||||
out, _ = await ask(name, masked_values=given)
|
||||
assert out.result["value"] == 1200 and _clean(script.tool_results()["c1"])
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert _cells(snapshot, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""]
|
||||
assert not given.strong - out.masked_values.strong, "交进来的值随遮掉的值一起交回"
|
||||
|
||||
|
||||
async def test_a_reference_to_a_cell_masked_by_value_says_so(monkeypatch, masked_shop):
|
||||
"""格子引用指到按值换成「已遮罩」的那一格:照实说,不说成「不是数值」。"""
|
||||
name = masked_shop
|
||||
steps = [q(name, "SELECT buyer_phone FROM sales ORDER BY id", "c1"),
|
||||
q(name, "SELECT phone_num, visits FROM contacts ORDER BY id", "c2")]
|
||||
Script(monkeypatch, steps, {"value": {"alias": "Q2", "row": 0, "column": "phone_num"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
assert out.result["value"] is None
|
||||
cell = out.cells[0]
|
||||
assert cell["status"] == "unresolved"
|
||||
assert cell["reason"] == "Q2 第 0 行「phone_num」与设置了遮罩的值相同,查询时已遮罩,不采信"
|
||||
|
||||
|
||||
async def test_a_table_function_over_a_masked_json_column_is_masked(monkeypatch, json_shop):
|
||||
"""遮罩的是 JSON 列(客户资料):用 json_each 拆开是最自然的写法,拆出来的 key、value 在交给模型、落库之前就遮掉。
|
||||
以前原样交给模型并落库,masked_at_query 是空的。"""
|
||||
name = json_shop
|
||||
sql = "SELECT c.id AS 编号, j.key, j.value FROM customers c, json_each(c.profile) j ORDER BY c.id, j.key"
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": "编号"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
assert out.result["value"] == 1 and _clean(script.tool_results()["c1"])
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["masked_at_query"] == ["key", "value"] and _cells(snapshot, "编号") == [1, 1, 2, 2]
|
||||
assert _clean(json.dumps(snapshot, ensure_ascii=False))
|
||||
|
||||
|
||||
async def test_a_failed_query_does_not_hand_masked_values_to_the_model(monkeypatch, masked_shop):
|
||||
"""数据库的报错会带上数据里的值(Postgres 类型转换失败:invalid input syntax for type bigint: "139-0000-0001"):
|
||||
数据源设了遮罩时,引号里、SQL 里没写着的那一段和不带引号的长数换成「已遮罩」;模型自己写的名字照留(它据此改写
|
||||
SQL)。遮罩开关没开的(Agent 节点)照旧原话。"""
|
||||
from app.tools import datasource
|
||||
|
||||
said = {"message": ""}
|
||||
|
||||
async def failing(source, sql, **kwargs):
|
||||
raise RuntimeError(said["message"])
|
||||
|
||||
monkeypatch.setattr(datasource, "run_query", failing)
|
||||
sql = "SELECT CAST(buyer_phon AS BIGINT) AS p FROM sales WHERE id = 12345"
|
||||
ctx = ToolContext(run_id="run-error", node_id="n1", data_versions=Versions.CURRENT)
|
||||
async with SessionLocal() as session:
|
||||
[masking_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session, mask_results=True)
|
||||
[plain_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session)
|
||||
for message, shown in [
|
||||
('invalid input syntax for type bigint: "139-0000-0001"', 'invalid input syntax for type bigint: "已遮罩"'),
|
||||
("value '13900000001' is out of range for type integer", "value '已遮罩' is out of range for type integer"),
|
||||
("Conversion Error: Type INT64 with value 13900000001 can't be cast to INT32",
|
||||
"Conversion Error: Type INT64 with value 已遮罩 can't be cast to INT32"),
|
||||
('column "buyer_phon" does not exist', 'column "buyer_phon" does not exist'),
|
||||
("no rows near id 12345", "no rows near id 12345"),
|
||||
]:
|
||||
said["message"] = message
|
||||
assert await masking_tool.ainvoke({"sql": sql}) == f"查询失败:{shown}"
|
||||
assert await plain_tool.ainvoke({"sql": sql}) == f"查询失败:{message}"
|
||||
|
||||
|
||||
def test_a_result_from_a_source_without_masks_is_masked_by_the_values_seen_so_far():
|
||||
"""数据源没设遮罩(另一个库里的联系方式表),此前查询遮掉的值照样按值换;这一份结果没有可收的值。"""
|
||||
known = masking._Secrets()
|
||||
known.add({"columns": ["buyer_phone"], "rows": [["13900000001"]]}, {"buyer_phone"})
|
||||
payload = {"sql": "SELECT phone_num, note FROM contacts", "columns": ["phone_num", "note"],
|
||||
"rows": [["13900000001", "客户 139 0000 0001 来电"], ["13800000000", "例行回访"]]}
|
||||
done = masking.mask_query_result(payload, [], "sqlite", known)
|
||||
assert done.payload["rows"] == [[MASKED, f"客户 {MASKED} 来电"], ["13800000000", "例行回访"]]
|
||||
assert done.columns == [] and not done.secrets.strong
|
||||
assert masking.mask_query_result(payload, [], "sqlite").payload is payload, "什么都没收到时原样"
|
||||
|
||||
|
||||
def test_labels_written_in_this_query_are_not_masked_by_values_from_earlier_ones():
|
||||
"""分档标签是这条 SQL 自己写的字:此前查询里认不准、当遮罩值收了的同名标签,不把这一份的标签列换掉。"""
|
||||
known = masking._Secrets()
|
||||
known.add({"columns": ["档"], "rows": [["大单"], ["小单"]]}, {"档"})
|
||||
payload = {"sql": "SELECT CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档, COUNT(*) AS 笔数 FROM sales "
|
||||
"GROUP BY 1", "columns": ["档", "笔数"], "rows": [["大单", 1], ["小单", 3]]}
|
||||
assert masking.mask_query_result(payload, ["amount"], "sqlite", known).payload["rows"] == [["大单", 1], ["小单", 3]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# * 把遮罩列带进别的列名、函数参数里的 JSON 箭头(复核第 2 轮)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, masked", [
|
||||
pytest.param("SELECT region, amount FROM sales WHERE id < 0 UNION ALL "
|
||||
"SELECT * FROM (SELECT buyer_phone, amount FROM sales ORDER BY id)", ["region"],
|
||||
id="UNION 后一支的 *"),
|
||||
pytest.param("WITH t(k, v) AS (SELECT * FROM (SELECT buyer_name, amount FROM sales ORDER BY id)) "
|
||||
"SELECT k AS 姓名, v AS amount FROM t", ["姓名"], id="CTE 列名表套 *"),
|
||||
pytest.param("SELECT (SELECT * FROM (SELECT s2.buyer_phone FROM sales s2 WHERE s2.id = s.id)) AS 号码, s.amount "
|
||||
"FROM sales s ORDER BY s.id", ["号码"], id="标量子查询里的 *"),
|
||||
])
|
||||
async def test_a_star_renaming_a_masked_column_is_masked_before_the_model_sees_it(monkeypatch, masked_shop, sql,
|
||||
masked):
|
||||
"""* 把遮罩列的值带进了别的列名(UNION 后一支、写了列名表的 CTE、标量子查询):交给模型的、落库的、通用接口给的都是
|
||||
「已遮罩」,金额照常。以前 * 本身不算取自哪一列,原值原样交给模型、落进快照。"""
|
||||
name = masked_shop
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": "amount"}, "rationale": "见 [[see:Q1]]。"})
|
||||
out, _ = await ask(name)
|
||||
assert out.error is None and out.result["value"] == 1200
|
||||
seen = script.tool_results()["c1"]
|
||||
assert MASKED in seen and _clean(seen), seen
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["masked_at_query"] == masked and set(_cells(snapshot, masked[0])) == {MASKED}
|
||||
assert _cells(snapshot, "amount") == [1200, 300, 900, 400]
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
got = await client.get(f"/api/artifacts/{out.queries[0]['artifact']}")
|
||||
assert got.status_code == 200 and got.json()["content"] == snapshot and _clean(got.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql, masked", [
|
||||
pytest.param("SELECT c.id AS 编号, j.value AS 值 FROM customers c, json_each(profile->'$') AS j "
|
||||
"ORDER BY c.id, j.key", ["值"], id="json_each 参数带箭头"),
|
||||
pytest.param("SELECT id AS 编号, upper(profile->'$.name') AS 姓名 FROM customers ORDER BY id", ["姓名"],
|
||||
id="函数参数里的箭头"),
|
||||
])
|
||||
async def test_a_json_arrow_over_the_masked_column_is_masked_before_the_model_sees_it(monkeypatch, json_shop, sql,
|
||||
masked):
|
||||
"""遮罩的是 JSON 列:函数参数里没写表名的 profile->'…' 被解析器当成 lambda,以前认不出,拆开、取出来的号码和姓名原样
|
||||
交给模型、落库。"""
|
||||
name = json_shop
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")],
|
||||
{"value": {"alias": "Q1", "row": 0, "column": "编号"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
assert out.result["value"] == 1 and _clean(script.tool_results()["c1"])
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["masked_at_query"] == masked and set(_cells(snapshot, masked[0])) == {MASKED}
|
||||
assert _clean(json.dumps(snapshot, ensure_ascii=False))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 遮罩值的上限:agent 自己查到的值先收(复核第 2 轮)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_values_the_agent_finds_survive_caller_values_that_fill_the_cap(monkeypatch, masked_shop):
|
||||
"""调用方交进来的值(报告快照里的)收满了上限 SECRETS_MAX:agent 自己查到的号码照样收,后面查到的备注按它遮,交回
|
||||
调用方的 masked_values 里也认得出。以前先并调用方的值、满了就停,agent 查到的一个也并不进去:后面的备注原样交给
|
||||
模型、原样落库,回答时按值遮也认不出。"""
|
||||
monkeypatch.setattr(masking, "SECRETS_MAX", 40)
|
||||
name = masked_shop
|
||||
given = masking._Secrets()
|
||||
given.add({"columns": ["buyer_phone"], "rows": [[str(13800000000 + i)] for i in range(40)]}, {"buyer_phone"})
|
||||
assert len(given.weak) == masking.SECRETS_MAX and not given.hit("13900000001")
|
||||
steps = [q(name, "SELECT buyer_phone, amount AS total FROM sales ORDER BY id", "c1"),
|
||||
q(name, "SELECT id, note FROM sales ORDER BY id", "c2")]
|
||||
script = Script(monkeypatch, steps, {"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "x"})
|
||||
out, _ = await ask(name, masked_values=given)
|
||||
assert out.result["value"] == 1200
|
||||
assert _clean(script.tool_results()["c2"]), script.tool_results()["c2"]
|
||||
notes = artifact_store.load(out.queries[1]["artifact"])
|
||||
assert _cells(notes, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""]
|
||||
assert out.masked_values.hit("13900000001") and out.masked_values.hit("13900000003")
|
||||
assert len(out.masked_values.weak) <= masking.SECRETS_MAX, "合起来照样不超过上限:挤掉的是调用方的值"
|
||||
|
||||
|
||||
#: 两个月的订单表,列一样、次序一样(上传表格里的月度工作表常这样)
|
||||
MONTHS = ("CREATE TABLE jan (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER);"
|
||||
"CREATE TABLE feb (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER);"
|
||||
"INSERT INTO jan VALUES (1, '华东', '13900000001', 1200), (2, '华南', '13900000002', 300);"
|
||||
"INSERT INTO feb VALUES (3, '华东', '13900000003', 900);")
|
||||
|
||||
|
||||
async def test_a_union_of_monthly_tables_is_counted_with_the_sources_schema(monkeypatch, tmp_path):
|
||||
"""几张列一样的月度表用 SELECT * 连起来再汇总:按数据源的表结构数得出号码落在第几列,汇总的金额照常交给模型、照常
|
||||
取值;选出全部列的,只遮号码那一列。* 展开数据源里的表又不按表结构数位置的话,展开落到的那一段整段都遮,金额也拿不到。"""
|
||||
name, source_id = await _source(tmp_path, {"mask_columns": "buyer_phone"}, script=MONTHS)
|
||||
try:
|
||||
steps = [q(name, "SELECT region, SUM(amount) AS total FROM (SELECT * FROM jan UNION ALL SELECT * FROM feb) t "
|
||||
"GROUP BY region ORDER BY total DESC", "c1"),
|
||||
q(name, "SELECT * FROM jan UNION ALL SELECT * FROM feb ORDER BY id", "c2")]
|
||||
script = Script(monkeypatch, steps, {"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
assert out.error is None and out.result["value"] == 2100
|
||||
assert all(_clean(text) for text in script.tool_results().values())
|
||||
totals, rows = (artifact_store.load(query["artifact"]) for query in out.queries)
|
||||
assert totals["masked_at_query"] == [] and totals["rows"] == [["华东", 2100], ["华南", 300]]
|
||||
assert rows["masked_at_query"] == ["buyer_phone"] and _cells(rows, "amount") == [1200, 300, 900]
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 过期的表结构、写了库名的表、整行与列名表(复核第 3 轮)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
#: 一月那张表后来多了一列 extra(排在第二),数据源的表结构是那之前探查的;二月那张号码排在第二列
|
||||
REBUILT = ("CREATE TABLE jan (id INTEGER PRIMARY KEY, extra TEXT, region TEXT, buyer_phone TEXT, amount INTEGER);"
|
||||
"CREATE TABLE feb (id INTEGER PRIMARY KEY, buyer_phone TEXT, region TEXT, amount INTEGER, extra TEXT);"
|
||||
"INSERT INTO jan VALUES (1, '加急', '华东', '13900000001', 1200);"
|
||||
"INSERT INTO feb VALUES (2, '13900000002', '华南', 300, '普通');")
|
||||
|
||||
|
||||
async def test_a_stale_schema_does_not_place_masked_values_by_its_old_layout(monkeypatch, tmp_path):
|
||||
"""数据源的表结构过期了(一月那张少了 extra):UNION 第一支的 * 按它数出的列名和后一支的列数对不上,不按它对位置,整份
|
||||
遮。以前照过期的列名对,二月的号码落进的「extra」认不出,原样交给模型、落库。"""
|
||||
name, source_id = await _source(tmp_path, {"mask_columns": "buyer_phone"}, script=REBUILT)
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(DataSource, source_id)
|
||||
cache = dict(row.schema_cache)
|
||||
jan = dict(cache["tables"]["jan"])
|
||||
jan["columns"] = [c for c in jan["columns"] if c["name"] != "extra"]
|
||||
row.schema_cache = {**cache, "tables": {**cache["tables"], "jan": jan}}
|
||||
await session.commit()
|
||||
try:
|
||||
sql = ("WITH t AS (SELECT * FROM jan UNION ALL SELECT id, buyer_phone, region, amount, extra FROM feb) "
|
||||
"SELECT extra AS 备注, id AS n FROM t ORDER BY id")
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "n"},
|
||||
"rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
seen = script.tool_results()["c1"]
|
||||
assert MASKED in seen and _clean(seen), seen
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert "备注" in snapshot["masked_at_query"] and _clean(json.dumps(snapshot, ensure_ascii=False))
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
got = await client.get(f"/api/artifacts/{out.queries[0]['artifact']}")
|
||||
assert got.status_code == 200 and _clean(got.text)
|
||||
finally:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
#: 造出来的 Postgres、Oracle 数据源的表结构:探查的是当前库(没设 schema),sales 一张表
|
||||
PG_LAYOUT = ["id", "region", "buyer_phone", "buyer_name", "amount", "note"]
|
||||
PG_SCHEMA = {"tables": {"sales": {"qualified": "sales", "schema": None,
|
||||
"columns": [{"name": c, "type": "TEXT"} for c in PG_LAYOUT]}}, "schema": None}
|
||||
PG_ROWS = [[1, "华东", "13900000001", "张伟", 1200, "改址"], [2, "华南", "13900000002", "李娜", 300, "回访"]]
|
||||
|
||||
|
||||
def _json_row(row: list) -> str:
|
||||
return json.dumps(dict(zip(PG_LAYOUT, row)), ensure_ascii=False)
|
||||
|
||||
|
||||
#: (数据源类型, SQL, 数据库交回的行, 查询时就整列遮掉的列, 编号那一格取到的值)。结果列按 SQL 里起的别名(j、n)。行照
|
||||
#: 驱动交回的样子造:JSON 是字符串,记录转成文字;archive.sales 是另一个库里的同名表,号码排在第二列。表的列名表只写了
|
||||
#: 前几列(Postgres 后面的列照原名)、另一个库里的同名表:说不清号码改叫了哪个名字,那一段整段遮,编号也遮
|
||||
DRIVER_CASES = [
|
||||
("postgresql", "SELECT json_agg(t) AS j, 1 AS n FROM (SELECT id, buyer_phone FROM sales) t",
|
||||
[[json.dumps([{"id": r[0], "buyer_phone": r[2]} for r in PG_ROWS]), 1]], ["j"], 1),
|
||||
("postgresql", "SELECT row_to_json(s) AS j, s.id AS n FROM sales s ORDER BY s.id",
|
||||
[[_json_row(r), r[0]] for r in PG_ROWS], ["j"], 1),
|
||||
("postgresql", "SELECT to_json(s.*) AS j, s.id AS n FROM sales s ORDER BY s.id",
|
||||
[[_json_row(r), r[0]] for r in PG_ROWS], ["j"], 1),
|
||||
("postgresql", "SELECT s AS j, s.id AS n FROM sales s ORDER BY s.id",
|
||||
[["(" + ",".join(map(str, r)) + ")", r[0]] for r in PG_ROWS], ["j"], 1),
|
||||
("postgresql", "SELECT p AS j, i AS n FROM sales AS s(i, r, p) ORDER BY i", [[r[2], r[0]] for r in PG_ROWS],
|
||||
["j", "n"], None),
|
||||
("postgresql", "WITH t(a, b, c, d, e, f) AS (TABLE sales) SELECT c AS j, a AS n FROM t ORDER BY a",
|
||||
[[r[2], r[0]] for r in PG_ROWS], ["j"], 1),
|
||||
("postgresql", "WITH t(a, b, c, d, e, f) AS (SELECT * FROM archive.sales) SELECT b AS j, a AS n FROM t",
|
||||
[["13900000007", 7]], ["j", "n"], None),
|
||||
("postgresql", "WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT c AS j, a AS n FROM t ORDER BY a",
|
||||
[[r[2], r[0]] for r in PG_ROWS], ["j"], 1),
|
||||
("oracle", "SELECT JSON_OBJECT(*) AS j, id AS n FROM sales ORDER BY id", [[_json_row(r), r[0]] for r in PG_ROWS],
|
||||
["j"], 1),
|
||||
("oracle", "SELECT id AS n, val AS j FROM sales UNPIVOT (val FOR col IN (buyer_phone, region)) ORDER BY id",
|
||||
[[1, "13900000001"], [1, "华东"], [2, "13900000002"], [2, "华南"]], ["n", "j"], None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def driver_shop():
|
||||
"""造一个 Postgres / Oracle 数据源(查询交给下面换掉的 run_query,不连库):买家手机号、姓名设了遮罩。返回建源的函数。"""
|
||||
made: list[int] = []
|
||||
|
||||
async def make(kind: str) -> str:
|
||||
name = f"pg_{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=name, kind=kind, host="127.0.0.1", port=5432, username="probe", database="shop",
|
||||
readonly=True, enabled=True, options={"mask_columns": "buyer_phone, buyer_name"},
|
||||
schema_cache=PG_SCHEMA)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
made.append(row.id)
|
||||
return name
|
||||
|
||||
yield make
|
||||
for source_id in made:
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind, sql, rows, masked, value", DRIVER_CASES,
|
||||
ids=["json_agg(t)", "row_to_json(s)", "to_json(s.*)", "SELECT s", "表的列名表", "CTE 里的 TABLE",
|
||||
"另一个库里的同名表", "本库的表按位置数", "JSON_OBJECT(*)", "UNPIVOT"])
|
||||
async def test_whole_rows_and_renamed_columns_from_other_databases_are_masked(monkeypatch, driver_shop, kind, sql,
|
||||
rows, masked, value):
|
||||
"""Postgres 的整行(json_agg(t)、row_to_json(s)、to_json(s.*)、SELECT s)、表的列名表、CTE 里的 TABLE sales、另一个库里的
|
||||
同名表,Oracle 的 JSON_OBJECT(*)、UNPIVOT:交给模型的、落库的、通用接口给的都是遮好的,编号照常(认不出位置的整份遮)。
|
||||
以前这些写法 masked_at_query 是空的,号码、姓名原样交给模型、落库。"""
|
||||
from app.data.engine import QueryResult
|
||||
from app.tools import datasource
|
||||
|
||||
async def driver(source, statement, **kwargs):
|
||||
assert statement == sql
|
||||
columns = ["n", "j"] if statement.startswith("SELECT id AS n") else ["j", "n"]
|
||||
return QueryResult(columns=columns, rows=[list(r) for r in rows], row_count=len(rows), truncated=False,
|
||||
elapsed_ms=1, sql=statement)
|
||||
|
||||
monkeypatch.setattr(datasource, "run_query", driver)
|
||||
name = await driver_shop(kind)
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "n"},
|
||||
"rationale": "x"})
|
||||
out, events = await ask(name)
|
||||
seen = script.tool_results()["c1"]
|
||||
assert MASKED in seen and _clean(seen), seen
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["masked_at_query"] == masked and _clean(json.dumps(snapshot, ensure_ascii=False))
|
||||
assert out.result["value"] == value
|
||||
[end] = [d for k, d in events if k == EventType.TOOL_END]
|
||||
assert _clean(end["preview"])
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
got = await client.get(f"/api/artifacts/{out.queries[0]['artifact']}")
|
||||
assert got.status_code == 200 and _clean(got.text)
|
||||
Reference in New Issue
Block a user