From 4690063f5d8ca2d44f31622cbaed71e1d539915b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:24:54 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E3=80=8C=E5=A4=B9=E4=BD=8F=E6=9F=90=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=80=BC=E3=80=8D=E5=8F=AA=E5=9C=A8=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=9D=A1=E4=BB=B6=E9=87=8C=E3=80=81=E6=8C=89=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=90=88=E8=B5=B7=E6=9D=A5=E7=9A=84=E7=9C=9F=E5=81=87?= =?UTF-8?q?=E8=AE=A4=EF=BC=9B=E9=98=88=E5=80=BC=E5=8F=AA=E5=9C=A8=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=95=B0=E5=9C=A8=E8=BF=99=E6=9D=A1=20SQL=20?= =?UTF-8?q?=E5=88=AB=E5=A4=84=E7=A1=AE=E5=AE=9E=E6=8D=A2=E6=8E=89=E6=97=B6?= =?UTF-8?q?=E6=89=8D=E4=B8=8D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复验留下的两条多遮回归(遮得太多,不漏): - _pinned 收全句所有范围比较一起配对,列名认不出的(SUM(amount)、函数)还和哪一列都配:分档的两个 WHEN 分支、两个选出列、 UNION 两支、WHERE 和 HAVING 各写一边,共用的边界被当成「夹住了某一笔」遮掉,回答里「以 20000 元为界」也跟着遮。改成从每个 比较往上只越过且、或、非、异或、括号,走到同一个条件根的才放在一起看;同一个条件里两边都被隔开了,再按条件合起来的真假看 是不是真只认这一个值(条件里别的部分当成不知道真假,有一种取法夹住就算)。BETWEEN v AND v、>= v AND <= v、> v OR < v、 整数的半开区间照旧认得出;age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31 这种谁也找不着的不算,第 2 轮的 原输入恢复并通过。列名认不出的只和写得一样的表达式配 - 夹住的值另记下来:两头都不是那个值的整数开区间(age > 24 AND age < 26)按夹住的 25 换,不再留着 24、26 等于写出 25 - 几百个条件连成一长串:条件改成不递归地算(以前上千层会超过递归深度,退回按词认,短的数就漏了);细看夹没夹住按步数设了 上限(PIN_WORK),看不过来的两边都隔开就按夹住算 - _literals 的「别处出现过同一个数」从所有非保留字面量里抽数字片段,日期里的 31、天数修饰里的 30、标签里的 30、没设遮罩的 列上的 amount = 30 都算,阈值就改记成等值遮掉,可那些地方一个字没换,回答正文又照样写着它。改成和真去换的同一套判断 (_swap_literal,_scrub_sql 也照它换):只有同一个数在这条 SQL 别处确实被换掉了(等值、别处的长数整个换掉,字符串里按值 换掉的遮罩数)才不留;换掉的是文字标签不牵连阈值。查询之间的正文免遮(_shown_literals)同理,按查询各自认遮罩列的比较 - 模块说明补上已知局限:按值认的门槛两头都有误差(短的数在正文里遮不住、5 位以上的数会误遮巧合的数)、遮罩数值列的 SUM/AVG/MIN/MAX 和按遮罩列分档的 CASE 标签算派生值(产品取舍,保持现状)、「夹住」只在同一个条件里认(拆进子查询或 CTE 的 认不出)、遮罩值收集有上限 - 补单元测试和端到端测试:重叠的分档边界(两个 WHEN 分支、两个选出列、UNION 两支、年龄段)、日期、天数、标签、未遮罩列的 等值照原样封存、正文不遮;同一条件里夹住一个值的几种写法(旁边有别的条件、CASE WHEN、HAVING 里同一个汇总值、整数开区间、 XOR)和别处确实换掉的同一个数照旧遮。每处改动都做过去掉实现、测试变红的验证 --- backend/app/engine/inquiry.py | 424 ++++++++++++++++++++------ backend/tests/test_inquiry_api.py | 86 +++++- backend/tests/test_inquiry_masking.py | 217 +++++++++++-- 3 files changed, 604 insertions(+), 123 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 3901764d..d20e4e82 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -16,9 +16,10 @@ (起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉);快照按列 遮,再按值遮(别的列里写着遮罩值的格子,_masked_view)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、 _scrub_sql):按值认有门槛;SQL 里的字面量按语法树认用在哪(_literal_uses),数字照原样留着的只有范围比较的边界 - (没把某一个值夹住的)和 LIMIT 的行数,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE……)和别处 - 够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样留着 - 的阈值只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的,这份回答不交出去 + (同一个条件里没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且同一个数在这条 SQL 别处没被换掉,封存的是实际 + 执行的语句;在找某一个值的(等值、IN、简单 CASE……)和别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和 + 选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样留着的阈值只在回答正文里不换,格子照旧换。组装之后还 + 认得出遮罩值的,这份回答不交出去 4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的 _usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键 收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存), @@ -29,6 +30,18 @@ 不发 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))认不出,两个边界都当阈值照原样留着 +- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧) """ from __future__ import annotations @@ -36,6 +49,7 @@ import asyncio import contextlib import json import logging +import operator import re import time from collections import OrderedDict @@ -110,6 +124,9 @@ HELD_TTL_S = 3600 HELD_MAX = 200 #: 运行的终态。封存之后的追问只在这几种状态上 _TERMINAL = ("succeeded", "failed", "cancelled") +#: 一条 SQL 里看条件合起来夹没夹住某一个值(_pinned)最多走这么多步(一步是条件里的一个且、或、非、括号或比较): +#: 几百个分档边界连成一长串时,挨个细看要算上好几秒,看不过来的两边都隔开就按夹住算(宁可多换) +PIN_WORK = 100_000 #: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截) _WORD = re.compile(r"[0-9A-Za-z_]") @@ -1297,25 +1314,45 @@ def _bare_literal(node: Any) -> Any: return node -def _bounds_of(node: Any) -> list[tuple[Any, str | None, Any, str]]: - """一个范围比较里写死的边界:(字面量节点, 列名, 值, 「列 运算符 值」的运算符)。边界是几个数算出来的也算, - 字面量节点记 None:它照样能夹住另一头的值,换的是另一头的字面量。""" +#: 范围比较的一半:(字面量节点, 另一边的键, 写死的值, 「键 运算符 值」的运算符)。边界是几个数算出来的 +#: (13900000002 - 1),字面量节点记 None:它照样能夹住另一头的值,换的是另一头的字面量 +_Half = tuple[Any, str, Any, str] + + +def _halves(node: Any) -> list[_Half | None]: + """一个范围比较拆成的几半:BETWEEN 是两半的「且」(不低于下界、不高于上界),> >= < <= 是一半。边界不是写死的值 + (另一列、子查询)或者两边都写死了的,这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。""" from sqlglot import exp if isinstance(node, exp.Between): sides = [(node.args.get("low"), node.this, ">="), (node.args.get("high"), node.this, "<=")] - elif (op := _range_ops().get(type(node))) is not None: - sides = [(node.expression, node.this, op), (node.this, node.expression, _FLIP[op])] else: - return [] - out = [] + op = _range_ops()[type(node)] + right, left = _constant(node.expression), _constant(node.this) + if (right is None) == (left is None): + return [None] + sides = [(node.expression, node.this, op)] if right is not None else [(node.this, node.expression, _FLIP[op])] + out: list[_Half | None] = [] for operand, other, op in sides: value = _constant(operand) if operand is not None else None - if value is not None: - out.append((_bare_literal(operand), _column_of(other, None), value, op)) + out.append((_bare_literal(operand), _key_of(other), value, op) if value is not None else None) return out +def _key_of(node: Any) -> str: + """范围比较另一边的键:单独一列取列名(小写、不带表名,同 _column_of);别的表达式(SUM(amount)、函数、算式)取 + 整个写法,只和写得一样的表达式算同一个——SUM(amount) <= v 和 amount >= v 说的不是一回事。前面垫一个列名里不会 + 有的字,免得和列名撞上。""" + from sqlglot import exp + + column = _column_of(node, None) + if column is not None: + return column + while isinstance(node, exp.Paren): + node = node.this + return "\0" + (node.sql() if node is not None else "") + + def _literal_types() -> tuple[Any, ...]: """语法树里的字面量:数字、字符串,以及字符串带前缀的几种变体(N'…'、$$…$$、U&'…'、E'…'、X'…'、B'…')。""" from sqlglot import exp @@ -1323,42 +1360,181 @@ def _literal_types() -> tuple[Any, ...]: return (exp.Literal, exp.National, exp.RawString, exp.UnicodeString, exp.ByteString, exp.HexString, exp.BitString) -def _pinned(bounds: list[tuple[Any, str | None, Any, str]]) -> set[int]: - """范围比较里把某一个值夹住了的边界字面量(交回 id(字面量节点))。bounds 见 _bounds_of。 +def _logic() -> tuple[Any, ...]: + """把几个条件连成一个条件的:且、或、非、异或(MySQL 的 XOR)、括号。""" + from sqlglot import exp - 一个值 v 被从下面隔开,是说 v 和比它小一点的值结果不同(col >= v、col < v);被从上面隔开,是和比它大一点的值结果 - 不同(col <= v、col > v)。同一列上 v 两边都被隔开,条件就只认 v 这一个值:col >= v AND col <= v、BETWEEN v AND v、 - col > v OR col < v(就是 col <> v)——这是在找 v,和等值比较一样换。整数的 col < v + 1 就是 col <= v、col > v - 1 - 就是 col >= v,所以 col >= v AND col < v + 1 也算夹住了 v。只从一边隔开的是阈值:col >= v 和 col < v 各数一遍, - 是按 v 分成两段,照原样。另一边不是单独一列的(SUM(amount) >= 900)和哪一列都算同一列(宁可多换)。""" - sides: dict[Any, list[tuple[str, str | None, Any]]] = {} - for literal, column, value, op in bounds: - whole = isinstance(value, Decimal) and value == value.to_integral_value() - marks = {">=": [(value, "below")], "<=": [(value, "above")], - ">": [(value, "above"), *([(value + 1, "below")] if whole else [])], - "<": [(value, "below"), *([(value - 1, "above")] if whole else [])]}[op] - for point, side in marks: - sides.setdefault(point, []).append((side, column, literal)) - out: set[int] = set() - for marks in sides.values(): - for side, column, literal in marks: - for other, column2, literal2 in marks: - if side == "below" and other == "above" and (column is None or column2 is None or column == column2): - out |= {id(n) for n in (literal, literal2) if n is not None} + return exp.And, exp.Or, exp.Not, exp.Xor, exp.Paren + + +def _condition_root(node: Any) -> Any: + """一个比较所在的条件:往上只越过且、或、非、异或、括号,走到的那一层。WHERE、ON、HAVING、CASE 的每个 WHEN、 + 选出列里的每个比较,各是各的条件;UNION 的两支各有各的 WHERE。""" + while isinstance(node.parent, _logic()): + node = node.parent + return node + + +def _marks(value: Any, op: str) -> list[tuple[Any, int]]: + """一个边界从哪边隔开了哪个值:(值, 0) 是从下面隔开(它和比它小一点的值结果不同:>= v、< v),(值, 1) 是从上面 + 隔开(和比它大一点的值结果不同:<= v、> v)。整数的 > v 也从下面隔开了 v + 1,< v 也从上面隔开了 v - 1。""" + 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] + + +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、 + 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。""" + from sqlglot import exp + + groups: dict[int, tuple[Any, dict[int, list[_Half | None]]]] = {} + 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): + 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): + continue + for literal in below + above: + 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()} + + +#: 条件里和这一列无关的部分(别的列、函数、子查询):这一列怎么取它都不变,可能真也可能假 +_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} + + +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)。""" + 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,))) + + +def _logic_children(node: Any) -> list[Any]: + """条件往下一层:括号、非里面的那一个,且、或、异或的两边;比较和别的写法没有下一层。""" + 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: + return [node.this, node.expression] + return [] + + +def _logic_size(top: Any) -> int: + """一个条件里有几个且、或、非、括号和比较:看一次夹没夹住要走的步数(PIN_WORK)。""" + count, stack = 0, [top] + while stack: + count += 1 + stack.extend(_logic_children(stack.pop())) + return count + + +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),否则按紧挨着的值。先算下层再算上层,不用递归:几百个条件连成的一长串会超过递归深度。""" + 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 + if isinstance(node, exp.Paren): + out = done[id(kids[0])] + elif isinstance(node, exp.Not): + out = {tuple(not x for x in t) for t in done[id(kids[0])]} + 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} + else: + out = _leaf_outcomes(halves.get(id(node)), key, point, steps) + done[id(node)] = out + return done[id(top)] + + +def _leaf_outcomes(parts: list[_Half | None] | None, key: str, point: Any, steps: bool) -> set[tuple[bool, ...]]: + """一个比较(几半的「且」)可能的真假组合;不是范围比较的(别的条件)当成不知道真假。""" + if parts is None: + return set(_EITHER) + out: set[tuple[bool, ...]] = {(True, True, True)} + for half in parts: + cell = {_truth(half, point, steps)} if half is not None and half[1] == key and ( + isinstance(half[2], Decimal) == isinstance(point, Decimal)) else _EITHER + out = {tuple(x and y for x, y in zip(s, t)) for s in out for t in cell} return out -def _literal_uses(sql: str, kind: str | None) -> dict[int, tuple[str, str | None]] | None: - """SQL 里每个字面量(按它在原文里的起始位置)用在哪,按语法树认(写法怎么变都认得出): - - ("equal", 列名):在找某一个值——等值、不等、IN(含 VALUES、行值)、LIKE 一类、IS [NOT] DISTINCT FROM、简单 CASE 的 - WHEN、= ANY(ARRAY[…]),套了括号、负号、CAST 的照认;把某一个值夹住的范围(_pinned)也算。列名是被比较的那一列 - (小写、不带表名),另一边不是单独一列时为 None; - - ("range", 列名):范围比较(> >= < <=、BETWEEN)的边界,是 agent 定的阈值; - - ("limit", None):LIMIT / OFFSET / FETCH / TOP 的行数; - - ("other", None):别的——选出列、函数参数、算式、THEN 后面的值、子查询里选出的值。 - 解析不了返回 None。""" - from sqlglot import exp +def _truth(half: _Half, point: Any, steps: bool) -> tuple[bool, ...]: + """一半范围比较在 point 和它两边的真假(steps 同 _outcomes)。""" + _, _, bound, op = half + if steps: + signs = [_sign(point + step, bound) for step in (-1, 0, 1)] + else: + at = _sign(point, bound) + signs = [at or -1, at, at or 1] + return tuple(_HOLDS[op](s) for s in signs) + +def _sign(a: Any, b: Any) -> int: + return (a > b) - (a < b) + + +def _point_text(point: Any) -> str: + """夹住的值写成格子里的样子(25、13900000001、12.5),拿去和遮罩列里的值比(_swap_literal)。""" + if isinstance(point, Decimal): + return format(point.to_integral_value() if point == point.to_integral_value() else point.normalize(), "f") + return str(point) + + +#: SQL 里一个字面量用在哪:(用法, 被比较的列名, 夹住的值),见 _literal_uses +_Use = tuple[str, str | None, tuple[str, ...]] + + +def _literal_uses(sql: str, kind: str | None) -> dict[int, _Use] | None: + """SQL 里每个字面量(按它在原文里的起始位置)用在哪,按语法树认(写法怎么变都认得出): + - ("equal", 列名, 夹住的值):在找某一个值——等值、不等、IN(含 VALUES、行值)、LIKE 一类、IS [NOT] DISTINCT FROM、 + 简单 CASE 的 WHEN、= ANY(ARRAY[…]),套了括号、负号、CAST 的照认;同一个条件里把某一个值夹住的范围(_pinned) + 也算,夹住的值记在第三项(换不换按它认:整数的 age > 24 AND age < 26 找的是 25),别的写法第三项为空。列名是 + 被比较的那一列(小写、不带表名),另一边不是单独一列时为 None; + - ("range", 列名, ()):范围比较(> >= < <=、BETWEEN)的边界,是 agent 定的阈值; + - ("limit", None, ()):LIMIT / OFFSET / FETCH / TOP 的行数; + - ("other", None, ()):别的——选出列、函数参数、算式、THEN 后面的值、子查询里选出的值。 + 解析不了返回 None。""" from app.data.sql_semantics import _parse, _read try: @@ -1369,15 +1545,16 @@ def _literal_uses(sql: str, kind: str | None) -> dict[int, tuple[str, str | None return None # 解析的是去掉头尾空白的 SQL:位置补上开头的空白,才对得上原文里的词 offset = len(sql) - len(sql.lstrip()) - uses: dict[int, tuple[str, str | None]] = {} + uses: dict[int, _Use] = {} try: literals = {id(n): n for n in root.walk() if isinstance(n, _literal_types())} - pinned = _pinned([b for n in root.find_all(exp.Between, *_range_ops()) for b in _bounds_of(n)]) + pinned = _pinned(root) for key, node in literals.items(): start = (node.meta or {}).get("start") if isinstance(start, int): use, column = _use_of(node) - uses[start + offset] = ("equal", column) if key in pinned and use == "range" else (use, column) + points = pinned.get(key) if use == "range" else None + uses[start + offset] = ("equal", column, points) if points else (use, column, ()) except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(认不准的地方不免) logger.warning("质询的 SQL 字面量分析失败", exc_info=True) return None @@ -1407,59 +1584,99 @@ def _sql_tokens(sql: str, kind: str | None) -> tuple[list[Any], list[str]] | Non return tokens, [t.token_type.name for t in tokens] -def _literals(sql: str, kind: str | None) -> tuple[list[Any], list[str], dict[int, tuple[str, str | None]]] | None: +def _literals(sql: str, kind: str | None, secrets: _Secrets, compared: set[str] + ) -> tuple[list[Any], list[str], dict[int, _Use]] | None: """分词,加上每个字面量(按词的下标)用在哪(_literal_uses)。语法树里对不上位置的字面量记 "other";SQL 解析 不了时只按词认简单的等值比较(_context),别的一律 "other"——认不准的地方不免。分词出错返回 None。 - 照原样留着的数还得在这条 SQL 的字面量里处处都是阈值、行数:同一个数在别的字面量里(等值比较、算式、字符串)也 - 出现过,那里会被换掉,这里留着就把它露了(BETWEEN 13900000001 AND 13900000001 + 0),所以这里也改记成在找这个值。 - 名字、注释里的不算:那是 agent 给阈值起的说法(AS 超过30000的笔数),照常按值换,留着的阈值不必跟着换。""" + 照原样留着的数还得在这条 SQL 里没有别处被换掉:同一个数在别的字面量里(等值比较、算式、字符串)确实被换掉了, + 这里留着就把它露了(BETWEEN 13900000001 AND 13900000001 + 0、age >= 30 AND age = 30),所以这里也改记成在找这个值。 + 换没换照 _swap_literal 认,和真去换的是同一套判断:只是写在日期、天数修饰、标签里('2026-01-31'、'-30 days'、 + '30岁以下'),或者没设遮罩的列上的短的数(amount = 30),那里一个字没换,阈值也照原样。名字、注释里的不算:那是 + agent 给阈值起的说法(AS 超过30000的笔数),照常按值换,留着的阈值不必跟着换。""" split = _sql_tokens(sql, kind) if split is None: return None tokens, kinds = split uses = _literal_uses(sql, kind) - found: dict[int, tuple[str, str | None]] = {} + found: dict[int, _Use] = {} for i, token in enumerate(tokens): if not _is_literal(kinds[i]): continue if uses is not None: - found[i] = uses.get(token.start, ("other", None)) + found[i] = uses.get(token.start, ("other", None, ())) else: use, column = _context(tokens, kinds, i) - found[i] = (use, column) if use == "equal" else ("other", None) - kept = {i for i, (use, _) in found.items() if kinds[i] == "NUMBER" and use in _KEPT} - elsewhere = _numbers_in(tokens[i].text for i in found if i not in kept) + found[i] = (use, column, ()) if use == "equal" else ("other", None, ()) + kept = {i for i, (use, _, _) in found.items() if kinds[i] == "NUMBER" and use in _KEPT} + swapped = _swapped(sql, tokens, kinds, found, kept, secrets, compared) for i in kept: - if _digits_of(tokens[i].text) in elsewhere: - found[i] = ("equal", found[i][1]) + if _digits_of(tokens[i].text) in swapped: + found[i] = ("equal", found[i][1], ()) return tokens, kinds, found -def _numbers_in(texts: Iterable[str]) -> set[str]: - """这几段字里写着的数(去掉千分位、正负号)。""" - return {_digits_of(n) for text in texts for n in _DIGITS.findall(text)} +def _swapped(sql: str, tokens: list[Any], kinds: list[str], uses: dict[int, _Use], skip: set[int], + secrets: _Secrets, compared: set[str]) -> set[str]: + """这几个字面量(skip 以外的)里确实换掉的数(_swap_literal),去掉千分位、正负号。""" + out: set[str] = set() + for i, use in uses.items(): + if i not in skip: + raw = sql[tokens[i].start:tokens[i].end + 1] + out |= _swap_literal(raw, tokens[i].text, kinds[i], use, secrets, compared)[1] + return out + + +def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets, compared: set[str] + ) -> tuple[str, set[str]]: + """SQL 里一个字面量(原文 raw、分出来的字 text、词的种类 kind)换成什么样,和换掉了哪些遮罩数(去掉千分位、正负号)。 + _scrub_sql 照它换,_literals 照它认别处确实换掉的数,两处是同一套判断: + + - 在找某一个值的(equal):和遮罩列(或它的别名,compared)比较的,是遮罩列里出现过的值就整个换,短的数也换 + (age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号)。夹住某一个值的范围按夹住 + 的值认(age > 24 AND age < 26 的两头都按 25 认); + - 字符串在哪都按值换里面的遮罩值(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人); + - 别的数:范围比较的边界、LIMIT / OFFSET 的行数照原样(agent 自己定的阈值,换了封存的就不是实际执行的语句); + 别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换。""" + how, column, points = use + word = _masked_word() + if how == "equal": + looked = [text, *points] + if any((column in compared and secrets.weak_hit(v)) or secrets.hit(v) for v in looked): + return _requote(raw, lambda _: word), _number_forms(looked) + if kind != "NUMBER": + found: list[str] = [] + + def scrub(inner: str) -> str: + pattern = secrets.pattern(inner) + if pattern is None: + return inner + found.extend(m.group() for m in pattern.finditer(inner)) + return pattern.sub(word, inner) + + return _requote(raw, scrub), _number_forms(found) + if how not in _KEPT and secrets.hit(text): + return word, _number_forms([text]) + return raw, set() + + +def _number_forms(values: Iterable[str]) -> set[str]: + """几个换掉的值里是数的那几个(去掉千分位、正负号):阈值只会和数撞上。""" + return {_digits_of(text) for value in values if _NUMERIC.fullmatch(text := _plain_text(value))} def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None) -> str: """SQL 里遮罩列的值换成「已遮罩」,别的一个字不动:查询清单随运行封存,它得是实际执行的那条,审批人看 what-if 的 - 条件(阈值、日期、LIMIT、别的列的条件)看的就是它。字面量按用在哪换(_literals): - - - 在找某一个值的(equal):和遮罩列(或它的别名,compared)比较的,是遮罩列里出现过的值就换,短的数也换 - (age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号); - - 范围比较的边界(age > 31、BETWEEN 10000 AND 50000)、LIMIT / OFFSET 的行数,数字照原样:它们是 agent 自己定的 - 阈值,不是在找某一个人,换了封存的就不是实际执行的语句; - - 别处的数(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值换; - - 字符串在哪都按值换(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人),注释、名字(AS - "13900000001 的单"、张伟的单数)也按值换(同正文:够长的数、两个字以上的文字)。 + 条件(阈值、日期、LIMIT、别的列的条件)看的就是它。字面量按用在哪换(_literals、_swap_literal):在找某一个值的 + 换,范围比较的边界、LIMIT / OFFSET 的行数照原样,别处的数够长的遮罩值换,字符串按值换;注释、名字(AS + "13900000001 的单"、张伟的单数)也按值换(同正文:够长的数、两个字以上的文字)。 分词出错就整条按正文的办法换;解析不了的认不准哪个数是阈值,范围边界、行数也不免(_literals)。""" if not sql or not (secrets.strong or secrets.weak): return sql - split = _literals(sql, kind) + split = _literals(sql, kind, secrets, compared) if split is None: return secrets.scrub(sql) tokens, kinds, uses = split - word = _masked_word() out: list[str] = [] last = 0 for i, token in enumerate(tokens): @@ -1470,13 +1687,7 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None out.append(secrets.scrub(gap) if gap.strip() else gap) raw = sql[start:end] if i in uses: - use, column = uses[i] - if use == "equal" and ((column in compared and secrets.weak_hit(token.text)) or secrets.hit(token.text)): - raw = _requote(raw, lambda _: word) - elif kinds[i] != "NUMBER": - raw = _requote(raw, secrets.scrub) - elif use not in _KEPT and secrets.hit(token.text): - raw = word + raw = _swap_literal(raw, token.text, kinds[i], uses[i], secrets, compared)[0] elif kinds[i] in _NAME_TOKENS: raw = _requote(raw, secrets.scrub) out.append(raw) @@ -1486,24 +1697,27 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None return "".join(out) -def _shown_literals(sqls: Iterable[tuple[str, str | None]]) -> set[str]: - """几条 SQL(和各自数据源的类型)里照原样留着的数,去掉千分位、正负号:范围比较的边界、LIMIT / OFFSET 的行数 - (_scrub_sql 不换的那两种)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额不低于 30000 元」)再遮也遮不住 - 什么,只会让回答和 SQL 对不上(_Secrets.show)。同一个数在别的查询里出现在别的字面量里也不算(等值比较、选出列、 - 函数参数、字符串;同一条里的 _literals 已经不留):那里它会被换掉,正文写出来就把那里换掉的值露了。SQL 解析 - 不了的一个都不算,里面写着的数也都不算。""" +def _shown_literals(sqls: Iterable[tuple[str, str | None, set[str]]], secrets: _Secrets) -> set[str]: + """几条 SQL(各自数据源的类型、SQL 里认哪些名字是遮罩列,见 _compared)里照原样留着的数,去掉千分位、正负号: + 范围比较的边界、LIMIT / OFFSET 的行数(_scrub_sql 不换的那两种)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额 + 不低于 30000 元」)再遮也遮不住什么,只会让回答和 SQL 对不上(_Secrets.show)。同一个数在别的查询里确实被换掉了 + 也不算(同一条里的 _literals 已经不留):正文写出来就把那里换掉的值露了;只是写在日期、批次号、标签里没换的不牵连。 + 分词都分不了的整条按值换(同 _scrub_sql),换掉的数也算。""" kept: set[str] = set() - elsewhere: set[str] = set() - for sql, kind in sqls: - split = _literals(sql, kind) if sql else None + swapped: set[str] = set() + for sql, kind, compared in sqls: + if not sql: + continue + split = _literals(sql, kind, secrets, compared) if split is None: - elsewhere |= _numbers_in([sql or ""]) + pattern = secrets.pattern(sql) + swapped |= _number_forms(m.group() for m in pattern.finditer(sql)) if pattern is not None else set() continue tokens, kinds, uses = split - keep = {i for i, (use, _) in uses.items() if kinds[i] == "NUMBER" and use in _KEPT} + keep = {i for i, (use, _, _) in uses.items() if kinds[i] == "NUMBER" and use in _KEPT} kept |= {_digits_of(tokens[i].text) for i in keep} - elsewhere |= _numbers_in(tokens[i].text for i in uses if i not in keep) - return kept - elsewhere + swapped |= _swapped(sql, tokens, kinds, uses, keep, secrets, compared) + return kept - swapped @dataclass @@ -1531,11 +1745,12 @@ async def _source_kinds(names: Iterable[str]) -> dict[str, str | None]: async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], given: list[str | None], - shown: list[tuple[str, str]] | None = None) -> _Plan: + shown: list[tuple[str, str, str]] | None = None, live: dict[str, list[str]] | None = None) -> _Plan: """own:agent 自己的查询快照(工件 id → 内容);others:报告自己的查询快照(被问的那句可能直接写着遮罩列的值, agent 会照着复述、写进查询条件);given:问题、选中的原文、报告里列出的指标和运行输入的值(都已经摆在读者眼前, - 里面的值哪里都不换);shown:agent 执行的 SQL 和它查的数据源名(照原样留在 SQL 里的阈值、LIMIT,回答正文里 - 不换,见 _Secrets.show;取质询工件不用:快照里的格子照旧按值遮)。 + 里面的值哪里都不换);shown:agent 执行的查询,(SQL, 数据源名, 快照工件 id)(照原样留在 SQL 里的阈值、LIMIT, + 回答正文里不换,见 _Secrets.show;取质询工件不用:快照里的格子照旧按值遮);live:用得上的数据源现在设的遮罩列 + (快照没取到的查询按它认 SQL 里的比较,见 _compared)。 查数据源的遮罩要读库,在这里读;解析 SQL、收上万个值、逐格比对是纯计算,放进线程:放在事件循环里会挡住同一进程 里别的运行(同 tools/datasource 的 _sql_checks)。""" @@ -1546,13 +1761,13 @@ async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], gi named = {artifact: await _hidden_columns(snapshot, masks) for artifact, snapshot in own.items()} based = [(snapshot, (await _hidden_columns(snapshot, masks))[0]) for snapshot in others] kinds = await _source_kinds([*(str(s.get("source") or "") for s in [*own.values(), *others]), - *(name for _, name in shown)]) - return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given, shown) + *(name for _, name, _ in shown)]) + return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given, shown, live or {}) def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str], list[str]]], based: list[tuple[dict[str, Any], set[str]]], kinds: dict[str, str | None], - given: list[str | None], shown: list[tuple[str, str]]) -> _Plan: + given: list[str | None], shown: list[tuple[str, str, str]], live: dict[str, list[str]]) -> _Plan: secrets = _Secrets() hidden: dict[str, set[str]] = {} tainted: dict[str, set[str]] = {} @@ -1563,7 +1778,8 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] for snapshot, base in based: secrets.add(snapshot, _derived_hidden(snapshot, base, kinds.get(str(snapshot.get("source") or "")))[0]) secrets.protect(given) - secrets.show(_shown_literals((sql, kinds.get(name)) for sql, name in shown)) + secrets.show(_shown_literals([(sql, kinds.get(name), _compared(tainted, artifact, live, name)) + for sql, name, artifact in shown], secrets)) secrets.prepare() views: dict[str, dict[str, Any]] = {} masked: dict[str, list[str]] = {} @@ -1573,6 +1789,12 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] gone={artifact: named[artifact][1] for artifact in own}) +def _compared(tainted: dict[str, set[str]], artifact: str, live: dict[str, list[str]], name: str) -> set[str]: + """一条查询的 SQL 里认哪些名字是遮罩列(和它们做等值比较的,短的数也换):快照认出的(_derived_hidden 的名字, + 含改了名字的);快照没取到的(存不下、被删了),按这个源现在设的遮罩列认。""" + return tainted.get(artifact) or {c.lower() for c in live.get(name, [])} + + def _masking_loader(views: dict[str, dict[str, Any]]) -> Callable[[str], Any]: """组装回答文档用的 loader:agent 自己的查询快照交遮好的那份(引用到遮罩的格子渲染成「已遮罩」);别的查询快照 不交(回答的目录里只有 agent 自己的查询);口径卡这类没有行的原样,哈希照旧复验。""" @@ -1664,7 +1886,8 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict others = [snapshot for entry in catalog.values() if isinstance(entry, dict) and entry.get("kind") == "query" and _query_shaped(snapshot := _quiet_load(op.loader, entry.get("artifact")))] plan = await _plan(own, others, [ask.question, ask.quote, *_ref_values(refs)], - [(str(q.get("sql") or ""), str(q.get("source") or "")) for q in outcome.queries]) + [(str(q.get("sql") or ""), str(q.get("source") or ""), str(q.get("artifact") or "")) + for q in outcome.queries], live) text, doc_id, failed = "", None, None source = (outcome.rationale or {}).get("source") if outcome.result is not None else None @@ -1704,8 +1927,7 @@ def _sealed_queries(queries: list[dict[str, Any]], plan: _Plan, live: dict[str, out: list[dict[str, Any]] = [] for q in queries: artifact, name = str(q.get("artifact") or ""), str(q.get("source") or "") - # 快照没取到的(存不下、被删了),SQL 里按这个源设的遮罩列认比较 - compared = plan.tainted.get(artifact) or {c.lower() for c in live.get(name, [])} + compared = _compared(plan.tainted, artifact, live, name) out.append({**{k: q.get(k) for k in QUERY_KEYS}, "sql": _scrub_sql(str(q.get("sql") or ""), plan.secrets, compared, plan.kinds.get(name))}) return out diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index b877bb30..811c0fa8 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -13,8 +13,9 @@ - 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出(提问人自己写的数不标);遮罩列(起了 别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现 遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码……) - 也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标;SQL 里照原样留着的 - 阈值只在回答正文里不遮,格子照旧按值遮,回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值 + 也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、 + 和日期、天数修饰、标签里写着的同一个数也不牵连阈值;SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮, + 回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值 - 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 数据源、没固定过版本的上传表格不查 - 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 @@ -989,6 +990,46 @@ async def test_numbers_that_merely_match_a_short_masked_value_are_left_alone(mon await engines.invalidate(source_id) +async def test_a_threshold_matching_numbers_left_alone_elsewhere_is_sealed_as_written(monkeypatch, tmp_path, client): + """遮罩列是年龄:阈值 30、31 和天数修饰、日期、年龄段标签里写着的同一个数撞在一起。那些地方一个字没换,阈值也 + 照原样封存(回答正文本来就写着「30 岁」),质询工件的 SQL 和事件里一字不差;找某一个年龄的等值照样遮,同一条 + SQL 里的阈值也跟着遮——留着就把等值里换掉的露了。""" + 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 }}"}])) + 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") + 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]]")} + 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 + item = r.json()["inquiry"] + assert item["answer"]["text"] == "近 30 天、30 岁及以上的有 2 人,31 岁以上、1 月 31 日前付款的有 0 人。" + 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") + 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 和事件里的一字不差" + finally: + await engines.invalidate(source_id) + + STAR_TAIL = "SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1" @@ -1116,6 +1157,42 @@ 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 分支"), + 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="两个选出列"), + 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 两支"), +]) +async def test_band_edges_shared_by_separate_conditions_are_sealed_and_shown_as_written( + monkeypatch, big_shop, client, sql, answer, text): + """金额设了遮罩,分档的边界 20000 正好是某一笔的金额:两个 WHEN 分支、两个选出列、UNION 两支各写一边,各是各的 + 条件,没有夹住哪一笔。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)) + [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, text) + [query] = item["queries"] + assert query["sql"] == sql + [event] = await events(paused.id, "approval.inquiry") + 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 和事件里的一字不差" + + async def test_a_masked_value_the_asker_wrote_stays_in_the_sql(monkeypatch, tmp_path, client): """提问人自己写的数恰好是遮罩列里的值(金额设了遮罩,问的就是 300 那一笔):问题里原样写着,SQL 里和金额做等值 比较也不换,事件、质询工件里封存的就是实际执行的那条。""" @@ -1193,6 +1270,11 @@ _COUNT = "SELECT COUNT(*) AS 笔数 FROM sales WHERE " _COUNT + "NOT (buyer_phone > 已遮罩 OR buyer_phone < 已遮罩)", id="既不大于也不小于"), pytest.param(PHONES, _COUNT + "buyer_phone >= 13900000001 AND buyer_phone < 13900000002", _COUNT + "buyer_phone >= 已遮罩 AND buyer_phone < 已遮罩", id="整数的半开区间"), + # 两头都不是那个号码,夹住的是中间那一个:留着 13900000000 就等于写出了号码 + pytest.param(PHONES, _COUNT + "buyer_phone > 13900000000 AND buyer_phone < 13900000002", + _COUNT + "buyer_phone > 已遮罩 AND buyer_phone < 已遮罩", id="整数的开区间"), + pytest.param(PHONES, _COUNT + "region = '华东' AND buyer_phone >= 13900000001 AND buyer_phone <= 13900000001", + _COUNT + "region = '华东' AND buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩", id="同一条件里还有别的条件"), pytest.param(PHONES, _COUNT + "(buyer_phone, region) = (13900000001, '华东')", _COUNT + "(buyer_phone, region) = (已遮罩, '华东')", id="行值"), pytest.param(PHONES, _COUNT + "buyer_phone IS NOT DISTINCT FROM 13900000001", diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 3db62f26..ecc76532 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -10,9 +10,14 @@ LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、 >= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换;选出列、函数参数、算式里够长的遮罩值也换;字符串 (范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免 +- 「夹住某一个值」只在同一个条件里认,按条件合起来的真假认(旁边的别的条件当成不知道真假):分档的两个 WHEN 分支、 + 两个选出列、UNION 两支、WHERE 和 HAVING 各写一边共用的边界是阈值;列名认不出的只和写得一样的表达式配 +- 阈值只在同一个数在这条 SQL 别处确实被换掉时才不留(换不换和 SQL 里换字面量是同一套判断):日期、天数、标签里写着 + 同一个短的数,没设遮罩的列上的等值,都不牵连阈值 - 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换 - 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去 -- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则 +- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串照样按结构认,成百个分档边界看不过来的 + 按夹住算,也不卡 数据都是虚构的:手机号、姓名、年龄、区域、金额。 """ @@ -216,9 +221,10 @@ def people() -> inquiry_mod._Secrets: ("SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40", "SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40"), ("SELECT amount FROM sales WHERE amount = 13900000001", "SELECT amount FROM sales WHERE amount = 已遮罩"), - # 和遮罩列做范围比较:边界是阈值,不是在找某个人,照原样(同一个数只从一边隔开:25 的几处都是「不超过 25」那一侧) - ("SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 20 AND 25", - "SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 20 AND 25"), + # 和遮罩列做范围比较:边界是阈值,不是在找某个人,照原样。25 两边都有边界(<= 25、NOT BETWEEN 25 …),可这几个 + # 条件合起来谁也找不着(> 31 和 <= 25 不会同时成立),夹不住 25 + ("SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31", + "SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31"), # 不等、SQLite 的 IS、NOT LIKE 也是在找某一个值 ("SELECT COUNT(*) FROM sales WHERE age != 25 AND age IS NOT 31 AND buyer_phone NOT LIKE '13900000001'", "SELECT COUNT(*) FROM sales WHERE age != 已遮罩 AND age IS NOT 已遮罩 AND buyer_phone NOT LIKE '已遮罩'"), @@ -327,6 +333,87 @@ def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown): assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == shown +@pytest.mark.parametrize("kind, sql, shown", [ + # 同一个条件里还有别的条件(且、或):照样夹住了 + ("sqlite", "SELECT COUNT(*) FROM sales WHERE region = '华东' AND buyer_phone >= 13900000001 " + "AND buyer_phone <= 13900000001", + "SELECT COUNT(*) FROM sales WHERE region = '华东' AND buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩"), + ("sqlite", "SELECT COUNT(*) FROM sales WHERE (age >= 25 AND age <= 25) OR region = '华南'", + "SELECT COUNT(*) FROM sales WHERE (age >= 已遮罩 AND age <= 已遮罩) OR region = '华南'"), + # CASE WHEN 里的一个条件 + ("sqlite", "SELECT COUNT(CASE WHEN buyer_phone BETWEEN 13900000001 AND 13900000001 THEN 1 END) AS 笔数 FROM sales", + "SELECT COUNT(CASE WHEN buyer_phone BETWEEN 已遮罩 AND 已遮罩 THEN 1 END) AS 笔数 FROM sales"), + # 整数的开区间:两头都不是那个值,夹住的是中间那一个,两头一样换(留着 24、26 就等于写出了 25) + ("sqlite", "SELECT COUNT(*) FROM sales WHERE age > 24 AND age < 26", + "SELECT COUNT(*) FROM sales WHERE age > 已遮罩 AND age < 已遮罩"), + ("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < 26", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age < 已遮罩"), + ("sqlite", "SELECT COUNT(*) FROM sales WHERE buyer_phone > 13900000000 AND buyer_phone < 13900000002", + "SELECT COUNT(*) FROM sales WHERE buyer_phone > 已遮罩 AND buyer_phone < 已遮罩"), + # 不低于、大于同一个值,二者只成立一个(MySQL 的 XOR):就是等于它 + ("mysql", "SELECT COUNT(*) FROM sales WHERE age >= 25 XOR age > 25", + "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 或 HAVING 里、整数的开区间。""" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == shown + + +@pytest.mark.parametrize("sql, shown", [ + ("SELECT region FROM sales GROUP BY region HAVING SUM(amount) BETWEEN 20000 AND 20000", + "SELECT region FROM sales GROUP BY region HAVING SUM(amount) BETWEEN 已遮罩 AND 已遮罩"), + ("SELECT region FROM sales GROUP BY region HAVING SUM(amount) >= 20000 AND SUM(amount) <= 20000", + "SELECT region FROM sales GROUP BY region HAVING SUM(amount) >= 已遮罩 AND SUM(amount) <= 已遮罩"), +]) +def test_the_same_aggregate_pinned_from_both_sides_is_a_lookup(amounts, sql, shown): + """列名认不出的(汇总值、函数)只和写得一样的表达式配:HAVING 里同一个汇总值两头相同,照样是在找它。""" + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == shown + + +@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", + # 两个选出列各按一边数 + "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", + # UNION 的两支 + "SELECT '小单' AS 档位, COUNT(*) AS 笔数 FROM sales WHERE amount <= 20000 " + "UNION ALL SELECT '大单', COUNT(*) FROM sales WHERE amount >= 20000", + # 不同子句:WHERE 里一笔的上限、HAVING 里汇总值的下限 + "SELECT region, COUNT(*) AS 笔数 FROM sales WHERE amount <= 20000 GROUP BY region HAVING SUM(amount) >= 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)", +]) +def test_band_edges_shared_by_separate_conditions_are_thresholds(amounts, sql): + """第 3 轮收全句所有范围比较一起配对,列名认不出的(SUM(amount)、函数)还和哪一列都配:分档的两个 WHEN 分支、 + 两个选出列、UNION 两支各写一边,共用的那个边界就被当成「夹住了某一笔」遮掉,回答里「以 20000 元为界」也跟着遮。 + 这些都是阈值:照原样封存,正文里复述也不遮。""" + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sql + assert "20000" in inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts) + + +@pytest.fixture +def ages() -> inquiry_mod._Secrets: + """年龄设了遮罩,都是两位数:和天数修饰、日期、标签、别的列上的数撞在一起。""" + return secrets_of({"columns": ["age"], "rows": [[25], [30], [20], [31]]}, {"age"}) + + +@pytest.mark.parametrize("sql, shown", [ + ("SELECT CASE WHEN age BETWEEN 18 AND 30 THEN '青年' WHEN age BETWEEN 30 AND 45 THEN '中年' END AS 年龄段, " + "COUNT(*) AS 人数 FROM sales GROUP BY 1", {"18", "30", "45"}), + # 标签里写着同样的数:短的数在字符串里不换,阈值也不跟着遮 + ("SELECT CASE WHEN age BETWEEN 18 AND 30 THEN '18-30岁' WHEN age BETWEEN 30 AND 45 THEN '30-45岁' END AS 年龄段, " + "COUNT(*) AS 人数 FROM sales GROUP BY 1", {"18", "30", "45"}), +]) +def test_overlapping_age_bands_are_thresholds(ages, sql, shown): + assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql + assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == shown + + @pytest.mark.parametrize("sql, shown", [ ("SELECT COUNT(*) AS 笔数, 13900000001 AS 号码 FROM sales WHERE buyer_phone >= 13900000001", "SELECT COUNT(*) AS 笔数, 已遮罩 AS 号码 FROM sales WHERE buyer_phone >= 已遮罩"), @@ -336,7 +423,70 @@ def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown): def test_a_number_masked_elsewhere_in_the_sql_is_not_kept_as_a_threshold(phones, sql, shown): """同一个号码在选出列、算式里被换掉了,条件里的单边范围也不留:留着就把那里换掉的露了。""" assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == shown - assert inquiry_mod._shown_literals([(sql, "sqlite")]) <= {"5"} + assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones) <= {"5"} + + +@pytest.mark.parametrize("sql, shown", [ + # 天数修饰、日期里的数:短的数在字符串里从来不换,日期里的一截也不认 + ("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND paid_on >= DATE('2026-02-03', '-30 days')", {"30"}), + ("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 31 AND paid_on <= '2026-01-31'", {"31"}), + # 标签里的数 + ("SELECT CASE WHEN age < 30 THEN '30岁以下' ELSE '30岁及以上' END AS 年龄段, COUNT(*) AS 人数 FROM sales GROUP BY 1", + {"30"}), + # 没设遮罩的列上的等值:短的数只在和遮罩列比较时换 + ("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND amount = 30", {"30"}), +]) +def test_a_threshold_stays_when_the_same_number_is_left_alone_elsewhere(ages, sql, shown): + """第 3 轮把「这条 SQL 别的字面量里写着同一个数」一律当成那里换掉了:日期、天数、标签里的数,没设遮罩的列上的 + 等值,那里其实一个字没换,阈值却跟着遮了,回答正文又照样写着它。现在只有别处确实换掉了才不留。""" + assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql + 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", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age = 已遮罩"), + ("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 31 OR age IN ('31', '99')", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age > 已遮罩 OR age IN ('已遮罩', '99')"), +]) +def test_a_threshold_goes_when_the_same_number_is_masked_elsewhere(ages, sql, sealed): + 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'", + "SELECT COUNT(*) FROM sales WHERE amount >= 20000 AND batch = '20000-01'"), + # 够长的遮罩值在别的列的等值里也换:换掉了,阈值也不留 + ("SELECT COUNT(*) FROM sales WHERE amount >= 20000 AND visits = 20000", + "SELECT COUNT(*) FROM sales WHERE amount >= 已遮罩 AND visits = 已遮罩"), +]) +def test_long_numbers_go_by_what_was_actually_masked_elsewhere(amounts, sql, sealed): + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sealed + + +def test_numbers_shown_across_queries_go_by_what_was_actually_masked(amounts): + """几条查询之间同理:别的查询里同一个数只写在批次号里、没换,正文里复述阈值照样不遮;别的查询里确实换掉了(和 + 金额做等值比较、分词都分不了整条按值换),正文里也遮。""" + shown = inquiry_mod._shown_literals + threshold = ("SELECT COUNT(*) FROM sales WHERE amount >= 20000 LIMIT 5", "sqlite", {"amount"}) + assert shown([threshold, ("SELECT COUNT(*) FROM sales WHERE batch = '20000-01'", "sqlite", {"amount"})], + amounts) == {"20000", "5"} + assert shown([threshold, ("SELECT COUNT(*) FROM sales WHERE amount = 20000", "sqlite", {"amount"})], + amounts) == {"5"} + assert shown([threshold, ("SELECT 'x FROM sales WHERE amount = 20000", "sqlite", {"amount"})], amounts) == {"5"} def test_a_threshold_named_in_an_alias_or_a_comment_stays(amounts): @@ -344,7 +494,7 @@ def test_a_threshold_named_in_an_alias_or_a_comment_stays(amounts): sql = "SELECT SUM(amount >= 30000) AS 超过30000的笔数 FROM sales -- 30000 以上算大单" assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == ( "SELECT SUM(amount >= 30000) AS 超过已遮罩的笔数 FROM sales -- 已遮罩 以上算大单") - assert inquiry_mod._shown_literals([(sql, "sqlite")]) == {"30000"} + assert inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts) == {"30000"} def test_sql_starting_with_blank_lines_is_read_at_the_right_places(): @@ -354,7 +504,7 @@ def test_sql_starting_with_blank_lines_is_read_at_the_right_places(): sql = "\n SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND buyer_phone IN (13900000001, 1) LIMIT 30000" assert inquiry_mod._scrub_sql(sql, secrets, {"buyer_phone", "amount"}, "sqlite") == ( "\n SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND buyer_phone IN (已遮罩, 1) LIMIT 30000") - assert inquiry_mod._shown_literals([(sql, "sqlite")]) == {"30000"} + assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "amount"})], secrets) == {"30000"} def test_string_range_bounds_are_masked_by_value(people): @@ -372,20 +522,22 @@ def test_sql_that_cannot_be_parsed_keeps_no_thresholds(): sql = "SELECT COUNT(*) FROM sales WHERE age = 25 AND amount >= 30000 AND visits > 31 LIMIT 10000 GROUP" assert inquiry_mod._scrub_sql(sql, secrets, {"age", "amount"}, "sqlite") == ( "SELECT COUNT(*) FROM sales WHERE age = 已遮罩 AND amount >= 已遮罩 AND visits > 31 LIMIT 已遮罩 GROUP") - assert inquiry_mod._shown_literals([(sql, "sqlite")]) == set() + assert inquiry_mod._shown_literals([(sql, "sqlite", {"age", "amount"})], secrets) == set() -def test_only_thresholds_and_row_counts_count_as_shown(): +def test_only_thresholds_and_row_counts_count_as_shown(amounts): """照原样留在 SQL 里的只有范围边界(没夹住某一个值的)和 LIMIT / OFFSET 的行数;选出列、函数参数、CASE、夹住 某一个值的范围里的数不算,在别处(这一条或别的查询里)被换掉的数也不算。""" - shown = inquiry_mod._shown_literals - assert shown([("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND visits > 20000 LIMIT 10000 OFFSET 12000", - "sqlite")]) == {"30000", "20000", "10000", "12000"} - assert shown([("SELECT 50000 AS 门槛, SUM(CASE amount WHEN 12000 THEN 1 END), ROUND(amount, 10000) FROM sales " - "WHERE amount BETWEEN 20000 AND 20000", "sqlite")]) == set() - assert shown([("SELECT COUNT(*) FROM sales WHERE amount >= 30000", "sqlite"), - ("SELECT COUNT(*) FROM sales WHERE amount = 30000", "sqlite")]) == set() - assert shown([("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND note LIKE '%30000%'", "sqlite")]) == set() + def shown(*sqls: str) -> set[str]: + return inquiry_mod._shown_literals([(sql, "sqlite", {"amount"}) for sql in sqls], amounts) + + assert shown("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND visits > 20000 LIMIT 10000 OFFSET 12000") == { + "30000", "20000", "10000", "12000"} + assert shown("SELECT 50000 AS 门槛, SUM(CASE amount WHEN 12000 THEN 1 END), ROUND(amount, 10000) FROM sales " + "WHERE amount BETWEEN 20000 AND 20000") == set() + assert shown("SELECT COUNT(*) FROM sales WHERE amount >= 30000", "SELECT COUNT(*) FROM sales WHERE amount = 30000" + ) == set() + assert shown("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND note LIKE '%30000%'") == set() @pytest.mark.parametrize("kind, sql, shown", [ @@ -415,8 +567,8 @@ def test_numbers_left_as_written_in_the_sql_are_not_masked_in_the_answer(amounts """范围比较的边界、LIMIT 照原样留在 SQL 里,回答正文里同样的数再遮也遮不住什么,只会和 SQL 对不上;等值比较 里换掉的照旧遮。""" sql = "SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND amount <> 50000 LIMIT 10000" - assert inquiry_mod._shown_literals([(sql, "sqlite")]) == {"30000", "10000"} - amounts.show(inquiry_mod._shown_literals([(sql, "sqlite")])) + assert inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts) == {"30000", "10000"} + amounts.show(inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts)) assert amounts.scrub("金额不低于 30000 元、不是 50000 元的,取前 10000 条", prose=True) == ( "金额不低于 30000 元、不是 已遮罩 元的,取前 10000 条") assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == ( @@ -430,7 +582,7 @@ def test_a_threshold_left_in_the_sql_is_shown_in_the_prose_but_not_in_the_cells( """SQL 里照原样留着的阈值只在回答正文里不遮:格子里的同一个数照旧按值遮(col >= v 再取第一行,查出来的可能就是 那一笔),列名、SQL 里的名字也照旧换。组装出来正文里的不算露出,格子里渲染出来的算。""" sql = "SELECT region AS 区域, visits AS 访问 FROM sales WHERE visits >= 20000 ORDER BY visits LIMIT 1" - amounts.show(inquiry_mod._shown_literals([(sql, "sqlite")])) + amounts.show(inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts)) assert amounts.scrub("访问不少于 20000 次", prose=True) == "访问不少于 20000 次" assert amounts.scrub("访问不少于 20000 次") == "访问不少于 已遮罩 次" snapshot = {"columns": ["区域", "访问"], "rows": [["华东", 20000]]} @@ -541,3 +693,28 @@ def test_twenty_thousand_masked_values_are_scrubbed_without_stalling(): elapsed = (time.perf_counter() - started) / 5 assert "13900000042" not in out and "客户00042" not in out and "近 30 天" in out assert elapsed < 0.5, f"换一遍用了 {elapsed:.2f} 秒" + + +def test_a_long_chain_of_conditions_is_still_read_by_its_structure(ages): + """上千个条件连成一长串(一层套一层的且):照样按条件合起来看,夹住的值照样换,不因为层数太深退回到按词认—— + 按词认不出范围夹住了值,短的数就漏了。""" + chain = " AND ".join(f"visits{i} >= {i}" for i in range(1500)) + sql = f"SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 25 AND {chain}" + assert inquiry_mod._literal_uses(sql, "sqlite") is not None + assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql.replace( + "age >= 25 AND age <= 25", "age >= 已遮罩 AND age <= 已遮罩") + + +def test_hundreds_of_band_edges_are_read_within_a_budget(): + """成百个分档边界连成一长串:看得过来的照条件合起来认(一百个相邻的分档连成一段,谁也没夹住);多到看不过来的, + 两边都隔开就按夹住算(宁可多换),也不会一个个细看算上好几秒。年龄从 0 到 599 都有,按夹住算的哪一档都会换。""" + every = secrets_of({"columns": ["age"], "rows": [[i] for i in range(600)]}, {"age"}) + + def bins(n: int) -> str: + return "SELECT COUNT(*) FROM sales WHERE " + " OR ".join(f"(age >= {i} AND age < {i + 1})" for i in range(n)) + + started = time.perf_counter() + assert inquiry_mod._scrub_sql(bins(100), every, {"age"}, "sqlite") == bins(100) + assert "已遮罩" in inquiry_mod._scrub_sql(bins(600), every, {"age"}, "sqlite") + elapsed = time.perf_counter() - started + assert elapsed < 5, f"用了 {elapsed:.2f} 秒" From 32c31190863e6ebd5f2376baf7a664743c7c5c98 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:25:48 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E8=B4=A8=E8=AF=A2=E5=B7=A5=E4=BB=B6=E9=87=8C?= =?UTF-8?q?=E6=96=87=E5=AD=97=E6=A0=BC=E5=A4=B9=E7=9D=80=E7=9A=84=E9=81=AE?= =?UTF-8?q?=E7=BD=A9=E5=80=BC=E6=8C=89=E5=AD=90=E4=B8=B2=E6=8D=A2=E6=8E=89?= =?UTF-8?q?=EF=BC=8C=E5=9B=9E=E7=AD=94=E4=B8=80=E8=B7=AF=E7=85=A7=E6=97=A7?= =?UTF-8?q?=E6=95=B4=E4=BB=BD=E4=B8=8D=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 1 轮起留着的缺口:备注列(没设遮罩)一格写着「客户13900000001来电改址」,手机号设了遮罩、被报告取数选中。agent 引用这一格, 回答被正确拦下(「回答中含有设了遮罩的值……已不予展示」),可事件的 inquiry_artifacts 照样记着这份快照, GET /api/runs/{id}/inquiries/artifacts/{artifact_id} 返回原样的格子、masked 为空,等于在「不予展示」旁边一点就能看到原值。 - _masked_view 加 embedded:没设遮罩的列里,一格文字(JSON 列按界面画出来的样子)夹着按值认得出的遮罩值,把那一截换成 「已遮罩」,这一格别的字、别的格子照原样;门槛同整格(_Secrets.scrub:够长的数、两个字以上的文字,提问人自己写的、报告 列出的指标和运行输入的值不换)。这一列不记进 masked:界面把记进去的列整列画成「已遮罩」,这一列在数据源里也没设遮罩 - 只有取质询工件这一路按子串换,而且只换要取的那一份(_plan 的 embedded 是工件 id 的集合);回答一路不动,渲染出来露了 照旧整份不交 - 接口说明写清 masked 的含义:有格子整格被遮掉的列;一格文字里夹着的只换那一截,列不记进去 - 补单元测试(文字格、字典形状的行、JSON 列、整格照旧整格换、回答一路照旧)和端到端测试(回答整份不交,工件里那一格变成 「客户已遮罩来电改址」、其余格子照原样、masked 为空)。做过去掉实现、测试变红的验证 --- backend/app/api/inquiry.py | 6 ++- backend/app/engine/inquiry.py | 54 ++++++++++++++++++--------- backend/tests/test_inquiry_api.py | 41 +++++++++++++++++++- backend/tests/test_inquiry_masking.py | 29 +++++++++++++- 4 files changed, 108 insertions(+), 22 deletions(-) diff --git a/backend/app/api/inquiry.py b/backend/app/api/inquiry.py index 65b8b774..f3353398 100644 --- a/backend/app/api/inquiry.py +++ b/backend/app/api/inquiry.py @@ -3,8 +3,10 @@ - POST /api/runs/{run_id}/inquiries:{report, unit, quote?, question, approval_id?} → {inquiry, recorded, note} - GET /api/runs/{run_id}/inquiries?report=&unit=:{items: [Inquiry]},封存前后的都算,按先后 - GET /api/runs/{run_id}/inquiries/artifacts/{artifact_id}:质询引用过的工件 {id, content, masked[, mask_note]}—— - 查询快照(queries[].artifact)照回答时的办法遮好:遮罩列(含起了别名、套了函数的)整列遮,别的列里写着遮罩值的格子 - 也遮,masked 是有格子被遮掉的列名;content.sql 就是事件里记下的那条(遮罩值已换掉),两处一字不差。回答文档 + 查询快照(queries[].artifact)照回答时的办法遮好:遮罩列(含起了别名、套了函数的)整列遮,别的列里整格是遮罩值的 + 格子也遮,masked 是有格子整格被遮掉的列名(界面把这几列整列画成「已遮罩」);一格文字里夹着遮罩值的(备注里写着 + 手机号),只把那一截换成「已遮罩」,这一列不记进 masked。content.sql 就是事件里记下的那条(遮罩值已换掉),两处 + 一字不差。回答文档 (answer.doc_artifact)组装时就遮好了,原样给;回答里引用的报告指标(doc.catalog 里 m: 条目的 artifact,口径卡) 也从这里取,界面一律按 doc.catalog[alias].artifact 取。没写进运行记录的回答(recorded 为假)在本进程里留一小时, 这段时间里它引用的工件照样能取;之后、或者服务重启过,回 404。别的工件一律 404 diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index d20e4e82..b6b8b9d7 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -14,12 +14,12 @@ 封存之后的追问查的是数据源现在的数据,任务里给报告生成的时间,回答末尾注明;触顶、查询失败、引用解析不了也在末尾注明 3. 遮罩(_plan):哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列 (起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉);快照按列 - 遮,再按值遮(别的列里写着遮罩值的格子,_masked_view)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、 - _scrub_sql):按值认有门槛;SQL 里的字面量按语法树认用在哪(_literal_uses),数字照原样留着的只有范围比较的边界 - (同一个条件里没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且同一个数在这条 SQL 别处没被换掉,封存的是实际 - 执行的语句;在找某一个值的(等值、IN、简单 CASE……)和别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和 - 选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样留着的阈值只在回答正文里不换,格子照旧换。组装之后还 - 认得出遮罩值的,这份回答不交出去 + 遮,再按值遮(别的列里写着遮罩值的格子,_masked_view;质询工件里,文字格里夹着的遮罩值另按子串换)。回答正文、 + 查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql):按值认有门槛;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 下:证据接口按那个键 收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存), @@ -1085,11 +1085,16 @@ def _scrubbed_source(text: str, secrets: _Secrets) -> str: return "".join(out) -def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) -> tuple[dict[str, Any], list[str]]: +def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False + ) -> tuple[dict[str, Any], list[str]]: """一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数); 别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。 列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。 - 原快照不动;没有要遮的返回原快照。""" + 原快照不动;没有要遮的返回原快照。 + + embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号),把那一截换成「已遮罩」,这一格别的字 + 照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源 + 里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。""" columns = [str(c) for c in snapshot.get("columns") or []] index = {i for i, c in enumerate(columns) if c.lower() in hidden} by_value = bool(secrets.strong) @@ -1097,16 +1102,24 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) return snapshot, [] word = _masked_word() touched = set(index) + edited = False position = {c: i for i, c in enumerate(columns)} names = [secrets.scrub(c) for c in columns] if by_value else columns def cell(i: int | None, value: Any) -> Any: + nonlocal edited if i in index: return word if by_value and secrets.hit(value): if i is not None: touched.add(i) return word + if embedded and by_value and isinstance(value, (str, dict, list)): + # JSON 列(对象、数组)按界面画出来的样子换,换过的照文字给 + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) + if (scrubbed := secrets.scrub(text)) != text: + edited = True + return scrubbed return value def key(k: Any) -> Any: @@ -1123,7 +1136,7 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) else: rows.append(row) renamed = names != columns - if not touched and not renamed: + if not touched and not renamed and not edited: return snapshot, [] types = {key(k): v for k, v in snapshot["column_types"].items()} \ if isinstance(snapshot.get("column_types"), dict) else {} @@ -1745,12 +1758,14 @@ async def _source_kinds(names: Iterable[str]) -> dict[str, str | None]: async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], given: list[str | None], - shown: list[tuple[str, str, str]] | None = None, live: dict[str, list[str]] | None = None) -> _Plan: + shown: list[tuple[str, str, str]] | None = None, live: dict[str, list[str]] | None = None, *, + embedded: frozenset[str] = frozenset()) -> _Plan: """own:agent 自己的查询快照(工件 id → 内容);others:报告自己的查询快照(被问的那句可能直接写着遮罩列的值, agent 会照着复述、写进查询条件);given:问题、选中的原文、报告里列出的指标和运行输入的值(都已经摆在读者眼前, 里面的值哪里都不换);shown:agent 执行的查询,(SQL, 数据源名, 快照工件 id)(照原样留在 SQL 里的阈值、LIMIT, 回答正文里不换,见 _Secrets.show;取质询工件不用:快照里的格子照旧按值遮);live:用得上的数据源现在设的遮罩列 - (快照没取到的查询按它认 SQL 里的比较,见 _compared)。 + (快照没取到的查询按它认 SQL 里的比较,见 _compared);embedded:这几份快照的文字格里夹着的遮罩值也按子串换 + (只给要取的那份质询工件,见 _masked_view)。 查数据源的遮罩要读库,在这里读;解析 SQL、收上万个值、逐格比对是纯计算,放进线程:放在事件循环里会挡住同一进程 里别的运行(同 tools/datasource 的 _sql_checks)。""" @@ -1762,12 +1777,13 @@ async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], gi based = [(snapshot, (await _hidden_columns(snapshot, masks))[0]) for snapshot in others] kinds = await _source_kinds([*(str(s.get("source") or "") for s in [*own.values(), *others]), *(name for _, name, _ in shown)]) - return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given, shown, live or {}) + return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given, shown, live or {}, embedded) def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str], list[str]]], based: list[tuple[dict[str, Any], set[str]]], kinds: dict[str, str | None], - given: list[str | None], shown: list[tuple[str, str, str]], live: dict[str, list[str]]) -> _Plan: + given: list[str | None], shown: list[tuple[str, str, str]], live: dict[str, list[str]], + embedded: frozenset[str]) -> _Plan: secrets = _Secrets() hidden: dict[str, set[str]] = {} tainted: dict[str, set[str]] = {} @@ -1784,7 +1800,8 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] views: dict[str, dict[str, Any]] = {} masked: dict[str, list[str]] = {} for artifact, snapshot in own.items(): - views[artifact], masked[artifact] = _masked_view(snapshot, hidden[artifact], secrets) + views[artifact], masked[artifact] = _masked_view(snapshot, hidden[artifact], secrets, + embedded=artifact in embedded) return _Plan(views=views, masked=masked, tainted=tainted, kinds=kinds, secrets=secrets, gone={artifact: named[artifact][1] for artifact in own}) @@ -2135,8 +2152,8 @@ async def _report_material(run_id: str, reports: Iterable[Any]) -> tuple[list[di async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: - """质询引用过的工件:查询快照(遮罩照回答时的办法,SQL 用事件里记下的那条,两处一字不差)、回答文档(组装时就遮好 - 了)、回答里引用的报告口径卡。{id, content, masked[, mask_note]}。 + """质询引用过的工件:查询快照(遮罩照回答时的办法,另把文字格里夹着的遮罩值按子串换掉;SQL 用事件里记下的那条, + 两处一字不差)、回答文档(组装时就遮好了)、回答里引用的报告口径卡。{id, content, masked[, mask_note]}。 认的是这次运行的质询事件引用过的,加上没写进运行记录、还留在进程里的回答(_HELD)引用过的;别的一律 404。""" from app.api.evidence import SOURCE_GONE @@ -2158,7 +2175,8 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: return {"id": artifact_id, "content": content, "masked": []} # 遮罩值照回答时收:引用它的那几条质询各自查到的全部快照(查询条件里写着的值,不一定在这一份的结果里),加上报告 # 自己的查询快照;免遮的也照回答时:提问人自己写的、报告里列出的指标和运行输入的值。SQL 里照原样留着的阈值只在 - # 回答正文里不遮,格子照旧按值遮,这里不用 + # 回答正文里不遮,格子照旧按值遮,这里不用。文字格里夹着的遮罩值按子串换(embedded):回答那边渲染出来露了就整份 + # 不交,这份快照却照样记在事件里、点得开,不换就等于在「不予展示」旁边把原值摆出来 own: dict[str, dict[str, Any]] = {artifact_id: content} for data in referenced: for other in dict.fromkeys(str(a) for a in data.get("inquiry_artifacts") or []): @@ -2166,7 +2184,7 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: own[other] = snapshot others, values = await _report_material(run_id, (d.get("report") for d in referenced)) asked = [t for d in referenced for t in (d.get("question"), d.get("quote")) if isinstance(t, str)] - plan = await _plan(own, others, [*asked, *values]) + plan = await _plan(own, others, [*asked, *values], embedded=frozenset({artifact_id})) shown = plan.views[artifact_id] recorded = next((q["sql"] for d in referenced for q in d.get("queries") or [] if isinstance(q, dict) and q.get("artifact") == artifact_id and isinstance(q.get("sql"), str)), diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 811c0fa8..a2fadcf9 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -15,7 +15,8 @@ 遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码……) 也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、 和日期、天数修饰、标签里写着的同一个数也不牵连阈值;SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮, - 回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值 + 回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里 + 只换掉那一截 - 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 数据源、没固定过版本的上传表格不查 - 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 @@ -1247,6 +1248,44 @@ async def test_a_value_seen_only_in_the_reports_snapshot_is_masked_in_the_artifa await engines.invalidate(source_id) +#: 另有一张回访记录:备注里写着手机号,备注这一列没设遮罩 +CALLBACKS = SALES + ("CREATE TABLE callbacks (id INTEGER PRIMARY KEY, region TEXT, note TEXT);" + "INSERT INTO callbacks VALUES (1, '华东', '客户13900000001来电改址'), (2, '华南', '例行回访');") +#: 回答中含有遮罩值、整份不交时给提问人的说法 +LEAKED = "回答中含有设了遮罩的值,无法遮住,已不予展示,请换个问法重新提问" + + +async def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact(monkeypatch, tmp_path, client): + """备注里写着手机号(备注没设遮罩,手机号设了遮罩、被报告取数选中):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) + try: + report_sql = "SELECT buyer_phone, amount AS 金额 FROM sales ORDER BY amount DESC" + paused = await pause(name, graph=fetching(name, report_sql)) + [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"]["text"], item["answer"]["doc_artifact"]) == (False, "", None) + assert item["answer"]["error"] == LEAKED + [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 + 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)," diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index ecc76532..f9996d29 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -14,7 +14,8 @@ 两个选出列、UNION 两支、WHERE 和 HAVING 各写一边共用的边界是阈值;列名认不出的只和写得一样的表达式配 - 阈值只在同一个数在这条 SQL 别处确实被换掉时才不留(换不换和 SQL 里换字面量是同一套判断):日期、天数、标签里写着 同一个短的数,没设遮罩的列上的等值,都不牵连阈值 -- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换 +- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里, + 没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交) - 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去 - 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串照样按结构认,成百个分档边界看不过来的 按夹住算,也不卡 @@ -642,6 +643,32 @@ def test_an_answer_still_showing_a_masked_value_is_not_handed_out(): assert doc["markdown"] == "没有遮罩值。" +def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact_view(): + """质询工件这一路(embedded):没设遮罩的列里一格文字夹着遮罩值,把那一截换成「已遮罩」,这一格别的字、别的格子 + 照原样;这一列不记进 masked(界面会把记进去的列整列画成「已遮罩」,这一列在数据源里也没设遮罩)。门槛同回答 + 一路:短的数不按值换。回答一路不这样换,渲染出来露了就整份不交(上一条测试)。""" + secrets = secrets_of({"columns": ["phone", "age"], "rows": [["13900000001", 25]]}, {"phone", "age"}) + snapshot = {"columns": ["区域", "备注", "金额"], "column_types": {"金额": "number"}, + "rows": [["华东", "客户13900000001来电改址", 1200], ["华南", "例行回访,25 岁", 900]]} + view, masked = inquiry_mod._masked_view(snapshot, set(), secrets, embedded=True) + assert view["rows"] == [["华东", "客户已遮罩来电改址", 1200], ["华南", "例行回访,25 岁", 900]] + assert masked == [] and view["column_types"] == {"金额": "number"} + assert snapshot["rows"][0][1] == "客户13900000001来电改址", "原快照不动" + as_dicts = {**snapshot, "rows": [{"区域": "华东", "备注": "客户13900000001来电改址", "金额": 1200}]} + assert inquiry_mod._masked_view(as_dicts, set(), secrets, embedded=True)[0]["rows"] == [ + {"区域": "华东", "备注": "客户已遮罩来电改址", "金额": 1200}] + # JSON 列(对象)按界面画出来的样子换,换过的照文字给 + nested = {"columns": ["联系人"], "rows": [[{"电话": "13900000001", "年龄": 25}]]} + assert inquiry_mod._masked_view(nested, set(), secrets, embedded=True)[0]["rows"] == [ + ['{"电话": "已遮罩", "年龄": 25}']] + # 整格就是遮罩值的照旧整格换、记进 masked;按列遮的照旧 + whole = {"columns": ["联系方式", "备注"], "rows": [["13900000001", "回电 13900000001"]]} + view, masked = inquiry_mod._masked_view(whole, set(), secrets, embedded=True) + assert view["rows"] == [[MASKED, "回电 已遮罩"]] and masked == ["联系方式"] + # 回答一路照旧:文字格不动,交给最后一道关 + assert inquiry_mod._masked_view(snapshot, set(), secrets)[0] is snapshot + + def test_report_values_that_happen_to_be_masked_values_are_shown(): """报告口径卡里的指标恰好和遮罩列里的某个值一样(单笔目标 20000,金额也有 20000 的单):报告里本来就看得到, 引用它的回答照常交出去。""" From f0e97f614fa719daae65f931353091b0669f35a7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:30:52 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E8=B4=A8=E8=AF=A2=E5=B7=A5=E4=BB=B6=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20SQL=20=E6=A3=80=E6=9F=A5=E7=BB=93=E6=9E=9C=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E7=85=A7=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=EF=BC=8C?= =?UTF-8?q?=E7=89=87=E6=AE=B5=E9=87=8C=E7=9A=84=E9=81=AE=E7=BD=A9=E5=80=BC?= =?UTF-8?q?=E7=85=A7=20SQL=20=E6=8D=A2=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 做项 3 时发现的同一类缺口:agent 的查询快照带着 SQL 检查结果(数据源查询工具对照数据目录查出的问题,checks),质询工件接口 原样带出——sql_excerpt 是从这条 SQL 摘的片段(按遮罩列筛选的那个条件,例如 buyer_phone = '13900000001'),for_model 是给模型 的改法、也照抄了片段。事件和工件里的 content.sql 已经换掉遮罩值,旁边的片段没换,等于从旁边把值露出来;证据面板对报告快照 本来就只给约定的几个字段、不给 for_model。 - inquiry_artifact 里的 checks 经 _masked_checks:字段照证据面板的 _query_checks(不交 for_model),sql_excerpt 照查询清单的 SQL 换(_scrub_sql,和遮罩列做等值比较的短的数、日期这样的字也认得出) - 界面不读 checks,只是接口上不再带原值 - 补单元测试和端到端测试(把 SQL 检查换成会摘出遮罩值片段的假检查)。做过去掉实现、测试变红的验证 --- backend/app/api/inquiry.py | 2 +- backend/app/engine/inquiry.py | 18 +++++++++++++--- backend/tests/test_inquiry_api.py | 31 ++++++++++++++++++++++++++- backend/tests/test_inquiry_masking.py | 19 +++++++++++++++- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/backend/app/api/inquiry.py b/backend/app/api/inquiry.py index f3353398..1c8c01f4 100644 --- a/backend/app/api/inquiry.py +++ b/backend/app/api/inquiry.py @@ -6,7 +6,7 @@ 查询快照(queries[].artifact)照回答时的办法遮好:遮罩列(含起了别名、套了函数的)整列遮,别的列里整格是遮罩值的 格子也遮,masked 是有格子整格被遮掉的列名(界面把这几列整列画成「已遮罩」);一格文字里夹着遮罩值的(备注里写着 手机号),只把那一截换成「已遮罩」,这一列不记进 masked。content.sql 就是事件里记下的那条(遮罩值已换掉),两处 - 一字不差。回答文档 + 一字不差;content.checks(SQL 检查结果)的字段照证据面板,片段 sql_excerpt 里的遮罩值照 SQL 换掉。回答文档 (answer.doc_artifact)组装时就遮好了,原样给;回答里引用的报告指标(doc.catalog 里 m: 条目的 artifact,口径卡) 也从这里取,界面一律按 doc.catalog[alias].artifact 取。没写进运行记录的回答(recorded 为假)在本进程里留一小时, 这段时间里它引用的工件照样能取;之后、或者服务重启过,回 404。别的工件一律 404 diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index b6b8b9d7..b8944dcd 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -2189,18 +2189,30 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: recorded = next((q["sql"] for d in referenced for q in d.get("queries") or [] if isinstance(q, dict) and q.get("artifact") == artifact_id and isinstance(q.get("sql"), str)), None) + compared, kind = plan.tainted.get(artifact_id, set()), plan.kinds.get(str(content.get("source") or "")) if recorded is not None: shown = {**shown, "sql": recorded} elif isinstance(shown.get("sql"), str): - name = str(content.get("source") or "") - shown = {**shown, "sql": _scrub_sql(shown["sql"], plan.secrets, plan.tainted.get(artifact_id, set()), - plan.kinds.get(name))} + 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)} 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]]: + """快照里记下的 SQL 检查结果(数据源查询工具对照数据目录查出的问题)交给质询工件的样子:字段照证据面板 + (_query_checks),给模型的那句改法不交出去——它照抄了 SQL 片段;SQL 片段(sql_excerpt)摘自同一条 SQL,照查询 + 清单的 SQL 换掉遮罩值(_scrub_sql):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})] + + __all__ = [ "BUSY_CONTINUE", "BUSY_DECIDE", "INQUIRY_BUSY", "INQUIRY_DISABLED", "INQUIRY_NOT_OPEN", "INQUIRY_SCHEMA", "INQUIRY_SYSTEM", "NOT_RECORDED", "QUESTION_MAX", "QUOTE_MAX", "Ask", "InquiryRefused", "ask", "inquiry_artifact", diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index a2fadcf9..586c6bea 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -16,7 +16,7 @@ 也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、 和日期、天数修饰、标签里写着的同一个数也不牵连阈值;SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮, 回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里 - 只换掉那一截 + 只换掉那一截;质询工件里 SQL 检查结果的片段照 SQL 换,给模型的那句改法不交出去 - 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 数据源、没固定过版本的上传表格不查 - 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 @@ -1286,6 +1286,35 @@ async def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact(monk 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 + 换掉遮罩值,给模型的那句改法照抄了片段,不交出去(同证据面板)。""" + from app.tools import datasource + + excerpt = "buyer_phone = '13900000001'" + + async def flagged(checker, sql): + return [{"code": "wrong_date_column", "level": "warning", "table": "sales", "column": "buyer_phone", + "message": "查询按「买家手机号」筛选", "for_model": f"这条 SQL 按 {excerpt} 筛选", + "sql_excerpt": excerpt}] if excerpt in sql else [] + + monkeypatch.setattr(datasource, "_sql_checks", flagged) + plays = {"买得最多": Play([TOP_BUYER, BUYER_ORDERS], "这位客户共 [[v:Q2.r0.笔数]] 笔。[[see:Q2]]")} + Models(monkeypatch, masked_shop, plays=plays) + 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 + _, orders = r.json()["inquiry"]["queries"] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{orders['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["checks"] == [ + {"code": "wrong_date_column", "level": "warning", "table": "sales", "column": "buyer_phone", + "message": "查询按「买家手机号」筛选", "sql_excerpt": "buyer_phone = '已遮罩'"}] + assert got.json()["content"]["sql"] == orders["sql"] and "1390000000" not in got.text + + #: 同样的四笔订单,手机号存成整数:遮罩值在 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)," diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index f9996d29..1bc8942a 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -15,7 +15,7 @@ - 阈值只在同一个数在这条 SQL 别处确实被换掉时才不留(换不换和 SQL 里换字面量是同一套判断):日期、天数、标签里写着 同一个短的数,没设遮罩的列上的等值,都不牵连阈值 - 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里, - 没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交) + 没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段照 SQL 换 - 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去 - 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串照样按结构认,成百个分档边界看不过来的 按夹住算,也不卡 @@ -669,6 +669,23 @@ def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact_view(): assert inquiry_mod._masked_view(snapshot, set(), secrets)[0] is snapshot +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"}) + 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") == [ + {"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)"}] + + def test_report_values_that_happen_to_be_masked_values_are_shown(): """报告口径卡里的指标恰好和遮罩列里的某个值一样(单笔目标 20000,金额也有 20000 的单):报告里本来就看得到, 引用它的回答照常交出去。""" From 8ca750d7ee0766e1db8210ba30a3e3d361afcac3 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:32:48 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=9A=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E8=B7=9F=E4=B8=8A=E5=AE=9E=E7=8E=B0=E2=80=94=E2=80=94=E3=80=8C?= =?UTF-8?q?=E5=A4=B9=E4=BD=8F=E3=80=8D=E8=B6=8A=E8=BF=87=E7=9A=84=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E8=AF=8D=E5=90=AB=E5=BC=82=E6=88=96=E3=80=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E9=95=BF=E5=88=B0=E7=BB=86=E7=9C=8B=E4=B8=8D=E8=BF=87?= =?UTF-8?q?=E6=9D=A5=E6=97=B6=E5=A4=9A=E9=81=AE=E5=87=A0=E4=B8=AA=E9=98=88?= =?UTF-8?q?=E5=80=BC=E5=86=99=E8=BF=9B=E5=B7=B2=E7=9F=A5=E5=B1=80=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模块说明的已知局限补一句:条件长到细看不过来(PIN_WORK)时,两边都隔开的一律按夹住算,会多遮几个阈值 - 测试说明对齐:同一个条件里夹住的那组用例越过的连接词含异或(XOR 用例),HAVING 的用例已挪到自己那条测试;年龄段那条 补上说明 --- backend/app/engine/inquiry.py | 3 ++- backend/tests/test_inquiry_masking.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index b8944dcd..895cb9e3 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -40,7 +40,8 @@ 安全,宁可多遮;数个数要用 COUNT。按遮罩列分档的 CASE(CASE WHEN age < 30 THEN '30岁以下' …)同理,标签列整列遮, 标签文字也成了遮罩值,SQL 和回答正文里写着的同样的字一并换掉(换掉的标签不是数,不牵连阈值) - 「夹住某一个值」只在同一个条件里认(_pinned):把找值拆进子查询、CTE 的写法(WHERE age >= 25 AND id IN - (SELECT id FROM t WHERE age <= 25))认不出,两个边界都当阈值照原样留着 + (SELECT id FROM t WHERE age <= 25))认不出,两个边界都当阈值照原样留着;反过来,条件长到细看不过来(PIN_WORK)时, + 两边都隔开的一律按夹住算,会多遮几个阈值 - 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧) """ from __future__ import annotations diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 1bc8942a..365d9a8e 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -356,8 +356,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 或 HAVING 里、整数的开区间。""" + """「夹住某一个值」只在同一个条件里认(从比较往上只越过且、或、非、异或、括号),同一个条件里夹住的照样换:旁边 + 还有别的条件、写在 CASE WHEN 里、整数的开区间、XOR。""" assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == shown @@ -411,6 +411,7 @@ def ages() -> inquiry_mod._Secrets: "COUNT(*) AS 人数 FROM sales GROUP BY 1", {"18", "30", "45"}), ]) def test_overlapping_age_bands_are_thresholds(ages, sql, shown): + """年龄段的两个 WHEN 分支共用边界 30:同上,是分档的界。""" assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == shown From 898bd8a276eff019c11f294d2f492b830ca74f17 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:34:55 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=9A=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=92=8C=E5=8F=96=E8=B4=A8=E8=AF=A2=E5=B7=A5?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E8=AF=B4=E6=98=8E=E5=86=99=E4=B8=8A=20SQL=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=BB=93=E6=9E=9C=E7=9A=84=E7=89=87=E6=AE=B5?= =?UTF-8?q?=E7=85=A7=20SQL=20=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一处改动(f0e97f6)只在接口说明和 _masked_checks 里写了,模块说明的「读取」一条和 inquiry_artifact 的说明补上,免得读的人以为工件里的 checks 原样给。 --- backend/app/engine/inquiry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 895cb9e3..404038cc 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -26,7 +26,7 @@ append_events 不写,回答照样交回给提问的人,说明没有写进运行记录(这样的回答在进程里留一阵,_HELD)。触顶、失败 照样记一条 5. 读取:列表读 approval.inquiry 事件(封存前后的都算,按先后);质询工件只认质询引用过的(查询快照、回答文档、回答里 - 引用的报告口径卡),遮罩照回答时的办法 + 引用的报告口径卡),遮罩照回答时的办法,快照里 SQL 检查结果的片段照 SQL 换(_masked_checks) 不发 tool.start、llm.start 这类事件(run_verify_agent 不传 emit):界面从接口的返回取回答;runner 的 _stale_replay 只看 tool.start,质询不影响恢复后的重放。 @@ -2154,7 +2154,8 @@ async def _report_material(run_id: str, reports: Iterable[Any]) -> tuple[list[di async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: """质询引用过的工件:查询快照(遮罩照回答时的办法,另把文字格里夹着的遮罩值按子串换掉;SQL 用事件里记下的那条, - 两处一字不差)、回答文档(组装时就遮好了)、回答里引用的报告口径卡。{id, content, masked[, mask_note]}。 + 两处一字不差;SQL 检查结果见 _masked_checks)、回答文档(组装时就遮好了)、回答里引用的报告口径卡。 + {id, content, masked[, mask_note]}。 认的是这次运行的质询事件引用过的,加上没写进运行记录、还留在进程里的回答(_HELD)引用过的;别的一律 404。""" from app.api.evidence import SOURCE_GONE From dde09274607b0134e6a9afb8de886fd4a2b22513 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 14:05:52 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=88=E7=AC=AC=201=20?= =?UTF-8?q?=E8=BD=AE=E5=A4=8D=E6=A0=B8=EF=BC=89=EF=BC=9A=E3=80=8C=E5=A4=B9?= =?UTF-8?q?=E4=BD=8F=E6=9F=90=E4=B8=80=E4=B8=AA=E5=80=BC=E3=80=8D=E5=9C=A8?= =?UTF-8?q?=E6=95=B4=E6=9D=A1=20SQL=20=E9=87=8C=E8=AE=A4=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E6=9C=89=E5=88=86=E6=A1=A3=E7=9A=84=E5=87=A0=E7=A7=8D=E5=86=99?= =?UTF-8?q?=E6=B3=95=E5=90=84=E7=AE=97=E5=90=84=E7=9A=84=EF=BC=9B=E5=88=86?= =?UTF-8?q?=E6=A1=A3=E6=A0=87=E7=AD=BE=E4=B8=8D=E5=BD=93=E9=81=AE=E7=BD=A9?= =?UTF-8?q?=E5=80=BC=EF=BC=9BSQL=20=E6=A3=80=E6=9F=A5=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E7=9A=84=E7=89=87=E6=AE=B5=E3=80=81=E8=AF=B4=E6=98=8E=E7=85=A7?= =?UTF-8?q?=E6=95=B4=E6=9D=A1=20SQL=20=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 夹住:上一轮只在同一个条件里配对,把找值拆到 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、分组标签、检查结果片段与说明;每处去掉实现都验证过变红 --- backend/app/api/inquiry.py | 3 +- backend/app/engine/inquiry.py | 496 ++++++++++++++++++++------ backend/tests/test_inquiry_api.py | 117 ++++-- backend/tests/test_inquiry_masking.py | 276 ++++++++++++-- 4 files changed, 747 insertions(+), 145 deletions(-) diff --git a/backend/app/api/inquiry.py b/backend/app/api/inquiry.py index 1c8c01f4..ced4d858 100644 --- a/backend/app/api/inquiry.py +++ b/backend/app/api/inquiry.py @@ -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 diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 404038cc..93327002 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -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__ = [ diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 586c6bea..da36b04a 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -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 …)、两头相同的范围、行值、选出列里写着同一个号码……),回答里 diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 365d9a8e..db6cd2a4 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -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 的单):报告里本来就看得到, 引用它的回答照常交出去。""" From 5d4d1fdc153e20de12999ea585f5bfd6bcec826b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 15:08:56 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=88=E7=AC=AC=202=20?= =?UTF-8?q?=E8=BD=AE=E5=A4=8D=E6=A0=B8=EF=BC=89=EF=BC=9A=E7=AE=97=E5=BC=8F?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E3=80=81=E5=A5=97=E4=BA=86=20CAST=20/=20?= =?UTF-8?q?=E6=B1=87=E6=80=BB=E7=9A=84=E5=88=97=E3=80=81=E6=8D=A2=E4=B8=AA?= =?UTF-8?q?=E5=86=99=E6=B3=95=E7=9A=84=E6=95=B0=E3=80=81=E4=B8=A4=E5=A4=B4?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E6=95=B4=E6=95=B0=E3=80=81=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E5=86=8D=E6=8E=92=E9=99=A4=E4=B8=80=E5=A4=B4=E9=83=BD=E8=AE=A4?= =?UTF-8?q?=E5=BE=97=E5=87=BA=EF=BC=9B=E5=8F=AA=E8=A3=85=E7=9D=80=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=9A=84=E5=88=97=E6=89=8D=E4=B8=8D=E6=94=B6=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=EF=BC=9B=E6=88=AA=E6=96=AD=E7=89=87=E6=AE=B5=E6=95=B4?= =?UTF-8?q?=E6=AE=B5=E5=8E=BB=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核留下的三条 major 和两条次要问题: - 范围的一头是算式(age <= 25 + 0、buyer_phone BETWEEN 13900000000 + 1 AND 13900000009 - 8):夹住的值认得出,算式里的数 却照原样留着,一算就是那个号码。现在几个写死的数的加减乘除按算出来的那一个值认(_lifts):夹住某一个值的边界、等值、 IN 里的算式,里面的数都换;选出列里算出来的号码按值换;没夹住的算式阈值和写成一个数的阈值一样照原样,正文里复述它 (连同算出来的值)不遮,算出来的值在别处换掉了也不留。原有两条用例(BETWEEN 13900000001 AND 13900000002 - 1、 … AND 13900000001 + 0)的期望随之改成算式里的数都换(多换一个 1、0,号码照样不露) - 几种找值写法以前认不出:被比较的一边套了 CAST、汇总(CAST(age AS INTEGER) = 25、HAVING MAX(age) BETWEEN 25 AND 25) 改按它的值取自哪些列认(_columns_of);同一个数换个写法(25.0、025、2.5e1、013900000001)按规范写法和遮罩值比 (_canonical);两头不是整数的范围(age > 24.5 AND age < 25.5、BETWEEN 25 AND 25.9)在整数上隔开紧挨着的两个整数; 范围再用不等、NOT IN 去掉一头(buyer_phone >= 13900000001 AND buyer_phone <= 13900000002 AND buyer_phone <> 13900000002, 以前号码被当成阈值、回答正文跟着不遮):等值一类只在同一个条件里和范围合起来看,整数上紧挨着的被排除的数越过去接着 看(age <= 30 时 27 照样成立,不算夹住),跨条件配对仍只看范围比较,不等、IN 单拿出来不当「写出了这个值」 - 算式求值改成不递归(_constants):visits <= 1+1+…写到 1500 项时以前超过递归深度,整条 SQL 退回按词认,同一句里夹住的 年龄照原样封存 - 分档标签不收进遮罩值的规则太宽(第 1 轮引入):改了名字的遮罩列(vip_level AS 等级)的 1、2、3 撞上同一条 SQL 里按 条件数个数的 THEN 1 ELSE 0,1 就不算遮罩值,WHERE vip_level = 1 照原样封存。现在只跳过只装着标签的列的格子 (_label_columns:选出项本身是结果全为常量的 CASE / IIF,或者经别名、子查询、CTE 原样传下去的,没起别名的按位置认; 同一个名字在哪一处装着别的就照收) - SQL 检查结果的说明:码值表里的码值和别的字一样按值换,说明照实写(多遮,不漏),补测试钉住 - 截断的片段停在带空格的遮罩文字值中间(地址):以前只去掉最后一段,前半截还留着;现在从没闭合的引号、注释起整个去掉 - 模块说明的已知局限照实更新:两个产品取舍保持;夹住的认法补上不等、NOT IN、两头不是整数和越过排除数的规则;新增一条 列出认得出的找值写法和仍认不出的(值藏进和列一起算的算式、拼接、函数拼出来的值) 测试:单元补算式边界、套了 CAST / 汇总、换个写法、两头不是整数、排除一头、不该夹住的几种(范围加不等仍不只一个值、 IN 列表旁的阈值、选出列里的等值和 WHERE 的范围)、上千项连加、改了名字的遮罩列撞上标签、标签列经子查询 / CTE / 没起 别名、截断片段、码值表;端到端补长号码的五种写法(算出来的边界、算出来的等值、两头不是整数、范围再排除一头、补零)、 年龄的六种写法和改了名字的等级列。每处实现都逐个去掉验证过测试变红 --- backend/app/engine/inquiry.py | 703 ++++++++++++++++++-------- backend/tests/test_inquiry_api.py | 113 ++++- backend/tests/test_inquiry_masking.py | 206 +++++++- 3 files changed, 800 insertions(+), 222 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 93327002..bcf3400f 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -16,11 +16,12 @@ (起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉;按遮罩列 分档、当分组键或套在汇总里数个数的不算,_band_only);快照按列遮,再按值遮(别的列里写着遮罩值的格子,_masked_view;质询 工件里,文字格里夹着的遮罩值另按子串换)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql): - 按值认有门槛,agent 写在 SQL 里的分档标签不算遮罩值(_written_labels);SQL 里的字面量按语法树认用在哪 - (_literal_uses),数字照原样留着的只有范围比较的边界(没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且同一个数 - 在这条 SQL 别处没被换掉,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE、夹住某一个值的范围……)和 - 别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样 - 留着的阈值只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的,这份回答不交出去 + 按值认有门槛,只装着分档标签的列(agent 写在 SQL 里的标签)不收进遮罩值(_label_columns);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 下:证据接口按那个键 收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存), @@ -42,16 +43,27 @@ 汇总里套着结果全是写死常量的 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),超出之后两边都隔开的一律按夹住算,分档很多时会成批多遮 + 在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收 + (选出项本身就是这样的 CASE / IIF,或者经别名、子查询原样传下去的),同一个名字在哪一处装着别的就照收;标签恰好和 + 别的列里的遮罩值相同时,正文里的档名、SQL 里写成字符串的标签也换(多遮) +- 「夹住某一个值」(_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)、两头不是整数的(col > v - 0.5 AND col < v + 0.5)只在整个条件只认 v 时算,被 + 更宽的范围吸收、和相邻的分档连成一段的不算。不等、NOT IN 只在同一个条件里和范围合起来看(age >= 25 AND age <= 26 + AND age <> 26 只认 25;age <= 30 时 27 也成立,不算),拆进别的条件(子查询、HAVING)认不出;整数上紧挨着 v 的一串 + 被排除的数最多越过 PAIR_MAX 个,再多就按夹住算(多遮)。细看的步数有上限(PIN_WORK),超出之后两边都有范围比较 + 隔开的一律按夹住算,分档很多时会成批多遮 +- 找某一个值的写法按语法树认常见的几种:等值、IN、简单 CASE、把它夹住的范围,被比较的一边套了 CAST、函数、汇总 + (CAST(age AS INTEGER) = 25、HAVING MAX(age) BETWEEN 25 AND 25),值换个写法(25.0、025、2.5e1)、写成几个数的 + 算式(13900000000 + 1、BETWEEN 20 + 5 AND 30 - 5,算式里的数都换)。更绕的写法认不出:值藏进和列一起算的算式 + (age - 25 = 0、buyer_phone - 1 = 13900000000)、拼接('1390' || '0000001')、函数拼出来的值(REPLACE、SUBSTR), + 短的值照原样封存,长号码只在整个写出来时按值换 - 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在 - 片段里别处出现也换(多遮);截断处半截的值去掉 + 片段里别处出现也换(多遮,算式里换掉的 0、1 这类也是);截断处半截的值去掉(停在字符串、带引号的名字、注释里的, + 从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮) - 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照记在这次运行名下(同报告自己的查询快照),从 那里打开不经这里的遮罩 - 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧) @@ -70,7 +82,7 @@ from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, field from datetime import datetime, timezone -from decimal import Decimal +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from typing import Any from sqlalchemy import func, select @@ -735,10 +747,14 @@ def _band_only(node: Any) -> bool: return False -def _written_labels(sql: Any, kind: str | None) -> set[str]: - """SQL 里 CASE、IIF 写死的结果(分档的标签、档位编号)在格子里的写法。它们是 agent 自己写在 SQL 里的字,不是数据源 - 里的值:派生列里出现了也不当遮罩值(_Secrets.add),不然 SQL 里的标签、回答正文里的档名都会被换成「已遮罩」。 - 解析不了的一个都不算。""" +def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, set[str]]: + """结果里只装着分档标签的列:列名(小写)→ 标签在格子里的写法。标签(CASE、IIF 写死的结果,分档的名字、档位编号) + 是 agent 自己写在 SQL 里的字,不是数据源里的值:这几列派生自遮罩列也不收进遮罩值(_Secrets.add),不然 SQL 里的 + 标签、回答正文里的档名都会被换成「已遮罩」。 + 只装着标签:这一列的选出项本身是结果全为写死常量的 CASE / IIF,或者经别名、子查询、CTE 原样传下去的这样一列 + (_label_texts);同一个名字在哪一处装着别的(vip_level AS 等级、UNION 另一支取的是遮罩列),就不算——改了名字的 + 遮罩列的 1、2、3 撞上同一条 SQL 里按条件数个数的 THEN 1 ELSE 0,照样是遮罩值。没起别名的标签列按位置认(每一支的 + 那一项都是标签)。解析不了的一个都不算(标签照旧当遮罩值,宁可多遮)。""" from sqlglot import exp from app.data.sql_semantics import _parse, _read @@ -748,16 +764,69 @@ def _written_labels(sql: Any, kind: str | None) -> set[str]: 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 []: + return {} + try: + items = [item for select in root.find_all(exp.Select) for item in select.expressions] + # 先当每个名字都只装着标签,再去掉装着别的,直到不再变:名字一层层往下传,先认哪一层说不准 + known: dict[str, set[str]] = {name: set() for item in items if (name := _output_name(item))} + for _ in range(len(items) + 2): + found: dict[str, set[str]] = {} + spoiled: set[str] = set() + for item in items: + if not (name := _output_name(item)): + continue + texts = _label_texts(item, known) + if texts is None: + spoiled.add(name) + else: + found.setdefault(name, set()).update(texts) + found = {name: texts for name, texts in found.items() if name not in spoiled} + if found == known: + break + known = found + out = {c.lower(): known[c.lower()] for c in columns if c.lower() in known} + width = len(columns) + for top, role, _listed in _blocks(root): + if role != "result": + continue + branches = _branches(top) + seen: dict[int, list[set[str] | None]] = {} + for branch in branches: + for item, place in zip(branch, _places(branch, width)): + if place is not None and place < width: + seen.setdefault(place, []).append(_label_texts(item, known)) + for place, texts in seen.items(): + if len(texts) == len(branches) and all(t is not None for t in texts): + out.setdefault(columns[place].lower(), set()).update(*texts) + return out + except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:一个都不算(宁可多遮) + logger.warning("质询的 SQL 标签列分析失败", exc_info=True) + return {} + + +def _label_texts(item: Any, known: dict[str, set[str]]) -> set[str] | None: + """一个选出项只装着标签时,标签在格子里的写法:结果全为写死常量的 CASE / IIF 取它的各个结果(数按两种写法:25、 + 25.0),单独一列、名字在 known 里(经别名、子查询传下来的标签列)取那一列的;不是为 None。""" + from sqlglot import exp + + node = item + while isinstance(node, (exp.Alias, exp.Paren)): + node = node.this + results = _branch_results(node) + if results is not None: + if not all(_fixed(r) for r in results): + return None + out: set[str] = set() + for result in results: 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 + return out + if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) and node.name.lower() in known: + return known[node.name.lower()] + return None def _is_star(item: Any) -> bool: @@ -1029,13 +1098,14 @@ 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], *, labels: Iterable[str] = (), base: Iterable[str] = ()) -> None: - """收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照的 SQL 里写死的分档标签(_written_labels):派生 - 列(hidden 里不在 base 的,base 是数据源设了遮罩的列)里的标签不收,遮罩列自己选出来的值照收。""" + def add(self, snapshot: Any, hidden: set[str], *, labels: dict[str, set[str]] | None = None, + base: Iterable[str] = ()) -> None: + """收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照里只装着分档标签的列(_label_columns):这几列的 + 格子等于标签的不收;改了名字的遮罩列、别的派生列照收,base(数据源设了遮罩的列,小写)自己选出来的值也照收。""" if not _query_shaped(snapshot) or not hidden: return - written = set(labels) - derived = set(hidden) - set(base) if written else set() + own = set(base) + written = {name: texts for name, texts in (labels or {}).items() if name not in own} 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"]: @@ -1051,7 +1121,7 @@ class _Secrets: if value is None or isinstance(value, bool): continue forms = _forms(value) - if name in derived and forms & written: + if forms & written.get(name, set()): continue for form in forms: self.weak.add(form) @@ -1259,8 +1329,8 @@ _LEADS = frozenset({"DASH", "PLUS", "INTRODUCER"}) _VALUE_ENDS = frozenset({"NUMBER", "VAR", "IDENTIFIER", "R_PAREN", "R_BRACKET", "NULL", "TRUE", "FALSE"}) #: 数字照原样留在 SQL 里的两种用法(_literal_uses):范围比较的边界、LIMIT 这类的行数 _KEPT = frozenset({"range", "limit"}) -#: 字面量写在左边时运算符翻过来:25 < age 就是 age > 25 -_FLIP = {">": "<", ">=": "<=", "<": ">", "<=": ">="} +#: 字面量写在左边时运算符翻过来:25 < age 就是 age > 25(等值一类翻过来还是它自己) +_FLIP = {">": "<", ">=": "<=", "<": ">", "<=": ">=", "=": "=", "<>": "<>"} #: 一个纯粹的数(可带正负号、小数):范围边界按数比大小,写成字符串的也一样 _PLAIN_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?") @@ -1321,25 +1391,51 @@ def _column_of(node: Any, index: int | None) -> str | None: """比较另一边的列名(小写、不带表名);另一边是行值时取第 index 项。不是单独一列(函数、算式、子查询)时为 None。""" from sqlglot import exp - if index is not None and isinstance(node, exp.Tuple): - items = node.expressions - node = items[index] if index < len(items) else None - while isinstance(node, exp.Paren): - node = node.this + node = _item_of(node, index) if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star): return node.name.lower() or None return None -def _use_of(node: Any) -> tuple[str, str | None]: - """语法树里一个字面量用在哪:(用法, 列名),用法见 _literal_uses。 - - 先往上越过不改变「找的是哪个值」的外壳:括号、负号、CAST、字符集前缀、COLLATE;行值、VALUES、ARRAY、ANY / ALL - 也越过,记下「在一组值里」(行值另记下是第几项)。再看外壳的上一层:等值一类、IN、简单 CASE 的 WHEN 是在找这个值; - 范围比较、LIMIT 一类只认单独一个值(一组值里的、算式里的都不算)。""" +def _item_of(node: Any, index: int | None) -> Any: + """比较的另一边去掉括号;是行值时取第 index 项(没有这一项为 None)。""" from sqlglot import exp - child, parent = node, node.parent + if index is not None and isinstance(node, exp.Tuple): + items = node.expressions + node = items[index] if index < len(items) else None + while isinstance(node, exp.Paren): + node = node.this + return node + + +def _columns_of(node: Any, index: int | None) -> frozenset[str]: + """比较另一边的值取自哪些列(小写、不带表名):单独一列就是它;套了 CAST、函数、汇总、算式的,按值取自哪些列算 + (_value_columns:CAST(age AS INTEGER)、MAX(age) 取的还是年龄;COUNT 只数个数,不算)。和遮罩列比较的字面量, + 短的数也按遮罩列里的值认(_swap_literal)。""" + from sqlglot import exp + + node = _item_of(node, index) + if node is None: + return frozenset() + if isinstance(node, exp.Column): + name = None if isinstance(node.this, exp.Star) else node.name.lower() + return frozenset({name} if name else ()) + return frozenset(_value_columns(node)) + + +def _use_of(node: Any, values: dict[int, Any], lifts: dict[int, Any]) -> tuple[str, frozenset[str], Any]: + """语法树里一个字面量用在哪:(用法, 被比较的一边取自哪些列, 算出来的值),用法见 _literal_uses。 + + 先往上越过不改变「找的是哪个值」的外壳:括号、负号、CAST、字符集前缀、COLLATE,以及几个写死的数的加减乘除—— + 整个算式按算出来的那一个值认(_lifts,第三项交回走到的那一层的值,什么都没越过为 None);行值、VALUES、ARRAY、ANY / ALL 也 + 越过,记下「在一组值里」(行值另记下是第几项)。再看外壳的上一层:等值一类、IN、简单 CASE 的 WHEN 是在找这个值; + 范围比较、LIMIT 一类只认单独一个值(一组值里的、和列一起算的算式里的都不算)。""" + from sqlglot import exp + + top = lifts.get(id(node), node) + computed = values.get(id(top)) if top is not node else None + child, parent = top, top.parent index, grouped = None, False while parent is not None: if isinstance(parent, exp.Tuple): @@ -1356,23 +1452,23 @@ def _use_of(node: Any) -> tuple[str, str | None]: child, parent = parent, parent.parent if isinstance(parent, (exp.EQ, exp.NEQ, exp.NullSafeEQ, exp.NullSafeNEQ, exp.Is, exp.Like, exp.ILike, exp.Glob, exp.RegexpLike, exp.SimilarTo)): - return "equal", _column_of(parent.expression if child is parent.this else parent.this, index) + return "equal", _columns_of(parent.expression if child is parent.this else parent.this, index), computed if isinstance(parent, exp.In): - return "equal", None if child is parent.this else _column_of(parent.this, index) + return "equal", frozenset() if child is parent.this else _columns_of(parent.this, index), computed if isinstance(parent, exp.If) and child is parent.this and isinstance(parent.parent, exp.Case) \ and parent.parent.this is not None: # 简单 CASE:CASE buyer_phone WHEN 13900000001 THEN …(CASE WHEN col = v 的写法走上面的等值) - return "equal", _column_of(parent.parent.this, index) + return "equal", _columns_of(parent.parent.this, index), computed if grouped: - return "other", None + return "other", frozenset(), computed if type(parent) in _range_ops() and (child is parent.this or child is parent.expression): - return "range", _column_of(parent.expression if child is parent.this else parent.this, None) + return "range", _columns_of(parent.expression if child is parent.this else parent.this, None), computed if isinstance(parent, exp.Between) and child is not parent.this: - return "range", _column_of(parent.this, None) + return "range", _columns_of(parent.this, None), computed if (isinstance(parent, (exp.Limit, exp.Offset)) and child is parent.expression) or ( isinstance(parent, exp.Fetch) and child is parent.args.get("count")): - return "limit", None - return "other", None + return "limit", frozenset(), computed + return "other", frozenset(), computed def _wraps(parent: Any, child: Any) -> bool: @@ -1391,71 +1487,149 @@ def _range_ops() -> dict[Any, str]: return {exp.GT: ">", exp.GTE: ">=", exp.LT: "<", exp.LTE: "<="} +def _exact_ops() -> dict[Any, str]: + """等值一类的语法树节点 → 运算符:=、IS NOT DISTINCT FROM、SQLite 的 IS 是「=」,<>、IS DISTINCT FROM 是「<>」。 + IN 另外认(一串「=」的「或」)。它们和范围比较合起来也能夹住一个值(age >= 25 AND age <= 26 AND age <> 26), + 只在同一个条件里算(_pinned)。""" + from sqlglot import exp + + return {exp.EQ: "=", exp.NullSafeEQ: "=", exp.Is: "=", exp.NEQ: "<>", exp.NullSafeNEQ: "<>"} + + +def _arithmetic() -> tuple[Any, ...]: + """加减乘除:几个写死的数算出来的整个算式按算出来的那一个值认(13900000000 + 1 就是 13900000001)。""" + from sqlglot import exp + + return exp.Add, exp.Sub, exp.Mul, exp.Div + + +#: 数的写法里指数最大多少(1e999999 这样的写法不按数认,免得展开成上百万位) +_EXPONENT_MAX = 40 + + +def _decimal(text: str) -> Decimal | None: + """一段字按数读(025、2.5e1、13900000001.0 都认);读不了、不是有限的数、指数太大为 None。""" + try: + value = Decimal(text.strip()) + except (ArithmeticError, ValueError): + return None + return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None + + +def _canonical(text: str) -> str | None: + """一个数的规范写法(_point_text):25.0、025、2.5e1 都是 25,13900000001.0 是 13900000001。SQL 里换个写法写同一个 + 数,照样认得出是不是遮罩值(_swap_literal)。不是数为 None。""" + value = _decimal(text) + return _point_text(value) if value is not None else None + + def _constant(node: Any) -> Any: - """写死的值:数按数(几个数加减乘除算出来的也算,13900000002 - 1),字符串按原文(纯粹的数照样按数);别的为 None。""" + """写死的值:数按数(几个数加减乘除算出来的也算,13900000002 - 1;025、2.5e1 这样的写法也按数),字符串按原文 + (纯粹的数照样按数);别的为 None。""" + return _constants(node).get(id(node)) if node is not None else None + + +def _constants(root: Any) -> dict[int, Any]: + """一棵语法树里每个写死的值(_constant):id(节点) → 值。从下往上一层层算(广度优先的倒序,孩子总在父亲前面), + 不递归:上千项的连加(1+1+…)会超过递归深度,以前整条 SQL 因此退回按词认,同一句里夹住的值就认不出了。""" from sqlglot import exp - if isinstance(node, exp.Neg): - value = _constant(node.this) - return -value if isinstance(value, Decimal) else None - if isinstance(node, (exp.Paren, exp.Cast, exp.Collate)): - return _constant(node.this) - if isinstance(node, exp.Introducer): - return _constant(node.expression) - if isinstance(node, _literal_types()): - text = str(node.this).strip() - return Decimal(text) if _PLAIN_NUMBER.fullmatch(text) else text - if isinstance(node, (exp.Add, exp.Sub, exp.Mul, exp.Div)): - a, b = _constant(node.this), _constant(node.expression) - if not (isinstance(a, Decimal) and isinstance(b, Decimal)): - return None - if isinstance(node, exp.Div): - return a / b if b else None - return a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b - return None - - -def _bare_literal(node: Any) -> Any: - """边界本身就是一个字面量(套了括号、负号、CAST 的也算)时交回那个字面量,否则 None。""" - from sqlglot import exp - - while node is not None and not isinstance(node, _literal_types()): - if isinstance(node, (exp.Paren, exp.Neg, exp.Cast, exp.Collate)): - node = node.this + out: dict[int, Any] = {} + for node in reversed(list(root.walk())): + value: Any = None + if isinstance(node, exp.Neg): + inner = out.get(id(node.this)) + value = -inner if isinstance(inner, Decimal) else None + elif isinstance(node, (exp.Paren, exp.Cast, exp.Collate)): + value = out.get(id(node.this)) elif isinstance(node, exp.Introducer): - node = node.expression + value = out.get(id(node.expression)) + elif isinstance(node, _literal_types()): + text = str(node.this).strip() + number = Decimal(text) if _PLAIN_NUMBER.fullmatch(text) else ( + _decimal(text) if isinstance(node, exp.Literal) and not node.is_string else None) + value = number if number is not None else text + elif isinstance(node, _arithmetic()): + value = _worked_out(node, out.get(id(node.this)), out.get(id(node.expression))) + if value is not None: + out[id(node)] = value + return out + + +def _worked_out(node: Any, a: Any, b: Any) -> Decimal | None: + """一步加减乘除的结果;不是两个数、除以零、结果太大为 None。""" + from sqlglot import exp + + if not (isinstance(a, Decimal) and isinstance(b, Decimal)): + return None + try: + if isinstance(node, exp.Div): + value = a / b if b else None else: - return None - return node + value = a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b + except ArithmeticError: + return None + return value if value is not None and value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None -#: 范围比较的一半:(字面量节点, 另一边的键, 写死的值, 「键 运算符 值」的运算符)。边界是几个数算出来的 -#: (13900000002 - 1),字面量节点记 None:它照样能夹住另一头的值,换的是另一头的字面量 -_Half = tuple[Any, str, Any, str] +def _lifts(root: Any, values: dict[int, Any]) -> dict[int, Any]: + """写死的值往上能越过的外壳和算式:id(节点) → 越过之后走到的那一层(13900000000 + 1 里的两个数都走到整个算式, + (25) 里的 25 走到括号)。先父亲后孩子(广度优先)地记,每个字面量直接跳到那一层,上千项的连加不必每个数都一层层 + 往上走。""" + out: dict[int, Any] = {} + for node in root.walk(): + if id(node) not in values: + continue + parent = node.parent + lifted = parent is not None and id(parent) in values and ( + isinstance(parent, _arithmetic()) or _wraps(parent, node)) + out[id(node)] = out[id(parent)] if lifted else node + return out -def _halves(node: Any) -> list[_Half | None]: - """一个范围比较拆成的几半:BETWEEN 是两半的「且」(不低于下界、不高于上界),> >= < <= 是一半。边界不是写死的值 - (另一列、子查询)或者两边都写死了的,这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。""" +#: 一个比较的一半:(算出边界的那几个字面量, 另一边的键, 写死的值, 「键 运算符 值」的运算符)。边界是几个数算出来的 +#: (13900000002 - 1),算式里的字面量都记下:夹住了某一个值,留着哪一个都可能算得出它,都换 +_Half = tuple[tuple[Any, ...], str, Any, str] +#: 一个比较拆成的几半,加上几半怎么连(False:且,BETWEEN;True:或,IN)、是不是等值一类(不等、IN) +_Leaf = tuple[list[_Half | None], bool, bool] + + +def _halves(node: Any, values: dict[int, Any]) -> list[_Half | None]: + """一个比较拆成的几半(values 是 _constants 算好的写死的值):BETWEEN 是两半的「且」(不低于下界、不高于上界), + > >= < <= 是一半,= <> 这类是一半,IN 是一串「=」的「或」。边界不是写死的值(另一列、子查询)或者两边都写死了的, + 这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。""" from sqlglot import exp if isinstance(node, exp.Between): sides = [(node.args.get("low"), node.this, ">="), (node.args.get("high"), node.this, "<=")] - else: - op = _range_ops()[type(node)] - right, left = _constant(node.expression), _constant(node.this) - if (right is None) == (left is None): + elif isinstance(node, exp.In): + if node.args.get("query") or node.args.get("unnest") or node.args.get("field") or id(node.this) in values: return [None] - sides = [(node.expression, node.this, op)] if right is not None else [(node.this, node.expression, _FLIP[op])] + sides = [(item, node.this, "=") for item in node.expressions] + else: + op = _op_of(node) + right, left = id(node.expression) in values, id(node.this) in values + if op is None or right == left: + return [None] + sides = [(node.expression, node.this, op)] if right else [(node.this, node.expression, _FLIP[op])] out: list[_Half | None] = [] for operand, other, op in sides: - value = _constant(operand) if operand is not None else None - out.append((_bare_literal(operand), _key_of(other), value, op) if value is not None else None) + value = values.get(id(operand)) if operand is not None else None + out.append((tuple(operand.find_all(*_literal_types())), _key_of(other), value, op) + if value is not None else None) return out +def _op_of(node: Any) -> str | None: + """范围比较、等值一类的运算符(「列 运算符 值」方向);别的为 None。""" + for kind, op in (*_range_ops().items(), *_exact_ops().items()): + if isinstance(node, kind): + return op + return None + + def _key_of(node: Any) -> str: - """范围比较另一边的键:单独一列取列名(小写、不带表名,同 _column_of);别的表达式(SUM(amount)、函数、算式)取 + """比较另一边的键:单独一列取列名(小写、不带表名,同 _column_of);别的表达式(SUM(amount)、函数、算式)取 整个写法,只和写得一样的表达式算同一个——SUM(amount) <= v 和 amount >= v 说的不是一回事。前面垫一个列名里不会 有的字,免得和列名撞上。""" from sqlglot import exp @@ -1532,51 +1706,75 @@ def _apart(a: Any, b: Any, paths: dict[int, dict[int, Any]]) -> tuple[bool, int] def _marks(value: Any, op: str) -> list[tuple[Any, int]]: """一个边界从哪边隔开了哪个值:(值, 0) 是从下面隔开(它和比它小一点的值结果不同:>= 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() - 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] + 隔开(和比它大一点的值结果不同:<= v、> v);= v、<> v 从两边隔开 v。整数上还隔开了相邻的那一个:> v、<= v 从下面 + 隔开 v + 1(v 不成立、v + 1 成立,或者反过来),< v、>= v 从上面隔开 v - 1——age <= 24 OR age >= 26 就是 + age <> 25;= v、<> v 从上面隔开 v - 1、从下面隔开 v + 1。边界不是整数的,整数上隔开的是它两边紧挨着的两个整数 + (age > 24.5 就是 age >= 25)。""" + if not isinstance(value, Decimal): + return {">=": [(value, 0)], "<=": [(value, 1)], ">": [(value, 1)], "<": [(value, 0)], + "=": [(value, 0), (value, 1)], "<>": [(value, 0), (value, 1)]}[op] + if value == value.to_integral_value(): + above, below = [(value + 1, 0)], [(value - 1, 1)] + return {">=": [(value, 0), *below], "<=": [(value, 1), *above], ">": [(value, 1), *above], + "<": [(value, 0), *below], "=": [(value, 0), (value, 1), *below, *above], + "<>": [(value, 0), (value, 1), *below, *above]}[op] + # 不是整数:整数上 = v 永远不成立、<> v 永远成立,隔不开谁 + if op in ("=", "<>"): + return [(value, 0), (value, 1)] + steps = [(value.to_integral_value(rounding=ROUND_CEILING), 0), (value.to_integral_value(rounding=ROUND_FLOOR), 1)] + return [(value, 0 if op in (">=", "<") else 1), *steps] -def _pinned(root: Any) -> dict[int, tuple[str, ...]]: - """范围比较里把某一个值夹住了的边界字面量:id(字面量节点) → 夹住的值(_point_text 的写法)。 +def _pinned(root: Any, values: dict[int, Any]) -> dict[int, tuple[str, ...]]: + """把某一个值夹住了的边界字面量:id(字面量节点) → 夹住的值(_point_text 的写法)。values 是 _constants 算好的 + 写死的值。 - 一个值 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。 - 细看、找公共祖先走的步数有上限(PIN_WORK),超出之后两边都隔开的一律按夹住算。""" + 一个值 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、两头不是整数的 + col > v - 0.5 AND col < v + 0.5 是在找 v,和等值比较一样换;范围再用不等、NOT IN 去掉一头的(age >= 25 AND + age <= 26 AND age <> 26)也是,不等、IN 只在同一个条件里和范围合起来看(跨条件配对只看范围比较:选出列里 + CASE WHEN age = 26 和 WHERE age >= 25 说不上在找谁);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)、边界是算式的 + (col <= 25 + 0),算式里的数都换,留着就等于写出了 v。 + 细看、找公共祖先走的步数有上限(PIN_WORK),超出之后两边都有范围比较隔开的一律按夹住算。""" from sqlglot import exp + exact_types = (*_exact_ops(), exp.In) 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()): + within: dict[int, dict[int, _Leaf]] = {} + # 范围比较隔开的、等值一类(不等、IN)隔开的分开记:(键, 值) → (从下面隔开的, 从上面隔开的),各按条件分组 + ranged: dict[tuple[str, Any], tuple[dict[int, list[Any]], dict[int, list[Any]]]] = {} + exact: dict[tuple[str, Any], tuple[dict[int, list[Any]], dict[int, list[Any]]]] = {} + # 同一个条件里等值一类写着的数(按条件、键):整数上细看时,紧挨着的这些数要越过去再看(_window) + nearby: dict[tuple[int, str], set[Decimal]] = {} + for node in root.find_all(exp.Between, *_range_ops(), *exact_types): + is_exact = isinstance(node, exact_types) + parts = _halves(node, values) + if is_exact and all(h is None for h in parts): + continue # 连接条件(两边都是列)这类:和夹住哪个值无关 top = _condition_root(node) 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): + within.setdefault(id(top), {})[id(node)] = (parts, isinstance(node, exp.In), is_exact) + marks = exact if is_exact else ranged + for literals, key, value, op in (h for h in parts if h is not None): + if is_exact and isinstance(value, Decimal): + nearby.setdefault((id(top), key), set()).add(value) for point, side in _marks(value, op): - sides.setdefault((key, point), ({}, {}))[side].setdefault(id(top), []).append(literal) + marks.setdefault((key, point), ({}, {}))[side].setdefault(id(top), []).extend(literals) 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 + for (key, point), (below, above) in ranged.items(): + low_exact, high_exact = exact.get((key, point), ({}, {})) marked: list[Any] = [] - # 不在同一个条件里的两边:不是各算各的,就按夹住算 + # 不在同一个条件里的两边(只看范围比较):不是各算各的,就按夹住算 for low, lows in below.items(): for high, highs in above.items(): if low == high: @@ -1587,18 +1785,22 @@ def _pinned(root: Any) -> dict[int, tuple[str, ...]]: budget -= steps if not apart[pair]: marked += lows + highs - # 同一个条件里的两边:细看合起来的真假 - for top in below.keys() & above.keys(): + # 同一个条件里的两边(至少一边是范围比较):细看合起来的真假 + for top in below.keys() | above.keys(): + lows = below.get(top, []) + low_exact.get(top, []) + highs = above.get(top, []) + high_exact.get(top, []) + if not (lows and highs): + continue 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] + if _isolated(orders[top], within[top], key, point, nearby.get((top, key), set())): + marked += lows + highs + elif top in below and top in above: + marked += below[top] + above[top] for literal in marked: - if literal is not None: - out.setdefault(id(literal), set()).add(_point_text(point)) + out.setdefault(id(literal), set()).add(_point_text(point)) return {literal: tuple(sorted(points)) for literal, points in out.items()} @@ -1607,25 +1809,50 @@ _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} -#: 且、或连起来的一串里,两两细看的项最多这么多(随这一列取值变真假的项):再多就按夹住算 + ">=": lambda s: s >= 0, ">": lambda s: s > 0, "<=": lambda s: s <= 0, "<": lambda s: s < 0, + "=": lambda s: s == 0, "<>": lambda s: s != 0} +#: 且、或连起来的一串里,两两细看的项最多这么多(随这一列取值变真假的项):再多就按夹住算。整数上细看时,紧挨着 +#: 的一串不等、IN 写着的数最多越过这么多个(_window) PAIR_MAX = 64 -def _isolated(order: list[tuple[Any, list[Any]]], halves: dict[int, list[_Half | None]], key: str, point: Any - ) -> bool: +def _isolated(order: list[tuple[Any, list[Any]]], halves: dict[int, _Leaf], key: str, point: Any, + nearby: set[Decimal] | frozenset[Decimal] = frozenset()) -> bool: """这个条件(order 是它的各层,_logic_order)里 key 取 point 时夹没夹住,两种都算: - 整个条件只认 point 这一个值,或者只排除它(真假和它两边都不一样)。数按两种看:连续的(紧挨着 point 两边的值) - 和整数的(point - 1、point + 1,col >= v AND col < v + 1 只在整数上夹住 v); + 和整数的(point - 1、point + 1,col >= v AND col < v + 1 只在整数上夹住 v)。整数上紧挨着的是同一个条件里 + 不等、IN 写着的数(nearby)时越过去接着看(_window):age >= 25 AND age <= 30 AND age <> 26 里 26 被去掉了, + 27 照样成立,不只认 25;age <= 26 时 27 不成立,就只认 25; - 条件里哪一层(一个比较、括号、非、异或,且 / 或连起来的一串里的任意一项、任意两项)不论整数都只认 point: BETWEEN v AND v、>= v AND <= v、> v OR < v 本身就写出了 v,旁边的条件把它吸收了(OR 一个更宽的范围)、和它 - 矛盾了也算,隔着别的条件写也一样。整数上的半开、开区间不这样一层层看:一长串相邻的分档(age >= 0 AND age < 1 - OR age >= 1 AND age < 2 …)每一档单拿出来都只认一个整数,连起来谁也没找。 + 矛盾了也算,隔着别的条件写也一样。不等、IN 不这样一层层看(单拿出来本来就只认、只排除写着的值,它们自己的 + 字面量照等值比较换),整数上的半开、开区间也不:一长串相邻的分档(age >= 0 AND age < 1 OR age >= 1 AND + age < 2 …)每一档单拿出来都只认一个整数,连起来谁也没找。 条件里和这一列无关的部分当成不知道真假,只要有一种取法夹住了就算(宁可多换)。""" - if _outcomes(order, halves, key, point, False, signals=True)[1]: + if _outcomes(order, halves, key, point, None, signals=True)[1]: return True - whole = isinstance(point, Decimal) and point == point.to_integral_value() - return whole and bool(_ISOLATED & _outcomes(order, halves, key, point, True)[0]) + if not (isinstance(point, Decimal) and point == point.to_integral_value()): + return False + offsets = _window(point, nearby) + if offsets is None: + return True + lone = tuple(o == 0 for o in offsets) + return bool({lone, tuple(not x for x in lone)} & _outcomes(order, halves, key, point, offsets)[0]) + + +def _window(point: Decimal, nearby: set[Decimal] | frozenset[Decimal]) -> tuple[int, ...] | None: + """整数上细看 point 时看哪几个整数(相对 point 的偏移):两边各看一个;紧挨着的是不等、IN 写着的数,越过它们再 + 多看一个。一边要越过的超过 PAIR_MAX 个时为 None(看不过来,按夹住算)。""" + below = above = 0 + while point - below - 1 in nearby: + below += 1 + if below > PAIR_MAX: + return None + while point + above + 1 in nearby: + above += 1 + if above > PAIR_MAX: + return None + return tuple(range(-below - 1, above + 2)) def _logic_children(node: Any) -> list[Any]: @@ -1667,13 +1894,14 @@ def _logic_order(top: Any) -> list[tuple[Any, list[Any]]]: 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)。""" +def _outcomes(order: list[tuple[Any, list[Any]]], halves: dict[int, _Leaf], key: str, point: Any, + offsets: tuple[int, ...] | None, *, signals: bool = False) -> tuple[set[tuple[bool, ...]], bool]: + """条件(各层见 _logic_order)在 key 取 point 和它两边时可能的真假组合。offsets 是整数上看的那几个偏移 + (_window),为 None 时按紧挨着的值看 (比它小一点, point, 比它大一点)。signals 为真时另交回条件里有没有哪一层、 + 且 / 或连起来的哪一两项单拿出来就只认 point(_isolated,只按紧挨着的值看)。""" from sqlglot import exp + either = _either(3 if offsets is None else len(offsets)) done: dict[int, set[tuple[bool, ...]]] = {} found = False for node, kids in order: @@ -1690,38 +1918,48 @@ def _outcomes(order: list[tuple[Any, list[Any]]], halves: dict[int, list[_Half | out = _joined(join, out, part) if signals and not found and len(parts) > 2: # 一串里随这一列取值变真假的项两两细看(两项的串就是它自己,下面照常看) - varying = [part for part in parts if not part <= _EITHER] + 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) + out = _leaf_outcomes(halves.get(id(node)), key, point, offsets) found = found or (signals and bool(_ISOLATED & out)) done[id(node)] = out return out, found +def _either(width: int) -> frozenset[tuple[bool, ...]]: + """和这一列无关的部分在 width 个取值上的真假:全真或全假(_EITHER 是三个取值的)。""" + return _EITHER if width == 3 else frozenset({(False,) * width, (True,) * width}) + + 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, ...]]: - """一个比较(几半的「且」)可能的真假组合;不是范围比较的(别的条件)当成不知道真假。""" - if parts is None: - return set(_EITHER) - out: set[tuple[bool, ...]] = {(True, True, True)} +def _leaf_outcomes(leaf: _Leaf | None, key: str, point: Any, offsets: tuple[int, ...] | None + ) -> set[tuple[bool, ...]]: + """一个比较(几半的「且」,IN 是几半的「或」)可能的真假组合;别的条件当成不知道真假。不等、IN 只在整数上细看 + 时算(offsets 不为 None),按紧挨着的值看时也当成不知道真假,见 _isolated。""" + either = _either(3 if offsets is None else len(offsets)) + if leaf is None or (leaf[2] and offsets is None): + return set(either) + parts, any_, _ = leaf + out: set[tuple[bool, ...]] = {(not any_,) * len(next(iter(either)))} + join = operator.or_ if any_ else operator.and_ for half in parts: - cell = {_truth(half, point, steps)} if half is not None and half[1] == key and ( - isinstance(half[2], Decimal) == isinstance(point, Decimal)) else _EITHER - out = {tuple(x and y for x, y in zip(s, t)) for s in out for t in cell} + cell = {_truth(half, point, offsets)} if half is not None and half[1] == key and ( + isinstance(half[2], Decimal) == isinstance(point, Decimal)) else either + out = _joined(join, out, cell) return out -def _truth(half: _Half, point: Any, steps: bool) -> tuple[bool, ...]: - """一半范围比较在 point 和它两边的真假(steps 同 _outcomes)。""" +def _truth(half: _Half, point: Any, offsets: tuple[int, ...] | None) -> tuple[bool, ...]: + """一半比较在 point 和它两边的真假(offsets 同 _outcomes)。""" _, _, bound, op = half - if steps: - signs = [_sign(point + step, bound) for step in (-1, 0, 1)] + if offsets is not None: + signs = [_sign(point + step, bound) for step in offsets] else: at = _sign(point, bound) signs = [at or -1, at, at or 1] @@ -1739,20 +1977,21 @@ def _point_text(point: Any) -> str: return str(point) -#: SQL 里一个字面量用在哪:(用法, 被比较的列名, 夹住的值),见 _literal_uses -_Use = tuple[str, str | None, tuple[str, ...]] +#: SQL 里一个字面量用在哪:(用法, 被比较的一边取自哪些列, 认它时一并看的值),见 _literal_uses +_Use = tuple[str, frozenset[str], tuple[str, ...]] def _literal_uses(sql: str, kind: str | None) -> dict[int, _Use] | None: """SQL 里每个字面量(按它在原文里的起始位置)用在哪,按语法树认(写法怎么变都认得出): - - ("equal", 列名, 夹住的值):在找某一个值——等值、不等、IN(含 VALUES、行值)、LIKE 一类、IS [NOT] DISTINCT FROM、 - 简单 CASE 的 WHEN、= ANY(ARRAY[…]),套了括号、负号、CAST 的照认;同一个条件里把某一个值夹住的范围(_pinned) - 也算,夹住的值记在第三项(换不换按它认:整数的 age > 24 AND age < 26 找的是 25),别的写法第三项为空。列名是 - 被比较的那一列(小写、不带表名),另一边不是单独一列时为 None; - - ("range", 列名, ()):范围比较(> >= < <=、BETWEEN)的边界,是 agent 定的阈值; - - ("limit", None, ()):LIMIT / OFFSET / FETCH / TOP 的行数; - - ("other", None, ()):别的——选出列、函数参数、算式、THEN 后面的值、子查询里选出的值。 - 解析不了返回 None。""" + - ("equal", 列, 值):在找某一个值——等值、不等、IN(含 VALUES、行值)、LIKE 一类、IS [NOT] DISTINCT FROM、 + 简单 CASE 的 WHEN、= ANY(ARRAY[…]),套了括号、负号、CAST 的照认;把某一个值夹住的范围(_pinned)也算,夹住的 + 值记在第三项(换不换按它认:整数的 age > 24 AND age < 26 找的是 25); + - ("range", 列, 值):范围比较(> >= < <=、BETWEEN)的边界,是 agent 定的阈值; + - ("limit", 空, 值):LIMIT / OFFSET / FETCH / TOP 的行数; + - ("other", 空, 值):别的——选出列、函数参数、和列一起算的算式、THEN 后面的值、子查询里选出的值。 + 列是被比较的一边取自哪些列(小写、不带表名,_columns_of:age、CAST(age AS INTEGER)、MAX(age) 都是 age)。 + 字面量是几个写死的数算出来的算式的一截(13900000000 + 1)时,算出来的值也记在第三项:整个算式按它认,用法看 + 算式摆在哪。解析不了返回 None。""" from app.data.sql_semantics import _parse, _read try: @@ -1765,14 +2004,18 @@ def _literal_uses(sql: str, kind: str | None) -> dict[int, _Use] | None: offset = len(sql) - len(sql.lstrip()) uses: dict[int, _Use] = {} try: - literals = {id(n): n for n in root.walk() if isinstance(n, _literal_types())} - pinned = _pinned(root) - for key, node in literals.items(): - start = (node.meta or {}).get("start") - if isinstance(start, int): - use, column = _use_of(node) - points = pinned.get(key) if use == "range" else None - uses[start + offset] = ("equal", column, points) if points else (use, column, ()) + values = _constants(root) + lifts = _lifts(root, values) + pinned = _pinned(root, values) + for node in root.walk(): + start = (node.meta or {}).get("start") if isinstance(node, _literal_types()) else None + if not isinstance(start, int): + continue + use, columns, computed = _use_of(node, values, lifts) + extra = (_point_text(computed),) if computed is not None else () + points = pinned.get(id(node)) + uses[start + offset] = ("equal", columns, tuple(dict.fromkeys((*points, *extra)))) if points \ + else (use, columns, extra) except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(认不准的地方不免) logger.warning("质询的 SQL 字面量分析失败", exc_info=True) return None @@ -1808,8 +2051,9 @@ def _literals(sql: str, kind: str | None, secrets: _Secrets, compared: set[str] 不了时只按词认简单的等值比较(_context),别的一律 "other"——认不准的地方不免。分词出错返回 None。 照原样留着的数还得在这条 SQL 里没有别处被换掉:同一个数在别的字面量里(等值比较、算式、字符串、夹住的范围) - 确实被换掉了,这里留着就把它露了(BETWEEN 13900000001 AND 13900000001 + 0、age >= 30 AND age = 30),所以这里改记 - 成在找这个值,再照等值的办法换:阈值也和遮罩列比较的(短的数也换)、数够长的换;别的列上的短阈值、LIMIT 的行数 + 确实被换掉了,这里留着就把它露了(buyer_phone >= 13900000001 AND buyer_phone - 13900000001 < 5、age >= 30 AND + age = 30;边界是算式的,算出来的值被换掉了也算:amount >= 15000 + 15000 OR amount = 30000),所以这里改记成在找 + 这个值,再照等值的办法换:阈值也和遮罩列比较的(短的数也换)、数够长的换;别的列上的短阈值、LIMIT 的行数 (age = 30 AND visits >= 30、LIMIT 30)换不掉,照原样——短的数本来就不按值遮(SECRET_DIGITS),撞上同一个数只是 巧合,一律换会把阈值、行数改得面目全非(见模块说明的已知局限)。 换没换照 _swap_literal 认,和真去换的是同一套判断:只是写在日期、天数修饰、标签里('2026-01-31'、'-30 days'、 @@ -1825,15 +2069,17 @@ def _literals(sql: str, kind: str | None, secrets: _Secrets, compared: set[str] if not _is_literal(kinds[i]): continue if uses is not None: - found[i] = uses.get(token.start, ("other", None, ())) + found[i] = uses.get(token.start, ("other", frozenset(), ())) else: use, column = _context(tokens, kinds, i) - found[i] = (use, column, ()) if use == "equal" else ("other", None, ()) + found[i] = (use, frozenset({column} if column else ()), ()) if use == "equal" \ + else ("other", frozenset(), ()) kept = {i for i, (use, _, _) in found.items() if kinds[i] == "NUMBER" and use in _KEPT} swapped = _swapped(sql, tokens, kinds, found, kept, secrets, compared) for i in kept: - if _digits_of(tokens[i].text) in swapped: - found[i] = ("equal", found[i][1], ()) + _, columns, points = found[i] + if any(_digits_of(v) in swapped for v in (tokens[i].text, *points)): + found[i] = ("equal", columns, points) return tokens, kinds, found @@ -1853,17 +2099,20 @@ def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets, """SQL 里一个字面量(原文 raw、分出来的字 text、词的种类 kind)换成什么样,和换掉了哪些遮罩数(去掉千分位、正负号)。 _scrub_sql 照它换,_literals 照它认别处确实换掉的数,两处是同一套判断: - - 在找某一个值的(equal):和遮罩列(或它的别名,compared)比较的,是遮罩列里出现过的值就整个换,短的数也换 - (age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号)。夹住某一个值的范围按夹住 - 的值认(age > 24 AND age < 26 的两头都按 25 认),同一个数在别处换掉了的阈值、行数也按等值认(见 _literals); + - 在找某一个值的(equal):和遮罩列(或它的别名,compared;套了 CAST、汇总的也算,见 _columns_of)比较的,是遮罩 + 列里出现过的值就整个换,短的数也换(age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个 + 手机号)。数换个写法照样认(25.0、025、2.5e1 按 25 认,_canonical);夹住某一个值的范围按夹住的值认(age > 24 + AND age < 26 的两头都按 25 认),算式里的数按算出来的值认,同一个数在别处换掉了的阈值、行数也按等值认(见 + _literals); - 字符串在哪都按值换里面的遮罩值(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人); - 别的数:范围比较的边界、LIMIT / OFFSET 的行数照原样(agent 自己定的阈值,换了封存的就不是实际执行的语句); - 别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换。""" - how, column, points = use + 别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换(算出来的值是也换)。""" + how, columns, points = use word = _masked_word() + canonical = _canonical(text) + looked = [text, *([canonical] if canonical not in (None, text) else []), *points] if how == "equal": - looked = [text, *points] - if any((column in compared and secrets.weak_hit(v)) or secrets.hit(v) for v in looked): + if any((columns & compared and secrets.weak_hit(v)) or secrets.hit(v) for v in looked): return _requote(raw, lambda _: word), _number_forms(looked) if kind != "NUMBER": found: list[str] = [] @@ -1876,8 +2125,8 @@ def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets, return pattern.sub(word, inner) return _requote(raw, scrub), _number_forms(found) - if how not in _KEPT and secrets.hit(text): - return word, _number_forms([text]) + if how not in _KEPT and any(secrets.hit(v) for v in looked): + return word, _number_forms(looked) return raw, set() @@ -1950,9 +2199,9 @@ def _scrub_excerpt(text: str, whole: set[str] | None, secrets: _Secrets, kind: s """SQL 检查结果的片段:检查器按语法树重新写出的一段表达式(最长 160 字,再长截断加「…」),单独拿出来认不出在找 哪一个值、哪个数是阈值,照整条 SQL 的认法换:整条 SQL 里整个换掉了的字面量(whole),片段里写着同样的字面量也换 (同一个值在片段里别处出现也换,宁可多换);整条 SQL 里照原样留着的阈值,片段里也照原样;字符串、名字、注释按值 - 换(同 _scrub_sql)。截断处可能正好截在一个值中间('1390000…),那半截按值认不出,去掉。""" + 换(同 _scrub_sql)。截断处可能正好截在一个值中间('1390000…),那半截按值认不出,去掉(_cut_tail)。""" cut = text.endswith("…") - body = re.sub(r"\S*$", "", text[:-1]) if cut else text + body = _cut_tail(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: @@ -1972,14 +2221,47 @@ def _scrub_excerpt(text: str, whole: set[str] | None, secrets: _Secrets, kind: s return out + "…" if cut else out +def _cut_tail(body: str) -> str: + """截断的片段去掉截断处半截的值:停在没闭合的字符串、带引号的名字、注释里的,从开头的引号、注释起整个去掉(地址 + 这类遮罩值中间有空格,只去掉最后一段还会留下前半截),紧贴在引号前的前缀(N'、E'、U&')一起去掉;停在不带引号 + 的词中间的,去掉那半个词。""" + opened = _unclosed(body) + if opened is not None: + return re.sub(r"[0-9A-Za-z_&]*$", "", body[:opened]) + return re.sub(r"\S*$", "", body) + + +def _unclosed(text: str) -> int | None: + """text 停在没闭合的字符串、带引号的名字('…、"…、`…)或注释(/* …、-- …)里时,交回它开头的位置;不然为 None。 + 引号里两个引号连写是引号本身。""" + i = 0 + while i < len(text): + if text.startswith(("/*", "--"), i): + end = text.find("*/" if text[i] == "/" else "\n", i + 2) + if end < 0: + return i + i = end + 2 if text[i] == "/" else end + 1 + elif (quote := text[i]) in "'\"`": + end = i + 1 + while (end := text.find(quote, end)) >= 0 and text.startswith(quote * 2, end): + end += 2 + if end < 0: + return i + i = end + 1 + else: + i += 1 + return None + + #: SQL 检查结果的说明里照引的取值:「…」括起来 _QUOTED = re.compile(r"「([^「」]*)」") def _scrub_message(text: str, whole: set[str] | None, secrets: _Secrets) -> str: """SQL 检查结果的说明:照引 SQL 里写的取值(码值表已列全时「没有码值「3」」),整条 SQL 里整个换掉了的(whole), - 这里一样换成「已遮罩」,负数照留负号(同 SQL 里);别的字按值换。码值表里列出的码值是数据目录写明的,不在引号 - 里,照原样。""" + 这里一样换成「已遮罩」,负数照留负号(同 SQL 里);别的字按值换。码值表里列出的码值(「已列出的全部码值: + 线上直销=…」)是数据目录写明的,不从 SQL 摘,也不另外免遮:和别的字一样按值换,恰好是按值认得出的遮罩值的码值 + (两个字以上的文字、够长的数)换成「已遮罩」,多遮,不漏;短的码值照原样。""" word = _masked_word() def quoted(m: re.Match[str]) -> str: @@ -1995,8 +2277,9 @@ def _scrub_message(text: str, whole: set[str] | None, secrets: _Secrets) -> str: def _shown_literals(sqls: Iterable[tuple[str, str | None, set[str]]], secrets: _Secrets) -> set[str]: """几条 SQL(各自数据源的类型、SQL 里认哪些名字是遮罩列,见 _compared)里照原样留着的数,去掉千分位、正负号: - 范围比较的边界、LIMIT / OFFSET 的行数(_scrub_sql 不换的那两种)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额 - 不低于 30000 元」)再遮也遮不住什么,只会让回答和 SQL 对不上(_Secrets.show)。同一个数在别的查询里确实被换掉了 + 范围比较的边界、LIMIT / OFFSET 的行数(_scrub_sql 不换的那两种;边界是算式的,算出来的值也算:20000 * 1.5 就是 + 30000)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额不低于 30000 元」)再遮也遮不住什么,只会让回答和 SQL + 对不上(_Secrets.show)。同一个数在别的查询里确实被换掉了 也不算(同一条里的 _literals 已经不留):正文写出来就把那里换掉的值露了;只是写在日期、批次号、标签里没换的不牵连。 分词都分不了的整条按值换(同 _scrub_sql),换掉的数也算。""" kept: set[str] = set() @@ -2011,7 +2294,7 @@ def _shown_literals(sqls: Iterable[tuple[str, str | None, set[str]]], secrets: _ continue tokens, kinds, uses = split keep = {i for i, (use, _, _) in uses.items() if kinds[i] == "NUMBER" and use in _KEPT} - kept |= {_digits_of(tokens[i].text) for i in keep} + kept |= {_digits_of(v) for i in keep for v in (tokens[i].text, *uses[i][2])} swapped |= _swapped(sql, tokens, kinds, uses, keep, secrets, compared) return kept - swapped @@ -2073,11 +2356,11 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] for artifact, snapshot in own.items(): 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) + secrets.add(snapshot, hidden[artifact], labels=_snapshot_labels(snapshot, kind), base=base) for snapshot, base in based: 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.add(snapshot, _derived_hidden(snapshot, base, kind)[0], labels=_snapshot_labels(snapshot, 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)) @@ -2091,6 +2374,14 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] gone={artifact: named[artifact][1] for artifact in own}) +def _snapshot_labels(snapshot: dict[str, Any], kind: str | None) -> dict[str, set[str]]: + """一份快照里只装着分档标签的列(_label_columns)。没有 SQL 的(合并查询)一个都不算。""" + sql = snapshot.get("sql") + if not isinstance(sql, str) or not sql.strip(): + return {} + return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []]) + + def _compared(tainted: dict[str, set[str]], artifact: str, live: dict[str, list[str]], name: str) -> set[str]: """一条查询的 SQL 里认哪些名字是遮罩列(和它们做等值比较的,短的数也换):快照认出的(_derived_hidden 的名字, 含改了名字的);快照没取到的(存不下、被删了),按这个源现在设的遮罩列认。""" diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index da36b04a..2780a8dc 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -13,11 +13,13 @@ - 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出(提问人自己写的数不标);遮罩列(起了 别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现 遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码,两半 - 拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、CTE 里……)也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也 - 一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、和日期、天数修饰、标签里写着的同一个数也不牵连阈值, - 分档的文字标签不当遮罩值(当分组键的档位列照常显示);SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮, - 回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里 - 只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条 SQL 换(短的数也换),给模型的那句改法不交出去 + 拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、CTE 里,边界、等值写成算式,两头不是整数,范围再排除一头, + 号码前面补零……)也一样,年龄这类短的值换着写法找(套了 CAST、汇总值两头相同、补零……)SQL 里也遮;遮罩值不误伤 + 阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、和日期、天数修饰、标签里 + 写着的同一个数也不牵连阈值,分档的文字标签不当遮罩值(当分组键的档位列照常显示),改了名字的遮罩列撞上标签照样遮; + SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,回答和质询工件对得上;质询工件按值遮只出现在报告快照里 + 的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条 + SQL 换(短的数也换),给模型的那句改法不交出去 - 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 数据源、没固定过版本的上传表格不查 - 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 @@ -1033,6 +1035,95 @@ async def test_a_threshold_matching_numbers_left_alone_elsewhere_is_sealed_as_wr await engines.invalidate(source_id) +@pytest.mark.parametrize("sql, sealed", [ + pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE CAST(age AS INTEGER) = 25", + "SELECT COUNT(*) AS 人数 FROM sales WHERE CAST(age AS INTEGER) = 已遮罩", id="套了 CAST"), + pytest.param("SELECT COUNT(*) AS 人数 FROM sales GROUP BY age HAVING MAX(age) BETWEEN 25 AND 25", + "SELECT COUNT(*) AS 人数 FROM sales GROUP BY age HAVING MAX(age) BETWEEN 已遮罩 AND 已遮罩", + id="汇总值两头相同"), + pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age = 25.0", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age = 已遮罩", id="补零的写法"), + pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 24.5 AND age < 25.5", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age > 已遮罩 AND age < 已遮罩", id="两头不是整数"), + pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 25 AND age <= 26 AND age <> 26", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age <= 26 AND age <> 已遮罩", + id="范围再排除一头"), + pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 20 + 5 AND 30 - 5", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩", + id="算出来的边界"), +]) +async def test_a_short_masked_value_looked_up_in_other_shapes_is_sealed_masked(monkeypatch, tmp_path, client, sql, + sealed): + """年龄设了遮罩,agent 换着写法找某一个年龄(套了 CAST、汇总值两头相同、补零、两头不是整数、范围再排除一头、 + 算出来的边界):第 2 轮复核时都照原样封存。现在查询清单、事件、质询工件里的 SQL 都换掉这个年龄,三处一字不差。""" + 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 }}"}])) + plays = {"这个年龄": Play([sql], "这个年龄的有 [[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 + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "这个年龄的有 1 人。") + [query] = item["queries"] + assert query["sql"] == sealed + [event] = await events(paused.id, "approval.inquiry") + assert event.data["queries"][0]["sql"] == sealed + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200 and got.json()["content"]["sql"] == sealed, "质询工件的 SQL 和事件里的一字不差" + finally: + await engines.invalidate(source_id) + + +#: 会员等级设了遮罩:等级是 1、2、3 这样的短码值,正好撞上按条件数个数写的 THEN 1 ELSE 0 +LEVELS = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, vip_level INTEGER, amount INTEGER);" + "INSERT INTO sales VALUES (1, '华东', 1, 1200), (2, '华东', 2, 300), (3, '华南', 3, 900)," + " (4, '华北', 1, 1500);") + + +async def test_a_renamed_masked_column_keeps_values_that_match_labels_written_elsewhere(monkeypatch, tmp_path, client): + """第 2 轮复核:先按等级数大单(vip_level AS 等级,SUM(CASE … THEN 1 ELSE 0 END)),再找等级为 1、2 的。以前分档标签 + 1、0 不收进遮罩值,改了名字的等级列也跟着不收,WHERE vip_level = 1 照原样封存,= 2 却遮了。现在只有只装着标签的 + 列不收,两条都遮;按等级数大单的那份快照里,等级整列遮,大单数照常显示。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "vip_level"}, script=LEVELS) + by_level = ("SELECT vip_level AS 等级, SUM(CASE WHEN amount > 1000 THEN 1 ELSE 0 END) AS 大单数 FROM sales " + "GROUP BY vip_level ORDER BY vip_level") + first, second = (f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}" for level in (1, 2)) + plays = {"各等级": Play([by_level, first, second], + "大单最多的等级有 [[v:Q1.r0.大单数]] 笔大单;问到的两个等级各有 [[v:Q2.r0.人数]] 人、" + "[[v:Q3.r0.人数]] 人。[[see:Q1]]")} + 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 笔大单;问到的两个等级各有 2 人、1 人。") + sealed = [by_level, *["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 got.json()["content"]["rows"] == [["已遮罩", 2], ["已遮罩", 0], ["已遮罩", 0]] + assert got.json()["masked"] == ["等级"] + finally: + await engines.invalidate(source_id) + + STAR_TAIL = "SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1" @@ -1431,6 +1522,18 @@ _COUNT = "SELECT COUNT(*) AS 笔数 FROM sales WHERE " # 整数上相邻的两头把中间那一个隔开 pytest.param(PHONES, _COUNT + "NOT (buyer_phone <= 13900000000 OR buyer_phone >= 13900000002)", _COUNT + "NOT (buyer_phone <= 已遮罩 OR buyer_phone >= 已遮罩)", id="相邻的整数隔开"), + # 第 2 轮复核:一头是算式、等值的值是算式、两头不是整数、范围再用不等去掉一头、前面补零的号码。以前都照原样 + # 封存,后两种的号码还被当成阈值,回答正文跟着不遮 + pytest.param(PHONES, _COUNT + "buyer_phone BETWEEN 13900000000 + 1 AND 13900000009 - 8", + _COUNT + "buyer_phone BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩", id="算出来的边界"), + pytest.param(PHONES, _COUNT + "buyer_phone = 13900000000 + 1", _COUNT + "buyer_phone = 已遮罩 + 已遮罩", + id="算出来的等值"), + pytest.param(PHONES, _COUNT + "buyer_phone > 13900000000.5 AND buyer_phone < 13900000001.5", + _COUNT + "buyer_phone > 已遮罩 AND buyer_phone < 已遮罩", id="两头不是整数"), + pytest.param(PHONES, _COUNT + "buyer_phone >= 13900000001 AND buyer_phone <= 13900000002 " + "AND buyer_phone <> 13900000002", + _COUNT + "buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩 AND buyer_phone <> 已遮罩", id="范围再排除一头"), + pytest.param(PHONES, _COUNT + "buyer_phone = 013900000001", _COUNT + "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 …)、两头相同的范围、行值、选出列里写着同一个号码……),回答里 diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index db6cd2a4..3d5947ac 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -8,23 +8,28 @@ 格子里的同一个数照旧换 - SQL 里数字照原样留着的只有范围比较的边界(没把某一个值夹住的)和 LIMIT / OFFSET 的行数;在找某一个值的(等值、IN、 LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、 - >= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换;选出列、函数参数、算式里够长的遮罩值也换;字符串 - (范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免 + >= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换,被比较的一边套了 CAST、汇总也算;同一个数换个 + 写法(25.0、025、2.5e1)、写成几个数的算式(25 + 0、13900000000 + 1)照样认,算式里的数都换;选出列、函数参数、 + 算式里够长的遮罩值也换;字符串(范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免 - 「夹住某一个值」在整条 SQL 里认,只有分档的几种写法各算各的:CASE 的不同分支、不同的选出列(连同 GROUP BY、 ORDER BY)、UNION 的不同支,共用的边界是阈值;同一行集上的条件(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、 包在 CASE / 函数 / 算式里的一半、子查询、CTE)照样认得出在找哪一个值。同一个条件里按合起来的真假认(旁边的别的 条件当成不知道真假),BETWEEN v AND v、>= v AND <= v 这类本身就只认一个值的,条件里哪一层都算;整数上 <= v、 - >= v 也隔开了相邻的整数;列名认不出的只和写得一样的表达式配 + >= v 也隔开了相邻的整数,两头不是整数的(age > 24.5 AND age < 25.5)也夹住中间那一个整数;范围再用不等、NOT IN + 去掉一头的(age >= 25 AND age <= 26 AND age <> 26)也认,不等、IN 只在同一个条件里和范围合起来看;列名认不出的 + 只和写得一样的表达式配 - 阈值只在同一个数在这条 SQL 别处确实被换掉时才不留(换不换和 SQL 里换字面量是同一套判断):日期、天数、标签里写着 同一个短的数,没设遮罩的列上的等值,都不牵连阈值;不留的照等值的办法换,别的列上的短阈值、LIMIT 撞上别处换掉的 短的数照原样(已知局限) - 按遮罩列分档的标签(CASE、IIF 的结果都是写死的常量)是 agent 自己写在 SQL 里的,不当遮罩值:SQL、回答正文里照原样; - 当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮 + 当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮。只有只装着标签的列不收(经别名、子查询 + 传下去、没起别名的也算),改了名字的遮罩列撞上同一条 SQL 里的 THEN 1 ELSE 0 照收 - 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里, - 没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换 + 没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换(截断的 + 片段从没闭合的引号、注释起去掉,说明里的码值表和别的字一样按值换) - 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去 -- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串照样按结构认,成百个分档边界看不过来的 - 按夹住算,也不卡 +- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串、上千项的连加照样按结构认,成百个分档 + 边界看不过来的按夹住算,也不卡 数据都是虚构的:手机号、姓名、年龄、区域、金额。 """ @@ -232,6 +237,47 @@ def test_a_value_of_the_masked_column_itself_stays_masked_even_if_written_as_a_l assert plan.secrets.scrub("VIP会员、铂金会员、普通会员") == "已遮罩、已遮罩、普通会员" +#: 按会员等级数大单:等级设了遮罩、改了名字,汇总里按条件数个数写着 THEN 1 ELSE 0 +LEVEL_COUNTS = ("SELECT vip_level AS 等级, SUM(CASE WHEN amount > 1000 THEN 1 ELSE 0 END) AS 大单数 FROM sales " + "GROUP BY vip_level") + + +def test_a_renamed_masked_column_keeps_values_that_match_labels_written_elsewhere(): + """第 2 轮复核:分档标签不收进遮罩值,以前对改了名字的遮罩列也一样——vip_level AS 等级 的 1、2、3 撞上同一条 SQL + 里按条件数个数的 THEN 1 ELSE 0,1 就不算遮罩值,之后 WHERE vip_level = 1 照原样封存。现在只跳过只装着标签的 + 那几列的格子,改了名字的遮罩列照收。""" + snapshot = {"source": "shop", "sql": LEVEL_COUNTS, "columns": ["等级", "大单数"], + "rows": [[1, 2], [2, 0], [3, 0]]} + plan = plan_of(snapshot, {"vip_level"}) + assert plan.views["a1"]["rows"] == [[MASKED, 2], [MASKED, 0], [MASKED, 0]] and plan.masked["a1"] == ["等级"] + 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 = 已遮罩") + # 同一个名字既装着标签又装着遮罩列的值(UNION 两支):不算只装着标签的列,照收 + mixed = {"source": "shop", "sql": "SELECT vip_level AS 等级 FROM sales UNION ALL " + "SELECT CASE WHEN amount > 1000 THEN 1 ELSE 0 END FROM sales", + "columns": ["等级"], "rows": [[1], [3], [0]]} + assert {"0", "1", "3"} <= plan_of(mixed, {"vip_level"}).secrets.weak + + +@pytest.mark.parametrize("sql, columns, rows", [ + # 标签列经子查询、CTE 原样传下去(外层再分组也一样:每一行的档位照旧算派生值,见上) + (f"SELECT 档位, COUNT(*) AS 笔数 FROM (SELECT {BANDS} AS 档位 FROM sales) t GROUP BY 档位", + ["档位", "笔数"], [["大单", 2], ["小单", 3]]), + (f"WITH t AS (SELECT {BANDS} AS 档位 FROM sales) SELECT * FROM t", ["档位"], [["小单"], ["大单"]]), + # 没起别名的标签列:按位置认 + (f"SELECT id, {BANDS} FROM sales", ["id", BANDS], [[1, "小单"], [2, "大单"]]), +]) +def test_labels_passed_on_under_their_own_name_are_still_not_masked_values(sql, columns, rows): + """只装着标签的列经别名、子查询、CTE 原样传下去,或者没起别名:照旧整列遮(逐行的档位),标签文字照旧不当遮罩值。""" + plan = plan_of({"source": "shop", "sql": sql, "columns": columns, "rows": rows}, {"amount"}) + label = columns.index("档位") if "档位" in columns else 1 + assert all(row[label] == MASKED for row in plan.views["a1"]["rows"]) + assert plan.secrets.scrub("小单 3 笔、大单 2 笔") == "小单 3 笔、大单 2 笔" + + # -------------------------------------------------------------------------- # 按值认:门槛、边界 # -------------------------------------------------------------------------- @@ -315,6 +361,9 @@ def people() -> inquiry_mod._Secrets: # 不等、SQLite 的 IS、NOT LIKE 也是在找某一个值 ("SELECT COUNT(*) FROM sales WHERE age != 25 AND age IS NOT 31 AND buyer_phone NOT LIKE '13900000001'", "SELECT COUNT(*) FROM sales WHERE age != 已遮罩 AND age IS NOT 已遮罩 AND buyer_phone NOT LIKE '已遮罩'"), + # IN 列表里写着遮罩值,换的是它;旁边的范围边界合起来不只认一个值(25、26 都要),照原样 + ("SELECT COUNT(*) FROM sales WHERE age > 24 AND age IN (25, 26)", + "SELECT COUNT(*) FROM sales WHERE age > 24 AND age IN (已遮罩, 26)"), ]) def test_sql_keeps_everything_but_the_masked_values(people, sql, shown): assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, "sqlite") == shown @@ -407,12 +456,11 @@ def test_a_value_looked_up_in_any_shape_is_masked_in_the_sql(phones, kind, sql, "SELECT COUNT(*) FROM sales WHERE buyer_phone >= '已遮罩' AND buyer_phone <= '已遮罩'"), ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN '13900000001' AND '13900000001'", "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN '已遮罩' AND '已遮罩'"), - # 一头是几个数算出来的(13900000002 - 1 就是 13900000001):照样夹住了 + # 一头是几个数算出来的(13900000002 - 1 就是 13900000001):照样夹住了,算式里的数都换(留着哪个都可能算得出号码) ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000002 - 1", - "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 - 1"), - # 同一个数在算式里被换掉了,边界上的也不留:留着就把算式里换掉的露了 + "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 - 已遮罩"), ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000001 + 0", - "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 + 0"), + "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 + 已遮罩"), ]) def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown): """两头是同一个值的范围(BETWEEN v AND v、>= v AND <= v)、大于或小于同一个值、整数的半开区间,都只夹住了一个值, @@ -420,6 +468,107 @@ def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown): assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == shown +@pytest.mark.parametrize("sql, sealed", [ + # 范围的一头是几个数算出来的:算式就是那个值,夹住了一样是在找它,算式里的数都换 + ("SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 25 + 0", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 + 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE age BETWEEN 20 + 5 AND 30 - 5", + "SELECT COUNT(*) FROM sales WHERE age BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE age >= 50 / 2 AND age <= (5 * 5)", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 / 已遮罩 AND age <= (已遮罩 * 已遮罩)"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000000 + 1 AND 13900000009 - 8", + "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩"), + # 等值、IN 里的值是算出来的:按算出来的值认 + ("SELECT COUNT(*) FROM sales WHERE age = 20 + 5 OR buyer_phone IN (13900000000 + 2, 7)", + "SELECT COUNT(*) FROM sales WHERE age = 已遮罩 + 已遮罩 OR buyer_phone IN (已遮罩 + 已遮罩, 7)"), + # 选出列里算出来的号码:同选出列里直接写着号码,够长的遮罩值换 + ("SELECT 13900000000 + 1 AS 号码, COUNT(*) AS 笔数 FROM sales", + "SELECT 已遮罩 + 已遮罩 AS 号码, COUNT(*) AS 笔数 FROM sales"), +]) +def test_a_value_worked_out_from_several_numbers_is_read_as_that_value(phones, sql, sealed): + """第 2 轮复核:范围的一头写成算式(25 + 0、13900000000 + 1),夹住的值照样认得出,可算式里的数照原样留着, + 一算就是那个号码。算式按算出来的值认:夹住某一个值的、等值、IN 里的,算式里的数都换;正文里也不免遮。""" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed + assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones) == set() + + +def test_a_threshold_worked_out_from_several_numbers_is_kept_like_one_number(amounts): + """算出来的阈值(20000 * 1.5)和写成一个数的阈值一样:没夹住某一个值就照原样,正文里复述它(连同算出来的 + 30000)也不遮;算出来的值在别处确实被换掉了,算式也不留(同写成一个数的阈值)。""" + sql = "SELECT COUNT(*) FROM sales WHERE amount >= 20000 * 1.5 AND amount < 20000 * 2.5" + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sql + assert inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts) == { + "20000", "1.5", "2.5", "30000", "50000"} + swapped = "SELECT COUNT(*) FROM sales WHERE amount >= 15000 + 15000 OR amount = 30000" + assert inquiry_mod._scrub_sql(swapped, amounts, {"amount"}, "sqlite") == ( + "SELECT COUNT(*) FROM sales WHERE amount >= 已遮罩 + 已遮罩 OR amount = 已遮罩") + assert inquiry_mod._shown_literals([(swapped, "sqlite", {"amount"})], amounts) == set() + + +@pytest.mark.parametrize("sql, sealed", [ + # 遮罩列套了 CAST、汇总:被比较的一边取的还是它的值,短的数照样换 + ("SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) = 25", + "SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) = 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) BETWEEN 25 AND 25", + "SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) BETWEEN 已遮罩 AND 已遮罩"), + ("SELECT region FROM sales GROUP BY region HAVING MAX(age) BETWEEN 25 AND 25", + "SELECT region FROM sales GROUP BY region HAVING MAX(age) BETWEEN 已遮罩 AND 已遮罩"), + # 同一个数换个写法(小数点后补零、前面补零、科学计数法) + ("SELECT COUNT(*) FROM sales WHERE age = 25.0 OR age = 025 OR age IN (2.5e1)", + "SELECT COUNT(*) FROM sales WHERE age = 已遮罩 OR age = 已遮罩 OR age IN (已遮罩)"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone = 013900000001 OR buyer_phone = 1.3900000001e10", + "SELECT COUNT(*) FROM sales WHERE buyer_phone = 已遮罩 OR buyer_phone = 已遮罩"), + # 两头不是整数的范围:整数上只夹住中间那一个 + ("SELECT COUNT(*) FROM sales WHERE age > 24.5 AND age < 25.5", + "SELECT COUNT(*) FROM sales WHERE age > 已遮罩 AND age < 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE age BETWEEN 25 AND 25.9", + "SELECT COUNT(*) FROM sales WHERE age BETWEEN 已遮罩 AND 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone > 13900000000.5 AND buyer_phone < 13900000001.5", + "SELECT COUNT(*) FROM sales WHERE buyer_phone > 已遮罩 AND buyer_phone < 已遮罩"), + # 范围再用不等、NOT IN 去掉一头:同一个条件里合起来只剩一个值 + ("SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 26 AND age <> 26", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 26 AND age <> 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone <= 13900000002 " + "AND buyer_phone <> 13900000002", + "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩 AND buyer_phone <> 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE age BETWEEN 25 AND 27 AND age NOT IN (26, 27)", + "SELECT COUNT(*) FROM sales WHERE age BETWEEN 已遮罩 AND 27 AND age NOT IN (已遮罩, 27)"), +]) +def test_other_ways_of_looking_up_one_value_are_masked_too(phones, sql, sealed): + """第 2 轮复核留下的几种找值写法:遮罩列套了 CAST、汇总,同一个数换个写法,整数列上两头不是整数的范围,范围再用 + 不等、NOT IN 去掉一头。以前都照原样封存,长号码的那几种还因为照原样留着、被当成阈值,回答正文跟着不遮。""" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed + shown = inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones) + assert not shown & {"25", "13900000001"} + + +@pytest.mark.parametrize("sql", [ + # 范围加上不等、IN,合起来不只认一个值:照原样 + "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 30 AND age <> 26", + "SELECT COUNT(*) FROM sales WHERE age > 0 AND age IN (26, 27)", + "SELECT COUNT(*) FROM sales WHERE age >= 25 OR age = 26", + # 两头不是整数、中间不只一个整数 + "SELECT COUNT(*) FROM sales WHERE age > 24.5 AND age < 26.5", + # 不等、IN 在别的条件里(选出列的 CASE 和 WHERE):只有范围比较跨条件配对 + "SELECT SUM(CASE WHEN age = 26 THEN 1 ELSE 0 END) AS 人数 FROM sales WHERE age >= 25", +]) +def test_ranges_next_to_other_comparisons_that_do_not_pin_a_value_stay(phones, sql): + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sql + + +def test_a_long_sum_of_numbers_does_not_stop_the_reading(ages): + """visits <= 1+1+…(上千项):以前算式递归求值,超过递归深度,整条 SQL 退回按词认,同一句里夹住的年龄就照原样 + 封存。现在不递归,照样按语法树认。""" + total = "+".join(["1"] * 1500) + sql = f"SELECT COUNT(*) FROM sales WHERE visits <= {total} AND age >= 25 AND age <= 25" + assert inquiry_mod._literal_uses(sql, "sqlite") is not None + started = time.perf_counter() + assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql.replace( + "age >= 25 AND age <= 25", "age >= 已遮罩 AND age <= 已遮罩") + elapsed = time.perf_counter() - started + assert elapsed < 5, f"用了 {elapsed:.2f} 秒" + + @pytest.mark.parametrize("kind, sql, shown", [ # 同一个条件里还有别的条件(且、或):照样夹住了 ("sqlite", "SELECT COUNT(*) FROM sales WHERE region = '华东' AND buyer_phone >= 13900000001 " @@ -919,6 +1068,41 @@ def test_sql_check_messages_do_not_quote_masked_values(): assert [c["sql_excerpt"] for c in out] == ["status IN (已遮罩, -已遮罩)", "channel_type = '已遮罩'"] +def test_codes_listed_in_a_check_message_are_masked_by_value_like_any_text(): + """说明里码值表列出的码值是数据目录写明的,不从 SQL 摘,也不另外免遮:和说明里别的字一样按值换,恰好是按值认得出 + 的遮罩值(两个字以上的文字、够长的数)的码值换成「已遮罩」(多遮,不漏),短的码值、别的码值照原样。""" + secrets = secrets_of({"columns": ["channel_type", "status"], "rows": [["线上直销", 1], ["分销", 0]]}, + {"channel_type", "status"}) + message = ("「渠道」的「渠道类型」没有码值「线下合作」(数据目录中已列出的全部码值:线上直销=官网和小程序、" + "线下=窗口售票、分销=旅行社、1=有效)。请核对筛选条件里的取值") + checks = [{"code": "unknown_code", "level": "warning", "table": "channels", "column": "channel_type", + "message": message, "sql_excerpt": "channel_type = '线下合作'"}] + [check] = inquiry_mod._masked_checks(checks, "SELECT COUNT(*) FROM channels WHERE channel_type = '线下合作'", + secrets, {"channel_type", "status"}, "sqlite") + assert check["message"] == ("「渠道」的「渠道类型」没有码值「线下合作」(数据目录中已列出的全部码值:已遮罩=官网和小程序、" + "线下=窗口售票、已遮罩=旅行社、1=有效)。请核对筛选条件里的取值") + + +@pytest.mark.parametrize("excerpt, shown", [ + # 截在带空格的地址中间:从没闭合的引号起整个去掉,不只去掉最后一段 + ("region = '华东' AND addr = '上海市 浦东新区 某…", "region = '华东' AND addr = …"), + # 写成两个单引号的引号不算闭合 + ("region = 'O''Neil 的店' AND addr = '上海市 浦东…", "region = 'O''Neil 的店' AND addr = …"), + # 截在带引号的名字、注释中间也一样 + ('region = \'华东\' AND "上海市 浦东新区…', "region = '华东' AND …"), + ("region = '华东' /* 上海市 浦东…", "region = '华东' …"), +]) +def test_a_cut_off_excerpt_drops_the_whole_unfinished_value(excerpt, shown): + """检查器截过的片段停在一个带空格的遮罩文字值中间(地址):以前只去掉「…」前最后一段连续的字,前面那几段还在; + 现在从没闭合的引号、注释起整个去掉。""" + secrets = secrets_of({"columns": ["addr"], "rows": [["上海市 浦东新区 某某路 1 号"]]}, {"addr"}) + sql = "SELECT SUM(balance) AS 余额 FROM accounts WHERE region = '华东' AND addr = '上海市 浦东新区 某某路 1 号'" + checks = [{"code": "stock_summed", "level": "warning", "table": "accounts", "message": "存量跨日期求和", + "sql_excerpt": excerpt}] + [check] = inquiry_mod._masked_checks(checks, sql, secrets, {"addr"}, "sqlite") + assert check["sql_excerpt"] == shown + + def test_report_values_that_happen_to_be_masked_values_are_shown(): """报告口径卡里的指标恰好和遮罩列里的某个值一样(单笔目标 20000,金额也有 20000 的单):报告里本来就看得到, 引用它的回答照常交出去。""" From 76b485a0ca8cb2de041772d9cc019f19d4afc0fe Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 16:01:28 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=88=E7=AC=AC=203=20?= =?UTF-8?q?=E8=BD=AE=E5=A4=8D=E6=A0=B8=EF=BC=89=EF=BC=9A=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=88=97=E6=8C=89=E4=BD=9C=E7=94=A8=E5=9F=9F=E8=AE=A4=E5=8F=96?= =?UTF-8?q?=E8=87=AA=E5=93=AA=E4=B8=AA=E6=9D=A5=E6=BA=90=EF=BC=8CUNION=20?= =?UTF-8?q?=E5=8F=A6=E4=B8=80=E6=94=AF=E3=80=81=E5=AD=90=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E9=81=AE=E7=BD=A9=E5=88=97=E6=B5=81=E8=BF=9B?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=88=97=E7=85=A7=E6=94=B6=EF=BC=9B=E5=8F=96?= =?UTF-8?q?=E6=A8=A1=E3=80=81=E6=95=B4=E9=99=A4=E3=80=81=E6=95=B4=E6=95=B0?= =?UTF-8?q?=E7=9B=B8=E9=99=A4=E6=8C=89=E7=AE=97=E5=87=BA=E6=9D=A5=E7=9A=84?= =?UTF-8?q?=E5=80=BC=E8=AE=A4=EF=BC=9BCAST=20=E7=9A=84=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E4=B8=8D=E5=BD=93=E8=BE=B9=E7=95=8C=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核留下的一条 major(第 2 轮只修了一部分)和两条次要问题: - 分档标签列:以前按名字认,从「所有名字都只装着标签」起往下剔。遮罩列原样选出(SELECT vip_level)时名字就是它自己, 永远剔不掉;结果的 UNION 按位置那一遍只往里加标签,另一支取的是遮罩列也不剔;派生来源里的 UNION 根本不按位置看。 于是标签在前、遮罩列在后(不起别名、别名不同、外层再套子查询、CTE 里的标签再 UNION),遮罩列经按原名选出的子查询、 以标签的别名传出,或者两个子查询 JOIN 起来同名,等级 1 撞上 THEN 1 都不收,WHERE vip_level = 1 在查询清单、事件、 质询工件里照原样封存(对基线是回退)。现在 _label_columns 按作用域认每一项取自哪个来源:结果的这一列在 UNION 的 每一支里都要是结果全为写死常量的 CASE / IIF,或者取自子查询、CTE、LATERAL 里只装着标签的一列(派生来源里的 UNION 也按位置看每一支,写了列名表的按列名表);取自数据源里的表的一律不算,哪怕和标签同名。没写表名的列,派生来源出 这一列的就是它(SQL 执行过,别的来源就没有同名的列,不然报列名有歧义),按这一列 USING、NATURAL 合成的除外;递归的 CTE、UNION 里落在某一支 * 里的列认不准,照收(多遮) - 取模、整除:_arithmetic、_worked_out 补上 %、MOD、DIV、//(往零取整,同 SQL)。顺带 / 照数据源的算法算:SQLite、 Postgres 里两个写成整数的数相除只留整数部分(sqlglot 记在 typed 上),以前一律按小数算,age >= 25 AND age < 53 / 2 在 SQLite 里夹住了 25 却认不出;写成小数的(53.0 / 2)、MySQL、DuckDB 照小数算 - CAST 的类型参数(第 2 轮引入,只多遮):_halves 只收往上走得到这一边的字面量(_lifts),CAST(30000 AS DECIMAL(10, 2)) 的 10、2 不再当成算出边界的数换掉,SQL 检查结果的片段里别处的 10、2 也不跟着换;_wraps 里 CAST 只算被转换的那个值, Oracle 的 CAST … DEFAULT 0 ON CONVERSION ERROR 里的 0 照原样 - 模块说明的已知局限照实更新:标签列认得出、认不准的几种写法;认得出的算式补上取模、整除和整数相除,认不出的补上 位运算、POWER、CAST 改了值的 测试:单元补标签在前、遮罩列在后的八种写法(不起别名、别名不同、子查询里的 UNION、写了列名表的 CTE、CTE 里的标签再 UNION、经按原名选出的子查询传出、两个子查询 JOIN 同名、RIGHT JOIN USING 同名列),按作用域仍认得出的五种标签(外层连上 数据源里的表不写表名、按别的列 USING、两层子查询写了表名、UNION 两支、派生来源的 UNION 经 * 带出);取模、整除、整数 相除的六种写法和照小数算的三种;CAST 的类型参数和 DEFAULT。端到端补 UNION 标签在前的两种写法、取模和整数相除算出来的 边界。每处实现都逐个去掉验证过测试变红(十一个变异全红) --- backend/app/engine/inquiry.py | 322 ++++++++++++++++++++------ backend/tests/test_inquiry_api.py | 58 ++++- backend/tests/test_inquiry_masking.py | 128 +++++++++- 3 files changed, 423 insertions(+), 85 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index bcf3400f..969c52de 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -43,9 +43,11 @@ 汇总里套着结果全是写死常量的 CASE(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END))只是按条件数个数,同 COUNT - 按遮罩列分档的标签(CASE、IIF 的结果全是写死的常量):标签文字是 agent 写在 SQL 里的,不当遮罩值,SQL、回答正文里 照原样;标签列当分组键(每一档一行)照常显示,不分组的逐行标签列整列遮(格子里看不出某一笔落在哪一档),可 agent - 在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收 - (选出项本身就是这样的 CASE / IIF,或者经别名、子查询原样传下去的),同一个名字在哪一处装着别的就照收;标签恰好和 - 别的列里的遮罩值相同时,正文里的档名、SQL 里写成字符串的标签也换(多遮) + 在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收: + 结果的这一列在 UNION 的每一支里都是这样的 CASE / IIF,或者按作用域认得出取自子查询、CTE 里只装着标签的一列;有一支 + 取自数据源里的表(遮罩列原样选出、经子查询按原名传出,哪怕起了标签的名字)就照收。认不准的也照收(多遮),比如 + 按这一列 USING、NATURAL 合成的同名列,递归的 CTE,UNION 里落在某一支 * 里的列,写死的字符串、和列一起算的值。 + 标签恰好和别的列里的遮罩值相同时,正文里的档名、SQL 里写成字符串的标签也换(多遮) - 「夹住某一个值」(_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) 再自己相减)认不出,边界照原样留着,正文里复述也不遮; @@ -58,9 +60,11 @@ 隔开的一律按夹住算,分档很多时会成批多遮 - 找某一个值的写法按语法树认常见的几种:等值、IN、简单 CASE、把它夹住的范围,被比较的一边套了 CAST、函数、汇总 (CAST(age AS INTEGER) = 25、HAVING MAX(age) BETWEEN 25 AND 25),值换个写法(25.0、025、2.5e1)、写成几个数的 - 算式(13900000000 + 1、BETWEEN 20 + 5 AND 30 - 5,算式里的数都换)。更绕的写法认不出:值藏进和列一起算的算式 - (age - 25 = 0、buyer_phone - 1 = 13900000000)、拼接('1390' || '0000001')、函数拼出来的值(REPLACE、SUBSTR), - 短的值照原样封存,长号码只在整个写出来时按值换 + 算式(加减乘除、取模、整除:13900000000 + 1、BETWEEN 20 + 5 AND 30 - 5、125 % 100、50 DIV 2,算式里的数都换; + 两个整数相除照数据源的算法,SQLite、Postgres 里 53 / 2 是 26,MySQL 里是 26.5)。更绕的写法认不出:值藏进和列 + 一起算的算式(age - 25 = 0、buyer_phone - 1 = 13900000000)、位运算(24 | 1)、拼接('1390' || '0000001')、 + 函数拼出来的值(REPLACE、SUBSTR、POWER)、CAST 改了值的(CAST(25.6 AS INTEGER) 按 25.6 认,CAST(53 AS REAL) / 2 + 按整数相除算),短的值照原样封存,长号码只在整个写出来时按值换 - 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在 片段里别处出现也换(多遮,算式里换掉的 0、1 这类也是);截断处半截的值去掉(停在字符串、带引号的名字、注释里的, 从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮) @@ -751,12 +755,12 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, """结果里只装着分档标签的列:列名(小写)→ 标签在格子里的写法。标签(CASE、IIF 写死的结果,分档的名字、档位编号) 是 agent 自己写在 SQL 里的字,不是数据源里的值:这几列派生自遮罩列也不收进遮罩值(_Secrets.add),不然 SQL 里的 标签、回答正文里的档名都会被换成「已遮罩」。 - 只装着标签:这一列的选出项本身是结果全为写死常量的 CASE / IIF,或者经别名、子查询、CTE 原样传下去的这样一列 - (_label_texts);同一个名字在哪一处装着别的(vip_level AS 等级、UNION 另一支取的是遮罩列),就不算——改了名字的 - 遮罩列的 1、2、3 撞上同一条 SQL 里按条件数个数的 THEN 1 ELSE 0,照样是遮罩值。没起别名的标签列按位置认(每一支的 - 那一项都是标签)。解析不了的一个都不算(标签照旧当遮罩值,宁可多遮)。""" - from sqlglot import exp - + 只装着标签:结果的这一列在 UNION 这类的每一支里,都是结果全为写死常量的 CASE / IIF,或者取自派生来源(子查询、 + CTE、LATERAL)里只装着标签的一列(_item_label)。一项取自哪个来源按作用域认,不按名字猜:按名字认时,遮罩列原样 + 选出(SELECT vip_level)的名字就是它自己,永远算只装着标签,UNION 另一支、子查询里的遮罩列流进标签列也认不出, + 等级 1 撞上 THEN 1 就不收。取自数据源里的表的(哪怕和标签同名)、有一支装着别的、认不准的(说不清取自哪个来源、 + 递归的 CTE、几支的 * 展开在哪说不清)都不算,照收(宁可多遮);同名的几列有一列不算就都不算。解析不了的一个都 + 不算。""" from app.data.sql_semantics import _parse, _read try: @@ -766,47 +770,53 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, if root is None: return {} try: - items = [item for select in root.find_all(exp.Select) for item in select.expressions] - # 先当每个名字都只装着标签,再去掉装着别的,直到不再变:名字一层层往下传,先认哪一层说不准 - known: dict[str, set[str]] = {name: set() for item in items if (name := _output_name(item))} - for _ in range(len(items) + 2): - found: dict[str, set[str]] = {} - spoiled: set[str] = set() - for item in items: - if not (name := _output_name(item)): - continue - texts = _label_texts(item, known) - if texts is None: - spoiled.add(name) - else: - found.setdefault(name, set()).update(texts) - found = {name: texts for name, texts in found.items() if name not in spoiled} - if found == known: - break - known = found - out = {c.lower(): known[c.lower()] for c in columns if c.lower() in known} - width = len(columns) - for top, role, _listed in _blocks(root): - if role != "result": - continue - branches = _branches(top) - seen: dict[int, list[set[str] | None]] = {} - for branch in branches: - for item, place in zip(branch, _places(branch, width)): - if place is not None and place < width: - seen.setdefault(place, []).append(_label_texts(item, known)) - for place, texts in seen.items(): - if len(texts) == len(branches) and all(t is not None for t in texts): - out.setdefault(columns[place].lower(), set()).update(*texts) - return out + result = next((top for top, role, _listed in _blocks(root) if role == "result"), None) + if result is None: + return {} + memo: _LabelMemo = {} + found: dict[str, list[set[str] | None]] = {} + for place, column in enumerate(columns): + name = column.lower() + found.setdefault(name, []).append(_result_label(result, place, name, len(columns), memo)) + return {name: texts for name, parts in found.items() if (texts := _all_labels(parts)) is not None} except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:一个都不算(宁可多遮) logger.warning("质询的 SQL 标签列分析失败", exc_info=True) return {} -def _label_texts(item: Any, known: dict[str, set[str]]) -> set[str] | None: - """一个选出项只装着标签时,标签在格子里的写法:结果全为写死常量的 CASE / IIF 取它的各个结果(数按两种写法:25、 - 25.0),单独一列、名字在 known 里(经别名、子查询传下来的标签列)取那一列的;不是为 None。""" +#: 标签列分析里「这个来源不出这一列」(_block_label、_source_label):和 None(出这一列,但装着别的、认不准)分开 +_ABSENT: Any = object() +#: 标签列分析记下的结果:(id(那块查询), 列名) → 标签的写法、None 或 _ABSENT +_LabelMemo = dict[tuple[int, str], Any] + + +def _result_label(top: Any, place: int, name: str, width: int, memo: _LabelMemo) -> set[str] | None: + """结果(width 列)的第 place 列(列名 name)只装着标签时标签的写法,不然为 None:UNION 这类的每一支,那一项都要 + 只装着标签。落在 * 里的(认不出是哪一项)按列名经 * 往下找,只有一支时才找(几支的 * 各展开成什么说不清)。""" + from sqlglot import exp + + leaves = _leaves(top) + if not all(isinstance(leaf, exp.Select) for leaf in leaves): + return None + out: set[str] = set() + for select in leaves: + items = list(select.expressions) + item = next((i for i, p in zip(items, _places(items, width)) if p == place), None) + if item is not None: + texts = _item_label(item, select, memo) + elif len(leaves) == 1: + texts = _star_label(select, name, memo) + else: + return None + if texts is None or texts is _ABSENT: + return None + out |= texts + return out + + +def _item_label(item: Any, select: Any, memo: _LabelMemo) -> set[str] | None: + """select 的一个选出项只装着标签时,标签在格子里的写法:结果全为写死常量的 CASE / IIF 取它的各个结果(数按两种 + 写法:25、25.0);单独一列取它在 FROM 里取自的那一列的(_source_label);别的为 None。""" from sqlglot import exp node = item @@ -824,22 +834,165 @@ def _label_texts(item: Any, known: dict[str, set[str]]) -> set[str] | None: elif value is not None: out.add(_plain_text(value)) return out - if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) and node.name.lower() in known: - return known[node.name.lower()] + if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) and node.name: + texts = _source_label(select, node.table.lower() or None, node.name.lower(), memo) + return None if texts is _ABSENT else texts return None +def _star_label(select: Any, name: str, memo: _LabelMemo) -> Any: + """select 经 *(t.* 只看 t)带出的 name 这一列只装着标签时标签的写法;没有 *、* 带不出这一列为 _ABSENT,带出来的 + 装着别的、认不准为 None。""" + from sqlglot import exp + + out: set[str] = set() + seen = False + for item in select.expressions: + if not _is_star(item): + continue + qualifier = (item.table.lower() or None) if isinstance(item, exp.Column) else None + texts = _source_label(select, qualifier, name, memo) + if texts is None: + return None + if texts is not _ABSENT: + out |= texts + seen = True + return out if seen else _ABSENT + + +def _source_label(select: Any, qualifier: str | None, name: str, memo: _LabelMemo) -> Any: + """select 的 FROM、JOIN 里读到的那一列(qualifier 是写的表名或别名,没写为 None)只装着标签时标签的写法;没有哪个 + 来源出这一列为 _ABSENT,出这一列但装着别的、认不准为 None。 + - 写了表名的只看那个来源(对不上的是外层的表,认不准); + - 没写的看所有来源:派生来源出这一列的就是它——SQL 执行过,别的来源就没有同名的列,不然会报列名有歧义。按这一列 + 连起来的(USING 里写着它、NATURAL)例外:几个来源的这一列合成一列,外连接时取的可能是另一边的值; + - 数据源里的表一律不算(遮罩列本身、别的列原样选出来,哪怕和标签同名),VALUES、函数这类来源也认不准。""" + sources = _from_sources(select) + if qualifier is not None: + sources = [(alias, source) for alias, source in sources if alias == qualifier] + if len(sources) != 1: + return None + found: list[set[str]] = [] + opaque = False + for _alias, source in sources: + derived = _derived_block(source) + if derived is None: + opaque = True + continue + texts = _block_label(derived[0], derived[1], name, memo) + if texts is None: + return None + if texts is not _ABSENT: + found.append(texts) + if not found: + return None if opaque else _ABSENT + if opaque and _merging(select, name): + return None + return set().union(*found) + + +def _block_label(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any: + """派生来源的那块查询(listed 是写的列名表)出的 name 这一列只装着标签时标签的写法;不出这一列为 _ABSENT,出这一列 + 但装着别的、认不准为 None。一条 SELECT 按选出项的名字找,没有的经 * 往下找;UNION 这类按位置看每一支,列名取写的 + 列名表,没写的取第一支的。""" + key = (id(block), name) + if key not in memo: + # 先记成 None:递归的 CTE 绕回自己时按认不准算 + memo[key] = None + memo[key] = _block_label_of(block, listed, name, memo) + return memo[key] + + +def _block_label_of(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any: + """_block_label 真去找的那一步(不看记下的结果)。""" + from sqlglot import exp + + leaves = _leaves(block) + if not all(isinstance(leaf, exp.Select) for leaf in leaves): + return None + if listed is None and len(leaves) == 1: + named = [item for item in leaves[0].expressions if _output_name(item) == name] + if not named: + return _star_label(leaves[0], name, memo) + return _all_labels([_item_label(item, leaves[0], memo) for item in named]) + width: int | None = None + if listed is not None: + if name not in listed: + return _ABSENT + place, width = (listed.index(name) if listed.count(name) == 1 else None), len(listed) + else: + first = list(leaves[0].expressions) + places = [p for item, p in zip(first, _places(first, None)) if _output_name(item) == name] + if not places: + # 第一支没明写这一列:有 * 时说不清它在不在、在哪个位置 + return None if any(_is_star(item) for item in first) else _ABSENT + place = places[0] if len(places) == 1 else None + if place is None: + return None + texts: list[set[str] | None] = [] + for select in leaves: + items = list(select.expressions) + item = next((i for i, p in zip(items, _places(items, width)) if p == place), None) + texts.append(_item_label(item, select, memo) if item is not None else None) + return _all_labels(texts) + + +def _all_labels(texts: list[set[str] | None]) -> set[str] | None: + """几项都只装着标签时,标签的写法合起来;有一项不是为 None。""" + return set().union(*texts) if all(t is not None for t in texts) else None + + +def _from_sources(select: Any) -> list[tuple[str, Any]]: + """一条 SELECT 的 FROM、JOIN 里的来源:(名字, 来源节点),名字是别名,没起别名的是表名(小写)。""" + from_ = select.args.get("from_") + nodes = [from_.this] if from_ is not None and from_.this is not None else [] + nodes += [join.this for join in select.args.get("joins") or [] if join.this is not None] + return [((node.alias_or_name or "").lower(), node) for node in nodes] + + +def _derived_block(source: Any) -> tuple[Any, list[str] | None] | None: + """FROM、JOIN 里的一个来源 → (它的那块查询, 写的列名表):FROM 里的子查询、LATERAL、指向 WITH 里一段的表名。数据源里 + 的表、VALUES、函数这类为 None(出哪些列、装着什么,SQL 里看不出)。""" + from sqlglot import exp + + from app.data.sql_semantics import _visible_ctes + + node, alias = source, None + if isinstance(node, exp.Lateral): + node, alias = node.this, node.args.get("alias") + if isinstance(node, exp.Table): + if node.args.get("db") or node.args.get("catalog") or not isinstance(node.this, exp.Identifier): + return None + cte = _visible_ctes(node).get(node.name.lower()) + if cte is None: + return None + node, alias = cte.this, cte.args.get("alias") + while isinstance(node, exp.Subquery): + alias = alias or node.args.get("alias") + node = node.this + if not isinstance(node, (exp.Select, exp.SetOperation)): + return None + return node, ([c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None) + + +def _merging(select: Any, name: str) -> bool: + """FROM 里按同名的 name 这一列连起来(USING 里写着它,或者 NATURAL):几个来源的这一列合成一列。""" + return any(str(join.args.get("method") or "").upper() == "NATURAL" + or any(str(getattr(u, "name", "")).lower() == name for u in join.args.get("using") or []) + for join in select.args.get("joins") or []) + + def _is_star(item: Any) -> bool: from sqlglot import exp return isinstance(item, exp.Star) or (isinstance(item, exp.Column) and isinstance(item.this, exp.Star)) -def _branches(root: Any) -> list[list[Any]]: - """一块查询选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。""" +def _leaves(root: Any) -> list[Any]: + """一块查询的每一支:UNION 这类连起来的按先后摊开,套在外面的括号去掉。一般是 SELECT,也可能是 VALUES 这类。""" from sqlglot import exp - out: list[list[Any]] = [] + out: list[Any] = [] stack = [root] while stack: node = stack.pop() @@ -847,11 +1000,18 @@ def _branches(root: Any) -> list[list[Any]]: stack.append(node.this) elif isinstance(node, exp.SetOperation): stack.extend((node.expression, node.this)) - elif isinstance(node, exp.Select): - out.append(list(node.expressions)) + else: + out.append(node) return out +def _branches(root: Any) -> list[list[Any]]: + """一块查询选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。""" + from sqlglot import exp + + return [list(node.expressions) for node in _leaves(root) if isinstance(node, exp.Select)] + + def _output_name(item: Any) -> str | None: """一个选出项在结果里的列名(小写):起了别名的是别名,直接选一列的是列名。别的表达式(函数、运算、CAST、括号、 子查询)的列名各数据库起法不同(SQLite 照写法原样记「SUBSTR(buyer_phone, 4)」,Postgres 记函数名或 ?column?), @@ -1472,10 +1632,11 @@ def _use_of(node: Any, values: dict[int, Any], lifts: dict[int, Any]) -> tuple[s def _wraps(parent: Any, child: Any) -> bool: - """parent 只是 child 的外壳,不改变它是哪个值:括号、负号、CAST、字符集前缀(_utf8mb4'x')、COLLATE。""" + """parent 只是 child 的外壳,不改变它是哪个值:括号、负号、CAST(只算被转换的那个值,类型参数、DEFAULT 后面的值 + 不算)、字符集前缀(_utf8mb4'x')、COLLATE。""" from sqlglot import exp - return isinstance(parent, (exp.Paren, exp.Neg, exp.Cast)) or ( + return (isinstance(parent, (exp.Paren, exp.Neg, exp.Cast)) and child is parent.this) or ( isinstance(parent, exp.Introducer) and child is parent.expression) or ( isinstance(parent, exp.Collate) and child is parent.this) @@ -1497,10 +1658,11 @@ def _exact_ops() -> dict[Any, str]: def _arithmetic() -> tuple[Any, ...]: - """加减乘除:几个写死的数算出来的整个算式按算出来的那一个值认(13900000000 + 1 就是 13900000001)。""" + """加减乘除、取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //):几个写死的数算出来的整个算式按算出来的那一个值认 + (13900000000 + 1 就是 13900000001,125 % 100 就是 25)。""" from sqlglot import exp - return exp.Add, exp.Sub, exp.Mul, exp.Div + return exp.Add, exp.Sub, exp.Mul, exp.Div, exp.Mod, exp.IntDiv #: 数的写法里指数最大多少(1e999999 这样的写法不按数认,免得展开成上百万位) @@ -1557,19 +1719,27 @@ def _constants(root: Any) -> dict[int, Any]: def _worked_out(node: Any, a: Any, b: Any) -> Decimal | None: - """一步加减乘除的结果;不是两个数、除以零、结果太大为 None。""" + """一步加减乘除、取模、整除的结果;不是两个数、除以零、结果太大为 None。取模、整除照 SQL 往零取整(-7 % 2 是 -1, + -7 DIV 2 是 -3,同 Decimal 的 % 和 //)。/ 在 SQLite、Postgres 这类方言里两个整数相除只留整数部分(解析时记在 + typed 上:53 / 2 是 26),写成小数的(53.0 / 2)、别的方言照小数算。以前一律按小数算,SQLite 里 age >= 25 AND + age < 53 / 2 夹住了 25 却认不出。""" from sqlglot import exp if not (isinstance(a, Decimal) and isinstance(b, Decimal)): return None try: - if isinstance(node, exp.Div): - value = a / b if b else None + if isinstance(node, (exp.Div, exp.IntDiv, exp.Mod)): + if not b: + return None + # 写成整数的数(和它们加减乘出来的)指数正好是 0;53.0、1e1 这样写的在 SQL 里不是整数 + whole = isinstance(node, exp.IntDiv) or ( + node.args.get("typed") and a.as_tuple().exponent == 0 and b.as_tuple().exponent == 0) + value = a % b if isinstance(node, exp.Mod) else a // b if whole else a / b else: value = a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b except ArithmeticError: return None - return value if value is not None and value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None + return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None def _lifts(root: Any, values: dict[int, Any]) -> dict[int, Any]: @@ -1588,16 +1758,19 @@ def _lifts(root: Any, values: dict[int, Any]) -> dict[int, Any]: #: 一个比较的一半:(算出边界的那几个字面量, 另一边的键, 写死的值, 「键 运算符 值」的运算符)。边界是几个数算出来的 -#: (13900000002 - 1),算式里的字面量都记下:夹住了某一个值,留着哪一个都可能算得出它,都换 +#: (13900000002 - 1),算式里的字面量都记下:夹住了某一个值,留着哪一个都可能算得出它,都换。不参与算值的字面量 +#: (CAST(30000 AS DECIMAL(10, 2)) 的类型参数 10、2)不记,见 _halves _Half = tuple[tuple[Any, ...], str, Any, str] #: 一个比较拆成的几半,加上几半怎么连(False:且,BETWEEN;True:或,IN)、是不是等值一类(不等、IN) _Leaf = tuple[list[_Half | None], bool, bool] -def _halves(node: Any, values: dict[int, Any]) -> list[_Half | None]: - """一个比较拆成的几半(values 是 _constants 算好的写死的值):BETWEEN 是两半的「且」(不低于下界、不高于上界), - > >= < <= 是一半,= <> 这类是一半,IN 是一串「=」的「或」。边界不是写死的值(另一列、子查询)或者两边都写死了的, - 这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。""" +def _halves(node: Any, values: dict[int, Any], lifts: dict[int, Any]) -> list[_Half | None]: + """一个比较拆成的几半(values 是 _constants 算好的写死的值,lifts 见 _lifts):BETWEEN 是两半的「且」(不低于下界、 + 不高于上界),> >= < <= 是一半,= <> 这类是一半,IN 是一串「=」的「或」。边界不是写死的值(另一列、子查询)或者 + 两边都写死了的,这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。算出边界的字面量是往上走得到这一边 + 的那几个(_lifts):CAST(30000 AS DECIMAL(10, 2)) 里只有 30000,类型参数 10、2 不算出值,换了只会让封存的 SQL 难读, + SQL 检查结果的片段里别处的 10、2 也会跟着换。""" from sqlglot import exp if isinstance(node, exp.Between): @@ -1615,8 +1788,9 @@ def _halves(node: Any, values: dict[int, Any]) -> list[_Half | None]: out: list[_Half | None] = [] for operand, other, op in sides: value = values.get(id(operand)) if operand is not None else None - out.append((tuple(operand.find_all(*_literal_types())), _key_of(other), value, op) - if value is not None else None) + literals = () if value is None else tuple( + literal for literal in operand.find_all(*_literal_types()) if lifts.get(id(literal)) is operand) + out.append((literals, _key_of(other), value, op) if value is not None else None) return out @@ -1725,9 +1899,9 @@ def _marks(value: Any, op: str) -> list[tuple[Any, int]]: return [(value, 0 if op in (">=", "<") else 1), *steps] -def _pinned(root: Any, values: dict[int, Any]) -> dict[int, tuple[str, ...]]: +def _pinned(root: Any, values: dict[int, Any], lifts: dict[int, Any]) -> dict[int, tuple[str, ...]]: """把某一个值夹住了的边界字面量:id(字面量节点) → 夹住的值(_point_text 的写法)。values 是 _constants 算好的 - 写死的值。 + 写死的值,lifts 见 _lifts。 一个值 v 两边都被隔开(_marks),条件才可能只认 v 这一个值。范围比较在整条 SQL 里配对,只有分档的几种写法各算 各的(_apart:CASE 的不同分支、SELECT 里不筛行的不同项、UNION 的不同支),它们共用的边界是分档的界,不是在找哪 @@ -1754,7 +1928,7 @@ def _pinned(root: Any, values: dict[int, Any]) -> dict[int, tuple[str, ...]]: nearby: dict[tuple[int, str], set[Decimal]] = {} for node in root.find_all(exp.Between, *_range_ops(), *exact_types): is_exact = isinstance(node, exact_types) - parts = _halves(node, values) + parts = _halves(node, values, lifts) if is_exact and all(h is None for h in parts): continue # 连接条件(两边都是列)这类:和夹住哪个值无关 top = _condition_root(node) @@ -2006,7 +2180,7 @@ def _literal_uses(sql: str, kind: str | None) -> dict[int, _Use] | None: try: values = _constants(root) lifts = _lifts(root, values) - pinned = _pinned(root, values) + pinned = _pinned(root, values, lifts) for node in root.walk(): start = (node.meta or {}).get("start") if isinstance(node, _literal_types()) else None if not isinstance(start, int): diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 2780a8dc..f657bf7f 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -14,12 +14,12 @@ 别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现 遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码,两半 拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、CTE 里,边界、等值写成算式,两头不是整数,范围再排除一头, - 号码前面补零……)也一样,年龄这类短的值换着写法找(套了 CAST、汇总值两头相同、补零……)SQL 里也遮;遮罩值不误伤 - 阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、和日期、天数修饰、标签里 - 写着的同一个数也不牵连阈值,分档的文字标签不当遮罩值(当分组键的档位列照常显示),改了名字的遮罩列撞上标签照样遮; - SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,回答和质询工件对得上;质询工件按值遮只出现在报告快照里 - 的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条 - SQL 换(短的数也换),给模型的那句改法不交出去 + 号码前面补零……)也一样,年龄这类短的值换着写法找(套了 CAST、汇总值两头相同、补零、取模和整数相除算出来的边界……) + SQL 里也遮;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、 + 和日期、天数修饰、标签里写着的同一个数也不牵连阈值,分档的文字标签不当遮罩值(当分组键的档位列照常显示),改了 + 名字的遮罩列、UNION 另一支取的遮罩列撞上标签照样遮;SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮, + 回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里 + 只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条 SQL 换(短的数也换),给模型的那句改法不交出去 - 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 数据源、没固定过版本的上传表格不查 - 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 @@ -1051,11 +1051,19 @@ async def test_a_threshold_matching_numbers_left_alone_elsewhere_is_sealed_as_wr pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 20 + 5 AND 30 - 5", "SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩", id="算出来的边界"), + # 第 3 轮复核:取模、整数相除(SQLite 里只留整数部分)算出来的边界 + pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 25 AND age <= 125 % 100", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 % 已遮罩", + id="取模算出来的边界"), + pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 25 AND age < 53 / 2", + "SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age < 已遮罩 / 已遮罩", + id="整数相除算出来的边界"), ]) async def test_a_short_masked_value_looked_up_in_other_shapes_is_sealed_masked(monkeypatch, tmp_path, client, sql, sealed): """年龄设了遮罩,agent 换着写法找某一个年龄(套了 CAST、汇总值两头相同、补零、两头不是整数、范围再排除一头、 - 算出来的边界):第 2 轮复核时都照原样封存。现在查询清单、事件、质询工件里的 SQL 都换掉这个年龄,三处一字不差。""" + 算出来的边界,取模、整数相除算出来的边界):第 2、3 轮复核时都照原样封存。现在查询清单、事件、质询工件里的 SQL + 都换掉这个年龄,三处一字不差。""" 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}", @@ -1124,6 +1132,42 @@ async def test_a_renamed_masked_column_keeps_values_that_match_labels_written_el await engines.invalidate(source_id) +@pytest.mark.parametrize("mixed", [ + pytest.param("SELECT CASE WHEN amount > 1000 THEN 1 ELSE 0 END AS 等级 FROM sales UNION ALL " + "SELECT vip_level FROM sales", id="标签在前、遮罩列在后"), + pytest.param("SELECT CASE WHEN amount > 1000 THEN 1 ELSE 0 END AS 等级 FROM sales UNION ALL " + "SELECT b.vip_level AS 等级 FROM (SELECT vip_level FROM sales) b", id="遮罩列经子查询按原名传出"), +]) +async def test_a_masked_column_after_a_label_in_a_union_keeps_its_values(monkeypatch, tmp_path, client, mixed): + """第 3 轮复核:UNION 的第一支是按条件数大单的标签(THEN 1 ELSE 0),另一支取的是等级(遮罩列原样选出,或者经按 + 原名选出的子查询、以标签的别名传出)。以前这一列被当成只装着标签,等级 1 撞上标签 1 不收,之后 WHERE vip_level = 1 + 在查询清单、事件、质询工件里都照原样,= 2 却遮了。现在这一列在一支里取的是遮罩列,就照收,两条都遮。""" + 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([mixed, 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 = [mixed, *["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" diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 3d5947ac..0ecd52f1 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -9,8 +9,9 @@ - SQL 里数字照原样留着的只有范围比较的边界(没把某一个值夹住的)和 LIMIT / OFFSET 的行数;在找某一个值的(等值、IN、 LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、 >= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换,被比较的一边套了 CAST、汇总也算;同一个数换个 - 写法(25.0、025、2.5e1)、写成几个数的算式(25 + 0、13900000000 + 1)照样认,算式里的数都换;选出列、函数参数、 - 算式里够长的遮罩值也换;字符串(范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免 + 写法(25.0、025、2.5e1)、写成几个数的算式(25 + 0、13900000000 + 1、125 % 100、50 DIV 2,整数相除照数据源的算法) + 照样认,算式里的数都换(CAST 的类型参数不算);选出列、函数参数、算式里够长的遮罩值也换;字符串(范围边界也一样)、 + 注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免 - 「夹住某一个值」在整条 SQL 里认,只有分档的几种写法各算各的:CASE 的不同分支、不同的选出列(连同 GROUP BY、 ORDER BY)、UNION 的不同支,共用的边界是阈值;同一行集上的条件(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、 包在 CASE / 函数 / 算式里的一半、子查询、CTE)照样认得出在找哪一个值。同一个条件里按合起来的真假认(旁边的别的 @@ -22,8 +23,9 @@ 同一个短的数,没设遮罩的列上的等值,都不牵连阈值;不留的照等值的办法换,别的列上的短阈值、LIMIT 撞上别处换掉的 短的数照原样(已知局限) - 按遮罩列分档的标签(CASE、IIF 的结果都是写死的常量)是 agent 自己写在 SQL 里的,不当遮罩值:SQL、回答正文里照原样; - 当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮。只有只装着标签的列不收(经别名、子查询 - 传下去、没起别名的也算),改了名字的遮罩列撞上同一条 SQL 里的 THEN 1 ELSE 0 照收 + 当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮。只有只装着标签的列不收(按作用域认取自 + 哪个来源:经别名、子查询、CTE 传下去、没起别名的也算),改了名字的遮罩列撞上同一条 SQL 里的 THEN 1 ELSE 0 照收, + UNION 另一支、子查询里的遮罩列流进标签列也照收 - 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里, 没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换(截断的 片段从没闭合的引号、注释起去掉,说明里的码值表和别的字一样按值换) @@ -278,6 +280,75 @@ def test_labels_passed_on_under_their_own_name_are_still_not_masked_values(sql, assert plan.secrets.scrub("小单 3 笔、大单 2 笔") == "小单 3 笔、大单 2 笔" +#: 按条件数大单写的 1、0:和会员等级(1、2、3)撞在一起 +BIG_ONE = "CASE WHEN amount > 1000 THEN 1 ELSE 0 END" +#: 标签那一支的 1、0 在前,等级 1、2、3 在后 +LEVEL_ROWS = [[1], [0], [0], [1], [1], [2], [3], [1]] + + +@pytest.mark.parametrize("sql, columns, rows", [ + # UNION 标签在前、遮罩列在后:遮罩列没起别名,或者起了别的名字 + (f"SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level FROM sales", ["等级"], LEVEL_ROWS), + (f"SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level AS lv FROM sales", ["等级"], LEVEL_ROWS), + # 子查询里的 UNION,外层按名字取;写了列名表的 CTE 里的 UNION + (f"SELECT 等级, COUNT(*) AS 人数 FROM (SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level FROM sales) t " + "GROUP BY 等级", ["等级", "人数"], [[0, 2], [1, 4], [2, 1], [3, 1]]), + (f"WITH t(lv) AS (SELECT {BIG_ONE} FROM sales UNION ALL SELECT vip_level FROM sales) SELECT lv AS 等级 FROM t", + ["等级"], LEVEL_ROWS), + # 标签写在 CTE 里,结果的 UNION 另一支取遮罩列 + (f"WITH b AS (SELECT {BIG_ONE} AS lvl FROM sales) SELECT lvl FROM b UNION ALL SELECT vip_level FROM sales", + ["lvl"], LEVEL_ROWS), + # 遮罩列经按原名选出的子查询、以标签的别名传出 + (f"SELECT {BIG_ONE} AS 档 FROM sales UNION ALL SELECT b.vip_level AS 档 FROM (SELECT vip_level FROM sales) b", + ["档"], LEVEL_ROWS), + # 不用 UNION:两个子查询连起来,一边是标签,一边是改了名字的遮罩列,名字相同 + (f"SELECT y.档 AS 档 FROM (SELECT id, {BIG_ONE} AS 档 FROM sales) x " + "JOIN (SELECT id, vip_level AS 档 FROM (SELECT id, vip_level FROM sales) z) y ON x.id = y.id", ["档"], + [[1], [2], [3], [1]]), + # USING 连起来的同名列:右连接时,左边没对上的行取的是右边那张表里的值 + (f"WITH b AS (SELECT id, {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 等级 FROM b " + "RIGHT JOIN members USING (vip_level)", ["等级"], [[1], [2], [3]]), +]) +def test_a_column_that_also_carries_the_masked_column_is_not_a_label_column(sql, columns, rows): + """第 3 轮复核:只装着标签的列以前按名字认,从「所有名字都只装着标签」起往下剔,遮罩列原样选出(SELECT vip_level) + 时名字就是它自己,永远剔不掉;结果的 UNION 按位置那一遍只往里加标签,另一支取的是遮罩列也不剔,派生来源里的 + UNION 根本不按位置看。于是标签在前、遮罩列在后时,等级 1 撞上 THEN 1 就不收,之后 WHERE vip_level = 1 照原样 + 封存。现在按作用域认每一项取自哪个来源,结果的这一列在每一支里都只装着标签才算,取自数据源里的表的一律不算。""" + plan = plan_of({"source": "shop", "sql": sql, "columns": columns, "rows": rows}, {"vip_level"}) + assert inquiry_mod._label_columns(sql, "sqlite", columns) == {} + 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 = 已遮罩") + + +_EXTRA = "CASE WHEN amount > 60000 THEN '特大单' END" + + +@pytest.mark.parametrize("sql, columns, texts", [ + # CTE 里分档,外层连上数据源里的表、按档位汇总:档位没写表名也认得出取自 CTE(SQL 能执行,别的表就没有同名的列, + # 不然会报列名有歧义) + (f"WITH banded AS (SELECT id, {BANDS} AS 档位 FROM sales) SELECT 档位, SUM(s.visits) AS 访问 FROM banded b " + "JOIN sales s ON s.id = b.id GROUP BY 档位", ["档位", "访问"], {"小单", "大单"}), + # 按别的列连起来(USING (id)):档位没合成一列,照样取自 CTE + (f"WITH banded AS (SELECT id, {BANDS} AS 档位 FROM sales) SELECT 档位, SUM(o.amount) AS 金额 FROM banded " + "JOIN orders o USING (id) GROUP BY 档位", ["档位", "金额"], {"小单", "大单"}), + # 写了表名、经两层子查询传下来 + (f"SELECT t.档位 FROM (SELECT b.档位 FROM (SELECT {BANDS} AS 档位 FROM sales) b) t", ["档位"], {"小单", "大单"}), + # UNION 两支都是标签(第二支没写 ELSE、没起别名):两支的标签都算 + (f"SELECT {BANDS} AS 档位 FROM sales UNION ALL SELECT {_EXTRA} FROM sales", ["档位"], {"小单", "大单", "特大单"}), + # 派生来源里的 UNION 两支都是标签,外层经 * 带出来 + (f"SELECT * FROM (SELECT {BANDS} AS 档位 FROM sales UNION ALL SELECT {_EXTRA} FROM sales) t", ["档位"], + {"小单", "大单", "特大单"}), +]) +def test_label_columns_are_traced_to_where_they_are_written(sql, columns, texts): + """改成按作用域认之后,写在别处、原样传下来的标签照样认得出:外层连上数据源里的表没写表名、按别的列 USING、写了 + 表名经两层子查询;UNION 两支都是标签的,每一支的标签都算(以前派生来源里的 UNION 按名字认,只算起了这个名字的 + 第一支)。""" + assert inquiry_mod._label_columns(sql, "sqlite", columns) == {"档位": texts} + + # -------------------------------------------------------------------------- # 按值认:门槛、边界 # -------------------------------------------------------------------------- @@ -505,6 +576,55 @@ def test_a_threshold_worked_out_from_several_numbers_is_kept_like_one_number(amo assert inquiry_mod._shown_literals([(swapped, "sqlite", {"amount"})], amounts) == set() +@pytest.mark.parametrize("kind, sql, sealed", [ + # 取模、整除算出来的边界和等值:125 % 100、MOD(125, 100) 是 25,50 DIV 2、51 // 2 也是 + ("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 125 % 100", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 % 已遮罩"), + ("postgresql", "SELECT COUNT(*) FROM sales WHERE age = MOD(125, 100)", + "SELECT COUNT(*) FROM sales WHERE age = MOD(已遮罩, 已遮罩)"), + ("mysql", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 50 DIV 2", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 DIV 已遮罩"), + ("duckdb", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 51 // 2", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 // 已遮罩"), + # SQLite、Postgres 里两个整数相除只留整数部分:53 / 2 是 26,age < 26 和 age >= 25 夹住 25 + ("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < 53 / 2", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age < 已遮罩 / 已遮罩"), + ("postgresql", "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone < 27800000005 / 2", + "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone < 已遮罩 / 已遮罩"), +]) +def test_a_value_worked_out_with_remainders_or_whole_division_is_read_as_that_value(phones, kind, sql, sealed): + """第 3 轮复核:取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //)写成的算式以前不按算出来的值认,age >= 25 AND + age <= 125 % 100 照原样封存;整数相除在 SQLite、Postgres 里只留整数部分,以前按小数算(53 / 2 算成 26.5), + 夹住的值也认不出。现在都按数据源算出来的那一个值认,算式里的数都换,正文里也不免遮。""" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == sealed + assert not inquiry_mod._shown_literals([(sql, kind, {"buyer_phone", "age"})], phones) & {"25", "13900000001"} + + +@pytest.mark.parametrize("kind, divided", [("mysql", "53 / 2"), ("duckdb", "53 / 2"), ("sqlite", "53.0 / 2")]) +def test_division_that_keeps_the_fraction_is_read_as_written(phones, kind, divided): + """MySQL、DuckDB 的 / 照小数算,SQLite 里写成小数的也是:53 / 2、53.0 / 2 是 26.5,age >= 25 AND age < 26.5 里 + 25、26 都成立,没夹住,照原样。""" + sql = f"SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < {divided}" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == sql + + +def test_type_parameters_of_a_cast_bound_are_left_alone(amounts): + """第 3 轮复核:夹住某一个值的边界写成 CAST(v AS DECIMAL(10, 2)) 时,类型参数 10、2 以前也当成算出边界的数换掉, + 封存的 SQL 成了 DECIMAL(已遮罩, 已遮罩),SQL 检查结果的片段里别处的 10、2 也跟着换。只换算出边界的那个数;Oracle + 的 CAST … DEFAULT 0 ON CONVERSION ERROR 里的 0 也不算出值,照原样。""" + sql = ("SELECT COUNT(*) FROM sales WHERE amount >= CAST(30000 AS DECIMAL(10, 2)) " + "AND amount <= CAST(30000 AS DECIMAL(10, 2))") + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sql.replace("30000", MASKED) + assert inquiry_mod._literal_swaps(sql, amounts, {"amount"}, "sqlite") == {"30000"} + checks = [{"code": "stock_summed", "level": "warning", "table": "sales", "message": "存量跨日期求和", + "sql_excerpt": "ROUND(SUM(amount), 2) >= 10"}] + [check] = inquiry_mod._masked_checks(checks, sql, amounts, {"amount"}, "sqlite") + assert check["sql_excerpt"] == "ROUND(SUM(amount), 2) >= 10" + fallback = ("SELECT COUNT(*) FROM sales WHERE amount >= CAST('30000' AS NUMBER DEFAULT 0 ON CONVERSION ERROR) " + "AND amount <= CAST('30000' AS NUMBER DEFAULT 0 ON CONVERSION ERROR)") + assert inquiry_mod._scrub_sql(fallback, amounts, {"amount"}, "oracle") == fallback.replace("30000", MASKED) + + @pytest.mark.parametrize("sql, sealed", [ # 遮罩列套了 CAST、汇总:被比较的一边取的还是它的值,短的数照样换 ("SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) = 25",