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
6.1 KiB
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 onderecord_sessionfoi 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 ficaAVAILABLE, se vira umAIJob(TRANSCRIPTION). Chamado a partir derecording.tslogo depois doRecording.create.apps/ai-worker/— serviço novo (Docker,tsxdireto, mesmo padrão deapps/predictive-dialer): tick a cadaAI_WORKER_TICK_INTERVAL_MS(default 5s), reserva atéAI_WORKER_JOBS_PER_TICK(default 5) jobs pendentes por tenant comFOR UPDATE SKIP LOCKED, processa TRANSCRIPTION/ANALYSIS, marca COMPLETED ou aplica retry/dead-letter.apps/api/src/ai/ai-prompts.controller.ts— CRUD deAIPromptTemplate/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):
- Entitlement do Plan (
aiEnabled+aiTranscriptionEnabled) — "essa conta contratou IA?". Não-lançante viaisFeatureEnabled(novo empackages/entitlements), porque isto é uma decisão em background, não uma requisição HTTP que deveria retornar 403. - 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 masPlan.aiEnabled=false→ nenhum job (o entitlement bloqueia mesmo quando a privacidade libera). splitStereoWavcom 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,
RecordingrealAVAILABLE,AIJobreal reservado viaFOR UPDATE SKIP LOCKEDpelo 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. processAnalysisJobcom 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).