288 lines
14 KiB
Python
288 lines
14 KiB
Python
"""免责声明强制注入与 `applicable_agents` 门控的单元测试。
|
||
|
||
两条需求来源:
|
||
1. 上线门禁 F5「免责声明强制注入,面向客户输出 **100%** 附固定话术」。所以**正常输出**与
|
||
**被负面词拦截后替换的输出**两个分支都必须带上免责声明——被拦截的回复同样是面向客户的输出。
|
||
2. Task 1 审查发现的 fail-open 缺陷:`applicable_agents` 为 `[]`/`NULL` 时旧的
|
||
`if agents and ...` 判据不执行过滤,规则**溢出到所有 Agent**,与项目"失败关闭"原则相反。
|
||
|
||
本文件不连数据库:`PlatformGovernance` 的取数会话用替身替换(与
|
||
`tests/unit/service/test_memory_extraction_service.py` 同一手法),
|
||
真实 MySQL 上的门控行为由 `tests/integration/test_compliance_seed_mysql.py` 固定。
|
||
"""
|
||
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.core.contracts import (
|
||
AgentDefinition,
|
||
AgentResult,
|
||
CoreResult,
|
||
RecalledMemory,
|
||
RequestContext,
|
||
ResolvedAgentConfig,
|
||
)
|
||
from app.service.agent import governance
|
||
from app.service.agent.governance import FALLBACK_DISCLAIMER, review_output
|
||
|
||
CONTEXT = RequestContext(user_id="9001", trace_id="t", roles=("customer",))
|
||
CONFIG = ResolvedAgentConfig(config_version="v", prompt_version="p", model_endpoint="m")
|
||
|
||
#: 固定话术的插入位置:`\n\n` 分隔,附在正文末尾。
|
||
DISCLAIMER = "\n\n" + FALLBACK_DISCLAIMER
|
||
|
||
|
||
def _result(text: str) -> AgentResult:
|
||
return AgentResult(run_id="r", result=CoreResult(text=text))
|
||
|
||
|
||
def test_disclaimer_is_appended_to_normal_output() -> None:
|
||
result = review_output(_result("基金申购后 T+1 确认份额。"), CONTEXT, CONFIG, ())
|
||
assert FALLBACK_DISCLAIMER in result.result.text
|
||
assert result.result.text.startswith("基金申购后 T+1 确认份额。")
|
||
assert result.result.text.endswith(DISCLAIMER)
|
||
|
||
|
||
def test_disclaimer_is_not_duplicated() -> None:
|
||
"""幂等:同一结果被治理两次(重放/重试)不得把免责声明叠成两条。"""
|
||
once = review_output(_result("答案。"), CONTEXT, CONFIG, ())
|
||
twice = review_output(once, CONTEXT, CONFIG, ())
|
||
assert twice.result.text.count(FALLBACK_DISCLAIMER) == 1
|
||
assert twice.result.text == once.result.text
|
||
|
||
|
||
def test_disclaimer_inside_the_answer_is_still_appended_at_the_end() -> None:
|
||
"""幂等判断不认"文本里出现过话术",只认"末尾是不是我们自己追加的那个形状"。
|
||
|
||
子串包含判断下,正文中间出现同样的文字就不会追加(实测过),于是"客户拿到的话术"取决于
|
||
正文里恰好有没有这段话——门禁 F5 要的是 100% 覆盖,正文里出现过不算"已附话术",
|
||
必须落在**末尾**。
|
||
"""
|
||
middle = f"前半句。{FALLBACK_DISCLAIMER}后半句。"
|
||
result = review_output(_result(middle), CONTEXT, CONFIG, ())
|
||
assert result.result.text == middle + DISCLAIMER
|
||
assert result.result.text.endswith(DISCLAIMER)
|
||
assert result.result.text.count(FALLBACK_DISCLAIMER) == 2
|
||
|
||
|
||
def test_answer_already_carrying_the_appended_shape_is_not_duplicated() -> None:
|
||
"""幂等的另一半语义:正文已经以"我们追加过的形状"结尾时,不重复追加(话术只出现一次)。"""
|
||
result = review_output(_result("答案。" + DISCLAIMER), CONTEXT, CONFIG, ())
|
||
assert result.result.text.count(FALLBACK_DISCLAIMER) == 1
|
||
assert result.result.text.endswith(DISCLAIMER)
|
||
|
||
|
||
def test_degenerate_published_wording_is_still_appended() -> None:
|
||
"""修复轮 2 的核心动机:**退化文案下也必须追加**。
|
||
|
||
管理员若把免责声明发布成"。"(退化数据,`agent_reply_template.content_text` 没有任何校验),
|
||
答案恰好是"您好。"时,`endswith(disclaimer_text)` 会判定"已经以话术结尾" → **不追加**,
|
||
客户一条话术都拿不到。只认我们自己追加过的形状("\\n\\n" + 文案)后,这种文本必然被追加。
|
||
代价是极端情况下可能多追加一次——方向正确:门禁 F5 要的是 100% 附话术,宁可多一次不可漏。
|
||
"""
|
||
result = review_output(_result("您好。"), CONTEXT, CONFIG, (), disclaimer="。")
|
||
assert result.result.text == "您好。\n\n。"
|
||
assert result.result.text.endswith("\n\n。")
|
||
|
||
|
||
def test_degenerate_published_wording_is_appended_only_once() -> None:
|
||
"""退化文案下幂等仍成立:第二次治理不再追加(认的正是第一次追加出来的那个形状)。"""
|
||
once = review_output(_result("您好。"), CONTEXT, CONFIG, (), disclaimer="。")
|
||
twice = review_output(once, CONTEXT, CONFIG, (), disclaimer="。")
|
||
assert twice.result.text == once.result.text
|
||
assert twice.result.text == "您好。\n\n。"
|
||
|
||
|
||
def test_fallback_disclaimer_survives_the_hardcoded_hard_patterns() -> None:
|
||
"""代码常量 `FALLBACK_DISCLAIMER` 的行为守卫:库侧 6 条话术有守卫,它此前没有。
|
||
|
||
若该常量命中硬编码 `hard_patterns`,治理层会先把回复判定为"需人工核实"并替换成安全话术,
|
||
也就是**平台自己的免责声明被平台自己的规则拦下**(Task 1 修过一次的"自绊")。这条断言是哨兵。
|
||
|
||
⚠️ 为什么**不能**按"输入正文 = 兜底文案 + `assert FALLBACK_DISCLAIMER in 输出`"来写:
|
||
那是恒真的空转——输入本身就是该文案,且命中硬编码词时文案会在替换**之后**被追加回末尾,
|
||
两种情况下 `in` 都成立(已实测:把常量改成含「保证收益」后那条断言仍为真)。
|
||
真正会失败的是下面两条:①没被判定为拦截;②正文原样保留(未被安全话术替换)。
|
||
入参刻意用"正文 + 兜底文案"而不是只有文案,避免"输入即断言对象"造成的自证。
|
||
"""
|
||
answer = "基金申购后 T+1 确认份额。" + FALLBACK_DISCLAIMER
|
||
result = review_output(_result(answer), CONTEXT, CONFIG, ())
|
||
assert result.result.transfer_required is False, (
|
||
"兜底免责声明被平台自己的硬编码 hard_patterns 拦下了(自绊)")
|
||
# 正文原样保留(未被替换成安全话术),并按规范形状补一次"\n\n"分隔的追加:
|
||
# 裸文案结尾不算"已追加",这正是修复轮 2 收紧后的语义。
|
||
assert result.result.text == answer + DISCLAIMER, "正文被替换成了安全话术,说明话术命中了规则"
|
||
assert result.result.text.endswith(DISCLAIMER)
|
||
|
||
|
||
def test_disclaimer_is_appended_after_negative_word_replacement() -> None:
|
||
"""裁定 2:命中负面词被替换成安全话术的分支同样要追加免责声明(F5 要求 100%)。
|
||
|
||
顺序固定为「校验 → 负面词替换 → 脱敏 → 追加免责声明」:免责声明必须落在**替换后**的
|
||
文本之后,否则拦截分支就没有话术。
|
||
"""
|
||
config = CONFIG.model_copy(update={"negative_rules": (("contains", "测试禁止词"),)})
|
||
result = review_output(_result("这只基金测试禁止词"), CONTEXT, config, ())
|
||
assert result.result.transfer_required is True
|
||
assert "测试禁止词" not in result.result.text
|
||
assert result.result.text.startswith("该内容需要人工核实")
|
||
assert result.result.text.endswith(DISCLAIMER)
|
||
|
||
|
||
def test_disclaimer_is_appended_after_hard_pattern_replacement() -> None:
|
||
"""硬编码 `hard_patterns`(不依赖库配置)走的是同一分支,同样必须带话术。"""
|
||
result = review_output(_result("这只产品保证收益"), CONTEXT, CONFIG, ())
|
||
assert "保证收益" not in result.result.text
|
||
assert result.result.text.endswith(DISCLAIMER)
|
||
|
||
|
||
def test_explicit_disclaimer_argument_wins_over_code_fallback() -> None:
|
||
"""异步层读到的库内话术优先于代码兜底。"""
|
||
result = review_output(_result("答案。"), CONTEXT, CONFIG, (),
|
||
disclaimer="库内免责声明。")
|
||
assert result.result.text == "答案。\n\n库内免责声明。"
|
||
assert FALLBACK_DISCLAIMER not in result.result.text
|
||
|
||
|
||
def test_blank_disclaimer_argument_still_gets_the_fixed_wording() -> None:
|
||
"""显式传入空串不得成为"绕过强制注入"的后门:空串一律退回代码兜底。
|
||
|
||
门禁 F5 要求 100% 覆盖,"传了但传空"不算注入话术。
|
||
"""
|
||
result = review_output(_result("答案。"), CONTEXT, CONFIG, (), disclaimer=" ")
|
||
assert FALLBACK_DISCLAIMER in result.result.text
|
||
|
||
|
||
def test_existing_callers_without_disclaimer_still_get_wording() -> None:
|
||
"""既有 4 处调用点按旧签名调用(不传 disclaimer)必须照旧拿到固定话术。"""
|
||
result = review_output(_result("答案。"), CONTEXT, CONFIG, ())
|
||
assert result.result.text == "答案。" + DISCLAIMER
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# `applicable_agents` 门控:fail-closed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _FakeRows:
|
||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||
self._rows = rows
|
||
|
||
def mappings(self) -> "_FakeRows":
|
||
return self
|
||
|
||
def all(self) -> list[dict[str, Any]]:
|
||
return self._rows
|
||
|
||
|
||
class _FakeSession:
|
||
"""只实现 `resolve()` 用到的那两个调用:scalar 取 release、execute 取规则。"""
|
||
|
||
def __init__(self, *, rows: list[dict[str, Any]] = (), template: str | None = None) -> None:
|
||
self._rows = rows
|
||
self._template = template
|
||
|
||
async def scalar(self, _statement: Any, _params: Any = None) -> Any:
|
||
# 返回话术文本(话术查询)或 None(没有 active release,工具分支整段跳过)。
|
||
return self._template
|
||
|
||
async def execute(self, _statement: Any) -> _FakeRows:
|
||
return _FakeRows(self._rows)
|
||
|
||
async def __aenter__(self) -> "_FakeSession":
|
||
return self
|
||
|
||
async def __aexit__(self, *args: object) -> None:
|
||
return None
|
||
|
||
|
||
def _rows(applicable_agents: Any) -> list[dict[str, Any]]:
|
||
"""一行规则:MySQL JSON 列经驱动回来是字符串,`NULL` 回来是 None(实测口径)。"""
|
||
return [{"match_type": "contains", "word_pattern": "测试禁止词",
|
||
"applicable_agents": applicable_agents}]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("applicable_agents", "customer_service_rules", "advisor_rules"),
|
||
[
|
||
# (客服加载数, 投顾加载数):与派发说明里的真值表逐格对应,第 2、3 行是本次修复的缺陷。
|
||
('["customer_service"]', 1, 0), # 正常门控:只对声明的 Agent 生效
|
||
([], 0, 0), # 空数组:不适用任何 Agent(fail-closed;原来溢出成 1/1)
|
||
('[]', 0, 0), # 同上,但按驱动真实口径写成 JSON 文本(实测回 str)
|
||
(None, 0, 0), # NULL:同上
|
||
('["advisor"]', 0, 1), # 指向别的 Agent:客服一条都拿不到(原来的静默失效)
|
||
],
|
||
)
|
||
async def test_applicable_agents_gating_fails_closed(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
applicable_agents: Any,
|
||
customer_service_rules: int,
|
||
advisor_rules: int,
|
||
) -> None:
|
||
"""三种坏取值都不得让规则溢出:空数组/NULL 是"不适用任何 Agent",不是"适用全部"。"""
|
||
monkeypatch.setattr(governance, "SessionFactory",
|
||
lambda: _FakeSession(rows=_rows(applicable_agents)))
|
||
service = governance.PlatformGovernance()
|
||
customer = await service.resolve(
|
||
AgentDefinition(agent_type="customer_service", version="1"), CONTEXT)
|
||
advisor = await service.resolve(AgentDefinition(agent_type="advisor", version="1"), CONTEXT)
|
||
assert len(customer.negative_rules) == customer_service_rules
|
||
assert len(advisor.negative_rules) == advisor_rules
|
||
|
||
|
||
async def test_empty_applicable_agents_rules_are_not_loaded_at_all(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""空取值的反证断言:不是"条数变少",而是该规则对**任何** Agent 都不存在。"""
|
||
monkeypatch.setattr(governance, "SessionFactory",
|
||
lambda: _FakeSession(rows=_rows([])))
|
||
service = governance.PlatformGovernance()
|
||
for agent_type in ("customer_service", "advisor", "fund_query_demo"):
|
||
resolved = await service.resolve(
|
||
AgentDefinition(agent_type=agent_type, version="1"), CONTEXT)
|
||
assert resolved.negative_rules == ()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 同步/异步分离(裁定 1):读库在异步层,追加在同步层
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_review_reads_template_from_database(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(governance, "SessionFactory",
|
||
lambda: _FakeSession(template="库内固定免责声明。"))
|
||
reviewed = await governance.PlatformGovernance().review(
|
||
_result("答案。"), CONTEXT, CONFIG, ())
|
||
assert reviewed.result.text == "答案。\n\n库内固定免责声明。"
|
||
|
||
|
||
@pytest.mark.parametrize("template", [None, "", " "])
|
||
async def test_review_falls_back_to_code_constant_when_template_missing(
|
||
monkeypatch: pytest.MonkeyPatch, template: str | None,
|
||
) -> None:
|
||
"""查不到/查到空白话术时退回代码常量:门禁 F5 不依赖数据库可用性。"""
|
||
monkeypatch.setattr(governance, "SessionFactory", lambda: _FakeSession(template=template))
|
||
reviewed = await governance.PlatformGovernance().review(
|
||
_result("答案。"), CONTEXT, CONFIG, ())
|
||
assert reviewed.result.text == "答案。" + DISCLAIMER
|
||
|
||
|
||
async def test_review_survives_database_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""取数抛异常不得让回复失败,也不得让回复**没有**免责声明。"""
|
||
|
||
def explode() -> Any:
|
||
raise RuntimeError("database unavailable")
|
||
|
||
monkeypatch.setattr(governance, "SessionFactory", explode)
|
||
reviewed = await governance.PlatformGovernance().review(
|
||
_result("答案。"), CONTEXT, CONFIG, ())
|
||
assert reviewed.result.text == "答案。" + DISCLAIMER
|
||
|
||
|
||
def test_recalled_memories_do_not_affect_disclaimer() -> None:
|
||
"""带记忆的正常路径同样注入(确认追加在引用校验之后、不受 memories 影响)。"""
|
||
memories = (RecalledMemory(memory_uuid="m1", customer_id="9001", content="偏好低风险"),)
|
||
reviewed = review_output(_result("答案。"), CONTEXT, CONFIG, memories)
|
||
assert reviewed.result.text == "答案。" + DISCLAIMER
|