Files
group_fqcd_jr/tests/unit/service/test_agent_governance.py
T
张胜宇 9675df8453 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)
- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 21:26:30 +08:00

349 lines
15 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 pytest
from app.core.contracts import (
AgentDefinition,
AgentRequest,
AgentResult,
CoreResult,
RecalledMemory,
RequestContext,
ResolvedAgentConfig,
SourceReference,
)
from app.core.errors import ForbiddenAgentError, RecoverableAgentError
from app.core.memory_scope import REQUIRED_EMPLOYEE_PERMISSION
from app.service.agent import governance as governance_module
from app.service.agent.base import BaseAgent
from app.service.agent.factory import AgentFactory
from app.service.agent.governance import PlatformGovernance, review_output
@pytest.mark.parametrize("name", ["recall_memory", "resolve_config", "check_compliance",
"bind_governance", "_execute_governed", "validate_input"])
def test_all_governance_hooks_protected(name):
with pytest.raises(TypeError):
type("Bypass", (BaseAgent,), {name: lambda *args: None})
@pytest.mark.asyncio
async def test_visitor_does_not_recall_customer_memory() -> None:
"""访客不能以匿名主体标识读取任何客户记忆。"""
class Demo(BaseAgent):
async def handle(self, request, context):
return CoreResult(text="unused")
class FailingGovernance:
async def recall(self, context):
raise AssertionError("visitor memory recall is forbidden")
definition = AgentDefinition(
agent_type="demo", version="1", allowed_roles=("visitor",), allowed_portals=("api",)
)
agent = Demo(definition)
agent.bind_governance(FailingGovernance())
request = AgentRequest(
agent_type="demo", message="公开问题", session_id="visitor-session",
idempotency_key="visitor-memory-request-0001",
)
context = RequestContext(
user_id="visitor-id", trace_id="visitor-trace", roles=("visitor",),
permissions=("agent:run",), data_scope="public",
)
await agent.recall_memory(request, context)
assert agent.memories == ()
# 原先这里还有一条 `test_authenticated_customer_service_does_not_recall_customer_memory`:
# 客服 Agent 即便面对已登录用户,也不得触发通用长期/画像记忆读取。该用例的主体
# (`CustomerServiceAgent`)已随客服模块移除,**重建客服 Agent 时须把该判据与用例一并带回来**。
async def test_resolve_recall_handle_review_order_and_snapshot(governance):
calls = []
config = ResolvedAgentConfig(config_version="released", prompt_version="p", model_endpoint="m")
memory = RecalledMemory(memory_uuid="m1", customer_id="1", content="偏好低风险")
class Governance:
async def resolve(self, definition, context):
calls.append("resolve")
return config
async def recall(self, context):
calls.append("recall")
return (memory,)
async def review(self, result, context, resolved, memories, *, agent_type: str = ""):
calls.append("review")
assert resolved is config
assert memories == (memory,)
return review_output(result, context, resolved, memories)
class Demo(BaseAgent):
async def handle(self, request, context):
calls.append("handle")
assert self.memories == (memory,)
self.config = None
self.memories = ()
self._governance = governance
return CoreResult(text="保证收益")
definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("customer",),
allowed_portals=("api",))
factory = AgentFactory(Governance())
factory.register(definition, lambda _: Demo(definition))
context = RequestContext(user_id="1", trace_id="t", roles=("customer",),
permissions=("agent:run",))
agent = factory.create("demo", context)
events = [event async for event in agent.execute(
AgentRequest(agent_type="demo", session_id="s", message="test",
idempotency_key="1234567890123456"), context, "r")]
assert calls == ["resolve", "recall", "handle", "review"]
assert "保证收益" not in events[-1].payload["result"]["result"]["text"]
def test_unissued_reference_rejected_and_sensitive_numbers_redacted():
context = RequestContext(user_id="1", trace_id="t")
config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m")
result = AgentResult(run_id="r", result=CoreResult(text="手机号13812345678"))
assert "13812345678" not in review_output(result, context, config, ()).result.text
forged = result.model_copy(update={"result": CoreResult(text="test", source_references=(
SourceReference(source_type="memory", source_id="other-customer"),))})
with pytest.raises(ForbiddenAgentError):
review_output(forged, context, config, ())
def test_public_business_identifier_survives_number_redaction():
"""`W7` HTTP 实测:公开的统一社会信用代码曾被脱敏成「[敏感号码已脱敏]K」。
它是**公开业务标识**(与客服热线同理),不是客户 PII —— 打码会把知识库里的公开事实
变成客户看不懂的乱码。修法是给「长数字串打码」加一条**整体放行**的优先分支,
**不放宽**任何 PII 规则(下面的反证逐条守)。
"""
context = RequestContext(user_id="1", trace_id="t")
config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m")
text = "统一社会信用代码 91440300279533137K,客服热线 400-889-8899"
reviewed = review_output(AgentResult(run_id="r", result=CoreResult(text=text)),
context, config, ()).result.text
assert "91440300279533137K" in reviewed
assert "敏感号码已脱敏" not in reviewed
assert "400-889-8899" in reviewed
@pytest.mark.parametrize(
"sensitive",
["身份证 440301199001011234", "银行卡 6222021234567890123", "客户号 12345678901234567"],
)
def test_pii_numbers_are_still_redacted(sensitive: str):
"""公开标识放行**不得**连带放过真正的 PII。"""
context = RequestContext(user_id="1", trace_id="t")
config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m")
reviewed = review_output(AgentResult(run_id="r", result=CoreResult(text=sensitive)),
context, config, ()).result.text
assert "敏感号码已脱敏" in reviewed
def test_knowledge_reference_is_rejected_so_it_must_stay_disabled():
"""`C-10`(乙·降级)的依据:knowledge 来源会被判为未授权引用 → **整个 run 失败**。
这是「本期不展示来源引用」的**技术原因**,不是取巧:治理层只放行 memory / tool
(`review_output` 的 `known` / `issued_tools` 两套白名单),知识引用既不在召回结果里、
也不在工具调用记录里,必然被拒。对策是先降级 + 加护栏(`S-8`),启用须走会签。
"""
context = RequestContext(user_id="1", trace_id="t")
config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m")
knowledge = AgentResult(run_id="r", result=CoreResult(text="answer", source_references=(
SourceReference(source_type="knowledge", source_id="FAQ-0026"),)))
with pytest.raises(ForbiddenAgentError):
review_output(knowledge, context, config, ())
async def test_missing_factory_dependencies_fail_closed():
class Demo(BaseAgent):
async def handle(self, request, context):
pytest.fail("must not execute")
definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("customer",),
allowed_portals=("api",))
context = RequestContext(user_id="1", trace_id="t", roles=("customer",),
permissions=("agent:run",))
with pytest.raises(RecoverableAgentError):
_ = [e async for e in Demo(definition).execute(
AgentRequest(agent_type="demo", message="test", session_id="s",
idempotency_key="1234567890123456"), context, "r")]
# --- 记忆可读范围:员工按 sys_customer_assignment 归属(2026-09-14 拍板口径) -------------
#
# 修复前的行为:`recall()` 把 `int(context.user_id)` 当客户号,于是员工身份**恒空**,
# 且员工号与客户号同号段时会读到陌生客户的记忆并注入提示词。下面这些用例把
# "只能读到该读的"钉死。
class _StubItem:
def __init__(self, memory_uuid: str, content: str, confidence: float) -> None:
self.memory_uuid = memory_uuid
self.content = content
self.confidence = confidence
class _StubResult:
def __init__(self, items: list[_StubItem]) -> None:
self.items = tuple(items)
self.degraded = False
self.degraded_reasons: tuple[str, ...] = ()
self.from_cache = False
class _StubRecallService:
"""按客户号返回记忆的召回服务替身;记录被查过哪些客户。"""
def __init__(self, by_customer: dict[int, list[_StubItem]]) -> None:
self.by_customer = by_customer
self.queried: list[int] = []
async def recall(self, customer_id: int) -> _StubResult:
self.queried.append(customer_id)
return _StubResult(self.by_customer.get(customer_id, []))
class _DummySession:
"""`PlatformGovernance.recall` 会 `async with SessionFactory() as session`;
召回服务是替身、不碰这个 session,所以给个空壳即可,测试不连库。"""
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *exc: object) -> bool:
return False
def _governance_with(
monkeypatch: pytest.MonkeyPatch,
service: _StubRecallService,
) -> PlatformGovernance:
monkeypatch.setattr(governance_module, "SessionFactory", lambda: _DummySession())
return PlatformGovernance(recall_factory=lambda _session: service) # type: ignore[arg-type,return-value]
def _employee_context(user_id: str, customer_ids: tuple[str, ...]) -> RequestContext:
"""员工身份上下文:带 `memory:read:customer` 能力码(读他人客户记忆的授权码)。"""
return RequestContext(
user_id=user_id, trace_id="t", roles=("advisor",), customer_ids=customer_ids,
permissions=(REQUIRED_EMPLOYEE_PERMISSION,),
)
async def test_employee_recall_reads_assigned_customers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""员工(投顾/风控)按归属读到客户记忆,且每条记忆标注其真实归属客户号。"""
service = _StubRecallService({
9001: [_StubItem("uuid-a", "偏好进取型", 0.9)],
9102: [_StubItem("uuid-b", "持有约三年", 0.5)],
})
governance = _governance_with(monkeypatch, service)
context = _employee_context("9020", ("9102", "9001"))
memories = await governance.recall(context)
assert service.queried == [9001, 9102] # 升序,确定性
# 置信度降序:uuid-a(0.9) 在前;归属客户号必须逐条正确,否则提示词会张冠李戴。
assert [(memory.memory_uuid, memory.customer_id) for memory in memories] == [
("uuid-a", "9001"), ("uuid-b", "9102"),
]
async def test_employee_without_assignment_reads_nothing_and_skips_database(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""归属未维护 ⇒ 失败关闭,且**连召回服务都不该被调用**(不做无谓的库/向量查询)。"""
service = _StubRecallService({9020: [_StubItem("uuid-self", "不该被读到", 0.9)]})
governance = _governance_with(monkeypatch, service)
context = _employee_context("9020", ())
memories = await governance.recall(context)
assert memories == ()
# 关键:员工号 9020 没有被当成客户号 —— 否则这里会读到"同号客户"的记忆。
assert service.queried == []
async def test_employee_without_capability_reads_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""有归属行但缺 `memory:read:customer` 能力码 ⇒ 同样失败关闭,且不查库。"""
service = _StubRecallService({9001: [_StubItem("uuid-a", "不该被读到", 0.9)]})
governance = _governance_with(monkeypatch, service)
context = RequestContext(
user_id="9005", trace_id="t", roles=("operator",), customer_ids=("9001",),
permissions=("offsite:write",),
)
memories = await governance.recall(context)
assert memories == ()
assert service.queried == []
async def test_customer_recall_ignores_assignments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""客户身份只读自己:分配表里有别行也不能顺带读别人。"""
service = _StubRecallService({
9001: [_StubItem("uuid-self", "自己的偏好", 0.9)],
9102: [_StubItem("uuid-other", "别人的偏好", 0.9)],
})
governance = _governance_with(monkeypatch, service)
context = RequestContext(user_id="9001", trace_id="t", roles=("customer",),
customer_ids=("9102",))
memories = await governance.recall(context)
assert service.queried == [9001]
assert [memory.memory_uuid for memory in memories] == ["uuid-self"]
def test_review_output_accepts_assigned_customer_memory_reference() -> None:
"""员工引用**归属客户**的记忆不算伪造引用;引用非归属客户的记忆必须被拦。"""
config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m")
context = _employee_context("9020", ("9001",))
recalled = (RecalledMemory(memory_uuid="uuid-a", customer_id="9001", content="偏好进取型"),)
def _result(memory_uuid: str) -> AgentResult:
return AgentResult(run_id="r", result=CoreResult(
text="分析如下",
source_references=(SourceReference(source_type="memory", source_id=memory_uuid),),
))
# 归属客户:放行(修复前 known 恒空,这里会抛 ForbiddenAgentError)。
assert review_output(_result("uuid-a"), context, config, recalled).result.text == "分析如下"
# 非归属客户:即便被塞进 memories 也必须被拦 —— 范围判定与召回共用同一口径。
forged = (RecalledMemory(memory_uuid="uuid-x", customer_id="9999", content="他人记忆"),)
with pytest.raises(ForbiddenAgentError):
review_output(_result("uuid-x"), context, config, forged)
async def test_recall_scope_guard_rejects_memory_outside_assignments() -> None:
"""`BaseAgent` 的范围守卫:越界记忆必须让整条运行失败关闭。"""
class StubGovernance:
async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]:
return (RecalledMemory(memory_uuid="uuid-x", customer_id="9999", content="他人记忆"),)
class Demo(BaseAgent):
async def handle(self, request, context):
pytest.fail("越界记忆不得进入 handle")
definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("advisor",),
allowed_portals=("api",))
agent = Demo(definition)
agent.bind_governance(StubGovernance()) # type: ignore[arg-type]
request = AgentRequest(agent_type="demo", message="看看客户", session_id="s",
idempotency_key="recall-scope-guard-0001")
with pytest.raises(RecoverableAgentError, match="越过客户范围"):
await agent.recall_memory(request, _employee_context("9020", ("9001",)))