feat: add customer profile candidate flow
This commit is contained in:
@@ -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) == []
|
||||
|
||||
@@ -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 的短期会话依赖与长期画像抽取依赖必须彼此独立。"""
|
||||
|
||||
Reference in New Issue
Block a user