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:
40
TODO.md
40
TODO.md
@@ -464,10 +464,44 @@
|
|||||||
- [ ] Chamadas manuais/internas não são gravadas — só o caminho do
|
- [ ] Chamadas manuais/internas não são gravadas — só o caminho do
|
||||||
discador tem originate próprio
|
discador tem originate próprio
|
||||||
- [ ] `max_recording_storage_gb` (Plan) existe mas não é aplicado
|
- [ ] `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,
|
## PHASE 19 — IA: Provider Layer (agente.md secao 95-103)
|
||||||
Security, Tests)
|
- [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@b2bcall/ai": "workspace:*",
|
||||||
"@b2bcall/auth": "workspace:*",
|
"@b2bcall/auth": "workspace:*",
|
||||||
"@b2bcall/database": "workspace:*",
|
"@b2bcall/database": "workspace:*",
|
||||||
"@b2bcall/entitlements": "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 { CallsModule } from "./calls/calls.module";
|
||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
import { RecordingsModule } from "./recordings/recordings.module";
|
import { RecordingsModule } from "./recordings/recordings.module";
|
||||||
|
import { AIModule } from "./ai/ai.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -34,6 +35,7 @@ import { RecordingsModule } from "./recordings/recordings.module";
|
|||||||
CallsModule,
|
CallsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
RecordingsModule,
|
RecordingsModule,
|
||||||
|
AIModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
171
docs/AI_PROVIDERS.md
Normal file
171
docs/AI_PROVIDERS.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# IA — Provider Layer
|
||||||
|
|
||||||
|
Agente.md secao 95-103. Primeira peça do módulo de IA: a abstração de
|
||||||
|
provider (nunca hardcoda OpenAI no domínio), os adapters OpenAI/Anthropic,
|
||||||
|
capabilities, e o cadastro de providers/modelos (global + BYOK). O
|
||||||
|
pipeline que de fato aciona isso depois de uma chamada terminar
|
||||||
|
(transcrição, análise, jobs assíncronos, prompts, scorecards, usage
|
||||||
|
metering — secao 104-124) é a próxima fase, ver TODO.md.
|
||||||
|
|
||||||
|
## Schema completo do módulo de IA, código parcial
|
||||||
|
|
||||||
|
O schema desta migration (`ai_module`) já cobre TODAS as tabelas das
|
||||||
|
secções 95-124 (`ai_providers`, `ai_models`, `ai_prompt_templates`/
|
||||||
|
`ai_prompt_versions`, `ai_jobs`, `call_transcriptions`/
|
||||||
|
`call_transcript_segments`, `call_ai_analyses`, `quality_scorecards`/
|
||||||
|
`quality_scorecard_items`/`quality_evaluations`, `ai_usage_records`) de
|
||||||
|
uma vez — mais barato revisar o desenho relacional inteiro (FKs entre
|
||||||
|
Call/CallAttempt/Campaign) numa única passada do que fatiar em várias
|
||||||
|
migrations pequenas que se emendam. O **código** que usa essas tabelas é
|
||||||
|
que vem em fases separadas — esta fase só implementa Provider/Model.
|
||||||
|
|
||||||
|
## `packages/ai` — `AIProvider` (secao 96)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AIProvider {
|
||||||
|
getCapabilities(): Promise<AICapabilityName[]>;
|
||||||
|
validateCredentials(): Promise<boolean>;
|
||||||
|
transcribe?(params): Promise<TranscribeResult>;
|
||||||
|
analyze?(params): Promise<AnalyzeResult>;
|
||||||
|
summarize?(text): Promise<string>;
|
||||||
|
structuredGenerate?(prompt, schema): Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nenhum código fora de `packages/ai` conhece o formato de request/response
|
||||||
|
de uma API de IA específica.
|
||||||
|
|
||||||
|
### `OpenAIProvider`/`AnthropicProvider` (secao 97-98)
|
||||||
|
|
||||||
|
Usam `fetch` nativo direto contra a "OpenAI API"/"Anthropic API" (secao
|
||||||
|
98: nomenclatura da API, nunca "ChatGPT" — o domínio não fica amarrado ao
|
||||||
|
nome do produto de consumidor) — sem SDK oficial, pra manter o request/
|
||||||
|
response inteiramente visível no nosso próprio código (relevante já que
|
||||||
|
potencialmente manda dados de cliente pra fora, secao 122-123).
|
||||||
|
|
||||||
|
- `transcribe` (só OpenAI — Anthropic não tem endpoint de áudio, secao
|
||||||
|
102: "nem todo provider terá todas as capacidades"): `POST /audio/
|
||||||
|
transcriptions`, `response_format=verbose_json` pra pegar segmentos com
|
||||||
|
timestamp.
|
||||||
|
- `analyze`/`structuredGenerate`: OpenAI via `response_format:
|
||||||
|
json_schema` (Structured Outputs); Anthropic via "tool use" forçado
|
||||||
|
(`tool_choice` apontando pra uma tool única cujo `input_schema` é o JSON
|
||||||
|
Schema pedido) — a técnica documentada da própria Anthropic pra output
|
||||||
|
estruturado confiável antes de terem suporte nativo equivalente ao da
|
||||||
|
OpenAI.
|
||||||
|
|
||||||
|
**Nunca exercitados nesta sessão** — a autorização de rede desta sessão
|
||||||
|
cobre só o servidor git do repositório (restrição definida desde o
|
||||||
|
primeiro pedido do usuário), então nenhuma chamada real saiu daqui.
|
||||||
|
Implementados seguindo o contrato documentado de cada API o mais fiel
|
||||||
|
possível; revisar contra as APIs reais antes de confiar em produção —
|
||||||
|
mesmo padrão de honestidade já usado pra `S3ObjectStorageProvider` (fase
|
||||||
|
Recording) e o caminho PSTN real (fase Predictive Dialer).
|
||||||
|
|
||||||
|
### `SensitiveDataRedactor` (secao 123)
|
||||||
|
|
||||||
|
Mascara CPF/CNPJ/telefone/cartão/email em texto livre antes de mandar pra
|
||||||
|
um provider, quando a política de privacidade exigir. Testado com casos
|
||||||
|
reais (CPF/CNPJ formatados, telefone com parênteses, email, número de
|
||||||
|
cartão) — todos os 5 tipos corretamente mascarados, frase sem dado
|
||||||
|
sensível passa intacta.
|
||||||
|
|
||||||
|
**Achado real testando**: um `\b` logo antes de um `\(?` opcional (regex
|
||||||
|
de telefone) falha em casar quando o caractere anterior também não é de
|
||||||
|
palavra (ex.: espaço seguido de `(`) — `\b` exige uma transição
|
||||||
|
palavra/não-palavra dos dois lados, e nem "espaço" nem "(" são caracteres
|
||||||
|
de palavra. Isso deixava o `(` de fora do match, vazando um parêntese
|
||||||
|
solto no texto redigido (nenhum dado sensível vazava de verdade, só um
|
||||||
|
caractere de formatação). Corrigido trocando o `\b` inicial por
|
||||||
|
`(?<!\w)`, que não tem esse problema.
|
||||||
|
|
||||||
|
## `ai_providers`/`ai_models` — global vs. BYOK (secao 99-101)
|
||||||
|
|
||||||
|
`scope=GLOBAL` (cadastrado pelo platform admin, `tenant_id` null) é
|
||||||
|
visível de **qualquer** tenant; `scope=TENANT` (BYOK — "Bring Your Own
|
||||||
|
Key") só do próprio dono. RLS híbrida (a mesma técnica já usada pra
|
||||||
|
`tenant_memberships` na descoberta de tenant durante login):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
USING (tenant_id = current_tenant_id() OR tenant_id IS NULL)
|
||||||
|
```
|
||||||
|
|
||||||
|
Quem pode **escrever** num provider `GLOBAL` é decidido na camada de
|
||||||
|
serviço (`isPlatformUser`, já existia desde a fase RBAC), não pela RLS —
|
||||||
|
RLS aqui só garante que o BYOK de um tenant nunca vaza pro outro.
|
||||||
|
|
||||||
|
`encryptedApiKey` (AES-256-GCM, mesmo padrão de `sipPasswordEnc`/
|
||||||
|
`passwordEnc`) nunca é reexposta — toda resposta troca por
|
||||||
|
`apiKeyPreview` (últimos 8 caracteres cifrados, só o suficiente pra
|
||||||
|
reconhecer qual key é).
|
||||||
|
|
||||||
|
`AIModel.tenantId` é copiado do provider dono na criação (evita subquery
|
||||||
|
de RLS cruzando tabelas, mesmo padrão de `CampaignStats`/`CallLeg`/etc.).
|
||||||
|
|
||||||
|
## Achados reais testando esta fase
|
||||||
|
|
||||||
|
- **Delete de provider bloqueado por FK**: `AIProvidersController.remove`
|
||||||
|
fazia hard delete (`tx.aIProvider.delete`), mas `AIModel.providerId` é
|
||||||
|
`ON DELETE RESTRICT` — apagar um provider com pelo menos um modelo
|
||||||
|
cadastrado (mesmo um modelo já "apagado" via soft delete, que só marca
|
||||||
|
`enabled=false`) falhava com violação de FK. Inconsistente com o resto
|
||||||
|
do sistema (Extension/Trunk/Queue/Campaign/Disposition/PauseReason —
|
||||||
|
todos soft delete). Corrigido: `remove` agora também faz
|
||||||
|
`enabled=false`, nunca hard delete — necessário de qualquer forma pra
|
||||||
|
preservar `provider_id` válido no histórico de
|
||||||
|
`CallTranscription`/`CallAIAnalysis` depois que um provider é
|
||||||
|
desativado.
|
||||||
|
- **`GET /ai/providers` não filtrava desabilitados**: consequência direta
|
||||||
|
do achado acima — sem o filtro `enabled: true`, um provider "apagado"
|
||||||
|
continuava aparecendo na lista. Corrigido junto.
|
||||||
|
|
||||||
|
## Verificado ponta a ponta
|
||||||
|
|
||||||
|
```
|
||||||
|
Tenant A (não platform admin) tenta criar provider GLOBAL -> 403
|
||||||
|
Tenant A cria provider TENANT (BYOK, Anthropic) -> sucesso,
|
||||||
|
encryptedApiKey nunca aparece na resposta (só apiKeyPreview)
|
||||||
|
providerType não suportado ("google") -> 403 com mensagem clara
|
||||||
|
|
||||||
|
Platform admin cria provider GLOBAL (OpenAI) -> sucesso, tenantId null
|
||||||
|
|
||||||
|
Tenant A lista -> ve' o proprio BYOK + o GLOBAL (2 providers)
|
||||||
|
Tenant B lista -> ve' SO' o GLOBAL (1 provider) — BYOK do Tenant A nunca
|
||||||
|
vaza, confirmando a RLS hibrida
|
||||||
|
Tenant B tenta apagar o BYOK do Tenant A (por id direto) -> 404 (RLS
|
||||||
|
esconde a linha antes mesmo do controller decidir)
|
||||||
|
Tenant B tenta apagar o provider GLOBAL -> 403 (nao e' platform admin)
|
||||||
|
|
||||||
|
Tenant A cria um AIModel sob o provider GLOBAL -> tenantId herdado como
|
||||||
|
null, aparece na lista de AMBOS os tenants (modelo de provider global e'
|
||||||
|
global tambem)
|
||||||
|
|
||||||
|
Apos a correcao do soft delete: apagar modelo -> 204, apagar o provider
|
||||||
|
GLOBAL que tinha esse modelo -> 204 (antes falhava com FK), lista fica
|
||||||
|
vazia pros dois tenants
|
||||||
|
```
|
||||||
|
|
||||||
|
typecheck do workspace inteiro limpo.
|
||||||
|
|
||||||
|
## O que falta
|
||||||
|
|
||||||
|
- Pipeline assíncrono pós-CALL_ENDED (secao 106-108: `ai_jobs`, retry com
|
||||||
|
backoff exponencial, dead-letter) — schema pronto, worker não
|
||||||
|
implementado ainda.
|
||||||
|
- Transcrição de verdade (secao 109-111: `call_transcriptions`/
|
||||||
|
`call_transcript_segments`, mapeamento de speaker via canal estéreo) —
|
||||||
|
schema pronto, nada chama `provider.transcribe()` ainda.
|
||||||
|
- Análise da chamada (secao 112-113), prompts por tenant/campanha (secao
|
||||||
|
114-116), scorecards/QA automático (secao 117-118) — schema pronto,
|
||||||
|
nada implementado.
|
||||||
|
- Privacidade em cascata Campaign > Queue > Tenant (secao 122) —
|
||||||
|
`Tenant.aiPrivacyLevel`/`Queue.aiPrivacyLevel` existem no schema, função
|
||||||
|
de resolução ainda não escrita (só faz sentido junto com o pipeline).
|
||||||
|
- Usage metering (secao 124) — `AIUsageRecord` existe, nada grava nele
|
||||||
|
ainda (só faz sentido junto com transcrição/análise reais).
|
||||||
|
- `ParseUUIDPipe`/validação de UUID em `@Param("id")` — um id malformado
|
||||||
|
na URL retorna 500 (erro genérico do Prisma) em vez de 400. Mesmo padrão
|
||||||
|
em TODOS os controllers deste projeto, não é regressão desta fase —
|
||||||
|
registrado aqui porque foi onde apareceu durante o teste, mas o fix (se
|
||||||
|
vier a acontecer) é um retrofit em todo o `apps/api`, fora do escopo
|
||||||
|
desta fase.
|
||||||
15
packages/ai/package.json
Normal file
15
packages/ai/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/ai/src/anthropic-provider.ts
Normal file
102
packages/ai/src/anthropic-provider.ts
Normal 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
14
packages/ai/src/index.ts
Normal 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";
|
||||||
133
packages/ai/src/openai-provider.ts
Normal file
133
packages/ai/src/openai-provider.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/ai/src/redactor.ts
Normal file
38
packages/ai/src/redactor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/ai/src/registry.ts
Normal file
25
packages/ai/src/registry.ts
Normal 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
77
packages/ai/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
8
packages/ai/tsconfig.json
Normal file
8
packages/ai/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -16,47 +16,72 @@ enum TenantStatus {
|
|||||||
@@map("tenant_status")
|
@@map("tenant_status")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tenant {
|
// Privacidade de IA (agente.md secao 122) — resolvida em cascata
|
||||||
id String @id @default(uuid()) @db.Uuid
|
// Campaign > Queue > Tenant (o mais específico que não for null vence).
|
||||||
code String @unique
|
// Tenant é o único nível obrigatório (default OFF: preciso de opt-in
|
||||||
slug String @unique
|
// explícito antes de mandar áudio de cliente pra um provider externo).
|
||||||
legalName String @map("legal_name")
|
enum AIPrivacyLevel {
|
||||||
tradeName String? @map("trade_name")
|
AI_OFF
|
||||||
taxId String? @map("tax_id")
|
TRANSCRIPTION_ONLY
|
||||||
status TenantStatus @default(TRIAL)
|
TRANSCRIPTION_AND_ANALYSIS
|
||||||
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")
|
|
||||||
|
|
||||||
plan Plan @relation(fields: [planId], references: [id])
|
@@map("ai_privacy_level")
|
||||||
memberships TenantMembership[]
|
}
|
||||||
userRoles UserRole[]
|
|
||||||
extensions Extension[]
|
model Tenant {
|
||||||
trunks Trunk[]
|
id String @id @default(uuid()) @db.Uuid
|
||||||
dialplanExtensions DialplanExtension[]
|
code String @unique
|
||||||
dialplanVersions DialplanVersion[]
|
slug String @unique
|
||||||
queues Queue[]
|
legalName String @map("legal_name")
|
||||||
agents Agent[]
|
tradeName String? @map("trade_name")
|
||||||
pauseReasons PauseReason[]
|
taxId String? @map("tax_id")
|
||||||
tiers Tier[]
|
status TenantStatus @default(TRIAL)
|
||||||
agentSessions AgentSession[]
|
timezone String @default("America/Sao_Paulo")
|
||||||
agentStateEvents AgentStateEvent[]
|
locale String @default("pt-BR")
|
||||||
agentPauseEvents AgentPauseEvent[]
|
billingCurrency String @default("BRL") @map("billing_currency")
|
||||||
campaigns Campaign[]
|
telephonyDomain String? @map("telephony_domain")
|
||||||
leads Lead[]
|
planId String @map("plan_id") @db.Uuid
|
||||||
suppressionEntries SuppressionEntry[]
|
aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level")
|
||||||
callAttempts CallAttempt[]
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
campaignStats CampaignStats[]
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
dispositions Disposition[]
|
deletedAt DateTime? @map("deleted_at")
|
||||||
calls Call[]
|
|
||||||
callLegs CallLeg[]
|
plan Plan @relation(fields: [planId], references: [id])
|
||||||
callEvents CallEvent[]
|
memberships TenantMembership[]
|
||||||
recordings Recording[]
|
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")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -487,6 +512,9 @@ model Queue {
|
|||||||
|
|
||||||
recordingEnabled Boolean @default(false) @map("recording_enabled")
|
recordingEnabled Boolean @default(false) @map("recording_enabled")
|
||||||
|
|
||||||
|
// null = herda do Tenant (secao 122).
|
||||||
|
aiPrivacyLevel AIPrivacyLevel? @map("ai_privacy_level")
|
||||||
|
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -721,19 +749,24 @@ model Campaign {
|
|||||||
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
|
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
|
||||||
aiAnalysisEnabled Boolean @default(false) @map("ai_analysis_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)
|
status CampaignStatus @default(DRAFT)
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
queue Queue @relation(fields: [queueId], references: [id])
|
queue Queue @relation(fields: [queueId], references: [id])
|
||||||
trunk Trunk @relation(fields: [trunkId], references: [id])
|
trunk Trunk @relation(fields: [trunkId], references: [id])
|
||||||
leads Lead[]
|
analysisPromptTemplate AIPromptTemplate? @relation(fields: [analysisPromptTemplateId], references: [id])
|
||||||
callAttempts CallAttempt[]
|
leads Lead[]
|
||||||
campaignStats CampaignStats[]
|
callAttempts CallAttempt[]
|
||||||
calls Call[]
|
campaignStats CampaignStats[]
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@unique([tenantId, name])
|
@@unique([tenantId, name])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -1001,18 +1034,23 @@ model Call {
|
|||||||
|
|
||||||
dispositionId String? @map("disposition_id") @db.Uuid
|
dispositionId String? @map("disposition_id") @db.Uuid
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
attempt CallAttempt? @relation(fields: [attemptId], references: [id])
|
attempt CallAttempt? @relation(fields: [attemptId], references: [id])
|
||||||
campaign Campaign? @relation(fields: [campaignId], references: [id])
|
campaign Campaign? @relation(fields: [campaignId], references: [id])
|
||||||
lead Lead? @relation(fields: [leadId], references: [id])
|
lead Lead? @relation(fields: [leadId], references: [id])
|
||||||
queue Queue? @relation(fields: [queueId], references: [id])
|
queue Queue? @relation(fields: [queueId], references: [id])
|
||||||
agent Agent? @relation(fields: [agentId], references: [id])
|
agent Agent? @relation(fields: [agentId], references: [id])
|
||||||
extension Extension? @relation(fields: [extensionId], references: [id])
|
extension Extension? @relation(fields: [extensionId], references: [id])
|
||||||
trunk Trunk? @relation(fields: [trunkId], references: [id])
|
trunk Trunk? @relation(fields: [trunkId], references: [id])
|
||||||
disposition Disposition? @relation(fields: [dispositionId], references: [id])
|
disposition Disposition? @relation(fields: [dispositionId], references: [id])
|
||||||
legs CallLeg[]
|
legs CallLeg[]
|
||||||
events CallEvent[]
|
events CallEvent[]
|
||||||
recording Recording?
|
recording Recording?
|
||||||
|
aijobs AIJob[]
|
||||||
|
callTranscriptions CallTranscription[]
|
||||||
|
callAIAnalyses CallAIAnalysis[]
|
||||||
|
qualityEvaluations QualityEvaluation[]
|
||||||
|
aiusageRecords AIUsageRecord[]
|
||||||
|
|
||||||
@@index([tenantId, createdAt])
|
@@index([tenantId, createdAt])
|
||||||
@@index([tenantId, queueId])
|
@@index([tenantId, queueId])
|
||||||
@@ -1118,3 +1156,419 @@ model Recording {
|
|||||||
@@index([retentionUntil])
|
@@index([retentionUntil])
|
||||||
@@map("recordings")
|
@@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
12
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@b2bcall/ai':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/ai
|
||||||
'@b2bcall/auth':
|
'@b2bcall/auth':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/auth
|
version: link:../../packages/auth
|
||||||
@@ -181,6 +184,15 @@ importers:
|
|||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
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:
|
packages/auth:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@b2bcall/database':
|
'@b2bcall/database':
|
||||||
|
|||||||
Reference in New Issue
Block a user