## 接线 发布 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 告警
594 lines
27 KiB
Python
594 lines
27 KiB
Python
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)
|