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