Files
group_fqcd_jr/tests/unit/service/test_agent_persistence_handover.py
T
张胜宇 9675df8453 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)
- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 21:26:30 +08:00

346 lines
13 KiB
Python

from datetime import UTC, datetime
from typing import Any
import pytest
from app.core.contracts import AgentResult, CoreResult, IntentResult
from app.model.audit import InteractionAudit
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
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),
)]
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
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])
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, transfer_reason: str = "write_or_dispute"
) -> AgentResult:
return AgentResult(
run_id="run-transfer-1",
result=CoreResult(
text="已为您转接人工客服。",
intent=IntentResult(intent="human_handover", confidence=1),
transfer_required=transfer_required,
# `E-01` ③:`reason_code` 是枚举列 —— fixture 必须用枚举码,
# 不能用公开接口的请求取值 `user_requested`。
transfer_reason=transfer_reason 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 == "write_or_dispute"
# `E-01` ②:优先级由原因码映射而来(此前恒为模型默认值 P1)
assert tickets[0].priority == "P2"
assert tickets[0].reason_detail == (
"系统自动转接;原因=write_or_dispute;澄清轮次=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"
)
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"] == "write_or_dispute"
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):
"""模拟访客运行:运行记录存在,但不存在对应的正式 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
@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)
)
@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) == []
@pytest.mark.asyncio
async def test_ticket_priority_follows_reason_code() -> None:
"""`E-01` ②:工单优先级必须来自原因码映射,且与安全路由层一致。"""
expected = {
"safety_risk": "P0",
"account_data": "P1",
"write_or_dispute": "P2",
"explicit_request": "P2",
}
for reason, priority in expected.items():
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1",
result(transfer_required=True, transfer_reason=reason),
memory_extraction_requested=False,
)
ticket = added_of(session.added, HandoverTicket)[0]
assert ticket.reason_code == reason
assert ticket.priority == priority
@pytest.mark.asyncio
async def test_non_enum_reason_is_normalised_but_ticket_is_still_created() -> None:
"""`E-01` ③:非枚举原因码收敛到枚举;但**不丢单** —— 已经对客户说过转接。
丢单等于把客户半路扔下,比原因码不够精确严重得多;因此这里断言「收敛 + 仍然建单」。
"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1",
result(transfer_required=True, transfer_reason="user_requested"),
memory_extraction_requested=False,
)
tickets = added_of(session.added, HandoverTicket)
assert len(tickets) == 1
assert tickets[0].reason_code == "explicit_request"
assert tickets[0].priority == "P2"
@pytest.mark.asyncio
async def test_suitability_disclosure_is_written_to_the_audit_table() -> None:
"""`E-04` 留痕:适当性裁决必须同时落**消息表**与**审计表**(零 DDL)。"""
session = FakeSession(queued_run())
record = {
"product": "南方季季盈90天",
"product_risk_level": 4,
"customer_risk_level": 3,
"allowed": True,
"reason_code": "SUITABLE_WITH_DISCLOSURE",
"required_disclosure": True,
"requires_recording": False,
"disclosure_first": True,
"customer_confirmed": False,
}
payload = result(transfer_required=False)
payload = payload.model_copy(update={
"result": payload.result.model_copy(update={"data": {"suitability": record}}),
})
await AgentPersistenceService(session).complete_run(
"run-transfer-1", payload, memory_extraction_requested=False
)
audits = added_of(session.added, InteractionAudit)
disclosed = [a for a in audits if a.action_type == "agent.suitability_disclosed"]
assert len(disclosed) == 1
detail = disclosed[0].detail
assert detail["product"] == "南方季季盈90天"
assert detail["disclosure_first"] is True
# 红线 2:客服永不代替客户确认
assert detail["customer_confirmed"] is False
# 消息表侧同一份留痕(结构化列,不改表结构)
messages = added_of(session.added, ConversationMessage)
assert messages[-1].tool_calls["data"]["suitability"]["reason_code"] == (
"SUITABLE_WITH_DISCLOSURE"
)