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 告警
This commit is contained in:
张胜宇
2026-09-22 10:12:06 +08:00
parent d315aa61db
commit 9dcfa64bc5
5 changed files with 341 additions and 3 deletions
+70
View File
@@ -75,9 +75,74 @@ def _filters(question: str) -> tuple[dict[str, Any], ...]:
)
#: **一级:明确的写动作词**。命中即拒绝 —— 本工具是只读查询,不执行任何修改。
#:
#: 这一组刻意收得**很窄**:只收「在查询语境里几乎不出现」的动词。
#: 像「修改 / 更新 / 变更 / 调整 / 导出」这些**两头都常见**的词(「修改记录」是查询、
#: 「修改风险等级」是写)放二级,见 `_AMBIGUOUS_WRITE_WORDS`。
_WRITE_ACTION_WORDS = (
"删除", "删掉", "清空", "清除", "抹掉", "撤销", "作废", "重置",
"改成", "改为", "改掉", "写入", "录入", "导入", "新增", "添加", "插入",
)
#: **二级:歧义写动词**。单独出现**不拦**(会误杀「费率变更历史」这类查询),
#: 只有与「查询语境词」**同现**时才放行判断 —— 详见 `is_write_intent`。
_AMBIGUOUS_WRITE_WORDS = ("修改", "更新", "变更", "调整", "导出", "改一下")
#: **查询语境词**。出现即说明客户要的是「看」,不是「改」。
#:
#: ⚠️ 这里刻意只收**动词性或疑问性**的词,**不收「记录 / 明细 / 列表」这类名词** ——
#: 「删除我的持仓**记录**」也是写意图,若把名词当豁免词就会把它放过去。
_QUERY_CONTEXT_WORDS = (
"查询", "查一下", "查查", "查看", "看看", "看下", "看一下", "是多少", "有多少",
"多少", "哪些", "哪几", "统计", "汇总", "列出", "列一下", "明细", "历史",
"怎么样", "是什么", "为什么", "有没有", "是否",
)
def is_write_intent(question: str) -> bool:
"""判断问句是不是**写诉求**(要求改数据),而不是查询。
为什么要这个判据(`W29` 实测出来的缺口):`RuleBasedFinancialPlanner` 只看「查什么」
不看「要干什么」,于是「删除所有客户的持仓记录」被当成「查持仓」,返回 `status="ready"`
并生成一段 SELECT —— **8 条写意图问句 8/8 复现**。
数据安全并没有破(SQL 仍是 SELECT,只读护栏兜住),但**答复与诉求不符**:
客户说「删」,收到「这是你的持仓列表」。且一旦将来给这个工具加写能力,这里就是起点。
⚠️ **误杀边界**(本判据最容易出错的地方):中文里「修改 / 更新 / 变更 / 导出」
在查询语境同样高频(「费率变更历史」「更新日期」「导出对账单」)。所以分成两级:
一级强拦,二级要求**没有**查询语境词才拦。
"""
text = (question or "").strip()
if not text:
return False
has_query_context = any(word in text for word in _QUERY_CONTEXT_WORDS)
if any(word in text for word in _WRITE_ACTION_WORDS):
return not has_query_context
if any(word in text for word in _AMBIGUOUS_WRITE_WORDS):
return not has_query_context
return False
#: 拒绝时给调用方看的话术。**只说明能力边界,不复述客户问句**(复述会把「删除」这类词
#: 带回答复里,读起来像系统在确认一个它做不到的动作)。
WRITE_INTENT_REPLY = "本功能仅支持只读查询,不执行任何修改、删除或导出操作。请调整问题后重试。"
class RuleBasedFinancialPlanner:
def plan(self, query: FinancialNL2SQLInput) -> FinancialQueryPlan:
question = query.question
# `W29`:**写意图预检**(会签项 18 · `A-10` 组 5)。
#
# 为什么放在**最前**:一旦判定是写诉求,就不该再去做任何「查什么」的推断 ——
# 否则「删除所有客户的持仓记录」会被理解成「查持仓」,返回一个看起来成功的
# `ready` 计划(实测 8/8 复现)。**拒绝必须发生在理解之前。**
if is_write_intent(question):
return FinancialQueryPlan(
intent="unsupported", domains=(), tables=(), limit=query.limit,
confidence=1.0, unsupported_reason=WRITE_INTENT_REPLY,
)
start, end = _range(question)
time_mode = "range" if start and end else "none"
if any(word in question for word in ("截至", "历史时点", "当时", "某日")):
@@ -296,6 +361,11 @@ class FinancialNL2SQLService:
@staticmethod
def _validate_plan(plan: FinancialQueryPlan, context: RequestContext) -> tuple[bool, str]:
# `W29`:**不可执行计划优先**。写意图预检(在 `plan()` 里)产出的计划在这里被拦下,
# 原样回传原因 ⇒ `query()` 走**现成**的 `status="rejected"` 分支,不需要新增代码路径,
# 审计也照旧留痕(这是会签项 18「最小化边界」的要求:只新增一条提前返回路径)。
if plan.unsupported_reason:
return False, plan.unsupported_reason
if len(plan.domains) > 3:
return False, "最多支持跨三个业务域查询"
if not set(plan.tables).issubset(ALLOWED_TABLES):