审批质询遮罩收口(第 1 轮复核):「夹住某一个值」在整条 SQL 里认,只有分档的几种写法各算各的;分档标签不当遮罩值;SQL 检查结果的片段、说明照整条 SQL 换

- 夹住:上一轮只在同一个条件里配对,把找值拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT / EXCEPT 两支、包在 CASE / 函数 / 算式里、子查询和 CTE 里,完整手机号就照原样封存、回答正文也跟着不遮。现在整条 SQL 里配对,只有 CASE 的不同分支、IIF 的真假两支、SELECT 里不筛行的不同项(选出列、GROUP BY、ORDER BY)、UNION 的不同支各算各的(_apart,看最近公共祖先);同一个条件里照旧按合起来的真假细看。整数上 <= v、>= v 也隔开相邻的整数(age <= 24 OR age >= 26 就是 age <> 25);BETWEEN v AND v、>= v AND <= v、> v OR < v 条件里哪一层这样写都算(被更宽的范围吸收、旁边有矛盾的条件、隔着别的条件写也认得出),整数的半开区间仍只看整个条件,成百个相邻分档照旧不遮。条件的各层摊开一次反复用,600 档的耗时和上一轮相当
- 分档标签:CASE / IIF 结果全是写死常量的,标签文字是 agent 写在 SQL 里的,不收进遮罩值,规格原句的 SQL、回答里的档名照原样;当分组键(每一档一行)或套在汇总里按条件数个数(SUM(CASE … THEN 1 ELSE 0 END))不算派生值,格子照常显示,分得清哪一档几笔;逐行的标签列、汇总遮罩列本身的值照旧整列遮
- 质询工件的 SQL 检查结果:片段是检查器重新写出的表达式,单拿出来认不出在找哪一个值,改为按整条 SQL 里整个换掉的字面量换(简单 CASE、行值、BETWEEN v AND v 里的短的数也换掉,阈值照原样);说明里照引的取值(「没有码值「3」」)一样换;截断处半截的号码去掉
- 同一个短的数在别处换掉时,别的列上的阈值、LIMIT 照原样:一律换会回退第 2 轮起的「age = 25 AND amount > 25 … LIMIT 25」,写进已知局限并补测试钉住
- 模块说明的已知局限照实改写(分档拆进两个 CTE / FROM 子查询、同一选出列里两档汇总相除会多遮,拆进 CASE 分支 / 不同选出列认不出,运行详情的工件页签不经这里的遮罩)
- 测试:端到端改回规格原句(文字档位、SUM(CASE …)、年龄段 CASE),补 ON/WHERE、WHERE/HAVING、INTERSECT、包在 CASE 里、CTE、相邻整数的端到端用例,单元补拆分写法、被吸收的 BETWEEN v AND v、分组标签、检查结果片段与说明;每处去掉实现都验证过变红
This commit is contained in:
ThearchyHelios
2026-10-06 14:05:52 +08:00
parent 898bd8a276
commit dde0927460
4 changed files with 747 additions and 145 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
查询快照(queries[].artifact)照回答时的办法遮好:遮罩列(含起了别名、套了函数的)整列遮,别的列里整格是遮罩值的
格子也遮,masked 是有格子整格被遮掉的列名(界面把这几列整列画成「已遮罩」);一格文字里夹着遮罩值的(备注里写着
手机号),只把那一截换成「已遮罩」,这一列不记进 masked。content.sql 就是事件里记下的那条(遮罩值已换掉),两处
一字不差;content.checks(SQL 检查结果)的字段照证据面板,片段 sql_excerpt 里的遮罩值照 SQL 换掉。回答文档
一字不差;content.checks(SQL 检查结果)的字段照证据面板,片段 sql_excerpt、说明 message 里的遮罩值照整条 SQL
的换法换掉(截断处半截的值去掉)。回答文档
(answer.doc_artifact)组装时就遮好了,原样给;回答里引用的报告指标(doc.catalog 里 m: 条目的 artifact,口径卡)
也从这里取,界面一律按 doc.catalog[alias].artifact 取。没写进运行记录的回答(recorded 为假)在本进程里留一小时,
这段时间里它引用的工件照样能取;之后、或者服务重启过,回 404。别的工件一律 404
+394 -102
View File
@@ -13,41 +13,54 @@
自己查询的格子,或者报告里已有的指标、运行输入;回答文档由 compose_doc 组装,裸数字被标出来(提问人自己写的数除外)。
封存之后的追问查的是数据源现在的数据,任务里给报告生成的时间,回答末尾注明;触顶、查询失败、引用解析不了也在末尾注明
3. 遮罩(_plan):哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列
(起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉);快照按列
遮,再按值遮(别的列里写着遮罩值的格子,_masked_view;质询工件里,文字格里夹着的遮罩值另按子串换)。回答正文、
查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql):按值认有门槛;SQL 里的字面量按语法树认用在哪
(_literal_uses),数字照原样留着的只有范围比较的边界(同一个条件里没把某一个值夹住的,_pinned)和 LIMIT 的行数,
而且同一个数在这条 SQL 别处没被换掉,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE……)和别处够长的
遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样留着的阈值
只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的,这份回答不交出去
(起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉;按遮罩列
分档、当分组键或套在汇总里数个数的不算,_band_only);快照按列遮,再按值遮(别的列里写着遮罩值的格子,_masked_view;质询
工件里,文字格里夹着的遮罩值另按子串换)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql):
按值认有门槛,agent 写在 SQL 里的分档标签不算遮罩值(_written_labels);SQL 里的字面量按语法树认用在哪
(_literal_uses),数字照原样留着的只有范围比较的边界(没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且同一个数
在这条 SQL 别处没被换掉,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE、夹住某一个值的范围……)和
别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样
留着的阈值只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的,这份回答不交出去
4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的
_usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键
收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存),
append_events 不写,回答照样交回给提问的人,说明没有写进运行记录(这样的回答在进程里留一阵,_HELD)。触顶、失败
照样记一条
5. 读取:列表读 approval.inquiry 事件(封存前后的都算,按先后);质询工件只认质询引用过的(查询快照、回答文档、回答里
引用的报告口径卡),遮罩照回答时的办法,快照里 SQL 检查结果的片段照 SQL 换(_masked_checks)
引用的报告口径卡),遮罩照回答时的办法,快照里 SQL 检查结果的片段、说明照整条 SQL 的换法换(_masked_checks)
不发 tool.start、llm.start 这类事件(run_verify_agent 不传 emit):界面从接口的返回取回答;runner 的 _stale_replay
只看 tool.start,质询不影响恢复后的重放。
已知局限(遮罩只减少暴露,不是安全边界,同证据面板):
- 按值认有门槛,两头都有误差:短的数(年龄、等级这类不到 SECRET_DIGITS 位的)在回答正文、格子里不按值遮,agent 在
正文里写出某人的年龄遮不住,只靠 SQL 里的等值比较和按列遮;够长的数在格子和正文里按值遮,碰巧和某个遮罩值相同的
数(访问次数正好等于某一笔金额)也遮掉。这是产品取舍,不按上下文去猜
- 遮罩数值列的 SUM / AVG / MIN / MAX 算派生自遮罩列,整列遮(只有 COUNT 不算,见 _value_columns),条件里用到遮罩列的
SUM(CASE WHEN … THEN 1 …) 也算:MIN / MAX 就是某一行的原值,只有一行的分组 SUM / AVG 也是,按列认不出哪些汇总值
安全,宁可多遮;数个数要用 COUNT。按遮罩列分档的 CASE(CASE WHEN age < 30 THEN '30岁以下' …)同理,标签列整列遮,
标签文字也成了遮罩值,SQL 和回答正文里写着的同样的字一并换掉(换掉的标签不是数,不牵连阈值)
- 「夹住某一个值」只在同一个条件里认(_pinned):把找值拆进子查询、CTE 的写法(WHERE age >= 25 AND id IN
(SELECT id FROM t WHERE age <= 25))认不出,两个边界都当阈值照原样留着;反过来,条件长到细看不过来(PIN_WORK)时,
两边都隔开的一律按夹住算,会多遮几个阈值
正文里写出某人的年龄遮不住,只靠 SQL 里的等值比较和按列遮;同一个短的数在 SQL 里被换掉了,别的列上撞上它的阈值、
LIMIT 的行数照原样(age = 30 AND visits >= 30)。够长的数在格子和正文里按值遮,碰巧和某个遮罩值相同的数(访问次数
正好等于某一笔金额)也遮掉。这是产品取舍,不按上下文去猜
- 遮罩数值列的 SUM / AVG / MIN / MAX 算派生自遮罩列,整列遮(COUNT 不算,见 _value_columns),CASE 里有一支取的是
遮罩列的也算:MIN / MAX 就是某一行的原值,只有一行的分组 SUM / AVG 也是,按列认不出哪些汇总值安全,宁可多遮。
汇总里套着结果全是写死常量的 CASE(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END))只是按条件数个数,同 COUNT
- 按遮罩列分档的标签(CASE、IIF 的结果全是写死的常量):标签文字是 agent 写在 SQL 里的,不当遮罩值,SQL、回答正文里
照原样;标签列当分组键(每一档一行)照常显示,不分组的逐行标签列整列遮(格子里看不出某一笔落在哪一档),可 agent
在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)
- 「夹住某一个值」(_pinned)在整条 SQL 里认,只有 CASE 的不同分支、SELECT 里不筛行的不同项(选出列、GROUP BY、
ORDER BY)、UNION 的不同支各算各的,两头误差都有:把找值拆进这几处(CASE WHEN age > 25 THEN … ELSE CASE WHEN
age >= 25 …,两个选出列 SUM(age >= 25)、SUM(age > 25) 再自己相减)认不出,边界照原样留着,正文里复述也不遮;反过来,
不在同一个条件里的两边只要不是这几处就按夹住算,分档写成两个 CTE、FROM 里的两个子查询再连起来,或者同一个选出列
里把两档的汇总值算在一起(小单数 / 大单数)时,共用的边界会多遮。同一个条件里细看合起来的真假,整数上的半开、开区间
(col >= v AND col < v + 1)只在整个条件只认 v 时算,被更宽的范围吸收、和相邻的分档连成一段的不算;细看的步数有
上限(PIN_WORK),超出之后两边都隔开的一律按夹住算,分档很多时会成批多遮
- 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在
片段里别处出现也换(多遮);截断处半截的值去掉
- 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照记在这次运行名下(同报告自己的查询快照),从
那里打开不经这里的遮罩
- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧)
"""
from __future__ import annotations
import asyncio
import contextlib
import itertools
import json
import logging
import operator
@@ -125,8 +138,9 @@ HELD_TTL_S = 3600
HELD_MAX = 200
#: 运行的终态。封存之后的追问只在这几种状态上
_TERMINAL = ("succeeded", "failed", "cancelled")
#: 一条 SQL 里看条件合起来夹没夹住某一个值(_pinned)最多走这么多步(一步是条件里的一个且、或、非、括号或比较):
#: 几百个分档边界连成一长串时,挨个细看要算上好几秒,看不过来的两边都隔开就按夹住算(宁可多换)
#: 一条 SQL 里认夹没夹住某一个值(_pinned)最多走这么多步(细看一个条件,一步是其中的一串且 / 或、一个非、括号或
#: 比较;找两个条件的公共祖先,一步是往上走一层):几百个分档边界连成一长串时,挨个细看要算上好几秒。超出之后两边
#: 都隔开的一律按夹住算(宁可多换),分档很多时会成批多遮
PIN_WORK = 100_000
#: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截)
_WORD = re.compile(r"[0-9A-Za-z_]")
@@ -644,8 +658,9 @@ def _masked_word() -> str:
def _value_columns(node: Any) -> set[str]:
"""一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,都带不出值,不算;
套在里面的子查询只看它选出来的列(它的条件、连接里写的列带不出值)。"""
"""一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,结果全是写死常量的
分档(_band_only:当分组键,或者套在汇总里按条件数个数)只决定一行落在哪一档,都带不出值,不算;套在里面的子查询
只看它选出来的列(它的条件、连接里写的列带不出值)。"""
from sqlglot import exp
out: set[str] = set()
@@ -655,7 +670,7 @@ def _value_columns(node: Any) -> set[str]:
if isinstance(item, exp.Column):
if not isinstance(item.this, exp.Star) and item.name:
out.add(item.name.lower())
elif isinstance(item, exp.Count):
elif isinstance(item, exp.Count) or (isinstance(item, (exp.Case, exp.If)) and _band_only(item)):
continue
elif isinstance(item, exp.Window):
stack.append(item.this)
@@ -666,6 +681,85 @@ def _value_columns(node: Any) -> set[str]:
return out
def _branch_results(node: Any) -> list[Any] | None:
"""CASE 的各个结果(每个 THEN,加上 ELSE;没写 ELSE 记 None,就是 NULL)、IIF / IF 的真假两支;别的写法为 None。
CASE 里的每个 WHEN … THEN 在语法树里也是 If,不单算。"""
from sqlglot import exp
if isinstance(node, exp.Case):
return [branch.args.get("true") for branch in node.args.get("ifs") or []] + [node.args.get("default")]
if isinstance(node, exp.If) and not isinstance(node.parent, exp.Case):
return [node.args.get("true"), node.args.get("false")]
return None
def _fixed(node: Any) -> bool:
"""写死的结果:字面量(几个数算出来的也算)、NULL、TRUE / FALSE、没写的 ELSE。"""
from sqlglot import exp
return node is None or isinstance(node, (exp.Null, exp.Boolean)) or _constant(node) is not None
def _band_only(node: Any) -> bool:
"""按遮罩列分档、只带出档位或个数的 CASE / IIF:结果全是写死的常量(标签、档位编号、1 和 0),而且
- 它本身就是一个选出列(起不起别名都行),又是这个 SELECT 的分组键(GROUP BY 按位置、按别名、照原样再写一遍都算):
每一档一行,这一列只说这一行是哪一档;
- 或者套在汇总里(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END)、AVG(IIF(…, 1.0, 0))):按条件数个数、算占比,
同 COUNT。
都带不出遮罩列的值,同按阈值数个数(col >= v 的 COUNT)。不分组、不汇总的逐行标签照旧算派生值,那是每一笔各落在
哪一档;汇总遮罩列的值本身(SUM(amount)、CASE 里有一支取 amount 的)也照旧算。"""
from sqlglot import exp
results = _branch_results(node)
if results is None or not all(_fixed(r) for r in results):
return False
parent = node.parent
while parent is not None and not isinstance(parent, (exp.Select, exp.Subquery)):
if isinstance(parent, exp.AggFunc):
return True
parent = parent.parent
item = node
while isinstance(item.parent, (exp.Paren, exp.Alias)) and item.arg_key == "this":
item = item.parent
select = item.parent
if not isinstance(select, exp.Select) or item.arg_key != "expressions" or select.args.get("group") is None:
return False
place = next((str(i + 1) for i, e in enumerate(select.expressions) if e is item), None)
alias = item.alias.lower() if isinstance(item, exp.Alias) and item.alias else None
for key in select.args["group"].expressions:
while isinstance(key, exp.Paren):
key = key.this
if (isinstance(key, exp.Literal) and not key.is_string and key.this == place) or key == node or (
alias and isinstance(key, exp.Column) and not key.table and key.name.lower() == alias):
return True
return False
def _written_labels(sql: Any, kind: str | None) -> set[str]:
"""SQL 里 CASE、IIF 写死的结果(分档的标签、档位编号)在格子里的写法。它们是 agent 自己写在 SQL 里的字,不是数据源
里的值:派生列里出现了也不当遮罩值(_Secrets.add),不然 SQL 里的标签、回答正文里的档名都会被换成「已遮罩」。
解析不了的一个都不算。"""
from sqlglot import exp
from app.data.sql_semantics import _parse, _read
try:
root, _ = _parse(sql, _read(kind))
except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理(标签照旧当遮罩值,宁可多遮)
root = None
if root is None:
return set()
out: set[str] = set()
for node in root.find_all(exp.Case, exp.If):
for result in _branch_results(node) or []:
value = _constant(result) if result is not None else None
if isinstance(value, Decimal):
out |= {_point_text(value), format(value, "f")}
elif value is not None:
out.add(_plain_text(value))
return out
def _is_star(item: Any) -> bool:
from sqlglot import exp
@@ -935,24 +1029,31 @@ class _Secrets:
_index: Any = field(default=None, repr=False, compare=False)
_compiled: dict[frozenset[str], re.Pattern[str]] = field(default_factory=dict, repr=False, compare=False)
def add(self, snapshot: Any, hidden: set[str]) -> None:
def add(self, snapshot: Any, hidden: set[str], *, labels: Iterable[str] = (), base: Iterable[str] = ()) -> None:
"""收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照的 SQL 里写死的分档标签(_written_labels):派生
列(hidden 里不在 base 的,base 是数据源设了遮罩的列)里的标签不收,遮罩列自己选出来的值照收。"""
if not _query_shaped(snapshot) or not hidden:
return
written = set(labels)
derived = set(hidden) - set(base) if written else set()
columns = [str(c) for c in snapshot.get("columns") or []]
index = [i for i, c in enumerate(columns) if c.lower() in hidden]
for row in snapshot["rows"]:
if len(self.weak) >= SECRETS_MAX:
break
if isinstance(row, list):
cells = [row[i] for i in index if i < len(row)]
cells = [(columns[i].lower(), row[i]) for i in index if i < len(row)]
elif isinstance(row, dict):
cells = [v for k, v in row.items() if str(k).lower() in hidden]
cells = [(str(k).lower(), v) for k, v in row.items() if str(k).lower() in hidden]
else:
continue
for value in cells:
for name, value in cells:
if value is None or isinstance(value, bool):
continue
for form in _forms(value):
forms = _forms(value)
if name in derived and forms & written:
continue
for form in forms:
self.weak.add(form)
if _strong(form):
self.strong.add(form)
@@ -1382,56 +1483,122 @@ def _logic() -> tuple[Any, ...]:
def _condition_root(node: Any) -> Any:
"""一个比较所在的条件:往上只越过且、或、非、异或、括号,走到的那一层。WHERE、ON、HAVING、CASE 的每个 WHEN、
选出列里的每个比较,各是各的条件;UNION 的两支各有各的 WHERE。"""
"""一个比较所在的条件:往上只越过且、或、非、异或、括号,走到的那一层。同一个条件里的几个比较按合起来的真假细看
(_isolated);不在同一个条件里的,看是不是分档的几种写法各算各的(_apart)。"""
while isinstance(node.parent, _logic()):
node = node.parent
return node
#: SELECT 里不筛行的几处(选出列、GROUP BY、ORDER BY 这些):对同一批行各算各的。WHERE、ON、HAVING、QUALIFY、FROM 里
#: 的子查询、WITH 里的一段都在筛这一批行
_VALUE_ARGS = frozenset({"expressions", "group", "order", "distinct", "windows", "sort", "cluster", "distribute"})
def _apart(a: Any, b: Any, paths: dict[int, dict[int, Any]]) -> tuple[bool, int]:
"""两个条件(不是同一个)是不是分档的几种写法各算各的,以及为此走了几步(记进 PIN_WORK)。各算各的只有:
- CASE 的不同分支(不同的 WHEN,或 WHEN 和 ELSE),IIF 的真假两支:一行只走其中一支;
- SELECT 里不筛行的不同项:两个选出列,选出列和 GROUP BY 里照原样再写一遍的 CASE;
- UNION、UNION ALL 的不同支:各查各的行,结果摞在一起。
别的都按同一批行上的条件算,宁可多换:ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、EXCEPT 两支(前一支成立、
后一支不成立)、包在 CASE 条件 / 函数 / 算式里的一半、选出列里的 FILTER 或 CASE 和 WHERE、子查询和 CTE 里的筛选
和外层。看的是两处的最近公共祖先,和它往下分别经哪一项走到两处;paths 记下走过的祖先链,同一个条件只走一遍。"""
from sqlglot import exp
steps = 0
path = paths.get(id(a))
if path is None:
path, child, node = {}, None, a
while node is not None:
path[id(node)] = child
child, node = node, node.parent
paths[id(a)], steps = path, len(path)
child, node = None, b
while node is not None and id(node) not in path:
child, node = node, node.parent
steps += 1
if node is None or child is None or path[id(node)] is None:
# 一处套在另一处里面(WHERE 的且里有个 CASE,CASE 的条件是另一个比较):经且、或连着,筛的是同一批行
return False, steps
keys = {path[id(node)].arg_key, child.arg_key}
if isinstance(node, exp.Case):
return keys <= {"ifs", "default"}, steps
if isinstance(node, exp.If):
return keys == {"true", "false"}, steps
if isinstance(node, exp.Select):
return keys <= _VALUE_ARGS, steps
return isinstance(node, exp.Union) and keys == {"this", "expression"}, steps
def _marks(value: Any, op: str) -> list[tuple[Any, int]]:
"""一个边界从哪边隔开了哪个值:(值, 0) 是从下面隔开(它和比它小一点的值结果不同:>= v、< v),(值, 1) 是从上面
隔开(和比它大一点的值结果不同:<= v、> v)。整数的 > v 也从下面隔开了 v + 1,< v 也从上面隔开了 v - 1。"""
隔开(和比它大一点的值结果不同:<= v、> v)。整数上还隔开了相邻的那一个:> v、<= v 从下面隔开 v + 1(v 不成立、
v + 1 成立,或者反过来),< v、>= v 从上面隔开 v - 1——age <= 24 OR age >= 26 就是 age <> 25。"""
whole = isinstance(value, Decimal) and value == value.to_integral_value()
return {">=": [(value, 0)], "<=": [(value, 1)],
">": [(value, 1), *([(value + 1, 0)] if whole else [])],
"<": [(value, 0), *([(value - 1, 1)] if whole else [])]}[op]
above = [(value + 1, 0)] if whole else []
below = [(value - 1, 1)] if whole else []
return {">=": [(value, 0), *below], "<=": [(value, 1), *above],
">": [(value, 1), *above], "<": [(value, 0), *below]}[op]
def _pinned(root: Any) -> dict[int, tuple[str, ...]]:
"""范围比较里把某一个值夹住了的边界字面量:id(字面量节点) → 夹住的值(_point_text 的写法)。
一个值 v 两边都被隔开(_marks),条件才可能只认 v 这一个值。只在同一个条件里看(_condition_root):分档的两个
WHEN 分支、两个选出列、UNION 两支、WHERE 和 HAVING 各写一边,共用的边界是分档的界,不是在找哪一个值。同一个条件
里两边都隔开了,还要看条件合起来是不是真只认 v(_isolated):col >= v AND col <= v、BETWEEN v AND v、
一个值 v 两边都被隔开(_marks),条件才可能只认 v 这一个值。在整条 SQL 里配对,只有分档的几种写法各算各的
(_apart:CASE 的不同分支、SELECT 里不筛行的不同项、UNION 的不同支),它们共用的边界是分档的界,不是在找哪一个
值;别处筛的是同一批行(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、包在 CASE / 函数 / 算式里的一半、子查询、
CTE),两边一隔开就按夹住算——只在同一个条件里配对的话,把找值拆到这些地方,号码就照原样留在 SQL 里,回答正文
也跟着不遮。同一个条件里的再细看合起来的真假(_isolated):col >= v AND col <= v、BETWEEN v AND v、
col > v OR col < v(就是 col <> v)、整数的 col >= v AND col < v + 1、col > v - 1 AND col < v + 1 是在找 v,
和等值比较一样换;col > 31 AND col <= 25 AND col NOT BETWEEN 25 AND 31 这样谁也找不着的、col <= v OR col >= v
这样全都要的不是。另一边不是单独一列的(SUM(amount) >= 900)只和写得一样的表达式配(_key_of)。夹住了就把隔开 v
的边界都记下:两头都不是 v 的(col > 24 AND col < 26)也换,留着就等于写出了 v。"""
的边界都记下:两头都不是 v 的(col > 24 AND col < 26)也换,留着就等于写出了 v。
细看、找公共祖先走的步数有上限(PIN_WORK),超出之后两边都隔开的一律按夹住算。"""
from sqlglot import exp
groups: dict[int, tuple[Any, dict[int, list[_Half | None]]]] = {}
tops: dict[int, Any] = {}
within: dict[int, dict[int, list[_Half | None]]] = {}
sides: dict[tuple[str, Any], tuple[dict[int, list[Any]], dict[int, list[Any]]]] = {}
for node in root.find_all(exp.Between, *_range_ops()):
top = _condition_root(node)
groups.setdefault(id(top), (top, {}))[1][id(node)] = _halves(node)
out: dict[int, set[str]] = {}
budget = PIN_WORK
for top, halves in groups.values():
cost = _logic_size(top)
sides: dict[tuple[str, Any], tuple[list[Any], list[Any]]] = {}
for literal, key, value, op in (h for parts in halves.values() for h in parts if h is not None):
tops[id(top)] = top
parts = _halves(node)
within.setdefault(id(top), {})[id(node)] = parts
for literal, key, value, op in (h for h in parts if h is not None):
for point, side in _marks(value, op):
sides.setdefault((key, point), ([], []))[side].append(literal)
for (key, point), (below, above) in sides.items():
if not (below and above):
continue
if cost <= budget:
budget -= cost
if not _isolated(top, halves, key, point):
sides.setdefault((key, point), ({}, {}))[side].setdefault(id(top), []).append(literal)
budget = PIN_WORK
orders: dict[int, list[tuple[Any, list[Any]]]] = {}
apart: dict[tuple[int, int], bool] = {}
paths: dict[int, dict[int, Any]] = {}
out: dict[int, set[str]] = {}
for (key, point), (below, above) in sides.items():
if not (below and above):
continue
marked: list[Any] = []
# 不在同一个条件里的两边:不是各算各的,就按夹住算
for low, lows in below.items():
for high, highs in above.items():
if low == high:
continue
for literal in below + above:
if literal is not None:
out.setdefault(id(literal), set()).add(_point_text(point))
pair = (low, high) if low < high else (high, low)
if pair not in apart:
apart[pair], steps = _apart(tops[pair[0]], tops[pair[1]], paths) if budget > 0 else (False, 0)
budget -= steps
if not apart[pair]:
marked += lows + highs
# 同一个条件里的两边:细看合起来的真假
for top in below.keys() & above.keys():
if top not in orders:
orders[top] = _logic_order(tops[top])
if len(orders[top]) <= budget:
budget -= len(orders[top])
if not _isolated(orders[top], within[top], key, point):
continue
marked += below[top] + above[top]
for literal in marked:
if literal is not None:
out.setdefault(id(literal), set()).add(_point_text(point))
return {literal: tuple(sorted(points)) for literal, points in out.items()}
@@ -1441,51 +1608,75 @@ _EITHER = frozenset({(False, False, False), (True, True, True)})
_ISOLATED = frozenset({(False, True, False), (True, False, True)})
_HOLDS: dict[str, Callable[[int], bool]] = {
">=": lambda s: s >= 0, ">": lambda s: s > 0, "<=": lambda s: s <= 0, "<": lambda s: s < 0}
#: 且、或连起来的一串里,两两细看的项最多这么多(随这一列取值变真假的项):再多就按夹住算
PAIR_MAX = 64
def _isolated(top: Any, halves: dict[int, list[_Half | None]], key: str, point: Any) -> bool:
"""这个条件里 key 取 point 时的真假和它两边都不一样:条件只认 point 这一个值,或者只排除它。条件里和这一列无关的
部分当成不知道真假,只要有一种取法夹住了就算(宁可多换)。数按两种看:连续的(紧挨着 point 两边的值)和整数的
(point - 1、point + 1,col >= v AND col < v + 1 只在整数上夹住 v)。"""
def _isolated(order: list[tuple[Any, list[Any]]], halves: dict[int, list[_Half | None]], key: str, point: Any
) -> bool:
"""这个条件(order 是它的各层,_logic_order)里 key 取 point 时夹没夹住,两种都算:
- 整个条件只认 point 这一个值,或者只排除它(真假和它两边都不一样)。数按两种看:连续的(紧挨着 point 两边的值)
和整数的(point - 1、point + 1,col >= v AND col < v + 1 只在整数上夹住 v);
- 条件里哪一层(一个比较、括号、非、异或,且 / 或连起来的一串里的任意一项、任意两项)不论整数都只认 point:
BETWEEN v AND v、>= v AND <= v、> v OR < v 本身就写出了 v,旁边的条件把它吸收了(OR 一个更宽的范围)、和它
矛盾了也算,隔着别的条件写也一样。整数上的半开、开区间不这样一层层看:一长串相邻的分档(age >= 0 AND age < 1
OR age >= 1 AND age < 2 …)每一档单拿出来都只认一个整数,连起来谁也没找。
条件里和这一列无关的部分当成不知道真假,只要有一种取法夹住了就算(宁可多换)。"""
if _outcomes(order, halves, key, point, False, signals=True)[1]:
return True
whole = isinstance(point, Decimal) and point == point.to_integral_value()
return any(_ISOLATED & _outcomes(top, halves, key, point, steps) for steps in ((False, True) if whole else (False,)))
return whole and bool(_ISOLATED & _outcomes(order, halves, key, point, True)[0])
def _logic_children(node: Any) -> list[Any]:
"""条件往下一层:括号、非里面的那一个,且、或、异或的两边;比较和别的写法没有下一层。"""
"""条件往下一层:括号、非里面的那一个,异或的两边;且(或)连起来的一串整串摊开,越过同一种连接和套在外面的括号,
一项一项给——这样隔着别的条件写的两项也挨得着(_outcomes 两两细看)。比较和别的写法没有下一层。"""
from sqlglot import exp
if isinstance(node, (exp.Paren, exp.Not)):
return [node.this]
if isinstance(node, (exp.And, exp.Or, exp.Xor)) and node.this is not None and node.expression is not None:
if isinstance(node, exp.Xor) and node.this is not None and node.expression is not None:
return [node.this, node.expression]
return []
def _logic_size(top: Any) -> int:
"""一个条件里有几个且、或、非、括号和比较:看一次夹没夹住要走的步数(PIN_WORK)。"""
count, stack = 0, [top]
if not (isinstance(node, (exp.And, exp.Or)) and node.this is not None and node.expression is not None):
return []
kind, items, stack = type(node), [], [node]
while stack:
count += 1
stack.extend(_logic_children(stack.pop()))
return count
item = inner = stack.pop()
while isinstance(inner, exp.Paren):
inner = inner.this
if type(inner) is kind and inner.this is not None and inner.expression is not None:
stack.extend((inner.expression, inner.this))
else:
items.append(item)
return items
def _outcomes(top: Any, halves: dict[int, list[_Half | None]], key: str, point: Any, steps: bool
) -> set[tuple[bool, ...]]:
"""条件在 key 取 point 和它两边时可能的真假组合:(比它小一点, point, 比它大一点)。steps 为真按整数看两边
(point - 1、point + 1),否则按紧挨着的值。先算下层再算上层,不用递归:几百个条件连成的一长串会超过递归深度。"""
def _logic_order(top: Any) -> list[tuple[Any, list[Any]]]:
"""一个条件里的各层(且 / 或连起来的串、非、括号、比较),先下层后上层排好,每层带着它的下一层:一个条件要细看
好几个值,摊开一次、反复用。有几层就是看一次要走的步数(PIN_WORK)。不用递归:几百个条件连成的一长串会超过
递归深度。"""
order: list[tuple[Any, list[Any]]] = []
stack: list[tuple[Any, list[Any] | None]] = [(top, None)]
while stack:
node, kids = stack.pop()
if kids is None and (kids := _logic_children(node)):
stack.append((node, kids))
stack.extend((kid, None) for kid in kids)
continue
order.append((node, kids))
return order
def _outcomes(order: list[tuple[Any, list[Any]]], halves: dict[int, list[_Half | None]], key: str, point: Any,
steps: bool, *, signals: bool = False) -> tuple[set[tuple[bool, ...]], bool]:
"""条件(各层见 _logic_order)在 key 取 point 和它两边时可能的真假组合:(比它小一点, point, 比它大一点)。steps
为真按整数看两边(point - 1、point + 1),否则按紧挨着的值。signals 为真时另交回条件里有没有哪一层、且 / 或连起来
的哪一两项单拿出来就只认 point(_isolated)。"""
from sqlglot import exp
done: dict[int, set[tuple[bool, ...]]] = {}
stack: list[tuple[Any, bool]] = [(top, False)]
while stack:
node, ready = stack.pop()
kids = _logic_children(node)
if kids and not ready:
stack.append((node, True))
stack.extend((kid, False) for kid in kids)
continue
found = False
for node, kids in order:
if isinstance(node, exp.Paren):
out = done[id(kids[0])]
elif isinstance(node, exp.Not):
@@ -1493,12 +1684,25 @@ def _outcomes(top: Any, halves: dict[int, list[_Half | None]], key: str, point:
elif kids:
join = operator.and_ if isinstance(node, exp.And) else operator.or_ if isinstance(node, exp.Or) \
else operator.xor
right = done[id(kids[1])]
out = {tuple(join(x, y) for x, y in zip(s, t)) for s in done[id(kids[0])] for t in right}
parts = [done[id(kid)] for kid in kids]
out = parts[0]
for part in parts[1:]:
out = _joined(join, out, part)
if signals and not found and len(parts) > 2:
# 一串里随这一列取值变真假的项两两细看(两项的串就是它自己,下面照常看)
varying = [part for part in parts if not part <= _EITHER]
found = len(varying) > PAIR_MAX or any(
_ISOLATED & _joined(join, s, t) for s, t in itertools.combinations(varying, 2))
else:
out = _leaf_outcomes(halves.get(id(node)), key, point, steps)
found = found or (signals and bool(_ISOLATED & out))
done[id(node)] = out
return done[id(top)]
return out, found
def _joined(join: Callable[[bool, bool], bool], left: set[tuple[bool, ...]], right: set[tuple[bool, ...]]
) -> set[tuple[bool, ...]]:
return {tuple(join(x, y) for x, y in zip(s, t)) for s in left for t in right}
def _leaf_outcomes(parts: list[_Half | None] | None, key: str, point: Any, steps: bool) -> set[tuple[bool, ...]]:
@@ -1603,8 +1807,11 @@ def _literals(sql: str, kind: str | None, secrets: _Secrets, compared: set[str]
"""分词,加上每个字面量(按词的下标)用在哪(_literal_uses)。语法树里对不上位置的字面量记 "other";SQL 解析
不了时只按词认简单的等值比较(_context),别的一律 "other"——认不准的地方不免。分词出错返回 None。
照原样留着的数还得在这条 SQL 里没有别处被换掉:同一个数在别的字面量里(等值比较、算式、字符串)确实被换掉了,
这里留着就把它露了(BETWEEN 13900000001 AND 13900000001 + 0、age >= 30 AND age = 30),所以这里也改记成在找这个值。
照原样留着的数还得在这条 SQL 里没有别处被换掉:同一个数在别的字面量里(等值比较、算式、字符串、夹住的范围)
确实被换掉了,这里留着就把它露了(BETWEEN 13900000001 AND 13900000001 + 0、age >= 30 AND age = 30),所以这里改记
成在找这个值,再照等值的办法换:阈值也和遮罩列比较的(短的数也换)、数够长的换;别的列上的短阈值、LIMIT 的行数
(age = 30 AND visits >= 30、LIMIT 30)换不掉,照原样——短的数本来就不按值遮(SECRET_DIGITS),撞上同一个数只是
巧合,一律换会把阈值、行数改得面目全非(见模块说明的已知局限)。
换没换照 _swap_literal 认,和真去换的是同一套判断:只是写在日期、天数修饰、标签里('2026-01-31'、'-30 days'、
'30岁以下'),或者没设遮罩的列上的短的数(amount = 30),那里一个字没换,阈值也照原样。名字、注释里的不算:那是
agent 给阈值起的说法(AS 超过30000的笔数),照常按值换,留着的阈值不必跟着换。"""
@@ -1648,7 +1855,7 @@ def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets,
- 在找某一个值的(equal):和遮罩列(或它的别名,compared)比较的,是遮罩列里出现过的值就整个换,短的数也换
(age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号)。夹住某一个值的范围按夹住
的值认(age > 24 AND age < 26 的两头都按 25 认);
的值认(age > 24 AND age < 26 的两头都按 25 认),同一个数在别处换掉了的阈值、行数也按等值认(见 _literals);
- 字符串在哪都按值换里面的遮罩值(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人);
- 别的数:范围比较的边界、LIMIT / OFFSET 的行数照原样(agent 自己定的阈值,换了封存的就不是实际执行的语句);
别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换。"""
@@ -1691,6 +1898,13 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None
if split is None:
return secrets.scrub(sql)
tokens, kinds, uses = split
return _rebuild(sql, tokens, kinds, secrets, lambda i, raw: _swap_literal(
raw, tokens[i].text, kinds[i], uses[i], secrets, compared)[0] if i in uses else raw)
def _rebuild(sql: str, tokens: list[Any], kinds: list[str], secrets: _Secrets, literal: Callable[[int, str], str]
) -> str:
"""按词重写一段 SQL:字面量交给 literal(第几个词、原文)定换成什么,名字(带不带引号)、注释和别的字按值换。"""
out: list[str] = []
last = 0
for i, token in enumerate(tokens):
@@ -1700,8 +1914,8 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None
gap = sql[last:start]
out.append(secrets.scrub(gap) if gap.strip() else gap)
raw = sql[start:end]
if i in uses:
raw = _swap_literal(raw, token.text, kinds[i], uses[i], secrets, compared)[0]
if _is_literal(kinds[i]):
raw = literal(i, raw)
elif kinds[i] in _NAME_TOKENS:
raw = _requote(raw, secrets.scrub)
out.append(raw)
@@ -1711,6 +1925,74 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None
return "".join(out)
def _literal_swaps(sql: Any, secrets: _Secrets, compared: set[str], kind: str | None) -> set[str] | None:
"""整条 SQL 里整个换掉了的字面量(分出来的字,字符串不带引号):SQL 检查结果的片段、说明照它换(_masked_checks)。
没有 SQL、分词都分不了时为 None(那时整条 SQL 也只是按值换)。"""
if not isinstance(sql, str) or not sql.strip():
return None
if not (secrets.strong or secrets.weak):
return set()
split = _literals(sql, kind, secrets, compared)
if split is None:
return None
tokens, kinds, uses = split
word = _masked_word()
out: set[str] = set()
for i, use in uses.items():
raw = sql[tokens[i].start:tokens[i].end + 1]
if tokens[i].text and _swap_literal(raw, tokens[i].text, kinds[i], use, secrets, compared)[0] in (
word, _requote(raw, lambda _: word)):
out.add(tokens[i].text)
return out
def _scrub_excerpt(text: str, whole: set[str] | None, secrets: _Secrets, kind: str | None) -> str:
"""SQL 检查结果的片段:检查器按语法树重新写出的一段表达式(最长 160 字,再长截断加「…」),单独拿出来认不出在找
哪一个值、哪个数是阈值,照整条 SQL 的认法换:整条 SQL 里整个换掉了的字面量(whole),片段里写着同样的字面量也换
(同一个值在片段里别处出现也换,宁可多换);整条 SQL 里照原样留着的阈值,片段里也照原样;字符串、名字、注释按值
换(同 _scrub_sql)。截断处可能正好截在一个值中间('1390000…),那半截按值认不出,去掉。"""
cut = text.endswith("…")
body = re.sub(r"\S*$", "", text[:-1]) if cut else text
split = _sql_tokens(body, kind) if whole is not None and body.strip() else None
word = _masked_word()
if split is None:
# 分不了词(截断后引号没闭合这类):按值换,整条 SQL 里整个换掉的字面量也按值找
out = secrets.scrub(body)
values = sorted((v for v in whole or () if v.strip()), key=lambda v: (-len(v), v))
out = re.sub("|".join(_pattern(v) for v in values), word, out) if values else out
else:
tokens, kinds = split
def literal(i: int, raw: str) -> str:
if tokens[i].text in whole:
return word if kinds[i] == "NUMBER" else _requote(raw, lambda _: word)
return raw if kinds[i] == "NUMBER" else _requote(raw, secrets.scrub)
out = _rebuild(body, tokens, kinds, secrets, literal)
return out + "…" if cut else out
#: SQL 检查结果的说明里照引的取值:「…」括起来
_QUOTED = re.compile(r"「([^「」]*)」")
def _scrub_message(text: str, whole: set[str] | None, secrets: _Secrets) -> str:
"""SQL 检查结果的说明:照引 SQL 里写的取值(码值表已列全时「没有码值「3」」),整条 SQL 里整个换掉了的(whole),
这里一样换成「已遮罩」,负数照留负号(同 SQL 里);别的字按值换。码值表里列出的码值是数据目录写明的,不在引号
里,照原样。"""
word = _masked_word()
def quoted(m: re.Match[str]) -> str:
inner = m.group(1)
if inner in whole:
return f"「{word}」"
if inner[:1] in ("-", "+") and inner[1:] in whole:
return f"「{inner[0]}{word}」"
return m.group()
return secrets.scrub(_QUOTED.sub(quoted, text) if whole else text)
def _shown_literals(sqls: Iterable[tuple[str, str | None, set[str]]], secrets: _Secrets) -> set[str]:
"""几条 SQL(各自数据源的类型、SQL 里认哪些名字是遮罩列,见 _compared)里照原样留着的数,去掉千分位、正负号:
范围比较的边界、LIMIT / OFFSET 的行数(_scrub_sql 不换的那两种)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额
@@ -1789,11 +2071,13 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str]
hidden: dict[str, set[str]] = {}
tainted: dict[str, set[str]] = {}
for artifact, snapshot in own.items():
hidden[artifact], tainted[artifact] = _derived_hidden(snapshot, named[artifact][0],
kinds.get(str(snapshot.get("source") or "")))
secrets.add(snapshot, hidden[artifact])
kind, base = kinds.get(str(snapshot.get("source") or "")), named[artifact][0]
hidden[artifact], tainted[artifact] = _derived_hidden(snapshot, base, kind)
secrets.add(snapshot, hidden[artifact], labels=_written_labels(snapshot.get("sql"), kind), base=base)
for snapshot, base in based:
secrets.add(snapshot, _derived_hidden(snapshot, base, kinds.get(str(snapshot.get("source") or "")))[0])
kind = kinds.get(str(snapshot.get("source") or ""))
secrets.add(snapshot, _derived_hidden(snapshot, base, kind)[0],
labels=_written_labels(snapshot.get("sql"), kind), base=base)
secrets.protect(given)
secrets.show(_shown_literals([(sql, kinds.get(name), _compared(tainted, artifact, live, name))
for sql, name, artifact in shown], secrets))
@@ -2197,22 +2481,30 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]:
elif isinstance(shown.get("sql"), str):
shown = {**shown, "sql": _scrub_sql(shown["sql"], plan.secrets, compared, kind)}
if "checks" in shown:
shown = {**shown, "checks": _masked_checks(shown["checks"], plan.secrets, compared, kind)}
shown = {**shown, "checks": _masked_checks(shown["checks"], content.get("sql"), plan.secrets, compared, kind)}
out: dict[str, Any] = {"id": artifact_id, "content": shown, "masked": plan.masked[artifact_id]}
if gone := plan.gone.get(artifact_id):
out["mask_note"] = SOURCE_GONE.format(source="、".join(gone))
return out
def _masked_checks(checks: Any, secrets: _Secrets, compared: set[str], kind: str | None) -> list[dict[str, Any]]:
def _masked_checks(checks: Any, sql: Any, secrets: _Secrets, compared: set[str], kind: str | None
) -> list[dict[str, Any]]:
"""快照里记下的 SQL 检查结果(数据源查询工具对照数据目录查出的问题)交给质询工件的样子:字段照证据面板
(_query_checks),给模型的那句改法不交出去——它照抄了 SQL 片段;SQL 片段(sql_excerpt)摘自同一条 SQL,照查询
清单的 SQL 换掉遮罩值(_scrub_sql):SQL 换了、片段没换,等于把换掉的值从旁边露出来。"""
(_query_checks),给模型的那句改法不交出去——它照抄了 SQL 片段。片段(sql_excerpt)、说明(message)摘自、照引
同一条 SQL(快照里原样的那条),照它在查询清单里的换法换掉遮罩值(_literal_swaps):SQL 换了、片段和说明没换,
等于把换掉的值从旁边露出来;片段单拿出来认不出在找哪一个值,不能自己另认一遍。"""
from app.api.evidence import _query_checks
return [{**check, "sql_excerpt": _scrub_sql(check["sql_excerpt"], secrets, compared, kind)}
if isinstance(check.get("sql_excerpt"), str) else check
for check in _query_checks({"checks": checks})]
whole = _literal_swaps(sql, secrets, compared, kind)
out: list[dict[str, Any]] = []
for check in _query_checks({"checks": checks}):
if isinstance(check.get("sql_excerpt"), str):
check = {**check, "sql_excerpt": _scrub_excerpt(check["sql_excerpt"], whole, secrets, kind)}
if isinstance(check.get("message"), str):
check = {**check, "message": _scrub_message(check["message"], whole, secrets)}
out.append(check)
return out
__all__ = [
+97 -20
View File
@@ -12,11 +12,12 @@
事件里记的报告文档不是这次运行的 422;输入不合法 422,指明是哪一项
- 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出(提问人自己写的数不标);遮罩列(起了
别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现
遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码……)
也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、
和日期、天数修饰、标签里写着的同一个数也不牵连阈值;SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,
遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码,两半
拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、CTE 里……)也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也
一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、和日期、天数修饰、标签里写着的同一个数也不牵连阈值,
分档的文字标签不当遮罩值(当分组键的档位列照常显示);SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,
回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里
只换掉那一截;质询工件里 SQL 检查结果的片段照 SQL 换,给模型的那句改法不交出去
只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条 SQL 换(短的数也换),给模型的那句改法不交出去
- 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的
数据源、没固定过版本的上传表格不查
- 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明
@@ -1004,13 +1005,13 @@ async def test_a_threshold_matching_numbers_left_alone_elsewhere_is_sealed_as_wr
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]))
recent = "SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND paid_on >= DATE('2026-02-03', '-30 days')"
older = "SELECT COUNT(*) AS 人数 FROM sales WHERE age > 31 AND paid_on <= '2026-01-31'"
# 标签是写死的字(不是按年龄分档算出来的列,不算派生值):里面的 30 一个字没换
bands = ("SELECT '30岁以下' AS 年龄段, COUNT(*) AS 人数 FROM sales WHERE age < 30 "
"UNION ALL SELECT '30岁及以上', COUNT(*) FROM sales WHERE age >= 30")
# 规格原句:按年龄分档的标签里写着同样的 30。标签是 agent 写在 SQL 里的字,不是遮罩值,阈值和标签都照原样
bands = ("SELECT CASE WHEN age < 30 THEN '30岁以下' ELSE '30岁及以上' END AS 年龄段, COUNT(*) AS 人数 FROM sales "
"GROUP BY 1 ORDER BY 1")
exact = "SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND age = 30"
plays = {"年龄大的": Play([recent, older, bands, exact],
"近 30 天、30 岁及以上的有 [[v:Q1.r0.人数]] 人,31 岁以上、1 月 31 日前付款的有 "
"[[v:Q2.r0.人数]] 人。[[see:Q1]]")}
"[[v:Q2.r0.人数]] 人;[[v:Q3.r0.年龄段]]的有 [[v:Q3.r0.人数]] 人。[[see:Q1]]")}
Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
try:
paused = await pause(name, graph=graph)
@@ -1019,7 +1020,8 @@ async def test_a_threshold_matching_numbers_left_alone_elsewhere_is_sealed_as_wr
approval_id=approval.id)
assert r.status_code == 200, r.text
item = r.json()["inquiry"]
assert item["answer"]["text"] == "近 30 天、30 岁及以上的有 2 人,31 岁以上、1 月 31 日前付款的有 0 人。"
assert item["answer"]["text"] == ("近 30 天、30 岁及以上的有 2 人,31 岁以上、1 月 31 日前付款的有 0 人;"
"30岁以下的有 2 人。")
sealed = [recent, older, bands, "SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age = 已遮罩"]
assert [q["sql"] for q in item["queries"]] == sealed
[event] = await events(paused.id, "approval.inquiry")
@@ -1158,25 +1160,35 @@ async def test_a_metric_from_the_report_that_equals_a_masked_value_can_be_cited(
assert got.json()["content"]["rows"] == [["已遮罩"]] and got.json()["masked"] == ["金额"]
@pytest.mark.parametrize("sql, answer, text", [
# 档位按金额分档算出来,算派生自遮罩列(格子按列遮);写成数字编号,免得文字标签也成了遮罩值(已知局限)
pytest.param("SELECT CASE WHEN amount BETWEEN 0 AND 20000 THEN 1 WHEN amount BETWEEN 20000 AND 60000 THEN 2 END "
"AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1 ORDER BY 1",
"以 20000 元为界,不超过的 [[v:Q1.r0.笔数]] 笔,超过的 [[v:Q1.r1.笔数]] 笔。[[see:Q1]]",
"以 20000 元为界,不超过的 3 笔,超过的 2 笔。", id="两个 WHEN 分支"),
#: 规格原句:按金额分档的文字标签,两个 WHEN 分支共用边界 20000
BANDS = "CASE WHEN amount BETWEEN 0 AND 20000 THEN '小单' WHEN amount BETWEEN 20000 AND 60000 THEN '大单' END"
@pytest.mark.parametrize("sql, answer, text, rows, masked", [
# 档位当分组键(每一档一行):标签是 agent 写在 SQL 里的字,不是遮罩值,格子照常显示,正文、SQL 里照原样
pytest.param(f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1 ORDER BY 1",
"以 20000 元为界,[[v:Q1.r0.档位]] [[v:Q1.r0.笔数]] 笔,[[v:Q1.r1.档位]] [[v:Q1.r1.笔数]] 笔,"
"小单更多。[[see:Q1]]",
"以 20000 元为界,大单 2 笔,小单 3 笔,小单更多。", [["大单", 2], ["小单", 3]], [], id="两个 WHEN 分支"),
pytest.param("SELECT COUNT(CASE WHEN amount <= 20000 THEN 1 END) AS 小单, "
"COUNT(CASE WHEN amount >= 20000 THEN 1 END) AS 大单 FROM sales",
"不超过 20000 元的有 [[v:Q1.r0.小单]] 笔,不低于 20000 元的有 [[v:Q1.r0.大单]] 笔。[[see:Q1]]",
"不超过 20000 元的有 3 笔,不低于 20000 元的有 3 笔。", id="两个选出列"),
"不超过 20000 元的有 3 笔,不低于 20000 元的有 3 笔。", [[3, 3]], [], id="两个选出列"),
# 规格原句的 SUM(CASE …):边界照原样封存、正文里不遮;汇总的是写死的 1 和 0,就是按条件数个数(同 COUNT),
# 格子照常显示
pytest.param("SELECT SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END) AS 小单, "
"SUM(CASE WHEN amount >= 20000 THEN 1 ELSE 0 END) AS 大单 FROM sales",
"以 20000 元为界:不超过的 [[v:Q1.r0.小单]] 笔,不低于的 [[v:Q1.r0.大单]] 笔。[[see:Q1]]",
"以 20000 元为界:不超过的 3 笔,不低于的 3 笔。", [[3, 3]], [], id="两个 SUM(CASE…) 选出列"),
pytest.param("SELECT '小单' AS 档位, COUNT(*) AS 笔数 FROM sales WHERE amount <= 20000 "
"UNION ALL SELECT '大单', COUNT(*) FROM sales WHERE amount >= 20000",
"不超过 20000 元的有 [[v:Q1.r0.笔数]] 笔,不低于 20000 元的有 [[v:Q1.r1.笔数]] 笔。[[see:Q1]]",
"不超过 20000 元的有 3 笔,不低于 20000 元的有 3 笔。", id="UNION 两支"),
"不超过 20000 元的有 3 笔,不低于 20000 元的有 3 笔。", [["小单", 3], ["大单", 3]], [], id="UNION 两支"),
])
async def test_band_edges_shared_by_separate_conditions_are_sealed_and_shown_as_written(
monkeypatch, big_shop, client, sql, answer, text):
monkeypatch, big_shop, client, sql, answer, text, rows, masked):
"""金额设了遮罩,分档的边界 20000 正好是某一笔的金额:两个 WHEN 分支、两个选出列、UNION 两支各写一边,各是各的
条件,没有夹住哪一笔。SQL 照原样封存,回答正文里复述的边界也不遮,质询工件的 SQL 和事件里一字不差。"""
条件,没有夹住哪一笔。SQL 照原样封存,回答正文里复述的边界、档位标签也不遮,质询工件的 SQL 和事件里一字不差。"""
plays = {"两万元上下": Play([sql], answer)}
Models(monkeypatch, big_shop, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
paused = await pause(big_shop, graph=fetching(big_shop, AMOUNTS))
@@ -1192,6 +1204,7 @@ async def test_band_edges_shared_by_separate_conditions_are_sealed_and_shown_as_
assert event.data["queries"][0]["sql"] == sql
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 和事件里的一字不差"
assert (got.json()["content"]["rows"], got.json()["masked"]) == (rows, masked)
async def test_a_masked_value_the_asker_wrote_stays_in_the_sql(monkeypatch, tmp_path, client):
@@ -1315,6 +1328,47 @@ async def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql(monkey
assert got.json()["content"]["sql"] == orders["sql"] and "1390000000" not in got.text
async def test_sql_check_results_in_the_artifact_do_not_show_short_masked_values(monkeypatch, tmp_path, client):
"""检查结果的片段是检查器按语法树重新写出的一段表达式,单独拿出来认不出在找哪一个值;说明里还照引 SQL 写的取值
(「没有码值「25」」)。年龄设了遮罩:整条 SQL 里换掉的 25,片段、说明里一样换掉,码值表里列出的照原样。"""
from app.tools import datasource
name, source_id = await _source(tmp_path, options={"mask_columns": "age"}, script=AGED)
graph = chain(node("start", "input"),
node("fetch", "tool", tool=f"db_query__{name}",
args={"sql": "SELECT region AS 区域, age, amount AS 金额 FROM sales ORDER BY id"}),
node("write", "report", "写周报", instructions="写周报"),
node("gate", "human", mode="approve", title="周报可以出具吗"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]))
pinned = "SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 25 AND 25"
listed = "(数据目录中已列出的全部码值:18=成年、60=退休)。请核对筛选条件里的取值"
async def flagged(checker, sql):
return [{"code": "unknown_code", "level": "warning", "table": "sales", "column": "age",
"message": f"「订单」的「年龄」没有码值「25」{listed}", "for_model": "SQL 里写的 25 不在其中",
"sql_excerpt": "age BETWEEN 25 AND 25"}] if "BETWEEN" in sql else []
monkeypatch.setattr(datasource, "_sql_checks", flagged)
plays = {"这个年龄": Play([pinned], "这个年龄的有 [[v:Q1.r0.人数]] 人。[[see:Q1]]")}
Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
try:
paused = await pause(name, graph=graph)
[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
[query] = r.json()["inquiry"]["queries"]
assert query["sql"] == "SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 已遮罩 AND 已遮罩"
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}")
assert got.status_code == 200, got.text
assert got.json()["content"]["checks"] == [
{"code": "unknown_code", "level": "warning", "table": "sales", "column": "age",
"message": f"「订单」的「年龄」没有码值「已遮罩」{listed}", "sql_excerpt": "age BETWEEN 已遮罩 AND 已遮罩"}]
assert got.json()["content"]["sql"] == query["sql"]
finally:
await engines.invalidate(source_id)
#: 同样的四笔订单,手机号存成整数:遮罩值在 SQL 里写成数字
PHONES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone INTEGER, amount INTEGER, is_test INTEGER);"
"INSERT INTO sales VALUES (1, '华东', 13900000001, 1200, 0), (2, '华东', 13900000002, 300, 1),"
@@ -1325,7 +1379,7 @@ _COUNT = "SELECT COUNT(*) AS 笔数 FROM sales WHERE "
@pytest.mark.parametrize("script, sql, sealed", [
# 数个数用 COUNT:SUM(CASE buyer_phone …) 的结果取自遮罩列,整列按列遮
# 简单 CASE 里找某一个号码,只数个数(COUNT,或者 SUM 套写死的 1):号码换掉,笔数照常显示
pytest.param(PHONES, "SELECT COUNT(CASE buyer_phone WHEN 13900000001 THEN 1 END) AS 笔数 FROM sales",
"SELECT COUNT(CASE buyer_phone WHEN 已遮罩 THEN 1 END) AS 笔数 FROM sales", id="简单 CASE"),
pytest.param(PHONES, _COUNT + "buyer_phone IN (VALUES (13900000001))", _COUNT + "buyer_phone IN (VALUES (已遮罩))",
@@ -1354,6 +1408,29 @@ _COUNT = "SELECT COUNT(*) AS 笔数 FROM sales WHERE "
_COUNT + "buyer_phone >= '已遮罩' AND buyer_phone <= '已遮罩'", id="字符串范围两头相同"),
pytest.param(SALES, _COUNT + "buyer_phone BETWEEN '13900000001' AND '13900000001'",
_COUNT + "buyer_phone BETWEEN '已遮罩' AND '已遮罩'", id="字符串 BETWEEN 两头相同"),
# 两半拆到同一行集的不同地方:第 4 轮只在同一个条件里认,号码照原样封存,回答正文里也露了出来
pytest.param(PHONES, "SELECT COUNT(*) AS 笔数 FROM sales s JOIN sales b ON b.id = s.id "
"AND b.buyer_phone >= 13900000001 WHERE b.buyer_phone <= 13900000001",
"SELECT COUNT(*) AS 笔数 FROM sales s JOIN sales b ON b.id = s.id "
"AND b.buyer_phone >= 已遮罩 WHERE b.buyer_phone <= 已遮罩", id="ON 和 WHERE 各写一边"),
pytest.param(PHONES, _COUNT + "buyer_phone >= 13900000001 GROUP BY buyer_phone HAVING buyer_phone <= 13900000001",
_COUNT + "buyer_phone >= 已遮罩 GROUP BY buyer_phone HAVING buyer_phone <= 已遮罩",
id="WHERE 和 HAVING 各写一边"),
pytest.param(PHONES, "SELECT COUNT(*) AS 笔数 FROM (SELECT id FROM sales WHERE buyer_phone >= 13900000001 "
"INTERSECT SELECT id FROM sales WHERE buyer_phone <= 13900000001) t",
"SELECT COUNT(*) AS 笔数 FROM (SELECT id FROM sales WHERE buyer_phone >= 已遮罩 "
"INTERSECT SELECT id FROM sales WHERE buyer_phone <= 已遮罩) t", id="INTERSECT 两支"),
pytest.param(PHONES, _COUNT + "buyer_phone >= 13900000001 AND CASE WHEN buyer_phone <= 13900000001 THEN 1 "
"ELSE 0 END = 1",
_COUNT + "buyer_phone >= 已遮罩 AND CASE WHEN buyer_phone <= 已遮罩 THEN 1 ELSE 0 END = 1",
id="一半包在 CASE 里"),
pytest.param(PHONES, "WITH a AS (SELECT * FROM sales WHERE buyer_phone >= 13900000001) "
"SELECT COUNT(*) AS 笔数 FROM a WHERE buyer_phone <= 13900000001",
"WITH a AS (SELECT * FROM sales WHERE buyer_phone >= 已遮罩) "
"SELECT COUNT(*) AS 笔数 FROM a WHERE buyer_phone <= 已遮罩", id="拆进 CTE"),
# 整数上相邻的两头把中间那一个隔开
pytest.param(PHONES, _COUNT + "NOT (buyer_phone <= 13900000000 OR buyer_phone >= 13900000002)",
_COUNT + "NOT (buyer_phone <= 已遮罩 OR buyer_phone >= 已遮罩)", id="相邻的整数隔开"),
])
async def test_a_customer_looked_up_in_any_shape_never_shows(monkeypatch, tmp_path, client, script, sql, sealed):
"""agent 换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码……),回答里
+254 -22
View File
@@ -10,12 +10,18 @@
LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、
>= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换;选出列、函数参数、算式里够长的遮罩值也换;字符串
(范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免
- 「夹住某一个值」只在同一个条件里认,按条件合起来的真假认(旁边的别的条件当成不知道真假):分档的两个 WHEN 分支、
两个选出列、UNION 两支、WHERE 和 HAVING 各写一边共用的边界是阈值;列名认不出的只和写得一样的表达式配
- 「夹住某一个值」在整条 SQL 里认,只有分档的几种写法各算各的:CASE 的不同分支、不同的选出列(连同 GROUP BY、
ORDER BY)、UNION 的不同支,共用的边界是阈值;同一行集上的条件(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、
包在 CASE / 函数 / 算式里的一半、子查询、CTE)照样认得出在找哪一个值。同一个条件里按合起来的真假认(旁边的别的
条件当成不知道真假),BETWEEN v AND v、>= v AND <= v 这类本身就只认一个值的,条件里哪一层都算;整数上 <= v、
>= v 也隔开了相邻的整数;列名认不出的只和写得一样的表达式配
- 阈值只在同一个数在这条 SQL 别处确实被换掉时才不留(换不换和 SQL 里换字面量是同一套判断):日期、天数、标签里写着
同一个短的数,没设遮罩的列上的等值,都不牵连阈值
同一个短的数,没设遮罩的列上的等值,都不牵连阈值;不留的照等值的办法换,别的列上的短阈值、LIMIT 撞上别处换掉的
短的数照原样(已知局限)
- 按遮罩列分档的标签(CASE、IIF 的结果都是写死的常量)是 agent 自己写在 SQL 里的,不当遮罩值:SQL、回答正文里照原样;
当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮
- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里,
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段照 SQL 换
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换
- 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去
- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串照样按结构认,成百个分档边界看不过来的
按夹住算,也不卡
@@ -130,11 +136,23 @@ def test_a_lateral_subquery_is_read_like_a_derived_table():
("SELECT * FROM (SELECT region, COUNT(DISTINCT buyer_phone) FROM sales GROUP BY region) t",
["region", "COUNT(DISTINCT buyer_phone)"]),
("SELECT region FROM sales WHERE amount > (SELECT AVG(LENGTH(buyer_phone)) FROM sales)", ["region"]),
# 汇总里套着结果全是写死的 CASE / IIF:按条件数个数、算占比,同 COUNT
("SELECT SUM(CASE WHEN buyer_phone LIKE '139%' THEN 1 ELSE 0 END) AS 笔数 FROM sales", ["笔数"]),
("SELECT region, AVG(IIF(buyer_phone LIKE '139%', 1.0, 0)) AS 占比 FROM sales GROUP BY region", ["region", "占比"]),
])
def test_counting_filtering_and_ordering_by_a_masked_column_shows_nothing(sql, columns):
assert hidden_of(sql, columns) == {"buyer_phone"}
@pytest.mark.parametrize("sql, columns", [
# 汇总的是遮罩列的值本身(已知局限:SUM / AVG / MIN / MAX 算派生),CASE 里有一支取的是它也一样
("SELECT region, MAX(buyer_phone) AS 最大号码 FROM sales GROUP BY region", ["region", "最大号码"]),
("SELECT SUM(CASE WHEN region = '华东' THEN buyer_phone END) AS 合计 FROM sales", ["合计"]),
])
def test_aggregating_the_masked_values_themselves_is_still_derived(sql, columns):
assert hidden_of(sql, columns) >= {"buyer_phone", columns[-1].lower()}
def test_sql_that_cannot_be_read_but_names_a_masked_column_hides_everything():
assert hidden_of("SELECT buyer_phone AS 号码 FROM sales WHERE (", ["号码", "金额"]) == {"buyer_phone", "号码", "金额"}
assert hidden_of("SELECT region FROM sales WHERE (", ["region"]) == {"buyer_phone"}
@@ -146,6 +164,74 @@ def test_names_that_point_at_a_masked_column_are_known_for_comparisons():
assert inquiry_mod._derived_hidden(snapshot, {"buyer_phone"}, "sqlite")[1] == {"buyer_phone", "p"}
BANDS = "CASE WHEN amount BETWEEN 0 AND 20000 THEN '小单' WHEN amount BETWEEN 20000 AND 60000 THEN '大单' END"
@pytest.mark.parametrize("sql", [
# 按位置、按别名、照原样再写一遍分组
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 档位",
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY {BANDS}",
# 档位编号、没写 ELSE、ELSE NULL、IIF 的两个分支也是写死的
"SELECT CASE WHEN amount <= 20000 THEN 1 WHEN amount <= 60000 THEN 2 ELSE NULL END AS 档位, COUNT(*) AS 笔数 "
"FROM sales GROUP BY 1",
"SELECT IIF(amount > 20000, '大单', '小单') AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
])
def test_a_band_label_used_as_the_group_key_is_not_a_masked_value(sql):
"""按遮罩列分档、每一档一行(CASE、IIF 的结果全是写死的标签,又是分组键):标签只说一行落在哪一档,带不出金额,
档位列不遮,才分得清哪一档是几笔。笔数照样只是计数。"""
assert hidden_of(sql, ["档位", "笔数"], {"amount"}) == {"amount"}
@pytest.mark.parametrize("sql", [
# 不分组:每一行的档位就是这一笔落在哪一档
f"SELECT id, {BANDS} AS 档位, 1 AS 笔数 FROM sales",
# 结果里有一支取的是金额本身
"SELECT CASE WHEN amount > 20000 THEN amount ELSE 0 END AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
# 分档写在子查询里,外层再分组:子查询里的档位逐行带出来,照旧算
f"SELECT 档位, COUNT(*) AS 笔数 FROM (SELECT {BANDS} AS 档位 FROM sales) t GROUP BY 档位",
])
def test_band_labels_row_by_row_still_count_as_derived(sql):
assert hidden_of(sql, ["档位", "笔数"], {"amount"}) >= {"amount", "档位"}
def plan_of(snapshot: dict, base: set[str], kind: str = "sqlite") -> inquiry_mod._Plan:
"""一份 agent 的查询快照按回答时的办法收遮罩值、遮好(数据源设了遮罩的列是 base)。"""
return inquiry_mod._settle_plan({"a1": snapshot}, {"a1": (base, [])}, [], {"shop": kind}, [], [], {},
frozenset())
def test_band_labels_written_in_the_sql_are_not_masked_values():
"""分档的标签是 agent 自己写在 SQL 里的字,不是数据源里的值:当分组键的整列照常显示;逐行的标签列照旧整列遮
(格子里看不出这一笔落在哪一档),但标签文字也不当遮罩值——SQL 里、回答正文里照原样,换掉了封存的就不是实际
执行的语句,「以 20000 元为界,小单 3 笔」也会变成满屏「已遮罩」。"""
grouped = {"source": "shop", "sql": f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1 ORDER BY 1",
"columns": ["档位", "笔数"], "rows": [["大单", 2], ["小单", 3]]}
plan = plan_of(grouped, {"amount"})
assert plan.views["a1"]["rows"] == [["大单", 2], ["小单", 3]] and plan.masked["a1"] == []
assert plan.secrets.scrub("以 20000 元为界,小单 3 笔、大单 2 笔") == "以 20000 元为界,小单 3 笔、大单 2 笔"
assert inquiry_mod._scrub_sql(grouped["sql"], plan.secrets, plan.tainted["a1"], "sqlite") == grouped["sql"]
rows = {"source": "shop", "sql": "SELECT id, CASE WHEN age < 30 THEN '30岁以下' ELSE '30岁及以上' END AS 年龄段 "
"FROM sales ORDER BY id",
"columns": ["id", "年龄段"], "rows": [[1, "30岁以下"], [2, "30岁及以上"]]}
plan = plan_of(rows, {"age"})
assert plan.views["a1"]["rows"] == [[1, MASKED], [2, MASKED]] and plan.masked["a1"] == ["年龄段"]
assert plan.secrets.scrub("30岁以下的有 1 人") == "30岁以下的有 1 人"
assert inquiry_mod._scrub_sql(rows["sql"], plan.secrets, plan.tainted["a1"], "sqlite") == rows["sql"]
assert inquiry_mod._shown_literals([(rows["sql"], "sqlite", plan.tainted["a1"])], plan.secrets) == {"30"}
def test_a_value_of_the_masked_column_itself_stays_masked_even_if_written_as_a_label():
"""只有派生列里的标签不收:遮罩列自己选出来的值照收,哪怕 agent 在 SQL 里把同样的字写成了标签。"""
snapshot = {"source": "shop", "sql": "SELECT level, CASE WHEN level = 'VIP会员' THEN 'VIP会员' ELSE '普通会员' END "
"AS 等级 FROM customers",
"columns": ["level", "等级"], "rows": [["VIP会员", "VIP会员"], ["铂金会员", "普通会员"]]}
plan = plan_of(snapshot, {"level"})
assert plan.views["a1"]["rows"] == [[MASKED, MASKED], [MASKED, MASKED]]
assert plan.secrets.scrub("VIP会员、铂金会员、普通会员") == "已遮罩、已遮罩、普通会员"
# --------------------------------------------------------------------------
# 按值认:门槛、边界
# --------------------------------------------------------------------------
@@ -356,8 +442,8 @@ def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown):
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 XOR age > 已遮罩"),
])
def test_a_value_pinned_within_one_condition_is_still_a_lookup(phones, kind, sql, shown):
"""「夹住某一个值」只在同一个条件里认(从比较往上只越过且、或、非、异或、括号),同一个条件里夹住的照样换:旁边
还有别的条件、写在 CASE WHEN 里、整数的开区间、XOR。"""
"""同一个条件里(从比较往上只越过且、或、非、异或、括号)夹住的,按条件合起来的真假认出来照样换:旁边还有别的
条件、写在 CASE WHEN 里、整数的开区间、XOR。"""
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == shown
@@ -374,17 +460,22 @@ def test_the_same_aggregate_pinned_from_both_sides_is_a_lookup(amounts, sql, sho
@pytest.mark.parametrize("sql", [
# 两个 WHEN 分支共用一个边界:各是各的条件,20000 只是分档的界
"SELECT CASE WHEN amount BETWEEN 0 AND 20000 THEN '小单' WHEN amount BETWEEN 20000 AND 60000 THEN '大单' END "
"AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
# 两个选出列各按一边数
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
# 分组时把 CASE 原样再写一遍:选出列和 GROUP BY 是同一个算法的两处写法,都不筛行
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY {BANDS}",
# 两个选出列各按一边数(写成汇总、写成标量子查询都一样)
"SELECT SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END) AS 小单, "
"SUM(CASE WHEN amount >= 20000 THEN 1 ELSE 0 END) AS 大单 FROM sales",
"SELECT (SELECT COUNT(*) FROM sales WHERE amount <= 20000) AS 小单, "
"(SELECT COUNT(*) FROM sales WHERE amount >= 20000) AS 大单",
# UNION 的两支
"SELECT '小单' AS 档位, COUNT(*) AS 笔数 FROM sales WHERE amount <= 20000 "
"UNION ALL SELECT '大单', COUNT(*) FROM sales WHERE amount >= 20000",
# 不同子句:WHERE 里一笔的上限、HAVING 里汇总值的下限
# IIF 的两个分支:华东按一边数,别的区域按另一边数
"SELECT SUM(IIF(region = '华东', amount <= 20000, amount >= 20000)) AS 笔数 FROM sales",
# WHERE 里一笔的上限、HAVING 里汇总值的下限:比的不是同一个东西(列名认不出的只和写得一样的表达式配)
"SELECT region, COUNT(*) AS 笔数 FROM sales WHERE amount <= 20000 GROUP BY region HAVING SUM(amount) >= 20000",
# 同一个条件里,算式不和金额这一列配(列名认不出的只和写得一样的表达式配):金额在 15000 到 20000 之间
# 同一个条件里,算式不和金额这一列配:金额在 15000 到 20000 之间
"SELECT COUNT(*) FROM sales WHERE amount <= 20000 AND amount + 5000 >= 20000",
# 边界两边都有比较,可合起来不只认 20000 这一个值:不超过 20000 的,或 20000 以上访问又多的
"SELECT COUNT(*) FROM sales WHERE amount <= 20000 OR (amount >= 20000 AND visits > 25000)",
@@ -397,6 +488,95 @@ def test_band_edges_shared_by_separate_conditions_are_thresholds(amounts, sql):
assert "20000" in inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts)
_PHONE, _NEXT = "13900000001", "13900000002"
@pytest.mark.parametrize("kind, sql", [
# 内连接的 ON 和 WHERE 筛的是同一批行:各写一边照样夹住
("sqlite", "SELECT o.region FROM sales o JOIN buyers b ON b.id = o.bid AND b.buyer_phone >= {v} "
"WHERE b.buyer_phone <= {v}"),
# WHERE 和 HAVING 比的是同一列(分组的键)
("sqlite", "SELECT buyer_phone, COUNT(*) FROM sales WHERE buyer_phone >= {v} GROUP BY buyer_phone "
"HAVING buyer_phone <= {v}"),
# INTERSECT 两支都要成立;EXCEPT 是前一支成立、后一支不成立(不低于 25 又不高于 25)
("sqlite", "SELECT id FROM sales WHERE age >= {a} INTERSECT SELECT id FROM sales WHERE age <= {a}"),
("sqlite", "SELECT id FROM sales WHERE age >= {a} EXCEPT SELECT id FROM sales WHERE age > {a}"),
# 一半包在 CASE、布尔比较、函数、算式里
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND CASE WHEN age <= {a} THEN 1 ELSE 0 END = 1"),
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND (age <= {a}) = TRUE"),
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND COALESCE(age <= {a}, 0)"),
("sqlite", "SELECT COUNT(*) FROM sales WHERE IIF(age >= {a}, 1, 0) + IIF(age <= {a}, 1, 0) = 2"),
# 嵌套的 CASE:里层只在外层成立时才算
("sqlite", "SELECT SUM(CASE WHEN age >= {a} THEN CASE WHEN age <= {a} THEN 1 END END) AS 人数 FROM sales"),
# 选出列里的 FILTER 数的是 WHERE 筛过的行
("duckdb", "SELECT COUNT(*) FILTER (WHERE age >= {a}) AS 人数 FROM sales WHERE age <= {a}"),
# 两个汇总值相减:不低于 25 的减去高于 25 的,就是正好 25 的
("sqlite", "SELECT SUM(age >= {a}) - SUM(age > {a}) AS 人数 FROM sales"),
# 拆进子查询、CTE
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND id IN (SELECT id FROM sales WHERE age <= {a})"),
("sqlite", "WITH a AS (SELECT * FROM sales WHERE buyer_phone >= {v}) SELECT COUNT(*) FROM a "
"WHERE buyer_phone <= {v}"),
])
def test_a_lookup_split_across_clauses_is_still_a_lookup(phones, kind, sql):
"""把找某一个值的两半拆到同一行集的不同地方(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、包在 CASE 或函数里、
子查询、CTE):第 4 轮只在同一个条件里认,两半各自当阈值照原样留着,长的号码随即被当成「摆在读者眼前」,回答
正文里也不遮,最后一道关跟着放行。只有分档的几种写法(CASE 的不同分支、不同的选出列、UNION 的不同支)各算各的,
别的都按同一行集看:两半照样换掉,正文里也不免遮。"""
written = sql.format(v=_PHONE, a=25)
assert inquiry_mod._scrub_sql(written, phones, {"buyer_phone", "age"}, kind) == sql.format(v=MASKED, a=MASKED)
assert inquiry_mod._shown_literals([(written, kind, {"buyer_phone", "age"})], phones) == set()
@pytest.mark.parametrize("sql, sealed", [
# 整数上 age <= 24 和 age < 25 一样,从下面把 25 隔开;age >= 26 和 age > 25 一样,从上面隔开
("SELECT COUNT(*) FROM sales WHERE NOT (age <= 24 OR age >= 26)",
"SELECT COUNT(*) FROM sales WHERE NOT (age <= 已遮罩 OR age >= 已遮罩)"),
("SELECT COUNT(*) FROM sales WHERE age >= 25 AND NOT age >= 26",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND NOT age >= 已遮罩"),
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone >= {_PHONE} AND NOT buyer_phone >= {_NEXT}",
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND NOT buyer_phone >= 已遮罩"),
# 就是 age <> 25
("SELECT COUNT(*) FROM sales WHERE age <= 24 OR age >= 26",
"SELECT COUNT(*) FROM sales WHERE age <= 已遮罩 OR age >= 已遮罩"),
])
def test_neighbouring_whole_numbers_pin_the_value_between_them(phones, sql, sealed):
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed
assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones) == set()
@pytest.mark.parametrize("sql", [
"SELECT COUNT(*) FROM sales WHERE age >= 18 AND age <= 30",
"SELECT CASE WHEN age <= 24 THEN '24岁及以下' WHEN age >= 26 THEN '26岁及以上' END AS 年龄段, COUNT(*) AS 人数 "
"FROM sales GROUP BY 1",
"SELECT COUNT(*) FROM sales WHERE age BETWEEN 18 AND 24",
])
def test_ordinary_ranges_are_not_pinned_by_neighbouring_whole_numbers(phones, sql):
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sql
@pytest.mark.parametrize("sql, sealed", [
# 被更宽的范围吸收了,条件合起来不只认这一个号码,可 BETWEEN v AND v 本身就写出了它
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN {_PHONE} AND {_PHONE} OR buyer_phone > 13000000000",
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 OR buyer_phone > 13000000000"),
# 旁边还有矛盾的条件(谁也查不着):两半紧挨着、隔着别的条件写都一样
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone >= {_PHONE} AND buyer_phone <= {_PHONE} "
"AND buyer_phone > 13900000005",
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩 AND buyer_phone > 13900000005"),
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone >= {_PHONE} AND buyer_phone > 13900000005 "
f"AND buyer_phone <= {_PHONE}",
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone > 13900000005 AND buyer_phone <= 已遮罩"),
# 大于或小于同一个值(就是不等于),旁边又或上一个更宽的范围
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone > {_PHONE} OR buyer_phone > 13000000000 OR buyer_phone < {_PHONE}",
"SELECT COUNT(*) FROM sales WHERE buyer_phone > 已遮罩 OR buyer_phone > 13000000000 OR buyer_phone < 已遮罩"),
])
def test_a_range_that_names_one_value_is_a_lookup_whatever_surrounds_it(phones, sql, sealed):
"""BETWEEN v AND v、>= v AND <= v、> v OR < v 不论整数都只认一个值:条件里哪一层这样写都算在找它,旁边的条件把它
吸收了、和它矛盾了也一样(规格要求这几种必须继续遮)。整数上的半开、开区间只看整个条件,和相邻的分档连成一段的
不算(见下面成百个分档的那条)。"""
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed
assert _PHONE not in inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones)
@pytest.fixture
def ages() -> inquiry_mod._Secrets:
"""年龄设了遮罩,都是两位数:和天数修饰、日期、标签、别的列上的数撞在一起。"""
@@ -445,16 +625,6 @@ def test_a_threshold_stays_when_the_same_number_is_left_alone_elsewhere(ages, sq
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == shown
def test_a_masked_label_that_is_not_a_number_does_not_take_the_threshold_with_it(ages):
"""按年龄分档算出来的标签算派生自遮罩列(已知局限),标签文字本身成了遮罩值,SQL 里照样按值换;换掉的是标签不是
数,阈值 30 照原样。"""
ages.add({"columns": ["年龄段"], "rows": [["30岁以下"], ["30岁及以上"]]}, {"年龄段"})
sql = "SELECT CASE WHEN age < 30 THEN '30岁以下' ELSE '30岁及以上' END AS 年龄段, COUNT(*) AS 人数 FROM sales GROUP BY 1"
assert inquiry_mod._scrub_sql(sql, ages, {"age", "年龄段"}, "sqlite") == (
"SELECT CASE WHEN age < 30 THEN '已遮罩' ELSE '已遮罩' END AS 年龄段, COUNT(*) AS 人数 FROM sales GROUP BY 1")
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age", "年龄段"})], ages) == {"30"}
@pytest.mark.parametrize("sql, sealed", [
# 找某一个年龄的等值把 30 换掉了:阈值留着就把它露了
("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND age = 30",
@@ -467,6 +637,23 @@ def test_a_threshold_goes_when_the_same_number_is_masked_elsewhere(ages, sql, se
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == set()
@pytest.mark.parametrize("sql, sealed", [
("SELECT COUNT(*) AS 人数 FROM sales WHERE age = 30 AND visits >= 30",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age = 已遮罩 AND visits >= 30"),
("SELECT region FROM sales WHERE age IN (30, 31) AND visits > 30 LIMIT 31",
"SELECT region FROM sales WHERE age IN (已遮罩, 已遮罩) AND visits > 30 LIMIT 31"),
# 开区间夹住的 25 两头都换了,别的列上的 25 照原样
("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 24 AND age < 26 AND visits >= 25",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age > 已遮罩 AND age < 已遮罩 AND visits >= 25"),
])
def test_a_short_number_masked_elsewhere_leaves_other_columns_and_limits_alone(ages, sql, sealed):
"""同一个短的数在别处换掉了,别的列上的阈值、LIMIT 撞上它照原样(已知局限):短的数本来就不按值遮,撞上只是巧合,
一律换会把阈值、行数改得面目全非(第 2 轮起「age = 25 AND amount > 25 … LIMIT 25」就照原样留着阈值和行数)。
正文里不免遮——反正短的数正文里也不按值遮。够长的数在别的列上照样换(visits = 20000 那条)。"""
assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sealed
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == set()
@pytest.mark.parametrize("sql, sealed", [
# 批次号里的 20000 是日期一样的写法,按值认不出、没换:阈值照原样
("SELECT COUNT(*) FROM sales WHERE amount >= 20000 AND batch = '20000-01'",
@@ -674,19 +861,64 @@ def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql():
"""质询工件里快照记下的 SQL 检查结果:片段(sql_excerpt)摘自同一条 SQL,照查询清单的 SQL 换掉遮罩值(日期这样的
字也认得出是在找哪一天);给模型的那句改法(for_model)照抄了片段,不交出去(同证据面板)。"""
secrets = secrets_of({"columns": ["birth_date"], "rows": [["1990-05-01"]]}, {"birth_date"})
sql = "SELECT SUM(balance) FROM customers c JOIN balances b ON b.cid = c.id WHERE birth_date = '1990-05-01'"
message = "「客户」的业务日期按「注册日期」计,查询却按「生日」筛选"
checks = [{"code": "wrong_date_column", "level": "warning", "table": "customers", "column": "birth_date",
"message": message, "for_model": "这条 SQL 按 birth_date = '1990-05-01' 筛选",
"sql_excerpt": "birth_date = '1990-05-01'"},
{"code": "stock_summed", "level": "info", "table": "balances", "message": "存量跨日期求和",
"sql_excerpt": "SUM(balance)"}]
assert inquiry_mod._masked_checks(checks, secrets, {"birth_date"}, "sqlite") == [
assert inquiry_mod._masked_checks(checks, sql, secrets, {"birth_date"}, "sqlite") == [
{"code": "wrong_date_column", "level": "warning", "table": "customers", "column": "birth_date",
"message": message, "sql_excerpt": "birth_date = '已遮罩'"},
{"code": "stock_summed", "level": "info", "table": "balances", "message": "存量跨日期求和",
"sql_excerpt": "SUM(balance)"}]
@pytest.mark.parametrize("sql, excerpt, shown", [
# 片段只是一段表达式,单独拿出来认不出在找哪一个值:按整条 SQL 里换掉的字面量换
("SELECT SUM(CASE age WHEN 25 THEN balance END) AS 余额 FROM accounts",
"SUM(CASE age WHEN 25 THEN balance END)", "SUM(CASE age WHEN 已遮罩 THEN balance END)"),
("SELECT SUM(balance) AS 余额 FROM accounts WHERE (age, region) = (25, '华东')",
"(age, region) = (25, '华东')", "(age, region) = (已遮罩, '华东')"),
("SELECT SUM(CASE WHEN age BETWEEN 25 AND 25 THEN balance END) AS 余额 FROM accounts",
"SUM(CASE WHEN age BETWEEN 25 AND 25 THEN balance END)",
"SUM(CASE WHEN age BETWEEN 已遮罩 AND 已遮罩 THEN balance END)"),
# 整条 SQL 里照原样留着的阈值,片段里也照原样
("SELECT SUM(CASE WHEN amount >= 20000 THEN balance END) AS 余额 FROM accounts",
"SUM(CASE WHEN amount >= 20000 THEN balance END)", "SUM(CASE WHEN amount >= 20000 THEN balance END)"),
# 检查器截过的片段:截在号码中间,那半截按值认不出,去掉
("SELECT SUM(balance) AS 余额 FROM accounts WHERE region = '华东' AND phone = '13900000001'",
"region = '华东' AND phone = '1390000…", "region = '华东' AND phone = …"),
])
def test_sql_check_excerpts_follow_what_the_whole_sql_masked(sql, excerpt, shown):
secrets = secrets_of({"columns": ["phone", "age", "amount"], "rows": [["13900000001", 25, 20000]]},
{"phone", "age", "amount"})
checks = [{"code": "stock_summed", "level": "warning", "table": "accounts", "message": "存量跨日期求和",
"sql_excerpt": excerpt}]
[check] = inquiry_mod._masked_checks(checks, sql, secrets, {"phone", "age", "amount"}, "sqlite")
assert check["sql_excerpt"] == shown
def test_sql_check_messages_do_not_quote_masked_values():
"""检查结果的说明照引 SQL 里写的取值(码值表已列全时「没有码值「3」」):整条 SQL 里换掉的取值,说明里一样换成
「已遮罩」,短的码值、文字码值都一样;码值表里列出的码值是数据目录写明的,照原样。"""
secrets = secrets_of({"columns": ["status", "channel_type"], "rows": [[3, "线下合作"], [1, "线上"]]},
{"status", "channel_type"})
sql = ("SELECT COUNT(*) FROM visits v JOIN channels c ON c.id = v.cid WHERE status IN (3, -3) "
"AND channel_type = '线下合作'")
listed = "(数据目录中已列出的全部码值:1=有效、0=作废)。请核对筛选条件里的取值"
checks = [{"code": "unknown_code", "level": "warning", "table": "visits", "column": "status",
"message": f"「入园记录」的「检票状态」没有码值「3」、「-3」{listed}", "sql_excerpt": "status IN (3, -3)"},
{"code": "unknown_code", "level": "warning", "table": "channels", "column": "channel_type",
"message": f"「渠道」的「渠道类型」没有码值「线下合作」{listed}",
"sql_excerpt": "channel_type = '线下合作'"}]
out = inquiry_mod._masked_checks(checks, sql, secrets, {"status", "channel_type"}, "sqlite")
assert [c["message"] for c in out] == [f"「入园记录」的「检票状态」没有码值「已遮罩」、「-已遮罩」{listed}",
f"「渠道」的「渠道类型」没有码值「已遮罩」{listed}"]
assert [c["sql_excerpt"] for c in out] == ["status IN (已遮罩, -已遮罩)", "channel_type = '已遮罩'"]
def test_report_values_that_happen_to_be_masked_values_are_shown():
"""报告口径卡里的指标恰好和遮罩列里的某个值一样(单笔目标 20000,金额也有 20000 的单):报告里本来就看得到,
引用它的回答照常交出去。"""