Pular para conteúdo

Handoff e notificação da equipe

Conceito e estado

Handoff é a transferência do atendimento automático para uma fila humana. HANDOFF_PENDING significa que o atendimento aguarda alguém; HUMAN significa que um operador assumiu. Enviar uma notificação não atribui a conversa nem muda automaticamente para HUMAN. O painel Atendimento executa assumir, responder e finalizar.

O fluxo principal em src/core/application/process_message_use_case.py gera resumo, verifica se a mensagem continua atual, persiste HANDOFF_PENDING e só então chama o notificador. Antes da notificação verifica se a sessão ativa ainda coincide com a sessão/estado esperado; takeover ou encerramento concorrente pode suprimi-la. execute_confirm_action() também termina nesse estado após cadastro validado, tanto no sucesso quanto no caminho api_failure.

O enum HandoffReason em src/core/domain/values.py contém empresa_grande, reclamacao, cancelamento, pos_venda, recusa_texto, incerteza, pedido_cliente, llm_invalid_json, llm_timeout, api_failure, kit_externo, timeout. Motivo enum não implica que todo uso termina na mesma transição: timeout também marca encerramento de sessões abandonadas. Consulte ações e estados para os gatilhos reais.

Arquivos, Protocol e seleção

Arquivo Responsabilidade
src/core/integrations/handoff/interface.py HandoffNotifier, HandoffNotifyResult
src/core/integrations/handoff/stub.py HandoffNotifierStub
src/core/integrations/handoff/whatsapp.py HandoffNotifierWhatsApp
src/core/integrations/handoff/email.py HandoffEmailConfig, HandoffEmailNotifier, SesClient
src/core/application/operator_availability.py Whitelist disponível, mensagem operacional e posição de fila
src/core/application/operational_summary.py Resumo estruturado e fallback
src/core/application/handoff_renotify.py Renotificação e abandono
src/handoff_renotify/app.py Handler horário
class HandoffNotifier(Protocol):
    def notify(self, session: Session) -> StubResponse: ...

build_handoff_notifier() em src/core/integrations/factory.py usa HANDOFF_NOTIFIER_ENABLED e HANDOFF_NOTIFIER_TYPE. Flag falso ou tipo stub devolve simulação. Tipo whatsapp exige instâncias de WhatsAppClient e SessionRepository; tipo email exige remetente e destinatários. Tipo desconhecido lança RuntimeError. Habilitar notificador WhatsApp sem habilitar Cloud pode chamar um WhatsApp stub: a seleção das duas integrações é independente.

Contrato do retorno e resumo operacional

HandoffNotifyResult estende StubResponse com notified, operator_phone e queue_position. success=True, notified=False pode significar fila legítima ou stub; não significa que a equipe recebeu algo.

{
  "success": true,
  "mock_id": null,
  "detail": "queued",
  "error_kind": null,
  "notified": false,
  "operator_phone": null,
  "queue_position": 2
}

O stub retorna success=True, mock_id="mock_handoff_notification", notified=False, detail="stub_logged_not_notified". O resultado de SES é um StubResponse simples; a orquestração interpreta sucesso sem atributo notified como notificação realizada.

O resumo salvo em Session.operational_summary é um mapa com motivo_encerramento, servico_identificado, segmento, pontos_chave, dados_coletados, proxima_acao, resumo_texto. É gerado por OPENAI_MODEL_SUMMARY. Erro IntegrationError ou JSON inválido leva a objeto mínimo de fallback, mantendo o histórico disponível. O tratamento não captura toda exceção Python possível. O notificador não deve reinventar o motivo nem recalcular o estado.

Canal WhatsApp: disponibilidade e entrega

O notificador calcula a interseção entre whitelist efetiva e operadores disponíveis no dia local. A disponibilidade exige opt-in (disponivel ou entrar), mesma data em BUSINESS_HOURS_TZ, idade não negativa e dentro de HANDOFF_OPERATOR_AVAILABILITY_TTL_HOURS. Os dias/horários comerciais não bloqueiam essa entrega: o opt-in explícito controla disponibilidade. Todos os operadores elegíveis são notificados; segmento é informação da conversa e filtro da tela, não restrição de destinatário.

Configuração legada de operadores

HANDOFF_OPERATOR_AGRO, HANDOFF_OPERATOR_HUMANO e HANDOFF_OPERATOR_GERAL continuam carregados por compatibilidade. O roteamento atual de HandoffNotifierWhatsApp não usa esses números para conceder acesso nem restringe entrega por área. A fonte vigente é whitelist efetiva + disponibilidade diária.

O request real é WhatsAppClient.send_message(to=operator_phone, text=build_operator_message(...)), usando a autenticação e payload descritos em WhatsApp. Inclui contexto operacional para encaminhar o atendimento e a URL configurada do painel. Se houver sucesso em pelo menos um destinatário, retorna success=True, notified=True, o primeiro operador alcançado e detail="notified:<sucessos>/<elegiveis>". Isso não garante entrega para todos.

Sem operadores disponíveis, retorna sucesso enfileirado com posição 1-based da fila global derivada das sessões HANDOFF_PENDING. Se todos os envios falharem, retorna success=False, notified=False, detail="send_failed"; a sessão permanece pendente e o cliente recebe mensagem de espera, sem promessa de prazo. Não há fila externa independente de notificações.

Canal SES: request, resposta e segurança

O client usa boto3.client("ses"), autenticado por credenciais/role AWS. Configuração: HANDOFF_EMAIL_FROM, HANDOFF_EMAIL_TO (CSV), HANDOFF_EMAIL_SUBJECT_PREFIX, HANDOFF_SES_REGION, HANDOFF_SES_CONFIGURATION_SET. A factory ordena destinatários para consistência.

ses.send_email(
    Source="equipe@example.invalid",
    Destination={"ToAddresses": ["operacao@example.invalid"]},
    Message={
        "Subject": {"Data": "[GoGenetic] Handoff pendente - +55***0000", "Charset": "UTF-8"},
        "Body": {"Text": {"Data": "<CONTEXTO_OPERACIONAL_SANITIZADO>", "Charset": "UTF-8"}},
    },
)

ConfigurationSetName é acrescentado se configurado. Resposta {"MessageId":"ses-demo"} vira StubResponse(success=True, mock_id="ses-demo", detail="handoff_email_sent"). Ausência de MessageId ainda é sucesso sem ID. Falha final retorna handoff_email_failed:<ClasseDaExcecao>. send_smoke() monta outro corpo deliberadamente sintético.

O assunto usa telefone mascarado. O corpo operacional contém também o telefone completo do cliente, necessário para ação humana, além de sessão, motivo, segmento, serviço, resumo e dados relevantes sanitizados. O sanitizador mascara email/documentos e chaves sensíveis; isso não torna o email anônimo. Controle destinatários, acesso à caixa e retenção. OPERATOR_UI_BASE_URL adiciona link; sem URL, instrui o operador a enviar entrar. O link de login e a sessão do operador têm autenticação própria, descrita em operadores.

Timeout, retries e idempotência

Camada Política
WhatsApp notifier Sem loop próprio; herda timeout/retry do WhatsAppClient para cada destinatário
SES HANDOFF_TIMEOUT_SECONDS default 10 configura connect/read; HANDOFF_RETRY_MAX default 2 controla loop externo
SES transitório HTTP 429/5xx, códigos de throttling/timeout/indisponibilidade, ou exceção sem response estruturada podem repetir
SES permanente HTTP 4xx não transitório retorna falha; sem retry externo
Espera SES min(2**attempt, 8) segundos
SDK SES Configuração botocore retries={"max_attempts":1,"mode":"standard"} existe além do loop do notificador

Persistir a sessão antes de notificar e marcar renotificações reduz duplicações. Não existe chave de idempotência remota para SES/WhatsApp. Uma aceitação externa seguida de falha de gravação local continua sendo uma janela de duplicidade possível.

Renotificação e takeover

HandoffRenotifyFunction é executada a cada hora pelo SAM. renotify_stale_handoffs() examina HANDOFF_PENDING com limite padrão de 24 h desde updated_at. O limiar é parâmetro Python, não env própria.

  1. Verifica marcadores internos e de cliente; repara marcador legado quando encontra resposta já registrada no histórico recente.
  2. Renotifica equipe e persiste handoff_renotified_at/contagem antes de tentar resposta ao cliente.
  3. Só envia lembrete ao cliente se houver inbound nos últimos 24 h entre as 50 mensagens recentes; fora da janela guarda a notificação interna e registra skipped_outside_window.
  4. Após envio ao cliente, grava handoff_customer_renotified_at e mensagens de sistema/outbound. Se o cliente falhar, a próxima execução pode tentar somente essa parte sem repetir a equipe.

close_abandoned_sessions() na mesma execução fecha TRIAGE, QUALIFYING, DATA_COLLECT, CONFIRM sem atualização por 24 h; marca timeout, estágio CRM perdido e usa transição condicional. Não fecha automaticamente HUMAN ou HANDOFF_PENDING por esse mecanismo.

Assumir no painel muda para HUMAN por operação atômica; novas mensagens do cliente passam ao humano e não invocam o agente. Finalizar muda para CLOSED e produz a projeção comercial. Veja atendimento humano.

Logs, testes e como substituir

Eventos úteis: stub_handoff_notify, handoff_operator_notified (campo sent), handoff_queued, handoff_email_sent, handoff_email_failed, handoff_notify_exception, handoff_notify_failed, handoff_renotify_notify_failed, handoff_renotify_queued, handoff_renotify_outside_window, handoff_renotify_whatsapp_failed, handoff_renotify_race_lost. Falha do notificador não desfaz o estado pendente.

Testes em tests/unit/integrations/test_handoff_whatsapp.py, test_handoff_email_notifier.py, test_factory.py, tests/unit/application/test_handoff_renotify.py e tests/unit/lambdas/test_handoff_renotify.py cobrem seleção, fila, falha parcial, logs, SES, janela e marcadores. O smoke SES em src/core/application/smokes/handoff_ses_smoke.py depende de SMOKE_ALLOW_REAL_EMAIL; uma execução bem-sucedida envia email real e não foi realizada nesta tarefa.

python -m pytest tests/unit/integrations/test_handoff_email_notifier.py -q

Para outro canal, implemente HandoffNotifier, acrescente seleção/configuração na factory e preserve a semântica success/notified. Mantenha transição/persistência na aplicação, autentique o provider e teste falha após entrega, fila sem elegíveis e takeover concorrente. Não transforme success=True em atribuição humana: são operações diferentes.