Files
group_fqcd_jr/tests/unit/service/test_agent_persistence_handover.py
T

261 lines
9.8 KiB
Python
Raw Normal View History

2026-09-10 19:51:08 +08:00
from datetime import UTC, datetime
from typing import Any
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
2026-09-10 19:51:08 +08:00
from app.service.agent_persistence_service import AgentPersistenceService
class FakeTransaction:
async def __aenter__(self) -> None:
return None
async def __aexit__(
self, exc_type: object, exc_value: object, traceback: object
) -> bool:
return False
class FakeSession:
"""仅收集持久化服务在同一事务内计划写入的 ORM 实体。"""
def __init__(self, run: AgentRun) -> None:
self.run = run
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),
)]
2026-09-10 19:51:08 +08:00
def begin(self) -> FakeTransaction:
return FakeTransaction()
async def scalar(self, statement: object) -> object:
self._scalar_calls += 1
if self._scalar_calls == 1:
return self.run
# 根据查询表名区分“正式客户存在”与“当前测试没有会话行”。
return self.run.user_id if "sys_user" in str(statement) else None
2026-09-10 19:51:08 +08:00
def add(self, item: Any) -> None:
self.added.append(item)
async def flush(self) -> None:
for item in self.added:
if isinstance(item, ConversationMessage) and item.id is None:
item.id = 901
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])
2026-09-10 19:51:08 +08:00
def queued_run() -> AgentRun:
now = datetime.now(UTC).replace(tzinfo=None)
return AgentRun(
id=1,
run_id="run-transfer-1",
idempotency_id=2,
session_id="session-transfer-1",
user_id=7,
agent_type="customer_service",
trace_id="trace-transfer-1",
request_message_id=800,
status="queued",
created_at=now,
updated_at=now,
)
def result(*, transfer_required: bool) -> AgentResult:
return AgentResult(
run_id="run-transfer-1",
result=CoreResult(
text="已为您转接人工客服。",
intent=IntentResult(intent="human_handover", confidence=1),
transfer_required=transfer_required,
transfer_reason="user_requested" if transfer_required else None,
),
)
def added_of(items: list[Any], model: type[Any]) -> list[Any]:
return [item for item in items if isinstance(item, model)]
@pytest.mark.asyncio
async def test_transfer_required_result_creates_pending_ticket_and_outbox_event() -> None:
"""Agent 发起的转人工必须在完成运行的事务内留待处理工单和通知事件。"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=True), memory_extraction_requested=False
)
tickets = added_of(session.added, HandoverTicket)
events = added_of(session.added, DomainEventOutbox)
assert len(tickets) == 1
assert tickets[0].status == "pending"
assert tickets[0].session_id == "session-transfer-1"
assert tickets[0].customer_id == 7
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 "")
2026-09-10 19:51:08 +08:00
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"
)
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
2026-09-10 19:51:08 +08:00
class VisitorSession(FakeSession):
"""模拟访客运行:运行记录存在,但不存在对应的正式 sys_user。"""
def __init__(self, run: AgentRun) -> None:
super().__init__(run)
async def scalar(self, statement: object) -> object:
self._scalar_calls += 1
if self._scalar_calls == 1:
return self.run
return None
@pytest.mark.asyncio
async def test_visitor_transfer_ticket_does_not_use_anonymous_id_as_customer_fk() -> None:
"""访客转人工写匿名工单,customer_id 必须为空而不是随机访客 ID。"""
session = VisitorSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=True), memory_extraction_requested=False
)
tickets = added_of(session.added, HandoverTicket)
assert len(tickets) == 1
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
2026-09-10 19:51:08 +08:00
@pytest.mark.asyncio
async def test_normal_result_does_not_create_handover_ticket_or_event() -> None:
"""非转人工回答不得污染管理员待处理队列。"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=False), memory_extraction_requested=False
)
assert added_of(session.added, HandoverTicket) == []
assert all(
event.event_type != "conversation.transfer_requested"
for event in added_of(session.added, DomainEventOutbox)
)
2026-09-11 17:16:43 +08:00
@pytest.mark.asyncio
async def test_profile_candidate_event_is_written_without_modifying_profile() -> None:
"""画像候选只写 Outbox,正式画像更新留给后续确认/审核服务。"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=False),
memory_extraction_requested=False, profile_candidate_requested=True,
)
events = added_of(session.added, DomainEventOutbox)
candidate = next(
event for event in events if event.event_type == "customer_profile.candidate_requested"
)
assert candidate.payload["customer_id"] == 7
assert candidate.payload["actor_type"] == "authenticated_customer"
assert added_of(session.added, HandoverTicket) == []