merge: integrate ZSY customer service and profile capabilities
This commit is contained in:
@@ -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 == []
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.model.memory import MemorySyncOutbox
|
||||
from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, event: MemorySyncOutbox | None) -> None:
|
||||
self.event = event
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
|
||||
async def __aenter__(self) -> "FakeSession":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def scalar(self, statement: object) -> MemorySyncOutbox | None:
|
||||
del statement
|
||||
return self.event
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rollbacks += 1
|
||||
|
||||
|
||||
def event(*, target: str = "neo4j", retry_count: int = 0) -> MemorySyncOutbox:
|
||||
return MemorySyncOutbox(
|
||||
id=1, event_uuid="event-1", aggregate_type="profile_snapshot",
|
||||
aggregate_uuid="profile-1", aggregate_version=1, target_store=target,
|
||||
operation="upsert", payload={"customer_id": 7}, status="pending",
|
||||
retry_count=retry_count, next_retry_at=None, last_error=None,
|
||||
created_at=datetime(2026, 1, 1), processed_at=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_marks_event_processed() -> None:
|
||||
item = event()
|
||||
session = FakeSession(item)
|
||||
seen: list[dict[str, object]] = []
|
||||
|
||||
async def handler(payload: dict[str, object]) -> None:
|
||||
seen.append(payload)
|
||||
|
||||
worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session)
|
||||
assert await worker.run_once() is True
|
||||
assert seen == [{"customer_id": 7}]
|
||||
assert item.status == "processed"
|
||||
assert item.processed_at is not None
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_failure_uses_backoff_and_keeps_event() -> None:
|
||||
item = event()
|
||||
session = FakeSession(item)
|
||||
|
||||
async def handler(payload: dict[str, object]) -> None:
|
||||
del payload
|
||||
raise TimeoutError
|
||||
|
||||
worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session)
|
||||
assert await worker.run_once() is True
|
||||
assert item.status == "failed"
|
||||
assert item.retry_count == 1
|
||||
assert item.next_retry_at is not None
|
||||
assert item.last_error == "TimeoutError"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fifth_failure_enters_dead_state() -> None:
|
||||
item = event(retry_count=4)
|
||||
session = FakeSession(item)
|
||||
|
||||
async def handler(payload: dict[str, object]) -> None:
|
||||
del payload
|
||||
raise RuntimeError
|
||||
|
||||
worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session)
|
||||
await worker.run_once()
|
||||
assert item.status == "dead"
|
||||
assert item.retry_count == 5
|
||||
assert item.next_retry_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_handler_enters_dead_state_without_external_call() -> None:
|
||||
item = event(target="milvus")
|
||||
session = FakeSession(item)
|
||||
worker = MemorySyncOutboxWorker({"neo4j": lambda _: None}, session_factory=lambda: session)
|
||||
|
||||
assert await worker.run_once() is True
|
||||
assert item.status == "dead"
|
||||
assert item.last_error == "target_handler_not_configured"
|
||||
@@ -1,9 +1,13 @@
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.contracts import AgentResult, CoreResult, RequestContext
|
||||
from app.model.platform import HandoverTicket
|
||||
from app.service.customer_service_session_memory_service import CustomerServiceSessionTurn
|
||||
from app.service.memory_recall_service import MemoryRecallService
|
||||
from app.service.model_gateway import ModelGenerationService
|
||||
from app.worker.runtime import WorkerRuntime
|
||||
@@ -14,14 +18,129 @@ 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。
|
||||
"memory.invalidated",
|
||||
"memory.deleted",
|
||||
"conversation.transfer_requested",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_restores_visitor_without_identity_repository_call() -> None:
|
||||
"""访客异步任务只能恢复最小公开上下文,不能查询正式身份库。"""
|
||||
runtime = WorkerRuntime.__new__(WorkerRuntime)
|
||||
runtime.resolve_identity = AsyncMock(side_effect=AssertionError("identity lookup is forbidden"))
|
||||
|
||||
context = await runtime.restore_context(
|
||||
actor_type="visitor", actor_id="visitor:test", trace_id="trace-test"
|
||||
)
|
||||
|
||||
assert context.roles == ("visitor",)
|
||||
assert context.permissions == ("agent:run", "knowledge:query")
|
||||
assert context.data_scope == "public"
|
||||
runtime.resolve_identity.assert_not_awaited()
|
||||
|
||||
|
||||
def test_visitor_does_not_request_memory_extraction() -> None:
|
||||
"""访客消息即使包含偏好信号,也不能进入客户记忆抽取队列。"""
|
||||
context = RequestContext(
|
||||
user_id="visitor:test", trace_id="visitor-trace", roles=("visitor",),
|
||||
permissions=("agent:run",), data_scope="public",
|
||||
)
|
||||
result = AgentResult(run_id="visitor-run", result=CoreResult(text="公开答复"))
|
||||
|
||||
requested = WorkerRuntime.should_request_memory_extraction(
|
||||
agent_type="customer_service", context=context,
|
||||
message="我的风险偏好是稳健型",
|
||||
result=result,
|
||||
business_events=(),
|
||||
)
|
||||
|
||||
assert requested is False
|
||||
|
||||
|
||||
def test_authenticated_customer_service_does_not_request_memory_extraction() -> None:
|
||||
"""客服消息即使命中长期记忆信号,也不能进入客户画像抽取队列。"""
|
||||
context = RequestContext(
|
||||
user_id="7", trace_id="customer-trace", roles=("customer",),
|
||||
permissions=("agent:run",), data_scope="self",
|
||||
)
|
||||
result = AgentResult(run_id="customer-run", result=CoreResult(text="公开答复"))
|
||||
|
||||
requested = WorkerRuntime.should_request_memory_extraction(
|
||||
agent_type="customer_service", context=context, message="我的风险偏好是稳健型",
|
||||
result=result, business_events=("trade.completed",),
|
||||
)
|
||||
|
||||
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 的短期会话依赖与长期画像抽取依赖必须彼此独立。"""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class StubSessionMemory:
|
||||
async def append(
|
||||
self, *, actor_id: str, session_id: str,
|
||||
turns: tuple[CustomerServiceSessionTurn, ...],
|
||||
) -> bool:
|
||||
captured["actor_id"] = actor_id
|
||||
captured["session_id"] = session_id
|
||||
captured["turns"] = turns
|
||||
return True
|
||||
|
||||
async def read(self, *, actor_id: str, session_id: str) -> Any:
|
||||
raise AssertionError("not used by worker write path")
|
||||
|
||||
runtime = WorkerRuntime(session_memory=StubSessionMemory())
|
||||
|
||||
await runtime._append_customer_service_session_memory(
|
||||
agent_type="customer_service", actor_id="7", session_id="short-memory-session",
|
||||
request_message="刚才那只基金的费率", response_message="请以产品资料页为准。",
|
||||
)
|
||||
await runtime._append_customer_service_session_memory(
|
||||
agent_type="risk", actor_id="7", session_id="other-agent-session",
|
||||
request_message="不应写入", response_message="不应写入",
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"actor_id": "7",
|
||||
"session_id": "short-memory-session",
|
||||
"turns": (
|
||||
CustomerServiceSessionTurn(role="user", content="刚才那只基金的费率"),
|
||||
CustomerServiceSessionTurn(role="assistant", content="请以产品资料页为准。"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class FakeSession(AbstractAsyncContextManager["FakeSession"]):
|
||||
def __init__(self) -> None:
|
||||
self.scalar = AsyncMock(return_value="event-1")
|
||||
@@ -117,6 +236,54 @@ async def test_incomplete_extraction_payload_is_rejected(
|
||||
assert captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handover_event_marks_internal_queue_ready_without_mutating_ticket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""消费成功只意味着管理员队列可查看,不能伪造人工已接单或完成。"""
|
||||
session = FakeSession()
|
||||
ticket = HandoverTicket(
|
||||
id=31,
|
||||
ticket_no="ticket-worker-test",
|
||||
session_id="handover-session",
|
||||
customer_id=7,
|
||||
source_agent="customer_service",
|
||||
reason_code="human_handover",
|
||||
status="pending",
|
||||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||||
updated_at=datetime.now(UTC).replace(tzinfo=None),
|
||||
)
|
||||
session.scalar = AsyncMock(return_value=ticket)
|
||||
session.add = Mock()
|
||||
session.flush = AsyncMock()
|
||||
monkeypatch.setattr("app.worker.runtime.SessionFactory", lambda: session)
|
||||
|
||||
class FakeOutboxWorker:
|
||||
def __init__(self, session: Any, handlers: dict[str, Any]) -> None:
|
||||
self.handlers = handlers
|
||||
|
||||
async def publish_one(self, *, aggregate_id: str | None = None) -> bool:
|
||||
del aggregate_id
|
||||
await self.handlers["conversation.transfer_requested"]({
|
||||
"ticket_no": "ticket-worker-test",
|
||||
})
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.worker.runtime.OutboxWorker", FakeOutboxWorker)
|
||||
|
||||
assert await WorkerRuntime().dispatch_one()
|
||||
assert ticket.status == "pending"
|
||||
audit = session.add.call_args.args[0]
|
||||
assert audit.action_type == "handover.queue_ready"
|
||||
assert audit.detail == {
|
||||
"ticket_no": "ticket-worker-test",
|
||||
"source_agent": "customer_service",
|
||||
"reason_code": "human_handover",
|
||||
"ticket_status": "pending",
|
||||
}
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_invalidate_deletes_release_and_memory_keys() -> None:
|
||||
"""失效动作必须打在召回热缓存的**真实**键上(此前手写的 mem:mid:hot 无写入方)。"""
|
||||
|
||||
Reference in New Issue
Block a user