feat: complete customer service safety and handover flow
This commit is contained in:
@@ -6,6 +6,7 @@ import pytest
|
||||
from app.core.contracts import AgentResult, CoreResult, IntentResult
|
||||
from app.model.conversation import ConversationMessage
|
||||
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket
|
||||
from app.model.session import ConversationSession
|
||||
from app.service.agent_persistence_service import AgentPersistenceService
|
||||
|
||||
|
||||
@@ -27,13 +28,21 @@ class FakeSession:
|
||||
self.added: list[Any] = []
|
||||
self.executed: list[Any] = []
|
||||
self._scalar_calls = 0
|
||||
self.conversation_messages = [ConversationMessage(
|
||||
session_id=run.session_id, customer_id=run.user_id, portal="api", role="user",
|
||||
content="我需要人工协助", trace_id=run.trace_id,
|
||||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||||
)]
|
||||
|
||||
def begin(self) -> FakeTransaction:
|
||||
return FakeTransaction()
|
||||
|
||||
async def scalar(self, statement: object) -> object:
|
||||
self._scalar_calls += 1
|
||||
return self.run if self._scalar_calls == 1 else self.run.user_id
|
||||
if self._scalar_calls == 1:
|
||||
return self.run
|
||||
# 根据查询表名区分“正式客户存在”与“当前测试没有会话行”。
|
||||
return self.run.user_id if "sys_user" in str(statement) else None
|
||||
|
||||
def add(self, item: Any) -> None:
|
||||
self.added.append(item)
|
||||
@@ -46,6 +55,13 @@ class FakeSession:
|
||||
async def execute(self, statement: object) -> None:
|
||||
self.executed.append(statement)
|
||||
|
||||
async def scalars(self, statement: object) -> Any:
|
||||
del statement
|
||||
assistant_messages = [
|
||||
item for item in self.added if isinstance(item, ConversationMessage)
|
||||
]
|
||||
return iter([*self.conversation_messages, *assistant_messages])
|
||||
|
||||
|
||||
def queued_run() -> AgentRun:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -98,6 +114,8 @@ async def test_transfer_required_result_creates_pending_ticket_and_outbox_event(
|
||||
assert tickets[0].source_agent == "customer_service"
|
||||
assert tickets[0].source_message_id == 901
|
||||
assert tickets[0].reason_code == "user_requested"
|
||||
assert tickets[0].reason_detail == "系统自动转接;原因=user_requested;澄清轮次=0;知识来源数=0"
|
||||
assert "最近会话(已脱敏)" in (tickets[0].conversation_summary or "")
|
||||
assert len(events) == 2 # agent.run_completed + conversation.transfer_requested
|
||||
transfer_event = next(
|
||||
event for event in events if event.event_type == "conversation.transfer_requested"
|
||||
@@ -105,6 +123,37 @@ async def test_transfer_required_result_creates_pending_ticket_and_outbox_event(
|
||||
assert transfer_event.aggregate_type == "conversation"
|
||||
assert transfer_event.aggregate_id == "session-transfer-1"
|
||||
assert transfer_event.payload["ticket_no"] == tickets[0].ticket_no
|
||||
context = transfer_event.payload["handover_context"]
|
||||
assert context["reason_code"] == "user_requested"
|
||||
assert context["clarification_round"] == 0
|
||||
assert context["source_references"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handover_context_redacts_historical_sensitive_values() -> None:
|
||||
"""即使旧会话里有原始凭据,工单与 Outbox 摘要也必须二次脱敏。"""
|
||||
session = FakeSession(queued_run())
|
||||
session.conversation_messages = [ConversationMessage(
|
||||
session_id=session.run.session_id, customer_id=session.run.user_id, portal="api",
|
||||
role="user", content="验证码 123456,银行卡 6222021234567890123,登录密码: Secret123",
|
||||
trace_id=session.run.trace_id, created_at=datetime.now(UTC).replace(tzinfo=None),
|
||||
)]
|
||||
|
||||
await AgentPersistenceService(session).complete_run(
|
||||
"run-transfer-1", result(transfer_required=True), memory_extraction_requested=False
|
||||
)
|
||||
|
||||
ticket = added_of(session.added, HandoverTicket)[0]
|
||||
event = next(
|
||||
item for item in added_of(session.added, DomainEventOutbox)
|
||||
if item.event_type == "conversation.transfer_requested"
|
||||
)
|
||||
summary = ticket.conversation_summary or ""
|
||||
event_summary = event.payload["handover_context"]["conversation_summary"]
|
||||
for raw_value in ("123456", "6222021234567890123", "Secret123"):
|
||||
assert raw_value not in summary
|
||||
assert raw_value not in event_summary
|
||||
assert "验证码" in summary
|
||||
|
||||
|
||||
class VisitorSession(FakeSession):
|
||||
@@ -134,6 +183,48 @@ async def test_visitor_transfer_ticket_does_not_use_anonymous_id_as_customer_fk(
|
||||
assert tickets[0].customer_id is None
|
||||
|
||||
|
||||
class ClarificationSession(FakeSession):
|
||||
"""模拟拥有真实会话行的客服运行,验证轮次仅在成功结果中改变。"""
|
||||
|
||||
def __init__(self, run: AgentRun, clarification_round: int) -> None:
|
||||
super().__init__(run)
|
||||
self.row = ConversationSession(
|
||||
session_id=run.session_id, user_id=run.user_id, agent_type=run.agent_type,
|
||||
portal="api", status="active", clarification_round=clarification_round,
|
||||
)
|
||||
|
||||
async def scalar(self, statement: object) -> object:
|
||||
self._scalar_calls += 1
|
||||
if self._scalar_calls == 1:
|
||||
return self.run
|
||||
if "svc_conversation_session" in str(statement):
|
||||
return self.row
|
||||
return self.run.user_id if "sys_user" in str(statement) else None
|
||||
|
||||
|
||||
def clarification_result(*, required: bool) -> AgentResult:
|
||||
return AgentResult(
|
||||
run_id="run-transfer-1",
|
||||
result=CoreResult(text="请补充一项信息。", clarification_required=required),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_customer_service_clarification_round_advances_and_resets() -> None:
|
||||
session = ClarificationSession(queued_run(), clarification_round=1)
|
||||
|
||||
await AgentPersistenceService(session).complete_run(
|
||||
"run-transfer-1", clarification_result(required=True), memory_extraction_requested=False
|
||||
)
|
||||
assert session.row.clarification_round == 2
|
||||
|
||||
resolved_session = ClarificationSession(queued_run(), clarification_round=2)
|
||||
await AgentPersistenceService(resolved_session).complete_run(
|
||||
"run-transfer-1", clarification_result(required=False), memory_extraction_requested=False
|
||||
)
|
||||
assert resolved_session.row.clarification_round == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_result_does_not_create_handover_ticket_or_event() -> None:
|
||||
"""非转人工回答不得污染管理员待处理队列。"""
|
||||
|
||||
Reference in New Issue
Block a user