Files
group_fqcd_jr/tests/unit/service/test_governance_disclaimer.py
T
qyqy 57c4add7d8 merge: 客服Agent+RAG+画像 与 架构师最新 qyqy_develop 合并
- customer_service.py 以架构师实现为骨架(三档置信/适当性/会话记忆/话题矩阵),嫁接本人画像出口
- 知识检索契约合并两条链路:架构师 search_knowledge(KnowledgeSearchInput) + 本线
  query_knowledge 链路所需常量(ALLOWED_CONSTANTS/VECTOR_DIM/intent_for_qa_id)
- bootstrap 保留架构师 6 工具/3 Agent,补回 query_customer_profile 与 get_milvus_knowledge_writer
- model_gateway 能力映射修正 intent_classification→chat,保留空集回退兜底
- governance 免责声明限定面向客户 Agent(agent_type 由定义透传),风控结构化输出不再被追加
- 修 JWT 密钥路径(config/jwt/dev)、文档 21 号撞号→25
- 测试基线 934 passed / 1 failed(既有空集缺陷)
2026-09-11 15:06:44 +08:00

371 lines
18 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.
"""免责声明强制注入与 `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 _review(
text_or_result: Any, config: ResolvedAgentConfig | None = None,
memories: tuple[RecalledMemory, ...] = (), **kwargs: Any,
) -> AgentResult:
"""面向客户的治理调用(带 `agent_type`)。
门禁 F5 的适用范围是**已确认面向客户**的 Agent(`agent_type` 由 `PlatformGovernance`
从发布版本反查)。本文件的断言全部针对该场景,所以统一在这里显式声明 agent 类型,
避免每处都写一遍;`test_internal_agent_gets_no_disclaimer` 是对照组。
"""
result = text_or_result if isinstance(text_or_result, AgentResult) \
else _result(text_or_result)
return review_output(result, CONTEXT, config or CONFIG, memories,
agent_type="customer_service", **kwargs)
def test_disclaimer_is_appended_to_normal_output() -> None:
result = _review("基金申购后 T+1 确认份额。")
assert FALLBACK_DISCLAIMER in result.result.text
assert result.result.text.startswith("基金申购后 T+1 确认份额。")
assert result.result.text.endswith(DISCLAIMER)
def test_internal_agent_gets_no_disclaimer() -> None:
"""对照组:内部 Agent(风控分析)的结构化输出**不得**被追加面向客户的免责声明。
判据是"输出形态":风控输出是字段化摘要(预警编号/级别/建议动作),追加一句面向
投资者的免责声明会破坏其字段契约,下游解析与 `tests/contract/test_risk_agent_contract.py`
都会因此失败(合并时实测复现过)。`agent_type` 为空(未声明)时同样不注入。
"""
for agent_type in ("risk", ""):
result = review_output(_result("工单标题:测试工单"), CONTEXT, CONFIG, (),
agent_type=agent_type)
assert result.result.text == "工单标题:测试工单", agent_type
def test_disclaimer_is_not_duplicated() -> None:
"""幂等:同一结果被治理两次(重放/重试)不得把免责声明叠成两条。"""
once = _review("答案。")
twice = _review(once)
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(middle)
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("答案。" + DISCLAIMER)
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("您好。", 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("您好。", disclaimer="。")
twice = _review(once, 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(answer)
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("这只基金测试禁止词", config=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("这只产品保证收益")
assert "保证收益" not in result.result.text
assert result.result.text.endswith(DISCLAIMER)
def test_explicit_disclaimer_argument_wins_over_code_fallback() -> None:
"""异步层读到的库内话术优先于代码兜底。"""
result = _review("答案。", 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("答案。", disclaimer=" ")
assert FALLBACK_DISCLAIMER in result.result.text
def test_existing_callers_without_disclaimer_still_get_wording() -> None:
"""既有调用点按旧签名调用(不传 disclaimer)必须照旧拿到固定话术。"""
result = _review("答案。")
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` 取发布版本/话术,`execute` 取规则。
这里**不**按 SQL 文本分派:`resolve()` 自己那条 `scalar` 就是取 release,改动它会
让"是否命中发布配置"的语义漂移,把门控测试变成假绿。`review()` 需要区分两次查询,
因此另有 `_FakeReviewSession`。
"""
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
class _FakeReviewSession(_FakeSession):
"""`PlatformGovernance.review()` 用到的替身:`scalar` 只回话术文本。
`agent_type` **不再**从库里反查——它由 `BaseAgent` 从 Agent 定义直接传给 `review()`
(`definition.agent_type` 是定义的一部分,没有理由为它多付一次查询)。
因此替身只需回答"话术是什么"。
"""
def __init__(self, *, template: str | None = None) -> None:
super().__init__(template=template)
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):读库在异步层,追加在同步层
# ---------------------------------------------------------------------------
CONFIG_WITH_RELEASE = CONFIG.model_copy(update={"release_id": 186})
async def test_review_reads_template_from_database(monkeypatch: pytest.MonkeyPatch) -> None:
"""走 `PlatformGovernance.review()`:话术取库,`agent_type` 由调用方透传。"""
monkeypatch.setattr(governance, "SessionFactory",
lambda: _FakeReviewSession(template="库内固定免责声明。"))
reviewed = await governance.PlatformGovernance().review(
_result("答案。"), CONTEXT, CONFIG_WITH_RELEASE, (), agent_type="customer_service")
assert reviewed.result.text == "答案。\n\n库内固定免责声明。"
async def test_review_skips_disclaimer_for_internal_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""内部 Agent(风控)不追加话术:它的输出是字段化摘要,加话术会破坏字段契约。
`agent_type` 由 `BaseAgent` 从定义传入,与发布配置无关(没有发布版本也要能判定)。
"""
monkeypatch.setattr(governance, "SessionFactory",
lambda: _FakeReviewSession(template="库内固定免责声明。"))
reviewed = await governance.PlatformGovernance().review(
_result("工单标题:测试工单"), CONTEXT, CONFIG_WITH_RELEASE, (), agent_type="risk")
assert reviewed.result.text == "工单标题:测试工单"
async def test_review_without_agent_type_declaration_does_not_inject(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""治理替身按旧签名调用(未传 `agent_type`)时不注入,而不是猜成面向客户。
这条是给**测试替身**定的口径:它们刻意不连库、也不知道 agent 类型,若默认注入,
每个替身测试都会被塞进一句话术,等于用测试噪声换一个假的安全感。生产路径恒有类型
(`BaseAgent` 从定义传),所以 F5 的覆盖不受影响。
"""
monkeypatch.setattr(governance, "SessionFactory",
lambda: _FakeReviewSession(template="库内固定免责声明。"))
reviewed = await governance.PlatformGovernance().review(
_result("答案。"), CONTEXT, CONFIG_WITH_RELEASE, ())
assert reviewed.result.text == "答案。"
@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: _FakeReviewSession(template=template))
reviewed = await governance.PlatformGovernance().review(
_result("答案。"), CONTEXT, CONFIG_WITH_RELEASE, (), agent_type="customer_service")
assert reviewed.result.text == "答案。" + DISCLAIMER
async def test_review_survives_database_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""取数抛异常不得让回复失败,也不得让回复**没有**免责声明。
场景取"会话可建、查询必炸"(比"建会话就炸"更贴近真实的连接池故障)。
"""
class _ExplodingTemplateSession(_FakeSession):
async def scalar(self, _statement: Any, _params: Any = None) -> Any:
raise RuntimeError("database unavailable")
monkeypatch.setattr(governance, "SessionFactory", lambda: _ExplodingTemplateSession())
reviewed = await governance.PlatformGovernance().review(
_result("答案。"), CONTEXT, CONFIG_WITH_RELEASE, (), agent_type="customer_service")
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("答案。", memories=memories)
assert reviewed.result.text == "答案。" + DISCLAIMER