- 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(既有空集缺陷)
371 lines
18 KiB
Python
371 lines
18 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 _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
|