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 告警
This commit is contained in:
@@ -75,9 +75,74 @@ def _filters(question: str) -> tuple[dict[str, Any], ...]:
|
||||
)
|
||||
|
||||
|
||||
#: **一级:明确的写动作词**。命中即拒绝 —— 本工具是只读查询,不执行任何修改。
|
||||
#:
|
||||
#: 这一组刻意收得**很窄**:只收「在查询语境里几乎不出现」的动词。
|
||||
#: 像「修改 / 更新 / 变更 / 调整 / 导出」这些**两头都常见**的词(「修改记录」是查询、
|
||||
#: 「修改风险等级」是写)放二级,见 `_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 ("截至", "历史时点", "当时", "某日")):
|
||||
@@ -296,6 +361,11 @@ class FinancialNL2SQLService:
|
||||
|
||||
@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):
|
||||
|
||||
Reference in New Issue
Block a user