feat: add customer profile candidate flow

This commit is contained in:
张胜宇
2026-09-11 17:16:43 +08:00
parent ef098e6a4b
commit 3a21afa847
10 changed files with 295 additions and 6 deletions
@@ -239,3 +239,22 @@ async def test_normal_result_does_not_create_handover_ticket_or_event() -> None:
event.event_type != "conversation.transfer_requested"
for event in added_of(session.added, DomainEventOutbox)
)
@pytest.mark.asyncio
async def test_profile_candidate_event_is_written_without_modifying_profile() -> None:
"""画像候选只写 Outbox,正式画像更新留给后续确认/审核服务。"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=False),
memory_extraction_requested=False, profile_candidate_requested=True,
)
events = added_of(session.added, DomainEventOutbox)
candidate = next(
event for event in events if event.event_type == "customer_profile.candidate_requested"
)
assert candidate.payload["customer_id"] == 7
assert candidate.payload["actor_type"] == "authenticated_customer"
assert added_of(session.added, HandoverTicket) == []
+18
View File
@@ -116,6 +116,24 @@ async def test_upsert_persists_structured_value_when_created() -> None:
assert created.memory_key == "preference:risk_level"
@pytest.mark.asyncio
async def test_upsert_candidate_never_updates_active_memory() -> None:
"""候选写入不读取或覆盖同键正式记忆,等待确认后再晋升。"""
session = fake_session()
session.scalar.side_effect = [None]
added: list[Any] = []
session.add = Mock(side_effect=added.append)
created = await MemoryService(session).upsert(
7, "preference:risk_level", "稳健型", memory_type="preference",
confidence=0.9, status="candidate",
)
assert created.status == "candidate"
assert created.content == "稳健型"
assert added == [created]
@pytest.mark.asyncio
async def test_upsert_refreshes_structured_value_on_existing_memory() -> None:
existing = memory(5, key="preference:risk_level", content="保守型")
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import RecoverableAgentError
from app.model.conversation import ConversationMessage
from app.service.memory_extraction_service import ExtractedMemory
from app.worker.customer_profile_candidate_worker import CustomerProfileCandidateWorker
from app.worker.memory_extraction_worker import MemoryExtractionWorker
NOW = datetime(2026, 9, 9, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None)
@@ -194,3 +195,40 @@ async def test_missing_message_is_skipped_without_write() -> None:
assert not await MemoryExtractionWorker(session, extractor=StubExtractor(EXTRACTED)).handle(
{"run_id": "run-1", "message_id": 11, "customer_id": 7}, event_id="event-1")
session.add.assert_not_called()
@pytest.mark.asyncio
async def test_profile_candidate_is_sanitized_and_not_active() -> None:
"""客服画像候选只保存脱敏证据,并且状态必须是 candidate。"""
session = link()
added: list[Any] = []
session.add = Mock(side_effect=added.append)
extractor = StubExtractor(EXTRACTED)
assert await CustomerProfileCandidateWorker(session, extractor=extractor).handle(
{
"run_id": "run-1", "message_id": 11, "customer_id": 7,
"actor_type": "authenticated_customer",
},
event_id="event-candidate",
)
memory = next(item for item in added if type(item).__name__ == "MemoryUnit")
evidence = next(item for item in added if type(item).__name__ == "MemoryEvidence")
assert memory.status == "candidate"
assert memory.source_type == "AI对话提取"
assert evidence.idempotency_key == "customer_profile.candidate_requested:event-candidate"
assert extractor.calls == [USER_FACT]
@pytest.mark.asyncio
async def test_profile_candidate_rejects_visitor_event() -> None:
"""消费者对缺少已登录身份标记的事件失败关闭。"""
session = link()
extractor = StubExtractor(EXTRACTED)
assert not await CustomerProfileCandidateWorker(session, extractor=extractor).handle(
{"run_id": "run-1", "message_id": 11, "customer_id": 7, "actor_type": "visitor"},
event_id="event-visitor",
)
assert extractor.calls == []
@@ -18,6 +18,7 @@ PAYLOAD: dict[str, Any] = {
OUTBOX = {
"agent.run_requested",
"memory.extraction_requested",
"customer_profile.candidate_requested",
"agent.run_completed",
"config.cache_invalidate_requested",
# 投影清理事件必须有消费者,否则 memory.invalidated/memory.deleted 永久 pending。
@@ -77,6 +78,30 @@ def test_authenticated_customer_service_does_not_request_memory_extraction() ->
assert requested is False
def test_authenticated_customer_service_requests_profile_candidate() -> None:
"""已登录客户明确陈述偏好时只生成候选,不复用正式记忆事件。"""
context = RequestContext(
user_id="7", trace_id="customer-trace", roles=("customer",),
permissions=("agent:run",), data_scope="self",
)
assert WorkerRuntime.should_request_profile_candidate(
agent_type="customer_service", context=context, message="我的风险偏好是稳健型"
) is True
def test_visitor_does_not_request_profile_candidate() -> None:
"""访客即使陈述偏好也不能创建画像候选。"""
context = RequestContext(
user_id="visitor:test", trace_id="visitor-trace", roles=("visitor",),
permissions=("agent:run",), data_scope="public",
)
assert WorkerRuntime.should_request_profile_candidate(
agent_type="customer_service", context=context, message="我的风险偏好是稳健型"
) is False
@pytest.mark.asyncio
async def test_customer_service_short_memory_append_is_available_on_runtime() -> None:
"""Worker 的短期会话依赖与长期画像抽取依赖必须彼此独立。"""