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
This commit is contained in:
171
docs/AI_PROVIDERS.md
Normal file
171
docs/AI_PROVIDERS.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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/ai` — `AIProvider` (secao 96)
|
||||
|
||||
```typescript
|
||||
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):
|
||||
|
||||
```sql
|
||||
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.
|
||||
Reference in New Issue
Block a user