Files
group_fqcd_jr/app/service/financial_nl2sql_service.py
T
张胜宇 9dcfa64bc5 feat(W29): NL2SQL 接线与只读边界守卫(会签项 18)
## 接线

发布 agent_tools/financial_nl2sql:financial_query -> [query_financial_data]
(tools/publish_financial_nl2sql_config.py --apply,治理动作)。

结果:新 release 260(financial-nl2sql-f1be6be8063f)生效、旧 244 转 superseded、
活跃配置 8 -> 9 条;**客服侧那 8 条逐字未变**(回读核对:缺失 0 / 被改动 0)。
在此之前该能力「代码全在、工具调不通」—— 按「工具 = 代码上限 ∩ 发布白名单,
缺配置失败关闭」,缺的就是这一条配置。

## 接线后实测量出的缺口(本次修复对象)

RuleBasedFinancialPlanner 不识别写意图动词:「删除所有客户的持仓记录」被判成
「查持仓」,返回 status="ready" 并生成一段 SELECT —— 8 条写意图问句 8/8 复现。

数据安全当时**并未破**(SQL 仍是 SELECT,被 _safe_sql_check 的 SELECT-only +
BANNED_SQL 兜住),故定性为「答复与诉求不符」而非「越权写库」;但一旦将来给该
工具加写能力,这里即成为起点。

## 会签(先补签、后改动)

该文件原本不在 docs/48 白名单任何一档(等于「白名单之外一律不动」)⇒ 先补
docs/49(A-10)组 5 · 会签 18 并登记为类 3,获批后才实施。同步更新
docs/48 类 3 表与 客服agent/D2.1 §1.6(镜像已同步)。

## 改法(守住会签单的「最小化边界」)

- plan() 入口增写意图预检 -> 返回带 unsupported_reason 的不可执行计划
- _validate_plan() 增「不可执行计划优先」判据
- query() 走**现成**的 status="rejected" 分支,未新增代码路径,审计照旧留痕

判据分两级以控制误杀:一级强拦(删除/清空/撤销/改成/写入/导入…);
二级歧义写动词(修改/更新/变更/导出…)须**无查询语境词**才拦 ——
否则会误杀「费率变更历史」这类真实续问。

## 验证

- 只读护栏回归锁 8 条(与判据互为独立防线,判据退化时仍须绿)
- 写意图 8 条由 xfail 转为正式断言(全通过)
- 误杀边界 7 条(查询语境的歧义写动词不得被拦)
- NL2SQL 相关测试 84 passed
- 全量 pytest 2540 passed / 3 skipped / 0 failed(xfailed 归零)
- 金标 55 条与 W27 基线判分**逐项零差异**(M-1 55/55、M-4 55/55、四项零容忍全 0)
- ruff:改动文件 0 告警
2026-09-22 10:12:06 +08:00

594 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError, ValidationAgentError
from app.core.nl2sql_catalog import (
ALLOWED_TABLES,
BANNED_SQL,
CURRENT_ONLY_TABLES,
DOMAINS,
JOIN_SQL,
TABLE_COLUMNS,
alias,
)
from app.core.nl2sql_contracts import (
FinancialNL2SQLInput,
FinancialNL2SQLResult,
FinancialQueryPlan,
)
from app.infrastructure.db import SessionFactory
SessionFactoryType = Callable[[], AbstractAsyncContextManager[AsyncSession]]
def _range(question: str) -> tuple[str | None, str | None]:
days = 0
if "近7天" in question or "最近7天" in question:
days = 7
elif "近30天" in question or "最近30天" in question or "近一个月" in question:
days = 30
elif "近90天" in question or "最近90天" in question or "近三个月" in question:
days = 90
if not days:
return None, None
end = datetime.now(UTC).replace(tzinfo=None)
start = end - timedelta(days=days)
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 _product_codes(question)
)
#: **一级:明确的写动作词**。命中即拒绝 —— 本工具是只读查询,不执行任何修改。
#:
#: 这一组刻意收得**很窄**:只收「在查询语境里几乎不出现」的动词。
#: 像「修改 / 更新 / 变更 / 调整 / 导出」这些**两头都常见**的词(「修改记录」是查询、
#: 「修改风险等级」是写)放二级,见 `_AMBIGUOUS_WRITE_WORDS`。
_WRITE_ACTION_WORDS = (
"删除", "删掉", "清空", "清除", "抹掉", "撤销", "作废", "重置",
"改成", "改为", "改掉", "写入", "录入", "导入", "新增", "添加", "插入",
)
#: **二级:歧义写动词**。单独出现**不拦**(会误杀「费率变更历史」这类查询),
#: 只有与「查询语境词」**同现**时才放行判断 —— 详见 `is_write_intent`。
_AMBIGUOUS_WRITE_WORDS = ("修改", "更新", "变更", "调整", "导出", "改一下")
#: **查询语境词**。出现即说明客户要的是「看」,不是「改」。
#:
#: ⚠️ 这里刻意只收**动词性或疑问性**的词,**不收「记录 / 明细 / 列表」这类名词** ——
#: 「删除我的持仓**记录**」也是写意图,若把名词当豁免词就会把它放过去。
_QUERY_CONTEXT_WORDS = (
"查询", "查一下", "查查", "查看", "看看", "看下", "看一下", "是多少", "有多少",
"多少", "哪些", "哪几", "统计", "汇总", "列出", "列一下", "明细", "历史",
"怎么样", "是什么", "为什么", "有没有", "是否",
)
def is_write_intent(question: str) -> bool:
"""判断问句是不是**写诉求**(要求改数据),而不是查询。
为什么要这个判据(`W29` 实测出来的缺口):`RuleBasedFinancialPlanner` 只看「查什么」
不看「要干什么」,于是「删除所有客户的持仓记录」被当成「查持仓」,返回 `status="ready"`
并生成一段 SELECT —— **8 条写意图问句 8/8 复现**。
数据安全并没有破(SQL 仍是 SELECT,只读护栏兜住),但**答复与诉求不符**:
客户说「删」,收到「这是你的持仓列表」。且一旦将来给这个工具加写能力,这里就是起点。
⚠️ **误杀边界**(本判据最容易出错的地方):中文里「修改 / 更新 / 变更 / 导出」
在查询语境同样高频(「费率变更历史」「更新日期」「导出对账单」)。所以分成两级:
一级强拦,二级要求**没有**查询语境词才拦。
"""
text = (question or "").strip()
if not text:
return False
has_query_context = any(word in text for word in _QUERY_CONTEXT_WORDS)
if any(word in text for word in _WRITE_ACTION_WORDS):
return not has_query_context
if any(word in text for word in _AMBIGUOUS_WRITE_WORDS):
return not has_query_context
return False
#: 拒绝时给调用方看的话术。**只说明能力边界,不复述客户问句**(复述会把「删除」这类词
#: 带回答复里,读起来像系统在确认一个它做不到的动作)。
WRITE_INTENT_REPLY = "本功能仅支持只读查询,不执行任何修改、删除或导出操作。请调整问题后重试。"
class RuleBasedFinancialPlanner:
def plan(self, query: FinancialNL2SQLInput) -> FinancialQueryPlan:
question = query.question
# `W29`:**写意图预检**(会签项 18 · `A-10` 组 5)。
#
# 为什么放在**最前**:一旦判定是写诉求,就不该再去做任何「查什么」的推断 ——
# 否则「删除所有客户的持仓记录」会被理解成「查持仓」,返回一个看起来成功的
# `ready` 计划(实测 8/8 复现)。**拒绝必须发生在理解之前。**
if is_write_intent(question):
return FinancialQueryPlan(
intent="unsupported", domains=(), tables=(), limit=query.limit,
confidence=1.0, unsupported_reason=WRITE_INTENT_REPLY,
)
start, end = _range(question)
time_mode = "range" if start and end else "none"
if any(word in question for word in ("截至", "历史时点", "当时", "某日")):
time_mode = "as_of"
filters = _filters(question)
for builder in (self._market_plan, self._trade_plan, self._profile_plan):
plan = builder(query, time_mode, start, end, filters)
if plan is not None:
return plan
return FinancialQueryPlan(
intent="unknown", domains=("customer_risk",), tables=("fin_customer_profile",),
limit=query.limit, confidence=0.45, needs_confirmation=True,
confirmation_question="请明确要查询的客户、产品、时间范围或指标口径。",
)
@staticmethod
def _market_plan(
query: FinancialNL2SQLInput,
time_mode: str,
start: str | None,
end: str | None,
filters: tuple[dict[str, Any], ...],
) -> FinancialQueryPlan | None:
question = query.question
if "净值" in question:
return FinancialQueryPlan(
intent="nav_history_query", domains=("market_nav", "product_fee"),
tables=("fin_nav_history", "fin_product"), metrics=("基金净值",),
dimensions=("fin_product.product_code", "fin_product.product_name",
"fin_nav_history.nav_date"),
filters=filters, time_mode=time_mode, time_column="nav_date",
start=start, end=end, limit=query.limit, confidence=0.90,
)
if any(word in question for word in ("行情", "收盘", "开盘", "最高价", "最低价")):
return FinancialQueryPlan(
intent="market_price_query", domains=("market_nav", "product_fee"),
tables=("fin_market_price", "fin_product"), metrics=("收盘价",),
dimensions=("fin_product.product_code", "fin_product.product_name",
"fin_market_price.trade_date"),
filters=filters, time_mode=time_mode, time_column="trade_date",
start=start, end=end, limit=query.limit, confidence=0.90,
)
return None
@staticmethod
def _trade_plan(
query: FinancialNL2SQLInput,
time_mode: str,
start: str | None,
end: str | None,
filters: tuple[dict[str, Any], ...],
) -> FinancialQueryPlan | None:
question = query.question
if "资金" in question or "现金流水" in question:
cash_metrics = (
("历史资金变化",)
if any(w in question for w in ("汇总", "合计", "变化"))
else ()
)
return FinancialQueryPlan(
intent="cash_ledger_query", domains=("trading_account", "customer_risk"),
tables=("fin_cash_ledger", "fin_sim_account", "fin_customer_profile"),
metrics=cash_metrics,
dimensions=("fin_cash_ledger.occurred_at", "fin_cash_ledger.entry_type"),
filters=filters, time_mode=time_mode, time_column="occurred_at",
start=start, end=end, limit=query.limit, confidence=0.88,
)
if "持仓" in question:
return FinancialQueryPlan(
intent="holding_query", domains=("trading_account", "customer_risk", "product_fee"),
tables=("fin_holding", "fin_customer_profile", "fin_product"),
metrics=("持仓市值", "浮动盈亏") if "盈亏" in question else ("持仓市值",),
dimensions=("fin_customer_profile.customer_id", "fin_product.product_code",
"fin_product.product_name"),
filters=filters, time_mode=time_mode, time_column="updated_at",
start=start, end=end, limit=query.limit, confidence=0.88,
)
if any(word in question for word in ("成交", "交易")):
transaction_metrics = (
("交易金额", "成交数量")
if any(w in question for w in ("统计", "汇总", "合计"))
else ()
)
return FinancialQueryPlan(
intent="transaction_query",
domains=("trading_account", "customer_risk", "product_fee"),
tables=("fin_transaction", "fin_customer_profile", "fin_product"),
metrics=transaction_metrics,
dimensions=("fin_customer_profile.customer_id", "fin_product.product_code",
"fin_transaction.order_side"),
filters=filters, time_mode=time_mode, time_column="executed_at",
start=start, end=end, limit=query.limit, confidence=0.87,
)
if "委托" in question or "订单" in question:
return FinancialQueryPlan(
intent="order_query", domains=("trading_account", "customer_risk", "product_fee"),
tables=("fin_sim_order", "fin_customer_profile", "fin_product"),
dimensions=("fin_customer_profile.customer_id", "fin_product.product_code",
"fin_sim_order.status"),
filters=filters, time_mode=time_mode, time_column="submitted_at",
start=start, end=end, limit=query.limit, confidence=0.86,
)
if "账户" in question or "余额" in question or "现金" in question:
return FinancialQueryPlan(
intent="account_query", domains=("trading_account", "customer_risk"),
tables=("fin_sim_account", "fin_customer_profile"),
metrics=("现金余额", "可用现金"),
dimensions=("fin_customer_profile.customer_id",),
time_mode=time_mode,
time_column="updated_at",
start=start,
end=end,
limit=query.limit,
confidence=0.88,
)
return None
@staticmethod
def _profile_plan(
query: FinancialNL2SQLInput,
time_mode: str,
start: str | None,
end: str | None,
filters: tuple[dict[str, Any], ...],
) -> FinancialQueryPlan | None:
question = query.question
if "怎么样" in question:
return FinancialQueryPlan(
intent="unknown", domains=("customer_risk",),
tables=("fin_customer_profile",), limit=query.limit,
confidence=0.45, needs_confirmation=True,
confirmation_question="请明确要查询客户画像、风险测评、交易、持仓还是账户信息。",
)
if "费率" in question or "费用" in question:
return FinancialQueryPlan(
intent="fee_rule_query", domains=("product_fee",),
tables=("fin_fee_rule", "fin_product"),
dimensions=("fin_product.product_code", "fin_fee_rule.order_side",
"fin_fee_rule.customer_tier"),
filters=filters, limit=query.limit, confidence=0.87,
)
if "内容" in question or "审核" in question or "发布" in question:
return FinancialQueryPlan(
intent="client_content_query", domains=("client_content", "customer_risk"),
tables=("client_facing_content", "fin_customer_profile"),
dimensions=(
"fin_customer_profile.customer_id",
"client_facing_content.content_type",
"client_facing_content.review_status",
),
time_mode=time_mode, time_column="created_at", start=start, end=end,
limit=query.limit, confidence=0.84, needs_confirmation=True,
confirmation_question="请确认要查询的是对客内容的审核或发布记录。",
)
if "风险" in question or "测评" in question or "画像" in question or "客户" in question:
return FinancialQueryPlan(
intent="customer_risk_query", domains=("customer_risk",),
tables=("fin_risk_assessment", "fin_customer_profile"),
metrics=("客户数",) if any(w in question for w in ("多少", "数量", "统计")) else (),
dimensions=(
"fin_customer_profile.customer_id",
"fin_customer_profile.investor_type",
),
time_mode=time_mode, time_column="assessed_at", start=start, end=end,
limit=query.limit, confidence=0.86,
)
return None
class FinancialNL2SQLService:
def __init__(
self,
planner: RuleBasedFinancialPlanner | None = None,
session_factory: SessionFactoryType = SessionFactory,
) -> None:
self.planner = planner or RuleBasedFinancialPlanner()
self.session_factory = session_factory
async def query(
self, arguments: FinancialNL2SQLInput, context: RequestContext
) -> dict[str, Any]:
self._require_context(context)
plan = self.planner.plan(arguments)
if arguments.confirmation and plan.needs_confirmation:
plan = plan.model_copy(update={
"needs_confirmation": False, "confidence": max(0.85, plan.confidence),
})
valid, message = self._validate_plan(plan, context)
if not valid:
return self._result("rejected", message, arguments, context, plan, None, {}, 0)
if plan.needs_confirmation or plan.confidence < 0.85:
question = plan.confirmation_question or "请确认查询范围、指标口径和时间条件。"
return self._result(
"need_confirmation", question, arguments, context, plan, None, {}, 0
)
sql, params = self._compile_sql(plan, context)
self._safe_sql_check(sql, params, plan)
if arguments.dry_run:
return self._result(
"ready", "SQL 已生成并通过只读校验",
arguments, context, plan, sql, params, 0,
)
rows = await self._execute(sql, params)
result = self._result(
"success", "查询成功", arguments, context, plan, sql, params, len(rows)
)
result["data"] = {"total": len(rows), "rows": rows}
return result
@staticmethod
def _require_context(context: RequestContext) -> None:
if "financial:nl2sql:read" not in context.permissions:
raise ForbiddenAgentError("缺少金融 NL2SQL 查询权限")
if not {"advisor", "operator", "admin", "super_admin"}.intersection(context.roles):
raise ForbiddenAgentError("当前角色不能使用金融 NL2SQL 查询")
@staticmethod
def _validate_plan(plan: FinancialQueryPlan, context: RequestContext) -> tuple[bool, str]:
# `W29`:**不可执行计划优先**。写意图预检(在 `plan()` 里)产出的计划在这里被拦下,
# 原样回传原因 ⇒ `query()` 走**现成**的 `status="rejected"` 分支,不需要新增代码路径,
# 审计也照旧留痕(这是会签项 18「最小化边界」的要求:只新增一条提前返回路径)。
if plan.unsupported_reason:
return False, plan.unsupported_reason
if len(plan.domains) > 3:
return False, "最多支持跨三个业务域查询"
if not set(plan.tables).issubset(ALLOWED_TABLES):
return False, "查询包含未纳入 NL2SQL 范围的数据表"
allowed = set().union(*(DOMAINS[d] for d in plan.domains if d in DOMAINS))
if not set(plan.tables).issubset(allowed):
return False, "查询计划的数据表和业务域不匹配"
if plan.time_mode == "as_of" and CURRENT_ONLY_TABLES.intersection(plan.tables):
return False, "当前快照表缺少历史版本,暂不支持该历史时点查询"
if context.data_scope not in {"self", "own_customers", "all"}:
return False, "未知的数据权限范围"
return True, "计划校验通过"
def _compile_sql(
self, plan: FinancialQueryPlan, context: RequestContext
) -> tuple[str, dict[str, Any]]:
select_parts: list[str] = []
group_parts: list[str] = []
for dimension in plan.dimensions:
table, column = self._split_field(dimension, plan.tables[0])
self._ensure_column(table, column)
select_parts.append(f"{alias(table)}.{column} AS {column}")
group_parts.append(f"{alias(table)}.{column}")
for metric in plan.metrics:
select_parts.append(self._metric_expression(metric))
if not select_parts:
select_parts = self._default_select(plan.tables[0])
params: dict[str, Any] = {}
where = ["1=1"]
base_alias = alias(plan.tables[0])
if plan.time_column and plan.start:
where.append(f"{base_alias}.{plan.time_column} >= :start_time")
params["start_time"] = plan.start
if plan.time_column and plan.end:
where.append(f"{base_alias}.{plan.time_column} <= :end_time")
params["end_time"] = plan.end
for index, item in enumerate(plan.filters):
table, column = self._split_field(str(item.get("field", "")), plan.tables[0])
self._ensure_column(table, column)
operator = str(item.get("operator", "=")).upper()
if operator not in {"=", "!=", ">", ">=", "<", "<=", "LIKE"}:
raise ValidationAgentError("查询筛选条件不合法")
key = f"filter_{index}"
where.append(f"{alias(table)}.{column} {operator} :{key}")
params[key] = item.get("value")
self._append_customer_scope(plan, context, where, params)
sql = (
f"SELECT {', '.join(select_parts)} FROM {self._join(plan.tables)} "
f"WHERE {' AND '.join(where)}"
)
if plan.metrics and group_parts:
sql += f" GROUP BY {', '.join(group_parts)}"
return f"{sql} LIMIT {plan.limit}", params
@staticmethod
def _metric_expression(metric: str) -> str:
mapping = {
"交易金额": "SUM(t.gross_amount) AS gross_amount",
"成交数量": "SUM(t.executed_quantity) AS executed_quantity",
"持仓市值": "h.market_value AS market_value",
"浮动盈亏": "h.profit_loss AS profit_loss",
"现金余额": "a.cash_balance AS cash_balance",
"可用现金": "a.available_cash AS available_cash",
"历史资金变化": "SUM(l.amount) AS cash_change",
"收盘价": "m.close_price AS close_price",
"基金净值": "n.nav AS nav",
"客户数": "COUNT(DISTINCT cp.customer_id) AS customer_count",
}
if metric not in mapping:
raise ValidationAgentError(f"暂不支持指标:{metric}")
return mapping[metric]
@staticmethod
def _default_select(primary: str) -> list[str]:
defaults = {
"fin_fee_rule": [
"p.product_code AS product_code",
"f.order_side AS order_side",
"f.fee_rate AS fee_rate",
],
"fin_sim_order": [
"o.order_no AS order_no",
"o.status AS status",
"o.submitted_at AS submitted_at",
],
"client_facing_content": [
"c.content_type AS content_type",
"c.review_status AS review_status",
],
"fin_transaction": [
"t.transaction_no AS transaction_no",
"t.gross_amount AS gross_amount",
],
}
return defaults.get(primary, [f"{alias(primary)}.id AS id"])
@staticmethod
def _split_field(field: str, default_table: str) -> tuple[str, str]:
if "." not in field:
return default_table, field
table, column = field.split(".", 1)
return table, column
@staticmethod
def _ensure_column(table: str, column: str) -> None:
if column not in TABLE_COLUMNS.get(table, set()):
raise ValidationAgentError("查询字段不在白名单内")
@staticmethod
def _join(tables: tuple[str, ...]) -> str:
primary = tables[0]
joined = {primary}
pending = set(tables) - joined
sql = f"{primary} {alias(primary)}"
while pending:
for table in sorted(pending):
condition = next(
(
JOIN_SQL.get((existing, table)) or JOIN_SQL.get((table, existing))
for existing in joined
if JOIN_SQL.get((existing, table)) or JOIN_SQL.get((table, existing))
),
None,
)
if condition:
sql += f" JOIN {table} {alias(table)} ON {condition}"
joined.add(table)
pending.remove(table)
break
else:
raise ValidationAgentError("查询涉及的表缺少合法关联路径")
return sql
@staticmethod
def _append_customer_scope(
plan: FinancialQueryPlan, context: RequestContext, where: list[str], params: dict[str, Any]
) -> None:
aliases = [
alias(table)
for table in plan.tables
if "customer_id" in TABLE_COLUMNS.get(table, set())
]
if not aliases or context.data_scope == "all":
return
customer_ids = tuple(context.customer_ids) or (context.user_id,)
keys = []
for index, customer_id in enumerate(customer_ids):
key = f"scope_customer_{index}"
keys.append(f":{key}")
params[key] = int(customer_id)
where.append(f"{aliases[0]}.customer_id IN ({', '.join(keys)})")
@staticmethod
def _safe_sql_check(sql: str, params: dict[str, Any], plan: FinancialQueryPlan) -> None:
normalized = re.sub(r"\s+", " ", sql.strip())
if not normalized.upper().startswith("SELECT "):
raise ForbiddenAgentError("仅支持 SELECT 查询")
if ";" in normalized.rstrip(";") or BANNED_SQL.search(normalized):
raise ForbiddenAgentError("检测到非只读数据库操作")
if "*" in normalized:
raise ForbiddenAgentError("禁止使用 SELECT *")
refs = set(re.findall(r"\b(?:FROM|JOIN)\s+([A-Za-z_][A-Za-z0-9_]*)", normalized, re.I))
if refs != set(plan.tables):
raise ForbiddenAgentError("SQL 使用的数据表与查询计划不一致")
if len(params) > 30:
raise ForbiddenAgentError("查询参数过多")
async def _execute(self, sql: str, params: dict[str, Any]) -> list[dict[str, Any]]:
async with self.session_factory() as session:
result = await session.execute(text(sql), params)
return [self._jsonable(dict(row)) for row in result.mappings().all()]
@staticmethod
def _jsonable(row: dict[str, Any]) -> dict[str, Any]:
converted: dict[str, Any] = {}
for key, value in row.items():
if isinstance(value, Decimal):
converted[key] = str(value)
elif isinstance(value, datetime):
converted[key] = value.isoformat(sep=" ", timespec="seconds")
elif isinstance(value, date):
converted[key] = value.isoformat()
else:
converted[key] = value
return converted
@staticmethod
def _result(
status: str,
message: str,
arguments: FinancialNL2SQLInput,
context: RequestContext,
plan: FinancialQueryPlan,
sql: str | None,
params: dict[str, Any],
row_count: int,
) -> dict[str, Any]:
audit = {
"tool": "query_financial_data",
"engine_version": "mvp-1",
"trace_id": context.trace_id,
"question": arguments.question,
"query_plan": plan.model_dump(mode="json"),
"generated_sql": sql,
"parameters": params,
"permission_check": {
"status": "passed" if status in {"ready", "success"} else status,
"roles": list(context.roles),
"data_scope": context.data_scope,
"permission": "financial:nl2sql:read",
"allowed_tables": list(plan.tables),
},
"execution": {"status": status, "row_count": row_count},
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="seconds"),
}
return FinancialNL2SQLResult(
status=status, message=message, query_plan=plan.model_dump(mode="json"),
sql=sql, parameters=params, audit=audit,
).model_dump(mode="json")
async def query_financial_data_tool(
arguments: FinancialNL2SQLInput, context: RequestContext
) -> dict[str, Any]:
return await FinancialNL2SQLService().query(arguments, context)