Skip to content

Pesquisa de satisfação (CSAT)

Depois de um atendimento humano, o flow pode perguntar ao contato uma nota de satisfação. A nota fica no próprio ticket, volta ao contexto do flow — a ponto de poder rotear por ela — e alimenta o relatório Relatórios → Atendimento → Satisfação.

Entregue na #496, fechando a última parte da #491. Antes dela existia só a marcação de pesquisa nas saídas do bloco de atendimento ({{ticket.exit.survey}}): um sinal para o flow, sem nada que perguntasse, gravasse ou medisse.


Visão geral

Quem pergunta é um bloco do flow — o bloco de pesquisa de satisfação —, colocado no caminho de saída do atendimento humano. Ele:

  1. decide se a pesquisa se aplica (há ticket? a janela da Meta ainda está aberta? a saída pediu pesquisa?);
  2. envia a pergunta como lista interativa, uma linha por nota;
  3. valida a resposta contra a escala declarada;
  4. grava rating, ratingScale e (se pedido) ratingComment no Ticket;
  5. devolve o resultado ao contexto, em {{survey}}, e remenda o {{ticket}} com a nota.

Por que um bloco, e não "mensagem + input + ação"

Porque a nota só serve se for comparável. Deixar escala e validação para cada autor montar à mão produziria um flow com 1–5, outro com 1–10 e um terceiro aceitando texto livre — e a média por fila do relatório somaria coisas diferentes. Aqui a escala é um campo do bloco, e é o mesmo campo que o relatório lê para normalizar.


Tipos TypeScript relevantes

ts
/** packages/flow-ai-types/src/flow-definition.ts */
export type SurveyScale = 5 | 10

export type SurveyBlock = {
  type: "survey"
  id: string
  scale: SurveyScale              // 5 → pergunta 1..5; 10 → pergunta 1..10
  question: string                // suporta {{var}}
  buttonText: string              // abre a lista (limite Meta: 20 chars)
  lowLabel?: string               // descrição da nota 1, ex.: "Muito ruim"
  highLabel?: string              // descrição da nota máxima
  onlyWhenMarked: boolean         // exige {{ticket.exit.survey}} === true
  comment?: { enabled: boolean; question: string }
  inputActions: Action[]
  outputActions: Action[]
  outputConditions: OutputCondition[]
  defaultOutput: BlockTarget      // caminho de QUEM RESPONDEU
  skipTarget?: BlockTarget        // saiu SEM nota (ver tabela abaixo)
  noAnswer?: {                    // prazo para a nota
    timeoutSeconds: number
    target: { type: "block"; blockId: string }
  }
  maxRetries?: number             // repetições quando a resposta está fora da escala
  invalidMessage?: string
}
ts
/** packages/flow-ai-types/src/survey-context.ts — publicado em {{survey}} */
export type SurveyContext = {
  answered: boolean
  rating: number | null
  scale: number
  comment: string | null
  skipReason: "noTicket" | "notMarked" | "metaWindowClosed" | "invalidAnswer" | null
  at: string                      // ISO 8601
}

A escala 0–10 (NPS) não existe de propósito: a pergunta é uma lista interativa da Meta, que aceita no máximo 10 linhas, e 0–10 precisaria de 11.


Fluxo de execução

1. Entrada no bloco — a pesquisa se aplica?

O bloco tira de {{ticket}} (#490) o ticket em que vai gravar. A regra de pular é uma só: não perguntar o que não se pode registrar nem entregar.

SituaçãoskipReasonPor quê
Sem {{ticket}} no contextonoTicketnão há onde gravar a nota
Ticket encerrado por janela de 24h da Meta expiradametaWindowCloseda pergunta não chegaria ao contato, e o bloco ficaria esperando resposta impossível
onlyWhenMarked ligado e a saída não marcou pesquisanotMarkedrespeita a decisão já tomada no bloco de atendimento

Pulando, o flow segue para o skipTarget (ou o defaultOutput, se não houver) sem rodar as outputActions — elas descrevem uma pesquisa respondida. É a mesma escolha do caminho de deflexão da #495.

2. A pergunta

Uma lista com uma linha por nota, em ordem crescente — 1 primeiro. Numa lista vertical a primeira opção é a que recebe mais toques, e começar pelo "excelente" mediria a lista, não o atendimento. lowLabel e highLabel viram descrição da primeira e da última linha.

3. A resposta

Aceita o toque na lista (payload csat_N) e o número digitado — muita gente responde "5" em vez de abrir a lista. Não vai além disso: "nota 5, mas demorou" não vira 5. Gravar a nota errada é pior que repetir a pergunta.

RespostaComportamento
Nota válidagrava no ticket, publica {{survey}}, sai pelo caminho de quem respondeu (outputConditionsdefaultOutput)
Fora da escala, com tentativas restantesenvia invalidMessage e repete a pergunta
Fora da escala, tentativas esgotadassai pelo skipTarget com skipReason: "invalidAnswer"
Nada, até o prazo de noAnswervai para noAnswer.target

4. O comentário

Com comment.enabled, o bloco faz uma segunda pergunta, de resposta livre. A nota é gravada quando chega, antes do comentário: quem deu 5 e não quis comentar já contribuiu com a medida, e perder isso porque a segunda pergunta ficou sem resposta seria trocar o dado principal pelo acessório.

O estágio corrente vive em session.variables._surveyStage (com _surveyRating, _surveyRetries e _surveyBlockId). O prefixo _ é a convenção interna que o espelho de contactVariables (#376) ignora, então nada disso vaza para as variáveis permanentes do contato.

5. O prazo de não-resposta

O timer é a chave Redis inactivity:{sessionId}, a mesma do controle de ociosidade — apagada a cada mensagem do contato, que é exatamente a semântica desejada. Mas ele não depende do inactivityControl do flow: "ninguém respondeu a pesquisa" é uma saída de produto configurada no bloco, não o controle genérico de inatividade.

  • estágio da notanoAnswer.target;
  • estágio do comentário → o defaultOutput do bloco, porque a nota já foi dada e o atendimento já foi avaliado. Só funciona se esse destino for um bloco concreto: o prazo estoura fora de um run do flow, sem contexto para interpolar {{variável}} nem para aplicar os modos reset/soft de endAttendance. Nos outros casos a sessão fica onde está e expira — a nota está salva.

Timeout no comentário vai direto ao defaultOutput

Pelo mesmo motivo, esse caminho não avalia as outputConditions: elas rodam dentro de um run do flow, e este não é um. Consequência prática: quem deu nota 2 e não comentou até o prazo não passa pela condição de recuperação por nota — cai no defaultOutput. Quem depende de rotear por nota deve preferir deixar o comentário desligado, ou tratar a recuperação também a partir do bloco de defaultOutput.

Sem noAnswer configurado, nenhum timer é criado: a pesquisa fica aberta até a sessão expirar. É uma escolha legítima (não insistir com quem não quis responder), mas tem de ser explícita.


A nota no contexto do flow

Duas leituras da mesma coisa:

TokenConteúdo
{{survey.rating}}a nota da pesquisa que acabou de acontecer
{{ticket.rating}}a mesma nota, dentro do objeto do ticket

O é regravado pelo bloco ao registrar a nota. É isso que permite uma condição de saída por nota — ticket.rating com comparador lessOrEqual, valor 3, para o caminho de recuperação — já no próprio bloco de pesquisa.

ticket.rating é null no encerramento

No objeto montado pelo core quando o atendimento fecha, rating é sempre null: a pergunta só vai ao contato depois. Só depois de o bloco de pesquisa rodar é que {{ticket.rating}} resolve. Para notas de atendimentos passados, a fonte é o Ticket no Postgres (o relatório), não este objeto.

{{survey}} É espelhado em Contact.contactVariables (o espelho só exclui as chaves com prefixo _ e o próprio ticket), então a última pesquisa aparece nos detalhes do contato. Quem ler {{contact.survey}} num atendimento futuro está lendo a pesquisa anterior.


Relatório

Relatórios → Atendimento → Satisfação. Definições, porque elas decidem se o número significa algo:

  • população: tickets humanos do router com closedAt no período e closeKind fora dos motivos de continuação (agent_transfer, queue_transfer, handoff_replaced). Ancorar no encerramento — e não na criação, como o relatório de tempos — é o que torna a taxa de resposta legível: a pesquisa acontece depois do fechamento, então "encerrados no dia" é a base de quem podia responder.
  • média normalizada, nunca a média crua: (rating - 1) / (ratingScale - 1), que põe a nota mínima em 0% e a máxima em 100% em qualquer escala. Um 4 numa escala de 5 e um 4 numa de 10 não são a mesma satisfação. avgOn5 é a mesma medida reexpressa em 1..5, para leitura humana.
  • distribuição por escala, sem fundir: um período em que a operação trocou a escala tem duas distribuições, e mostrá-las juntas inventaria notas.
  • taxa de resposta: respostas ÷ encerrados no período.

Endpoint: GET /routers/:routerId/reports/helpdesk/satisfaction?from&to&dimension, com dimension em queue ou agent. Não há quebra por etiqueta: etiqueta faz fan-out (um ticket conta em cada uma), o que duplicaria a mesma nota na média.


Cenários de erro

CenárioComportamento
Falha ao gravar a nota no bancoerro registrado no log, contexto atualizado do mesmo jeito e o flow continua. O contato acabou de responder e não pode ficar pendurado por causa de uma coluna: perde-se a nota no relatório, não o turno
O flow sobrescreveu a variável ticket durante a pesquisasai pelo skipTarget com skipReason: "noTicket"
Sessão atravessou um deploy e perdeu o estágioa resposta é lida como nota (é a pergunta que o bloco faz ao entrar)
Estágio de comentário sem nota no estadosó o comentário se perde — a nota já foi persistida quando chegou
Segunda pesquisa na mesma sessãoo estado interno é limpo em toda saída do bloco, então começa do zero
Bloco de pesquisa como onboardingBlockId ou bloco de exceçãorejeitado pelo schema: os dois exigem type: "standard"

Arquivos relevantes

ArquivoPapel
packages/flow-ai-types/src/flow-definition.tsSurveyBlock, SurveyScale
packages/flow-ai-types/src/survey-context.tscontrato de {{survey}}
packages/flow-ai-types/src/flow-schema.tsvalidação Zod do bloco
services/flow-ai-engine/src/runtime/survey.tsdecisões puras + gravação da nota
services/flow-ai-engine/src/runtime/execute.tsentrada no bloco e processamento da resposta
services/flow-ai-engine/src/inactivity-handler.tsprazo de não-resposta
services/flow-ai-core/src/http/routes/reports-helpdesk-queries.tsagregações do relatório
apps/flow-ai-core-ui/components/studio/SurveyBlockTab.tsxconfiguração no builder
apps/flow-ai-core-ui/app/(studio)/.../relatorios/atendimento/satisfacao/page.tsxpágina do relatório

Relacionados

  • Atendimento humano — o bloco de atendimento, suas saídas por motivo de encerramento e o contrato do {{ticket}}.

flow-ai — plataforma proprietária de atendimento via WhatsApp