This commit is contained in:
2026-06-13 15:40:44 -03:00
parent 0714b503d1
commit 1f1188dd4e
181 changed files with 1345 additions and 849 deletions

10
app/channels/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""Channel adapters owned by the application domain.
The framework ChannelGateway remains the canonical normalization boundary.
Domain REST contracts should enter the backend through adapters in this package
instead of calling workflow runtimes directly from FastAPI routes.
"""
from .backoffice_rest_adapter import BackofficeChannelEnvelope, BackofficeRestChannelAdapter
__all__ = ["BackofficeChannelEnvelope", "BackofficeRestChannelAdapter"]

Binary file not shown.

View File

@@ -0,0 +1,192 @@
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"