Files
compass_backoffice/app/channels/backoffice_rest_adapter.py
2026-06-13 15:40:44 -03:00

193 lines
8.0 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
@dataclass(slots=True)
class BackofficeChannelEnvelope:
"""Canonical channel envelope for Backoffice REST requests.
REST remains only a transport/channel contract. This envelope carries the
normalized information required by the framework layers before a workflow is
dispatched. The domain payload is preserved under ``payload`` so existing
business nodes can continue to read the original TIM/ANATEL contract from
``state["metadata"]["request_context"]``.
"""
workflow_id: str
payload: dict[str, Any]
transaction_id: str
channel: str = "backoffice_rest"
tenant_id: str = "default"
agent_id: str = "backoffice_anatel"
user_id: str | None = None
message_id: str = field(default_factory=lambda: str(uuid4()))
text: str = ""
business_context: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
def gateway_payload(self) -> dict[str, Any]:
"""Payload sent to the framework ChannelGateway.
The web adapter shape is intentionally also populated as a compatibility
fallback for framework versions that do not yet register a dedicated
``backoffice_rest`` adapter.
"""
return {
"channel": self.channel,
"message": self.text,
"text": self.text,
"session_id": self.transaction_id,
"conversation_key": f"{self.tenant_id}:{self.agent_id}:{self.transaction_id}",
"message_id": self.message_id,
"user_id": self.user_id or self.business_context.get("customer_key") or self.transaction_id,
"tenant_id": self.tenant_id,
"agent_id": self.agent_id,
"business_context": self.business_context,
"metadata": {
**self.metadata,
"workflow_id": self.workflow_id,
"transaction_id": self.transaction_id,
"source_channel": self.channel,
"request_context": self.payload,
},
}
class BackofficeRestChannelAdapter:
"""Adapter that turns legacy Backoffice REST contracts into a framework channel.
This class deliberately contains translation only. It does not run LangGraph,
does not know node order and does not call domain services. Execution is
delegated to the framework workflow dispatcher after channel normalization.
"""
channel_name = "backoffice_rest"
agent_id = "backoffice_anatel"
tenant_id = "default"
def from_ticket_event(self, event: Any, *, legacy_contract: str) -> BackofficeChannelEnvelope:
payload = self._to_dict(event)
transaction_id = payload.get("transactionId") or payload.get("transaction_id") or f"man-{uuid4().hex[:8]}"
complaint = payload.get("complaint") or {}
customer = payload.get("customer") or {}
text = self._first_non_empty(
complaint.get("description"),
complaint.get("motive"),
payload.get("description"),
f"Processar chamado backoffice {transaction_id}",
)
business_context = self._business_context(payload, transaction_id)
return BackofficeChannelEnvelope(
workflow_id="backoffice_checklist",
payload=payload,
transaction_id=transaction_id,
channel=self.channel_name,
tenant_id=self.tenant_id,
agent_id=self.agent_id,
user_id=customer.get("cpfCnpj") or customer.get("msisdn") or business_context.get("customer_key"),
text=text,
business_context=business_context,
metadata={
"legacy_contract": legacy_contract,
"case_type": payload.get("caseType"),
"complaint_protocol": complaint.get("complaintProtocol"),
"crm_protocol": payload.get("crmProtocol"),
"adapter": self.__class__.__name__,
},
)
def from_emulator_event(self, event: Any, *, legacy_contract: str) -> BackofficeChannelEnvelope:
payload = self._to_dict(event)
transaction_id = payload.get("transactionId") or payload.get("transaction_id") or f"emu-{uuid4().hex[:8]}"
selected_actions = payload.get("selected_actions") or payload.get("selectedActions") or []
flow_mode = payload.get("flow_mode") or payload.get("flowMode") or payload.get("action")
text = self._first_non_empty(
payload.get("operator_instructions"),
payload.get("previous_response"),
f"Executar emulador de resposta do caso {transaction_id}",
)
return BackofficeChannelEnvelope(
workflow_id="backoffice_response_emulator",
payload=payload,
transaction_id=transaction_id,
channel=self.channel_name,
tenant_id=self.tenant_id,
agent_id=self.agent_id,
user_id=transaction_id,
text=text,
business_context={"interaction_key": transaction_id, "session_key": transaction_id},
metadata={
"legacy_contract": legacy_contract,
"flow_mode": flow_mode,
"selected_actions": selected_actions,
"adapter": self.__class__.__name__,
},
)
async def normalize(self, channel_gateway: Any, envelope: BackofficeChannelEnvelope) -> Any:
"""Normalize through ChannelGateway, with a compatibility fallback.
Newer framework versions may support registering ``backoffice_rest`` as a
first-class adapter. Older versions only know web/whatsapp/voice; in that
case the payload is still passed through the web adapter shape and then
tagged back as ``backoffice_rest``.
"""
payload = envelope.gateway_payload()
try:
msg = await channel_gateway.normalize(envelope.channel, payload)
return msg
except Exception:
try:
msg = await channel_gateway.normalize("web", payload)
try:
msg.channel = envelope.channel
except Exception:
pass
return msg
except Exception:
return SimpleNamespace(
channel=envelope.channel,
channel_id=None,
session_id=envelope.transaction_id,
user_id=envelope.user_id,
text=envelope.text,
context=payload,
)
def _business_context(self, payload: dict[str, Any], transaction_id: str) -> dict[str, Any]:
customer = payload.get("customer") or {}
complaint = payload.get("complaint") or {}
cpf = customer.get("cpfCnpj") or (customer.get("subscriber") or {}).get("cpfCnpj")
msisdn = customer.get("msisdn") or ((customer.get("phones") or [])[:1] or [None])[0]
protocol = complaint.get("complaintProtocol") or payload.get("crmProtocol") or transaction_id
return {
"customer_key": msisdn or cpf,
"contract_key": cpf,
"interaction_key": protocol,
"session_key": transaction_id,
"metadata": {
"cpf_cnpj": cpf,
"msisdn": msisdn,
"transaction_id": transaction_id,
},
}
def _to_dict(self, value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if hasattr(value, "model_dump"):
return value.model_dump(mode="json", by_alias=True)
if hasattr(value, "dict"):
return value.dict(by_alias=True)
raise TypeError(f"Unsupported backoffice REST event type: {type(value).__name__}")
def _first_non_empty(self, *values: Any) -> str:
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return "Backoffice REST request"