Files
group_fqcd_jr/tests/unit/service/test_agent_governance.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

303 lines
13 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, ())
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",)))