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" )