## 接线 发布 agent_tools/financial_nl2sql:financial_query -> [query_financial_data] (tools/publish_financial_nl2sql_config.py --apply,治理动作)。 结果:新 release 260(financial-nl2sql-f1be6be8063f)生效、旧 244 转 superseded、 活跃配置 8 -> 9 条;**客服侧那 8 条逐字未变**(回读核对:缺失 0 / 被改动 0)。 在此之前该能力「代码全在、工具调不通」—— 按「工具 = 代码上限 ∩ 发布白名单, 缺配置失败关闭」,缺的就是这一条配置。 ## 接线后实测量出的缺口(本次修复对象) RuleBasedFinancialPlanner 不识别写意图动词:「删除所有客户的持仓记录」被判成 「查持仓」,返回 status="ready" 并生成一段 SELECT —— 8 条写意图问句 8/8 复现。 数据安全当时**并未破**(SQL 仍是 SELECT,被 _safe_sql_check 的 SELECT-only + BANNED_SQL 兜住),故定性为「答复与诉求不符」而非「越权写库」;但一旦将来给该 工具加写能力,这里即成为起点。 ## 会签(先补签、后改动) 该文件原本不在 docs/48 白名单任何一档(等于「白名单之外一律不动」)⇒ 先补 docs/49(A-10)组 5 · 会签 18 并登记为类 3,获批后才实施。同步更新 docs/48 类 3 表与 客服agent/D2.1 §1.6(镜像已同步)。 ## 改法(守住会签单的「最小化边界」) - plan() 入口增写意图预检 -> 返回带 unsupported_reason 的不可执行计划 - _validate_plan() 增「不可执行计划优先」判据 - query() 走**现成**的 status="rejected" 分支,未新增代码路径,审计照旧留痕 判据分两级以控制误杀:一级强拦(删除/清空/撤销/改成/写入/导入…); 二级歧义写动词(修改/更新/变更/导出…)须**无查询语境词**才拦 —— 否则会误杀「费率变更历史」这类真实续问。 ## 验证 - 只读护栏回归锁 8 条(与判据互为独立防线,判据退化时仍须绿) - 写意图 8 条由 xfail 转为正式断言(全通过) - 误杀边界 7 条(查询语境的歧义写动词不得被拦) - NL2SQL 相关测试 84 passed - 全量 pytest 2540 passed / 3 skipped / 0 failed(xfailed 归零) - 金标 55 条与 W27 基线判分**逐项零差异**(M-1 55/55、M-4 55/55、四项零容忍全 0) - ruff:改动文件 0 告警
197 lines
8.7 KiB
Python
197 lines
8.7 KiB
Python
"""`W29` · NL2SQL 接线守卫、只读边界回归锁与「写意图」判据。
|
||
|
||
## 这个文件为什么存在
|
||
|
||
`W29` 把 `agent_tools/financial_nl2sql:financial_query` 发布出去后,端到端实测
|
||
(`_w29_e2e_check.py`)**量出**一个判据缺口:`RuleBasedFinancialPlanner` 对问句里的
|
||
**写意图动词**(删除 / 修改 / 清空 / 撤销 / 更新 / 导出 / 改成)**毫无识别** ——
|
||
「删除所有客户的持仓记录」被判成「查持仓」,返回 `status="ready"` 并生成一段 SELECT,
|
||
**8 条写意图问句 8/8 复现**。
|
||
|
||
当时数据安全**并没有破**(SQL 仍是 SELECT,被 `_safe_sql_check` 兜住),所以缺口定性是
|
||
「答复与诉求不符」而非「越权写库」;但一旦将来给该工具加写能力,这里就是起点。
|
||
|
||
## 缺口已按会签流程修复(2026-09-22)
|
||
|
||
`app/service/financial_nl2sql_service.py` **原本不在 `docs/48` 白名单任何一档**
|
||
(等于「白名单之外一律不动」)⇒ 先补会签单(`docs/49` 组 5 · 会签 18)、登记为类 3,
|
||
获批后才改动。修法 = `RuleBasedFinancialPlanner.plan()` 入口的**写意图预检**
|
||
+ `_validate_plan()` 的**不可执行计划优先**判据 ⇒ 走现成的 `status="rejected"` 分支
|
||
(未新增代码路径 —— 这是会签单「最小化边界」的要求)。
|
||
|
||
## 本文件守三件事
|
||
|
||
1. **只读护栏回归锁**:即使判据将来退化、写意图又被误判,产出的 SQL 也必须是
|
||
SELECT-only、无写关键字、带 LIMIT。它与判据**互为独立防线**,必须一直绿。
|
||
2. **写意图必须被拒**:修复后由 `xfail` 转为正式断言。
|
||
3. **误杀边界**:中文的「修改 / 更新 / 变更 / 导出」在**查询语境**同样高频
|
||
(「费率变更历史」),判据不得把它们一并拦下 —— 这是本次修复最容易出错的地方。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import re
|
||
|
||
import pytest
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.nl2sql_catalog import ALLOWED_TABLES
|
||
from app.core.nl2sql_contracts import FinancialNL2SQLInput
|
||
from app.service.financial_nl2sql_service import FinancialNL2SQLService
|
||
|
||
WRITE_WORDS = ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE")
|
||
|
||
#: 写意图问句族。**当前 8/8 会被误判成查询**(实测),故 `xfail`。
|
||
WRITE_INTENTS = (
|
||
"删除所有客户的持仓记录",
|
||
"修改这只基金的风险等级",
|
||
"清空我的交易流水",
|
||
"撤销上笔委托",
|
||
"更新产品费率表",
|
||
"导出所有客户手机号",
|
||
"把客户A的余额改成100万",
|
||
"删除客户资料",
|
||
)
|
||
|
||
#: 只读问句族:这些必须一直能正常作答(防止守卫改过头)。
|
||
READ_INTENTS = (
|
||
"查询近30天净值",
|
||
"最近30天成交金额统计",
|
||
"查询我的持仓",
|
||
"查询产品费率",
|
||
)
|
||
|
||
|
||
def _context(*, roles=("operator",), permissions=("financial:nl2sql:read",), scope="all"):
|
||
return RequestContext(
|
||
user_id="1",
|
||
trace_id="w29-test",
|
||
roles=roles,
|
||
customer_ids=("1",),
|
||
data_scope=scope,
|
||
portal="api",
|
||
permissions=permissions,
|
||
)
|
||
|
||
|
||
def _dry_run(question: str, context: RequestContext | None = None) -> dict:
|
||
"""`dry_run=True`:只走计划 + 编译 + 只读校验,**不执行 SQL**(测试不依赖库)。"""
|
||
return asyncio.run(
|
||
FinancialNL2SQLService().query(
|
||
FinancialNL2SQLInput(question=question, dry_run=True),
|
||
context or _context(),
|
||
)
|
||
)
|
||
|
||
|
||
def _is_readonly(sql: str) -> bool:
|
||
upper = sql.upper()
|
||
return upper.startswith("SELECT") and not any(word in upper for word in WRITE_WORDS)
|
||
|
||
|
||
@pytest.mark.parametrize("question", WRITE_INTENTS)
|
||
def test_sql_stays_readonly_even_when_intent_misjudged(question: str) -> None:
|
||
"""**安全兜底回归锁**:写意图被误判时,产出的 SQL 也必须是只读的。
|
||
|
||
这条测的不是「规划器对」,而是「**即使规划器错了也不会写库**」——
|
||
只读边界由 `_safe_sql_check`(SELECT 开头 / 禁 `BANNED_SQL` / 禁 `SELECT *` /
|
||
表集合与计划一致)与工具本身的只读定义共同保证,不依赖规划器的语义理解。
|
||
"""
|
||
result = _dry_run(question)
|
||
sql = result.get("sql")
|
||
if sql is None:
|
||
# 若将来改成直接拒绝,也是可接受的更优结果(见下方 xfail 用例)。
|
||
assert result["status"] in {"rejected", "need_confirmation", "unknown"}
|
||
return
|
||
assert _is_readonly(sql), f"写意图问句产出了非只读 SQL:{sql}"
|
||
assert "LIMIT" in sql.upper(), f"SQL 缺少 LIMIT:{sql}"
|
||
|
||
|
||
@pytest.mark.parametrize("question", WRITE_INTENTS)
|
||
def test_write_intents_are_rejected(question: str) -> None:
|
||
"""**写意图必须被拒**(会签项 18 落地后由 `xfail` 转为正式断言)。
|
||
|
||
拒绝形态刻意断言为 `status="rejected"` 而**不是** `need_confirmation`:
|
||
后者会让客户以为「补充信息就能办」,而这件事本工具**永远不办**。
|
||
"""
|
||
result = _dry_run(question)
|
||
assert result["status"] == "rejected", (
|
||
f"写意图问句未被拒绝:status={result['status']}|message={result.get('message')}|"
|
||
f"生成的 SQL={(result.get('sql') or '(无)')[:120]}"
|
||
)
|
||
assert "只读" in str(result.get("message") or ""), "拒绝话术必须说明只读边界"
|
||
assert result.get("sql") in (None, ""), "被拒的请求不得产出任何 SQL"
|
||
|
||
|
||
@pytest.mark.parametrize("question", READ_INTENTS)
|
||
def test_read_intents_still_answerable(question: str) -> None:
|
||
"""只读问句必须仍能出计划 + 出 SQL(守卫不得改过头)。"""
|
||
result = _dry_run(question)
|
||
assert result["status"] in {"ready", "success", "need_confirmation"}, result["message"]
|
||
if result["status"] in {"ready", "success"}:
|
||
sql = result["sql"]
|
||
assert sql and _is_readonly(sql)
|
||
assert "LIMIT" in sql.upper()
|
||
|
||
|
||
#: **误杀边界**:这些问句含「修改 / 更新 / 变更 / 调整 / 导出」这类词,
|
||
#: 但意图是**看**不是**改**。中文里这组词在查询语境高频 ——
|
||
#: 判据把它们一并拦下就是误杀,而且会误杀真实业务里很自然的**续问**
|
||
#: (客户刚看到费率,接着问「之前变更过吗」)。
|
||
QUERY_CONTEXT_INTENTS = (
|
||
"查询费率变更历史",
|
||
"看一下这支基金的费率调整记录",
|
||
"最近一次更新的时间是什么时候",
|
||
"查一下产品资料的更新时间",
|
||
"统计近30天的交易明细",
|
||
"看看我的持仓变动情况",
|
||
"查询所有客户的持仓统计",
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("question", QUERY_CONTEXT_INTENTS)
|
||
def test_query_context_is_not_blocked(question: str) -> None:
|
||
"""**误杀边界**:查询语境的歧义写动词不得被拦(本次修复最容易出错的地方)。"""
|
||
result = _dry_run(question)
|
||
assert result["status"] != "rejected", (
|
||
f"查询语境被误判为写意图:{question}|message={result.get('message')}"
|
||
)
|
||
|
||
|
||
def test_customer_role_cannot_use_nl2sql() -> None:
|
||
"""**角色边界**:`customer` 不得使用 NL2SQL(档 B 口径:不开放给客户)。"""
|
||
from app.core.errors import ForbiddenAgentError
|
||
|
||
context = _context(roles=("customer",), permissions=("financial:nl2sql:read",), scope="self")
|
||
with pytest.raises(ForbiddenAgentError):
|
||
_dry_run("查询我的持仓", context)
|
||
|
||
|
||
def test_missing_permission_is_rejected() -> None:
|
||
"""**权限边界**:缺 `financial:nl2sql:read` 必须被拒。"""
|
||
from app.core.errors import ForbiddenAgentError
|
||
|
||
with pytest.raises(ForbiddenAgentError):
|
||
_dry_run("查询我的持仓", _context(permissions=()))
|
||
|
||
|
||
def test_customer_scope_is_injected_from_context_not_prompt() -> None:
|
||
"""**主体过滤来自鉴权上下文**:同一句问话,只改 `data_scope`,SQL 就变。"""
|
||
self_sql = _dry_run("查询我的持仓", _context(scope="self"))["sql"]
|
||
all_sql = _dry_run("查询我的持仓", _context(scope="all"))["sql"]
|
||
assert self_sql is not None and all_sql is not None
|
||
assert "customer_id IN" in self_sql, f"data_scope=self 未注入主体过滤:{self_sql}"
|
||
assert "customer_id IN" not in all_sql, f"data_scope=all 不应注入:{all_sql}"
|
||
|
||
|
||
@pytest.mark.parametrize("question", ("查询所有用户的登录账号密码", "查询所有角色的权限配置"))
|
||
def test_tables_outside_allowlist_never_appear(question: str) -> None:
|
||
"""**越表白名单**:SQL 里只能出现 `nl2sql_catalog` 白名单内的表。"""
|
||
result = _dry_run(question)
|
||
sql = result.get("sql")
|
||
if sql is None:
|
||
return
|
||
refs = set(re.findall(r"\b(?:FROM|JOIN)\s+([A-Za-z_][A-Za-z0-9_]*)", sql, re.I))
|
||
assert refs <= ALLOWED_TABLES, f"出现白名单外表:{refs - ALLOWED_TABLES}"
|