NL2SQL:5 位产品代码被丢弃导致"查询成功但数据错"(演示产品 15911 正是 5 位)
## 现象
通用自然语言查询里问「查询15911的净值」,返回的是**别的产品**的数据:
```
问:查询15911的净值 → 返回 511810 货币ETF南方 的净值
问:查询基金代码15911最近30天净值 → 同上
```
而且 `status = success`、`message = 查询成功` —— **不报错,只是数据是错的**。
## 根因
`financial_nl2sql_service._filters()` 只认 6 位数字:
```python
re.findall(r"(?<!\d)\d{6}(?!\d)", question) # 15911 只有 5 位 ⇒ 被静默丢弃
```
过滤条件为空 ⇒ 生成的 SQL **没有产品过滤**(`WHERE 1=1 ... GROUP BY ... LIMIT 50`)
⇒ 返回一批无关于问题的产品净值。现库实测:**6 位代码 25 个、5 位代码 1 个**
(后者就是演示产品 `15911`)—— 所以这个缺陷精确地打在了演示产品上。
`tests/unit/service/test_financial_nl2sql_golden_cases.py` 里 30 条黄金用例**全是 6 位代码**
(159511/588890/159948),因此从未暴露。
## 修法
`app/service/financial_nl2sql_service.py`:
- 产品代码模式放宽为 **5–6 位**(`(?<!\d)\d{5,6}(?!\d)`);
- 5 位数字更容易与"金额/数量/天数"撞车(6 位不会),因此加单位守卫:
紧跟 `元/万元/万/亿/份/股/手/天/日/周/个月/年/次/笔` 的一律**不当**产品代码
(例:「申购金额50000元」里的 50000);
- 抽成 `_product_codes()` 便于单测。
## 验证
- `query("查询15911的净值")` → SQL 含 `p.product_code = :filter_0`、参数 `15911`、
返回行 `{"product_code": "15911", "product_name": "15911 自定义产品", "nav": "1.074596"}`;
三种问法("查询15911的净值"/"查询基金代码 15911 最近 30 天的净值"/"15911 的最新净值是多少")全部命中 15911;
- 新增 5 条单测:5 位代码成为过滤条件、6 位仍正常、金额不当代码、
年份/天数不被误认、多代码并存;
- `pytest tests/unit tests/contract` → **1458 passed, 2 skipped, 0 failed**;
- `ruff` 干净。
## 说明(未改,供你决定)
问「最新净值」时返回的是该产品的 50 行净值明细(SQL 无 `ORDER BY`),
不是"最新的那一条"。要的话我可以补成按日期倒序、单点取值。
This commit is contained in:
@@ -46,10 +46,32 @@ def _range(question: str) -> tuple[str | None, str | None]:
|
||||
return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
#: 产品代码形态:本平台现库**5 位与 6 位并存**(6 位 25 个,5 位 1 个 —— 演示产品 `15911`)。
|
||||
#:
|
||||
#: 原先只认 `\d{6}`,于是 5 位代码被**静默丢弃**:查询退化成"不带产品过滤",
|
||||
#: SQL 里没有 `fin_product.product_code = ?`,却仍返回 `status=success` —— 实测
|
||||
#: 「查询15911的净值」返回的是 511810 等其它产品的净值,**看起来成功、数据却是错的**。
|
||||
_PRODUCT_CODE = re.compile(r"(?<!\d)\d{5,6}(?!\d)")
|
||||
|
||||
#: 5 位数字更可能与"金额/数量/天数"撞车(`\d{6}` 不会)。紧跟这些单位的一律不当产品代码:
|
||||
#: 例如「申购金额50000元」里的 50000。
|
||||
_AMOUNT_UNIT_AFTER = re.compile(r"^\s*(?:元|万元|万|亿元|亿|份|股|手|天|日|周|个月|年|次|笔)")
|
||||
|
||||
|
||||
def _product_codes(question: str) -> list[str]:
|
||||
codes: list[str] = []
|
||||
for match in _PRODUCT_CODE.finditer(question):
|
||||
code = match.group(0)
|
||||
if len(code) == 5 and _AMOUNT_UNIT_AFTER.match(question[match.end():]):
|
||||
continue
|
||||
codes.append(code)
|
||||
return sorted(set(codes))
|
||||
|
||||
|
||||
def _filters(question: str) -> tuple[dict[str, Any], ...]:
|
||||
return tuple(
|
||||
{"field": "fin_product.product_code", "operator": "=", "value": code}
|
||||
for code in sorted(set(re.findall(r"(?<!\d)\d{6}(?!\d)", question)))
|
||||
for code in _product_codes(question)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -78,3 +78,53 @@ async def test_cash_ledger_is_in_nl2sql_scope() -> None:
|
||||
assert result["status"] == "ready"
|
||||
assert "fin_cash_ledger" in result["sql"]
|
||||
assert result["query_plan"]["intent"] == "cash_ledger_query"
|
||||
|
||||
|
||||
# --- 产品代码提取:5 位与 6 位并存(2026-09-14 修) --------------------------------
|
||||
#
|
||||
# 原先只认 `\d{6}`,5 位代码(演示产品 `15911`)被静默丢弃 ⇒ 查询**不带产品过滤**,
|
||||
# 却仍返回 `status=success`:实测问 15911 的净值拿到的是 511810 的数据。
|
||||
# 「看起来成功、数据却是错的」比报错更危险,所以这几条要钉住。
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_five_digit_product_code_is_used_as_filter() -> None:
|
||||
result = await FinancialNL2SQLService().query(
|
||||
FinancialNL2SQLInput(question="查询15911的净值", dry_run=True),
|
||||
context(),
|
||||
)
|
||||
|
||||
assert result["status"] == "ready"
|
||||
assert result["parameters"]["filter_0"] == "15911"
|
||||
# 关键:SQL 里必须**真的带上产品过滤**(列名走别名 `p.`),否则就是"成功但查错数据"。
|
||||
assert "p.product_code = :filter_0" in result["sql"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_six_digit_product_code_still_works() -> None:
|
||||
result = await FinancialNL2SQLService().query(
|
||||
FinancialNL2SQLInput(question="查询510300的净值", dry_run=True),
|
||||
context(),
|
||||
)
|
||||
|
||||
assert result["parameters"]["filter_0"] == "510300"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amount_like_five_digit_number_is_not_treated_as_product_code() -> None:
|
||||
"""「申购金额50000元」里的 50000 是金额,不是产品代码 —— 它后面紧跟单位。"""
|
||||
result = await FinancialNL2SQLService().query(
|
||||
FinancialNL2SQLInput(question="查询申购金额50000元的记录", dry_run=True),
|
||||
context(),
|
||||
)
|
||||
|
||||
assert "filter_0" not in result.get("parameters", {})
|
||||
|
||||
|
||||
def test_product_codes_ignores_years_and_short_numbers() -> None:
|
||||
from app.service.financial_nl2sql_service import _product_codes
|
||||
|
||||
assert _product_codes("查询2026年的净值") == [] # 4 位年份
|
||||
assert _product_codes("查询近30天净值") == [] # 天数
|
||||
assert _product_codes("查询15911的净值") == ["15911"]
|
||||
assert _product_codes("对比15911和510300") == ["15911", "510300"]
|
||||
|
||||
Reference in New Issue
Block a user