Files
group_fqcd_jr/tests/unit/service/test_agent_governance.py
T
lzf_0626 9eebf9627f 记忆召回:按 sys_customer_assignment 归属定范围,不再把员工号当客户号
## 修的是什么

`governance.recall()` 把 `int(context.user_id)` 当客户号用。后果有两个,
方向相反但都致命:

1. **员工身份(风控/投顾/运营/管理员/system)恒空** —— 员工不是客户,
   那是个不存在的客户号;日志只说 "empty",看不出是"设计如此"还是"记忆坏了"。
2. **越权陷阱** —— 员工号与客户号同号段(演示数据里客户 9001-9020、
   员工 9002/9020 并存)。`int(user_id)` 一旦与真实客户号重合,就会把
   **陌生客户的长期记忆读进来并注入提示词**,且不报错、看起来正常。

同一个问题在代码里还有另外两处**各自判断**、口径互不一致:
`BaseAgent.recall_memory()` 要求"每条记忆 customer_id == context.user_id"
(否则抛"越过客户范围"),`review_output()` 的引用校验只认同一条件。

## 怎么修的

新增 `app/core/memory_scope.py` 作为**唯一判定口径**,三处共用:

- 客户身份(customer / authenticated_user):**只读自己**,分配表里有别行也不读别人;
- 员工身份:**只读 `sys_customer_assignment` 分配给自己**的客户
  (`context.customer_ids`,由 `IdentityRepository.load_context()` 读入);
  归属未维护 ⇒ **失败关闭**,并在日志里点名"归属未维护",与"库里确实没有记忆"区分开;
- 访客:无(上游已拦)。

细节约定:
- 归属客户按客户号**升序**召回、单次上限 `MAX_RECALL_CUSTOMERS=10`
  —— 升序是为了确定性(同一身份每次取同一批,不随数据库返回顺序漂移),
  上限是为了别把成百上千条他人记忆塞进一个提示词;
- 跨客户合并后按置信度降序、`(客户号, uuid)` 兜底排序,最多 10 条;
- 员工同时持有多个归属客户的记忆时,`memory_context_text()` **逐行标注客户号**
  并把提示词改成"多个客户的长期事实" —— 否则模型会把 A 客户的事实当成 B 客户的。
  单一客户时保持原格式(客户身份的提示词与改动前逐字相同);
- 引用校验与范围守卫都改用同一口径:员工引用**归属客户**的记忆不再被判成伪造引用;
  引用**非归属客户**的记忆即便被塞进 memories 也照样拦下。

## 验证(真实身份链路 + 生产召回装配)

`IdentityRepository.load_context` → `PlatformGovernance.recall`(含 Milvus 语义通道):

- 身份展开:roles=('advisor',)、customer_ids=('9001',)(sys_customer_assignment
  里唯一那行 9020→9001)、可读范围 (9001,);
- **修复前** `recall(int(user_id)=9020)` → **0 条**;
- **修复后** `recall(按归属)` → **2 条**(客户9001:进取型 / 约三年);
- 边界:客户身份 9001 可读范围 (9001,);无归属员工 9002 = ()(失败关闭,
  且**没有**把 9002 当客户号);未分配时的 9020 = ()。

测试:`pytest tests/unit tests/contract` → **1445 passed, 2 skipped, 1 failed**
(1432 + 新增 13;唯一失败是组员正在改的投顾页面,与记忆链路无关)。
新增用例:`tests/unit/core/test_memory_scope.py`(8 条,含"员工号不得被当成客户号"
的反例断言)、`tests/unit/service/test_agent_governance.py`(+5 条:归属召回/
无归属失败关闭且不碰数据库/客户只读自己/引用校验/越界守卫)。

## 遗留(已在 AGENTS.md 与文档里写明,未自行实施)

风控扫描这条线**仍读不到记忆**:它是唯一消费召回内容的地方
(`risk_agent.py:224`),而扫描上下文是 user_id="0"/roles=("system",) 且无归属行。
根因是**顺序问题**:召回发生在 handle() 之前,上下文里没有"本次目标客户"这个概念。
出路有两条:① 给风控专员补 sys_customer_assignment 行(运维动作,立即可用);
② 在 RequestContext 加显式的 target_customer_id 并校验它落在归属集合内
(推荐,但属跨线协议改动,等确认)。

文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md 新增 §五(含 §5.4 遗留说明)、
AGENTS.md 新增"记忆可读范围只有一个判定口径"易错点,并按 2026-09-14 复测更新测试基线。
2026-09-14 21:33:16 +08:00

300 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.service.agent import governance as governance_module
from app.service.agent.base import BaseAgent
from app.service.agent.customer_service_agent import CustomerServiceAgent
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 == ()
@pytest.mark.asyncio
async def test_authenticated_customer_service_does_not_recall_customer_memory() -> None:
"""客服即使面对已登录用户,也不得触发通用长期或画像记忆读取。"""
class FailingGovernance:
async def recall(self, context):
raise AssertionError("customer service memory recall is forbidden")
agent = CustomerServiceAgent()
agent.bind_governance(FailingGovernance())
request = AgentRequest(
agent_type="customer_service", message="查询我的持仓", session_id="customer-session",
idempotency_key="customer-memory-request-0001",
)
context = RequestContext(
user_id="1", trace_id="customer-trace", roles=("customer",),
permissions=("agent:run",), data_scope="self",
)
await agent.recall_memory(request, context)
assert agent.memories == ()
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]
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 = RequestContext(user_id="9020", trace_id="t", roles=("advisor",),
customer_ids=("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 = RequestContext(user_id="9020", trace_id="t", roles=("risk_operator",))
memories = await governance.recall(context)
assert memories == ()
# 关键:员工号 9020 没有被当成客户号 —— 否则这里会读到"同号客户"的记忆。
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 = RequestContext(user_id="9020", trace_id="t", roles=("advisor",),
customer_ids=("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")
context = RequestContext(user_id="9020", trace_id="t", roles=("advisor",),
customer_ids=("9001",))
with pytest.raises(RecoverableAgentError, match="越过客户范围"):
await agent.recall_memory(request, context)