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
119 lines
6.1 KiB
Markdown
119 lines
6.1 KiB
Markdown
# 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).
|