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):
+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` 中标注为**降级**。
@@ -0,0 +1,196 @@
"""`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}"
+18 -1
View File
@@ -1253,6 +1253,23 @@
> ⚠️ **v5.2 修订**:原清单中的 `app/api/**`、`app/worker/**`、`app/service/agent/base.py` **已移出**——由批次 G 触碰(§1.3)。
### 1.6 组 5 · NL2SQL 只读边界(**`W29` 新增 · 2026-09-22 补签**)
> **为什么单列**:`app/service/financial_nl2sql_service.py` **不在 §1.1 / §1.3 名单内,也不在本 §1.5 零改动清单内** ⇒
> 按 `docs/48`「白名单之外一律不动」,它原本属**白名单之外**。`W29` 把 NL2SQL 工具白名单接线后做端到端实测,
> 反向守卫**量出**写意图边界缺陷(「删除所有客户的持仓记录」被判成查询,8 条问句 **8/8 复现**)⇒
> **先补会签单、后改动**(与组 3 / 组 4 的"追认既成事实"性质不同),并在此登记为**类 3(组 5)**。
| # | 文件 | 触碰项 | 改什么 | 最小化边界 |
|---|---|---|---|---|
| 5-1 | `app/service/financial_nl2sql_service.py` | **只读工具的写意图边界** | `RuleBasedFinancialPlanner.plan()` 入口增**写意图预检** → 返回带 `unsupported_reason` 的不可执行计划;`_validate_plan()` 增一条判据 ⇒ `query()` 走**现成**的 `status="rejected"` 分支(审计自动留痕) | 不改函数签名 / 不改 `FinancialQueryPlan`·`FinancialNL2SQLResult` 形状 / **零 DDL** / 不新增路由与权限点 / **不动五重白名单与 `CURRENT_ONLY_TABLES`** / 只新增一条提前返回路径 |
**依据**:`INV-2`(无证据不生成事实)、`INV-6`(数字必须来自受控数据源)的取向 —— **读工具不应回答写诉求**。
**影响面**:`tests/unit/service/test_nl2sql_wiring_w29.py`(8 条 `xfail` → 正式断言 + 误杀边界用例)、`tests/unit/service/test_financial_nl2sql_*.py`。
**未纳入本组**:`offsite_nl2sql_adapter` 实际调用的仓库根 `nl2sql_yc.py` —— 同类缺口,但**不在白名单任何一档**,须另立单处理。
**会签单**:`docs/49`(`A-10`)**组 5 · 会签 18**(☑ 2026-09-22 受理)。
**落地证据**:全量 `pytest` 与 55 条金标**逐项零差异**,见 `_W29-NL2SQL接线与对话内图表实施报告-2026-09-22.md` §2。
---
## 2. 怎么用这份看板
@@ -1262,7 +1279,7 @@
| 状态标记 | `[ ]` 未开始 / `[x]` 完成 / `🔴` 高风险或带会签 / `⏸` 挂起 |
| 开工顺序 | 按 §6.1 关键路径;**同类文件的任务不要拆成多次提交**(测试彼此交叠,拆开会导致反复红) |
| 完成判定 | `[x]` 表示 DoD 全部满足且「验证」列已执行;**未跑验证不算完成** |
| 会签项 | 见 §1.1 / §1.3;**未获会签不得开工**,未受理则走 §1.4 的降级并**在文档中标注** |
| 会签项 | 见 §1.1 / §1.3 / §1.6;**未获会签不得开工**,未受理则走 §1.4 的降级并**在文档中标注** |
| 与需求对齐 | 每个批次末尾的「交付判据」直接对应《需求文档》§2.2 的验收标准 |
---