## 修的是什么
`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 复测更新测试基线。
300 lines
13 KiB
Python
300 lines
13 KiB
Python
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)
|