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:
118
docs/AI_PIPELINE.md
Normal file
118
docs/AI_PIPELINE.md
Normal 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).
|
||||
Reference in New Issue
Block a user