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.
- Verifica marcadores internos e de cliente; repara marcador legado quando encontra resposta já registrada no histórico recente.
- Renotifica equipe e persiste
handoff_renotified_at/contagem antes de tentar resposta ao cliente. - 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. - Após envio ao cliente, grava
handoff_customer_renotified_ate 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.