Appearance
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:
- decide se a pesquisa se aplica (há ticket? a janela da Meta ainda está aberta? a saída pediu pesquisa?);
- envia a pergunta como lista interativa, uma linha por nota;
- valida a resposta contra a escala declarada;
- grava
rating,ratingScalee (se pedido)ratingCommentnoTicket; - 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ção | skipReason | Por quê |
|---|---|---|
Sem {{ticket}} no contexto | noTicket | não há onde gravar a nota |
| Ticket encerrado por janela de 24h da Meta expirada | metaWindowClosed | a pergunta não chegaria ao contato, e o bloco ficaria esperando resposta impossível |
onlyWhenMarked ligado e a saída não marcou pesquisa | notMarked | respeita 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.
| Resposta | Comportamento |
|---|---|
| Nota válida | grava no ticket, publica {{survey}}, sai pelo caminho de quem respondeu (outputConditions → defaultOutput) |
| Fora da escala, com tentativas restantes | envia invalidMessage e repete a pergunta |
| Fora da escala, tentativas esgotadas | sai pelo skipTarget com skipReason: "invalidAnswer" |
Nada, até o prazo de noAnswer | vai 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 nota →
noAnswer.target; - estágio do comentário → o
defaultOutputdo 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 deendAttendance. 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:
| Token | Conteú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
closedAtno período ecloseKindfora 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ário | Comportamento |
|---|---|
| Falha ao gravar a nota no banco | erro 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 pesquisa | sai pelo skipTarget com skipReason: "noTicket" |
| Sessão atravessou um deploy e perdeu o estágio | a resposta é lida como nota (é a pergunta que o bloco faz ao entrar) |
| Estágio de comentário sem nota no estado | só o comentário se perde — a nota já foi persistida quando chegou |
| Segunda pesquisa na mesma sessão | o estado interno é limpo em toda saída do bloco, então começa do zero |
Bloco de pesquisa como onboardingBlockId ou bloco de exceção | rejeitado pelo schema: os dois exigem type: "standard" |
Arquivos relevantes
| Arquivo | Papel |
|---|---|
packages/flow-ai-types/src/flow-definition.ts | SurveyBlock, SurveyScale |
packages/flow-ai-types/src/survey-context.ts | contrato de {{survey}} |
packages/flow-ai-types/src/flow-schema.ts | validação Zod do bloco |
services/flow-ai-engine/src/runtime/survey.ts | decisões puras + gravação da nota |
services/flow-ai-engine/src/runtime/execute.ts | entrada no bloco e processamento da resposta |
services/flow-ai-engine/src/inactivity-handler.ts | prazo de não-resposta |
services/flow-ai-core/src/http/routes/reports-helpdesk-queries.ts | agregações do relatório |
apps/flow-ai-core-ui/components/studio/SurveyBlockTab.tsx | configuração no builder |
apps/flow-ai-core-ui/app/(studio)/.../relatorios/atendimento/satisfacao/page.tsx | página do relatório |
Relacionados
- Atendimento humano — o bloco de atendimento, suas saídas por motivo de encerramento e o contrato do
{{ticket}}.