DeskPilot v0.2 ganha Métricas v1: denominadores explícitos no lugar de uma taxa de aceite de 80%, um guardrail que pode vetar a métrica primária, e um experimento desenhado — mas ainda não rodado, por falta de tenants suficientes
O relatório da semana no DeskPilot dizia: 81,8% das sugestões de categoria e resumo mostradas a um agente foram aceitas ou editadas. Alguém no time de produto arredondou para "80% de aceite" no slide e propôs expandir a sugestão para mais tenants na semana seguinte.
A pergunta que ninguém fez antes do slide: das sugestões aceitas, quantas geraram um ticket que teve que ser reaberto? Quantas terminaram sem nenhuma nota de satisfação do cliente, porque ninguém perguntou? A resposta, tirada dos mesmos dados que geraram o 81,8%, é 63,6% de "fechamento limpo" — ticket resolvido, sem reabertura, com uma nota de satisfação real de 3 ou mais. Uma sugestão editada gerou reabertura; uma sugestão aceita nunca recebeu avaliação do cliente. Nenhuma das duas conta como sucesso na conta de 81,8%, e as duas deveriam.
Esse é o assunto deste capítulo: não construir mais IA, mas construir a métrica que diz se a IA que já existe está ajudando.
Product Engineer conecta eventos e comportamento a decisões de negócio, com denominadores, guardrails e desenho de avaliação proporcional ao tráfego. Uma métrica sem denominador explícito e sem janela declarada não é uma métrica: é um número que parece uma métrica até alguém perguntar "de quantos, exatamente, e desde quando?".
A decisão que este capítulo prepara o leitor para tomar: o produto melhorou a tarefa e o negócio, ou apenas aumentou cliques em "aceitar" e volume de sugestões, sem reduzir retrabalho? A resposta não está em uma taxa de aceite. Está em uma árvore de métricas que liga o resultado ao cliente ao evento bruto, com auditoria de correção em cada elo.
Antes de qualquer árvore de métricas, o exemplo mínimo que sustenta tudo abaixo:
-- Denominador: toda sugestão MOSTRADA a um agente (11 nesta fixture).
-- Numerador: aceita ou editada (9). 9/11 = 81,8%.
SELECT count(*) FILTER (WHERE review_action IN ('accepted','edited'))::float
/ count(*) AS pct_naive_acceptance
FROM ticket_facts WHERE suggestion_category IS NOT NULL;
-- Mesmo denominador (11), numerador diferente: aceita/editada E sem
-- reabertura E com CSAT real >= 3. 7/11 = 63,6%.
SELECT count(*) FILTER (
WHERE review_action IN ('accepted','edited')
AND reopens = 0 AND csat_score >= 3
)::float / count(*) AS pct_clean_close
FROM ticket_facts WHERE suggestion_category IS NOT NULL;
Mesmo denominador, duas perguntas diferentes. "Quantas sugestões o agente não descartou de cara?" e "quantas sugestões terminaram num ticket resolvido, sem retrabalho, com um cliente que confirmou estar satisfeito?" são perguntas distintas, e só a segunda decide se a IA está funcionando. A primeira decide só se a IA está sendo tolerada.
O erro não é calcular 81,8%. É colocar 81,8% sozinho no topo do dashboard e chamá-lo de "taxa de sucesso". A taxa de aceite mede uma decisão de um segundo do agente, tomada antes de qualquer consequência aparecer. A taxa de fechamento limpo mede o que aconteceu depois — se o ticket voltou, se o cliente reclamou, se ninguém sequer perguntou. As duas pertencem ao dashboard, lado a lado, com o rótulo dizendo exatamente o que cada uma é uma proxy de o quê.
Por coincidência desta fixture sintética — não por desenho —, a métrica primária da semana 1 (tickets elegíveis atribuídos corretamente dentro do SLA, 7 de 11) chega ao mesmo 63,6% do fechamento limpo de sugestão (7 de 11 sugestões mostradas), mas são 11 tickets diferentes medindo coisas diferentes: um mede se a fila de atribuição funcionou, o outro mede se uma sugestão específica terminou bem. Duas métricas concordarem em valor numérico não as torna a mesma métrica — é exatamente o tipo de coincidência que confunde um leitor de dashboard apressado, e vale a pena nomeá-la em vez de deixar que pareça corroboração.
Os capítulos 01–05 construíram, em ordem: uma fatia vertical determinística de triagem (import, revisão, atribuição, auditoria); uma máquina de estados para ticket e sugestão, com o papel "ai" estruturalmente excluído de qualquer transição; e uma sugestão de categoria/resumo, validada campo a campo, disponível só em modo sombra, com 47 casos sintéticos e 55 testes. Nenhuma dessas peças mede se a sugestão ajuda um agente de verdade — elas provam que a sugestão é segura de mostrar, não que vale a pena mostrar.
Este capítulo adiciona a peça que faltava: uma árvore de métricas do resultado ao cliente até o evento bruto.
Resultado ao cliente
└─ Ticket resolvido, dentro do SLA, sem reabertura, com satisfação real
└─ Comportamento de valor
└─ Ticket atribuído ao agente certo, dentro do SLA
└─ Drivers
└─ Sugestão de IA aceita/editada sem gerar retrabalho
└─ Regra de roteamento correta
└─ Eventos
└─ ticket.created, ticket.assigned,
suggestion.reviewed, ticket.resolved,
ticket.reopened, csat.received
Cada nível da árvore é uma pergunta que o nível abaixo tenta responder sem provar sozinho. Um evento suggestion.reviewed com action: "accepted" não prova comportamento de valor — só prova que o agente clicou em aceitar. O comportamento de valor só existe quando o ticket termina sem reabertura e com uma nota real. E o resultado ao cliente só existe quando isso acontece dentro do SLA que o cliente contratou, não em qualquer prazo.
A métrica candidata escolhida para o topo desta árvore é tickets elegíveis atribuídos corretamente dentro do SLA:
T-1004 foi criado marcado como duplicate_ticket (inelegível). Dois dias depois, um gestor descobriu que não era duplicata e registrou um evento ticket.eligibility_corrected — não uma edição do evento ticket.created original, que continua dizendo exatamente o que dizia no momento em que foi emitido. A consulta usa a eligibilidade mais recente conhecida por ticket; o histórico completo continua auditável.examples/analytics/events.synthetic.jsonl implementa um contrato de eventos de analytics (schema_version: "analytics.1.0.0"), paralelo ao log de auditoria fechado de 8 chaves que os capítulos 03–05 já usam (AUDIT_SCHEMA_VERSION = "1.0.0", inalterado). São contratos diferentes para perguntas diferentes: o log de auditoria prova quem fez o quê, quando, sem nunca carregar texto de ticket; o stream de analytics carrega os campos que uma métrica de produto precisa (payload com prioridade, categoria, ação de revisão, nota de satisfação), sem nunca virar o registro de conformidade.
| Evento | Significado | Dono | Duplicação | Ausência |
|---|---|---|---|---|
ticket.created | Ticket entra na fila | Motor de regras | event_id idempotente | Nunca ausente por definição |
ticket.eligibility_corrected | Correção retroativa de elegibilidade | Gestor humano | Um por correção | Ausente = elegibilidade original vale |
suggestion.shadow_written | Sugestão de IA gerada | Adaptador (src/ai/shadow.ts, cap. 05) | Um por ticket elegível a sugestão | Ausência = sem sugestão (rollout limita a prioridade normal+) |
suggestion.fallback | Sugestão não gerada (timeout etc.) | Adaptador | Um por tentativa falha | — |
suggestion.reviewed | Agente decide aceitar/editar/rejeitar | Agente | Um por sugestão (tipo novo deste capítulo — ver nota abaixo) | Ausência = sugestão ainda pendente |
ticket.assigned | Ticket atribuído a um agente | Motor de regras | Um por ciclo (reabertura gera novo) | Nunca ausente para ticket elegível resolvido |
ticket.first_response | Primeira resposta ao cliente | Agente | Um por ciclo | Pode faltar em ticket ainda aberto |
ticket.resolved | Ticket resolvido | Agente | Um por ciclo | — |
ticket.reopened | Cliente reabre | Sistema (resposta inbound) | Um por reabertura | Ausência = nunca reaberto |
csat.received | Nota do cliente | Cliente | No máx. um por ciclo final | Tratada como "desconhecido", nunca como sucesso silencioso |
agent.activated | Agente conclui primeiro ticket com sugestão revisada | Sistema | Um por agente, para sempre | Ausência = agente nunca ativado |
O timezone é UTC em todo o pipeline — occurred_at e ingested_at são TIMESTAMPTZ, e a janela semanal usa date_trunc('week', ...) sobre o valor UTC, não o horário local de nenhum tenant. Um capítulo futuro que precisar de janelas por fuso do cliente precisa declarar essa mudança explicitamente, não inferir do campo existente.
Nota honesta sobre uma lacuna real: a rota POST /tickets/:id/suggestions/review já existe desde o capítulo 05 e já persiste a decisão do agente — mas nunca emitiu um evento de auditoria para essa decisão. suggestion.reviewed é um tipo de evento que este capítulo define na tracking plan e usa na fixture sintética; implementá-lo de fato em src/api/server.ts fica registrado como pendência em handoff.md, não escondido atrás de uma fixture que finge que o código já emite esse evento.
examples/analytics/events.synthetic.jsonl tem 93 linhas, 14 tickets, 3 tenants deliberadamente desbalanceados (t_apex com 9 tickets, t_beta com 3, t_gamma com 2), e quatro problemas de dados reais embutidos de propósito:
ticket.created de T-1008 aparece duas vezes, mesmo event_id, simulando reentrega de um produtor "pelo menos uma vez". SELECT DISTINCT ON (event_id) ... ORDER BY event_id, ingested_at resolve isso — testado em verification.md (2 linhas cruas, 1 após dedup).ticket.first_response de T-1007 ocorreu às 09:30, mas só chegou ao pipeline às 15:35 — depois do próprio ticket.resolved do mesmo ticket, que ocorreu às 15:00 e chegou às 15:05. Toda consulta neste pacote ordena por occurred_at, nunca por ingested_at nem por ordem de arquivo; o tempo até primeira resposta calculado é 90 minutos (09:30 − 08:00), não um número inflado pela chegada tardia.T-1002 foi atribuído, resolvido, reaberto pelo cliente, atribuído de novo e resolvido de novo. A métrica primária usa só a primeira atribuição (45 minutos, dentro do SLA de high); a segunda atribuição pertence à análise de retrabalho, não à atribuição inicial — contar as duas dobraria uma oportunidade de atribuição em duas.t_gamma, com 2 tickets em 2 dias, não tem tráfego suficiente para nenhuma leitura quantitativa confiável — e é tratado como tal, nunca forçado a caber na mesma régua estatística de t_apex.examples/analytics/metrics.sql (11 views Postgres, rodadas contra postgres:17.6 em container series-ai-pe06-pg, removido ao final) e examples/analytics/check-metrics.ts (13 testes Node nativos, implementação independente, sem tocar banco algum) concordam número a número nesta fixture — ver verification.md para a saída completa de ambos.
suggestion_downstream_quality classificava um ticket sem CSAT como clean_close (por um OR csat_score IS NULL indevido), e a soma das três categorias (10) não batia com o total de sugestões aceitas/editadas (9) — o erro apareceu porque as duas implementações discordavam, não porque alguém revisou a lógica manualmente.ticket.eligibility_corrected), nunca como UPDATE no evento original — o mesmo princípio de log append-only que o capítulo 03 já aplica ao log de auditoria, estendido para o stream de analytics.t_apex/t_beta desta fixture. Com um tenant por braço, não existe variância entre braços para estimar; qualquer número aqui seria uma fórmula de livro-texto aplicada a um lugar onde ela não se sustenta.Sintoma: um slide de produto diz "80% de aceite" e propõe expandir o rollout com base só nesse número.
Causa: a taxa de aceite mede uma decisão de um segundo, antes de qualquer consequência do ticket aparecer — nunca vê reabertura, nota do cliente ou retrabalho.
Resposta: suggestion_downstream_quality classifica cada sugestão aceita/editada em três categorias exaustivas e não sobrepostas — clean_close (7), rework (1), quality_unknown (1) — e o dashboard mostra as duas taxas lado a lado: 81,8% de aceite, 63,6% de fechamento limpo (clean_close sobre o total de sugestões mostradas, não só sobre as aceitas). A diferença entre os dois números é o tamanho do problema que "aceite" sozinho escondia.
Sintoma: uma consulta reporta 100% de aceitação de sugestão.
Causa: o denominador da consulta é "sugestões aceitas ou editadas" — o mesmo conjunto do numerador. Por construção, essa fração é sempre 100%, não importa quantas sugestões os agentes rejeitaram de fato.
Resposta: suggestion_acceptance_WRONG em metrics.sql existe só para mostrar essa armadilha lado a lado com suggestion_funnel, que usa o denominador correto — toda sugestão mostrada, incluindo as 2 rejeitadas. 9/9 = 100% (errado) contra 9/11 = 81,8% (correto, e ainda assim insuficiente sozinho, ver Falha 1).
Sintoma: um número único, "61,5% desde o início", num dashboard que deveria mostrar tendência.
Causa: somar tickets de semanas com volume completamente diferente (11 elegíveis na semana 1, 2 na semana 2) num único pool esconde tanto a diferença real entre semanas quanto o fato de que a semana 2 é pequena demais para confiar isoladamente.
Resposta: primary_metric_by_week reporta 63,6% (semana 1, 11 tickets) e 50,0% (semana 2, apenas 2 tickets) separadamente; primary_metric_cumulative_WRONG existe só para mostrar o 61,5% blendado ao lado, nomeado como o padrão a evitar, não como uma alternativa válida.
Sintoma: "a taxa caiu de 63,6% para 50% entre as semanas — a IA piorou".
Causa: a semana 2 tem um único tenant novo (t_gamma), um agente que nunca tinha trabalhado antes (agent_5, ativado nesta mesma semana), e apenas 2 tickets — qualquer um desses fatores, sozinho, explica uma queda de 13,6 pontos percentuais sem que a IA tenha mudado em nada. Não existe randomização entre as duas semanas: são períodos diferentes, tenants diferentes, sem controle.
Resposta: nenhuma alegação causal aparece neste pacote. examples/experiment-plan.md desenha o experimento correto — randomização por tenant, com a unidade justificada por risco de interferência entre agentes da mesma fila — e declara explicitamente, na própria seção de poder estatístico, que dois tenants (um por braço) não sustentam nenhum p-value. "Ainda não temos tenants suficientes" é a resposta certa nesta sessão, não um placeholder para um número que será preenchido depois sem mais tenants.
A métrica primária desta sessão — 63,6% dos tickets elegíveis da semana 1 atribuídos corretamente dentro do SLA — prova que a fila de atribuição funciona para pouco mais de 6 em cada 10 tickets elegíveis nesta fixture sintética. Não prova que a sugestão de IA causou esse número: T-1009, atribuído corretamente dentro do SLA, nunca recebeu sugestão nenhuma (prioridade low, fora do gate de rollout), e T-1003, que recebeu uma sugestão aceita, foi atribuído ao agente errado e fora do SLA de qualquer forma — um caso de erro grave (sugestão aceita, atribuição incorreta) que nenhuma taxa agregada sozinha revelaria sem uma consulta dedicada a ele.
O guardrail de reabertura após aceite — 0% nesta fixture (0 de 7 sugestões aceitas geraram reabertura) — está limpo, mas por amostra pequena: um único caso a mais mudaria esse número de forma desproporcional. O único ticket reaberto do conjunto (T-1002) seguiu uma sugestão editada, não aceita, então não entra no numerador deste guardrail específico — o que é uma decisão de desenho documentada, não um jeito de esconder o reabertura da conta: ele continua contando na taxa de reabertura geral por tenant (time_to_stage_by_tenant, 12,5% para t_apex).
A taxa de ativação de agente — 83,3%, 5 de 6 agentes que já foram donos de algum ticket revisaram ao menos uma sugestão — tem uma explicação estrutural, não misteriosa: agent_6 só trabalhou o único ticket de prioridade low do conjunto, que o próprio rollout exclui de receber sugestão. Não é um agente resistente à ferramenta; é um agente que a ferramenta ainda não alcança.
O que este capítulo não mede, e diz isso em vez de estimar: impacto de negócio real, retenção de tenant, ou qualquer efeito causal de mostrar a sugestão versus não mostrar. examples/experiment-plan.md é o desenho para medir isso quando houver tenants suficientes — não uma substituição para a medição em si.
Custo: examples/unit-economics.csv atribui a cada uma das 12 tarefas (11 sugestões mostradas mais uma tentativa de fallback) um custo de inferência (Claude Haiku 4.5, US$ 1/US$ 5 por milhão de tokens de entrada/saída, preço revalidado em claude.com/pricing em 2026-09-28), um custo de infraestrutura estimado, minutos de revisão humana a uma taxa de cenário (US$ 35/hora, carregada, declarada como suposição didática) e minutos de retrabalho quando houve reabertura. O resultado: custo médio de US$ 1,24 por tarefa, mas US$ 2,13 por tarefa útil (fechamento limpo) — porque o único ticket com retrabalho (T-1002, US$ 10,21, dominado pelos 15 minutos extras de um segundo ciclo de atendimento) custou sozinho mais que as sete tarefas de fechamento limpo somadas (US$ 3,22). Tempo poupado na primeira sugestão não significa despesa menor quando um único retrabalho concentra a maior parte do custo total — o oposto do que "80% de aceite" sugeriria a quem só olhasse esse número.
Segurança: nenhum evento de analytics carrega texto de ticket, nome de cliente ou qualquer PII — os payloads são fechados a categoria, ação, nota numérica e identificadores. A correção de elegibilidade (ticket.eligibility_corrected) preserva o evento original em vez de sobrescrevê-lo, mantendo a trilha de auditoria intacta mesmo quando uma decisão inicial estava errada. Isolamento por tenant não foi reavaliado neste capítulo — herdado sem alteração dos capítulos 03–05, onde cross_tenant_denied já é testado no domínio.
Reversibilidade: o guardrail de reabertura após aceite, por desenho, pode reverter uma sugestão de "mostrada" para "sombra apenas" sem excluir nenhum dado — os eventos continuam sendo gerados e registrados, só param de chegar à tela do agente. Nenhuma decisão deste capítulo é automática: a rotina semanal de leitura (examples/experiment-plan.md, seção 7) sempre termina em "continuar", "limitar rollout" ou "retirar a IA", registrado com o motivo, nunca em silêncio.
Básico. Usando time_to_stage_by_tenant, calcule o p50 de tempo até primeira resposta só para t_beta. Critério de aceite: sua consulta usa WHERE tenant_id = 't_beta' sobre a mesma view, sem duplicar a lógica de ticket_facts.
Solução comentada: SELECT p50_minutes_to_first_response FROM time_to_stage_by_tenant WHERE tenant_id = 't_beta'; retorna 107.5 — a view já agrupa por tenant, então filtrar é suficiente; reescrever a lógica do zero arriscaria divergir da versão testada em check-metrics.ts.
Intermediário. Escreva uma consulta de checagem de contaminação: nenhum assignee_id deveria aparecer em mais de um tenant_id em ticket.assigned. Critério de aceite: a consulta roda contra events_dedup e retorna zero linhas nesta fixture.
Solução comentada:
SELECT payload->>'assignee_id' AS agent_id, count(DISTINCT tenant_id) AS tenants
FROM events_dedup
WHERE type = 'ticket.assigned'
GROUP BY 1
HAVING count(DISTINCT tenant_id) > 1;
Zero linhas — cada agente desta fixture é escopado a um único tenant, a mesma checagem que examples/experiment-plan.md (seção 3) descreve como pré-condição para um experimento por tenant sem contaminação.
Avançado. Escreva uma consulta ou um teste em check-metrics.ts que encontre "erro grave": sugestão aceita (não editada) cujo ticket foi atribuído incorretamente (correct_assignment = false). Critério de aceite: a consulta identifica exatamente T-1003 nesta fixture, e você explica por que uma taxa agregada de aceite não teria revelado esse caso sozinha.
Solução comentada:
SELECT ticket_id FROM ticket_facts
WHERE suggestion_category IS NOT NULL
AND review_action = 'accepted'
AND correct_assignment = false;
-- T-1003
A taxa de aceite trata T-1003 como um sucesso (a sugestão foi aceita); a taxa de fechamento limpo o exclui por falta de CSAT, mas por um motivo diferente do erro real (atribuição errada, não insatisfação do cliente). Só uma consulta dedicada ao par aceite+atribuição-incorreta revela o erro grave — motivo pelo qual examples/experiment-plan.md trata esse tipo de caso individualmente, nunca diluído numa média.
Este capítulo entrega ao capítulo 07 exatamente três coisas, sem depender de nenhuma memória de sessão: a árvore de métricas com denominador e janela por nível; o pipeline de dedup/ordenação/correção que qualquer evento futuro (inclusive suggestion.reviewed, ainda não emitido pelo código, só pela tracking plan) precisa respeitar; e um desenho de experimento pronto para rodar assim que houver tenants suficientes para gerar um MDE real, não uma fórmula aplicada a uma amostra de um tenant por braço. Veja ../product-designer-serie-07/prompt.md para o que vem a seguir.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares
81.8% of DeskPilot's AI suggestions were accepted or edited. That number looks great until someone asks how many of those suggestions led to a reopened ticket, or a missing satisfaction score,…
A rule, an AI suggestion, and a human agent's decision, for the same synthetic ticket: only one of them can ever change the ticket's state. From that constraint, DeskPilot gains a typed…
DeskPilot's tests were green. A synthetic provider outage, an actual local restore, and a release review exposed what those tests did not establish: whether a support agent can safely use the pilot.
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.