Files
group_fqcd_jr/tools/seed_compliance_baseline.py
张胜宇 3e24033f72 feat(W27): L0 表层判定层 + 出口 E6 行情 + 收益过滤槽位白名单 + 免责声明分档(设计与代码同轮完成)
设计与依据:新增 D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md(CS-ARCH-2026-024)
DEC-W27-1~12 全部批准并落地。

## 新增(3 个源文件 + 3 个测试文件)
- app/core/exit_codes.py:出口码注册表(E0/E1/E2a~E2e/E3/E4/E5a~E5c/E6/E8/CHAT/LOGIN/CONTACT)
  + NON_BUSINESS_EXIT_CODES 白名单(免责分档的判据面)
- app/service/fund_trend_service.py(E6):summarize_trend() 纯函数 + query_fund_trend 工具
  读 fin_nav_history;按净值日个数(5/20/60/120)给区间涨跌 / 区间高低 / 来源与区间
- tests/unit/{service,tools} 新增 45 条守卫单测(E6 出口 / L0 表层判定 / 工具配置)

## 改动(重点)
- customer_service.py:
  · _route_surface()(L0-a 闲聊 / L0-b 行情 / L0-e 无信息量),位置在安全路由之后
  · drop_yield_claims() 重写为「入口闸 × 槽位白名单」+ fail-closed
    (旧黑名单会误删权重:A-06 业绩基准公式整行消失)
  · allowed_tools 补 query_fund_trend —— 修掉发布被拒 422「配置超出 Agent 工具上限」
    (该 422 是**子集**校验而非数量上限,根因即此项漏配)
  · 25 处 CoreResult 全部补 exit_code(AST 守卫保证零遗漏)
- customer_service_rules.py:闲聊判定重写(业务实体边界 + 词表 + 特征串 + 语气词)
  + is_low_information_message() + is_contact_inquiry()
- governance.py:免责声明按 exit_code 分档(业务档完整 / 非业务档轻型)
- actor.py:VISITOR_PERMISSIONS 补 fund:quote:read(E6 对访客开放,DEC-W27-11)
- tools/:seed_compliance_baseline 新增 TPL_DISCLAIMER_LIGHT;publish 工具改为
  「从生效版本派生原列表再追加」(_inherit_tools 取不到返回 None,防静默改窄)

## 实测
- 金标扩容 46 → 55(新增 Q 组行情 5 条 + C 组闲聊 4 条)
- M-1 55/55、M-4 55/55;M-5 与四项零容忍(M-7/M-8/M-9/M-10)全 0
- 原 46 条可比基线逐项不变(46/46);M-6 9.1%(5 条全在转人工白名单内)
- 全量 pytest 2094 passed / 3 skipped;ruff 零新增(余 5 条与 HEAD 逐条对应)
- 配置版本 244(cs-tools-75813de45421)已发布并激活

## 文档
- 新增 D3.9(设计专册);D1.1 §34 补「代码实施已同轮完成」并把旧表述作废
- D3.7:新增 Q 组判据表 + §6.4 第四次实测 + M-3/M-9 口径澄清
- D2.9:新增 §2.11(9 条新增用例的实测答复原文)+ 汇总表改双列口径
- D2.1:新增 v6.41 执行条目
- 权威副本(D:\桌面\金融\)→ 仓库镜像 全量比对一致(0 缺失 / 0 不一致)

## 诚实未做项
- 知识出口(E3/E4)侧的实体锚点闸门未做(故金标扩容 9 条而非 12 条,A-11~A-13 未成集)
- 阈值未重标(D3.9 §3.4 只给方法);M-3 分母未改(仅明示口径);
  D1.1 §0 与 §4.0 历史计数偏移(差 1)仍沿用
2026-09-21 22:32:39 +08:00

274 lines
16 KiB
Python
Raw Permalink 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.
r"""合规层种子数据:7 个零容忍负面词(+4 变体 + C-05 三类词 14 条)与 6 类固定话术。
C-05(`D2.1` §1.4 D-a):在既有 11 行**之外追加**三类词 —— 收益比较 / 稀缺性 / 费率误导。
三条硬约束:① `applicable_agents` 非空且含客服 Agent(空数组 = 失败关闭跳过,规则永不生效);
② **不得改写既有 11 行的 `word_pattern`**(追加项单独放在 `EXTRA_NEGATIVE_RULES`,既有元组
一字未动);③ 按**承诺性短语**收录而非形容词。
⚠️ 为什么落点是**本文件**而不是另存一份数据(甲案,用户 2026-09-18 已拍板):管理端
`negative-word-rules` 通道只能建 `draft`(须审核才 `active`),而本脚本是唯一可复现、可重跑
的装载器;另存第二份种子会让新环境**漏跑即静默缺失** —— 正是本项目反复踩过的坑。
`C-05` 的 DoD 写「不改任何 `.py`」,此处登记为「不改 `app/**` 与底座 `.py`」:本文件是
**数据装载器**,与 `D-b`(两条话术热线改定值)同源,追加的是数据行而不是拦截逻辑。
背景:`docs/02` §10.2/§10.3 要求这些数据,但迁移只建表、没有 seed。而
`app/service/agent/governance.py` L62-65 的取数条件是
`status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL`,
两张表 0 行就意味着一**条规则都查不到**:客服 Agent 说"这只基金稳赚"不会被拦,
免责声明也不会出现在任何回复里。合规过滤是静默失效的——不报错,只是不生效。
取值来源(不臆造,均以真实 DDL 为准):
- `agent_negative_word.match_type` CHECK:`exact` / `contains` / `regex`,
这里只用 `contains`;`regex` 被 governance.py L75-77 显式拒绝
(Python `re` 无执行超时,底座不跑未经约束的管理员正则)。
- `agent_negative_word.severity` CHECK:`warn` / `regenerate` / `block`,
零容忍词一律 `block`。
- `agent_negative_word.category` **无 CHECK 约束**,按语义取值(《理财销售办法》
的口径:误导性 / 绝对化 / 承诺 / 收益)。
- `agent_reply_template.scene` CHECK:`disclaimer` / `low_confidence` /
`compliance_block` / `transfer` / `model_failure` / `system_busy` / `clarification`。
- `agent_reply_template.active_key` 是 **生成列**(`status='active'` 时等于
`template_code:locale`,否则 NULL):不能写、也不该写,由库自己算。
它上面有唯一键 `uk_reply_template_active_one`,所以同一 template_code 只能有一条 active。
幂等:按 `rule_code`(唯一键 `uk_negative_rule_code`)与
`template_code`+`version`(唯一键 `uk_reply_template_version`)upsert,可重复执行;
重复执行不会新增行,只会把已存在的行重新置为 active 并刷新审核信息。
执行:`.\.venv\Scripts\python.exe tools\seed_compliance_baseline.py`(可重复执行)
"""
from __future__ import annotations
import asyncio
import json
import sys
from datetime import UTC, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from sqlalchemy import text # noqa: E402
from app.infrastructure.db import SessionFactory # noqa: E402
#: 审核人与创建人:`sys_user.id=9003`(T-ADMIN,由 tools/seed_test_rbac.py 种入)。
#: `created_by`/`reviewer_id` 都有指向 `sys_user` 的外键,写不存在的用户会直接失败。
ADMIN_ID = 9003
#: 只对客服 Agent 生效,避免影响其他 Agent 的表达自由度。
APPLICABLE_AGENTS = '["customer_service"]'
#: 命中后统一的合规拒答话术(就是本脚本要种的 6 条之一,所以话术先插)。
SAFE_REPLY_TEMPLATE_CODE = "TPL_COMPLIANCE_REFUSAL"
#: `docs/02` §10.2 逐字要求的 7 个零容忍词。前 7 条规则必须恰好覆盖它们。
REQUIRED_ZERO_TOLERANCE_WORDS = (
"保本", "稳赚", "无风险", "保证收益", "预期收益率", "年化收益率", "安全",
)
#: (rule_code, word_pattern, category, reason)
#: 前 7 条是 `docs/02` §10.2 逐字要求的零容忍词,一个都不能少;
#: 后 4 条是专项设计 §6.5 评审建议的变体(换个说法绕过关键词的常见写法)。
NEGATIVE_RULES: tuple[tuple[str, str, str, str], ...] = (
("NEG-001", "保本", "guarantee", "资管新规后非保本"),
("NEG-002", "稳赚", "misleading", "误导性表述"),
("NEG-003", "无风险", "absolute", "绝对化表述"),
("NEG-004", "保证收益", "guarantee", "禁止刚兑承诺"),
("NEG-005", "预期收益率", "yield_claim", "《理财销售办法》明文禁止"),
("NEG-006", "年化收益率", "yield_claim", "监管处罚点名措辞"),
("NEG-007", "安全", "absolute", "绝对化表述"),
("NEG-008", "零风险", "absolute", "绝对化表述变体"),
("NEG-009", "稳赚不赔", "misleading", "误导性表述变体"),
("NEG-010", "躺着赚", "misleading", "误导性表述变体"),
("NEG-011", "坐享收益", "misleading", "误导性表述变体"),
)
#: C-05 追加的三类词(`D2.1` §1.4 D-a 硬约束 ③:**承诺性短语**,不按形容词收录)。
#:
#: 入库前做过**误伤体检**:对 `knowledge/_chunks.jsonl` 逐词计数,下列 14 个词**全部 0 命中**,
#: 且与 6 条话术零自绊(`verify()` 会强制复查)。两个刻意的排除:
#: · **剔除 `X 折优惠`** —— 语料里「1 折优惠」「免申购费」是**公司自己的合法费率事实**
#: (FAQ「申购和赎回的费率是多少」的答案正文就在写它),收进来会把费率问答整条拦成
#: 转人工,与「放宽答不上来时的去向」正好相反。
#: · **不收「高收益」「稳健」「低风险」这类形容词** —— 它们是风险提示里的正常用词
#: (「高收益伴随高风险」会被一并拦下)。
EXTRA_NEGATIVE_RULES: tuple[tuple[str, str, str, str], ...] = (
("NEG-012", "承诺高收益", "yield_compare", "收益比较:以承诺口吻作横向比较"),
("NEG-013", "保证高收益", "yield_compare", "收益比较:以保证口吻作横向比较"),
("NEG-014", "比同类收益高", "yield_compare", "收益比较:与同类产品比较"),
("NEG-015", "收益高于同类", "yield_compare", "收益比较:与同类产品比较"),
("NEG-016", "跑赢同类", "yield_compare", "收益比较:比较性表述"),
("NEG-017", "稀缺额度", "scarcity", "稀缺性:以额度稀缺促单"),
("NEG-018", "额度有限", "scarcity", "稀缺性:以额度有限促单"),
("NEG-019", "仅剩", "scarcity", "稀缺性:以示数量促单"),
("NEG-020", "优先认购权", "scarcity", "稀缺性:暗示优先资格"),
("NEG-021", "优先配置权", "scarcity", "稀缺性:暗示优先资格"),
("NEG-022", "零费率", "fee_misleading", "费率误导:宣称零费率"),
("NEG-023", "费率最低", "fee_misleading", "费率误导:最低化宣称"),
("NEG-024", "手续费全免", "fee_misleading", "费率误导:宣称全免"),
("NEG-025", "全网最低", "fee_misleading", "费率误导:全市场最低宣称"),
)
#: C-05 三类词必须齐备的类目(`verify()` 逐类检查至少 1 条 active)。
C05_CATEGORIES: tuple[str, ...] = ("yield_compare", "scarcity", "fee_misleading")
#: 真正落库的全部规则 = 既有 11 条 + C-05 追加 14 条。
ALL_NEGATIVE_RULES = NEGATIVE_RULES + EXTRA_NEGATIVE_RULES
#: 规则必须挂到的 Agent(D-a 硬约束 ①:空数组 = 规则永不生效,且是「假成功」)。
REQUIRED_APPLICABLE_AGENT = "customer_service"
#: (template_code, scene, title, content_text)
#: scene 取自 `chk_template_scene` 的 7 个合法值里的 6 个,覆盖 6 类固定话术。
#: TPL_AI_NOTICE 用 `clarification`(AI 生成标识属主动澄清披露);
#: TPL_COMPLIANCE_REFUSAL 用 `compliance_block`(合规拦截后替换的安全话术)。
#:
#: ⚠️ **话术文本本身不得命中 NEGATIVE_RULES 里的任何词**(`verify()` 会强制校验)。
#: 治理层(governance.py L127-128)对 `contains` 是**朴素子串匹配**,没有否定式豁免:
#: 旧文案里的「非保本」会命中「保本」规则,形成"合规话术被自己的规则拦截"的自绊,
#: Task 2 接入话术后就会变成"替换文本又被过滤"的循环。因此合规语义必须用
#: **不含禁用字面**的说法表达("不承诺本金不受损失" 而不是 "非保本")。
REPLY_TEMPLATES: tuple[tuple[str, str, str, str], ...] = (
("TPL_COMPLIANCE_REFUSAL", "compliance_block", "合规拒答",
"根据监管要求,我不能对收益做出任何承诺,也不承诺本金不受损失。"
"本产品收益可能为负,请以产品说明书为准。"),
("TPL_DISCLAIMER", "disclaimer", "固定免责声明",
"本内容仅为投资分析参考,不构成任何直接投资建议,不构成对任何产品的收益承诺,"
"据此操作风险自负,请谨慎对待。"),
# ✅ `W27` / `DEC-W27-10`:**轻量提示**。门禁 `F5` 分档后,非业务答复
# (闲聊 / 联系方式 / 引导登录 / 澄清)挂这一句,业务答复仍挂 `TPL_DISCLAIMER`。
# 与代码兜底 `app/service/agent/governance.FALLBACK_LIGHT_NOTICE` **必须同值**:
# 一个走库、一个走代码,值不一致时"哪句生效"取决于数据库可不可用(已踩过一次)。
("TPL_DISCLAIMER_LIGHT", "disclaimer", "轻量提示(非业务答复)",
"以上为一般性说明,仅供参考,具体以产品说明书与官方披露为准。"),
("TPL_AI_NOTICE", "clarification", "AI 生成标识",
"本回答由 AI 生成,仅供参考。"),
("TPL_LOW_CONFIDENCE", "low_confidence", "低置信兜底",
"抱歉,我暂时无法准确回答您的问题,建议您转接人工客服获取更准确的帮助。"),
("TPL_TRANSFER_HUMAN", "transfer", "转人工提示",
"已为您转接人工客服,服务时间为每日 7:00—22:00,客服电话 400-889-8899。"),
("TPL_SYSTEM_BUSY", "system_busy", "系统繁忙/模型故障",
"系统繁忙,暂时无法回答,请稍后重试或联系人工客服 400-889-8899。"),
)
#: template_code -> content_text,供 `verify()` 做自绊检查(须定义在 REPLY_TEMPLATES 之后)。
CONTENT_BY_TEMPLATE_CODE = {code: content for code, _scene, _title, content in REPLY_TEMPLATES}
INSERT_NEGATIVE_RULE = text(
"INSERT INTO agent_negative_word"
" (rule_code, word_pattern, match_type, category, severity, applicable_agents,"
" safe_reply_template_code, status, version, created_by, reviewer_id, reviewed_at,"
" created_at, updated_at)"
" VALUES (:rule_code, :word, 'contains', :category, 'block', :agents,"
" :safe_code, 'active', 1, :admin, :admin, :now, :now, :now)"
" ON DUPLICATE KEY UPDATE word_pattern=:word, match_type='contains', category=:category,"
" severity='block', applicable_agents=:agents, safe_reply_template_code=:safe_code,"
" status='active', reviewer_id=:admin, reviewed_at=:now, updated_at=:now"
)
INSERT_REPLY_TEMPLATE = text(
"INSERT INTO agent_reply_template"
" (template_code, scene, title, content_text, variables, locale, version, status,"
" created_by, reviewer_id, reviewed_at, created_at, updated_at)"
" VALUES (:code, :scene, :title, :content, '{}', 'zh-CN', 1, 'active',"
" :admin, :admin, :now, :now, :now)"
" ON DUPLICATE KEY UPDATE scene=:scene, title=:title, content_text=:content,"
" status='active', reviewer_id=:admin, reviewed_at=:now, updated_at=:now"
)
async def seed() -> None:
"""幂等落库。话术先于负面词插入:负面词会引用话术 code。"""
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
for code, scene, title, content in REPLY_TEMPLATES:
await session.execute(INSERT_REPLY_TEMPLATE, {
"code": code, "scene": scene, "title": title, "content": content,
"admin": ADMIN_ID, "now": now,
})
for rule_code, word, category, _reason in ALL_NEGATIVE_RULES:
await session.execute(INSERT_NEGATIVE_RULE, {
"rule_code": rule_code, "word": word, "category": category,
"agents": APPLICABLE_AGENTS, "safe_code": SAFE_REPLY_TEMPLATE_CODE,
"admin": ADMIN_ID, "now": now,
})
async def verify() -> None:
"""按 governance.py 的**同一口径**复查,确认真的能被查到。"""
async with SessionFactory() as session:
rules = (await session.execute(text(
"SELECT rule_code, word_pattern, category, severity, match_type, applicable_agents"
" FROM agent_negative_word"
" WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
" ORDER BY rule_code"
))).mappings().all()
templates = (await session.execute(text(
"SELECT template_code, scene, status FROM agent_reply_template"
" WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
" ORDER BY template_code"
))).all()
for row in rules:
print(f" rule {row['rule_code']} {row['word_pattern']!r}"
f" category={row['category']} severity={row['severity']}"
f" match_type={row['match_type']} agents={row['applicable_agents']}")
for code, scene, status in templates:
print(f" tpl {code} scene={scene} status={status}")
# 种子层的自绊检查:话术不得命中自己这套规则。治理层是朴素子串匹配,
# 没有否定式豁免——「非保本」这类写法会命中「保本」规则。
# 在种子阶段就拦住,比等 Task 2 接入话术后再发现在替换结果里循环排查便宜得多。
patterns = {row["word_pattern"] for row in rules}
self_trips = [
(code, word)
for code, content in CONTENT_BY_TEMPLATE_CODE.items()
for word in patterns
if word in content
]
if self_trips:
raise SystemExit(f"话术命中了自己的禁用词(自绊):{self_trips}")
missing = {
word for word in REQUIRED_ZERO_TOLERANCE_WORDS
if not any(word in pattern for pattern in patterns)
}
if missing:
raise SystemExit(f"零容忍词未生效:{sorted(missing)}")
scene_count = len({scene for _, scene, _ in templates})
if scene_count < 6:
raise SystemExit(f"生效话术场景不足 6 类:{scene_count}")
# D-a 硬约束 ①:`applicable_agents` 为空 = 治理层**失败关闭跳过**(规则永不生效)。
# 这条最容易「看着种进去了、其实一条都没生效」,所以在种子阶段正面断言。
ungated = [
row["rule_code"] for row in rules
if REQUIRED_APPLICABLE_AGENT not in {
str(agent) for agent in (
json.loads(row["applicable_agents"])
if isinstance(row["applicable_agents"], str)
else (row["applicable_agents"] or [])
)
}
]
if ungated:
raise SystemExit(f"规则未挂到客服 Agent(等于永不生效):{ungated}")
# C-05:三类词必须齐备 —— 否则「补了三类词」只是文档里的说法。
active_categories = {row["category"] for row in rules}
missing_categories = [c for c in C05_CATEGORIES if c not in active_categories]
if missing_categories:
raise SystemExit(f"C-05 三类词未齐备:{missing_categories}")
print(f"verified: {len(rules)} active rules (含 7 个零容忍词), "
f"{len(templates)} active templates covering {scene_count} scenes")
async def main() -> None:
await seed()
print(f"seeded {len(ALL_NEGATIVE_RULES)} negative rules"
f"(既有 {len(NEGATIVE_RULES)} + C-05 {len(EXTRA_NEGATIVE_RULES)}),"
f"{len(REPLY_TEMPLATES)} reply templates")
await verify()
if __name__ == "__main__":
asyncio.run(main())