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:
2026-08-28 15:05:00 -03:00
parent c24a86776c
commit 7597b35454
20 changed files with 1878 additions and 62 deletions

View File

@@ -0,0 +1,83 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateAIModelDto } from "./dto/create-ai-model.dto";
/** "ai_models" (agente.md secao 102-103) — modelos configuráveis por
* provider, cada um com seu conjunto de capabilities e custos. */
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("ai/models")
export class AIModelsController {
@RequirePermission("ai.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAIModelDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const provider = await withTenantContext(prisma, tenantId, (tx) =>
tx.aIProvider.findFirst({ where: { id: dto.providerId } }),
);
if (!provider) throw new NotFoundException("Provider nao encontrado");
const model = await withTenantContext(prisma, tenantId, (tx) =>
tx.aIModel.create({
data: {
tenantId: provider.tenantId,
providerId: provider.id,
externalModelId: dto.externalModelId,
displayName: dto.displayName,
capabilities: (dto.capabilities ?? []) as Prisma.AIModelCreateInput["capabilities"],
inputCost: dto.inputCost,
outputCost: dto.outputCost,
audioCost: dto.audioCost,
},
}),
);
await recordAuditEvent(prisma, {
action: "AI_MODEL_CREATE",
tenantId: provider.tenantId,
userId: user.sub,
entityType: "ai_model",
entityId: model.id,
after: { displayName: model.displayName, externalModelId: model.externalModelId },
});
return model;
}
@RequirePermission("ai.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.aIModel.findMany({ where: { enabled: true }, orderBy: { displayName: "asc" } }),
);
}
@RequirePermission("ai.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.aIModel.updateMany({ where: { id }, data: { enabled: false } }),
);
if (result.count === 0) throw new NotFoundException();
await recordAuditEvent(prisma, {
action: "AI_MODEL_DELETE",
tenantId,
userId: user.sub,
entityType: "ai_model",
entityId: id,
});
}
}