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:
+2
-1
@@ -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 · 禁止修改(**红线**)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+24
-1
@@ -346,6 +346,12 @@ def _safe_sql_check(sql: str, params: dict[str, Any], plan: QueryPlan, auth: Aut
|
||||
return True, "SQL 安全校验通过"
|
||||
|
||||
|
||||
#: 聚合函数探测(`W29-c` · 会签项 19)。用途有二:
|
||||
#: ① 判断 `GROUP BY` 是否**必要** —— 没有聚合就不该分组;
|
||||
#: ② 判断哪些 select 列**必须**进 `GROUP BY` —— 非聚合的那些。
|
||||
_AGGREGATE_CALL = re.compile(r"\b(SUM|COUNT|AVG|MIN|MAX)\s*\(", re.IGNORECASE)
|
||||
|
||||
|
||||
def _alias(table: str) -> str:
|
||||
return {
|
||||
"fin_transaction": "t", "fin_product": "p", "fin_holding": "h",
|
||||
@@ -441,7 +447,24 @@ def _compile_sql(plan: QueryPlan, auth: AuthContext) -> tuple[str, dict[str, Any
|
||||
if pending:
|
||||
raise ValueError(f"缺少合法 Join 路径:{', '.join(sorted(pending))}")
|
||||
sql = f"SELECT {', '.join(select_parts)} FROM {from_sql} WHERE {' AND '.join(where)}"
|
||||
if group_parts:
|
||||
# `W29-c`(会签项 19):`GROUP BY` 只在**确实需要分组**时才加。
|
||||
#
|
||||
# 原实现只要 `dimensions` 非空就加 `GROUP BY`,但 `SELECT` 里还有 `metrics` ——
|
||||
# 其中**非聚合**的那些(`n.nav` / `h.market_value` / `m.close_price` …)没被放进
|
||||
# `GROUP BY`,MySQL `only_full_group_by`(本项目默认 `sql_mode`)直接拒绝:
|
||||
# ERROR 1055 Expression #3 of SELECT list is not in GROUP BY clause ...
|
||||
# 症状是**一条完全正常的只读问句返回 `status=error`**(实测「查询近30天净值」必现)。
|
||||
#
|
||||
# 两条修正:
|
||||
# ① 没有聚合函数就不需要分组 —— 加了只会引入这类错误;
|
||||
# ② 有聚合时,把**所有非聚合的 select 列**一并纳入 `GROUP BY`,
|
||||
# 而不是只放 `dimensions`(漏掉非聚合 metric 正是 1055 的成因)。
|
||||
if group_parts and any(_AGGREGATE_CALL.search(part) for part in select_parts):
|
||||
for part in select_parts:
|
||||
expression = part.split(" AS ")[0]
|
||||
if _AGGREGATE_CALL.search(part) or expression in group_parts:
|
||||
continue
|
||||
group_parts.append(expression)
|
||||
sql += f" GROUP BY {', '.join(group_parts)}"
|
||||
if plan.sort:
|
||||
sort_items = []
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""`W29-c` · `nl2sql_yc` 的 `GROUP BY` 拼装回归(会签项 19)。
|
||||
|
||||
## 为什么这个测试断言的是「SQL 长什么样」而不是「跑得通」
|
||||
|
||||
缺陷的症状是 MySQL **1055**(`only_full_group_by`),但单元测试跑在 **sqlite** 上 ——
|
||||
sqlite **不检查**这条规则。所以「查询能跑」根本证明不了修好了:
|
||||
修之前拿 sqlite 跑也是绿的,缺陷只在真实 MySQL 上现形。
|
||||
|
||||
⇒ 断言必须落在**生成的 SQL 结构**上。这样既与数据库无关,又能把不变量钉死。
|
||||
|
||||
## 两个不变量
|
||||
|
||||
1. **没有聚合就不加分组的 `GROUP BY`** —— 加了只会引入 1055 风险;
|
||||
2. **有 `GROUP BY` 时,`SELECT` 里每个非聚合列都必须在 `GROUP BY` 里** ——
|
||||
这正是 `only_full_group_by` 的全部要求,也是本缺陷的成因
|
||||
(原实现只把 `dimensions` 放进 `GROUP BY`,漏掉了**非聚合的 `metrics`**,
|
||||
如 `n.nav AS nav`)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.service.offsite_nl2sql_adapter import OffsiteNl2SqlAdapter
|
||||
|
||||
_AGGREGATE = re.compile(r"\b(SUM|COUNT|AVG|MIN|MAX)\s*\(", re.IGNORECASE)
|
||||
|
||||
#: 覆盖两条路由:`_mock_plan` 的通用兜底路由(有 dimensions)与
|
||||
#: `_offsite_mock_plan` 的场外路由(无 dimensions)。
|
||||
QUESTIONS = (
|
||||
"查询近30天净值",
|
||||
"查询近30天收盘行情",
|
||||
"查询近30天资金流水",
|
||||
"基金代码为15911,查询该基金最新净值",
|
||||
)
|
||||
|
||||
|
||||
def _engine():
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
for ddl in (
|
||||
"CREATE TABLE fin_product "
|
||||
"(id INTEGER PRIMARY KEY, product_code TEXT, product_name TEXT)",
|
||||
"CREATE TABLE fin_nav_history "
|
||||
"(id INTEGER PRIMARY KEY, product_id INTEGER, nav NUMERIC, nav_date TEXT)",
|
||||
"CREATE TABLE fin_market_price (id INTEGER PRIMARY KEY, product_id INTEGER, "
|
||||
"close_price NUMERIC, trade_date TEXT, total_fund_shares NUMERIC)",
|
||||
"CREATE TABLE fin_cash_ledger (id INTEGER PRIMARY KEY, account_id INTEGER, "
|
||||
"amount NUMERIC, occurred_at TEXT)",
|
||||
"CREATE TABLE fin_sim_account (id INTEGER PRIMARY KEY, customer_id INTEGER)",
|
||||
):
|
||||
connection.execute(text(ddl))
|
||||
return engine
|
||||
|
||||
|
||||
def _context() -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="1",
|
||||
trace_id="w29c-group-by",
|
||||
roles=("operator",),
|
||||
permissions=("offsite:write",),
|
||||
data_scope="all",
|
||||
)
|
||||
|
||||
|
||||
def _query(question: str) -> dict:
|
||||
adapter = OffsiteNl2SqlAdapter(db_engine=_engine())
|
||||
try:
|
||||
return adapter.query(question, _context())
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
def _select_expressions(sql: str) -> list[str]:
|
||||
select_part = sql.split(" FROM ", 1)[0]
|
||||
return [item.strip() for item in select_part[len("SELECT "):].split(", ")]
|
||||
|
||||
|
||||
def _group_by_clause(sql: str) -> str | None:
|
||||
if " GROUP BY " not in sql:
|
||||
return None
|
||||
tail = sql.split(" GROUP BY ", 1)[1]
|
||||
for terminator in (" ORDER BY ", " LIMIT "):
|
||||
if terminator in tail:
|
||||
tail = tail.split(terminator, 1)[0]
|
||||
return tail.strip()
|
||||
|
||||
|
||||
def test_nav_query_has_no_group_by() -> None:
|
||||
"""**非聚合 metric(`基金净值`)+ dimensions ⇒ 不得生成 `GROUP BY`。**
|
||||
|
||||
这条问句就是实测中必现 `error 1055` 的那条(修复前)。
|
||||
"""
|
||||
result = _query("查询近30天净值")
|
||||
assert result["status"] == "success", result.get("message")
|
||||
sql = result["sql"]
|
||||
assert "n.nav AS nav" in sql
|
||||
assert _group_by_clause(sql) is None, f"非聚合查询不应分组:{sql}"
|
||||
|
||||
|
||||
def test_market_price_query_has_no_group_by() -> None:
|
||||
"""**非聚合 metric(`收盘价`)+ dimensions ⇒ 同样不得生成 `GROUP BY`。**"""
|
||||
result = _query("查询近30天收盘行情")
|
||||
assert result["status"] == "success", result.get("message")
|
||||
sql = result["sql"]
|
||||
assert "m.close_price AS close_price" in sql
|
||||
assert _group_by_clause(sql) is None, f"非聚合查询不应分组:{sql}"
|
||||
|
||||
|
||||
def test_aggregate_query_keeps_group_by_with_all_nonaggregate_columns() -> None:
|
||||
"""**聚合 metric(`SUM`)⇒ 保留 `GROUP BY`,且非聚合列必须进组。**
|
||||
|
||||
后半句是关键:修法不是"一律不加 GROUP BY",而是"加的时候把非聚合列补齐"。
|
||||
资金流水走的是 `SUM(l.amount)`,分组语义**必须**保留。
|
||||
"""
|
||||
result = _query("查询近30天资金流水")
|
||||
assert result["status"] == "success", result.get("message")
|
||||
sql = result["sql"]
|
||||
assert _AGGREGATE.search(sql), f"该问句应为聚合查询:{sql}"
|
||||
group_by = _group_by_clause(sql)
|
||||
assert group_by is not None, f"聚合查询必须分组:{sql}"
|
||||
assert "l.occurred_at" in group_by, f"非聚合维度未进 GROUP BY:{sql}"
|
||||
|
||||
|
||||
def test_nonaggregate_columns_never_outside_group_by() -> None:
|
||||
"""**通用不变量**:有 `GROUP BY` 时,`SELECT` 里每个非聚合列都必须在组内。
|
||||
|
||||
这是 `only_full_group_by` 的全部要求,也是对「以后再加指标」的回归锁 ——
|
||||
将来往 `metric_map` 里加一个非聚合指标时,这条会在 CI 上先红,
|
||||
而不是等到真实 MySQL 上冒出一个 1055。
|
||||
|
||||
⚠️ 断言 SQL **结构**而非执行结果:sqlite 不做该检查(见文件头)。
|
||||
"""
|
||||
checked = 0
|
||||
for question in QUESTIONS:
|
||||
result = _query(question)
|
||||
sql = result.get("sql")
|
||||
if not sql:
|
||||
continue
|
||||
group_by = _group_by_clause(sql)
|
||||
if group_by is None:
|
||||
continue
|
||||
checked += 1
|
||||
for expression in _select_expressions(sql):
|
||||
if _AGGREGATE.search(expression):
|
||||
continue
|
||||
column = expression.split(" AS ")[0].strip()
|
||||
assert column in group_by, (
|
||||
f"非聚合列 {column!r} 未出现在 GROUP BY {group_by!r} 中(MySQL 会报 1055):{sql}"
|
||||
)
|
||||
assert checked >= 1, "本用例至少要覆盖一条带 GROUP BY 的查询,否则等于没测"
|
||||
|
||||
|
||||
def test_offsite_route_still_works_without_group_by() -> None:
|
||||
"""**场外路由不受影响**:它本就没有 `dimensions`(`GROUP BY` 为空),修法不得改变它。"""
|
||||
result = _query("基金代码为15911,查询该基金最新净值")
|
||||
assert result["status"] == "success", result.get("message")
|
||||
sql = result["sql"]
|
||||
assert "p.product_code = :filter_0" in sql
|
||||
assert _group_by_clause(sql) is None
|
||||
@@ -1266,10 +1266,37 @@
|
||||
|
||||
**依据**:`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` —— 同类缺口,但**不在白名单任何一档**,须另立单处理。
|
||||
**未纳入本组**:`offsite_nl2sql_adapter` 实际调用的仓库根 `nl2sql_yc.py` —— **不在白名单任何一档**,另立单处理。
|
||||
|
||||
> ⚠️ **同日更正(2026-09-22)**:本行原写「同类缺口」,**该表述有误,特此更正**。
|
||||
> 立组 6 前先取证,实测 8 条写意图问句对它 **8/8 已被拒绝** —— 它**没有宽泛关键词路由**
|
||||
> (「删除所有客户的**持仓**记录」不命中它的「当前持仓」⇒ 计划落 `unknown` ⇒ 被 `_validate_plan` 拒绝)。
|
||||
> ⇒ **`nl2sql_yc.py` 不需要写意图守卫**。但它有一个**真实的**缺陷(`GROUP BY` 拼装 ⇒ 正常只读查询报 MySQL 1055),见 **§1.7 组 6**。
|
||||
|
||||
**会签单**:`docs/49`(`A-10`)**组 5 · 会签 18**(☑ 2026-09-22 受理)。
|
||||
**落地证据**:全量 `pytest` 与 55 条金标**逐项零差异**,见 `_W29-NL2SQL接线与对话内图表实施报告-2026-09-22.md` §2。
|
||||
|
||||
### 1.7 组 6 · `GROUP BY` 拼装缺陷(**`W29-c` 新增 · 2026-09-22 补签**)
|
||||
|
||||
> **怎么发现的**:立 §1.6 时按计划去核实 `nl2sql_yc.py` 是否「同类缺口」,于是**先取证、再立单** ——
|
||||
> 取证**推翻了原判断**(它没有写意图缺口),同时**量出一个真缺陷**:一条完全正常的只读问句直接报错。
|
||||
> 这次取证的价值正在于此:**凭印象立单会同时要求一个不需要的改动、漏掉一个真实的 bug。**
|
||||
|
||||
| # | 文件 | 触碰项 | 改什么 | 最小化边界 |
|
||||
|---|---|---|---|---|
|
||||
| 6-1 | `nl2sql_yc.py`(仓库根) | **`GROUP BY` 拼装** | `_compile_sql()`:① **仅当** `select_parts` 含聚合函数(`SUM/COUNT/AVG/MIN/MAX`)时才加 `GROUP BY`;② 加时把**所有非聚合的 select 列**一并纳入 | 不改函数签名 / 不改 `QueryPlan`·`AuthContext` 形状 / **零 DDL** / **不动** `ALLOWED_TABLES`·`TABLE_COLUMNS`·`JOIN_SQL`·`_safe_sql_check`·路由与指标映射 / 只改 `GROUP BY` 的判定与列集合 |
|
||||
|
||||
**实测**(`OffsiteNl2SqlAdapter.query`,`use_llm=False`,真连库):
|
||||
`查询近30天净值` → `status=error`,MySQL **1055**(`Expression #3 of SELECT list is not in GROUP BY clause ... n.nav`)。
|
||||
**成因**:`SELECT` 里既有 `dimensions`(已进 `GROUP BY`)又有 `metrics`,而 `metrics` 里的**非聚合**列(`n.nav` 等)**没进 `GROUP BY`**。
|
||||
**影响面**:`_mock_plan` 的 4 条路由踩(收盘/行情、**净值**(已实证)、当前持仓/持仓市值、账户余额/可用现金);
|
||||
**不踩**:资金变动/资金流水(`SUM`)、兜底路由(`COUNT`)、`_offsite_mock_plan` 全部 5 条(均无 `dimensions`)。
|
||||
⇒ **场外主链路不受影响;受影响的是通用兜底路由的 4 类问句**(演示与离线联调正走这条)。
|
||||
**依据**:`INV-6`(数字必须来自受控数据源)—— 查询报错时连"受控数据"都给不出;本项修**正确性**,不是安全。
|
||||
**降级(不受理时)**:保留现状 ⇒ 上述 4 类问句在真实库上恒 `error`;或用 MySQL 方言 `ANY_VALUE()` 压掉报错,
|
||||
但**会掩盖语义问题**(分组后取哪一行不确定),不推荐。
|
||||
**会签单**:`docs/49`(`A-10`)**组 6 · 会签 19**(☑ 2026-09-22 受理)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 怎么用这份看板
|
||||
@@ -1279,7 +1306,7 @@
|
||||
| 状态标记 | `[ ]` 未开始 / `[x]` 完成 / `🔴` 高风险或带会签 / `⏸` 挂起 |
|
||||
| 开工顺序 | 按 §6.1 关键路径;**同类文件的任务不要拆成多次提交**(测试彼此交叠,拆开会导致反复红) |
|
||||
| 完成判定 | `[x]` 表示 DoD 全部满足且「验证」列已执行;**未跑验证不算完成** |
|
||||
| 会签项 | 见 §1.1 / §1.3 / §1.6;**未获会签不得开工**,未受理则走 §1.4 的降级并**在文档中标注** |
|
||||
| 会签项 | 见 §1.1 / §1.3 / §1.6 / §1.7;**未获会签不得开工**,未受理则走 §1.4 的降级并**在文档中标注** |
|
||||
| 与需求对齐 | 每个批次末尾的「交付判据」直接对应《需求文档》§2.2 的验收标准 |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user