审批质询遮罩收口(后端):质询工件里的 SQL 检查结果字段照证据面板,片段里的遮罩值照 SQL 换掉

做项 3 时发现的同一类缺口:agent 的查询快照带着 SQL 检查结果(数据源查询工具对照数据目录查出的问题,checks),质询工件接口
原样带出——sql_excerpt 是从这条 SQL 摘的片段(按遮罩列筛选的那个条件,例如 buyer_phone = '13900000001'),for_model 是给模型
的改法、也照抄了片段。事件和工件里的 content.sql 已经换掉遮罩值,旁边的片段没换,等于从旁边把值露出来;证据面板对报告快照
本来就只给约定的几个字段、不给 for_model。

- inquiry_artifact 里的 checks 经 _masked_checks:字段照证据面板的 _query_checks(不交 for_model),sql_excerpt 照查询清单的
  SQL 换(_scrub_sql,和遮罩列做等值比较的短的数、日期这样的字也认得出)
- 界面不读 checks,只是接口上不再带原值
- 补单元测试和端到端测试(把 SQL 检查换成会摘出遮罩值片段的假检查)。做过去掉实现、测试变红的验证
This commit is contained in:
ThearchyHelios
2026-10-06 12:30:52 +08:00
parent 32c3119086
commit f0e97f614f
4 changed files with 64 additions and 6 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
查询快照(queries[].artifact)照回答时的办法遮好:遮罩列(含起了别名、套了函数的)整列遮,别的列里整格是遮罩值的
格子也遮,masked 是有格子整格被遮掉的列名(界面把这几列整列画成「已遮罩」);一格文字里夹着遮罩值的(备注里写着
手机号),只把那一截换成「已遮罩」,这一列不记进 masked。content.sql 就是事件里记下的那条(遮罩值已换掉),两处
一字不差。回答文档
一字不差;content.checks(SQL 检查结果)的字段照证据面板,片段 sql_excerpt 里的遮罩值照 SQL 换掉。回答文档
(answer.doc_artifact)组装时就遮好了,原样给;回答里引用的报告指标(doc.catalog 里 m: 条目的 artifact,口径卡)
也从这里取,界面一律按 doc.catalog[alias].artifact 取。没写进运行记录的回答(recorded 为假)在本进程里留一小时,
这段时间里它引用的工件照样能取;之后、或者服务重启过,回 404。别的工件一律 404
+15 -3
View File
@@ -2189,18 +2189,30 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]:
recorded = next((q["sql"] for d in referenced for q in d.get("queries") or []
if isinstance(q, dict) and q.get("artifact") == artifact_id and isinstance(q.get("sql"), str)),
None)
compared, kind = plan.tainted.get(artifact_id, set()), plan.kinds.get(str(content.get("source") or ""))
if recorded is not None:
shown = {**shown, "sql": recorded}
elif isinstance(shown.get("sql"), str):
name = str(content.get("source") or "")
shown = {**shown, "sql": _scrub_sql(shown["sql"], plan.secrets, plan.tainted.get(artifact_id, set()),
plan.kinds.get(name))}
shown = {**shown, "sql": _scrub_sql(shown["sql"], plan.secrets, compared, kind)}
if "checks" in shown:
shown = {**shown, "checks": _masked_checks(shown["checks"], plan.secrets, compared, kind)}
out: dict[str, Any] = {"id": artifact_id, "content": shown, "masked": plan.masked[artifact_id]}
if gone := plan.gone.get(artifact_id):
out["mask_note"] = SOURCE_GONE.format(source="、".join(gone))
return out
def _masked_checks(checks: Any, secrets: _Secrets, compared: set[str], kind: str | None) -> list[dict[str, Any]]:
"""快照里记下的 SQL 检查结果(数据源查询工具对照数据目录查出的问题)交给质询工件的样子:字段照证据面板
(_query_checks),给模型的那句改法不交出去——它照抄了 SQL 片段;SQL 片段(sql_excerpt)摘自同一条 SQL,照查询
清单的 SQL 换掉遮罩值(_scrub_sql):SQL 换了、片段没换,等于把换掉的值从旁边露出来。"""
from app.api.evidence import _query_checks
return [{**check, "sql_excerpt": _scrub_sql(check["sql_excerpt"], secrets, compared, kind)}
if isinstance(check.get("sql_excerpt"), str) else check
for check in _query_checks({"checks": checks})]
__all__ = [
"BUSY_CONTINUE", "BUSY_DECIDE", "INQUIRY_BUSY", "INQUIRY_DISABLED", "INQUIRY_NOT_OPEN", "INQUIRY_SCHEMA",
"INQUIRY_SYSTEM", "NOT_RECORDED", "QUESTION_MAX", "QUOTE_MAX", "Ask", "InquiryRefused", "ask", "inquiry_artifact",
+30 -1
View File
@@ -16,7 +16,7 @@
也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、
和日期、天数修饰、标签里写着的同一个数也不牵连阈值;SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,
回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里
只换掉那一截
只换掉那一截;质询工件里 SQL 检查结果的片段照 SQL 换,给模型的那句改法不交出去
- 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的
数据源、没固定过版本的上传表格不查
- 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明
@@ -1286,6 +1286,35 @@ async def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact(monk
await engines.invalidate(source_id)
async def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql(monkeypatch, masked_shop, client):
"""agent 的查询带着 SQL 检查结果(对照数据目录查出的问题,片段摘自这条 SQL):质询工件里片段照查询清单的 SQL
换掉遮罩值,给模型的那句改法照抄了片段,不交出去(同证据面板)。"""
from app.tools import datasource
excerpt = "buyer_phone = '13900000001'"
async def flagged(checker, sql):
return [{"code": "wrong_date_column", "level": "warning", "table": "sales", "column": "buyer_phone",
"message": "查询按「买家手机号」筛选", "for_model": f"这条 SQL 按 {excerpt} 筛选",
"sql_excerpt": excerpt}] if excerpt in sql else []
monkeypatch.setattr(datasource, "_sql_checks", flagged)
plays = {"买得最多": Play([TOP_BUYER, BUYER_ORDERS], "这位客户共 [[v:Q2.r0.笔数]] 笔。[[see:Q2]]")}
Models(monkeypatch, masked_shop, plays=plays)
paused = await pause(masked_shop)
[approval] = await pending(paused.id)
r = await ask(client, paused.id, report="write", unit=None, question="买得最多的客户买过几笔?",
approval_id=approval.id)
assert r.status_code == 200, r.text
_, orders = r.json()["inquiry"]["queries"]
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{orders['artifact']}")
assert got.status_code == 200, got.text
assert got.json()["content"]["checks"] == [
{"code": "wrong_date_column", "level": "warning", "table": "sales", "column": "buyer_phone",
"message": "查询按「买家手机号」筛选", "sql_excerpt": "buyer_phone = '已遮罩'"}]
assert got.json()["content"]["sql"] == orders["sql"] and "1390000000" not in got.text
#: 同样的四笔订单,手机号存成整数:遮罩值在 SQL 里写成数字
PHONES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone INTEGER, amount INTEGER, is_test INTEGER);"
"INSERT INTO sales VALUES (1, '华东', 13900000001, 1200, 0), (2, '华东', 13900000002, 300, 1),"
+18 -1
View File
@@ -15,7 +15,7 @@
- 阈值只在同一个数在这条 SQL 别处确实被换掉时才不留(换不换和 SQL 里换字面量是同一套判断):日期、天数、标签里写着
同一个短的数,没设遮罩的列上的等值,都不牵连阈值
- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里,
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交)
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段照 SQL 换
- 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去
- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串照样按结构认,成百个分档边界看不过来的
按夹住算,也不卡
@@ -669,6 +669,23 @@ def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact_view():
assert inquiry_mod._masked_view(snapshot, set(), secrets)[0] is snapshot
def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql():
"""质询工件里快照记下的 SQL 检查结果:片段(sql_excerpt)摘自同一条 SQL,照查询清单的 SQL 换掉遮罩值(日期这样的
字也认得出是在找哪一天);给模型的那句改法(for_model)照抄了片段,不交出去(同证据面板)。"""
secrets = secrets_of({"columns": ["birth_date"], "rows": [["1990-05-01"]]}, {"birth_date"})
message = "「客户」的业务日期按「注册日期」计,查询却按「生日」筛选"
checks = [{"code": "wrong_date_column", "level": "warning", "table": "customers", "column": "birth_date",
"message": message, "for_model": "这条 SQL 按 birth_date = '1990-05-01' 筛选",
"sql_excerpt": "birth_date = '1990-05-01'"},
{"code": "stock_summed", "level": "info", "table": "balances", "message": "存量跨日期求和",
"sql_excerpt": "SUM(balance)"}]
assert inquiry_mod._masked_checks(checks, secrets, {"birth_date"}, "sqlite") == [
{"code": "wrong_date_column", "level": "warning", "table": "customers", "column": "birth_date",
"message": message, "sql_excerpt": "birth_date = '已遮罩'"},
{"code": "stock_summed", "level": "info", "table": "balances", "message": "存量跨日期求和",
"sql_excerpt": "SUM(balance)"}]
def test_report_values_that_happen_to_be_masked_values_are_shown():
"""报告口径卡里的指标恰好和遮罩列里的某个值一样(单笔目标 20000,金额也有 20000 的单):报告里本来就看得到,
引用它的回答照常交出去。"""