Files
DeskcommCRM/docs/prd/05-prd-ai-rag-handoff.md
Rafael MelgaçoandClaude Opus 5 d69d708d8d feat: concluir jornadas da comunidade — organizações, atendimento, agenda e autonomia (#613)
* feat: complete community workflows for organizations, agents and scheduling

* docs: cite selected community evidence in the journey map

* test(automation): supply event boundary and agenda fixtures for AI sends

* fix: prevent readonly support from changing channel AI access

* test: verify integrated community journeys and secure fixture randomness

* fix(agenda): ocupação do Google sem catálogo volta a contar, e o gatilho do Meet trava na ordem das irmãs

fn_google_counts_for_conflicts exigia linha em calendar_connection_calendars
para um evento externo contar. Os três leitores (grade, semente da página e o
motor de horários livres) passaram a ler a view que a usa, então conexão sem
catálogo montado perdia a ocupação na tela E deixava de bloquear o horário —
o oposto do que os três leitores faziam antes. Passa a falhar ABERTO: só não
conta quem tem linha dizendo counts_for_conflicts=false.

fn_meet_delivery_enqueue travava job_queue segurando a linha do compromisso
sem o mutex do contato; fn_meet_redact_contact (0229) faz a ordem inversa.
Duas ordens opostas sobre os mesmos recursos = 40P01 sob concorrência.

* fix(contatos): fundir duplicado volta a funcionar no caso ordinário

A guarda `mescla_conversas_colidentes`, introduzida em 0222, abortava a fusão
sempre que os contatos do grupo tivessem mais de uma conversa no mesmo
`channel_session_id` — que é EXATAMENTE como a duplicata de WhatsApp nasce
(dois cadastros, dois números, o mesmo número de atendimento). O caminho
dominante do recurso virava 409.

Medido no Postgres da QA, fixture de dois contatos com uma conversa cada na
mesma sessão:

  antes:  ERROR: mescla_conversas_colidentes
  depois: {"repontado": {"messages.contact_id": 1, "demandas.contact_id": 1},
           "nao_repontado": {"conversations.contact_id": 1}, ...}

A colisão já tinha dono e não é perda de mensagem: `messages.contact_id` não
tem índice único por contato e passa inteira para o vencedor; quem colide é a
conversa, contra `uniq_conversations_1to1_per_contact_session`, e o passo 5 já
cai para repontamento linha a linha, deixa a conversa na lápide e a CONTA em
`nao_repontado` — que a rota devolve e a tela anuncia. É o desfecho que
`tests/e2e/juntar-contatos-duplicados.spec.ts` trava, com número.

O mutex de atendimento que 0222 trouxe (fn_service_lock + `for no key update`)
fica: o problema nunca foi a ordem de trava, foi a recusa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ia): rascunho do assistido volta a respeitar dono humano e silêncio

O drain desliga o gate de elegibilidade antes de enfileirar quando a org tem
agente assistido publicado no canal (`canAssist`, drain.ts:307/315). Isso é
deliberado — o rascunho é o produto do modo assistido — e transfere a checagem
inteira para o turno. Só que o ramo assistido de `createInboundTurnHandler`
devolvia ANTES de `runAgentTurn`, que é onde as duas guardas moram
(isLeadInHandoff e decidirElegibilidadeDaConversa). O gêmeo de :1572 está
depois delas e por isso nunca sofreu.

O gate não é só o pré-go-live: a mesma consulta lê `contacts.force_human`,
`conversations.assignee_kind` (dono humano) e `bot_silenced_until`
(consulta-pg.ts:34-48). Na prática, uma conversa que uma PESSOA assumiu seguia
recebendo rascunho do robô.

As guardas entram DENTRO do ramo assistido, não antes dele: o caminho
automático já as refaz em `runAgentTurn`, e antecipá-las custaria duas queries
por turno sem mudar desfecho nenhum. Handoff falha fechado; falha da consulta
de elegibilidade degrada aberto, igual ao gêmeo.

tests/unit/assistido-respeita-o-gate.test.ts cobre os três motivos da regra
pura, o handoff, o controle positivo (sem ele, um `return` cedo demais deixaria
tudo verde por ausência) e a degradação aberta.

  npx vitest run tests/unit/assistido-respeita-o-gate.test.ts
  Test Files  1 passed (1) / Tests  6 passed (6)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(agenda): o veredito de I3 sai de cima do relógio

googlePushCandidates filtra `google_next_attempt_at <= now`, com `now` no
relógio do PROCESSO (new Date()) e a coluna no default `now()` do BANCO, que
aqui roda num container. O veredito de I3 passava a depender de os dois
relógios concordarem na casa dos milissegundos.

Medido nesta máquina, 30 amostras: o lote selecionado tem SEMPRE 1 linha (a
`healthy`), os 50 vínculos redigidos estão sempre fora, e a folga entre os dois
relógios é de 6 a 204 ms. Sabotagem de 5 ms (`now()+interval '5 milliseconds'`
na fixture) reproduz na hora o vermelho do CI — `:377:61 expected false to be
true`, 1 vermelho em 2 rodadas. A fixture passa a gravar o instante um minuto
no passado pelo relógio do próprio banco: tolera qualquer desvio abaixo de 60s
e não afrouxa nada do que I3 mede.

⚠️ freeze-invariants.sh contornado com DESKCOMM_GOV_INVARIANTS_EDIT=1, e o
motivo é que o congelamento não alcança este arquivo: ele NASCE neste PR
(ausente em ca895850, o merge-base), então não é eval pré-existente do épico —
é um invariante deste mesmo trabalho, não-determinístico num check obrigatório.

* fix(atendimento): quem já usa não perde o acompanhamento no update

A 0222 criou `messages.service_revision`, `conversations.current_demanda_id`,
`demanda_conversas.service_revision` e `followup_enrollments.service_boundary`
NULOS e sem backfill. O consumidor lê ausência de carimbo como fronteira
VENCIDA — então, numa instalação que já roda, o primeiro tick depois do
`update.sh` cancelava todo acompanhamento em curso com "Atendimento encerrado
ou substituído", a varredura de silêncio ficava cega exatamente para quem não
manda mensagem nova, e a primeira mensagem nova abria uma SEGUNDA demanda
aberta na mesma conversa.

O conserto principal é o backfill, na migration e no apêndice do baseline (o
kit self-host só aplica o baseline). Ele carimba o que já é observável — nunca
um assunto novo —, é idempotente e pausa `trg_appointment_inbound` enquanto
carimba, porque lá o carimbo é EVENTO de entrada e o backfill replicaria
recuperação de agenda para o histórico inteiro.

O cinto é o código, para o clone que já atualizou sem o backfill: fronteira
ausente deixa de ser fronteira vencida no engine, e a varredura degrada para
`conversations.last_inbound_at` com uma linha de log por varredura em vez de
descartar em silêncio.

Ensaio em pg15 descartável, baseline aplicado em install e update, cenário
legado com e sem o bloco de backfill:

  COM backfill  | demandas abertas 1 -> 2 (R2 do baseline) -> 2 apos a 1a
                  mensagem nova | carimbo da msg legada: tem | fronteira: tem
  SEM backfill  | demandas abertas 1 -> 2                  -> 3 apos a 1a
                  mensagem nova | carimbo: NULO             | fronteira: NULA

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): o backfill diz ao operador o que carimbou

O dump do baseline abre com `set client_min_messages = warning`, então o
`raise notice` do bloco de backfill NUNCA chegava a quem roda o `update.sh` —
nem o de sucesso nem o de falha ao pausar o gatilho de agenda. Vira `warning`,
e a linha traz a contagem e se o gatilho foi mesmo pausado. Em banco já
carimbado a contagem é zero e nada é escrito.

  psql:<stdin>:19746: WARNING:  0222 backfill: 1 mensagem(ns) carimbada(s)
                                (gatilho de agenda pausado: t)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(suporte): a varredura de guarda passa a enxergar handler exportado como const

`export const PATCH = async () => {}` é forma válida de handler no App Router e
era invisível para o gate: a varredura só casava `ts.isFunctionDeclaration`.
Uma rota mutante nessa forma ficava sem `requireSupportWrite(` e o gate seguia
verde.

Prova de que ganhou dente — rota sintética `app/api/v1/__sonda_frente_c/route.ts`
com uma única linha, `export const PATCH = async () => new Response(...)`:

  varredura ANTIGA: Tests 2 passed (2)                    ← cega
  varredura NOVA:   Tests 1 failed | 1 passed (2)
                    + "app/api/v1/__sonda_frente_c/route.ts:PATCH"

Sem dívida herdada: as 6 ocorrências da forma `const` hoje em `app/api/v1`
estão todas sob `cron/`, que a varredura já pula na linha 9.

Para a forma `const` o texto medido é a declaração inteira, não só o corpo —
assim um wrapper (`export const POST = comX(async () => …)`) também é lido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(drain): a frase "o turno revalida" era falsa no modo assistido

O comentário do gate de elegibilidade afirmava que desligar a checagem aqui era
seguro porque "o turno revalida (defesa em profundidade)". Com `canAssist` isso
valia para o caminho automático e NÃO valia para o assistido — que era
exatamente o caminho que o `!canAssist` liberava. A prosa é o que fazia a
ausência parecer intencional.

Agora aponta para onde a revalidação de fato mora.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(contatos): o invariante da colisão passa a travar o contrato que vale

EXCEÇÃO DECLARADA ao freeze-invariants.sh (DESKCOMM_GOV_INVARIANTS_EDIT=1).
O hook nomeia dois casos: código errado, ou invariante mal-escrito. Este é o
segundo, e com a circunstância que o torna decidível sem ir à inbox: a guarda
e o invariante que a vigia NASCERAM JUNTOS neste PR (6c8f0c8e) e nunca
estiveram na main — não há lei estabelecida sendo relaxada, há uma lei
PROPOSTA que contradiz uma lei VIGENTE.

O caso exigia que colisão de conversas abortasse a fusão inteira. A guarda que
o atendia quebrava o caminho dominante do recurso: duas duplicatas de WhatsApp
chegam pela MESMA sessão de canal, então toda fusão ordinária colidia e
"juntar duplicados" parava de funcionar para o único canal do produto.

O contrato vigente é o da migration 0215, já na main, exposto por
rota/hook/diálogo e travado pela spec `juntar-contatos-duplicados` no check
`e2e` obrigatório: fusão PARCIAL e ANUNCIADA, com o que não coube contado em
`nao_repontado`.

A preocupação da guarda não foi apagada, foi medida. Checkpoint é lido por
contato (`retomada.ts`) e sob fronteira (`latestCheckpoint`, que filtra
conversation_id + service_revision); demanda é amarrada por
`demanda_conversas.conversation_id`. O registro que fica na lápide é inerte
para o vencedor. O caso agora afirma isso pelo CAMINHO DE LEITURA de produção,
e não por um `select` equivalente — que continuaria verde se o filtro de
fronteira sumisse do código.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(e2e): uma terceira parte, porque o teto de 30 min disparou certo

`e2e-parte (2)` foi CANCELADA aos 30 min neste PR (job 101799282999) com 55 dos
57 arquivos terminados. Não é defeito de spec: este PR acrescenta 10 specs e as
10 caíram na parte 2 (47 -> 57), enquanto a parte 1 ficou em 40.

Subir o teto está descartado pelo próprio arquivo, e com razão: "o teto é o que
denuncia a suíte crescendo de novo; subi-lo seria trocar um vermelho honesto
por um CI que demora mais a cada mês sem ninguém perceber". Ele disparou
corretamente — a suíte chegou a 100 specs.

Duas partes também não davam mais para equilibrar com segurança. Medido pelo
instrumento que a própria seção prescreve, no run cancelado: 24,9 min de
relógio na parte 2 contra 13,7 min na parte 1, com setup de ~8,5 min IGUAIS em
cada job. Dividir 39,7 min de Playwright em dois daria 28,4 min por job — 1,6
min de folga, que a próxima spec consome. Em três, dá ~22 min, a mesma folga
que a parte 1 tem hoje.

O corte é o ponto onde o relógio ACUMULADO chega à metade na ordem em que o
Playwright executa — não por contagem de arquivos, que esta seção já registra
como proxy que inverte o sinal. Ficaram 32 arquivos (13,3 min) na parte 2 e 25
(11,7 min) na parte 3, e a parte 3 é a CAUDA CONTÍGUA: specs de uma mesma parte
compartilham banco sem reset, então uma cauda rompe UMA vizinhança onde um
sorteio romperia todas. Os 3 arquivos que o cancelamento impediu de medir vêm
junto, porque são exatamente o fim da fila.

Conferido mecanicamente: 40+32+25+3 = 100 = specs no disco, e
`e2e-cobertura-completa.test.ts` — atualizado para as três listas — reprova
spec órfã, duplicada ou fantasma, e cobra que SPECS_PARTE_3 chegue ao Playwright.

Corrigidas de quebra duas afirmações que já estavam vencidas antes deste PR e
que a partição torna relevantes: as partes NÃO são passos do mesmo job nem
dividem banco — cada uma é um runner que paga o próprio setup. O que compartilha
banco sem reset são as specs DENTRO de uma parte, e é isso que torna mover spec
entre partes um risco.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(acompanhamento): retirar o cinto de fronteira; quem cuida do legado é o backfill

O cinto que eu mesmo pus em ae4b4bc3 — sem carimbo, medir silêncio por
`last_inbound_at`; sem fronteira, seguir o acompanhamento — protegia uma
população VAZIA e abria dois caminhos reais. Medido por verificação
adversarial, em Postgres com o baseline aplicado:

· a 0222 não é ancestral de `origin/main`, nenhuma tag a contém, e a main para
  na 0218 — a migration nunca chegou a instalação nenhuma;
· o backfill vive no apêndice idempotente do baseline, então o `update.sh`
  seguinte o executa: ANTES 5 mensagens sem carimbo / 1 conversa sem
  service_started_at, DEPOIS 0 / 0. A janela teórica se cura sozinha.

O custo, em banco JÁ backfilled, é que falta de carimbo NÃO é sinal de legado —
é normal e recorrente em duas classes, e nas duas o cinto inscrevia quem não
devia em follow-up automático:

· conversa de GRUPO: `fn_service_inbound` retorna cedo e nunca carimba, e a
  consulta da varredura não filtra `is_group`;
· mensagem entregue FORA DE ORDEM depois de um fechamento
  (`m.sent_at <= c.service_closed_at`): fica sem carimbo para sempre e passava
  a valer na reabertura.

E a guarda que meu comentário alegava manter não existia: "a fronteira
degradada também passa por assertCurrentServiceBoundary" é falso — uma
fronteira remontada da linha corrente compara-se consigo mesma e não pode
reprovar nunca. Foi essa frase que impediu a brecha de ser vista.

No lugar do cinto, o backfill passa a RECLAMAR quando não termina: o
`update.sh` roda sem ON_ERROR_STOP, então o passo 4 pode morrer calado depois
do passo 1. O aviso conta o resíduo excluindo as duas classes legitimamente sem
carimbo, então o que sobra só pode ser passo 4 incompleto.

`ae4b4bc3` também havia removido `.not("messages.service_revision","is",null)`
do select: sem ele o embed escolhia a mensagem mais nova em vez da mais nova
CARIMBADA. Restaurado.

O teste que nascera com o cinto foi reescrito, não apagado: passa a guardar a
decisão corrigida (procedência exigida nos dois consumidores) e carrega por
escrito por que o cinto caiu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): progressão de fronteira deixa de ser lida como conflito

Dois lugares tratavam "o mundo avançou de nada para algo" como "o mundo mudou
sob mim". É a mesma forma da guarda de colisão de fusão que este PR já
consertou: o caminho ORDINÁRIO lido como conflito.

1 · `assertCurrentServiceBoundary` comparava `demanda_id` por igualdade crua,
    mas a 0222 só incrementa `service_revision` quando TROCA de demanda — ir de
    "nenhuma demanda" para a primeira é o MESMO atendimento, por decisão do
    schema. O TypeScript discordava do SQL.

    O custo não era um teste: o gatilho de silêncio captura a fronteira de um
    contato CALADO (que por definição não tem demanda aberta) e o nó
    `ai_classify` espera o inbound do lead. Era essa resposta que abria a
    primeira demanda e vencia o acompanhamento que ela acabara de acordar — o
    nó ficava morto por construção, em produção.

2 · O CAS de `fn_service_begin` comparava a fronteira atual contra o literal
    `{"absent":true}`, que difere sempre. Um lead criado e depois movido de
    etapa gera dois eventos observados como `absent`: resolver o primeiro cria
    a conversa e o segundo morria com 40001 — que `serviceForEvent` engole como
    `stale_origin`. O follow-up de etapa não nascia, sem erro em lugar nenhum.

Nos dois casos o que se recusa continua sendo o atendimento OUTRO: demanda
fechada, conversa terminal, e trocar de demanda ou reabrir (que incrementam a
revisão). O estado sucessor admitido é exatamente um.

Também neste commit:

· `followup-builder`: a 6ª opção de gatilho não é decorativa. `appointment_no_show`
  tem motor ponta a ponta — `fn_appointment_change` emite
  `appointment.outcome_confirmed`, `gatilho-presenca.handler.ts` consome e está
  REGISTRADO em `register-handlers.ts`, e chama `fn_appointment_recover`, que
  insere em `followup_enrollments`. A lista da spec é que estava velha.

· `roteamento-por-canal`: a guarda `assertNoForeignRoutingDue` exigia fila
  global vazia, e nenhum outro ponto da suíte drena essa fila — era
  insatisfazível por construção. Medido nas duas pontas antes de trocar: mesma
  recusa com 152 specs antes e com 18. Agora ADIA o que não é da org (empurra
  `next_attempt_at`), em vez de recusar.

· gatilhos de etapa e de caso: `status:"ok"` descarta o `detail` no dreno, e era
  justamente ali que morava `origem_obsoleta=`. Com `matched && enrolled > 0`,
  o motivo de o follow-up NÃO nascer passa a chegar à linha do event_log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ia): agente PAUSADO volta a ser invisível para quem decide se ele atende

O PR reescreveu `estadoDoAgente` para ler `paused_at`, mas o campo continuou
OPCIONAL em `FatosDoAgente` — então consulta que não trouxesse a coluna
entregava `undefined`, `!= null` dava falso, e o agente pausado voltava a
contar como no ar. O TypeScript não podia acusar.

Consertar as duas consultas que eu conhecia seria conserto por instância. O
campo virou OBRIGATÓRIO, e aí o compilador achou CINCO sítios:

· `api/v1/ai/automatico-ativo` — se a tela diz "Automático atendendo";
· `lib/ai/agents/org-tem-automatico` — o mesmo fato, no servidor;
· `api/v1/ai/agents/assignable` — quais agentes podem receber conversa;
· `workers/ai-sentiment-worker` — QUAL agente cuida da conversa;
· `workers/ai-response-worker` (via o contexto em `lib/ai/types`) — se ele
  responde.

Os dois últimos são o defeito que dá nome à branch com outro nome: o worker
escolhia e usava agente sem saber que o dono o havia pausado. Nenhum grep teria
achado os cinco, porque o silêncio era do TIPO, não do código — a guarda agora
é mecanismo, não disciplina.

Também aqui: a precondição de `inbox-quem-manda`. "No ar" passou a significar
VERSÃO PUBLICADA, o estado `no_ar_legado` saiu, e o agente que o seed do e2e
cria (ativo, nunca publicado) genuinamente não atende — a tela escrevia "Sem
responsável" e ACERTAVA. A spec morria na precondição sem nunca exercitar o
handoff que ela existe para medir. A spec passa a publicar uma versão; a
asserção continua intacta. Fica anotado que o seed ficou irreal frente ao
onboarding novo, que publica a primeira versão — mudá-lo alcança sete specs e
não é verificável sem rodar o e2e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): a progressão se conserta na ORIGEM DO EVENTO, não no CAS

EXCEÇÃO DECLARADA ao freeze-invariants.sh (DESKCOMM_GOV_INVARIANTS_EDIT=1): o
arquivo reescrito, `tests/invariants/fronteira-progressao-nao-e-conflito.test.ts`,
foi CRIADO por mim no commit anterior (7afb2857) e nunca esteve na main. Ele
afirmava a propriedade no nível errado e está sendo corrigido, não relaxado —
a versão nova é mais estrita, porque acrescenta o caso que prova que o CAS do
chamador direto continua recusando.

Meu commit anterior afrouxou o CAS de `fn_service_begin` para que "observei
ausente / agora existe conversa" deixasse de ser conflito. Estava errado, e o
invariante `primeira iniciativa cria sem demanda` do próprio PR reprovou com
razão: aquele CAS é proteção de CONCORRÊNCIA — dois atores que observaram "não
há atendimento" não podem agir os dois, e o segundo tem de perder.

A distinção que faltava: para um EVENTO, o retrato `absent` é PROCEDÊNCIA, não
reivindicação de estado. Ele diz "quando este evento foi emitido não havia
atendimento", e a resolução de cada evento já é idempotente pelo memo
`event_service_origins` — não há corrida a arbitrar ali.

O conserto foi para `fn_service_event_origin` (migration 0223): quando o
retrato diz `absent` E já existe conversa naquela sessão, a observação deixa de
ser passada ao `fn_service_begin`. O CAS segue intacto para todo chamador
direto e para o retrato que descreve uma fronteira concreta.

Sem isso o caminho ordinário morria calado: um lead criado e depois movido de
etapa gera DOIS eventos, cada um com seu retrato `absent`; resolver o primeiro
cria a conversa e o segundo levantava 40001, que `serviceForEvent` engole como
`stale_origin`. O follow-up não nascia e nada aparecia em lugar nenhum.

O `baseline.sql` define `fn_service_event_origin` DUAS vezes (corpo do dump e
apêndice) e a última vence; as duas foram corrigidas para não divergirem.

Medido: `service-boundary.test.ts` (que eu havia quebrado),
`service-event-origin.test.ts` e o invariante novo passam juntos — 23 casos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(e2e): duas fixtures anteriores à fronteira criam o estado que a produção cria

Ao retirar o cinto do `silence-sweep`, duas specs da parte 1 ficaram vermelhas.
Parecia regressão minha. Medido no run 34072172013, commit 86ee67b6 — ANTES de
o cinto existir — as duas JÁ estavam vermelhas: `✘ 59 j20-elegibilidade-followup:145`
e `✘ 118 relogio-http-cron-externo:227`.

Ou seja: o cinto não as quebrou, ele as MASCARAVA. Ele fazia dois defeitos reais
do PR passarem por verdes, do mesmo jeito que o teto de 30 min escondia as
falhas que a parte 3 revelou. Rede de segurança que faz teste passar não é rede,
é venda nos olhos.

As duas fixtures nasceram antes da fronteira do atendimento e semeavam estados
que o produto não cria:

· `seed-silent-contact` criava a conversa com `last_inbound_at` e NENHUMA
  mensagem — o silêncio como um CAMPO. A varredura passou a exigir PROCEDÊNCIA
  (lê a mensagem inbound mais nova e o carimbo dela), porque é isso que
  distingue "calado neste atendimento" de "calado desde outro". Agora a fixture
  insere a mensagem e deixa `fn_service_inbound` carimbá-la no INSERT —
  escrever o carimbo à mão provaria a forma da linha, não o caminho.

· `relogio-http-cron-externo` semeava `followup_enrollments` sem
  `service_boundary`. Todo caminho de produção carimba (`lib/followup/enroll.ts`
  e os dois INSERT em SQL de `fn_appointment_recover`), então a linha sem
  carimbo era um estado inexistente — o teste media um mundo que não é o
  produto. Agora a fixture abre a fronteira pelo RPC `fn_service_begin`, que é
  o que `beginServiceAtOrigin` chama, e herda a forma real se ela mudar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(e2e): a fixture da jornada também cria a mensagem carimbada

Mesma classe do commit anterior, em outro arquivo — que é o modo de falha
"conserto por instância, não por classe". `seed-silent-contact` de
`e2e-followup-journey-helpers.ts` criava o silêncio como um CAMPO
(`last_inbound_at`) sem linha em `messages`, e a varredura passou a exigir
PROCEDÊNCIA. Sem a mensagem, a conversa é invisível para o gatilho.

Medido depois do conserto da fronteira: o erro da spec MUDOU de
`service_boundary_stale` no `complete-turn` para "varredura de silêncio não
enrollou o contato a tempo" — ou seja, ela passou do defeito antigo e morreu no
seguinte, que é esta fixture.

Varri a classe: `seed-e2e-escalacao` usa `last_inbound_at` de agora (não é
silêncio) e `seed-e2e-queue` é da visão "Fila", não da varredura — nenhum dos
dois alimenta o gatilho, e as specs deles passam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(schema): a 0224 sobrescrevia o conserto da 0223, e agora um gate mede isso

A migration 0224 redefine `fn_service_event_origin` — assinatura idêntica, logo
`create or replace` puro — com o corpo ANTERIOR ao conserto da 0223. E
`20260906120000 > 20260906030000`: na CADEIA de migrations o conserto sumia.

No `baseline.sql` não sumia, porque lá o bloco foi editado à mão. Os dois
artefatos divergiram, e nenhum gate via: `pnpm test:db` aplica só o baseline.
Quem aplica a cadeia (Supabase CLI, `db reset`, clone que migra em vez de
re-aplicar o baseline) ficava com o defeito. Reproduzido em Postgres: aplicar o
bloco da 0224 sobre o baseline reintroduz `service_stale` no segundo evento.

O conserto entrou DENTRO da 0224, que é a versão funcionalmente mais nova (ela
acrescenta o ramo de `appointment.outcome_confirmed`) — não numa migration
nova, porque a 0224 é deste mesmo PR e nunca foi aplicada em lugar nenhum.

E como o custo aqui não é o defeito e sim a INVISIBILIDADE dele, entra o gate:
`apendice-do-baseline-nao-diverge-da-cadeia.test.ts` compara, para toda função
escrita à mão no apêndice, o corpo da última definição da cadeia com o do
baseline. Ele mede SEMÂNTICA, não prosa — três normalizações, cada uma exigida
por um falso vermelho que ele mesmo produziu antes de eu confiar nele:

· comentários fora (11 funções antigas diferiam só em comentário reescrito);
· marca do delimitador uniformizada (`$fn$` do baseline contra `$$` da
  migration — o corpo é o mesmo, e um parser que procura `$$;` fixo lê ALÉM do
  fim da função);
· espaço colado ou não em `(`, `)`, `,` (o `pg_dump` e a mão humana discordam).

Com a régua certa: 88 funções tocadas por este PR, 129 comuns aos dois
artefatos, e ZERO divergências — sem allowlist nenhuma, que é o que separa um
gate de uma lista de desculpas.

Terceira peça: o resumo do dreno passa a carregar `pulados`. O `detail` de um
`skipped` já sobrevivia na linha do `event_log`, e isso basta para quem tem
psql — não basta para o CI, onde o único artefato que sobra do job é o trace, e
o trace guarda o CORPO da resposta. Um e2e que morre porque o gatilho pulou não
conseguia dizer QUAL pulo foi: `failed=0` e nenhuma pista, que foi o que travou
o diagnóstico de `gatilho-de-etapa.spec.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(crm): a origem do atendimento é reservada ao servidor — então ele passa a escrevê-la

EXCEÇÃO DECLARADA ao freeze-invariants.sh: o arquivo tocado,
`fronteira-progressao-nao-e-conflito.test.ts`, foi criado por mim neste PR e
ganha DOIS casos novos. Nada foi relaxado.

CAUSA RAIZ do último vermelho, e ela é de produto, não de teste. O `pulados` que
eu acabara de acrescentar ao dreno entregou o dado na primeira rodada:

  lead.stage_changed/followup-gatilho-etapa.v1:
    armados=1 enrolled=0 origem_obsoleta=1 ja_vivo=0 gate=0 sem_contato=0

`emit_event` RECUSA `service_origin` vindo de chamador autenticado (42501, e com
razão: é o campo que autoriza efeito operacional, não payload público) — e
ninguém o escrevia no lugar dele. O resultado:

· quem move o negócio PELA IA carimba a origem no servidor
  (`agent-stage-sync`, `appointment-stage-move`, `handoff-stage-move`) e o
  follow-up nasce;
· quem move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um
  evento SEM origem. `fn_service_event_origin` caía no `service_stale` final
  (40001), `serviceForEvent` engolia como `stale_origin`, e o follow-up nunca
  nascia. Sem erro em lugar nenhum.

Ou seja: o gatilho de etapa era inalcançável pelo único caminho que o produto
oferece na tela. A spec não estava vermelha por acaso — ela mede exatamente
"o negócio movido NO QUADRO arma o follow-up sozinho".

O conserto lê "campo reservado" pelo que ele significa: reservado AO SERVIDOR,
e o servidor tem de escrevê-lo. `emit_event` passa a carimbar a origem quando
ela está ausente, tirando o retrato no instante da emissão — que é a semântica
de procedência que a 0223 quer. A resolução do contato repete a regra que
`fn_service_event_origin` já usa; tipo que ela não sabe resolver segue sem
origem, como antes. Origem já presente NÃO é sobrescrita: quem move pela IA
pode estar passando uma CONTINUAÇÃO, que é o que amarra o efeito ao atendimento
de onde ele nasceu.

Entrou na 0224 (a última da cadeia a definir `emit_event`) e na definição
correspondente do baseline — o gate de espelho confirma que os dois artefatos
seguem iguais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(e2e): a contagem de cobertura também soma a parte 3 — e o gate passa a cobrar isso

As TRÊS partes ficaram verdes e o job agregador reprovou assim mesmo:

  ::error::recorte das listas divergiu — rodou=72 fora=3 disco=100

40+32+3 = 75, e faltavam exatamente as 25 da parte 3. O passo de cobertura do
agregador soma as listas à mão, e eu tinha atualizado a matriz, o `case` que
alimenta `LISTA` e o gate de teste — mas não essa soma.

O defeito de verdade não é a linha esquecida: são DUAS implementações da mesma
regra sem nada ligando uma à outra. `e2e-cobertura-completa.test.ts` guardava
"toda spec está em alguma lista" e o passo do workflow guardava a mesma coisa
por outro caminho; deu para divergir porque nada media a divergência.

Então o gate parou de enumerar as partes à mão — enumerar aqui repetiria
exatamente o erro que ele existe para impedir. Ele DESCOBRE as
`SPECS_PARTE_\d+` declaradas no workflow e cobra, para cada uma:

  · que ela alimente `LISTA` (senão a lista existe e nunca roda);
  · que ela entre na soma `RODOU=$( { ... } )` do agregador.

Quem acrescentar uma quarta parte não precisa lembrar de nada — e se esquecer
de ligá-la, descobre no gate em vez de descobrir no CI vermelho.

Sabotagem, com previsão declarada antes: tirar a parte 3 da soma reprova
nomeando `SPECS_PARTE_3 não entra na contagem de cobertura`; tirar a linha do
`case` reprova com `SPECS_PARTE_3 não alimenta a variável que roda`. As duas
bateram.

`AGENTS.md` dizia "listas SPECS_PARTE_1/SPECS_PARTE_2" — afirmação de estado que
esta mudança venceu. Trocada pelo padrão `SPECS_PARTE_*`, que não envelhece.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:50:37 -03:00

31 KiB
Raw Permalink Blame History

title, parent, depends_on, version, status, date, owner, referencia_arquitetural
title parent depends_on version status date owner referencia_arquitetural
Sub-PRD 05 — IA Conversacional + RAG por tenant + Sentiment Detection + Handoff 00-prd-master.md 01-prd-platform-base.md, 02-prd-customer-360.md, 03-prd-whatsapp-waha.md, 04-prd-pipeline-attendance.md 0.1 em revisão 2026-04-28 Rafael Melgaço docs/research/reference-synthesis.md

Sub-PRD 05 — IA Conversacional + RAG por tenant + Sentiment Detection + Handoff

Camada de inteligência operacional do DeskcommCRM. Define como o chatbot responde inbounds com contexto rico (perfil + último pedido + base de conhecimento do tenant), como o sistema detecta frustração em tempo real, e como o produto orquestra a transição bot→humano sem perda de contexto. Sem essa camada, o produto vira CRM convencional com WhatsApp colado; com ela, é o diferencial que justifica a tese do AI Sales OS — agentes de IA operando a venda de verdade (ver VISION.md).


1. Contexto & Posicionamento

PMEs de e-commerce recebem volume de atendimento que não fecha economicamente com humano puro: 60–70% das conversas são repetitivas (rastreio, prazo, troca, frete). Chatbot decorativo (FAQ em árvore) é pior que humano — frustra, escala reclamação, derruba reputação. A camada de IA do DeskcommCRM ocupa esse meio: bot que lê contexto real (últimas 20 mensagens + perfil + último pedido), conhece o tenant (FAQ + política + catálogo Nuvemshop + conversas resolvidas) e sabe a hora de chamar humano.

Dentro da arquitetura herdada: bot é mais um produtor de activities na timeline polimórfica do Sub-PRD 02 (crm_lead_activities com type='ai_responded' | 'handoff_triggered' | 'sentiment_alert'); RAG é camada de leitura sobre fontes versionadas por tenant; sentiment roda fora do path crítico via event_log (doutrina §2: trigger nunca faz HTTP). Modelo default vem do PRD-mestre §6.5 — Vercel AI Gateway com strings "anthropic/claude-sonnet-4-6" e fallback Anthropic→OpenAI no Gateway, sem import direto de SDK. Camada invisível pro cliente final, mas é a maior alavanca de margem operacional do produto.


2. Escopo

Dentro do escopo

  1. AI Agents por tenant (modelo lógico; 1 tenant N agents; MVP 1 default)
  2. Estratégia de modelo via Vercel AI Gateway (Sonnet 4.6 + Haiku 4.5 triagem)
  3. RAG por tenant com 4 fontes (FAQ + política + catálogo Nuvemshop + conversas resolvidas opt-in)
  4. Pipeline de ingestão (chunking + embedding + persistência + re-indexação incremental + versionamento)
  5. Roteamento da chamada do bot (contexto + RAG + invocação + persistência)
  6. Sentiment detection em tempo real (Haiku, paralelo via event_log)
  7. Handoff bot→humano com 4 gatilhos + política de retomada
  8. Logs de chamadas LLM (ai_invocations) + dashboard de uso/custo
  9. Orçamento de IA por tenant (alarme 80%, ação em 100%)
  10. Guardrails (ai_agents.guardrails jsonb)
  11. Citações de fonte em messages.metadata.citations[]
  12. Modo "humano sempre" (por tenant ou por contact)
  13. Logging de prompts/responses (debugability + LGPD)

Fora do escopo

  • Captura/envio físico WhatsApp → Sub-PRD 03
  • Roteamento de atendentes humanos e UI da fila → Sub-PRD 04
  • Sync e webhooks Nuvemshop que populam catálogo → Sub-PRD 06
  • MCP tools-side, A/B testing → Fase 2
  • Voice/áudio, multi-language, geração proativa, co-pilot → pós-MVP

3. Capacidades Funcionais

3.1 AI Agents por tenant

Tabela lógica ai_agents com (organization_id, name, system_prompt, knowledge_base_id, is_active, model_config jsonb, guardrails jsonb). 1 tenant pode ter N agents (ex: "Atendimento Vendas" + "Suporte Pós-venda"); MVP seeda 1 default no onboarding. Roteamento avançado (qual agent atende qual conversa por pipeline/stage/tag) deferido — MVP usa default ativo. system_prompt editável pelo admin via UI; mudança auditada (Sub-PRD 01 §3.5). model_config controla model (default "anthropic/claude-sonnet-4-6"), temperature, max_tokens, top_k_rag. is_active=false cai em modo humano sempre (§3.12).

ACs. Onboarding cria 1 agent default. Edit de system prompt aplica na próxima inbound. Tenant com 0 agents ativos cai em humano sempre com banner. Audit captura ai_agent.created|updated|deactivated.

3.2 Estratégia de modelo

Adesão ao PRD-mestre §6.5. Resposta principal: Sonnet 4.6 via Gateway, string "anthropic/claude-sonnet-4-6". Sentiment: Haiku 4.5 via Gateway. Fallback Anthropic→OpenAI configurado no Gateway, transparente pra app. Zero data retention configurável por tenant. Atualização de versão upstream não é automática: pin de versão, smoke test em staging antes de promover (risco A6).

ACs. Código usa string de modelo, nunca import direto de SDK. Failover do Gateway mantém p95 <3s. zero_data_retention=true propaga pras chamadas.

3.3 RAG por tenant — fontes de ingestão

Base vetorizada por tenant, isolada via organization_id. 4 fontes:

  1. FAQ manual — markdown editável via UI do admin. Item = pergunta + resposta + tags. Edição dispara re-indexação incremental.
  2. Política da loja — upload de PDF/markdown (troca, frete, garantia, privacidade). Parsing PDF→texto, chunking, embedding. Versão registrada (rollback possível).
  3. Catálogo Nuvemshop sincronizado — produtos vindos do Sub-PRD 06 (nome, descrição, preço, categoria, disponibilidade). Sync incremental por webhook product/created|updated.
  4. Conversas resolvidas como few-shot — opt-in + anonimização obrigatória (nome/telefone/email/CPF substituídos por tokens). Apenas conversas status='resolved' com flag usable_for_rag=true marcada por atendente humano.

Princípios: isolamento forte (filter organization_id em toda query); versionamento por kb_version com rollback; re-indexação incremental, não em massa; pipeline assíncrono via event_log (kb_source.changed → worker kb-reindex); fontes desabilitáveis individualmente.

ACs. Edit de FAQ → bot usa conteúdo novo em ≤30s. Upload de PDF gera embedding e notifica admin. product/updated reindexa só o produto afetado. Conversa só entra como few-shot após anonimização validada. Query sem filter de organization_id é bloqueada com alerta.

3.4 RAG por tenant — pipeline técnico

Vector store: pgvector vs Supabase Vector — deferido pra Spec (§9). Trade-off: pgvector dá controle fino e roda no mesmo Postgres do CRM (simplifica RLS); Supabase Vector tem managed APIs e melhor DX. Benchmark obrigatório. Embeddings: OpenAI text-embedding-3-small vs Voyage — deferido. Chunking: fixed-size com overlap vs semantic — deferido; default inicial 512 tokens overlap 64. Retrieval: top-K=5 default, configurável por tenant (range 1–10). Persistência em kb_chunks (organization_id, source_type, source_id, kb_version, content, embedding, metadata). Ativação atômica (swap de versão ativa); rollback = reativar versão anterior.

ACs. Re-indexação total de 1k itens em ≤5min. Retrieval top-K=5 em <300ms p95. Rollback em <2s. Filter de organization_id obrigatório no retrieval layer.

3.5 Roteamento da chamada do bot

Quando inbound chega numa conversa cujo agent default está ativo (e force_human não setado), bot monta contexto + RAG + invoca + persiste resposta como activity + dispara outbound (Sub-PRD 03).

Contexto montado: últimas 20 mensagens da conversation; perfil do contact (name, tags, custom_fields relevantes); último pedido linkado via crm_lead_links (número, status, valor, data); top-K=5 RAG hits; system prompt + guardrails do agent.

Output do bot: response_text, confidence_score (0.0–1.0; algoritmo deferido), citations[], should_handoff + handoff_reason (quando o próprio modelo decide escalar — gatilho 3).

Latência alvo <3s p95 entre inbound (após HMAC ok) e outbound sending. Bot respeita janela 24h Meta (§3.10) e contacts.is_blocked=true. Activity ai_responded registra metadata.tokens|latency_ms|confidence_score|citations[]. Persistência otimista como messages.status='sending' (mesmo padrão do Sub-PRD 03).

ACs. Resposta contextual em <3s p95. Bot cita número de pedido correto (do contexto, não alucinado). Confidence persistido em metadata. Inbound após janela 24h não gera outbound; cria activity system.window_24h_expired.

3.6 Sentiment detection em tempo real

Cada inbound roda análise binária (alta / baixa frustração) com Haiku 4.5, fora do path crítico. Webhook do WAHA emite message.received; worker dedicado consome via event_log — não bloqueia response-time do canal nem do bot. Output: sentiment_score float 0.0–1.0 (1.0=neutro/positivo; 0.0=altamente frustrado), persistido em messages.metadata.sentiment_score. Latência 1–2s. Threshold por tenant (default 0.3); abaixo dispara handoff (G2). Activity sentiment_alert criada apenas quando score < threshold.

ACs. Score em ≤2s p95 sem afetar bot. Score < threshold dispara sentiment_alert + handoff. Ajuste de threshold aplica nas próximas mensagens. Falha do worker degrada graceful.

3.7 Handoff bot→humano (4 gatilhos)

  • G1 — Pedido explícito. Regex no inbound tipo /humano|atendente|pessoa|gente real|falar com algu[eé]m/i (lista deferida). Match síncrono.
  • G2 — Sentiment baixo. sentiment_score < threshold do tenant. Avaliado quando worker grava score.
  • G3 — Incerteza da IA. Resposta contém marcadores ("não sei", "vou verificar") OU confidence_score < threshold (default deferido). Avaliado pós-resposta, antes do despacho — bot retém mensagem e dispara handoff.
  • G4 — Estágio crítico. Conversa entra em stage marcado requires_human=true (configurável por manager); ou contexto detecta menção a fraude/jurídico/produto fora do catálogo (guardrails §3.10).

Ao acionar: criar activity handoff_triggered com metadata.trigger_reason ('explicit_request'|'low_sentiment'|'ai_uncertainty'|'critical_stage') + metadata.sentiment_score/confidence_score quando aplicáveis; mudar conversation.status='pending' (Sub-PRD 04); notificar atendentes online via Realtime + push. Bot fica silencioso até reativação (§3.8).

ACs. "Quero falar com humano" → handoff em ≤500ms; bot não responde. Sentiment 0.15 (threshold 0.3) → handoff_triggered em ≤2s. Bot que ia responder "não sei" intercepta, dispara handoff, não envia o "não sei". Stage requires_human=true dispara handoff na entrada do stage.

3.8 Política de retomada após handoff

Default: bot não reassume. Humano fica responsável até conversation.status='resolved'. Atendente pode reativar bot via botão "Passar pra IA"; ação auditada (ai_reactivated_by_agent activity). Após resolved, próxima conversation que abrir naquele contact começa com bot (default), exceto se contacts.force_human=true (§3.12). Sem retomada automática por "cliente voltou a engajar bem" no MVP — risco de oscilação confunde cliente.

ACs. Handoff → bot silencioso até reativação ou resolved. "Passar pra IA" → próxima inbound com bot. Conversa resolvida + nova conversation 7 dias depois no mesmo contact → bot responde. Contact com force_human=true → bot nunca responde.

3.9 Logs de chamadas LLM

Tabela lógica ai_invocations por tenant: agent_id, conversation_id, message_id, model, tokens_prompt, tokens_completion, latency_ms, cost_cents, finish_reason, kb_version_used, created_at. Custo calculado a partir de tabela de pricing por modelo (atualizável sem deploy). Insert fire-and-forget. Dashboard do admin: custo do mês, mensagens processadas, taxa de handoff, latência média, distribuição de confidence.

ACs. Toda chamada LLM gera 1 linha em ai_invocations em ≤500ms p99. Dashboard mostra custo do mês com diff vs anterior. Custo total bate ±2% com fatura do Gateway no fim do mês.

3.10 Guardrails

Em ai_agents.guardrails jsonb. Aplicados em duas camadas: instruções no system prompt + validação programática pós-resposta (defesa em profundidade — modelo pode ignorar prompt; validador intercepta).

Guardrails default MVP:

  • Nunca prometer ressarcimento (estorno, reembolso) sem confirmação humana → handoff
  • Nunca falar de produto fora do catálogo Nuvemshop — sem RAG hit em pergunta sobre produto → handoff
  • Sempre escalar se cliente mencionar fraude | jur[ií]dico | ANPD | pol[ií]cia | processo | advogado
  • Sempre escalar se cliente pede dados sensíveis (CPF de terceiro, dados bancários completos)
  • Respeitar janela 24h Meta — não envia outbound se passou >24h da última inbound; alerta atendente
  • Respeitar contacts.is_blocked=true — não responde

Guardrails versionados junto com o agent (mudança auditada). Formato declarativo do jsonb deferido pra Spec.

ACs. "Quero meu dinheiro de volta" → handoff. "Vocês têm produto X?" sem X no catálogo → handoff. "Vou processar vocês" → handoff imediato com tag legal_mention. Outbound em janela expirada → bloqueado; activity system.guardrail_blocked.

3.11 Citações de fonte (debug interno)

Resposta com RAG hit persiste messages.metadata.citations[] com {chunk_id, source_type, source_id, score, kb_version}. No MVP, citações não vão pro cliente final (resposta vai limpa pelo WhatsApp); ficam disponíveis pra debug interno (UI do atendente em modo debug, logs). Pós-MVP: explorar exposição opcional ("Conforme nossa política de troca...").

ACs. Resposta com RAG hit → ≥1 citation. Sem RAG → citations=[]. UI debug mostra fontes citadas. Citação aponta kb_version correta (rastreabilidade pós-rollback).

3.12 Modo "humano sempre"

Override que desliga bot total ou pontualmente. Por tenant: admin desliga bot inteiro (is_active=false em todos os agents OU organizations.settings.ai_disabled=true); inbounds vão direto pra fila humana com conversation.status='pending'. Por contact: contacts.force_human=true (cliente VIP, conta sensível, em disputa) — bot nunca responde mesmo com agent ativo. Mudança auditada. UI mostra badge "Humano forçado". Reset de force_human=false requer manager+.

ACs. Admin desliga IA → inbounds não disparam bot. Contact com force_human=true → status='pending' direto. UI mostra badge + botão "Reativar IA pra esse contato" (manager+).

3.13 Orçamento de IA por tenant

Reescrita em 2026-08-15 (migrations 0159/0160). A versão anterior descrevia action_at_100pct: 'throttle'|'disable', alarme por e-mail e platform_max_per_tenant. Nenhum dos três existia no produto: o dropdown gravava um campo que ninguém lia, o e-mail não tinha chamador, e o teto que a tela editava (ai_budgets.monthly_limit_cents) NÃO era o campo que o enforcement consultava (organizations.settings.llm.monthly_budget_cents) — ou seja, quem preenchia a tela acreditava estar protegido e não estava.

A escada de três estados, escolhida pelo admin em Uso de IA › Orçamento (ai_budgets.enforcement_mode, default off — nenhuma instalação nasce armada):

  • off — só acompanhar. A IA nunca para por gasto. É o estado de 100% das organizações no dia em que a migration é aplicada, e o único que a migration escreve.
  • avisar — abre um agent_inbox_items kind budget_warning na Central ao passar de alarm_threshold_pct (50..99, default 80) do teto, e segue respondendo.
  • bloquear — recusa a chamada de LLM quando o gasto do mês atinge o teto.

Regras que protegem o degrau bloquear, todas validadas no servidor (PATCH /api/v1/ai/budget) e não só na tela:

  1. A escada não se pula. off → bloquear é 422 invalid_state_transition.
  2. Carência de 72h. Armar grava enforcement_effective_at = now() + 72h; até lá o modo é bloquear e o efeito é o de avisar. confirmar_imediato: true renuncia a ela.
  3. Piso de US$ 1,00 (monthly_limit_cents >= 100) para sair de off, resolvido sobre o estado pós-escrita. Teto 0 significa sem limite, nunca "bloqueia tudo".
  4. Ninguém é bloqueado sem ter sido avisado no mês corrente — a primeira chamada que cruza o teto avisa e segue; a segunda bloqueia.
  5. Kill switch da instalação: AI_BUDGET_ENFORCEMENT (on | avisar | off). Só sabe AFROUXAR; on apenas respeita o que cada organização escolheu.

O gasto tem uma régua só: public.fn_gasto_de_ia_do_mes(uuid), que soma llm_calls do mês corrente. É a mesma função que o gate, o card do cliente e o painel de saúde por tenant consultam. Valores em centavo de DÓLAR — pricing.ts cota o provedor em USD.

Quando o teto para a IA, a conversa NÃO fica sem dono: ela vai para a fila de atendimento humano (performHumanHandoff no engine, triggerHandoff no caminho pré-engine), com item na Central e razão orcamento_de_ia. Volta ao automático uma a uma, pelo botão "Devolver ao automático" no cabeçalho da conversa — subir o teto evita paradas NOVAS, não desfaz as que já aconteceram.

Não implementado, declarado: platform_max_per_tenant (teto de plataforma para o super-admin) não existe. Alarme por e-mail não existe — lib/email/templates/ai-budget-alarm.tsx está sem chamador desde que o cron morto foi apagado (dívida D1 em tests/unit/branding.test.ts). O aviso hoje é a Central, não o e-mail.

ACs. Organização recém-instalada tem enforcement_mode='off' e a IA nunca para por gasto. off → bloquear num único PATCH → 422. Armar sem confirmar_imediato → a parada não vale antes de 72h. Gasto cruza o limiar em avisar → 1 budget_warning aberto, sem duplicar, e a resposta SAI. Gasto atinge o teto em bloquear, com aviso já aberto no mês → chamada recusada antes de sair byte, budget_exceeded na Central, linha orcamento_esgotado em llm_calls, e a conversa em status='pending'. Gasto volta abaixo do limiar (virou o mês, ou o teto subiu) → os itens são retratados sozinhos.

3.14 Logging de prompts e responses

Toda invocação grava prompt completo + response + tools chamados (Fase 2). Retenção: 90 dias hot + cold storage (S3) com lifecycle (doutrina audit Sub-PRD 01). Sanitização de PII opcional por tenant (CPF, telefone, email, nome próprio mascarados antes do envio ao modelo — LGPD Sub-PRD 01 §3.6). Acesso restrito a admin do tenant + super-admin; toda leitura auditada. Armazenado como blob comprimido (gzip/zstd), não jsonb cru.

ACs. Admin abre invocação X e vê prompt+response. Sanitização de CPF ativa → próximo prompt grava ***********. Logs >90d migram pra cold storage. Tentativa de acesso cross-tenant retorna 403 + alerta.


4. Requisitos Não-Funcionais

Performance. Bot inbound→outbound sending <3s p95. Sentiment <2s p95 (paralelo). Retrieval RAG top-K=5 <300ms p95. Re-indexação incremental de 1 item <5s p95. Insert em ai_invocations <500ms p99.

Custo. Custo médio por conversa resolvida pelo bot target <R$ 0,50 (revisitado mensalmente). AI Gateway com observability por tenant é mandatório.

Confiabilidade. Falha do provedor primário → fallback transparente no Gateway. Worker de sentiment falho não derruba bot (degrada graceful). Vector store down → bot continua sem RAG (maior chance de handoff por incerteza).

Segurança & Conformidade. Isolamento cross-tenant forte no vector store (A5). Logs de prompts respeitam LGPD. Zero data retention configurável por tenant. Mudanças de system_prompt, guardrails, is_active, force_human, monthly_limit_cents são auditadas (Sub-PRD 01 §3.5).

Observabilidade. Dashboard por tenant (custo, mensagens, taxa de handoff, latência, distribuição de confidence). Métricas globais (super-admin) com top consumers e alertas de tenants em 80%/100%. Sentry nos workers com sanitização antes do send.


5. Acceptance Criteria do sub-PRD

A camada é MVP-completa quando:

  1. Tenant criado tem 1 ai_agent default ativo; bot responde em <3s p95 a inbound padrão
  2. RAG ingere as 4 fontes (FAQ via UI + 1 PDF + catálogo Nuvemshop ≥10 produtos + ≥5 conversas resolvidas anonimizadas) e retrieval retorna top-5 relevantes
  3. Edit de FAQ → bot usa conteúdo novo em ≤30s; rollback de versão em <2s
  4. Sentiment grava messages.metadata.sentiment_score em ≤2s p95 sem afetar bot principal
  5. 4 gatilhos de handoff funcionam com metadata.trigger_reason correto; após handoff bot fica silencioso até "Passar pra IA" ou resolved
  6. ai_invocations registra todas chamadas; dashboard mostra custo do mês com diff <2% vs fatura do Gateway
  7. Orçamento: alarme 80%, ação configurável em 100%; reset no dia 1º
  8. Guardrail bloqueia promessa de ressarcimento (handoff) e produto fora de catálogo (handoff)
  9. Modo humano sempre funciona em 2 níveis (tenant e contact via force_human)
  10. messages.metadata.citations[] com chunk_id + kb_version corretos
  11. Logs de prompt+response retidos 90d hot; sanitização de PII configurável
  12. Isolamento cross-tenant testado: query forçando outro organization_id é bloqueada e gera alerta
  13. Bot respeita janela 24h Meta e contacts.is_blocked=true

6. Dependências

Internas

  • Sub-PRD 01 — auth, RLS, audit, event_log, convenções API
  • Sub-PRD 02 — crm_lead_activities polimórfica (tipos ai_responded, handoff_triggered, sentiment_alert); contacts (is_blocked, is_anonymized); crm_lead_links pra último pedido
  • Sub-PRD 03 — webhook inbound, envio outbound, janela 24h, idempotência
  • Sub-PRD 04 — conversation.status='pending', roteamento, notificação Realtime/push, stages requires_human=true
  • Sub-PRD 06 — sync de catálogo (fonte 3 do RAG); webhooks product/created|updated (bloqueante pra RAG completo; bot funcional sem catálogo se outras 3 fontes existirem)

Externas

  • Vercel AI Gateway com fallback Anthropic→OpenAI
  • Anthropic API (Sonnet 4.6 + Haiku 4.5)
  • OpenAI API (fallback + potencialmente embeddings)
  • Voyage AI opcional (decisão deferida)
  • pgvector ou Supabase Vector
  • S3 (cold storage de logs >90d)

7. Riscos Específicos do sub-PRD

# Risco Severidade Mitigação
A1 Custo de IA explode (top-K alto + Sonnet em todos) Crítico Top-K conservador (5); orçamento por tenant com alarme 80% / throttle 100%; observability via Gateway; fallback Haiku em throttle
A2 Bot aluciena sem RAG (catalog miss) Crítico Guardrail "produto fora de catálogo" + validador pós-resposta; sem RAG hit em pergunta sobre produto → handoff
A3 Bot escala humano demais (false positive sentiment) Alto Threshold por tenant; revisão semanal nos primeiros 30d; humano marca "handoff desnecessário" alimentando ajuste
A4 Bot escala humano de menos (false negative) Alto 4 gatilhos redundantes; regex de pedido explícito como rede de segurança; revisão amostral de NPS
A5 Vector store cross-tenant leak Crítico Filter organization_id em toda query (programático + RLS quando viável); teste de isolamento no CI; alerta se query sem filter
A6 Modelo upstream muda comportamento sem aviso Médio Pin de versão via Gateway; smoke test em staging; monitoramento de regressão semanal; rollback rápido
A7 Latência alta em pico (>5s p95) Alto Gateway com fallback regional; controle de tamanho de contexto; alerta em p95 >3.5s sustained; degradação graceful
A8 RAG stale (FAQ editado e bot responde antigo) Alto Re-indexação incremental síncrona; SLA <30s; UI mostra "última indexação"; alerta se lag >5min
A9 Bot envia fora da janela 24h e tenant é banido Crítico Guardrail hard de janela 24h; validação pré-despacho; activity system.window_24h_expired em vez de tentar enviar
A10 Custo de embeddings em sync inicial de catálogo grande Médio Batching agressivo; modelo de embeddings menor pra initial load; sync spread em horas
A11 Conversas anonimizadas vazam PII residual no few-shot Alto Validador automático (regex CPF/email/telefone/nome próprio); opt-in explícito; revisão amostral
A12 Logging de prompts armazena PII e vira problema LGPD Médio Sanitização opcional por tenant; retenção 90d hot + cold; acesso restrito; auditado

8. Fora de Escopo (deste sub-PRD)

  • MCP tools-side (bot mutar CRM via tools) — Fase 2
  • A/B testing de prompts — Fase 2
  • Multi-language (tenant em ES/EN) — pós-MVP
  • Voice/áudio (transcrição de áudio) — pós-MVP
  • Geração proativa (recovery de carrinho por IA) — pós-MVP
  • NPS automatizado com cálculo agregado — pós-MVP
  • Roteamento avançado de agents (1 default no MVP) — pós-MVP
  • Fine-tuning custom por tenant — não previsto
  • Modo "co-pilot" (sugestão pro humano em vez de envio) — pós-MVP
  • Aprendizado online (bot melhora com feedback) — pós-MVP

9. Decisões deferidas pra Spec (Fase 3)

A decidir em docs/specs/05-spec-ai-rag-handoff.md:

  1. Vector store: pgvector vs Supabase Vector — benchmark (latência, custo, DX, RLS)
  2. Embeddings: OpenAI text-embedding-3-small vs Voyage — benchmark em PT-BR
  3. Chunking: fixed-size com overlap vs semantic; tamanho/overlap ótimos
  4. Schema SQL completo de ai_agents, ai_invocations, kb_chunks, kb_versions, kb_sources
  5. Política de re-indexação: debounce e batching (sync de 10k produtos em quantos lotes?)
  6. Formato declarativo dos guardrails jsonb (regex? JSON schema com operadores? híbrido?)
  7. Sistema de prompt templates com variáveis (Handlebars-like? Mustache? custom mínimo?)
  8. Threshold default de confidence_score pra disparar G3
  9. Listas canônicas: regex de pedido explícito de humano (PT-BR), marcadores de incerteza, termos de escalação obrigatória
  10. Algoritmo de cálculo de confidence_score (heurística + auto-avaliação ou só heurística)
  11. Política de modo throttle em 100%: degrada pra Haiku ou rejeita inbounds excedentes?
  12. Estratégia de anonimização de conversas resolvidas pra few-shot (validador + revisão amostral)
  13. A/B testing framework (deferido pra Fase 2, spec deve considerar encaixe)
  14. Layout do dashboard de uso + fluxo de UI pra editar FAQ, upload de política, marcar usable_for_rag

Anexos

  • docs/research/reference-synthesis.md (especialmente §2 Arquitetura, §3 Data model, §11 Gaps a desenhar — pontos 3 e 4)
  • docs/prd/00-prd-master.md (especialmente §6.2, §6.3, §6.5)
  • docs/prd/01-prd-platform-base.md (audit log, event_log, LGPD framework)
  • docs/prd/02-prd-customer-360.md (timeline polimórfica, contacts, crm_lead_links)
  • docs/prd/03-prd-whatsapp-waha.md (inbound webhook, outbound dispatch, janela 24h)
  • docs/prd/04-prd-pipeline-attendance.md (conversation.status, roteamento, stages)
  • tasks/todo.md

Anexo — Sistema de Follow-up Inteligente (2026-07)

Contrato detalhado: docs/superpowers/specs/2026-07-21-followup-system-design.md. Pesquisa de referência (odysseus/hermes/openclaw + autópsia TomikCRM): docs/research/followup-reference-mining.md.

Contrato vigente (Tasks 6–7, 2026-09): a extensão operacional está em docs/architecture/ponte-agendamento-followup.md e docs/architecture/agenda-google-sync.architecture.json. O desenho de 2026-07 abaixo continua como referência do motor, sujeito aos contratos atuais.

Um compromisso fica ligado a contato e, opcionalmente, a conversa, ou é marcado explicitamente como pessoal. Comparecimento e falta são desfechos confirmados por uma pessoa após o início; relógio e Google nunca os inferem. Compromisso vivo ou presença ainda desconhecida protegem o contato do follow-up proativo até o horizonte configurado. Vencê-lo retira somente essa proteção: não registra falta nem inicia recuperação. A falta confirmada pode iniciar o fluxo publicado configurado, vinculado ao compromisso e à sua revisão; inbound canônico novo invalida o recibo ou interrompe a recuperação já iniciada, preservando o resultado original para revisão humana.

Na integração Google, a pessoa seleciona calendários fonte de ocupação e um destino gravável entre as próprias conexões. Publicação e reconciliação preservam a identidade estável de organização, conexão, calendário e evento; trocar o destino não move evento já publicado. Alterações incompatíveis no mesmo aspecto compartilhado ou substituição de uma edição remota exigem resolução explícita; mudanças compatíveis são reconciliadas preservando os campos não alterados. A cobertura do cache tem janela e frescor locais, sem prometer visão completa fora delas. A prova de transporte usou receiver HTTP controlado e não comprova conta Google real, consentimento OAuth ou entrega de convite.

O agente ganhou um subsistema de follow-up com um motor, um enrollment, um relógio (followup_enrollments.next_eval_at) — silêncio, demanda ("me chama em X dias") e campanha são gatilhos do MESMO grafo, nunca motores paralelos (o anti-padrão-raiz do CRM anterior).

Modelo de dados (migrations 0054/0056/0057/0061, todas no baseline + MANIFEST):

  • followup_flow_versions (grafo imutável) + followup_flow_pointers (identidade, draft_graph, handoff_policy, trigger_config) — padrão *_versions/*_pointers do harness; lead em voo fica pinado na versão em que entrou.
  • followup_enrollments (lead no fluxo: status active/waiting_reply/paused_handoff/completed/cancelled/dead, next_eval_at, outcome) + followup_enrollment_events (append-only, idempotência por (enrollment_id, idempotency_key)).
  • ai_agent_versions.followup jsonb ({enabled, flow_pointer_ids}) — seletor de fluxos por agente, no veto de imutabilidade da versão.

Motor (lib/followup/engine.ts, cron followup-flow-worker 1/min): claim FOR UPDATE SKIP LOCKED, 1 nó/tick, envio at-most-once (mensagem duplicada de follow-up = ban), backoff [30s,1m,5m,15m,1h] → dead-letter em agent_inbox_items. Nós de IA delegam a job_queue (kind followup_turn).

6 nós (Zod + validador estrutural exato no publish, lib/followup/validate-publish.ts): Gatilho (silêncio/estágio/fim-de-conversa), Espera (fixa/inteligente com clamp), Condição, IA Classifica (grace obrigatório ≥15min), Ação (ai_message/template), Fim. Builder visual React Flow em /app/ai/followups.

As 3 causas-raiz do CRM anterior viraram regras estruturais: (1) janela 24h validada no PUBLISH (espera longa exige template fallback); (2) ai_classify com grace obrigatório (nunca classifica no instante zero); (3) paused_handoff com retomada por evento (ai.handoff_resolved) — estado órfão impossível.

Reatividade (lib/followup/reactivity.ts, via event_log dispatcher): inbound acorda classify; STOP cancela tudo (opted_out); handoff pausa/retoma. Gatilho de silêncio (lib/followup/silence-sweep.ts) só enrolla se um agente publicado tem o fluxo habilitado (gate org-wide). Flywheel: lib/followup/outcome-stats.ts agrega outcomes por fluxo/versão (conversion_rate) no run do flywheel.

Fila (/app/ai/followups aba Fila): união de enrollments vivos + promessas do schedule_followup, com cancelamento manager+.