M2 复核第 4 轮:报告自查认遮罩列和证据面板同一个口径,哪张表(含视图)里叫这个名字的列都算;结论句裁判的摘录按快照逐份遮确定取自遮罩列的列;没起别名、盖住全部结果列的表达式算确定取自遮罩列,值照收

第 3 轮的「遮罩表」推断和退回的裁判摘录各开了一个口子,收值时又把按位置认得出的列丢了:

- 报告自查去掉「遮罩表」推断(_mask_owners)。数据源的 mask_columns 只认列名、不支持写成「表.列」,按列名在整个数据源上
  生效:引用的那一格按 SQL 认出取自任何一张表里叫这个名字的列就记 masked_cell,视图(customer_spend.name)、没有第二个
  遮罩列的表(employees.name)、门店名(stores.name)都算,和证据面板的遮法一致。以前经视图引用客户姓名、引用店员姓名,
  正式运行照常成功,姓名写进了报告。SQL 血缘只用来点名出处,和排除值确实取自普通表别的列的(SELECT region AS phone
  FROM stores);视图、表结构里没有的表、写了别的库名的表、没标 is_view 的老表结构、没有冻结表结构的快照看不出列的出处,
  照算(_frozen_schema 只把 is_view 明写为 false 的表当普通表)。违规说明照实写取自哪张表的哪一列:「列「store」按 SQL
  认出取自 stores.name,数据源对列名「name」设置了遮罩(按列名对所有表生效)」
- 交给结论句裁判的摘录按快照逐份遮(api/evidence.py 的 _judge_loader):每份快照里确定取自遮罩列的列(_mask_sets 的
  sure)并进交给 judge.prepare 的那一份快照的 mask_columns(工件本身不动),裁判照它自己的办法遮掉,judge.py 不用改。
  c.phone AS tel 的号码不再进摘录;不按数据源名合并(另一份快照里同名、取自门店热线的列照给),不用宁可多遮的 hidden
  (整份遮的快照、只数个数的列照给)。按需裁判和报告撰写节点里当场裁判同一个函数。摘录变了的句子复用键随之变,此前
  判过的会重判一次
- 收「这次运行查到过的遮罩值」:_sure 以前凡是要遮的列盖住了全部结果列、名字又对不上,就当认不准、整份算,
  SELECT UPPER(phone) FROM customers 一个值都收不到,证据面板之后照原样显示 SQL 里写着的号码。现在结果后面垫一列再认一遍
  (_whole),垫的那一列也算进来的才是整份算的;按位置认得出的照收,整份遮的照旧只遮显示、不收值

测试:test_report_masked_owners.py 改名 test_report_masked_any_table.py 并按新口径改写(经视图改名 / 不改名、店员、门店名、
客户名真跑判失败且成果里没有值;* 展开、WITH、UNION、视图里改成遮罩列名字的列、没有表结构、没标 is_view、表结构里没有的
表、写了库名的表);新增 test_judge_excerpt_masks.py(按需、当场两条路的摘录都不给改了名字的号码列,同名的门店热线照给;
没起别名的 UPPER(phone) 摘录也不给);test_evidence_mask_memo.py 加没起别名的表达式照收值;违规说明的断言跟着改
This commit is contained in:
ThearchyHelios
2026-10-12 13:50:31 +08:00
parent 65d6a1e63c
commit 1cfa5a524d
10 changed files with 750 additions and 348 deletions
+146 -58
View File
@@ -31,13 +31,15 @@ options.mask_columns 里的列换成「已遮罩」,SQL 里给它们起了别
结论句的判定(四期)是模型给的,不是证据:正式运行的判定在报告文档里、随文档封存;探索运行
按需裁判(POST …/evidence/judge),判定作为 evidence.judged 事件追加在封存之后,只填补没判过的
句子。交给裁判的摘录同样只取封存范围内的工件。
句子。交给裁判的摘录同样只取封存范围内的工件,每份查询快照里确定取自遮罩列的列不给(_judge_loader,报告撰写节点
当场裁判同一个函数)。
"""
from __future__ import annotations
import asyncio
import contextlib
import csv
import functools
import io
import logging
import re
@@ -1474,9 +1476,10 @@ class _Masks:
def _snapshot_secrets(snap: dict[str, Any], sets: _MaskSets) -> Any:
"""一份查询快照里确定取自遮罩列的值(masking._Secrets,一个都没有为 None):只收 sets.sure 那几列(列名就是遮罩列的、
SQL 里认得出取自遮罩列的),只装着分档标签的格子不收,和核查 agent 在工具边界收值同一个口径(masking:整份遮的快照只收
确定的那几列)。SQL 认不准、整份遮的快照只遮显示:以前连它的区域名、金额一起收,之后每条 SQL 里的「'华东'」都显示成
「已遮罩」。证据面板(_Masks.run_secrets)、推演(api/whatif.py 的 _Hidden.secrets)都按它收。"""
SQL 里认得出取自遮罩列的,没起别名、按位置认得出的 SELECT UPPER(phone) 也算),只装着分档标签的格子不收,和核查 agent
在工具边界收值同一个口径(masking:认得清的照收,整份遮的快照只收确定的那几列)。SQL 认不准、整份遮的快照只遮显示:
以前连它的区域名、金额一起收,之后每条 SQL 里的「'华东'」都显示成「已遮罩」。证据面板(_Masks.run_secrets)、推演
(api/whatif.py 的 _Hidden.secrets)都按它收。"""
from app.engine.masking import _Secrets
secrets = _Secrets()
@@ -1612,9 +1615,10 @@ class _MaskSets:
"""一份查询快照的遮罩(_mask_sets):
- hidden:要遮的列(小写)。证据面板、推演、勾行按它遮,宁可多遮:SQL 认不准时整份的列都算
- sure:其中能确定取自遮罩列的:列名就是遮罩列的,SQL 里认得出取自遮罩列的(别名、函数、子查询、WITH)。SQL 认不准、
整份算进来的那几列不在里面,只装着分档标签的列(CASE 写死的字)也不在。收「这次运行查到过的遮罩值」只收这几列
(_snapshot_secrets);报告自查从这几列起,再按血缘去掉别的表里同名的列(_cited_masks)
- sure:其中能确定取自遮罩列的:列名就是遮罩列的,SQL 里认得出取自遮罩列的(别名、函数、子查询、WITH,没起别名
却按位置认得出的表达式)。SQL 认不准、整份算进来的那几列不在里面,只装着分档标签的列(CASE 写死的字)也不在。
收「这次运行查到过的遮罩值」只收这几列(_snapshot_secrets),交给结论句裁判的摘录逐份遮这几列(_judge_loader),
报告自查从这几列起(_cited_masks,只排除血缘证明值取自普通表别的列的)
- base:遮罩列本身(查询当时记下的加数据源现在设的,小写)
- labels:只装着分档标签的列 → 标签的写法(masking._snapshot_labels),收遮罩值时不收标签
- gone:已经不在的数据源
@@ -1655,12 +1659,15 @@ def _derived(snap: dict[str, Any], hidden: set[str], kind: str | None,
def _sure(snap: dict[str, Any], base: set[str], kind: str | None,
tables: dict[str, list[str]] | None = None) -> tuple[set[str], dict[str, set[str]]]:
"""(能确定取自遮罩列的结果列, 只装着分档标签的列),都是小写,报告自查按前一项判违规(_MaskSets.sure)。
"""(能确定取自遮罩列的结果列, 只装着分档标签的列),都是小写(_MaskSets.sure):收遮罩值、交给裁判的摘录、报告自查
都从前一项起。
按数据源自己的方言认(masking._derived_hidden,交回要遮的列和 SQL 里指代遮罩列的名字):结果列名就是遮罩列、或者是
SQL 里指代遮罩列的名字(c.phone AS tel 的 tel、UPPER(phone) AS p 的 p)的算。认不准时那一套把整份的列都算上(派生
来源里没起别名的表达式、PIVOT、SQL 解析不了又写着遮罩列……):要遮的列盖住了全部结果列、名字却对不上这么多的,只认
名字对上的那几列——展示上多遮无妨,自查多算就是判失败,写作者也改不掉(最大的一笔订单金额被说成取自手机号)。
按数据源自己的方言认(masking._derived_hidden,交回要遮的列和 SQL 里指代遮罩列的名字):结果列名就是遮罩列、SQL 里
指代遮罩列的名字(c.phone AS tel 的 tel、UPPER(phone) AS p 的 p)、没起别名却按位置认得出取自遮罩列的
(SELECT UPPER(phone) FROM customers)都算。认不准时那一套把整份的列都算上(派生来源里没起别名的表达式、PIVOT、SQL
解析不了又写着遮罩列……):这样整份算进来的(_whole),只认名字对上的那几列——展示上多遮无妨,这里多算,自查就判
失败、写作者也改不掉(最大的一笔订单金额被说成取自手机号),区域名、金额也被收成遮罩值。以前凡是要遮的列盖住了全部
结果列、名字又对不上的都当整份算,按位置认得出的那一列也丢了,一个值都收不到(复核第 4 轮)。
只装着分档标签的列(CASE WHEN phone IS NULL THEN '未留' ELSE '已留' END)格子里是 SQL 写死的字,不算;只数个数的
(COUNT(*) FILTER (WHERE phone IS NULL)、SUM(phone IS NULL))先改写成那一套认得出的样子再认(_counted)。
出了意外只认列名对上的。"""
@@ -1671,10 +1678,12 @@ def _sure(snap: dict[str, Any], base: set[str], kind: str | None,
try:
from app.engine.masking import _derived_hidden, _snapshot_labels
found, names = _derived_hidden(_counted(snap, kind), set(base), kind, tables)
counted = _counted(snap, kind)
found, names = _derived_hidden(counted, set(base), kind, tables)
found &= columns
named = (names & columns) | direct
sure = named if columns <= found and not columns <= named else found | direct
whole = columns <= found and not columns <= named and _whole(counted, base, kind, tables)
sure = named if whole else found | direct
labels = _snapshot_labels(snap, kind)
return sure - {name for name in labels if name not in base}, labels
except Exception: # noqa: BLE001 - 认不准:只认列名对上的
@@ -1682,6 +1691,21 @@ def _sure(snap: dict[str, Any], base: set[str], kind: str | None,
return direct, {}
#: _whole 垫在结果列后面的一列:SQL 里的名字不会是它
_PAD_COLUMN = "\x00pad"
def _whole(snap: dict[str, Any], base: set[str], kind: str | None, tables: dict[str, list[str]] | None) -> bool:
"""要遮的列盖住了全部结果列时,是认不准、整份算进来的,还是每一列都真认得出取自遮罩列(_sure)。masking 那一套
认不准时交回全部的列,和每一列都按位置认出来的样子一样,所以结果后面垫一列再认一遍:认得出的列按 SQL 里的位置算,
垫的那一列没有哪一项落得上;整份算的连它一起算进来。"""
from app.engine.masking import _derived_hidden
padded = [*(str(c) for c in snap.get("columns") or []), _PAD_COLUMN]
found, _ = _derived_hidden({**snap, "columns": padded}, set(base), kind, tables)
return {c.lower() for c in padded} <= found
def _counted(snap: dict[str, Any], kind: str | None) -> dict[str, Any]:
"""报告自查认确定取自遮罩列的列之前(_sure),把只决定算不算进来的写法改写成认遮罩列的那一套认得出的样子:
@@ -1753,11 +1777,12 @@ async def _mask_sets(snap: dict[str, Any] | None, source: Any, masks: _Masks, *,
async def _cited_masks(snap: dict[str, Any], source: Any, masks: _Masks,
artifact: str | None = None) -> dict[str, list[str]]:
"""报告自查(engine/nodes/report.py 的 _MaskedCells)认一份查询快照里引用了就记 masked_cell 的列:{结果列(小写):
它取自的遮罩列},违规说明点名后者。
它的出处},违规说明点名出处(认得出表的写「表.列」,见 _owned_columns)。
从 _mask_sets 的 sure 起(列名就是遮罩列的、SQL 里认得出取自遮罩列的;SQL 认不准、整份算进来的和只装着分档标签的
不在里面),普通查询再按血缘核一遍值取自哪张表的哪一列,只认取自遮罩表里那一列的(_owned_columns:stores.name 和设了
遮罩的 customers.name 同名不算)。合并查询的值来自它的各个输入、不是数据源里的表,照 sure 算。"""
不在里面)。遮罩按列名在整个数据源上生效,和证据面板一样:哪张表(含视图)里叫这个名字的列都算,门店名(stores.name)
和客户姓名(customers.name)同叫 name、都设了遮罩时两者都算。普通查询再按血缘点名出处(_owned_columns);合并查询的值
来自它的各个输入、不是数据源里的表,照 sure 算。"""
sets = await _mask_sets(snap, source, masks, artifact=artifact)
if not sets.sure:
return {}
@@ -1767,59 +1792,72 @@ async def _cited_masks(snap: dict[str, Any], source: Any, masks: _Masks,
def _owned_columns(snap: dict[str, Any], sure: set[str], base: set[str], kind: str | None) -> dict[str, list[str]]:
"""sure 里值确实取自遮罩表里那一列的结果列 → 它取自的遮罩列(按血缘里出现的先后)。报告自查按它判违规。
"""sure 里每一列的出处:{结果列: 它取自的遮罩列},认得出表的写「表.列」(stores.name、customer_spend.name),认不出的
写这份快照的全部遮罩列。报告自查按它判违规、写违规说明。
遮罩按列名设在整个数据源上(data/engine.py 的 masked_columns,不带表名):设了 name,stores.name 和 customers.name 按
列名都算。证据面板、可验证报告、核查 agent 照这样遮(宁可多遮);自查多算一列就是判失败,所以按 SQL 的血缘
(sqlglot.lineage,按查询当时冻结的表结构展开 *、认出不带表名的列出自哪张表)核一遍每一列的值取自哪张表的哪一列:
遮罩按列名设在整个数据源上(data/engine.py 的 masked_columns 只认列名,不带表名):设了 name,stores.name、
customers.name、视图里的 name 都算,和证据面板、可验证报告的遮法一致。自查不推断「这项遮罩本意管哪张表」:第 3 轮
这样推断过(遮罩列在几张表里都有时只认另有别的遮罩列的那几张),视图、没有第二个遮罩列的表(employees.name)因此被
排除,正式运行照常成功、姓名写进了报告。SQL 血缘(sqlglot.lineage,按查询当时冻结的表结构展开 *、认出不带表名的列出自
哪张表)只用来:
- 遮罩列在几张表里都有、其中有的表里另有别的遮罩列(customers 有 name 和 phone,stores 只有 name):只认那几张表
(_mask_owners)。别的情形(只在一张表里有、哪张都没有别的遮罩列)照列名认
- 血缘里有认不出出处的(找不到这一列、表值函数、不带表名又有歧义的列、* 展开不了):排除不了,照算
- 认得出、可一处都不取自遮罩表里的遮罩列(门店名、SELECT region AS phone):不算
- 快照里没有冻结的表结构(数据源没探查过):认不出同名的列还在哪几张表里,照列名认;值根本不取自遮罩列的照样不算
- 点名出处:血缘的末端里叫遮罩列名字的列,按出现的先后写「表.列」,读的人看得出是哪一列被这项遮罩管到了
- 排除值确实不取自遮罩列的(SELECT region AS phone FROM stores):每个末端都落在表结构里标明是普通表(is_view 为
false)的列上、没有一处叫遮罩列的名字,才排除
- 认不出出处的照算(_owned_masks):血缘认不出的列(没起别名的表达式……)、表值函数、视图(看不出它的列取自哪张表)、
表结构里没有的表、没有冻结表结构的快照、表结构没标是不是视图的老快照
只排除、不新增:sure 里没有的列血缘认出来也不算。分析出了意外,照 sure 算。纯计算,在调用方的线程里算。"""
只排除、不新增:sure 里没有的列血缘认出来也不算。分析出了意外,照 sure 算(出处写全部遮罩列)。纯计算,在调用方的
线程里算。"""
plain = {c: sorted(base) for c in sorted(sure)}
sql = snap.get("sql")
if not sure or not isinstance(sql, str) or not sql.strip():
return plain
try:
from app.data.sql_semantics import _parse, _read
from app.engine.masking import _frozen_columns
read = _read(kind)
root, _ = _parse(sql, read)
if root is None:
return plain
frozen = _frozen_columns(snap["schema_artifact"]) if isinstance(snap.get("schema_artifact"), str) else None
# 表结构里一张表按好几个名字记着(带不带库名),这里只要不带库名的那一份
tables = {name: cols for name, cols in (frozen or {}).items() if "." not in name}
owners = _mask_owners(tables, base)
artifact = snap.get("schema_artifact")
tables, ordinary = _frozen_schema(artifact) if isinstance(artifact, str) else ({}, frozenset())
schema = {name: {c: "TEXT" for c in cols} for name, cols in tables.items()} or None
nodes = _lineages(root, schema, read, [str(c) for c in snap.get("columns") or [] if str(c).lower() in sure])
out: dict[str, list[str]] = {}
for column in sorted(sure):
found = _owned_masks(nodes.get(column), base, owners, set(tables))
found = _owned_masks(nodes.get(column), base, ordinary)
if found:
out[column] = found
return out
except Exception: # noqa: BLE001 - 核对不了出自哪张表:照 sure 算
logger.warning("报告自查:按血缘核对遮罩列出自哪张表时出错,照列名认", exc_info=True)
except Exception: # noqa: BLE001 - 核对不了出处:照 sure 算
logger.warning("报告自查:按血缘核对遮罩列的出处时出错,照列名认", exc_info=True)
return plain
def _mask_owners(tables: dict[str, list[str]], base: set[str]) -> dict[str, set[str]]:
"""遮罩列 → 它的遮罩表(小写的表名),只记排除得掉一些表的:遮罩列在几张表里都有,其中有的表里另有别的遮罩列,
只认那几张(设了 name, phone,customers 有这两列,stores 只有 name:name 指的是客户姓名)。只在一张表里有、几张表
里都有可哪张都没有别的遮罩列的不记:照列名认,哪张表的都算。"""
out: dict[str, set[str]] = {}
for mask in base:
having = {t for t, cols in tables.items() if mask in cols}
paired = {t for t in having if any(c in base and c != mask for c in tables[t])}
if paired and paired != having:
out[mask] = paired
return out
@functools.lru_cache(maxsize=64)
def _frozen_schema(artifact: str) -> tuple[dict[str, list[str]], frozenset[str]]:
"""表结构快照工件 → ({表名: 按次序的列名}, 标明是普通表的表名),都是小写(_owned_columns)。
前一项只要不带库名的那份名字(表结构里一张表按好几个名字记着,masking._schema_keys),血缘按它展开 *、认不带表名的
列。后一项是 is_view 明写为 false 的表(带不带库名的名字都记):视图的列看不出取自哪张表,没标的老快照看不出是表还是
视图;几个库里同名的表有一张不是普通表,这个名字就不算。取不到、被改过的两样都是空的。工件按内容寻址、不会变,记下来。"""
from app.engine.masking import _schema_columns, _schema_keys
try:
schema = load(artifact)
except Exception: # noqa: BLE001 - 取不到就当没有冻结的表结构(出处认不出的照算)
return {}, frozenset()
entries = schema.get("tables") if isinstance(schema, dict) else None
if not isinstance(entries, dict):
return {}, frozenset()
columns = {name: cols for name, cols in (_schema_columns(schema) or {}).items() if "." not in name}
ordinary: dict[str, bool] = {}
for key, meta in entries.items():
if isinstance(meta, dict):
for name in _schema_keys(key, meta):
ordinary[name] = ordinary.get(name, True) and meta.get("is_view") is False
return columns, frozenset(name for name, yes in ordinary.items() if yes)
def _lineages(root: Any, schema: dict[str, Any] | None, read: str | None, columns: list[str]) -> dict[str, Any]:
@@ -1843,10 +1881,12 @@ def _lineages(root: Any, schema: dict[str, Any] | None, read: str | None, column
return out
def _owned_masks(node: Any, base: set[str], owners: dict[str, set[str]], known: set[str]) -> list[str]:
"""一个结果列的值取自哪几列遮罩列(按血缘里出现的先后),别的表里同名的列不算(owners,_mask_owners)。血缘认不出来
(node 为 None)、里面有认不出出处的(_leaf_of 交回 None),有取自遮罩列的就点名它们、没有就照全部遮罩列算:排除不了。
known 是冻结的表结构里有的表:不在里面的表认不出有没有别的遮罩列,照算。"""
def _owned_masks(node: Any, base: set[str], ordinary: frozenset[str]) -> list[str]:
"""一个结果列的出处:血缘的末端里叫遮罩列名字的列(哪张表、哪个视图的都算),按出现的先后写「表.列」。
一处都没有时:有认不出出处的末端——血缘认不出来(node 为 None)、_leaf_of 交回 None(表值函数、整张表的 *……)、落在
不是普通表(ordinary,_frozen_schema)的表上(视图、表结构里没有的表)——排除不了,写全部遮罩列(照算);每个末端都落在
普通表的别的列上(region AS phone)的交回空:不算。"""
if node is None:
return sorted(base)
found: list[str] = []
@@ -1861,10 +1901,12 @@ def _owned_masks(node: Any, base: set[str], owners: dict[str, set[str]], known:
if place is _NO_COLUMN:
continue
table, column = place
if column not in base or (column in owners and table in known and table not in owners[column]):
continue
if column not in found:
found.append(column)
if column in base:
if (named := f"{table}.{column}") not in found:
found.append(named)
elif table not in ordinary:
# 视图、表结构里没有的表:看不出这一列取自哪里,排除不了
unplaced = True
return found or (sorted(base) if unplaced else [])
@@ -1873,8 +1915,9 @@ _NO_COLUMN = object()
def _leaf_of(leaf: Any) -> Any:
"""血缘的一个末端出自哪张表的哪一列:(表名, 列名),都是小写;没有取任何列的(写死的值、COUNT(*))交回 _NO_COLUMN;
认不出出处的(sqlglot 找不到来源、表值函数、整张表的 *、派生来源里的 *)交回 None。"""
"""血缘的一个末端出自哪张表的哪一列:(表名, 列名),都是小写,表名照 SQL 里写的带上库名(archive.sales);没有取任何列
的(写死的值、COUNT(*))交回 _NO_COLUMN;认不出出处的(sqlglot 找不到来源、表值函数、整张表的 *、派生来源里的 *)交回
None。"""
from sqlglot import exp
source = leaf.source
@@ -1885,7 +1928,8 @@ def _leaf_of(leaf: Any) -> Any:
return None
if not isinstance(column, exp.Column) or isinstance(column.this, exp.Star) or not column.name:
return None
return source.name.lower(), column.name.lower()
table = ".".join(part for part in (source.catalog, source.db, source.name) if part)
return table.lower(), column.name.lower()
if isinstance(source, (exp.Table, exp.Placeholder)) or leaf.expression is source:
return None
return _NO_COLUMN
@@ -3180,6 +3224,49 @@ def _sealed_loader(sealed: _Sealed, *, chain: bool = False) -> Callable[[str], A
return loader
async def _judge_loader(doc: dict[str, Any], catalog: dict[str, Any], units: list[str] | None = None,
loader: Callable[[str], Any] | None = None) -> Callable[[str], Any]:
"""交给结论句裁判的 loader(judge.prepare 的 loader):摘录里的每份查询快照逐份遮,这份快照里确定取自遮罩列的列
(_mask_sets 的 sure,和证据面板同一套认法:c.phone AS tel 的 tel、按位置认得出的 UPPER(phone))也不给。
裁判自己只按列名遮(快照记下的 mask_columns 加数据源现在设的,judge._hidden):改了名字的号码列照样进摘录,裁判理由
一引用就在证据面板、审计 CSV 里露出来。这里把那几列并进交给裁判的这一份快照的 mask_columns(只是交给 prepare 的
副本,工件本身不动),裁判照它自己的办法遮掉。只并这份快照自己的:不按数据源名合并(另一份快照里同名、却取自别的列
的照给),不用证据面板宁可多遮的 hidden(整份遮的快照、只数个数的列照给,被引用的金额、人数裁判要看得到才判得了)。
这样摘录变了的句子复用键随之变,此前判过的会重判一次。
只读要裁判的句子(units,None 是全部结论句,judge.candidates)挂的查询;读出来的快照另记一份交给 prepare 再取,
不读第二遍。loader 缺省读工件库;按需裁判传只认封存工件的那个(_sealed_loader)。报告撰写节点当场裁判
(engine/nodes/report.py)用同一个函数。"""
from app.engine.evidence import _mask_names
from app.engine.judge import candidates
read = loader or load
masks = _Masks()
shown: dict[str, Any] = {}
for alias in dict.fromkeys(a for c in candidates(doc, units) for a in c.aliases):
entry = catalog.get(alias)
if not isinstance(entry, dict) or entry.get("kind") != "query" or not entry.get("artifact"):
continue
artifact = str(entry["artifact"])
if artifact in shown:
continue
try:
snap = read(artifact)
except Exception: # noqa: BLE001 - 读不出来、不在封存范围里:摘录本来就不给它,交给 prepare 照它的办法处理
continue
if not isinstance(snap, dict):
continue
recorded = _mask_names(snap.get("mask_columns"))
sure = set((await masks.sets(artifact, entry.get("source") or snap.get("source"), snap)).sure)
shown[artifact] = {**snap, "mask_columns": sorted(recorded | sure)} if sure - recorded else snap
def reader(artifact: str) -> Any:
key = str(artifact)
return shown[key] if key in shown else read(key)
return reader
def _chain_set(sealed: _Sealed) -> set[str]:
"""封存范围内每份表结构快照经哈希链列出的清单 id 的并集。表结构快照取不回、哈希不符的跳过(不在链上)。"""
out: set[str] = set()
@@ -3304,7 +3391,8 @@ async def _judge_now(sealed: _Sealed, report: _Report, units: list[str]) -> dict
# 带期 4 标记的文档才让裁判取得到哈希链上的清单(摘录的来历行要用);老文档的 loader 和以前完全相同
loader = _sealed_loader(sealed, chain=doc.get("provenance") == DOC_PROVENANCE)
masked = await judging.source_masks(catalog, loader=loader)
request = judging.prepare(doc, catalog, units=pending, loader=loader, masked=masked)
request = judging.prepare(doc, catalog, units=pending, masked=masked,
loader=await _judge_loader(doc, catalog, pending, loader))
skipped = dict(request.skipped)
if request.cands:
async with SessionLocal() as session:
+26 -12
View File
@@ -3131,26 +3131,40 @@ def absence_claims(doc: dict[str, Any]) -> list[dict[str, Any]]:
def _masked_cell_message(ref: str, column: str, sources: list[str]) -> _Reason:
"""违规 masked_cell 的说法:列名就是遮罩列的说「在数据源中设置了遮罩」,按 SQL 认出来的点名它取自的那几列遮罩列(sources,
按血缘认出来的;认不出时是这份快照的全部遮罩列)——遮罩按列名设在整个数据源上,读的人要看得出是哪一项遮罩设置管到了
这里。给写作者的那句说清这一列的值不能以任何写法写进报告:只说「删掉引用」,写作者会把值照抄成文字。"""
own = column.lower() in {s.lower() for s in sources} or not sources
where = (f"列「{column}」在数据源中设置了遮罩" if own
else f"列「{column}」按 SQL 认出取自设置了遮罩的列「{'、'.join(sources[:3])}」")
"""违规 masked_cell 的说法,点名这一格的出处(sources):
- 认得出表的写「表.列」(api/evidence.py 的 _owned_columns 按血缘认出来的):照实写取自哪张表的哪一列、数据源对哪个
列名设了遮罩,并说明遮罩按列名对所有表生效——设了 name,门店名(stores.name)和客户姓名一样算,读的人要看得出是
哪一项遮罩设置管到了这里(遮罩设置只认列名,没有按表写的办法,这里不教人去改设置)
- 认不出表的(没有血缘、合并查询):列名就是遮罩列的说「在数据源中设置了遮罩」,别的点名这份快照的遮罩列
给写作者的那句说清这一列的值不能以任何写法写进报告:只说「删掉引用」,写作者会把值照抄成文字。"""
tabled = [s for s in sources if "." in s]
masks = list(dict.fromkeys(s.rsplit(".", 1)[-1] for s in sources))
renamed = column.lower() not in {m.lower() for m in masks}
if tabled:
named = "、".join(f"「{m}」" for m in masks[:3])
where = (f"列「{column}」{'按 SQL 认出' if renamed else ''}取自 {'、'.join(tabled[:3])},"
f"数据源对列名{named}设置了遮罩(按列名对所有表生效)")
elif sources and renamed:
where = f"列「{column}」按 SQL 认出取自设置了遮罩的列「{'、'.join(masks[:3])}」"
else:
where = f"列「{column}」在数据源中设置了遮罩"
return _Reason(
f"引用了取自遮罩列的格子 [[{ref}]]:{where},值不应写进报告",
f"[[{ref}]] 引用的{where}(手机号、姓名这类),这一列的值不能以任何形式写进报告,引用、数字、照抄的文字都不行:"
"删掉这处引用和相应的内容;整表引用时用 cols= 只列别的列",
f"[[{ref}]] 引用的{where}。设置了遮罩的列(手机号、姓名这类)的值不能以任何形式写进报告,引用、数字、照抄的文字"
"都不行:删掉这处引用和相应的内容;整表引用时用 cols= 只列别的列",
)
def masked_cells(doc: dict[str, Any], catalog: dict[str, Any], masked: dict[str, Any] | None) -> list[dict[str, Any]]:
"""正文直接引用了取自遮罩列的格子([[v:Q2.r0.tel]],整表展开出来的格也算),每格一条违规 masked_cell。
masked 是 {查询快照工件: 取自遮罩列的结果列},结果列可以是列名的集合,也可以是 {列名: 它取自的遮罩列}(说法里点名)。
由报告节点算好交进来(api/evidence.py 的 _cited_masks:列名就是遮罩列的、SQL 里给遮罩列起了别名套了函数的,按血缘
去掉别的表里同名的列;SQL 认不准、整份算进来的不在里面):遮罩设在数据源上,这里只认文档和目录,查不了库。写作目录
不遮罩(写作者要看得到数据),所以拦在自查:和没有出处的数字一样交回写作者重写,仍不改的按「违规处置」办(report.py)。
masked 是 {查询快照工件: 取自遮罩列的结果列},结果列可以是列名的集合,也可以是 {列名: 它的出处}(说法里点名,认得出
表的写「表.列」)。由报告节点算好交进来(api/evidence.py 的 _cited_masks:列名就是遮罩列的、SQL 里给遮罩列起了别名
套了函数的,哪张表里叫这个名字的列都算;SQL 认不准、整份算进来的不在里面):遮罩设在数据源上,这里只认文档和目录,
查不了库。写作目录不遮罩(写作者要看得到数据),所以拦在自查:和没有出处的数字一样交回写作者重写,仍不改的按
「违规处置」办(report.py)。
只认引用,不按值在正文里找遮罩值(两个字的姓名会撞上常用词):照抄成正文的号码由数字核对拦下。
违规不带那一格的值(不写 text、context):它会进 report.checked、重写的日志、出具缺口,这些地方不能再露一次。带着
+10 -6
View File
@@ -324,12 +324,13 @@ class _MaskedCells:
重写(evidence.masked_cells)。写作目录本身不遮(写作者要看得到数据),只拦引用。
哪几列算按 api/evidence.py 的 _cited_masks 认:列名就是遮罩列的,SQL 里给遮罩列起了别名、套了函数、经子查询或 WITH
转一道的(_mask_sets 的 sure,和证据面板同一个函数),再按血缘去掉别的表里同名的列(设了 name, phone,门店名不算客户
姓名);合并查询按逐格来历和合并 SQL 认。证据面板为了展示宁可多遮(SQL 认不准时整份的列都遮),自查不跟着多算:多算
一列就是一次判失败,写作者也改不掉。
转一道的(_mask_sets 的 sure,和证据面板同一个函数);合并查询按逐格来历和合并 SQL 认。遮罩按列名在整个数据源上
生效,和证据面板一样:哪张表(含视图)里叫这个名字的列都算,设了 name 时门店名和客户姓名都算,违规说明点名取自哪张表
的哪一列。证据面板为了展示宁可多遮(SQL 认不准时整份的列都遮),自查不跟着多算:多算一列就是一次判失败,写作者也
改不掉。
只查引用,不按值在正文里找遮罩值:两个字的姓名会撞上「销售高峰」这类常用词,同名列的值会撞上门店名,正式运行因此判
失败(复核第 3 轮去掉了按值认);正文里照抄的号码本来就会被「数字没有出处」拦下。
只查引用,不按值在正文里找遮罩值:两个字的姓名会撞上「销售高峰」这类常用词,正式运行因此判失败(复核第 3 轮去掉了
按值认);正文里照抄的号码本来就会被「数字没有出处」拦下。
组装好一稿之后才认:只读正文引用到的那几份快照(读盘复验哈希放进线程),每份只读一次,重写的稿子不再读;没引用格子的
一份都不读。"""
@@ -821,7 +822,10 @@ async def _judge_inline(ctx: NodeContext, doc: dict[str, Any], catalog: dict[str
async def judge_round(round_doc: dict[str, Any], limits: judging.Budget, known: dict[str, Any],
first: bool) -> dict[str, Any]:
request = judging.prepare(round_doc, catalog, masked=masks)
# 摘录里每份查询快照逐份遮,改了名字的遮罩列(c.phone AS tel)也不给,和按需裁判同一个函数(_judge_loader)
from app.api.evidence import _judge_loader
request = judging.prepare(round_doc, catalog, masked=masks, loader=await _judge_loader(round_doc, catalog))
key = judging.request_key(request, judge_spec, limits, known)
if _in_graph():
# 写作不进 checkpoint,接着跑时写作者会重写一稿:缓存里先前那一稿的判定对不上指纹,往后翻
+17
View File
@@ -113,6 +113,23 @@ async def test_values_of_a_snapshot_masked_whole_are_not_collected(engine_up, ma
assert shown.text(sql) == sql.replace(PHONES[0], "已遮罩") and shown.changed, shown.text(sql)
@pytest.mark.parametrize("sql, column", [
("SELECT UPPER(phone) FROM customers", "UPPER(phone)"),
("SELECT TRIM(c.phone) FROM customers c ORDER BY c.id", "TRIM(c.phone)"),
])
async def test_values_of_an_unaliased_expression_over_every_column_are_collected(engine_up, mall, sql, column):
"""复核第 4 轮:没起别名、表达式盖住全部结果列,SQL 认得出这一列取自遮罩列(按位置),不是认不准、整份算进来的:
算确定取自遮罩列(sure),值照收,之后别的查询步骤里写着的号码显示成「已遮罩」(第 3 轮一个值都收不到,号码原样显示)。"""
from app.api.evidence import _Masks, _Shown, _mask_sets
art = await put(sql, [column], [[p] for p in PHONES])
sets = await _mask_sets(artifact_store.load(art), "mall", _Masks(), artifact=art)
assert sets.sure == {column.lower()}, (sets.sure, sets.hidden)
secrets = await _Masks(sealed_of(art)).run_secrets()
probe = f"SELECT SUM(amount) FROM orders o JOIN customers c ON c.id = o.customer_id WHERE c.phone = '{PHONES[1]}'"
assert _Shown(secrets).text(probe) == probe.replace(PHONES[1], "已遮罩")
# --------------------------------------------------------------------------
# 按快照工件在进程里记下
# --------------------------------------------------------------------------
@@ -7,8 +7,8 @@
- 查询步骤原样给出 SQL 里写着的遮罩值(按号码筛的 WHERE c.phone IN (…)),合并 SQL、SQL 检查的片段也一样;可验证报告
按「这次运行查到过的遮罩值」认出来、整份只给哈希。现在展示时换成「已遮罩」,底下写明只是展示时换的
- 第 2 轮把面板那套宁可多遮的列按数据源名并给裁判(裁判按数据源名合起来遮,对同一数据源的每一份快照都生效):整份遮的
快照的全部列、只数个数的列一并从摘录里消失,被引用的金额、人数裁判看不到,只能判证据不足。第 3 轮退回 M2 之前的做法,
只按数据源设的列名遮;c.phone AS tel 这种改了名字的号码列仍会进摘录,是裁判摘录已知的缺口,另行处理(judge.py)
快照的全部列、只数个数的列一并从摘录里消失,被引用的金额、人数裁判看不到,只能判证据不足。第 4 轮起摘录按快照逐份遮,
只遮确定取自遮罩列的列(改了名字的号码列见 test_judge_excerpt_masks.py),这两样照给
真跑的部分用虚构的 SQLite 商城库(号码是编的),报告由 MockChatModel 照剧本写。
"""
@@ -198,7 +198,7 @@ def test_sql_check_excerpts_and_merge_parts_are_shown_with_values_masked():
# --------------------------------------------------------------------------
# 交给裁判的摘录:照数据源设的遮罩列(复核第 3 轮退回 M2 之前的做法)
# 交给裁判的摘录:整份遮的快照、只数个数的列照给(第 4 轮起逐份只遮确定取自遮罩列的列)
# --------------------------------------------------------------------------
#: 派生来源里有没起别名的 UPPER(c.phone):证据面板整份遮(宁可多遮),可这份快照的列和号码无关
@@ -269,9 +269,9 @@ def excerpt_of(request: str) -> str:
@pytest.mark.parametrize("where", ["on_demand", "inline"])
async def test_the_judge_sees_the_cited_columns_of_every_snapshot(client, monkeypatch, mall, where):
"""交给裁判的遮罩列只按数据源设的列名(judge.source_masks),不并证据面板那套宁可多遮的列:Q1 整份遮、Q2 只数个数
的列,面板都不显示,可它们和号码无关,裁判要看得到被引用的金额、人数才判得了。按需裁判(探索运行)、节点里当场裁判
(正式运行)同一个办法。号码列照旧不给(摘录里一个号码都没有)。"""
"""交给裁判的摘录不并证据面板那套宁可多遮的列:Q1 整份遮、Q2 只数个数的列,面板都不显示,可它们和号码无关(不在
_mask_sets 的 sure 里),裁判要看得到被引用的金额、人数才判得了。按需裁判(探索运行)、节点里当场裁判(正式运行)
同一个办法(api/evidence.py 的 _judge_loader)。号码列照旧不给(摘录里一个号码都没有)。"""
seen = judge_models(monkeypatch)
if where == "on_demand":
row = await judged_run("exploratory", claims="judge")
+210
View File
@@ -0,0 +1,210 @@
"""交给结论句裁判的摘录:按快照逐份遮,SQL 认得确定取自遮罩列的列(改了名字的号码列)不给,金额、计数照给。
复核发现(第 4 轮):
- 第 3 轮把摘录退回只按数据源设的列名遮:c.phone AS tel 这种改了名字的号码列又进了摘录(「列:spend | tel / r0:380 |
199…」),裁判理由在证据面板、审计 CSV 里原样显示,裁判一引用号码就露出来。按需裁判(探索运行)和报告撰写节点里当场
裁判(正式运行)都是这样
- 第 2 轮把面板那套宁可多遮的列按数据源名并给裁判,整份遮的快照、只数个数的列从摘录里消失,被引用的金额、人数裁判看不到
现在每份快照只遮这份快照里确定取自遮罩列的那几列(_mask_sets 的 sure,和证据面板同一套认法):不按数据源名合并(另一份
快照里同名、却取自别的列的照给),不用宁可多遮的 hidden(整份遮的快照、只数个数的列照给)。摘录变了,复用键随之变,已判过
的句子会重判一次。
真跑的部分用虚构的 SQLite 零售库(号码、热线都是编的),写作者和裁判都是 MockChatModel。
"""
from __future__ import annotations
import asyncio
import json
import re
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import delete, select
from app.core import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, Setting
from app.engine.evidence import compose_doc, iter_units, make_eid
from app.engine.runner import run_manager
from app.main import app
SOURCE = "judgeshop"
CUSTOMERS = [(1, "客户甲", "19900000611"), (2, "客户乙", "19900000612"), (3, "客户丙", "19900000613")]
STORES = [(1, "一号店", "0571-88000001"), (2, "二号店", "0571-88000002")]
ORDERS = [(1, 1, 1, 120.5), (2, 2, 2, 300.0), (3, 2, 2, 80.0), (4, 2, 3, 210.25)]
PHONES = [c[2] for c in CUSTOMERS]
#: 改了名字的号码列 contact:确定取自遮罩列
SQL_CUSTOMER = ("SELECT c.phone AS contact, ROUND(SUM(o.amount), 2) AS spend FROM customers c "
"JOIN orders o ON o.customer_id = c.id GROUP BY c.phone ORDER BY spend DESC")
#: 同样叫 contact,取自门店热线(没设遮罩):照给
SQL_STORE = ("SELECT s.hotline AS contact, COUNT(*) AS orders FROM stores s JOIN orders o ON o.store_id = s.id "
"GROUP BY s.hotline ORDER BY orders DESC")
TEXT = "消费最高的一位客户共消费 [[v:Q1.r0.spend]],订单最多的门店有 [[v:Q2.r0.orders]] 笔订单,热线 [[v:Q2.r0.contact]]。"
def leaks(value) -> list[str]:
"""写着的号码,按千分位写的(摘录里数值格按数渲染:19,900,000,611)也算。"""
raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return [p for p in PHONES if p in raw or p in raw.replace(",", "")]
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client(engine_up):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def set_source(path, options=None) -> str:
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none()
if row is None:
row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
row.options = dict(options or {})
await session.commit()
return row.id
@pytest.fixture
async def shop(tmp_path, engine_up):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);"
"CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT, hotline TEXT);"
"CREATE TABLE orders (id INTEGER PRIMARY KEY, store_id INTEGER, customer_id INTEGER,"
" amount REAL);")
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", CUSTOMERS)
db.executemany("INSERT INTO stores VALUES (?, ?, ?)", STORES)
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ORDERS)
db.commit()
db.close()
source_id = await set_source(path, {"mask_columns": ["phone"]})
await engines.invalidate(source_id)
yield path
await set_source(path, {})
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def judge_models(monkeypatch) -> list[str]:
"""写作者回 TEXT;裁判一律判有依据。返回每次交给裁判的请求原文。"""
from app.engine import judge as judge_mod
from app.engine.judge import JUDGE_ROLE
from app.providers.mock_model import MockChatModel
seen: list[str] = []
def decide(model, messages):
if JUDGE_ROLE in str(messages[0].content):
request = str(messages[-1].content)
seen.append(request)
items = [{"unit": uid, "verdict": "supported", "rationale": "虚构的理由", "used": []}
for uid in re.findall(r"^\[(u\d+)\]", request, re.M)]
return AIMessage(content=json.dumps({"items": items}, ensure_ascii=False))
return AIMessage(content=TEXT)
monkeypatch.setattr(MockChatModel, "_decide", decide)
async def judge_model_of(session, spec):
return MockChatModel(model_name="claude-sonnet-5"), "claude-sonnet-5"
monkeypatch.setattr(judge_mod, "get_chat_model", judge_model_of)
return seen
async def judged_run(run_class: str) -> Run:
from app.engine.judge import SPEND_KEY
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([SPEND_KEY, "judge"])))
await session.commit()
nodes = [node("start", "input"),
node("q_customer", "tool", tool=f"db_query__{SOURCE}", args={"sql": SQL_CUSTOMER}),
node("q_store", "tool", tool=f"db_query__{SOURCE}", args={"sql": SQL_STORE}),
node("write", "report", instructions="写一句", claims="judge", on_violation="flag"),
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}])]
spec = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
run_id = (await run_manager.start(graph=spec, input_payload={}, run_class=run_class)).id
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
assert row.status == "succeeded", row.error
return row
raise AssertionError(f"运行 {run_id} 没跑完:{row.status}")
def excerpt_of(request: str, alias: str) -> str:
"""裁判请求里一份查询的那一段摘录。"""
return request.split(f"【{alias}】", 1)[1].split("【", 1)[0]
def columns_line(excerpt: str) -> str:
return next(line for line in excerpt.splitlines() if line.startswith("列:"))
@pytest.mark.parametrize("where", ["on_demand", "inline"])
async def test_the_judge_does_not_see_a_renamed_masked_column(client, monkeypatch, shop, where):
"""Q1 的 contact 是 c.phone 改了名字:摘录里没有这一列、没有号码,说明有的列遮掉了;被引用的消费额照给。Q2 的 contact
取自门店热线:同名不同出处,照给(不按数据源名合并)。按需裁判(探索运行)、节点里当场裁判(正式运行)同一个办法。"""
seen = judge_models(monkeypatch)
if where == "on_demand":
row = await judged_run("exploratory")
[report] = (await client.get(f"/api/runs/{row.id}/evidence")).json()["reports"]
doc = artifact_store.load(report["doc_artifact"])
uid = next(u["id"] for _, u in iter_units(doc)
if any(seg.get("ref") == "v:Q1.r0.spend" for seg in u.get("segments") or []))
res = await client.post(f"/api/runs/{row.id}/evidence/judge", json={"units": [uid]})
assert res.status_code == 200, res.text
else:
await judged_run("formal")
[request] = seen
q1, q2 = excerpt_of(request, "Q1"), excerpt_of(request, "Q2")
assert "contact" not in columns_line(q1) and "spend" in columns_line(q1) and "380" in q1, q1
assert "有的列按数据源的设置遮罩了" in q1, q1
assert "contact" in columns_line(q2) and "0571-88000002" in q2 and "orders" in columns_line(q2), q2
assert leaks(request) == []
async def test_an_unaliased_expression_over_every_column_is_masked_in_the_excerpt(engine_up, shop):
"""SELECT UPPER(phone) FROM customers:没起别名、表达式盖住全部结果列,SQL 认得出取自遮罩列(不是认不准整份算的),
摘录不给这一列。派生来源里有没起别名的号码表达式、整份遮的快照,金额照给(裁判要看得到才判得了)。"""
from app.api.evidence import _judge_loader
from app.engine import judge as judging
upper = await artifact_store.put_json({"sql": "SELECT UPPER(phone) FROM customers", "columns": ["UPPER(phone)"],
"rows": [[p] for p in PHONES], "source": SOURCE}, kind="query")
whole = await artifact_store.put_json(
{"sql": "SELECT name, amount FROM (SELECT c.name, UPPER(c.phone), o.amount FROM customers c "
"JOIN orders o ON o.customer_id = c.id) t ORDER BY amount DESC",
"columns": ["name", "amount"], "rows": [["客户乙", 300.0]], "source": SOURCE}, kind="query")
listed = (("Q1", upper, ["UPPER(phone)"], 3), ("Q2", whole, ["name", "amount"], 1))
catalog = {alias: {"alias": alias, "kind": "query", "artifact": art, "columns": columns, "rows": rows,
"node_id": alias, "source": SOURCE, "eid": make_eid("query", art, {})}
for alias, art, columns, rows in listed}
doc = compose_doc("客户都留了号码,最大一笔订单 [[v:Q2.r0.amount]],见 [[see:Q1]]。", catalog, node_id="write",
run_id="r1")
masked = await judging.source_masks(catalog)
request = judging.prepare(doc, catalog, masked=masked, loader=await _judge_loader(doc, catalog))
assert leaks(request.excerpts["Q1"]) == [] and "UPPER" not in columns_line(request.excerpts["Q1"]), request.excerpts
assert "amount" in columns_line(request.excerpts["Q2"]) and "300" in request.excerpts["Q2"], request.excerpts
# 不经 _judge_loader(只按数据源设的列名遮)时号码就在摘录里:上面的断言测的是这一层
plain = judging.prepare(doc, catalog, masked=masked)
assert leaks(plain.excerpts["Q1"]) != []
@@ -0,0 +1,329 @@
"""报告自查认「遮罩列」和证据面板同一个口径:遮罩按列名设在整个数据源上,哪张表(含视图)里叫这个名字的列都算。
复核发现(第 4 轮):
- 第 3 轮给自查加了「遮罩表」的推断:遮罩列在几张表里都有、其中有的表另有别的遮罩列时,只认那几张表(customers 有 name
和 phone,stores 只有 name,就只认客户表)。视图的列看不出取自哪张表,没有第二个遮罩列的表(employees.name)也被排除:
经视图 customer_spend 引用客户姓名、引用店员姓名,正式运行照常成功,姓名写进报告;证据面板照列名把同一格遮成「已遮罩」
- 现在不再推断遮罩表:引用的那一格按这份快照的 SQL 认出取自任何一张表里叫这个名字的列,就记 masked_cell,门店名
(stores.name)也算,和证据面板的遮法一致。违规说明照实写取自哪张表的哪一列,并说明遮罩按列名对所有表生效
- SQL 血缘只用来点名出处,和排除值确实取自别的列的(SELECT region AS phone FROM stores:stores 是表结构里的普通表);
视图、表结构里没有的表、没有冻结表结构的快照看不出列的出处,照算
正文里写着的字照旧不按值认(第 3 轮的结论不变):两个字的姓名会撞上「销售高峰」,照抄的号码由「数字没有出处」拦下。
真跑的部分用虚构的 SQLite 零售库(号码、姓名、门店名都是编的),数据源探查过表结构(查询快照里冻结着表结构,视图标着
is_view),写作者是 MockChatModel 照剧本写。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import pytest
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.core import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.evidence import SCHEMA_SNAPSHOT, compose_doc
from app.engine.runner import run_manager
SOURCE = "m2shop"
STORES = [(1, "杭州西湖店", "华东"), (2, "广州天河店", "华南")]
CUSTOMERS = [(1, "陈思远", "19900000801"), (2, "高峰", "19900000802"), (3, "林若溪", "19900000803")]
EMPLOYEES = [(1, "王建国", 1), (2, "赵晓梅", 2)]
ORDERS = [(1, 1, 1, 1, 120.5), (2, 2, 2, 2, 300.0), (3, 1, 2, 1, 80.0), (4, 2, 3, 2, 210.25)]
PHONES = [c[2] for c in CUSTOMERS]
NAMES = [c[1] for c in CUSTOMERS] + [e[1] for e in EMPLOYEES]
SQL_STORE = ("SELECT s.name AS store, ROUND(SUM(o.amount), 2) AS sales FROM stores s JOIN orders o ON o.store_id = s.id "
"GROUP BY s.name ORDER BY sales DESC")
SQL_CUSTOMER = ("SELECT c.name AS customer, ROUND(SUM(o.amount), 2) AS spend FROM customers c "
"JOIN orders o ON o.customer_id = c.id GROUP BY c.name ORDER BY spend DESC")
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def set_source(path, options: dict, *, probe: bool = True) -> str:
"""数据源 m2shop:probe 时探查表结构(工具查询时把它冻结进快照),不然清空。名字只这个文件用,探查的结构不会
冻结进别的测试的快照。"""
from app.data.introspect import introspect
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none()
if row is None:
row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
row.options = dict(options)
row.schema_cache = (await introspect(row)) if probe else None
await session.commit()
return row.id
@pytest.fixture
async def shop(tmp_path, engine_up):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript(
"CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT, region TEXT);"
"CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);"
"CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, store_id INTEGER);"
"CREATE TABLE orders (id INTEGER PRIMARY KEY, store_id INTEGER, customer_id INTEGER, clerk_id INTEGER,"
" amount REAL);"
# 视图里装的就是客户姓名:表结构里只看得到它的列名 name、spend
"CREATE VIEW customer_spend AS SELECT c.name AS name, SUM(o.amount) AS spend FROM customers c "
"JOIN orders o ON o.customer_id = c.id GROUP BY c.name;")
db.executemany("INSERT INTO stores VALUES (?, ?, ?)", STORES)
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", CUSTOMERS)
db.executemany("INSERT INTO employees VALUES (?, ?, ?)", EMPLOYEES)
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", ORDERS)
db.commit()
db.close()
source_id = await set_source(path, {"mask_columns": ["name", "phone"]})
await engines.invalidate(source_id)
yield path
await set_source(path, {}, probe=False)
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(sql: str, **report) -> dict:
nodes = [node("start", "input"), node("top", "tool", tool=f"db_query__{SOURCE}", args={"sql": sql}),
node("write", "report", instructions="写一句", **report),
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}])]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def finish(monkeypatch, spec: dict, text: str) -> Run:
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
run_id = (await run_manager.start(graph=spec, input_payload={})).id
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"运行 {run_id} 没跑完:{row.status}")
async def checked_of(run_id: str) -> dict:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == "report.checked")
return (await session.execute(q)).scalars().one().data
def masked_of(violations: list[dict]) -> list[dict]:
return [v for v in violations if v["code"] == "masked_cell"]
def leaks(value) -> list[str]:
raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return [p for p in PHONES if p in raw or p in raw.replace(",", "")]
def named(value) -> list[str]:
raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return [n for n in NAMES if n in raw]
# --------------------------------------------------------------------------
# 正文里写着的字不按值认
# --------------------------------------------------------------------------
@pytest.mark.parametrize("sql, text", [
# 客户里有一位叫「高峰」:常用词「销售高峰」不是在写这位客户
(SQL_CUSTOMER, "本周销售高峰出现在周五,最高一位客户消费 [[v:Q1.r0.spend]]。"),
# 正文用文字写门店名:不是引用,自查不认(引用门店名才算,见下)
(SQL_STORE, "销售额最高的门店是广州天河店,销售额 [[v:Q1.r0.sales]]。"),
])
async def test_words_that_happen_to_equal_masked_values_do_not_fail_a_formal_report(monkeypatch, shop, sql, text):
"""违规即失败(正式运行的默认值):正文里的字和遮罩列中的值相同,不判 masked_cell,运行照常成功。"""
row = await finish(monkeypatch, graph(sql, on_violation="fail"), text)
assert row.status == "succeeded", row.error
checked = await checked_of(row.id)
assert masked_of(checked["violations"]) == [] and checked["ok"] is True, checked["violations"]
async def test_a_phone_copied_into_the_text_is_stopped_by_the_number_check(monkeypatch, shop):
"""照抄成正文的号码不按值认,「数字没有出处」照样拦下:交回重写、仍不改就判失败;违规里没有 masked_cell。"""
row = await finish(monkeypatch, graph(SQL_CUSTOMER, on_violation="fail"),
f"消费最高的客户电话 {PHONES[1]},共消费 [[v:Q1.r0.spend]]。")
assert row.status == "failed", row.error
checked = await checked_of(row.id)
assert {v["code"] for v in checked["violations"]} == {"uncited_number"}, checked["violations"]
assert "遮罩" not in (row.error or "")
# --------------------------------------------------------------------------
# 引用:哪张表(含视图)里叫这个名字的列都算
# --------------------------------------------------------------------------
#: (说明, SQL, 正文, 被引用的那一格的值, 违规说明里点名的出处)
CITED = [
("经视图引用客户姓名(改了名字)", "SELECT name AS customer, spend FROM customer_spend ORDER BY spend DESC",
"消费最高的客户是 [[v:Q1.r0.customer]],共消费 [[v:Q1.r0.spend]]。", "林若溪",
"按 SQL 认出取自 customer_spend.name"),
("经视图引用客户姓名(列名照旧)", "SELECT name, spend FROM customer_spend ORDER BY spend DESC",
"消费最高的客户是 [[v:Q1.r0.name]],共消费 [[v:Q1.r0.spend]]。", "林若溪", "列「name」取自 customer_spend.name"),
("店员姓名:所在的表里没有第二个遮罩列",
"SELECT e.name AS clerk, SUM(o.amount) AS sales FROM employees e JOIN orders o ON o.clerk_id = e.id "
"GROUP BY e.name ORDER BY sales DESC",
"业绩最高的店员是 [[v:Q1.r0.clerk]],销售额 [[v:Q1.r0.sales]]。", "赵晓梅", "按 SQL 认出取自 employees.name"),
("门店名:和客户姓名同叫 name", SQL_STORE, "销售额最高的门店是 [[v:Q1.r0.store]],销售额 [[v:Q1.r0.sales]]。",
"广州天河店", "按 SQL 认出取自 stores.name"),
("客户姓名", SQL_CUSTOMER, "消费最高的客户是 [[v:Q1.r0.customer]],共消费 [[v:Q1.r0.spend]]。", "林若溪",
"按 SQL 认出取自 customers.name"),
]
@pytest.mark.parametrize("label, sql, text, value, origin", CITED, ids=[c[0] for c in CITED])
async def test_a_cited_masked_name_fails_the_run_whatever_table_it_comes_from(monkeypatch, shop, label, sql, text,
value, origin):
"""违规即失败:引用的那一格取自叫 name 的列(视图、只有一个遮罩列的表、门店表都算),判 masked_cell,运行失败,
成果里没有这个值;说明点名取自哪张表的哪一列,说清遮罩按列名对所有表生效。"""
row = await finish(monkeypatch, graph(sql, on_violation="fail"), text)
assert row.status == "failed", label
[v] = masked_of((await checked_of(row.id))["violations"])
assert origin in v["message"] and "数据源对列名「name」设置了遮罩" in v["message"], v["message"]
assert "按列名对所有表生效" in v["message"] and value not in v["message"], v["message"]
assert origin in row.error and value not in row.error, row.error
async with SessionLocal() as session:
run = await session.get(Run, row.id)
assert value not in json.dumps(run.output, ensure_ascii=False, default=str), run.output
async def test_flagging_keeps_the_report_and_names_the_view(monkeypatch, shop):
"""违规处置为标注:照常产出,违规记着取自视图的哪一列;违规本身不写值。"""
row = await finish(monkeypatch, graph(CITED[0][1], on_violation="flag"), CITED[0][2])
assert row.status == "succeeded", row.error
[v] = masked_of((await checked_of(row.id))["violations"])
assert v["ref"] == "v:Q1.r0.customer" and "customer_spend.name" in v["message"] and named(v) == [], v
# --------------------------------------------------------------------------
# 认法:_cited_masks(报告节点经 _MaskedCells 调用)
# --------------------------------------------------------------------------
#: 冻结的表结构(introspect 的形状):普通表标 is_view=False;视图 customer_spend 装着客户姓名,contact_book 的 contact
#: 装着号码(视图的列看不出取自哪张表)
TABLES = {"stores": ["id", "name", "region"], "customers": ["id", "name", "phone"],
"employees": ["id", "name", "store_id"], "orders": ["id", "store_id", "customer_id", "clerk_id", "amount"]}
VIEWS = {"customer_spend": ["name", "spend"], "contact_book": ["who", "contact"]}
async def schema_of(tables: dict[str, list[str]], views: dict[str, list[str]] | None = None, *,
marked: bool = True) -> str:
"""表结构快照。marked=False 时不写 is_view(看不出是表还是视图的老形状)。"""
def meta(cols: list[str], view: bool) -> dict:
return {"columns": [{"name": c, "type": "TEXT"} for c in cols], **({"is_view": view} if marked else {})}
return await artifact_store.put_json(
{"source": SOURCE, "tables": {**{t: meta(cols, False) for t, cols in tables.items()},
**{t: meta(cols, True) for t, cols in (views or {}).items()}}},
kind=SCHEMA_SNAPSHOT)
async def cited(sql: str, columns: list[str], *, masks: list[str], schema: bool = True, marked: bool = True,
tables: dict[str, list[str]] = TABLES, views: dict[str, list[str]] = VIEWS) -> dict[str, list[str]]:
"""一份快照按报告自查认出的列:{结果列: 它的出处(认得出表的写「表.列」,认不出的是全部遮罩列)}。"""
from app.api.evidence import _cited_masks, _Masks
await set_source(":memory:", {"mask_columns": masks}, probe=False)
snap = {"sql": sql, "columns": columns, "rows": [], "source": SOURCE,
**({"schema_artifact": await schema_of(tables, views, marked=marked)} if schema else {})}
art = await artifact_store.put_json(snap, kind="query")
return await _cited_masks(snap, SOURCE, _Masks(), art)
MIXED = ("SELECT s.name AS store, c.name AS customer, c.phone AS tel, SUM(o.amount) AS spend FROM orders o "
"JOIN stores s ON s.id = o.store_id JOIN customers c ON c.id = o.customer_id GROUP BY s.name, c.name, c.phone")
@pytest.mark.parametrize("label, sql, columns, masks, expected", [
("两张表的同名列在一条查询里:都算,各自点名", MIXED, ["store", "customer", "tel", "spend"], ["name", "phone"],
{"store": ["stores.name"], "customer": ["customers.name"], "tel": ["customers.phone"]}),
("只设了 name", MIXED, ["store", "customer", "tel", "spend"], ["name"],
{"store": ["stores.name"], "customer": ["customers.name"]}),
("直接选出门店表的 name", "SELECT name, region FROM stores", ["name", "region"], ["name", "phone"],
{"name": ["stores.name"]}),
("* 展开门店表", "SELECT * FROM stores", ["id", "name", "region"], ["name", "phone"], {"name": ["stores.name"]}),
("* 展开客户表", "SELECT * FROM customers", ["id", "name", "phone"], ["name", "phone"],
{"name": ["customers.name"], "phone": ["customers.phone"]}),
("店员表只有一个遮罩列", "SELECT e.name AS clerk FROM employees e", ["clerk"], ["name", "phone"],
{"clerk": ["employees.name"]}),
("经视图(改了名字)", "SELECT name AS customer, spend FROM customer_spend", ["customer", "spend"], ["name", "phone"],
{"customer": ["customer_spend.name"]}),
("视图的列名不是遮罩列、改成了遮罩列的名字:看不出取自哪里,照算", "SELECT contact AS phone FROM contact_book",
["phone"], ["phone"], {"phone": ["phone"]}),
("经 WITH 转两道", "WITH a AS (SELECT c.phone AS tel FROM customers c), b AS (SELECT tel AS contact FROM a) "
"SELECT contact FROM b", ["contact"], ["phone"], {"contact": ["customers.phone"]}),
("列名和遮罩列一样,值却取自普通表的别的列", "SELECT region AS phone FROM stores", ["phone"], ["phone"], {}),
("UNION:两支都取自叫 name 的列", "SELECT name FROM stores UNION ALL SELECT name FROM customers", ["name"],
["name", "phone"], {"name": ["stores.name", "customers.name"]}),
("UNION:一支取自普通表的别的列,另一支经视图", "SELECT region AS name FROM stores UNION ALL "
"SELECT name FROM customer_spend", ["name"], ["name"], {"name": ["customer_spend.name"]}),
])
async def test_cited_masks_count_the_column_name_in_every_table(engine_up, label, sql, columns, masks, expected):
assert await cited(sql, columns, masks=masks) == expected, label
@pytest.mark.parametrize("label, sql, kwargs", [
("没有冻结的表结构:看不出 stores 是表还是视图", "SELECT region AS phone FROM stores", {"schema": False}),
("表结构没标 is_view", "SELECT region AS phone FROM stores", {"marked": False}),
("表结构里没有这张表", "SELECT region AS phone FROM stores", {"tables": {"customers": ["id", "name", "phone"]}}),
("写了库名:表结构探查的是另一个库,同名的表可能是视图", "SELECT s.region AS phone FROM archive.stores s", {}),
])
async def test_a_column_whose_origin_cannot_be_seen_is_flagged(engine_up, label, sql, kwargs):
"""值看起来取自别的列(region AS phone),可这张表看不出是不是视图:排除不了,照列名算(和证据面板一样遮)。"""
assert await cited(sql, ["phone"], masks=["phone"], **kwargs) == {"phone": ["phone"]}, label
async def test_without_a_frozen_schema_the_sql_still_names_the_tables(engine_up):
"""数据源没探查过表结构:SQL 里写了表的照样点名出处;认不出、排除不了的照算。"""
assert await cited(MIXED, ["store", "customer", "tel", "spend"], masks=["name", "phone"], schema=False) == \
{"store": ["stores.name"], "customer": ["customers.name"], "tel": ["customers.phone"]}
@pytest.mark.parametrize("sql, columns", [
# 表值函数:拆的是号码,血缘认不出出处,照遮罩那一套认
("SELECT x.value AS p FROM customers c, json_each(c.phone) x", ["p"]),
# 没起别名的表达式:血缘按列名找不到这一列
("SELECT id, UPPER(phone) FROM customers", ["id", "UPPER(phone)"]),
])
async def test_columns_the_lineage_cannot_place_stay_flagged(engine_up, sql, columns):
assert set(await cited(sql, columns, masks=["name", "phone"])) == {columns[-1].lower()}
async def test_the_report_node_flags_every_cited_name_column(engine_up):
"""报告节点的自查(_MaskedCells)按同一个函数认:同一条查询里引用门店名、客户名都算,各自点名出处,不写值。"""
from app.engine.nodes.report import _MaskedCells
await set_source(":memory:", {"mask_columns": ["name", "phone"]}, probe=False)
art = await artifact_store.put_json({"sql": MIXED, "columns": ["store", "customer", "tel", "spend"],
"rows": [["广州天河店", "高峰", PHONES[1], 300.0]], "source": SOURCE,
"schema_artifact": await schema_of(TABLES, VIEWS)}, kind="query")
catalog = {"Q1": {"alias": "Q1", "kind": "query", "artifact": art, "columns": ["store", "customer", "tel", "spend"],
"rows": 1, "node_id": "top", "source": SOURCE}}
doc = compose_doc("门店 [[v:Q1.r0.store]],客户 [[v:Q1.r0.customer]],消费 [[v:Q1.r0.spend]]。", catalog,
node_id="write", run_id="r1")
store, customer = masked_of((await _MaskedCells(catalog)(doc))["violations"])
assert store["ref"] == "v:Q1.r0.store" and "stores.name" in store["message"], store
assert customer["ref"] == "v:Q1.r0.customer" and "customers.name" in customer["message"], customer
for v in (store, customer):
assert "按列名对所有表生效" in v["message"] and "按列名对所有表生效" in v["for_model"], v
assert leaks(v) == [] and "广州天河店" not in json.dumps(v, ensure_ascii=False) and "高峰" not in v["message"]
-261
View File
@@ -1,261 +0,0 @@
"""报告自查只查引用,而且只认真取自遮罩表里那一列的格子(复核第 3 轮)。
复核发现:
- 第 2 轮加的按值自查把两个字的遮罩值当子串去认:客户里有一位叫「高峰」,正文写「本周销售高峰」就判 masked_cell,正式
运行(默认违规即失败)因此失败,报错还叫人去改写作指令
- 按值自查连同名列的值一起收:mask_columns 设了 name(本意是客户姓名),正文提到门店名(stores.name)同样判失败
- 引用同理:遮罩按列名设在整个数据源上(不带表名),s.name AS store 按列名认也算取自遮罩列「name」
现在自查只认写作者用 [[v:Qn.rX.列]] 引用的格子(正文里照抄的号码本来就会被「数字没有出处」拦下),并且按 SQL 的血缘核一遍
这一列的值取自哪张表的哪一列:遮罩列在几张表里都有、其中有的表里另有别的遮罩列时(customers 有 name 和 phone,stores 只有
name),只算那几张表。证据面板、可验证报告照列名多遮,不受影响。
真跑的部分用虚构的 SQLite 零售库(号码、姓名、门店名都是编的),数据源探查过表结构(查询快照里冻结着表结构),写作者是
MockChatModel 照剧本写。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import pytest
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.core import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.evidence import SCHEMA_SNAPSHOT, compose_doc
from app.engine.runner import run_manager
SOURCE = "m2shop"
STORES = [(1, "杭州西湖店", "华东"), (2, "广州天河店", "华南")]
CUSTOMERS = [(1, "陈思远", "19900000801"), (2, "高峰", "19900000802"), (3, "林若溪", "19900000803")]
ORDERS = [(1, 1, 1, 120.5), (2, 2, 2, 300.0), (3, 1, 2, 80.0), (4, 2, 3, 210.25)]
PHONES = [c[2] for c in CUSTOMERS]
SQL_STORE = ("SELECT s.name AS store, ROUND(SUM(o.amount), 2) AS sales FROM stores s JOIN orders o ON o.store_id = s.id "
"GROUP BY s.name ORDER BY sales DESC")
SQL_CUSTOMER = ("SELECT c.name AS customer, ROUND(SUM(o.amount), 2) AS spend FROM customers c "
"JOIN orders o ON o.customer_id = c.id GROUP BY c.name ORDER BY spend DESC")
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def set_source(path, options: dict, *, probe: bool = True) -> str:
"""数据源 m2shop:probe 时探查表结构(工具查询时把它冻结进快照),不然清空。名字只这个文件用,探查的结构不会
冻结进别的测试的快照。"""
from app.data.introspect import introspect
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none()
if row is None:
row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
row.options = dict(options)
row.schema_cache = (await introspect(row)) if probe else None
await session.commit()
return row.id
@pytest.fixture
async def shop(tmp_path, engine_up):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT, region TEXT);"
"CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);"
"CREATE TABLE orders (id INTEGER PRIMARY KEY, store_id INTEGER, customer_id INTEGER, amount REAL);")
db.executemany("INSERT INTO stores VALUES (?, ?, ?)", STORES)
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", CUSTOMERS)
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ORDERS)
db.commit()
db.close()
source_id = await set_source(path, {"mask_columns": ["name", "phone"]})
await engines.invalidate(source_id)
yield path
await set_source(path, {}, probe=False)
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(sql: str, **report) -> dict:
nodes = [node("start", "input"), node("top", "tool", tool=f"db_query__{SOURCE}", args={"sql": sql}),
node("write", "report", instructions="写一句", **report),
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}])]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def finish(monkeypatch, spec: dict, text: str) -> Run:
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
run_id = (await run_manager.start(graph=spec, input_payload={})).id
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"运行 {run_id} 没跑完:{row.status}")
async def checked_of(run_id: str) -> dict:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == "report.checked")
return (await session.execute(q)).scalars().one().data
def masked_of(violations: list[dict]) -> list[dict]:
return [v for v in violations if v["code"] == "masked_cell"]
def leaks(value) -> list[str]:
raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return [p for p in PHONES if p in raw or p in raw.replace(",", "")]
# --------------------------------------------------------------------------
# 正文里写着的字不按值认
# --------------------------------------------------------------------------
@pytest.mark.parametrize("sql, text", [
# 客户里有一位叫「高峰」:常用词「销售高峰」不是在写这位客户
(SQL_CUSTOMER, "本周销售高峰出现在周五,最高一位客户消费 [[v:Q1.r0.spend]]。"),
# 设了 name 本意是客户姓名:正文用文字写门店名照常
(SQL_STORE, "销售额最高的门店是广州天河店,销售额 [[v:Q1.r0.sales]]。"),
])
async def test_words_that_happen_to_equal_masked_values_do_not_fail_a_formal_report(monkeypatch, shop, sql, text):
"""违规即失败(正式运行的默认值):正文里的字和遮罩列中的值相同,不判 masked_cell,运行照常成功。"""
row = await finish(monkeypatch, graph(sql, on_violation="fail"), text)
assert row.status == "succeeded", row.error
checked = await checked_of(row.id)
assert masked_of(checked["violations"]) == [] and checked["ok"] is True, checked["violations"]
async def test_a_phone_copied_into_the_text_is_stopped_by_the_number_check(monkeypatch, shop):
"""照抄成正文的号码不按值认,「数字没有出处」照样拦下:交回重写、仍不改就判失败;违规里没有 masked_cell。"""
row = await finish(monkeypatch, graph(SQL_CUSTOMER, on_violation="fail"),
f"消费最高的客户电话 {PHONES[1]},共消费 [[v:Q1.r0.spend]]。")
assert row.status == "failed", row.error
checked = await checked_of(row.id)
assert {v["code"] for v in checked["violations"]} == {"uncited_number"}, checked["violations"]
assert "遮罩" not in (row.error or "")
# --------------------------------------------------------------------------
# 引用:只认真取自遮罩表里那一列的
# --------------------------------------------------------------------------
async def test_citing_a_store_name_is_not_flagged_though_name_is_masked(monkeypatch, shop):
"""s.name AS store:门店表只有 name、没有别的遮罩列,customers 里 name 和 phone 都在——设了 name 管的是客户姓名。"""
row = await finish(monkeypatch, graph(SQL_STORE, on_violation="fail"),
"销售额最高的门店是 [[v:Q1.r0.store]],销售额 [[v:Q1.r0.sales]]。")
assert row.status == "succeeded", row.error
assert masked_of((await checked_of(row.id))["violations"]) == []
async def test_citing_a_customer_name_is_flagged_and_names_the_masked_column(monkeypatch, shop):
"""c.name AS customer:取自客户表的 name,照旧拦下;说明只点名它取自的那一列遮罩列,不把 phone 也列进来。"""
row = await finish(monkeypatch, graph(SQL_CUSTOMER, on_violation="flag"),
"消费最高的客户是 [[v:Q1.r0.customer]],共消费 [[v:Q1.r0.spend]]。")
assert row.status == "succeeded", row.error
[v] = masked_of((await checked_of(row.id))["violations"])
assert v["ref"] == "v:Q1.r0.customer" and "取自设置了遮罩的列「name」" in v["message"], v["message"]
# --------------------------------------------------------------------------
# 认法:_cited_masks(报告节点经 _MaskedCells 调用)
# --------------------------------------------------------------------------
#: 冻结的表结构(introspect 的形状):stores 只有 name,customers 有 name 和 phone
TABLES = {"stores": ["id", "name", "region"], "customers": ["id", "name", "phone"],
"orders": ["id", "store_id", "customer_id", "amount"]}
async def schema_of(tables: dict[str, list[str]]) -> str:
return await artifact_store.put_json(
{"source": SOURCE, "tables": {t: {"columns": [{"name": c, "type": "TEXT"} for c in cols]}
for t, cols in tables.items()}}, kind=SCHEMA_SNAPSHOT)
async def cited(sql: str, columns: list[str], *, masks: list[str], schema: bool = True,
tables: dict[str, list[str]] = TABLES) -> dict[str, list[str]]:
"""一份快照按报告自查认出的列:{结果列: 它取自的遮罩列}。"""
from app.api.evidence import _cited_masks, _Masks
await set_source(":memory:", {"mask_columns": masks}, probe=False)
snap = {"sql": sql, "columns": columns, "rows": [], "source": SOURCE,
**({"schema_artifact": await schema_of(tables)} if schema else {})}
art = await artifact_store.put_json(snap, kind="query")
return await _cited_masks(snap, SOURCE, _Masks(), art)
MIXED = ("SELECT s.name AS store, c.name AS customer, c.phone AS tel, SUM(o.amount) AS spend FROM orders o "
"JOIN stores s ON s.id = o.store_id JOIN customers c ON c.id = o.customer_id GROUP BY s.name, c.name, c.phone")
@pytest.mark.parametrize("label, sql, columns, masks, expected", [
("两张表的同名列在一条查询里:只认客户表的", MIXED, ["store", "customer", "tel", "spend"], ["name", "phone"],
{"customer": ["name"], "tel": ["phone"]}),
("只设了 name:几张表都有 name、哪张都没有别的遮罩列,照列名认", MIXED, ["store", "customer", "tel", "spend"],
["name"], {"store": ["name"], "customer": ["name"]}),
("直接选出门店表的 name(列名对上遮罩列)", "SELECT name, region FROM stores", ["name", "region"],
["name", "phone"], {}),
("* 展开门店表", "SELECT * FROM stores", ["id", "name", "region"], ["name", "phone"], {}),
("* 展开客户表", "SELECT * FROM customers", ["id", "name", "phone"], ["name", "phone"],
{"name": ["name"], "phone": ["phone"]}),
("经 WITH 转两道", "WITH a AS (SELECT c.phone AS tel FROM customers c), b AS (SELECT tel AS contact FROM a) "
"SELECT contact FROM b", ["contact"], ["phone"], {"contact": ["phone"]}),
("列名和遮罩列一样,值却取自别的列", "SELECT region AS phone FROM stores", ["phone"], ["phone"], {}),
("UNION:有一支取自客户表就算", "SELECT name FROM stores UNION ALL SELECT name FROM customers", ["name"],
["name", "phone"], {"name": ["name"]}),
])
async def test_cited_masks_follow_the_table_the_value_comes_from(engine_up, label, sql, columns, masks, expected):
assert await cited(sql, columns, masks=masks) == expected, label
async def test_without_a_frozen_schema_the_mask_is_read_by_column_name(engine_up):
"""数据源没探查过表结构(快照里没有冻结的表结构):认不出同名的列还在哪几张表里,照列名认,门店名也算;值根本不取自
遮罩列的(region AS phone)照样不算。"""
assert await cited(MIXED, ["store", "customer", "tel", "spend"], masks=["name", "phone"], schema=False) == \
{"store": ["name"], "customer": ["name"], "tel": ["phone"]}
assert await cited("SELECT region AS phone FROM stores", ["phone"], masks=["phone"], schema=False) == {}
@pytest.mark.parametrize("sql, columns", [
# 表值函数:拆的是号码,血缘认不出出处,照遮罩那一套认
("SELECT x.value AS p FROM customers c, json_each(c.phone) x", ["p"]),
# 没起别名的表达式:血缘按列名找不到这一列
("SELECT id, UPPER(phone) FROM customers", ["id", "UPPER(phone)"]),
])
async def test_columns_the_lineage_cannot_place_stay_flagged(engine_up, sql, columns):
assert set(await cited(sql, columns, masks=["name", "phone"])) == {columns[-1].lower()}
async def test_the_report_node_flags_only_the_customer_column(engine_up):
"""报告节点的自查(_MaskedCells)按同一个函数认:同一条查询里引用门店名不算、引用客户名算。"""
from app.engine.nodes.report import _MaskedCells
await set_source(":memory:", {"mask_columns": ["name", "phone"]}, probe=False)
art = await artifact_store.put_json({"sql": MIXED, "columns": ["store", "customer", "tel", "spend"],
"rows": [["广州天河店", "高峰", PHONES[1], 300.0]], "source": SOURCE,
"schema_artifact": await schema_of(TABLES)}, kind="query")
catalog = {"Q1": {"alias": "Q1", "kind": "query", "artifact": art, "columns": ["store", "customer", "tel", "spend"],
"rows": 1, "node_id": "top", "source": SOURCE}}
doc = compose_doc("门店 [[v:Q1.r0.store]],客户 [[v:Q1.r0.customer]],消费 [[v:Q1.r0.spend]]。", catalog,
node_id="write", run_id="r1")
[v] = masked_of((await _MaskedCells(catalog)(doc))["violations"])
assert v["ref"] == "v:Q1.r0.customer" and "「name」" in v["message"] and leaks(v) == [], v
@@ -184,10 +184,11 @@ async def test_the_sure_columns_follow_the_dialect_of_the_source(engine_up):
async def test_a_failure_points_at_the_instructions_not_at_the_model(monkeypatch, mall):
"""遮罩列多半是写作指令点名要的:判失败时只说去掉指令里的引用,不建议换模型、不建议先改成标注。说明点名取自哪一列。"""
"""遮罩列多半是写作指令点名要的:判失败时只说去掉指令里的引用,不建议换模型、不建议先改成标注。说明点名取自哪张表
的哪一列(数据源没探查过表结构,SQL 里写着 c.phone、c 是 customers)。"""
row = await finish(monkeypatch, graph(SQL_TOP, on_violation="fail"), "联系电话 [[v:Q1.r0.tel]]。")
assert row.status == "failed", row.error
assert "指令中去掉对这些列的引用" in row.error and "按 SQL 认出取自设置了遮罩的列「phone」" in row.error, row.error
assert "指令中去掉对这些列的引用" in row.error and "按 SQL 认出取自 customers.phone" in row.error, row.error
assert "指令遵循能力" not in row.error and "标注" not in row.error and leaks(row.error) == []
+3 -3
View File
@@ -248,9 +248,9 @@ export const ABSENCE_CLAIM_TEXT = {
}
/**
* 报告直接引用了取自遮罩列的格子(报告自查的 masked_cell:[[v:Q2.r0.tel]],c.phone AS tel 这种改了名字的也算,别的表里
* 同名的列不算),重写后仍在、「违规处置」为标注时照常产出:时间线上那一行(日志 report_masked_cell)和证据面板违规清单
* 里的叫法。自查只查引用,不按值在正文里找遮罩值。
* 报告直接引用了取自遮罩列的格子(报告自查的 masked_cell:[[v:Q2.r0.tel]],c.phone AS tel 这种改了名字的也算;遮罩按
* 列名对数据源里所有表生效,视图、别的表里同名的列也算),重写后仍在、「违规处置」为标注时照常产出:时间线上那一行(日志
* report_masked_cell)和证据面板违规清单里的叫法。自查只查引用,不按值在正文里找遮罩值。
* 只说记为缺口,不说出具:探索运行、没设出具契约的成果不出具,报告接在侧枝上时缺口也进不了出具,降不降档由出具那一行说
*/
export const MASKED_CELL_TEXT = {