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
@@ -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 的短期会话依赖与长期画像抽取依赖必须彼此独立。"""