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

40
TODO.md
View File

@@ -464,10 +464,44 @@
- [ ] Chamadas manuais/internas não são gravadas — só o caminho do
discador tem originate próprio
- [ ] `max_recording_storage_gb` (Plan) existe mas não é aplicado
- [ ] Transcrição (secao 95+, fase IA)
- [ ] Transcrição (secao 95+, fase IA) — implementado parcialmente na
PHASE 19 (só o Provider Layer; pipeline/transcrição real na PHASE 20)
## PHASE 19+ver `agente.md` seções 95 em diante (AI, Billing, Frontend,
Security, Tests)
## PHASE 19 — IA: Provider Layer (agente.md secao 95-103)
- [x] Schema completo do módulo de IA numa única migration (todas as
tabelas das secoes 95-124: providers/models/prompts/jobs/
transcriptions/analyses/scorecards/usage) — código só pro Provider
Layer nesta fase, resto vem em fases separadas
- [x] `packages/ai`: interface `AIProvider` (secao 96, nunca hardcoda
OpenAI no domínio), `OpenAIProvider`/`AnthropicProvider` (secao
97-98, nomenclatura "OpenAI API"/"Anthropic API" nunca "ChatGPT"),
`SensitiveDataRedactor` (secao 123, CPF/CNPJ/telefone/email/cartão)
- [x] **Nunca exercitados contra rede real**: adapters OpenAI/Anthropic —
esta sessão só tem autorização de rede pro servidor git (restrição
desde o primeiro pedido do usuário)
- [x] `ai_providers`/`ai_models`: global (platform admin, visível de
qualquer tenant) vs. BYOK (secao 100-101) — RLS híbrida (`tenant_id
= current OR tenant_id IS NULL`, mesma técnica de
`tenant_memberships` no login), escrita em GLOBAL exige
`isPlatformUser` na camada de serviço, key nunca reexposta
(`apiKeyPreview`)
- [x] **Bug real, achado no teste desta 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` também
não filtrava desabilitados, corrigido junto.
- [x] **Bug real, achado no teste do redactor**: `\b` antes de `\(?`
opcional falha quando o char anterior também não é de palavra
(espaço + `(`) — vazava um parêntese solto. Corrigido com `(?<!\w)`.
- [x] Testado ponta a ponta com 2 tenants + platform admin: GLOBAL só
platform admin cria/apaga, BYOK isolado por RLS (tenant B nunca vê
BYOK do tenant A, 404 em id direto), modelo de provider GLOBAL
visível dos dois tenants, redactor com 5 tipos de dado sensível
- [ ] Pipeline assíncrono pós-CALL_ENDED, transcrição/análise/prompts/
scorecards/usage metering reais — PHASE 20
## PHASE 20+ — ver `agente.md` seções 104 em diante (Transcrição, Análise,
Scorecards, Usage Metering, Billing, Frontend, Security, Tests)
---

View File

@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@b2bcall/ai": "workspace:*",
"@b2bcall/auth": "workspace:*",
"@b2bcall/database": "workspace:*",
"@b2bcall/entitlements": "workspace:*",

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,
});
}
}

View 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,
});
}
}

View 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 {}

View 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;
}

View 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;
}

View File

@@ -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 {}

171
docs/AI_PROVIDERS.md Normal file
View 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.

15
packages/ai/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "@b2bcall/ai",
"version": "0.0.1",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.20.1",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,102 @@
import type {
AIProvider,
AICapabilityName,
AIProviderCredentials,
AnalyzeParams,
AnalyzeResult,
} from "./types";
const ANTHROPIC_VERSION = "2023-06-01";
const TOOL_NAME = "emit_structured_result";
/**
* Adapter pra "Anthropic API" (agente.md secao 97-98). Sem endpoint de
* transcrição de áudio (`transcribe` fica undefined — secao 102: "nem todo
* provider terá todas as capacidades"). Output estruturado (secao 113) via
* "tool use" forçado: define uma tool única com `input_schema` = o JSON
* Schema pedido e `tool_choice` forçando essa tool — o bloco `input` da
* resposta já vem no formato certo, técnica documentada da própria
* Anthropic pra output estruturado confiável.
*
* **Nunca exercitado nesta sessão** — mesma restrição de rede do
* `OpenAIProvider` (só o servidor git é autorizado nesta sessão).
*/
export class AnthropicProvider implements AIProvider {
private readonly baseUrl: string;
constructor(private readonly credentials: AIProviderCredentials) {
this.baseUrl = credentials.baseUrl ?? "https://api.anthropic.com/v1";
}
private headers(): Record<string, string> {
return {
"x-api-key": this.credentials.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
"Content-Type": "application/json",
};
}
async getCapabilities(): Promise<AICapabilityName[]> {
return ["TEXT_ANALYSIS", "STRUCTURED_OUTPUT"];
}
async validateCredentials(): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers() });
return res.ok;
}
async analyze(params: AnalyzeParams): Promise<AnalyzeResult> {
const res = await fetch(`${this.baseUrl}/messages`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({
model: "claude-opus-5",
max_tokens: 4096,
system: params.promptContent,
messages: [{ role: "user", content: params.transcriptText }],
tools: [
{
name: TOOL_NAME,
description: "Emite o resultado estruturado da análise da chamada.",
input_schema: params.jsonSchema,
},
],
tool_choice: { type: "tool", name: TOOL_NAME },
}),
});
if (!res.ok) {
throw new Error(`Anthropic analysis falhou: ${res.status} ${await res.text()}`);
}
const body = (await res.json()) as {
id: string;
content: { type: string; name?: string; input?: Record<string, unknown> }[];
usage?: { input_tokens: number; output_tokens: number };
};
const toolUse = body.content.find((block) => block.type === "tool_use" && block.name === TOOL_NAME);
if (!toolUse?.input) {
throw new Error("Anthropic nao retornou o tool_use esperado com o resultado estruturado");
}
return {
data: toolUse.input,
providerRequestId: body.id,
inputTokens: body.usage?.input_tokens,
outputTokens: body.usage?.output_tokens,
};
}
async summarize(text: string): Promise<string> {
const result = await this.analyze({
transcriptText: text,
promptContent: "Resuma o texto a seguir em até 3 frases.",
jsonSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
});
return result.data.summary as string;
}
async structuredGenerate(prompt: string, schema: Record<string, unknown>): Promise<Record<string, unknown>> {
const result = await this.analyze({ transcriptText: "", promptContent: prompt, jsonSchema: schema });
return result.data;
}
}

14
packages/ai/src/index.ts Normal file
View File

@@ -0,0 +1,14 @@
export type {
AIProvider,
AICapabilityName,
AIProviderCredentials,
TranscribeParams,
TranscribeResult,
TranscribeSegment,
AnalyzeParams,
AnalyzeResult,
} from "./types";
export { OpenAIProvider } from "./openai-provider";
export { AnthropicProvider } from "./anthropic-provider";
export { createAIProvider, SUPPORTED_PROVIDER_TYPES } from "./registry";
export { SensitiveDataRedactor } from "./redactor";

View File

@@ -0,0 +1,133 @@
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import type {
AIProvider,
AICapabilityName,
AIProviderCredentials,
AnalyzeParams,
AnalyzeResult,
TranscribeParams,
TranscribeResult,
} from "./types";
/**
* Adapter pra "OpenAI API" (agente.md secao 97-98 — nomenclatura da API,
* nunca "ChatGPT": o domínio não deve ficar amarrado ao nome do produto de
* consumidor). **Nunca exercitado nesta sessão** — esta sessão só tem
* autorização de rede pro servidor git do repositório (restrição definida
* no início da sessão), então nenhuma chamada real chegou a sair daqui.
* Implementado seguindo o contrato documentado da OpenAI API o mais fiel
* possível; revisar contra a API real antes de confiar em produção.
*/
export class OpenAIProvider implements AIProvider {
private readonly baseUrl: string;
constructor(private readonly credentials: AIProviderCredentials) {
this.baseUrl = credentials.baseUrl ?? "https://api.openai.com/v1";
}
private headers(extra: Record<string, string> = {}): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.credentials.apiKey}`,
...extra,
};
if (this.credentials.organization) headers["OpenAI-Organization"] = this.credentials.organization;
if (this.credentials.project) headers["OpenAI-Project"] = this.credentials.project;
return headers;
}
async getCapabilities(): Promise<AICapabilityName[]> {
return ["TRANSCRIPTION", "TEXT_ANALYSIS", "STRUCTURED_OUTPUT", "EMBEDDINGS"];
}
async validateCredentials(): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers() });
return res.ok;
}
async transcribe(params: TranscribeParams): Promise<TranscribeResult> {
const fileBuffer = await readFile(params.audioFilePath);
const form = new FormData();
form.append("file", new Blob([fileBuffer]), basename(params.audioFilePath));
form.append("model", "whisper-1");
form.append("response_format", "verbose_json");
if (params.language) form.append("language", params.language);
const res = await fetch(`${this.baseUrl}/audio/transcriptions`, {
method: "POST",
headers: this.headers(),
body: form,
});
if (!res.ok) {
throw new Error(`OpenAI transcription falhou: ${res.status} ${await res.text()}`);
}
const body = (await res.json()) as {
text: string;
language?: string;
duration?: number;
segments?: { start: number; end: number; text: string }[];
};
return {
text: body.text,
language: body.language,
durationSeconds: body.duration,
segments: body.segments?.map((s) => ({
startMs: Math.round(s.start * 1000),
endMs: Math.round(s.end * 1000),
text: s.text,
})),
};
}
async analyze(params: AnalyzeParams): Promise<AnalyzeResult> {
// "Structured Outputs" (response_format json_schema, strict) — a API
// já valida contra o schema do lado do provider; ainda assim quem
// chama esta função deve validar de novo antes de persistir (secao
// 113), nunca confiar cegamente.
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: this.headers({ "Content-Type": "application/json" }),
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: params.promptContent },
{ role: "user", content: params.transcriptText },
],
response_format: {
type: "json_schema",
json_schema: { name: "call_analysis", strict: true, schema: params.jsonSchema },
},
}),
});
if (!res.ok) {
throw new Error(`OpenAI analysis falhou: ${res.status} ${await res.text()}`);
}
const body = (await res.json()) as {
id: string;
choices: { message: { content: string } }[];
usage?: { prompt_tokens: number; completion_tokens: number };
};
return {
data: JSON.parse(body.choices[0].message.content),
providerRequestId: body.id,
inputTokens: body.usage?.prompt_tokens,
outputTokens: body.usage?.completion_tokens,
};
}
async summarize(text: string): Promise<string> {
const result = await this.analyze({
transcriptText: text,
promptContent: "Resuma o texto a seguir em até 3 frases.",
jsonSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
});
return result.data.summary as string;
}
async structuredGenerate(prompt: string, schema: Record<string, unknown>): Promise<Record<string, unknown>> {
const result = await this.analyze({ transcriptText: "", promptContent: prompt, jsonSchema: schema });
return result.data;
}
}

View File

@@ -0,0 +1,38 @@
/**
* `SensitiveDataRedactor` (agente.md secao 123) — mascara dados sensíveis
* ANTES de mandar texto pra um provider de IA externo, quando a política
* de privacidade exigir (secao 122: níveis por tenant/campanha/fila).
* Preparado inicialmente pro Brasil (mesmo escopo de
* `packages/shared/src/phone.ts`) — CPF/CNPJ são específicos daqui;
* telefone/email/cartão são padrões razoavelmente universais.
*
* Ordem de aplicação importa: CNPJ (14 dígitos) e cartão (13-19 dígitos)
* precisam ser checados antes de padrões mais curtos, senão um CNPJ sem
* pontuação poderia ser parcialmente capturado por um regex de telefone.
*/
const PATTERNS: { label: string; regex: RegExp }[] = [
// CNPJ: XX.XXX.XXX/XXXX-XX ou 14 dígitos corridos.
{ label: "CNPJ", regex: /\b\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}\b/g },
// CPF: XXX.XXX.XXX-XX ou 11 dígitos corridos.
{ label: "CPF", regex: /\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/g },
// Cartão de crédito: 13-19 dígitos, opcionalmente agrupados de 4 em 4.
{ label: "CARTAO", regex: /\b(?:\d[ -]?){13,19}\b/g },
// E-mail.
{ label: "EMAIL", regex: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g },
// Telefone BR: com ou sem +55/DDD/parênteses/traço. Sem `\b` no começo —
// `\(` não é caractere de palavra, então `\b` logo antes de um `\(?`
// opcional falha em casar quando o char anterior também não é de
// palavra (ex.: espaço seguido de "("), deixando o parêntese de fora do
// match. `(?<!\w)` cobre o mesmo caso sem esse problema.
{ label: "TELEFONE", regex: /(?<!\w)(?:\+?55\s?)?\(?\d{2}\)?[\s.-]?\d{4,5}[\s.-]?\d{4}\b/g },
];
export class SensitiveDataRedactor {
redact(text: string): string {
let result = text;
for (const { label, regex } of PATTERNS) {
result = result.replace(regex, `[${label}]`);
}
return result;
}
}

View File

@@ -0,0 +1,25 @@
import type { AIProvider, AIProviderCredentials } from "./types";
import { OpenAIProvider } from "./openai-provider";
import { AnthropicProvider } from "./anthropic-provider";
/**
* Registro de adapters implementados de verdade — `providerType` é uma
* `string` livre no banco (agente.md secao 96-97: "arquitetura deve
* permitir Google/Azure/Bedrock/modelos locais/outros futuramente"), não
* um enum fechado. Adicionar um provider novo é só criar o adapter e
* registrar aqui, nunca uma migration.
*/
const ADAPTERS: Record<string, (credentials: AIProviderCredentials) => AIProvider> = {
openai: (c) => new OpenAIProvider(c),
anthropic: (c) => new AnthropicProvider(c),
};
export const SUPPORTED_PROVIDER_TYPES = Object.keys(ADAPTERS);
export function createAIProvider(providerType: string, credentials: AIProviderCredentials): AIProvider {
const factory = ADAPTERS[providerType];
if (!factory) {
throw new Error(`Provider de IA nao suportado: ${providerType}`);
}
return factory(credentials);
}

77
packages/ai/src/types.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Abstração de provider de IA (agente.md secao 96): "não hardcode OpenAI
* no domínio". Nenhum código fora deste pacote deve importar um SDK de
* provider específico ou saber o formato de request/response de uma API
* de IA em particular — só fala com esta interface.
*/
// Secao 102: nem todo provider tem todas as capacidades (ex.: Anthropic
// não tem endpoint de transcrição de áudio).
export type AICapabilityName =
| "TRANSCRIPTION"
| "DIARIZATION"
| "TEXT_ANALYSIS"
| "STRUCTURED_OUTPUT"
| "EMBEDDINGS"
| "REALTIME_AUDIO";
export interface TranscribeParams {
audioFilePath: string;
language?: string;
diarization?: boolean;
}
export interface TranscribeSegment {
speaker?: string;
startMs: number;
endMs: number;
text: string;
confidence?: number;
}
export interface TranscribeResult {
text: string;
language?: string;
durationSeconds?: number;
segments?: TranscribeSegment[];
providerRequestId?: string;
inputUsage?: number;
outputUsage?: number;
}
export interface AnalyzeParams {
/** Texto já passado pelo SensitiveDataRedactor quando a política exigir
* (secao 123) — o provider nunca decide isso sozinho. */
transcriptText: string;
promptContent: string;
/** JSON Schema que a resposta precisa satisfazer (secao 113: "não usar
* somente texto livre... validar antes de persistir"). */
jsonSchema: Record<string, unknown>;
}
export interface AnalyzeResult {
/** JSON já validado contra `jsonSchema` — quem chama ainda faz a própria
* validação de novo antes de persistir (defesa em profundidade, nunca
* confia cegamente na promessa do provider de que respeitou o schema). */
data: Record<string, unknown>;
providerRequestId?: string;
inputTokens?: number;
outputTokens?: number;
}
export interface AIProvider {
getCapabilities(): Promise<AICapabilityName[]>;
validateCredentials(): Promise<boolean>;
transcribe?(params: TranscribeParams): Promise<TranscribeResult>;
analyze?(params: AnalyzeParams): Promise<AnalyzeResult>;
summarize?(text: string): Promise<string>;
structuredGenerate?(prompt: string, schema: Record<string, unknown>): Promise<Record<string, unknown>>;
}
export interface AIProviderCredentials {
apiKey: string;
baseUrl?: string;
organization?: string;
project?: string;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,436 @@
-- CreateEnum
CREATE TYPE "ai_privacy_level" AS ENUM ('AI_OFF', 'TRANSCRIPTION_ONLY', 'TRANSCRIPTION_AND_ANALYSIS');
-- CreateEnum
CREATE TYPE "ai_provider_scope" AS ENUM ('GLOBAL', 'TENANT');
-- CreateEnum
CREATE TYPE "ai_capability" AS ENUM ('TRANSCRIPTION', 'DIARIZATION', 'TEXT_ANALYSIS', 'STRUCTURED_OUTPUT', 'EMBEDDINGS', 'REALTIME_AUDIO');
-- CreateEnum
CREATE TYPE "ai_prompt_purpose" AS ENUM ('ANALYSIS', 'SCORECARD');
-- CreateEnum
CREATE TYPE "ai_job_type" AS ENUM ('TRANSCRIPTION', 'ANALYSIS', 'REANALYSIS');
-- CreateEnum
CREATE TYPE "ai_job_status" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRYING', 'CANCELLED');
-- CreateEnum
CREATE TYPE "transcription_status" AS ENUM ('PENDING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "transcript_speaker" AS ENUM ('AGENT', 'CUSTOMER', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "ai_usage_type" AS ENUM ('AI_TRANSCRIPTION_SECONDS', 'AI_ANALYSIS_REQUEST', 'AI_INPUT_TOKENS', 'AI_OUTPUT_TOKENS');
-- AlterTable
ALTER TABLE "campaigns" ADD COLUMN "analysis_prompt_template_id" UUID;
-- AlterTable
ALTER TABLE "queues" ADD COLUMN "ai_privacy_level" "ai_privacy_level";
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "ai_privacy_level" "ai_privacy_level" NOT NULL DEFAULT 'AI_OFF';
-- CreateTable
CREATE TABLE "ai_providers" (
"id" UUID NOT NULL,
"scope" "ai_provider_scope" NOT NULL,
"tenant_id" UUID,
"provider_type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"base_url" TEXT,
"encrypted_api_key" TEXT NOT NULL,
"organization" TEXT,
"project" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ai_providers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_models" (
"id" UUID NOT NULL,
"tenant_id" UUID,
"provider_id" UUID NOT NULL,
"external_model_id" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"capabilities" "ai_capability"[],
"input_cost" DOUBLE PRECISION,
"output_cost" DOUBLE PRECISION,
"audio_cost" DOUBLE PRECISION,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ai_models_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_prompt_templates" (
"id" UUID NOT NULL,
"tenant_id" UUID,
"purpose" "ai_prompt_purpose" NOT NULL,
"name" TEXT NOT NULL,
"active_version_id" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ai_prompt_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_prompt_versions" (
"id" UUID NOT NULL,
"tenant_id" UUID,
"template_id" UUID NOT NULL,
"version" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_prompt_versions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_jobs" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID NOT NULL,
"type" "ai_job_type" NOT NULL,
"status" "ai_job_status" NOT NULL DEFAULT 'PENDING',
"attempt_count" INTEGER NOT NULL DEFAULT 0,
"max_attempts" INTEGER NOT NULL DEFAULT 5,
"last_error" TEXT,
"scheduled_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"completed_at" TIMESTAMP(3),
CONSTRAINT "ai_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "call_transcriptions" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID NOT NULL,
"provider_id" UUID,
"model" TEXT,
"language" TEXT,
"text" TEXT,
"status" "transcription_status" NOT NULL DEFAULT 'PENDING',
"duration_seconds" INTEGER,
"provider_request_id" TEXT,
"input_usage" INTEGER,
"output_usage" INTEGER,
"provider_cost" DOUBLE PRECISION,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "call_transcriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "call_transcript_segments" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"transcription_id" UUID NOT NULL,
"speaker" "transcript_speaker" NOT NULL DEFAULT 'UNKNOWN',
"start_ms" INTEGER NOT NULL,
"end_ms" INTEGER NOT NULL,
"text" TEXT NOT NULL,
"confidence" DOUBLE PRECISION,
CONSTRAINT "call_transcript_segments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "call_ai_analyses" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID NOT NULL,
"provider_id" UUID,
"model" TEXT,
"summary" TEXT,
"customer_intent" TEXT,
"outcome" TEXT,
"sentiment" TEXT,
"topics" TEXT[],
"keywords" TEXT[],
"objections" TEXT[],
"questions" TEXT[],
"action_items" TEXT[],
"compliance_flags" TEXT[],
"risk_flags" TEXT[],
"quality_score" INTEGER,
"agent_score" INTEGER,
"customer_sentiment_score" DOUBLE PRECISION,
"sales_opportunity" BOOLEAN,
"next_best_action" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "call_ai_analyses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quality_scorecards" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "quality_scorecards_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quality_scorecard_items" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"scorecard_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"weight" DOUBLE PRECISION NOT NULL DEFAULT 1,
"description" TEXT,
"evaluation_prompt" TEXT,
CONSTRAINT "quality_scorecard_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quality_evaluations" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID NOT NULL,
"scorecard_id" UUID NOT NULL,
"score" INTEGER NOT NULL,
"criterion_scores" JSONB NOT NULL,
"summary_justification" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "quality_evaluations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_usage_records" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID,
"type" "ai_usage_type" NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"provider_id" UUID,
"model" TEXT,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_usage_records_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ai_providers_tenant_id_idx" ON "ai_providers"("tenant_id");
-- CreateIndex
CREATE INDEX "ai_models_tenant_id_idx" ON "ai_models"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "ai_models_provider_id_external_model_id_key" ON "ai_models"("provider_id", "external_model_id");
-- CreateIndex
CREATE UNIQUE INDEX "ai_prompt_templates_active_version_id_key" ON "ai_prompt_templates"("active_version_id");
-- CreateIndex
CREATE INDEX "ai_prompt_templates_tenant_id_idx" ON "ai_prompt_templates"("tenant_id");
-- CreateIndex
CREATE INDEX "ai_prompt_versions_tenant_id_idx" ON "ai_prompt_versions"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "ai_prompt_versions_template_id_version_key" ON "ai_prompt_versions"("template_id", "version");
-- CreateIndex
CREATE INDEX "ai_jobs_tenant_id_status_scheduled_at_idx" ON "ai_jobs"("tenant_id", "status", "scheduled_at");
-- CreateIndex
CREATE INDEX "ai_jobs_tenant_id_call_id_idx" ON "ai_jobs"("tenant_id", "call_id");
-- CreateIndex
CREATE INDEX "call_transcriptions_tenant_id_call_id_idx" ON "call_transcriptions"("tenant_id", "call_id");
-- CreateIndex
CREATE INDEX "call_transcript_segments_tenant_id_idx" ON "call_transcript_segments"("tenant_id");
-- CreateIndex
CREATE INDEX "call_transcript_segments_transcription_id_idx" ON "call_transcript_segments"("transcription_id");
-- CreateIndex
CREATE INDEX "call_ai_analyses_tenant_id_call_id_idx" ON "call_ai_analyses"("tenant_id", "call_id");
-- CreateIndex
CREATE INDEX "quality_scorecards_tenant_id_idx" ON "quality_scorecards"("tenant_id");
-- CreateIndex
CREATE INDEX "quality_scorecard_items_tenant_id_idx" ON "quality_scorecard_items"("tenant_id");
-- CreateIndex
CREATE INDEX "quality_scorecard_items_scorecard_id_idx" ON "quality_scorecard_items"("scorecard_id");
-- CreateIndex
CREATE INDEX "quality_evaluations_tenant_id_call_id_idx" ON "quality_evaluations"("tenant_id", "call_id");
-- CreateIndex
CREATE INDEX "ai_usage_records_tenant_id_occurred_at_idx" ON "ai_usage_records"("tenant_id", "occurred_at");
-- CreateIndex
CREATE INDEX "ai_usage_records_tenant_id_type_idx" ON "ai_usage_records"("tenant_id", "type");
-- AddForeignKey
ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_analysis_prompt_template_id_fkey" FOREIGN KEY ("analysis_prompt_template_id") REFERENCES "ai_prompt_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_providers" ADD CONSTRAINT "ai_providers_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_models" ADD CONSTRAINT "ai_models_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_models" ADD CONSTRAINT "ai_models_provider_id_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai_providers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_prompt_templates" ADD CONSTRAINT "ai_prompt_templates_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_prompt_templates" ADD CONSTRAINT "ai_prompt_templates_active_version_id_fkey" FOREIGN KEY ("active_version_id") REFERENCES "ai_prompt_versions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_prompt_versions" ADD CONSTRAINT "ai_prompt_versions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_prompt_versions" ADD CONSTRAINT "ai_prompt_versions_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "ai_prompt_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_jobs" ADD CONSTRAINT "ai_jobs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_jobs" ADD CONSTRAINT "ai_jobs_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_transcriptions" ADD CONSTRAINT "call_transcriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_transcriptions" ADD CONSTRAINT "call_transcriptions_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_transcriptions" ADD CONSTRAINT "call_transcriptions_provider_id_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai_providers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_transcript_segments" ADD CONSTRAINT "call_transcript_segments_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_transcript_segments" ADD CONSTRAINT "call_transcript_segments_transcription_id_fkey" FOREIGN KEY ("transcription_id") REFERENCES "call_transcriptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_ai_analyses" ADD CONSTRAINT "call_ai_analyses_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_ai_analyses" ADD CONSTRAINT "call_ai_analyses_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_ai_analyses" ADD CONSTRAINT "call_ai_analyses_provider_id_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai_providers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quality_scorecards" ADD CONSTRAINT "quality_scorecards_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quality_scorecard_items" ADD CONSTRAINT "quality_scorecard_items_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quality_scorecard_items" ADD CONSTRAINT "quality_scorecard_items_scorecard_id_fkey" FOREIGN KEY ("scorecard_id") REFERENCES "quality_scorecards"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quality_evaluations" ADD CONSTRAINT "quality_evaluations_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quality_evaluations" ADD CONSTRAINT "quality_evaluations_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "quality_evaluations" ADD CONSTRAINT "quality_evaluations_scorecard_id_fkey" FOREIGN KEY ("scorecard_id") REFERENCES "quality_scorecards"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_usage_records" ADD CONSTRAINT "ai_usage_records_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_usage_records" ADD CONSTRAINT "ai_usage_records_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_usage_records" ADD CONSTRAINT "ai_usage_records_provider_id_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai_providers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Tabelas de negocio tenant-scoped: RLS obrigatorio em todas (ver docs/TENANT_ISOLATION.md).
-- RLS padrao (tenant_id NOT NULL, so' visivel/gravavel no proprio tenant).
ALTER TABLE "ai_jobs" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ai_jobs" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ai_jobs"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "call_transcriptions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "call_transcriptions" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "call_transcriptions"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "call_transcript_segments" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "call_transcript_segments" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "call_transcript_segments"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "call_ai_analyses" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "call_ai_analyses" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "call_ai_analyses"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "quality_scorecards" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "quality_scorecards" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "quality_scorecards"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "quality_scorecard_items" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "quality_scorecard_items" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "quality_scorecard_items"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "quality_evaluations" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "quality_evaluations" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "quality_evaluations"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "ai_usage_records" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ai_usage_records" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ai_usage_records"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
-- RLS hibrida (tenant_id NULLABLE): scope=GLOBAL (tenant_id null, cadastrado
-- pelo platform admin) e' visivel de QUALQUER contexto de tenant (mesma
-- tecnica ja usada pra tenant_memberships na descoberta de tenant durante
-- login, agente.md secao 100 "Provider Global e BYOK"). Quem pode ESCREVER
-- num registro GLOBAL e' decidido na camada de servico (so' platform
-- admin), nao pela RLS -- RLS aqui so' garante isolamento entre tenants,
-- nunca vaza BYOK de um tenant pra outro.
ALTER TABLE "ai_providers" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ai_providers" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ai_providers"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid OR tenant_id IS NULL);
ALTER TABLE "ai_models" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ai_models" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ai_models"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid OR tenant_id IS NULL);
ALTER TABLE "ai_prompt_templates" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ai_prompt_templates" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ai_prompt_templates"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid OR tenant_id IS NULL);
ALTER TABLE "ai_prompt_versions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ai_prompt_versions" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ai_prompt_versions"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid OR tenant_id IS NULL);

View File

@@ -16,47 +16,72 @@ enum TenantStatus {
@@map("tenant_status")
}
model Tenant {
id String @id @default(uuid()) @db.Uuid
code String @unique
slug String @unique
legalName String @map("legal_name")
tradeName String? @map("trade_name")
taxId String? @map("tax_id")
status TenantStatus @default(TRIAL)
timezone String @default("America/Sao_Paulo")
locale String @default("pt-BR")
billingCurrency String @default("BRL") @map("billing_currency")
telephonyDomain String? @map("telephony_domain")
planId String @map("plan_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
// Privacidade de IA (agente.md secao 122) — resolvida em cascata
// Campaign > Queue > Tenant (o mais específico que não for null vence).
// Tenant é o único nível obrigatório (default OFF: preciso de opt-in
// explícito antes de mandar áudio de cliente pra um provider externo).
enum AIPrivacyLevel {
AI_OFF
TRANSCRIPTION_ONLY
TRANSCRIPTION_AND_ANALYSIS
plan Plan @relation(fields: [planId], references: [id])
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
trunks Trunk[]
dialplanExtensions DialplanExtension[]
dialplanVersions DialplanVersion[]
queues Queue[]
agents Agent[]
pauseReasons PauseReason[]
tiers Tier[]
agentSessions AgentSession[]
agentStateEvents AgentStateEvent[]
agentPauseEvents AgentPauseEvent[]
campaigns Campaign[]
leads Lead[]
suppressionEntries SuppressionEntry[]
callAttempts CallAttempt[]
campaignStats CampaignStats[]
dispositions Disposition[]
calls Call[]
callLegs CallLeg[]
callEvents CallEvent[]
recordings Recording[]
@@map("ai_privacy_level")
}
model Tenant {
id String @id @default(uuid()) @db.Uuid
code String @unique
slug String @unique
legalName String @map("legal_name")
tradeName String? @map("trade_name")
taxId String? @map("tax_id")
status TenantStatus @default(TRIAL)
timezone String @default("America/Sao_Paulo")
locale String @default("pt-BR")
billingCurrency String @default("BRL") @map("billing_currency")
telephonyDomain String? @map("telephony_domain")
planId String @map("plan_id") @db.Uuid
aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
plan Plan @relation(fields: [planId], references: [id])
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
trunks Trunk[]
dialplanExtensions DialplanExtension[]
dialplanVersions DialplanVersion[]
queues Queue[]
agents Agent[]
pauseReasons PauseReason[]
tiers Tier[]
agentSessions AgentSession[]
agentStateEvents AgentStateEvent[]
agentPauseEvents AgentPauseEvent[]
campaigns Campaign[]
leads Lead[]
suppressionEntries SuppressionEntry[]
callAttempts CallAttempt[]
campaignStats CampaignStats[]
dispositions Disposition[]
calls Call[]
callLegs CallLeg[]
callEvents CallEvent[]
recordings Recording[]
aiproviders AIProvider[]
aimodels AIModel[]
aipromptTemplates AIPromptTemplate[]
aijobs AIJob[]
callTranscriptions CallTranscription[]
callAIAnalyses CallAIAnalysis[]
qualityScorecards QualityScorecard[]
qualityEvaluations QualityEvaluation[]
aiusageRecords AIUsageRecord[]
aipromptVersions AIPromptVersion[]
callTranscriptSegments CallTranscriptSegment[]
qualityScorecardItems QualityScorecardItem[]
@@map("tenants")
}
@@ -487,6 +512,9 @@ model Queue {
recordingEnabled Boolean @default(false) @map("recording_enabled")
// null = herda do Tenant (secao 122).
aiPrivacyLevel AIPrivacyLevel? @map("ai_privacy_level")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
@@ -721,19 +749,24 @@ model Campaign {
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
aiAnalysisEnabled Boolean @default(false) @map("ai_analysis_enabled")
// Secao 116: sobrescreve o template padrão do tenant pra ANALYSIS
// (null = usa o template ANALYSIS padrão resolvido normalmente).
analysisPromptTemplateId String? @map("analysis_prompt_template_id") @db.Uuid
status CampaignStatus @default(DRAFT)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
queue Queue @relation(fields: [queueId], references: [id])
trunk Trunk @relation(fields: [trunkId], references: [id])
leads Lead[]
callAttempts CallAttempt[]
campaignStats CampaignStats[]
calls Call[]
tenant Tenant @relation(fields: [tenantId], references: [id])
queue Queue @relation(fields: [queueId], references: [id])
trunk Trunk @relation(fields: [trunkId], references: [id])
analysisPromptTemplate AIPromptTemplate? @relation(fields: [analysisPromptTemplateId], references: [id])
leads Lead[]
callAttempts CallAttempt[]
campaignStats CampaignStats[]
calls Call[]
@@unique([tenantId, name])
@@index([tenantId])
@@ -1001,18 +1034,23 @@ model Call {
dispositionId String? @map("disposition_id") @db.Uuid
tenant Tenant @relation(fields: [tenantId], references: [id])
attempt CallAttempt? @relation(fields: [attemptId], references: [id])
campaign Campaign? @relation(fields: [campaignId], references: [id])
lead Lead? @relation(fields: [leadId], references: [id])
queue Queue? @relation(fields: [queueId], references: [id])
agent Agent? @relation(fields: [agentId], references: [id])
extension Extension? @relation(fields: [extensionId], references: [id])
trunk Trunk? @relation(fields: [trunkId], references: [id])
disposition Disposition? @relation(fields: [dispositionId], references: [id])
legs CallLeg[]
events CallEvent[]
recording Recording?
tenant Tenant @relation(fields: [tenantId], references: [id])
attempt CallAttempt? @relation(fields: [attemptId], references: [id])
campaign Campaign? @relation(fields: [campaignId], references: [id])
lead Lead? @relation(fields: [leadId], references: [id])
queue Queue? @relation(fields: [queueId], references: [id])
agent Agent? @relation(fields: [agentId], references: [id])
extension Extension? @relation(fields: [extensionId], references: [id])
trunk Trunk? @relation(fields: [trunkId], references: [id])
disposition Disposition? @relation(fields: [dispositionId], references: [id])
legs CallLeg[]
events CallEvent[]
recording Recording?
aijobs AIJob[]
callTranscriptions CallTranscription[]
callAIAnalyses CallAIAnalysis[]
qualityEvaluations QualityEvaluation[]
aiusageRecords AIUsageRecord[]
@@index([tenantId, createdAt])
@@index([tenantId, queueId])
@@ -1118,3 +1156,419 @@ model Recording {
@@index([retentionUntil])
@@map("recordings")
}
// ============================================================
// Módulo IA (agente.md secao 95-124)
// ============================================================
enum AIProviderScope {
GLOBAL
TENANT
@@map("ai_provider_scope")
}
// "ai_providers" (secao 96-101). `providerType` é String, não enum — a
// especificação exige explicitamente "não hardcode OpenAI no domínio" e
// "arquitetura deve permitir Google/Azure/Bedrock/modelos locais/outros
// futuramente" (secao 96-97); um enum Postgres exigiria uma migration
// pra cada provider novo, o oposto do espírito da abstração. A validação
// de quais `providerType` têm adapter implementado de verdade acontece na
// camada de serviço (packages/ai), não no banco.
//
// RLS híbrida: `scope=GLOBAL` (tenantId null, cadastrado pelo platform
// admin) é visível de QUALQUER contexto de tenant — é a mesma técnica já
// usada pra `tenant_memberships` na descoberta de tenant durante login
// (USING com OR). `scope=TENANT` (BYOK, secao 100) só é visível no
// próprio contexto do tenant dono. Quem pode ESCREVER num provider GLOBAL
// é decidido na camada de serviço (só platform admin), não pela RLS.
model AIProvider {
id String @id @default(uuid()) @db.Uuid
scope AIProviderScope
tenantId String? @map("tenant_id") @db.Uuid
providerType String @map("provider_type")
name String
baseUrl String? @map("base_url")
// AES-256-GCM (secao 101, mesmo padrão de sipPasswordEnc/passwordEnc) —
// a key inteira nunca é reexibida depois de salva.
encryptedApiKey String @map("encrypted_api_key")
organization String?
project String?
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant? @relation(fields: [tenantId], references: [id])
models AIModel[]
callTranscriptions CallTranscription[]
callAIAnalyses CallAIAnalysis[]
aiusageRecords AIUsageRecord[]
@@index([tenantId])
@@map("ai_providers")
}
enum AICapability {
TRANSCRIPTION
DIARIZATION
TEXT_ANALYSIS
STRUCTURED_OUTPUT
EMBEDDINGS
REALTIME_AUDIO
@@map("ai_capability")
}
// "ai_models" (secao 102-103) — mesma RLS híbrida do provider dono
// (tenantId copiado do AIProvider na criação, evita subquery de RLS
// cruzando tabelas).
model AIModel {
id String @id @default(uuid()) @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
providerId String @map("provider_id") @db.Uuid
externalModelId String @map("external_model_id")
displayName String @map("display_name")
capabilities AICapability[]
inputCost Float? @map("input_cost")
outputCost Float? @map("output_cost")
audioCost Float? @map("audio_cost")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant? @relation(fields: [tenantId], references: [id])
provider AIProvider @relation(fields: [providerId], references: [id])
@@unique([providerId, externalModelId])
@@index([tenantId])
@@map("ai_models")
}
enum AIPromptPurpose {
ANALYSIS
SCORECARD
@@map("ai_prompt_purpose")
}
// "ai_prompt_templates"/"ai_prompt_versions" (secao 114-116). `tenantId
// null` = template padrão da plataforma; Campaign pode sobrescrever
// apontando pro seu próprio template via `analysisPromptTemplateId`
// (secao 116) — o template em si não carrega campaignId, evita um
// template "pertencer" a uma campanha específica de forma rígida (a
// mesma campanha de cobrança de vários tenants pode reusar o mesmo
// template). Versões nunca são editadas in-place — cada mudança de
// conteúdo cria uma nova `AIPromptVersion`, a versão ativa é apontada por
// `activeVersionId` (histórico completo preservado, mesmo espírito de
// "immutable usage ledger" da secao 233 aplicado a prompts).
model AIPromptTemplate {
id String @id @default(uuid()) @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
purpose AIPromptPurpose
name String
activeVersionId String? @unique @map("active_version_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant? @relation(fields: [tenantId], references: [id])
activeVersion AIPromptVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id])
versions AIPromptVersion[] @relation("TemplateVersions")
campaigns Campaign[]
@@index([tenantId])
@@map("ai_prompt_templates")
}
model AIPromptVersion {
id String @id @default(uuid()) @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
templateId String @map("template_id") @db.Uuid
version Int
content String
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant? @relation(fields: [tenantId], references: [id])
template AIPromptTemplate @relation("TemplateVersions", fields: [templateId], references: [id])
activeFor AIPromptTemplate? @relation("ActiveVersion")
@@unique([templateId, version])
@@index([tenantId])
@@map("ai_prompt_versions")
}
enum AIJobType {
TRANSCRIPTION
ANALYSIS
REANALYSIS
@@map("ai_job_type")
}
enum AIJobStatus {
PENDING
PROCESSING
COMPLETED
FAILED
RETRYING
CANCELLED
@@map("ai_job_status")
}
// "ai_jobs" (secao 106-108) — pipeline sempre assíncrono, nunca bloqueia
// a chamada esperando IA. `scheduledAt` é quando o job pode rodar de novo
// (exponential backoff no retry); `attemptCount >= maxAttempts` vira
// FAILED terminal (dead-letter, secao 108: nunca retry infinito).
model AIJob {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String @map("call_id") @db.Uuid
type AIJobType
status AIJobStatus @default(PENDING)
attemptCount Int @default(0) @map("attempt_count")
maxAttempts Int @default(5) @map("max_attempts")
lastError String? @map("last_error")
scheduledAt DateTime @default(now()) @map("scheduled_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call @relation(fields: [callId], references: [id])
@@index([tenantId, status, scheduledAt])
@@index([tenantId, callId])
@@map("ai_jobs")
}
enum TranscriptionStatus {
PENDING
COMPLETED
FAILED
@@map("transcription_status")
}
// "call_transcriptions" (secao 109).
model CallTranscription {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String @map("call_id") @db.Uuid
providerId String? @map("provider_id") @db.Uuid
model String?
language String?
text String?
status TranscriptionStatus @default(PENDING)
durationSeconds Int? @map("duration_seconds")
providerRequestId String? @map("provider_request_id")
inputUsage Int? @map("input_usage")
outputUsage Int? @map("output_usage")
providerCost Float? @map("provider_cost")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call @relation(fields: [callId], references: [id])
provider AIProvider? @relation(fields: [providerId], references: [id])
segments CallTranscriptSegment[]
@@index([tenantId, callId])
@@map("call_transcriptions")
}
enum TranscriptSpeaker {
AGENT
CUSTOMER
UNKNOWN
@@map("transcript_speaker")
}
// "call_transcript_segments" (secao 110-111) — `speaker` prioriza o canal
// estéreo (secao 111: "não confiar cegamente em diarização quando a
// direção do áudio permite identificação melhor") sobre a diarização
// probabilística do provider, quando a gravação for estéreo.
model CallTranscriptSegment {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
transcriptionId String @map("transcription_id") @db.Uuid
speaker TranscriptSpeaker @default(UNKNOWN)
startMs Int @map("start_ms")
endMs Int @map("end_ms")
text String
confidence Float?
tenant Tenant @relation(fields: [tenantId], references: [id])
transcription CallTranscription @relation(fields: [transcriptionId], references: [id])
@@index([tenantId])
@@index([transcriptionId])
@@map("call_transcript_segments")
}
// "call_ai_analyses" (secao 112-113) — resultado estruturado, validado
// contra um schema antes de persistir (secao 113: "não usar somente texto
// livre"), nunca o texto cru da resposta do modelo.
model CallAIAnalysis {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String @map("call_id") @db.Uuid
providerId String? @map("provider_id") @db.Uuid
model String?
summary String?
customerIntent String? @map("customer_intent")
outcome String?
sentiment String?
topics String[]
keywords String[]
objections String[]
questions String[]
actionItems String[] @map("action_items")
complianceFlags String[] @map("compliance_flags")
riskFlags String[] @map("risk_flags")
qualityScore Int? @map("quality_score")
agentScore Int? @map("agent_score")
customerSentimentScore Float? @map("customer_sentiment_score")
salesOpportunity Boolean? @map("sales_opportunity")
nextBestAction String? @map("next_best_action")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call @relation(fields: [callId], references: [id])
provider AIProvider? @relation(fields: [providerId], references: [id])
@@index([tenantId, callId])
@@map("call_ai_analyses")
}
// "quality_scorecards"/"quality_scorecard_items"/"quality_evaluations"
// (secao 117-118).
model QualityScorecard {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
items QualityScorecardItem[]
evaluations QualityEvaluation[]
@@index([tenantId])
@@map("quality_scorecards")
}
model QualityScorecardItem {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
scorecardId String @map("scorecard_id") @db.Uuid
name String
weight Float @default(1)
description String?
evaluationPrompt String? @map("evaluation_prompt")
tenant Tenant @relation(fields: [tenantId], references: [id])
scorecard QualityScorecard @relation(fields: [scorecardId], references: [id])
@@index([tenantId])
@@index([scorecardId])
@@map("quality_scorecard_items")
}
// Secao 118: guarda score final + scores por critério + justificativa —
// nunca o chain-of-thought do modelo.
model QualityEvaluation {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String @map("call_id") @db.Uuid
scorecardId String @map("scorecard_id") @db.Uuid
score Int
criterionScores Json @map("criterion_scores")
summaryJustification String? @map("summary_justification")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call @relation(fields: [callId], references: [id])
scorecard QualityScorecard @relation(fields: [scorecardId], references: [id])
@@index([tenantId, callId])
@@map("quality_evaluations")
}
enum AIUsageType {
AI_TRANSCRIPTION_SECONDS
AI_ANALYSIS_REQUEST
AI_INPUT_TOKENS
AI_OUTPUT_TOKENS
@@map("ai_usage_type")
}
// "AI usage metering" (secao 124) — ledger imutável (secao 233: "immutable
// usage ledger > reconstruir billing de forma improvisada"), só INSERT
// pelo código da aplicação, nunca UPDATE/DELETE. Alimenta a fase Billing
// (Rating Engine), ainda não construída.
model AIUsageRecord {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String? @map("call_id") @db.Uuid
type AIUsageType
quantity Float
providerId String? @map("provider_id") @db.Uuid
model String?
occurredAt DateTime @default(now()) @map("occurred_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call? @relation(fields: [callId], references: [id])
provider AIProvider? @relation(fields: [providerId], references: [id])
@@index([tenantId, occurredAt])
@@index([tenantId, type])
@@map("ai_usage_records")
}

12
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
apps/api:
dependencies:
'@b2bcall/ai':
specifier: workspace:*
version: link:../../packages/ai
'@b2bcall/auth':
specifier: workspace:*
version: link:../../packages/auth
@@ -181,6 +184,15 @@ importers:
specifier: ^5.7.0
version: 5.9.3
packages/ai:
devDependencies:
'@types/node':
specifier: ^22.20.1
version: 22.20.1
typescript:
specifier: ^5.7.0
version: 5.9.3
packages/auth:
dependencies:
'@b2bcall/database':