Files
B2BCall-dialer/docs/AI_PROVIDERS.md
Matheus 7597b35454 feat(ai): provider layer — abstracao, OpenAI/Anthropic, global+BYOK
Fecha agente.md secao 95-103. Primeira peca do modulo de IA — a
abstracao de provider, os adapters OpenAI/Anthropic, capabilities, e o
cadastro de providers/modelos (global + BYOK). O pipeline que aciona
isso depois de uma chamada terminar (transcricao, analise, jobs
assincronos, prompts, scorecards, usage metering — secao 104-124) fica
pra proxima fase.

## Schema completo do modulo de IA numa unica migration

Todas as tabelas das secoes 95-124 de uma vez (ai_providers/ai_models/
ai_prompt_templates+versions/ai_jobs/call_transcriptions+segments/
call_ai_analyses/quality_scorecards+items+evaluations/ai_usage_records) —
mais barato revisar o desenho relacional inteiro numa unica passada do
que fatiar em migrations pequenas que se emendam. O codigo que usa essas
tabelas vem em fases separadas, so' Provider/Model nesta.

## packages/ai

Interface AIProvider (secao 96: nunca hardcoda OpenAI no dominio).
OpenAIProvider/AnthropicProvider (secao 97-98, nomenclatura "OpenAI API"/
"Anthropic API", nunca "ChatGPT") via fetch nativo direto contra cada API
— sem SDK oficial, request/response inteiramente visivel no proprio
codigo (relevante ja' que manda dado de cliente pra fora, secao 122-123).
transcribe so' na OpenAI (Anthropic nao tem endpoint de audio, secao 102:
"nem todo provider tem todas as capacidades"); analyze/structuredGenerate
via Structured Outputs na OpenAI e "tool use" forcado na Anthropic.
SensitiveDataRedactor (secao 123): CPF/CNPJ/telefone/email/cartao.

**Nunca exercitados contra rede real** — esta sessao so' tem autorizacao
de rede pro servidor git (restricao definida desde o primeiro pedido do
usuario). Mesmo padrao de honestidade ja' usado pro S3ObjectStorageProvider
e o caminho PSTN real.

## ai_providers/ai_models — global vs. BYOK

scope=GLOBAL (platform admin, tenant_id null) visivel de qualquer tenant;
scope=TENANT (BYOK) so' do dono. RLS hibrida (tenant_id = current OR
tenant_id IS NULL, mesma tecnica de tenant_memberships no login); quem
pode ESCREVER num GLOBAL e' decidido na camada de servico
(isPlatformUser), nao pela RLS. Key nunca reexposta (so' apiKeyPreview).

## Dois bugs reais achados testando esta fase

- Delete de provider fazia hard delete, bloqueado por FK quando um
  AIModel (mesmo soft-deleted) ainda referenciava — inconsistente com o
  resto do sistema (tudo soft delete). Corrigido; GET /ai/providers
  tambem nao filtrava desabilitados, corrigido junto.
- SensitiveDataRedactor: \b antes de \(? opcional falha quando o char
  anterior tambem nao e' de palavra (espaco + "("), vazando um parenteses
  solto (nenhum dado sensivel de verdade vazava). Corrigido com (?<!\w).

Verificado ponta a ponta com 2 tenants + platform admin: GLOBAL so'
platform admin cria/apaga, BYOK isolado por RLS (tenant B nunca ve' BYOK
do tenant A, 404 em id direto), modelo de provider GLOBAL visivel dos
dois tenants, redactor com 5 tipos de dado sensivel todos corretos.

typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
2026-08-28 15:05:00 -03:00

8.2 KiB

IA — Provider Layer

Agente.md secao 95-103. Primeira peça do módulo de IA: a abstração de provider (nunca hardcoda OpenAI no domínio), os adapters OpenAI/Anthropic, capabilities, e o cadastro de providers/modelos (global + BYOK). O pipeline que de fato aciona isso depois de uma chamada terminar (transcrição, análise, jobs assíncronos, prompts, scorecards, usage metering — secao 104-124) é a próxima fase, ver TODO.md.

Schema completo do módulo de IA, código parcial

O schema desta migration (ai_module) já cobre TODAS as tabelas das secções 95-124 (ai_providers, ai_models, ai_prompt_templates/ ai_prompt_versions, ai_jobs, call_transcriptions/ call_transcript_segments, call_ai_analyses, quality_scorecards/ quality_scorecard_items/quality_evaluations, ai_usage_records) de uma vez — mais barato revisar o desenho relacional inteiro (FKs entre Call/CallAttempt/Campaign) numa única passada do que fatiar em várias migrations pequenas que se emendam. O código que usa essas tabelas é que vem em fases separadas — esta fase só implementa Provider/Model.

packages/aiAIProvider (secao 96)

interface AIProvider {
  getCapabilities(): Promise<AICapabilityName[]>;
  validateCredentials(): Promise<boolean>;
  transcribe?(params): Promise<TranscribeResult>;
  analyze?(params): Promise<AnalyzeResult>;
  summarize?(text): Promise<string>;
  structuredGenerate?(prompt, schema): Promise<Record<string, unknown>>;
}

Nenhum código fora de packages/ai conhece o formato de request/response de uma API de IA específica.

OpenAIProvider/AnthropicProvider (secao 97-98)

Usam fetch nativo direto contra a "OpenAI API"/"Anthropic API" (secao 98: nomenclatura da API, nunca "ChatGPT" — o domínio não fica amarrado ao nome do produto de consumidor) — sem SDK oficial, pra manter o request/ response inteiramente visível no nosso próprio código (relevante já que potencialmente manda dados de cliente pra fora, secao 122-123).

  • transcribe (só OpenAI — Anthropic não tem endpoint de áudio, secao 102: "nem todo provider terá todas as capacidades"): POST /audio/ transcriptions, response_format=verbose_json pra pegar segmentos com timestamp.
  • analyze/structuredGenerate: OpenAI via response_format: json_schema (Structured Outputs); Anthropic via "tool use" forçado (tool_choice apontando pra uma tool única cujo input_schema é o JSON Schema pedido) — a técnica documentada da própria Anthropic pra output estruturado confiável antes de terem suporte nativo equivalente ao da OpenAI.

Nunca exercitados nesta sessão — a autorização de rede desta sessão cobre só o servidor git do repositório (restrição definida desde o primeiro pedido do usuário), então nenhuma chamada real saiu daqui. Implementados seguindo o contrato documentado de cada API o mais fiel possível; revisar contra as APIs reais antes de confiar em produção — mesmo padrão de honestidade já usado pra S3ObjectStorageProvider (fase Recording) e o caminho PSTN real (fase Predictive Dialer).

SensitiveDataRedactor (secao 123)

Mascara CPF/CNPJ/telefone/cartão/email em texto livre antes de mandar pra um provider, quando a política de privacidade exigir. Testado com casos reais (CPF/CNPJ formatados, telefone com parênteses, email, número de cartão) — todos os 5 tipos corretamente mascarados, frase sem dado sensível passa intacta.

Achado real testando: um \b logo antes de um \(? opcional (regex de telefone) falha em casar quando o caractere anterior também não é de palavra (ex.: espaço seguido de () — \b exige uma transição palavra/não-palavra dos dois lados, e nem "espaço" nem "(" são caracteres de palavra. Isso deixava o ( de fora do match, vazando um parêntese solto no texto redigido (nenhum dado sensível vazava de verdade, só um caractere de formatação). Corrigido trocando o \b inicial por (?<!\w), que não tem esse problema.

ai_providers/ai_models — global vs. BYOK (secao 99-101)

scope=GLOBAL (cadastrado pelo platform admin, tenant_id null) é visível de qualquer tenant; scope=TENANT (BYOK — "Bring Your Own Key") só do próprio dono. RLS híbrida (a mesma técnica já usada pra tenant_memberships na descoberta de tenant durante login):

USING (tenant_id = current_tenant_id() OR tenant_id IS NULL)

Quem pode escrever num provider GLOBAL é decidido na camada de serviço (isPlatformUser, já existia desde a fase RBAC), não pela RLS — RLS aqui só garante que o BYOK de um tenant nunca vaza pro outro.

encryptedApiKey (AES-256-GCM, mesmo padrão de sipPasswordEnc/ passwordEnc) nunca é reexposta — toda resposta troca por apiKeyPreview (últimos 8 caracteres cifrados, só o suficiente pra reconhecer qual key é).

AIModel.tenantId é copiado do provider dono na criação (evita subquery de RLS cruzando tabelas, mesmo padrão de CampaignStats/CallLeg/etc.).

Achados reais testando esta fase

  • Delete de provider bloqueado por FK: AIProvidersController.remove fazia hard delete (tx.aIProvider.delete), mas AIModel.providerId é ON DELETE RESTRICT — apagar um provider com pelo menos um modelo cadastrado (mesmo um modelo já "apagado" via soft delete, que só marca enabled=false) falhava com violação de FK. Inconsistente com o resto do sistema (Extension/Trunk/Queue/Campaign/Disposition/PauseReason — todos soft delete). Corrigido: remove agora também faz enabled=false, nunca hard delete — necessário de qualquer forma pra preservar provider_id válido no histórico de CallTranscription/CallAIAnalysis depois que um provider é desativado.
  • GET /ai/providers não filtrava desabilitados: consequência direta do achado acima — sem o filtro enabled: true, um provider "apagado" continuava aparecendo na lista. Corrigido junto.

Verificado ponta a ponta

Tenant A (não platform admin) tenta criar provider GLOBAL -> 403
Tenant A cria provider TENANT (BYOK, Anthropic) -> sucesso,
  encryptedApiKey nunca aparece na resposta (só apiKeyPreview)
providerType não suportado ("google") -> 403 com mensagem clara

Platform admin cria provider GLOBAL (OpenAI) -> sucesso, tenantId null

Tenant A lista -> ve' o proprio BYOK + o GLOBAL (2 providers)
Tenant B lista -> ve' SO' o GLOBAL (1 provider) — BYOK do Tenant A nunca
  vaza, confirmando a RLS hibrida
Tenant B tenta apagar o BYOK do Tenant A (por id direto) -> 404 (RLS
  esconde a linha antes mesmo do controller decidir)
Tenant B tenta apagar o provider GLOBAL -> 403 (nao e' platform admin)

Tenant A cria um AIModel sob o provider GLOBAL -> tenantId herdado como
  null, aparece na lista de AMBOS os tenants (modelo de provider global e'
  global tambem)

Apos a correcao do soft delete: apagar modelo -> 204, apagar o provider
  GLOBAL que tinha esse modelo -> 204 (antes falhava com FK), lista fica
  vazia pros dois tenants

typecheck do workspace inteiro limpo.

O que falta

  • Pipeline assíncrono pós-CALL_ENDED (secao 106-108: ai_jobs, retry com backoff exponencial, dead-letter) — schema pronto, worker não implementado ainda.
  • Transcrição de verdade (secao 109-111: call_transcriptions/ call_transcript_segments, mapeamento de speaker via canal estéreo) — schema pronto, nada chama provider.transcribe() ainda.
  • Análise da chamada (secao 112-113), prompts por tenant/campanha (secao 114-116), scorecards/QA automático (secao 117-118) — schema pronto, nada implementado.
  • Privacidade em cascata Campaign > Queue > Tenant (secao 122) — Tenant.aiPrivacyLevel/Queue.aiPrivacyLevel existem no schema, função de resolução ainda não escrita (só faz sentido junto com o pipeline).
  • Usage metering (secao 124) — AIUsageRecord existe, nada grava nele ainda (só faz sentido junto com transcrição/análise reais).
  • ParseUUIDPipe/validação de UUID em @Param("id") — um id malformado na URL retorna 500 (erro genérico do Prisma) em vez de 400. Mesmo padrão em TODOS os controllers deste projeto, não é regressão desta fase — registrado aqui porque foi onde apareceu durante o teste, mas o fix (se vier a acontecer) é um retrofit em todo o apps/api, fora do escopo desta fase.