diff --git a/docs/48-可改文件白名单.md b/docs/48-可改文件白名单.md index f18c6d1..7f1710a 100644 --- a/docs/48-可改文件白名单.md +++ b/docs/48-可改文件白名单.md @@ -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 · 禁止修改(**红线**) diff --git a/docs/49-底座会签申请单-2026-09-19.md b/docs/49-底座会签申请单-2026-09-19.md index 0576fb9..f0c5abb 100644 --- a/docs/49-底座会签申请单-2026-09-19.md +++ b/docs/49-底座会签申请单-2026-09-19.md @@ -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) diff --git a/nl2sql_yc.py b/nl2sql_yc.py index b1dc864..b759fd6 100644 --- a/nl2sql_yc.py +++ b/nl2sql_yc.py @@ -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 = [] diff --git a/tests/unit/service/test_nl2sql_yc_group_by_w29c.py b/tests/unit/service/test_nl2sql_yc_group_by_w29c.py new file mode 100644 index 0000000..342bb6b --- /dev/null +++ b/tests/unit/service/test_nl2sql_yc_group_by_w29c.py @@ -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 diff --git a/客服agent/D2.1-客服Agent执行Todolist.md b/客服agent/D2.1-客服Agent执行Todolist.md index a12994b..d2a60d4 100644 --- a/客服agent/D2.1-客服Agent执行Todolist.md +++ b/客服agent/D2.1-客服Agent执行Todolist.md @@ -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 的验收标准 | ---