Files
group_fqcd_jr/app/service/agent/governance.py
T
张胜宇 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

428 lines
26 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.
import logging
import re
from collections.abc import Callable
from typing import Protocol
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.compliance_context import first_violation as _first_violation
from app.core.contracts import (
AgentDefinition,
AgentResult,
RecalledMemory,
RequestContext,
ResolvedAgentConfig,
)
from app.core.customer_service_rules import PUBLIC_BUSINESS_IDENTIFIERS
from app.core.errors import ForbiddenAgentError, RecoverableAgentError
from app.core.exit_codes import NON_BUSINESS_EXIT_CODES
from app.core.memory_scope import (
RECALL_ITEM_LIMIT,
REQUIRED_EMPLOYEE_PERMISSION,
customer_memory_scope,
is_customer_identity,
memory_customer_in_scope,
)
from app.infrastructure.db import SessionFactory
from app.model.configuration import ConfigRelease
from app.service.memory_recall_service import MemoryRecallService
from app.service.runtime_config_service import RuntimeConfigService
logger = logging.getLogger(__name__)
# 组装层注入的召回服务工厂:允许带 Redis 缓存与向量库,缺省退化为纯结构化召回。
RecallServiceFactory = Callable[[AsyncSession], MemoryRecallService]
#: 固定免责声明在 `agent_reply_template` 里的 `template_code`(Task 1 已种入并审核通过)。
#: 按 `template_code` 解析而不是按 `scene`:唯一键 `uk_reply_template_active_one` 保证的是
#: "同一 template_code 只有一条 active",`scene` 上没有唯一性——按 scene 取会在同场景多模板时
#: 取到哪条不确定,而免责声明是必须逐字固定的合规话术。
DISCLAIMER_TEMPLATE_CODE = "TPL_DISCLAIMER"
#: 平台对外客服热线:**公开业务号码**,必须原样出现在面向客户的回复里
#: (spec B §5 要求固定联系方式)。脱敏规则专门放行它,
#: 否则 `TPL_TRANSFER_HUMAN` 与安全路由的 P2 话术会被打成 "[手机号已脱敏]",
#: 客户拿不到联系方式(已实测复现)。该号码原先与 `app/core/customer_service_rules.py` 的
#: `CONTACT_PHONE` 成对存在(改一处必须同步另一处);客服模块已移除,**重建话术时须重新
#: 建立这层同步**,否则同一类脱敏事故会再次出现。
#:
#: 2026-09-17 品牌统一(CS-CONTENT-2026-016):取值由旧的客服手机号改为本项目建设主体
#: 「南方基金」的公开客服热线 `400-889-8899`(官网 `nffund.com`)。同批改动的还有
#: `tools/seed_compliance_baseline.py` 的两条话术(`TPL_TRANSFER_HUMAN` / 兜底话术)——
#: **两侧必须同值**,否则脱敏放行的号码与实际话术里的号码不一致,会再次出现脱敏事故。
#: ⚠️ 注意:本常量的作用是「**脱敏放行**」,不是「替换」;话术里写错号码它不会纠正。
CUSTOMER_SERVICE_HOTLINE = "400-889-8899"
#: 代码兜底免责声明,逐字等于 `TPL_DISCLAIMER` 的已审文案。
#: 免责声明是安全关键路径(上线门禁 F5:面向客户输出 100% 附固定话术),所以**不允许**
#: "库里查不到就没有话术"这条路径——取数失败或话术缺失时用本常量,宁可多一次失败的查询,
#: 也不能少一句话术。文案本身不得命中任何禁用字面(`agent_negative_word` 规则与下方
#: `hard_patterns`):治理层是朴素子串匹配、没有否定式豁免,写过「非保本」的合规话术会被
#: 自己的规则拦下(Task 1 已修复过一次的"自绊")。
def _build_pii_number_pattern(identifiers: tuple[str, ...]) -> re.Pattern[str]:
r"""长数字串打码模式;**公开业务标识优先整体放行**。
分支顺序不能反:`\d{15,19}` 会先吃掉统一社会信用代码的前 17 位数字,把尾字母留在外面。
`identifiers` 为空时退回纯打码 —— 直接拼会得到 `(?:|\d{15,19})`,那是**匹配空串**,
脱敏会在每个字符位置插入标记(灾难性),所以这里显式兜住。
"""
alternatives = [re.escape(item) for item in identifiers if item]
if not alternatives:
return re.compile(r"(?<!\d)\d{15,19}[Xx]?(?!\d)")
return re.compile(rf"(?<!\d)(?:{"|".join(alternatives)}|\d{{15,19}}[Xx]?)(?!\d)")
_PII_NUMBER = _build_pii_number_pattern(PUBLIC_BUSINESS_IDENTIFIERS)
#: **轻量提示**(`DEC-W27-10` 批准的分档):非业务答复(闲聊 / 联系方式 / 引导登录 /
#: 澄清)只附这一句。为什么不一锅端:门禁 `F5` 原来的口径是"面向客户输出 100% 附
#: 固定话术",于是「讲个笑话」后面跟着一段投资风险提示 —— 话术与内容不匹配,
#: 客户体感是"这个客服在背模板"。**分档不减少业务答复的披露**,只是不再给
#: 非业务答复挂不相关的话术(`D3.9` §6.2)。
#: ⚠️ 判据必须是**出口码**(`CoreResult.exit_code`),不得用"文本里有没有数字"这类
#: 间接判据 —— 后者会把 `E5b` 空答误判成非业务,静默掉档。
#: 文案同样不得命中 `agent_negative_word` 的任何禁用字面(与 `FALLBACK_DISCLAIMER` 同理)。
FALLBACK_LIGHT_NOTICE = (
"以上为一般性说明,仅供参考,具体以产品说明书与官方披露为准。"
)
#: 轻量提示的库内话术 code。与 `TPL_DISCLAIMER` 同 `scene='disclaimer'`(`chk_template_scene`
#: 的合法值集合不变,因此**不需要改表结构**),差别只在 `template_code`。
LIGHT_NOTICE_TEMPLATE_CODE = "TPL_DISCLAIMER_LIGHT"
FALLBACK_DISCLAIMER = (
"本内容仅为投资分析参考,不构成任何直接投资建议,不构成对任何产品的收益承诺,"
"据此操作风险自负,请谨慎对待。"
)
#: **内部** Agent 清单:其输出不面向客户,因此不追加面向客户的固定免责声明。
#: 判据是"输出形态"而不是"重要性"——风控/投顾分析的输出是字段化摘要
#: (预警编号、级别、建议动作),追加一句面向投资者的免责声明会破坏其字段契约,
#: 下游解析与 `tests/contract/test_risk_agent_contract.py` 都会因此失败(已实测)。
INTERNAL_AGENT_TYPES = frozenset({"risk"})
#: **确认面向客户**的 Agent 清单:门禁 F5(面向客户输出 100% 附固定话术)只对这些生效。
#: 为什么用"确认式"而不是"未知即注入":`review_output` 是同步纯函数,它的调用方里既有
#: 生产装配的 `PlatformGovernance`(能反查发布版本的 `agent_type`),也有各测试的治理替身
#: (**刻意不连库**,因此无从得知 agent 类型)。若把"未知"当成面向客户,每个替身测试都会被
#: 塞进一句话术,等于用测试噪声换一个假的安全感;而这些测试恰恰是在断言 Agent 的结构化输出。
#: 生产路径下面向客户的 Agent 必然带发布版本(`config_release` 有 agent_tools 白名单),
#: 所以 F5 的覆盖不受影响——**未发布配置的 Agent 本来就没有可用工具、也不接受验收**。
#: `customer_service` 仍在名单内:客服 Agent 处于重构期、当前未注册,但重建后必须**自动**
#: 继承 F5(面向客户输出 100% 附固定话术)。从这里摘掉它等于给重建后的客服留一个合规缺口。
CUSTOMER_FACING_AGENT_TYPES = frozenset({"customer_service", "fund_query_demo"})
class AgentGovernance(Protocol):
async def resolve(
self, definition: AgentDefinition, context: RequestContext
) -> ResolvedAgentConfig: ...
async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]: ...
async def review(
self, result: AgentResult, context: RequestContext, config: ResolvedAgentConfig,
memories: tuple[RecalledMemory, ...],
*,
agent_type: str = "",
) -> AgentResult: ...
class PlatformGovernance:
"""Request-scoped snapshots; no AsyncSession or customer state retained on the singleton."""
def __init__(self, recall_factory: RecallServiceFactory | None = None) -> None:
# 组装层可注入带缓存/向量库的召回服务;缺省退化为纯结构化召回,
# 依赖缺失或故障只降级,不让记忆功能整体不可用。
self._recall_factory = recall_factory
async def resolve(
self, definition: AgentDefinition, context: RequestContext
) -> ResolvedAgentConfig:
async with SessionFactory() as session:
release = await session.scalar(select(ConfigRelease).where(
ConfigRelease.status == "active"))
tools: dict[str, tuple[str, ...]] = {}
if release is not None:
service = RuntimeConfigService(session)
for intent in definition.supported_intents:
configured = await service.allowed_tools(
release.id, definition.agent_type, intent)
tools[intent] = tuple(sorted(set(configured) & set(definition.allowed_tools)))
rows = (await session.execute(text("""
SELECT match_type, word_pattern, applicable_agents FROM agent_negative_word
WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL
"""))).mappings().all()
rules: list[tuple[str, str]] = []
import json
for row in rows:
agents = row["applicable_agents"]
if isinstance(agents, str):
agents = json.loads(agents)
if not agents or definition.agent_type not in agents:
# 失败关闭:`applicable_agents` 为空数组/NULL 表示"不适用任何 Agent",
# 跳过该规则。
# 旧判据 `if agents and ...` 在空取值时整条 `continue` 不执行,规则因此**溢出到
# 所有 Agent**(实测 advisor 也加载到 11 条):配置为空不是失效,而是扩大适用
# 范围,与项目一贯的失败关闭原则相反。
continue
if row["match_type"] not in {"exact", "contains"}:
# Python re has no execution timeout: never run unchecked admin regex.
raise RecoverableAgentError("禁止表达正则规则需要受限匹配器")
rules.append((str(row["match_type"]), str(row["word_pattern"])))
return ResolvedAgentConfig(
config_version=release.release_no if release else f"code:{definition.version}",
release_id=release.id if release else None,
prompt_version="none", model_endpoint="", allowed_tools=(),
allowed_tools_by_intent=tools, negative_rules=tuple(rules),
)
async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]:
"""按**身份可读范围**召回长期记忆(范围口径见 `app/core/memory_scope.py`)。
此前这里把 `int(context.user_id)` 直接当客户号,于是:
① 员工身份(风控/投顾/管理员/system)**恒空** —— 员工不是客户,那是个不存在的客户号;
② 更糟的是**越权陷阱** —— 员工号与客户号同号段时,会把陌生客户的长期记忆读进来
并注入提示词。现在改为:客户身份只读自己,员工身份只读
`sys_customer_assignment` 分配给自己的客户(最小可见),
归属未维护时**失败关闭**(读不到任何东西)并在日志里点名原因。
"""
scope = customer_memory_scope(context)
if not scope:
self._log_empty_scope(context)
return ()
async with SessionFactory() as session:
service = (
self._recall_factory(session)
if self._recall_factory is not None
else MemoryRecallService(session)
)
merged: dict[str, tuple[float, RecalledMemory]] = {}
reasons: list[str] = []
for customer_id in scope:
result = await service.recall(customer_id)
reasons.extend(result.degraded_reasons)
for item in result.items:
previous = merged.get(item.memory_uuid)
if previous is None or item.confidence > previous[0]:
merged[item.memory_uuid] = (
item.confidence,
RecalledMemory(
memory_uuid=item.memory_uuid,
customer_id=str(customer_id),
content=item.content,
),
)
# 排序:置信度降序,再按 (客户号, uuid) 兜底 —— 置信度相同的两条不能因为
# 字典/数据库返回顺序不同而每次换位,同一输入必须给出同一结果。
ordered = sorted(
merged.values(),
key=lambda pair: (-pair[0], int(pair[1].customer_id), pair[1].memory_uuid),
)
items = tuple(memory for _, memory in ordered[:RECALL_ITEM_LIMIT])
# 召回结果此前完全没有出口:即使召回到内容也无人消费,运维无法判断
# "库里没有记忆"与"召回了但被丢弃"。这里把条数、来源与摘要打出来。
logger.info(
"memory recall scope=%s customers=%s count=%s degraded=%s reasons=%s items=%s",
"self" if is_customer_identity(context) else "assigned",
list(scope), len(items), bool(reasons), ",".join(sorted(set(reasons))) or "-",
[f"c{memory.customer_id}:{memory.content[:40]}" for memory in items[:5]],
)
if reasons:
logger.warning("memory recall degraded customers=%s reasons=%s",
list(scope), ",".join(sorted(set(reasons))))
if not items:
# 范围非空却一条都没有:这才是"库里确实没有该客户的记忆",
# 与"没有授权范围"必须区分开(后者见 `_log_empty_scope`)。
logger.info(
"memory recall empty: 客户 %s 在 memory_unit 里没有 active 记忆",
list(scope),
)
return items
@staticmethod
def _log_empty_scope(context: RequestContext) -> None:
"""可读范围为空时**点名原因**,避免把"没有授权"误判成"记忆坏了"。"""
if is_customer_identity(context):
logger.info(
"memory recall skipped: 客户身份但 user_id 不可解析为客户号 user_id=%r",
context.user_id,
)
return
# 员工身份范围为空有两种原因,必须分开讲清楚,否则运维会在错误的表上找问题。
if REQUIRED_EMPLOYEE_PERMISSION not in context.permissions:
logger.warning(
"memory recall skipped: 员工身份 roles=%s 缺少 %s 能力 ⇒ 无可读客户"
"(失败关闭)。这不是记忆故障;要读他人客户记忆需先具备该权限码",
list(context.roles), REQUIRED_EMPLOYEE_PERMISSION,
)
return
logger.warning(
"memory recall skipped: 员工身份 roles=%s 在 sys_customer_assignment 里"
"没有生效的归属客户 ⇒ 无可读客户(失败关闭)。这不是记忆故障;"
"要读指定客户请先维护分配关系(或走 query_customer_profile 工具)",
list(context.roles),
)
async def review(
self, result: AgentResult, context: RequestContext, config: ResolvedAgentConfig,
memories: tuple[RecalledMemory, ...],
*,
agent_type: str = "",
) -> AgentResult:
# 裁定 1:读库是异步的,由本方法(异步层)做;`review_output` 保持同步、不接触数据库,
# 只把拿到的文本追加到输出末尾。
#
# `agent_type` 由 `BaseAgent._execute_governed()` 从**定义**传入(不是查库):它决定
# 门禁 F5 是否适用(见 `CUSTOMER_FACING_AGENT_TYPES`)。给了默认值是为了让既有的
# 治理替身按旧签名调用时仍能工作——那种情况下按"未声明"处理,不注入话术。
disclaimer = await _load_template_text(DISCLAIMER_TEMPLATE_CODE)
# `DEC-W27-10`:轻量提示与完整免责话术**一起取**。取两次而不是按出口码只取一份,
# 是因为"取哪一份"的决定要留在 `review_output` 里做 —— 那里是门禁的**唯一**判定点,
# 分头判定必然漂移。
light_notice = await _load_template_text(LIGHT_NOTICE_TEMPLATE_CODE)
return review_output(result, context, config, memories, disclaimer=disclaimer,
light_notice=light_notice, agent_type=agent_type)
async def _load_template_text(template_code: str) -> str | None:
"""按 `template_code` 读取生效话术;查不到或取数失败返回 None,由调用方退回代码兜底。
取数条件与 `resolve()` 的规则查询同口径(`status='active'` 且审核字段非空),
避免"库里改了状态但代码照用"的偏差。异常一律吞掉并记日志:免责声明必须 100% 注入,
数据库故障时"少一句话术"比"整条回复失败"更糟,退回 `FALLBACK_DISCLAIMER` 即可满足门禁。
"""
try:
async with SessionFactory() as session:
content: str | None = await session.scalar(
text("""
SELECT content_text FROM agent_reply_template
WHERE template_code = :code AND status='active'
AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL
ORDER BY version DESC LIMIT 1
"""),
{"code": template_code},
)
except Exception:
logger.warning("免责声明话术查询失败,退回代码兜底 template_code=%s",
template_code, exc_info=True)
return None
if content is None or not str(content).strip():
logger.warning("免责声明话术不存在或为空,退回代码兜底 template_code=%s", template_code)
return None
return str(content)
def review_output(
result: AgentResult, context: RequestContext, config: ResolvedAgentConfig,
memories: tuple[RecalledMemory, ...],
*,
disclaimer: str | None = None, light_notice: str | None = None,
agent_type: str = "",
) -> AgentResult:
"""同步治理:引用校验 → 负面词判定/替换 → 脱敏 → 追加固定免责声明。
`disclaimer` 由异步层(`PlatformGovernance.review`)注入库内话术;本函数是同步的、
不接触数据库。默认 `None` 表示"调用方未提供",此时用 `FALLBACK_DISCLAIMER` 兜底——
因此既有调用点不必改签名也能拿到固定话术(门禁 F5 的 100% 覆盖)。
`agent_type` 用于判定**是否面向客户**:门禁 F5 要求的是"客服答复 100% 附固定话术",
而内部 Agent(风控预警、投顾分析)的输出是结构化摘要,追加话术会破坏它的字段契约
(`test_risk_agent_contract` 实测因此失败)。空串按"调用方未声明"处理,**保守照旧追加**,
避免漏加;只有明确列入内部清单的 Agent 才跳过。
"""
content = result.result
# 门禁 F5 的适用范围:**已确认面向客户**的 Agent。判据来自 `agent_type`,它由
# `PlatformGovernance.review()` 从发布版本反查(`tests` 的治理替身不连库 → 空串 →
# 不注入,与它们的断言一致)。空串/未知一律**不注入**而不是注入,理由见
# `CUSTOMER_FACING_AGENT_TYPES` 的说明。
customer_facing = agent_type in CUSTOMER_FACING_AGENT_TYPES
issued_tools = {f"{context.trace_id}:{record.tool_name}" for record in content.tool_calls
if record.status == "succeeded"}
# "本轮已知记忆"必须用**与召回同一套范围判定**(`app/core/memory_scope.py`)。
# 此前只在 `customer_id == context.user_id` 时才算已知,于是员工身份下
# `known` 恒空:即使 `recall()` 按归属召回了客户记忆,风控 Agent 一引用
# 就被判成"引用未来自本次已授权召回结果",整条运行直接失败。
scope = customer_memory_scope(context)
known = {memory.memory_uuid for memory in memories
if memory_customer_in_scope(memory.customer_id, scope)}
for reference in content.source_references:
valid = ((reference.source_type == "memory" and reference.source_id in known)
or (reference.source_type == "tool" and reference.source_id in issued_tools))
if not valid:
raise ForbiddenAgentError("引用未来自本次已授权召回结果")
output = content.text
hard_patterns = ("保证收益", "稳赚不赔", "保本保收益", "已为您下单", "已替您交易")
# 语境豁免(详见 `app/core/compliance_context.py`):零容忍规则是朴素子串匹配,
# 无法区分"作出承诺"与"禁止承诺/谈论该表述"。政策类问答会被误伤 —— 实测
# 「基金销售有哪些合规要求」的答案因含"严禁承诺保本保收益""禁止使用『保证收益』"
# 被整条替换,答复从 1097 字掉到 83 字,合规问答反而答不出来。
# 判定:命中处附近出现否定/禁止/引用线索才豁免,否则照旧拦(宁可误拦)。
blocked = _first_violation(output, hard_patterns) is not None
for kind, pattern in config.negative_rules:
if kind == "exact":
blocked |= output == pattern
else:
blocked |= _first_violation(output, (pattern,)) is not None
if blocked:
content = content.model_copy(update={
"text": "该内容需要人工核实。基金投资存在风险,本系统不代客交易。",
"source_references": (), "transfer_required": True,
"transfer_reason": "compliance_review_required",
})
else:
# Apply to text and citation titles, not only to the displayed answer.
def redact(value: str) -> str:
def _mask_mobile(match: re.Match[str]) -> str:
# 平台对外的客服热线是**公开业务号码**,不是客户个人手机号:
# 它必须原样出现在面向客户的回复里(spec B §5 要求固定联系方式),
# 否则 `TPL_TRANSFER_HUMAN` / 安全路由的 P2 话术会被脱敏成
# "[手机号已脱敏]",客户拿不到联系方式(实测过)。
if match.group(0) == CUSTOMER_SERVICE_HOTLINE:
return match.group(0)
return "[手机号已脱敏]"
value = re.sub(r"(?<!\d)1[3-9]\d{9}(?!\d)", _mask_mobile, value)
def _mask_number(match: re.Match[str]) -> str:
# 公开业务标识(统一社会信用代码等)**原样保留**:它不是客户 PII,
# 打码会把公开事实变成客户看不懂的乱码(2026-09-19 `W7` HTTP 实测)。
if match.group(0) in PUBLIC_BUSINESS_IDENTIFIERS:
return match.group(0)
return "[敏感号码已脱敏]"
return _PII_NUMBER.sub(_mask_number, value)
content = content.model_copy(update={
"text": redact(output),
"source_references": tuple(ref.model_copy(update={
"title": redact(ref.title) if ref.title else None,
}) for ref in content.source_references),
})
# 门禁 F5:面向客户输出 100% 附固定话术,所以**拦截分支与正常分支都要走到这里**——
# 被负面词替换掉的回复同样是面向客户的输出。位置固定在最后:文案不参与上面的负面词
# 判定,也不会被替换逻辑吃掉。
# 幂等:只认**我们自己追加过的那个形状**("\n\n" + 话术),而不是"文本以话术结尾"。
# 比 `endswith(disclaimer_text)` 更紧的原因:后者在**退化文案**下仍会漏加——管理员把文案
# 发布成"。"、答案恰好是"您好。"时,`endswith("。")` 为真 → 不追加,客户一条话术都拿不到;
# 认形状后这种文本必然被追加。失败形态因此从"合规漏加"变成"极端情况下多追加一次",
# 这正是门禁 F5(面向客户输出 100% 附固定话术)要的方向。正文中间出现同样文字也不会被
# 误判成"已追加"——只有**末尾这个形状**才算。
# 空串不算话术:显式传空一律退回代码兜底,避免"传了但传空"成为绕过强制注入的后门。
# ---- 门禁 `F5` 分档(`DEC-W27-10`)----
# 判据**只看出口码**:非业务档(澄清 / 闲聊 / 引导登录 / 联系方式)挂轻量提示,
# 其余(含 `E5b` 空答、`E5c` 转人工、`E6` 行情、`P0`—`P2` 安全出口)挂完整投资免责话术。
# 为什么不能用"文本里有没有数字"这类间接判据:`E5b` 空答的正文里一个数字都没有,
# 但它恰恰是"业务上答不上来",投资风险提示一个字都不能少 —— 用间接判据会让它静默掉档。
# `NON_BUSINESS_EXIT_CODES` 取**白名单式**:新增出口若忘记登记,默认落到业务档(多说一句),
# 失败方向与 `INV-5`(收敛)一致。
if content.exit_code in NON_BUSINESS_EXIT_CODES:
disclaimer_text = (light_notice or "").strip() or FALLBACK_LIGHT_NOTICE
else:
disclaimer_text = (disclaimer or "").strip() or FALLBACK_DISCLAIMER
# 追加形状只在这里定义一次:判据与追加共用同一份,避免两处各写一个分隔符而漂移。
appended_shape = f"\n\n{disclaimer_text}"
if customer_facing and not content.text.endswith(appended_shape):
content = content.model_copy(update={"text": f"{content.text}{appended_shape}"})
return result.model_copy(update={"result": content})