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:
@@ -9,6 +9,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@b2bcall/ai": "workspace:*",
|
||||
"@b2bcall/auth": "workspace:*",
|
||||
"@b2bcall/database": "workspace:*",
|
||||
"@b2bcall/entitlements": "workspace:*",
|
||||
|
||||
83
apps/api/src/ai/ai-models.controller.ts
Normal file
83
apps/api/src/ai/ai-models.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
136
apps/api/src/ai/ai-providers.controller.ts
Normal file
136
apps/api/src/ai/ai-providers.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext, type AIProvider } from "@b2bcall/database";
|
||||
import { encryptSecret } from "@b2bcall/shared";
|
||||
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { SUPPORTED_PROVIDER_TYPES } from "@b2bcall/ai";
|
||||
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 { CreateAIProviderDto } from "./dto/create-ai-provider.dto";
|
||||
|
||||
// Nunca reexpõe encryptedApiKey (agente.md secao 101: "nunca mostrar key
|
||||
// inteira após salvar") — só um preview dos últimos 4 caracteres, o
|
||||
// suficiente pra reconhecer qual key é sem nunca vazar a key completa de
|
||||
// volta pro client.
|
||||
function serializeProvider(provider: AIProvider) {
|
||||
const { encryptedApiKey: _encryptedApiKey, ...rest } = provider;
|
||||
return { ...rest, apiKeyPreview: `...${provider.encryptedApiKey.slice(-8)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers de IA (agente.md secao 96-101). `scope=GLOBAL` (cadastrado
|
||||
* pelo platform admin, visível de qualquer tenant — secao 100) só pode ser
|
||||
* criado/apagado por quem tem role PLATFORM; `scope=TENANT` (BYOK) é livre
|
||||
* pra qualquer tenant admin gerenciar só o próprio.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("ai/providers")
|
||||
export class AIProvidersController {
|
||||
@RequirePermission("ai.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAIProviderDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
if (dto.scope === "GLOBAL") {
|
||||
const isPlatform = await isPlatformUser(user.sub);
|
||||
if (!isPlatform) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode cadastrar um provider GLOBAL");
|
||||
}
|
||||
}
|
||||
if (!SUPPORTED_PROVIDER_TYPES.includes(dto.providerType)) {
|
||||
throw new ForbiddenException(
|
||||
`providerType nao suportado: ${dto.providerType} (suportados: ${SUPPORTED_PROVIDER_TYPES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
const provider = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIProvider.create({
|
||||
data: {
|
||||
scope: dto.scope,
|
||||
tenantId: dto.scope === "GLOBAL" ? null : tenantId,
|
||||
providerType: dto.providerType,
|
||||
name: dto.name,
|
||||
baseUrl: dto.baseUrl,
|
||||
encryptedApiKey: encryptSecret(dto.apiKey),
|
||||
organization: dto.organization,
|
||||
project: dto.project,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_PROVIDER_CREATE",
|
||||
tenantId: dto.scope === "GLOBAL" ? null : tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_provider",
|
||||
entityId: provider.id,
|
||||
after: { providerType: provider.providerType, name: provider.name, scope: provider.scope },
|
||||
});
|
||||
|
||||
return serializeProvider(provider);
|
||||
}
|
||||
|
||||
/** Global (visível de qualquer tenant) + BYOK do próprio tenant — a
|
||||
* própria RLS híbrida já resolve essa união, sem WHERE extra aqui. */
|
||||
@RequirePermission("ai.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const providers = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.aIProvider.findMany({ where: { enabled: true }, orderBy: { name: "asc" } }),
|
||||
);
|
||||
return providers.map(serializeProvider);
|
||||
}
|
||||
|
||||
@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 provider = await withTenantContext(prisma, tenantId, (tx) => tx.aIProvider.findFirst({ where: { id } }));
|
||||
if (!provider) throw new NotFoundException();
|
||||
|
||||
if (provider.scope === "GLOBAL") {
|
||||
const isPlatform = await isPlatformUser(user.sub);
|
||||
if (!isPlatform) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode apagar um provider GLOBAL");
|
||||
}
|
||||
} else if (provider.tenantId !== tenantId) {
|
||||
// Nunca deveria acontecer (RLS ja' filtra), mas nunca custa checar
|
||||
// explicitamente antes de uma escrita (secao 31/146).
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Soft delete (mesmo padrão de Disposition/PauseReason/etc.) — nunca
|
||||
// hard delete: um AIModel ainda pode referenciar este provider (FK
|
||||
// RESTRICT), e o histórico de CallTranscription/CallAIAnalysis que já
|
||||
// usou este provider precisa continuar apontando pra um provider_id
|
||||
// válido mesmo depois de desativado.
|
||||
await withTenantContext(prisma, tenantId, (tx) => tx.aIProvider.update({ where: { id }, data: { enabled: false } }));
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AI_PROVIDER_DELETE",
|
||||
tenantId: provider.scope === "GLOBAL" ? null : tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ai_provider",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
apps/api/src/ai/ai.module.ts
Normal file
8
apps/api/src/ai/ai.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AIProvidersController } from "./ai-providers.controller";
|
||||
import { AIModelsController } from "./ai-models.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [AIProvidersController, AIModelsController],
|
||||
})
|
||||
export class AIModule {}
|
||||
33
apps/api/src/ai/dto/create-ai-model.dto.ts
Normal file
33
apps/api/src/ai/dto/create-ai-model.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||
|
||||
const CAPABILITIES = ["TRANSCRIPTION", "DIARIZATION", "TEXT_ANALYSIS", "STRUCTURED_OUTPUT", "EMBEDDINGS", "REALTIME_AUDIO"];
|
||||
|
||||
export class CreateAIModelDto {
|
||||
@IsUUID()
|
||||
providerId!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
externalModelId!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
displayName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(CAPABILITIES, { each: true })
|
||||
capabilities?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
inputCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
outputCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
audioCost?: number;
|
||||
}
|
||||
34
apps/api/src/ai/dto/create-ai-provider.dto.ts
Normal file
34
apps/api/src/ai/dto/create-ai-provider.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class CreateAIProviderDto {
|
||||
@IsIn(["GLOBAL", "TENANT"])
|
||||
scope!: "GLOBAL" | "TENANT";
|
||||
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
providerType!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
baseUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(500)
|
||||
apiKey!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
organization?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
project?: string;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { DispositionsModule } from "./dispositions/dispositions.module";
|
||||
import { CallsModule } from "./calls/calls.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { RecordingsModule } from "./recordings/recordings.module";
|
||||
import { AIModule } from "./ai/ai.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { RecordingsModule } from "./recordings/recordings.module";
|
||||
CallsModule,
|
||||
ReportsModule,
|
||||
RecordingsModule,
|
||||
AIModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
Reference in New Issue
Block a user