2026-09-14 18:13:10 +08:00
|
|
|
|
from datetime import date
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.contracts import RequestContext
|
|
|
|
|
|
from app.core.nl2sql_contracts import FinancialNL2SQLInput
|
|
|
|
|
|
from app.service.financial_nl2sql_service import FinancialNL2SQLService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def context(**updates):
|
|
|
|
|
|
base = RequestContext(
|
|
|
|
|
|
user_id="1",
|
|
|
|
|
|
trace_id="trace-nl2sql",
|
|
|
|
|
|
roles=("advisor",),
|
|
|
|
|
|
permissions=("financial:nl2sql:read",),
|
|
|
|
|
|
data_scope="all",
|
|
|
|
|
|
)
|
|
|
|
|
|
return base.model_copy(update=updates)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 18:13:10 +08:00
|
|
|
|
def test_query_rows_convert_date_values_to_json_strings() -> None:
|
|
|
|
|
|
row = FinancialNL2SQLService._jsonable({"nav_date": date(2026, 9, 10)})
|
|
|
|
|
|
|
|
|
|
|
|
assert row == {"nav_date": "2026-09-10"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_generates_read_only_market_sql_with_product_filter() -> None:
|
|
|
|
|
|
result = await FinancialNL2SQLService().query(
|
|
|
|
|
|
FinancialNL2SQLInput(question="查询159511近30天行情收盘价", dry_run=True),
|
|
|
|
|
|
context(),
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result["status"] == "ready"
|
|
|
|
|
|
assert result["sql"].startswith("SELECT ")
|
|
|
|
|
|
assert "fin_market_price" in result["sql"]
|
|
|
|
|
|
assert "DROP" not in result["sql"]
|
|
|
|
|
|
assert result["parameters"]["filter_0"] == "159511"
|
|
|
|
|
|
assert result["audit"]["permission_check"]["status"] == "passed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_low_confidence_question_requires_confirmation() -> None:
|
|
|
|
|
|
result = await FinancialNL2SQLService().query(
|
|
|
|
|
|
FinancialNL2SQLInput(question="帮我看看这个情况", dry_run=True),
|
|
|
|
|
|
context(),
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result["status"] == "need_confirmation"
|
|
|
|
|
|
assert result["query_plan"]["confidence"] < 0.85
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_as_of_query_rejects_current_snapshot_tables() -> None:
|
|
|
|
|
|
result = await FinancialNL2SQLService().query(
|
|
|
|
|
|
FinancialNL2SQLInput(question="截至某日客户当前持仓市值", dry_run=True),
|
|
|
|
|
|
context(),
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result["status"] == "rejected"
|
|
|
|
|
|
assert "历史版本" in result["message"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_customer_scope_is_injected_for_non_all_scope() -> None:
|
|
|
|
|
|
result = await FinancialNL2SQLService().query(
|
|
|
|
|
|
FinancialNL2SQLInput(question="查询客户账户余额", dry_run=True),
|
|
|
|
|
|
context(data_scope="own_customers", customer_ids=("7", "8")),
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "customer_id IN" in result["sql"]
|
|
|
|
|
|
assert result["parameters"]["scope_customer_0"] == 7
|
|
|
|
|
|
assert result["parameters"]["scope_customer_1"] == 8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_cash_ledger_is_in_nl2sql_scope() -> None:
|
|
|
|
|
|
result = await FinancialNL2SQLService().query(
|
|
|
|
|
|
FinancialNL2SQLInput(question="查询近7天资金流水变化", dry_run=True),
|
|
|
|
|
|
context(),
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result["status"] == "ready"
|
|
|
|
|
|
assert "fin_cash_ledger" in result["sql"]
|
|
|
|
|
|
assert result["query_plan"]["intent"] == "cash_ledger_query"
|
2026-09-14 22:32:26 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 产品代码提取: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"]
|