Files
group_fqcd_jr/tests/unit/service/test_agent_governance.py
T

349 lines
15 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
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
2026-09-09 21:55:37 +08:00
from app.service.agent.base import BaseAgent
from app.service.agent.factory import AgentFactory
from app.service.agent.governance import PlatformGovernance, review_output
2026-09-09 21:55:37 +08:00
@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 时须把该判据与用例一并带回来**。
2026-09-09 21:55:37 +08:00
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 = ""):
2026-09-09 21:55:37 +08:00
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, ())
2026-09-09 21:55:37 +08:00
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",)))