feat:客服agent接入nl2sql
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""NL2SQL 查询结果 → 客服口吻回复的渲染器。
|
||||
|
||||
不调用 LLM:自然语言摘要由 execute_query 的 summary_llm 生成(result.summary),
|
||||
这里只负责把摘要 + Markdown 表格组装成客服回复,保证确定性降级。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nl2sql.contracts import DataQueryResult
|
||||
|
||||
# 表格最多渲染的行数:超出部分提示"仅展示前 N 条",避免回复过长
|
||||
_MAX_TABLE_ROWS = 20
|
||||
|
||||
_EMPTY_ANSWER = "暂时没有查到相关数据,您可以换个问法,或者问我基金知识、开户流程~"
|
||||
|
||||
|
||||
def render_markdown_table(columns: list[str], rows: list[dict[str, Any]]) -> str:
|
||||
"""把结果行列渲染为 Markdown 表格;无数据返回空串。"""
|
||||
if not columns or not rows:
|
||||
return ""
|
||||
shown = rows[:_MAX_TABLE_ROWS]
|
||||
header = "| " + " | ".join(str(column) for column in columns) + " |"
|
||||
separator = "| " + " | ".join("---" for _ in columns) + " |"
|
||||
lines = [header, separator]
|
||||
for row in shown:
|
||||
cells = [str(row.get(column, "")) for column in columns]
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_query_answer(result: DataQueryResult) -> str:
|
||||
"""组装最终客服回复:摘要开头 + 数据表格 + 截断/收尾提示。"""
|
||||
if result.row_count == 0 or not result.rows:
|
||||
return _EMPTY_ANSWER
|
||||
|
||||
parts: list[str] = []
|
||||
summary = (result.summary or "").strip()
|
||||
if summary:
|
||||
parts.append(summary)
|
||||
|
||||
table = render_markdown_table(result.columns, result.rows)
|
||||
if table:
|
||||
parts.append(table)
|
||||
|
||||
if result.truncated or result.row_count > len(result.rows):
|
||||
shown = min(len(result.rows), _MAX_TABLE_ROWS)
|
||||
parts.append(f"结果较多,本次为您展示 {shown} 条(共 {result.row_count} 条),您可以缩小查询范围再看。")
|
||||
|
||||
if not summary and len(result.rows) <= _MAX_TABLE_ROWS:
|
||||
parts.append(f"共为您查到 {result.row_count} 条记录。")
|
||||
|
||||
return "\n\n".join(part for part in parts if part).strip() or _EMPTY_ANSWER
|
||||
@@ -0,0 +1,157 @@
|
||||
"""登录客户(CUSTOMER)的 NL2SQL 权限快照服务。
|
||||
|
||||
与员工路径(permission_service.load_query_permission,按 nl2sql_query_role
|
||||
配置)不同,客户权限不落库、不做管理后台:表白名单通过 sys_config 配置管理,
|
||||
且只能从内置白名单中做"减法",行级范围由服务端强制注入 customer_ids,
|
||||
保证客户永远只能查询自己的数据。
|
||||
|
||||
列级校验:快照时从 information_schema 加载白名单表的真实列清单写入
|
||||
columns,validate_select_sql 据此在执行前拦截 LLM 幻觉列(避免把
|
||||
Unknown column 错误漏到执行期)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from inspect import isawaitable
|
||||
|
||||
from sqlalchemy import bindparam, text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 客户可查询的内置表白名单(配置只能在其中做减法,不能新增表)
|
||||
CUSTOMER_DEFAULT_TABLES: tuple[str, ...] = (
|
||||
"fin_holdings",
|
||||
"fin_transaction",
|
||||
"fin_product",
|
||||
"fund_nav_history",
|
||||
"fund_performance",
|
||||
)
|
||||
|
||||
# 行级隔离:出现这些表的 SQL 会被强制注入 customer_id IN (<登录用户>) 条件
|
||||
CUSTOMER_ROW_SCOPES: dict[str, dict[str, str]] = {
|
||||
"fin_holdings": {"type": "customer_ids", "column": "customer_id"},
|
||||
"fin_transaction": {"type": "customer_ids", "column": "customer_id"},
|
||||
}
|
||||
|
||||
# 客户路径首期不开放敏感档案表;后续开放时在此配置 (table, column) -> mask_type
|
||||
CUSTOMER_MASKS: dict[tuple[str, str], str] = {}
|
||||
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
_COLUMNS_SQL = text(
|
||||
"SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name "
|
||||
"FROM information_schema.columns "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN :tables "
|
||||
"ORDER BY TABLE_NAME, ORDINAL_POSITION"
|
||||
).bindparams(bindparam("tables", expanding=True))
|
||||
|
||||
|
||||
async def _load_real_columns(db, tables: set[str]) -> dict[str, set[str]] | None:
|
||||
"""加载白名单表的真实列名;db 为 None 时返回 None(仅测试路径)。"""
|
||||
if db is None:
|
||||
return None
|
||||
result = await db.execute(_COLUMNS_SQL, {"tables": sorted(tables)})
|
||||
columns: dict[str, set[str]] = {}
|
||||
for row in result.mappings():
|
||||
table_name = str(row["table_name"] or "").strip()
|
||||
column_name = str(row["column_name"] or "").strip()
|
||||
if table_name and column_name:
|
||||
columns.setdefault(table_name, set()).add(column_name)
|
||||
return columns
|
||||
|
||||
|
||||
def _denied_permission() -> dict:
|
||||
return {
|
||||
"can_query": False,
|
||||
"role": "customer_self",
|
||||
"tables": set(),
|
||||
"columns": None,
|
||||
"masks": {},
|
||||
"row_scopes": {},
|
||||
"max_rows": 0,
|
||||
"daily_quota": 0,
|
||||
}
|
||||
|
||||
|
||||
async def _config(config_getter, key: str, default: str) -> str:
|
||||
value = config_getter(key, default)
|
||||
if isawaitable(value):
|
||||
value = await value
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
return str(value)
|
||||
|
||||
|
||||
def _parse_allowed_tables(raw: str) -> set[str]:
|
||||
"""解析表白名单配置;非法表名直接忽略,只允许内置白名单的子集。"""
|
||||
known = set(CUSTOMER_DEFAULT_TABLES)
|
||||
names = {
|
||||
item.strip().lower()
|
||||
for item in str(raw).replace(";", ",").replace(";", ",").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
tables = names & known
|
||||
return tables
|
||||
|
||||
|
||||
async def load_customer_query_permission(
|
||||
db,
|
||||
user_id: int,
|
||||
*,
|
||||
config_getter,
|
||||
) -> dict:
|
||||
"""每次请求重建客户权限快照。
|
||||
|
||||
客户身份已由 API 层(require_customer)和会话归属校验保证,
|
||||
快照不依赖数据库中的角色配置;db 用于加载白名单表的真实列清单
|
||||
(传入 None 时跳过列清单,columns 保持 None,仅限测试路径)。
|
||||
"""
|
||||
del user_id # 权限与具体请求上下文无关,签名对齐 execute_query 的 permission_loader
|
||||
if config_getter is None:
|
||||
return _denied_permission()
|
||||
enabled = (await _config(config_getter, "nl2sql.customer.enabled", "true")).lower()
|
||||
if enabled not in _TRUTHY:
|
||||
return _denied_permission()
|
||||
|
||||
raw_tables = await _config(
|
||||
config_getter,
|
||||
"nl2sql.customer.allowed_tables",
|
||||
",".join(CUSTOMER_DEFAULT_TABLES),
|
||||
)
|
||||
tables = _parse_allowed_tables(raw_tables)
|
||||
if not tables:
|
||||
return _denied_permission()
|
||||
|
||||
try:
|
||||
max_rows = int(await _config(config_getter, "nl2sql.customer.max_rows", "200"))
|
||||
daily_quota = int(
|
||||
await _config(config_getter, "nl2sql.customer.daily_quota", "20")
|
||||
)
|
||||
except ValueError:
|
||||
max_rows, daily_quota = 200, 20
|
||||
max_rows = max(1, max_rows)
|
||||
daily_quota = max(0, daily_quota)
|
||||
|
||||
# 列级校验用真实列清单:拦截 LLM 幻觉列,避免执行期 Unknown column。
|
||||
# 信息读取失败时按"拒绝"处理(fail-closed),不让无列校验的快照放行。
|
||||
try:
|
||||
real_columns = await _load_real_columns(db, tables)
|
||||
except Exception:
|
||||
logger.exception("load customer nl2sql columns failed")
|
||||
return _denied_permission()
|
||||
|
||||
return {
|
||||
"can_query": True,
|
||||
"role": "customer_self",
|
||||
"tables": tables,
|
||||
# 真实列清单(db=None 的测试路径保持 None = 不限列)
|
||||
"columns": real_columns,
|
||||
"masks": dict(CUSTOMER_MASKS),
|
||||
"row_scopes": {
|
||||
table: dict(scope)
|
||||
for table, scope in CUSTOMER_ROW_SCOPES.items()
|
||||
if table in tables
|
||||
},
|
||||
"max_rows": max_rows,
|
||||
"daily_quota": daily_quota,
|
||||
}
|
||||
@@ -88,13 +88,18 @@ async def query(
|
||||
sort_by=request.sort_by,
|
||||
sort_order=request.sort_order,
|
||||
)
|
||||
final_columns = {
|
||||
table: set(columns)
|
||||
for table, columns in (permission.get("columns") or {}).items()
|
||||
}
|
||||
for table, scope in (permission.get("row_scopes") or {}).items():
|
||||
if scope.get("column"):
|
||||
final_columns.setdefault(table, set()).add(scope["column"])
|
||||
# columns 为 None 表示不做列级限制;仅当配置了列权限时才需要
|
||||
# 保证行级范围列可访问。保持 dict(含空 dict)行为不变。
|
||||
if permission.get("columns") is None:
|
||||
final_columns = None
|
||||
else:
|
||||
final_columns = {
|
||||
table: set(columns)
|
||||
for table, columns in permission["columns"].items()
|
||||
}
|
||||
for table, scope in (permission.get("row_scopes") or {}).items():
|
||||
if scope.get("column"):
|
||||
final_columns.setdefault(table, set()).add(scope["column"])
|
||||
return validate_select_sql(
|
||||
option_sql,
|
||||
authorized_tables=permission.get("tables", set()),
|
||||
|
||||
Reference in New Issue
Block a user