feat: complete customer service safety and handover flow
This commit is contained in:
@@ -1,10 +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
|
||||
@@ -20,6 +23,7 @@ OUTBOX = {
|
||||
# 投影清理事件必须有消费者,否则 memory.invalidated/memory.deleted 永久 pending。
|
||||
"memory.invalidated",
|
||||
"memory.deleted",
|
||||
"conversation.transfer_requested",
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +52,7 @@ def test_visitor_does_not_request_memory_extraction() -> None:
|
||||
result = AgentResult(run_id="visitor-run", result=CoreResult(text="公开答复"))
|
||||
|
||||
requested = WorkerRuntime.should_request_memory_extraction(
|
||||
context=context,
|
||||
agent_type="customer_service", context=context,
|
||||
message="我的风险偏好是稳健型",
|
||||
result=result,
|
||||
business_events=(),
|
||||
@@ -57,6 +61,61 @@ def test_visitor_does_not_request_memory_extraction() -> None:
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
@@ -152,6 +211,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