feat: complete customer service safety and handover flow

This commit is contained in:
张胜宇
2026-09-11 16:11:30 +08:00
parent 0059701509
commit ef098e6a4b
30 changed files with 1868 additions and 50 deletions
@@ -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 无写入方)。"""