Files
AgentLab/backend/app/engine/masking.py
T
ThearchyHelios b0fdf56eed K3 复核修正(第 3 轮):写了库名的表按「库名.表名」查表结构;UNION 第一支按表结构数出的列名要和后面每一支一样宽才用;整行引用、函数参数里的 *、表的列名表、TABLE x、PIVOT 这类认得出
- 写了库名的表(复核 major 1):表结构以前按表名查,查询写的是另一个库里的同名表(archive.sales,列序不同)时照探查
  的那张数位置,号码原样交给模型、落库。现在表结构另按「库名.表名」记一份(库名取探查时记下的 schema、qualified 的
  前缀,masking._schema_keys),写了库名的引用按它查(_table_key),查不到的按数不清整段遮;探查时没设 schema、查询又
  写了库名的也查不到(多遮)。数位置(_star_columns、_branch_names)、认标签列(_table_lacks)都照这个查法
- 过期的表结构(复核 major 2,对 099473c 的回退):UNION 第一支是 * 时,按表结构数出的列名以前不核对列数就拿来对后一支
  的位置,表结构过期、列数已经变了也照对,遮罩值记到错的列名上。现在后面每一支数得出的列数都和它一样宽才用
  (_source_names、_width),对不上、数不清的退回整段遮
- 整行和别的 * 写法(复核 major 3):写在函数参数、表达式里的 *、s.*(to_json(s.*)、concat_ws(',', s.*)、ROW(s.*)、
  Oracle 的 JSON_OBJECT(*))按它展开的来源认(_value_columns、_scoped_star_inputs,相关子查询、FROM 里的子查询照样
  找得到外层的来源)。几种写法先改写语法树再认(_normalized,原文不动):Postgres 的整行引用(row_to_json(s)、
  json_agg(t)、SELECT s、s::text)按 Postgres 先认列的规矩,不是列的改成 ROW(s.*);带列名表的表(FROM sales AS
  s(i, r, p),引用 CTE 时写的也算)改成派生来源;解析器读错的 TABLE x 改成 SELECT * FROM x;选出项套了括号的 *
  ((s.*)、(s).*)按 * 数位置。PIVOT、UNPIVOT、MATCH_RECOGNIZE 读到遮罩列的整份遮。SQL 解析不了、里面有 *(COUNT(*)
  除外)或 TABLE x 的(UNION ALL TABLE x、带库名的 TABLE),同写着遮罩列一样整份遮(_implicit)
- Postgres 的列名表只写前几列时,不按表结构补全:别的方言里列名表比列少只能是表结构过期了,补全就丢了列数这道核对;
  这时改了名的几列整段遮(多遮),照原名的按名字认
- inquiry 模块说明的已知局限补上:写了库名的表、过期表结构的列数核对、Postgres 列名表只写前几列时整段遮、探查的不是
  默认库时没写库名的同名表可能数错、整行引用按表结构先认列(查不到时按整行多遮)、解析不了的 SQL 里的整行引用认不出

测试:新增 test_masking_whole_rows(整行、函数参数里的 *、列名表、TABLE x、Oracle 的 JSON_OBJECT(*) / UNPIVOT / PIVOT /
MATCH_RECOGNIZE、解析不了的 SQL、不碰遮罩列的照常);test_masking_star_renames 补写了库名的表(SQLite ATTACH 真跑)、
过期的表结构;test_verify_agent_masking 补核查 agent 的真实查询工具:过期的表结构,Postgres / Oracle 形状的结果(换掉
驱动)交给模型、落库、通用工件接口都遮好。每处实现都做过去掉之后测试变红的验证;复核给的 Postgres 18 探针、端到端
在临时库上重跑,都不再漏。
2026-10-06 21:50:48 +08:00

2080 lines
118 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""遮罩:一份查询结果里哪些列要遮(按 SQL 认出改了名字的遮罩列)、哪些值是遮罩值(按值认)。
数据源在 options.mask_columns 里设了遮罩的列(手机号、姓名这类),证据面板按列名遮(api/evidence.py)。核查 agent
(engine/verify_agent.py:双盲复算、反方审稿、审批质询共用)自己写 SQL,起别名、套函数、经子查询转一道,结果列名就
对不上遮罩列;按值认又有门槛和写法的问题。认法集中在这里,核查 agent 的查询工具(tools/datasource 的遮罩开关)、审批
质询(engine/inquiry.py)、双盲复算(nodes/dual_check.py,经 inquiry 的同名导出)共用:
- 工具边界先遮(mask_query_result):核查 agent 的查询结果交给模型、落进快照之前,取自遮罩列的格子换成「已遮罩」,
别的格子按值换:这份结果里的遮罩值,加上这个 agent 前面的查询遮掉的、调用方交进来的(报告快照里的)。模型看不到
遮罩值,就不会把它写进回答、理由、SQL——事后按值认每一轮复核都找到新的漏法,根治靠这一步;审批质询回答、反方
审稿理由里的按值遮降为第二道防线。查询失败时报错原话里的值也换(mask_error_text)
- 哪些列(_derived_hidden):从遮罩列本身起,按 SQL 一层层认出值取自遮罩列的结果列(别名、函数、子查询、WITH、UNION 的
另一支、和 * 写在同一支里没起别名的表达式、FROM 里拆开遮罩列的表值函数、函数参数里的 JSON 箭头 col->'path');* 把
遮罩列带进别的列名的(写了列名表的派生来源、UNION 后一支的 *、标量子查询里的 *)按它展开落到的位置算(_star_places,
展开数据源里的表的按查询当时冻结的表结构数位置,_schema_columns;写了库名的表按「库名.表名」查,另一个库里的同名表
查不到)。整行和函数参数里的 *(Postgres 的 row_to_json(s)、SELECT s、to_json(s.*),Oracle 的 JSON_OBJECT(*))按 *
展开的来源算;表的列名表(FROM sales AS s(i, r, p))、TABLE x、套了括号的 * 先改写成认得出的样子(_normalized)。
COUNT 只数个数、窗口的分区键只决定次序、按遮罩列分档又当分组键或套在汇总里数个数的、汇总里的 CASE 只在条件里用到的
不算(_value_columns、_band_only)。认不出落在哪一列的(PIVOT、UNPIVOT 这类也算)、SQL 解析不了又写着遮罩列(或者 *、
TABLE x)的,整份快照的列都算。双盲复算按同一套认法在派盲算之前挡下取自遮罩列的指标(derived_columns)
- 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值;* 展开数据源里的表时按冻结的表结构
认它带不带同名的列(_schema_tables)
- 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),换的时候先按词、按开头几个字筛出
正文里真有的那几个值,不现编上万个分支的正则;正文、文字格、SQL 的字符串和注释里换了写法的长号码(199-1474-2785、
+86 …、全角数字)按纯数字写法在集合里查(loose_spans);已经摆在读者眼前的值免遮,之后并进来的照样免(protected)
- 快照遮好的样子(_masked_view),回答时才认得出、事后要照位置重放的地方(view_edits、replay_edits)
- 一次核查里认出的遮罩值(SeenValues):调用方交进来的和 agent 查询时遮掉的分开记,合起来时 agent 查到的在前
除 mask_query_result、换了写法的号码和这一轮新加的几样之外,这里的函数原来都在 engine/inquiry.py 里,名字不变,
inquiry 照旧导出(别处按 inquiry 的名字引用)。
"""
from __future__ import annotations
import contextlib
import functools
import json
import logging
import re
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from app.engine.evidence import MARKER_RE, RenderError, render_cell
from app.engine.expressions import numeric_text
from app.engine.merge_query import MERGE_SOURCE
logger = logging.getLogger(__name__)
#: 按值认遮罩值的门槛:文字至少这么多字(一个字的「是」「男」换掉,会把不相干的词也换得面目全非);纯数字至少这么多位
#: (年龄、等级、小额金额这类短的数和阈值、天数、日期、LIMIT 里的数太容易撞上,按值换会把实际执行的 SQL、提问人自己
#: 的条件改得面目全非。它们只在 SQL 里和遮罩列做等值一类比较时才换,见 _scrub_sql;引用到的格子照旧按列遮)
SECRET_MIN = 2
SECRET_DIGITS = 5
#: 遮罩列的值最多收这么多种写法(一份快照最多上千行):再多就按已经收到的换
SECRETS_MAX = 20_000
#: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截)
_WORD = re.compile(r"[0-9A-Za-z_]")
def _query_shaped(content: Any) -> bool:
return isinstance(content, dict) and isinstance(content.get("rows"), list)
def _masked_word() -> str:
from app.api.evidence import MASKED
return MASKED
# --------------------------------------------------------------------------
# 遮罩:哪些列
# --------------------------------------------------------------------------
#: _value_columns 里「值可能取自数据源里一张表的任何一列」:套在里面的子查询里的 * 展开了表,SQL 里看不出这张表有哪几列。
#: 按它认(_reads)一律算取自遮罩列(宁可多遮);真实的列名不会是这个
_ANY_COLUMN = "*"
def _value_columns(node: Any) -> set[str]:
"""一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,结果全是写死常量的
分档(_band_only:当分组键,或者套在汇总里按条件数个数)只决定一行落在哪一档,都带不出值,不算;套在里面的子查询
只看它选出来的列(它的条件、连接里写的列带不出值)。汇总里的 CASE / IIF 只看各个结果:条件只决定这一行算不算进来,
同写在 WHERE 里(SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END) 的值全部来自金额);不汇总的逐行
CASE 照旧连条件一起算(每一行落在哪一支本身就是这一行的信息)。
套在里面的子查询选的是 *(标量子查询、ARRAY(SELECT * …)):值取自 * 展开的来源(_star_inputs),派生来源看它的各个
选出项,表值函数看参数,展开了数据源里的表的记成 _ANY_COLUMN;EXISTS 里的 * 只说有没有,不算。写在函数参数、表达式
里的 *(to_json(s.*)、concat_ws(',', s.*)、ROW(s.*)、Oracle 的 JSON_OBJECT(*),Postgres 的整行引用改写成的 ROW(s.*),
见 _normalized)同样按它展开的来源算(_scoped_star_inputs);COUNT(*)、count(s.*) 只数个数,不算。函数参数里的
col->'path'(JSON 箭头)被解析器当成以 col 为参数的 lambda,参数名照列名收(真的 lambda 的参数多算一列无害)。"""
from sqlglot import exp
out: set[str] = set()
stack = [node]
# 已经展开过的派生来源:递归的 CTE 里 * 展开它自己时不再绕回去
expanded = {id(node)}
while stack:
item = stack.pop()
if isinstance(item, exp.Star) or (isinstance(item, exp.Column) and isinstance(item.this, exp.Star)):
inputs = _scoped_star_inputs(item)
if inputs is None:
out.add(_ANY_COLUMN)
continue
fresh = [n for n in inputs if id(n) not in expanded]
expanded.update(id(n) for n in fresh)
stack.extend(fresh)
elif isinstance(item, exp.Column):
if item.name:
out.add(item.name.lower())
elif isinstance(item, exp.Count) or (isinstance(item, (exp.Case, exp.If)) and _band_only(item)):
continue
elif isinstance(item, (exp.Case, exp.If)) and (results := _branch_results(item)) is not None \
and _aggregated(item):
stack.extend(r for r in results if r is not None)
elif isinstance(item, exp.Window):
stack.append(item.this)
elif isinstance(item, exp.Lambda):
out.update(p.name.lower() for p in item.expressions if isinstance(p, exp.Identifier) and p.name)
stack.append(item.this)
elif isinstance(item, exp.Select):
for part in item.expressions:
if not _is_star(part):
stack.append(part)
elif not isinstance(item.parent, exp.Exists):
inputs = _star_inputs(part, item)
if inputs is None:
out.add(_ANY_COLUMN)
continue
fresh = [n for n in inputs if id(n) not in expanded]
expanded.update(id(n) for n in fresh)
stack.extend(fresh)
else:
stack.extend(item.iter_expressions())
return out
def _reads(names: set[str], columns: set[str]) -> bool:
"""值取自 columns(_value_columns)的一项是不是取自 names:有一列对上,或者 * 展开了数据源里的表(_ANY_COLUMN,说不清
带出的是哪一列)。"""
return bool(names & columns) or _ANY_COLUMN in columns
def _aggregated(node: Any) -> bool:
"""node 套在同一层 SELECT 的汇总函数里(SUM(…)、AVG(…) OVER (…) 也算)。"""
from sqlglot import exp
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
return False
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
if _aggregated(node):
return True
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 _label_columns(sql: Any, kind: str | None, columns: list[str], tables: dict[str, set[str]] | None = None
) -> dict[str, set[str]]:
"""结果里只装着分档标签的列:列名(小写)→ 标签在格子里的写法。标签(CASE、IIF 写死的结果,分档的名字、档位编号)
是 agent 自己写在 SQL 里的字,不是数据源里的值:这几列派生自遮罩列也不收进遮罩值(_Secrets.add),不然 SQL 里的
标签、回答正文里的档名都会被换成「已遮罩」。
只装着标签:结果的这一列在 UNION 这类的每一支里,都是结果全为写死常量的 CASE / IIF,或者取自派生来源(子查询、
CTE、LATERAL)里只装着标签的一列(_item_label)。一项取自哪个来源按作用域认,不按名字猜:按名字认时,遮罩列原样
选出(SELECT vip_level)的名字就是它自己,永远算只装着标签,UNION 另一支、子查询里的遮罩列流进标签列也认不出,
等级 1 撞上 THEN 1 就不收。取自数据源里的表的(哪怕和标签同名)、有一支装着别的、认不准的(说不清取自哪个来源、
递归的 CTE、几支的 * 展开在哪说不清)都不算,照收(宁可多遮);同名的几列有一列不算就都不算。解析不了的一个都
不算。tables 是查询当时冻结的表结构里每张表的列名(_schema_tables):没写表名的 * 展开的表查得到、又没有同名的列
时,这张表带不出这一列(_table_lacks),不再因为它不算。"""
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 {}
try:
# 和认遮罩列时一样先改写(套了括号的 * 按 * 数位置)。这里的表结构只有列名的集合,认整行引用时不看它(多遮)
_normalized(root, _read(kind), None)
result = next((top for top, role, _listed in _blocks(root) if role == "result"), None)
if result is None:
return {}
memo = _LabelMemo()
memo.tables = tables
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 {}
#: 标签列分析里「这个来源不出这一列」(_block_label、_source_label):和 None(出这一列,但装着别的、认不准)分开
_ABSENT: Any = object()
class _LabelMemo(dict): # type: ignore[type-arg]
"""标签列分析记下的结果:(id(那块查询), 列名) → 标签的写法、None 或 _ABSENT。tables 是冻结的表结构里每张表的列名
(小写,_schema_tables),没有为 None。"""
tables: dict[str, set[str]] | None = None
def _schema_keys(key: Any, meta: dict[str, Any]) -> set[str]:
"""表结构里的一张表按哪些名字查(小写,查法见 _table_key):没写库名的引用按登记的名字、不带库名的表名查;写了库名的
(archive.sales)按「库名.表名」查,库名取表结构记下的 schema 和 qualified 的前缀。表结构探查的是一个库(schema),
查询写了别的库名的同名表是另一张表(列的次序可能不一样),查不到,按说不清处理;探查时没设 schema 的,表结构里没有
库名,写了库名的一律查不到(宁可多遮)。"""
name = str(key).lower()
qualified = str(meta.get("qualified") or key).lower()
bare = qualified.rsplit(".", 1)[-1]
keys = {name, name.rsplit(".", 1)[-1], bare, qualified}
if meta.get("schema"):
keys.add(f"{str(meta['schema']).lower()}.{bare}")
return keys
def _schema_tables(schema: Any) -> dict[str, set[str]] | None:
"""冻结的表结构(数据源的 schema_cache、表结构快照的内容:{"tables": {表名: {"columns": [{"name": …}]}}})→
{表名(小写): 列名(小写)},按 _schema_keys 的几个名字各记一份(几个库里同名的表合起来算,宁可多遮)。表结构
里没列出的表不记(截断了的表结构也一样),查不到就当说不清;整份拿不到的为 None。"""
tables = schema.get("tables") if isinstance(schema, dict) else None
if not isinstance(tables, dict):
return None
out: dict[str, set[str]] = {}
for key, meta in tables.items():
columns = meta.get("columns") if isinstance(meta, dict) else None
if not isinstance(columns, list):
continue
names = {str(c["name"]).lower() for c in columns if isinstance(c, dict) and c.get("name") is not None}
for name in _schema_keys(key, meta):
out.setdefault(name, set()).update(names)
return out
def _schema_columns(schema: Any) -> dict[str, list[str]] | None:
"""冻结的表结构 → {表名(小写): 按表里次序的列名(小写)},名字的记法同 _schema_tables;几个库里同名、列又不一样的表
不按表名记(说不清 * 展开的是哪一张)。* 展开数据源里的表时按它数位置(_star_columns);整份拿不到的为 None。"""
tables = schema.get("tables") if isinstance(schema, dict) else None
if not isinstance(tables, dict):
return None
out: dict[str, list[str]] = {}
clashed: set[str] = set()
for key, meta in tables.items():
columns = meta.get("columns") if isinstance(meta, dict) else None
if not isinstance(columns, list) or not all(isinstance(c, dict) and c.get("name") is not None for c in columns):
continue
names = [str(c["name"]).lower() for c in columns]
for name in _schema_keys(key, meta):
if out.setdefault(name, names) != names:
clashed.add(name)
return {name: names for name, names in out.items() if name not in clashed}
def _table_key(source: Any) -> str:
"""FROM 里一张数据源里的表按什么名字查表结构(_schema_keys):没写库名的是表名,写了的连着库名(archive.sales,写了
三段的连着 catalog),小写。"""
return ".".join(part for part in (source.catalog, source.db, source.name) if part).lower()
def _table_lacks(source: Any, name: str, memo: _LabelMemo) -> bool:
"""FROM 里的这个来源是数据源里的一张表,冻结的表结构里查得到它、又没有 name 这一列:* 展开它带不出 name。写了库名的
按「库名.表名」查(_table_key)。"""
from sqlglot import exp
if not memo.tables or not isinstance(source, exp.Table) or not isinstance(source.this, exp.Identifier):
return False
columns = memo.tables.get(_table_key(source))
return columns is not None and name not in columns
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
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
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, *, before: int | None = None) -> Any:
"""select 经 *(t.* 只看 t)带出的 name 这一列只装着标签时标签的写法;没有 *、* 带不出这一列为 _ABSENT,带出来的
装着别的、认不准为 None。before 给了只看排在它前面的那几项(_block_label_of 看同名项前面有没有 *)。
没写表名的 * 展开了数据源里的表(或 VALUES、函数这类说不清出哪些列的来源)为 None:表里可能就有这一列,* 照样把它
带出来,不像直接写列名那样会报列名有歧义,_source_label 那条「派生来源出这一列的就是它」在这里不成立。"""
from sqlglot import exp
out: set[str] = set()
seen = False
for item in select.expressions[:before]:
if not _is_star(item):
continue
qualifier = (item.table.lower() or None) if isinstance(item, exp.Column) else None
if qualifier is None and any(_derived_block(source) is None and not _table_lacks(source, name, memo)
for _, source in _from_sources(select)):
return 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 = opaque or not _table_lacks(source, name, memo)
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:
items = list(leaves[0].expressions)
named = [i for i, item in enumerate(items) if _same_name(item, name)]
if not named:
return _star_label(leaves[0], name, memo)
# SQLite 让同名的几列都留着,外层按名字取的是排在最前面的那一列:第一个同名项前面的 * 带得出这一列(或说不清带
# 不带),取到的就是 * 里的那一列(SELECT *, CASE … END AS vip_level 交回的是真实等级)
if _star_label(leaves[0], name, memo, before=named[0]) is not _ABSENT:
return None
return _all_labels([_item_label(items[i], leaves[0], memo) for i 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 _same_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)
#: 表值函数常见的出列名:SQLite 的 json_each / json_tree(key、value、atom、fullkey、path),Postgres 的 jsonb_each
#: (key、value),Spark 的 explode / posexplode(col、pos),Snowflake 的 FLATTEN(key、value、path、index、this)。
#: 结构性的几列(id、parent、type)带不出值,不算——算上的话,同一条 SQL 里别的 id 列都会被遮
_FUNCTION_COLUMNS = frozenset({"key", "value", "atom", "fullkey", "path", "col", "pos", "index", "this"})
#: 出列名认得全的表值函数:出的是上面那几列,或者一列、名字就是别名(没起别名的是函数名)
_TABLE_FUNCTIONS = frozenset({
"json_each", "json_tree", "jsonb_each", "jsonb_each_text", "json_each_text", "json_array_elements",
"json_array_elements_text", "jsonb_array_elements", "jsonb_array_elements_text", "json_object_keys",
"jsonb_object_keys", "unnest", "explode", "explode_outer", "posexplode", "regexp_split_to_table",
"string_to_table", "string_split", "openjson", "flatten",
})
def _function_sources(select: Any) -> list[Any]:
"""一条 SELECT 的 FROM、JOIN、LATERAL VIEW 里说不清出哪些列、装着什么的来源:不是数据源里的表(含指向 WITH 的名字),
也不是子查询这类派生来源——表值函数(json_each、unnest、JSON_TABLE)、LATERAL 函数、VALUES。"""
from sqlglot import exp
sources = [source for _, source in _from_sources(select)] + list(select.args.get("laterals") or [])
out: list[Any] = []
for source in sources:
if _derived_block(source) is not None:
continue
node = source.this if isinstance(source, exp.Lateral) else source
if isinstance(node, exp.Table) and isinstance(node.this, exp.Identifier):
continue
out.append(source)
return out
def _function_columns(source: Any) -> set[str] | None:
"""表值函数这类来源出的列名(小写):写的列名表、别名(Postgres 里一列的函数出的列就叫别名,没起别名的叫函数名)、
函数名、常见的出列名(_FUNCTION_COLUMNS);JSON_TABLE 按 COLUMNS 里写的,VALUES 按 column1、column2…。不认得的函数
又没写列名表的,说不清出哪几列,为 None。"""
from sqlglot import exp
node = source.this if isinstance(source, exp.Lateral) else source
alias = source.args.get("alias") or (node.args.get("alias") if node is not source else None)
names: set[str] = set(_FUNCTION_COLUMNS)
listed: list[str] = []
if alias is not None:
if alias.name:
names.add(alias.name.lower())
listed = [c.name.lower() for c in alias.args.get("columns") or [] if c.name]
names |= set(listed)
func = node.this if isinstance(node, exp.Table) else node
while isinstance(func, exp.Subquery):
func = func.this
if isinstance(func, exp.JSONTable):
schema = func.args.get("schema")
defined = [str(c.name).lower() for c in (schema.expressions if schema is not None else []) if c.name]
return names | set(defined) if defined or listed else None
if isinstance(func, exp.Values):
width = max((len(row.expressions) for row in func.expressions if isinstance(row, exp.Tuple)), default=1)
return names | {f"column{i}" for i in range(1, width + 1)}
name = (func.name if isinstance(func, exp.Anonymous) else func.sql_name() if isinstance(func, exp.Func)
else "").lower()
if name:
names.add(name)
return names if listed or name in _TABLE_FUNCTIONS 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 _leaves(root: Any) -> list[Any]:
"""一块查询的每一支:UNION 这类连起来的按先后摊开,套在外面的括号去掉。一般是 SELECT,也可能是 VALUES 这类。"""
from sqlglot import exp
out: list[Any] = []
stack = [root]
while stack:
node = stack.pop()
if isinstance(node, exp.Subquery):
stack.append(node.this)
elif isinstance(node, exp.SetOperation):
stack.extend((node.expression, node.this))
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 _same_name(item: Any, name: str) -> bool:
"""一个选出项在派生来源的结果里是不是叫 name(小写):起了别名的、直接选一列的按 _output_name;没起别名、套了括号
或 COLLATE 的一列((vip_level)、vip_level COLLATE NOCASE)也算——SQLite 给子查询的结果列起名时去掉括号和 COLLATE,
外层按 vip_level 取得到它。只在认标签列时用:_output_name 别处照旧认不准这种写法(那边认不准就整份遮)。"""
from sqlglot import exp
if _output_name(item) == name:
return True
node = item
while isinstance(node, (exp.Paren, exp.Collate)):
node = node.this
return node is not item and isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) \
and node.name.lower() == name
def _output_name(item: Any) -> str | None:
"""一个选出项在结果里的列名(小写):起了别名的是别名,直接选一列的是列名。别的表达式(函数、运算、CAST、括号、
子查询)的列名各数据库起法不同(SQLite 照写法原样记「SUBSTR(buyer_phone, 4)」,Postgres 记函数名或 ?column?),
认不准,返回 None。"""
from sqlglot import exp
if isinstance(item, exp.Alias):
return item.alias.lower() or None
if isinstance(item, exp.Column) and not isinstance(item.this, exp.Star):
return item.name.lower() or None
return None
def _places(branch: list[Any], width: int | None) -> list[int | None]:
"""一支里每一项落在结果的第几列。没有 * 的一一对上;有 * 的,第一个 * 前面的从头数,最后一个 * 后面的从结果末尾
倒着数(width 是结果的列数,不知道就数不了);两个 * 之间的、* 本身认不出位置(None)。"""
stars = [i for i, item in enumerate(branch) if _is_star(item)]
if not stars:
return list(range(len(branch)))
first, last = stars[0], stars[-1]
tail = len(branch) - 1 - last
# * 至少展开成 0 列:倒着数要是数到了 * 前面那几项的位置上,列数对不上,后面的一项都不认
countable = width is not None and width - tail >= first
return [i if i < first else width - (len(branch) - i) if i > last and countable else None
for i in range(len(branch))]
#: 按位置数 * 展开成的列最多往下数几层:再深的(递归的 CTE 绕回自己)按数不清算
_STAR_DEPTH = 16
#: * 上的改动(DuckDB 一类的 EXCLUDE / REPLACE / RENAME / ILIKE):展开成的列不再是来源那几列的原样,不按位置数
_STAR_EDITS = ("except_", "replace", "rename", "ilike")
def _star_sources(star: Any, select: Any) -> list[Any] | None:
"""select 里的这个 * 展开的是哪几个来源:没写表名的是 FROM、JOIN、LATERAL VIEW 里的全部,写了的(t.*)只有那一个;
写的表名对不上任何来源(外层的表)为 None。"""
from sqlglot import exp
sources = _from_sources(select) + [((lateral.alias_or_name or "").lower(), lateral)
for lateral in select.args.get("laterals") or []]
qualifier = (star.table.lower() or None) if isinstance(star, exp.Column) else None
if qualifier is None:
return [source for _, source in sources]
found = [source for alias, source in sources if alias == qualifier]
return found or None
def _star_inputs(star: Any, select: Any) -> list[Any] | None:
"""* 带出的值取自哪里:要接着看的节点——派生来源的每一支(值取自它的各个选出项),表值函数、VALUES 这类来源本身
(值取自参数)。展开了数据源里的表、写的表名对不上任何来源的为 None:SQL 里看不出这张表有哪几列,哪一列都可能带出来。"""
from sqlglot import exp
sources = _star_sources(star, select)
if sources is None:
return None
out: list[Any] = []
for source in sources:
derived = _derived_block(source)
if derived is not None:
out += _leaves(derived[0])
elif isinstance(source, exp.Table) and isinstance(source.this, exp.Identifier):
return None
else:
out.append(source)
return out
def _outer_selects(node: Any) -> Iterator[Any]:
"""由里往外,node 所在的位置看得见 FROM 里的来源的那几层 SELECT。CTE、FROM / JOIN 里的子查询(不是 LATERAL)看不见
同一层 FROM 里的别的来源:WITH、FROM 属于的那一层跳过,再外层的照样看得见(相关子查询)。"""
from sqlglot import exp
skip = None
node = node.parent
while node is not None:
if isinstance(node, exp.Select) and node is not skip:
yield node
if isinstance(node, exp.CTE) or (isinstance(node, exp.Subquery)
and isinstance(node.parent, (exp.From, exp.Join))):
skip = node.parent.parent
node = node.parent
def _scoped_star_inputs(star: Any) -> list[Any] | None:
"""写在表达式里的 *(函数参数里的 s.*、JSON_OBJECT(*))带出的值取自哪里(_star_inputs):由里往外找 FROM 里有这个来源的
那一层 SELECT(_outer_selects,相关子查询里的 s.* 指外层的 s),没写表名的 * 展开最近一层的全部来源。找不到为 None
(说不清,按取自遮罩列算)。"""
for select in _outer_selects(star):
if _star_sources(star, select) is not None:
return _star_inputs(star, select)
return None
def _star_feeds(star: Any, select: Any, names: set[str]) -> bool:
"""select 里的这个 * 带出的值里有没有取自 names 的(展开了数据源里的表的一律算,见 _star_inputs)。"""
inputs = _star_inputs(star, select)
return inputs is None or any(_reads(names, _value_columns(node)) for node in inputs)
def _table_columns(source: Any, tables: dict[str, list[str]] | None) -> list[str] | None:
"""FROM 里的这个来源是数据源里的一张表、冻结的表结构里查得到它时,它按次序的列名(_schema_columns);别的为 None。
写了库名的按「库名.表名」查(_table_key):另一个库里的同名表查不到,不拿探查的那张表的列序去数。"""
from sqlglot import exp
if not tables or not isinstance(source, exp.Table) or not isinstance(source.this, exp.Identifier):
return None
return tables.get(_table_key(source))
def _single_source(star: Any, select: Any, depth: int) -> Any:
"""* 只展开一个来源、展开成的就是那个来源的列原样(没带 EXCLUDE 这类改动)时,那个来源;别的为 None。"""
from sqlglot import exp
node = star.this if isinstance(star, exp.Column) else star
if depth > _STAR_DEPTH or any(node.args.get(k) for k in _STAR_EDITS):
return None
sources = _star_sources(star, select)
return sources[0] if sources is not None and len(sources) == 1 else None
def _star_columns(star: Any, select: Any, names: set[str], memo: dict[int, Any],
tables: dict[str, list[str]] | None, depth: int = 0) -> list[bool] | None:
"""* 展开成的每一列(按位置)取不取自 names:只展开一个来源时才数得清——派生来源按它的每一列(_block_columns),数据源
里的表按查询当时冻结的表结构(tables,_schema_columns)。表结构里查不到的表、几个来源(按同名列连起来的会合成一列)、
表值函数,或者 * 带着 EXCLUDE / REPLACE 这类改动的,为 None。"""
source = _single_source(star, select, depth)
if source is None:
return None
if (derived := _derived_block(source)) is not None:
return _block_columns(derived[0], names, memo, tables, depth + 1)
columns = _table_columns(source, tables)
return [column in names for column in columns] if columns is not None else None
def _block_columns(block: Any, names: set[str], memo: dict[int, Any], tables: dict[str, list[str]] | None,
depth: int = 0) -> list[bool] | None:
"""一块查询(派生来源)的每一列(按位置)取不取自 names:每一支一项项数,* 按它展开成的几列数(_star_columns),任何
一支的那一列取自 names 就算;有一支数不清、几支不一样宽的为 None。memo 记下数过的(id(那块查询) → 结果)。"""
from sqlglot import exp
if id(block) in memo:
return memo[id(block)]
memo[id(block)] = None
rows: list[list[bool]] = []
for leaf in _leaves(block):
if not isinstance(leaf, exp.Select):
return None
row: list[bool] = []
for item in leaf.expressions:
if not _is_star(item):
row.append(_reads(names, _value_columns(item)))
elif (spread := _star_columns(item, leaf, names, memo, tables, depth)) is not None:
row += spread
else:
return None
rows.append(row)
if not rows or len({len(row) for row in rows}) != 1:
return None
memo[id(block)] = [any(column) for column in zip(*rows)]
return memo[id(block)]
def _branch_names(select: Any, tables: dict[str, list[str]] | None, depth: int = 0) -> list[str | None] | None:
"""一支(一条 SELECT)结果里按次序的列名:没起别名的表达式为 None(_output_name 认不准的);* 按它展开成的列名
(只展开一个来源时:数据源里的表按表结构,派生来源按它写的列名表、第一支的列名)。有 * 说不清展开成哪几列的为 None。
UNION 第一支是 * 时,后一支的值按位置落进的就是这几个名字(_source_names)。"""
from sqlglot import exp
out: list[str | None] = []
for item in select.expressions:
if not _is_star(item):
out.append(_output_name(item))
continue
source = _single_source(item, select, depth)
if source is None:
return None
if (derived := _derived_block(source)) is not None:
block, listed = derived
first = _leaves(block)[0]
names = list(listed) if listed is not None else (
_branch_names(first, tables, depth + 1) if isinstance(first, exp.Select) else None)
else:
names = _table_columns(source, tables)
if names is None:
return None
out += names
return out
def _width(select: Any, tables: dict[str, list[str]] | None) -> int | None:
"""一支(一条 SELECT)结果的列数,按 _branch_names 数;* 说不清展开成几列的为 None。"""
names = _branch_names(select, tables)
return len(names) if names is not None else None
def _star_places(select: Any, width: int | None, names: set[str], tables: dict[str, list[str]] | None = None
) -> set[int] | None:
"""select 这一支里的 * 把取自 names 的值带到了结果(width 列)的哪几列。* 展开的列落在第一个 * 起、最后一个 * 后面
那几项之前的那一段:只有一个 *、它展开成的每一列都数得清的(_star_columns:派生来源,表结构里查得到的表)只算取自
names 的那几列,别的整段都算。数出来的列数和这一段对不上的(表结构过期了)也整段算。没有 * 带出这样的值为空;不知道
结果有几列、* 前后的几项就比结果还宽的为 None。
用在 * 展开的列换了名字的地方:写了列名表的派生来源(WITH t(a, b) AS (SELECT * FROM sales))、UNION 后一支的 *
(结果的列名取第一支的)。"""
branch = list(select.expressions)
stars = [i for i, item in enumerate(branch) if _is_star(item)]
# 展开数据源里的表的一律算带出了遮罩列的值:表结构只用来数位置,不用来断定这张表没有遮罩列(表结构可能过期)
if not any(_star_feeds(branch[i], select, names) for i in stars):
return set()
if width is None:
return None
first, end = stars[0], width - (len(branch) - 1 - stars[-1])
if end < first:
return None
spread = _star_columns(branch[first], select, names, {}, tables) if len(stars) == 1 else None
if spread is not None and len(spread) == end - first:
return {first + k for k, hit in enumerate(spread) if hit}
return set(range(first, end))
#: 有整行引用的方言(sqlglot 的方言名):不带表名的名字是 FROM 里一个来源时,可能指它的整行(Postgres 的
#: row_to_json(s)、SELECT s,DuckDB 同)。SQLite、MySQL、Oracle 里这样写只能是列
_ROW_DIALECTS = frozenset({"postgres", "duckdb"})
def _normalized(root: Any, read: str | None, tables: dict[str, list[str]] | None) -> None:
"""几种写法先就地改写成认得出的样子,再认遮罩列、标签列(_tainted、_label_columns)。只改解析出来的语法树,原文不动;
改写前后结果有哪几列、每一列的值取自哪里都一样:
- TABLE x(Postgres、MySQL):解析器认不出,CTE、子查询里的读成了一个叫 TABLE 的列、表。改成 SELECT * FROM x;带库名、
ORDER BY 的解析不了,按解析不了处理(_implicit)
- 选出项套了括号的 *((s.*))、(s).*:Postgres 照样展开成好几列,改成 s.*,不然按一列数位置。括号里不是来源的
((t.profile).* 展开一个复合类型的列)改成展开一个对不上任何来源的 *:说不清展开成几列,整段遮
- 带列名表的表(Postgres 的 FROM sales AS s(i, r, p),引用 CTE 时再写列名表的也算):改成 (SELECT * FROM sales) AS
s(i, r, p),按派生来源的列名表认改了名的列。Postgres 的列名表可以比列少(只改前几列的名字,后面的照原名),这时
* 展开的列数和列名表对不上,列名表里的几列整段算(_star_places),后面照原名的按名字认。不按表结构补全列名表:
别的方言里列名表比列少只能是表结构过期了,补全就丢了这一道核对
- 整行引用(read 是 _ROW_DIALECTS 之一,_row_source):row_to_json(s)、json_agg(t)、SELECT s、s::text 里的 s 改成
ROW(s.*),按 * 展开的来源认(_value_columns)
tables 是查询当时冻结的表结构(_schema_columns,按次序的列名),认整行引用时看来源出不出某一列;没有为 None。"""
from sqlglot import exp
for cte in list(root.find_all(exp.CTE)):
body = cte.this
if isinstance(body, exp.Alias) and _table_keyword(body.this) and isinstance(body.args.get("alias"),
exp.Identifier):
cte.set("this", exp.select("*").from_(exp.Table(this=body.args["alias"].copy())))
for sub in list(root.find_all(exp.Subquery)):
body = sub.this
alias = body.args.get("alias") if isinstance(body, exp.Table) else None
if alias is not None and _table_keyword(body.this) and not alias.columns \
and isinstance(alias.this, exp.Identifier):
sub.set("this", exp.select("*").from_(exp.Table(this=alias.this.copy())))
selects = list(root.find_all(exp.Select))
for select in selects:
for item in list(select.expressions):
if (star := _bare_star(item)) is not None:
item.replace(star)
for select in selects:
for _name, source in _from_sources(select):
alias = source.args.get("alias")
if isinstance(source, exp.Table) and isinstance(source.this, exp.Identifier) and alias is not None \
and alias.columns:
inner = source.copy()
inner.set("alias", None)
source.replace(exp.Subquery(this=exp.select("*").from_(inner), alias=alias.copy()))
if read in _ROW_DIALECTS:
for column in list(root.find_all(exp.Column)):
if column.table or not isinstance(column.this, exp.Identifier) or not _carries_value(column):
continue
source = _row_source(column, tables)
if source is None:
continue
row = exp.Anonymous(this="ROW", expressions=[exp.Column(this=exp.Star(), table=exp.to_identifier(source))])
# 直接是选出项的起回原名:结果的列名照旧(SELECT s 的列叫 s)
bare = isinstance(column.parent, exp.Select) and column.arg_key == "expressions"
column.replace(exp.alias_(row, column.this.copy()) if bare else row)
def _table_keyword(node: Any) -> bool:
"""解析器把 TABLE x 读成的那个名字:不带引号、不带表名的 TABLE。"""
from sqlglot import exp
ident = node.this if isinstance(node, exp.Column) and not node.table else node
return isinstance(ident, exp.Identifier) and not ident.quoted and ident.name.upper() == "TABLE"
def _bare_star(item: Any) -> Any:
"""选出项是套了括号的 *((s.*)、((*)))或 (s).* 时,改写成的 *、s.*;别的为 None(_normalized)。"""
from sqlglot import exp
node = item
while isinstance(node, exp.Paren):
node = node.this
if node is not item and _is_star(node):
return node.copy()
if not (isinstance(item, exp.Dot) and isinstance(item.expression, exp.Star)):
return None
inner = item.this
while isinstance(inner, exp.Paren):
inner = inner.this
if isinstance(inner, exp.Column) and not inner.table and isinstance(inner.this, exp.Identifier):
return exp.Column(this=exp.Star(), table=inner.this.copy())
# 展开的不是 FROM 里的来源(复合类型的列、函数交回的记录):记成一个对不上任何来源的 *(_star_sources 为 None)
return exp.Column(this=exp.Star(), table=exp.to_identifier("*"))
def _carries_value(column: Any) -> bool:
"""列引用写在选出项里、FROM 里(函数参数、LATERAL):值会流进结果。WHERE、GROUP BY、ORDER BY、JOIN 的 ON / USING
里的不算(_normalized 只改写会带出值的整行引用)。"""
from sqlglot import exp
node = column
while node.parent is not None and not isinstance(node.parent, exp.Select):
if isinstance(node.parent, exp.Join) and node.arg_key in ("on", "using"):
return False
node = node.parent
return node.parent is not None and node.arg_key in ("expressions", "from", "from_", "joins", "laterals")
def _row_source(column: Any, tables: dict[str, list[str]] | None) -> str | None:
"""Postgres 里不带表名的名字 column 指整行时,它指的来源的名字(别名,没起别名的是表名);是列、不是任何来源的为 None。
Postgres 先按列认:由里往外看得见的每一层(_outer_selects)的来源里,有一个出得了这一列(_has_column)就是列;都不是、
又有一个来源叫这个名字,才是整行(取最里面的那个)。说不清出不出这一列的(表结构里查不到的表、* 展开的列)按出不了算,
当整行(宁可多遮)。"""
name = column.name.lower()
found: str | None = None
for select in _outer_selects(column):
for alias, source in _from_sources(select):
if _has_column(source, name, tables):
return None
if found is None and alias == name:
found = source.alias_or_name
return found
def _has_column(source: Any, name: str, tables: dict[str, list[str]] | None) -> bool:
"""FROM 里的这个来源说得准出 name 这一列:派生来源看列名表、第一支里起了名字的选出项(列名表改了名的不算),数据源里
的表看表结构(_table_columns),表值函数这类看它出的列名(_function_columns)。* 展开的列说不准,不算。"""
from sqlglot import exp
derived = _derived_block(source)
if derived is not None:
block, listed = derived
first = _leaves(block)[0]
items = list(first.expressions) if isinstance(first, exp.Select) else []
if listed is not None:
if name in listed:
return True
if any(_is_star(item) for item in items):
return False
items = items[len(listed):]
return any(_output_name(item) == name for item in items if not _is_star(item))
if isinstance(source, exp.Table) and isinstance(source.this, exp.Identifier):
columns = _table_columns(source, tables)
return columns is not None and name in columns
columns = _function_columns(source)
return columns is not None and name in columns
def _blocks(root: Any) -> list[tuple[Any, str, list[str] | None]]:
"""SQL 里的每一块查询(一条 SELECT,或者 UNION 这类连起来的一串)和它的结果流到哪:
- "result":最外层,结果就是快照的列;
- "source":当成一张表用的——WITH 里的一段、FROM / JOIN 里的子查询、LATERAL,外层按列名取它的列,* 会把它的列
全带出去。写了列名表的(t(p))带上列名表;
- "other":标量子查询、IN / EXISTS 里的,值经外层的表达式带出(_value_columns 已经算上),列名无关紧要。
套在外面的括号(可能好几层)不改变结果流到哪:先越过它们再看。UNION 里的一支不另算,整串按一块。"""
from sqlglot import exp
out: list[tuple[Any, str, list[str] | None]] = []
for node in root.find_all(exp.Select, exp.SetOperation):
wrappers: list[Any] = []
outer = node
while isinstance(outer.parent, exp.Subquery):
outer = outer.parent
wrappers.append(outer)
parent = outer.parent
if isinstance(parent, exp.SetOperation):
continue
if parent is None:
out.append((node, "result", None))
elif isinstance(parent, (exp.CTE, exp.Lateral)) or (wrappers and isinstance(parent, (exp.From, exp.Join))):
# 别名(和列名表)记在 WITH 的那一段、LATERAL 上,FROM 里的子查询记在带别名的那层括号上
alias = next((h.args["alias"] for h in [parent, *reversed(wrappers)] if h.args.get("alias")), None)
listed = [c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None
out.append((node, "source", listed))
else:
out.append((node, "other", None))
return out
def _source_names(top: Any, listed: list[str] | None, names: set[str],
tables: dict[str, list[str]] | None = None) -> tuple[set[str], bool]:
"""派生来源的结果里值取自遮罩列的那几列的名字,以及有没有认不出名字的(没起别名的表达式、两个 * 之间的项)。
写了列名表的按位置对上列名表;没写的,单独一条 SELECT 取各项自己的列名,UNION 这类按位置对上第一支的列名。
* 带出遮罩列的值、展开的列又换了名字的(写了列名表、UNION 后一支的 *),它展开落到的那几列照位置对上(_star_places);
单独一条 SELECT、第一支的 * 不改名,遮罩列照原名传出去,按名字就认得出。第一支的 * 展开成哪几列数得清的(_branch_names),
后面每一支数出的列数又都和它一样宽时,后一支落进去的位置按它对上列名;对不上的(表结构过期了)不按它对。tables 是
查询当时冻结的表结构(_schema_columns),* 展开数据源里的表时按它数位置。"""
from sqlglot import exp
selects = [leaf for leaf in _leaves(top) if isinstance(leaf, exp.Select)]
found: set[str] = set()
unknown = False
def take(name: str | None) -> None:
nonlocal unknown
if name is None:
unknown = True
else:
found.add(name)
def taken(select: Any, width: int | None, head: Callable[[int | None], str | None]) -> None:
branch = list(select.expressions)
for item, place in zip(branch, _places(branch, width)):
if not _is_star(item) and _reads(names, _value_columns(item)):
take(head(place))
spread = _star_places(select, width, names, tables)
for place in sorted(spread) if spread is not None else [None]:
take(head(place))
if listed is not None:
for select in selects:
taken(select, len(listed), lambda p: listed[p] if p is not None and p < len(listed) else None)
return found, unknown
first = list(selects[0].expressions) if selects else []
for item in first:
if not _is_star(item) and _reads(names, _value_columns(item)):
take(_output_name(item))
if len(selects) > 1:
header = _branch_names(selects[0], tables)
# 第一支的 * 按表结构数出来的列名:后面每一支数得出的列数都和它一样宽才按它对位置。表结构过期了(这张表后来多了、
# 少了列),按它数出的列名和实际的不一样宽,对上的名字是错的;有一支对不上、数不清的,退回不按表结构数(* 展开的
# 位置认不出,整段遮)
if header is not None and any(_width(select, tables) != len(header) for select in selects[1:]):
header = None
if header is not None:
width, heads = len(header), dict(enumerate(header))
else:
# 每一支一样宽:拿一支没有 * 的定宽度,* 后面的项才数得出位置
width = next((len(s.expressions) for s in selects if not any(_is_star(i) for i in s.expressions)), None)
heads = {place: _output_name(item) for item, place in zip(first, _places(first, width))
if place is not None}
for select in selects[1:]:
taken(select, width, lambda p: heads.get(p) if p is not None else None)
return found, unknown
def _tainted(sql: str, kind: str | None, masked: set[str], width: int, tables: dict[str, list[str]] | None = None
) -> tuple[set[str], set[int]] | None:
"""SQL 里指代遮罩列的名字(小写),和结果(width 列)里值取自遮罩列的那几列的位置。解析不了返回 None。
名字从遮罩列本身起,一层层传下去:选出列的值取自这些名字的(buyer_phone AS 手机号、UPPER(buyer_phone) AS x),它的
名字也算;派生来源(子查询、WITH 里的一段)按位置对上列名表、UNION 第一支的列名,也算,直到不再多出名字。同名
不同层的也一并算上(宁可多遮)。
位置是给没起别名的表达式用的(UPPER(buyer_phone) 的结果列名对不上任何名字):和 * 写在同一支里的,* 前面的从头数、
后面的从末尾倒着数。认不出的——两个 * 之间的项、派生来源里没起别名的表达式(外层的 * 会把它原样带出来,列名
又认不准)——整份快照的列都算(同解析不了)。
FROM、JOIN、LATERAL 里的表值函数(json_each、unnest、JSON_TABLE、LATERAL VALUES)参数里写着这些名字的,它出的列
都算(_function_columns):遮罩列是 JSON 时,json_each 拆开是最自然的写法。出哪几列说不清的整份快照都算。
* 展开的列换了名字(写了列名表的派生来源、UNION 后一支的 *):* 带出这些名字的值时,它展开落到的那几列照位置算
(_star_places,展开数据源里的表的按 tables 数位置,数不清的整段算);标量子查询里的 *、函数参数里的 *(to_json(s.*)、
JSON_OBJECT(*))、整行引用(row_to_json(s))按 _value_columns 算进外层的表达式。表的列名表、TABLE x、套了括号的 *
先改写成上面这些认得出的样子(_normalized)。
PIVOT、UNPIVOT、MATCH_RECOGNIZE 读到这些名字的:值转进了它们自己起名的列(UNPIVOT 的值列、PIVOT 按 IN 里的值起名的列、
MEASURES),出的是哪几列不好认,整份快照的列都算。"""
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 None
try:
_normalized(root, _read(kind), tables)
names = set(masked)
selects = list(root.find_all(exp.Select))
blocks = _blocks(root)
sources = [(top, listed) for top, role, listed in blocks if role == "source"]
functions = [source for select_ in selects for source in _function_sources(select_)]
unreadable = False
for _ in range(len(selects) + len(sources) + len(functions) + 1):
before = len(names)
for select_ in selects:
for item in select_.expressions:
name = item.alias_or_name.lower()
if name and not _is_star(item) and name not in names and _reads(names, _value_columns(item)):
names.add(name)
for top, listed in sources:
names |= _source_names(top, listed, names, tables)[0]
for source in functions:
if _reads(names, _value_columns(source)):
columns = _function_columns(source)
unreadable = unreadable or columns is None
names |= columns or set()
if len(names) == before:
break
everything = unreadable or any(_source_names(top, listed, names, tables)[1] for top, listed in sources) \
or any(_reads(names, _value_columns(node)) for node in root.find_all(exp.Pivot, exp.MatchRecognize))
positions: set[int] = set()
for top, role, _listed in blocks:
if role != "result":
continue
branches = [leaf for leaf in _leaves(top) if isinstance(leaf, exp.Select)]
for k, select_ in enumerate(branches):
branch = list(select_.expressions)
for item, place in zip(branch, _places(branch, width)):
if not _is_star(item) and _reads(names, _value_columns(item)):
if place is None or place >= width:
everything = True
else:
positions.add(place)
# UNION 后一支的 * 按位置落进第一支起的列名里;第一支、单独一条 SELECT 的 * 照原名带出,按名字认得出
spread = _star_places(select_, width, names, tables) if k else set()
if spread is None or any(place >= width for place in spread):
everything = True
else:
positions |= spread
return names, set(range(width)) if everything else positions
except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(宁可多遮)
logger.warning("质询的 SQL 遮罩分析失败", exc_info=True)
return None
def _mentions(sql: str, names: Iterable[str]) -> bool:
return any(re.search(rf"(?<![0-9A-Za-z_]){re.escape(n)}(?![0-9A-Za-z_])", sql, re.I) for n in names if n)
#: COUNT(*):只数个数,不把列带出来(_implicit 先去掉它)
_COUNT_STAR = re.compile(r"\bcount\s*\(\s*(?:all\s+|distinct\s+)?\*\s*\)", re.I)
#: SQL 文字里不写列名也会带出遮罩列的写法:选出项的 *、t.*、函数参数里的 *,TABLE x 语句。乘号前面是值(列名、数、右
#: 括号),对不上
_IMPLICIT = re.compile(r"(?:\bselect|\bdistinct|\ball|[,(.])\s*\*|\btable\s+[\w\"`]", re.I)
def _implicit(sql: str) -> bool:
"""解析不了的 SQL 里有没有不写列名也会带出遮罩列的写法(*、TABLE x):它们能把遮罩列带进别的列名(列名表、UNION 的
后一支),同写着遮罩列一样整份遮(_derived_hidden)。只看文字,字符串、注释里的也算(多遮)。"""
return bool(_IMPLICIT.search(_COUNT_STAR.sub("", sql)))
def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None,
tables: dict[str, list[str]] | None = None) -> tuple[set[str], set[str]]:
"""(这份快照要遮的列, SQL 里指代遮罩列的名字),都是小写。tables 是查询当时冻结的表结构(_schema_columns),* 展开
数据源里的表时按它数位置;没给的按快照记着的表结构快照(schema_artifact)认,和工具边界遮的时候是同一份。
证据面板只按列名遮(hidden);agent 的查询起了别名、套了函数,结果列名就对不上。本仓库的查询惯例是起中文别名
(region AS 区域),所以按 SQL 认出改了名字的遮罩列(_tainted)。SQL 解析不了、里面又写着遮罩列的(*、TABLE x 不写
列名也会把遮罩列带进别的列名,同样算,_implicit),整份快照的列都遮掉;合并查询的快照不看 SQL,它记着自己的来历
(_hidden_columns 已按 _merge_masks 遮好)。"""
base = set(hidden)
sql = snapshot.get("sql")
if not base or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE:
return base, base
columns = [str(c) for c in snapshot.get("columns") or []]
found = _tainted(sql, kind, base, len(columns), _snapshot_columns(snapshot) if tables is None else tables)
if found is None:
hides = _mentions(sql, base) or _implicit(sql)
return (base | {c.lower() for c in columns}, base) if hides else (base, base)
names, positions = found
return names | {columns[i].lower() for i in positions if i < len(columns)}, names
def derived_columns(snapshot: dict[str, Any], hidden: set[str], kind: str | None) -> set[str] | None:
"""按 SQL 认出值取自遮罩列(hidden,小写)的结果列(小写,列名直接对上的也算)。SQL 解析不了、合并查询的快照(不看
SQL)为 None:认不准,宽严由调用方定(_derived_hidden 整份遮;双盲复算照常派盲算,由盲算自己的查询去认)。表结构同
_derived_hidden,按快照记着的表结构快照认。"""
sql = snapshot.get("sql")
if not hidden or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE:
return None
columns = [str(c) for c in snapshot.get("columns") or []]
found = _tainted(sql, kind, set(hidden), len(columns), _snapshot_columns(snapshot))
if found is None:
return None
names, positions = found
return {c.lower() for i, c in enumerate(columns) if c.lower() in names or i in positions}
def _snapshot_labels(snapshot: dict[str, Any], kind: str | None, tables: dict[str, set[str]] | None = None
) -> dict[str, set[str]]:
"""一份快照里只装着分档标签的列(_label_columns)。没有 SQL 的(合并查询)一个都不算。tables 没给的,按快照记着
的表结构快照(schema_artifact,查询当时冻结的)认。"""
sql = snapshot.get("sql")
if not isinstance(sql, str) or not sql.strip():
return {}
if tables is None and isinstance(snapshot.get("schema_artifact"), str):
tables = _frozen_tables(snapshot["schema_artifact"])
return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []], tables)
def _snapshot_columns(snapshot: dict[str, Any]) -> dict[str, list[str]] | None:
"""快照记着的表结构快照(schema_artifact,查询当时冻结的,和工具边界遮的时候用的是同一份)里按次序的列名。"""
artifact = snapshot.get("schema_artifact")
return _frozen_columns(artifact) if isinstance(artifact, str) else None
@functools.lru_cache(maxsize=64)
def _frozen_columns(artifact: str) -> dict[str, list[str]] | None:
"""表结构快照工件里每张表按次序的列名(_schema_columns)。同 _frozen_tables:按内容寻址,记下来;取不到的为 None。"""
from app.core import artifact_store
try:
return _schema_columns(artifact_store.load(artifact))
except Exception: # noqa: BLE001 - 取不到就当不知道表结构(* 展开表的整段遮,宁可多遮)
return None
@functools.lru_cache(maxsize=64)
def _frozen_tables(artifact: str) -> dict[str, set[str]] | None:
"""表结构快照工件里每张表的列名(_schema_tables)。工件按内容寻址、不会变,记下来;取不到、被改过的为 None。"""
from app.core import artifact_store
try:
return _schema_tables(artifact_store.load(artifact))
except Exception: # noqa: BLE001 - 取不到就当不知道表结构(标签照旧当遮罩值,宁可多遮)
return None
# --------------------------------------------------------------------------
# SQL 里写死的值
# --------------------------------------------------------------------------
#: 一个纯粹的数(可带正负号、小数):范围边界按数比大小,写成字符串的也一样
_PLAIN_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?")
def _arithmetic() -> tuple[Any, ...]:
"""加减乘除、取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //):几个写死的数算出来的整个算式按算出来的那一个值认
(13900000000 + 1 就是 13900000001,125 % 100 就是 25)。"""
from sqlglot import exp
return exp.Add, exp.Sub, exp.Mul, exp.Div, exp.Mod, exp.IntDiv
#: 数的写法里指数最大多少(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;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
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):
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。取模、整除照 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 却认不出。已知局限:SQLite 的取模先把两边取整再算(25.9 % 26 是 25),这里照小数算
(25.9),这样写的值认不出。"""
from sqlglot import exp
if not (isinstance(a, Decimal) and isinstance(b, Decimal)):
return None
try:
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_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None
def _literal_types() -> tuple[Any, ...]:
"""语法树里的字面量:数字、字符串,以及字符串带前缀的几种变体(N'…'、$$…$$、U&'…'、E'…'、X'…'、B'…')。"""
from sqlglot import exp
return (exp.Literal, exp.National, exp.RawString, exp.UnicodeString, exp.ByteString, exp.HexString, exp.BitString)
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)
# --------------------------------------------------------------------------
# 遮罩:哪些值
# --------------------------------------------------------------------------
#: 一个数的写法:可带正负号、千分位、小数、百分号
_NUMERIC = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?")
#: 正文里一个个「词」:字母数字下划线连成的一串。头尾都是这种字的遮罩值,只能整个对上这样一串
_WORDS = re.compile(r"[0-9A-Za-z_]+")
#: 正文里的一串数(可带千分位、小数):带千分位的遮罩值按去掉逗号的样子比
_DIGITS = re.compile(r"\d[\d,]*(?:\.\d+)?")
#: 中文这类遮罩值按开头几个字分桶找(_Secrets._partition)
_PREFIX = 8
#: 正文里一串允许夹分隔符的数字(换了写法的号码,见 _Secrets.loose_spans):数字(含全角)之间夹着一到四个分隔符,开头
#: 可以带 + 和括号。分隔符是同一行里的各种空白(空格、制表符、全角空格、不换行空格,以及窄空格、细空格这类 U+2000 至
#: U+200B、U+202F、U+205F)、连字符和减号(含全角、U+2010 至 U+2015、U+2212)、点(含全角、间隔号)、括号(含全角)。
#: 逗号不算分隔符(千分位按 _DIGITS 认),冒号、斜杠也不算(时间、日期),不跨行
_LOOSE_SEPARATORS = (" \t\u00a0\u2000-\u200b\u202f\u205f\u3000"
"\\-\u2010-\u2015\u2212\uff0d.\uff0e\u00b7\u30fb()\uff08\uff09")
_LOOSE_RUN = re.compile(rf"[((]?[++]?[((]?[0-90-9]+(?:[{_LOOSE_SEPARATORS}]{{1,4}}[0-90-9]+)*[))]?")
_LOOSE_DIGITS = re.compile(r"[0-90-9]+")
#: 两截数字之间只隔着这样一个字(不带空格):是更长的编号、小数、日期的两截(13900000001-2、13900000001.5),号码到这里
#: 不算完——同 _pattern 里「前后不能是 [.:/-] 加数字」。减号(U+2212)、全角连字符同连字符
_JOINERS = frozenset("-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uff0d.\uff0e")
_POINTS = frozenset("..")
#: 去掉国家码(0086、86)之后至少还要有这么多位才算号码(固话号码最短 7 位):86 开头的短数不拆
_LOCAL_MIN = 7
_OPENS, _CLOSES = frozenset("(("), frozenset("))")
_HALF_DIGITS = str.maketrans("0123456789", "0123456789")
def _plain_text(value: Any) -> str:
text = json.dumps(value, ensure_ascii=False, default=str) if isinstance(value, (dict, list)) else str(value)
return re.sub(r"\s+", " ", text).strip()
def _numeric(value: Any) -> bool:
if isinstance(value, bool):
return False
return isinstance(value, (int, float)) or (isinstance(value, str) and numeric_text(value) is not None)
def _forms(value: Any) -> set[str]:
"""一格的值在正文里可能的写法:原样(压成一行),是数的再加上千分位的写法(13,900,000,001)。"""
text = _plain_text(value)
forms = {text} if text else set()
if _numeric(value):
with contextlib.suppress(RenderError, ValueError, ArithmeticError):
forms.add(render_cell(value))
return forms
def _strong(form: str) -> bool:
"""按值认得出的写法(见 SECRET_MIN、SECRET_DIGITS)。"""
if _NUMERIC.fullmatch(form):
return sum(c.isdigit() for c in form) >= SECRET_DIGITS
return len(form) >= SECRET_MIN
def _digits_of(form: str) -> str:
return form.replace(",", "").lstrip("+-").rstrip("%")
def _loose_candidates(digits: str) -> list[str]:
"""一串数字(半角)可能是哪个号码的纯数字写法:原样;以国家码(0086、86)开头、去掉之后还有 _LOCAL_MIN 位以上的,
去掉国家码的也算。"""
out = [digits]
for code in ("0086", "86"):
if digits.startswith(code) and len(digits) - len(code) >= _LOCAL_MIN:
out.append(digits[len(code):])
return out
def _loose_keys(form: str) -> list[str]:
"""一个按值认得出的遮罩值的纯数字写法(换了写法的号码按它认,见 _Secrets.loose_spans):整个是一个整数(可带千分位、
正负号、全角数字)的取它的数字;整个是夹着分隔符的数字串(存的就是 199-1474-2785、+86 199 1474 2785)的取规范化
之后的数字;以国家码开头的另加去掉国家码的。带小数的、不到 SECRET_DIGITS 位的、别的写法没有。"""
if _NUMERIC.fullmatch(form):
digits = _digits_of(form).translate(_HALF_DIGITS)
elif _LOOSE_RUN.fullmatch(form):
digits = "".join(_LOOSE_DIGITS.findall(form)).translate(_HALF_DIGITS)
else:
return []
if not (digits.isascii() and digits.isdigit()) or len(digits) < SECRET_DIGITS:
return []
return _loose_candidates(digits)
def _loose_whole(text: str, run: re.Match[str], groups: list[tuple[int, int, str]], i: int, j: int) -> bool:
"""一串数字里第 i 到第 j 截拼起来是一个完整的号码,不是更长的编号、小数、日期的一截:两头不和别的数字只隔着一个
连字符或点(13900000001-2、13900000001.5、2026-10-06),只有两截、中间是一个点的按小数算(19914.74);这一串
前后不紧挨着字母数字(ID13900000001),同 _pattern 的词边界。"""
def gap(a: int, b: int) -> str:
return text[groups[a][1]:groups[b][0]]
if (i > 0 and gap(i - 1, i) in _JOINERS) or (j < len(groups) - 1 and gap(j, j + 1) in _JOINERS):
return False
if j == i + 1 and gap(i, j) in _POINTS:
return False
before = text[run.start() - 1] if i == 0 and run.start() > 0 else ""
after = text[run.end()] if j == len(groups) - 1 and run.end() < len(text) else ""
return not (_WORD.match(before) or _WORD.match(after))
def _loose_span(text: str, run: re.Match[str], groups: list[tuple[int, int, str]], i: int, j: int) -> tuple[int, int]:
"""第 i 到第 j 截这个号码在 text 里换掉的那一段:从第一截到最后一截,紧挨在前面的 + 一起换。括号只换号码里的:号码
里有落单的右括号((199)1474-2785、(+86)199…),把前面配对的左括号一起换掉;落单的左括号,把后面紧跟着的右括号一起
换掉。整个号码外面套着的一对括号是标点(「来自(13900000001)」),留着不换。"""
start, end = groups[i][0], groups[j][1]
if i == 0 and start > run.start() and text[start - 1] in "++":
start -= 1
depth = sum((c in _OPENS) - (c in _CLOSES) for c in text[start:end])
while depth < 0 and start > 0 and text[start - 1] in _OPENS:
start, depth = start - 1, depth + 1
while depth > 0 and end < len(text) and text[end] in _CLOSES:
end, depth = end + 1, depth - 1
return start, end
def _pattern(value: str) -> str:
"""一个值的正则:头尾是字母数字的按词边界认;数字还不能是更长的数、日期、时间的一截(12.5 里的 2.5、2026-01-25 里的
25、12:30 里的 30),也不能是千分位写法里的一组(1,390 里的 390)。逗号隔开的一串长号码(13900000001,13900000002)
各自照认:千分位一组只有三位。"""
head = tail = ""
if _WORD.match(value[0]):
head = r"(?<![0-9A-Za-z_])"
if value[0].isdigit():
head += r"(?<!\d[.:/-])" + (r"(?<!\d,)" if len(re.match(r"\d*", value).group()) == 3 else "")
if _WORD.match(value[-1]):
tail = r"(?![0-9A-Za-z_])"
if value[-1].isdigit():
tail += r"(?![.:/-]\d)(?!,\d{3}(?!\d))"
return head + re.escape(value) + tail
@dataclass
class _Secrets:
"""遮罩列里出现过的值。模型照着报告正文、提问、别处的文字格里看到的值复述、或者把值写进查询条件,都会把它带进
回答正文和查询清单;按值找出来换成「已遮罩」。核查 agent 自己的查询结果在工具边界就遮好了(mask_query_result),
这里是第二道防线。这只减少暴露,不是安全边界(同证据面板的遮罩)。
strong 是按值认得出的写法(够长的数、两个字以上的文字),正文、SQL 里的字面量、名字和注释、别的列里的格子、列名都按
它换;weak 是遮罩列里的全部写法(含短的数),只用在 SQL 里和遮罩列做等值比较的字面量上;shown 是 SQL 里照原样
留着的阈值、行数(去掉千分位、正负号的数字),只在回答正文里不换(scrub 的 prose)。换的时候先按词、按开头两个
字筛出正文里真有的那几个值,再拿它们现编正则:遮罩值上万个时,现编一个上万分支的正则要一秒上下,每次都卡住整个进程。
正文、文字格里另认换了写法的长号码(199-1474-2785、+86 199 1474 2785、全角数字,见 loose_spans):按纯数字写法
在集合里查,不进正则。protected 是 protect 收下的、已经摆在读者眼前的文字:之后并进来的值(核查 agent 后面的
查询遮掉的)照样按它们免遮。"""
strong: set[str] = field(default_factory=set)
weak: set[str] = field(default_factory=set)
shown: set[str] = field(default_factory=set)
protected: tuple[str, ...] = ()
_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: dict[str, set[str]] | None = None,
base: Iterable[str] = ()) -> None:
"""收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照里只装着分档标签的列(_label_columns):这几列的
格子等于标签的不收;改了名字的遮罩列、别的派生列照收,base(数据源设了遮罩的列,小写)自己选出来的值也照收。"""
if not _query_shaped(snapshot) or not hidden:
return
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]
# 核查 agent 的查询快照在工具边界就遮好了(mask_query_result):遮罩列的格子是「已遮罩」,不是遮罩值
word = _masked_word()
for row in snapshot["rows"]:
if len(self.weak) >= SECRETS_MAX:
break
if isinstance(row, list):
cells = [(columns[i].lower(), row[i]) for i in index if i < len(row)]
elif isinstance(row, dict):
cells = [(str(k).lower(), v) for k, v in row.items() if str(k).lower() in hidden]
else:
continue
for name, value in cells:
if value is None or isinstance(value, bool) or value == word:
continue
forms = _forms(value)
if forms & written.get(name, set()):
continue
for form in forms:
self.weak.add(form)
if _strong(form):
self.strong.add(form)
self._index = None
def merge(self, other: _Secrets | None) -> None:
"""并进另一份收好的遮罩值(核查 agent 查询时在工具边界遮掉的,见 mask_query_result)。合起来最多 SECRETS_MAX 种。
两边免遮的文字合起来,并进来的值照样按它们免遮:报告的指标正好等于某一笔金额时,agent 查出了那一笔,别的查询里
正好是这个数的格子也不遮。"""
if other is None:
return
for form in other.weak:
if len(self.weak) >= SECRETS_MAX:
break
self.weak.add(form)
if form in other.strong:
self.strong.add(form)
self.protected = tuple(dict.fromkeys((*self.protected, *other.protected)))
self._index, self._compiled = None, {}
if self.protected:
self._spare(" ".join(self.protected))
def protect(self, texts: Iterable[str | None]) -> None:
"""已经摆在读者眼前的值不换:提问人自己写进问题、选中原文里的(「13900000001 这位客户」「排除 300 以下的」),
报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。正文、格子、SQL 里都不换。文字记下来,
之后并进来的值照样免遮(merge)。"""
given = " ".join(t for t in texts if t)
if not given:
return
self.protected = tuple(dict.fromkeys((*self.protected, given)))
self._spare(given)
def _spare(self, given: str) -> None:
"""去掉 given(几段免遮的文字)里写着的值。"""
numbers = {_digits_of(n) for n in _DIGITS.findall(given)}
# 换了写法写进来的号码(「199-1474-2785 这位客户」)按纯数字写法认
reformatted = {key for _, _, key in self.loose_spans(given)}
def asked(form: str) -> bool:
if _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given:
return True
return bool(reformatted) and any(key in reformatted for key in _loose_keys(form))
self.strong = {f for f in self.strong if not asked(f)}
self.weak = {f for f in self.weak if not asked(f)}
self._index, self._compiled = None, {}
def show(self, numbers: Iterable[str]) -> None:
"""SQL 里照原样留着的阈值、行数(_shown_literals):回答正文里复述它们不换(「金额不低于 30000 元」),换了只会
让回答和 SQL 对不上。只免正文:格子、列名、SQL 里照旧按值换——格子里的值正好是这个数,可能就是那个人
(col >= v 再按 col 排序取第一行,查出来的就是 v)。"""
self.shown = set(numbers)
def is_shown(self, form: str) -> bool:
return bool(_NUMERIC.fullmatch(form)) and _digits_of(form) in self.shown
def hit(self, value: Any) -> bool:
"""一格(或 SQL 里一个字面量)的值是不是按值认得出的遮罩值。短的数不算(见 SECRET_DIGITS)。整格就是一个换了
写法的号码的(没设遮罩的联系方式列里写着 199-1474-2785)也算。"""
if value is None or isinstance(value, bool) or not self.strong:
return False
text = _plain_text(value)
if text in self.strong:
return True
if _numeric(value) and sum(c.isdigit() for c in text) >= SECRET_DIGITS:
with contextlib.suppress(RenderError, ValueError, ArithmeticError):
if render_cell(value) in self.strong:
return True
return isinstance(value, str) and any(start == 0 and end == len(text) for start, end, _ in self.loose_spans(text))
def weak_hit(self, text: str) -> bool:
"""SQL 里一个字面量是不是遮罩列里出现过的值(短的数也算)。"""
return _plain_text(text) in self.weak
def scrub(self, text: str, *, prose: bool = False, loose: bool | None = None) -> str:
"""text 里的遮罩值换成「已遮罩」。prose:这是回答正文,SQL 里照原样留着的阈值、行数不换(show)。loose:换了写法
的长号码也换(loose_spans),缺省同 prose——正文、文字格、SQL 的字符串和注释里这样认,SQL 里不带引号的
199-1474-2785 是一个算式。"""
return self.swap(text, prose=prose, loose=loose)[0]
def swap(self, text: str, *, prose: bool = False, loose: bool | None = None) -> tuple[str, list[str]]:
"""scrub,另交回换掉了哪些值:按值认出的照原文的写法,换了写法的号码交它的纯数字写法(SQL 里同一个数写成阈值的
据此不留,见 inquiry._swap_literal)。"""
hits = self._hits(text, prose=prose, loose=loose)
word = _masked_word()
for start, end, _ in reversed(hits):
text = text[:start] + word + text[end:]
return text, [value for _, _, value in hits]
def spans(self, text: str, *, prose: bool = False, loose: bool | None = None) -> list[tuple[int, int]]:
"""scrub 会换掉的那几段(起, 止),按先后、不重叠。质询工件按位置重放回答时换掉的地方(view_edits)。"""
return [(start, end) for start, end, _ in self._hits(text, prose=prose, loose=loose)]
def _hits(self, text: str, *, prose: bool, loose: bool | None) -> list[tuple[int, int, str]]:
"""text 里要换掉的遮罩值:(起, 止, 值),按先后、不重叠。换了写法的号码先认(+86 19914742785 整个换掉,不留下
一截「+86」),按值认的落在它们里面的不再算。"""
if not text or not self.strong:
return []
hits = [(start, end, key) for start, end, key in self.loose_spans(text, prose=prose)] \
if (prose if loose is None else loose) else []
pattern = self.pattern(text, prose=prose)
if pattern is not None:
taken = list(hits)
hits += [(m.start(), m.end(), m.group()) for m in pattern.finditer(text)
if not any(m.start() < end and start < m.end() for start, end, _ in taken)]
return sorted(hits)
def copy(self) -> _Secrets:
"""另一份一样的遮罩值(改它不影响这一份):交给线程、按句另加免遮的值时用。"""
return _Secrets(strong=set(self.strong), weak=set(self.weak), shown=set(self.shown), protected=self.protected)
def loose_spans(self, text: str, *, prose: bool = False) -> list[tuple[int, int, str]]:
"""text 里换了写法的长号码(199-1474-2785、(199)1474-2785、+86 199 1474 2785、全角数字……):(起, 止, 纯数字
写法),按先后、不重叠。在正文里找允许夹分隔符的数字串(_LOOSE_RUN),从每一截起往后拼,规范化(全角转半角、去
分隔符、去国家码)之后在遮罩值的纯数字写法里查(集合查找,不现编正则),取拼得最长的那个;拼出来的不是完整的
号码(更长的编号、小数、日期的一截,见 _loose_whole)不算。prose 同 scrub:SQL 里照原样留着的阈值不算。"""
if not text or not self.strong:
return []
keys, longest = self._partition()[3]
if not keys:
return []
out: list[tuple[int, int, str]] = []
for run in _LOOSE_RUN.finditer(text):
groups = [(run.start() + g.start(), run.start() + g.end(), g.group().translate(_HALF_DIGITS))
for g in _LOOSE_DIGITS.finditer(run.group())]
i = 0
while i < len(groups):
best: tuple[int, str] | None = None
digits = ""
for j in range(i, len(groups)):
digits += groups[j][2]
if len(digits) > longest + 4: # 再拼也对不上任何一个了(4 是国家码 0086 的长度)
break
key = next((c for c in _loose_candidates(digits) if c in keys), None)
if key is not None and _loose_whole(text, run, groups, i, j):
best = (j, key)
if best is None:
i += 1
continue
j, key = best
if not (prose and key in self.shown):
out.append((*_loose_span(text, run, groups, i, j), key))
i = j + 1
return out
def pattern(self, text: str, *, prose: bool = False) -> re.Pattern[str] | None:
"""认 text 里出现的遮罩值的正则;一个都没有时为 None。prose 同 scrub。"""
found = self._found(text) if text and self.strong else set()
if prose and self.shown:
found = {v for v in found if not self.is_shown(v)}
if not found:
return None
key = frozenset(found)
if key not in self._compiled:
if len(self._compiled) > 64:
self._compiled = {}
# 长的在前:13900000001 先于它的一截被认出来
self._compiled[key] = re.compile("|".join(_pattern(v) for v in sorted(found, key=lambda v: (-len(v), v))))
return self._compiled[key]
def _found(self, text: str) -> set[str]:
words, numbers, others, _ = self._partition()
found = {w for w in _WORDS.findall(text) if w in words}
if numbers:
for run in _DIGITS.findall(text):
found |= numbers.get(_digits_of(run), set())
for i in range(len(text) - 1) if others else ():
for size, buckets in others.items():
for length, values in buckets.get(text[i:i + size], {}).items():
if (piece := text[i:i + length]) in values:
found.add(piece)
return found
def prepare(self) -> None:
"""先把索引建好(收完值、去掉提问人写的之后):上万个值建一次要几十毫秒,放在线程里建,别等到事件循环里第一次换。"""
self._partition()
def _partition(self) -> tuple[set[str], dict[str, set[str]], dict[int, dict[str, dict[int, set[str]]]],
tuple[frozenset[str], int]]:
"""strong 分成三类:只有字母数字的(按词比)、带千分位小数的数(按去掉逗号的数比)、其余的(中文、日期、
邮箱)。其余的按开头至多 _PREFIX 个字分桶、桶里再按长短分:同一个市区的上万个地址开头七八个字都一样,只按开头
分的话,正文里每碰到一次那几个字都要逐个比;再按长短分,碰到一次只是按几种长短各查一下。三类各自只在正文里
真出现时才进正则。另记长号码的纯数字写法和其中最长的有几位(_loose_keys,loose_spans 按它查)。"""
if self._index is None:
words: set[str] = set()
numbers: dict[str, set[str]] = {}
others: dict[int, dict[str, dict[int, set[str]]]] = {}
loose: set[str] = set()
for value in self.strong:
if _WORDS.fullmatch(value):
words.add(value)
elif _NUMERIC.fullmatch(value):
numbers.setdefault(_digits_of(value), set()).add(value)
else:
size = min(len(value), _PREFIX)
others.setdefault(size, {}).setdefault(value[:size], {}).setdefault(len(value), set()).add(value)
loose.update(_loose_keys(value))
self._index = (words, numbers, others, (frozenset(loose), max(map(len, loose), default=0)))
return self._index
#: 单元格引用的前一截(Q1.r0.):后面才是列名
_CELL_HEAD = re.compile(r"^\s*[QK]\d+\.r\d+\.")
def _scrubbed_source(text: str, secrets: _Secrets) -> str:
"""回答原文里的遮罩值换成「已遮罩」:标记之外按正文的办法换(SQL 里照原样留着的阈值不换);标记里的按段换(| 隔开
的几段各自换),单元格引用只换列名那一截——编号、行号换不出东西,整段换的话「r0.」会让列名开头的长号码看着像小数
的一截,反而认不出来。列名照快照、SQL 里的名字那样换(阈值也换,换了写法的号码也认),换得一样才对得上。
标记里写着遮罩值的引用换完多半解析不了(列名里写着遮罩值的除外:快照的列名也换过了,正好对上),宁可弄坏一个
引用,也不让值留在存下的原文、片段里,或者从解析不了的占位里露出来。"""
if not text or not secrets.strong:
return text
out: list[str] = []
last = 0
for m in MARKER_RE.finditer(text):
out.append(secrets.scrub(text[last:m.start()], prose=True))
start, end = m.span("body")
parts = text[start:end].split("|")
head = _CELL_HEAD.match(parts[0]) if m.group("kind").strip() == "v" else None
first = parts[0][:head.end()] + secrets.scrub(parts[0][head.end():], loose=True) if head \
else secrets.scrub(parts[0], prose=True)
parts = [first, *(secrets.scrub(p, prose=True) for p in parts[1:])]
out.append(text[m.start():start] + "|".join(parts) + text[end:m.end()])
last = m.end()
out.append(secrets.scrub(text[last:], prose=True))
return "".join(out)
def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False,
rename: bool = True) -> tuple[dict[str, Any], list[str]]:
"""一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数);
别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。
列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。
原快照不动;没有要遮的返回原快照。
embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号,换了写法的也算),把那一截换成「已遮罩」,
这一格别的字照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源
里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。
rename 为假时列名不换(工具边界先遮时:列名是模型自己写的,换了它引用的列名就对不上)。列名按文字的办法换(换了写法的
号码也认,同 SQL 里带引号的名字)。
工具边界遮过的快照(记着 masked_at_query)里,整格就是「已遮罩」的是查询时按值换掉的:这一列照样记进遮掉的列,和回答时
按值换掉的一样画。"""
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)
premasked = "masked_at_query" in snapshot
if not index and not by_value and not premasked:
return snapshot, []
word = _masked_word()
touched = set(index)
edited = False
position = {c: i for i, c in enumerate(columns)}
names = [secrets.scrub(c, loose=True) for c in columns] if by_value and rename else columns
def cell(i: int | None, value: Any) -> Any:
nonlocal edited
if i in index:
return word
if premasked and value == word:
if i is not None:
touched.add(i)
return value
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, loose=True)) != text:
edited = True
return scrubbed
return value
def key(k: Any) -> Any:
i = position.get(str(k))
return names[i] if i is not None else k
rows: list[Any] = []
for row in snapshot["rows"]:
if isinstance(row, list):
rows.append([cell(i, v) for i, v in enumerate(row)])
elif isinstance(row, dict):
rows.append({key(k): word if str(k).lower() in hidden else cell(position.get(str(k)), v)
for k, v in row.items()})
else:
rows.append(row)
renamed = names != columns
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 {}
types.update({names[i]: "text" for i in index})
view = {**snapshot, "rows": rows, "column_types": types, **({"columns": names} if renamed else {})}
return view, [names[i] for i in sorted(touched)]
def _cell_text(value: Any) -> str | None:
"""一格按文字换的样子(_masked_view 的 embedded):文字照原样,JSON(对象、数组)按界面画出来的样子;别的为 None。"""
if isinstance(value, str):
return value
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, default=str)
return None
def view_edits(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, baseline: _Secrets
) -> dict[str, Any] | None:
"""质询工件按回答时的办法遮(_masked_view 的 embedded)时,secrets 比 baseline 多换掉的地方,相对原快照记成位置,
不含值:{"cells": [[行, 列], …](整格换成「已遮罩」), "spans": [[行, 列, 起, 止], …](文字格里换掉的那一截,起止按
这一格的文字算), "names": [[列, 起, 止], …](列名里换掉的那一截), "masked": [列名, …](因此画成遮罩列的)};没有
多换的为 None。
agent 先查了备注(那时还不知道备注里的号码是遮罩值,原样交回、原样存下),后来又查出了手机号(查询时遮掉,值只在
内存里):回答时按内存里的值(secrets)认得出,事后取质询工件时只剩 baseline 认得出的。这份位置记进质询事件,
事后照着再换一遍(replay_edits)。行是字典的快照不记(核查的查询快照都是行列表)。"""
full, full_masked = _masked_view(snapshot, hidden, secrets, embedded=True)
base, base_masked = _masked_view(snapshot, hidden, baseline, embedded=True)
if full is base:
return None
word = _masked_word()
cells: list[list[int]] = []
spans: list[list[int]] = []
for r, (row, shown, plain) in enumerate(zip(snapshot.get("rows") or [], full["rows"], base["rows"])):
if not (isinstance(row, list) and isinstance(shown, list) and isinstance(plain, list)):
continue
for c, (value, now, before) in enumerate(zip(row, shown, plain)):
if now == before:
continue
if now == word:
cells.append([r, c])
elif (text := _cell_text(value)) is not None:
spans += [[r, c, start, end] for start, end in secrets.spans(text, loose=True)]
columns = [str(c) for c in snapshot.get("columns") or []]
full_names, base_names = full.get("columns", columns), base.get("columns", columns)
names = [[i, start, end] for i, (column, now, before) in enumerate(zip(columns, full_names, base_names))
if now != before for start, end in secrets.spans(column, loose=True)]
# 按位置比:同一列在两边可能叫不同的名字(列名里的值只有一边换了)
already = {i for i, name in enumerate(base_names) if name in base_masked}
masked = [name for i, name in enumerate(full_names) if name in full_masked and i not in already]
if not (cells or spans or names or masked):
return None
return {"cells": cells, "spans": spans, "names": names, "masked": masked}
def replay_edits(snapshot: dict[str, Any], edits: Iterable[Any]) -> dict[str, Any]:
"""照记下的位置(view_edits,可能有好几份:同一份快照被几条质询引用)把回答时换掉的地方再换一遍。位置对不上的
(行、列越界,起止不合理,形状不对)跳过:记录是封存的事件,坏了也不能让取工件出错。没有可换的返回原快照。"""
rows = snapshot.get("rows")
columns = [str(c) for c in snapshot.get("columns") or []]
if not isinstance(rows, list):
return snapshot
def ints(entry: Any, size: int) -> list[int] | None:
ok = isinstance(entry, list) and len(entry) == size and all(
isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in entry)
return entry if ok else None
whole: set[tuple[int, int]] = set()
parts: dict[tuple[int, int], list[tuple[int, int]]] = {}
heads: dict[int, list[tuple[int, int]]] = {}
for edit in edits:
if not isinstance(edit, dict):
continue
for entry in edit.get("cells") or []:
if (at := ints(entry, 2)) is not None:
whole.add((at[0], at[1]))
for entry in edit.get("spans") or []:
if (at := ints(entry, 4)) is not None and at[2] < at[3]:
parts.setdefault((at[0], at[1]), []).append((at[2], at[3]))
for entry in edit.get("names") or []:
if (at := ints(entry, 3)) is not None and at[1] < at[2]:
heads.setdefault(at[0], []).append((at[1], at[2]))
word = _masked_word()
def swapped(text: str, cuts: list[tuple[int, int]]) -> str:
merged: list[list[int]] = []
for start, end in sorted(cuts):
if end > len(text):
continue
if merged and start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
for start, end in reversed(merged):
text = text[:start] + word + text[end:]
return text
out_rows: list[Any] = []
changed = False
for r, row in enumerate(rows):
if not isinstance(row, list):
out_rows.append(row)
continue
new_row = list(row)
for c, value in enumerate(row):
if (r, c) in whole:
new_row[c] = word
elif (r, c) in parts and (text := _cell_text(value)) is not None:
new_row[c] = swapped(text, parts[(r, c)])
changed = changed or new_row != row
out_rows.append(new_row)
names = [swapped(name, heads[i]) if i in heads else name for i, name in enumerate(columns)]
if not changed and names == columns:
return snapshot
view = {**snapshot, "rows": out_rows}
if names != columns:
view["columns"] = names
if isinstance(snapshot.get("column_types"), dict):
rename = dict(zip(columns, names))
view["column_types"] = {rename.get(str(k), k): v for k, v in snapshot["column_types"].items()}
return view
# --------------------------------------------------------------------------
# 工具边界:查询结果交给核查 agent 之前先遮
# --------------------------------------------------------------------------
@dataclass
class QueryMask:
"""一份查询结果在工具边界遮好的样子(mask_query_result)。
payload:遮好的结果,交给模型的、落进快照的都是它。columns:查询时就整列遮掉的结果列(结果里的列名,按先后)。
secrets:这份结果里遮掉的值,只在内存里交给调用方(审批质询按值遮的第二道防线:模型照着别处看到的号码写进回答、
查询条件时认得出),不进快照、不交给模型。"""
payload: dict[str, Any]
columns: list[str]
secrets: _Secrets
@dataclass
class SeenValues:
"""一次核查里认出的遮罩值,只在内存里:given 是调用方交进来的(审批质询、反方审稿收的报告快照里的值),found 是
agent 自己的查询在工具边界遮掉的(tools/datasource 每次查询并进来,add)。两份分开记:合起来(pooled)时 agent 查到的
在前,到了上限 SECRETS_MAX 挤掉的是报告里的值——agent 查到的就在它眼前这几份结果里,后面的查询、它写的回答和理由
最可能再碰到。以前先并报告的值,收满了上限,agent 查到的一个也并不进去。"""
given: _Secrets = field(default_factory=_Secrets)
found: _Secrets = field(default_factory=_Secrets)
def add(self, secrets: _Secrets) -> None:
"""并进一次查询时遮掉的值(mask_query_result 交回的 secrets)。"""
self.found.merge(secrets)
def pooled(self) -> _Secrets:
"""合起来的另一份(改它不影响这两份):agent 查到的在前,调用方交进来的在后;调用方免遮的文字(问题、被审的那句
结论原文里写着的值)对两边都算。上万个值合一遍要几十毫秒,调用方放进线程。"""
out = self.found.copy()
out.merge(self.given)
return out
def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str | None,
known: _Secrets | None = None, *, schema: Any = None) -> QueryMask:
"""一份查询结果(tools/datasource 的查询工具交回的样子:columns / rows / column_types / sql)在交给核查 agent、落进
快照之前先遮。masked 是数据源设了遮罩的列,kind 是数据源的类型(SQL 按它的方言解析)。known 是此前已经认出的遮罩值:
这个 agent 前面的查询遮掉的,加上调用方交进来的(审批质询、反方审稿收的报告快照里的值)。schema 是数据源的表结构
(schema_cache,和随快照冻结的是同一份):认分档标签(_label_columns 的 tables)、数 * 展开数据源里的表落在第几列
(_derived_hidden 的 tables)时用。
- 整列遮:列名直接对上遮罩列的,加上按 SQL 认出值取自遮罩列的(_derived_hidden:别名、函数、子查询、WITH、
SELECT *(展开的列换了名字的也算)、表值函数、整行引用和函数参数里的 *、列名表、TABLE x;COUNT、只在条件里用到、
分档当分组键的不算);SQL 解析不了、又写着遮罩列(或者 *、TABLE x)的,整份结果的列都遮。这几列的格子换成
「已遮罩」,类型记成文本
- 别的列按值换:整格就是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,_Secrets.loose_spans)。遮罩值
是这份结果里的加上 known:先查号码、再查写着号码的备注、没设遮罩的联系方式表(别的库里的也一样)时,后一份同样
遮好。门槛同按值遮(SECRET_DIGITS、SECRET_MIN);只装着分档标签的列里的标签不算遮罩值,这份结果自己写的标签也不
按 known 换(前面的查询认不准、当遮罩值收了的同名标签,不能把这一份的档位列换掉)
- 列名不换(模型自己写的,换了它引用的列名就对不上);结果里一列都没取自遮罩列、也没有可按值换的原样交回:数值的
汇总、只数个数的查询不受影响
交回的 secrets 只有这份结果里的遮罩值(known 调用方自己有)。分析本身出了意外,整份结果的列都遮:这一步挡在模型
前面,分析的缺陷不能把原值放过去(宁可多遮)。"""
base = {str(c).strip().lower() for c in masked if str(c).strip()}
columns = [str(c) for c in payload.get("columns") or []]
given = known is not None and bool(known.strong)
if not (base or given) or not columns or not _query_shaped(payload):
return QueryMask(payload, [], _Secrets())
try:
hidden = _derived_hidden(payload, base, kind, _schema_columns(schema))[0] if base else set()
own = _Secrets()
labels = _snapshot_labels(payload, kind, _schema_tables(schema))
if any(c.lower() in hidden for c in columns):
own.add(payload, hidden, labels=labels, base=base)
pool = own
if given:
pool = own.copy()
pool.merge(known)
# 这份结果自己写的标签(CASE 写死的档名)不当遮罩值,哪怕前面的查询把同名的字收了
if written := [text for name, texts in labels.items() if name not in base for text in texts]:
pool.protect(written)
if not pool.strong and not any(c.lower() in hidden for c in columns):
return QueryMask(payload, [], own)
pool.prepare()
view, _ = _masked_view(payload, hidden, pool, embedded=True, rename=False)
except Exception: # noqa: BLE001 - 认不准就整份遮掉,原值不交给模型
logger.warning("核查查询的遮罩分析出错,整份结果按遮罩处理", exc_info=True)
hidden, own = {c.lower() for c in columns}, _Secrets()
view, _ = _masked_view(payload, hidden, own, rename=False)
return QueryMask(view, [c for c in columns if c.lower() in hidden], own)
#: 查询报错里引号括起来的一段(Postgres、DuckDB、MySQL 把转换失败的值写在引号里:invalid input syntax for type
#: bigint: "139-0000-0001")。引号前后紧挨着字母数字的不算(can't、isn't 里的撇号)
_QUOTED_ERROR = re.compile(r"(?<![0-9A-Za-z_])(\"[^\"\n]*\"|'[^'\n]*')(?![0-9A-Za-z_])")
#: 报错里不带引号的长数(DuckDB:Type INT64 with value 13900000001 can't be cast)
_ERROR_NUMBER = re.compile(r"(?<![0-9A-Za-z_.])\d{%d,}(?![0-9A-Za-z_])" % SECRET_DIGITS)
def mask_error_text(message: str, sql: str, known: _Secrets | None = None) -> str:
"""查询失败时交给核查 agent、写进 tool.end 的报错原话。驱动的原话是模型改写 SQL 的线索,可数据库会在报错里带上
数据里的值(类型转换失败、越界),数据源设了遮罩时交出去之前先换:引号里的一段、不带引号的长数,SQL 里没写着的
换成「已遮罩」——SQL 里写着的是模型自己写的表名、列名、字面量,照留,它要靠这些改写;此前查询遮掉的值(known)
按值换。不认得是不是数据的也换(多遮):报错只是线索,说不全也不耽误改写。"""
if not message:
return message
word = _masked_word()
lowered = sql.lower()
def quoted(m: re.Match[str]) -> str:
inner = m.group(1)[1:-1]
if not inner.strip() or inner.lower() in lowered:
return m.group()
return m.group(1)[0] + word + m.group(1)[-1]
out = _QUOTED_ERROR.sub(quoted, message)
out = _ERROR_NUMBER.sub(lambda m: m.group() if m.group() in sql else word, out)
return known.scrub(out, loose=True) if known is not None and known.strong else out