feat(ai): pipeline assincrono — transcricao, analise, prompts (fase 20)

Sub-fase B do modulo de IA: novo servico apps/ai-worker (poll + FOR UPDATE
SKIP LOCKED) processa AIJob de TRANSCRIPTION/ANALYSIS disparados
automaticamente apos uma gravacao ficar disponivel, respeitando a cascata
de privacidade Tenant>Queue>Campaign e o entitlement do Plan. Transcricao
separa o WAV estereo em 2 canais (parser proprio, sem ffmpeg) e transcreve
cada perna independente; analise sempre redige dados sensiveis antes de
sair pro provider e valida o resultado contra o schema antes de persistir.
CRUD de AIPromptTemplate/AIPromptVersion em apps/api.

Testado ponta a ponta contra o worker real em Docker e Postgres real com
RLS (cascata de privacidade em 3 cenarios, WAV sintetico real no object
storage, claim/retry/dead-letter reais) — chamada de rede contra
OpenAI/Anthropic continua nunca exercitada (mesma restricao de rede desde
o Provider Layer). Detalhes em docs/AI_PIPELINE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 16:39:38 -03:00
parent 7597b35454
commit 91c0448dd4
29 changed files with 1427 additions and 5 deletions

118
docs/AI_PIPELINE.md Normal file
View File

@@ -0,0 +1,118 @@
# AI Pipeline (agente.md secao 104-116)
Sub-fase B da fase de IA: transforma o Provider Layer (PHASE 19, ver
`docs/AI_PROVIDERS.md`) num pipeline assíncrono de verdade — transcrição e
análise de chamadas, disparado sozinho depois que uma gravação fica
disponível.
## Onde cada peça mora
- `packages/ai/src/privacy.ts` — cascata Tenant > Queue > Campaign (secao
122), pura, sem I/O.
- `packages/ai/src/retry.ts` — backoff exponencial + dead-letter (secao
108), pura.
- `packages/ai/src/call-analysis-schema.ts` — schema JSON + validação
manual do resultado de análise (secao 112-113).
- `packages/ai/src/wav-stereo-split.ts` — separa um WAV estéreo em dois
WAV mono (canal 0 = perna onde `record_session` foi chamado/"cliente",
canal 1 = perna bridgeada/"agente" — convenção não confirmada contra
áudio real distinguível nesta sessão, só com tons sintéticos).
- `apps/freeswitch-events/src/ai-trigger.ts` — decide, no momento em que
uma gravação fica `AVAILABLE`, se vira um `AIJob(TRANSCRIPTION)`.
Chamado a partir de `recording.ts` logo depois do `Recording.create`.
- `apps/ai-worker/` — serviço novo (Docker, `tsx` direto, mesmo padrão de
`apps/predictive-dialer`): tick a cada `AI_WORKER_TICK_INTERVAL_MS`
(default 5s), reserva até `AI_WORKER_JOBS_PER_TICK` (default 5) jobs
pendentes por tenant com `FOR UPDATE SKIP LOCKED`, processa
TRANSCRIPTION/ANALYSIS, marca COMPLETED ou aplica retry/dead-letter.
- `apps/api/src/ai/ai-prompts.controller.ts` — CRUD de
`AIPromptTemplate`/`AIPromptVersion` (secao 114-116), mesma RLS híbrida
GLOBAL/tenant dos providers/models.
## Decisão de disparar (ou não) um job
Duas checagens independentes, as duas precisam passar (`ai-trigger.ts`):
1. **Entitlement do Plan** (`aiEnabled` + `aiTranscriptionEnabled`) — "essa
conta contratou IA?". Não-lançante via `isFeatureEnabled` (novo em
`packages/entitlements`), porque isto é uma decisão em background, não
uma requisição HTTP que deveria retornar 403.
2. **Cascata de privacidade** Tenant > Queue > Campaign (secao 122) —
"esse tenant/fila/campanha específica autorizou processar ESTA
chamada?".
O job de ANALYSIS nunca é criado de cara — só depois que o job de
TRANSCRIPTION correspondente completa com sucesso, reconsultando a mesma
cascata (a config pode ter mudado entre os dois).
## Resolução de provider/modelo
agente.md não especifica um algoritmo de seleção quando o tenant tem
múltiplos providers/modelos com a mesma capability. Política adotada
(`apps/ai-worker/src/provider-resolution.ts`): prefere BYOK do próprio
tenant sobre o provider GLOBAL da plataforma; dentro de cada grupo, o
primeiro `AIModel` habilitado com a capability pedida, por `createdAt`.
Sem heurística de custo/qualidade ainda — revisar se precisar de algo mais
rico (ex.: fallback em cadeia, menor custo).
## Resolução de prompt template (análise)
`Campaign.analysisPromptTemplateId` sobrescreve quando setado; senão o
primeiro `AIPromptTemplate` de `purpose=ANALYSIS` com versão ativa,
preferindo um template do próprio tenant sobre o padrão GLOBAL. Se nada
for encontrado, o job de ANALYSIS falha com uma mensagem clara ("nenhum
template configurado") e segue o retry/dead-letter normal — não é um
crash, é uma configuração pendente do admin do tenant.
## Redação de dados sensíveis
O texto da transcrição sempre passa pelo `SensitiveDataRedactor` (secao
123) antes de sair pro `provider.analyze()`, **independente** do nível de
privacidade — o nível de privacidade controla SE a análise roda, nunca O
QUE é enviado quando roda. Defesa em profundidade.
## Limitação conhecida: Campaign só pode opt-in, nunca opt-out
`Campaign.aiTranscriptionEnabled`/`aiAnalysisEnabled` são dois booleanos
independentes (existiam desde a fase Campaigns, secao 63), não o mesmo
enum de 3 níveis de `Tenant`/`Queue`. Uma campanha não consegue forçar
`AI_OFF` explicitamente quando o tenant/fila já tem um nível mais
permissivo — só pode ligar mais IA, nunca desligar. Como "opt-in" é a
direção mais segura por padrão, aceitável por agora.
## O que foi testado de verdade nesta sessão
Restrição de rede desta sessão continua valendo: só o servidor git é
autorizado, nenhuma chamada real saiu pra OpenAI/Anthropic. Testado ao
vivo, contra o container `b2bcall-ai-worker` rodando de verdade (não
mocks) e Postgres real com RLS:
- Cascata de privacidade + entitlement, 3 cenários reais: tenant
permissivo → job criado; `tenant.aiPrivacyLevel=AI_OFF` → nenhum job;
privacidade permitindo mas `Plan.aiEnabled=false` → nenhum job (o
entitlement bloqueia mesmo quando a privacidade libera).
- `splitStereoWav` com um buffer sintético: valores extremos
(±32767/-32768), zero, negativo, todos de-interleaved corretamente;
header malformado lança como esperado.
- `retry.ts`/`privacy.ts`/`call-analysis-schema.ts`: backoff exponencial
com teto, dead-letter no limite exato, cascata de privacidade nos 3
níveis, validação aceitando/rejeitando resultado de análise conforme
schema.
- Pipeline completo ponta a ponta: WAV estéreo sintético real gravado no
object storage local, `Recording` real `AVAILABLE`, `AIJob` real
reservado via `FOR UPDATE SKIP LOCKED` pelo worker rodando em Docker,
download+split+resolução de provider bem-sucedidos, chamada de rede
real tentada contra uma porta loopback fechada (`http://127.0.0.1:1`
nunca sai da máquina), falha real (`fetch failed`), retry com backoff
de 30s, dead-letter exatamente na 2ª tentativa configurada.
- `processAnalysisJob` com uma transcrição semeada manualmente: resolveu
template, resolveu provider, redigiu o texto, tentou a chamada real,
falhou da mesma forma esperada.
**Nunca exercitado**: a chamada de rede de verdade contra a API da OpenAI
ou Anthropic (mesma restrição desde o Provider Layer); o encadeamento
automático TRANSCRIPTION→ANALYSIS após uma transcrição *bem-sucedida* (só
dá pra testar a criação do job de ANALYSIS a partir de uma transcrição
semeada manualmente, já que nenhuma transcrição real chega a completar sem
rede); a convenção de canal 0/1 contra áudio real distinguível (só tons
sintéticos).