mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 13:54:20 +00:00
193 lines
8.0 KiB
Python
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"
|