fix(W29-c): nl2sql_yc 的 GROUP BY 拼装缺陷(会签组 6)+ 更正一处误判

## 更正:上一个提交里有一句话是错的

docs/49 组 5 与 D2.1 §1.6 原写「nl2sql_yc.py 同样缺写意图守卫」—— 该表述有误。
立组 6 时按纪律**先取证、再立单**,实测 8 条写意图问句对它 **8/8 已被拒绝**
(它没有宽泛关键词路由:「删除所有客户的持仓记录」不命中它的「当前持仓」
⇒ 计划落 unknown ⇒ 被 _validate_plan 拒绝)⇒ **它不需要写意图守卫**。

三处文档的错误表述已当日更正并**保留更正痕迹**(docs/49 组 5 的更正块、
D2.1 §1.6 的更正块),不静默抹掉。取证日志见报告 §10.1。

## 顺带量出的真缺陷:GROUP BY 拼装

取证同时发现一条**完全正常**的只读问句直接报错:

    查询近30天净值 -> status=error
    (pymysql.err.OperationalError) (1055, "Expression #3 of SELECT list is not in
     GROUP BY clause and contains nonaggregated column 'jr_agent.n.nav'
     ... incompatible with sql_mode=only_full_group_by")

根因(_compile_sql):SELECT 里既有 dimensions(已进 GROUP BY)又有 metrics,
而 metrics 里的**非聚合**列(n.nav / m.close_price / h.market_value …)**没进 GROUP BY**。

修法(仅 +24 / −1 行,守住会签单的「最小化边界」):
① 没有聚合函数就不加 GROUP BY;② 有聚合时把**所有非聚合 select 列**一并纳入,
而不是只放 dimensions(漏掉非聚合 metric 正是 1055 的成因)。

## 影响面

踩:_mock_plan 的 4 条路由(收盘/行情、净值(已实证)、当前持仓、账户余额);
不踩:SUM / COUNT 聚合路由,以及 _offsite_mock_plan 全部 5 条(均无 dimensions)。
=> 场外主链路不受影响;受影响的是通用兜底路由的 4 类问句(演示与离线联调走的正是这条)。

## 验证

- 实测(真连库):净值/行情查询由 error 转 success;资金流水(SUM)GROUP BY 语义不变;error 归零
- 新增测试 5 条:**断言 SQL 结构而非「跑得通」** —— 缺陷只在真实 MySQL 的
  only_full_group_by 下现形,而单测跑在 sqlite(不做该检查);断言"能跑"在修复前后
  都是绿的,等于没测。其中一条是**通用不变量**:有 GROUP BY 时 SELECT 里每个非聚合列
  都必须在组内 —— 将来往 metric_map 加非聚合指标时会**先红**,而不是等真实库冒 1055
- offsite 相关 13 passed;全量 2545 passed / 3 skipped / 0 failed(2540 + 5)
- 金标 55 条与 W27 基线判分逐项零差异
- ruff:新文件 0 告警;nl2sql_yc.py **零新增告警**
  (既存 39 条 E501/F601/UP035/F401/B905 未清理 —— 不在会签单「最小化边界」内)

会签:docs/49(A-10)组 6 · 会签 19(☑ 2026-09-22 受理);白名单已登记 docs/48 类 3 表。
This commit is contained in:
张胜宇
2026-09-22 10:31:24 +08:00
parent 020f17363f
commit 39ca52449a
5 changed files with 290 additions and 6 deletions
+2 -1
View File
@@ -33,7 +33,7 @@
### 类 3 · 提案后由底座方修改(**须会签**)
即 `D2.1` **§1.1 六文件八处** + **§1.3 四文件** + **§1.6 组 5 一项**(`W29` 新增)。逐项会签申请见 `A-10`(`docs/49-底座会签申请单-2026-09-19.md`)。
即 `D2.1` **§1.1 六文件八处** + **§1.3 四文件** + **§1.6 组 5 一项** + **§1.7 组 6 一项**(`W29` 新增)。逐项会签申请见 `A-10`(`docs/49-底座会签申请单-2026-09-19.md`)。
| 组 | 文件 | 触碰项 |
|---|---|---|
@@ -46,6 +46,7 @@
| 组 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) |
| **组 6** | `nl2sql_yc.py`(仓库根) | **`GROUP BY` 拼装缺陷**(`W29-c`:`SELECT` 里的非聚合 metric 未进 `GROUP BY` ⇒ MySQL `only_full_group_by` 报 1055,正常只读查询直接 `error`;**2026-09-22 补签受理**,见 `A-10` 组 6) |
### 类 4 · 禁止修改(**红线**)
+72 -2
View File
@@ -182,8 +182,13 @@
**五、影响面**
`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 的收口建议)。
⚠️ **不在本次范围**:`offsite_nl2sql_adapter` 实际调用的仓库根 `nl2sql_yc.py` —— **不在可改白名单任何一档**,另立单处理。
> ⚠️ **同日更正(2026-09-22,立组 6 时实测得出)**:本行原写「它**同样缺**该守卫」——**该表述有误,特此更正**。
> 实测 8 条写意图问句对它 **8/8 均已被拒绝**(`rejected` / `need_confirmation`):它**没有宽泛关键词路由**
> (「删除所有客户的**持仓**记录」不会命中它的「当前持仓」),计划落到 `unknown` / 低置信 ⇒ 被 `_validate_plan` 拒绝。
> ⇒ **`nl2sql_yc.py` 不需要写意图守卫**(组 5 的修复对它并非必需)。
> 但它另有一个**真实的**缺陷(`GROUP BY` 拼装导致正常只读查询直接报错),见 **组 6 · 会签 19**。
**六、降级方案(不受理时)**
保留现状,并以**两道测试**把缺口钉住(**本轮在补签前已先行实施**):
@@ -192,6 +197,70 @@
---
## 组 6 · `GROUP BY` 拼装缺陷(**`W29-c` 新增 · 须补签**)
> **触发方式**:立组 5 时按计划要去核实 `nl2sql_yc.py` 是否「同类缺口」,于是**先取证、再立单**。
> 取证同时得到两个结果:
> **① 原判断被推翻** —— 它**没有**写意图缺口(8/8 已拦,见组 5 的更正块);
> **② 顺带量出一个真缺陷** —— 一条**完全正常**的只读问句直接报错。
>
> 实测(`OffsiteNl2SqlAdapter.query`,`use_llm=False`,真连库):
>
> ```text
> 查询近30天净值 -> status=error
> (pymysql.err.OperationalError) (1055, "Expression #3 of SELECT list is not in GROUP BY clause
> and contains nonaggregated column 'jr_agent.n.nav' ... incompatible with sql_mode=only_full_group_by")
> [SQL: SELECT p.product_name AS product_name, n.nav_date AS nav_date, n.nav AS nav
> FROM fin_nav_history n JOIN fin_product p ON n.product_id = p.id
> WHERE 1=1 AND n.nav_date >= %(start_time)s AND n.nav_date <= %(end_time)s
> GROUP BY p.product_name, n.nav_date LIMIT 50]
> ```
>
> **性质**:这不是「答得不够好」,是**根本答不了** —— 客户问一句正常的话,拿到的是服务错误。
### 会签 19 · `nl2sql_yc.py`
**一、改什么**
`_compile_sql()` 末尾的 `GROUP BY` 拼装改为两条:
① **仅当** `select_parts` 里含聚合函数(`SUM|COUNT|AVG|MIN|MAX`)时才加 `GROUP BY`;
② 加时把**所有非聚合的 select 列**一并纳入 `GROUP BY` —— 当前只放了 `dimensions`,
而 `metrics` 里的非聚合列(如 `n.nav AS nav`)被漏掉,**这正是 1055 的成因**。
**二、为什么是「公共缺陷」而不是「场外私需」**
`nl2sql_yc.py` 是**仓库根的唯一实现**,被 `app/service/offsite_fund_service.py` 与
`app/service/offsite_nl2sql_adapter.py` **共同消费**,测试面覆盖 4 个 integration + 2 个 unit。
它不属客服 Agent 私域;且缺陷在 MySQL `only_full_group_by`(本项目默认 `sql_mode`)下**恒现**。
**三、最小化边界**
不改函数签名 / 不改 `QueryPlan`·`AuthContext` 形状 / **零 DDL** / **不动** `ALLOWED_TABLES`·
`TABLE_COLUMNS`·`JOIN_SQL`·`_safe_sql_check`·路由与指标映射 / 只改 `GROUP BY` 的**判定与列集合**。
(组 5 的写意图预检**不在此单** —— 见上方更正块,实测它不需要。)
**四、依据**
`INV-6`(数字必须来自受控数据源):查询报错时连"受控数据"都给不出。
`_safe_sql_check` 已**独立**保证只读性,本项修的是**正确性**而非安全。
**五、影响面(实测 + 代码推定)**
| 路由(`_mock_plan`) | metrics | dimensions | 是否踩 |
|---|---|---|---|
| 收盘 / 行情 | 收盘价(**非聚合**) | 产品名 + 交易日 | ❌ 踩(推定) |
| **净值** | 基金净值(**非聚合**) | 产品名 + 净值日 | ❌ **已实证 error 1055** |
| 当前持仓 / 持仓市值 | 持仓市值(**非聚合**) | 真实姓名 + 产品名 | ❌ 踩(推定) |
| 账户余额 / 可用现金 | 可用现金(**非聚合**) | 真实姓名 | ❌ 踩(推定) |
| 资金变动 / 资金流水 | 历史资金变化(`SUM`) | occurred_at | ✅ 不踩(聚合) |
| 兜底路由 | 客户数(`COUNT`) | 无 | ✅ 不踩 |
**不受影响**:`_offsite_mock_plan` 全部 5 条路由(**均无 `dimensions`** ⇒ 不加 `GROUP BY`)。
⇒ **场外主链路不受影响;受影响的是通用兜底路由的 4 类问句**(演示与离线联调走的正是这条)。
**六、降级方案(不受理时)**
保留现状 ⇒ 上述 4 类问句在真实库上**恒 `error`**,演示现场可被直接复现。
另一种降级是用 MySQL 方言 `ANY_VALUE()` 包住非聚合列 —— 能压掉报错,但**会掩盖语义问题**
(分组后取哪一行是不确定的),**不推荐**。
---
## 会签结论
| 组 | 项数 | 结论 |
@@ -201,6 +270,7 @@
| 组 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 补签) |
| 组 6(`GROUP BY` 拼装缺陷) | 1 文件 / 1 张单 | ☑ **受理**(2026-09-22 补签) |
**会签人签名 / 日期**:项目 owner(本人会签,`甲-3` 口径:一次性授权 + 逐项留痕) **2026-09-22**(组 1—4 为 2026-09-20)