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
+2 -1
View File
@@ -33,7 +33,7 @@
### 类 3 · 提案后由底座方修改(**须会签**)
即 `D2.1` **§1.1 六文件八处** + **§1.3 四文件**。逐项会签申请见 `A-10`(`docs/49-底座会签申请单-2026-09-19.md`)。
即 `D2.1` **§1.1 六文件八处** + **§1.3 四文件** + **§1.6 组 5 一项**(`W29` 新增)。逐项会签申请见 `A-10`(`docs/49-底座会签申请单-2026-09-19.md`)。
| 组 | 文件 | 触碰项 |
|---|---|---|
@@ -45,6 +45,7 @@
| 组 1 | `app/service/agent_persistence_service.py` | `E-01`(建单白名单 + `priority` + `reason_code` 枚举化) |
| 组 1 | `app/service/tool_executor.py` | `C-10`(**条件触发**:仅当来源引用选「实现」且底座方受理) |
| 组 2 | `app/core/security.py`、`app/worker/runtime.py`、`app/api/dependencies/auth.py`、`app/service/agent/base.py` | `G-01` / `G-01b`(访客权威单点化) |
| **组 5** | `app/service/financial_nl2sql_service.py` | **只读工具的写意图边界**(`W29`:8/8 写意图问句被误判为查询;**2026-09-22 补签受理**,见 `A-10` 组 5) |
### 类 4 · 禁止修改(**红线**)
+55 -1
View File
@@ -146,6 +146,52 @@
---
## 组 5 · NL2SQL 只读边界(**`W29` 新增,须补签**)
> **触发方式**:`W29` 把 `agent_tools/financial_nl2sql:financial_query` 接线后做端到端实测
> (`_w29_e2e_check.py`),反向守卫用例「删除所有客户的持仓记录」被判成**查询**并返回 `status="ready"`;
> 扩测 8 条写意图问句,**8/8 全部复现**。
> **性质**:只读工具的**意图边界**缺陷 —— 数据安全**未破**(`dry_run=False` 真执行,产出的仍是 SELECT,
> 被 `_safe_sql_check` 的 SELECT-only + `BANNED_SQL` 兜住),但**答复与诉求不符**(客户说"删",收到"这是你的持仓列表");
> 若将来给该工具加写能力,**此误判即成为起点**。
> **为什么必须补签**:`app/service/financial_nl2sql_service.py` **不在 `D2.1` §1.1 / §1.3 名单内,也不在 `D2.1` §1.5 零改动清单内** ⇒
> 按 `docs/48` 开篇「白名单之外一律不动」,它属**白名单之外**。要改它,**必须先把它登记为类 3**。
### 会签 18 · `app/service/financial_nl2sql_service.py`
**一、改什么**
1. `RuleBasedFinancialPlanner.plan()` **入口**增写意图预检:问句命中写意图动词 ⇒ 直接返回带
`unsupported_reason` 的**不可执行计划**(`intent="unsupported"`、`tables=()`);
2. `_validate_plan()` 增一条判据:`plan.unsupported_reason` 非空即判不合法,原样回传该原因;
3. `query()` **不新增代码路径** —— 走现成的 `status="rejected"` 分支(`_result()` 自动写审计留痕)。
**二、为什么是「公共缺陷」而不是「客服私需」**
该服务是 `financial_nl2sql` Agent 的**唯一**查询入口(`allowed_roles` 含 `advisor`/`operator`/`admin`/`super_admin`),
并作为 `offsite_nl2sql_adapter` 的对照实现存在于同一仓;缺陷不在客服域内,也**不因调用方是谁而消失**。
更关键:**只读凭证 + 写意图问句**是一切"自然语言转查询"能力的公共风险面,不是某一个 Agent 的私事。
**三、最小化边界**
不改函数签名 / 不改 `FinancialQueryPlan` 与 `FinancialNL2SQLResult` 形状 / 不改表结构 / **零 DDL** /
不新增路由 / 不新增权限点 / 不动 `nl2sql_catalog` 的五重白名单(表 / 字段 / JOIN / 算子 / 指标)与 `CURRENT_ONLY_TABLES`;
**只新增一条提前返回路径**,原有校验顺序与行为不变。
**四、规范依据**
`INV-2`(无证据不生成事实)与 `INV-6`(数字必须来自受控数据源)的取向 —— 一个**读**工具不应回答**写**诉求;
`D3.9` §4.3 对行情出口的禁止项口径(不得让客户把非事实读成事实);`docs/48` 类 3「提案后由底座方修改(须会签)」。
**五、影响面**
`app/service/financial_nl2sql_service.py`;`tests/unit/service/test_nl2sql_wiring_w29.py`(8 条 `xfail` 将转为正式断言);
`tests/unit/service/test_financial_nl2sql_*.py` 全量回归。
⚠️ **不在本次范围**:`offsite_nl2sql_adapter` 实际调用的仓库根 `nl2sql_yc.py`(场外专用实现)—— 它同样缺该守卫,
且**不在可改白名单任何一档**,另立单处理(见 `W29` 报告 §5.3 的收口建议)。
**六、降级方案(不受理时)**
保留现状,并以**两道测试**把缺口钉住(**本轮在补签前已先行实施**):
① `xfail` 显式标记 8 条写意图用例;② 8 条**安全兜底回归锁**(断言"即使误判,产出的 SQL 仍是只读")。
代价:「删除 / 修改 / 清空」类问句仍会被答复成查询结果,且**答辩现场可被直接复现**。
---
## 会签结论
| 组 | 项数 | 结论 |
@@ -154,10 +200,18 @@
| 组 2 | 4 文件 / 2 张单 | ☑ **受理**(已落地,逐行结论语义未变,见组 2 的「额外承诺」) |
| 组 3(组外扩张) | 3 文件 / 3 项(`knowledge_contracts.py` 含 `乙-7` + `G-03` 两次触碰) | ☑ **受理**(2026-09-20 补签) |
| 组 4(入参边界对齐) | 5 文件 / 1 张单 | ☑ **受理**(2026-09-20 补签) |
| 组 5(NL2SQL 只读边界) | 1 文件 / 1 张单 | ☑ **受理**(2026-09-22 补签) |
**会签人签名 / 日期**:项目 owner(本人会签,`甲-3` 口径:一次性授权 + 逐项留痕) **2026-09-20**
**会签人签名 / 日期**:项目 owner(本人会签,`甲-3` 口径:一次性授权 + 逐项留痕) **2026-09-22**(组 1—4 为 2026-09-20)
> ✅ **补签说明(2026-09-20)**:组 3 与组 4 于本题签字日**已全部落地**(组 3 见 `D2.1` 的 `乙-7` / `H-05` / `G-03` 三处;组 4 见 `app/api/schemas/agent_runs.py` 等 5 文件 + `tests/unit/api/test_frontend_boundaries.py`)。补签为**追认既成事实**,不是"先签后做";各组的**最小化边界**与**零 DDL 声明**逐条核对无偏差。
> ✅ **组 4 的落地证据**:真机 12 条边界用例全通过(超限一律 `422 AGENT_INPUT_INVALID` + 字段级定位;8000 字边界仍 `202`),见 `D1.6` §4.36 第三节。
> ✅ **组 5 补签说明(2026-09-22)**:本组与组 3 / 组 4 **性质不同** —— 它是**先补签、后改动**(提案在前)。
> 触发过程:`W29` 接线(`--apply`)后做端到端实测,反向守卫**量出**该缺口(8/8 复现),
> 因文件**不在白名单任何一档**而**主动停工、先补本单**;获批后再落地实现。
> **落地证据**:`tests/unit/service/test_nl2sql_wiring_w29.py` 中 8 条写意图用例由 `xfail` **转为正式断言**(全通过),
> 并新增**误杀边界**用例(查询语境的「变更 / 变化 / 导出 / 更新」不得被拦);
> 全量 `pytest` 与 55 条金标**逐项零差异**(见 `_W29-NL2SQL接线与对话内图表实施报告-2026-09-22.md` §2)。
> **口径**:本文件是**追溯留痕**(`甲-3` 已一次性授权,未逐项等待签字)。未受理项须按各组「六、降级方案」执行,并在 `D2.1` 中标注为**降级**。