Files
group_fqcd_jr/tests/unit/service/test_nl2sql_wiring_w29.py
T
张胜宇 9dcfa64bc5 feat(W29): NL2SQL 接线与只读边界守卫(会签项 18)
## 接线

发布 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 告警
2026-09-22 10:12:06 +08:00

197 lines
8.7 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.
"""`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}"